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
agentconnect/sse.py ADDED
@@ -0,0 +1,234 @@
1
+ """The SSE adapter: a hub behind ``GET /v0/events`` (Accept: text/event-stream).
2
+
3
+ Same payloads as the webhook adapter (:mod:`agentconnect.payloads`), pulled
4
+ instead of pushed. Every message and event is appended to a **durable feed**
5
+ in the daemon's encrypted database; the SSE ``id`` is the feed's sequence
6
+ number. A consumer that reconnects with ``Last-Event-ID`` gets everything after
7
+ that id first, whether the daemon restarted meanwhile or not.
8
+
9
+ Delivery is **at least once**:
10
+
11
+ * With no consumer connected for a message's group, ``deliver`` returns
12
+ ``RETRY``: the daemon keeps the message in its durable inbound queue, in
13
+ order, and retries with backoff. Nothing is lost while nobody listens.
14
+ * Once a consumer is connected, the message is appended to the feed and acked.
15
+ If the consumer dies before handling it, resuming with ``Last-Event-ID``
16
+ replays it from the feed.
17
+ * A message or event the daemon hands over again after a crash (same
18
+ ``msg_id`` / ``event_id``) is already in the feed and is not appended twice.
19
+ * The feed keeps the last :data:`FEED_KEEP` items. A consumer that is away
20
+ longer than that misses the oldest ones.
21
+ * A connection without ``Last-Event-ID`` starts live.
22
+
23
+ The feed holds message payloads (the plaintext the adapter was given) for as
24
+ long as they stay in that window. It lives in ``daemon.sqlite3``, which is
25
+ encrypted at rest (ADR-003).
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import asyncio
31
+ import json
32
+ from dataclasses import dataclass, field
33
+ from typing import Any, Protocol
34
+
35
+ from agentconnect.adapter import (
36
+ Capabilities,
37
+ Delivery,
38
+ DeliveryResult,
39
+ Event,
40
+ GroupView,
41
+ OutboundSend,
42
+ Sender,
43
+ )
44
+ from agentconnect.clock import Clock, SystemClock, unix_seconds
45
+ from agentconnect.payloads import event_payload, message_payload
46
+ from agentconnect.tasks import cancel_and_wait
47
+
48
+ FEED_KEEP = 10_000
49
+ RETENTION_SECS = 3600
50
+ GAP_EVENT = "sse_gap"
51
+ MAX_PURGE_INTERVAL = 60.0
52
+ SUBSCRIBER_QUEUE = 1024
53
+
54
+
55
+ class Feed(Protocol):
56
+ """The durable feed (``Store`` implements it)."""
57
+
58
+ def feed_add(
59
+ self, key: str, group_id: str | None, payload: dict[str, Any], now: int
60
+ ) -> int | None: ...
61
+
62
+ def feed_purge(self, before: int) -> int: ...
63
+
64
+ def feed_purged(self) -> int: ...
65
+
66
+ def feed_since(
67
+ self, after: int, group_id: str | None = None, limit: int = ...
68
+ ) -> list[tuple[int, str | None, dict[str, Any]]]: ...
69
+
70
+ def feed_last(self) -> int: ...
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class Item:
75
+ seq: int
76
+ group_id: str | None
77
+ payload: dict[str, Any]
78
+
79
+ @property
80
+ def id(self) -> str:
81
+ return str(self.seq)
82
+
83
+ def frame(self) -> bytes:
84
+ """One SSE frame (the JSON is on a single ``data:`` line)."""
85
+ data = json.dumps(self.payload, sort_keys=True, separators=(",", ":"))
86
+ return f"id: {self.id}\nevent: {self.payload['event']}\ndata: {data}\n\n".encode()
87
+
88
+
89
+ @dataclass(eq=False)
90
+ class Subscription:
91
+ """One connected consumer. ``queue`` gets the replay first, then live items."""
92
+
93
+ group_id: str | None
94
+ queue: asyncio.Queue[Item] = field(default_factory=lambda: asyncio.Queue(SUBSCRIBER_QUEUE))
95
+ overflowed: bool = False
96
+
97
+ def wants(self, item: Item) -> bool:
98
+ return self.group_id is None or item.group_id == self.group_id
99
+
100
+
101
+ class SseAdapter:
102
+ """Implements :class:`agentconnect.adapter.Adapter` for stream consumers."""
103
+
104
+ name = "sse"
105
+
106
+ def __init__(
107
+ self,
108
+ clock: Clock | None = None,
109
+ retention: int = RETENTION_SECS,
110
+ feed: Feed | None = None,
111
+ ) -> None:
112
+ self._clock: Clock = clock or SystemClock()
113
+ self._retention = retention
114
+ self._feed = feed
115
+ self._purger: asyncio.Task[None] | None = None
116
+ self._subs: set[Subscription] = set()
117
+ self._changed = asyncio.Event()
118
+
119
+ def bind_store(self, feed: Feed) -> None:
120
+ """Called by the daemon at start with its store."""
121
+ self._feed = feed
122
+
123
+ @property
124
+ def feed(self) -> Feed:
125
+ if self._feed is None:
126
+ msg = "the SSE adapter has no feed: the daemon binds its store at start"
127
+ raise RuntimeError(msg)
128
+ return self._feed
129
+
130
+ def capabilities(self) -> Capabilities:
131
+ return Capabilities(accepts=["text", "data"], originates=False)
132
+
133
+ async def on_outbound(self, send: OutboundSend) -> None:
134
+ self.purge() # whatever expired while the daemon was down
135
+ if self._purger is None or self._purger.done():
136
+ self._purger = asyncio.get_running_loop().create_task(self._run_purger())
137
+
138
+ def purge(self) -> int:
139
+ """Delete feed items older than the retention window."""
140
+ return self.feed.feed_purge(unix_seconds(self._clock) - self._retention)
141
+
142
+ async def _run_purger(self) -> None:
143
+ interval = min(float(self._retention), MAX_PURGE_INTERVAL)
144
+ while True:
145
+ await self._clock.sleep(interval)
146
+ self.purge()
147
+
148
+ async def close(self) -> None:
149
+ if self._purger is not None:
150
+ await cancel_and_wait(self._purger)
151
+ self._purger = None
152
+
153
+ def _publish(self, key: str, group_id: str | None, payload: dict[str, Any]) -> None:
154
+ self.purge()
155
+ seq = self.feed.feed_add(key, group_id, payload, unix_seconds(self._clock))
156
+ if seq is None:
157
+ return # handed over again after a crash: already in the feed
158
+ item = Item(seq, group_id, payload)
159
+ for sub in list(self._subs):
160
+ if not sub.wants(item):
161
+ continue
162
+ try:
163
+ sub.queue.put_nowait(item)
164
+ except asyncio.QueueFull:
165
+ # Too slow: drop it. It resumes with Last-Event-ID.
166
+ sub.overflowed = True
167
+ self._subs.discard(sub)
168
+
169
+ def has_listener(self, group_id: str) -> bool:
170
+ return any(s.group_id in (None, group_id) for s in self._subs)
171
+
172
+ async def deliver(
173
+ self, group: GroupView, sender: Sender, to: list[str], inner: Delivery
174
+ ) -> DeliveryResult:
175
+ if not self.has_listener(group.group_id):
176
+ return DeliveryResult.RETRY
177
+ self._publish(inner.msg_id, group.group_id, message_payload(group, inner))
178
+ return DeliveryResult.ACK
179
+
180
+ async def on_event(self, group: GroupView | None, event: Event) -> None:
181
+ self._publish(event.event_id, event.group_id, event_payload(group, event))
182
+
183
+ def subscribe(self, group_id: str | None, last_event_id: str | None) -> Subscription:
184
+ """Register a consumer, queueing the replay after ``last_event_id``.
185
+
186
+ Synchronous on purpose: no item can slip between the replay and the
187
+ live feed.
188
+ """
189
+ self.purge()
190
+ after = self._resume_point(last_event_id)
191
+ rows = self.feed.feed_since(after, group_id, FEED_KEEP)
192
+ gap = self._gap(last_event_id, after)
193
+ sub = Subscription(group_id, asyncio.Queue(SUBSCRIBER_QUEUE + len(rows) + 1))
194
+ if gap is not None:
195
+ sub.queue.put_nowait(gap)
196
+ for seq, gid, payload in rows:
197
+ sub.queue.put_nowait(Item(seq, gid, payload))
198
+ self._subs.add(sub)
199
+ self._changed.set()
200
+ return sub
201
+
202
+ def _gap(self, last_event_id: str | None, after: int) -> Item | None:
203
+ """The ``sse_gap`` item for a consumer that resumed from before the
204
+ retention window: items up to ``missed_through`` are gone. Its ``id``
205
+ is ``missed_through``, so the consumer's resume point moves past the
206
+ hole and it should re-sync from the source of truth."""
207
+ purged = self.feed.feed_purged()
208
+ if last_event_id is None or after >= purged:
209
+ return None
210
+ payload = {
211
+ "event": GAP_EVENT,
212
+ "event_id": f"{GAP_EVENT}-{purged}",
213
+ "group": None,
214
+ "untrusted": True,
215
+ "data": {"missed_through": purged, "resumed_from": after},
216
+ }
217
+ return Item(purged, None, payload)
218
+
219
+ def unsubscribe(self, sub: Subscription) -> None:
220
+ self._subs.discard(sub)
221
+ self._changed.set()
222
+
223
+ async def wait_listeners(self, count: int) -> None:
224
+ """Wait until exactly ``count`` consumers are connected (for tests)."""
225
+ while len(self._subs) != count:
226
+ self._changed.clear()
227
+ await self._changed.wait()
228
+
229
+ def _resume_point(self, last_event_id: str | None) -> int:
230
+ """The feed ``seq`` to resume after. No (or a malformed) id starts
231
+ live, with no replay."""
232
+ if last_event_id is None or not last_event_id.isascii() or not last_event_id.isdigit():
233
+ return self.feed.feed_last()
234
+ return int(last_event_id)