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,373 @@
1
+ """One MCP session's runtime: host it, or attach to the one that runs.
2
+
3
+ ADR-005, Decision 2: a session that finds the data directory free hosts the
4
+ runtime (:class:`~agentconnect.mcp.backend.HostBackend`); one that finds it
5
+ locked attaches to the holder (:class:`~agentconnect.mcp.backend.AttachBackend`).
6
+
7
+ When the holder goes away, the session decides from the lock as it is now,
8
+ on every reconnect: held, it attaches to whoever holds it; free, it takes
9
+ the directory over unless its last holder was a daemon or an embedded agent
10
+ (those are supervised by a host of their own, which brings them back, and a
11
+ session holding the lock would keep them from starting). ``flock`` lets one
12
+ session win; the new owner finishes what the old one left in its journal
13
+ (ADR-004). A reconnect that fails leaves no backend: the next call tries
14
+ again.
15
+
16
+ Acknowledgement (at least once, never lost): a wait's batch stays leased
17
+ until the session has written the tool result to the client
18
+ (:meth:`Session.result_written`); only then is it acknowledged. A result
19
+ that was never written (the call was cancelled, the client stopped reading)
20
+ acknowledges nothing, and the batch is offered again when its lease runs
21
+ out. An ack the owner refuses or never answers is retried on the next wait,
22
+ events call or close, and until it went through the batch's messages are
23
+ not treated as repeats: offered again, they are returned again.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import asyncio
29
+ import contextvars
30
+ from collections import OrderedDict
31
+ from collections.abc import Sequence
32
+ from dataclasses import dataclass, field
33
+ from typing import Any, Literal
34
+
35
+ import httpx
36
+
37
+ from agentconnect import log
38
+ from agentconnect.deliveries import LEASE_SECS, delivery_key
39
+ from agentconnect.keystore import DataDirAccessError, DataDirLock
40
+ from agentconnect.mcp.backend import (
41
+ AttachBackend,
42
+ Backend,
43
+ HostBackend,
44
+ OwnerGoneError,
45
+ Pulled,
46
+ ToolError,
47
+ )
48
+ from agentconnect.runtime import Clock, Config, DataDirLockedError, Keystore, Options
49
+
50
+ LOG = log.get("agentconnect.mcp")
51
+
52
+ REMEMBER = 4096 # keys of acknowledged messages kept in memory
53
+ UNWRITTEN_KEEP = 64 # batches whose result isn't written yet, at most
54
+ UNACKED_KEEP = 8 # batches whose ack failed, kept for a retry, at most
55
+ MAX_WAITS = 2 # agentconnect_wait calls running at once in one session
56
+ SUPERVISED = frozenset({"daemon", "embedded"}) # holders a session never takes over
57
+ # Calls that are safe to repeat when the owner went away mid-request.
58
+ REPEATABLE = frozenset(
59
+ {"groups", "group_info", "events", "knock_inspect", "knocks", "listing_status", "take"}
60
+ )
61
+
62
+ # The MCP request a tool call serves (set by the server around each call).
63
+ current_request: contextvars.ContextVar[object | None] = contextvars.ContextVar(
64
+ "agentconnect_mcp_request", default=None
65
+ )
66
+
67
+
68
+ @dataclass
69
+ class _Batch:
70
+ lease: str
71
+ ids: list[str]
72
+ keys: list[str]
73
+ msg_ids: list[str] = field(default_factory=list) # what its result returns
74
+ taken_at: float = 0.0 # Unix seconds on the session's clock
75
+
76
+
77
+ class Session:
78
+ """The agent-scope operations of one MCP session, over whichever backend
79
+ serves it now."""
80
+
81
+ def __init__(
82
+ self,
83
+ config: Config,
84
+ clock: Clock,
85
+ *,
86
+ keystore: Keystore | None = None,
87
+ options: Options | None = None,
88
+ transport: httpx.AsyncBaseTransport | None = None,
89
+ ) -> None:
90
+ self.config, self._clock = config, clock
91
+ self._keystore, self._options, self._transport = keystore, options, transport
92
+ self.backend: Backend | None = None
93
+ self._opened = False
94
+ self._switch = asyncio.Lock()
95
+ self._waits = 0
96
+ self._acked: OrderedDict[str, None] = OrderedDict() # keys acknowledged
97
+ self._unwritten: OrderedDict[object, _Batch] = OrderedDict()
98
+ self._unacked: list[_Batch] = [] # written, but the ack didn't go through
99
+ self._acking: set[asyncio.Task[None]] = set()
100
+
101
+ @property
102
+ def mode(self) -> Literal["host", "attach"]:
103
+ return "attach" if self.backend is None else self.backend.mode
104
+
105
+ async def open(self) -> None:
106
+ """Host the runtime if the directory is free, else attach. Without a
107
+ relay this session can't host: it attaches, or fails if nothing
108
+ holds the directory."""
109
+ self.backend = await self._connect(startup=True)
110
+ self._opened = True
111
+ LOG.info("mcp_session", mode=self.backend.mode)
112
+
113
+ async def _connect(self, *, startup: bool = False) -> Backend:
114
+ """A backend for the directory as it is now (see the module doc)."""
115
+ try:
116
+ held, holder = DataDirLock.probe(self.config.data_dir)
117
+ except DataDirAccessError as e:
118
+ raise ToolError("data_dir_not_accessible", str(e), 403) from None
119
+ if not held:
120
+ if not startup and holder in SUPERVISED:
121
+ msg = (
122
+ f"the {holder} runtime that owns {self.config.data_dir} isn't running; "
123
+ "start it again and retry"
124
+ )
125
+ raise ToolError("not_running", msg, 503)
126
+ if not self.config.relay_url:
127
+ msg = (
128
+ f"nothing runs on {self.config.data_dir} and no relay is configured: "
129
+ "pass --relay (or set AC_RELAY_URL) so this server can run it"
130
+ )
131
+ raise ToolError("bad_config", msg)
132
+ try:
133
+ return await HostBackend.start(
134
+ self.config, self._clock, keystore=self._keystore, options=self._options
135
+ )
136
+ except DataDirLockedError as e:
137
+ holder = e.holder # another session won the lock meanwhile
138
+ return AttachBackend(self.config, holder, self._transport)
139
+
140
+ def _connected(self) -> Backend | None:
141
+ return self.backend
142
+
143
+ async def _backend(self) -> Backend:
144
+ """The backend, connecting again first if the last reconnect failed."""
145
+ if self.backend is not None:
146
+ return self.backend
147
+ if not self._opened:
148
+ msg = "the session isn't open"
149
+ raise ToolError("not_running", msg, 503)
150
+ async with self._switch:
151
+ current = self._connected() # another call may have connected meanwhile
152
+ if current is None:
153
+ current = await self._connect()
154
+ self.backend = current
155
+ LOG.info("mcp_reconnected", mode=current.mode)
156
+ return current
157
+
158
+ async def _recover(self, failed: Backend) -> None:
159
+ """The holder went away: drop the failed backend, then connect to the
160
+ directory as it is now. If that fails too, the next call retries."""
161
+ async with self._switch:
162
+ if self.backend is not failed:
163
+ return # another call already switched
164
+ self.backend = None
165
+ await failed.close()
166
+ self.backend = await self._connect()
167
+ LOG.info("mcp_reconnected", mode=self.backend.mode)
168
+
169
+ async def _call(self, op: str, *args: Any, **kwargs: Any) -> Any:
170
+ backend = await self._backend()
171
+ try:
172
+ return await getattr(backend, op)(*args, **kwargs)
173
+ except OwnerGoneError as e:
174
+ if e.sent and op not in REPEATABLE:
175
+ await self._recover(backend)
176
+ msg = (
177
+ "the runtime went away during this call: it may or may not have "
178
+ "happened; check (agentconnect_groups, agentconnect_events) before "
179
+ "trying again"
180
+ )
181
+ raise ToolError("interrupted", msg, 503) from None
182
+ await self._recover(backend)
183
+ try:
184
+ return await getattr(await self._backend(), op)(*args, **kwargs)
185
+ except OwnerGoneError:
186
+ msg = (
187
+ f"the runtime that owns {self.config.data_dir} isn't answering; "
188
+ "start it again (or end the other session) and retry"
189
+ )
190
+ raise ToolError("not_running", msg, 503) from None
191
+
192
+ async def create_group(self, name: str, purpose: str) -> dict[str, Any]:
193
+ return await self._call("create_group", name, purpose)
194
+
195
+ async def create_invite(self, group: str, max_uses: int, ttl_secs: int) -> dict[str, Any]:
196
+ return await self._call("create_invite", group, max_uses, ttl_secs)
197
+
198
+ async def accept(self, url: str, display_name: str | None) -> dict[str, Any]:
199
+ return await self._call("accept", url, display_name)
200
+
201
+ async def send(
202
+ self, group: str, text: str, to: Sequence[str], reply_to: Sequence[str] | None
203
+ ) -> dict[str, Any]:
204
+ return await self._call("send", group, text, to, reply_to)
205
+
206
+ async def groups(self) -> dict[str, Any]:
207
+ return await self._call("groups")
208
+
209
+ async def group_info(self, group: str) -> dict[str, Any]:
210
+ return await self._call("group_info", group)
211
+
212
+ async def leave(self, group: str) -> dict[str, Any]:
213
+ return await self._call("leave", group)
214
+
215
+ async def events(self, after: int, group: str | None) -> dict[str, Any]:
216
+ await self.retry_acks()
217
+ return await self._call("events", after, group)
218
+
219
+ async def knock_inspect(self, listing: str, domain_check: bool | None) -> dict[str, Any]:
220
+ return await self._call("knock_inspect", listing, domain_check)
221
+
222
+ async def knock(
223
+ self,
224
+ listing: str,
225
+ *,
226
+ listing_sha256: str | None,
227
+ display_name: str | None,
228
+ note: str | None,
229
+ domain_check: bool | None,
230
+ ) -> dict[str, Any]:
231
+ return await self._call(
232
+ "knock",
233
+ listing,
234
+ listing_sha256=listing_sha256,
235
+ display_name=display_name,
236
+ note=note,
237
+ domain_check=domain_check,
238
+ )
239
+
240
+ async def knocks(self) -> dict[str, Any]:
241
+ return await self._call("knocks")
242
+
243
+ async def listing_status(self) -> dict[str, Any]:
244
+ return await self._call("listing_status")
245
+
246
+ async def take(
247
+ self, group: str | None, addressed_only: bool, wait_secs: float
248
+ ) -> tuple[list[Pulled], bool]:
249
+ """A wait's leased batch. At most :data:`MAX_WAITS` run at once per
250
+ session (another is refused with ``busy``). Messages whose ack went
251
+ through already are acknowledged again quietly and left out. The
252
+ batch is acknowledged once its result is written (for the MCP request
253
+ in :data:`current_request`; without one, at once)."""
254
+ if self._waits >= MAX_WAITS:
255
+ msg = f"{MAX_WAITS} agentconnect_wait calls run already in this session"
256
+ raise ToolError("busy", msg, 429)
257
+ self._waits += 1
258
+ try:
259
+ await self.retry_acks()
260
+ pulls = await self._call("take", group, addressed_only, wait_secs)
261
+ finally:
262
+ self._waits -= 1
263
+ fresh: list[Pulled] = []
264
+ repeats: list[Pulled] = []
265
+ for item in pulls.items:
266
+ key = delivery_key(item.payload)
267
+ (repeats if key in self._acked else fresh).append(item)
268
+ now = self._now()
269
+ if repeats:
270
+ await self._ack(_Batch(pulls.lease, [r.delivery_id for r in repeats], [], [], now))
271
+ if fresh:
272
+ batch = _Batch(
273
+ pulls.lease,
274
+ [f.delivery_id for f in fresh],
275
+ [delivery_key(f.payload) for f in fresh],
276
+ [str(f.payload.get("msg_id")) for f in fresh],
277
+ now,
278
+ )
279
+ request = current_request.get()
280
+ if request is None:
281
+ await self._ack(batch)
282
+ else:
283
+ # A batch still kept under this id never had its result
284
+ # written (the client reused the id): it goes back now.
285
+ await self.abandon(request)
286
+ self._unwritten[request] = batch
287
+ while len(self._unwritten) > UNWRITTEN_KEEP:
288
+ self._unwritten.popitem(last=False) # its lease runs out
289
+ return fresh, bool(pulls.more)
290
+
291
+ def awaiting_write(self) -> bool:
292
+ """Whether some batch waits for its result to be written."""
293
+ return bool(self._unwritten)
294
+
295
+ def _now(self) -> float:
296
+ return self._clock.now().timestamp()
297
+
298
+ async def result_written(self, request: object, msg_ids: Sequence[str]) -> None:
299
+ """A result for ``request`` returning ``msg_ids`` reached the client's
300
+ pipe: acknowledge the batch kept under that id, if that result is the
301
+ one that returned it. Another answer with the same id (a client that
302
+ reused it) acknowledges nothing."""
303
+ batch = self._unwritten.get(request)
304
+ if batch is not None and list(msg_ids) == batch.msg_ids:
305
+ del self._unwritten[request]
306
+ await self._ack(batch)
307
+
308
+ def result_written_soon(self, request: object, msg_ids: Sequence[str]) -> None:
309
+ """:meth:`result_written` in a task of its own (from the writer)."""
310
+ if request not in self._unwritten:
311
+ return
312
+ task = asyncio.ensure_future(self.result_written(request, msg_ids))
313
+ self._acking.add(task)
314
+ task.add_done_callback(self._acking.discard)
315
+
316
+ async def abandon(self, request: object) -> None:
317
+ """The batch kept under ``request`` won't be written (the call was
318
+ cancelled, or the client reused the id): release its lease, so its
319
+ messages are offered again at once."""
320
+ batch = self._unwritten.pop(request, None)
321
+ if batch is None:
322
+ return
323
+ try:
324
+ backend = await self._backend()
325
+ await backend.release(batch.lease, batch.ids)
326
+ except (OwnerGoneError, ToolError) as e:
327
+ LOG.info("mcp_release_failed", code=getattr(e, "code", "gone"))
328
+
329
+ async def _ack(self, batch: _Batch) -> bool:
330
+ """Acknowledge ``batch``; if the owner refuses or doesn't answer, keep
331
+ it for :meth:`retry_acks` (the newest :data:`UNACKED_KEEP`). Keys
332
+ count as acknowledged only after the owner confirmed them."""
333
+ try:
334
+ backend = await self._backend()
335
+ done = set(await backend.ack(batch.lease, batch.ids))
336
+ except (OwnerGoneError, ToolError) as e:
337
+ LOG.info("mcp_ack_failed", code=getattr(e, "code", "gone"), count=len(batch.ids))
338
+ self._unacked.append(batch)
339
+ del self._unacked[:-UNACKED_KEEP]
340
+ return False
341
+ for delivery_id, key in zip(batch.ids, batch.keys, strict=False):
342
+ if delivery_id in done:
343
+ self._acked[key] = None
344
+ self._acked.move_to_end(key)
345
+ while len(self._acked) > REMEMBER:
346
+ self._acked.popitem(last=False)
347
+ return True
348
+
349
+ async def retry_acks(self) -> None:
350
+ """Try again the acks that didn't go through, oldest first, until one
351
+ fails again. A batch older than its lease is dropped instead: its
352
+ messages are offered again anyway."""
353
+ fresh_since = self._now() - LEASE_SECS
354
+ pending = [b for b in self._unacked if b.taken_at >= fresh_since]
355
+ self._unacked = []
356
+ for n, batch in enumerate(pending):
357
+ if not await self._ack(batch):
358
+ self._unacked = [*self._unacked[:-1], *pending[n + 1 :], batch][-UNACKED_KEEP:]
359
+ return
360
+
361
+ async def close(self) -> None:
362
+ """Finish the acks (written results only), then stop the runtime
363
+ (host) or the client (attach)."""
364
+ if self._acking:
365
+ await asyncio.gather(*self._acking, return_exceptions=True)
366
+ if self.backend is not None:
367
+ for request in list(self._unwritten):
368
+ await self.abandon(request)
369
+ await self.retry_acks()
370
+ backend, self.backend = self.backend, None
371
+ self._opened = False
372
+ if backend is not None:
373
+ await backend.close()