From 386e34260f0fc53312d779f072e58ce5bba0d013 Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Fri, 15 May 2026 03:08:33 +0300 Subject: [PATCH 01/42] feat: add supervaizer v2 contract primitives --- src/supervaizer/__init__.py | 43 +++ src/supervaizer/contracts.py | 204 +++++++++++++- .../supervaizer_v2/agent_interviewer_mvp.json | 254 ++++++++++++++++++ tests/test_contracts.py | 98 +++++++ 4 files changed, 598 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/supervaizer_v2/agent_interviewer_mvp.json diff --git a/src/supervaizer/__init__.py b/src/supervaizer/__init__.py index 32a0677..5280c79 100644 --- a/src/supervaizer/__init__.py +++ b/src/supervaizer/__init__.py @@ -65,6 +65,18 @@ "TelemetryCategory": ("supervaizer.telemetry", "TelemetryCategory"), "TelemetrySeverity": ("supervaizer.telemetry", "TelemetrySeverity"), "TelemetryType": ("supervaizer.telemetry", "TelemetryType"), + "SUPERVAIZER_V2_A2A_VERSION": ( + "supervaizer.contracts", + "SUPERVAIZER_V2_A2A_VERSION", + ), + "SUPERVAIZER_V2_A2UI_VERSION": ( + "supervaizer.contracts", + "SUPERVAIZER_V2_A2UI_VERSION", + ), + "SUPERVAIZER_V2_CONTRACT_VERSION": ( + "supervaizer.contracts", + "SUPERVAIZER_V2_CONTRACT_VERSION", + ), "AgentMethodContract": ("supervaizer.contracts", "AgentMethodContract"), "AgentMethodsContract": ("supervaizer.contracts", "AgentMethodsContract"), "AgentRegistrationContract": ("supervaizer.contracts", "AgentRegistrationContract"), @@ -80,6 +92,37 @@ "supervaizer.contracts", "ServerRegistrationContract", ), + "SupervaizerV2AgentRegistrationContract": ( + "supervaizer.contracts", + "SupervaizerV2AgentRegistrationContract", + ), + "V2ActionRequest": ("supervaizer.contracts", "V2ActionRequest"), + "V2ActionResult": ("supervaizer.contracts", "V2ActionResult"), + "V2AgentCapabilities": ("supervaizer.contracts", "V2AgentCapabilities"), + "V2AgentIdentity": ("supervaizer.contracts", "V2AgentIdentity"), + "V2ArtifactRef": ("supervaizer.contracts", "V2ArtifactRef"), + "V2ArtifactTypeDefinition": ( + "supervaizer.contracts", + "V2ArtifactTypeDefinition", + ), + "V2AwaitingState": ("supervaizer.contracts", "V2AwaitingState"), + "V2CaseLaneDefinition": ("supervaizer.contracts", "V2CaseLaneDefinition"), + "V2CaseSnapshot": ("supervaizer.contracts", "V2CaseSnapshot"), + "V2DatasetDefinition": ("supervaizer.contracts", "V2DatasetDefinition"), + "V2Effect": ("supervaizer.contracts", "V2Effect"), + "V2JobPolicy": ("supervaizer.contracts", "V2JobPolicy"), + "V2JobSnapshot": ("supervaizer.contracts", "V2JobSnapshot"), + "V2JobSource": ("supervaizer.contracts", "V2JobSource"), + "V2JobStateSnapshot": ("supervaizer.contracts", "V2JobStateSnapshot"), + "V2JobSyncResult": ("supervaizer.contracts", "V2JobSyncResult"), + "V2ProtocolVersions": ("supervaizer.contracts", "V2ProtocolVersions"), + "V2ReplaySafetyMetadata": ( + "supervaizer.contracts", + "V2ReplaySafetyMetadata", + ), + "V2ResourceDefinition": ("supervaizer.contracts", "V2ResourceDefinition"), + "V2StepSnapshot": ("supervaizer.contracts", "V2StepSnapshot"), + "V2WorkspaceContext": ("supervaizer.contracts", "V2WorkspaceContext"), "build_data_resource_context_headers": ( "supervaizer.contracts", "build_data_resource_context_headers", diff --git a/src/supervaizer/contracts.py b/src/supervaizer/contracts.py index e30203c..ee47cf4 100644 --- a/src/supervaizer/contracts.py +++ b/src/supervaizer/contracts.py @@ -14,12 +14,15 @@ from __future__ import annotations from enum import StrEnum -from typing import Any +from typing import Any, Literal from pydantic import BaseModel, Field CONTROLLER_CONTRACT_VERSION = "1.0" API_BASE_PATH = "/api" +SUPERVAIZER_V2_CONTRACT_VERSION = 2 +SUPERVAIZER_V2_A2UI_VERSION = "v0.8" +SUPERVAIZER_V2_A2A_VERSION = "0.2.6" class ContractModel(BaseModel): @@ -242,6 +245,205 @@ class DataResourceListResponse(ContractModel): items: list[dict[str, Any]] = Field(default_factory=list) +class V2AgentIdentity(ContractModel): + id: str + slug: str + display_name: str + + +class V2ProtocolVersions(ContractModel): + a2ui_version: str + a2ui_catalog_version: str + a2a_version: str + ag_ui_version: str | None = None + + +class V2A2ATransport(ContractModel): + json_rpc: bool = True + sse: bool = True + push_notifications: bool = True + + +class V2A2AExternalInterop(ContractModel): + inbound_tasks: bool = False + outbound_delegation: bool = False + + +class V2A2AController(ContractModel): + agent_card_url: str + controller_url: str + transport: V2A2ATransport = Field(default_factory=V2A2ATransport) + external_interop: V2A2AExternalInterop = Field(default_factory=V2A2AExternalInterop) + + +class V2CaseLaneDefinition(ContractModel): + id: str + label: str + default: bool = False + + +class V2ArtifactTypeDefinition(ContractModel): + type: str + label: str + renderer_surface: str | None = None + + +class V2AgentCapabilities(ContractModel): + surfaces: list[str] = Field(default_factory=list) + actions: list[str] = Field(default_factory=list) + case_lanes: list[V2CaseLaneDefinition] = Field(default_factory=list) + artifact_types: list[V2ArtifactTypeDefinition] = Field(default_factory=list) + + +class V2JobSyncPolicy(ContractModel): + action: str = "job.sync" + supported_statuses: list[str] = Field(default_factory=list) + + +class V2JobPolicy(ContractModel): + default_timeout_seconds: int | None = None + offline_start_policy: Literal["block"] = "block" + offline_running_policy: Literal["fail_in_studio"] = "fail_in_studio" + sync: V2JobSyncPolicy | None = None + + +class V2ResourceDisplayDefinition(ContractModel): + title_field: str | None = None + columns: list[str] = Field(default_factory=list) + search_fields: list[str] = Field(default_factory=list) + + +class V2MountedResourceViewDefinition(ContractModel): + view: str + surface: str + + +class V2ResourceDefinition(ContractModel): + id: str + label: str + auto_surface: bool = False + operations: list[str] = Field(default_factory=list) + display: V2ResourceDisplayDefinition | None = None + mounted_views: list[V2MountedResourceViewDefinition] = Field(default_factory=list) + + +class V2DatasetDefinition(ContractModel): + id: str + label: str + auto_surface: bool = False + + +class SupervaizerV2AgentRegistrationContract(ContractModel): + supervaizer_contract_version: Literal[2] = SUPERVAIZER_V2_CONTRACT_VERSION + agent: V2AgentIdentity + versions: V2ProtocolVersions + a2a: V2A2AController + capabilities: V2AgentCapabilities = Field(default_factory=V2AgentCapabilities) + job_policy: V2JobPolicy = Field(default_factory=V2JobPolicy) + resources: list[V2ResourceDefinition] = Field(default_factory=list) + datasets: list[V2DatasetDefinition] = Field(default_factory=list) + + +class V2ActorContext(ContractModel): + user_id: str + + +class V2WorkspaceContext(ContractModel): + id: str + slug: str | None = None + + +class V2ActionRequest(ContractModel): + request_id: str + actor: V2ActorContext + workspace: V2WorkspaceContext + mission_id: str + agent_slug: str + surface: str + action: str + input: dict[str, Any] = Field(default_factory=dict) + idempotency_key: str | None = None + draft_session_id: str | None = None + job_id: str | None = None + case_id: str | None = None + step_id: str | None = None + + +class V2Effect(ContractModel): + type: str + + +class V2ActionResult(ContractModel): + status: Literal["ok", "error"] + effects: list[V2Effect] = Field(default_factory=list) + + +class V2ArtifactRef(ContractModel): + id: str + type: str + title: str | None = None + external_id: str | None = None + media_type: str | None = None + + +class V2AwaitingState(ContractModel): + reason: str + surface: str + action: str + + +class V2StepSnapshot(ContractModel): + id: str + activity: Literal["operation", "delegation"] + status: str + title: str | None = None + external_id: str | None = None + awaiting: V2AwaitingState | None = None + outputs: list[V2ArtifactRef] = Field(default_factory=list) + + +class V2CaseSnapshot(ContractModel): + id: str + lane: str = "work" + title: str | None = None + status: str | None = None + external_id: str | None = None + steps: list[V2StepSnapshot] = Field(default_factory=list) + + +class V2JobSource(ContractModel): + type: Literal["fresh_start", "external"] + external_ref: str | None = None + previous_job_id: str | None = None + + +class V2JobSnapshot(ContractModel): + id: str + agent_slug: str + mission_id: str + status: str + source: V2JobSource + + +class V2JobStateSnapshot(ContractModel): + job: V2JobSnapshot + cases: list[V2CaseSnapshot] = Field(default_factory=list) + + +class V2JobSyncResult(V2ActionResult): + external_ref: str | None = None + external_version: str | None = None + sync_cursor: str | None = None + observed_at: str | None = None + + +class V2ReplaySafetyMetadata(ContractModel): + dedupe_keys: list[str] = Field(default_factory=list) + stable_external_ids_required: bool = True + strictly_idempotent_response: bool = False + convergent: bool = True + + def _endpoint_key(endpoint: ControllerEndpoint | str) -> str: return endpoint.value if isinstance(endpoint, ControllerEndpoint) else endpoint diff --git a/tests/fixtures/supervaizer_v2/agent_interviewer_mvp.json b/tests/fixtures/supervaizer_v2/agent_interviewer_mvp.json new file mode 100644 index 0000000..18b1f95 --- /dev/null +++ b/tests/fixtures/supervaizer_v2/agent_interviewer_mvp.json @@ -0,0 +1,254 @@ +{ + "registration": { + "supervaizer_contract_version": 2, + "agent": { + "id": "agent_interviewer", + "slug": "agent_interviewer", + "display_name": "Agent Interviewer" + }, + "versions": { + "a2ui_version": "v0.8", + "a2ui_catalog_version": "supervaizer-v2-mvp.0", + "a2a_version": "0.2.6", + "ag_ui_version": null + }, + "a2a": { + "agent_card_url": "https://agent.example.com/.well-known/agent-card.json", + "controller_url": "https://agent.example.com/a2a", + "transport": { + "json_rpc": true, + "sse": true, + "push_notifications": true + }, + "external_interop": { + "inbound_tasks": false, + "outbound_delegation": false + } + }, + "capabilities": { + "surfaces": [ + "mission.agent.resource.campaigns", + "job.start", + "case.step.awaiting", + "case.step.detail", + "mission.analytics" + ], + "actions": [ + "job.start.preview", + "job.start", + "job.sync", + "campaigns.sync", + "step.awaiting.submit" + ], + "case_lanes": [ + { + "id": "setup", + "label": "Setup" + }, + { + "id": "work", + "label": "Work", + "default": true + }, + { + "id": "deliverable", + "label": "Deliverables" + } + ], + "artifact_types": [ + { + "type": "agent_interviewer.transcript", + "label": "Transcript", + "renderer_surface": "case.step.detail" + }, + { + "type": "agent_interviewer.synthesis", + "label": "Synthesis", + "renderer_surface": "case.step.detail" + } + ] + }, + "job_policy": { + "default_timeout_seconds": 3600, + "offline_start_policy": "block", + "offline_running_policy": "fail_in_studio", + "sync": { + "action": "job.sync", + "supported_statuses": [ + "active", + "awaiting", + "failed", + "completed" + ] + } + }, + "resources": [ + { + "id": "campaigns", + "label": "Campaigns", + "auto_surface": true, + "operations": [ + "list", + "get", + "create", + "update" + ], + "display": { + "title_field": "name", + "columns": [ + "name", + "status", + "contact_count", + "updated_at" + ], + "search_fields": [ + "name" + ] + } + }, + { + "id": "prompts", + "label": "Prompts", + "auto_surface": true, + "operations": [ + "list", + "get", + "create", + "update" + ], + "mounted_views": [ + { + "view": "edit", + "surface": "mission.agent.surface.prompt_editor" + } + ] + } + ], + "datasets": [ + { + "id": "campaign_progress", + "label": "Campaign Progress", + "auto_surface": true + } + ] + }, + "action_request": { + "request_id": "req_start_123", + "idempotency_key": "idem_start_campaign_123", + "actor": { + "user_id": "user_123" + }, + "workspace": { + "id": "workspace_123", + "slug": "acme" + }, + "mission_id": "mission_123", + "agent_slug": "agent_interviewer", + "draft_session_id": "draft_123", + "job_id": "job_123", + "surface": "job.start", + "action": "job.start", + "input": { + "campaign_id": "campaign_123" + } + }, + "action_result": { + "status": "ok", + "effects": [ + { + "type": "job.started", + "job_id": "job_123", + "external_ref": "campaign_123" + }, + { + "type": "case.created", + "case": { + "id": "case_setup_123", + "external_id": "campaign_123:setup", + "job_id": "job_123", + "lane": "setup", + "title": "Campaign setup", + "status": "completed" + } + } + ] + }, + "job_state": { + "job": { + "id": "job_123", + "agent_slug": "agent_interviewer", + "mission_id": "mission_123", + "status": "active", + "source": { + "type": "fresh_start", + "external_ref": "campaign_123" + } + }, + "cases": [ + { + "id": "case_session_abc", + "external_id": "campaign_123:session:session_abc", + "lane": "work", + "title": "Interview session for alex@example.com", + "status": "awaiting", + "steps": [ + { + "id": "step_session_review", + "external_id": "session_abc:human_review", + "activity": "operation", + "status": "awaiting", + "title": "Review session synthesis", + "awaiting": { + "reason": "human_input", + "surface": "case.step.awaiting", + "action": "step.awaiting.submit" + }, + "outputs": [ + { + "id": "artifact_transcript_session_abc", + "external_id": "session_abc:transcript", + "type": "agent_interviewer.transcript", + "title": "Transcript", + "media_type": "text/markdown" + }, + { + "id": "artifact_synthesis_session_abc", + "external_id": "session_abc:synthesis", + "type": "agent_interviewer.synthesis", + "title": "Synthesis", + "media_type": "application/json" + } + ] + } + ] + } + ] + }, + "sync_result": { + "status": "ok", + "external_ref": "campaign_123", + "external_version": "campaign_123:rev_42", + "sync_cursor": "rev_42", + "observed_at": "2026-05-15T10:00:00Z", + "effects": [ + { + "type": "step.updated", + "step": { + "id": "step_session_review", + "external_id": "session_abc:human_review", + "status": "completed" + } + } + ] + }, + "replay_safety": { + "dedupe_keys": [ + "job_123", + "campaign_123", + "campaign_123:rev_42" + ], + "stable_external_ids_required": true, + "strictly_idempotent_response": false, + "convergent": true + } +} diff --git a/tests/test_contracts.py b/tests/test_contracts.py index b12f171..14f8f59 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -9,17 +9,32 @@ from __future__ import annotations import importlib +import json import sys +from pathlib import Path from supervaizer.contracts import ( ControllerEndpoint, ControllerContract, ServerRegistrationContract, + SupervaizerV2AgentRegistrationContract, + V2ActionRequest, + V2ActionResult, + V2JobStateSnapshot, + V2JobSyncResult, + V2ReplaySafetyMetadata, build_data_resource_context_headers, controller_contract_info, resolve_controller_endpoint, ) +FIXTURE_DIR = Path(__file__).parent / "fixtures" / "supervaizer_v2" + + +def load_v2_fixture(name: str) -> dict: + with (FIXTURE_DIR / name).open() as fixture_file: + return json.load(fixture_file) + def test_controller_contract_endpoints_are_api_prefixed() -> None: info = controller_contract_info() @@ -127,3 +142,86 @@ def test_data_resource_context_headers() -> None: "X-Supervaize-Mission-Id": "mission-1", "X-Supervaize-Request-Id": "request-1", } + + +def test_v2_agent_interviewer_registration_fixture() -> None: + fixture = load_v2_fixture("agent_interviewer_mvp.json") + + registration = SupervaizerV2AgentRegistrationContract.model_validate( + fixture["registration"] + ) + + assert registration.supervaizer_contract_version == 2 + assert registration.versions.a2ui_version == "v0.8" + assert registration.versions.a2a_version == "0.2.6" + assert registration.job_policy.sync is not None + assert registration.job_policy.sync.action == "job.sync" + assert "job.start" in registration.capabilities.surfaces + assert "campaigns.sync" in registration.capabilities.actions + assert any( + lane.id == "work" and lane.default + for lane in registration.capabilities.case_lanes + ) + assert {resource.id for resource in registration.resources} >= { + "campaigns", + "prompts", + } + + +def test_v2_action_request_and_result_fixture() -> None: + fixture = load_v2_fixture("agent_interviewer_mvp.json") + + request = V2ActionRequest.model_validate(fixture["action_request"]) + result = V2ActionResult.model_validate(fixture["action_result"]) + + assert request.action == "job.start" + assert request.idempotency_key == "idem_start_campaign_123" + assert request.draft_session_id == "draft_123" + assert result.status == "ok" + assert [effect.type for effect in result.effects] == [ + "job.started", + "case.created", + ] + + +def test_v2_job_state_snapshot_fixture() -> None: + fixture = load_v2_fixture("agent_interviewer_mvp.json") + + snapshot = V2JobStateSnapshot.model_validate(fixture["job_state"]) + step = snapshot.cases[0].steps[0] + + assert snapshot.job.source.type == "fresh_start" + assert snapshot.cases[0].lane == "work" + assert step.activity == "operation" + assert step.status == "awaiting" + assert step.awaiting is not None + assert step.awaiting.reason == "human_input" + assert {artifact.type for artifact in step.outputs} == { + "agent_interviewer.transcript", + "agent_interviewer.synthesis", + } + + +def test_v2_job_sync_result_is_convergent_not_strictly_idempotent() -> None: + fixture = load_v2_fixture("agent_interviewer_mvp.json") + + sync_result = V2JobSyncResult.model_validate(fixture["sync_result"]) + replay_safety = V2ReplaySafetyMetadata.model_validate(fixture["replay_safety"]) + + assert sync_result.status == "ok" + assert sync_result.external_version == "campaign_123:rev_42" + assert sync_result.sync_cursor == "rev_42" + assert replay_safety.convergent is True + assert replay_safety.strictly_idempotent_response is False + + +def test_v2_contract_models_are_public_sdk_exports() -> None: + import supervaizer + + assert supervaizer.SUPERVAIZER_V2_CONTRACT_VERSION == 2 + assert ( + supervaizer.SupervaizerV2AgentRegistrationContract + is SupervaizerV2AgentRegistrationContract + ) + assert supervaizer.V2ActionRequest is V2ActionRequest + assert supervaizer.V2JobStateSnapshot is V2JobStateSnapshot From 20151283e104ac11dae87572791fd8adf9a74b01 Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Fri, 15 May 2026 07:51:58 +0300 Subject: [PATCH 02/42] feat: add supervaizer v2 a2a action endpoint --- src/supervaizer/protocol/a2a/__init__.py | 3 +- src/supervaizer/protocol/a2a/controller.py | 147 +++++++++++++++++++++ src/supervaizer/protocol/a2a/routes.py | 20 +++ src/supervaizer/routers/public.py | 6 +- tests/test_a2a.py | 89 +++++++++++++ 5 files changed, 263 insertions(+), 2 deletions(-) create mode 100644 src/supervaizer/protocol/a2a/controller.py diff --git a/src/supervaizer/protocol/a2a/__init__.py b/src/supervaizer/protocol/a2a/__init__.py index 6383686..c191473 100644 --- a/src/supervaizer/protocol/a2a/__init__.py +++ b/src/supervaizer/protocol/a2a/__init__.py @@ -17,11 +17,12 @@ create_agents_list, create_health_data, ) -from supervaizer.protocol.a2a.routes import create_routes +from supervaizer.protocol.a2a.routes import create_controller_routes, create_routes __all__ = [ "create_agent_card", "create_agents_list", "create_health_data", + "create_controller_routes", "create_routes", ] diff --git a/src/supervaizer/protocol/a2a/controller.py b/src/supervaizer/protocol/a2a/controller.py new file mode 100644 index 0000000..99a0e6f --- /dev/null +++ b/src/supervaizer/protocol/a2a/controller.py @@ -0,0 +1,147 @@ +# Copyright (c) 2024-2026 Alain Prasquier - Supervaize.com. All rights reserved. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, you can obtain one at +# https://mozilla.org/MPL/2.0/. + +"""A2A JSON-RPC controller methods for Supervaizer v2.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from inspect import isawaitable +from typing import TYPE_CHECKING, Any, Literal + +from pydantic import Field, ValidationError + +from supervaizer.contracts import ContractModel, V2ActionRequest, V2ActionResult + +if TYPE_CHECKING: + from supervaizer.server import Server + +SUPERVAIZER_ACTION_INVOKE_METHOD = "supervaizer/action.invoke" + +JSON_RPC_METHOD_NOT_FOUND = -32601 +JSON_RPC_INVALID_PARAMS = -32602 +JSON_RPC_ACTION_NOT_REGISTERED = -32010 +JSON_RPC_INTERNAL_ERROR = -32603 + +ActionHandler = Callable[ + [V2ActionRequest], + V2ActionResult | dict[str, Any] | Awaitable[V2ActionResult | dict[str, Any]], +] + + +class JsonRpcRequest(ContractModel): + jsonrpc: Literal["2.0"] = "2.0" + id: str | int | None = None + method: str + params: dict[str, Any] = Field(default_factory=dict) + + +class JsonRpcError(ContractModel): + code: int + message: str + data: dict[str, Any] | None = None + + +class JsonRpcResponse(ContractModel): + jsonrpc: Literal["2.0"] = "2.0" + id: str | int | None = None + result: dict[str, Any] | None = None + error: JsonRpcError | None = None + + +def register_v2_action_handler( + server: "Server", action: str, handler: ActionHandler +) -> None: + """Register a Supervaizer v2 action handler for the current server process.""" + handlers = _get_action_handlers(server) + handlers[action] = handler + + +async def dispatch_json_rpc(server: "Server", body: dict[str, Any]) -> JsonRpcResponse: + """Dispatch one A2A JSON-RPC request.""" + try: + request = JsonRpcRequest.model_validate(body) + except ValidationError as exc: + return _json_rpc_error( + request_id=body.get("id"), + code=JSON_RPC_INVALID_PARAMS, + message="Invalid JSON-RPC request", + data={"errors": exc.errors()}, + ) + + if request.method != SUPERVAIZER_ACTION_INVOKE_METHOD: + return _json_rpc_error( + request_id=request.id, + code=JSON_RPC_METHOD_NOT_FOUND, + message=f"Method not found: {request.method}", + ) + + return await _dispatch_action(server, request) + + +async def _dispatch_action( + server: "Server", request: JsonRpcRequest +) -> JsonRpcResponse: + try: + action_request = _validate_action_request(request.params) + except ValidationError as exc: + return _json_rpc_error( + request_id=request.id, + code=JSON_RPC_INVALID_PARAMS, + message="Invalid Supervaizer v2 action request", + data={"errors": exc.errors()}, + ) + + handler = _get_action_handlers(server).get(action_request.action) + if handler is None: + return _json_rpc_error( + request_id=request.id, + code=JSON_RPC_ACTION_NOT_REGISTERED, + message=f"Action handler not registered: {action_request.action}", + data={"action": action_request.action}, + ) + + try: + handler_result = handler(action_request) + if isawaitable(handler_result): + handler_result = await handler_result + result = V2ActionResult.model_validate(handler_result) + except Exception as exc: + return _json_rpc_error( + request_id=request.id, + code=JSON_RPC_INTERNAL_ERROR, + message="Action handler failed", + data={"action": action_request.action, "error": str(exc)}, + ) + + return JsonRpcResponse(id=request.id, result=result.model_dump(mode="json")) + + +def _validate_action_request(params: dict[str, Any]) -> V2ActionRequest: + action_payload = params.get("action_request", params) + return V2ActionRequest.model_validate(action_payload) + + +def _get_action_handlers(server: "Server") -> dict[str, ActionHandler]: + state = server.app.state + handlers = getattr(state, "supervaizer_v2_action_handlers", None) + if handlers is None: + handlers = {} + state.supervaizer_v2_action_handlers = handlers + return handlers + + +def _json_rpc_error( + *, + request_id: str | int | None, + code: int, + message: str, + data: dict[str, Any] | None = None, +) -> JsonRpcResponse: + return JsonRpcResponse( + id=request_id, + error=JsonRpcError(code=code, message=message, data=data), + ) diff --git a/src/supervaizer/protocol/a2a/routes.py b/src/supervaizer/protocol/a2a/routes.py index a24f2eb..41b0ba8 100644 --- a/src/supervaizer/protocol/a2a/routes.py +++ b/src/supervaizer/protocol/a2a/routes.py @@ -15,6 +15,7 @@ from fastapi import APIRouter from supervaizer.common import log +from supervaizer.protocol.a2a.controller import dispatch_json_rpc from supervaizer.protocol.a2a.model import ( create_agent_card, create_agents_list, @@ -103,3 +104,22 @@ async def get_agent_card_legacy() -> Dict[str, Any]: create_agent_route_legacy(agent) return router + + +def create_controller_routes(server: "Server") -> APIRouter: + """Create A2A JSON-RPC controller routes for Supervaizer v2.""" + router = APIRouter(tags=["Protocol A2A"]) + + @router.post( + "/a2a", + summary="A2A JSON-RPC Controller", + description="Dispatches Supervaizer v2 controller methods over A2A JSON-RPC.", + response_model=Dict[str, Any], + ) + @handle_route_errors() + async def post_a2a_controller(body: Dict[str, Any]) -> Dict[str, Any]: + log.info("[A2A] POST /a2a [JSON-RPC controller]") + response = await dispatch_json_rpc(server, body) + return response.model_dump(mode="json", exclude_none=True) + + return router diff --git a/src/supervaizer/routers/public.py b/src/supervaizer/routers/public.py index 9e95b21..87f98c3 100644 --- a/src/supervaizer/routers/public.py +++ b/src/supervaizer/routers/public.py @@ -43,7 +43,10 @@ def create_public_router( * ``GET /`` — home page * ``/.well-known/*`` — A2A discovery (no auth) """ - from supervaizer.protocol.a2a.routes import create_routes as create_a2a_routes + from supervaizer.protocol.a2a.routes import ( + create_controller_routes, + create_routes as create_a2a_routes, + ) router = APIRouter(tags=["Public"]) @@ -67,5 +70,6 @@ async def home_page(request: Request) -> HTMLResponse: # <-- MOVED from server. if server.a2a_endpoints: router.include_router(create_a2a_routes(server)) + router.include_router(create_controller_routes(server)) return router diff --git a/tests/test_a2a.py b/tests/test_a2a.py index e0b8e0d..1b260d8 100644 --- a/tests/test_a2a.py +++ b/tests/test_a2a.py @@ -15,11 +15,18 @@ from fastapi.testclient import TestClient from supervaizer import Agent, Server +from supervaizer.contracts import V2ActionRequest, V2ActionResult, V2Effect from supervaizer.protocol.a2a import ( create_agent_card, create_agents_list, create_health_data, ) +from supervaizer.protocol.a2a.controller import ( + JSON_RPC_ACTION_NOT_REGISTERED, + JSON_RPC_METHOD_NOT_FOUND, + SUPERVAIZER_ACTION_INVOKE_METHOD, + register_v2_action_handler, +) def test_create_agent_card(agent_fixture: Agent) -> None: @@ -173,6 +180,88 @@ def test_a2a_route_endpoints(server_fixture: Server) -> None: assert response.status_code == 404 +def test_a2a_controller_rejects_unknown_method(server_fixture: Server) -> None: + client = TestClient(server_fixture.app) + + response = client.post( + "/a2a", + json={"jsonrpc": "2.0", "id": "rpc-1", "method": "missing.method"}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["id"] == "rpc-1" + assert payload["error"]["code"] == JSON_RPC_METHOD_NOT_FOUND + + +def test_a2a_controller_rejects_unregistered_v2_action( + server_fixture: Server, +) -> None: + client = TestClient(server_fixture.app) + + response = client.post( + "/a2a", + json={ + "jsonrpc": "2.0", + "id": "rpc-2", + "method": SUPERVAIZER_ACTION_INVOKE_METHOD, + "params": _v2_action_payload(action="job.start"), + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["id"] == "rpc-2" + assert payload["error"]["code"] == JSON_RPC_ACTION_NOT_REGISTERED + assert payload["error"]["data"]["action"] == "job.start" + + +def test_a2a_controller_dispatches_registered_v2_action( + server_fixture: Server, +) -> None: + def start_job(request: V2ActionRequest) -> V2ActionResult: + assert request.action == "job.start" + return V2ActionResult( + status="ok", + effects=[V2Effect(type="job.created", job_id="job-123")], + ) + + register_v2_action_handler(server_fixture, "job.start", start_job) + client = TestClient(server_fixture.app) + + response = client.post( + "/a2a", + json={ + "jsonrpc": "2.0", + "id": "rpc-3", + "method": SUPERVAIZER_ACTION_INVOKE_METHOD, + "params": _v2_action_payload(action="job.start"), + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["id"] == "rpc-3" + assert payload["result"]["status"] == "ok" + assert payload["result"]["effects"] == [ + {"type": "job.created", "job_id": "job-123"} + ] + + +def _v2_action_payload(action: str) -> dict[str, object]: + return { + "request_id": "request-1", + "actor": {"user_id": "user-1"}, + "workspace": {"id": "workspace-1", "slug": "workspace"}, + "mission_id": "mission-1", + "agent_slug": "agent-interviewer", + "surface": "job.start", + "action": action, + "input": {"campaign_id": "campaign-1"}, + "draft_session_id": "draft-1", + } + + def test_a2a_schema_conformance(agent_fixture: Agent) -> None: """Test that the A2A output conforms to the JSON schema.""" # Define a minimal A2A schema for validation From 2d78d9590a760c13c7e2e9381ffbaf38b6a52828 Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Fri, 15 May 2026 08:48:47 +0300 Subject: [PATCH 03/42] feat: expose supervaizer v2 registration in a2a card --- src/supervaizer/agent.py | 12 ++++++++++ src/supervaizer/protocol/a2a/model.py | 4 ++++ tests/test_a2a.py | 31 ++++++++++++++++++++++++++ tests/test_agent.py | 32 +++++++++++++++++++++++++++ 4 files changed, 79 insertions(+) diff --git a/src/supervaizer/agent.py b/src/supervaizer/agent.py index 53a1bb7..7a4c6f6 100644 --- a/src/supervaizer/agent.py +++ b/src/supervaizer/agent.py @@ -36,6 +36,7 @@ from supervaizer.lifecycle import EntityStatus from supervaizer.parameter import ParametersSetup from supervaizer.case import CaseNodes +from supervaizer.contracts import SupervaizerV2AgentRegistrationContract from supervaizer.data_resource import DataResource if TYPE_CHECKING: @@ -655,6 +656,10 @@ class AgentAbstract(SvBaseModel): description="Data resources this agent exposes for Studio CRUD access", exclude=True, ) + supervaizer_v2_registration: SupervaizerV2AgentRegistrationContract | None = Field( + default=None, + description="Optional Supervaizer v2 registration contract for A2A/A2UI Studio integrations", + ) model_config = cast( ConfigDict, {"reference_group": "Core", "arbitrary_types_allowed": True} @@ -684,6 +689,9 @@ def __init__( custom_routes: Any | None = None, dynamic_choices_callback: Any | None = None, data_resources: list["DataResource"] | None = None, + supervaizer_v2_registration: SupervaizerV2AgentRegistrationContract + | dict[str, Any] + | None = None, **kwargs: Any, ) -> None: """ @@ -741,6 +749,7 @@ def __init__( custom_routes=custom_routes, dynamic_choices_callback=dynamic_choices_callback, data_resources=data_resources or [], + supervaizer_v2_registration=supervaizer_v2_registration, **kwargs, ) @@ -791,6 +800,9 @@ def registration_info(self) -> Dict[str, Any]: "max_execution_time": self.max_execution_time, "instructions_path": self.instructions_path, "data_resources": [r.registration_info for r in self.data_resources], + "supervaizer_v2": self.supervaizer_v2_registration.model_dump(mode="json") + if self.supervaizer_v2_registration + else None, } def update_agent_from_server(self, server: "Server") -> Optional["Agent"]: diff --git a/src/supervaizer/protocol/a2a/model.py b/src/supervaizer/protocol/a2a/model.py index dd5a2d7..3e9baa8 100644 --- a/src/supervaizer/protocol/a2a/model.py +++ b/src/supervaizer/protocol/a2a/model.py @@ -130,6 +130,10 @@ def create_agent_card(agent: Agent, base_url: str) -> Dict[str, Any]: "tools": tools, "authentication": authentication, } + if agent.supervaizer_v2_registration is not None: + agent_card["supervaizer"] = { + "v2": agent.supervaizer_v2_registration.model_dump(mode="json") + } return agent_card diff --git a/tests/test_a2a.py b/tests/test_a2a.py index 1b260d8..73264b4 100644 --- a/tests/test_a2a.py +++ b/tests/test_a2a.py @@ -91,6 +91,37 @@ def test_create_agent_card(agent_fixture: Agent) -> None: assert "changelog_url" in card["version_info"] +def test_create_agent_card_includes_supervaizer_v2_extension() -> None: + agent = Agent( + name="agentName", + author="authorName", + developer="Dev", + version="1.0.0", + description="description", + supervaizer_v2_registration={ + "agent": { + "id": "agent_name", + "slug": "agent-name", + "display_name": "Agent Name", + }, + "versions": { + "a2ui_version": "v0.8", + "a2ui_catalog_version": "test.0", + "a2a_version": "0.2.6", + }, + "a2a": { + "agent_card_url": "/.well-known/agents/v1.0.0/agent-name_agent.json", + "controller_url": "/a2a", + }, + }, + ) + + card = create_agent_card(agent, "https://agent.example.com") + + assert card["supervaizer"]["v2"]["supervaizer_contract_version"] == 2 + assert card["supervaizer"]["v2"]["a2a"]["controller_url"] == "/a2a" + + def test_create_agents_list(agent_fixture: Agent) -> None: """Test the create_agents_list function.""" base_url = "http://test.example.com" diff --git a/tests/test_agent.py b/tests/test_agent.py index db962a2..11dc723 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -1305,3 +1305,35 @@ def test_agent_registration_info_includes_release_notes_url() -> None: assert info["version"] == "1.0.0" assert info["release_notes_url"] == "https://example.com/releases/1.0.0" + + +def test_agent_registration_info_includes_supervaizer_v2_contract() -> None: + """Agent.registration_info includes the optional Supervaizer v2 contract.""" + agent = Agent( + name="agentName", + author="authorName", + developer="Dev", + version="1.0.0", + description="description", + supervaizer_v2_registration={ + "agent": { + "id": "agent_name", + "slug": "agent-name", + "display_name": "Agent Name", + }, + "versions": { + "a2ui_version": "v0.8", + "a2ui_catalog_version": "test.0", + "a2a_version": "0.2.6", + }, + "a2a": { + "agent_card_url": "/.well-known/agents/v1.0.0/agent-name_agent.json", + "controller_url": "/a2a", + }, + }, + ) + + info = agent.registration_info + + assert info["supervaizer_v2"]["supervaizer_contract_version"] == 2 + assert info["supervaizer_v2"]["versions"]["a2ui_version"] == "v0.8" From 104c8bcb46b3e3b0ffec1900b5dc1be92354328a Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Fri, 15 May 2026 09:17:02 +0300 Subject: [PATCH 04/42] feat: add supervaizer v2 action decorator --- src/supervaizer/server.py | 19 ++++++++++++++++++- tests/test_a2a.py | 27 +++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/supervaizer/server.py b/src/supervaizer/server.py index 2faae3d..9300a2b 100644 --- a/src/supervaizer/server.py +++ b/src/supervaizer/server.py @@ -15,7 +15,7 @@ import secrets import sys import time -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, Callable from contextlib import asynccontextmanager import uuid from datetime import datetime # <-- REMOVED: Path (no longer needed) @@ -51,6 +51,10 @@ ) from supervaizer.contracts import controller_contract_info from supervaizer.instructions import display_instructions +from supervaizer.protocol.a2a.controller import ( + ActionHandler, + register_v2_action_handler, +) from supervaizer.routes import get_server # <-- MODIFIED: removed per-router imports from supervaizer.routers import ( create_api_router, @@ -745,3 +749,16 @@ def encrypt(self, parameters: str) -> str: if result is None: raise ValueError("Failed to encrypt parameters") return result + + def register_v2_action(self, action: str, handler: ActionHandler) -> ActionHandler: + """Register a Supervaizer v2 action handler on this server.""" + register_v2_action_handler(self, action, handler) + return handler + + def v2_action(self, action: str) -> Callable[[ActionHandler], ActionHandler]: + """Decorator form of register_v2_action for SDK users.""" + + def decorator(handler: ActionHandler) -> ActionHandler: + return self.register_v2_action(action, handler) + + return decorator diff --git a/tests/test_a2a.py b/tests/test_a2a.py index 73264b4..70992e1 100644 --- a/tests/test_a2a.py +++ b/tests/test_a2a.py @@ -279,6 +279,33 @@ def start_job(request: V2ActionRequest) -> V2ActionResult: ] +def test_server_v2_action_decorator_registers_handler(server_fixture: Server) -> None: + @server_fixture.v2_action("job.start.preview") + def preview_job_start(request: V2ActionRequest) -> dict[str, object]: + assert request.action == "job.start.preview" + return {"status": "ok", "effects": [{"type": "job.start.previewed"}]} + + client = TestClient(server_fixture.app) + + response = client.post( + "/a2a", + json={ + "jsonrpc": "2.0", + "id": "rpc-4", + "method": SUPERVAIZER_ACTION_INVOKE_METHOD, + "params": _v2_action_payload(action="job.start.preview"), + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["id"] == "rpc-4" + assert payload["result"] == { + "status": "ok", + "effects": [{"type": "job.start.previewed"}], + } + + def _v2_action_payload(action: str) -> dict[str, object]: return { "request_id": "request-1", From 336e311e3ce3f4907d526c381ac6ee6832560838 Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Fri, 15 May 2026 10:28:25 +0300 Subject: [PATCH 05/42] docs: update supervaizer v2 changelog --- docs/CHANGELOG.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index deb0c37..1c918eb 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -15,12 +15,32 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Supervaizer v2 2️⃣ + +- **Supervaizer v2 contract primitives** — Added typed SDK models for the v2 registration and action contract, including pinned A2UI/A2A versions, resources, datasets, case lanes, artifact declarations, job snapshots, sync metadata, and replay-safety metadata. +- **A2A JSON-RPC action runtime** — Added the `/a2a` `supervaizer/action.invoke` dispatcher, v2 Agent Card extension payloads, and public SDK helpers for registering typed v2 actions through `Server.register_v2_action()` and `@server.v2_action(...)`. + +### Tests + +`just test` + +| Status | Count | +| ---------- | ----- | +| ✅ Passed | 581 | +| 🤔 Skipped | 0 | +| 🔴 Failed | 0 | +| ⏱️ in | 69s | + ## [0.20.1] - 2026-05-13 ### Security - **`uv.lock`** — Refreshed transitive versions to address open Dependabot / GHSA advisories on the default branch graph: **urllib3** (redirect and decompression-chain issues), **requests** (`extract_zipped_paths` temp reuse), **protobuf** (JSON recursion depth), **pyasn1** (decoder / recursion DoS), **pygments** (ReDoS in GUID lexer), and **uv** (ZIP / tar / RECORD handling; dev dependency via hatch). +### Tests + +- `uv run pytest tests/test_a2a.py tests/test_contracts.py -q` + ## [0.20.0] - 2026-05-13 ### Security From ca2f4fd2c798f503f48d004a885bc6a4ac9e81e9 Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Fri, 15 May 2026 10:41:11 +0300 Subject: [PATCH 06/42] precommit fix --- src/supervaizer/contracts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/supervaizer/contracts.py b/src/supervaizer/contracts.py index ee47cf4..dafbe33 100644 --- a/src/supervaizer/contracts.py +++ b/src/supervaizer/contracts.py @@ -20,7 +20,7 @@ CONTROLLER_CONTRACT_VERSION = "1.0" API_BASE_PATH = "/api" -SUPERVAIZER_V2_CONTRACT_VERSION = 2 +SUPERVAIZER_V2_CONTRACT_VERSION: Literal[2] = 2 SUPERVAIZER_V2_A2UI_VERSION = "v0.8" SUPERVAIZER_V2_A2A_VERSION = "0.2.6" From 6f28c6b33c2b04d785ad6de33d173ff8a4fa4687 Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Fri, 15 May 2026 10:51:19 +0300 Subject: [PATCH 07/42] refactor: move controller api version to contracts --- src/supervaizer/__init__.py | 1 + src/supervaizer/__version__.py | 9 +-------- src/supervaizer/admin/routes.py | 3 ++- src/supervaizer/admin/workbench_routes.py | 2 +- src/supervaizer/contracts.py | 1 + src/supervaizer/routers/public.py | 3 ++- src/supervaizer/server.py | 4 ++-- tests/test_contracts.py | 10 ++++++++++ 8 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/supervaizer/__init__.py b/src/supervaizer/__init__.py index 5280c79..350ee67 100644 --- a/src/supervaizer/__init__.py +++ b/src/supervaizer/__init__.py @@ -65,6 +65,7 @@ "TelemetryCategory": ("supervaizer.telemetry", "TelemetryCategory"), "TelemetrySeverity": ("supervaizer.telemetry", "TelemetrySeverity"), "TelemetryType": ("supervaizer.telemetry", "TelemetryType"), + "API_VERSION": ("supervaizer.contracts", "API_VERSION"), "SUPERVAIZER_V2_A2A_VERSION": ( "supervaizer.contracts", "SUPERVAIZER_V2_A2A_VERSION", diff --git a/src/supervaizer/__version__.py b/src/supervaizer/__version__.py index 2729d51..39ba7ce 100644 --- a/src/supervaizer/__version__.py +++ b/src/supervaizer/__version__.py @@ -4,13 +4,6 @@ # If a copy of the MPL was not distributed with this file, you can obtain one at # https://mozilla.org/MPL/2.0/. -# Copyright (c) 2024-2025 Alain Prasquier - Supervaize.com. All rights reserved. -# -# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. -# If a copy of the MPL was not distributed with this file, you can obtain one at -# https://mozilla.org/MPL/2.0/. - VERSION = "0.20.1" -API_VERSION = "v1" -TELEMETRY_VERSION = "v1" +__version__ = VERSION diff --git a/src/supervaizer/admin/routes.py b/src/supervaizer/admin/routes.py index 0e052fd..3e327c7 100644 --- a/src/supervaizer/admin/routes.py +++ b/src/supervaizer/admin/routes.py @@ -31,8 +31,9 @@ from pydantic import BaseModel from sse_starlette.sse import EventSourceResponse -from supervaizer.__version__ import API_VERSION, VERSION +from supervaizer.__version__ import VERSION from supervaizer.common import log +from supervaizer.contracts import API_VERSION from supervaizer.lifecycle import EntityStatus from supervaizer.storage import ( StorageManager, diff --git a/src/supervaizer/admin/workbench_routes.py b/src/supervaizer/admin/workbench_routes.py index 968186b..1315bfb 100644 --- a/src/supervaizer/admin/workbench_routes.py +++ b/src/supervaizer/admin/workbench_routes.py @@ -24,10 +24,10 @@ from fastapi.templating import Jinja2Templates from starlette.responses import Response -from supervaizer.__version__ import API_VERSION from supervaizer.agent import Agent from supervaizer.case import Cases, CaseNodeUpdate from supervaizer.common import log +from supervaizer.contracts import API_VERSION from supervaizer.job import Job, JobContext, JobResponse, Jobs from supervaizer.lifecycle import EntityStatus diff --git a/src/supervaizer/contracts.py b/src/supervaizer/contracts.py index dafbe33..e369384 100644 --- a/src/supervaizer/contracts.py +++ b/src/supervaizer/contracts.py @@ -19,6 +19,7 @@ from pydantic import BaseModel, Field CONTROLLER_CONTRACT_VERSION = "1.0" +API_VERSION = "v1" API_BASE_PATH = "/api" SUPERVAIZER_V2_CONTRACT_VERSION: Literal[2] = 2 SUPERVAIZER_V2_A2UI_VERSION = "v0.8" diff --git a/src/supervaizer/routers/public.py b/src/supervaizer/routers/public.py index 87f98c3..dae9115 100644 --- a/src/supervaizer/routers/public.py +++ b/src/supervaizer/routers/public.py @@ -21,7 +21,8 @@ from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates -from supervaizer.__version__ import API_VERSION, VERSION +from supervaizer.__version__ import VERSION +from supervaizer.contracts import API_VERSION if TYPE_CHECKING: from supervaizer.server import Server diff --git a/src/supervaizer/server.py b/src/supervaizer/server.py index 9300a2b..9839a4c 100644 --- a/src/supervaizer/server.py +++ b/src/supervaizer/server.py @@ -35,7 +35,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator from rich import inspect -from supervaizer.__version__ import API_VERSION, VERSION +from supervaizer.__version__ import VERSION from supervaizer.account import Account from supervaizer.agent import ( Agent, @@ -49,7 +49,7 @@ is_local_mode, log, ) -from supervaizer.contracts import controller_contract_info +from supervaizer.contracts import API_VERSION, controller_contract_info from supervaizer.instructions import display_instructions from supervaizer.protocol.a2a.controller import ( ActionHandler, diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 14f8f59..bca09f4 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -14,6 +14,7 @@ from pathlib import Path from supervaizer.contracts import ( + API_VERSION, ControllerEndpoint, ControllerContract, ServerRegistrationContract, @@ -39,6 +40,7 @@ def load_v2_fixture(name: str) -> dict: def test_controller_contract_endpoints_are_api_prefixed() -> None: info = controller_contract_info() + assert API_VERSION == "v1" assert info["controller_contract_version"] == "1.0" assert info["api_base_path"] == "/api" assert ( @@ -78,6 +80,14 @@ def test_contract_module_import_does_not_load_controller_runtime() -> None: assert "supervaizer.routes" not in sys.modules +def test_version_module_is_package_version_only() -> None: + version_info = importlib.import_module("supervaizer.__version__") + + assert version_info.__version__ == version_info.VERSION + assert not hasattr(version_info, "API_VERSION") + assert not hasattr(version_info, "TELEMETRY_VERSION") + + def test_agent_method_contract_exports_timeout_metadata() -> None: server_schema = ServerRegistrationContract.model_json_schema() method_schema = server_schema["$defs"]["AgentMethodContract"] From 247eddd5fef930d6e6fe0ba8031f967858cf52a1 Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Fri, 15 May 2026 10:54:19 +0300 Subject: [PATCH 08/42] minor --- docs/CHANGELOG.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1c918eb..5704710 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -22,6 +22,8 @@ All notable changes to this project will be documented in this file. ### Tests +- `uv run pytest tests/test_a2a.py tests/test_contracts.py -q` + `just test` | Status | Count | @@ -37,10 +39,6 @@ All notable changes to this project will be documented in this file. - **`uv.lock`** — Refreshed transitive versions to address open Dependabot / GHSA advisories on the default branch graph: **urllib3** (redirect and decompression-chain issues), **requests** (`extract_zipped_paths` temp reuse), **protobuf** (JSON recursion depth), **pyasn1** (decoder / recursion DoS), **pygments** (ReDoS in GUID lexer), and **uv** (ZIP / tar / RECORD handling; dev dependency via hatch). -### Tests - -- `uv run pytest tests/test_a2a.py tests/test_contracts.py -q` - ## [0.20.0] - 2026-05-13 ### Security From bc8668b3b4573896282a2b179f1f7a77db04064c Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Fri, 15 May 2026 10:56:11 +0300 Subject: [PATCH 09/42] fix: scope v2 action handlers by agent --- src/supervaizer/protocol/a2a/controller.py | 37 +++++++-- src/supervaizer/server.py | 12 ++- tests/test_a2a.py | 87 +++++++++++++++++++++- 3 files changed, 123 insertions(+), 13 deletions(-) diff --git a/src/supervaizer/protocol/a2a/controller.py b/src/supervaizer/protocol/a2a/controller.py index 99a0e6f..22e00e2 100644 --- a/src/supervaizer/protocol/a2a/controller.py +++ b/src/supervaizer/protocol/a2a/controller.py @@ -30,6 +30,7 @@ [V2ActionRequest], V2ActionResult | dict[str, Any] | Awaitable[V2ActionResult | dict[str, Any]], ] +ActionHandlerKey = tuple[str, str] class JsonRpcRequest(ContractModel): @@ -53,11 +54,17 @@ class JsonRpcResponse(ContractModel): def register_v2_action_handler( - server: "Server", action: str, handler: ActionHandler + server: "Server", + action: str, + handler: ActionHandler, + *, + agent_slug: str | None = None, ) -> None: """Register a Supervaizer v2 action handler for the current server process.""" handlers = _get_action_handlers(server) - handlers[action] = handler + handlers[_action_handler_key(_resolve_agent_slug(server, agent_slug), action)] = ( + handler + ) async def dispatch_json_rpc(server: "Server", body: dict[str, Any]) -> JsonRpcResponse: @@ -95,13 +102,18 @@ async def _dispatch_action( data={"errors": exc.errors()}, ) - handler = _get_action_handlers(server).get(action_request.action) + handler = _get_action_handlers(server).get( + _action_handler_key(action_request.agent_slug, action_request.action) + ) if handler is None: return _json_rpc_error( request_id=request.id, code=JSON_RPC_ACTION_NOT_REGISTERED, message=f"Action handler not registered: {action_request.action}", - data={"action": action_request.action}, + data={ + "agent_slug": action_request.agent_slug, + "action": action_request.action, + }, ) try: @@ -125,7 +137,7 @@ def _validate_action_request(params: dict[str, Any]) -> V2ActionRequest: return V2ActionRequest.model_validate(action_payload) -def _get_action_handlers(server: "Server") -> dict[str, ActionHandler]: +def _get_action_handlers(server: "Server") -> dict[ActionHandlerKey, ActionHandler]: state = server.app.state handlers = getattr(state, "supervaizer_v2_action_handlers", None) if handlers is None: @@ -134,6 +146,21 @@ def _get_action_handlers(server: "Server") -> dict[str, ActionHandler]: return handlers +def _resolve_agent_slug(server: "Server", agent_slug: str | None) -> str: + agent_slugs = {agent.slug for agent in server.agents} + if agent_slug: + if agent_slug not in agent_slugs: + raise ValueError(f"Unknown agent_slug for v2 action handler: {agent_slug}") + return agent_slug + if len(agent_slugs) == 1: + return next(iter(agent_slugs)) + raise ValueError("agent_slug is required for multi-agent v2 action handlers") + + +def _action_handler_key(agent_slug: str, action: str) -> ActionHandlerKey: + return (agent_slug, action) + + def _json_rpc_error( *, request_id: str | int | None, diff --git a/src/supervaizer/server.py b/src/supervaizer/server.py index 9839a4c..3fc9844 100644 --- a/src/supervaizer/server.py +++ b/src/supervaizer/server.py @@ -750,15 +750,19 @@ def encrypt(self, parameters: str) -> str: raise ValueError("Failed to encrypt parameters") return result - def register_v2_action(self, action: str, handler: ActionHandler) -> ActionHandler: + def register_v2_action( + self, action: str, handler: ActionHandler, *, agent_slug: str | None = None + ) -> ActionHandler: """Register a Supervaizer v2 action handler on this server.""" - register_v2_action_handler(self, action, handler) + register_v2_action_handler(self, action, handler, agent_slug=agent_slug) return handler - def v2_action(self, action: str) -> Callable[[ActionHandler], ActionHandler]: + def v2_action( + self, action: str, *, agent_slug: str | None = None + ) -> Callable[[ActionHandler], ActionHandler]: """Decorator form of register_v2_action for SDK users.""" def decorator(handler: ActionHandler) -> ActionHandler: - return self.register_v2_action(action, handler) + return self.register_v2_action(action, handler, agent_slug=agent_slug) return decorator diff --git a/tests/test_a2a.py b/tests/test_a2a.py index 70992e1..6696a95 100644 --- a/tests/test_a2a.py +++ b/tests/test_a2a.py @@ -244,12 +244,15 @@ def test_a2a_controller_rejects_unregistered_v2_action( payload = response.json() assert payload["id"] == "rpc-2" assert payload["error"]["code"] == JSON_RPC_ACTION_NOT_REGISTERED + assert payload["error"]["data"]["agent_slug"] == "agent-interviewer" assert payload["error"]["data"]["action"] == "job.start" def test_a2a_controller_dispatches_registered_v2_action( server_fixture: Server, ) -> None: + agent_slug = server_fixture.agents[0].slug + def start_job(request: V2ActionRequest) -> V2ActionResult: assert request.action == "job.start" return V2ActionResult( @@ -266,7 +269,7 @@ def start_job(request: V2ActionRequest) -> V2ActionResult: "jsonrpc": "2.0", "id": "rpc-3", "method": SUPERVAIZER_ACTION_INVOKE_METHOD, - "params": _v2_action_payload(action="job.start"), + "params": _v2_action_payload(action="job.start", agent_slug=agent_slug), }, ) @@ -280,6 +283,8 @@ def start_job(request: V2ActionRequest) -> V2ActionResult: def test_server_v2_action_decorator_registers_handler(server_fixture: Server) -> None: + agent_slug = server_fixture.agents[0].slug + @server_fixture.v2_action("job.start.preview") def preview_job_start(request: V2ActionRequest) -> dict[str, object]: assert request.action == "job.start.preview" @@ -293,7 +298,9 @@ def preview_job_start(request: V2ActionRequest) -> dict[str, object]: "jsonrpc": "2.0", "id": "rpc-4", "method": SUPERVAIZER_ACTION_INVOKE_METHOD, - "params": _v2_action_payload(action="job.start.preview"), + "params": _v2_action_payload( + action="job.start.preview", agent_slug=agent_slug + ), }, ) @@ -306,13 +313,85 @@ def preview_job_start(request: V2ActionRequest) -> dict[str, object]: } -def _v2_action_payload(action: str) -> dict[str, object]: +def test_v2_action_handlers_are_scoped_by_agent_slug(server_fixture: Server) -> None: + first_slug = server_fixture.agents[0].slug + second_agent = Agent( + name="Second Agent", + author="authorName", + developer="Dev", + version="1.0.0", + description="description", + ) + server_fixture.agents.append(second_agent) + + register_v2_action_handler( + server_fixture, + "job.start", + lambda _request: {"status": "ok", "effects": [{"type": "first-agent"}]}, + agent_slug=first_slug, + ) + register_v2_action_handler( + server_fixture, + "job.start", + lambda _request: {"status": "ok", "effects": [{"type": "second-agent"}]}, + agent_slug=second_agent.slug, + ) + client = TestClient(server_fixture.app) + + first_response = client.post( + "/a2a", + json={ + "jsonrpc": "2.0", + "id": "rpc-5", + "method": SUPERVAIZER_ACTION_INVOKE_METHOD, + "params": _v2_action_payload(action="job.start", agent_slug=first_slug), + }, + ) + second_response = client.post( + "/a2a", + json={ + "jsonrpc": "2.0", + "id": "rpc-6", + "method": SUPERVAIZER_ACTION_INVOKE_METHOD, + "params": _v2_action_payload( + action="job.start", agent_slug=second_agent.slug + ), + }, + ) + + assert first_response.json()["result"]["effects"] == [{"type": "first-agent"}] + assert second_response.json()["result"]["effects"] == [{"type": "second-agent"}] + + +def test_v2_action_registration_requires_agent_slug_for_multi_agent_server( + server_fixture: Server, +) -> None: + server_fixture.agents.append( + Agent( + name="Second Agent", + author="authorName", + developer="Dev", + version="1.0.0", + description="description", + ) + ) + + with pytest.raises(ValueError, match="agent_slug is required"): + server_fixture.register_v2_action( + "job.start", + lambda _request: V2ActionResult(status="ok"), + ) + + +def _v2_action_payload( + action: str, agent_slug: str = "agent-interviewer" +) -> dict[str, object]: return { "request_id": "request-1", "actor": {"user_id": "user-1"}, "workspace": {"id": "workspace-1", "slug": "workspace"}, "mission_id": "mission-1", - "agent_slug": "agent-interviewer", + "agent_slug": agent_slug, "surface": "job.start", "action": action, "input": {"campaign_id": "campaign-1"}, From 68ae8cc5d996748c72ab44132b0c90e6f0467a8e Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Fri, 15 May 2026 11:57:12 +0300 Subject: [PATCH 10/42] feat: guard supervaizer v2 agent identity --- docs/CHANGELOG.md | 3 +- src/supervaizer/agent.py | 14 +++++++++ tests/test_a2a.py | 2 +- tests/test_agent.py | 67 +++++++++++++++++++++++++++++++++++++++- 4 files changed, 83 insertions(+), 3 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5704710..a17bcc8 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,6 +19,7 @@ All notable changes to this project will be documented in this file. - **Supervaizer v2 contract primitives** — Added typed SDK models for the v2 registration and action contract, including pinned A2UI/A2A versions, resources, datasets, case lanes, artifact declarations, job snapshots, sync metadata, and replay-safety metadata. - **A2A JSON-RPC action runtime** — Added the `/a2a` `supervaizer/action.invoke` dispatcher, v2 Agent Card extension payloads, and public SDK helpers for registering typed v2 actions through `Server.register_v2_action()` and `@server.v2_action(...)`. +- **Supervaizer v2 agent identity guard** — `Agent` now rejects v2 registration payloads whose declared `agent.slug` differs from the runtime SDK slug, preventing A2A action handlers from registering under one slug while Studio invokes another. ### Tests @@ -28,7 +29,7 @@ All notable changes to this project will be documented in this file. | Status | Count | | ---------- | ----- | -| ✅ Passed | 581 | +| ✅ Passed | 582 | | 🤔 Skipped | 0 | | 🔴 Failed | 0 | | ⏱️ in | 69s | diff --git a/src/supervaizer/agent.py b/src/supervaizer/agent.py index 7a4c6f6..9a70973 100644 --- a/src/supervaizer/agent.py +++ b/src/supervaizer/agent.py @@ -753,6 +753,8 @@ def __init__( **kwargs, ) + self._validate_supervaizer_v2_identity() + seen_resource_names: set[str] = set() for r in self.data_resources: if r.name in seen_resource_names: @@ -765,6 +767,18 @@ def __init__( def __str__(self) -> str: return f"{self.name} ({self.id})" + def _validate_supervaizer_v2_identity(self) -> None: + registration = self.supervaizer_v2_registration + if registration is None: + return + + declared_slug = registration.agent.slug + if declared_slug != self.slug: + raise ValueError( + "Supervaizer v2 registration agent.slug must match Agent.slug: " + f"{declared_slug!r} != {self.slug!r}" + ) + @property def slug(self) -> str: return slugify(self.name) diff --git a/tests/test_a2a.py b/tests/test_a2a.py index 6696a95..66de427 100644 --- a/tests/test_a2a.py +++ b/tests/test_a2a.py @@ -93,7 +93,7 @@ def test_create_agent_card(agent_fixture: Agent) -> None: def test_create_agent_card_includes_supervaizer_v2_extension() -> None: agent = Agent( - name="agentName", + name="Agent Name", author="authorName", developer="Dev", version="1.0.0", diff --git a/tests/test_agent.py b/tests/test_agent.py index 11dc723..73b4383 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -1310,7 +1310,7 @@ def test_agent_registration_info_includes_release_notes_url() -> None: def test_agent_registration_info_includes_supervaizer_v2_contract() -> None: """Agent.registration_info includes the optional Supervaizer v2 contract.""" agent = Agent( - name="agentName", + name="Agent Name", author="authorName", developer="Dev", version="1.0.0", @@ -1337,3 +1337,68 @@ def test_agent_registration_info_includes_supervaizer_v2_contract() -> None: assert info["supervaizer_v2"]["supervaizer_contract_version"] == 2 assert info["supervaizer_v2"]["versions"]["a2ui_version"] == "v0.8" + + +def test_agent_accepts_v2_registration_with_matching_slug() -> None: + """Agent accepts a v2 registration whose declared slug matches its runtime slug.""" + agent = Agent( + name="Agent Name", + author="authorName", + developer="Dev", + version="1.0.0", + description="description", + supervaizer_v2_registration={ + "agent": { + "id": "agent_name", + "slug": "agent-name", + "display_name": "Agent Name", + }, + "versions": { + "a2ui_version": "v0.8", + "a2ui_catalog_version": "test.0", + "a2a_version": "0.2.6", + }, + "a2a": { + "agent_card_url": "/.well-known/agents/v1.0.0/agent-name_agent.json", + "controller_url": "/a2a", + }, + }, + ) + + assert agent.slug == "agent-name" + assert agent.supervaizer_v2_registration is not None + assert agent.supervaizer_v2_registration.agent.slug == "agent-name" + + +def test_agent_rejects_v2_registration_with_mismatched_slug() -> None: + """Agent rejects a v2 registration whose declared slug differs from runtime slug.""" + with pytest.raises( + ValueError, + match=( + "Supervaizer v2 registration agent.slug must match Agent.slug: " + "'other-agent' != 'agent-name'" + ), + ): + Agent( + name="Agent Name", + author="authorName", + developer="Dev", + version="1.0.0", + description="description", + supervaizer_v2_registration={ + "agent": { + "id": "agent_name", + "slug": "other-agent", + "display_name": "Agent Name", + }, + "versions": { + "a2ui_version": "v0.8", + "a2ui_catalog_version": "test.0", + "a2a_version": "0.2.6", + }, + "a2a": { + "agent_card_url": "/.well-known/agents/v1.0.0/agent-name_agent.json", + "controller_url": "/a2a", + }, + }, + ) From e8206a706833ad3d6d101783d2a32d5ca01fccf1 Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Fri, 15 May 2026 12:11:39 +0300 Subject: [PATCH 11/42] feat: include v2 job state in sync result --- docs/CHANGELOG.md | 1 + src/supervaizer/contracts.py | 1 + .../supervaizer_v2/agent_interviewer_mvp.json | 51 +++++++++++++++++++ tests/test_contracts.py | 5 ++ 4 files changed, 58 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a17bcc8..16a8434 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -20,6 +20,7 @@ All notable changes to this project will be documented in this file. - **Supervaizer v2 contract primitives** — Added typed SDK models for the v2 registration and action contract, including pinned A2UI/A2A versions, resources, datasets, case lanes, artifact declarations, job snapshots, sync metadata, and replay-safety metadata. - **A2A JSON-RPC action runtime** — Added the `/a2a` `supervaizer/action.invoke` dispatcher, v2 Agent Card extension payloads, and public SDK helpers for registering typed v2 actions through `Server.register_v2_action()` and `@server.v2_action(...)`. - **Supervaizer v2 agent identity guard** — `Agent` now rejects v2 registration payloads whose declared `agent.slug` differs from the runtime SDK slug, preventing A2A action handlers from registering under one slug while Studio invokes another. +- **Supervaizer v2 job sync state** — `V2JobSyncResult` now carries an optional `job_state` snapshot so agents can return convergent Job/Case/Step/Artifact state through `job.sync`. ### Tests diff --git a/src/supervaizer/contracts.py b/src/supervaizer/contracts.py index e369384..3378e3d 100644 --- a/src/supervaizer/contracts.py +++ b/src/supervaizer/contracts.py @@ -436,6 +436,7 @@ class V2JobSyncResult(V2ActionResult): external_version: str | None = None sync_cursor: str | None = None observed_at: str | None = None + job_state: V2JobStateSnapshot | None = None class V2ReplaySafetyMetadata(ContractModel): diff --git a/tests/fixtures/supervaizer_v2/agent_interviewer_mvp.json b/tests/fixtures/supervaizer_v2/agent_interviewer_mvp.json index 18b1f95..7730659 100644 --- a/tests/fixtures/supervaizer_v2/agent_interviewer_mvp.json +++ b/tests/fixtures/supervaizer_v2/agent_interviewer_mvp.json @@ -230,6 +230,57 @@ "external_version": "campaign_123:rev_42", "sync_cursor": "rev_42", "observed_at": "2026-05-15T10:00:00Z", + "job_state": { + "job": { + "id": "job_123", + "agent_slug": "agent_interviewer", + "mission_id": "mission_123", + "status": "active", + "source": { + "type": "fresh_start", + "external_ref": "campaign_123" + } + }, + "cases": [ + { + "id": "case_session_abc", + "external_id": "campaign_123:session:session_abc", + "lane": "work", + "title": "Interview session for alex@example.com", + "status": "awaiting", + "steps": [ + { + "id": "step_session_review", + "external_id": "session_abc:human_review", + "activity": "operation", + "status": "awaiting", + "title": "Review session synthesis", + "awaiting": { + "reason": "human_input", + "surface": "case.step.awaiting", + "action": "step.awaiting.submit" + }, + "outputs": [ + { + "id": "artifact_transcript_session_abc", + "external_id": "session_abc:transcript", + "type": "agent_interviewer.transcript", + "title": "Transcript", + "media_type": "text/markdown" + }, + { + "id": "artifact_synthesis_session_abc", + "external_id": "session_abc:synthesis", + "type": "agent_interviewer.synthesis", + "title": "Synthesis", + "media_type": "application/json" + } + ] + } + ] + } + ] + }, "effects": [ { "type": "step.updated", diff --git a/tests/test_contracts.py b/tests/test_contracts.py index bca09f4..d314da7 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -221,6 +221,11 @@ def test_v2_job_sync_result_is_convergent_not_strictly_idempotent() -> None: assert sync_result.status == "ok" assert sync_result.external_version == "campaign_123:rev_42" assert sync_result.sync_cursor == "rev_42" + assert sync_result.job_state is not None + assert ( + sync_result.job_state.cases[0].steps[0].outputs[0].type + == "agent_interviewer.transcript" + ) assert replay_safety.convergent is True assert replay_safety.strictly_idempotent_response is False From cd74d278da8ad5b95d2442ec747e2cc86b2e5247 Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Fri, 15 May 2026 13:30:52 +0300 Subject: [PATCH 12/42] feat: add v2 resource form fields --- docs/CHANGELOG.md | 1 + src/supervaizer/__init__.py | 4 ++++ src/supervaizer/contracts.py | 10 ++++++++++ .../fixtures/supervaizer_v2/agent_interviewer_mvp.json | 10 +++++++++- tests/test_contracts.py | 8 ++++++++ 5 files changed, 32 insertions(+), 1 deletion(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 16a8434..a7ce193 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -21,6 +21,7 @@ All notable changes to this project will be documented in this file. - **A2A JSON-RPC action runtime** — Added the `/a2a` `supervaizer/action.invoke` dispatcher, v2 Agent Card extension payloads, and public SDK helpers for registering typed v2 actions through `Server.register_v2_action()` and `@server.v2_action(...)`. - **Supervaizer v2 agent identity guard** — `Agent` now rejects v2 registration payloads whose declared `agent.slug` differs from the runtime SDK slug, preventing A2A action handlers from registering under one slug while Studio invokes another. - **Supervaizer v2 job sync state** — `V2JobSyncResult` now carries an optional `job_state` snapshot so agents can return convergent Job/Case/Step/Artifact state through `job.sync`. +- **Supervaizer v2 resource form fields** — `V2ResourceDefinition` now carries typed `fields` metadata so Studio can render simple agent-owned resource create/edit forms without callback-style dynamic field logic. ### Tests diff --git a/src/supervaizer/__init__.py b/src/supervaizer/__init__.py index 350ee67..9992e9d 100644 --- a/src/supervaizer/__init__.py +++ b/src/supervaizer/__init__.py @@ -122,6 +122,10 @@ "V2ReplaySafetyMetadata", ), "V2ResourceDefinition": ("supervaizer.contracts", "V2ResourceDefinition"), + "V2ResourceFieldDefinition": ( + "supervaizer.contracts", + "V2ResourceFieldDefinition", + ), "V2StepSnapshot": ("supervaizer.contracts", "V2StepSnapshot"), "V2WorkspaceContext": ("supervaizer.contracts", "V2WorkspaceContext"), "build_data_resource_context_headers": ( diff --git a/src/supervaizer/contracts.py b/src/supervaizer/contracts.py index 3378e3d..9022656 100644 --- a/src/supervaizer/contracts.py +++ b/src/supervaizer/contracts.py @@ -314,6 +314,15 @@ class V2ResourceDisplayDefinition(ContractModel): search_fields: list[str] = Field(default_factory=list) +class V2ResourceFieldDefinition(ContractModel): + id: str + label: str + type: str = "string" + required: bool = False + read_only: bool = False + multiline: bool = False + + class V2MountedResourceViewDefinition(ContractModel): view: str surface: str @@ -325,6 +334,7 @@ class V2ResourceDefinition(ContractModel): auto_surface: bool = False operations: list[str] = Field(default_factory=list) display: V2ResourceDisplayDefinition | None = None + fields: list[V2ResourceFieldDefinition] = Field(default_factory=list) mounted_views: list[V2MountedResourceViewDefinition] = Field(default_factory=list) diff --git a/tests/fixtures/supervaizer_v2/agent_interviewer_mvp.json b/tests/fixtures/supervaizer_v2/agent_interviewer_mvp.json index 7730659..a98412d 100644 --- a/tests/fixtures/supervaizer_v2/agent_interviewer_mvp.json +++ b/tests/fixtures/supervaizer_v2/agent_interviewer_mvp.json @@ -104,7 +104,15 @@ "search_fields": [ "name" ] - } + }, + "fields": [ + { + "id": "name", + "label": "Name", + "type": "string", + "required": true + } + ] }, { "id": "prompts", diff --git a/tests/test_contracts.py b/tests/test_contracts.py index d314da7..18d56f0 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -176,6 +176,11 @@ def test_v2_agent_interviewer_registration_fixture() -> None: "campaigns", "prompts", } + campaigns = next( + resource for resource in registration.resources if resource.id == "campaigns" + ) + assert [field.id for field in campaigns.fields] == ["name"] + assert campaigns.fields[0].required is True def test_v2_action_request_and_result_fixture() -> None: @@ -240,3 +245,6 @@ def test_v2_contract_models_are_public_sdk_exports() -> None: ) assert supervaizer.V2ActionRequest is V2ActionRequest assert supervaizer.V2JobStateSnapshot is V2JobStateSnapshot + assert supervaizer.V2ResourceFieldDefinition.__name__ == ( + "V2ResourceFieldDefinition" + ) From 6fa0ad19446e6a6fc486911277f873dc9a9e1b8f Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Fri, 15 May 2026 13:41:51 +0300 Subject: [PATCH 13/42] feat: add v2 resource option sources --- docs/CHANGELOG.md | 1 + src/supervaizer/__init__.py | 4 ++++ src/supervaizer/contracts.py | 8 ++++++++ tests/test_contracts.py | 24 ++++++++++++++++++++++++ 4 files changed, 37 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a7ce193..fe7bc49 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -22,6 +22,7 @@ All notable changes to this project will be documented in this file. - **Supervaizer v2 agent identity guard** — `Agent` now rejects v2 registration payloads whose declared `agent.slug` differs from the runtime SDK slug, preventing A2A action handlers from registering under one slug while Studio invokes another. - **Supervaizer v2 job sync state** — `V2JobSyncResult` now carries an optional `job_state` snapshot so agents can return convergent Job/Case/Step/Artifact state through `job.sync`. - **Supervaizer v2 resource form fields** — `V2ResourceDefinition` now carries typed `fields` metadata so Studio can render simple agent-owned resource create/edit forms without callback-style dynamic field logic. +- **Supervaizer v2 resource option sources** — Resource fields can now declare typed resource-backed `options_source` metadata so Studio can render relationship selectors without callback-style dynamic choices. ### Tests diff --git a/src/supervaizer/__init__.py b/src/supervaizer/__init__.py index 9992e9d..a0b88db 100644 --- a/src/supervaizer/__init__.py +++ b/src/supervaizer/__init__.py @@ -126,6 +126,10 @@ "supervaizer.contracts", "V2ResourceFieldDefinition", ), + "V2ResourceFieldOptionsSource": ( + "supervaizer.contracts", + "V2ResourceFieldOptionsSource", + ), "V2StepSnapshot": ("supervaizer.contracts", "V2StepSnapshot"), "V2WorkspaceContext": ("supervaizer.contracts", "V2WorkspaceContext"), "build_data_resource_context_headers": ( diff --git a/src/supervaizer/contracts.py b/src/supervaizer/contracts.py index 9022656..64a388a 100644 --- a/src/supervaizer/contracts.py +++ b/src/supervaizer/contracts.py @@ -314,6 +314,13 @@ class V2ResourceDisplayDefinition(ContractModel): search_fields: list[str] = Field(default_factory=list) +class V2ResourceFieldOptionsSource(ContractModel): + type: Literal["resource"] = "resource" + resource: str + value_field: str = "id" + label_field: str | None = None + + class V2ResourceFieldDefinition(ContractModel): id: str label: str @@ -321,6 +328,7 @@ class V2ResourceFieldDefinition(ContractModel): required: bool = False read_only: bool = False multiline: bool = False + options_source: V2ResourceFieldOptionsSource | None = None class V2MountedResourceViewDefinition(ContractModel): diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 18d56f0..614d798 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -24,6 +24,7 @@ V2JobStateSnapshot, V2JobSyncResult, V2ReplaySafetyMetadata, + V2ResourceFieldDefinition, build_data_resource_context_headers, controller_contract_info, resolve_controller_endpoint, @@ -183,6 +184,26 @@ def test_v2_agent_interviewer_registration_fixture() -> None: assert campaigns.fields[0].required is True +def test_v2_resource_field_options_source_is_typed() -> None: + field = V2ResourceFieldDefinition.model_validate({ + "id": "contact_id", + "label": "Contact", + "type": "resource_ref", + "required": True, + "options_source": { + "type": "resource", + "resource": "contacts", + "value_field": "id", + "label_field": "email", + }, + }) + + assert field.options_source is not None + assert field.options_source.resource == "contacts" + assert field.options_source.value_field == "id" + assert field.options_source.label_field == "email" + + def test_v2_action_request_and_result_fixture() -> None: fixture = load_v2_fixture("agent_interviewer_mvp.json") @@ -248,3 +269,6 @@ def test_v2_contract_models_are_public_sdk_exports() -> None: assert supervaizer.V2ResourceFieldDefinition.__name__ == ( "V2ResourceFieldDefinition" ) + assert supervaizer.V2ResourceFieldOptionsSource.__name__ == ( + "V2ResourceFieldOptionsSource" + ) From 5fac7238d644466a0f274413595a7ef322521f7e Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Fri, 15 May 2026 13:51:22 +0300 Subject: [PATCH 14/42] feat: add v2 awaiting form fields --- docs/CHANGELOG.md | 1 + src/supervaizer/__init__.py | 4 ++++ src/supervaizer/contracts.py | 8 ++++++++ tests/test_contracts.py | 24 ++++++++++++++++++++++++ 4 files changed, 37 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index fe7bc49..6ddb955 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -23,6 +23,7 @@ All notable changes to this project will be documented in this file. - **Supervaizer v2 job sync state** — `V2JobSyncResult` now carries an optional `job_state` snapshot so agents can return convergent Job/Case/Step/Artifact state through `job.sync`. - **Supervaizer v2 resource form fields** — `V2ResourceDefinition` now carries typed `fields` metadata so Studio can render simple agent-owned resource create/edit forms without callback-style dynamic field logic. - **Supervaizer v2 resource option sources** — Resource fields can now declare typed resource-backed `options_source` metadata so Studio can render relationship selectors without callback-style dynamic choices. +- **Supervaizer v2 awaiting form fields** — Step awaiting state can now declare typed form fields so Studio can submit HITL actions through `step.awaiting.submit`. ### Tests diff --git a/src/supervaizer/__init__.py b/src/supervaizer/__init__.py index a0b88db..5b5d3b9 100644 --- a/src/supervaizer/__init__.py +++ b/src/supervaizer/__init__.py @@ -106,6 +106,10 @@ "supervaizer.contracts", "V2ArtifactTypeDefinition", ), + "V2AwaitingFieldDefinition": ( + "supervaizer.contracts", + "V2AwaitingFieldDefinition", + ), "V2AwaitingState": ("supervaizer.contracts", "V2AwaitingState"), "V2CaseLaneDefinition": ("supervaizer.contracts", "V2CaseLaneDefinition"), "V2CaseSnapshot": ("supervaizer.contracts", "V2CaseSnapshot"), diff --git a/src/supervaizer/contracts.py b/src/supervaizer/contracts.py index 64a388a..5b505aa 100644 --- a/src/supervaizer/contracts.py +++ b/src/supervaizer/contracts.py @@ -405,10 +405,18 @@ class V2ArtifactRef(ContractModel): media_type: str | None = None +class V2AwaitingFieldDefinition(ContractModel): + id: str + label: str + type: str = "boolean" + required: bool = False + + class V2AwaitingState(ContractModel): reason: str surface: str action: str + fields: list[V2AwaitingFieldDefinition] = Field(default_factory=list) class V2StepSnapshot(ContractModel): diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 614d798..ba9f711 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -21,6 +21,7 @@ SupervaizerV2AgentRegistrationContract, V2ActionRequest, V2ActionResult, + V2AwaitingState, V2JobStateSnapshot, V2JobSyncResult, V2ReplaySafetyMetadata, @@ -204,6 +205,26 @@ def test_v2_resource_field_options_source_is_typed() -> None: assert field.options_source.label_field == "email" +def test_v2_awaiting_state_declares_typed_form_fields() -> None: + awaiting = V2AwaitingState.model_validate({ + "reason": "Review campaign setup", + "surface": "case.step.awaiting", + "action": "step.awaiting.submit", + "fields": [ + { + "id": "approve_scenario", + "label": "Approve scenario", + "type": "boolean", + "required": True, + } + ], + }) + + assert awaiting.fields[0].id == "approve_scenario" + assert awaiting.fields[0].type == "boolean" + assert awaiting.fields[0].required is True + + def test_v2_action_request_and_result_fixture() -> None: fixture = load_v2_fixture("agent_interviewer_mvp.json") @@ -266,6 +287,9 @@ def test_v2_contract_models_are_public_sdk_exports() -> None: ) assert supervaizer.V2ActionRequest is V2ActionRequest assert supervaizer.V2JobStateSnapshot is V2JobStateSnapshot + assert supervaizer.V2AwaitingFieldDefinition.__name__ == ( + "V2AwaitingFieldDefinition" + ) assert supervaizer.V2ResourceFieldDefinition.__name__ == ( "V2ResourceFieldDefinition" ) From 9895b0ee30722f6f005fddbcb64c3c7725d89542 Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Fri, 15 May 2026 15:03:05 +0300 Subject: [PATCH 15/42] feat: load supervaizer v2 surfaces over a2a --- docs/CHANGELOG.md | 1 + src/supervaizer/__init__.py | 2 + src/supervaizer/contracts.py | 21 ++++ src/supervaizer/protocol/a2a/controller.py | 108 +++++++++++++++++-- src/supervaizer/server.py | 19 ++++ tests/test_a2a.py | 115 ++++++++++++++++++++- tests/test_contracts.py | 27 +++++ 7 files changed, 284 insertions(+), 9 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6ddb955..09a0ee0 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,6 +19,7 @@ All notable changes to this project will be documented in this file. - **Supervaizer v2 contract primitives** — Added typed SDK models for the v2 registration and action contract, including pinned A2UI/A2A versions, resources, datasets, case lanes, artifact declarations, job snapshots, sync metadata, and replay-safety metadata. - **A2A JSON-RPC action runtime** — Added the `/a2a` `supervaizer/action.invoke` dispatcher, v2 Agent Card extension payloads, and public SDK helpers for registering typed v2 actions through `Server.register_v2_action()` and `@server.v2_action(...)`. +- **A2A JSON-RPC surface runtime** — Added `supervaizer/surface.load`, typed `V2SurfaceRequest`/`V2SurfaceResult` models, and public SDK helpers for registering A2UI surface handlers through `Server.register_v2_surface()` and `@server.v2_surface(...)`. - **Supervaizer v2 agent identity guard** — `Agent` now rejects v2 registration payloads whose declared `agent.slug` differs from the runtime SDK slug, preventing A2A action handlers from registering under one slug while Studio invokes another. - **Supervaizer v2 job sync state** — `V2JobSyncResult` now carries an optional `job_state` snapshot so agents can return convergent Job/Case/Step/Artifact state through `job.sync`. - **Supervaizer v2 resource form fields** — `V2ResourceDefinition` now carries typed `fields` metadata so Studio can render simple agent-owned resource create/edit forms without callback-style dynamic field logic. diff --git a/src/supervaizer/__init__.py b/src/supervaizer/__init__.py index 5b5d3b9..6594f61 100644 --- a/src/supervaizer/__init__.py +++ b/src/supervaizer/__init__.py @@ -135,6 +135,8 @@ "V2ResourceFieldOptionsSource", ), "V2StepSnapshot": ("supervaizer.contracts", "V2StepSnapshot"), + "V2SurfaceRequest": ("supervaizer.contracts", "V2SurfaceRequest"), + "V2SurfaceResult": ("supervaizer.contracts", "V2SurfaceResult"), "V2WorkspaceContext": ("supervaizer.contracts", "V2WorkspaceContext"), "build_data_resource_context_headers": ( "supervaizer.contracts", diff --git a/src/supervaizer/contracts.py b/src/supervaizer/contracts.py index 5b505aa..74aa08a 100644 --- a/src/supervaizer/contracts.py +++ b/src/supervaizer/contracts.py @@ -388,6 +388,20 @@ class V2ActionRequest(ContractModel): step_id: str | None = None +class V2SurfaceRequest(ContractModel): + request_id: str + actor: V2ActorContext + workspace: V2WorkspaceContext + mission_id: str + agent_slug: str + surface: str + input: dict[str, Any] = Field(default_factory=dict) + draft_session_id: str | None = None + job_id: str | None = None + case_id: str | None = None + step_id: str | None = None + + class V2Effect(ContractModel): type: str @@ -397,6 +411,13 @@ class V2ActionResult(ContractModel): effects: list[V2Effect] = Field(default_factory=list) +class V2SurfaceResult(ContractModel): + surface: str + a2ui_version: str | None = None + a2ui_catalog_version: str | None = None + document: dict[str, Any] = Field(default_factory=dict) + + class V2ArtifactRef(ContractModel): id: str type: str diff --git a/src/supervaizer/protocol/a2a/controller.py b/src/supervaizer/protocol/a2a/controller.py index 22e00e2..8abf0ae 100644 --- a/src/supervaizer/protocol/a2a/controller.py +++ b/src/supervaizer/protocol/a2a/controller.py @@ -14,23 +14,36 @@ from pydantic import Field, ValidationError -from supervaizer.contracts import ContractModel, V2ActionRequest, V2ActionResult +from supervaizer.contracts import ( + ContractModel, + V2ActionRequest, + V2ActionResult, + V2SurfaceRequest, + V2SurfaceResult, +) if TYPE_CHECKING: from supervaizer.server import Server SUPERVAIZER_ACTION_INVOKE_METHOD = "supervaizer/action.invoke" +SUPERVAIZER_SURFACE_LOAD_METHOD = "supervaizer/surface.load" JSON_RPC_METHOD_NOT_FOUND = -32601 JSON_RPC_INVALID_PARAMS = -32602 JSON_RPC_ACTION_NOT_REGISTERED = -32010 +JSON_RPC_SURFACE_NOT_REGISTERED = -32011 JSON_RPC_INTERNAL_ERROR = -32603 ActionHandler = Callable[ [V2ActionRequest], V2ActionResult | dict[str, Any] | Awaitable[V2ActionResult | dict[str, Any]], ] +SurfaceHandler = Callable[ + [V2SurfaceRequest], + V2SurfaceResult | dict[str, Any] | Awaitable[V2SurfaceResult | dict[str, Any]], +] ActionHandlerKey = tuple[str, str] +SurfaceHandlerKey = tuple[str, str] class JsonRpcRequest(ContractModel): @@ -67,6 +80,20 @@ def register_v2_action_handler( ) +def register_v2_surface_handler( + server: "Server", + surface: str, + handler: SurfaceHandler, + *, + agent_slug: str | None = None, +) -> None: + """Register a Supervaizer v2 A2UI surface handler for the current server process.""" + handlers = _get_surface_handlers(server) + handlers[_surface_handler_key(_resolve_agent_slug(server, agent_slug), surface)] = ( + handler + ) + + async def dispatch_json_rpc(server: "Server", body: dict[str, Any]) -> JsonRpcResponse: """Dispatch one A2A JSON-RPC request.""" try: @@ -79,14 +106,16 @@ async def dispatch_json_rpc(server: "Server", body: dict[str, Any]) -> JsonRpcRe data={"errors": exc.errors()}, ) - if request.method != SUPERVAIZER_ACTION_INVOKE_METHOD: - return _json_rpc_error( - request_id=request.id, - code=JSON_RPC_METHOD_NOT_FOUND, - message=f"Method not found: {request.method}", - ) + if request.method == SUPERVAIZER_ACTION_INVOKE_METHOD: + return await _dispatch_action(server, request) + if request.method == SUPERVAIZER_SURFACE_LOAD_METHOD: + return await _dispatch_surface(server, request) - return await _dispatch_action(server, request) + return _json_rpc_error( + request_id=request.id, + code=JSON_RPC_METHOD_NOT_FOUND, + message=f"Method not found: {request.method}", + ) async def _dispatch_action( @@ -132,11 +161,59 @@ async def _dispatch_action( return JsonRpcResponse(id=request.id, result=result.model_dump(mode="json")) +async def _dispatch_surface( + server: "Server", request: JsonRpcRequest +) -> JsonRpcResponse: + try: + surface_request = _validate_surface_request(request.params) + except ValidationError as exc: + return _json_rpc_error( + request_id=request.id, + code=JSON_RPC_INVALID_PARAMS, + message="Invalid Supervaizer v2 surface request", + data={"errors": exc.errors()}, + ) + + handler = _get_surface_handlers(server).get( + _surface_handler_key(surface_request.agent_slug, surface_request.surface) + ) + if handler is None: + return _json_rpc_error( + request_id=request.id, + code=JSON_RPC_SURFACE_NOT_REGISTERED, + message=f"Surface handler not registered: {surface_request.surface}", + data={ + "agent_slug": surface_request.agent_slug, + "surface": surface_request.surface, + }, + ) + + try: + handler_result = handler(surface_request) + if isawaitable(handler_result): + handler_result = await handler_result + result = V2SurfaceResult.model_validate(handler_result) + except Exception as exc: + return _json_rpc_error( + request_id=request.id, + code=JSON_RPC_INTERNAL_ERROR, + message="Surface handler failed", + data={"surface": surface_request.surface, "error": str(exc)}, + ) + + return JsonRpcResponse(id=request.id, result=result.model_dump(mode="json")) + + def _validate_action_request(params: dict[str, Any]) -> V2ActionRequest: action_payload = params.get("action_request", params) return V2ActionRequest.model_validate(action_payload) +def _validate_surface_request(params: dict[str, Any]) -> V2SurfaceRequest: + surface_payload = params.get("surface_request", params) + return V2SurfaceRequest.model_validate(surface_payload) + + def _get_action_handlers(server: "Server") -> dict[ActionHandlerKey, ActionHandler]: state = server.app.state handlers = getattr(state, "supervaizer_v2_action_handlers", None) @@ -146,6 +223,17 @@ def _get_action_handlers(server: "Server") -> dict[ActionHandlerKey, ActionHandl return handlers +def _get_surface_handlers( + server: "Server", +) -> dict[SurfaceHandlerKey, SurfaceHandler]: + state = server.app.state + handlers = getattr(state, "supervaizer_v2_surface_handlers", None) + if handlers is None: + handlers = {} + state.supervaizer_v2_surface_handlers = handlers + return handlers + + def _resolve_agent_slug(server: "Server", agent_slug: str | None) -> str: agent_slugs = {agent.slug for agent in server.agents} if agent_slug: @@ -161,6 +249,10 @@ def _action_handler_key(agent_slug: str, action: str) -> ActionHandlerKey: return (agent_slug, action) +def _surface_handler_key(agent_slug: str, surface: str) -> SurfaceHandlerKey: + return (agent_slug, surface) + + def _json_rpc_error( *, request_id: str | int | None, diff --git a/src/supervaizer/server.py b/src/supervaizer/server.py index 3fc9844..0a06202 100644 --- a/src/supervaizer/server.py +++ b/src/supervaizer/server.py @@ -53,7 +53,9 @@ from supervaizer.instructions import display_instructions from supervaizer.protocol.a2a.controller import ( ActionHandler, + SurfaceHandler, register_v2_action_handler, + register_v2_surface_handler, ) from supervaizer.routes import get_server # <-- MODIFIED: removed per-router imports from supervaizer.routers import ( @@ -766,3 +768,20 @@ def decorator(handler: ActionHandler) -> ActionHandler: return self.register_v2_action(action, handler, agent_slug=agent_slug) return decorator + + def register_v2_surface( + self, surface: str, handler: SurfaceHandler, *, agent_slug: str | None = None + ) -> SurfaceHandler: + """Register a Supervaizer v2 A2UI surface handler on this server.""" + register_v2_surface_handler(self, surface, handler, agent_slug=agent_slug) + return handler + + def v2_surface( + self, surface: str, *, agent_slug: str | None = None + ) -> Callable[[SurfaceHandler], SurfaceHandler]: + """Decorator form of register_v2_surface for SDK users.""" + + def decorator(handler: SurfaceHandler) -> SurfaceHandler: + return self.register_v2_surface(surface, handler, agent_slug=agent_slug) + + return decorator diff --git a/tests/test_a2a.py b/tests/test_a2a.py index 66de427..1894e0c 100644 --- a/tests/test_a2a.py +++ b/tests/test_a2a.py @@ -15,7 +15,12 @@ from fastapi.testclient import TestClient from supervaizer import Agent, Server -from supervaizer.contracts import V2ActionRequest, V2ActionResult, V2Effect +from supervaizer.contracts import ( + V2ActionRequest, + V2ActionResult, + V2Effect, + V2SurfaceRequest, +) from supervaizer.protocol.a2a import ( create_agent_card, create_agents_list, @@ -24,8 +29,11 @@ from supervaizer.protocol.a2a.controller import ( JSON_RPC_ACTION_NOT_REGISTERED, JSON_RPC_METHOD_NOT_FOUND, + JSON_RPC_SURFACE_NOT_REGISTERED, SUPERVAIZER_ACTION_INVOKE_METHOD, + SUPERVAIZER_SURFACE_LOAD_METHOD, register_v2_action_handler, + register_v2_surface_handler, ) @@ -248,6 +256,29 @@ def test_a2a_controller_rejects_unregistered_v2_action( assert payload["error"]["data"]["action"] == "job.start" +def test_a2a_controller_rejects_unregistered_v2_surface( + server_fixture: Server, +) -> None: + client = TestClient(server_fixture.app) + + response = client.post( + "/a2a", + json={ + "jsonrpc": "2.0", + "id": "rpc-surface-1", + "method": SUPERVAIZER_SURFACE_LOAD_METHOD, + "params": _v2_surface_payload(surface="job.start"), + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["id"] == "rpc-surface-1" + assert payload["error"]["code"] == JSON_RPC_SURFACE_NOT_REGISTERED + assert payload["error"]["data"]["agent_slug"] == "agent-interviewer" + assert payload["error"]["data"]["surface"] == "job.start" + + def test_a2a_controller_dispatches_registered_v2_action( server_fixture: Server, ) -> None: @@ -282,6 +313,43 @@ def start_job(request: V2ActionRequest) -> V2ActionResult: ] +def test_a2a_controller_dispatches_registered_v2_surface( + server_fixture: Server, +) -> None: + agent_slug = server_fixture.agents[0].slug + + def load_job_start(request: V2SurfaceRequest) -> dict[str, object]: + assert request.surface == "job.start" + return { + "surface": "job.start", + "a2ui_version": "v0.8", + "document": {"type": "Form", "fields": []}, + } + + register_v2_surface_handler(server_fixture, "job.start", load_job_start) + client = TestClient(server_fixture.app) + + response = client.post( + "/a2a", + json={ + "jsonrpc": "2.0", + "id": "rpc-surface-2", + "method": SUPERVAIZER_SURFACE_LOAD_METHOD, + "params": _v2_surface_payload(surface="job.start", agent_slug=agent_slug), + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["id"] == "rpc-surface-2" + assert payload["result"] == { + "surface": "job.start", + "a2ui_version": "v0.8", + "a2ui_catalog_version": None, + "document": {"type": "Form", "fields": []}, + } + + def test_server_v2_action_decorator_registers_handler(server_fixture: Server) -> None: agent_slug = server_fixture.agents[0].slug @@ -313,6 +381,36 @@ def preview_job_start(request: V2ActionRequest) -> dict[str, object]: } +def test_server_v2_surface_decorator_registers_handler(server_fixture: Server) -> None: + agent_slug = server_fixture.agents[0].slug + + @server_fixture.v2_surface("job.start") + def load_job_start(request: V2SurfaceRequest) -> dict[str, object]: + assert request.surface == "job.start" + return { + "surface": "job.start", + "document": {"type": "Form", "submit": {"action": "job.start"}}, + } + + client = TestClient(server_fixture.app) + + response = client.post( + "/a2a", + json={ + "jsonrpc": "2.0", + "id": "rpc-surface-3", + "method": SUPERVAIZER_SURFACE_LOAD_METHOD, + "params": _v2_surface_payload(surface="job.start", agent_slug=agent_slug), + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["id"] == "rpc-surface-3" + assert payload["result"]["surface"] == "job.start" + assert payload["result"]["document"]["submit"] == {"action": "job.start"} + + def test_v2_action_handlers_are_scoped_by_agent_slug(server_fixture: Server) -> None: first_slug = server_fixture.agents[0].slug second_agent = Agent( @@ -399,6 +497,21 @@ def _v2_action_payload( } +def _v2_surface_payload( + surface: str, agent_slug: str = "agent-interviewer" +) -> dict[str, object]: + return { + "request_id": "request-1", + "actor": {"user_id": "user-1"}, + "workspace": {"id": "workspace-1", "slug": "workspace"}, + "mission_id": "mission-1", + "agent_slug": agent_slug, + "surface": surface, + "input": {"campaign_id": "campaign-1"}, + "draft_session_id": "draft-1", + } + + def test_a2a_schema_conformance(agent_fixture: Agent) -> None: """Test that the A2A output conforms to the JSON schema.""" # Define a minimal A2A schema for validation diff --git a/tests/test_contracts.py b/tests/test_contracts.py index ba9f711..ac464c8 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -26,6 +26,8 @@ V2JobSyncResult, V2ReplaySafetyMetadata, V2ResourceFieldDefinition, + V2SurfaceRequest, + V2SurfaceResult, build_data_resource_context_headers, controller_contract_info, resolve_controller_endpoint, @@ -241,6 +243,29 @@ def test_v2_action_request_and_result_fixture() -> None: ] +def test_v2_surface_request_and_result_models() -> None: + request = V2SurfaceRequest.model_validate({ + "request_id": "surface-request-1", + "actor": {"user_id": "user-1"}, + "workspace": {"id": "workspace-1", "slug": "workspace"}, + "mission_id": "mission-1", + "agent_slug": "agent-interviewer", + "surface": "job.start", + "draft_session_id": "draft-1", + "input": {"campaign_id": "campaign-1"}, + }) + result = V2SurfaceResult.model_validate({ + "surface": "job.start", + "a2ui_version": "v0.8", + "document": {"type": "Form", "submit": {"action": "job.start"}}, + }) + + assert request.surface == "job.start" + assert request.draft_session_id == "draft-1" + assert result.a2ui_version == "v0.8" + assert result.document["submit"] == {"action": "job.start"} + + def test_v2_job_state_snapshot_fixture() -> None: fixture = load_v2_fixture("agent_interviewer_mvp.json") @@ -296,3 +321,5 @@ def test_v2_contract_models_are_public_sdk_exports() -> None: assert supervaizer.V2ResourceFieldOptionsSource.__name__ == ( "V2ResourceFieldOptionsSource" ) + assert supervaizer.V2SurfaceRequest is V2SurfaceRequest + assert supervaizer.V2SurfaceResult is V2SurfaceResult From 50a4e7d9b3545fa1f6f05d5808454cc61c6101e5 Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Fri, 15 May 2026 17:17:51 +0300 Subject: [PATCH 16/42] feat: expose local hello world v2 contract --- docs/CHANGELOG.md | 1 + src/supervaizer/examples/hello_world_agent.py | 104 ++++++++++++++++++ src/supervaizer/examples/local_server.py | 68 +++++++++++- src/supervaizer/server.py | 12 ++ tests/test_server.py | 74 +++++++++++++ 5 files changed, 257 insertions(+), 2 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 09a0ee0..e92722a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -25,6 +25,7 @@ All notable changes to this project will be documented in this file. - **Supervaizer v2 resource form fields** — `V2ResourceDefinition` now carries typed `fields` metadata so Studio can render simple agent-owned resource create/edit forms without callback-style dynamic field logic. - **Supervaizer v2 resource option sources** — Resource fields can now declare typed resource-backed `options_source` metadata so Studio can render relationship selectors without callback-style dynamic choices. - **Supervaizer v2 awaiting form fields** — Step awaiting state can now declare typed form fields so Studio can submit HITL actions through `step.awaiting.submit`. +- **Local Hello World v2 contract** — The built-in local Hello World agent now declares a minimal Supervaizer v2 registration and registers `job.start` A2UI/action handlers for local Studio and SDK smoke tests. ### Tests diff --git a/src/supervaizer/examples/hello_world_agent.py b/src/supervaizer/examples/hello_world_agent.py index b506461..df1a3ea 100644 --- a/src/supervaizer/examples/hello_world_agent.py +++ b/src/supervaizer/examples/hello_world_agent.py @@ -25,12 +25,14 @@ from supervaizer.account import Account from supervaizer.case import Case, CaseNodeUpdate from supervaizer.common import ApiSuccess, log +from supervaizer.contracts import SUPERVAIZER_V2_A2UI_VERSION from supervaizer.job import JobInstructions, JobResponse, Jobs from supervaizer.lifecycle import EntityStatus STEPS_WITH_HITL = ["Begin", "Progress", "Human Review", "End"] STEPS_WITHOUT_HITL = ["Begin", "Progress", "End"] HITL_POLL_INTERVAL = 1.0 # seconds between polls while waiting for human input +HELLO_WORLD_A2UI_CATALOG_VERSION = "supervaizer-v2-local.0" class _LocalAccount(Account): @@ -239,3 +241,105 @@ def job_status(**kwargs: Any) -> JobResponse: status=EntityStatus.STOPPED, message="idle", ) + + +def handle_v2_surface(surface_request: Any) -> dict[str, Any]: + """Return the local Hello World job.start A2UI document.""" + request = _request_dict(surface_request) + surface = str(request.get("surface") or "").strip() + return { + "surface": surface, + "a2ui_version": SUPERVAIZER_V2_A2UI_VERSION, + "a2ui_catalog_version": HELLO_WORLD_A2UI_CATALOG_VERSION, + "document": { + "type": "Form", + "id": "supervaizer.local.hello_world.job.start", + "title": "Start Hello World", + "fields": [ + { + "id": "count", + "label": "How many times to say hello", + "type": "number", + "required": True, + "default": 3, + }, + { + "id": "enable_human_review", + "label": "Enable human review", + "type": "boolean", + "required": False, + "default": False, + }, + ], + "submit": {"action": "job.start", "label": "Start"}, + "preview": {"action": "job.start.preview"}, + "state": {"draft_session_id": request.get("draft_session_id")}, + }, + } + + +def handle_v2_action(action_request: Any) -> dict[str, Any]: + """Dispatch minimal v2 actions for the local Hello World agent.""" + request = _request_dict(action_request) + action = str(request.get("action") or "").strip() + if action == "job.start.preview": + return _ok_result("job.start.previewed", request_id=request.get("request_id")) + if action != "job.start": + return { + "status": "error", + "effects": [{"type": "action.unsupported", "action": action}], + } + + response = job_start( + fields=_legacy_job_start_fields(_action_input(request)), + context={"job_id": request.get("job_id") or "local-v2-job"}, + ) + return { + "status": "ok", + "effects": [ + { + "type": "job.started", + "job_id": response.job_id, + "status": _status_value(response.status), + "message": response.message, + "payload": response.payload, + } + ], + } + + +def _legacy_job_start_fields(action_input: dict[str, Any]) -> dict[str, Any]: + return { + "How many times to say hello": action_input.get( + "count", + action_input.get("How many times to say hello", 1), + ), + "Enable human review": action_input.get( + "enable_human_review", + action_input.get("Enable human review", False), + ), + } + + +def _ok_result(effect_type: str, **effect: Any) -> dict[str, Any]: + return { + "status": "ok", + "effects": [{"type": effect_type, **effect}], + } + + +def _action_input(request: dict[str, Any]) -> dict[str, Any]: + value = request.get("input") + return value if isinstance(value, dict) else {} + + +def _request_dict(request: Any) -> dict[str, Any]: + if isinstance(request, dict): + return request + if hasattr(request, "model_dump"): + return request.model_dump(mode="python") + raise TypeError(f"Unsupported request type: {type(request).__name__}") + + +def _status_value(status: Any) -> str: + return str(getattr(status, "value", status)) diff --git a/src/supervaizer/examples/local_server.py b/src/supervaizer/examples/local_server.py index 10b88d1..8d4e193 100644 --- a/src/supervaizer/examples/local_server.py +++ b/src/supervaizer/examples/local_server.py @@ -16,6 +16,7 @@ """ import os +from typing import Any import shortuuid @@ -28,11 +29,73 @@ Server, ) from supervaizer.agent import AgentMethodField +from supervaizer.contracts import ( + SUPERVAIZER_V2_A2A_VERSION, + SUPERVAIZER_V2_A2UI_VERSION, + SUPERVAIZER_V2_CONTRACT_VERSION, +) + +HELLO_WORLD_AGENT_NAME = "Hello World AI Agent" +HELLO_WORLD_AGENT_SLUG = "hello-world-ai-agent" +HELLO_WORLD_AGENT_VERSION = "1.0" +HELLO_WORLD_A2UI_CATALOG_VERSION = "supervaizer-v2-local.0" + + +def build_default_local_v2_registration( + *, + agent_slug: str = HELLO_WORLD_AGENT_SLUG, + agent_version: str = HELLO_WORLD_AGENT_VERSION, +) -> dict[str, Any]: + """Return the minimal Supervaizer v2 contract for the built-in local agent.""" + return { + "supervaizer_contract_version": SUPERVAIZER_V2_CONTRACT_VERSION, + "agent": { + "id": agent_slug, + "slug": agent_slug, + "display_name": HELLO_WORLD_AGENT_NAME, + }, + "versions": { + "a2ui_version": SUPERVAIZER_V2_A2UI_VERSION, + "a2ui_catalog_version": HELLO_WORLD_A2UI_CATALOG_VERSION, + "a2a_version": SUPERVAIZER_V2_A2A_VERSION, + "ag_ui_version": None, + }, + "a2a": { + "agent_card_url": f"/.well-known/agents/v{agent_version}/{agent_slug}_agent.json", + "controller_url": "/a2a", + }, + "capabilities": { + "surfaces": ["job.start"], + "actions": ["job.start.preview", "job.start"], + "case_lanes": [{"id": "work", "label": "Work", "default": True}], + "artifact_types": [], + }, + "resources": [], + "datasets": [], + } + + +def register_default_local_v2_handlers( + server: Any, + *, + agent_slug: str = HELLO_WORLD_AGENT_SLUG, +) -> None: + """Register minimal v2 handlers for the built-in local Hello World agent.""" + from supervaizer.examples.hello_world_agent import ( + handle_v2_action, + handle_v2_surface, + ) + + server.register_v2_surface("job.start", handle_v2_surface, agent_slug=agent_slug) + server.register_v2_action( + "job.start.preview", handle_v2_action, agent_slug=agent_slug + ) + server.register_v2_action("job.start", handle_v2_action, agent_slug=agent_slug) def get_default_local_agent() -> Agent: """Default Hello World agent for local test mode (mirrors supervaize_hello_world).""" - agent_name = "Hello World AI Agent" + agent_name = HELLO_WORLD_AGENT_NAME module = "supervaizer.examples.hello_world_agent" parameters = ParametersSetup.from_list([ @@ -98,7 +161,7 @@ def get_default_local_agent() -> Agent: name=agent_name, id=shortuuid.uuid(agent_name), author="Supervaizer (local test)", - version="1.0", + version=HELLO_WORLD_AGENT_VERSION, description="Built-in Hello World agent for local testing without Studio.", tags=["hello world", "ai agent", "local"], methods=AgentMethods( @@ -108,6 +171,7 @@ def get_default_local_agent() -> Agent: human_answer=human_answer_method, ), parameters_setup=parameters, + supervaizer_v2_registration=build_default_local_v2_registration(), ) diff --git a/src/supervaizer/server.py b/src/supervaizer/server.py index 0a06202..bc90a82 100644 --- a/src/supervaizer/server.py +++ b/src/supervaizer/server.py @@ -410,6 +410,7 @@ def __init__( # Local mode: skip Studio, inject Hello World, default api_key local_mode = is_local_mode() + local_hello_world_slug: str | None = None if local_mode: if supervisor_account is not None: log.warning( @@ -432,6 +433,7 @@ def __init__( existing_slugs = {a.slug for a in agents} if hw_agent.slug not in existing_slugs: agents = [hw_agent] + list(agents) + local_hello_world_slug = hw_agent.slug elif not agents: log.warning( "[Server] Local mode with Hello World disabled and no" @@ -535,6 +537,16 @@ async def validation_exception_handler( **kwargs, ) + if local_hello_world_slug: + from supervaizer.examples.local_server import ( + register_default_local_v2_handlers, + ) + + register_default_local_v2_handlers( + self, + agent_slug=local_hello_world_slug, + ) + log.info(f"[Server launch] Server ID: {self.server_id}") # Store server instance on app state before building routers diff --git a/tests/test_server.py b/tests/test_server.py index 5cc5bd9..ca118d1 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -605,6 +605,80 @@ def test_local_mode_injects_hello_world_agent(self, agent_fixture: Agent) -> Non assert len(server.agents) == 2 assert server.agents[0].name == "Hello World AI Agent" assert server.agents[1].name == agent_fixture.name + assert server.agents[0].supervaizer_v2_registration is not None + finally: + del os.environ["SUPERVAIZER_LOCAL_MODE"] + + def test_local_mode_registers_hello_world_v2_handlers(self) -> None: + """The built-in Hello World agent works through the v2 A2A surface/action path.""" + os.environ["SUPERVAIZER_LOCAL_MODE"] = "true" + try: + server = Server( + agents=[], + host="localhost", + port=8002, + environment="test", + api_key="test-key", + ) + agent_slug = server.agents[0].slug + client = TestClient(server.app) + + card_response = client.get( + f"/.well-known/agents/v{server.agents[0].version}/{agent_slug}_agent.json" + ) + assert card_response.status_code == 200 + assert ( + card_response.json()["supervaizer"]["v2"]["a2a"]["controller_url"] + == "/a2a" + ) + + surface_response = client.post( + "/a2a", + json={ + "jsonrpc": "2.0", + "id": "surface-1", + "method": "supervaizer/surface.load", + "params": { + "request_id": "surface-1", + "actor": {"user_id": "user-1"}, + "workspace": {"id": "workspace-1"}, + "mission_id": "mission-1", + "agent_slug": agent_slug, + "surface": "job.start", + "input": {}, + "draft_session_id": "draft-1", + }, + }, + ) + assert surface_response.status_code == 200 + assert ( + surface_response.json()["result"]["document"]["submit"]["action"] + == "job.start" + ) + + action_response = client.post( + "/a2a", + json={ + "jsonrpc": "2.0", + "id": "action-1", + "method": "supervaizer/action.invoke", + "params": { + "request_id": "action-1", + "actor": {"user_id": "user-1"}, + "workspace": {"id": "workspace-1"}, + "mission_id": "mission-1", + "agent_slug": agent_slug, + "surface": "job.start", + "action": "job.start.preview", + "input": {"count": 0}, + }, + }, + ) + assert action_response.status_code == 200 + assert ( + action_response.json()["result"]["effects"][0]["type"] + == "job.start.previewed" + ) finally: del os.environ["SUPERVAIZER_LOCAL_MODE"] From 752a4532723f442b87da05641e96875e7edcf17c Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Fri, 15 May 2026 18:01:05 +0300 Subject: [PATCH 17/42] feat: add v2 job source target type --- docs/CHANGELOG.md | 1 + src/supervaizer/contracts.py | 1 + tests/fixtures/supervaizer_v2/agent_interviewer_mvp.json | 6 ++++-- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e92722a..26ea5b2 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -22,6 +22,7 @@ All notable changes to this project will be documented in this file. - **A2A JSON-RPC surface runtime** — Added `supervaizer/surface.load`, typed `V2SurfaceRequest`/`V2SurfaceResult` models, and public SDK helpers for registering A2UI surface handlers through `Server.register_v2_surface()` and `@server.v2_surface(...)`. - **Supervaizer v2 agent identity guard** — `Agent` now rejects v2 registration payloads whose declared `agent.slug` differs from the runtime SDK slug, preventing A2A action handlers from registering under one slug while Studio invokes another. - **Supervaizer v2 job sync state** — `V2JobSyncResult` now carries an optional `job_state` snapshot so agents can return convergent Job/Case/Step/Artifact state through `job.sync`. +- **Supervaizer v2 job source target metadata** — `V2JobSource` now includes an optional `target_type` so external sources can declare the business object Studio should use for dedupe and catch-up. - **Supervaizer v2 resource form fields** — `V2ResourceDefinition` now carries typed `fields` metadata so Studio can render simple agent-owned resource create/edit forms without callback-style dynamic field logic. - **Supervaizer v2 resource option sources** — Resource fields can now declare typed resource-backed `options_source` metadata so Studio can render relationship selectors without callback-style dynamic choices. - **Supervaizer v2 awaiting form fields** — Step awaiting state can now declare typed form fields so Studio can submit HITL actions through `step.awaiting.submit`. diff --git a/src/supervaizer/contracts.py b/src/supervaizer/contracts.py index 74aa08a..ea5165e 100644 --- a/src/supervaizer/contracts.py +++ b/src/supervaizer/contracts.py @@ -463,6 +463,7 @@ class V2JobSource(ContractModel): type: Literal["fresh_start", "external"] external_ref: str | None = None previous_job_id: str | None = None + target_type: str | None = None class V2JobSnapshot(ContractModel): diff --git a/tests/fixtures/supervaizer_v2/agent_interviewer_mvp.json b/tests/fixtures/supervaizer_v2/agent_interviewer_mvp.json index a98412d..5e948b1 100644 --- a/tests/fixtures/supervaizer_v2/agent_interviewer_mvp.json +++ b/tests/fixtures/supervaizer_v2/agent_interviewer_mvp.json @@ -189,7 +189,8 @@ "status": "active", "source": { "type": "fresh_start", - "external_ref": "campaign_123" + "external_ref": "campaign_123", + "target_type": "campaign" } }, "cases": [ @@ -246,7 +247,8 @@ "status": "active", "source": { "type": "fresh_start", - "external_ref": "campaign_123" + "external_ref": "campaign_123", + "target_type": "campaign" } }, "cases": [ From 56fbeb5665486e8bf4ca0e049d8fd0730e95385d Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Fri, 15 May 2026 19:02:32 +0300 Subject: [PATCH 18/42] feat: stream v2 action effects over a2a --- docs/CHANGELOG.md | 1 + src/supervaizer/protocol/a2a/controller.py | 11 +++ src/supervaizer/protocol/a2a/events.py | 87 ++++++++++++++++++++++ src/supervaizer/protocol/a2a/routes.py | 14 +++- tests/test_a2a.py | 44 +++++++++++ 5 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 src/supervaizer/protocol/a2a/events.py diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 26ea5b2..c5a32d5 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,6 +19,7 @@ All notable changes to this project will be documented in this file. - **Supervaizer v2 contract primitives** — Added typed SDK models for the v2 registration and action contract, including pinned A2UI/A2A versions, resources, datasets, case lanes, artifact declarations, job snapshots, sync metadata, and replay-safety metadata. - **A2A JSON-RPC action runtime** — Added the `/a2a` `supervaizer/action.invoke` dispatcher, v2 Agent Card extension payloads, and public SDK helpers for registering typed v2 actions through `Server.register_v2_action()` and `@server.v2_action(...)`. +- **A2A SSE event stream** — Added `/a2a/events` and an in-process v2 event bus so action effects returned through `supervaizer/action.invoke` can also be observed over Server-Sent Events. - **A2A JSON-RPC surface runtime** — Added `supervaizer/surface.load`, typed `V2SurfaceRequest`/`V2SurfaceResult` models, and public SDK helpers for registering A2UI surface handlers through `Server.register_v2_surface()` and `@server.v2_surface(...)`. - **Supervaizer v2 agent identity guard** — `Agent` now rejects v2 registration payloads whose declared `agent.slug` differs from the runtime SDK slug, preventing A2A action handlers from registering under one slug while Studio invokes another. - **Supervaizer v2 job sync state** — `V2JobSyncResult` now carries an optional `job_state` snapshot so agents can return convergent Job/Case/Step/Artifact state through `job.sync`. diff --git a/src/supervaizer/protocol/a2a/controller.py b/src/supervaizer/protocol/a2a/controller.py index 8abf0ae..cfac04a 100644 --- a/src/supervaizer/protocol/a2a/controller.py +++ b/src/supervaizer/protocol/a2a/controller.py @@ -21,6 +21,7 @@ V2SurfaceRequest, V2SurfaceResult, ) +from supervaizer.protocol.a2a.events import A2A_EFFECT_EVENT, publish_v2_event if TYPE_CHECKING: from supervaizer.server import Server @@ -150,6 +151,16 @@ async def _dispatch_action( if isawaitable(handler_result): handler_result = await handler_result result = V2ActionResult.model_validate(handler_result) + publish_v2_event( + server, + A2A_EFFECT_EVENT, + { + "agent_slug": action_request.agent_slug, + "action": action_request.action, + "request_id": action_request.request_id, + "effects": [effect.model_dump(mode="json") for effect in result.effects], + }, + ) except Exception as exc: return _json_rpc_error( request_id=request.id, diff --git a/src/supervaizer/protocol/a2a/events.py b/src/supervaizer/protocol/a2a/events.py new file mode 100644 index 0000000..0941d70 --- /dev/null +++ b/src/supervaizer/protocol/a2a/events.py @@ -0,0 +1,87 @@ +# Copyright (c) 2024-2026 Alain Prasquier - Supervaize.com. All rights reserved. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, you can obtain one at +# https://mozilla.org/MPL/2.0/. + +"""SSE event stream support for the Supervaizer v2 A2A controller.""" + +from __future__ import annotations + +import asyncio +import json +from collections.abc import AsyncIterator +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from fastapi import Request + + from supervaizer.server import Server + +A2A_EFFECT_EVENT = "supervaizer.effect" +DEFAULT_QUEUE_SIZE = 100 +HEARTBEAT_SECONDS = 15.0 + + +def subscribe_v2_events( + server: "Server", + *, + max_size: int = DEFAULT_QUEUE_SIZE, +) -> asyncio.Queue[dict[str, Any]]: + queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=max_size) + subscribers = _event_subscribers(server) + subscribers.add(queue) + return queue + + +def unsubscribe_v2_events(server: "Server", queue: asyncio.Queue[dict[str, Any]]) -> None: + _event_subscribers(server).discard(queue) + + +def publish_v2_event(server: "Server", event: str, data: dict[str, Any]) -> None: + payload = {"event": event, "data": data} + for queue in tuple(_event_subscribers(server)): + _enqueue_event(queue, payload) + + +async def stream_v2_events( + server: "Server", + *, + request: "Request | None" = None, +) -> AsyncIterator[dict[str, str]]: + queue = subscribe_v2_events(server) + try: + yield _sse_event("supervaizer.connected", {"status": "connected"}) + while True: + if request is not None and await request.is_disconnected(): + break + try: + event = await asyncio.wait_for(queue.get(), timeout=HEARTBEAT_SECONDS) + except TimeoutError: + yield _sse_event("supervaizer.heartbeat", {"status": "ok"}) + continue + yield _sse_event(event["event"], event["data"]) + finally: + unsubscribe_v2_events(server, queue) + + +def _event_subscribers(server: "Server") -> set[asyncio.Queue[dict[str, Any]]]: + state = server.app.state + subscribers = getattr(state, "supervaizer_v2_event_subscribers", None) + if subscribers is None: + subscribers = set() + state.supervaizer_v2_event_subscribers = subscribers + return subscribers + + +def _enqueue_event(queue: asyncio.Queue[dict[str, Any]], payload: dict[str, Any]) -> None: + if queue.full(): + try: + queue.get_nowait() + except asyncio.QueueEmpty: + pass + queue.put_nowait(payload) + + +def _sse_event(event: str, data: dict[str, Any]) -> dict[str, str]: + return {"event": event, "data": json.dumps(data, default=str)} diff --git a/src/supervaizer/protocol/a2a/routes.py b/src/supervaizer/protocol/a2a/routes.py index 41b0ba8..f142bb9 100644 --- a/src/supervaizer/protocol/a2a/routes.py +++ b/src/supervaizer/protocol/a2a/routes.py @@ -12,10 +12,12 @@ from typing import TYPE_CHECKING, Any, Dict -from fastapi import APIRouter +from fastapi import APIRouter, Request +from sse_starlette.sse import EventSourceResponse from supervaizer.common import log from supervaizer.protocol.a2a.controller import dispatch_json_rpc +from supervaizer.protocol.a2a.events import stream_v2_events from supervaizer.protocol.a2a.model import ( create_agent_card, create_agents_list, @@ -122,4 +124,14 @@ async def post_a2a_controller(body: Dict[str, Any]) -> Dict[str, Any]: response = await dispatch_json_rpc(server, body) return response.model_dump(mode="json", exclude_none=True) + @router.get( + "/a2a/events", + summary="A2A SSE Event Stream", + description="Streams Supervaizer v2 controller effects over Server-Sent Events.", + ) + @handle_route_errors() + async def get_a2a_events(request: Request) -> EventSourceResponse: + log.info("[A2A] GET /a2a/events [SSE event stream]") + return EventSourceResponse(stream_v2_events(server, request=request)) + return router diff --git a/tests/test_a2a.py b/tests/test_a2a.py index 1894e0c..b9b74f2 100644 --- a/tests/test_a2a.py +++ b/tests/test_a2a.py @@ -35,6 +35,11 @@ register_v2_action_handler, register_v2_surface_handler, ) +from supervaizer.protocol.a2a.events import ( + A2A_EFFECT_EVENT, + subscribe_v2_events, + unsubscribe_v2_events, +) def test_create_agent_card(agent_fixture: Agent) -> None: @@ -313,6 +318,45 @@ def start_job(request: V2ActionRequest) -> V2ActionResult: ] +def test_a2a_controller_publishes_v2_action_effects( + server_fixture: Server, +) -> None: + agent_slug = server_fixture.agents[0].slug + queue = subscribe_v2_events(server_fixture) + + def start_job(_request: V2ActionRequest) -> V2ActionResult: + return V2ActionResult( + status="ok", + effects=[V2Effect(type="job.started", job_id="job-123")], + ) + + try: + register_v2_action_handler(server_fixture, "job.start", start_job) + client = TestClient(server_fixture.app) + + response = client.post( + "/a2a", + json={ + "jsonrpc": "2.0", + "id": "rpc-event-1", + "method": SUPERVAIZER_ACTION_INVOKE_METHOD, + "params": _v2_action_payload(action="job.start", agent_slug=agent_slug), + }, + ) + + assert response.status_code == 200 + event = queue.get_nowait() + assert event["event"] == A2A_EFFECT_EVENT + assert event["data"] == { + "agent_slug": agent_slug, + "action": "job.start", + "request_id": "request-1", + "effects": [{"type": "job.started", "job_id": "job-123"}], + } + finally: + unsubscribe_v2_events(server_fixture, queue) + + def test_a2a_controller_dispatches_registered_v2_surface( server_fixture: Server, ) -> None: From 9774bea1b1bd7a90cabff78a49f81440a2684219 Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Fri, 15 May 2026 19:05:11 +0300 Subject: [PATCH 19/42] fix: advertise v2 push notifications as unsupported --- docs/CHANGELOG.md | 1 + src/supervaizer/contracts.py | 2 +- .../fixtures/supervaizer_v2/agent_interviewer_mvp.json | 10 +++++----- tests/test_contracts.py | 3 +++ 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c5a32d5..b576733 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -20,6 +20,7 @@ All notable changes to this project will be documented in this file. - **Supervaizer v2 contract primitives** — Added typed SDK models for the v2 registration and action contract, including pinned A2UI/A2A versions, resources, datasets, case lanes, artifact declarations, job snapshots, sync metadata, and replay-safety metadata. - **A2A JSON-RPC action runtime** — Added the `/a2a` `supervaizer/action.invoke` dispatcher, v2 Agent Card extension payloads, and public SDK helpers for registering typed v2 actions through `Server.register_v2_action()` and `@server.v2_action(...)`. - **A2A SSE event stream** — Added `/a2a/events` and an in-process v2 event bus so action effects returned through `supervaizer/action.invoke` can also be observed over Server-Sent Events. +- **Supervaizer v2 transport honesty** — `V2A2ATransport.push_notifications` now defaults to `false`; the MVP advertises JSON-RPC and SSE support only until A2A push notifications are implemented. - **A2A JSON-RPC surface runtime** — Added `supervaizer/surface.load`, typed `V2SurfaceRequest`/`V2SurfaceResult` models, and public SDK helpers for registering A2UI surface handlers through `Server.register_v2_surface()` and `@server.v2_surface(...)`. - **Supervaizer v2 agent identity guard** — `Agent` now rejects v2 registration payloads whose declared `agent.slug` differs from the runtime SDK slug, preventing A2A action handlers from registering under one slug while Studio invokes another. - **Supervaizer v2 job sync state** — `V2JobSyncResult` now carries an optional `job_state` snapshot so agents can return convergent Job/Case/Step/Artifact state through `job.sync`. diff --git a/src/supervaizer/contracts.py b/src/supervaizer/contracts.py index ea5165e..f8026a7 100644 --- a/src/supervaizer/contracts.py +++ b/src/supervaizer/contracts.py @@ -262,7 +262,7 @@ class V2ProtocolVersions(ContractModel): class V2A2ATransport(ContractModel): json_rpc: bool = True sse: bool = True - push_notifications: bool = True + push_notifications: bool = False class V2A2AExternalInterop(ContractModel): diff --git a/tests/fixtures/supervaizer_v2/agent_interviewer_mvp.json b/tests/fixtures/supervaizer_v2/agent_interviewer_mvp.json index 5e948b1..1cdef36 100644 --- a/tests/fixtures/supervaizer_v2/agent_interviewer_mvp.json +++ b/tests/fixtures/supervaizer_v2/agent_interviewer_mvp.json @@ -15,11 +15,11 @@ "a2a": { "agent_card_url": "https://agent.example.com/.well-known/agent-card.json", "controller_url": "https://agent.example.com/a2a", - "transport": { - "json_rpc": true, - "sse": true, - "push_notifications": true - }, + "transport": { + "json_rpc": true, + "sse": true, + "push_notifications": false + }, "external_interop": { "inbound_tasks": false, "outbound_delegation": false diff --git a/tests/test_contracts.py b/tests/test_contracts.py index ac464c8..6edf07d 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -168,6 +168,9 @@ def test_v2_agent_interviewer_registration_fixture() -> None: assert registration.supervaizer_contract_version == 2 assert registration.versions.a2ui_version == "v0.8" assert registration.versions.a2a_version == "0.2.6" + assert registration.a2a.transport.json_rpc is True + assert registration.a2a.transport.sse is True + assert registration.a2a.transport.push_notifications is False assert registration.job_policy.sync is not None assert registration.job_policy.sync.action == "job.sync" assert "job.start" in registration.capabilities.surfaces From 6fb1c11bb761c3b65de22a3b75c071ff280aecbb Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Fri, 15 May 2026 19:30:10 +0300 Subject: [PATCH 20/42] refactor: remove legacy dynamic choices --- docs/CHANGELOG.md | 1 + docs/api/openapi.json | 1538 +++++++++-------- docs/model_reference/model_core.md | 12 +- docs/model_reference/model_extra.md | 797 ++++++++- src/supervaizer/__init__.py | 2 - src/supervaizer/agent.py | 30 +- src/supervaizer/contracts.py | 13 - .../examples/controller_template.py | 15 +- src/supervaizer/routes.py | 44 - tests/test_agent.py | 221 +-- tests/test_contracts.py | 1 + tests/test_routes.py | 174 +- 12 files changed, 1669 insertions(+), 1179 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b576733..ccc2eca 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -27,6 +27,7 @@ All notable changes to this project will be documented in this file. - **Supervaizer v2 job source target metadata** — `V2JobSource` now includes an optional `target_type` so external sources can declare the business object Studio should use for dedupe and catch-up. - **Supervaizer v2 resource form fields** — `V2ResourceDefinition` now carries typed `fields` metadata so Studio can render simple agent-owned resource create/edit forms without callback-style dynamic field logic. - **Supervaizer v2 resource option sources** — Resource fields can now declare typed resource-backed `options_source` metadata so Studio can render relationship selectors without callback-style dynamic choices. +- **Legacy dynamic choices removed** — Removed the v1 `dynamic_choices` field metadata, `dynamic_choices_callback`, `/start/dynamic_choices` route, and related contract exports; dynamic options now belong to v2 resource `options_source` metadata or typed A2A actions. - **Supervaizer v2 awaiting form fields** — Step awaiting state can now declare typed form fields so Studio can submit HITL actions through `step.awaiting.submit`. - **Local Hello World v2 contract** — The built-in local Hello World agent now declares a minimal Supervaizer v2 registration and registers `job.start` A2UI/action handlers for local Studio and SDK smoke tests. diff --git a/docs/api/openapi.json b/docs/api/openapi.json index c09260f..bbde762 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Supervaize API", - "description": "API version: v1 Controller version: 0.13.0\n\nAPI for controlling and managing Supervaize agents. \n\nMore information at [https://doc.supervaize.com](https://doc.supervaize.com)\n\n## Authentication\n\nSome endpoints require API key authentication. Protected endpoints expect the API key in the X-API-Key header.\n\n[Swagger](/docs)\n[Redoc](/redoc)\n[OpenAPI](/openapi.json)\n", + "description": "API version: v1 Controller version: 0.20.1\n\nAPI for controlling and managing Supervaize agents. \n\nMore information at [https://doc.supervaize.com](https://doc.supervaize.com)\n\n## Authentication\n\nSome endpoints require API key authentication. Protected endpoints expect the API key in the X-API-Key header.\n\n[Swagger](/docs)\n[Redoc](/redoc)\n[OpenAPI](/openapi.json)\n", "termsOfService": "https://supervaize.com/terms/", "contact": { "name": "Support Team", @@ -16,19 +16,135 @@ "version": "v1" }, "paths": { - "/supervaizer/jobs/{job_id}": { + "/api/supervaizer/contract": { "get": { "tags": [ "Supervision" ], - "summary": "Get Job Status", - "description": "Get the status of a job by its ID", - "operationId": "get_job_status_supervaizer_jobs__job_id__get", - "security": [ + "summary": "Get Controller Contract", + "description": "Return the controller contract Studio should use for route resolution.", + "operationId": "get_controller_contract_api_supervaizer_contract_get", + "parameters": [ { - "APIKeyHeader": [] + "name": "X-API-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get Controller Contract Api Supervaizer Contract Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/supervaizer/registration/refresh": { + "post": { + "tags": [ + "Supervision" + ], + "summary": "Refresh Controller Registration", + "description": "Accept a Studio request to re-send the canonical server.register event.", + "operationId": "refresh_controller_registration_api_supervaizer_registration_refresh_post", + "parameters": [ + { + "name": "X-API-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/RegistrationRefreshRequest" + }, + { + "type": "null" + } + ], + "title": "Request Data" + } + } + } + }, + "responses": { + "202": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Refresh Controller Registration Api Supervaizer Registration Refresh Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } + } + } + }, + "/api/supervaizer/jobs/{job_id}": { + "get": { + "tags": [ + "Supervision" ], + "summary": "Get Job Status", + "description": "Get the status of a job by its ID", + "operationId": "get_job_status_api_supervaizer_jobs__job_id__get", "parameters": [ { "name": "job_id", @@ -38,6 +154,22 @@ "type": "string", "title": "Job Id" } + }, + { + "name": "X-API-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } } ], "responses": { @@ -64,19 +196,14 @@ } } }, - "/supervaizer/jobs": { + "/api/supervaizer/jobs": { "get": { "tags": [ "Supervision" ], "summary": "Get All Jobs", "description": "Get all jobs across all agents with pagination and optional status filtering", - "operationId": "get_all_jobs_supervaizer_jobs_get", - "security": [ - { - "APIKeyHeader": [] - } - ], + "operationId": "get_all_jobs_api_supervaizer_jobs_get", "parameters": [ { "name": "skip", @@ -122,6 +249,22 @@ "title": "Status" }, "description": "Filter jobs by status" + }, + { + "name": "X-API-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } } ], "responses": { @@ -137,7 +280,7 @@ "$ref": "#/components/schemas/JobResponse" } }, - "title": "Response Get All Jobs Supervaizer Jobs Get" + "title": "Response Get All Jobs Api Supervaizer Jobs Get" } } } @@ -155,19 +298,14 @@ } } }, - "/supervaizer/jobs/{job_id}/cases/{case_id}/update": { + "/api/supervaizer/jobs/{job_id}/cases/{case_id}/update": { "post": { "tags": [ "Supervision" ], "summary": "Update case with answer to question", "description": "Provide an answer to a question that was requested by a case step", - "operationId": "update_case_with_answer_supervaizer_jobs__job_id__cases__case_id__update_post", - "security": [ - { - "APIKeyHeader": [] - } - ], + "operationId": "update_case_with_answer_api_supervaizer_jobs__job_id__cases__case_id__update_post", "parameters": [ { "name": "job_id", @@ -186,6 +324,22 @@ "type": "string", "title": "Case Id" } + }, + { + "name": "X-API-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } } ], "requestBody": { @@ -208,7 +362,7 @@ "additionalProperties": { "type": "string" }, - "title": "Response 200 Update Case With Answer Supervaizer Jobs Job Id Cases Case Id Update Post" + "title": "Response 200 Update Case With Answer Api Supervaizer Jobs Job Id Cases Case Id Update Post" } } } @@ -256,14 +410,14 @@ } } }, - "/supervaizer/agents": { + "/api/supervaizer/agents": { "get": { "tags": [ "Supervision" ], "summary": "Get All Agents", "description": "Get all registered agents with pagination", - "operationId": "get_all_agents_supervaizer_agents_get", + "operationId": "get_all_agents_api_supervaizer_agents_get", "parameters": [ { "name": "skip", @@ -291,6 +445,22 @@ "title": "Limit" }, "description": "Number of jobs to return" + }, + { + "name": "X-API-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } } ], "responses": { @@ -303,7 +473,7 @@ "items": { "$ref": "#/components/schemas/AgentResponse" }, - "title": "Response Get All Agents Supervaizer Agents Get" + "title": "Response Get All Agents Api Supervaizer Agents Get" } } } @@ -321,14 +491,14 @@ } } }, - "/supervaizer/agent/{agent_id}": { + "/api/supervaizer/agent/{agent_id}": { "get": { "tags": [ "Supervision" ], "summary": "Get Agent Details", "description": "Get details of a specific agent by ID", - "operationId": "get_agent_details_supervaizer_agent__agent_id__get", + "operationId": "get_agent_details_api_supervaizer_agent__agent_id__get", "parameters": [ { "name": "agent_id", @@ -338,6 +508,22 @@ "type": "string", "title": "Agent Id" } + }, + { + "name": "X-API-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } } ], "responses": { @@ -364,14 +550,32 @@ } } }, - "/supervaizer/utils/public_key": { + "/api/supervaizer/utils/public_key": { "get": { "tags": [ "Supervision" ], "summary": "Get server's public key", "description": "Returns the server's public key in PEM format", - "operationId": "get_public_key_supervaizer_utils_public_key_get", + "operationId": "get_public_key_api_supervaizer_utils_public_key_get", + "parameters": [ + { + "name": "X-API-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } + } + ], "responses": { "200": { "description": "Successful Response", @@ -379,7 +583,17 @@ "application/json": { "schema": { "type": "string", - "title": "Response Get Public Key Supervaizer Utils Public Key Get" + "title": "Response Get Public Key Api Supervaizer Utils Public Key Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" } } } @@ -387,15 +601,34 @@ } } }, - "/supervaizer/utils/encrypt": { + "/api/supervaizer/utils/encrypt": { "post": { "tags": [ "Supervision" ], "summary": "Encrypt a string", "description": "Encrypts a string using the server's public key. Example: {'key':'value'}", - "operationId": "encrypt_string_supervaizer_utils_encrypt_post", + "operationId": "encrypt_string_api_supervaizer_utils_encrypt_post", + "parameters": [ + { + "name": "X-API-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } + } + ], "requestBody": { + "required": true, "content": { "application/json": { "schema": { @@ -403,8 +636,7 @@ "title": "Text" } } - }, - "required": true + } }, "responses": { "200": { @@ -413,7 +645,7 @@ "application/json": { "schema": { "type": "string", - "title": "Response Encrypt String Supervaizer Utils Encrypt Post" + "title": "Response Encrypt String Api Supervaizer Utils Encrypt Post" } } } @@ -431,7 +663,7 @@ } } }, - "/supervaizer/agents/competitor-summary/": { + "/api/supervaizer/agents/competitor-summary/": { "get": { "tags": [ "Supervision", @@ -440,7 +672,25 @@ ], "summary": "Get information about the agent competitor_summary", "description": "Detailed information about the agent, returned as a JSON object with Agent class fields", - "operationId": "agent_info_supervaizer_agents_competitor_summary__get", + "operationId": "agent_info_api_supervaizer_agents_competitor_summary__get", + "parameters": [ + { + "name": "X-API-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } + } + ], "responses": { "200": { "description": "Successful Response", @@ -451,16 +701,21 @@ } } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } - }, - "security": [ - { - "APIKeyHeader": [] - } - ] + } } }, - "/supervaizer/agents/competitor-summary/supervaize_instructions.html": { + "/api/supervaizer/agents/competitor-summary/supervaize_instructions.html": { "get": { "tags": [ "Supervision", @@ -469,71 +724,32 @@ ], "summary": "Get supervaize instructions page for agent competitor_summary", "description": "HTML page displaying agent registration information and instructions", - "operationId": "supervaize_instructions_supervaizer_agents_competitor_summary_supervaize_instructions_html_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "text/html": { - "schema": { + "operationId": "supervaize_instructions_api_supervaizer_agents_competitor_summary_supervaize_instructions_html_get", + "parameters": [ + { + "name": "X-API-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { "type": "string" + }, + { + "type": "null" } - } + ], + "title": "X-Api-Key" } } - } - } - }, - "/supervaizer/agents/competitor-summary/validate-agent-parameters": { - "post": { - "tags": [ - "Supervision", - "Supervision" ], - "summary": "Validate agent parameters for agent: competitor_summary", - "description": "Validate agent configuration parameters (secrets, API keys, etc.) before starting a job", - "operationId": "validate_agent_parameters_supervaizer_agents_competitor_summary_validate_agent_parameters_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "title": "Body Params" - } - } - }, - "required": true - }, "responses": { "200": { "description": "Successful Response", "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "type": "object", - "title": "Response 200 Validate Agent Parameters Supervaizer Agents Competitor Summary Validate Agent Parameters Post" - } - } - } - }, - "400": { - "description": "Bad Request", - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "type": "object", - "title": "Response 400 Validate Agent Parameters Supervaizer Agents Competitor Summary Validate Agent Parameters Post" - } - } - } - }, - "500": { - "description": "Internal Server Error", - "content": { - "application/json": { + "text/html": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "type": "string" } } } @@ -548,32 +764,45 @@ } } } - }, - "security": [ - { - "APIKeyHeader": [] - } - ] + } } }, - "/supervaizer/agents/competitor-summary/validate-method-fields": { + "/api/supervaizer/agents/competitor-summary/validate-agent-parameters": { "post": { "tags": [ "Supervision", "Supervision" ], - "summary": "Validate method fields for agent: competitor_summary", - "description": "Validate job input fields against the method's field definitions before starting a job", - "operationId": "validate_method_fields_supervaizer_agents_competitor_summary_validate_method_fields_post", + "summary": "Validate agent parameters for agent: competitor_summary", + "description": "Validate agent configuration parameters (secrets, API keys, etc.) before starting a job", + "operationId": "validate_agent_parameters_api_supervaizer_agents_competitor_summary_validate_agent_parameters_post", + "parameters": [ + { + "name": "X-API-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } + } + ], "requestBody": { + "required": true, "content": { "application/json": { "schema": { "title": "Body Params" } } - }, - "required": true + } }, "responses": { "200": { @@ -581,34 +810,34 @@ "content": { "application/json": { "schema": { - "additionalProperties": true, "type": "object", - "title": "Response 200 Validate Method Fields Supervaizer Agents Competitor Summary Validate Method Fields Post" + "additionalProperties": true, + "title": "Response 200 Validate Agent Parameters Api Supervaizer Agents Competitor Summary Validate Agent Parameters Post" } } } }, "400": { - "description": "Bad Request", "content": { "application/json": { "schema": { - "additionalProperties": true, "type": "object", - "title": "Response 400 Validate Method Fields Supervaizer Agents Competitor Summary Validate Method Fields Post" + "additionalProperties": true, + "title": "Response 400 Validate Agent Parameters Api Supervaizer Agents Competitor Summary Validate Agent Parameters Post" } } - } + }, + "description": "Bad Request" }, "500": { - "description": "Internal Server Error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } - } + }, + "description": "Internal Server Error" }, "422": { "description": "Validation Error", @@ -620,32 +849,45 @@ } } } - }, - "security": [ - { - "APIKeyHeader": [] - } - ] + } } }, - "/supervaizer/agents/competitor-summary/start/dynamic_choices": { + "/api/supervaizer/agents/competitor-summary/validate-method-fields": { "post": { "tags": [ "Supervision", "Supervision" ], - "summary": "Get dynamic choices for agent: competitor_summary start method", - "description": "Returns dynamic choice values for fields that use dynamic_choices. Accepts workspace and mission context (including workspace slug) for contextualized choices.", - "operationId": "get_dynamic_choices_supervaizer_agents_competitor_summary_start_dynamic_choices_post", + "summary": "Validate method fields for agent: competitor_summary", + "description": "Validate job input fields against the method's field definitions before starting a job", + "operationId": "validate_method_fields_api_supervaizer_agents_competitor_summary_validate_method_fields_post", + "parameters": [ + { + "name": "X-API-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } + } + ], "requestBody": { + "required": true, "content": { "application/json": { "schema": { "title": "Body Params" } } - }, - "required": true + } }, "responses": { "200": { @@ -653,32 +895,34 @@ "content": { "application/json": { "schema": { - "additionalProperties": true, "type": "object", - "title": "Response 200 Get Dynamic Choices Supervaizer Agents Competitor Summary Start Dynamic Choices Post" + "additionalProperties": true, + "title": "Response 200 Validate Method Fields Api Supervaizer Agents Competitor Summary Validate Method Fields Post" } } } }, - "404": { - "description": "Not Found", + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "type": "object", + "additionalProperties": true, + "title": "Response 400 Validate Method Fields Api Supervaizer Agents Competitor Summary Validate Method Fields Post" } } - } + }, + "description": "Bad Request" }, "500": { - "description": "Internal Server Error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } - } + }, + "description": "Internal Server Error" }, "422": { "description": "Validation Error", @@ -690,15 +934,10 @@ } } } - }, - "security": [ - { - "APIKeyHeader": [] - } - ] + } } }, - "/supervaizer/agents/competitor-summary/jobs": { + "/api/supervaizer/agents/competitor-summary/jobs": { "post": { "tags": [ "Supervision", @@ -706,10 +945,23 @@ ], "summary": "Start a job with agent: competitor_summary", "description": "Start the collection of new competitor summary", - "operationId": "start_job_supervaizer_agents_competitor_summary_jobs_post", - "security": [ + "operationId": "start_job_api_supervaizer_agents_competitor_summary_jobs_post", + "parameters": [ { - "APIKeyHeader": [] + "name": "X-API-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } } ], "requestBody": { @@ -739,7 +991,7 @@ "schema": { "type": "object", "additionalProperties": true, - "title": "Response 400 Start Job Supervaizer Agents Competitor Summary Jobs Post" + "title": "Response 400 Start Job Api Supervaizer Agents Competitor Summary Jobs Post" } } }, @@ -784,12 +1036,7 @@ ], "summary": "Get all jobs for agent: competitor_summary", "description": "Get all jobs for this agent with pagination and optional status filtering", - "operationId": "get_agent_jobs_supervaizer_agents_competitor_summary_jobs_get", - "security": [ - { - "APIKeyHeader": [] - } - ], + "operationId": "get_agent_jobs_api_supervaizer_agents_competitor_summary_jobs_get", "parameters": [ { "name": "skip", @@ -835,6 +1082,22 @@ "title": "Status" }, "description": "Filter jobs by status" + }, + { + "name": "X-API-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } } ], "responses": { @@ -847,7 +1110,7 @@ "items": { "$ref": "#/components/schemas/JobResponse" }, - "title": "Response 200 Get Agent Jobs Supervaizer Agents Competitor Summary Jobs Get" + "title": "Response 200 Get Agent Jobs Api Supervaizer Agents Competitor Summary Jobs Get" } } } @@ -875,7 +1138,7 @@ } } }, - "/supervaizer/agents/competitor-summary/jobs/{job_id}": { + "/api/supervaizer/agents/competitor-summary/jobs/{job_id}": { "get": { "tags": [ "Supervision", @@ -883,12 +1146,7 @@ ], "summary": "Get job status for agent: competitor_summary", "description": "Get the status and details of a specific job", - "operationId": "get_job_status_supervaizer_agents_competitor_summary_jobs__job_id__get", - "security": [ - { - "APIKeyHeader": [] - } - ], + "operationId": "get_job_status_api_supervaizer_agents_competitor_summary_jobs__job_id__get", "parameters": [ { "name": "job_id", @@ -898,6 +1156,22 @@ "type": "string", "title": "Job Id" } + }, + { + "name": "X-API-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } } ], "responses": { @@ -944,7 +1218,7 @@ } } }, - "/supervaizer/agents/competitor-summary/stop": { + "/api/supervaizer/agents/competitor-summary/stop": { "post": { "tags": [ "Supervision", @@ -952,18 +1226,36 @@ ], "summary": "Stop the agent: competitor_summary", "description": "Stop the agent", - "operationId": "stop_agent_supervaizer_agents_competitor_summary_stop_post", + "operationId": "stop_agent_api_supervaizer_agents_competitor_summary_stop_post", + "parameters": [ + { + "name": "X-API-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } + } + ], "requestBody": { + "required": true, "content": { "application/json": { "schema": { - "additionalProperties": true, "type": "object", + "additionalProperties": true, "title": "Params" } } - }, - "required": true + } }, "responses": { "200": { @@ -977,14 +1269,14 @@ } }, "202": { - "description": "Accepted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AgentResponse" } } - } + }, + "description": "Accepted" }, "422": { "description": "Validation Error", @@ -996,15 +1288,10 @@ } } } - }, - "security": [ - { - "APIKeyHeader": [] - } - ] + } } }, - "/supervaizer/agents/competitor-summary/status": { + "/api/supervaizer/agents/competitor-summary/status": { "post": { "tags": [ "Supervision", @@ -1012,16 +1299,34 @@ ], "summary": "Get the status of the agent: competitor_summary", "description": "Get the status of the agent", - "operationId": "status_agent_supervaizer_agents_competitor_summary_status_post", + "operationId": "status_agent_api_supervaizer_agents_competitor_summary_status_post", + "parameters": [ + { + "name": "X-API-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } + } + ], "requestBody": { + "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AgentMethodParams" } } - }, - "required": true + } }, "responses": { "200": { @@ -1035,34 +1340,34 @@ } }, "400": { - "description": "Bad Request", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } - } + }, + "description": "Bad Request" }, "404": { - "description": "Not Found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } - } + }, + "description": "Not Found" }, "500": { - "description": "Internal Server Error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } - } + }, + "description": "Internal Server Error" }, "422": { "description": "Validation Error", @@ -1074,28 +1379,41 @@ } } } - }, - "security": [ - { - "APIKeyHeader": [] - } - ] + } } }, - "/supervaizer/agents/competitor-summary/parameters": { + "/api/supervaizer/agents/competitor-summary/parameters": { "post": { "tags": [ "Supervision", "Supervision" ], - "summary": "Server updates agent: competitor_summary", - "description": "Server updates agent onboarding status and/or encrypted parameters", - "operationId": "server_update_agent_supervaizer_agents_competitor_summary_parameters_post", + "summary": "Server updates agent: competitor_summary", + "description": "Server updates agent onboarding status and/or encrypted parameters", + "operationId": "server_update_agent_api_supervaizer_agents_competitor_summary_parameters_post", + "parameters": [ + { + "name": "X-API-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } + } + ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Body_server_update_agent_supervaizer_agents_competitor_summary_parameters_post" + "$ref": "#/components/schemas/Body_server_update_agent_api_supervaizer_agents_competitor_summary_parameters_post" } } } @@ -1112,14 +1430,14 @@ } }, "500": { - "description": "Internal Server Error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } - } + }, + "description": "Internal Server Error" }, "422": { "description": "Validation Error", @@ -1131,15 +1449,10 @@ } } } - }, - "security": [ - { - "APIKeyHeader": [] - } - ] + } } }, - "/supervaizer/agents/competitor-summary/custom/custom1": { + "/api/supervaizer/agents/competitor-summary/custom/custom1": { "post": { "tags": [ "Supervision", @@ -1147,16 +1460,34 @@ ], "summary": "Trigger custom method 'custom1' for agent: competitor_summary", "description": "Custom method", - "operationId": "competitor_summary_custom_custom1_supervaizer_agents_competitor_summary_custom_custom1_post", + "operationId": "competitor_summary_custom_custom1_api_supervaizer_agents_competitor_summary_custom_custom1_post", + "parameters": [ + { + "name": "X-API-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } + } + ], "requestBody": { + "required": true, "content": { "application/json": { "schema": { "title": "Body Params" } } - }, - "required": true + } }, "responses": { "200": { @@ -1170,36 +1501,36 @@ } }, "202": { - "description": "Accepted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/JobResponse" } } - } + }, + "description": "Accepted" }, "400": { - "description": "Bad Request", "content": { "application/json": { "schema": { - "additionalProperties": true, "type": "object", - "title": "Response 400 Competitor Summary Custom Custom1 Supervaizer Agents Competitor Summary Custom Custom1 Post" + "additionalProperties": true, + "title": "Response 400 Competitor Summary Custom Custom1 Api Supervaizer Agents Competitor Summary Custom Custom1 Post" } } - } + }, + "description": "Bad Request" }, "405": { - "description": "Method Not Allowed", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } - } + }, + "description": "Method Not Allowed" }, "422": { "description": "Validation Error", @@ -1211,15 +1542,10 @@ } } } - }, - "security": [ - { - "APIKeyHeader": [] - } - ] + } } }, - "/supervaizer/agents/competitor-summary/custom/custom2": { + "/api/supervaizer/agents/competitor-summary/custom/custom2": { "post": { "tags": [ "Supervision", @@ -1227,16 +1553,34 @@ ], "summary": "Trigger custom method 'custom2' for agent: competitor_summary", "description": "Custom method", - "operationId": "competitor_summary_custom_custom2_supervaizer_agents_competitor_summary_custom_custom2_post", + "operationId": "competitor_summary_custom_custom2_api_supervaizer_agents_competitor_summary_custom_custom2_post", + "parameters": [ + { + "name": "X-API-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } + } + ], "requestBody": { + "required": true, "content": { "application/json": { "schema": { "title": "Body Params" } } - }, - "required": true + } }, "responses": { "200": { @@ -1250,36 +1594,36 @@ } }, "202": { - "description": "Accepted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/JobResponse" } } - } + }, + "description": "Accepted" }, "400": { - "description": "Bad Request", "content": { "application/json": { "schema": { - "additionalProperties": true, "type": "object", - "title": "Response 400 Competitor Summary Custom Custom2 Supervaizer Agents Competitor Summary Custom Custom2 Post" + "additionalProperties": true, + "title": "Response 400 Competitor Summary Custom Custom2 Api Supervaizer Agents Competitor Summary Custom Custom2 Post" } } - } + }, + "description": "Bad Request" }, "405": { - "description": "Method Not Allowed", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } - } + }, + "description": "Method Not Allowed" }, "422": { "description": "Validation Error", @@ -1291,17 +1635,34 @@ } } } - }, - "security": [ - { - "APIKeyHeader": [] + } + } + }, + "/": { + "get": { + "tags": [ + "Public" + ], + "summary": "Home Page", + "operationId": "home_page__get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + } } - ] + } } }, "/.well-known/agents.json": { "get": { "tags": [ + "Public", "Protocol A2A" ], "summary": "A2A Agents Discovery", @@ -1326,6 +1687,7 @@ "/.well-known/health": { "get": { "tags": [ + "Public", "Protocol A2A" ], "summary": "A2A Health Status", @@ -1350,6 +1712,7 @@ "/.well-known/agents/v1.3/competitor-summary_agent.json": { "get": { "tags": [ + "Public", "Protocol A2A" ], "summary": "A2A Agent Card for competitor_summary (v1)", @@ -1374,6 +1737,7 @@ "/.well-known/agents/competitor-summary_agent.json": { "get": { "tags": [ + "Public", "Protocol A2A" ], "summary": "A2A Agent Card for competitor_summary (Legacy)", @@ -1395,14 +1759,82 @@ } } }, - "/admin/": { + "/a2a": { + "post": { + "tags": [ + "Public", + "Protocol A2A" + ], + "summary": "A2A JSON-RPC Controller", + "description": "Dispatches Supervaizer v2 controller methods over A2A JSON-RPC.", + "operationId": "post_a2a_controller_a2a_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Body" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Post A2A Controller A2A Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/a2a/events": { + "get": { + "tags": [ + "Public", + "Protocol A2A" + ], + "summary": "A2A SSE Event Stream", + "description": "Streams Supervaizer v2 controller effects over Server-Sent Events.", + "operationId": "get_a2a_events_a2a_events_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/manage/": { "get": { "tags": [ "admin" ], "summary": "Admin Dashboard", "description": "Admin dashboard page.", - "operationId": "admin_dashboard_admin__get", + "operationId": "admin_dashboard_manage__get", "responses": { "200": { "description": "Successful Response", @@ -1417,14 +1849,14 @@ } } }, - "/admin/jobs": { + "/manage/jobs": { "get": { "tags": [ "admin" ], "summary": "Admin Jobs Page", "description": "Jobs management page.", - "operationId": "admin_jobs_page_admin_jobs_get", + "operationId": "admin_jobs_page_manage_jobs_get", "responses": { "200": { "description": "Successful Response", @@ -1439,14 +1871,14 @@ } } }, - "/admin/cases": { + "/manage/cases": { "get": { "tags": [ "admin" ], "summary": "Admin Cases Page", "description": "Cases management page.", - "operationId": "admin_cases_page_admin_cases_get", + "operationId": "admin_cases_page_manage_cases_get", "responses": { "200": { "description": "Successful Response", @@ -1461,14 +1893,14 @@ } } }, - "/admin/server": { + "/manage/server": { "get": { "tags": [ "admin" ], "summary": "Admin Server Page", "description": "Server status and configuration page.", - "operationId": "admin_server_page_admin_server_get", + "operationId": "admin_server_page_manage_server_get", "responses": { "200": { "description": "Successful Response", @@ -1483,14 +1915,14 @@ } } }, - "/admin/agents": { + "/manage/agents": { "get": { "tags": [ "admin" ], "summary": "Admin Agents Page", "description": "Agents management page.", - "operationId": "admin_agents_page_admin_agents_get", + "operationId": "admin_agents_page_manage_agents_get", "responses": { "200": { "description": "Successful Response", @@ -1505,14 +1937,14 @@ } } }, - "/admin/job-start-test": { + "/manage/job-start-test": { "get": { "tags": [ "admin" ], "summary": "Admin Job Start Test Page", "description": "Job start form test page.", - "operationId": "admin_job_start_test_page_admin_job_start_test_get", + "operationId": "admin_job_start_test_page_manage_job_start_test_get", "responses": { "200": { "description": "Successful Response", @@ -1527,14 +1959,14 @@ } } }, - "/admin/static/{file_path}": { + "/manage/static/{file_path}": { "get": { "tags": [ "admin" ], "summary": "Serve Static", "description": "Serve static files from the admin static directory.", - "operationId": "serve_static_admin_static__file_path__get", + "operationId": "serve_static_manage_static__file_path__get", "parameters": [ { "name": "file_path", @@ -1568,14 +2000,14 @@ } } }, - "/admin/console": { + "/manage/console": { "get": { "tags": [ "admin" ], "summary": "Admin Console Page", - "description": "Interactive console page - publicly accessible, authentication handled by frontend.", - "operationId": "admin_console_page_admin_console_get", + "description": "Interactive console page \u2014 access enforced by Tailscale at router level.", + "operationId": "admin_console_page_manage_console_get", "responses": { "200": { "description": "Successful Response", @@ -1590,14 +2022,14 @@ } } }, - "/admin/api/stats": { + "/manage/api/stats": { "get": { "tags": [ "admin" ], "summary": "Get Stats", "description": "Get system statistics.", - "operationId": "get_stats_admin_api_stats_get", + "operationId": "get_stats_manage_api_stats_get", "responses": { "200": { "description": "Successful Response", @@ -1612,14 +2044,14 @@ } } }, - "/admin/api/server/status": { + "/manage/api/server/status": { "get": { "tags": [ "admin" ], "summary": "Get Server Status Api", "description": "Get current server status for HTMX refresh.", - "operationId": "get_server_status_api_admin_api_server_status_get", + "operationId": "get_server_status_api_manage_api_server_status_get", "responses": { "200": { "description": "Successful Response", @@ -1632,14 +2064,14 @@ } } }, - "/admin/api/server/register": { + "/manage/api/server/register": { "post": { "tags": [ "admin" ], "summary": "Register Server With Supervisor", "description": "Trigger SERVER_REGISTER to the supervaizer supervisor (no frontend API key; backend sends to SUPERVAIZE_API_URL).", - "operationId": "register_server_with_supervisor_admin_api_server_register_post", + "operationId": "register_server_with_supervisor_manage_api_server_register_post", "responses": { "200": { "description": "Successful Response", @@ -1652,14 +2084,14 @@ } } }, - "/admin/api/agents": { + "/manage/api/agents": { "get": { "tags": [ "admin" ], "summary": "Get Agents Api", "description": "Get agents with filtering for HTMX refresh.", - "operationId": "get_agents_api_admin_api_agents_get", + "operationId": "get_agents_api_manage_api_agents_get", "parameters": [ { "name": "status", @@ -1742,14 +2174,14 @@ } } }, - "/admin/api/agents/{agent_slug}": { + "/manage/api/agents/{agent_slug}": { "get": { "tags": [ "admin" ], "summary": "Get Agent Details", "description": "Get detailed agent information.", - "operationId": "get_agent_details_admin_api_agents__agent_slug__get", + "operationId": "get_agent_details_manage_api_agents__agent_slug__get", "parameters": [ { "name": "agent_slug", @@ -1783,14 +2215,14 @@ } } }, - "/admin/api/jobs": { + "/manage/api/jobs": { "get": { "tags": [ "admin" ], "summary": "Get Jobs Api", "description": "Get jobs with filtering and pagination.", - "operationId": "get_jobs_api_admin_api_jobs_get", + "operationId": "get_jobs_api_manage_api_jobs_get", "parameters": [ { "name": "status", @@ -1895,14 +2327,14 @@ } } }, - "/admin/api/jobs/{job_id}": { + "/manage/api/jobs/{job_id}": { "get": { "tags": [ "admin" ], "summary": "Get Job Details", "description": "Get detailed job information.", - "operationId": "get_job_details_admin_api_jobs__job_id__get", + "operationId": "get_job_details_manage_api_jobs__job_id__get", "parameters": [ { "name": "job_id", @@ -1941,7 +2373,7 @@ ], "summary": "Delete Job", "description": "Delete a job and its related cases.", - "operationId": "delete_job_admin_api_jobs__job_id__delete", + "operationId": "delete_job_manage_api_jobs__job_id__delete", "parameters": [ { "name": "job_id", @@ -1963,7 +2395,7 @@ "additionalProperties": { "type": "string" }, - "title": "Response Delete Job Admin Api Jobs Job Id Delete" + "title": "Response Delete Job Manage Api Jobs Job Id Delete" } } } @@ -1981,14 +2413,14 @@ } } }, - "/admin/api/cases": { + "/manage/api/cases": { "get": { "tags": [ "admin" ], "summary": "Get Cases Api", "description": "Get cases with filtering and pagination.", - "operationId": "get_cases_api_admin_api_cases_get", + "operationId": "get_cases_api_manage_api_cases_get", "parameters": [ { "name": "status", @@ -2093,14 +2525,14 @@ } } }, - "/admin/api/cases/{case_id}": { + "/manage/api/cases/{case_id}": { "get": { "tags": [ "admin" ], "summary": "Get Case Details", "description": "Get detailed case information.", - "operationId": "get_case_details_admin_api_cases__case_id__get", + "operationId": "get_case_details_manage_api_cases__case_id__get", "parameters": [ { "name": "case_id", @@ -2139,7 +2571,7 @@ ], "summary": "Delete Case", "description": "Delete a case.", - "operationId": "delete_case_admin_api_cases__case_id__delete", + "operationId": "delete_case_manage_api_cases__case_id__delete", "parameters": [ { "name": "case_id", @@ -2161,7 +2593,7 @@ "additionalProperties": { "type": "string" }, - "title": "Response Delete Case Admin Api Cases Case Id Delete" + "title": "Response Delete Case Manage Api Cases Case Id Delete" } } } @@ -2179,14 +2611,14 @@ } } }, - "/admin/api/jobs/{job_id}/status": { + "/manage/api/jobs/{job_id}/status": { "post": { "tags": [ "admin" ], "summary": "Update Job Status", "description": "Update job status.", - "operationId": "update_job_status_admin_api_jobs__job_id__status_post", + "operationId": "update_job_status_manage_api_jobs__job_id__status_post", "parameters": [ { "name": "job_id", @@ -2222,7 +2654,7 @@ "additionalProperties": { "type": "string" }, - "title": "Response Update Job Status Admin Api Jobs Job Id Status Post" + "title": "Response Update Job Status Manage Api Jobs Job Id Status Post" } } } @@ -2240,14 +2672,14 @@ } } }, - "/admin/api/cases/{case_id}/status": { + "/manage/api/cases/{case_id}/status": { "post": { "tags": [ "admin" ], "summary": "Update Case Status", "description": "Update case status.", - "operationId": "update_case_status_admin_api_cases__case_id__status_post", + "operationId": "update_case_status_manage_api_cases__case_id__status_post", "parameters": [ { "name": "case_id", @@ -2283,7 +2715,7 @@ "additionalProperties": { "type": "string" }, - "title": "Response Update Case Status Admin Api Cases Case Id Status Post" + "title": "Response Update Case Status Manage Api Cases Case Id Status Post" } } } @@ -2301,14 +2733,14 @@ } } }, - "/admin/api/recent-activity": { + "/manage/api/recent-activity": { "get": { "tags": [ "admin" ], "summary": "Get Recent Activity", "description": "Get recent entity activity.", - "operationId": "get_recent_activity_admin_api_recent_activity_get", + "operationId": "get_recent_activity_manage_api_recent_activity_get", "responses": { "200": { "description": "Successful Response", @@ -2321,48 +2753,14 @@ } } }, - "/admin/log-stream": { + "/manage/log-stream": { "get": { "tags": [ "admin" ], "summary": "Log Stream", "description": "Stream log messages via Server-Sent Events.", - "operationId": "log_stream_admin_log_stream_get", - "parameters": [ - { - "name": "token", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Token" - } - }, - { - "name": "key", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Key" - } - } - ], + "operationId": "log_stream_manage_log_stream_get", "responses": { "200": { "description": "Successful Response", @@ -2371,28 +2769,18 @@ "schema": {} } } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } } } } }, - "/admin/test-log": { + "/manage/test-log": { "get": { "tags": [ "admin" ], "summary": "Test Log", "description": "Test endpoint to generate a log message.", - "operationId": "test_log_admin_test_log_get", + "operationId": "test_log_manage_test_log_get", "responses": { "200": { "description": "Successful Response", @@ -2403,31 +2791,7 @@ "type": "string" }, "type": "object", - "title": "Response Test Log Admin Test Log Get" - } - } - } - } - } - } - }, - "/admin/debug-tokens": { - "get": { - "tags": [ - "admin" - ], - "summary": "Debug Tokens", - "description": "Debug endpoint to see current tokens.", - "operationId": "debug_tokens_admin_debug_tokens_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "type": "object", - "title": "Response Debug Tokens Admin Debug Tokens Get" + "title": "Response Test Log Manage Test Log Get" } } } @@ -2435,14 +2799,14 @@ } } }, - "/admin/test-loguru": { + "/manage/test-loguru": { "get": { "tags": [ "admin" ], "summary": "Test Loguru", "description": "Test endpoint to generate loguru messages.", - "operationId": "test_loguru_admin_test_loguru_get", + "operationId": "test_loguru_manage_test_loguru_get", "responses": { "200": { "description": "Successful Response", @@ -2453,7 +2817,7 @@ "type": "string" }, "type": "object", - "title": "Response Test Loguru Admin Test Loguru Get" + "title": "Response Test Loguru Manage Test Loguru Get" } } } @@ -2461,14 +2825,14 @@ } } }, - "/admin/debug-queue": { + "/manage/debug-queue": { "get": { "tags": [ "admin" ], "summary": "Debug Queue", "description": "Debug endpoint to check log queue status.", - "operationId": "debug_queue_admin_debug_queue_get", + "operationId": "debug_queue_manage_debug_queue_get", "responses": { "200": { "description": "Successful Response", @@ -2477,7 +2841,7 @@ "schema": { "additionalProperties": true, "type": "object", - "title": "Response Debug Queue Admin Debug Queue Get" + "title": "Response Debug Queue Manage Debug Queue Get" } } } @@ -2485,45 +2849,27 @@ } } }, - "/admin/api/console/execute": { + "/manage/api/console/execute": { "post": { "tags": [ "admin" ], "summary": "Execute Console Command", - "description": "Execute a console command and add output to log stream.", - "operationId": "execute_console_command_admin_api_console_execute_post", - "parameters": [ - { - "name": "token", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Token" - } - } - ], + "description": "Execute a console command \u2014 access enforced by Tailscale at router level.", + "operationId": "execute_console_command_manage_api_console_execute_post", "requestBody": { - "required": true, "content": { "application/json": { "schema": { - "type": "object", "additionalProperties": { "type": "string" }, + "type": "object", "title": "Command Data" } } - } + }, + "required": true }, "responses": { "200": { @@ -2531,11 +2877,11 @@ "content": { "application/json": { "schema": { - "type": "object", "additionalProperties": { "type": "string" }, - "title": "Response Execute Console Command Admin Api Console Execute Post" + "type": "object", + "title": "Response Execute Console Command Manage Api Console Execute Post" } } } @@ -2553,7 +2899,7 @@ } } }, - "/admin/agents/{slug}/workbench": { + "/manage/agents/{slug}/workbench": { "get": { "tags": [ "admin", @@ -2561,12 +2907,7 @@ ], "summary": "Workbench Page", "description": "Render the main workbench page.", - "operationId": "workbench_page_admin_agents__slug__workbench_get", - "security": [ - { - "APIKeyHeader": [] - } - ], + "operationId": "workbench_page_manage_agents__slug__workbench_get", "parameters": [ { "name": "slug", @@ -2576,22 +2917,6 @@ "type": "string", "title": "Slug" } - }, - { - "name": "key", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Key" - } } ], "responses": { @@ -2618,7 +2943,7 @@ } } }, - "/admin/agents/{slug}/workbench/start": { + "/manage/agents/{slug}/workbench/start": { "post": { "tags": [ "admin", @@ -2626,12 +2951,7 @@ ], "summary": "Workbench Start Job", "description": "Start a job from the workbench \u2014 no Studio communication.", - "operationId": "workbench_start_job_admin_agents__slug__workbench_start_post", - "security": [ - { - "APIKeyHeader": [] - } - ], + "operationId": "workbench_start_job_manage_agents__slug__workbench_start_post", "parameters": [ { "name": "slug", @@ -2641,22 +2961,6 @@ "type": "string", "title": "Slug" } - }, - { - "name": "key", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Key" - } } ], "responses": { @@ -2681,7 +2985,7 @@ } } }, - "/admin/agents/{slug}/workbench/jobs/{job_id}": { + "/manage/agents/{slug}/workbench/jobs/{job_id}": { "get": { "tags": [ "admin", @@ -2689,12 +2993,7 @@ ], "summary": "Workbench Job Monitor", "description": "HTMX partial \u2014 returns execution monitor HTML for polling.", - "operationId": "workbench_job_monitor_admin_agents__slug__workbench_jobs__job_id__get", - "security": [ - { - "APIKeyHeader": [] - } - ], + "operationId": "workbench_job_monitor_manage_agents__slug__workbench_jobs__job_id__get", "parameters": [ { "name": "slug", @@ -2713,22 +3012,6 @@ "type": "string", "title": "Job Id" } - }, - { - "name": "key", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Key" - } } ], "responses": { @@ -2755,7 +3038,7 @@ } } }, - "/admin/agents/{slug}/workbench/jobs/{job_id}/stop": { + "/manage/agents/{slug}/workbench/jobs/{job_id}/stop": { "post": { "tags": [ "admin", @@ -2763,12 +3046,7 @@ ], "summary": "Workbench Stop Job", "description": "Stop a running job.", - "operationId": "workbench_stop_job_admin_agents__slug__workbench_jobs__job_id__stop_post", - "security": [ - { - "APIKeyHeader": [] - } - ], + "operationId": "workbench_stop_job_manage_agents__slug__workbench_jobs__job_id__stop_post", "parameters": [ { "name": "slug", @@ -2787,22 +3065,6 @@ "type": "string", "title": "Job Id" } - }, - { - "name": "key", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Key" - } } ], "responses": { @@ -2827,7 +3089,7 @@ } } }, - "/admin/agents/{slug}/workbench/jobs/{job_id}/poll": { + "/manage/agents/{slug}/workbench/jobs/{job_id}/poll": { "post": { "tags": [ "admin", @@ -2835,12 +3097,7 @@ ], "summary": "Workbench Poll Job", "description": "Trigger manual poll for external updates on a job.", - "operationId": "workbench_poll_job_admin_agents__slug__workbench_jobs__job_id__poll_post", - "security": [ - { - "APIKeyHeader": [] - } - ], + "operationId": "workbench_poll_job_manage_agents__slug__workbench_jobs__job_id__poll_post", "parameters": [ { "name": "slug", @@ -2859,22 +3116,6 @@ "type": "string", "title": "Job Id" } - }, - { - "name": "key", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Key" - } } ], "responses": { @@ -2899,7 +3140,7 @@ } } }, - "/admin/agents/{slug}/workbench/jobs/{job_id}/status": { + "/manage/agents/{slug}/workbench/jobs/{job_id}/status": { "get": { "tags": [ "admin", @@ -2907,12 +3148,7 @@ ], "summary": "Workbench Job Status", "description": "Get job status via agent's job_status method.", - "operationId": "workbench_job_status_admin_agents__slug__workbench_jobs__job_id__status_get", - "security": [ - { - "APIKeyHeader": [] - } - ], + "operationId": "workbench_job_status_manage_agents__slug__workbench_jobs__job_id__status_get", "parameters": [ { "name": "slug", @@ -2931,22 +3167,6 @@ "type": "string", "title": "Job Id" } - }, - { - "name": "key", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Key" - } } ], "responses": { @@ -2971,7 +3191,7 @@ } } }, - "/admin/agents/{slug}/workbench/jobs/{job_id}/cases/{case_id}/answer": { + "/manage/agents/{slug}/workbench/jobs/{job_id}/cases/{case_id}/answer": { "post": { "tags": [ "admin", @@ -2979,12 +3199,7 @@ ], "summary": "Workbench Answer Hitl", "description": "Submit HITL answer \u2014 two-step dispatch (receive + invoke human_answer).", - "operationId": "workbench_answer_hitl_admin_agents__slug__workbench_jobs__job_id__cases__case_id__answer_post", - "security": [ - { - "APIKeyHeader": [] - } - ], + "operationId": "workbench_answer_hitl_manage_agents__slug__workbench_jobs__job_id__cases__case_id__answer_post", "parameters": [ { "name": "slug", @@ -3012,22 +3227,6 @@ "type": "string", "title": "Case Id" } - }, - { - "name": "key", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Key" - } } ], "responses": { @@ -3052,7 +3251,7 @@ } } }, - "/admin/agents/{slug}/workbench/jobs/{job_id}/steps/{case_id}/{step_index}/execute": { + "/manage/agents/{slug}/workbench/jobs/{job_id}/steps/{case_id}/{step_index}/execute": { "post": { "tags": [ "admin", @@ -3060,12 +3259,7 @@ ], "summary": "Workbench Execute Step", "description": "Execute a scheduled step immediately.", - "operationId": "workbench_execute_step_admin_agents__slug__workbench_jobs__job_id__steps__case_id___step_index__execute_post", - "security": [ - { - "APIKeyHeader": [] - } - ], + "operationId": "workbench_execute_step_manage_agents__slug__workbench_jobs__job_id__steps__case_id___step_index__execute_post", "parameters": [ { "name": "slug", @@ -3102,22 +3296,6 @@ "type": "integer", "title": "Step Index" } - }, - { - "name": "key", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Key" - } } ], "responses": { @@ -3142,7 +3320,7 @@ } } }, - "/admin/agents/{slug}/workbench/jobs/{job_id}/steps/{case_id}/{step_index}/cancel": { + "/manage/agents/{slug}/workbench/jobs/{job_id}/steps/{case_id}/{step_index}/cancel": { "post": { "tags": [ "admin", @@ -3150,12 +3328,7 @@ ], "summary": "Workbench Cancel Step", "description": "Cancel a pending scheduled step.", - "operationId": "workbench_cancel_step_admin_agents__slug__workbench_jobs__job_id__steps__case_id___step_index__cancel_post", - "security": [ - { - "APIKeyHeader": [] - } - ], + "operationId": "workbench_cancel_step_manage_agents__slug__workbench_jobs__job_id__steps__case_id___step_index__cancel_post", "parameters": [ { "name": "slug", @@ -3192,22 +3365,6 @@ "type": "integer", "title": "Step Index" } - }, - { - "name": "key", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Key" - } } ], "responses": { @@ -3232,7 +3389,7 @@ } } }, - "/admin/agents/{slug}/workbench/jobs/{job_id}/steps/{case_id}/{step_index}/schedule": { + "/manage/agents/{slug}/workbench/jobs/{job_id}/steps/{case_id}/{step_index}/schedule": { "patch": { "tags": [ "admin", @@ -3240,12 +3397,7 @@ ], "summary": "Workbench Reschedule Step", "description": "Reschedule a pending scheduled step.", - "operationId": "workbench_reschedule_step_admin_agents__slug__workbench_jobs__job_id__steps__case_id___step_index__schedule_patch", - "security": [ - { - "APIKeyHeader": [] - } - ], + "operationId": "workbench_reschedule_step_manage_agents__slug__workbench_jobs__job_id__steps__case_id___step_index__schedule_patch", "parameters": [ { "name": "slug", @@ -3282,22 +3434,6 @@ "type": "integer", "title": "Step Index" } - }, - { - "name": "key", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Key" - } } ], "responses": { @@ -3322,7 +3458,7 @@ } } }, - "/admin/agents/{slug}/workbench/console": { + "/manage/agents/{slug}/workbench/console": { "get": { "tags": [ "admin", @@ -3330,12 +3466,7 @@ ], "summary": "Workbench Console", "description": "HTMX partial \u2014 returns recent console log entries.", - "operationId": "workbench_console_admin_agents__slug__workbench_console_get", - "security": [ - { - "APIKeyHeader": [] - } - ], + "operationId": "workbench_console_manage_agents__slug__workbench_console_get", "parameters": [ { "name": "slug", @@ -3345,22 +3476,6 @@ "type": "string", "title": "Slug" } - }, - { - "name": "key", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Key" - } } ], "responses": { @@ -3387,7 +3502,7 @@ } } }, - "/admin/agents/{slug}/workbench/jobs": { + "/manage/agents/{slug}/workbench/jobs": { "get": { "tags": [ "admin", @@ -3395,12 +3510,7 @@ ], "summary": "Workbench Jobs List", "description": "HTMX partial \u2014 returns job history list.", - "operationId": "workbench_jobs_list_admin_agents__slug__workbench_jobs_get", - "security": [ - { - "APIKeyHeader": [] - } - ], + "operationId": "workbench_jobs_list_manage_agents__slug__workbench_jobs_get", "parameters": [ { "name": "slug", @@ -3410,22 +3520,6 @@ "type": "string", "title": "Slug" } - }, - { - "name": "key", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Key" - } } ], "responses": { @@ -3451,24 +3545,6 @@ } } } - }, - "/": { - "get": { - "summary": "Home Page", - "operationId": "home_page__get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "text/html": { - "schema": { - "type": "string" - } - } - } - } - } - } } }, "components": { @@ -3561,6 +3637,19 @@ "description": "Whether the method is asynchronous", "default": false }, + "timeout": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Timeout", + "description": "Maximum automatic job duration in seconds. Use None for jobs that must run until Studio stops them manually.", + "default": 600 + }, "nodes": { "anyOf": [ { @@ -3668,18 +3757,6 @@ "title": "Required", "description": "Whether field is required for form submission", "default": false - }, - "dynamic_choices": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Dynamic Choices", - "description": "Key name for dynamic choices resolved at runtime via Agent.dynamic_choices_callback. Mutually exclusive with 'choices'." } }, "type": "object", @@ -3867,6 +3944,17 @@ "type": "string", "title": "Version" }, + "release_notes_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Release Notes Url" + }, "api_path": { "type": "string", "title": "Api Path" @@ -3970,7 +4058,7 @@ "title": "AgentResponse", "description": "Response model for agent endpoints - values provided by Agent.registration_info" }, - "Body_server_update_agent_supervaizer_agents_competitor_summary_parameters_post": { + "Body_server_update_agent_api_supervaizer_agents_competitor_summary_parameters_post": { "properties": { "onboarding_status": { "anyOf": [ @@ -3996,7 +4084,7 @@ } }, "type": "object", - "title": "Body_server_update_agent_supervaizer_agents_competitor_summary_parameters_post" + "title": "Body_server_update_agent_api_supervaizer_agents_competitor_summary_parameters_post" }, "CaseNode": { "properties": { @@ -4125,7 +4213,7 @@ "type": "string", "format": "date-time", "title": "Timestamp", - "default": "2026-04-09T00:25:26.894055" + "default": "2026-05-15T19:28:48.909572" }, "status_code": { "type": "integer", @@ -4285,6 +4373,12 @@ "type": "array", "title": "Case Ids", "default": [] + }, + "metadata": { + "additionalProperties": true, + "type": "object", + "title": "Metadata", + "description": "Agent-provided domain metadata (e.g. campaign context)" } }, "type": "object", @@ -4466,6 +4560,35 @@ ], "title": "JobResponse" }, + "RegistrationRefreshRequest": { + "properties": { + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reason" + }, + "requested_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Requested At" + } + }, + "type": "object", + "title": "RegistrationRefreshRequest", + "description": "Request model for re-sending the server registration event." + }, "ValidationError": { "properties": { "loc": { @@ -4506,13 +4629,6 @@ ], "title": "ValidationError" } - }, - "securitySchemes": { - "APIKeyHeader": { - "type": "apiKey", - "in": "header", - "name": "X-API-Key" - } } } -} +} \ No newline at end of file diff --git a/docs/model_reference/model_core.md b/docs/model_reference/model_core.md index 68b82d4..163ce8d 100644 --- a/docs/model_reference/model_core.md +++ b/docs/model_reference/model_core.md @@ -1,6 +1,6 @@ # Model Reference Core -**Version:** 0.13.0 +**Version:** 0.20.1 ### `account.Account` @@ -116,6 +116,7 @@ _No additional fields beyond parent class._ | `maintainer` | `str` | `None` | Maintainer of the integration | | `editor` | `str` | `None` | Editor (usually a company) | | `version` | `str` | '' | Version string | +| `release_notes_url` | `str` | `None` | URL for release notes matching this agent version | | `description` | `str` | '' | Description of what the agent does | | `tags` | `list[str]` | `None` | Tags for categorizing the agent | | `methods` | `AgentMethods` | `None` | Methods supported by this agent | @@ -127,8 +128,9 @@ _No additional fields beyond parent class._ | `max_execution_time` | `int` | 3600 | Maximum execution time in seconds, defaults to 1 hour | | `supervaize_instructions_template_path` | `str` | `None` | Optional path to a custom template file for supervaize_instructions.html page | | `instructions_path` | `str` | 'supervaize_instructions.html' | Path where the supervaize instructions page is served (relative to agent path) | -| `custom_routes` | `Any` | `None` | Optional FastAPI APIRouter; mounted at `/api/agents/{slug}/...` on the API surface | -| `dynamic_choices_callback` | `Any` | `None` | Callable that returns dynamic choices for method fields. Signature: (method_name: str, context: dict) -> dict[str, list[tuple[str, str]]] | +| `custom_routes` | `Any` | `None` | Optional FastAPI APIRouter; mounted on the API app at /api/agents/{slug}/... | +| `data_resources` | `list[data_resource.DataResource]` | — | Data resources this agent exposes for Studio CRUD access | +| `supervaizer_v2_registration` | `SupervaizerV2AgentRegistrationContract` | `None` | Optional Supervaizer v2 registration contract for A2A/A2UI Studio integrations | ### `agent.AgentMethod` @@ -185,6 +187,7 @@ Attributes: | `fields` | `typing.List[supervaizer.agent.AgentMethodField]` | `None` | A list of field specifications for generating forms/UI, following the django.forms.fields definition | | `description` | `str` | `None` | Optional description of what the method does | | `is_async` | `bool` | False | Whether the method is asynchronous | +| `timeout` | `int` | 600 | Maximum automatic job duration in seconds. Use None for jobs that must run until Studio stops them manually. | | `nodes` | `CaseNodes` | `None` | The definition of the Case Nodes (=steps) for this method | #### Example @@ -242,7 +245,6 @@ field definitions for consistency. | `default` | `Any` | `None` | Default value for the field - displayed in the UI | | `widget` | `str` | `None` | UI widget to use (e.g. RadioSelect, TextInput) - as a django widget name | | `required` | `bool` | False | Whether field is required for form submission | -| `dynamic_choices` | `str` | `None` | Key name for dynamic choices resolved at runtime via Agent.dynamic_choices_callback. Mutually exclusive with 'choices'. | #### Examples @@ -440,4 +442,4 @@ public_url: full url (including scheme and port) to use for outbound connections ``` -*Uploaded on 2026-04-09 00:25:26* +*Uploaded on 2026-05-15 19:28:48* \ No newline at end of file diff --git a/docs/model_reference/model_extra.md b/docs/model_reference/model_extra.md index be0810c..415a030 100644 --- a/docs/model_reference/model_extra.md +++ b/docs/model_reference/model_extra.md @@ -1,6 +1,6 @@ # Model Reference extra -**Version:** 0.13.0 +**Version:** 0.20.1 ### `common.SvBaseModel` @@ -86,6 +86,7 @@ Response model for agent endpoints - values provided by Agent.registration_info | `maintainer` | `str` | `None` | | | `editor` | `str` | `None` | | | `version` | `str` | **required** | | +| `release_notes_url` | `str` | `None` | | | `api_path` | `str` | **required** | | | `description` | `str` | **required** | | | `tags` | `list[str]` | `None` | | @@ -106,6 +107,48 @@ Response model for agent endpoints - values provided by Agent.registration_info |---|---|---|---| | `nodes` | `List[case.CaseNode]` | [] | | +### `data_resource.DataResource` + +**Inherits from:** [`common.SvBaseModel`](#commonsvbasemodel) + +Declares a named data resource the agent exposes for Studio CRUD access. + +The agent provides callback functions for each operation. The SDK generates +the corresponding FastAPI routes automatically. + +Example:: + + contacts_resource = DataResource( + name="contacts", + display_name="Contacts", + fields=[ + DataResourceField(name="id", editable=Editable.NEVER, visible_on=["list", "detail"]), + DataResourceField(name="email", field_type=FieldType.EMAIL, required=True), + ], + on_list=lambda: repo.list_all(), + on_get=lambda item_id: repo.get(item_id), + on_create=lambda data: repo.create(data), + on_update=lambda item_id, data: repo.update(item_id, data), + on_delete=lambda item_id: repo.delete(item_id), + ) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | `str` | **required** | URL-safe resource identifier, e.g. 'contacts'. Lowercase letters, digits, underscores, and hyphens only; must start with a letter or digit. | +| `display_name` | `str` | '' | | +| `description` | `str` | '' | | +| `fields` | `list[data_resource.DataResourceField]` | — | | +| `read_only` | `bool` | False | | +| `importable` | `bool` | False | Enables CSV bulk import route | +| `on_list` | `typing.Callable[..., list[dict[str, typing.Any]]]` | `None` | | +| `on_get` | `typing.Callable[..., dict[str, typing.Any] | None]` | `None` | | +| `on_create` | `typing.Callable[..., dict[str, typing.Any]]` | `None` | | +| `on_update` | `typing.Callable[..., dict[str, typing.Any] | None]` | `None` | | +| `on_delete` | `typing.Callable[..., bool]` | `None` | | +| `on_import` | `typing.Callable[..., dict[str, typing.Any]]` | `None` | | + ### `job.Job` **Inherits from:** [`job.AbstractJob`](#jobabstractjob) @@ -164,6 +207,23 @@ _No additional fields beyond parent class._ _No additional fields beyond parent class._ +### `contracts.SupervaizerV2AgentRegistrationContract` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `supervaizer_contract_version` | `Literal[2]` | 2 | | +| `agent` | `V2AgentIdentity` | **required** | | +| `versions` | `V2ProtocolVersions` | **required** | | +| `a2a` | `V2A2AController` | **required** | | +| `capabilities` | `V2AgentCapabilities` | — | | +| `job_policy` | `V2JobPolicy` | — | | +| `resources` | `list[contracts.V2ResourceDefinition]` | — | | +| `datasets` | `list[contracts.V2DatasetDefinition]` | — | | + ### `case.Case` **Inherits from:** [`case.CaseAbstractModel`](#casecaseabstractmodel) @@ -188,6 +248,7 @@ _No additional fields beyond parent class._ | `total_cost` | `float` | 0.0 | | | `final_delivery` | `typing.Dict[str, typing.Any]` | `None` | | | `finished_at` | `datetime` | `None` | | +| `metadata` | `Dict[str, Any]` | — | Agent-provided domain metadata (e.g. contact context) | ### `case.CaseNode` @@ -222,12 +283,688 @@ Returns: | `name` | `str` | `None` | | | `payload` | `typing.Dict[str, typing.Any]` | `None` | | | `is_final` | `bool` | False | | +| `upsert` | `bool` | False | | | `error` | `str` | `None` | | | `scheduled_at` | `datetime` | `None` | | | `scheduled_method` | `str` | `None` | | | `scheduled_params` | `typing.Dict[str, typing.Any]` | `None` | | | `scheduled_status` | `str` | `None` | | +### `contracts.AgentMethodContract` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | `str` | **required** | | +| `method` | `str` | **required** | | +| `params` | `dict[str, typing.Any]` | `None` | | +| `fields` | `list[supervaizer.contracts.AgentMethodFieldContract | dict[str, typing.Any]]` | `None` | | +| `description` | `str` | `None` | | +| `is_async` | `bool` | False | | +| `timeout` | `int` | 600 | | +| `nodes` | `dict[str, typing.Any]` | `None` | | + +### `contracts.AgentMethodFieldContract` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | `str` | **required** | | +| `type` | `str` | `None` | | +| `field_type` | `str` | 'CharField' | | +| `description` | `str` | `None` | | +| `choices` | `list[typing.Any]` | `None` | | +| `default` | `Any` | `None` | | +| `widget` | `str` | `None` | | +| `required` | `bool` | False | | + +### `contracts.AgentMethodsContract` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `job_start` | `AgentMethodContract` | **required** | | +| `job_stop` | `AgentMethodContract` | `None` | | +| `job_status` | `AgentMethodContract` | `None` | | +| `job_poll` | `AgentMethodContract` | `None` | | +| `human_answer` | `AgentMethodContract` | `None` | | +| `chat` | `AgentMethodContract` | `None` | | +| `custom` | `dict[str, supervaizer.contracts.AgentMethodContract]` | `None` | | + +### `contracts.AgentRegistrationContract` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +Minimal schema for agent registration payloads consumed by Studio. + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `id` | `str` | `None` | | +| `slug` | `str` | **required** | | +| `name` | `str` | **required** | | +| `api_path` | `str` | **required** | | +| `release_notes_url` | `str` | `None` | | +| `methods` | `AgentMethodsContract` \| `dict[str, typing.Any]` | — | | +| `parameters_setup` | `list[dict[str, Any]]` | — | | +| `data_resources` | `list[contracts.DataResourceContract | dict[str, Any]]` | — | | + +### `contracts.CaseUpdateEvent` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | `str` | **required** | | +| `payload` | `dict[str, Any]` | — | | +| `cost` | `float` | 0.0 | | +| `index` | `int` | `None` | | +| `is_final` | `bool` | False | | + +### `contracts.CaseUpdateRequest` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `answer` | `dict[str, Any]` | **required** | | +| `message` | `str` | `None` | | + +### `contracts.ContractModel` + +Base class for SDK-owned wire contract models. + +_No fields found._ + +### `contracts.ControllerContract` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +Canonical controller surface advertised by a Supervaizer server. + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `controller_contract_version` | `str` | '1.0' | | +| `api_base_path` | `str` | '/api' | | +| `endpoints` | `dict[str, str]` | — | | + +### `contracts.DataResourceContextContract` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `workspace_id` | `str` | `None` | | +| `workspace_slug` | `str` | `None` | | +| `mission_id` | `str` | `None` | | +| `agent_slug` | `str` | `None` | | +| `request_id` | `str` | `None` | | + +### `contracts.DataResourceContract` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | `str` | **required** | | +| `display_name` | `str` | **required** | | +| `description` | `str` | '' | | +| `fields` | `list[contracts.DataResourceFieldContract]` | — | | +| `read_only` | `bool` | False | | +| `importable` | `bool` | False | | +| `operations` | `dict[str, bool]` | — | | + +### `contracts.DataResourceFieldContract` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | `str` | **required** | | +| `field_type` | `str` | 'string' | | +| `label` | `str` | `None` | | +| `required` | `bool` | False | | +| `editable` | `str` | 'always' | | +| `visible_on` | `list[str]` | — | | +| `description` | `str` | `None` | | +| `related_resource` | `str` | `None` | | +| `sensitive` | `bool` | False | | +| `display_label` | `str` | `None` | | + +### `contracts.DataResourceListResponse` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +Structured response shape for DataResource list operations. + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `items` | `list[dict[str, Any]]` | — | | + +### `contracts.JobStartRequest` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `job_context` | `dict[str, Any]` | **required** | | +| `job_fields` | `dict[str, Any]` | — | | +| `encrypted_agent_parameters` | `str` | `None` | | + +### `contracts.ServerRegistrationContract` + +**Inherits from:** [`contracts.ControllerContract`](#contractscontrollercontract) + +Minimal schema for server.register details. + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `server_id` | `str` | **required** | | +| `url` | `str` | **required** | | +| `uri` | `str` | **required** | | +| `api_version` | `str` | **required** | | +| `environment` | `str` | `None` | | +| `agents` | `list[contracts.AgentRegistrationContract]` | — | | + +### `contracts.V2A2AController` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `agent_card_url` | `str` | **required** | | +| `controller_url` | `str` | **required** | | +| `transport` | `V2A2ATransport` | — | | +| `external_interop` | `V2A2AExternalInterop` | — | | + +### `contracts.V2A2AExternalInterop` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `inbound_tasks` | `bool` | False | | +| `outbound_delegation` | `bool` | False | | + +### `contracts.V2A2ATransport` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `json_rpc` | `bool` | True | | +| `sse` | `bool` | True | | +| `push_notifications` | `bool` | False | | + +### `contracts.V2ActionRequest` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `request_id` | `str` | **required** | | +| `actor` | `V2ActorContext` | **required** | | +| `workspace` | `V2WorkspaceContext` | **required** | | +| `mission_id` | `str` | **required** | | +| `agent_slug` | `str` | **required** | | +| `surface` | `str` | **required** | | +| `action` | `str` | **required** | | +| `input` | `dict[str, Any]` | — | | +| `idempotency_key` | `str` | `None` | | +| `draft_session_id` | `str` | `None` | | +| `job_id` | `str` | `None` | | +| `case_id` | `str` | `None` | | +| `step_id` | `str` | `None` | | + +### `contracts.V2ActionResult` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `status` | `Literal['ok', 'error']` | **required** | | +| `effects` | `list[contracts.V2Effect]` | — | | + +### `contracts.V2ActorContext` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `user_id` | `str` | **required** | | + +### `contracts.V2AgentCapabilities` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `surfaces` | `list[str]` | — | | +| `actions` | `list[str]` | — | | +| `case_lanes` | `list[contracts.V2CaseLaneDefinition]` | — | | +| `artifact_types` | `list[contracts.V2ArtifactTypeDefinition]` | — | | + +### `contracts.V2AgentIdentity` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `id` | `str` | **required** | | +| `slug` | `str` | **required** | | +| `display_name` | `str` | **required** | | + +### `contracts.V2ArtifactRef` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `id` | `str` | **required** | | +| `type` | `str` | **required** | | +| `title` | `str` | `None` | | +| `external_id` | `str` | `None` | | +| `media_type` | `str` | `None` | | + +### `contracts.V2ArtifactTypeDefinition` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `type` | `str` | **required** | | +| `label` | `str` | **required** | | +| `renderer_surface` | `str` | `None` | | + +### `contracts.V2AwaitingFieldDefinition` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `id` | `str` | **required** | | +| `label` | `str` | **required** | | +| `type` | `str` | 'boolean' | | +| `required` | `bool` | False | | + +### `contracts.V2AwaitingState` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `reason` | `str` | **required** | | +| `surface` | `str` | **required** | | +| `action` | `str` | **required** | | +| `fields` | `list[contracts.V2AwaitingFieldDefinition]` | — | | + +### `contracts.V2CaseLaneDefinition` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `id` | `str` | **required** | | +| `label` | `str` | **required** | | +| `default` | `bool` | False | | + +### `contracts.V2CaseSnapshot` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `id` | `str` | **required** | | +| `lane` | `str` | 'work' | | +| `title` | `str` | `None` | | +| `status` | `str` | `None` | | +| `external_id` | `str` | `None` | | +| `steps` | `list[contracts.V2StepSnapshot]` | — | | + +### `contracts.V2DatasetDefinition` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `id` | `str` | **required** | | +| `label` | `str` | **required** | | +| `auto_surface` | `bool` | False | | + +### `contracts.V2Effect` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `type` | `str` | **required** | | + +### `contracts.V2JobPolicy` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `default_timeout_seconds` | `int` | `None` | | +| `offline_start_policy` | `Literal['block']` | 'block' | | +| `offline_running_policy` | `Literal['fail_in_studio']` | 'fail_in_studio' | | +| `sync` | `V2JobSyncPolicy` | `None` | | + +### `contracts.V2JobSnapshot` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `id` | `str` | **required** | | +| `agent_slug` | `str` | **required** | | +| `mission_id` | `str` | **required** | | +| `status` | `str` | **required** | | +| `source` | `V2JobSource` | **required** | | + +### `contracts.V2JobSource` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `type` | `Literal['fresh_start', 'external']` | **required** | | +| `external_ref` | `str` | `None` | | +| `previous_job_id` | `str` | `None` | | +| `target_type` | `str` | `None` | | + +### `contracts.V2JobStateSnapshot` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `job` | `V2JobSnapshot` | **required** | | +| `cases` | `list[contracts.V2CaseSnapshot]` | — | | + +### `contracts.V2JobSyncPolicy` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `action` | `str` | 'job.sync' | | +| `supported_statuses` | `list[str]` | — | | + +### `contracts.V2JobSyncResult` + +**Inherits from:** [`contracts.V2ActionResult`](#contractsv2actionresult) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `external_ref` | `str` | `None` | | +| `external_version` | `str` | `None` | | +| `sync_cursor` | `str` | `None` | | +| `observed_at` | `str` | `None` | | +| `job_state` | `V2JobStateSnapshot` | `None` | | + +### `contracts.V2MountedResourceViewDefinition` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `view` | `str` | **required** | | +| `surface` | `str` | **required** | | + +### `contracts.V2ProtocolVersions` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `a2ui_version` | `str` | **required** | | +| `a2ui_catalog_version` | `str` | **required** | | +| `a2a_version` | `str` | **required** | | +| `ag_ui_version` | `str` | `None` | | + +### `contracts.V2ReplaySafetyMetadata` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `dedupe_keys` | `list[str]` | — | | +| `stable_external_ids_required` | `bool` | True | | +| `strictly_idempotent_response` | `bool` | False | | +| `convergent` | `bool` | True | | + +### `contracts.V2ResourceDefinition` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `id` | `str` | **required** | | +| `label` | `str` | **required** | | +| `auto_surface` | `bool` | False | | +| `operations` | `list[str]` | — | | +| `display` | `V2ResourceDisplayDefinition` | `None` | | +| `fields` | `list[contracts.V2ResourceFieldDefinition]` | — | | +| `mounted_views` | `list[contracts.V2MountedResourceViewDefinition]` | — | | + +### `contracts.V2ResourceDisplayDefinition` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `title_field` | `str` | `None` | | +| `columns` | `list[str]` | — | | +| `search_fields` | `list[str]` | — | | + +### `contracts.V2ResourceFieldDefinition` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `id` | `str` | **required** | | +| `label` | `str` | **required** | | +| `type` | `str` | 'string' | | +| `required` | `bool` | False | | +| `read_only` | `bool` | False | | +| `multiline` | `bool` | False | | +| `options_source` | `V2ResourceFieldOptionsSource` | `None` | | + +### `contracts.V2ResourceFieldOptionsSource` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `type` | `Literal['resource']` | 'resource' | | +| `resource` | `str` | **required** | | +| `value_field` | `str` | 'id' | | +| `label_field` | `str` | `None` | | + +### `contracts.V2StepSnapshot` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `id` | `str` | **required** | | +| `activity` | `Literal['operation', 'delegation']` | **required** | | +| `status` | `str` | **required** | | +| `title` | `str` | `None` | | +| `external_id` | `str` | `None` | | +| `awaiting` | `V2AwaitingState` | `None` | | +| `outputs` | `list[contracts.V2ArtifactRef]` | — | | + +### `contracts.V2SurfaceRequest` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `request_id` | `str` | **required** | | +| `actor` | `V2ActorContext` | **required** | | +| `workspace` | `V2WorkspaceContext` | **required** | | +| `mission_id` | `str` | **required** | | +| `agent_slug` | `str` | **required** | | +| `surface` | `str` | **required** | | +| `input` | `dict[str, Any]` | — | | +| `draft_session_id` | `str` | `None` | | +| `job_id` | `str` | `None` | | +| `case_id` | `str` | `None` | | +| `step_id` | `str` | `None` | | + +### `contracts.V2SurfaceResult` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `surface` | `str` | **required** | | +| `a2ui_version` | `str` | `None` | | +| `a2ui_catalog_version` | `str` | `None` | | +| `document` | `dict[str, Any]` | — | | + +### `contracts.V2WorkspaceContext` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `id` | `str` | **required** | | +| `slug` | `str` | `None` | | + +### `data_resource.DataResourceContext` + +**Inherits from:** [`common.SvBaseModel`](#commonsvbasemodel) + +Studio request context passed to DataResource callbacks. + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `workspace_id` | `str` | `None` | | +| `workspace_slug` | `str` | `None` | | +| `mission_id` | `str` | `None` | | +| `agent_slug` | `str` | **required** | | +| `request_id` | `str` | `None` | | + +### `data_resource.DataResourceField` + +**Inherits from:** [`common.SvBaseModel`](#commonsvbasemodel) + +Describes a single field in a DataResource for Studio rendering. + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | `str` | **required** | Column/attribute name | +| `field_type` | `` | `string` | One of: string, integer, boolean, date, datetime, text, email, url | +| `label` | `str` | `None` | Human-readable label; defaults to name.title() | +| `required` | `bool` | False | Required on create form | +| `editable` | `` | `always` | | +| `visible_on` | `list[str]` | — | Views that render this field: list, detail, create, edit | +| `description` | `str` | `None` | Help text shown in Studio | +| `related_resource` | `str` | `None` | Name of another DataResource this field FK-references | +| `sensitive` | `bool` | False | True when Studio should mask this field for non-manager users | + ### `deploy.drivers.base.DeploymentPlan` Deployment plan containing all actions to be taken. @@ -420,6 +1157,7 @@ _No additional fields beyond parent class._ | `created_at` | `datetime` | `None` | | | `agent_parameters` | `list[dict[str, typing.Any]]` | `None` | | | `case_ids` | `list[str]` | [] | | +| `metadata` | `dict[str, Any]` | — | Agent-provided domain metadata (e.g. campaign context) | ### `job.JobInstructions` @@ -436,6 +1174,44 @@ _No additional fields beyond parent class._ | `stop_on_error` | `bool` | True | | | `job_start_time` | `float` | `None` | | +### `protocol.a2a.controller.JsonRpcError` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `code` | `int` | **required** | | +| `message` | `str` | **required** | | +| `data` | `dict[str, typing.Any]` | `None` | | + +### `protocol.a2a.controller.JsonRpcRequest` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `jsonrpc` | `Literal['2.0']` | '2.0' | | +| `id` | `str` \| `int` | `None` | | +| `method` | `str` | **required** | | +| `params` | `dict[str, Any]` | — | | + +### `protocol.a2a.controller.JsonRpcResponse` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `jsonrpc` | `Literal['2.0']` | '2.0' | | +| `id` | `str` \| `int` | `None` | | +| `result` | `dict[str, typing.Any]` | `None` | | +| `error` | `JsonRpcError` | `None` | | + ### `routes.CaseUpdateRequest` **Inherits from:** [`common.SvBaseModel`](#commonsvbasemodel) @@ -446,7 +1222,7 @@ Request model for updating a case with answer to a question. | Field | Type | Default | Description | |---|---|---|---| -| `answer` | `Dict[str, Any]` | **required** | | +| `answer` | `dict[str, Any]` | **required** | | | `message` | `str` | `None` | | ### `server_utils.ErrorResponse` @@ -458,9 +1234,22 @@ Standard error response model | `error` | `str` | **required** | | | `error_type` | `` | **required** | | | `detail` | `str` | `None` | | -| `timestamp` | `datetime` | datetime.datetime(2026, 4, 9, 0, 25, 25, 819185) | | +| `timestamp` | `datetime` | datetime.datetime(2026, 5, 15, 19, 28, 48, 205222) | | | `status_code` | `int` | **required** | | +### `routes.RegistrationRefreshRequest` + +**Inherits from:** [`common.SvBaseModel`](#commonsvbasemodel) + +Request model for re-sending the server registration event. + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `reason` | `str` | `None` | | +| `requested_at` | `str` | `None` | | + ### `server.ServerInfo` Complete server information for storage. @@ -490,4 +1279,4 @@ A base class for creating Pydantic models. | `details` | `Dict[str, Any]` | **required** | | -*Uploaded on 2026-04-09 00:25:26* +*Uploaded on 2026-05-15 19:28:48* \ No newline at end of file diff --git a/src/supervaizer/__init__.py b/src/supervaizer/__init__.py index 6594f61..548f5b9 100644 --- a/src/supervaizer/__init__.py +++ b/src/supervaizer/__init__.py @@ -85,8 +85,6 @@ "ControllerEndpoint": ("supervaizer.contracts", "ControllerEndpoint"), "DataResourceContract": ("supervaizer.contracts", "DataResourceContract"), "DataResourceFieldContract": ("supervaizer.contracts", "DataResourceFieldContract"), - "DynamicChoicesRequest": ("supervaizer.contracts", "DynamicChoicesRequest"), - "DynamicChoicesResponse": ("supervaizer.contracts", "DynamicChoicesResponse"), "EventType": ("supervaizer.contracts", "EventType"), "JobStartRequest": ("supervaizer.contracts", "JobStartRequest"), "ServerRegistrationContract": ( diff --git a/src/supervaizer/agent.py b/src/supervaizer/agent.py index 9a70973..7861fdb 100644 --- a/src/supervaizer/agent.py +++ b/src/supervaizer/agent.py @@ -107,19 +107,16 @@ class AgentMethodField(BaseModel): required: bool = Field( default=False, description="Whether field is required for form submission" ) - dynamic_choices: str | None = Field( - default=None, - description="Key name for dynamic choices resolved at runtime via Agent.dynamic_choices_callback. Mutually exclusive with 'choices'.", - ) - @model_validator(mode="after") - def validate_choices_mutual_exclusion(self) -> "AgentMethodField": - if self.choices is not None and self.dynamic_choices is not None: + @model_validator(mode="before") + @classmethod + def reject_dynamic_choices(cls, data: Any) -> Any: + if isinstance(data, dict) and "dynamic_choices" in data: raise ValueError( - "'choices' and 'dynamic_choices' are mutually exclusive. " - "Use 'choices' for static options or 'dynamic_choices' for runtime-resolved options." + "dynamic_choices was removed in Supervaizer v2. " + "Use v2 resource option_sources or typed A2A actions for dynamic options." ) - return self + return data model_config = cast( ConfigDict, @@ -646,11 +643,6 @@ class AgentAbstract(SvBaseModel): description="Optional FastAPI APIRouter; mounted on the API app at /api/agents/{slug}/...", exclude=True, ) - dynamic_choices_callback: Any | None = Field( - default=None, - description="Callable that returns dynamic choices for method fields. Signature: (method_name: str, context: dict) -> dict[str, list[tuple[str, str]]]. Context includes workspace_id, workspace_slug, mission_id from the dynamic_choices request body.", - exclude=True, - ) data_resources: list[DataResource] = Field( default_factory=list, description="Data resources this agent exposes for Studio CRUD access", @@ -687,7 +679,6 @@ def __init__( server_encrypted_parameters: str | None = None, max_execution_time: int = 60 * 60, # 1 hour (in seconds) custom_routes: Any | None = None, - dynamic_choices_callback: Any | None = None, data_resources: list["DataResource"] | None = None, supervaizer_v2_registration: SupervaizerV2AgentRegistrationContract | dict[str, Any] @@ -722,6 +713,12 @@ def __init__( Tested in tests/test_agent.py """ + if "dynamic_choices_callback" in kwargs: + raise ValueError( + "dynamic_choices_callback was removed in Supervaizer v2. " + "Use v2 resource option_sources or typed A2A actions for dynamic options." + ) + # Validate or generate agent ID agent_id = id or shortuuid.uuid(name=name) if id is not None and id != shortuuid.uuid(name=name): @@ -747,7 +744,6 @@ def __init__( server_encrypted_parameters=server_encrypted_parameters, max_execution_time=max_execution_time, custom_routes=custom_routes, - dynamic_choices_callback=dynamic_choices_callback, data_resources=data_resources or [], supervaizer_v2_registration=supervaizer_v2_registration, **kwargs, diff --git a/src/supervaizer/contracts.py b/src/supervaizer/contracts.py index f8026a7..d68fbc8 100644 --- a/src/supervaizer/contracts.py +++ b/src/supervaizer/contracts.py @@ -47,7 +47,6 @@ class ControllerEndpoint(StrEnum): POST_AGENT_CASE_UPDATE = "POST_AGENT_CASE_UPDATE" POST_AGENT_PARAMETER_VALIDATION = "POST_AGENT_PARAMETER_VALIDATION" POST_AGENT_METHOD_FIELD_VALIDATION = "POST_AGENT_METHOD_FIELD_VALIDATION" - POST_AGENT_JOB_START_DYNAMIC_CHOICES = "POST_AGENT_JOB_START_DYNAMIC_CHOICES" DATA_RESOURCE = "DATA_RESOURCE" DATA_RESOURCE_ITEM = "DATA_RESOURCE_ITEM" DATA_RESOURCE_IMPORT = "DATA_RESOURCE_IMPORT" @@ -71,7 +70,6 @@ class ControllerEndpoint(StrEnum): ControllerEndpoint.POST_AGENT_CASE_UPDATE: "/api/supervaizer/jobs/{job_id}/cases/{case_id}/update", ControllerEndpoint.POST_AGENT_PARAMETER_VALIDATION: "/api/supervaizer/agents/{agent_slug}/validate-agent-parameters", ControllerEndpoint.POST_AGENT_METHOD_FIELD_VALIDATION: "/api/supervaizer/agents/{agent_slug}/validate-method-fields", - ControllerEndpoint.POST_AGENT_JOB_START_DYNAMIC_CHOICES: "/api/supervaizer/agents/{agent_slug}/start/dynamic_choices", ControllerEndpoint.DATA_RESOURCE: "/api/agents/{agent_slug}/data/{resource_name}/", ControllerEndpoint.DATA_RESOURCE_ITEM: "/api/agents/{agent_slug}/data/{resource_name}/{item_id}", ControllerEndpoint.DATA_RESOURCE_IMPORT: "/api/agents/{agent_slug}/data/{resource_name}/import/", @@ -148,7 +146,6 @@ class AgentMethodFieldContract(ContractModel): default: Any = None widget: str | None = None required: bool = False - dynamic_choices: str | None = None class AgentMethodContract(ContractModel): @@ -217,16 +214,6 @@ class JobStartRequest(ContractModel): encrypted_agent_parameters: str | None = None -class DynamicChoicesRequest(ContractModel): - workspace_id: str - mission_id: str - workspace_slug: str | None = None - - -class DynamicChoicesResponse(ContractModel): - choices: dict[str, list[Any]] = Field(default_factory=dict) - - class CaseUpdateEvent(ContractModel): name: str payload: dict[str, Any] = Field(default_factory=dict) diff --git a/src/supervaizer/examples/controller_template.py b/src/supervaizer/examples/controller_template.py index 622e30d..07ee3da 100644 --- a/src/supervaizer/examples/controller_template.py +++ b/src/supervaizer/examples/controller_template.py @@ -42,18 +42,6 @@ PROD_PUBLIC_URL = "https://myagent.cloud-hosting.net:8001" -def get_dynamic_choices( - method_name: str, context: dict -) -> dict[str, list[tuple[str, str]]]: - if method_name == "start": - return { - "projects": [("P1", "Project 1"), ("P2", "Project 2"), ("P3", "Project 3")], - } - return { - "projects": [("P1", "Project 1"), ("P2", "Project 2"), ("P3", "Project 3")], - } - - # Define the parameters and secrets expected by the agent agent_parameters: ParametersSetup | None = ParametersSetup.from_list([ Parameter( @@ -115,7 +103,7 @@ def get_dynamic_choices( name="List of projects", type=str, field_type="ChoiceField", - dynamic_choices="projects", + choices=[("P1", "Project 1"), ("P2", "Project 2"), ("P3", "Project 3")], required=True, ), AgentMethodField( @@ -200,7 +188,6 @@ def get_dynamic_choices( ), parameters_setup=agent_parameters, instructions_path="supervaize_instructions.html", # Path where instructions page is served - dynamic_choices_callback=get_dynamic_choices, ) # For export purposes, use dummy values if environment variables are not set diff --git a/src/supervaizer/routes.py b/src/supervaizer/routes.py index 79bfa55..a6918f0 100644 --- a/src/supervaizer/routes.py +++ b/src/supervaizer/routes.py @@ -723,50 +723,6 @@ async def validate_method_fields( ) return result - @router.post( - "/start/dynamic_choices", - summary=f"Get dynamic choices for agent: {agent.name} start method", - description="Returns dynamic choice values for fields that use dynamic_choices. Accepts workspace and mission context (including workspace slug) for contextualized choices.", - response_model=dict[str, Any], - responses={ - http_status.HTTP_200_OK: {"model": dict[str, Any]}, - http_status.HTTP_404_NOT_FOUND: {"model": ErrorResponse}, - http_status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ErrorResponse}, - }, - dependencies=[ - Depends(require_scope("write")) - ], # <-- MODIFIED: scope-enforced write - ) - @handle_route_errors() - async def get_dynamic_choices( - body_params: Any = Body(...), - agent: Agent = Depends(get_agent), - ) -> dict[str, Any]: - """Get dynamic choices for the start method fields.""" - log.info(f"📥 POST /start/dynamic_choices [Dynamic choices] {agent.name}") - - if not agent.dynamic_choices_callback: - raise HTTPException( - status_code=http_status.HTTP_404_NOT_FOUND, - detail=f"Agent {agent.name} does not have dynamic choices configured", - ) - - if body_params is None: - body_params = {} - - context = { - "workspace_id": body_params.get("workspace_id"), - "workspace_slug": body_params.get("workspace_slug"), - "mission_id": body_params.get("mission_id"), - } - - choices = await asyncio.to_thread( - agent.dynamic_choices_callback, "start", context - ) - - log.info(f"📤 Agent {agent.name}: Dynamic choices keys: {list(choices.keys())}") - return {"choices": choices} - if not agent.methods: raise ValueError(f"Agent {agent.name} has no methods defined") diff --git a/tests/test_agent.py b/tests/test_agent.py index 73b4383..01a0e19 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -930,137 +930,37 @@ def test_custom_method_key_validation_empty_dict() -> None: assert methods.custom == {} -def test_agent_method_field_dynamic_choices(): - """Test that AgentMethodField accepts dynamic_choices attribute.""" - field = AgentMethodField( - name="List of projects", - type=str, - field_type="ChoiceField", - dynamic_choices="projects", - required=True, - ) - assert field.dynamic_choices == "projects" - assert field.choices is None - - -def test_agent_method_field_dynamic_choices_default_none(): - """Test that dynamic_choices defaults to None.""" - field = AgentMethodField( - name="color", - type=str, - field_type="ChoiceField", - choices=[("R", "Red"), ("B", "Blue")], - required=True, - ) - assert field.dynamic_choices is None - - -def test_agent_method_field_dynamic_choices_mutual_exclusion(): - """Test that choices and dynamic_choices cannot both be set.""" +def test_agent_method_field_rejects_dynamic_choices() -> None: + """Supervaizer v2 routes dynamic options through resources/actions, not v1 callbacks.""" from pydantic import ValidationError - with pytest.raises(ValidationError, match="mutually exclusive"): + with pytest.raises(ValidationError, match="dynamic_choices was removed"): AgentMethodField( name="List of projects", type=str, field_type="ChoiceField", - choices=[("A", "Option A")], dynamic_choices="projects", required=True, ) -def test_agent_method_fields_definitions_includes_dynamic_choices(): - """Test that fields_definitions includes dynamic_choices in the output.""" - method = AgentMethod( - name="start", - method="my_module.start", - fields=[ - AgentMethodField( - name="Project", - type=str, - field_type="ChoiceField", - dynamic_choices="projects", - required=True, - ), - ], - description="Start", - ) - definitions = method.fields_definitions - assert len(definitions) == 1 - assert definitions[0]["dynamic_choices"] == "projects" - assert definitions[0]["choices"] is None - - -def test_agent_method_registration_info_includes_dynamic_choices(): - """Test that registration_info propagates dynamic_choices through fields.""" - method = AgentMethod( - name="start", - method="my_module.start", - fields=[ - AgentMethodField( - name="Project", - type=str, - field_type="ChoiceField", - dynamic_choices="projects", - required=True, - ), - ], - description="Start", - ) - info = method.registration_info - assert info["fields"][0]["dynamic_choices"] == "projects" - - -def test_agent_with_dynamic_choices_callback(agent_method_fixture: AgentMethod): - """Test that Agent accepts a dynamic_choices_callback callable.""" - - def my_dynamic_choices( - method_name: str, context: dict - ) -> dict[str, list[tuple[str, str]]]: - return {"projects": [("P1", "Project 1"), ("P2", "Project 2")]} - - agent = Agent( - name="dynamicAgent", - author="test", - version="1.0", - description="test agent", - methods=AgentMethods(job_start=agent_method_fixture), - dynamic_choices_callback=my_dynamic_choices, - ) - assert agent.dynamic_choices_callback is not None - result = agent.dynamic_choices_callback("start", {}) - assert result == {"projects": [("P1", "Project 1"), ("P2", "Project 2")]} - - -def test_agent_without_dynamic_choices_callback(agent_method_fixture: AgentMethod): - """Test that dynamic_choices_callback defaults to None.""" - agent = Agent( - name="staticAgent", - author="test", - version="1.0", - description="test agent", - methods=AgentMethods(job_start=agent_method_fixture), - ) - assert agent.dynamic_choices_callback is None - - -def test_agent_method_field_dynamic_choices_in_model_dump(): - """Test that dynamic_choices appears in model_dump output.""" - field = AgentMethodField( - name="Project", - type=str, - field_type="ChoiceField", - dynamic_choices="projects", - required=True, - ) - dumped = field.model_dump() - assert dumped["dynamic_choices"] == "projects" - assert dumped["choices"] is None +def test_agent_rejects_dynamic_choices_callback( + agent_method_fixture: AgentMethod, +) -> None: + """Removed v1 dynamic callbacks fail fast instead of being ignored.""" + with pytest.raises(ValueError, match="dynamic_choices_callback was removed"): + Agent( + name="dynamicAgent", + author="test", + version="1.0", + description="test agent", + methods=AgentMethods(job_start=agent_method_fixture), + dynamic_choices_callback=lambda _method_name, _context: {}, + ) -def test_agent_method_field_static_choices_no_dynamic(): - """Test that static choices field has dynamic_choices=None in model_dump.""" +def test_agent_method_field_static_choices_model_dump_has_no_dynamic_key() -> None: + """Static choices remain supported by the v1 field model.""" field = AgentMethodField( name="Color", type=str, @@ -1069,25 +969,12 @@ def test_agent_method_field_static_choices_no_dynamic(): required=True, ) dumped = field.model_dump() - assert dumped["dynamic_choices"] is None + assert "dynamic_choices" not in dumped assert dumped["choices"] == [("R", "Red"), ("B", "Blue")] -def test_agent_method_field_optional_choice_field_with_dynamic_choices(): - """Test that dynamic_choices can be set on an optional (required=False) ChoiceField.""" - field = AgentMethodField( - name="Items", - type=str, - field_type="ChoiceField", - dynamic_choices="items", - required=False, - ) - assert field.dynamic_choices == "items" - assert field.field_type == "ChoiceField" - - -def test_agent_method_mixed_static_and_dynamic_fields(): - """Test a method with both static and dynamic choice fields.""" +def test_agent_method_fields_definitions_exclude_dynamic_choices() -> None: + """Registration metadata no longer advertises v1 dynamic option keys.""" method = AgentMethod( name="start", method="my_module.start", @@ -1099,13 +986,6 @@ def test_agent_method_mixed_static_and_dynamic_fields(): choices=[("A", "Alpha"), ("B", "Beta")], required=True, ), - AgentMethodField( - name="Project", - type=str, - field_type="ChoiceField", - dynamic_choices="projects", - required=True, - ), AgentMethodField( name="Name", type=str, @@ -1117,64 +997,7 @@ def test_agent_method_mixed_static_and_dynamic_fields(): ) defs = method.fields_definitions assert defs[0]["choices"] == [("A", "Alpha"), ("B", "Beta")] - assert defs[0]["dynamic_choices"] is None - assert defs[1]["choices"] is None - assert defs[1]["dynamic_choices"] == "projects" - assert defs[2]["dynamic_choices"] is None - - -def test_agent_dynamic_choices_callback_dispatches_by_method_name( - agent_method_fixture: AgentMethod, -): - """Test that the callback receives the method name and can return different results per method.""" - - def my_dynamic_choices( - method_name: str, context: dict - ) -> dict[str, list[tuple[str, str]]]: - if method_name == "start": - return {"projects": [("P1", "Project 1")]} - return {} - - agent = Agent( - name="emptyCallbackAgent", - author="test", - version="1.0", - description="test", - methods=AgentMethods(job_start=agent_method_fixture), - dynamic_choices_callback=my_dynamic_choices, - ) - assert agent.dynamic_choices_callback("start", {}) == { - "projects": [("P1", "Project 1")] - } - assert agent.dynamic_choices_callback("unknown", {}) == {} - - -def test_agent_dynamic_choices_callback_multiple_keys( - agent_method_fixture: AgentMethod, -): - """Test callback returning multiple choice keys.""" - - def my_dynamic_choices( - method_name: str, context: dict - ) -> dict[str, list[tuple[str, str]]]: - return { - "projects": [("P1", "Project 1"), ("P2", "Project 2")], - "teams": [("T1", "Team Alpha"), ("T2", "Team Beta")], - } - - agent = Agent( - name="multiKeyAgent", - author="test", - version="1.0", - description="test", - methods=AgentMethods(job_start=agent_method_fixture), - dynamic_choices_callback=my_dynamic_choices, - ) - result = agent.dynamic_choices_callback("start", {}) - assert "projects" in result - assert "teams" in result - assert len(result["projects"]) == 2 - assert len(result["teams"]) == 2 + assert all("dynamic_choices" not in field for field in defs) def test_agent_method_fields_definitions() -> None: diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 6edf07d..6a74074 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -63,6 +63,7 @@ def test_controller_contract_endpoints_are_api_prefixed() -> None: info["endpoints"]["POST_CONTROLLER_REGISTRATION_REFRESH"] == "/api/supervaizer/registration/refresh" ) + assert "POST_AGENT_JOB_START_DYNAMIC_CHOICES" not in info["endpoints"] def test_contract_models_export_json_schema() -> None: diff --git a/tests/test_routes.py b/tests/test_routes.py index 3679519..4c97e96 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -232,19 +232,9 @@ async def get_current_server() -> Server: assert resp.json()["detail"] == "No supervisor account configured" -def test_dynamic_choices_endpoint(server_fixture: Server) -> None: - """Test POST /supervaizer/agents/{slug}/start/dynamic_choices returns choices.""" - - def mock_dynamic_choices( - method_name: str, context: dict - ) -> dict[str, list[tuple[str, str]]]: - if method_name == "start": - return {"projects": [("P1", "Project 1"), ("P2", "Project 2")]} - return {} - +def test_dynamic_choices_endpoint_is_removed(server_fixture: Server) -> None: + """V1 dynamic choices were removed in favor of v2 resource/action option sources.""" agent = server_fixture.agents[0] - agent.dynamic_choices_callback = mock_dynamic_choices - app = server_fixture.app app.include_router(create_agents_routes(server_fixture)) client = TestClient(app) @@ -259,9 +249,8 @@ def mock_dynamic_choices( "mission_id": "m-1", }, ) - assert resp.status_code == 200 - data = resp.json() - assert data["choices"]["projects"] == [["P1", "Project 1"], ["P2", "Project 2"]] + + assert resp.status_code == 404 def test_agent_status_endpoint_returns_job_status_response( @@ -297,161 +286,6 @@ def test_agent_status_endpoint_returns_job_status_response( assert data["payload"]["campaign"]["status"] == "in_progress" -def test_dynamic_choices_endpoint_passes_workspace_slug_in_context( - server_fixture: Server, -) -> None: - """Callback context includes workspace_slug from the request body.""" - - captured: dict[str, Any] = {} - - def mock_dynamic_choices( - method_name: str, context: dict - ) -> dict[str, list[tuple[str, str]]]: - captured["context"] = dict(context) - return {"projects": [("P1", "Project 1")]} - - agent = server_fixture.agents[0] - agent.dynamic_choices_callback = mock_dynamic_choices - - app = server_fixture.app - app.include_router(create_agents_routes(server_fixture)) - client = TestClient(app) - headers = {"X-API-Key": server_fixture.api_key or ""} - - resp = client.post( - f"/supervaizer/agents/{agent.slug}/start/dynamic_choices", - headers=headers, - json={ - "workspace_id": 23, - "workspace_slug": "adl", - "mission_id": "01KNPPT249HFSSW662KW9R477N", - }, - ) - assert resp.status_code == 200 - assert captured["context"] == { - "workspace_id": 23, - "workspace_slug": "adl", - "mission_id": "01KNPPT249HFSSW662KW9R477N", - } - - -def test_dynamic_choices_endpoint_multiple_keys( - server_fixture: Server, -) -> None: - """Test endpoint returns multiple choice keys.""" - - def mock_dynamic_choices( - method_name: str, context: dict - ) -> dict[str, list[tuple[str, str]]]: - return { - "projects": [("P1", "Project 1")], - "teams": [("T1", "Team Alpha")], - } - - agent = server_fixture.agents[0] - agent.dynamic_choices_callback = mock_dynamic_choices - - app = server_fixture.app - app.include_router(create_agents_routes(server_fixture)) - client = TestClient(app) - headers = {"X-API-Key": server_fixture.api_key or ""} - - resp = client.post( - f"/supervaizer/agents/{agent.slug}/start/dynamic_choices", - headers=headers, - json={ - "workspace_id": "ws-1", - "workspace_slug": "slug-1", - "mission_id": "m-1", - }, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["choices"]["projects"] == [["P1", "Project 1"]] - assert data["choices"]["teams"] == [["T1", "Team Alpha"]] - - -def test_dynamic_choices_endpoint_empty_result( - server_fixture: Server, -) -> None: - """Test endpoint returns empty choices when callback returns empty dict.""" - - def mock_dynamic_choices( - method_name: str, context: dict - ) -> dict[str, list[tuple[str, str]]]: - return {} - - agent = server_fixture.agents[0] - agent.dynamic_choices_callback = mock_dynamic_choices - - app = server_fixture.app - app.include_router(create_agents_routes(server_fixture)) - client = TestClient(app) - headers = {"X-API-Key": server_fixture.api_key or ""} - - resp = client.post( - f"/supervaizer/agents/{agent.slug}/start/dynamic_choices", - headers=headers, - json={ - "workspace_id": "ws-1", - "workspace_slug": "slug-1", - "mission_id": "m-1", - }, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["choices"] == {} - - -def test_dynamic_choices_endpoint_requires_api_key( - server_fixture: Server, -) -> None: - """Test that the dynamic choices endpoint requires API key authentication.""" - - def mock_dynamic_choices( - method_name: str, context: dict - ) -> dict[str, list[tuple[str, str]]]: - return {"projects": [("P1", "Project 1")]} - - agent = server_fixture.agents[0] - agent.dynamic_choices_callback = mock_dynamic_choices - - app = server_fixture.app - app.include_router(create_agents_routes(server_fixture)) - client = TestClient(app) - - # No API key header - resp = client.post( - f"/supervaizer/agents/{agent.slug}/start/dynamic_choices", - json={}, - ) - assert resp.status_code == 401 - - -def test_dynamic_choices_endpoint_no_callback( - server_fixture: Server, -) -> None: - """Test that endpoint returns 404 when no callback is registered.""" - agent = server_fixture.agents[0] - agent.dynamic_choices_callback = None - - app = server_fixture.app - app.include_router(create_agents_routes(server_fixture)) - client = TestClient(app) - headers = {"X-API-Key": server_fixture.api_key or ""} - - resp = client.post( - f"/supervaizer/agents/{agent.slug}/start/dynamic_choices", - headers=headers, - json={ - "workspace_id": "ws-1", - "workspace_slug": "slug-1", - "mission_id": "m-1", - }, - ) - assert resp.status_code == 404 - - def test_data_resource_openapi_operation_ids_unique_per_agent( account_fixture: Account, agent_method_fixture: AgentMethod, From a742b03746c0aa10f6f87381f043851f55984a7d Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Fri, 15 May 2026 19:51:32 +0300 Subject: [PATCH 21/42] feat: complete local hello world v2 hitl flow --- docs/CHANGELOG.md | 2 +- src/supervaizer/examples/hello_world_agent.py | 269 ++++++++++++++++-- src/supervaizer/examples/local_server.py | 15 +- tests/test_server.py | 84 ++++++ 4 files changed, 340 insertions(+), 30 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ccc2eca..92ee399 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -29,7 +29,7 @@ All notable changes to this project will be documented in this file. - **Supervaizer v2 resource option sources** — Resource fields can now declare typed resource-backed `options_source` metadata so Studio can render relationship selectors without callback-style dynamic choices. - **Legacy dynamic choices removed** — Removed the v1 `dynamic_choices` field metadata, `dynamic_choices_callback`, `/start/dynamic_choices` route, and related contract exports; dynamic options now belong to v2 resource `options_source` metadata or typed A2A actions. - **Supervaizer v2 awaiting form fields** — Step awaiting state can now declare typed form fields so Studio can submit HITL actions through `step.awaiting.submit`. -- **Local Hello World v2 contract** — The built-in local Hello World agent now declares a minimal Supervaizer v2 registration and registers `job.start` A2UI/action handlers for local Studio and SDK smoke tests. +- **Local Hello World v2 contract** — The built-in local Hello World agent now declares a minimal Supervaizer v2 registration and registers `job.start`, `job.sync`, and `case.step.awaiting` handlers for local Studio and SDK smoke tests. ### Tests diff --git a/src/supervaizer/examples/hello_world_agent.py b/src/supervaizer/examples/hello_world_agent.py index df1a3ea..de87b98 100644 --- a/src/supervaizer/examples/hello_world_agent.py +++ b/src/supervaizer/examples/hello_world_agent.py @@ -244,9 +244,18 @@ def job_status(**kwargs: Any) -> JobResponse: def handle_v2_surface(surface_request: Any) -> dict[str, Any]: - """Return the local Hello World job.start A2UI document.""" + """Return local Hello World A2UI documents.""" request = _request_dict(surface_request) surface = str(request.get("surface") or "").strip() + if surface == "case.step.awaiting": + return _awaiting_surface(request) + if surface != "job.start": + return { + "surface": surface, + "a2ui_version": SUPERVAIZER_V2_A2UI_VERSION, + "a2ui_catalog_version": HELLO_WORLD_A2UI_CATALOG_VERSION, + "document": {"type": "UnsupportedSurface", "surface": surface}, + } return { "surface": surface, "a2ui_version": SUPERVAIZER_V2_A2UI_VERSION, @@ -284,43 +293,257 @@ def handle_v2_action(action_request: Any) -> dict[str, Any]: action = str(request.get("action") or "").strip() if action == "job.start.preview": return _ok_result("job.start.previewed", request_id=request.get("request_id")) - if action != "job.start": + if action == "job.start": + return _job_start_result(request) + if action == "job.sync": + return _job_sync_result(request) + if action == "step.awaiting.submit": + return _awaiting_submit_result(request) + return { + "status": "error", + "effects": [{"type": "action.unsupported", "action": action}], + } + + +def _awaiting_surface(request: dict[str, Any]) -> dict[str, Any]: + return { + "surface": "case.step.awaiting", + "a2ui_version": SUPERVAIZER_V2_A2UI_VERSION, + "a2ui_catalog_version": HELLO_WORLD_A2UI_CATALOG_VERSION, + "document": { + "type": "Form", + "id": "supervaizer.local.hello_world.case.step.awaiting", + "title": "Human Review", + "fields": _human_review_fields(), + "submit": {"action": "step.awaiting.submit", "label": "Submit"}, + "state": { + "job_id": request.get("job_id"), + "case_id": request.get("case_id"), + "step_id": request.get("step_id"), + }, + }, + } + + +def _job_start_result(request: dict[str, Any]) -> dict[str, Any]: + action_input = _action_input(request) + job_id = str(request.get("job_id") or "local-v2-job") + if _human_review_enabled(action_input): return { - "status": "error", - "effects": [{"type": "action.unsupported", "action": action}], + "status": "ok", + "effects": [ + { + "type": "job.started", + "job_id": job_id, + "status": "awaiting", + "message": "Awaiting human review", + } + ], + "job_state": _awaiting_job_state(request), } - response = job_start( - fields=_legacy_job_start_fields(_action_input(request)), - context={"job_id": request.get("job_id") or "local-v2-job"}, - ) + count = _bounded_count(action_input) return { "status": "ok", "effects": [ { "type": "job.started", - "job_id": response.job_id, - "status": _status_value(response.status), - "message": response.message, - "payload": response.payload, + "job_id": job_id, + "status": "completed", + "message": f"Completed {count} cases", + } + ], + "job_state": _completed_job_state(request), + } + + +def _job_sync_result(request: dict[str, Any]) -> dict[str, Any]: + action_input = _action_input(request) + if _human_review_enabled(action_input): + return { + "status": "ok", + "effects": [ + { + "type": "job.synced", + "job_id": request.get("job_id"), + "status": "awaiting", + "message": "Awaiting human review", + } + ], + "job_state": _awaiting_job_state(request), + } + return { + "status": "ok", + "effects": [ + { + "type": "job.synced", + "job_id": request.get("job_id"), + "status": "completed", + "message": "Completed", + } + ], + "job_state": _completed_job_state(request), + } + + +def _awaiting_submit_result(request: dict[str, Any]) -> dict[str, Any]: + return { + "status": "ok", + "effects": [ + { + "type": "step.awaiting.submitted", + "job_id": request.get("job_id"), + "case_id": request.get("case_id"), + "step_id": request.get("step_id"), + "status": "completed", } ], + "job_state": _completed_review_job_state(request), } -def _legacy_job_start_fields(action_input: dict[str, Any]) -> dict[str, Any]: +def _human_review_enabled(action_input: dict[str, Any]) -> bool: + value = action_input.get( + "enable_human_review", + action_input.get("Enable human review", False), + ) + return str(value).lower() in ("true", "on", "1", "yes") + + +def _bounded_count(action_input: dict[str, Any]) -> int: + value = action_input.get( + "count", action_input.get("How many times to say hello", 1) + ) + try: + count = int(value) + except (TypeError, ValueError): + count = 1 + return max(0, min(count, 100)) + + +def _awaiting_job_state(request: dict[str, Any]) -> dict[str, Any]: + return _job_state( + request, + status="awaiting", + cases=[ + { + "id": "hello-review-1", + "lane": "work", + "title": "Hello review", + "status": "awaiting", + "steps": [ + { + "id": "human-review", + "activity": "operation", + "status": "awaiting", + "title": "Human Review", + "awaiting": { + "reason": "human_input", + "surface": "case.step.awaiting", + "action": "step.awaiting.submit", + "fields": _human_review_fields(), + }, + "outputs": [], + } + ], + } + ], + ) + + +def _completed_review_job_state(request: dict[str, Any]) -> dict[str, Any]: + return _job_state( + request, + status="completed", + cases=[ + { + "id": request.get("case_id") or "hello-review-1", + "lane": "work", + "title": "Hello review", + "status": "completed", + "steps": [ + { + "id": request.get("step_id") or "human-review", + "activity": "operation", + "status": "completed", + "title": "Human Review", + "outputs": [ + { + "id": "review-decision", + "type": "decision", + "title": "Review decision", + } + ], + } + ], + } + ], + ) + + +def _completed_job_state(request: dict[str, Any]) -> dict[str, Any]: + action_input = _action_input(request) + cases = [] + for index in range(1, _bounded_count(action_input) + 1): + cases.append({ + "id": f"hello-case-{index}", + "lane": "work", + "title": f"Hello case {index}", + "status": "completed", + "steps": [ + { + "id": f"hello-case-{index}-complete", + "activity": "operation", + "status": "completed", + "title": "Say hello", + "outputs": [ + { + "id": f"hello-case-{index}-message", + "type": "message", + "title": f"Hello case {index}", + } + ], + } + ], + }) + return _job_state(request, status="completed", cases=cases) + + +def _job_state( + request: dict[str, Any], + *, + status: str, + cases: list[dict[str, Any]], +) -> dict[str, Any]: return { - "How many times to say hello": action_input.get( - "count", - action_input.get("How many times to say hello", 1), - ), - "Enable human review": action_input.get( - "enable_human_review", - action_input.get("Enable human review", False), - ), + "job": { + "id": str(request.get("job_id") or "local-v2-job"), + "agent_slug": str(request.get("agent_slug") or "hello-world-ai-agent"), + "mission_id": str(request.get("mission_id") or "local-mission"), + "status": status, + "source": {"type": "fresh_start"}, + }, + "cases": cases, } +def _human_review_fields() -> list[dict[str, Any]]: + return [ + { + "id": "approved", + "label": "Approve this case", + "type": "boolean", + "required": True, + }, + { + "id": "comment", + "label": "Comment", + "type": "string", + "required": False, + }, + ] + + def _ok_result(effect_type: str, **effect: Any) -> dict[str, Any]: return { "status": "ok", @@ -339,7 +562,3 @@ def _request_dict(request: Any) -> dict[str, Any]: if hasattr(request, "model_dump"): return request.model_dump(mode="python") raise TypeError(f"Unsupported request type: {type(request).__name__}") - - -def _status_value(status: Any) -> str: - return str(getattr(status, "value", status)) diff --git a/src/supervaizer/examples/local_server.py b/src/supervaizer/examples/local_server.py index 8d4e193..5acbe68 100644 --- a/src/supervaizer/examples/local_server.py +++ b/src/supervaizer/examples/local_server.py @@ -65,8 +65,13 @@ def build_default_local_v2_registration( "controller_url": "/a2a", }, "capabilities": { - "surfaces": ["job.start"], - "actions": ["job.start.preview", "job.start"], + "surfaces": ["job.start", "case.step.awaiting"], + "actions": [ + "job.start.preview", + "job.start", + "job.sync", + "step.awaiting.submit", + ], "case_lanes": [{"id": "work", "label": "Work", "default": True}], "artifact_types": [], }, @@ -86,11 +91,13 @@ def register_default_local_v2_handlers( handle_v2_surface, ) - server.register_v2_surface("job.start", handle_v2_surface, agent_slug=agent_slug) + for surface in ("job.start", "case.step.awaiting"): + server.register_v2_surface(surface, handle_v2_surface, agent_slug=agent_slug) server.register_v2_action( "job.start.preview", handle_v2_action, agent_slug=agent_slug ) - server.register_v2_action("job.start", handle_v2_action, agent_slug=agent_slug) + for action in ("job.start", "job.sync", "step.awaiting.submit"): + server.register_v2_action(action, handle_v2_action, agent_slug=agent_slug) def get_default_local_agent() -> Agent: diff --git a/tests/test_server.py b/tests/test_server.py index ca118d1..4953fa0 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -631,6 +631,10 @@ def test_local_mode_registers_hello_world_v2_handlers(self) -> None: card_response.json()["supervaizer"]["v2"]["a2a"]["controller_url"] == "/a2a" ) + capabilities = card_response.json()["supervaizer"]["v2"]["capabilities"] + assert "case.step.awaiting" in capabilities["surfaces"] + assert "job.sync" in capabilities["actions"] + assert "step.awaiting.submit" in capabilities["actions"] surface_response = client.post( "/a2a", @@ -679,6 +683,86 @@ def test_local_mode_registers_hello_world_v2_handlers(self) -> None: action_response.json()["result"]["effects"][0]["type"] == "job.start.previewed" ) + + start_response = client.post( + "/a2a", + json={ + "jsonrpc": "2.0", + "id": "start-1", + "method": "supervaizer/action.invoke", + "params": { + "request_id": "start-1", + "actor": {"user_id": "user-1"}, + "workspace": {"id": "workspace-1"}, + "mission_id": "mission-1", + "agent_slug": agent_slug, + "surface": "job.start", + "action": "job.start", + "input": {"count": 1, "enable_human_review": True}, + "job_id": "job-1", + }, + }, + ) + assert start_response.status_code == 200 + start_result = start_response.json()["result"] + assert start_result["effects"][0]["status"] == "awaiting" + step = start_result["job_state"]["cases"][0]["steps"][0] + assert step["status"] == "awaiting" + assert step["awaiting"]["surface"] == "case.step.awaiting" + + awaiting_surface_response = client.post( + "/a2a", + json={ + "jsonrpc": "2.0", + "id": "awaiting-surface-1", + "method": "supervaizer/surface.load", + "params": { + "request_id": "awaiting-surface-1", + "actor": {"user_id": "user-1"}, + "workspace": {"id": "workspace-1"}, + "mission_id": "mission-1", + "agent_slug": agent_slug, + "surface": "case.step.awaiting", + "input": {}, + "job_id": "job-1", + "case_id": "hello-review-1", + "step_id": "human-review", + }, + }, + ) + assert awaiting_surface_response.status_code == 200 + assert ( + awaiting_surface_response.json()["result"]["document"]["submit"][ + "action" + ] + == "step.awaiting.submit" + ) + + submit_response = client.post( + "/a2a", + json={ + "jsonrpc": "2.0", + "id": "submit-1", + "method": "supervaizer/action.invoke", + "params": { + "request_id": "submit-1", + "actor": {"user_id": "user-1"}, + "workspace": {"id": "workspace-1"}, + "mission_id": "mission-1", + "agent_slug": agent_slug, + "surface": "case.step.awaiting", + "action": "step.awaiting.submit", + "input": {"response_data": {"approved": True}}, + "job_id": "job-1", + "case_id": "hello-review-1", + "step_id": "human-review", + }, + }, + ) + assert submit_response.status_code == 200 + submit_result = submit_response.json()["result"] + assert submit_result["effects"][0]["type"] == "step.awaiting.submitted" + assert submit_result["job_state"]["job"]["status"] == "completed" finally: del os.environ["SUPERVAIZER_LOCAL_MODE"] From 587a1de9018d7cff7798901dce285e83a26a28e2 Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Fri, 15 May 2026 20:17:49 +0300 Subject: [PATCH 22/42] refactor: remove legacy job poll --- docs/CHANGELOG.md | 1 + .../admin/static/js/workbench-form.js | 34 +------------------ .../admin/templates/workbench.html | 15 -------- src/supervaizer/admin/workbench_routes.py | 26 -------------- src/supervaizer/agent.py | 11 ++++-- src/supervaizer/contracts.py | 12 +++++-- tests/test_agent.py | 9 +++++ tests/test_contracts.py | 12 +++++++ 8 files changed, 42 insertions(+), 78 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 92ee399..56dbf2f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -28,6 +28,7 @@ All notable changes to this project will be documented in this file. - **Supervaizer v2 resource form fields** — `V2ResourceDefinition` now carries typed `fields` metadata so Studio can render simple agent-owned resource create/edit forms without callback-style dynamic field logic. - **Supervaizer v2 resource option sources** — Resource fields can now declare typed resource-backed `options_source` metadata so Studio can render relationship selectors without callback-style dynamic choices. - **Legacy dynamic choices removed** — Removed the v1 `dynamic_choices` field metadata, `dynamic_choices_callback`, `/start/dynamic_choices` route, and related contract exports; dynamic options now belong to v2 resource `options_source` metadata or typed A2A actions. +- **Legacy job poll removed** — Removed `job_poll` from the public v1 method contract and the local workbench poll route/button; v2 status convergence is represented by the typed `job.sync` action. - **Supervaizer v2 awaiting form fields** — Step awaiting state can now declare typed form fields so Studio can submit HITL actions through `step.awaiting.submit`. - **Local Hello World v2 contract** — The built-in local Hello World agent now declares a minimal Supervaizer v2 registration and registers `job.start`, `job.sync`, and `case.step.awaiting` handlers for local Studio and SDK smoke tests. diff --git a/src/supervaizer/admin/static/js/workbench-form.js b/src/supervaizer/admin/static/js/workbench-form.js index 028088e..43c875a 100644 --- a/src/supervaizer/admin/static/js/workbench-form.js +++ b/src/supervaizer/admin/static/js/workbench-form.js @@ -122,35 +122,6 @@ class WorkbenchForm { } } - async pollJob() { - if (!this.activeJobId) return; - const btn = document.getElementById('btn-poll'); - if (btn) { - btn.disabled = true; - btn.classList.add('opacity-50'); - } - try { - const response = await fetch(`${this.basePath}/jobs/${this.activeJobId}/poll`, { - method: 'POST', - headers: { 'X-API-Key': this.getApiKey() }, - }); - const result = await response.json(); - if (!response.ok) { - this.onError(result.detail || 'Poll failed'); - } else { - this.onError(''); - this.refreshMonitor(true); - } - } catch (e) { - this.onError(`Network error: ${e.message}`); - } finally { - if (btn) { - btn.disabled = false; - btn.classList.remove('opacity-50'); - } - } - } - async executeStepNow(caseId, stepIndex) { if (!this.activeJobId) return; try { @@ -298,18 +269,15 @@ class WorkbenchForm { await this._postAnswer(caseId, { action: 'confirm' }); } - /** Show/hide Stop, Poll, and Status buttons based on active job. */ + /** Show/hide Stop and Status buttons based on active job. */ _updateButtons(terminal) { const stop = document.getElementById('btn-stop'); const status = document.getElementById('btn-status'); - const poll = document.getElementById('btn-poll'); if (this.activeJobId && !terminal) { if (stop) stop.classList.remove('hidden'); if (status) status.classList.remove('hidden'); - if (poll) poll.classList.remove('hidden'); } else { if (stop) stop.classList.add('hidden'); - if (poll) poll.classList.add('hidden'); if (status) status.classList.remove('hidden'); } } diff --git a/src/supervaizer/admin/templates/workbench.html b/src/supervaizer/admin/templates/workbench.html index 3e898f6..4227124 100644 --- a/src/supervaizer/admin/templates/workbench.html +++ b/src/supervaizer/admin/templates/workbench.html @@ -160,21 +160,6 @@

Jo Start Job - {# Poll button — visible only when agent has job_poll #} - {% if has_poll %} - - {% endif %} - {# Stop button #}