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/server.py
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""`A2AServer` — exposes an `Agent` over the A2A wire format
|
|
2
|
+
(feat-014).
|
|
3
|
+
|
|
4
|
+
FastAPI app with three endpoints:
|
|
5
|
+
|
|
6
|
+
- `POST /a2a/v1/calls` — invoke a whitelisted endpoint
|
|
7
|
+
synchronously and return one `A2AResponse`.
|
|
8
|
+
- `POST /a2a/v1/calls/stream` — invoke a whitelisted endpoint
|
|
9
|
+
and emit a Server-Sent-Events stream of `A2AChunk` frames
|
|
10
|
+
(v0.2 follow-up).
|
|
11
|
+
- `GET /a2a/v1/info` — return the endpoint catalogue +
|
|
12
|
+
framework version.
|
|
13
|
+
|
|
14
|
+
Lifecycle:
|
|
15
|
+
|
|
16
|
+
1. Bearer auth via the canonical `AuthPolicy` (feat-014
|
|
17
|
+
chunk 1).
|
|
18
|
+
2. Endpoint name validated against the whitelist (404
|
|
19
|
+
otherwise; on the stream endpoint we instead emit a
|
|
20
|
+
single ``kind="error"`` frame and close the stream so
|
|
21
|
+
the contract stays "always SSE" once authenticated).
|
|
22
|
+
3. `X-AgentForge-Run-Id` (if present) is recorded on the
|
|
23
|
+
response as `parent_run_id` for the cross-framework chain.
|
|
24
|
+
4. `X-AgentForge-Budget-Usd` (if present + valid) caps the
|
|
25
|
+
inner `Agent.run`'s budget.
|
|
26
|
+
5. The supplied `task_builder` callable converts the payload
|
|
27
|
+
into the task string the `Agent` receives.
|
|
28
|
+
6. Findings on the result are serialised through
|
|
29
|
+
`agentforge.recording._finding_payload` so the wire
|
|
30
|
+
shape stays tolerant of any `Finding` Protocol-compatible
|
|
31
|
+
object.
|
|
32
|
+
|
|
33
|
+
Streaming (v0.3) drives `Agent.stream(task)` and forwards each
|
|
34
|
+
`StreamingEvent` emitted by the strategy as one `A2AChunk` SSE
|
|
35
|
+
frame. The strategy's terminal `done` event is swallowed; the
|
|
36
|
+
server emits its own canonical `done` chunk carrying
|
|
37
|
+
`output` + `cost_usd` + `run_id`. The v0.2 transient
|
|
38
|
+
`agent._on_step` hook dance is gone — events arrive
|
|
39
|
+
pre-typed from the strategy generator.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import json
|
|
45
|
+
import logging
|
|
46
|
+
from collections.abc import AsyncIterator, Callable
|
|
47
|
+
from typing import Any
|
|
48
|
+
|
|
49
|
+
import uvicorn
|
|
50
|
+
from agentforge.agent import Agent
|
|
51
|
+
from agentforge.recording import _finding_payload
|
|
52
|
+
from agentforge_core.contracts.auth import AuthPolicy
|
|
53
|
+
from agentforge_core.observability.tracing import get_tracer
|
|
54
|
+
from agentforge_core.production.budget import BudgetPolicy
|
|
55
|
+
from fastapi import Depends, FastAPI, HTTPException, Request, status
|
|
56
|
+
from fastapi.responses import StreamingResponse
|
|
57
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
58
|
+
from opentelemetry.trace.propagation.tracecontext import (
|
|
59
|
+
TraceContextTextMapPropagator,
|
|
60
|
+
)
|
|
61
|
+
from pydantic import BaseModel, ConfigDict
|
|
62
|
+
|
|
63
|
+
from agentforge_a2a._runner import A2AServerRunner
|
|
64
|
+
from agentforge_a2a.values import (
|
|
65
|
+
A2AChunk,
|
|
66
|
+
A2AEndpointConfig,
|
|
67
|
+
A2AEndpointDescriptor,
|
|
68
|
+
A2APeerInfo,
|
|
69
|
+
A2AResponse,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
_log = logging.getLogger("agentforge_a2a.server")
|
|
73
|
+
_VERSION = "0.1"
|
|
74
|
+
"""A2A wire-format version we ship — bumped when the request/
|
|
75
|
+
response shape changes."""
|
|
76
|
+
|
|
77
|
+
TaskBuilder = Callable[[str, dict[str, Any]], str]
|
|
78
|
+
"""Maps (endpoint_name, payload) -> the task string the agent
|
|
79
|
+
receives. Default: JSON-encode the payload."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _default_task_builder(endpoint: str, payload: dict[str, Any]) -> str:
|
|
83
|
+
"""Concatenates endpoint name + JSON payload for the agent."""
|
|
84
|
+
body = json.dumps(payload, sort_keys=True)
|
|
85
|
+
return f"a2a.{endpoint}: {body}"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _sse_frame(chunk: A2AChunk) -> bytes:
|
|
89
|
+
"""Encode one `A2AChunk` as a single SSE `data:` frame."""
|
|
90
|
+
return b"data: " + chunk.model_dump_json().encode("utf-8") + b"\n\n"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class CallRequest(BaseModel):
|
|
94
|
+
"""Body of `POST /a2a/v1/calls`."""
|
|
95
|
+
|
|
96
|
+
model_config = ConfigDict(extra="forbid")
|
|
97
|
+
|
|
98
|
+
endpoint: str
|
|
99
|
+
payload: dict[str, Any]
|
|
100
|
+
budget_usd: float | None = None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class A2AServer:
|
|
104
|
+
"""FastAPI app exposing an `Agent` as an A2A peer."""
|
|
105
|
+
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
agent: Agent,
|
|
109
|
+
*,
|
|
110
|
+
auth: AuthPolicy,
|
|
111
|
+
endpoints: list[str],
|
|
112
|
+
task_builder: TaskBuilder | None = None,
|
|
113
|
+
host: str = "0.0.0.0", # noqa: S104 # nosec B104
|
|
114
|
+
port: int = 8080,
|
|
115
|
+
runner: A2AServerRunner | None = None,
|
|
116
|
+
endpoint_descriptors: list[A2AEndpointConfig] | None = None,
|
|
117
|
+
server_name: str = "agentforge",
|
|
118
|
+
) -> None:
|
|
119
|
+
self._agent = agent
|
|
120
|
+
self._auth = auth
|
|
121
|
+
self._endpoints = list(endpoints)
|
|
122
|
+
self._task_builder = task_builder or _default_task_builder
|
|
123
|
+
self._host = host
|
|
124
|
+
self._port = port
|
|
125
|
+
self._runner = runner
|
|
126
|
+
self._endpoint_descriptors = list(endpoint_descriptors or [])
|
|
127
|
+
self._server_name = server_name
|
|
128
|
+
self.app = self._build_app()
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def endpoints(self) -> tuple[str, ...]:
|
|
132
|
+
return tuple(self._endpoints)
|
|
133
|
+
|
|
134
|
+
def _build_app(self) -> FastAPI:
|
|
135
|
+
app = FastAPI(title="agentforge-a2a")
|
|
136
|
+
bearer = HTTPBearer(auto_error=False)
|
|
137
|
+
|
|
138
|
+
async def require_principal(
|
|
139
|
+
credentials: HTTPAuthorizationCredentials | None = Depends(bearer), # noqa: B008
|
|
140
|
+
) -> Any:
|
|
141
|
+
token = credentials.credentials if credentials is not None else None
|
|
142
|
+
principal = await self._auth.authenticate(token)
|
|
143
|
+
if principal is None:
|
|
144
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
|
145
|
+
return principal
|
|
146
|
+
|
|
147
|
+
@app.get("/a2a/v1/info")
|
|
148
|
+
async def info(_p: Any = Depends(require_principal)) -> dict[str, Any]: # noqa: B008
|
|
149
|
+
return self._build_info().model_dump(mode="json")
|
|
150
|
+
|
|
151
|
+
@app.post("/a2a/v1/calls")
|
|
152
|
+
async def call(
|
|
153
|
+
body: CallRequest,
|
|
154
|
+
request: Request,
|
|
155
|
+
_p: Any = Depends(require_principal), # noqa: B008
|
|
156
|
+
) -> dict[str, Any]:
|
|
157
|
+
return await self._handle_call(body, request)
|
|
158
|
+
|
|
159
|
+
@app.post("/a2a/v1/calls/stream")
|
|
160
|
+
async def stream_call(
|
|
161
|
+
body: CallRequest,
|
|
162
|
+
request: Request,
|
|
163
|
+
_p: Any = Depends(require_principal), # noqa: B008
|
|
164
|
+
) -> StreamingResponse:
|
|
165
|
+
return StreamingResponse(
|
|
166
|
+
self._stream_call(body, request),
|
|
167
|
+
media_type="text/event-stream",
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
return app
|
|
171
|
+
|
|
172
|
+
def _build_info(self) -> A2APeerInfo:
|
|
173
|
+
"""Render the discovery payload returned by `/a2a/v1/info`."""
|
|
174
|
+
by_name = {d.name: d for d in self._endpoint_descriptors}
|
|
175
|
+
descriptors = [
|
|
176
|
+
A2AEndpointDescriptor(
|
|
177
|
+
name=name,
|
|
178
|
+
description=(by_name[name].description if name in by_name else ""),
|
|
179
|
+
input_schema=(dict(by_name[name].accepts) if name in by_name else {}),
|
|
180
|
+
)
|
|
181
|
+
for name in self._endpoints
|
|
182
|
+
]
|
|
183
|
+
return A2APeerInfo(
|
|
184
|
+
version=_VERSION,
|
|
185
|
+
server_name=self._server_name,
|
|
186
|
+
endpoints=descriptors,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
async def _handle_call(self, body: CallRequest, request: Request) -> dict[str, Any]:
|
|
190
|
+
if body.endpoint not in self._endpoints:
|
|
191
|
+
raise HTTPException(
|
|
192
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
193
|
+
detail=f"unknown endpoint: {body.endpoint!r}",
|
|
194
|
+
)
|
|
195
|
+
parent_run_id = request.headers.get("X-AgentForge-Run-Id")
|
|
196
|
+
# feat-009 v0.3 polish: W3C TraceContext propagation. Extract
|
|
197
|
+
# the caller's span context from `traceparent` (no-op when
|
|
198
|
+
# missing) and use it as the parent for the inner work so the
|
|
199
|
+
# `a2a.call` span — and its child `agent.run` — stitches into
|
|
200
|
+
# the caller's trace.
|
|
201
|
+
extracted_ctx = TraceContextTextMapPropagator().extract(dict(request.headers))
|
|
202
|
+
tracer = get_tracer()
|
|
203
|
+
with tracer.start_as_current_span(
|
|
204
|
+
"a2a.call",
|
|
205
|
+
context=extracted_ctx,
|
|
206
|
+
attributes={
|
|
207
|
+
"agentforge.a2a.endpoint": body.endpoint,
|
|
208
|
+
"agentforge.a2a.parent_run_id": parent_run_id or "",
|
|
209
|
+
},
|
|
210
|
+
):
|
|
211
|
+
result = await self._run_with_budget_cap(body, request)
|
|
212
|
+
response = A2AResponse(
|
|
213
|
+
output=result.output,
|
|
214
|
+
findings=tuple(_finding_payload(f) for f in getattr(result, "findings", ())),
|
|
215
|
+
cost_usd=result.cost_usd,
|
|
216
|
+
run_id=result.run_id,
|
|
217
|
+
parent_run_id=parent_run_id,
|
|
218
|
+
)
|
|
219
|
+
body_dict: dict[str, Any] = json.loads(response.model_dump_json())
|
|
220
|
+
return body_dict
|
|
221
|
+
|
|
222
|
+
async def _stream_call(self, body: CallRequest, request: Request) -> AsyncIterator[bytes]:
|
|
223
|
+
"""SSE generator for `POST /a2a/v1/calls/stream`.
|
|
224
|
+
|
|
225
|
+
Drives `Agent.stream(task)` and forwards each
|
|
226
|
+
``StreamingEvent`` as one ``A2AChunk`` ``data:`` frame.
|
|
227
|
+
The strategy's terminal ``done`` event is swallowed; this
|
|
228
|
+
method emits the canonical ``done`` chunk carrying
|
|
229
|
+
``output`` + ``cost_usd`` + ``run_id``. Strategy errors
|
|
230
|
+
surface as a terminal ``kind="error"`` frame.
|
|
231
|
+
"""
|
|
232
|
+
parent_run_id = request.headers.get("X-AgentForge-Run-Id")
|
|
233
|
+
if body.endpoint not in self._endpoints:
|
|
234
|
+
yield _sse_frame(
|
|
235
|
+
A2AChunk(
|
|
236
|
+
kind="error",
|
|
237
|
+
content={
|
|
238
|
+
"error": "unknown_endpoint",
|
|
239
|
+
"message": f"unknown endpoint: {body.endpoint!r}",
|
|
240
|
+
},
|
|
241
|
+
parent_run_id=parent_run_id,
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
return
|
|
245
|
+
|
|
246
|
+
# feat-009 v0.3 polish: W3C TraceContext propagation.
|
|
247
|
+
extracted_ctx = TraceContextTextMapPropagator().extract(dict(request.headers))
|
|
248
|
+
tracer = get_tracer()
|
|
249
|
+
a2a_span_cm = tracer.start_as_current_span(
|
|
250
|
+
"a2a.call",
|
|
251
|
+
context=extracted_ctx,
|
|
252
|
+
attributes={
|
|
253
|
+
"agentforge.a2a.endpoint": body.endpoint,
|
|
254
|
+
"agentforge.a2a.parent_run_id": parent_run_id or "",
|
|
255
|
+
"agentforge.a2a.streaming": True,
|
|
256
|
+
},
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
task_str = self._task_builder(body.endpoint, body.payload)
|
|
260
|
+
budget_cap = self._read_budget_header(request)
|
|
261
|
+
original_budget = self._agent._budget
|
|
262
|
+
if budget_cap is not None:
|
|
263
|
+
self._agent._budget = BudgetPolicy(
|
|
264
|
+
usd=min(original_budget.usd, budget_cap),
|
|
265
|
+
max_tokens=original_budget.max_tokens,
|
|
266
|
+
max_iterations=original_budget.max_iterations,
|
|
267
|
+
error_streak_limit=original_budget.error_streak_limit,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
done_content: dict[str, Any] | None = None
|
|
271
|
+
a2a_span_cm.__enter__()
|
|
272
|
+
try:
|
|
273
|
+
async for event in self._agent.stream(task_str):
|
|
274
|
+
if event.kind == "done":
|
|
275
|
+
raw = event.content if isinstance(event.content, dict) else {}
|
|
276
|
+
done_content = {
|
|
277
|
+
"output": _coerce_jsonable(raw.get("output")),
|
|
278
|
+
"cost_usd": float(raw.get("cost_usd", 0.0) or 0.0),
|
|
279
|
+
"run_id": str(raw.get("run_id", "")),
|
|
280
|
+
}
|
|
281
|
+
continue
|
|
282
|
+
yield _sse_frame(
|
|
283
|
+
A2AChunk(
|
|
284
|
+
kind=event.kind,
|
|
285
|
+
content=event.content,
|
|
286
|
+
metadata=dict(event.metadata),
|
|
287
|
+
parent_run_id=parent_run_id,
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
except Exception as exc:
|
|
291
|
+
yield _sse_frame(
|
|
292
|
+
A2AChunk(
|
|
293
|
+
kind="error",
|
|
294
|
+
content={
|
|
295
|
+
"error": type(exc).__name__,
|
|
296
|
+
"message": str(exc),
|
|
297
|
+
},
|
|
298
|
+
parent_run_id=parent_run_id,
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
return
|
|
302
|
+
finally:
|
|
303
|
+
if budget_cap is not None:
|
|
304
|
+
self._agent._budget = original_budget
|
|
305
|
+
a2a_span_cm.__exit__(None, None, None)
|
|
306
|
+
|
|
307
|
+
final = done_content or {"output": None, "cost_usd": 0.0, "run_id": ""}
|
|
308
|
+
yield _sse_frame(
|
|
309
|
+
A2AChunk(
|
|
310
|
+
kind="done",
|
|
311
|
+
content=final,
|
|
312
|
+
run_id=final["run_id"] or None,
|
|
313
|
+
parent_run_id=parent_run_id,
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
async def _run_with_budget_cap(self, body: CallRequest, request: Request) -> Any:
|
|
318
|
+
"""Run the agent honouring the per-call ``X-AgentForge-Budget-Usd``
|
|
319
|
+
header. Restores the original budget on the way out."""
|
|
320
|
+
budget_cap = self._read_budget_header(request)
|
|
321
|
+
task = self._task_builder(body.endpoint, body.payload)
|
|
322
|
+
if budget_cap is None:
|
|
323
|
+
return await self._agent.run(task)
|
|
324
|
+
original_budget = self._agent._budget
|
|
325
|
+
self._agent._budget = BudgetPolicy(
|
|
326
|
+
usd=min(original_budget.usd, budget_cap),
|
|
327
|
+
max_tokens=original_budget.max_tokens,
|
|
328
|
+
max_iterations=original_budget.max_iterations,
|
|
329
|
+
error_streak_limit=original_budget.error_streak_limit,
|
|
330
|
+
)
|
|
331
|
+
try:
|
|
332
|
+
return await self._agent.run(task)
|
|
333
|
+
finally:
|
|
334
|
+
self._agent._budget = original_budget
|
|
335
|
+
|
|
336
|
+
@staticmethod
|
|
337
|
+
def _read_budget_header(request: Request) -> float | None:
|
|
338
|
+
raw = request.headers.get("X-AgentForge-Budget-Usd")
|
|
339
|
+
if raw is None or not raw:
|
|
340
|
+
return None
|
|
341
|
+
try:
|
|
342
|
+
value = float(raw)
|
|
343
|
+
except ValueError:
|
|
344
|
+
_log.warning("invalid X-AgentForge-Budget-Usd header: %r", raw)
|
|
345
|
+
return None
|
|
346
|
+
if value < 0:
|
|
347
|
+
return None
|
|
348
|
+
return value
|
|
349
|
+
|
|
350
|
+
async def serve(self) -> None:
|
|
351
|
+
"""Run the server until interrupted. Uses the injected
|
|
352
|
+
runner when present, else falls back to a real uvicorn
|
|
353
|
+
server."""
|
|
354
|
+
if self._runner is not None:
|
|
355
|
+
await self._runner.serve()
|
|
356
|
+
return
|
|
357
|
+
config = uvicorn.Config(self.app, host=self._host, port=self._port, log_level="info")
|
|
358
|
+
server = uvicorn.Server(config)
|
|
359
|
+
await server.serve()
|
|
360
|
+
|
|
361
|
+
async def stop(self) -> None:
|
|
362
|
+
if self._runner is not None:
|
|
363
|
+
await self._runner.stop()
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _coerce_jsonable(value: Any) -> Any:
|
|
367
|
+
"""Best-effort JSON-friendly coercion for the streamed
|
|
368
|
+
``done`` content. Strings / numbers / bools / None / dicts /
|
|
369
|
+
lists pass through; everything else becomes ``str(value)``."""
|
|
370
|
+
if isinstance(value, str | int | float | bool) or value is None:
|
|
371
|
+
return value
|
|
372
|
+
if isinstance(value, dict):
|
|
373
|
+
return {str(k): _coerce_jsonable(v) for k, v in value.items()}
|
|
374
|
+
if isinstance(value, list | tuple):
|
|
375
|
+
return [_coerce_jsonable(v) for v in value]
|
|
376
|
+
return str(value)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
__all__ = [
|
|
380
|
+
"A2AServer",
|
|
381
|
+
"CallRequest",
|
|
382
|
+
"TaskBuilder",
|
|
383
|
+
]
|
agentforge_a2a/values.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""A2A value types (feat-014).
|
|
2
|
+
|
|
3
|
+
Wire-format-shaped Pydantic models that ride between the client
|
|
4
|
+
(`agent_call`) and the server (`A2AServer`). All frozen — values
|
|
5
|
+
are immutable once built.
|
|
6
|
+
|
|
7
|
+
`A2AResponse` mirrors spec §4.2. The three config models
|
|
8
|
+
(`A2APeerConfig`, `A2AEndpointConfig`, `A2AExposeConfig`) feed
|
|
9
|
+
the YAML side via `A2AConfig`.
|
|
10
|
+
|
|
11
|
+
v0.2 follow-up adds the discovery + streaming wire shapes:
|
|
12
|
+
|
|
13
|
+
- `A2AEndpointDescriptor` + `A2APeerInfo` — the rich shape
|
|
14
|
+
returned by `GET /a2a/v1/info` and consumed by
|
|
15
|
+
`discover_peer(...)`.
|
|
16
|
+
- `A2AChunk` + `A2AChunkKind` — the streaming wire format
|
|
17
|
+
emitted by `POST /a2a/v1/calls/stream`.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from agentforge_core.values.chat import StreamingChunkKind
|
|
25
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
26
|
+
|
|
27
|
+
A2AChunkKind = StreamingChunkKind
|
|
28
|
+
"""Kinds of frames streamed over `POST /a2a/v1/calls/stream`.
|
|
29
|
+
|
|
30
|
+
Aliased to the framework-wide ``StreamingChunkKind`` so chat and A2A
|
|
31
|
+
share one wire vocabulary (feat-014 v0.3). Common kinds:
|
|
32
|
+
|
|
33
|
+
- ``text`` — per-token text chunk emitted by strategies that override
|
|
34
|
+
``ReasoningStrategy.stream()``.
|
|
35
|
+
- ``thinking`` — model-internal reasoning token (when surfaced).
|
|
36
|
+
- ``step`` — generic agent step boundary (think / observe envelope).
|
|
37
|
+
- ``tool_call`` — agent invoked a tool.
|
|
38
|
+
- ``tool_result`` — observation from a tool call.
|
|
39
|
+
- ``done`` — terminal frame carrying the final output + cost.
|
|
40
|
+
- ``error`` — terminal error frame.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class A2AResponse(BaseModel):
|
|
45
|
+
"""Response returned by `agent_call(...)` and built by
|
|
46
|
+
`A2AServer.POST /a2a/v1/calls`."""
|
|
47
|
+
|
|
48
|
+
# Not strict: the wire format coerces tuple↔list on JSON round-trip,
|
|
49
|
+
# so the client-side `model_validate(response.json())` needs the
|
|
50
|
+
# default lax sequence handling.
|
|
51
|
+
model_config = ConfigDict(frozen=True)
|
|
52
|
+
|
|
53
|
+
output: Any
|
|
54
|
+
findings: tuple[dict[str, Any], ...] = ()
|
|
55
|
+
cost_usd: float = Field(default=0.0, ge=0.0)
|
|
56
|
+
run_id: str
|
|
57
|
+
parent_run_id: str | None = None
|
|
58
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class A2APeerConfig(BaseModel):
|
|
62
|
+
"""One entry under `modules.protocols[a2a].config.peers:`.
|
|
63
|
+
|
|
64
|
+
`auth` is a free-form dict: `{type: bearer, token: ...}` or
|
|
65
|
+
`{type: mtls, cert: ..., key: ...}` — interpreted by
|
|
66
|
+
`agentforge_a2a.auth.build_outgoing_auth`.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
model_config = ConfigDict(strict=True, extra="forbid")
|
|
70
|
+
|
|
71
|
+
name: str = Field(min_length=1)
|
|
72
|
+
url: str = Field(min_length=1)
|
|
73
|
+
auth: dict[str, Any] = Field(default_factory=dict)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class A2AEndpointConfig(BaseModel):
|
|
77
|
+
"""One entry under `modules.protocols[a2a].config.expose.endpoints:`."""
|
|
78
|
+
|
|
79
|
+
model_config = ConfigDict(strict=True, extra="forbid")
|
|
80
|
+
|
|
81
|
+
name: str = Field(min_length=1)
|
|
82
|
+
description: str = ""
|
|
83
|
+
accepts: dict[str, Any] = Field(default_factory=dict)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class A2AExposeConfig(BaseModel):
|
|
87
|
+
"""`modules.protocols[a2a].config.expose:` — server-side
|
|
88
|
+
configuration when this agent acts as an A2A peer."""
|
|
89
|
+
|
|
90
|
+
model_config = ConfigDict(strict=True, extra="forbid")
|
|
91
|
+
|
|
92
|
+
enabled: bool = True
|
|
93
|
+
host: str = "0.0.0.0" # noqa: S104 # nosec B104 — caller binds explicitly in prod
|
|
94
|
+
port: int = 8080
|
|
95
|
+
auth: dict[str, Any] = Field(default_factory=dict)
|
|
96
|
+
endpoints: list[A2AEndpointConfig] = Field(default_factory=list)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class A2AEndpointDescriptor(BaseModel):
|
|
100
|
+
"""One endpoint advertised by `GET /a2a/v1/info`."""
|
|
101
|
+
|
|
102
|
+
model_config = ConfigDict(frozen=True, strict=True)
|
|
103
|
+
|
|
104
|
+
name: str = Field(min_length=1)
|
|
105
|
+
description: str = ""
|
|
106
|
+
input_schema: dict[str, Any] = Field(default_factory=dict)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class A2APeerInfo(BaseModel):
|
|
110
|
+
"""Discovery payload returned by `GET /a2a/v1/info`."""
|
|
111
|
+
|
|
112
|
+
model_config = ConfigDict(frozen=True, strict=True)
|
|
113
|
+
|
|
114
|
+
version: str
|
|
115
|
+
server_name: str = ""
|
|
116
|
+
endpoints: list[A2AEndpointDescriptor] = Field(default_factory=list)
|
|
117
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class A2AChunk(BaseModel):
|
|
121
|
+
"""One frame on the streaming `/a2a/v1/calls/stream` channel."""
|
|
122
|
+
|
|
123
|
+
model_config = ConfigDict(frozen=True, strict=True)
|
|
124
|
+
|
|
125
|
+
kind: A2AChunkKind
|
|
126
|
+
content: dict[str, Any] | str | None = None
|
|
127
|
+
step: dict[str, Any] | None = None
|
|
128
|
+
run_id: str | None = None
|
|
129
|
+
parent_run_id: str | None = None
|
|
130
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
__all__ = [
|
|
134
|
+
"A2AChunk",
|
|
135
|
+
"A2AChunkKind",
|
|
136
|
+
"A2AEndpointConfig",
|
|
137
|
+
"A2AEndpointDescriptor",
|
|
138
|
+
"A2AExposeConfig",
|
|
139
|
+
"A2APeerConfig",
|
|
140
|
+
"A2APeerInfo",
|
|
141
|
+
"A2AResponse",
|
|
142
|
+
]
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentforge-a2a
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: A2A (Agent-to-Agent) protocol client + server for AgentForge
|
|
5
|
+
Project-URL: Homepage, https://github.com/Scaffoldic/agentforge-py
|
|
6
|
+
Project-URL: Repository, https://github.com/Scaffoldic/agentforge-py
|
|
7
|
+
Project-URL: Changelog, https://github.com/Scaffoldic/agentforge-py/blob/main/CHANGELOG.md
|
|
8
|
+
Author: The AgentForge Authors
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: a2a,agent,ai,multi-agent,protocol
|
|
12
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
13
|
+
Classifier: Framework :: FastAPI
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.13
|
|
21
|
+
Requires-Dist: agentforge-core~=0.2.1
|
|
22
|
+
Requires-Dist: agentforge-py~=0.2.1
|
|
23
|
+
Requires-Dist: fastapi>=0.115
|
|
24
|
+
Requires-Dist: httpx>=0.27
|
|
25
|
+
Requires-Dist: uvicorn>=0.32
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# agentforge-a2a
|
|
29
|
+
|
|
30
|
+
A2A (Agent-to-Agent) protocol support for AgentForge: cross-framework
|
|
31
|
+
agent invocation over HTTP, with bearer / mTLS auth, run_id chain,
|
|
32
|
+
and budget propagation.
|
|
33
|
+
|
|
34
|
+
See [`docs/features/feat-014-a2a-protocol.md`](https://github.com/Scaffoldic/agentforge-py/blob/main/docs/features/feat-014-a2a-protocol.md)
|
|
35
|
+
for the design and runbook.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install agentforge-a2a
|
|
41
|
+
# or, from a scaffolded project:
|
|
42
|
+
agentforge add module a2a
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Call another agent
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from agentforge_a2a import A2APeer, agent_call
|
|
49
|
+
|
|
50
|
+
peer = A2APeer.from_config({
|
|
51
|
+
"name": "fact-checker",
|
|
52
|
+
"url": "https://internal.fact-checker.example/a2a",
|
|
53
|
+
"auth": {"type": "bearer", "token": "${FACT_CHECKER_TOKEN}"},
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
result = await agent_call(
|
|
57
|
+
"fact-checker:verify",
|
|
58
|
+
{"claim": "The capital of Australia is Sydney."},
|
|
59
|
+
timeout_s=30,
|
|
60
|
+
peers={"fact-checker": peer},
|
|
61
|
+
)
|
|
62
|
+
print(result.output)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Expose this agent
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from agentforge import Agent, EnvBearerAuth
|
|
69
|
+
from agentforge_a2a import A2AServer
|
|
70
|
+
|
|
71
|
+
server = A2AServer(
|
|
72
|
+
agent=Agent(model="anthropic:claude-sonnet-4-6", strategy="react"),
|
|
73
|
+
auth=EnvBearerAuth("A2A_TOKENS"),
|
|
74
|
+
endpoints=["review-pr"],
|
|
75
|
+
host="0.0.0.0",
|
|
76
|
+
port=8080,
|
|
77
|
+
)
|
|
78
|
+
await server.serve()
|
|
79
|
+
```
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
agentforge_a2a/__init__.py,sha256=PTQojfSXZl_kIJmEZBoQsClboyX22aERp-iFoohuqxw,1680
|
|
2
|
+
agentforge_a2a/_inmem_runner.py,sha256=HfUc6LAY0jhrxR39Z0zLXMeZgrFz_ULlisEKDCOHROA,4651
|
|
3
|
+
agentforge_a2a/_runner.py,sha256=STAvp2MN7Nf5wZXQw7QrzN6fTY3Ap9Lw6ogkczT6Wq8,7807
|
|
4
|
+
agentforge_a2a/auth.py,sha256=BckHadKq-RVfyYbecdnTZOAAU7ydLfRppUg7Pw5Fckw,2940
|
|
5
|
+
agentforge_a2a/bridge.py,sha256=M5DttrM-zAeuXX50RhhUqU-RpCu4defpFsEh1PWA3ks,5474
|
|
6
|
+
agentforge_a2a/client.py,sha256=wDWMH81UZ1N65XyUTmIDFWJTR8BVtiIp0nX-7-h_yco,11557
|
|
7
|
+
agentforge_a2a/config.py,sha256=F4e18Qr9SNmVaLBWR-YOAN55rwDTqW5f8brMZABDFoo,698
|
|
8
|
+
agentforge_a2a/manifest.yaml,sha256=yZ5vdyhH9JPORsfF3mLFnbz9jbP3BUuq-sWexDCFjeE,709
|
|
9
|
+
agentforge_a2a/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
agentforge_a2a/server.py,sha256=T1iBkgRQiFSOGnhtgnyBe-T5Nb35hCd2Hzf9IGzMIfw,14354
|
|
11
|
+
agentforge_a2a/values.py,sha256=gf3bXyhX4o4KhoN-0zpXoPDkVguRxDkKAXupSvW0Jq4,4485
|
|
12
|
+
agentforge_a2a-0.2.1.dist-info/METADATA,sha256=MC-hH7zqNcSjgSQVRv96uz5OijkMGpG7SgS1yvhV1dQ,2344
|
|
13
|
+
agentforge_a2a-0.2.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
14
|
+
agentforge_a2a-0.2.1.dist-info/entry_points.txt,sha256=2z9S4rbsUvPtzTRpXZpHUyPpePjJ4A-tkVJqg0jjmr4,61
|
|
15
|
+
agentforge_a2a-0.2.1.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
16
|
+
agentforge_a2a-0.2.1.dist-info/RECORD,,
|