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,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
+ ]
@@ -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"]