From ba3edf814f95adc5702980136ea698e9062ffa3d Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Wed, 30 Sep 2026 08:21:07 -1000 Subject: [PATCH 1/4] feat(protocol): a canonical attribution-probe/v1 extension The A2A validator asks an agent which project and task attribution it last sent upstream, through urn:agentenv:attribution-probe/v1 at POST /ext/attribution-probe. Hand-written agents advertise it, but an AgentEnvAgent cannot: reserved urn:agentenv: URIs must use a canonical SDK definition, and there was none, so the runtime attribution check could only record "skipped". ATTRIBUTION_PROBE_V1 has one runtime-owned, bodyless operation, probe, answered with AttributionProbeResponse: {last_seen_attribution: {project_id?, task_id?}, last_seen_at_utc?}, the shape the validator already reads. The SDK records nothing itself; only the agent knows what it sent. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/agentenv-protocol/README.md | 8 ++ .../agentenv_protocol/a2a_agent/__init__.py | 6 ++ .../agentenv_protocol/a2a_agent/extensions.py | 26 +++++++ .../agentenv-protocol/tests/test_a2a_agent.py | 78 +++++++++++++++++++ 4 files changed, 118 insertions(+) diff --git a/packages/agentenv-protocol/README.md b/packages/agentenv-protocol/README.md index 45f6f3f8..6d51f501 100644 --- a/packages/agentenv-protocol/README.md +++ b/packages/agentenv-protocol/README.md @@ -472,6 +472,14 @@ tools with (agent-env relays the env card's name: the MultiEnv's declared name, `env` + 4 random digits, giving e.g. `mcp__env4821__`); when absent the agent mints `mcp_<8 hex>`. +`ATTRIBUTION_PROBE_V1.probe` lets a caller check that an agent forwards attribution +upstream. An agent that sends the caller's project and task identifiers with its +model requests (typically from `project_id` and `task_id` config fields it +declares) records the last values it sent, and returns them from a bodyless +`POST /ext/attribution-probe` as an `AttributionProbeResponse`: +`{"last_seen_attribution": {"project_id": ..., "task_id": ...}, "last_seen_at_utc": ...}`. +The SDK does not record attribution itself, because only the agent knows what it sent. + Runnable, self-contained reference agents live in [`examples/`](examples/): the normal, streaming, and multimodal `run(request)` paths, single- and multi-operation custom extensions, and advanced ASGI-lifespan plus diff --git a/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/__init__.py b/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/__init__.py index f0a798f3..05b19511 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/__init__.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/__init__.py @@ -4,6 +4,7 @@ from .extensions import ( AGENT_CONFIG_V1, + ATTRIBUTION_PROBE_V1, INSTALL_V1, MCP_CONFIG_V1, PEER_AGENTS_V1, @@ -12,6 +13,7 @@ STANDARD_EXTENSIONS, TRAJECTORY_V1, TRIGGERS_V1, + AttributionProbeResponse, BundleSkillRequest, ChangelogIncrement, ContextObjectTrajectoryRequest, @@ -24,6 +26,7 @@ FieldSchema, ImplementationOwner, InlineSkillRequest, + LastSeenAttribution, McpAddRequest, NamespaceChangelogEnableRequest, NamespaceChangelogEnableResponse, @@ -108,6 +111,7 @@ __all__ = [ "AGENT_CONFIG_V1", + "ATTRIBUTION_PROBE_V1", "INSTALL_V1", "MCP_CONFIG_V1", "PEER_AGENTS_V1", @@ -122,6 +126,7 @@ "AgentEnvAgent", "AgentIdentity", "AgentRunResult", + "AttributionProbeResponse", "BundleSkillRequest", "ChangelogIncrement", "ContextObjectTrajectoryRequest", @@ -141,6 +146,7 @@ "HttpPutGrant", "ImplementationOwner", "InlineSkillRequest", + "LastSeenAttribution", "McpAddRequest", "NamespaceChangelogEnableRequest", "NamespaceChangelogEnableResponse", diff --git a/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/extensions.py b/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/extensions.py index ac4c50ef..8e79aec9 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/extensions.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/extensions.py @@ -231,6 +231,16 @@ class PeerAgentsSetRequest(ExtensionRequest): peers: list[PeerAgent] +class LastSeenAttribution(ExtensionResponse): + project_id: str | None = None + task_id: str | None = None + + +class AttributionProbeResponse(ExtensionResponse): + last_seen_attribution: LastSeenAttribution + last_seen_at_utc: str | None = None + + class TriggerRegisterRequest(ExtensionRequest): triggers: list[dict[str, Any]] @@ -1037,6 +1047,21 @@ def _configure_install(**declaration: Any) -> ExtensionConfiguration: }, ) +ATTRIBUTION_PROBE_V1 = ExtensionDefinition( + uri="urn:agentenv:attribution-probe/v1", + description="Report the project and task attribution last sent with a model request.", + endpoint="/ext/attribution-probe", + core_operations={ + "probe": _op( + "probe", + "POST", + "/ext/attribution-probe", + RUNTIME, + response_model=AttributionProbeResponse, + ), + }, +) + TRIGGERS_V1 = ExtensionDefinition( uri="urn:agentenv:triggers/v1", description="Register triggers and decide deterministic responses.", @@ -1084,6 +1109,7 @@ def _configure_install(**declaration: Any) -> ExtensionConfiguration: TRAJECTORY_V1, SNAPSHOT_V1, PEER_AGENTS_V1, + ATTRIBUTION_PROBE_V1, TRIGGERS_V1, INSTALL_V1, ) diff --git a/packages/agentenv-protocol/tests/test_a2a_agent.py b/packages/agentenv-protocol/tests/test_a2a_agent.py index a98a82e9..376acf37 100644 --- a/packages/agentenv-protocol/tests/test_a2a_agent.py +++ b/packages/agentenv-protocol/tests/test_a2a_agent.py @@ -30,6 +30,7 @@ ) from agentenv_protocol.a2a_agent import ( AGENT_CONFIG_V1, + ATTRIBUTION_PROBE_V1, MCP_CONFIG_V1, PEER_AGENTS_V1, SKILL_CONFIG_V1, @@ -40,6 +41,7 @@ AgentConfig, AgentEnvAgent, AgentIdentity, + AttributionProbeResponse, BundleSkillRequest, ContextObjectTrajectoryRequest, ContextTrajectoryRequest, @@ -50,6 +52,7 @@ FieldSchema, ImplementationOwner, InlineSkillRequest, + LastSeenAttribution, McpAddRequest, NamespaceChangelogEnableRequest, NativeTrajectory, @@ -1348,6 +1351,81 @@ async def list_peers(self): } +def test_attribution_probe_reports_what_the_agent_last_sent() -> None: + @a2a_agent( + identity=AgentIdentity(name="attributing-agent", description="test", version="1") + ) + class Agent(AgentEnvAgent): + def __init__(self) -> None: + self.sent = LastSeenAttribution() + + async def run(self, request: TaskRequest) -> TaskResult: + self.sent = LastSeenAttribution(project_id="project-1", task_id="task-1") + return TaskResult.text("ok") + + @extension(ATTRIBUTION_PROBE_V1.probe) + async def probe(self): + return AttributionProbeResponse( + last_seen_attribution=self.sent, last_seen_at_utc="2026-01-01T00:00:00Z" + ) + + agent = Agent() + with TestClient(agent.create_app()) as client: + card = client.get("/.well-known/agent.json").json() + assert _extension(card, ATTRIBUTION_PROBE_V1.uri)["params"]["endpoint"] == ( + "/ext/attribution-probe" + ) + assert _operation(client, card, ATTRIBUTION_PROBE_V1.uri, "probe", {}).json() == { + "last_seen_attribution": {}, + "last_seen_at_utc": "2026-01-01T00:00:00Z", + } + agent.sent = LastSeenAttribution(project_id="project-1", task_id="task-1") + assert _operation(client, card, ATTRIBUTION_PROBE_V1.uri, "probe", {}).json() == { + "last_seen_attribution": {"project_id": "project-1", "task_id": "task-1"}, + "last_seen_at_utc": "2026-01-01T00:00:00Z", + } + assert ( + _operation( + client, card, ATTRIBUTION_PROBE_V1.uri, "probe", {"project_id": "x"} + ).status_code + == 400 + ) + + +def test_attribution_probe_rejects_an_undeclared_response_field() -> None: + @a2a_agent( + identity=AgentIdentity(name="attributing-agent", description="test", version="1") + ) + class Agent(AgentEnvAgent): + async def run(self, request: TaskRequest) -> TaskResult: + return TaskResult.text("ok") + + @extension(ATTRIBUTION_PROBE_V1.probe) + async def probe(self): + return {"last_seen_attribution": {}, "headers": {"x-secret": "value"}} + + with TestClient(Agent().create_app(), raise_server_exceptions=False) as client: + card = client.get("/.well-known/agent.json").json() + response = _operation(client, card, ATTRIBUTION_PROBE_V1.uri, "probe", {}) + assert response.status_code == 500 + assert "x-secret" not in response.text + + +def test_attribution_probe_is_only_declared_by_a_handler() -> None: + @a2a_agent( + identity=AgentIdentity(name="plain-agent", description="test", version="1") + ) + class Agent(AgentEnvAgent): + async def run(self, request: TaskRequest) -> TaskResult: + return TaskResult.text("ok") + + with TestClient(Agent().create_app()) as client: + card = client.get("/.well-known/agent.json").json() + uris = {item["uri"] for item in card["capabilities"]["extensions"]} + assert ATTRIBUTION_PROBE_V1.uri not in uris + assert client.post("/ext/attribution-probe", json={}).status_code in (404, 405) + + def test_handler_advertises_the_canonical_request_model() -> None: class Agent: @extension(SNAPSHOT_V1.save) From 6ddb1372562d0b8824ea2f27fe3b6c12e225fbed Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Wed, 30 Sep 2026 08:29:32 -1000 Subject: [PATCH 2/4] test(protocol): drive the attribution probe through a real task The probe test now sends a message through the A2A endpoint before reading the updated answer, so it fails if running a task stops updating what the probe reports. The README notes that the answer is agent-wide rather than per caller, and that the route carries no access check of its own. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/agentenv-protocol/README.md | 3 +++ packages/agentenv-protocol/tests/test_a2a_agent.py | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/agentenv-protocol/README.md b/packages/agentenv-protocol/README.md index 6d51f501..fae7c55e 100644 --- a/packages/agentenv-protocol/README.md +++ b/packages/agentenv-protocol/README.md @@ -479,6 +479,9 @@ declares) records the last values it sent, and returns them from a bodyless `POST /ext/attribution-probe` as an `AttributionProbeResponse`: `{"last_seen_attribution": {"project_id": ..., "task_id": ...}, "last_seen_at_utc": ...}`. The SDK does not record attribution itself, because only the agent knows what it sent. +The answer is agent-wide rather than per caller, and like every extension route the +probe carries no access check of its own, so expose an agent only to the control plane +that drives it. Runnable, self-contained reference agents live in [`examples/`](examples/): the normal, streaming, and multimodal `run(request)` paths, single- and diff --git a/packages/agentenv-protocol/tests/test_a2a_agent.py b/packages/agentenv-protocol/tests/test_a2a_agent.py index 376acf37..99041276 100644 --- a/packages/agentenv-protocol/tests/test_a2a_agent.py +++ b/packages/agentenv-protocol/tests/test_a2a_agent.py @@ -1379,7 +1379,8 @@ async def probe(self): "last_seen_attribution": {}, "last_seen_at_utc": "2026-01-01T00:00:00Z", } - agent.sent = LastSeenAttribution(project_id="project-1", task_id="task-1") + response = client.post("/a2a", json=_message_request()) + assert response.json()["result"]["status"]["state"] == "completed" assert _operation(client, card, ATTRIBUTION_PROBE_V1.uri, "probe", {}).json() == { "last_seen_attribution": {"project_id": "project-1", "task_id": "task-1"}, "last_seen_at_utc": "2026-01-01T00:00:00Z", From 19aaa1eee96864ea2db91e49b2a09b49acd53cd3 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Wed, 30 Sep 2026 11:19:33 -1000 Subject: [PATCH 3/4] feat(protocol): report attribution as an open map of dimensions The probe's answer was a closed {project_id, task_id} model, which put one deployment's attribution vocabulary into the protocol. AgentEnv's cost attribution is already an open dict of string dimensions that the framework threads without reading, leaving each backend to pick the keys it understands. The probe now does the same: last_seen_attribution is a dict[str, str] of whatever dimensions the agent last sent with a model request, and LastSeenAttribution is gone. The validator keeps reading project_id and task_id out of it. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/agentenv-protocol/README.md | 10 ++++++---- .../src/agentenv_protocol/a2a_agent/__init__.py | 2 -- .../src/agentenv_protocol/a2a_agent/extensions.py | 9 ++------- packages/agentenv-protocol/tests/test_a2a_agent.py | 11 +++++++---- 4 files changed, 15 insertions(+), 17 deletions(-) diff --git a/packages/agentenv-protocol/README.md b/packages/agentenv-protocol/README.md index fae7c55e..5b872003 100644 --- a/packages/agentenv-protocol/README.md +++ b/packages/agentenv-protocol/README.md @@ -473,11 +473,13 @@ tools with (agent-env relays the env card's name: the MultiEnv's declared name, `mcp_<8 hex>`. `ATTRIBUTION_PROBE_V1.probe` lets a caller check that an agent forwards attribution -upstream. An agent that sends the caller's project and task identifiers with its -model requests (typically from `project_id` and `task_id` config fields it -declares) records the last values it sent, and returns them from a bodyless +upstream. Attribution is an open map of string dimensions, like AgentEnv's cost +attribution: the SDK assigns no keys, and each deployment uses whatever dimensions +its model gateway understands (the AgentEnv control plane configures `project_id` +and `task_id` on agents that declare those config fields). An agent records the +dimensions it last sent with a model request and returns them from a bodyless `POST /ext/attribution-probe` as an `AttributionProbeResponse`: -`{"last_seen_attribution": {"project_id": ..., "task_id": ...}, "last_seen_at_utc": ...}`. +`{"last_seen_attribution": {: , ...}, "last_seen_at_utc": ...}`. The SDK does not record attribution itself, because only the agent knows what it sent. The answer is agent-wide rather than per caller, and like every extension route the probe carries no access check of its own, so expose an agent only to the control plane diff --git a/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/__init__.py b/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/__init__.py index 05b19511..f62bbf5b 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/__init__.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/__init__.py @@ -26,7 +26,6 @@ FieldSchema, ImplementationOwner, InlineSkillRequest, - LastSeenAttribution, McpAddRequest, NamespaceChangelogEnableRequest, NamespaceChangelogEnableResponse, @@ -146,7 +145,6 @@ "HttpPutGrant", "ImplementationOwner", "InlineSkillRequest", - "LastSeenAttribution", "McpAddRequest", "NamespaceChangelogEnableRequest", "NamespaceChangelogEnableResponse", diff --git a/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/extensions.py b/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/extensions.py index 8e79aec9..a02291fe 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/extensions.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/extensions.py @@ -231,13 +231,8 @@ class PeerAgentsSetRequest(ExtensionRequest): peers: list[PeerAgent] -class LastSeenAttribution(ExtensionResponse): - project_id: str | None = None - task_id: str | None = None - - class AttributionProbeResponse(ExtensionResponse): - last_seen_attribution: LastSeenAttribution + last_seen_attribution: dict[str, str] = Field(default_factory=dict) last_seen_at_utc: str | None = None @@ -1049,7 +1044,7 @@ def _configure_install(**declaration: Any) -> ExtensionConfiguration: ATTRIBUTION_PROBE_V1 = ExtensionDefinition( uri="urn:agentenv:attribution-probe/v1", - description="Report the project and task attribution last sent with a model request.", + description="Report the attribution dimensions last sent with a model request.", endpoint="/ext/attribution-probe", core_operations={ "probe": _op( diff --git a/packages/agentenv-protocol/tests/test_a2a_agent.py b/packages/agentenv-protocol/tests/test_a2a_agent.py index 99041276..56833d75 100644 --- a/packages/agentenv-protocol/tests/test_a2a_agent.py +++ b/packages/agentenv-protocol/tests/test_a2a_agent.py @@ -52,7 +52,6 @@ FieldSchema, ImplementationOwner, InlineSkillRequest, - LastSeenAttribution, McpAddRequest, NamespaceChangelogEnableRequest, NativeTrajectory, @@ -1357,10 +1356,10 @@ def test_attribution_probe_reports_what_the_agent_last_sent() -> None: ) class Agent(AgentEnvAgent): def __init__(self) -> None: - self.sent = LastSeenAttribution() + self.sent: dict[str, str] = {} async def run(self, request: TaskRequest) -> TaskResult: - self.sent = LastSeenAttribution(project_id="project-1", task_id="task-1") + self.sent = {"project_id": "project-1", "task_id": "task-1", "team": "evals"} return TaskResult.text("ok") @extension(ATTRIBUTION_PROBE_V1.probe) @@ -1382,7 +1381,11 @@ async def probe(self): response = client.post("/a2a", json=_message_request()) assert response.json()["result"]["status"]["state"] == "completed" assert _operation(client, card, ATTRIBUTION_PROBE_V1.uri, "probe", {}).json() == { - "last_seen_attribution": {"project_id": "project-1", "task_id": "task-1"}, + "last_seen_attribution": { + "project_id": "project-1", + "task_id": "task-1", + "team": "evals", + }, "last_seen_at_utc": "2026-01-01T00:00:00Z", } assert ( From da58d002c3c2925704194435656b446ee594da70 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Wed, 30 Sep 2026 11:53:50 -1000 Subject: [PATCH 4/4] fix(protocol): keep last_seen_attribution a required field of the probe answer Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/agentenv_protocol/a2a_agent/extensions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/extensions.py b/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/extensions.py index a02291fe..bbe10112 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/extensions.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/extensions.py @@ -232,7 +232,7 @@ class PeerAgentsSetRequest(ExtensionRequest): class AttributionProbeResponse(ExtensionResponse): - last_seen_attribution: dict[str, str] = Field(default_factory=dict) + last_seen_attribution: dict[str, str] last_seen_at_utc: str | None = None