agentforge-a2a 0.2.1__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.
- agentforge_a2a/__init__.py +58 -0
- agentforge_a2a/_inmem_runner.py +163 -0
- agentforge_a2a/_runner.py +242 -0
- agentforge_a2a/auth.py +92 -0
- agentforge_a2a/bridge.py +148 -0
- agentforge_a2a/client.py +332 -0
- agentforge_a2a/config.py +25 -0
- agentforge_a2a/manifest.yaml +26 -0
- agentforge_a2a/py.typed +0 -0
- agentforge_a2a/server.py +383 -0
- agentforge_a2a/values.py +142 -0
- agentforge_a2a-0.2.1.dist-info/METADATA +79 -0
- agentforge_a2a-0.2.1.dist-info/RECORD +16 -0
- agentforge_a2a-0.2.1.dist-info/WHEEL +4 -0
- agentforge_a2a-0.2.1.dist-info/entry_points.txt +2 -0
- agentforge_a2a-0.2.1.dist-info/licenses/LICENSE +202 -0
agentforge_a2a/client.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""A2A client (feat-014).
|
|
2
|
+
|
|
3
|
+
`agent_call(target, payload, ...)` resolves a `<peer>:<endpoint>`
|
|
4
|
+
target against the configured peers and dispatches an HTTP POST
|
|
5
|
+
through the peer's `A2AClientRunner`. The caller's current
|
|
6
|
+
`RunContext` is propagated via `X-AgentForge-Run-Id` so the
|
|
7
|
+
callee can record it as `parent_run_id` (feat-007).
|
|
8
|
+
|
|
9
|
+
Budget propagation: when the caller binds an
|
|
10
|
+
`agentforge.cli._build`-style budget to the current run, the
|
|
11
|
+
proposed `budget_usd` reserves against it before the call;
|
|
12
|
+
`commit(actual)` + `release_reservation(budget_usd)` fires on
|
|
13
|
+
success; `release_reservation(budget_usd)` on failure. v0.4
|
|
14
|
+
threads the budget via the optional `budget` kwarg — the helper
|
|
15
|
+
stays callable from contexts without a bound budget too.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from collections.abc import AsyncIterator
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from agentforge_core.production.budget import BudgetPolicy
|
|
25
|
+
from agentforge_core.production.exceptions import (
|
|
26
|
+
A2AAuthError,
|
|
27
|
+
A2ACallError,
|
|
28
|
+
A2ATimeout,
|
|
29
|
+
ModuleError,
|
|
30
|
+
)
|
|
31
|
+
from agentforge_core.production.run_context import current_run
|
|
32
|
+
|
|
33
|
+
from agentforge_a2a._runner import A2AClientRunner
|
|
34
|
+
from agentforge_a2a.auth import ClientAuth, build_outgoing_auth
|
|
35
|
+
from agentforge_a2a.values import A2AChunk, A2APeerConfig, A2APeerInfo, A2AResponse
|
|
36
|
+
|
|
37
|
+
_CALLS_SUFFIX = "/calls"
|
|
38
|
+
_INFO_PATH = "/info"
|
|
39
|
+
_STREAM_PATH = "/calls/stream"
|
|
40
|
+
|
|
41
|
+
HTTP_UNAUTHORIZED = 401
|
|
42
|
+
HTTP_FORBIDDEN = 403
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class A2APeer:
|
|
47
|
+
"""One configured A2A peer + its outgoing-auth + runner."""
|
|
48
|
+
|
|
49
|
+
name: str
|
|
50
|
+
url: str
|
|
51
|
+
auth: ClientAuth
|
|
52
|
+
runner: A2AClientRunner
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_config(
|
|
56
|
+
cls,
|
|
57
|
+
config: dict[str, Any] | A2APeerConfig,
|
|
58
|
+
*,
|
|
59
|
+
runner: A2AClientRunner,
|
|
60
|
+
) -> A2APeer:
|
|
61
|
+
pc = config if isinstance(config, A2APeerConfig) else A2APeerConfig.model_validate(config)
|
|
62
|
+
return cls(
|
|
63
|
+
name=pc.name,
|
|
64
|
+
url=pc.url,
|
|
65
|
+
auth=build_outgoing_auth(pc.auth),
|
|
66
|
+
runner=runner,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def agent_call(
|
|
71
|
+
target: str,
|
|
72
|
+
payload: dict[str, Any],
|
|
73
|
+
*,
|
|
74
|
+
peers: dict[str, A2APeer],
|
|
75
|
+
timeout_s: float = 60.0,
|
|
76
|
+
budget_usd: float | None = None,
|
|
77
|
+
budget: BudgetPolicy | None = None,
|
|
78
|
+
) -> A2AResponse:
|
|
79
|
+
"""Invoke a remote A2A peer.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
target: ``"<peer>:<endpoint>"``. ``<peer>`` resolves
|
|
83
|
+
against the ``peers`` map.
|
|
84
|
+
payload: Endpoint-specific JSON body.
|
|
85
|
+
peers: Map of peer name → `A2APeer`.
|
|
86
|
+
timeout_s: Per-call timeout in seconds.
|
|
87
|
+
budget_usd: Proposed budget the callee should respect.
|
|
88
|
+
budget: Optional `BudgetPolicy` the call reserves against
|
|
89
|
+
(caller-side accounting). When None, the helper does
|
|
90
|
+
no reserve/commit dance — useful for fire-and-forget
|
|
91
|
+
calls.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Parsed `A2AResponse`.
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
A2AAuthError: peer rejected credentials.
|
|
98
|
+
A2ATimeout: call exceeded ``timeout_s``.
|
|
99
|
+
A2ACallError: any other transport / protocol failure.
|
|
100
|
+
"""
|
|
101
|
+
peer_name, endpoint = _parse_target(target)
|
|
102
|
+
peer = peers.get(peer_name)
|
|
103
|
+
if peer is None:
|
|
104
|
+
raise ModuleError(f"unknown a2a peer: {peer_name!r}")
|
|
105
|
+
|
|
106
|
+
headers = _build_headers(peer.auth, budget_usd)
|
|
107
|
+
body = {"endpoint": endpoint, "payload": payload, "budget_usd": budget_usd}
|
|
108
|
+
|
|
109
|
+
if budget is not None and budget_usd is not None:
|
|
110
|
+
budget.reserve(budget_usd)
|
|
111
|
+
try:
|
|
112
|
+
try:
|
|
113
|
+
raw = await peer.runner.post(
|
|
114
|
+
peer.url,
|
|
115
|
+
headers=headers,
|
|
116
|
+
json=body,
|
|
117
|
+
ssl_context=peer.auth.ssl_context,
|
|
118
|
+
timeout_s=timeout_s,
|
|
119
|
+
)
|
|
120
|
+
except TimeoutError as exc:
|
|
121
|
+
# Python 3.11+ aliases asyncio.TimeoutError to the
|
|
122
|
+
# builtin TimeoutError; a single except clause catches
|
|
123
|
+
# both.
|
|
124
|
+
raise A2ATimeout(f"a2a call to {peer_name!r} exceeded {timeout_s:.1f}s") from exc
|
|
125
|
+
except Exception as exc:
|
|
126
|
+
raise A2ACallError(f"a2a call to {peer_name!r} failed: {exc}") from exc
|
|
127
|
+
|
|
128
|
+
_raise_for_error_body(peer_name, raw)
|
|
129
|
+
response = A2AResponse.model_validate(raw)
|
|
130
|
+
except BaseException:
|
|
131
|
+
if budget is not None and budget_usd is not None:
|
|
132
|
+
budget.release_reservation(budget_usd)
|
|
133
|
+
raise
|
|
134
|
+
else:
|
|
135
|
+
if budget is not None and budget_usd is not None:
|
|
136
|
+
budget.commit(response.cost_usd)
|
|
137
|
+
budget.release_reservation(budget_usd)
|
|
138
|
+
return response
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def agent_call_stream(
|
|
142
|
+
target: str,
|
|
143
|
+
payload: dict[str, Any],
|
|
144
|
+
*,
|
|
145
|
+
peers: dict[str, A2APeer],
|
|
146
|
+
timeout_s: float = 60.0,
|
|
147
|
+
budget_usd: float | None = None,
|
|
148
|
+
budget: BudgetPolicy | None = None,
|
|
149
|
+
) -> AsyncIterator[A2AChunk]:
|
|
150
|
+
"""Streaming counterpart to `agent_call`.
|
|
151
|
+
|
|
152
|
+
Opens an SSE channel against ``peer.url + "/stream"`` (the
|
|
153
|
+
stream endpoint is derived from the unary calls URL) and
|
|
154
|
+
yields each `A2AChunk` frame as it arrives. The terminal
|
|
155
|
+
``kind="done"`` frame commits actual cost against the
|
|
156
|
+
supplied ``budget``; ``kind="error"`` releases the
|
|
157
|
+
reservation and raises the matching A2A* exception.
|
|
158
|
+
"""
|
|
159
|
+
peer_name, endpoint = _parse_target(target)
|
|
160
|
+
peer = peers.get(peer_name)
|
|
161
|
+
if peer is None:
|
|
162
|
+
raise ModuleError(f"unknown a2a peer: {peer_name!r}")
|
|
163
|
+
|
|
164
|
+
headers = _build_headers(peer.auth, budget_usd)
|
|
165
|
+
body = {"endpoint": endpoint, "payload": payload, "budget_usd": budget_usd}
|
|
166
|
+
stream_url = _stream_url_from_calls_url(peer.url)
|
|
167
|
+
|
|
168
|
+
if budget is not None and budget_usd is not None:
|
|
169
|
+
budget.reserve(budget_usd)
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
stream = peer.runner.post_stream(
|
|
173
|
+
stream_url,
|
|
174
|
+
headers=headers,
|
|
175
|
+
json=body,
|
|
176
|
+
ssl_context=peer.auth.ssl_context,
|
|
177
|
+
timeout_s=timeout_s,
|
|
178
|
+
)
|
|
179
|
+
async for raw in _wrap_stream_errors(peer_name, timeout_s, stream):
|
|
180
|
+
chunk = A2AChunk.model_validate(raw)
|
|
181
|
+
if chunk.kind == "error":
|
|
182
|
+
_raise_for_error_chunk(peer_name, chunk)
|
|
183
|
+
if chunk.kind == "done" and budget is not None:
|
|
184
|
+
content = chunk.content if isinstance(chunk.content, dict) else {}
|
|
185
|
+
budget.commit(float(content.get("cost_usd", 0.0)))
|
|
186
|
+
yield chunk
|
|
187
|
+
finally:
|
|
188
|
+
if budget is not None and budget_usd is not None:
|
|
189
|
+
budget.release_reservation(budget_usd)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
async def _wrap_stream_errors(
|
|
193
|
+
peer_name: str,
|
|
194
|
+
timeout_s: float,
|
|
195
|
+
stream: AsyncIterator[dict[str, Any]],
|
|
196
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
197
|
+
"""Yield from ``stream`` while mapping transport errors to A2A*."""
|
|
198
|
+
try:
|
|
199
|
+
async for raw in stream:
|
|
200
|
+
yield raw
|
|
201
|
+
except (A2AAuthError, A2ACallError, A2ATimeout):
|
|
202
|
+
raise
|
|
203
|
+
except TimeoutError as exc:
|
|
204
|
+
raise A2ATimeout(f"a2a stream to {peer_name!r} exceeded {timeout_s:.1f}s") from exc
|
|
205
|
+
except Exception as exc:
|
|
206
|
+
raise A2ACallError(f"a2a stream to {peer_name!r} failed: {exc}") from exc
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _raise_for_error_chunk(peer_name: str, chunk: A2AChunk) -> None:
|
|
210
|
+
content = chunk.content if isinstance(chunk.content, dict) else {}
|
|
211
|
+
code = str(content.get("error", "")) if content else ""
|
|
212
|
+
message = str(content.get("message", "")) if content else ""
|
|
213
|
+
if code in ("unauthorized", "forbidden", "A2AAuthError"):
|
|
214
|
+
raise A2AAuthError(f"a2a peer {peer_name!r} rejected credentials: {message}")
|
|
215
|
+
raise A2ACallError(f"a2a peer {peer_name!r} streamed error {code!r}: {message}")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _stream_url_from_calls_url(calls_url: str) -> str:
|
|
219
|
+
if calls_url.endswith(_CALLS_SUFFIX):
|
|
220
|
+
return calls_url + "/stream"
|
|
221
|
+
head, sep, _ = calls_url.rpartition(_CALLS_SUFFIX)
|
|
222
|
+
if sep:
|
|
223
|
+
return head + _STREAM_PATH
|
|
224
|
+
return calls_url.rstrip("/") + _STREAM_PATH
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
async def discover_peer(
|
|
228
|
+
peer: A2APeer,
|
|
229
|
+
*,
|
|
230
|
+
timeout_s: float = 10.0,
|
|
231
|
+
) -> A2APeerInfo:
|
|
232
|
+
"""Probe ``peer``'s ``GET /a2a/v1/info`` endpoint and return
|
|
233
|
+
the parsed `A2APeerInfo`.
|
|
234
|
+
|
|
235
|
+
`peer.url` is the unary calls URL (e.g.
|
|
236
|
+
``https://x/a2a/v1/calls``); the info URL is derived by
|
|
237
|
+
swapping the trailing ``/calls`` for ``/info`` so callers
|
|
238
|
+
only ever configure one URL per peer.
|
|
239
|
+
"""
|
|
240
|
+
info_url = _info_url_from_calls_url(peer.url)
|
|
241
|
+
headers = dict(peer.auth.headers)
|
|
242
|
+
headers.setdefault("Accept", "application/json")
|
|
243
|
+
try:
|
|
244
|
+
raw = await peer.runner.get(
|
|
245
|
+
info_url,
|
|
246
|
+
headers=headers,
|
|
247
|
+
ssl_context=peer.auth.ssl_context,
|
|
248
|
+
timeout_s=timeout_s,
|
|
249
|
+
)
|
|
250
|
+
except (A2AAuthError, A2ACallError, A2ATimeout):
|
|
251
|
+
raise
|
|
252
|
+
except TimeoutError as exc:
|
|
253
|
+
raise A2ATimeout(f"a2a discovery of {peer.name!r} exceeded {timeout_s:.1f}s") from exc
|
|
254
|
+
except Exception as exc:
|
|
255
|
+
raise A2ACallError(f"a2a discovery of {peer.name!r} failed: {exc}") from exc
|
|
256
|
+
_raise_for_error_body(peer.name, raw)
|
|
257
|
+
return A2APeerInfo.model_validate(raw)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _info_url_from_calls_url(calls_url: str) -> str:
|
|
261
|
+
if calls_url.endswith(_CALLS_SUFFIX):
|
|
262
|
+
return calls_url[: -len(_CALLS_SUFFIX)] + _INFO_PATH
|
|
263
|
+
head, sep, _ = calls_url.rpartition(_CALLS_SUFFIX)
|
|
264
|
+
if sep:
|
|
265
|
+
return head + _INFO_PATH
|
|
266
|
+
return calls_url.rstrip("/") + _INFO_PATH
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _parse_target(target: str) -> tuple[str, str]:
|
|
270
|
+
if ":" not in target:
|
|
271
|
+
raise ModuleError(f"a2a target must be '<peer>:<endpoint>', got {target!r}")
|
|
272
|
+
peer_name, endpoint = target.split(":", 1)
|
|
273
|
+
if not peer_name or not endpoint:
|
|
274
|
+
raise ModuleError(f"a2a target must be '<peer>:<endpoint>', got {target!r}")
|
|
275
|
+
return peer_name, endpoint
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _build_headers(auth: ClientAuth, budget_usd: float | None) -> dict[str, str]:
|
|
279
|
+
headers: dict[str, str] = {"Content-Type": "application/json"}
|
|
280
|
+
headers.update(auth.headers)
|
|
281
|
+
try:
|
|
282
|
+
ctx = current_run()
|
|
283
|
+
except RuntimeError:
|
|
284
|
+
ctx = None
|
|
285
|
+
if ctx is not None:
|
|
286
|
+
headers["X-AgentForge-Run-Id"] = ctx.run_id
|
|
287
|
+
if budget_usd is not None:
|
|
288
|
+
headers["X-AgentForge-Budget-Usd"] = f"{budget_usd:.6f}"
|
|
289
|
+
# feat-009 v0.3 polish: W3C TraceContext propagation. The
|
|
290
|
+
# propagator is a no-op when no active OTel span is bound,
|
|
291
|
+
# so this is safe to call unconditionally.
|
|
292
|
+
_trace_propagator().inject(headers)
|
|
293
|
+
return headers
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _trace_propagator() -> Any:
|
|
297
|
+
"""Lazily import OTel's W3C TraceContext propagator.
|
|
298
|
+
|
|
299
|
+
Cached at module level via ``_PROPAGATOR_CACHE`` so the import
|
|
300
|
+
only runs once. OpenTelemetry is a required dependency of
|
|
301
|
+
`agentforge-core`, so this import is always safe.
|
|
302
|
+
"""
|
|
303
|
+
if _PROPAGATOR_CACHE[0] is None:
|
|
304
|
+
from opentelemetry.trace.propagation.tracecontext import ( # noqa: PLC0415
|
|
305
|
+
TraceContextTextMapPropagator,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
_PROPAGATOR_CACHE[0] = TraceContextTextMapPropagator()
|
|
309
|
+
return _PROPAGATOR_CACHE[0]
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
_PROPAGATOR_CACHE: list[Any] = [None]
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _raise_for_error_body(peer_name: str, raw: dict[str, Any]) -> None:
|
|
316
|
+
"""Map an error-shaped body to the right A2A* exception."""
|
|
317
|
+
if "error" not in raw:
|
|
318
|
+
return
|
|
319
|
+
code = raw.get("error", "")
|
|
320
|
+
message = raw.get("message", "")
|
|
321
|
+
status = int(raw.get("status", 0)) if isinstance(raw.get("status"), (int, str)) else 0
|
|
322
|
+
if status in (HTTP_UNAUTHORIZED, HTTP_FORBIDDEN) or code in ("unauthorized", "forbidden"):
|
|
323
|
+
raise A2AAuthError(f"a2a peer {peer_name!r} rejected credentials: {message}")
|
|
324
|
+
raise A2ACallError(f"a2a peer {peer_name!r} returned error {code!r}: {message}")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
__all__ = [
|
|
328
|
+
"A2APeer",
|
|
329
|
+
"agent_call",
|
|
330
|
+
"agent_call_stream",
|
|
331
|
+
"discover_peer",
|
|
332
|
+
]
|
agentforge_a2a/config.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""`A2AConfig` — Pydantic schema for `modules.protocols[a2a].config:`
|
|
2
|
+
(feat-014).
|
|
3
|
+
|
|
4
|
+
`A2ABridge.from_config(config_dict)` validates the raw dict
|
|
5
|
+
against this schema before wiring peers + an optional server.
|
|
6
|
+
Strict / ``extra="forbid"`` per project convention.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
12
|
+
|
|
13
|
+
from agentforge_a2a.values import A2AExposeConfig, A2APeerConfig
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class A2AConfig(BaseModel):
|
|
17
|
+
"""Top-level shape of the A2A protocol's config block."""
|
|
18
|
+
|
|
19
|
+
model_config = ConfigDict(strict=True, extra="forbid")
|
|
20
|
+
|
|
21
|
+
peers: list[A2APeerConfig] = Field(default_factory=list)
|
|
22
|
+
expose: A2AExposeConfig | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
__all__ = ["A2AConfig"]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Module manifest for `agentforge add module a2a` (feat-010 / feat-014).
|
|
2
|
+
name: a2a
|
|
3
|
+
description: |
|
|
4
|
+
A2A (Agent-to-Agent) protocol support. Adds `agent_call(...)` for
|
|
5
|
+
reaching out to other-framework agents, plus `A2AServer` for
|
|
6
|
+
exposing this agent over the A2A wire format. Bearer and mTLS
|
|
7
|
+
auth backends ship in v0.4.
|
|
8
|
+
|
|
9
|
+
distribution:
|
|
10
|
+
pip_name: agentforge-a2a
|
|
11
|
+
|
|
12
|
+
config_block:
|
|
13
|
+
modules.protocols:
|
|
14
|
+
- name: a2a
|
|
15
|
+
config:
|
|
16
|
+
peers: []
|
|
17
|
+
expose:
|
|
18
|
+
enabled: false
|
|
19
|
+
host: "0.0.0.0"
|
|
20
|
+
port: 8080
|
|
21
|
+
auth: {type: "bearer", expected_tokens_env: "A2A_TOKENS"}
|
|
22
|
+
endpoints: []
|
|
23
|
+
|
|
24
|
+
entry_points:
|
|
25
|
+
agentforge.protocols:
|
|
26
|
+
a2a: agentforge_a2a.bridge:A2ABridge
|
agentforge_a2a/py.typed
ADDED
|
File without changes
|