agentconnect-cli 0.1.0a1__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.
Files changed (64) hide show
  1. agentconnect/__init__.py +3 -0
  2. agentconnect/a2a_adapter/__init__.py +6 -0
  3. agentconnect/a2a_adapter/adapter.py +565 -0
  4. agentconnect/a2a_adapter/cards.py +149 -0
  5. agentconnect/a2a_adapter/convert.py +157 -0
  6. agentconnect/a2a_adapter/facade.py +560 -0
  7. agentconnect/a2a_adapter/seen.py +49 -0
  8. agentconnect/a2a_adapter/serve.py +67 -0
  9. agentconnect/adapter.py +319 -0
  10. agentconnect/adapters.py +52 -0
  11. agentconnect/apiclient.py +51 -0
  12. agentconnect/backoff.py +42 -0
  13. agentconnect/callback.py +314 -0
  14. agentconnect/cli.py +2324 -0
  15. agentconnect/cli_json.py +470 -0
  16. agentconnect/cli_support.py +444 -0
  17. agentconnect/clock.py +161 -0
  18. agentconnect/config.py +1213 -0
  19. agentconnect/daemon/__init__.py +41 -0
  20. agentconnect/deliveries.py +295 -0
  21. agentconnect/directory_client.py +255 -0
  22. agentconnect/failpoints.py +99 -0
  23. agentconnect/identity.py +111 -0
  24. agentconnect/isolation.py +345 -0
  25. agentconnect/keystore.py +279 -0
  26. agentconnect/limits.py +457 -0
  27. agentconnect/log.py +143 -0
  28. agentconnect/mcp/__init__.py +30 -0
  29. agentconnect/mcp/backend.py +405 -0
  30. agentconnect/mcp/server.py +382 -0
  31. agentconnect/mcp/session.py +373 -0
  32. agentconnect/mcp/tools.py +583 -0
  33. agentconnect/ownerfile.py +238 -0
  34. agentconnect/payloads.py +109 -0
  35. agentconnect/policy.py +1066 -0
  36. agentconnect/provision.py +239 -0
  37. agentconnect/py.typed +0 -0
  38. agentconnect/relay.py +405 -0
  39. agentconnect/runtime/__init__.py +46 -0
  40. agentconnect/runtime/agent.py +225 -0
  41. agentconnect/runtime/api.py +2043 -0
  42. agentconnect/runtime/engine.py +5359 -0
  43. agentconnect/runtime/errors.py +47 -0
  44. agentconnect/runtime/floor.py +649 -0
  45. agentconnect/runtime/groups.py +1351 -0
  46. agentconnect/runtime/knocks.py +1422 -0
  47. agentconnect/runtime/ledger.py +94 -0
  48. agentconnect/runtime/listing.py +1307 -0
  49. agentconnect/runtime/registry.py +53 -0
  50. agentconnect/runtime/runtime.py +158 -0
  51. agentconnect/runtime/sas.py +1002 -0
  52. agentconnect/sas_emoji.py +83 -0
  53. agentconnect/schemas.py +381 -0
  54. agentconnect/shapes.py +56 -0
  55. agentconnect/sockets.py +86 -0
  56. agentconnect/sse.py +234 -0
  57. agentconnect/store.py +3282 -0
  58. agentconnect/tasks.py +35 -0
  59. agentconnect/tokens.py +63 -0
  60. agentconnect/webhook.py +199 -0
  61. agentconnect_cli-0.1.0a1.dist-info/METADATA +103 -0
  62. agentconnect_cli-0.1.0a1.dist-info/RECORD +64 -0
  63. agentconnect_cli-0.1.0a1.dist-info/WHEEL +4 -0
  64. agentconnect_cli-0.1.0a1.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,560 @@
