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,3 @@
1
+ """AgentConnect daemon and CLI."""
2
+
3
+ __version__ = "0.1.0a1"
@@ -0,0 +1,6 @@
1
+ """The A2A adapter (docs/adapters/a2a.md): A2A v1.0 server agents in groups,
2
+ and an A2A server facade on loopback for local A2A clients."""
3
+
4
+ from agentconnect.a2a_adapter.adapter import A2AAdapter, A2AOptions
5
+
6
+ __all__ = ["A2AAdapter", "A2AOptions"]
@@ -0,0 +1,565 @@
1
+ """The A2A adapter: lets an A2A v1.0 server agent take part in groups
2
+ without knowing about AgentConnect (docs/adapters/a2a.md).
3
+
4
+ **Direction 1, inbound.** A message that triggers this member (SPEC §9.3,
5
+ ``addressed_to_me``) becomes a ``SendStreamingMessage`` call (``SendMessage``
6
+ when the agent doesn't stream) on the local agent, with ``contextId`` = the
7
+ group and the SPEC §10.1 framing in ``metadata``. Messages that don't
8
+ trigger it are kept, bounded, and handed over with the next call as
9
+ ``metadata.context_messages``. What the agent streams back goes to the group
10
+ as ``a2a.task_status`` / ``a2a.task_artifact`` / ``a2a.message`` with
11
+ ``reply_to`` = the triggering ``msg_id`` (SPEC §10.3), to the whole group
12
+ (``to: ["*"]``) with the requester as the only ``addressee`` (SPEC §9.3).
13
+ A follow-up that names one of the agent's tasks (``input-required``)
14
+ continues it; an ``a2a.task_cancel`` from the task's requester cancels it.
15
+
16
+ **Direction 2, the facade** (:mod:`.facade`): a local A2A client talks to
17
+ the group through a server on ``127.0.0.1``.
18
+
19
+ **At least once.** The daemon may repeat a delivery or an event (ADR-004);
20
+ both are deduplicated here by sender and ``msg_id`` (as the inbox does) and
21
+ by ``event_id`` (:mod:`.seen`), so the agent never sees one twice. The
22
+ message sent to the agent also has a ``messageId`` derived from the sender
23
+ and the ``msg_id`` (:func:`message_id`), for the crash window between the
24
+ agent accepting it and the adapter recording that.
25
+
26
+ Peer content is untrusted. It goes to the agent as data with the framing in
27
+ ``metadata``; the peer's own ``metadata`` is nested under
28
+ ``metadata.peer_metadata`` so it can't overwrite the framing, and nothing a
29
+ peer sends changes this adapter's configuration or the agent's credentials.
30
+ Logs carry tags and error types, never bodies.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import asyncio
36
+ import base64
37
+ import contextlib
38
+ import hashlib
39
+ import json
40
+ import logging
41
+ import secrets
42
+ from collections import OrderedDict, deque
43
+ from collections.abc import AsyncIterator, Coroutine, Mapping
44
+ from dataclasses import dataclass, field
45
+ from pathlib import Path
46
+ from typing import TYPE_CHECKING, Any
47
+
48
+ import httpx
49
+ from a2a.client import A2ACardResolver, A2AClientError, Client, ClientConfig, ClientFactory
50
+ from a2a.types import (
51
+ AgentCard,
52
+ CancelTaskRequest,
53
+ Message,
54
+ Part,
55
+ Role,
56
+ SendMessageRequest,
57
+ StreamResponse,
58
+ )
59
+ from a2a.utils.errors import A2AError, UnsupportedOperationError
60
+ from jwt import PyJWK
61
+
62
+ from agentconnect import log
63
+ from agentconnect.a2a_adapter import cards, convert
64
+ from agentconnect.a2a_adapter.facade import Facade
65
+ from agentconnect.a2a_adapter.seen import Seen
66
+ from agentconnect.adapter import (
67
+ Capabilities,
68
+ Delivery,
69
+ DeliveryResult,
70
+ Event,
71
+ GroupView,
72
+ OutboundSend,
73
+ Sender,
74
+ )
75
+ from agentconnect.clock import Clock, unix_seconds
76
+ from agentconnect.tasks import cancel_and_wait
77
+
78
+ if TYPE_CHECKING:
79
+ from agentconnect.config import Config
80
+ from agentconnect.store import Store
81
+
82
+ LOG = log.get("a2a")
83
+ FACADE_TOKEN_FILE = "a2a-facade-token" # noqa: S105 - a file name
84
+ SEEN_FILE = "a2a-seen.db"
85
+ MAX_CONTEXT_ENTRY = 64 * 1024 # bytes of JSON; bigger context entries are summarised
86
+ MAX_TASKS = 1024 # local agent tasks kept in memory (a cache of the database's)
87
+ TASKS_PER_PEER = 64 # tasks kept per requesting instance in the database
88
+ CARD_TIMEOUT = 5.0
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class A2AOptions:
93
+ """The adapter's configuration (``[a2a]`` in config.toml)."""
94
+
95
+ local_agent_url: str | None = None
96
+ local_agent_token: str | None = field(default=None, repr=False)
97
+ card_keys: Mapping[str, PyJWK] = field(default_factory=dict, repr=False)
98
+ facade_port: int | None = None # None: no facade; 0: any free port
99
+ context_messages: int = 20
100
+ frame_text: bool = True
101
+ reply_timeout: float = 600.0
102
+ data_dir: Path | None = None
103
+
104
+ @staticmethod
105
+ def from_config(config: Config) -> A2AOptions:
106
+ token = None
107
+ if config.a2a_local_agent_token_file is not None:
108
+ text = config.a2a_local_agent_token_file.read_text(encoding="utf-8").strip()
109
+ token = text or None
110
+ return A2AOptions(
111
+ local_agent_url=config.a2a_local_agent_url,
112
+ local_agent_token=token,
113
+ card_keys=cards.load_jwks(config.a2a_card_keys_file),
114
+ facade_port=config.a2a_facade_port if config.a2a_facade_port > 0 else None,
115
+ context_messages=config.a2a_context_messages,
116
+ frame_text=config.a2a_frame_text,
117
+ reply_timeout=float(config.a2a_reply_timeout),
118
+ data_dir=config.data_dir,
119
+ )
120
+
121
+
122
+ @dataclass(frozen=True)
123
+ class TaskRef:
124
+ """One of the local agent's tasks: the group and the member it runs for."""
125
+
126
+ group_id: str
127
+ requester: str
128
+ instance: str
129
+ trigger: str
130
+
131
+
132
+ def _seen_key(sender: str, msg_id: str) -> str:
133
+ """By sender too: a msg_id is its sender's, so another member reusing
134
+ it must not pass for a redelivery and hide the first message."""
135
+ return f"{sender}\0{msg_id}"
136
+
137
+
138
+ def message_id(d: Delivery) -> str:
139
+ """The ``messageId`` the local agent sees: ``ac-`` and 22 base64url
140
+ characters of SHA-256(sender, msg_id). The same for every redelivery,
141
+ and different for another sender's message with the same msg_id."""
142
+ digest = hashlib.sha256(f"{d.sender.identity}\0{d.msg_id}".encode()).digest()
143
+ return "ac-" + base64.urlsafe_b64encode(digest[:16]).rstrip(b"=").decode()
144
+
145
+
146
+ class A2AAdapter:
147
+ """See the module docstring."""
148
+
149
+ def __init__(self, options: A2AOptions, name: str = "a2a") -> None:
150
+ self.name = name
151
+ self.options = options
152
+ self._seen_store: Seen | None = None
153
+ self._send: OutboundSend | None = None
154
+ self._http: httpx.AsyncClient | None = None
155
+ self._client: Client | None = None
156
+ self._plain: Client | None = None
157
+ self._card: AgentCard | None = None
158
+ self._card_json: dict[str, Any] | None = None
159
+ self._groups: dict[str, GroupView] = {}
160
+ self._cards: dict[str, dict[str, tuple[AgentCard, str]]] = {}
161
+ self._peers: dict[str, set[str]] = {}
162
+ self._context: dict[str, deque[dict[str, Any]]] = {}
163
+ self._tasks: OrderedDict[str, TaskRef] = OrderedDict()
164
+ # The daemon's encrypted store, where the task map outlives a restart,
165
+ # and the runtime's clock for its entries.
166
+ self._store: Store | None = None
167
+ self._clock: Clock | None = None
168
+ self._running: set[asyncio.Task[None]] = set()
169
+ self.facade: Facade | None = None
170
+ self.facade_token: str | None = None
171
+
172
+ def bind_store(self, store: Store) -> None:
173
+ """The daemon's database: the local agent's tasks are kept there,
174
+ so a follow-up or a cancel after a restart still finds its task."""
175
+ self._store = store
176
+
177
+ def bind_clock(self, clock: Clock) -> None:
178
+ """The runtime's clock, which stamps the task map's entries."""
179
+ self._clock = clock
180
+
181
+ def _task(self, task_id: str) -> TaskRef | None:
182
+ """One of the local agent's tasks, in memory or, after a restart,
183
+ in the daemon's database."""
184
+ ref = self._tasks.get(task_id)
185
+ if ref is None and self._store is not None:
186
+ row = self._store.a2a_task(task_id)
187
+ ref = None if row is None else TaskRef(*row)
188
+ return ref
189
+
190
+ def asked(self, d: Delivery, request: str) -> bool:
191
+ """Whether the request ``request`` this daemon sent asked the member
192
+ (and instance) that sent ``d`` (SPEC 9.3, the daemon's record of its
193
+ requests). Without the daemon's store (tests), every member."""
194
+ return self._store is None or self._store.request_asks(
195
+ d.group_id, request, d.sender.identity, d.sender.instance_id
196
+ )
197
+
198
+ def _keep_task(self, task_id: str, ref: TaskRef) -> None:
199
+ self._tasks[task_id] = ref
200
+ self._tasks.move_to_end(task_id)
201
+ while len(self._tasks) > MAX_TASKS:
202
+ self._tasks.popitem(last=False)
203
+ if self._store is not None and self._clock is not None:
204
+ self._store.a2a_task_put(
205
+ task_id,
206
+ group_id=ref.group_id,
207
+ requester=ref.requester,
208
+ instance=ref.instance,
209
+ msg_id=ref.trigger,
210
+ now=unix_seconds(self._clock),
211
+ keep=TASKS_PER_PEER,
212
+ )
213
+
214
+ @property
215
+ def _seen(self) -> Seen:
216
+ # Opened on first use: the daemon creates the data directory.
217
+ if self._seen_store is None:
218
+ data_dir = self.options.data_dir
219
+ self._seen_store = Seen(None if data_dir is None else data_dir / SEEN_FILE)
220
+ return self._seen_store
221
+
222
+ # -- Adapter protocol ----------------------------------------------------
223
+
224
+ def capabilities(self) -> Capabilities:
225
+ return Capabilities(
226
+ accepts=["text", "data"],
227
+ originates=True,
228
+ agent_card=self._card_json,
229
+ # The facade alone answers nothing: it is triggered only by
230
+ # replies to its own requests.
231
+ answers=self.options.local_agent_url is not None,
232
+ )
233
+
234
+ async def on_outbound(self, send: OutboundSend) -> None:
235
+ self._send = send
236
+ await self.start()
237
+
238
+ async def deliver(
239
+ self, group: GroupView, sender: Sender, to: list[str], inner: Delivery
240
+ ) -> DeliveryResult:
241
+ self._groups[group.group_id] = group
242
+ if self.handled(sender.identity, inner.msg_id):
243
+ return DeliveryResult.ACK # redelivered (at least once)
244
+ self._peers.setdefault(group.group_id, set()).add(sender.identity)
245
+ result = await self._handle(group, inner)
246
+ if result is DeliveryResult.ACK:
247
+ self._seen.add("m", _seen_key(sender.identity, inner.msg_id))
248
+ return result
249
+
250
+ def handled(self, sender: str, msg_id: str) -> bool:
251
+ """Whether this sender's message ``msg_id`` was handled already."""
252
+ return self._seen.has("m", _seen_key(sender, msg_id))
253
+
254
+ async def on_event(self, group: GroupView | None, event: Event) -> None:
255
+ if group is not None:
256
+ self._groups[group.group_id] = group
257
+ if event.event_id and self._seen.has("e", event.event_id):
258
+ return
259
+ gid = event.group_id
260
+ identity = event.data.get("identity")
261
+ if gid is not None and isinstance(identity, str):
262
+ if event.type == "member_joined":
263
+ self._peers.setdefault(gid, set()).add(identity)
264
+ elif event.type in ("member_left", "member_removed"):
265
+ self._peers.get(gid, set()).discard(identity)
266
+ self._cards.get(gid, {}).pop(identity, None)
267
+ if self.facade is not None and event.type in ("send_approved", "send_failed"):
268
+ # A facade send the group held for the human: approved or not.
269
+ hold_id = event.data.get("hold_id")
270
+ if isinstance(hold_id, str):
271
+ self.facade.decided(hold_id, str(event.data.get("code", "approved")))
272
+ if event.type == "joined" and self.options.local_agent_url is not None:
273
+ # SPEC: the card goes out on join; fetch it fresh first.
274
+ await self._load_card()
275
+ if gid is not None and event.type in ("removed", "left"):
276
+ self._forget(gid)
277
+ if event.event_id:
278
+ self._seen.add("e", event.event_id)
279
+
280
+ # -- lifecycle -----------------------------------------------------------
281
+
282
+ async def start(self) -> None:
283
+ # a2a-sdk logs card contents and events at INFO/DEBUG: peer content.
284
+ logging.getLogger("a2a").setLevel(logging.ERROR)
285
+ if self.options.local_agent_url is not None and self._http is None:
286
+ headers = {}
287
+ if self.options.local_agent_token is not None:
288
+ headers["Authorization"] = f"Bearer {self.options.local_agent_token}"
289
+ # No read timeout: a stream may stay quiet while the agent works.
290
+ self._http = httpx.AsyncClient(headers=headers, timeout=httpx.Timeout(30.0, read=None))
291
+ await self._load_card()
292
+ if self.options.facade_port is not None and self.facade is None:
293
+ self.facade_token = self._facade_token()
294
+ facade = Facade(self, self.facade_token, self.options.facade_port)
295
+ await facade.start()
296
+ self.facade = facade
297
+ LOG.info("a2a_facade_started", port=facade.port)
298
+
299
+ async def close(self) -> None:
300
+ """Called by ``Engine.stop``."""
301
+ await self.aclose()
302
+
303
+ async def aclose(self) -> None:
304
+ try:
305
+ await cancel_and_wait(*self._running, ignore=(Exception,))
306
+ finally:
307
+ try:
308
+ if self.facade is not None:
309
+ await self.facade.stop()
310
+ self.facade = None
311
+ if self._http is not None:
312
+ await self._http.aclose()
313
+ self._http = None
314
+ finally:
315
+ if self._seen_store is not None:
316
+ self._seen_store.close()
317
+ self._seen_store = None
318
+
319
+ def _facade_token(self) -> str:
320
+ if self.options.data_dir is None:
321
+ return secrets.token_urlsafe(32)
322
+ from agentconnect.tokens import load_token # noqa: PLC0415 - avoid an import cycle
323
+
324
+ return load_token(self.options.data_dir / FACADE_TOKEN_FILE)
325
+
326
+ # -- for the facade --------------------------------------------------------
327
+
328
+ def group_info(self, group_id: str) -> GroupView | None:
329
+ return self._groups.get(group_id)
330
+
331
+ def peers(self, group_id: str) -> set[str]:
332
+ return set(self._peers.get(group_id, set()))
333
+
334
+ def peer_cards(self, group_id: str) -> dict[str, AgentCard]:
335
+ return {i: card for i, (card, _status) in self._cards.get(group_id, {}).items()}
336
+
337
+ def card_status(self, group_id: str, identity: str) -> str | None:
338
+ entry = self._cards.get(group_id, {}).get(identity)
339
+ return None if entry is None else entry[1]
340
+
341
+ async def send_to_group(
342
+ self,
343
+ group_id: str,
344
+ body: dict[str, Any],
345
+ to: list[str],
346
+ kind: str = "a2a.message",
347
+ instance: str | None = None,
348
+ ) -> str:
349
+ if self._send is None:
350
+ msg = "the adapter has not been started"
351
+ raise RuntimeError(msg)
352
+ return await self._send(group_id, body, to=to, kind=kind, instance=instance)
353
+
354
+ # -- inbound -------------------------------------------------------------
355
+
356
+ async def _handle(self, group: GroupView, d: Delivery) -> DeliveryResult:
357
+ if d.kind == "a2a.agent_card":
358
+ self._keep_card(d)
359
+ return DeliveryResult.ACK
360
+ if self.facade is not None and self.facade.route_reply(d):
361
+ return DeliveryResult.ACK
362
+ if d.kind == "a2a.task_cancel":
363
+ await self._maybe_cancel(d)
364
+ return DeliveryResult.ACK
365
+ if d.kind != "a2a.message" or self.options.local_agent_url is None:
366
+ return DeliveryResult.ACK
367
+ if not d.addressed_to_me:
368
+ self._remember(d)
369
+ return DeliveryResult.ACK
370
+ return await self._trigger(group, d)
371
+
372
+ def _keep_card(self, d: Delivery) -> None:
373
+ card = convert.parse_card(d.body)
374
+ if card is None:
375
+ return
376
+ status = cards.signature_status(card, self.options.card_keys)
377
+ if status == "invalid":
378
+ LOG.warning("a2a_peer_card_invalid", group=log.hid(d.group_id))
379
+ return
380
+ self._cards.setdefault(d.group_id, {})[d.sender.identity] = (card, status)
381
+
382
+ def _remember(self, d: Delivery) -> None:
383
+ entry: dict[str, Any] = {
384
+ "msg_id": d.msg_id,
385
+ "sender": d.sender.identity,
386
+ "sender_name": d.sender.display_name or "",
387
+ "to": d.to,
388
+ "hop": d.hop,
389
+ "parts": convert.to_json(convert.parse_message(d.body)).get("parts", []),
390
+ }
391
+ if len(json.dumps(entry)) > MAX_CONTEXT_ENTRY:
392
+ entry = {k: v for k, v in entry.items() if k != "parts"} | {"omitted": "too_large"}
393
+ limit = max(self.options.context_messages, 0)
394
+ self._context.setdefault(d.group_id, deque(maxlen=limit)).append(entry)
395
+
396
+ def _local_message(self, d: Delivery) -> Message:
397
+ peer = convert.parse_message(d.body)
398
+ msg = Message(role=Role.ROLE_USER, message_id=message_id(d), context_id=d.group_id)
399
+ # The notice goes first unless the adapter (a2a.frame_text) or the
400
+ # group's safety policy (frame_text) turned it off.
401
+ framed = self.options.frame_text and d.frame_text
402
+ if framed:
403
+ msg.parts.append(Part(text=d.framed_text()))
404
+ msg.parts.extend(peer.parts)
405
+ ref = self._task(peer.task_id) if peer.task_id else None
406
+ if ref is not None and ref.group_id == d.group_id and ref.requester == d.sender.identity:
407
+ msg.task_id = peer.task_id # a follow-up on one of the agent's tasks
408
+ meta: dict[str, Any] = {
409
+ "untrusted": True,
410
+ "scope": d.scope,
411
+ "frame_text": framed,
412
+ "notice": d.framed_text(),
413
+ "sender": {
414
+ "identity": d.sender.identity,
415
+ "instance_id": d.sender.instance_id,
416
+ "display_name": d.sender.display_name,
417
+ "verified": d.sender.verified,
418
+ },
419
+ "to": list(d.to),
420
+ "addressed_to_me": d.addressed_to_me,
421
+ "trust_state": dict(d.trust_state),
422
+ "group_id": d.group_id,
423
+ "msg_id": d.msg_id,
424
+ "hop": d.hop,
425
+ "origin": d.origin,
426
+ }
427
+ context = self._context.get(d.group_id)
428
+ if context is not None and len(context) > 0:
429
+ meta["context_messages"] = list(context)
430
+ peer_meta = convert.to_json(peer).get("metadata")
431
+ if peer_meta:
432
+ meta["peer_metadata"] = peer_meta
433
+ if peer.message_id:
434
+ meta["peer_message_id"] = peer.message_id
435
+ msg.metadata.update(meta)
436
+ return msg
437
+
438
+ async def _ensure_client(self) -> bool:
439
+ return self._client is not None or await self._load_card()
440
+
441
+ async def _trigger(self, group: GroupView, d: Delivery) -> DeliveryResult:
442
+ if not await self._ensure_client() or self._client is None or self._plain is None:
443
+ return DeliveryResult.RETRY # the local agent is unreachable for now
444
+ request = SendMessageRequest(message=self._local_message(d))
445
+ try:
446
+ try:
447
+ stream = aiter(self._client.send_message(request))
448
+ first = await anext(stream, None)
449
+ except UnsupportedOperationError:
450
+ # The agent refused streaming: fall back to SendMessage.
451
+ stream = aiter(self._plain.send_message(request))
452
+ first = await anext(stream, None)
453
+ except A2AClientError as e:
454
+ LOG.warning(
455
+ "a2a_local_unreachable", group=log.hid(group.group_id), error=type(e).__name__
456
+ )
457
+ return DeliveryResult.RETRY
458
+ except A2AError as e:
459
+ # A protocol error is the agent's answer, not a transient failure:
460
+ # retrying would block every later delivery.
461
+ LOG.warning("a2a_local_refused", group=log.hid(group.group_id), error=type(e).__name__)
462
+ return DeliveryResult.ACK
463
+ self._context.pop(d.group_id, None)
464
+ LOG.info("a2a_triggered", group=log.hid(group.group_id), msg=log.hid(d.msg_id))
465
+ if first is not None:
466
+ self._spawn(self._forward(d, first, stream))
467
+ return DeliveryResult.ACK
468
+
469
+ def _spawn(self, work: Coroutine[Any, Any, None]) -> None:
470
+ task = asyncio.get_running_loop().create_task(work)
471
+ self._running.add(task)
472
+ task.add_done_callback(self._running.discard)
473
+
474
+ async def _forward(
475
+ self, d: Delivery, first: StreamResponse, rest: AsyncIterator[StreamResponse]
476
+ ) -> None:
477
+ """Send what the local agent streams to the group."""
478
+ # SPEC 9.3: everyone sees the progress; only the requester is triggered.
479
+ addressee = [d.sender.identity]
480
+ await self._emit(d, addressee, first)
481
+ if convert.is_final(first):
482
+ return
483
+ try:
484
+ async for resp in rest:
485
+ await self._emit(d, addressee, resp)
486
+ if convert.is_final(resp):
487
+ break
488
+ except (A2AError, httpx.HTTPError) as e:
489
+ LOG.warning("a2a_stream_failed", group=log.hid(d.group_id), error=type(e).__name__)
490
+
491
+ async def _emit(self, d: Delivery, addressee: list[str], resp: StreamResponse) -> None:
492
+ task_id = convert.task_id_of(resp)
493
+ if task_id and task_id not in self._tasks:
494
+ self._keep_task(
495
+ task_id, TaskRef(d.group_id, d.sender.identity, d.sender.instance_id, d.msg_id)
496
+ )
497
+ if self._send is None:
498
+ return
499
+ for kind, body in convert.stream_to_inner(resp, d.group_id):
500
+ try:
501
+ await self._send(
502
+ d.group_id,
503
+ body,
504
+ to=["*"],
505
+ kind=kind,
506
+ reply_to=[d.msg_id],
507
+ addressee=addressee,
508
+ # SPEC 7.2/9.4: only the requesting instance is triggered.
509
+ instance=d.sender.instance_id,
510
+ )
511
+ except Exception as e:
512
+ code = getattr(e, "code", type(e).__name__)
513
+ LOG.warning("a2a_reply_not_sent", group=log.hid(d.group_id), code=code)
514
+
515
+ async def _maybe_cancel(self, d: Delivery) -> None:
516
+ """SPEC 7.2: ``a2a.task_cancel`` (an A2A ``CancelTaskRequest``) from
517
+ the task's requester cancels the agent's task; from anyone else it is
518
+ ignored, and so is one delivered as context (past the loop guard, or
519
+ addressed to another member): it has no command's standing."""
520
+ request = convert.parse_cancel(d.body)
521
+ ref = None if request is None else self._task(request.id)
522
+ if (
523
+ not d.addressed_to_me
524
+ or request is None
525
+ or ref is None
526
+ or ref.group_id != d.group_id
527
+ or ref.requester != d.sender.identity
528
+ or self._client is None
529
+ ):
530
+ LOG.info("a2a_cancel_ignored", group=log.hid(d.group_id))
531
+ return
532
+ with contextlib.suppress(A2AError, httpx.HTTPError):
533
+ await self._client.cancel_task(CancelTaskRequest(id=request.id))
534
+ LOG.info("a2a_task_canceled", group=log.hid(d.group_id))
535
+
536
+ # -- the local agent's card ----------------------------------------------
537
+
538
+ async def _load_card(self) -> bool:
539
+ if self._http is None or self.options.local_agent_url is None:
540
+ return False
541
+ resolver = A2ACardResolver(self._http, self.options.local_agent_url)
542
+ try:
543
+ async with asyncio.timeout(CARD_TIMEOUT):
544
+ card = await resolver.get_agent_card()
545
+ except (A2AError, httpx.HTTPError, TimeoutError) as e:
546
+ LOG.warning("a2a_card_unavailable", error=type(e).__name__)
547
+ return False
548
+ status = cards.signature_status(card, self.options.card_keys)
549
+ if status == "invalid":
550
+ LOG.warning("a2a_local_card_invalid")
551
+ return False
552
+ self._card = card
553
+ self._card_json = convert.to_json(card)
554
+ self._client = ClientFactory(ClientConfig(httpx_client=self._http, streaming=True)).create(
555
+ card
556
+ )
557
+ self._plain = ClientFactory(ClientConfig(httpx_client=self._http, streaming=False)).create(
558
+ card
559
+ )
560
+ LOG.info("a2a_card_loaded", signature=status, skills=len(card.skills))
561
+ return True
562
+
563
+ def _forget(self, group_id: str) -> None:
564
+ for table in (self._groups, self._cards, self._peers, self._context):
565
+ table.pop(group_id, None)