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.
- agentconnect/__init__.py +3 -0
- agentconnect/a2a_adapter/__init__.py +6 -0
- agentconnect/a2a_adapter/adapter.py +565 -0
- agentconnect/a2a_adapter/cards.py +149 -0
- agentconnect/a2a_adapter/convert.py +157 -0
- agentconnect/a2a_adapter/facade.py +560 -0
- agentconnect/a2a_adapter/seen.py +49 -0
- agentconnect/a2a_adapter/serve.py +67 -0
- agentconnect/adapter.py +319 -0
- agentconnect/adapters.py +52 -0
- agentconnect/apiclient.py +51 -0
- agentconnect/backoff.py +42 -0
- agentconnect/callback.py +314 -0
- agentconnect/cli.py +2324 -0
- agentconnect/cli_json.py +470 -0
- agentconnect/cli_support.py +444 -0
- agentconnect/clock.py +161 -0
- agentconnect/config.py +1213 -0
- agentconnect/daemon/__init__.py +41 -0
- agentconnect/deliveries.py +295 -0
- agentconnect/directory_client.py +255 -0
- agentconnect/failpoints.py +99 -0
- agentconnect/identity.py +111 -0
- agentconnect/isolation.py +345 -0
- agentconnect/keystore.py +279 -0
- agentconnect/limits.py +457 -0
- agentconnect/log.py +143 -0
- agentconnect/mcp/__init__.py +30 -0
- agentconnect/mcp/backend.py +405 -0
- agentconnect/mcp/server.py +382 -0
- agentconnect/mcp/session.py +373 -0
- agentconnect/mcp/tools.py +583 -0
- agentconnect/ownerfile.py +238 -0
- agentconnect/payloads.py +109 -0
- agentconnect/policy.py +1066 -0
- agentconnect/provision.py +239 -0
- agentconnect/py.typed +0 -0
- agentconnect/relay.py +405 -0
- agentconnect/runtime/__init__.py +46 -0
- agentconnect/runtime/agent.py +225 -0
- agentconnect/runtime/api.py +2043 -0
- agentconnect/runtime/engine.py +5359 -0
- agentconnect/runtime/errors.py +47 -0
- agentconnect/runtime/floor.py +649 -0
- agentconnect/runtime/groups.py +1351 -0
- agentconnect/runtime/knocks.py +1422 -0
- agentconnect/runtime/ledger.py +94 -0
- agentconnect/runtime/listing.py +1307 -0
- agentconnect/runtime/registry.py +53 -0
- agentconnect/runtime/runtime.py +158 -0
- agentconnect/runtime/sas.py +1002 -0
- agentconnect/sas_emoji.py +83 -0
- agentconnect/schemas.py +381 -0
- agentconnect/shapes.py +56 -0
- agentconnect/sockets.py +86 -0
- agentconnect/sse.py +234 -0
- agentconnect/store.py +3282 -0
- agentconnect/tasks.py +35 -0
- agentconnect/tokens.py +63 -0
- agentconnect/webhook.py +199 -0
- agentconnect_cli-0.1.0a1.dist-info/METADATA +103 -0
- agentconnect_cli-0.1.0a1.dist-info/RECORD +64 -0
- agentconnect_cli-0.1.0a1.dist-info/WHEEL +4 -0
- agentconnect_cli-0.1.0a1.dist-info/entry_points.txt +3 -0
agentconnect/__init__.py
ADDED
|
@@ -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)
|