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.
@@ -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
+ ]
@@ -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
File without changes