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
|
@@ -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)
|