From 014d6f72ccef32d38703e4df8b1dda04b8ebe52f Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Sat, 29 Aug 2026 19:38:51 +0300 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20feat(contracts):=20declare=20ac?= =?UTF-8?q?tion=20scope=20and=20mutability=20(#92)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V2AgentCapabilities.actions becomes list[V2ActionDefinition] carrying id, mutating and scope, so consumers authorize and group actions from declared metadata instead of pattern-matching the caller-supplied id. Bare strings still validate and coerce to mutating=True, scope=job. The builder derives metadata from each action's parent definition; precedence is explicit > derived > bare. Also adds V2DatasetDefinition.scope and V2AwaitingState.reopenable. --- docs/CHANGELOG.md | 14 +++ src/supervaizer/__init__.py | 1 + src/supervaizer/agent.py | 16 ++-- src/supervaizer/contracts.py | 174 ++++++++++++++++++++++++++++------- tests/test_agent.py | 4 +- tests/test_contracts.py | 142 ++++++++++++++++++++++++---- tests/test_server.py | 7 +- 7 files changed, 297 insertions(+), 61 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c1bdb37..6ae5788 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -12,6 +12,20 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- **Declared action scope and mutability (#92)** — `V2AgentCapabilities.actions` is now `list[V2ActionDefinition]` instead of an opaque `list[str]`. Each action declares `id`, `mutating` (does invoking it change agent-side state?), and `scope` (`workspace` / `mission` / `job`), so a consumer can authorize and group actions without pattern-matching the identifier. `id` is caller-supplied at invocation time, so deriving an authorization decision from it puts that decision in the caller's hands. + + Plain strings are still accepted everywhere a list of actions is taken — in `build_v2_agent_registration(actions=...)` and when validating a registration payload — and coerce to `mutating=True, scope="job"`, the fail-closed reading that matches today's behaviour. Only the serialized form changes: `capabilities.actions` now emits objects. Consumers must accept both, because agents pinned to older Supervaizer releases keep sending bare strings. No protocol version discriminates the two shapes: `versions.a2ui_version` and `versions.a2a_version` cover the surface catalog and the A2A protocol, not the registration payload. + + The builder derives the metadata from the definition each action comes from: resource actions take `V2ResourceDefinition.scope`, dataset queries are `mutating=False`, `V2JobSetupPolicy.preview_action` is `mutating=False`, and workspace binding actions are `workspace`-scoped. Resource operation ids are freeform, so operations stay `mutating=True` unless the agent declares otherwise. + + Metadata precedence is explicit > derived > bare: an explicit `V2ActionDefinition` in `actions=` overrides the derived metadata — that is how a job-scoped `resource.invoice.reconcile` on a workspace-scoped resource is declared — while a bare id string declares nothing and defers to the definition it was derived from. First mention fixes the order in either case. + +- **`V2DatasetDefinition.scope`** — Mirrors `V2ResourceDefinition.scope` (`workspace` / `mission` / `job`, defaulting to `workspace`) so dataset query actions carry a declared scope rather than an assumed one. + +- **`V2AwaitingState.reopenable`** — Declares whether an already-answered awaiting step may be reopened and resubmitted with edited values. Defaults to `False`; previously consumers inferred this from a substring of `surface`. + ## [1.5.0] - 2026-08-28 ### Added diff --git a/src/supervaizer/__init__.py b/src/supervaizer/__init__.py index 6177720..0acd7da 100644 --- a/src/supervaizer/__init__.py +++ b/src/supervaizer/__init__.py @@ -138,6 +138,7 @@ "V2A2UIResourceImportDocument", ), "V2A2UISubmitDefinition": ("supervaizer.contracts", "V2A2UISubmitDefinition"), + "V2ActionDefinition": ("supervaizer.contracts", "V2ActionDefinition"), "V2AgentCapabilities": ("supervaizer.contracts", "V2AgentCapabilities"), "V2AgentIdentity": ("supervaizer.contracts", "V2AgentIdentity"), "V2AgentMethod": ("supervaizer.contracts", "V2AgentMethod"), diff --git a/src/supervaizer/agent.py b/src/supervaizer/agent.py index 81910f5..720fe34 100644 --- a/src/supervaizer/agent.py +++ b/src/supervaizer/agent.py @@ -39,6 +39,7 @@ from supervaizer.common import ApiSuccess, SvBaseModel, log from supervaizer.contracts import ( SupervaizerV2AgentRegistrationContract, + V2ActionDefinition, V2ActionRequest, V2AgentMethod, V2AgentMethods, @@ -849,13 +850,16 @@ def _validate_supervaizer_v2_identity(self) -> None: def _apply_v2_method_capabilities(self) -> None: if self.supervaizer_v2_registration is None or self.v2_methods is None: return - actions = [ - *self.supervaizer_v2_registration.capabilities.actions, - *self.v2_methods.action_ids, + declared = self.supervaizer_v2_registration.capabilities.actions + known_ids = {action.id for action in declared} + self.supervaizer_v2_registration.capabilities.actions = [ + *declared, + *( + V2ActionDefinition(id=action_id, mutating=True, scope="job") + for action_id in dict.fromkeys(self.v2_methods.action_ids) + if action_id not in known_ids + ), ] - self.supervaizer_v2_registration.capabilities.actions = list( - dict.fromkeys(actions) - ) @property def slug(self) -> str: diff --git a/src/supervaizer/contracts.py b/src/supervaizer/contracts.py index 545ea3b..8955d04 100644 --- a/src/supervaizer/contracts.py +++ b/src/supervaizer/contracts.py @@ -308,12 +308,50 @@ class V2ArtifactTypeDefinition(ContractModel): renderer_surface: str | None = None +class V2ActionDefinition(ContractModel): + """Declared metadata for one invokable agent action. + + Consumers authorize, group and filter actions from these fields. They must + never be inferred from the identifier string: `id` is caller-supplied on + invocation, so pattern-matching it turns an authorization decision into + something the caller controls. + """ + + id: str = Field(description="Action identifier used to invoke the action.") + mutating: bool = Field( + default=True, + description=( + "Whether invoking the action changes agent-side state. " + "Defaults to True so an undeclared action requires write permission." + ), + ) + scope: Literal["workspace", "mission", "job"] = Field( + default="job", + description="Context the action operates within.", + ) + + @field_validator("id") + @classmethod + def validate_id_is_named(cls, value: str) -> str: + if not value.strip(): + raise ValueError("action definitions must have a non-empty id") + return value + + class V2AgentCapabilities(ContractModel): surfaces: list[str] = Field(default_factory=list) - actions: list[str] = Field(default_factory=list) + actions: list[V2ActionDefinition] = Field(default_factory=list) case_lanes: list[V2CaseLaneDefinition] = Field(default_factory=list) artifact_types: list[V2ArtifactTypeDefinition] = Field(default_factory=list) + @field_validator("actions", mode="before") + @classmethod + def coerce_action_strings(cls, value: Any) -> Any: + """Accept the legacy `list[str]` form; a bare id keeps the safe defaults.""" + if not isinstance(value, list): + return value + return [{"id": item} if isinstance(item, str) else item for item in value] + class V2AgentMethod(ContractModel): method: str @@ -485,6 +523,7 @@ class V2DatasetDefinition(ContractModel): id: str label: str auto_surface: bool = False + scope: Literal["workspace", "mission", "job"] = "workspace" display: V2ResourceDisplayDefinition | None = None @@ -592,7 +631,7 @@ def build_v2_agent_registration( controller_url: str, a2ui_catalog_version: str, surfaces: Iterable[str] = (), - actions: Iterable[str] = (), + actions: Iterable[str | V2ActionDefinition | dict[str, Any]] = (), resources: Iterable[V2ResourceDefinition | dict[str, Any]] = (), datasets: Iterable[V2DatasetDefinition | dict[str, Any]] = (), dashboards: Iterable[V2DashboardDefinition | dict[str, Any]] = (), @@ -622,15 +661,19 @@ def build_v2_agent_registration( *_dashboard_surface_ids(dashboard_definitions), *_workspace_binding_surface_ids(workspace_binding_definition), ]) - capability_actions = _unique_strings([ - *actions, - *_resource_action_ids(resource_definitions), - *_dataset_action_ids(dataset_definitions), - *_job_sync_actions(job_policy_definition), - *_job_setup_actions(job_policy_definition), - *_workspace_binding_action_ids(workspace_binding_definition), - *_agent_method_action_ids(agent_method_definitions), - ]) + declared_actions = list(actions) + capability_actions = _merge_actions( + declared=_declared_actions(declared_actions), + derived=[ + *_resource_actions(resource_definitions), + *_dataset_actions(dataset_definitions), + *_job_sync_actions(job_policy_definition), + *_job_setup_actions(job_policy_definition), + *_workspace_binding_actions(workspace_binding_definition), + *_agent_method_actions(agent_method_definitions), + ], + bare_ids={action for action in declared_actions if isinstance(action, str)}, + ) return SupervaizerV2AgentRegistrationContract( agent=V2AgentIdentity( @@ -741,51 +784,111 @@ def _dashboard_surface_ids(dashboards: Iterable[V2DashboardDefinition]) -> list[ return [dashboard.surface for dashboard in dashboards] -def _resource_action_ids(resources: Iterable[V2ResourceDefinition]) -> list[str]: +def _declared_actions( + actions: Iterable[str | V2ActionDefinition | dict[str, Any]], +) -> list[V2ActionDefinition]: return [ - f"resource.{resource.id}.{operation}" + action + if isinstance(action, V2ActionDefinition) + else V2ActionDefinition.model_validate( + {"id": action} if isinstance(action, str) else action + ) + for action in actions + ] + + +def _resource_actions( + resources: Iterable[V2ResourceDefinition], +) -> list[V2ActionDefinition]: + # Resource operations are freeform ids, so mutability cannot be derived from + # them: declare the action explicitly in `actions` to mark one read-only. + return [ + V2ActionDefinition( + id=f"resource.{resource.id}.{operation}", + mutating=True, + scope=resource.scope, + ) for resource in resources for operation in resource.operations ] -def _dataset_action_ids(datasets: Iterable[V2DatasetDefinition]) -> list[str]: - return [f"dataset.{dataset.id}.query" for dataset in datasets] +def _dataset_actions( + datasets: Iterable[V2DatasetDefinition], +) -> list[V2ActionDefinition]: + return [ + V2ActionDefinition( + id=f"dataset.{dataset.id}.query", + mutating=False, + scope=dataset.scope, + ) + for dataset in datasets + ] -def _job_sync_actions(job_policy: V2JobPolicy) -> list[str]: +def _job_sync_actions(job_policy: V2JobPolicy) -> list[V2ActionDefinition]: if job_policy.sync is None: return [] - return [job_policy.sync.action] + return [V2ActionDefinition(id=job_policy.sync.action, mutating=True, scope="job")] -def _job_setup_actions(job_policy: V2JobPolicy) -> list[str]: - if job_policy.setup is None: +def _job_setup_actions(job_policy: V2JobPolicy) -> list[V2ActionDefinition]: + setup = job_policy.setup + if setup is None: return [] return [ - job_policy.setup.preview_action, - job_policy.setup.start_action, - job_policy.setup.submit_action, + V2ActionDefinition(id=setup.preview_action, mutating=False, scope="job"), + V2ActionDefinition(id=setup.start_action, mutating=True, scope="job"), + V2ActionDefinition(id=setup.submit_action, mutating=True, scope="job"), ] -def _agent_method_action_ids(agent_methods: V2AgentMethods | None) -> list[str]: +def _agent_method_actions( + agent_methods: V2AgentMethods | None, +) -> list[V2ActionDefinition]: if agent_methods is None: return [] - return agent_methods.action_ids + return [ + V2ActionDefinition(id=action_id, mutating=True, scope="job") + for action_id in agent_methods.action_ids + ] -def _workspace_binding_action_ids( +def _workspace_binding_actions( workspace_binding: V2WorkspaceBindingDefinition | None, -) -> list[str]: +) -> list[V2ActionDefinition]: if workspace_binding is None: return [] - action_ids: list[str] = [] - if workspace_binding.existing is not None: - action_ids.append(workspace_binding.existing.action) - if workspace_binding.create is not None: - action_ids.append(workspace_binding.create.action) - return action_ids + bindings = [workspace_binding.existing, workspace_binding.create] + return [ + V2ActionDefinition(id=binding.action, mutating=True, scope="workspace") + for binding in bindings + if binding is not None + ] + + +def _merge_actions( + declared: list[V2ActionDefinition], + derived: list[V2ActionDefinition], + bare_ids: set[str], +) -> list[V2ActionDefinition]: + """Merge explicitly declared actions with the ones derived from definitions. + + First mention fixes the order. A bare id string in ``actions`` declares no + metadata, so it defers to the definition it was derived from; an explicit + ``V2ActionDefinition`` overrides the derived one. + """ + pending_bare = set(bare_ids) + resolved: dict[str, V2ActionDefinition] = {} + for action in declared: + resolved.setdefault(action.id, action) + for action in derived: + if action.id not in resolved: + resolved[action.id] = action + elif action.id in pending_bare: + resolved[action.id] = action + pending_bare.discard(action.id) + return list(resolved.values()) def _workspace_binding_surface_ids( @@ -948,6 +1051,13 @@ class V2AwaitingState(ContractModel): surface: str action: str fields: list[V2AwaitingFieldDefinition] = Field(default_factory=list) + reopenable: bool = Field( + default=False, + description=( + "Whether an already-answered step may be reopened and resubmitted " + "with edited values." + ), + ) class V2StepSnapshot(ContractModel): diff --git a/tests/test_agent.py b/tests/test_agent.py index 318752e..d5b2cbe 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -1273,7 +1273,9 @@ def test_agent_v2_methods_are_added_to_v2_capabilities() -> None: ) assert agent.supervaizer_v2_registration is not None - assert agent.supervaizer_v2_registration.capabilities.actions == [ + assert [ + action.id for action in agent.supervaizer_v2_registration.capabilities.actions + ] == [ "job.sync", AGENT_REFRESH_ACTION, ] diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 1a4fd53..1275671 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -26,8 +26,10 @@ ServerRegistrationContract, SupervaizerV2AgentRegistrationContract, V2A2UIResourceImportDocument, + V2ActionDefinition, V2ActionRequest, V2ActionResult, + V2AgentCapabilities, V2AgentMethod, V2AgentMethods, V2AwaitingState, @@ -43,6 +45,7 @@ V2JobSyncPolicy, V2JobSyncResult, V2ReplaySafetyMetadata, + V2ResourceDefinition, V2ResourceFieldDefinition, V2SurfaceRequest, V2SurfaceResult, @@ -270,11 +273,12 @@ def test_v2_agent_interviewer_registration_fixture() -> None: assert ( "mission.agent.surface.scenario_builder" in registration.capabilities.surfaces ) - assert AGENT_REFRESH_ACTION in registration.capabilities.actions - assert "resource.campaign_contacts.create" in registration.capabilities.actions - assert "resource.campaign_contacts.delete" in registration.capabilities.actions - assert "resource.contacts.import" in registration.capabilities.actions - assert "resource.scenarios.update" in registration.capabilities.actions + action_ids = [action.id for action in registration.capabilities.actions] + assert AGENT_REFRESH_ACTION in action_ids + assert "resource.campaign_contacts.create" in action_ids + assert "resource.campaign_contacts.delete" in action_ids + assert "resource.contacts.import" in action_ids + assert "resource.scenarios.update" in action_ids assert any( lane.id == "work" and lane.default for lane in registration.capabilities.case_lanes @@ -423,15 +427,18 @@ def test_build_v2_agent_registration_derives_capabilities() -> None: "mission.analytics", "workspace_binding.create", ] - assert registration.capabilities.actions == [ - "job.start", - "step.awaiting.submit", - "resource.contacts.list", - "resource.contacts.create", - "dataset.campaign_progress.query", - "job.sync", - "workspace_binding.options", - "workspace_binding.create", + assert [ + (action.id, action.mutating, action.scope) + for action in registration.capabilities.actions + ] == [ + ("job.start", True, "job"), + ("step.awaiting.submit", True, "job"), + ("resource.contacts.list", True, "workspace"), + ("resource.contacts.create", True, "workspace"), + ("dataset.campaign_progress.query", False, "workspace"), + ("job.sync", True, "job"), + ("workspace_binding.options", True, "workspace"), + ("workspace_binding.create", True, "workspace"), ] assert registration.workspace_binding is not None assert registration.workspace_binding.existing is not None @@ -479,13 +486,104 @@ def test_build_v2_agent_registration_derives_agent_method_actions() -> None: ), ) - assert registration.capabilities.actions == [ + assert [action.id for action in registration.capabilities.actions] == [ AGENT_REFRESH_ACTION, "agent.custom.reindex", "agent.custom.dry-run", ] +def test_v2_capabilities_coerce_bare_action_strings() -> None: + """A legacy `list[str]` keeps today's meaning: mutating and job-scoped.""" + capabilities = V2AgentCapabilities.model_validate({"actions": ["job.start"]}) + + assert capabilities.actions == [ + V2ActionDefinition(id="job.start", mutating=True, scope="job") + ] + assert capabilities.model_dump()["actions"] == [ + {"id": "job.start", "mutating": True, "scope": "job"} + ] + + +def test_v2_capabilities_reject_blank_action_id() -> None: + with pytest.raises(ValidationError, match="non-empty id"): + V2AgentCapabilities.model_validate({"actions": [" "]}) + + +def test_v2_declared_action_overrides_derived_metadata() -> None: + """An explicit definition wins over the metadata derived from its parent.""" + registration = build_v2_agent_registration( + agent_id="agent-1", + agent_slug="agent", + display_name="Agent", + agent_card_url="/card.json", + controller_url="/a2a", + a2ui_catalog_version="supervaizer-v2-local.0", + actions=[ + V2ActionDefinition( + id="resource.invoices.reconcile", mutating=True, scope="job" + ) + ], + resources=[ + V2ResourceDefinition( + id="invoices", + label="Invoices", + scope="workspace", + operations=["reconcile"], + ) + ], + ) + + reconcile = next( + action + for action in registration.capabilities.actions + if action.id == "resource.invoices.reconcile" + ) + assert reconcile.scope == "job" + assert len(registration.capabilities.actions) == 1 + + +def test_v2_bare_action_string_defers_to_derived_metadata() -> None: + """A bare id declares no metadata, so the setup policy's derivation wins.""" + registration = build_v2_agent_registration( + agent_id="agent-1", + agent_slug="agent", + display_name="Agent", + agent_card_url="/card.json", + controller_url="/a2a", + a2ui_catalog_version="supervaizer-v2-local.0", + actions=["job.start.preview", "job.start"], + job_policy={"setup": {}}, + ) + + assert [ + (action.id, action.mutating) for action in registration.capabilities.actions + ] == [ + ("job.start.preview", False), + ("job.start", True), + ("step.awaiting.submit", True), + ] + + +def test_v2_awaiting_state_is_not_reopenable_by_default() -> None: + awaiting = V2AwaitingState.model_validate({ + "reason": "Review campaign setup", + "surface": "case.step.awaiting", + "action": "step.awaiting.submit", + }) + + assert awaiting.reopenable is False + assert ( + V2AwaitingState.model_validate({ + "reason": "Review campaign setup", + "surface": "case.step.awaiting", + "action": "step.awaiting.submit", + "reopenable": True, + }).reopenable + is True + ) + + def test_v2_workspace_binding_required_requires_mode() -> None: with pytest.raises(ValidationError, match="at least one mode"): V2WorkspaceBindingDefinition(required=True) @@ -575,11 +673,17 @@ def test_v2_job_setup_policy_round_trips_through_registration_builder() -> None: "case", "step", ] - assert set(round_trip.capabilities.actions) >= { - "job.start.preview", - "job.start", - "step.awaiting.submit", + mutating_by_id = { + action.id: action.mutating for action in round_trip.capabilities.actions } + assert ( + mutating_by_id.items() + >= { + "job.start.preview": False, + "job.start": True, + "step.awaiting.submit": True, + }.items() + ) def test_v2_job_setup_policy_declares_no_scopes_by_default() -> None: diff --git a/tests/test_server.py b/tests/test_server.py index 4a0f4d8..75302bb 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1113,9 +1113,10 @@ def test_local_mode_registers_hello_world_v2_handlers(self) -> None: capabilities = card_response.json()["supervaizer"]["v2"]["capabilities"] assert "case.step.awaiting" in capabilities["surfaces"] assert "mission.agent.resource.hello_messages" in capabilities["surfaces"] - assert "job.sync" in capabilities["actions"] - assert "step.awaiting.submit" in capabilities["actions"] - assert "resource.hello_messages.list" in capabilities["actions"] + action_ids = [action["id"] for action in capabilities["actions"]] + assert "job.sync" in action_ids + assert "step.awaiting.submit" in action_ids + assert "resource.hello_messages.list" in action_ids surface_response = client.post( "/a2a", From b3d1a1b86aac47ab655a0381b1a123979d1f6746 Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Sat, 29 Aug 2026 19:38:55 +0300 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=93=9D=20chore:=20update=20documentat?= =?UTF-8?q?ion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api/openapi.json | 4 ++-- docs/model_reference/model_core.md | 4 ++-- docs/model_reference/model_extra.md | 29 +++++++++++++++++++++++++---- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 435428b..4c158f4 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: 1.3.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", + "description": "API version: v1 Controller version: 1.5.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", "termsOfService": "https://supervaize.com/terms/", "contact": { "name": "Support Team", @@ -4187,7 +4187,7 @@ "type": "string", "format": "date-time", "title": "Timestamp", - "default": "2026-08-26T17:21:02.106370" + "default": "2026-08-29T19:33:56.088920" }, "status_code": { "type": "integer", diff --git a/docs/model_reference/model_core.md b/docs/model_reference/model_core.md index fce37b8..fe54a16 100644 --- a/docs/model_reference/model_core.md +++ b/docs/model_reference/model_core.md @@ -1,6 +1,6 @@ # Model Reference Core -**Version:** 1.3.1 +**Version:** 1.5.0 ### `account.Account` @@ -443,4 +443,4 @@ public_url: full url (including scheme and port) to use for outbound connections ``` -*Uploaded on 2026-08-26 17:21:01* +*Uploaded on 2026-08-29 19:33:55* diff --git a/docs/model_reference/model_extra.md b/docs/model_reference/model_extra.md index 54e034c..6810c0d 100644 --- a/docs/model_reference/model_extra.md +++ b/docs/model_reference/model_extra.md @@ -1,6 +1,6 @@ # Model Reference extra -**Version:** 1.3.1 +**Version:** 1.5.0 ### `common.SvBaseModel` @@ -226,6 +226,25 @@ _No additional fields beyond parent class._ | `dashboards` | `list[contracts.V2DashboardDefinition]` | — | | | `workspace_binding` | `V2WorkspaceBindingDefinition` | `None` | | +### `contracts.V2ActionDefinition` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +Declared metadata for one invokable agent action. + +Consumers authorize, group and filter actions from these fields. They must +never be inferred from the identifier string: `id` is caller-supplied on +invocation, so pattern-matching it turns an authorization decision into +something the caller controls. + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `id` | `str` | **required** | Action identifier used to invoke the action. | +| `mutating` | `bool` | True | Whether invoking the action changes agent-side state. Defaults to True so an undeclared action requires write permission. | +| `scope` | `Literal['workspace', 'mission', 'job']` | 'job' | Context the action operates within. | + ### `contracts.V2ActionRequest` **Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) @@ -719,7 +738,7 @@ A2UI-shaped resource import surface consumed by Studio. | Field | Type | Default | Description | |---|---|---|---| | `surfaces` | `list[str]` | — | | -| `actions` | `list[str]` | — | | +| `actions` | `list[contracts.V2ActionDefinition]` | — | | | `case_lanes` | `list[contracts.V2CaseLaneDefinition]` | — | | | `artifact_types` | `list[contracts.V2ArtifactTypeDefinition]` | — | | @@ -786,6 +805,7 @@ A2UI-shaped resource import surface consumed by Studio. | `surface` | `str` | **required** | | | `action` | `str` | **required** | | | `fields` | `list[contracts.V2AwaitingFieldDefinition]` | — | | +| `reopenable` | `bool` | False | Whether an already-answered step may be reopened and resubmitted with edited values. | ### `contracts.V2CaseLaneDefinition` @@ -904,6 +924,7 @@ A2UI-shaped resource import surface consumed by Studio. | `id` | `str` | **required** | | | `label` | `str` | **required** | | | `auto_surface` | `bool` | False | | +| `scope` | `Literal['workspace', 'mission', 'job']` | 'workspace' | | | `display` | `V2ResourceDisplayDefinition` | `None` | | ### `contracts.V2Effect` @@ -1562,7 +1583,7 @@ Standard error response model | `error` | `str` | **required** | | | `error_type` | `` | **required** | | | `detail` | `str` | `None` | | -| `timestamp` | `datetime` | datetime.datetime(2026, 8, 26, 17, 21, 1, 586513) | | +| `timestamp` | `datetime` | datetime.datetime(2026, 8, 29, 19, 33, 55, 605372) | | | `status_code` | `int` | **required** | | ### `routes.RegistrationRefreshRequest` @@ -1630,4 +1651,4 @@ A base class for creating Pydantic models. | `jti` | `str` | `None` | | -*Uploaded on 2026-08-26 17:21:01* +*Uploaded on 2026-08-29 19:33:55* From 593c43b723b7f15af560e98d3e1be29eb28dd4eb Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Sun, 30 Aug 2026 09:24:37 +0300 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=90=9B=20fix(contracts):=20keep=20exp?= =?UTF-8?q?licit=20action=20metadata=20when=20an=20id=20is=20declared=20tw?= =?UTF-8?q?ice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An id given both as a V2ActionDefinition and as a bare string in actions= marked the id bare regardless of which declaration was retained, so the derived entry then overwrote the explicit metadata. Bareness is now tracked per retained declaration and an explicit definition wins wherever the two appear. --- docs/CHANGELOG.md | 2 +- src/supervaizer/contracts.py | 45 ++++++++++++++++-------------------- tests/test_contracts.py | 32 +++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 26 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6ae5788..da81f7b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -20,7 +20,7 @@ All notable changes to this project will be documented in this file. The builder derives the metadata from the definition each action comes from: resource actions take `V2ResourceDefinition.scope`, dataset queries are `mutating=False`, `V2JobSetupPolicy.preview_action` is `mutating=False`, and workspace binding actions are `workspace`-scoped. Resource operation ids are freeform, so operations stay `mutating=True` unless the agent declares otherwise. - Metadata precedence is explicit > derived > bare: an explicit `V2ActionDefinition` in `actions=` overrides the derived metadata — that is how a job-scoped `resource.invoice.reconcile` on a workspace-scoped resource is declared — while a bare id string declares nothing and defers to the definition it was derived from. First mention fixes the order in either case. + Metadata precedence is explicit > derived > bare: an explicit `V2ActionDefinition` in `actions=` overrides the derived metadata — that is how a job-scoped `resource.invoice.reconcile` on a workspace-scoped resource is declared — while a bare id string declares nothing and defers to the definition it was derived from. Precedence is about metadata, not position: an id declared both ways within `actions=` keeps the explicit metadata regardless of which form comes first, and the earlier mention still fixes the order. - **`V2DatasetDefinition.scope`** — Mirrors `V2ResourceDefinition.scope` (`workspace` / `mission` / `job`, defaulting to `workspace`) so dataset query actions carry a declared scope rather than an assumed one. diff --git a/src/supervaizer/contracts.py b/src/supervaizer/contracts.py index 8955d04..545614a 100644 --- a/src/supervaizer/contracts.py +++ b/src/supervaizer/contracts.py @@ -661,9 +661,8 @@ def build_v2_agent_registration( *_dashboard_surface_ids(dashboard_definitions), *_workspace_binding_surface_ids(workspace_binding_definition), ]) - declared_actions = list(actions) capability_actions = _merge_actions( - declared=_declared_actions(declared_actions), + declared=actions, derived=[ *_resource_actions(resource_definitions), *_dataset_actions(dataset_definitions), @@ -672,7 +671,6 @@ def build_v2_agent_registration( *_workspace_binding_actions(workspace_binding_definition), *_agent_method_actions(agent_method_definitions), ], - bare_ids={action for action in declared_actions if isinstance(action, str)}, ) return SupervaizerV2AgentRegistrationContract( @@ -784,19 +782,6 @@ def _dashboard_surface_ids(dashboards: Iterable[V2DashboardDefinition]) -> list[ return [dashboard.surface for dashboard in dashboards] -def _declared_actions( - actions: Iterable[str | V2ActionDefinition | dict[str, Any]], -) -> list[V2ActionDefinition]: - return [ - action - if isinstance(action, V2ActionDefinition) - else V2ActionDefinition.model_validate( - {"id": action} if isinstance(action, str) else action - ) - for action in actions - ] - - def _resource_actions( resources: Iterable[V2ResourceDefinition], ) -> list[V2ActionDefinition]: @@ -868,26 +853,36 @@ def _workspace_binding_actions( def _merge_actions( - declared: list[V2ActionDefinition], - derived: list[V2ActionDefinition], - bare_ids: set[str], + declared: Iterable[str | V2ActionDefinition | dict[str, Any]], + derived: Iterable[V2ActionDefinition], ) -> list[V2ActionDefinition]: """Merge explicitly declared actions with the ones derived from definitions. First mention fixes the order. A bare id string in ``actions`` declares no metadata, so it defers to the definition it was derived from; an explicit - ``V2ActionDefinition`` overrides the derived one. + ``V2ActionDefinition`` overrides the derived one. Precedence is about + metadata, not position: an id declared both ways within ``actions`` keeps the + explicit metadata wherever the two appear, and the earlier mention still + fixes the order. """ - pending_bare = set(bare_ids) resolved: dict[str, V2ActionDefinition] = {} - for action in declared: - resolved.setdefault(action.id, action) + bare_ids: set[str] = set() + for entry in declared: + is_bare = isinstance(entry, str) + action = V2ActionDefinition.model_validate({"id": entry} if is_bare else entry) + if action.id not in resolved: + resolved[action.id] = action + if is_bare: + bare_ids.add(action.id) + elif not is_bare and action.id in bare_ids: + resolved[action.id] = action + bare_ids.discard(action.id) for action in derived: if action.id not in resolved: resolved[action.id] = action - elif action.id in pending_bare: + elif action.id in bare_ids: resolved[action.id] = action - pending_bare.discard(action.id) + bare_ids.discard(action.id) return list(resolved.values()) diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 1275671..d55d79c 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -543,6 +543,38 @@ def test_v2_declared_action_overrides_derived_metadata() -> None: assert len(registration.capabilities.actions) == 1 +@pytest.mark.parametrize("bare_first", [False, True]) +def test_v2_mixed_action_forms_keep_explicit_metadata(bare_first: bool) -> None: + """An id declared both ways keeps the explicit metadata, in either order.""" + explicit = V2ActionDefinition( + id="resource.invoices.reconcile", mutating=False, scope="job" + ) + declared: list[str | V2ActionDefinition] = ( + ["resource.invoices.reconcile", explicit] + if bare_first + else [explicit, "resource.invoices.reconcile"] + ) + registration = build_v2_agent_registration( + agent_id="agent-1", + agent_slug="agent", + display_name="Agent", + agent_card_url="/card.json", + controller_url="/a2a", + a2ui_catalog_version="supervaizer-v2-local.0", + actions=declared, + resources=[ + V2ResourceDefinition( + id="invoices", + label="Invoices", + scope="workspace", + operations=["reconcile"], + ) + ], + ) + + assert registration.capabilities.actions == [explicit] + + def test_v2_bare_action_string_defers_to_derived_metadata() -> None: """A bare id declares no metadata, so the setup policy's derivation wins.""" registration = build_v2_agent_registration( From 9763bbdf9bab893439c0c5102c4880e96860249a Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Sun, 30 Aug 2026 09:31:15 +0300 Subject: [PATCH 4/4] Changelog --- docs/CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index da81f7b..3ec28d8 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -26,6 +26,17 @@ All notable changes to this project will be documented in this file. - **`V2AwaitingState.reopenable`** — Declares whether an already-answered awaiting step may be reopened and resubmitted with edited values. Defaults to `False`; previously consumers inferred this from a substring of `surface`. +### Tests + +`just test` + +| Status | Count | +| ---------- | ----- | +| ✅ Passed | 698 | +| 🤔 Skipped | 0 | +| 🔴 Failed | 0 | +| ⏱️ in | 67s | + ## [1.5.0] - 2026-08-28 ### Added