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
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""`agentforge-a2a` — A2A protocol support for AgentForge (feat-014).
|
|
2
|
+
|
|
3
|
+
Public surface:
|
|
4
|
+
|
|
5
|
+
- `agent_call(target, payload, *, peers, ...)` — outgoing A2A
|
|
6
|
+
client.
|
|
7
|
+
- `discover_peer(peer)` — probe a peer's `/a2a/v1/info`.
|
|
8
|
+
- `A2APeer` — per-peer config + runner.
|
|
9
|
+
- `A2AServer(agent, ..., auth, endpoints)` — FastAPI server
|
|
10
|
+
exposing this agent as an A2A peer.
|
|
11
|
+
- `A2ABridge.from_config(config, ...)` — orchestrator that
|
|
12
|
+
wires both halves from a config dict.
|
|
13
|
+
- `BearerAuth` / `MutualTLSAuth` — client-side credential
|
|
14
|
+
builders. (Server-side bearer validation uses the canonical
|
|
15
|
+
`agentforge_core.contracts.auth.AuthPolicy`.)
|
|
16
|
+
- `A2APeerInfo`, `A2AEndpointDescriptor` — discovery wire
|
|
17
|
+
shapes returned by `/a2a/v1/info`.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from agentforge_a2a.auth import BearerAuth, ClientAuth, MutualTLSAuth, build_outgoing_auth
|
|
23
|
+
from agentforge_a2a.bridge import A2ABridge
|
|
24
|
+
from agentforge_a2a.client import A2APeer, agent_call, agent_call_stream, discover_peer
|
|
25
|
+
from agentforge_a2a.config import A2AConfig
|
|
26
|
+
from agentforge_a2a.server import A2AServer
|
|
27
|
+
from agentforge_a2a.values import (
|
|
28
|
+
A2AChunk,
|
|
29
|
+
A2AChunkKind,
|
|
30
|
+
A2AEndpointConfig,
|
|
31
|
+
A2AEndpointDescriptor,
|
|
32
|
+
A2AExposeConfig,
|
|
33
|
+
A2APeerConfig,
|
|
34
|
+
A2APeerInfo,
|
|
35
|
+
A2AResponse,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"A2ABridge",
|
|
40
|
+
"A2AChunk",
|
|
41
|
+
"A2AChunkKind",
|
|
42
|
+
"A2AConfig",
|
|
43
|
+
"A2AEndpointConfig",
|
|
44
|
+
"A2AEndpointDescriptor",
|
|
45
|
+
"A2AExposeConfig",
|
|
46
|
+
"A2APeer",
|
|
47
|
+
"A2APeerConfig",
|
|
48
|
+
"A2APeerInfo",
|
|
49
|
+
"A2AResponse",
|
|
50
|
+
"A2AServer",
|
|
51
|
+
"BearerAuth",
|
|
52
|
+
"ClientAuth",
|
|
53
|
+
"MutualTLSAuth",
|
|
54
|
+
"agent_call",
|
|
55
|
+
"agent_call_stream",
|
|
56
|
+
"build_outgoing_auth",
|
|
57
|
+
"discover_peer",
|
|
58
|
+
]
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""In-memory fakes implementing the A2A runner protocols (feat-014).
|
|
2
|
+
|
|
3
|
+
`FakeA2AClientRunner` lets unit + integration tests run without
|
|
4
|
+
spinning up an HTTP server. It records every call for later
|
|
5
|
+
assertion and returns scripted responses.
|
|
6
|
+
|
|
7
|
+
`FakeA2AServerRunner` is a tiny placeholder for the server-side
|
|
8
|
+
runner — `A2AServer.serve()` against this runner becomes a
|
|
9
|
+
no-op suitable for tests.
|
|
10
|
+
|
|
11
|
+
Lives in `src/` (not `tests/`) so external packages can import
|
|
12
|
+
the fakes for their own integration tests.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import ssl
|
|
18
|
+
from collections.abc import AsyncIterator
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class _Call:
|
|
25
|
+
url: str
|
|
26
|
+
headers: dict[str, str]
|
|
27
|
+
json: dict[str, Any] | None
|
|
28
|
+
ssl_context: ssl.SSLContext | None
|
|
29
|
+
timeout_s: float
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class FakeA2AClientRunner:
|
|
33
|
+
"""Records POSTs / GETs / streams and returns canned data."""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
*,
|
|
38
|
+
response: dict[str, Any] | None = None,
|
|
39
|
+
get_response: dict[str, Any] | None = None,
|
|
40
|
+
responses_stream: list[dict[str, Any]] | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
self._response: dict[str, Any] = response if response is not None else {}
|
|
43
|
+
self._get_response: dict[str, Any] | None = get_response
|
|
44
|
+
self._stream: list[dict[str, Any]] = list(responses_stream or [])
|
|
45
|
+
self._error: Exception | None = None
|
|
46
|
+
self.calls: list[_Call] = []
|
|
47
|
+
self.get_calls: list[_Call] = []
|
|
48
|
+
self.stream_calls: list[_Call] = []
|
|
49
|
+
self.closed = False
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def with_response(cls, response: dict[str, Any]) -> FakeA2AClientRunner:
|
|
53
|
+
return cls(response=response)
|
|
54
|
+
|
|
55
|
+
def set_response(self, response: dict[str, Any]) -> None:
|
|
56
|
+
self._response = response
|
|
57
|
+
|
|
58
|
+
def set_get_response(self, response: dict[str, Any]) -> None:
|
|
59
|
+
self._get_response = response
|
|
60
|
+
|
|
61
|
+
def set_stream(self, chunks: list[dict[str, Any]]) -> None:
|
|
62
|
+
self._stream = list(chunks)
|
|
63
|
+
|
|
64
|
+
def set_error(self, error: Exception) -> None:
|
|
65
|
+
"""Next `post()` (or `get()` / `post_stream()`) raises this."""
|
|
66
|
+
self._error = error
|
|
67
|
+
|
|
68
|
+
async def post(
|
|
69
|
+
self,
|
|
70
|
+
url: str,
|
|
71
|
+
*,
|
|
72
|
+
headers: dict[str, str],
|
|
73
|
+
json: dict[str, Any],
|
|
74
|
+
ssl_context: ssl.SSLContext | None,
|
|
75
|
+
timeout_s: float,
|
|
76
|
+
) -> dict[str, Any]:
|
|
77
|
+
self.calls.append(
|
|
78
|
+
_Call(
|
|
79
|
+
url=url,
|
|
80
|
+
headers=dict(headers),
|
|
81
|
+
json=dict(json),
|
|
82
|
+
ssl_context=ssl_context,
|
|
83
|
+
timeout_s=timeout_s,
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
if self._error is not None:
|
|
87
|
+
err, self._error = self._error, None
|
|
88
|
+
raise err
|
|
89
|
+
return dict(self._response)
|
|
90
|
+
|
|
91
|
+
async def get(
|
|
92
|
+
self,
|
|
93
|
+
url: str,
|
|
94
|
+
*,
|
|
95
|
+
headers: dict[str, str],
|
|
96
|
+
ssl_context: ssl.SSLContext | None,
|
|
97
|
+
timeout_s: float,
|
|
98
|
+
) -> dict[str, Any]:
|
|
99
|
+
self.get_calls.append(
|
|
100
|
+
_Call(
|
|
101
|
+
url=url,
|
|
102
|
+
headers=dict(headers),
|
|
103
|
+
json=None,
|
|
104
|
+
ssl_context=ssl_context,
|
|
105
|
+
timeout_s=timeout_s,
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
if self._error is not None:
|
|
109
|
+
err, self._error = self._error, None
|
|
110
|
+
raise err
|
|
111
|
+
body = self._get_response if self._get_response is not None else self._response
|
|
112
|
+
return dict(body)
|
|
113
|
+
|
|
114
|
+
async def post_stream(
|
|
115
|
+
self,
|
|
116
|
+
url: str,
|
|
117
|
+
*,
|
|
118
|
+
headers: dict[str, str],
|
|
119
|
+
json: dict[str, Any],
|
|
120
|
+
ssl_context: ssl.SSLContext | None,
|
|
121
|
+
timeout_s: float,
|
|
122
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
123
|
+
self.stream_calls.append(
|
|
124
|
+
_Call(
|
|
125
|
+
url=url,
|
|
126
|
+
headers=dict(headers),
|
|
127
|
+
json=dict(json),
|
|
128
|
+
ssl_context=ssl_context,
|
|
129
|
+
timeout_s=timeout_s,
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
if self._error is not None:
|
|
133
|
+
err, self._error = self._error, None
|
|
134
|
+
raise err
|
|
135
|
+
for chunk in self._stream:
|
|
136
|
+
yield dict(chunk)
|
|
137
|
+
|
|
138
|
+
async def close(self) -> None:
|
|
139
|
+
self.closed = True
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass
|
|
143
|
+
class FakeA2AServerRunner:
|
|
144
|
+
"""No-op server runner suitable for tests + the inline bridge."""
|
|
145
|
+
|
|
146
|
+
serving: bool = False
|
|
147
|
+
stop_called: bool = False
|
|
148
|
+
_events: list[str] = field(default_factory=list)
|
|
149
|
+
|
|
150
|
+
async def serve(self) -> None:
|
|
151
|
+
self.serving = True
|
|
152
|
+
self._events.append("serve")
|
|
153
|
+
|
|
154
|
+
async def stop(self) -> None:
|
|
155
|
+
self.serving = False
|
|
156
|
+
self.stop_called = True
|
|
157
|
+
self._events.append("stop")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
__all__ = [
|
|
161
|
+
"FakeA2AClientRunner",
|
|
162
|
+
"FakeA2AServerRunner",
|
|
163
|
+
]
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Internal A2A runner protocols (feat-014).
|
|
2
|
+
|
|
3
|
+
`A2AClientRunner` is the thin slice of httpx we depend on for
|
|
4
|
+
outgoing calls; `A2AServerRunner` wraps the uvicorn lifecycle
|
|
5
|
+
for the embedded server. Tests inject fakes so the unit suite
|
|
6
|
+
doesn't need a real network socket.
|
|
7
|
+
|
|
8
|
+
Production runners (`_HTTPXClientRunner`, `_UvicornServerRunner`)
|
|
9
|
+
are exercised by the `@pytest.mark.live` integration tests in
|
|
10
|
+
`tests/integration/` (feat-014 v0.2 follow-up). Their bodies
|
|
11
|
+
stay under ``# pragma: no cover`` because the unit suite uses
|
|
12
|
+
the fakes in `_inmem_runner.py`; coverage of the real transport
|
|
13
|
+
lives in the live job.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import ssl
|
|
19
|
+
from collections.abc import AsyncIterator
|
|
20
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
import httpx
|
|
24
|
+
import uvicorn
|
|
25
|
+
from fastapi import FastAPI
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class A2AClientRunner(Protocol):
|
|
29
|
+
"""Subset of `httpx.AsyncClient` we depend on for outgoing
|
|
30
|
+
A2A calls. Tests inject a fake; production builds a real
|
|
31
|
+
`httpx.AsyncClient` via `_HTTPXClientRunner`."""
|
|
32
|
+
|
|
33
|
+
async def post(
|
|
34
|
+
self,
|
|
35
|
+
url: str,
|
|
36
|
+
*,
|
|
37
|
+
headers: dict[str, str],
|
|
38
|
+
json: dict[str, Any],
|
|
39
|
+
ssl_context: ssl.SSLContext | None,
|
|
40
|
+
timeout_s: float,
|
|
41
|
+
) -> dict[str, Any]: ...
|
|
42
|
+
|
|
43
|
+
async def get(
|
|
44
|
+
self,
|
|
45
|
+
url: str,
|
|
46
|
+
*,
|
|
47
|
+
headers: dict[str, str],
|
|
48
|
+
ssl_context: ssl.SSLContext | None,
|
|
49
|
+
timeout_s: float,
|
|
50
|
+
) -> dict[str, Any]: ...
|
|
51
|
+
|
|
52
|
+
def post_stream(
|
|
53
|
+
self,
|
|
54
|
+
url: str,
|
|
55
|
+
*,
|
|
56
|
+
headers: dict[str, str],
|
|
57
|
+
json: dict[str, Any],
|
|
58
|
+
ssl_context: ssl.SSLContext | None,
|
|
59
|
+
timeout_s: float,
|
|
60
|
+
) -> AsyncIterator[dict[str, Any]]: ...
|
|
61
|
+
|
|
62
|
+
async def close(self) -> None: ...
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class A2AServerRunner(Protocol):
|
|
66
|
+
"""Subset of `uvicorn.Server` we depend on. Tests inject a
|
|
67
|
+
fake; production builds a real uvicorn server via
|
|
68
|
+
`_UvicornServerRunner`."""
|
|
69
|
+
|
|
70
|
+
async def serve(self) -> None: ...
|
|
71
|
+
|
|
72
|
+
async def stop(self) -> None: ...
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
_HTTP_UNAUTHORIZED = 401
|
|
76
|
+
_HTTP_FORBIDDEN = 403
|
|
77
|
+
_SSE_DATA_PREFIX = "data: "
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class _HTTPXClientRunner: # pragma: no cover — exercised only with `-m live`
|
|
81
|
+
"""Production `A2AClientRunner` — wraps `httpx.AsyncClient`.
|
|
82
|
+
|
|
83
|
+
The client is allocated lazily on the first call so
|
|
84
|
+
instantiation stays cheap and the constructor stays sync.
|
|
85
|
+
`close()` shuts the underlying httpx client down.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(self) -> None:
|
|
89
|
+
self._client: httpx.AsyncClient | None = None
|
|
90
|
+
|
|
91
|
+
def _ensure_client(self, ssl_context: ssl.SSLContext | None) -> httpx.AsyncClient:
|
|
92
|
+
if self._client is None:
|
|
93
|
+
import httpx # noqa: PLC0415
|
|
94
|
+
|
|
95
|
+
verify: ssl.SSLContext | bool = ssl_context if ssl_context is not None else True
|
|
96
|
+
self._client = httpx.AsyncClient(verify=verify)
|
|
97
|
+
return self._client
|
|
98
|
+
|
|
99
|
+
async def post(
|
|
100
|
+
self,
|
|
101
|
+
url: str,
|
|
102
|
+
*,
|
|
103
|
+
headers: dict[str, str],
|
|
104
|
+
json: dict[str, Any],
|
|
105
|
+
ssl_context: ssl.SSLContext | None,
|
|
106
|
+
timeout_s: float,
|
|
107
|
+
) -> dict[str, Any]:
|
|
108
|
+
from agentforge_core.production.exceptions import ( # noqa: PLC0415
|
|
109
|
+
A2AAuthError,
|
|
110
|
+
A2ACallError,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
client = self._ensure_client(ssl_context)
|
|
114
|
+
response = await client.post(url, headers=headers, json=json, timeout=timeout_s)
|
|
115
|
+
if response.status_code in (_HTTP_UNAUTHORIZED, _HTTP_FORBIDDEN):
|
|
116
|
+
raise A2AAuthError(
|
|
117
|
+
f"a2a peer rejected credentials ({response.status_code}): {response.text!r}"
|
|
118
|
+
)
|
|
119
|
+
if response.status_code >= 400: # noqa: PLR2004
|
|
120
|
+
raise A2ACallError(f"a2a peer returned HTTP {response.status_code}: {response.text!r}")
|
|
121
|
+
parsed: dict[str, Any] = response.json()
|
|
122
|
+
return parsed
|
|
123
|
+
|
|
124
|
+
async def get(
|
|
125
|
+
self,
|
|
126
|
+
url: str,
|
|
127
|
+
*,
|
|
128
|
+
headers: dict[str, str],
|
|
129
|
+
ssl_context: ssl.SSLContext | None,
|
|
130
|
+
timeout_s: float,
|
|
131
|
+
) -> dict[str, Any]:
|
|
132
|
+
from agentforge_core.production.exceptions import ( # noqa: PLC0415
|
|
133
|
+
A2AAuthError,
|
|
134
|
+
A2ACallError,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
client = self._ensure_client(ssl_context)
|
|
138
|
+
response = await client.get(url, headers=headers, timeout=timeout_s)
|
|
139
|
+
if response.status_code in (_HTTP_UNAUTHORIZED, _HTTP_FORBIDDEN):
|
|
140
|
+
raise A2AAuthError(
|
|
141
|
+
f"a2a peer rejected credentials ({response.status_code}): {response.text!r}"
|
|
142
|
+
)
|
|
143
|
+
if response.status_code >= 400: # noqa: PLR2004
|
|
144
|
+
raise A2ACallError(f"a2a peer returned HTTP {response.status_code}: {response.text!r}")
|
|
145
|
+
parsed: dict[str, Any] = response.json()
|
|
146
|
+
return parsed
|
|
147
|
+
|
|
148
|
+
async def post_stream(
|
|
149
|
+
self,
|
|
150
|
+
url: str,
|
|
151
|
+
*,
|
|
152
|
+
headers: dict[str, str],
|
|
153
|
+
json: dict[str, Any],
|
|
154
|
+
ssl_context: ssl.SSLContext | None,
|
|
155
|
+
timeout_s: float,
|
|
156
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
157
|
+
import json as _json # noqa: PLC0415
|
|
158
|
+
|
|
159
|
+
from agentforge_core.production.exceptions import ( # noqa: PLC0415
|
|
160
|
+
A2AAuthError,
|
|
161
|
+
A2ACallError,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
client = self._ensure_client(ssl_context)
|
|
165
|
+
stream_headers = dict(headers)
|
|
166
|
+
stream_headers.setdefault("Accept", "text/event-stream")
|
|
167
|
+
async with client.stream(
|
|
168
|
+
"POST", url, headers=stream_headers, json=json, timeout=timeout_s
|
|
169
|
+
) as response:
|
|
170
|
+
if response.status_code in (_HTTP_UNAUTHORIZED, _HTTP_FORBIDDEN):
|
|
171
|
+
await response.aread()
|
|
172
|
+
raise A2AAuthError(
|
|
173
|
+
f"a2a peer rejected credentials ({response.status_code}): {response.text!r}"
|
|
174
|
+
)
|
|
175
|
+
if response.status_code >= 400: # noqa: PLR2004
|
|
176
|
+
await response.aread()
|
|
177
|
+
raise A2ACallError(
|
|
178
|
+
f"a2a peer returned HTTP {response.status_code}: {response.text!r}"
|
|
179
|
+
)
|
|
180
|
+
async for line in response.aiter_lines():
|
|
181
|
+
if not line or not line.startswith(_SSE_DATA_PREFIX):
|
|
182
|
+
continue
|
|
183
|
+
payload = line[len(_SSE_DATA_PREFIX) :]
|
|
184
|
+
try:
|
|
185
|
+
parsed: dict[str, Any] = _json.loads(payload)
|
|
186
|
+
except _json.JSONDecodeError as exc:
|
|
187
|
+
raise A2ACallError(
|
|
188
|
+
f"a2a peer streamed an unparseable SSE frame: {payload!r}"
|
|
189
|
+
) from exc
|
|
190
|
+
yield parsed
|
|
191
|
+
|
|
192
|
+
async def close(self) -> None:
|
|
193
|
+
if self._client is None:
|
|
194
|
+
return
|
|
195
|
+
client, self._client = self._client, None
|
|
196
|
+
await client.aclose()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class _UvicornServerRunner: # pragma: no cover — exercised only with `-m live`
|
|
200
|
+
"""Production `A2AServerRunner` — wraps `uvicorn.Server`.
|
|
201
|
+
|
|
202
|
+
`serve()` blocks until either an external signal sets
|
|
203
|
+
`should_exit = True` or `stop()` is called from another
|
|
204
|
+
task. `stop()` is idempotent.
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
def __init__(
|
|
208
|
+
self,
|
|
209
|
+
app: FastAPI,
|
|
210
|
+
*,
|
|
211
|
+
host: str = "127.0.0.1",
|
|
212
|
+
port: int = 8080,
|
|
213
|
+
log_level: str = "info",
|
|
214
|
+
) -> None:
|
|
215
|
+
self._app = app
|
|
216
|
+
self._host = host
|
|
217
|
+
self._port = port
|
|
218
|
+
self._log_level = log_level
|
|
219
|
+
self._server: uvicorn.Server | None = None
|
|
220
|
+
|
|
221
|
+
async def serve(self) -> None:
|
|
222
|
+
import uvicorn # noqa: PLC0415
|
|
223
|
+
|
|
224
|
+
config = uvicorn.Config(
|
|
225
|
+
self._app, host=self._host, port=self._port, log_level=self._log_level
|
|
226
|
+
)
|
|
227
|
+
self._server = uvicorn.Server(config)
|
|
228
|
+
try:
|
|
229
|
+
await self._server.serve()
|
|
230
|
+
finally:
|
|
231
|
+
self._server = None
|
|
232
|
+
|
|
233
|
+
async def stop(self) -> None:
|
|
234
|
+
if self._server is None:
|
|
235
|
+
return
|
|
236
|
+
self._server.should_exit = True
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
__all__ = [
|
|
240
|
+
"A2AClientRunner",
|
|
241
|
+
"A2AServerRunner",
|
|
242
|
+
]
|
agentforge_a2a/auth.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Client-side credential providers for A2A (feat-014).
|
|
2
|
+
|
|
3
|
+
`agent_call` resolves per-peer auth config to one of the two
|
|
4
|
+
built-in `ClientAuth` shapes:
|
|
5
|
+
|
|
6
|
+
- `BearerAuth(token)` — attaches ``Authorization: Bearer
|
|
7
|
+
<token>`` to outgoing requests.
|
|
8
|
+
- `MutualTLSAuth(cert_path, key_path)` — builds an
|
|
9
|
+
`ssl.SSLContext` the httpx client passes to the peer.
|
|
10
|
+
|
|
11
|
+
`build_outgoing_auth(config)` accepts the dict form found in
|
|
12
|
+
YAML (`{type: bearer, token: ...}` / `{type: mtls, cert: ...,
|
|
13
|
+
key: ...}`) and returns the corresponding `ClientAuth`.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import ssl
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from agentforge_core.production.exceptions import ModuleError
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ClientAuth:
|
|
28
|
+
"""Resolved client-side credentials for an A2A peer.
|
|
29
|
+
|
|
30
|
+
`headers` is merged into the outgoing request headers;
|
|
31
|
+
`ssl_context` is passed to the httpx client when present.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
35
|
+
ssl_context: ssl.SSLContext | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def BearerAuth(token: str) -> ClientAuth: # noqa: N802 — factory-named like a class
|
|
39
|
+
"""Bearer-token credentials. Returns a `ClientAuth` ready to
|
|
40
|
+
use with `agent_call`."""
|
|
41
|
+
return ClientAuth(headers={"Authorization": f"Bearer {token}"})
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def MutualTLSAuth( # noqa: N802 — factory-named like a class
|
|
45
|
+
cert_path: str | Path,
|
|
46
|
+
key_path: str | Path,
|
|
47
|
+
*,
|
|
48
|
+
ca_path: str | Path | None = None,
|
|
49
|
+
) -> ClientAuth:
|
|
50
|
+
"""mTLS credentials. Builds an `ssl.SSLContext` loading the
|
|
51
|
+
client cert + key (and an optional CA bundle)."""
|
|
52
|
+
ctx = ssl.create_default_context(
|
|
53
|
+
purpose=ssl.Purpose.SERVER_AUTH,
|
|
54
|
+
cafile=str(ca_path) if ca_path is not None else None,
|
|
55
|
+
)
|
|
56
|
+
ctx.load_cert_chain(certfile=str(cert_path), keyfile=str(key_path))
|
|
57
|
+
return ClientAuth(ssl_context=ctx)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build_outgoing_auth(config: dict[str, Any]) -> ClientAuth:
|
|
61
|
+
"""Resolve a per-peer auth dict to a `ClientAuth`.
|
|
62
|
+
|
|
63
|
+
Accepted shapes:
|
|
64
|
+
- ``{}`` — no auth.
|
|
65
|
+
- ``{type: "bearer", token: "..."}``.
|
|
66
|
+
- ``{type: "mtls", cert: "/path", key: "/path",
|
|
67
|
+
ca: "/path" (optional)}``.
|
|
68
|
+
"""
|
|
69
|
+
if not config:
|
|
70
|
+
return ClientAuth()
|
|
71
|
+
auth_type = config.get("type", "")
|
|
72
|
+
if auth_type == "bearer":
|
|
73
|
+
token = config.get("token", "")
|
|
74
|
+
if not token:
|
|
75
|
+
raise ModuleError("bearer auth requires a non-empty 'token'")
|
|
76
|
+
return BearerAuth(token)
|
|
77
|
+
if auth_type == "mtls":
|
|
78
|
+
cert = config.get("cert", "")
|
|
79
|
+
key = config.get("key", "")
|
|
80
|
+
ca = config.get("ca")
|
|
81
|
+
if not cert or not key:
|
|
82
|
+
raise ModuleError("mtls auth requires both 'cert' and 'key' paths")
|
|
83
|
+
return MutualTLSAuth(cert, key, ca_path=ca)
|
|
84
|
+
raise ModuleError(f"unknown a2a auth type: {auth_type!r}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
__all__ = [
|
|
88
|
+
"BearerAuth",
|
|
89
|
+
"ClientAuth",
|
|
90
|
+
"MutualTLSAuth",
|
|
91
|
+
"build_outgoing_auth",
|
|
92
|
+
]
|
agentforge_a2a/bridge.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""`A2ABridge` — orchestrates A2A clients + an optional server
|
|
2
|
+
(feat-014).
|
|
3
|
+
|
|
4
|
+
Loaded by feat-010's resolver from
|
|
5
|
+
`modules.protocols[a2a].config:`. Mirrors `MCPBridge`'s shape:
|
|
6
|
+
|
|
7
|
+
- `from_config(config_dict, *, agent, auth, client_runner,
|
|
8
|
+
server_runner)` builds peers + an optional server from a
|
|
9
|
+
validated `A2AConfig`.
|
|
10
|
+
- `peers` is a dict of `{name -> A2APeer}` ready for
|
|
11
|
+
`agent_call(...)`.
|
|
12
|
+
- `server` is an `A2AServer | None`; when present, `start()`
|
|
13
|
+
schedules it as an asyncio task and `close()` cancels.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import contextlib
|
|
20
|
+
from typing import Any, ClassVar
|
|
21
|
+
|
|
22
|
+
from agentforge.agent import Agent
|
|
23
|
+
from agentforge_core.contracts.auth import AuthPolicy
|
|
24
|
+
|
|
25
|
+
from agentforge_a2a._runner import A2AClientRunner, A2AServerRunner
|
|
26
|
+
from agentforge_a2a.client import A2APeer, discover_peer
|
|
27
|
+
from agentforge_a2a.config import A2AConfig
|
|
28
|
+
from agentforge_a2a.server import A2AServer
|
|
29
|
+
from agentforge_a2a.values import A2APeerInfo
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class A2ABridge:
|
|
33
|
+
"""Top-level orchestrator for a2a clients + server.
|
|
34
|
+
|
|
35
|
+
The bridge does NOT own the `Agent` instance — callers pass
|
|
36
|
+
it explicitly when they want a server side. Without an
|
|
37
|
+
agent, `from_config` builds the client side only (peers
|
|
38
|
+
available; `server is None`).
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
config_schema: ClassVar[type[A2AConfig]] = A2AConfig
|
|
42
|
+
"""Picked up by feat-012's `validate_module_configs` so
|
|
43
|
+
`agentforge config validate` enforces the A2A schema."""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
*,
|
|
48
|
+
peers: dict[str, A2APeer],
|
|
49
|
+
server: A2AServer | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
self._peers = dict(peers)
|
|
52
|
+
self._server = server
|
|
53
|
+
self._serve_task: asyncio.Task[None] | None = None
|
|
54
|
+
self._peer_info: dict[str, A2APeerInfo] = {}
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def peers(self) -> dict[str, A2APeer]:
|
|
58
|
+
return dict(self._peers)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def server(self) -> A2AServer | None:
|
|
62
|
+
return self._server
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def peer_info(self) -> dict[str, A2APeerInfo]:
|
|
66
|
+
"""Cached `A2APeerInfo` keyed by peer name (populated by
|
|
67
|
+
`discover_all()` — empty until then)."""
|
|
68
|
+
return dict(self._peer_info)
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def from_config(
|
|
72
|
+
cls,
|
|
73
|
+
config: dict[str, Any],
|
|
74
|
+
*,
|
|
75
|
+
agent: Agent | None = None,
|
|
76
|
+
auth: AuthPolicy | None = None,
|
|
77
|
+
client_runner: A2AClientRunner,
|
|
78
|
+
server_runner: A2AServerRunner | None = None,
|
|
79
|
+
) -> A2ABridge:
|
|
80
|
+
"""Build a bridge from a validated A2A config dict.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
config: Raw ``{peers: [...], expose: {...}}`` dict.
|
|
84
|
+
The chunk-5 schema (`A2AConfig`) validates this.
|
|
85
|
+
agent: Required when ``config.expose.enabled`` is
|
|
86
|
+
True. The server side wraps it.
|
|
87
|
+
auth: Required when ``config.expose.enabled`` is
|
|
88
|
+
True. The server validates incoming bearers
|
|
89
|
+
against this policy.
|
|
90
|
+
client_runner: Shared `A2AClientRunner` for all
|
|
91
|
+
peers. Pass a `FakeA2AClientRunner` in tests.
|
|
92
|
+
server_runner: Optional `A2AServerRunner` injected
|
|
93
|
+
into the server's lifecycle.
|
|
94
|
+
"""
|
|
95
|
+
validated = A2AConfig.model_validate(config)
|
|
96
|
+
peers = {pc.name: A2APeer.from_config(pc, runner=client_runner) for pc in validated.peers}
|
|
97
|
+
server: A2AServer | None = None
|
|
98
|
+
if validated.expose is not None and validated.expose.enabled:
|
|
99
|
+
if agent is None or auth is None:
|
|
100
|
+
msg = (
|
|
101
|
+
"A2A expose.enabled=True requires both an Agent and an "
|
|
102
|
+
"AuthPolicy to be supplied to A2ABridge.from_config."
|
|
103
|
+
)
|
|
104
|
+
raise ValueError(msg)
|
|
105
|
+
server = A2AServer(
|
|
106
|
+
agent=agent,
|
|
107
|
+
auth=auth,
|
|
108
|
+
endpoints=[e.name for e in validated.expose.endpoints],
|
|
109
|
+
endpoint_descriptors=list(validated.expose.endpoints),
|
|
110
|
+
host=validated.expose.host,
|
|
111
|
+
port=validated.expose.port,
|
|
112
|
+
runner=server_runner,
|
|
113
|
+
)
|
|
114
|
+
return cls(peers=peers, server=server)
|
|
115
|
+
|
|
116
|
+
async def discover_all(self, *, timeout_s: float = 10.0) -> dict[str, A2APeerInfo]:
|
|
117
|
+
"""Probe every configured peer's `/a2a/v1/info` endpoint
|
|
118
|
+
and cache the result on `self.peer_info`.
|
|
119
|
+
|
|
120
|
+
Re-callable — replaces the cached entry per peer. Caller-
|
|
121
|
+
driven: never invoked automatically by `start()`.
|
|
122
|
+
"""
|
|
123
|
+
fresh: dict[str, A2APeerInfo] = {}
|
|
124
|
+
for name, peer in self._peers.items():
|
|
125
|
+
fresh[name] = await discover_peer(peer, timeout_s=timeout_s)
|
|
126
|
+
self._peer_info = fresh
|
|
127
|
+
return dict(self._peer_info)
|
|
128
|
+
|
|
129
|
+
async def start(self) -> None:
|
|
130
|
+
"""Launch the server (if any) in the background. Idempotent."""
|
|
131
|
+
if self._server is None or self._serve_task is not None:
|
|
132
|
+
return
|
|
133
|
+
self._serve_task = asyncio.create_task(self._server.serve())
|
|
134
|
+
|
|
135
|
+
async def close(self) -> None:
|
|
136
|
+
"""Stop the server (if running) and drain peers."""
|
|
137
|
+
if self._server is not None:
|
|
138
|
+
await self._server.stop()
|
|
139
|
+
if self._serve_task is not None and not self._serve_task.done():
|
|
140
|
+
self._serve_task.cancel()
|
|
141
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
142
|
+
await self._serve_task
|
|
143
|
+
self._serve_task = None
|
|
144
|
+
for peer in self._peers.values():
|
|
145
|
+
await peer.runner.close()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
__all__ = ["A2ABridge"]
|