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