1
+ """Direction 2: a local A2A client talks to a group through an A2A server
2
+ facade on ``127.0.0.1``.
3
+
4
+ * ``/g/<group_id>/``: the group. A 1:1 group's card mirrors the peer's; a
5
+ larger group's is a composite of the members' skills. Address members with
6
+ ``metadata.to`` (a list of identities); without it a message goes to
7
+ ``["*"]``, which in ``mention`` mode triggers nobody (SPEC §9.3).
8
+ * ``/g/<group_id>/m/<identity>/``: one member; messages go to it alone.
9
+
10
+ Each path serves the JSON-RPC binding at its root and its card at
11
+ ``.well-known/agent-card.json``. Every request needs
12
+ ``Authorization: Bearer <a2a-facade-token>``, a loopback client, a ``Host``
13
+ of ``127.0.0.1:<port>`` or ``localhost:<port>``, and no ``Origin`` (a
14
+ browser page can't reach it).
15
+
16
+ Tasks are namespaced by group: each group has its own task store, and a
17
+ message's ``contextId`` must be the group (or empty). The facade's task ids
18
+ are its own; the peer's ``taskId`` is kept end to end behind them (follow-ups
19
+ on an ``input-required`` task carry it) and is reported in each event's
20
+ ``metadata.remote_task_id``. ``GetTask``, ``CancelTask``, ``SubscribeToTask``
21
+ and push-notification configs are served by ``a2a-sdk``.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ import contextlib
28
+ import hmac
29
+ import re
30
+ import time
31
+ from collections import OrderedDict
32
+ from collections.abc import Callable
33
+ from dataclasses import dataclass
34
+ from typing import TYPE_CHECKING, Any, override
35
+ from urllib.parse import unquote
36
+
37
+ import httpx
38
+ from a2a.helpers.proto_helpers import new_task
39
+ from a2a.server.agent_execution import AgentExecutor, RequestContext
40
+ from a2a.server.agent_execution.simple_request_context_builder import (
41
+ SimpleRequestContextBuilder,
42
+ )
43
+ from a2a.server.context import ServerCallContext
44
+ from a2a.server.events import EventQueue
45
+ from a2a.server.request_handlers import DefaultRequestHandler
46
+ from a2a.server.routes import create_jsonrpc_routes
47
+ from a2a.server.tasks import (
48
+ BasePushNotificationSender,
49
+ InMemoryPushNotificationConfigStore,
50
+ InMemoryTaskStore,
51
+ )
52
+ from a2a.types import (
53
+ AgentCard,
54
+ Message,
55
+ Role,
56
+ SendMessageRequest,
57
+ Task,
58
+ TaskState,
59
+ TaskStatus,
60
+ TaskStatusUpdateEvent,
61
+ )
62
+ from a2a.utils.errors import InvalidParamsError
63
+ from starlette.applications import Starlette
64
+ from starlette.requests import Request
65
+ from starlette.responses import JSONResponse, Response
66
+ from starlette.routing import Route
67
+
68
+ from agentconnect import log
69
+ from agentconnect.a2a_adapter import cards, convert
70
+ from agentconnect.a2a_adapter.serve import LoopbackServer
71
+ from agentconnect.adapter import SendReceipt
72
+
73
+ if TYPE_CHECKING:
74
+ from agentconnect.a2a_adapter.adapter import A2AAdapter
75
+ from agentconnect.adapter import Delivery
76
+
77
+ LOG = log.get("a2a.facade")
78
+ CARD_PATH = "/.well-known/agent-card.json"
79
+ MAX_TO = 50
80
+ MAX_GROUPS = 256 # facade apps kept; the oldest is dropped beyond this
81
+ MAX_EARLY = 256 # decisions kept that arrived before their send was waited on
82
+ EARLY_TTL_SECS = 3600.0 # ...and for at most this long
83
+ GROUP_ID = re.compile(r"[A-Za-z0-9_-]{1,64}")
84
+
85
+
86
+ @dataclass
87
+ class Bound:
88
+ """A facade task's peer side: who answers it (the identity and the
89
+ instance, since replicas of one identity each run their own task) and
90
+ under which task id."""
91
+
92
+ responder: str
93
+ remote_task_id: str
94
+ instance: str
95
+
96
+
97
+ class _GroupContext(SimpleRequestContextBuilder):
98
+ """``contextId`` is the group: an empty one becomes the group, another
99
+ one is refused (tasks never cross groups)."""
100
+
101
+ def __init__(self, group_id: str) -> None:
102
+ super().__init__()
103
+ self._group_id = group_id
104
+
105
+ @override
106
+ async def build(
107
+ self,
108
+ context: ServerCallContext,
109
+ params: SendMessageRequest | None = None,
110
+ task_id: str | None = None,
111
+ context_id: str | None = None,
112
+ task: Task | None = None,
113
+ ) -> RequestContext:
114
+ if context_id not in (None, "", self._group_id):
115
+ raise InvalidParamsError(message="contextId must be this group's id")
116
+ if params is not None:
117
+ params.message.context_id = self._group_id
118
+ return await super().build(context, params, task_id, self._group_id, task)
119
+
120
+
121
+ def _to_list(raw: Any) -> list[str] | None:
122
+ if isinstance(raw, str) and raw:
123
+ return [raw]
124
+ if isinstance(raw, list) and 0 < len(raw) <= MAX_TO and all(isinstance(x, str) for x in raw):
125
+ return [str(x) for x in raw]
126
+ return None
127
+
128
+
129
+ class BridgeExecutor(AgentExecutor):
130
+ """Runs one facade request: sends it to the group and turns the peer's
131
+ replies (``reply_to`` naming it) into task events."""
132
+
133
+ def __init__(self, facade: Facade, group_id: str, member: str | None) -> None:
134
+ self._facade = facade
135
+ self._group_id = group_id
136
+ self._member = member
137
+
138
+ def _recipients(self, msg: Message) -> list[str]:
139
+ if self._member is not None:
140
+ return [self._member]
141
+ meta = convert.to_json(msg).get("metadata")
142
+ to = _to_list(meta.get("to")) if isinstance(meta, dict) else None
143
+ return to or ["*"]
144
+
145
+ @override
146
+ async def execute(self, context: RequestContext, event_queue: EventQueue) -> None: # noqa: PLR0912
147
+ msg = context.message
148
+ task_id = context.task_id or ""
149
+ if msg is None or not task_id:
150
+ return
151
+ bound = self._facade.bound.get(task_id)
152
+ body = convert.to_json(msg)
153
+ body["contextId"] = self._group_id
154
+ body.pop("taskId", None)
155
+ if bound is not None:
156
+ body["taskId"] = bound.remote_task_id
157
+ to = [bound.responder] if bound is not None else self._recipients(msg)
158
+ # A follow-up goes to the instance running the task (SPEC 9.4).
159
+ instance = None if bound is None else bound.instance
160
+ replies: asyncio.Queue[Delivery] = asyncio.Queue()
161
+ try:
162
+ msg_id = await self._facade.adapter.send_to_group(
163
+ self._group_id, body, to, instance=instance
164
+ )
165
+ except Exception as e:
166
+ code = getattr(e, "code", type(e).__name__)
167
+ await self._start_task(context, event_queue)
168
+ await self._status(
169
+ event_queue, context, TaskState.TASK_STATE_FAILED, f"not sent: {code}"
170
+ )
171
+ return
172
+ self._facade.waiters[msg_id] = replies
173
+ task_mode = context.current_task is not None
174
+ try:
175
+ if isinstance(msg_id, SendReceipt) and msg_id.held:
176
+ # The group holds the agent's messages for the human: say so
177
+ # at once, wait for the human's decision, then for a reply.
178
+ if not await self._wait_for_human(context, event_queue, msg_id):
179
+ return
180
+ task_mode = True
181
+ async with asyncio.timeout(self._facade.adapter.options.reply_timeout):
182
+ while True:
183
+ d = await replies.get()
184
+ if bound is not None and (
185
+ d.sender.identity != bound.responder
186
+ or d.sender.instance_id != bound.instance
187
+ ):
188
+ continue # the task is bound to its first responder
189
+ if d.kind == "a2a.message" and not task_mode:
190
+ reply = convert.parse_message(d.body)
191
+ reply.role = Role.ROLE_AGENT
192
+ reply.context_id = self._group_id
193
+ reply.ClearField("task_id")
194
+ self._facade.tag(reply, d, "")
195
+ await event_queue.enqueue_event(reply)
196
+ return
197
+ if not task_mode:
198
+ await self._start_task(context, event_queue)
199
+ task_mode = True
200
+ done, bound = await self._forward(context, event_queue, d, bound)
201
+ if done:
202
+ return
203
+ except TimeoutError:
204
+ if not task_mode:
205
+ await self._start_task(context, event_queue)
206
+ await self._status(event_queue, context, TaskState.TASK_STATE_FAILED, "no reply")
207
+ finally:
208
+ self._facade.waiters.pop(msg_id, None)
209
+
210
+ async def _wait_for_human(
211
+ self, context: RequestContext, queue: EventQueue, receipt: SendReceipt
212
+ ) -> bool:
213
+ """A held send: ``input-required`` until the human decides, then
214
+ ``working`` (approved, sent) or ``rejected``/``failed``. True if it went.
215
+ If the request is cancelled meanwhile, the send is withdrawn."""
216
+ hold_id = receipt.hold_id or str(receipt)
217
+ task_id = context.task_id or ""
218
+ self._facade.track(hold_id)
219
+ self._facade.held_sends[task_id] = receipt
220
+ decision = self._facade.hold(hold_id)
221
+ await self._start_task(context, queue)
222
+ await self._status(
223
+ queue,
224
+ context,
225
+ TaskState.TASK_STATE_INPUT_REQUIRED,
226
+ "held for the human's approval: the group gets it once they approve it",
227
+ )
228
+ try:
229
+ outcome = await decision
230
+ except asyncio.CancelledError:
231
+ await asyncio.shield(receipt.withdraw())
232
+ raise
233
+ finally:
234
+ self._facade.held.pop(hold_id, None)
235
+ self._facade.held_sends.pop(task_id, None)
236
+ if outcome == "approved":
237
+ await self._status(
238
+ queue, context, TaskState.TASK_STATE_WORKING, "approved and sent to the group"
239
+ )
240
+ return True
241
+ state = (
242
+ TaskState.TASK_STATE_REJECTED if outcome == "rejected" else TaskState.TASK_STATE_FAILED
243
+ )
244
+ await self._status(queue, context, state, f"not sent: {outcome}")
245
+ return False
246
+
247
+ async def _forward(
248
+ self, context: RequestContext, queue: EventQueue, d: Delivery, bound: Bound | None
249
+ ) -> tuple[bool, Bound | None]:
250
+ task_id = context.task_id or ""
251
+ if d.kind == "a2a.task_status":
252
+ event = convert.parse_status(d.body)
253
+ if event is None:
254
+ return False, bound
255
+ if bound is None and event.task_id:
256
+ bound = Bound(d.sender.identity, event.task_id, d.sender.instance_id)
257
+ self._facade.bound[task_id] = bound
258
+ remote = event.task_id
259
+ event.task_id, event.context_id = task_id, self._group_id
260
+ self._facade.tag(event, d, remote)
261
+ await queue.enqueue_event(event)
262
+ state = event.status.state
263
+ return state in convert.TERMINAL | convert.INTERRUPTED, bound
264
+ if d.kind == "a2a.task_artifact":
265
+ art = convert.parse_artifact(d.body)
266
+ if art is None:
267
+ return False, bound
268
+ remote = art.task_id
269
+ art.task_id, art.context_id = task_id, self._group_id
270
+ self._facade.tag(art, d, remote)
271
+ await queue.enqueue_event(art)
272
+ return False, bound
273
+ if d.kind == "a2a.message":
274
+ # A plain reply inside task mode completes the task with it.
275
+ reply = convert.parse_message(d.body)
276
+ reply.role = Role.ROLE_AGENT
277
+ reply.context_id = self._group_id
278
+ reply.task_id = task_id
279
+ event = TaskStatusUpdateEvent(
280
+ task_id=task_id,
281
+ context_id=self._group_id,
282
+ status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED, message=reply),
283
+ )
284
+ self._facade.tag(event, d, "")
285
+ await queue.enqueue_event(event)
286
+ return True, bound
287
+ return False, bound
288
+
289
+ async def _start_task(self, context: RequestContext, queue: EventQueue) -> None:
290
+ if context.current_task is not None or context.message is None:
291
+ return
292
+ task = new_task(
293
+ context.task_id or "",
294
+ self._group_id,
295
+ TaskState.TASK_STATE_SUBMITTED,
296
+ history=[context.message],
297
+ )
298
+ await queue.enqueue_event(task)
299
+
300
+ async def _status(
301
+ self, queue: EventQueue, context: RequestContext, state: TaskState, text: str
302
+ ) -> None:
303
+ event = TaskStatusUpdateEvent(
304
+ task_id=context.task_id or "",
305
+ context_id=self._group_id,
306
+ status=TaskStatus(state=state, message=convert.agent_text(text, self._group_id)),
307
+ )
308
+ await queue.enqueue_event(event)
309
+
310
+ @override
311
+ async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
312
+ task_id = context.task_id or ""
313
+ held = self._facade.held_sends.pop(task_id, None)
314
+ if held is not None:
315
+ # Still waiting for the human: take the send back, so a late
316
+ # approval can't send what the client cancelled.
317
+ await held.withdraw()
318
+ bound = self._facade.bound.get(task_id)
319
+ if bound is not None:
320
+ body = convert.cancel_body(bound.remote_task_id)
321
+ try:
322
+ await self._facade.adapter.send_to_group(
323
+ self._group_id,
324
+ body,
325
+ [bound.responder],
326
+ kind="a2a.task_cancel",
327
+ instance=bound.instance,
328
+ )
329
+ except Exception as e:
330
+ LOG.info("a2a_cancel_not_sent", error=type(e).__name__)
331
+ event = TaskStatusUpdateEvent(
332
+ task_id=task_id,
333
+ context_id=self._group_id,
334
+ status=TaskStatus(state=TaskState.TASK_STATE_CANCELED),
335
+ )
336
+ await event_queue.enqueue_event(event)
337
+
338
+
339
+ class _GroupApps:
340
+ """One group's task store and push-config store, shared by the group's
341
+ path and its member paths."""
342
+
343
+ def __init__(self, http: httpx.AsyncClient) -> None:
344
+ self.tasks = InMemoryTaskStore()
345
+ self.push = InMemoryPushNotificationConfigStore()
346
+ self.sender = BasePushNotificationSender(http, self.push)
347
+ self.apps: dict[str | None, Any] = {}
348
+ self.handlers: list[DefaultRequestHandler] = []
349
+
350
+
351
+ class Facade:
352
+ def __init__(self, adapter: A2AAdapter, token: str, port: int) -> None:
353
+ self.adapter = adapter
354
+ self._token = token
355
+ self._server = LoopbackServer(self, port)
356
+ self._groups: dict[str, _GroupApps] = {}
357
+ self._http = httpx.AsyncClient(timeout=10.0)
358
+ # msg_id sent by the facade -> the request waiting for replies to it
359
+ self.waiters: dict[str, asyncio.Queue[Delivery]] = {}
360
+ # facade task id -> its peer side
361
+ self.bound: dict[str, Bound] = {}
362
+ # hold id of a held facade send -> the human's decision, once it comes
363
+ self.held: dict[str, asyncio.Future[str]] = {}
364
+ # facade task id -> its held send, while the human decides
365
+ self.held_sends: dict[str, SendReceipt] = {}
366
+ # Hold ids of the facade's own held sends (only theirs are buffered),
367
+ # and decisions that came before their wait, with a deadline.
368
+ self._mine: OrderedDict[str, float] = OrderedDict()
369
+ self._decided: OrderedDict[str, tuple[str, float]] = OrderedDict()
370
+ self.now: Callable[[], float] = time.monotonic
371
+
372
+ @property
373
+ def port(self) -> int:
374
+ return self._server.port
375
+
376
+ def listening(self) -> list[tuple[str, int]]:
377
+ return self._server.addresses()
378
+
379
+ def url(self, group_id: str, member: str | None = None) -> str:
380
+ base = f"http://127.0.0.1:{self.port}/g/{group_id}/"
381
+ return base if member is None else f"{base}m/{member}/"
382
+
383
+ async def start(self) -> None:
384
+ await self._server.start()
385
+
386
+ async def stop(self) -> None:
387
+ for group in self._groups.values():
388
+ for handler in group.handlers:
389
+ with contextlib.suppress(Exception):
390
+ await handler.aclose()
391
+ await self._server.stop()
392
+ await self._http.aclose()
393
+
394
+ def track(self, hold_id: str) -> None:
395
+ """Note one of the facade's own held sends: only their decisions are
396
+ kept when they come early."""
397
+ self._sweep()
398
+ self._mine[hold_id] = self.now() + EARLY_TTL_SECS
399
+ while len(self._mine) > MAX_EARLY:
400
+ self._mine.popitem(last=False)
401
+
402
+ def early(self) -> int:
403
+ """How many early decisions are kept (for tests)."""
404
+ self._sweep()
405
+ return len(self._decided)
406
+
407
+ def _sweep(self) -> None:
408
+ now = self.now()
409
+ for store in (self._mine, self._decided):
410
+ for key in [
411
+ k for k, v in store.items() if (v[1] if isinstance(v, tuple) else v) <= now
412
+ ]:
413
+ del store[key]
414
+
415
+ def hold(self, hold_id: str) -> asyncio.Future[str]:
416
+ """The future of a held send's decision (``approved``, ``rejected``,
417
+ ``expired`` or another ``send_failed`` code)."""
418
+ future = asyncio.Future[str](loop=asyncio.get_running_loop())
419
+ early = self._decided.pop(hold_id, None)
420
+ self._mine.pop(hold_id, None)
421
+ if early is not None:
422
+ future.set_result(early[0])
423
+ self.held[hold_id] = future
424
+ return future
425
+
426
+ def decided(self, hold_id: str, outcome: str) -> None:
427
+ """The daemon's word on a held send (send_approved or send_failed)."""
428
+ self._sweep()
429
+ future = self.held.get(hold_id)
430
+ if future is None:
431
+ if hold_id in self._mine:
432
+ self._decided[hold_id] = (outcome, self.now() + EARLY_TTL_SECS)
433
+ while len(self._decided) > MAX_EARLY:
434
+ self._decided.popitem(last=False)
435
+ return
436
+ if not future.done():
437
+ future.set_result(outcome)
438
+
439
+ def route_reply(self, d: Delivery) -> bool:
440
+ """Hand a reply to the facade request it answers; False if none. Only
441
+ a member the request asked (SPEC 9.3) answers it: every member reads
442
+ the request, and an answer from anyone else is context, so it can't
443
+ stand in for the member the client asked, nor bind its task."""
444
+ refs = d.inner.get("reply_to")
445
+ if not isinstance(refs, list):
446
+ return False
447
+ for ref in refs:
448
+ queue = self.waiters.get(ref) if isinstance(ref, str) else None
449
+ if queue is not None and self.adapter.asked(d, ref):
450
+ queue.put_nowait(d)
451
+ return True
452
+ return False
453
+
454
+ @staticmethod
455
+ def tag(event: Any, d: Delivery, remote_task_id: str) -> None:
456
+ """Say who answered (SPEC §10.1 framing), on an event's metadata."""
457
+ meta: dict[str, Any] = {
458
+ "untrusted": True,
459
+ "sender": d.sender.identity,
460
+ "sender_name": d.sender.display_name or "",
461
+ "sender_verified": d.sender.verified,
462
+ "scope": d.scope,
463
+ "msg_id": d.msg_id,
464
+ }
465
+ if remote_task_id:
466
+ meta["remote_task_id"] = remote_task_id
467
+ event.metadata.update(meta)
468
+
469
+ # -- cards -------------------------------------------------------------
470
+
471
+ def card(self, group_id: str, member: str | None) -> AgentCard:
472
+ known = self.adapter.peer_cards(group_id)
473
+ names = {i: c.name for i, c in known.items()}
474
+ url = self.url(group_id, member)
475
+ info = self.adapter.group_info(group_id)
476
+ if member is not None:
477
+ return cards.facade_card(url, member, "", [], known.get(member))
478
+ peers = self.adapter.peers(group_id)
479
+ if len(peers) == 1 and next(iter(peers)) in known:
480
+ return cards.facade_card(url, "", "", [], known[next(iter(peers))])
481
+ name = info.name if info is not None else "AgentConnect group"
482
+ purpose = info.purpose if info is not None else ""
483
+ return cards.facade_card(url, name, purpose, cards.composite_skills(known, names))
484
+
485
+ # -- ASGI --------------------------------------------------------------
486
+
487
+ def _app(self, group_id: str, member: str | None) -> Any:
488
+ group = self._groups.get(group_id)
489
+ if group is None:
490
+ if len(self._groups) >= MAX_GROUPS:
491
+ self._groups.pop(next(iter(self._groups)))
492
+ group = self._groups[group_id] = _GroupApps(self._http)
493
+ app = group.apps.get(member)
494
+ if app is not None:
495
+ return app
496
+ handler = DefaultRequestHandler(
497
+ agent_executor=BridgeExecutor(self, group_id, member),
498
+ task_store=group.tasks,
499
+ agent_card=cards.facade_card(self.url(group_id, member), "", "", []),
500
+ push_config_store=group.push,
501
+ push_sender=group.sender,
502
+ request_context_builder=_GroupContext(group_id),
503
+ )
504
+ group.handlers.append(handler)
505
+
506
+ async def card_route(_request: Request) -> Response:
507
+ return JSONResponse(convert.to_json(self.card(group_id, member)))
508
+
509
+ routes = [Route(CARD_PATH, card_route, methods=["GET"])]
510
+ routes.extend(create_jsonrpc_routes(handler, "/"))
511
+ app = Starlette(routes=routes)
512
+ group.apps[member] = app
513
+ return app
514
+
515
+ def _refuse(self, scope: dict[str, Any]) -> tuple[int, str] | None:
516
+ client = scope.get("client")
517
+ if not client or client[0] not in ("127.0.0.1", "::1"):
518
+ return 403, "loopback only"
519
+ headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope["headers"]}
520
+ if "origin" in headers:
521
+ return 403, "browser requests are refused"
522
+ if headers.get("host") not in (f"127.0.0.1:{self.port}", f"localhost:{self.port}"):
523
+ return 403, "bad host"
524
+ scheme, _, token = headers.get("authorization", "").partition(" ")
525
+ if scheme.lower() != "bearer" or not hmac.compare_digest(
526
+ token.encode(), self._token.encode()
527
+ ):
528
+ return 401, "missing or invalid bearer token"
529
+ return None
530
+
531
+ @staticmethod
532
+ def _split(path: str) -> tuple[str, str | None, str] | None:
533
+ """``/g/<group>[/m/<member>]<rest>`` -> (group, member, rest)."""
534
+ parts = path.split("/")
535
+ if len(parts) < 3 or parts[0] != "" or parts[1] != "g": # noqa: PLR2004
536
+ return None
537
+ if not GROUP_ID.fullmatch(parts[2]):
538
+ return None
539
+ group, rest = parts[2], parts[3:]
540
+ member = None
541
+ if len(rest) >= 2 and rest[0] == "m" and rest[1]: # noqa: PLR2004
542
+ member, rest = unquote(rest[1]), rest[2:]
543
+ return group, member, "/" + "/".join(rest)
544
+
545
+ async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
546
+ if scope["type"] != "http":
547
+ return
548
+ refused = self._refuse(scope)
549
+ target = self._split(scope["path"]) if refused is None else None
550
+ if refused is None and target is None:
551
+ refused = (404, "not found")
552
+ if refused is not None or target is None:
553
+ status, text = refused or (404, "not found")
554
+ await JSONResponse({"error": text}, status_code=status)(scope, receive, send)
555
+ return
556
+ group_id, member, rest = target
557
+ inner = dict(scope)
558
+ inner["path"] = rest
559
+ inner["raw_path"] = rest.encode()
560
+ await self._app(group_id, member)(inner, receive, send)
@@ -0,0 +1,49 @@
1
+ """What the adapter has already handled, so at-least-once redelivery (ADR-004)
2
+ never reaches the A2A agent twice.
3
+
4
+ Keys are ``msg_id``s and ``event_id``s. Only their SHA-256 is stored, so the
5
+ file (in the daemon's data directory, mode 0600) holds no group or member
6
+ identifiers. It keeps the most recent :data:`LIMIT` entries.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import os
13
+ import sqlite3
14
+ from pathlib import Path
15
+
16
+ LIMIT = 50_000
17
+ PRUNE_EVERY = 1_000
18
+
19
+
20
+ class Seen:
21
+ def __init__(self, path: Path | None) -> None:
22
+ target = ":memory:"
23
+ if path is not None:
24
+ if not path.exists():
25
+ os.close(os.open(path, os.O_CREAT | os.O_WRONLY, 0o600))
26
+ target = str(path)
27
+ self._db = sqlite3.connect(target, isolation_level=None)
28
+ self._db.execute("CREATE TABLE IF NOT EXISTS seen (h TEXT PRIMARY KEY, n INTEGER)")
29
+ row = self._db.execute("SELECT COALESCE(MAX(n), 0) FROM seen").fetchone()
30
+ self._n = int(row[0])
31
+
32
+ @staticmethod
33
+ def _h(kind: str, key: str) -> str:
34
+ return hashlib.sha256(f"{kind}\0{key}".encode()).hexdigest()
35
+
36
+ def has(self, kind: str, key: str) -> bool:
37
+ row = self._db.execute("SELECT 1 FROM seen WHERE h = ?", (self._h(kind, key),)).fetchone()
38
+ return row is not None
39
+
40
+ def add(self, kind: str, key: str) -> None:
41
+ self._n += 1
42
+ self._db.execute(
43
+ "INSERT OR IGNORE INTO seen (h, n) VALUES (?, ?)", (self._h(kind, key), self._n)
44
+ )
45
+ if self._n % PRUNE_EVERY == 0:
46
+ self._db.execute("DELETE FROM seen WHERE n <= ?", (self._n - LIMIT,))
47
+
48
+ def close(self) -> None:
49
+ self._db.close()
@@ -0,0 +1,67 @@
1
+ """A small ASGI server on loopback (uvicorn), started and stopped in-process.
2
+
3
+ uvicorn's own ``serve()`` installs signal handlers (the daemon owns those)
4
+ and polls; this runs only its startup, and stops by closing the listener
5
+ and cancelling the connections' tasks.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import socket
12
+ from typing import Any
13
+
14
+ import uvicorn
15
+
16
+ LOOPBACK = "127.0.0.1"
17
+
18
+
19
+ class LoopbackServer:
20
+ def __init__(self, app: Any, port: int = 0) -> None:
21
+ self._app = app
22
+ self._port = port
23
+ self._server: uvicorn.Server | None = None
24
+ self.port = 0
25
+
26
+ async def start(self) -> int:
27
+ """Listen on ``127.0.0.1:<port>`` (0: any free port); returns it."""
28
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
29
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
30
+ try:
31
+ sock.bind((LOOPBACK, self._port))
32
+ except OSError:
33
+ sock.close()
34
+ raise
35
+ config = uvicorn.Config(
36
+ self._app, lifespan="off", access_log=False, log_level="warning", log_config=None
37
+ )
38
+ config.load()
39
+ server = uvicorn.Server(config)
40
+ server.lifespan = config.lifespan_class(config)
41
+ await server.startup(sockets=[sock])
42
+ self._server = server
43
+ self.port = int(sock.getsockname()[1])
44
+ return self.port
45
+
46
+ def addresses(self) -> list[tuple[str, int]]:
47
+ """Where it listens (host, port)."""
48
+ if self._server is None:
49
+ return []
50
+ return [
51
+ (str(s.getsockname()[0]), int(s.getsockname()[1]))
52
+ for listener in self._server.servers
53
+ for s in listener.sockets
54
+ ]
55
+
56
+ async def stop(self) -> None:
57
+ server, self._server = self._server, None
58
+ if server is None:
59
+ return
60
+ for listener in server.servers:
61
+ listener.close()
62
+ for connection in list(server.server_state.connections):
63
+ connection.shutdown()
64
+ tasks = list(server.server_state.tasks)
65
+ for task in tasks:
66
+ task.cancel()
67
+ await asyncio.gather(*tasks, return_exceptions=True)