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/callback.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""The in-process adapter: deliveries and events go to Python callbacks.
|
|
2
|
+
|
|
3
|
+
The embedded SDK registers it, so an agent running the runtime in its own
|
|
4
|
+
process gets messages without a webhook or a socket. The callbacks receive
|
|
5
|
+
the same JSON payloads the webhook and SSE adapters send
|
|
6
|
+
(:mod:`agentconnect.payloads`), so the SDK parses one shape in every mode.
|
|
7
|
+
|
|
8
|
+
Semantics match the webhook adapter (``docs/adapters/webhook.md``):
|
|
9
|
+
|
|
10
|
+
* **Messages** are handed over by the runtime's delivery workers, in order
|
|
11
|
+
within each group; groups don't wait for each other. A callback that
|
|
12
|
+
returns is an ack. One that raises, or still runs after ``timeout``
|
|
13
|
+
seconds (on the runtime's clock; it is then cancelled), is ``RETRY``: the
|
|
14
|
+
runtime retries that group with exponential backoff and hands over nothing
|
|
15
|
+
later in that group before it, as for a webhook that answers 500 or times
|
|
16
|
+
out. Nothing is dead-lettered.
|
|
17
|
+
* **Events** are queued in the runtime's encrypted database and handed over
|
|
18
|
+
by workers of their own, in order within each group, with the same
|
|
19
|
+
timeout and a backoff on failure, so a slow handler never holds up the
|
|
20
|
+
runtime or another group. The queue keeps the newest 10 000 per adapter.
|
|
21
|
+
A crash before the callback returned hands the event over again after the
|
|
22
|
+
restart.
|
|
23
|
+
* Until :meth:`CallbackAdapter.open` is called (the SDK's ``serve()``),
|
|
24
|
+
nothing is handed over: messages wait in the inbox, events in the queue.
|
|
25
|
+
|
|
26
|
+
Every callback runs in a task of its own; :meth:`CallbackAdapter.in_handler`
|
|
27
|
+
tells whether the caller is one (the embedded host uses it to shut down from
|
|
28
|
+
inside a handler without waiting on that handler), and
|
|
29
|
+
:meth:`CallbackAdapter.drain` waits until none runs.
|
|
30
|
+
|
|
31
|
+
A callback failure is logged with the exception type only, never the
|
|
32
|
+
message or its body.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import asyncio
|
|
38
|
+
import contextlib
|
|
39
|
+
import random
|
|
40
|
+
from collections.abc import Awaitable, Callable
|
|
41
|
+
from typing import Any, Protocol
|
|
42
|
+
|
|
43
|
+
from agentconnect import log
|
|
44
|
+
from agentconnect.adapter import (
|
|
45
|
+
Capabilities,
|
|
46
|
+
Delivery,
|
|
47
|
+
DeliveryResult,
|
|
48
|
+
Event,
|
|
49
|
+
GroupView,
|
|
50
|
+
OutboundSend,
|
|
51
|
+
Sender,
|
|
52
|
+
)
|
|
53
|
+
from agentconnect.backoff import Backoff
|
|
54
|
+
from agentconnect.clock import Clock
|
|
55
|
+
from agentconnect.payloads import event_payload, message_payload
|
|
56
|
+
from agentconnect.tasks import cancel_and_wait
|
|
57
|
+
|
|
58
|
+
LOG = log.get("agentconnect.callback")
|
|
59
|
+
|
|
60
|
+
DEFAULT_TIMEOUT = 120.0 # seconds a callback may run; agent turns call models
|
|
61
|
+
GRACE_SECS = 5.0 # after the timeout's cancellation, before a handler is abandoned
|
|
62
|
+
STOP_GRACE_SECS = 1.0 # real seconds a stopping runtime waits for a cancelled handler
|
|
63
|
+
|
|
64
|
+
# One payload in; returning means handled, raising means try again later.
|
|
65
|
+
PayloadCallback = Callable[[dict[str, Any]], Awaitable[None]]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class EventQueue(Protocol):
|
|
69
|
+
"""Where the adapter keeps events until a callback took them (the store)."""
|
|
70
|
+
|
|
71
|
+
def adapter_event_add(self, adapter: str, event_id: str, payload: dict[str, Any]) -> bool: ...
|
|
72
|
+
def adapter_events(
|
|
73
|
+
self, adapter: str, group_id: str | None = None, limit: int = 100
|
|
74
|
+
) -> list[tuple[int, dict[str, Any]]]: ...
|
|
75
|
+
def adapter_event_groups(self, adapter: str) -> list[str | None]: ...
|
|
76
|
+
def adapter_event_done(self, seq: int) -> None: ...
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class HandlerCancelledError(Exception):
|
|
80
|
+
"""A handler ended with ``CancelledError`` although nothing cancelled it
|
|
81
|
+
(it awaited a task or future something else cancelled): a failure of
|
|
82
|
+
that handler, retried like any other, not the end of the delivery."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _group_of(payload: dict[str, Any]) -> str | None:
|
|
86
|
+
group = payload.get("group")
|
|
87
|
+
return str(group["id"]) if isinstance(group, dict) and "id" in group else None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class CallbackAdapter:
|
|
91
|
+
"""Implements :class:`agentconnect.adapter.Adapter` with two callbacks."""
|
|
92
|
+
|
|
93
|
+
def __init__( # noqa: PLR0913
|
|
94
|
+
self,
|
|
95
|
+
on_message: PayloadCallback,
|
|
96
|
+
on_event: PayloadCallback,
|
|
97
|
+
clock: Clock,
|
|
98
|
+
*,
|
|
99
|
+
name: str = "sdk",
|
|
100
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
101
|
+
rng: random.Random | None = None,
|
|
102
|
+
on_abandon: Callable[[dict[str, Any]], None] | None = None,
|
|
103
|
+
) -> None:
|
|
104
|
+
self.name = name
|
|
105
|
+
self._on_abandon = on_abandon
|
|
106
|
+
# At most one handler per group that ignored its cancellation and
|
|
107
|
+
# was left running.
|
|
108
|
+
self._abandoned: dict[str | None, asyncio.Future[None]] = {}
|
|
109
|
+
self._on_message, self._on_event = on_message, on_event
|
|
110
|
+
self._clock = clock
|
|
111
|
+
self._timeout = timeout
|
|
112
|
+
self._rng = rng
|
|
113
|
+
self._queue: EventQueue | None = None
|
|
114
|
+
self._open = asyncio.Event()
|
|
115
|
+
self._wake = asyncio.Event()
|
|
116
|
+
self._worker: asyncio.Task[None] | None = None
|
|
117
|
+
self._groups: dict[str | None, asyncio.Task[None]] = {}
|
|
118
|
+
self._handlers: set[asyncio.Future[None]] = set()
|
|
119
|
+
self._idle = asyncio.Event()
|
|
120
|
+
self._idle.set()
|
|
121
|
+
|
|
122
|
+
def capabilities(self) -> Capabilities:
|
|
123
|
+
# Sends go through the runtime's AgentApi, not through on_outbound.
|
|
124
|
+
return Capabilities(accepts=["text", "data"], originates=False)
|
|
125
|
+
|
|
126
|
+
def bind_store(self, queue: EventQueue) -> None:
|
|
127
|
+
"""Called by the runtime at start with its store."""
|
|
128
|
+
self._queue = queue
|
|
129
|
+
|
|
130
|
+
async def on_outbound(self, send: OutboundSend) -> None:
|
|
131
|
+
if self._worker is None or self._worker.done():
|
|
132
|
+
self._worker = asyncio.get_running_loop().create_task(self._run_events())
|
|
133
|
+
|
|
134
|
+
def open(self) -> None:
|
|
135
|
+
"""Start handing deliveries and events to the callbacks."""
|
|
136
|
+
self._open.set()
|
|
137
|
+
self._wake.set()
|
|
138
|
+
|
|
139
|
+
def pause(self) -> None:
|
|
140
|
+
"""Stop handing new ones over; they wait, nothing is lost. Callbacks
|
|
141
|
+
already running go on (:meth:`drain` waits for them)."""
|
|
142
|
+
self._open.clear()
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def is_open(self) -> bool:
|
|
146
|
+
return self._open.is_set()
|
|
147
|
+
|
|
148
|
+
def in_handler(self) -> bool:
|
|
149
|
+
"""Whether the calling task is one of this adapter's callbacks."""
|
|
150
|
+
try:
|
|
151
|
+
task = asyncio.current_task()
|
|
152
|
+
except RuntimeError:
|
|
153
|
+
return False
|
|
154
|
+
return task is not None and task in self._handlers
|
|
155
|
+
|
|
156
|
+
def busy(self) -> bool:
|
|
157
|
+
"""Whether any callback is running now."""
|
|
158
|
+
return bool(self._handlers)
|
|
159
|
+
|
|
160
|
+
async def drain(self, within: float) -> bool:
|
|
161
|
+
"""Wait (real time, at most ``within`` seconds) until no callback runs;
|
|
162
|
+
whether none does."""
|
|
163
|
+
if not self._idle.is_set() and within > 0:
|
|
164
|
+
with contextlib.suppress(TimeoutError):
|
|
165
|
+
await asyncio.wait_for(self._idle.wait(), within)
|
|
166
|
+
return self._idle.is_set()
|
|
167
|
+
|
|
168
|
+
def _track(self, task: asyncio.Future[None]) -> None:
|
|
169
|
+
self._handlers.add(task)
|
|
170
|
+
self._idle.clear()
|
|
171
|
+
|
|
172
|
+
def finished(done: asyncio.Future[None]) -> None:
|
|
173
|
+
self._handlers.discard(done)
|
|
174
|
+
if not self._handlers:
|
|
175
|
+
self._idle.set()
|
|
176
|
+
if not done.cancelled():
|
|
177
|
+
done.exception() # retrieved: an abandoned one has no awaiter
|
|
178
|
+
|
|
179
|
+
task.add_done_callback(finished)
|
|
180
|
+
|
|
181
|
+
async def _call(self, callback: PayloadCallback, payload: dict[str, Any]) -> None:
|
|
182
|
+
"""Run ``callback`` in a task of its own, bounded by the timeout on the
|
|
183
|
+
runtime's clock.
|
|
184
|
+
|
|
185
|
+
* It returns: done. It raises: that error. It ends with
|
|
186
|
+
``CancelledError`` while this caller isn't being cancelled:
|
|
187
|
+
:class:`HandlerCancelledError`.
|
|
188
|
+
* Past the timeout it is cancelled and gets :data:`GRACE_SECS` to
|
|
189
|
+
stop (``TimeoutError``). One that ignores the cancellation is left
|
|
190
|
+
running (logged ``handler_ignored_cancel``), and ``on_abandon`` is
|
|
191
|
+
told, so the redelivery isn't refused as a duplicate in progress;
|
|
192
|
+
a group keeps at most one such handler and otherwise waits for it.
|
|
193
|
+
* If this caller is cancelled (the runtime stops), the handler is
|
|
194
|
+
cancelled too and waited for, at most :data:`STOP_GRACE_SECS`.
|
|
195
|
+
"""
|
|
196
|
+
group = _group_of(payload)
|
|
197
|
+
task: asyncio.Future[None] = asyncio.ensure_future(callback(payload))
|
|
198
|
+
self._track(task)
|
|
199
|
+
timer = asyncio.ensure_future(self._clock.sleep(self._timeout, deadline=True))
|
|
200
|
+
settled = False
|
|
201
|
+
try:
|
|
202
|
+
done, _ = await asyncio.wait({task, timer}, return_when=asyncio.FIRST_COMPLETED)
|
|
203
|
+
if task in done:
|
|
204
|
+
settled = True
|
|
205
|
+
if task.cancelled():
|
|
206
|
+
current = asyncio.current_task()
|
|
207
|
+
if current is not None and current.cancelling():
|
|
208
|
+
raise asyncio.CancelledError
|
|
209
|
+
raise HandlerCancelledError
|
|
210
|
+
task.result()
|
|
211
|
+
return
|
|
212
|
+
task.cancel()
|
|
213
|
+
grace = asyncio.ensure_future(self._clock.sleep(GRACE_SECS, deadline=True))
|
|
214
|
+
try:
|
|
215
|
+
await asyncio.wait({task, grace}, return_when=asyncio.FIRST_COMPLETED)
|
|
216
|
+
finally:
|
|
217
|
+
grace.cancel()
|
|
218
|
+
if not task.done():
|
|
219
|
+
held = self._abandoned.get(group)
|
|
220
|
+
if held is not None and not held.done():
|
|
221
|
+
LOG.warning("handler_ignores_cancel", group=log.hid(group), waiting=True)
|
|
222
|
+
await asyncio.wait({task})
|
|
223
|
+
else:
|
|
224
|
+
self._abandoned[group] = task
|
|
225
|
+
LOG.warning("handler_ignored_cancel", group=log.hid(group))
|
|
226
|
+
if self._on_abandon is not None:
|
|
227
|
+
self._on_abandon(payload)
|
|
228
|
+
settled = True
|
|
229
|
+
raise TimeoutError
|
|
230
|
+
finally:
|
|
231
|
+
timer.cancel()
|
|
232
|
+
if not settled and not task.done():
|
|
233
|
+
# This caller is cancelled: the handler goes too.
|
|
234
|
+
task.cancel()
|
|
235
|
+
await asyncio.wait({task}, timeout=STOP_GRACE_SECS)
|
|
236
|
+
|
|
237
|
+
async def deliver(
|
|
238
|
+
self, group: GroupView, sender: Sender, to: list[str], inner: Delivery
|
|
239
|
+
) -> DeliveryResult:
|
|
240
|
+
await self._open.wait()
|
|
241
|
+
try:
|
|
242
|
+
await self._call(self._on_message, message_payload(group, inner))
|
|
243
|
+
except Exception as e:
|
|
244
|
+
LOG.warning("callback_failed", kind="message", error=type(e).__name__)
|
|
245
|
+
return DeliveryResult.RETRY
|
|
246
|
+
return DeliveryResult.ACK
|
|
247
|
+
|
|
248
|
+
async def on_event(self, group: GroupView | None, event: Event) -> None:
|
|
249
|
+
"""Queue the event durably; a worker hands it over. Raises (so the
|
|
250
|
+
runtime keeps the event and sends it again) only without a store."""
|
|
251
|
+
if self._queue is None:
|
|
252
|
+
msg = "the callback adapter has no store yet"
|
|
253
|
+
raise RuntimeError(msg)
|
|
254
|
+
self._queue.adapter_event_add(self.name, event.event_id, event_payload(group, event))
|
|
255
|
+
self._wake.set()
|
|
256
|
+
|
|
257
|
+
def _queued(self, group: str | None) -> list[tuple[int, dict[str, Any]]]:
|
|
258
|
+
if self._queue is None:
|
|
259
|
+
return []
|
|
260
|
+
return self._queue.adapter_events(self.name, group_id=group)
|
|
261
|
+
|
|
262
|
+
async def _run_events(self) -> None:
|
|
263
|
+
"""Start a worker for every group with queued events."""
|
|
264
|
+
try:
|
|
265
|
+
while True:
|
|
266
|
+
self._wake.clear()
|
|
267
|
+
await self._open.wait()
|
|
268
|
+
groups = [] if self._queue is None else self._queue.adapter_event_groups(self.name)
|
|
269
|
+
for group in groups:
|
|
270
|
+
running = self._groups.get(group)
|
|
271
|
+
if running is None or running.done():
|
|
272
|
+
worker = asyncio.ensure_future(self._run_group(group))
|
|
273
|
+
worker.add_done_callback(self._group_done)
|
|
274
|
+
self._groups[group] = worker
|
|
275
|
+
await self._wake.wait()
|
|
276
|
+
finally:
|
|
277
|
+
workers, self._groups = list(self._groups.values()), {}
|
|
278
|
+
await cancel_and_wait(*workers)
|
|
279
|
+
|
|
280
|
+
def _group_done(self, worker: asyncio.Future[None]) -> None:
|
|
281
|
+
"""A group's event worker that ended by surprise is logged, and the
|
|
282
|
+
next wake starts a new one."""
|
|
283
|
+
if worker.cancelled():
|
|
284
|
+
return
|
|
285
|
+
error = worker.exception()
|
|
286
|
+
if error is not None:
|
|
287
|
+
LOG.error("event_worker_died", reason=type(error).__name__)
|
|
288
|
+
self._wake.set()
|
|
289
|
+
|
|
290
|
+
async def _run_group(self, group: str | None) -> None:
|
|
291
|
+
"""One group's events, in order, until none is queued."""
|
|
292
|
+
backoff = Backoff(base=1.0, cap=300.0, rng=self._rng)
|
|
293
|
+
while True:
|
|
294
|
+
rows = self._queued(group)
|
|
295
|
+
if not rows:
|
|
296
|
+
return
|
|
297
|
+
for seq, payload in rows:
|
|
298
|
+
await self._open.wait()
|
|
299
|
+
try:
|
|
300
|
+
await self._call(self._on_event, payload)
|
|
301
|
+
except Exception as e:
|
|
302
|
+
LOG.warning("callback_failed", kind="event", error=type(e).__name__)
|
|
303
|
+
await self._clock.sleep(backoff.next())
|
|
304
|
+
break # retry from the head, in order
|
|
305
|
+
backoff.reset()
|
|
306
|
+
if self._queue is not None:
|
|
307
|
+
self._queue.adapter_event_done(seq)
|
|
308
|
+
|
|
309
|
+
async def close(self) -> None:
|
|
310
|
+
"""Called by the runtime when it stops."""
|
|
311
|
+
self._open.clear()
|
|
312
|
+
if self._worker is not None:
|
|
313
|
+
worker, self._worker = self._worker, None
|
|
314
|
+
await cancel_and_wait(worker)
|