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.
- counterpart/__init__.py +35 -0
- counterpart/adapters/__init__.py +1 -0
- counterpart/adapters/a2a/__init__.py +151 -0
- counterpart/adapters/a2a/client.py +263 -0
- counterpart/adapters/a2a/constants.py +151 -0
- counterpart/adapters/a2a/mockagent.py +144 -0
- counterpart/adapters/a2a/server.py +434 -0
- counterpart/adapters/a2a/types.py +907 -0
- counterpart/adapters/a2a/wrap.py +78 -0
- counterpart/cli/__init__.py +1 -0
- counterpart/cli/checks.py +405 -0
- counterpart/cli/main.py +133 -0
- counterpart/conformance/__init__.py +1 -0
- counterpart/core/__init__.py +60 -0
- counterpart/core/behaviour.py +148 -0
- counterpart/core/contract.py +261 -0
- counterpart/core/lifecycle.py +124 -0
- counterpart/personas/__init__.py +70 -0
- counterpart/personas/library.py +144 -0
- counterpart/py.typed +0 -0
- counterpart/pytest_plugin/__init__.py +8 -0
- counterpart/pytest_plugin/plugin.py +75 -0
- counterpart-0.1.0.dist-info/METADATA +228 -0
- counterpart-0.1.0.dist-info/RECORD +27 -0
- counterpart-0.1.0.dist-info/WHEEL +4 -0
- counterpart-0.1.0.dist-info/entry_points.txt +5 -0
- counterpart-0.1.0.dist-info/licenses/LICENSE +216 -0
counterpart/__init__.py
ADDED
|
@@ -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"
|