diff --git a/schemas/protocol/intent.event.v1.json b/schemas/protocol/intent.event.v1.json index ef26e19..233ab83 100644 --- a/schemas/protocol/intent.event.v1.json +++ b/schemas/protocol/intent.event.v1.json @@ -33,7 +33,12 @@ "intent.failed", "intent.canceled", "intent.transfer", - "intent.timeout" + "intent.timeout", + "intent.timed_out", + "intent.reminder", + "intent.escalated", + "intent.delivery_failed", + "intent.human_task_assigned" ] }, "status": { @@ -47,7 +52,8 @@ "WAITING", "COMPLETED", "FAILED", - "CANCELED" + "CANCELED", + "TIMED_OUT" ] }, "at": { diff --git a/schemas/protocol/intent.lifecycle.v1.json b/schemas/protocol/intent.lifecycle.v1.json index 54f0986..8c67e38 100644 --- a/schemas/protocol/intent.lifecycle.v1.json +++ b/schemas/protocol/intent.lifecycle.v1.json @@ -32,7 +32,8 @@ "WAITING", "COMPLETED", "FAILED", - "CANCELED" + "CANCELED", + "TIMED_OUT" ] }, "seq": { diff --git a/schemas/public_api/api.agents.get.response.v1.json b/schemas/public_api/api.agents.get.response.v1.json new file mode 100644 index 0000000..5343fc4 --- /dev/null +++ b/schemas/public_api/api.agents.get.response.v1.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://axme.dev/schemas/public_api/api.agents.get.response.v1.json", + "title": "ApiAgentsGetResponseV1", + "description": "Response for GET /v1/agents/{address} — resolve a single registered agent address.", + "type": "object", + "additionalProperties": false, + "required": ["ok", "agent"], + "properties": { + "ok": { + "type": "boolean", + "const": true + }, + "agent": { + "type": "object", + "additionalProperties": false, + "required": ["address", "service_account_id", "service_account_name", "status", "created_at"], + "properties": { + "address": { + "description": "Canonical agent address: agent://{org_slug}/{workspace_slug}/{sa_name}", + "type": "string", + "minLength": 10, + "maxLength": 383 + }, + "service_account_id": { + "type": "string", + "minLength": 3 + }, + "service_account_name": { + "type": "string", + "minLength": 2, + "maxLength": 120 + }, + "display_name": { + "type": ["string", "null"], + "maxLength": 255 + }, + "status": { + "type": "string", + "enum": ["active", "suspended", "deleted"] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + } + } +} diff --git a/schemas/public_api/api.agents.list.response.v1.json b/schemas/public_api/api.agents.list.response.v1.json new file mode 100644 index 0000000..0ed6f7f --- /dev/null +++ b/schemas/public_api/api.agents.list.response.v1.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://axme.dev/schemas/public_api/api.agents.list.response.v1.json", + "title": "ApiAgentsListResponseV1", + "description": "Response for GET /v1/agents — list of registered agent addresses for the workspace.", + "type": "object", + "additionalProperties": false, + "required": ["ok", "agents"], + "properties": { + "ok": { + "type": "boolean", + "const": true + }, + "agents": { + "type": "array", + "items": { "$ref": "#/$defs/AgentEntry" } + } + }, + "$defs": { + "AgentEntry": { + "type": "object", + "additionalProperties": false, + "required": ["address", "service_account_id", "service_account_name", "status", "created_at"], + "properties": { + "address": { + "description": "Canonical agent address: agent://{org_slug}/{workspace_slug}/{sa_name}", + "type": "string", + "minLength": 10, + "maxLength": 383 + }, + "service_account_id": { + "type": "string", + "minLength": 3 + }, + "service_account_name": { + "type": "string", + "minLength": 2, + "maxLength": 120 + }, + "display_name": { + "type": ["string", "null"], + "maxLength": 255 + }, + "status": { + "type": "string", + "enum": ["active", "suspended", "deleted"] + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + } + } +} diff --git a/schemas/public_api/api.intents.create.request.v1.json b/schemas/public_api/api.intents.create.request.v1.json index 760307c..71bf325 100644 --- a/schemas/public_api/api.intents.create.request.v1.json +++ b/schemas/public_api/api.intents.create.request.v1.json @@ -7,7 +7,6 @@ "required": [ "intent_type", "correlation_id", - "from_agent", "to_agent", "payload" ], @@ -21,22 +20,79 @@ "format": "uuid" }, "from_agent": { + "description": "Deprecated — derived automatically from the API key. Accepted for backward compatibility but ignored by the server.", "type": "string", "minLength": 3, - "maxLength": 255 + "maxLength": 383 }, "to_agent": { "type": "string", "minLength": 3, - "maxLength": 255 + "maxLength": 383 }, "reply_to": { "type": "string", "minLength": 3, - "maxLength": 255 + "maxLength": 383 }, "payload": { "type": "object" + }, + "deadline_at": { + "description": "ISO-8601 UTC datetime after which the intent is automatically transitioned to TIMED_OUT.", + "type": "string", + "format": "date-time" + }, + "remind_after_seconds": { + "description": "Seconds after submission before the first reminder is sent to the human holder.", + "type": "integer", + "minimum": 1 + }, + "remind_interval_seconds": { + "description": "Seconds between subsequent reminders after the first.", + "type": "integer", + "minimum": 1 + }, + "max_reminders": { + "description": "Maximum number of reminders before escalating.", + "type": "integer", + "minimum": 1 + }, + "escalate_to": { + "description": "Agent address or role alias to escalate to once max_reminders is reached.", + "type": "string", + "maxLength": 383 + }, + "max_delivery_attempts": { + "description": "Maximum delivery failures before transitioning to FAILED.", + "type": "integer", + "minimum": 1 + }, + "human_task": { + "description": "Structured task description shown to the human when the intent is assigned to a human participant.", + "$ref": "#/$defs/HumanTaskSpec" + } + }, + "$defs": { + "HumanTaskSpec": { + "type": "object", + "additionalProperties": false, + "required": ["title"], + "properties": { + "title": { + "type": "string", + "maxLength": 255 + }, + "description": { + "type": "string", + "maxLength": 2000 + }, + "form_schema": { + "type": "object", + "description": "JSON Schema (draft 2020-12) for validating task_result submitted by the human.", + "additionalProperties": true + } + } } } } diff --git a/schemas/public_api/api.intents.create.response.v1.json b/schemas/public_api/api.intents.create.response.v1.json index 4590116..8230309 100644 --- a/schemas/public_api/api.intents.create.response.v1.json +++ b/schemas/public_api/api.intents.create.response.v1.json @@ -22,16 +22,17 @@ "status": { "type": "string", "enum": [ - "CREATED", - "SUBMITTED", - "DELIVERED", - "ACKNOWLEDGED", - "IN_PROGRESS", - "WAITING", - "COMPLETED", - "FAILED", - "CANCELED" - ] + "CREATED", + "SUBMITTED", + "DELIVERED", + "ACKNOWLEDGED", + "IN_PROGRESS", + "WAITING", + "COMPLETED", + "FAILED", + "CANCELED", + "TIMED_OUT" + ] }, "created_at": { "type": "string", diff --git a/schemas/public_api/api.intents.get.response.v1.json b/schemas/public_api/api.intents.get.response.v1.json index 584e456..113bc31 100644 --- a/schemas/public_api/api.intents.get.response.v1.json +++ b/schemas/public_api/api.intents.get.response.v1.json @@ -42,7 +42,8 @@ "WAITING", "COMPLETED", "FAILED", - "CANCELED" + "CANCELED", + "TIMED_OUT" ] }, "legacy_status": { @@ -83,6 +84,58 @@ }, "payload": { "type": "object" + }, + "deadline_at": { + "type": "string", + "format": "date-time" + }, + "remind_after_seconds": { + "type": "integer", + "minimum": 1 + }, + "remind_interval_seconds": { + "type": "integer", + "minimum": 1 + }, + "max_reminders": { + "type": "integer", + "minimum": 1 + }, + "remind_count": { + "type": "integer", + "minimum": 0 + }, + "escalate_to": { + "type": "string", + "maxLength": 383 + }, + "max_delivery_attempts": { + "type": "integer", + "minimum": 1 + }, + "delivery_attempt": { + "type": "integer", + "minimum": 0 + }, + "human_task": { + "type": "object", + "additionalProperties": true + }, + "pending_with": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": ["agent", "human", "internal"] + }, + "ref": { + "type": "string" + }, + "name": { + "type": "string" + } + } } } } diff --git a/schemas/public_api/api.scenarios.bundle.request.v1.json b/schemas/public_api/api.scenarios.bundle.request.v1.json new file mode 100644 index 0000000..92c9b6c --- /dev/null +++ b/schemas/public_api/api.scenarios.bundle.request.v1.json @@ -0,0 +1,343 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://axme.dev/schemas/public_api/api.scenarios.bundle.request.v1.json", + "title": "ApiScenariosBundleRequestV1", + "description": "ScenarioBundle — one JSON document submitted to POST /v1/scenarios/apply that provisions agents, compiles a workflow, and submits an intent in a single atomic operation.", + "type": "object", + "additionalProperties": false, + "required": ["scenario_id", "intent"], + "properties": { + "scenario_id": { + "type": "string", + "description": "Stable identifier for this scenario definition, e.g. 'approval.nginx_rollout.v1'.", + "pattern": "^[a-z0-9_]+(?:\\.[a-z0-9_]+)*$", + "minLength": 3, + "maxLength": 255 + }, + "description": { + "type": "string", + "maxLength": 1000 + }, + "idempotency_key": { + "type": "string", + "description": "If provided, duplicate submissions with the same key return the original intent instead of creating a new one.", + "maxLength": 255 + }, + "agents": { + "type": "array", + "description": "Service-account agents that participate in this scenario.", + "items": { "$ref": "#/$defs/AgentSpec" }, + "maxItems": 50 + }, + "humans": { + "type": "array", + "description": "Human participants (org members) that may be assigned approval or task steps.", + "items": { "$ref": "#/$defs/HumanSpec" }, + "maxItems": 20 + }, + "workflow": { + "$ref": "#/$defs/WorkflowSpec", + "description": "Inline workflow definition or reference to a pre-compiled macro." + }, + "intent": { + "$ref": "#/$defs/IntentSpec", + "description": "Intent submission settings — type, payload, deadline, reminders, delivery limits." + } + }, + "$defs": { + "AgentSpec": { + "type": "object", + "additionalProperties": false, + "required": ["role", "address"], + "properties": { + "role": { + "type": "string", + "description": "Logical role alias used to reference this agent in workflow steps.", + "minLength": 1, + "maxLength": 100 + }, + "address": { + "type": "string", + "description": "Agent address — either a short SA name or full agent:// URI.", + "minLength": 1, + "maxLength": 383 + }, + "create_if_missing": { + "type": "boolean", + "description": "If true, a service account is automatically created when the address is not registered.", + "default": false + }, + "display_name": { + "type": "string", + "maxLength": 255 + } + } + }, + "HumanSpec": { + "type": "object", + "additionalProperties": false, + "required": ["role", "contact"], + "properties": { + "role": { + "type": "string", + "description": "Logical role alias used to reference this human in workflow steps.", + "minLength": 1, + "maxLength": 100 + }, + "contact": { + "type": "string", + "description": "Email address or org_member_id of the human participant.", + "minLength": 1, + "maxLength": 255 + }, + "display_name": { + "type": "string", + "maxLength": 255 + } + } + }, + "WorkflowSpec": { + "type": "object", + "additionalProperties": false, + "properties": { + "macro_id": { + "type": "string", + "description": "ID of a pre-registered macro template to use. When set, 'steps' are used as parameter overrides only.", + "maxLength": 255 + }, + "steps": { + "type": "array", + "description": "Ordered list of workflow steps. Required when macro_id is not provided.", + "items": { "$ref": "#/$defs/WorkflowStepSpec" }, + "minItems": 1, + "maxItems": 100 + } + } + }, + "WorkflowStepSpec": { + "type": "object", + "additionalProperties": false, + "required": ["step_id"], + "properties": { + "step_id": { + "type": "string", + "description": "Unique step identifier within this workflow.", + "minLength": 1, + "maxLength": 100 + }, + "tool_id": { + "type": "string", + "description": "Tool to execute at this step. Required unless runtime_type is set.", + "minLength": 1, + "maxLength": 255 + }, + "runtime_type": { + "type": "string", + "description": "Built-in AXME runtime primitive. When set, tool_id and assigned_to are not required. Mutually exclusive with tool_id for built-in steps.", + "enum": [ + "human_approval", + "timeout", + "reminder", + "delay", + "escalation", + "notification" + ] + }, + "runtime_config": { + "type": "object", + "description": "Configuration for the runtime_type primitive. Schema depends on the specific runtime_type.", + "additionalProperties": true + }, + "assigned_to": { + "type": "string", + "description": "Role alias or agent address that owns this step. Used to update pending_with on each workflow tick.", + "maxLength": 383 + }, + "requires_approval": { + "type": "boolean", + "description": "When true, the workflow pauses at this step until a human submits resume_intent with task_result.", + "default": false + }, + "step_deadline_seconds": { + "type": "integer", + "description": "Maximum seconds this step may run before being failed with 'step_timeout'.", + "minimum": 1 + }, + "retry": { + "$ref": "#/$defs/RetryPolicy" + }, + "human_task": { + "$ref": "#/$defs/HumanTaskSpec", + "description": "Structured task description shown to the human when requires_approval=true." + }, + "remind_after_seconds": { + "type": "integer", + "description": "Seconds to wait before sending the first reminder to the human assigned to this step.", + "minimum": 1 + }, + "remind_interval_seconds": { + "type": "integer", + "description": "Seconds between subsequent reminders after the first.", + "minimum": 1 + }, + "max_reminders": { + "type": "integer", + "description": "Maximum number of reminders before escalating to escalate_to.", + "minimum": 1 + }, + "escalate_to": { + "type": "string", + "description": "Role alias or address to escalate to once max_reminders is reached.", + "maxLength": 383 + }, + "on_success": { + "type": "string", + "description": "step_id to transition to on success. Omit to end the workflow.", + "maxLength": 100 + }, + "on_failure": { + "type": "string", + "description": "step_id to transition to on failure. Omit to fail the workflow.", + "maxLength": 100 + } + } + }, + "RetryPolicy": { + "type": "object", + "additionalProperties": false, + "properties": { + "max_retries": { + "type": "integer", + "minimum": 0, + "default": 0 + }, + "retry_delay_seconds": { + "type": "integer", + "minimum": 1, + "default": 30 + } + } + }, + "HumanTaskSpec": { + "type": "object", + "additionalProperties": false, + "required": ["title"], + "properties": { + "title": { + "type": "string", + "maxLength": 255 + }, + "description": { + "type": "string", + "maxLength": 2000 + }, + "task_type": { + "type": "string", + "description": "Semantic type of the human task. Determines the expected interaction pattern.", + "enum": [ + "approval", + "review", + "clarification", + "provide_data", + "attach_file", + "manual_action", + "confirmation", + "validate_real_world", + "validate_output", + "assignment", + "delegation", + "reroute", + "override", + "escalate" + ] + }, + "allowed_outcomes": { + "type": "array", + "description": "Permitted values for outcome in task_result. If set, resume will be rejected if outcome is not in this list.", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "minItems": 1, + "maxItems": 20 + }, + "required_comment": { + "type": "boolean", + "description": "When true, task_result.comment must be non-empty for the resume to be accepted.", + "default": false + }, + "assignees": { + "type": "array", + "description": "Role aliases or addresses of humans who may respond to this task. Replaces single 'contact' field.", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 383 + }, + "minItems": 1, + "maxItems": 20 + }, + "evidence_required": { + "type": "boolean", + "description": "When true, task_result must include a non-empty data.evidence field.", + "default": false + }, + "form_schema": { + "type": "object", + "description": "JSON Schema (draft 2020-12) for validating task_result submitted by the human.", + "additionalProperties": true + } + } + }, + "IntentSpec": { + "type": "object", + "additionalProperties": false, + "required": ["type"], + "properties": { + "type": { + "type": "string", + "description": "Intent type identifier, e.g. 'approval.change.v1'.", + "pattern": "^[a-z0-9_]+(?:\\.[a-z0-9_]+)*$", + "minLength": 3, + "maxLength": 255 + }, + "payload": { + "type": "object", + "description": "Domain-specific data for this intent.", + "additionalProperties": true + }, + "deadline_at": { + "type": "string", + "format": "date-time", + "description": "ISO-8601 UTC datetime after which the intent is automatically transitioned to TIMED_OUT." + }, + "remind_after_seconds": { + "type": "integer", + "description": "Seconds after which the first reminder is sent to the human holder.", + "minimum": 1 + }, + "remind_interval_seconds": { + "type": "integer", + "minimum": 1 + }, + "max_reminders": { + "type": "integer", + "minimum": 1 + }, + "escalate_to": { + "type": "string", + "maxLength": 383 + }, + "max_delivery_attempts": { + "type": "integer", + "description": "Maximum agent delivery failures before transitioning to FAILED.", + "minimum": 1 + }, + "human_task": { + "$ref": "#/$defs/HumanTaskSpec" + } + } + } + } +} diff --git a/schemas/public_api/api.scenarios.bundle.response.v1.json b/schemas/public_api/api.scenarios.bundle.response.v1.json new file mode 100644 index 0000000..213606a --- /dev/null +++ b/schemas/public_api/api.scenarios.bundle.response.v1.json @@ -0,0 +1,60 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://axme.dev/schemas/public_api/api.scenarios.bundle.response.v1.json", + "title": "ApiScenariosBundleResponseV1", + "description": "Response from POST /v1/scenarios/bundle — returns the created (or idempotent-matched) intent plus provisioned agent addresses.", + "type": "object", + "additionalProperties": false, + "required": ["intent_id", "scenario_id", "status"], + "properties": { + "intent_id": { + "type": "string", + "description": "UUID of the created or matched intent." + }, + "scenario_id": { + "type": "string" + }, + "status": { + "type": "string", + "description": "Initial lifecycle status of the intent.", + "enum": ["DELIVERED", "IN_PROGRESS", "WAITING"] + }, + "pending_with": { + "type": "string", + "description": "Agent address or human contact currently holding the intent." + }, + "workflow_compile_id": { + "type": "string", + "description": "ID of the compiled workflow run, if a workflow was compiled and started." + }, + "idempotent": { + "type": "boolean", + "description": "True when an existing intent was returned rather than a new one created." + }, + "agents": { + "type": "array", + "description": "Resolved agent addresses for each role specified in the bundle.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["role", "address"], + "properties": { + "role": { "type": "string" }, + "address": { "type": "string" }, + "created": { + "type": "boolean", + "description": "True when the service account was freshly created by this request." + } + } + } + }, + "deadline_at": { + "type": ["string", "null"], + "format": "date-time" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } +} diff --git a/schemas/public_api/api.service_accounts.create.response.v1.json b/schemas/public_api/api.service_accounts.create.response.v1.json index 24f6a4c..c22f18b 100644 --- a/schemas/public_api/api.service_accounts.create.response.v1.json +++ b/schemas/public_api/api.service_accounts.create.response.v1.json @@ -71,6 +71,19 @@ "updated_at": { "type": "string", "format": "date-time" + }, + "agent_address": { + "description": "Canonical agent address auto-registered on creation: agent://{org_slug}/{workspace_slug}/{sa_name}", + "type": "string", + "minLength": 10, + "maxLength": 383 + }, + "display_name": { + "type": [ + "string", + "null" + ], + "maxLength": 255 } } } diff --git a/schemas/public_api/api.tasks.list.response.v1.json b/schemas/public_api/api.tasks.list.response.v1.json new file mode 100644 index 0000000..58131a9 --- /dev/null +++ b/schemas/public_api/api.tasks.list.response.v1.json @@ -0,0 +1,123 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://axme.dev/schemas/public_api/api.tasks.list.response.v1.json", + "title": "ApiTasksListResponseV1", + "description": "Response for GET /v1/me/tasks — list of pending human tasks for the authenticated actor.", + "type": "object", + "additionalProperties": false, + "required": ["ok", "tasks"], + "properties": { + "ok": { + "type": "boolean", + "const": true + }, + "tasks": { + "type": "array", + "items": { "$ref": "#/$defs/HumanTaskItem" } + } + }, + "$defs": { + "HumanTaskItem": { + "type": "object", + "additionalProperties": false, + "required": ["intent_id", "intent_type", "status", "assigned_at", "human_task"], + "properties": { + "intent_id": { + "type": "string", + "format": "uuid" + }, + "intent_type": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["WAITING"] + }, + "from_agent": { + "type": "string", + "maxLength": 383 + }, + "assigned_at": { + "description": "When this task was assigned to the current actor.", + "type": "string", + "format": "date-time" + }, + "due_at": { + "description": "Deadline for responding — maps to deadline_at on the intent.", + "type": "string", + "format": "date-time" + }, + "human_task": { + "$ref": "#/$defs/HumanTaskSpec" + } + } + }, + "HumanTaskSpec": { + "type": "object", + "additionalProperties": false, + "required": ["title"], + "properties": { + "title": { + "type": "string", + "maxLength": 255 + }, + "description": { + "type": "string", + "maxLength": 2000 + }, + "task_type": { + "type": "string", + "enum": [ + "approval", + "review", + "clarification", + "provide_data", + "attach_file", + "manual_action", + "confirmation", + "validate_real_world", + "validate_output", + "assignment", + "delegation", + "reroute", + "override", + "escalate" + ] + }, + "allowed_outcomes": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "minItems": 1, + "maxItems": 20 + }, + "required_comment": { + "type": "boolean", + "default": false + }, + "assignees": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 383 + }, + "minItems": 1, + "maxItems": 20 + }, + "evidence_required": { + "type": "boolean", + "default": false + }, + "form_schema": { + "type": "object", + "description": "JSON Schema (draft 2020-12) for validating task_result.data submitted by the human.", + "additionalProperties": true + } + } + } + } +} diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_schema_contracts.py b/tests/test_schema_contracts.py new file mode 100644 index 0000000..9dbe5e4 --- /dev/null +++ b/tests/test_schema_contracts.py @@ -0,0 +1,478 @@ +""" +Schema contract tests for AXME protocol and public API schemas. + +Covers all changes introduced in the PARTICIPANT_MODEL / DURABLE_WORKFLOW / +B2B_CORE_V2 updates: + - TIMED_OUT status in lifecycle and event schemas + - New event types (timed_out, reminder, escalated, delivery_failed, human_task_assigned) + - from_agent optional in create request + durability fields + - agent address schemas + - runtime_type on WorkflowStepSpec + - HumanTaskSpec enrichment (task_type, allowed_outcomes, assignees, etc.) + - api.tasks.list response schema + - service_account create response has agent_address +""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +PROTOCOL = ROOT / "schemas" / "protocol" +PUBLIC_API = ROOT / "schemas" / "public_api" + + +def load(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def statuses_in(schema: dict, path: list[str]) -> list[str]: + """Navigate nested dict by key path and return the 'enum' list.""" + node = schema + for key in path: + node = node[key] + return node["enum"] + + +def collect_enum(schema: dict, *path: str) -> list[str]: + node = schema + for key in path: + node = node[key] + return node + + +# --------------------------------------------------------------------------- +# Group 1: intent.lifecycle.v1 — TIMED_OUT status +# --------------------------------------------------------------------------- + +class TestIntentLifecycle: + schema = load(PROTOCOL / "intent.lifecycle.v1.json") + + def test_schema_has_correct_id(self): + assert self.schema["$id"] == "https://axme.dev/schemas/protocol/intent.lifecycle.v1.json" + + def test_timed_out_in_status_enum(self): + statuses = self.schema["properties"]["status"]["enum"] + assert "TIMED_OUT" in statuses + + def test_all_expected_statuses_present(self): + expected = { + "CREATED", "SUBMITTED", "DELIVERED", "ACKNOWLEDGED", + "IN_PROGRESS", "WAITING", "COMPLETED", "FAILED", "CANCELED", "TIMED_OUT" + } + statuses = set(self.schema["properties"]["status"]["enum"]) + assert expected == statuses + + def test_waiting_reason_enum_unchanged(self): + reasons = self.schema["properties"]["waiting_reason"]["enum"] + assert "WAITING_FOR_HUMAN" in reasons + assert "WAITING_FOR_TOOL" in reasons + assert "WAITING_FOR_AGENT" in reasons + assert "WAITING_FOR_TIME" in reasons + + def test_required_fields_present(self): + required = self.schema["required"] + for field in ["intent_id", "correlation_id", "status", "seq", "created_at", "updated_at"]: + assert field in required + + def test_allOf_waiting_requires_waiting_reason(self): + all_of = self.schema["allOf"] + assert len(all_of) >= 1 + rule = all_of[0] + assert rule["if"]["properties"]["status"]["const"] == "WAITING" + assert "waiting_reason" in rule["then"]["required"] + + +# --------------------------------------------------------------------------- +# Group 2: intent.event.v1 — new event_type values + TIMED_OUT status +# --------------------------------------------------------------------------- + +class TestIntentEvent: + schema = load(PROTOCOL / "intent.event.v1.json") + + def test_schema_has_correct_id(self): + assert self.schema["$id"] == "https://axme.dev/schemas/protocol/intent.event.v1.json" + + def test_timed_out_in_status_enum(self): + statuses = self.schema["properties"]["status"]["enum"] + assert "TIMED_OUT" in statuses + + def test_new_event_types_present(self): + event_types = self.schema["properties"]["event_type"]["enum"] + for et in ["intent.timed_out", "intent.reminder", "intent.escalated", + "intent.delivery_failed", "intent.human_task_assigned"]: + assert et in event_types, f"Missing event_type: {et}" + + def test_legacy_event_types_preserved(self): + event_types = self.schema["properties"]["event_type"]["enum"] + for et in ["intent.created", "intent.submitted", "intent.delivered", + "intent.acknowledged", "intent.in_progress", "intent.waiting", + "intent.completed", "intent.failed", "intent.canceled", + "intent.transfer", "intent.timeout"]: + assert et in event_types, f"Missing legacy event_type: {et}" + + def test_all_expected_statuses_present(self): + expected = { + "CREATED", "SUBMITTED", "DELIVERED", "ACKNOWLEDGED", + "IN_PROGRESS", "WAITING", "COMPLETED", "FAILED", "CANCELED", "TIMED_OUT" + } + assert expected == set(self.schema["properties"]["status"]["enum"]) + + def test_required_fields(self): + required = self.schema["required"] + for field in ["intent_id", "seq", "event_type", "status", "at"]: + assert field in required + + def test_allOf_waiting_requires_waiting_reason(self): + all_of = self.schema["allOf"] + rule = all_of[0] + assert rule["if"]["properties"]["status"]["const"] == "WAITING" + assert "waiting_reason" in rule["then"]["required"] + + +# --------------------------------------------------------------------------- +# Group 3: api.intents.create.request — from_agent optional + durability fields +# --------------------------------------------------------------------------- + +class TestIntentsCreateRequest: + schema = load(PUBLIC_API / "api.intents.create.request.v1.json") + + def test_schema_has_correct_id(self): + assert self.schema["$id"] == "https://axme.dev/schemas/public_api/api.intents.create.request.v1.json" + + def test_from_agent_not_required(self): + assert "from_agent" not in self.schema["required"] + + def test_to_agent_still_required(self): + assert "to_agent" in self.schema["required"] + + def test_intent_type_still_required(self): + assert "intent_type" in self.schema["required"] + + def test_payload_still_required(self): + assert "payload" in self.schema["required"] + + def test_from_agent_field_still_present(self): + # backwards compat — field accepted but deprecated + assert "from_agent" in self.schema["properties"] + + def test_durability_fields_present(self): + props = self.schema["properties"] + for field in ["deadline_at", "remind_after_seconds", "remind_interval_seconds", + "max_reminders", "escalate_to", "max_delivery_attempts"]: + assert field in props, f"Missing field: {field}" + + def test_deadline_at_is_datetime(self): + assert self.schema["properties"]["deadline_at"]["format"] == "date-time" + + def test_remind_after_seconds_is_positive_int(self): + prop = self.schema["properties"]["remind_after_seconds"] + assert prop["type"] == "integer" + assert prop["minimum"] == 1 + + def test_max_delivery_attempts_is_positive_int(self): + prop = self.schema["properties"]["max_delivery_attempts"] + assert prop["type"] == "integer" + assert prop["minimum"] == 1 + + def test_human_task_field_present(self): + assert "human_task" in self.schema["properties"] + + def test_defs_has_human_task_spec(self): + assert "HumanTaskSpec" in self.schema.get("$defs", {}) + + +# --------------------------------------------------------------------------- +# Group 4: api.intents.create.response — TIMED_OUT in status +# --------------------------------------------------------------------------- + +class TestIntentsCreateResponse: + schema = load(PUBLIC_API / "api.intents.create.response.v1.json") + + def test_timed_out_in_status_enum(self): + statuses = self.schema["properties"]["status"]["enum"] + assert "TIMED_OUT" in statuses + + +# --------------------------------------------------------------------------- +# Group 5: api.intents.get.response — TIMED_OUT + durability + pending_with +# --------------------------------------------------------------------------- + +class TestIntentsGetResponse: + schema = load(PUBLIC_API / "api.intents.get.response.v1.json") + + def test_timed_out_in_status_enum(self): + statuses = self.schema["properties"]["intent"]["properties"]["status"]["enum"] + assert "TIMED_OUT" in statuses + + def test_durability_fields_in_intent(self): + intent_props = self.schema["properties"]["intent"]["properties"] + for field in ["deadline_at", "remind_after_seconds", "remind_interval_seconds", + "max_reminders", "remind_count", "escalate_to", + "max_delivery_attempts", "delivery_attempt", "human_task"]: + assert field in intent_props, f"Missing intent response field: {field}" + + def test_pending_with_field_present(self): + intent_props = self.schema["properties"]["intent"]["properties"] + assert "pending_with" in intent_props + + def test_pending_with_has_type_enum(self): + pw = self.schema["properties"]["intent"]["properties"]["pending_with"] + assert "type" in pw["properties"] + assert set(pw["properties"]["type"]["enum"]) == {"agent", "human", "internal"} + + +# --------------------------------------------------------------------------- +# Group 6: api.agents.list.response — new schema +# --------------------------------------------------------------------------- + +class TestAgentsListResponse: + schema = load(PUBLIC_API / "api.agents.list.response.v1.json") + + def test_schema_has_correct_id(self): + assert self.schema["$id"] == "https://axme.dev/schemas/public_api/api.agents.list.response.v1.json" + + def test_required_top_level_fields(self): + assert "ok" in self.schema["required"] + assert "agents" in self.schema["required"] + + def test_agents_is_array(self): + assert self.schema["properties"]["agents"]["type"] == "array" + + def test_agent_entry_required_fields(self): + entry = self.schema["$defs"]["AgentEntry"] + for field in ["address", "service_account_id", "service_account_name", "status", "created_at"]: + assert field in entry["required"], f"Missing required field in AgentEntry: {field}" + + def test_agent_entry_status_enum(self): + status_enum = self.schema["$defs"]["AgentEntry"]["properties"]["status"]["enum"] + assert set(status_enum) == {"active", "suspended", "deleted"} + + def test_agent_entry_display_name_nullable(self): + dn = self.schema["$defs"]["AgentEntry"]["properties"]["display_name"] + assert "null" in dn["type"] + + def test_address_has_min_length(self): + addr = self.schema["$defs"]["AgentEntry"]["properties"]["address"] + assert addr.get("minLength", 0) >= 10 + + +# --------------------------------------------------------------------------- +# Group 7: api.agents.get.response — new schema +# --------------------------------------------------------------------------- + +class TestAgentsGetResponse: + schema = load(PUBLIC_API / "api.agents.get.response.v1.json") + + def test_schema_has_correct_id(self): + assert self.schema["$id"] == "https://axme.dev/schemas/public_api/api.agents.get.response.v1.json" + + def test_required_top_level_fields(self): + assert "ok" in self.schema["required"] + assert "agent" in self.schema["required"] + + def test_agent_required_fields(self): + agent = self.schema["properties"]["agent"] + for field in ["address", "service_account_id", "service_account_name", "status", "created_at"]: + assert field in agent["required"], f"Missing required field in agent: {field}" + + def test_agent_has_updated_at(self): + # get response has updated_at, list does not + agent_props = self.schema["properties"]["agent"]["properties"] + assert "updated_at" in agent_props + + def test_address_format_documented(self): + desc = self.schema["properties"]["agent"]["properties"]["address"].get("description", "") + assert "agent://" in desc + + +# --------------------------------------------------------------------------- +# Group 8: api.scenarios.bundle.request — runtime_type + HumanTaskSpec enrichment +# --------------------------------------------------------------------------- + +class TestScenariosBundleRequest: + schema = load(PUBLIC_API / "api.scenarios.bundle.request.v1.json") + + def test_schema_has_correct_id(self): + assert self.schema["$id"] == "https://axme.dev/schemas/public_api/api.scenarios.bundle.request.v1.json" + + def test_workflow_step_tool_id_not_required(self): + step = self.schema["$defs"]["WorkflowStepSpec"] + assert "tool_id" not in step["required"] + + def test_workflow_step_step_id_required(self): + step = self.schema["$defs"]["WorkflowStepSpec"] + assert "step_id" in step["required"] + + def test_runtime_type_field_present(self): + step_props = self.schema["$defs"]["WorkflowStepSpec"]["properties"] + assert "runtime_type" in step_props + + def test_runtime_type_enum_values(self): + rt_enum = self.schema["$defs"]["WorkflowStepSpec"]["properties"]["runtime_type"]["enum"] + expected = {"human_approval", "timeout", "reminder", "delay", "escalation", "notification"} + assert expected == set(rt_enum) + + def test_runtime_config_field_present(self): + step_props = self.schema["$defs"]["WorkflowStepSpec"]["properties"] + assert "runtime_config" in step_props + + def test_human_task_spec_has_task_type(self): + ht = self.schema["$defs"]["HumanTaskSpec"]["properties"] + assert "task_type" in ht + + def test_task_type_enum_v1_priorities(self): + task_type_enum = self.schema["$defs"]["HumanTaskSpec"]["properties"]["task_type"]["enum"] + for t in ["approval", "review", "clarification", "manual_action", + "confirmation", "assignment", "override"]: + assert t in task_type_enum, f"Missing v1 priority task_type: {t}" + + def test_allowed_outcomes_field(self): + ht = self.schema["$defs"]["HumanTaskSpec"]["properties"] + assert "allowed_outcomes" in ht + assert ht["allowed_outcomes"]["type"] == "array" + + def test_required_comment_field(self): + ht = self.schema["$defs"]["HumanTaskSpec"]["properties"] + assert "required_comment" in ht + assert ht["required_comment"]["type"] == "boolean" + + def test_assignees_field(self): + ht = self.schema["$defs"]["HumanTaskSpec"]["properties"] + assert "assignees" in ht + assert ht["assignees"]["type"] == "array" + + def test_evidence_required_field(self): + ht = self.schema["$defs"]["HumanTaskSpec"]["properties"] + assert "evidence_required" in ht + assert ht["evidence_required"]["type"] == "boolean" + + def test_humans_spec_present(self): + # humans[] in bundle top-level + assert "humans" in self.schema["properties"] + + def test_human_spec_required_fields(self): + hs = self.schema["$defs"]["HumanSpec"] + assert "role" in hs["required"] + assert "contact" in hs["required"] + + +# --------------------------------------------------------------------------- +# Group 9: api.tasks.list.response — new schema +# --------------------------------------------------------------------------- + +class TestTasksListResponse: + schema = load(PUBLIC_API / "api.tasks.list.response.v1.json") + + def test_schema_has_correct_id(self): + assert self.schema["$id"] == "https://axme.dev/schemas/public_api/api.tasks.list.response.v1.json" + + def test_required_top_level_fields(self): + assert "ok" in self.schema["required"] + assert "tasks" in self.schema["required"] + + def test_tasks_is_array(self): + assert self.schema["properties"]["tasks"]["type"] == "array" + + def test_task_item_required_fields(self): + item = self.schema["$defs"]["HumanTaskItem"] + for field in ["intent_id", "intent_type", "status", "assigned_at", "human_task"]: + assert field in item["required"], f"Missing required field in HumanTaskItem: {field}" + + def test_task_item_status_only_waiting(self): + status = self.schema["$defs"]["HumanTaskItem"]["properties"]["status"] + assert status["enum"] == ["WAITING"] + + def test_task_item_intent_id_is_uuid(self): + intent_id = self.schema["$defs"]["HumanTaskItem"]["properties"]["intent_id"] + assert intent_id["format"] == "uuid" + + def test_task_item_due_at_is_datetime(self): + due_at = self.schema["$defs"]["HumanTaskItem"]["properties"]["due_at"] + assert due_at["format"] == "date-time" + + def test_task_item_has_human_task(self): + item_props = self.schema["$defs"]["HumanTaskItem"]["properties"] + assert "human_task" in item_props + + def test_human_task_spec_has_task_type(self): + ht = self.schema["$defs"]["HumanTaskSpec"]["properties"] + assert "task_type" in ht + + def test_human_task_spec_has_allowed_outcomes(self): + ht = self.schema["$defs"]["HumanTaskSpec"]["properties"] + assert "allowed_outcomes" in ht + + def test_human_task_spec_has_form_schema(self): + ht = self.schema["$defs"]["HumanTaskSpec"]["properties"] + assert "form_schema" in ht + + +# --------------------------------------------------------------------------- +# Group 10: api.service_accounts.create.response — agent_address + display_name +# --------------------------------------------------------------------------- + +class TestServiceAccountsCreateResponse: + schema = load(PUBLIC_API / "api.service_accounts.create.response.v1.json") + + def test_agent_address_field_present(self): + sa_props = self.schema["properties"]["service_account"]["properties"] + assert "agent_address" in sa_props + + def test_agent_address_has_description_with_scheme(self): + desc = self.schema["properties"]["service_account"]["properties"]["agent_address"].get("description", "") + assert "agent://" in desc + + def test_display_name_field_present(self): + sa_props = self.schema["properties"]["service_account"]["properties"] + assert "display_name" in sa_props + + def test_display_name_nullable(self): + dn = self.schema["properties"]["service_account"]["properties"]["display_name"] + assert "null" in dn["type"] + + def test_core_required_fields_unchanged(self): + required = self.schema["properties"]["service_account"]["required"] + for field in ["service_account_id", "org_id", "workspace_id", "name", "status", "created_at"]: + assert field in required + + +# --------------------------------------------------------------------------- +# Group 11: schema uniqueness and $id integrity (meta) +# --------------------------------------------------------------------------- + +class TestSchemaIntegrity: + def test_all_schemas_have_unique_ids(self): + ids: dict[str, Path] = {} + for schema_path in sorted((ROOT / "schemas").rglob("*.json")): + doc = json.loads(schema_path.read_text(encoding="utf-8")) + schema_id = doc.get("$id") + assert schema_id, f"Schema missing $id: {schema_path}" + assert schema_id not in ids, ( + f"Duplicate $id={schema_id} in {schema_path} and {ids[schema_id]}" + ) + ids[schema_id] = schema_path + + def test_new_schemas_exist(self): + for name in [ + "api.agents.list.response.v1.json", + "api.agents.get.response.v1.json", + "api.tasks.list.response.v1.json", + ]: + assert (PUBLIC_API / name).exists(), f"Expected new schema file missing: {name}" + + def test_all_protocol_schemas_parseable(self): + for schema_path in sorted(PROTOCOL.rglob("*.json")): + doc = json.loads(schema_path.read_text(encoding="utf-8")) + assert "$id" in doc + + def test_all_public_api_schemas_parseable(self): + for schema_path in sorted(PUBLIC_API.rglob("*.json")): + doc = json.loads(schema_path.read_text(encoding="utf-8")) + assert "$id" in doc