counterpart 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,35 @@
1
+ """counterpart: test your A2A agent against simulated counterparty agents.
2
+
3
+ Test your A2A agent against simulated counterparties — cooperative, broken, or
4
+ hostile — before you connect it to a real one. The centerpiece is catching a peer
5
+ that reports success while returning incomplete or corrupt work (silent partial
6
+ completion), via declarative contract assertions and an adversarial persona suite.
7
+ """
8
+
9
+ __version__ = "0.1.0"
10
+
11
+ from typing import Any
12
+
13
+ from counterpart.adapters.a2a.client import A2AClient, TaskResult
14
+ from counterpart.adapters.a2a.mockagent import MockAgent
15
+ from counterpart.adapters.a2a.wrap import wrap
16
+ from counterpart.core.contract import Contract, ContractReport, FailureCategory
17
+ from counterpart.personas import available as available_personas
18
+
19
+
20
+ def mock_agent(persona: str = "cooperative", **config: Any) -> MockAgent:
21
+ """Convenience factory mirroring the pytest fixture: ``mock_agent(persona=...)``."""
22
+ return MockAgent(persona, **config)
23
+
24
+
25
+ __all__ = [
26
+ "A2AClient",
27
+ "Contract",
28
+ "ContractReport",
29
+ "FailureCategory",
30
+ "MockAgent",
31
+ "TaskResult",
32
+ "available_personas",
33
+ "mock_agent",
34
+ "wrap",
35
+ ]
@@ -0,0 +1 @@
1
+ """Protocol adapters. Each subpackage binds the protocol-agnostic core to one wire protocol."""
@@ -0,0 +1,151 @@
1
+ """A2A protocol adapter: protocol types today; Agent Card serving, task lifecycle,
2
+ JSON-RPC transport, and SSE streaming land in milestone 3.
3
+
4
+ Targets A2A spec v1.0 (verified against release tag v1.0.1); see docs/spec-notes.md.
5
+ """
6
+
7
+ from counterpart.adapters.a2a.constants import (
8
+ A2A_EXTENSIONS_HEADER,
9
+ A2A_VERSION_HEADER,
10
+ BINDING_GRPC,
11
+ BINDING_HTTP_JSON,
12
+ BINDING_JSONRPC,
13
+ ERROR_HTTP_STATUS,
14
+ ERROR_INFO_DOMAIN,
15
+ ERROR_INFO_TYPE_URL,
16
+ ERROR_NAMES,
17
+ JSONRPC_VERSION,
18
+ MEDIA_TYPE_A2A_JSON,
19
+ MEDIA_TYPE_JSON,
20
+ MEDIA_TYPE_SSE,
21
+ PROTOCOL_VERSION,
22
+ SPEC_RELEASE,
23
+ STANDARD_ERROR_MESSAGES,
24
+ WELL_KNOWN_AGENT_CARD_PATH,
25
+ A2AErrorCode,
26
+ A2AMethod,
27
+ error_reason,
28
+ )
29
+ from counterpart.adapters.a2a.types import (
30
+ AgentCapabilities,
31
+ AgentCard,
32
+ AgentCardSignature,
33
+ AgentExtension,
34
+ AgentInterface,
35
+ AgentProvider,
36
+ AgentSkill,
37
+ APIKeySecurityScheme,
38
+ Artifact,
39
+ AuthenticationInfo,
40
+ AuthorizationCodeOAuthFlow,
41
+ CancelTaskRequest,
42
+ ClientCredentialsOAuthFlow,
43
+ DeleteTaskPushNotificationConfigRequest,
44
+ DeviceCodeOAuthFlow,
45
+ GetExtendedAgentCardRequest,
46
+ GetTaskPushNotificationConfigRequest,
47
+ GetTaskRequest,
48
+ HTTPAuthSecurityScheme,
49
+ ImplicitOAuthFlow,
50
+ JSONRPCError,
51
+ JSONRPCErrorResponse,
52
+ JSONRPCRequest,
53
+ JSONRPCSuccessResponse,
54
+ ListTaskPushNotificationConfigsRequest,
55
+ ListTaskPushNotificationConfigsResponse,
56
+ ListTasksRequest,
57
+ ListTasksResponse,
58
+ Message,
59
+ MutualTlsSecurityScheme,
60
+ OAuth2SecurityScheme,
61
+ OAuthFlows,
62
+ OpenIdConnectSecurityScheme,
63
+ Part,
64
+ PasswordOAuthFlow,
65
+ Role,
66
+ SecurityRequirement,
67
+ SecurityScheme,
68
+ SendMessageConfiguration,
69
+ SendMessageRequest,
70
+ SendMessageResponse,
71
+ StreamResponse,
72
+ SubscribeToTaskRequest,
73
+ Task,
74
+ TaskArtifactUpdateEvent,
75
+ TaskPushNotificationConfig,
76
+ TaskState,
77
+ TaskStatus,
78
+ TaskStatusUpdateEvent,
79
+ )
80
+
81
+ __all__ = [
82
+ "A2A_EXTENSIONS_HEADER",
83
+ "A2A_VERSION_HEADER",
84
+ "BINDING_GRPC",
85
+ "BINDING_HTTP_JSON",
86
+ "BINDING_JSONRPC",
87
+ "ERROR_HTTP_STATUS",
88
+ "ERROR_INFO_DOMAIN",
89
+ "ERROR_INFO_TYPE_URL",
90
+ "ERROR_NAMES",
91
+ "JSONRPC_VERSION",
92
+ "MEDIA_TYPE_A2A_JSON",
93
+ "MEDIA_TYPE_JSON",
94
+ "MEDIA_TYPE_SSE",
95
+ "PROTOCOL_VERSION",
96
+ "SPEC_RELEASE",
97
+ "STANDARD_ERROR_MESSAGES",
98
+ "WELL_KNOWN_AGENT_CARD_PATH",
99
+ "A2AErrorCode",
100
+ "A2AMethod",
101
+ "APIKeySecurityScheme",
102
+ "AgentCapabilities",
103
+ "AgentCard",
104
+ "AgentCardSignature",
105
+ "AgentExtension",
106
+ "AgentInterface",
107
+ "AgentProvider",
108
+ "AgentSkill",
109
+ "Artifact",
110
+ "AuthenticationInfo",
111
+ "AuthorizationCodeOAuthFlow",
112
+ "CancelTaskRequest",
113
+ "ClientCredentialsOAuthFlow",
114
+ "DeleteTaskPushNotificationConfigRequest",
115
+ "DeviceCodeOAuthFlow",
116
+ "GetExtendedAgentCardRequest",
117
+ "GetTaskPushNotificationConfigRequest",
118
+ "GetTaskRequest",
119
+ "HTTPAuthSecurityScheme",
120
+ "ImplicitOAuthFlow",
121
+ "JSONRPCError",
122
+ "JSONRPCErrorResponse",
123
+ "JSONRPCRequest",
124
+ "JSONRPCSuccessResponse",
125
+ "ListTaskPushNotificationConfigsRequest",
126
+ "ListTaskPushNotificationConfigsResponse",
127
+ "ListTasksRequest",
128
+ "ListTasksResponse",
129
+ "Message",
130
+ "MutualTlsSecurityScheme",
131
+ "OAuth2SecurityScheme",
132
+ "OAuthFlows",
133
+ "OpenIdConnectSecurityScheme",
134
+ "Part",
135
+ "PasswordOAuthFlow",
136
+ "Role",
137
+ "SecurityRequirement",
138
+ "SecurityScheme",
139
+ "SendMessageConfiguration",
140
+ "SendMessageRequest",
141
+ "SendMessageResponse",
142
+ "StreamResponse",
143
+ "SubscribeToTaskRequest",
144
+ "Task",
145
+ "TaskArtifactUpdateEvent",
146
+ "TaskPushNotificationConfig",
147
+ "TaskState",
148
+ "TaskStatus",
149
+ "TaskStatusUpdateEvent",
150
+ "error_reason",
151
+ ]
@@ -0,0 +1,263 @@
1
+ """A2A client role: send a task to an A2A agent and get back a verifiable result.
2
+
3
+ Talks to any A2A v1.0 JSON-RPC agent — a real one over the network (``base_url``) or an
4
+ in-process one over ASGI (``app``, no socket, for fast/deterministic tests). Returns a
5
+ :class:`TaskResult` that carries the peer's self-reported status next to the extracted
6
+ result, and can verify both against a :class:`~counterpart.core.contract.Contract`.
7
+
8
+ v0 routes JSON-RPC to ``{base}/`` and fetches the card from the well-known path; honouring
9
+ the card's declared interface URL for routing is the conformance checker's job (roadmap).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import uuid
15
+ from collections.abc import Sequence
16
+ from dataclasses import dataclass, field
17
+ from typing import Any
18
+
19
+ import httpx
20
+
21
+ from counterpart.adapters.a2a.constants import (
22
+ A2A_VERSION_HEADER,
23
+ MEDIA_TYPE_JSON,
24
+ PROTOCOL_VERSION,
25
+ WELL_KNOWN_AGENT_CARD_PATH,
26
+ A2AMethod,
27
+ )
28
+ from counterpart.adapters.a2a.types import (
29
+ AgentCard,
30
+ Artifact,
31
+ JSONRPCRequest,
32
+ JSONRPCSuccessResponse,
33
+ Message,
34
+ Part,
35
+ Role,
36
+ SendMessageConfiguration,
37
+ SendMessageRequest,
38
+ StreamResponse,
39
+ Task,
40
+ TaskState,
41
+ )
42
+ from counterpart.core.contract import Contract, ContractReport
43
+
44
+
45
+ def _extract_result(task: Task) -> Any:
46
+ """The delegated result to verify: the latest artifact's data, else its text.
47
+
48
+ Prefers a structured ``data`` part over ``text`` regardless of part order (a contract's
49
+ ``.returns(Model)`` wants the structured payload), then falls back to text.
50
+ """
51
+ if not task.artifacts:
52
+ return None
53
+ parts = task.artifacts[-1].parts
54
+ for part in parts:
55
+ if part.data is not None:
56
+ return part.data
57
+ for part in parts:
58
+ if part.text is not None:
59
+ return part.text
60
+ return None
61
+
62
+
63
+ @dataclass
64
+ class TaskResult:
65
+ """The outcome of sending a task: the raw task, the states seen, and any contract report."""
66
+
67
+ task: Task
68
+ states: list[str] = field(default_factory=list) # friendly aliases, in order
69
+ report: ContractReport[Any] | None = None
70
+
71
+ @property
72
+ def status(self) -> str:
73
+ """The peer's final self-reported status, as a friendly alias (e.g. ``"completed"``)."""
74
+ return self.task.status.state.alias
75
+
76
+ @property
77
+ def result(self) -> Any:
78
+ return _extract_result(self.task)
79
+
80
+ @property
81
+ def artifacts(self) -> list[Artifact]:
82
+ return self.task.artifacts or []
83
+
84
+ @property
85
+ def completed(self) -> bool:
86
+ return self.task.status.state is TaskState.COMPLETED
87
+
88
+ @property
89
+ def contract_violated(self) -> bool:
90
+ """True if a contract was supplied and the delegated result did not satisfy it."""
91
+ return self.report is not None and self.report.contract_violated
92
+
93
+ def reached_state(self, state: str) -> bool:
94
+ return TaskState.coerce(state).alias in self.states
95
+
96
+
97
+ class A2AClient:
98
+ """An async A2A JSON-RPC client. Use ``base_url`` for a real agent, ``app`` for ASGI."""
99
+
100
+ def __init__(
101
+ self,
102
+ base_url: str | None = None,
103
+ *,
104
+ app: Any = None,
105
+ timeout: float = 30.0,
106
+ protocol_version: str = PROTOCOL_VERSION,
107
+ ) -> None:
108
+ if (base_url is None) == (app is None):
109
+ raise ValueError("provide exactly one of base_url or app")
110
+ self._protocol_version = protocol_version
111
+ if app is not None:
112
+ self._base = "http://mock.local"
113
+ transport: httpx.AsyncBaseTransport = httpx.ASGITransport(app=app)
114
+ self._http = httpx.AsyncClient(
115
+ transport=transport, base_url=self._base, timeout=timeout
116
+ )
117
+ else:
118
+ self._base = base_url.rstrip("/") # type: ignore[union-attr]
119
+ self._http = httpx.AsyncClient(base_url=self._base, timeout=timeout)
120
+
121
+ async def __aenter__(self) -> A2AClient:
122
+ return self
123
+
124
+ async def __aexit__(self, *exc: object) -> None:
125
+ await self.aclose()
126
+
127
+ async def aclose(self) -> None:
128
+ await self._http.aclose()
129
+
130
+ def _headers(self) -> dict[str, str]:
131
+ return {A2A_VERSION_HEADER: self._protocol_version, "content-type": MEDIA_TYPE_JSON}
132
+
133
+ async def resolve_card(self) -> AgentCard:
134
+ """Fetch and validate the counterparty's Agent Card (spec section 8.2)."""
135
+ resp = await self._http.get(WELL_KNOWN_AGENT_CARD_PATH, headers=self._headers())
136
+ resp.raise_for_status()
137
+ return AgentCard.from_wire(resp.content)
138
+
139
+ def _build_message(
140
+ self, text: str, *, data: Any, task_id: str | None, context_id: str | None
141
+ ) -> Message:
142
+ parts = [Part(text=text)] if text else []
143
+ if data is not None:
144
+ parts.append(Part(data=data))
145
+ if not parts:
146
+ parts = [Part(text="")]
147
+ return Message(
148
+ message_id=f"msg-{uuid.uuid4().hex}",
149
+ role=Role.USER,
150
+ parts=parts,
151
+ task_id=task_id,
152
+ context_id=context_id,
153
+ )
154
+
155
+ async def send_message(
156
+ self,
157
+ text: str,
158
+ *,
159
+ data: Any = None,
160
+ task_id: str | None = None,
161
+ context_id: str | None = None,
162
+ stream: bool = False,
163
+ contract: Contract[Any] | None = None,
164
+ ) -> TaskResult:
165
+ """Send a task (or a follow-up, if ``task_id`` is set) and return the result."""
166
+ message = self._build_message(text, data=data, task_id=task_id, context_id=context_id)
167
+ request = SendMessageRequest(
168
+ message=message,
169
+ configuration=SendMessageConfiguration(accepted_output_modes=["application/json"]),
170
+ )
171
+ if stream:
172
+ result = await self._send_streaming(request)
173
+ else:
174
+ result = await self._send_blocking(request)
175
+ if contract is not None:
176
+ result.report = contract.verify(result=result.result, reported_status=result.status)
177
+ return result
178
+
179
+ async def reply(
180
+ self, task_id: str, text: str, *, context_id: str | None = None, **kw: Any
181
+ ) -> TaskResult:
182
+ """Continue an interrupted task (e.g. answer an input-required question, spec 3.4.3)."""
183
+ return await self.send_message(text, task_id=task_id, context_id=context_id, **kw)
184
+
185
+ async def _send_blocking(self, request: SendMessageRequest) -> TaskResult:
186
+ rpc = JSONRPCRequest(method=A2AMethod.SEND_MESSAGE.value, id=1, params=request.to_wire())
187
+ resp = await self._http.post("/", content=rpc.to_wire_json(), headers=self._headers())
188
+ resp.raise_for_status()
189
+ body = resp.json()
190
+ # JSON-RPC errors arrive over HTTP 200, so surface them instead of dropping them.
191
+ if isinstance(body, dict) and body.get("error") is not None:
192
+ err = body["error"]
193
+ raise A2AProtocolError(
194
+ f"peer returned JSON-RPC error {err.get('code')}: {err.get('message')}"
195
+ )
196
+ result = body.get("result") if isinstance(body, dict) else None
197
+ if not isinstance(result, dict) or "task" not in result:
198
+ raise A2AProtocolError(f"expected a task result, got: {result!r}")
199
+ task = Task.from_wire(result["task"])
200
+ return TaskResult(task=task, states=[task.status.state.alias])
201
+
202
+ async def _send_streaming(self, request: SendMessageRequest) -> TaskResult:
203
+ rpc = JSONRPCRequest(
204
+ method=A2AMethod.SEND_STREAMING_MESSAGE.value, id=1, params=request.to_wire()
205
+ )
206
+ task: Task | None = None
207
+ states: list[str] = []
208
+ # Accumulate artifacts by id so chunked updates (append=true) are reassembled
209
+ # rather than duplicated (spec 4.2.2).
210
+ artifacts: dict[str, Artifact] = {}
211
+ async with self._http.stream(
212
+ "POST", "/", content=rpc.to_wire_json(), headers=self._headers()
213
+ ) as resp:
214
+ resp.raise_for_status()
215
+ async for line in resp.aiter_lines():
216
+ if not line.startswith("data:"):
217
+ continue
218
+ envelope = JSONRPCSuccessResponse.from_wire(line[len("data:") :].strip())
219
+ event = StreamResponse.from_wire(envelope.result)
220
+ if event.task is not None:
221
+ task = event.task
222
+ states.append(event.task.status.state.alias)
223
+ elif event.status_update is not None:
224
+ task = _apply_status(task, event.status_update)
225
+ states.append(event.status_update.status.state.alias)
226
+ elif event.artifact_update is not None:
227
+ _accumulate_artifact(artifacts, event.artifact_update)
228
+ if task is None:
229
+ raise A2AProtocolError("stream produced no task")
230
+ if artifacts:
231
+ task = task.model_copy(update={"artifacts": list(artifacts.values())})
232
+ return TaskResult(task=task, states=states)
233
+
234
+
235
+ class A2AProtocolError(Exception):
236
+ """The peer's response did not conform to what the A2A method promises."""
237
+
238
+
239
+ def _apply_status(task: Task | None, event: Any) -> Task:
240
+ if task is None:
241
+ raise A2AProtocolError("status update arrived before the initial task event")
242
+ return task.model_copy(update={"status": event.status})
243
+
244
+
245
+ def _accumulate_artifact(artifacts: dict[str, Artifact], event: Any) -> None:
246
+ """Merge a TaskArtifactUpdateEvent into the id-keyed accumulator (spec 4.2.2).
247
+
248
+ ``append=true`` extends the parts of an already-seen artifact with the same id;
249
+ otherwise the artifact replaces (or first establishes) that id.
250
+ """
251
+ art = event.artifact
252
+ if event.append and art.artifact_id in artifacts:
253
+ existing = artifacts[art.artifact_id]
254
+ artifacts[art.artifact_id] = existing.model_copy(
255
+ update={"parts": [*existing.parts, *art.parts]}
256
+ )
257
+ else:
258
+ artifacts[art.artifact_id] = art
259
+
260
+
261
+ def collect_states(results: Sequence[TaskResult]) -> list[str]:
262
+ """Small helper: flatten the states seen across several results (for assertions)."""
263
+ return [s for r in results for s in r.states]
@@ -0,0 +1,151 @@
1
+ """A2A protocol constants, verified against spec v1.0 (tag v1.0.1).
2
+
3
+ Every value here is traceable to docs/spec-notes.md, which cites the spec section it
4
+ came from. Do not "fix" a value without re-checking the spec.
5
+ """
6
+
7
+ from enum import IntEnum, StrEnum
8
+ from typing import Final
9
+
10
+ # --- Versioning (spec section 3.6) ---
11
+ PROTOCOL_VERSION: Final = "1.0" # Major.Minor only; patch numbers never go on the wire
12
+ SPEC_RELEASE: Final = "v1.0.1" # the spec release these constants were verified against
13
+
14
+ # --- Service parameters (spec sections 3.2.6, 14.2) ---
15
+ A2A_VERSION_HEADER: Final = "A2A-Version" # empty/absent MUST be interpreted as 0.3
16
+ A2A_EXTENSIONS_HEADER: Final = "A2A-Extensions" # comma-separated extension URIs
17
+
18
+ # --- Discovery (spec sections 8.2, 14.3) ---
19
+ WELL_KNOWN_AGENT_CARD_PATH: Final = "/.well-known/agent-card.json"
20
+
21
+ # --- Media types (spec sections 9.1, 14.1.1) ---
22
+ MEDIA_TYPE_JSON: Final = "application/json" # JSON-RPC binding
23
+ MEDIA_TYPE_A2A_JSON: Final = "application/a2a+json" # HTTP+JSON/REST binding
24
+ MEDIA_TYPE_SSE: Final = "text/event-stream" # streaming responses
25
+
26
+ # --- Protocol bindings (spec section 4.4.6: AgentInterface.protocolBinding) ---
27
+ BINDING_JSONRPC: Final = "JSONRPC"
28
+ BINDING_GRPC: Final = "GRPC"
29
+ BINDING_HTTP_JSON: Final = "HTTP+JSON"
30
+
31
+ JSONRPC_VERSION: Final = "2.0"
32
+
33
+
34
+ class A2AMethod(StrEnum):
35
+ """The complete v1.0 method set (spec section 5.3 Method Mapping Reference).
36
+
37
+ These exact PascalCase strings are the JSON-RPC ``method`` values and the gRPC rpc
38
+ names (spec section 9.1). The v0.3 slash-style names (``message/send``, ...) do not
39
+ exist in v1.0.
40
+ """
41
+
42
+ SEND_MESSAGE = "SendMessage"
43
+ SEND_STREAMING_MESSAGE = "SendStreamingMessage"
44
+ GET_TASK = "GetTask"
45
+ LIST_TASKS = "ListTasks"
46
+ CANCEL_TASK = "CancelTask"
47
+ SUBSCRIBE_TO_TASK = "SubscribeToTask"
48
+ CREATE_TASK_PUSH_NOTIFICATION_CONFIG = "CreateTaskPushNotificationConfig"
49
+ GET_TASK_PUSH_NOTIFICATION_CONFIG = "GetTaskPushNotificationConfig"
50
+ LIST_TASK_PUSH_NOTIFICATION_CONFIGS = "ListTaskPushNotificationConfigs"
51
+ DELETE_TASK_PUSH_NOTIFICATION_CONFIG = "DeleteTaskPushNotificationConfig"
52
+ GET_EXTENDED_AGENT_CARD = "GetExtendedAgentCard"
53
+
54
+
55
+ class A2AErrorCode(IntEnum):
56
+ """JSON-RPC error codes: standard (spec section 9.5) + A2A-specific (section 5.4).
57
+
58
+ A2A-specific errors use the range -32001..-32099; only -32001..-32009 are assigned
59
+ and -32000 is not defined in v1.0.
60
+ """
61
+
62
+ # Standard JSON-RPC 2.0 (spec section 9.5)
63
+ JSON_PARSE_ERROR = -32700
64
+ INVALID_REQUEST = -32600
65
+ METHOD_NOT_FOUND = -32601
66
+ INVALID_PARAMS = -32602
67
+ INTERNAL_ERROR = -32603
68
+ # A2A-specific (spec section 5.4)
69
+ TASK_NOT_FOUND = -32001
70
+ TASK_NOT_CANCELABLE = -32002
71
+ PUSH_NOTIFICATION_NOT_SUPPORTED = -32003
72
+ UNSUPPORTED_OPERATION = -32004
73
+ CONTENT_TYPE_NOT_SUPPORTED = -32005
74
+ INVALID_AGENT_RESPONSE = -32006
75
+ EXTENDED_AGENT_CARD_NOT_CONFIGURED = -32007
76
+ EXTENSION_SUPPORT_REQUIRED = -32008
77
+ VERSION_NOT_SUPPORTED = -32009
78
+
79
+
80
+ # Error type names exactly as the spec tables write them (sections 5.4, 9.5).
81
+ ERROR_NAMES: Final[dict[A2AErrorCode, str]] = {
82
+ A2AErrorCode.JSON_PARSE_ERROR: "JSONParseError",
83
+ A2AErrorCode.INVALID_REQUEST: "InvalidRequestError",
84
+ A2AErrorCode.METHOD_NOT_FOUND: "MethodNotFoundError",
85
+ A2AErrorCode.INVALID_PARAMS: "InvalidParamsError",
86
+ A2AErrorCode.INTERNAL_ERROR: "InternalError",
87
+ A2AErrorCode.TASK_NOT_FOUND: "TaskNotFoundError",
88
+ A2AErrorCode.TASK_NOT_CANCELABLE: "TaskNotCancelableError",
89
+ A2AErrorCode.PUSH_NOTIFICATION_NOT_SUPPORTED: "PushNotificationNotSupportedError",
90
+ A2AErrorCode.UNSUPPORTED_OPERATION: "UnsupportedOperationError",
91
+ A2AErrorCode.CONTENT_TYPE_NOT_SUPPORTED: "ContentTypeNotSupportedError",
92
+ A2AErrorCode.INVALID_AGENT_RESPONSE: "InvalidAgentResponseError",
93
+ A2AErrorCode.EXTENDED_AGENT_CARD_NOT_CONFIGURED: "ExtendedAgentCardNotConfiguredError",
94
+ A2AErrorCode.EXTENSION_SUPPORT_REQUIRED: "ExtensionSupportRequiredError",
95
+ A2AErrorCode.VERSION_NOT_SUPPORTED: "VersionNotSupportedError",
96
+ }
97
+
98
+ # Normative default messages exist ONLY for the five standard JSON-RPC codes
99
+ # (spec section 9.5 table, "Standard Message" column). A2A-specific errors have no
100
+ # normative message strings: match on code, never on message text.
101
+ STANDARD_ERROR_MESSAGES: Final[dict[A2AErrorCode, str]] = {
102
+ A2AErrorCode.JSON_PARSE_ERROR: "Invalid JSON payload",
103
+ A2AErrorCode.INVALID_REQUEST: "Request payload validation error",
104
+ A2AErrorCode.METHOD_NOT_FOUND: "Method not found",
105
+ A2AErrorCode.INVALID_PARAMS: "Invalid parameters",
106
+ A2AErrorCode.INTERNAL_ERROR: "Internal error",
107
+ }
108
+
109
+ # HTTP status the same error maps to in the HTTP+JSON binding (spec section 5.4).
110
+ # Useful to the conformance checker; the JSON-RPC binding itself replies HTTP 200.
111
+ ERROR_HTTP_STATUS: Final[dict[A2AErrorCode, int]] = {
112
+ A2AErrorCode.JSON_PARSE_ERROR: 400,
113
+ A2AErrorCode.INVALID_REQUEST: 400,
114
+ A2AErrorCode.METHOD_NOT_FOUND: 404,
115
+ A2AErrorCode.INVALID_PARAMS: 400,
116
+ A2AErrorCode.INTERNAL_ERROR: 500,
117
+ A2AErrorCode.TASK_NOT_FOUND: 404,
118
+ A2AErrorCode.TASK_NOT_CANCELABLE: 400,
119
+ A2AErrorCode.PUSH_NOTIFICATION_NOT_SUPPORTED: 400,
120
+ A2AErrorCode.UNSUPPORTED_OPERATION: 400,
121
+ A2AErrorCode.CONTENT_TYPE_NOT_SUPPORTED: 400,
122
+ A2AErrorCode.INVALID_AGENT_RESPONSE: 500,
123
+ A2AErrorCode.EXTENDED_AGENT_CARD_NOT_CONFIGURED: 400,
124
+ A2AErrorCode.EXTENSION_SUPPORT_REQUIRED: 400,
125
+ A2AErrorCode.VERSION_NOT_SUPPORTED: 400,
126
+ }
127
+
128
+
129
+ def error_reason(code: A2AErrorCode) -> str:
130
+ """``google.rpc.ErrorInfo.reason`` for an A2A error (spec sections 10.6, 11.6).
131
+
132
+ The error type name in UPPER_SNAKE_CASE without the ``Error`` suffix, e.g.
133
+ ``TaskNotFoundError`` -> ``TASK_NOT_FOUND``. The spec states this rule for the
134
+ A2A-specific errors; we apply the same derivation to the standard JSON-RPC names
135
+ (e.g. ``InternalError`` -> ``INTERNAL``). ``domain`` is always ``a2a-protocol.org``.
136
+ """
137
+ name = ERROR_NAMES[code].removesuffix("Error")
138
+ # CamelCase -> UPPER_SNAKE, keeping acronym runs together ("JSONParse" -> "JSON_PARSE").
139
+ out: list[str] = []
140
+ for i, ch in enumerate(name):
141
+ starts_word = ch.isupper() and i > 0
142
+ after_lower = i > 0 and not name[i - 1].isupper()
143
+ before_lower = i + 1 < len(name) and name[i + 1].islower()
144
+ if starts_word and (after_lower or before_lower):
145
+ out.append("_")
146
+ out.append(ch)
147
+ return "".join(out).upper()
148
+
149
+
150
+ ERROR_INFO_DOMAIN: Final = "a2a-protocol.org"
151
+ ERROR_INFO_TYPE_URL: Final = "type.googleapis.com/google.rpc.ErrorInfo"