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/limits.py
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
"""SPEC §10.2 endpoint limits and the §7.2 kind and part checks.
|
|
2
|
+
|
|
3
|
+
The SPEC defaults (:data:`SPEC_LIMITS`):
|
|
4
|
+
|
|
5
|
+
* Messages are at most 256 KiB (the canonical JSON of the inner message).
|
|
6
|
+
* Each sender instance may send 30 messages per minute, all instances of one
|
|
7
|
+
identity together 3 times that, and a group 120.
|
|
8
|
+
* Updates for a task this instance asked for (``a2a.task_status``,
|
|
9
|
+
``a2a.task_artifact`` with a ``taskId``, answering a message it sent)
|
|
10
|
+
have their own budget: 300 per minute per task and sender
|
|
11
|
+
instance, 3 times that for all of one identity's tasks, and 1200 for the
|
|
12
|
+
group's. The first update of a task in the window counts as an ordinary
|
|
13
|
+
message, so opening tasks is held to the message rates. Other task
|
|
14
|
+
updates get the message rates, in windows of their own.
|
|
15
|
+
* A2A parts may be ``text`` or ``data``; ``file`` parts are disabled.
|
|
16
|
+
* Only the kinds of §7.2/§7.3 are delivered; unknown kinds are ignored.
|
|
17
|
+
|
|
18
|
+
A group, or one sender in it, can have lower values (:class:`Limits`), never
|
|
19
|
+
higher ones (SPEC 10.2); the safety policy (:mod:`agentconnect.policy`) picks
|
|
20
|
+
them. Rates are counted on the daemon clock over a sliding 60-second
|
|
21
|
+
window, separately for what is received (inbound) and what is queued
|
|
22
|
+
(outbound).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import re
|
|
29
|
+
from collections import OrderedDict, deque
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from itertools import accumulate
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
from agentconnect.clock import Clock
|
|
35
|
+
from agentconnect.schemas import schema_ok
|
|
36
|
+
|
|
37
|
+
MAX_MESSAGE_BYTES = 256 * 1024
|
|
38
|
+
SENDER_PER_MINUTE = 30
|
|
39
|
+
GROUP_PER_MINUTE = 120
|
|
40
|
+
IDENTITY_RATE_MULTIPLE = 3 # all instances of an identity: 3 x SENDER_PER_MINUTE
|
|
41
|
+
TASK_PER_MINUTE = 300
|
|
42
|
+
GROUP_TASK_PER_MINUTE = 1200 # task updates from the whole group: 4 x TASK_PER_MINUTE
|
|
43
|
+
MAX_KEYS = 50_000 # rate windows tracked at most (least recently used go first)
|
|
44
|
+
SWEEP_FLOOR = 256 # sweep emptied windows once more than this many are tracked
|
|
45
|
+
MAX_TASK_ID = 256 # longer taskIds get no task budget
|
|
46
|
+
U32_MAX = 2**32 - 1 # SPEC 7.2: `hop` is a u32
|
|
47
|
+
MAX_DEPTH = 64 # SPEC 7.2: nesting of the inner message, itself the first level
|
|
48
|
+
# What wraps an inner message (a relay frame, an adapter payload, an API
|
|
49
|
+
# reply) nests a few levels more: JSON read from them is bounded at this.
|
|
50
|
+
MAX_PAYLOAD_DEPTH = 2 * MAX_DEPTH
|
|
51
|
+
MAX_RECIPIENTS = 50 # SPEC 7.2: entries in `to` and `addressee` (the group size)
|
|
52
|
+
MAX_RECIPIENT_LEN = 128 # characters in one entry (an identity has 49)
|
|
53
|
+
DEFAULT_MAX_FILE_BYTES = 64 * 1024
|
|
54
|
+
TASK_UPDATE_KINDS = frozenset({"a2a.task_status", "a2a.task_artifact"})
|
|
55
|
+
WINDOW_SECS = 60.0
|
|
56
|
+
ALLOWED_PARTS = frozenset({"text", "data"})
|
|
57
|
+
KNOWN_KINDS = frozenset(
|
|
58
|
+
{
|
|
59
|
+
"a2a.message",
|
|
60
|
+
"a2a.task_status",
|
|
61
|
+
"a2a.task_artifact",
|
|
62
|
+
"a2a.agent_card",
|
|
63
|
+
"a2a.task_cancel",
|
|
64
|
+
"control.leave",
|
|
65
|
+
"control.close",
|
|
66
|
+
"control.floor",
|
|
67
|
+
"control.pause",
|
|
68
|
+
"control.resume",
|
|
69
|
+
"control.capabilities",
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
_PART_FIELDS = ("text", "data", "file", "raw", "url")
|
|
73
|
+
PART_EXTRAS = frozenset({"kind", "metadata", "filename", "mediaType"})
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class Limits:
|
|
78
|
+
"""The §10.2 limits one message is held to."""
|
|
79
|
+
|
|
80
|
+
max_message_bytes: int = MAX_MESSAGE_BYTES
|
|
81
|
+
sender_per_minute: int = SENDER_PER_MINUTE
|
|
82
|
+
group_per_minute: int = GROUP_PER_MINUTE
|
|
83
|
+
identity_rate_multiple: int = IDENTITY_RATE_MULTIPLE
|
|
84
|
+
task_per_minute: int = TASK_PER_MINUTE
|
|
85
|
+
group_task_per_minute: int = GROUP_TASK_PER_MINUTE
|
|
86
|
+
allow_files: bool = False
|
|
87
|
+
max_file_bytes: int = DEFAULT_MAX_FILE_BYTES
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
SPEC_LIMITS = Limits()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def part_kind(part: Any) -> str:
|
|
94
|
+
"""The kind of one A2A part, ``text``, ``data`` or ``file``, or ``invalid``.
|
|
95
|
+
|
|
96
|
+
A part has exactly one content member (A2A 1.0: ``text``, ``data``,
|
|
97
|
+
``raw`` or ``url``; A2A 0.x: ``file``), an optional ``kind`` (A2A 0.x)
|
|
98
|
+
that must name it, and otherwise only ``metadata``, ``filename`` and
|
|
99
|
+
``mediaType``. Anything else is invalid: a member could otherwise ride
|
|
100
|
+
along under a kind that is checked and shown as something else
|
|
101
|
+
(testdata/a2a-parts.json)."""
|
|
102
|
+
if not isinstance(part, dict):
|
|
103
|
+
return "invalid"
|
|
104
|
+
present = [f for f in _PART_FIELDS if f in part]
|
|
105
|
+
if len(present) != 1 or not part.keys() <= {*present, *PART_EXTRAS}:
|
|
106
|
+
return "invalid"
|
|
107
|
+
found = "file" if present[0] in ("file", "raw", "url") else present[0]
|
|
108
|
+
if found == "text" and not isinstance(part["text"], str):
|
|
109
|
+
return "invalid"
|
|
110
|
+
kind = part.get("kind", found)
|
|
111
|
+
return found if kind == found else "invalid"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _parts_of(kind: str, body: dict[str, Any]) -> list[Any]:
|
|
115
|
+
"""Every part list a body of ``kind`` carries."""
|
|
116
|
+
if kind == "a2a.message":
|
|
117
|
+
parts = body.get("parts", [])
|
|
118
|
+
return parts if isinstance(parts, list) else [None]
|
|
119
|
+
if kind == "a2a.task_artifact":
|
|
120
|
+
artifact = body.get("artifact")
|
|
121
|
+
parts = artifact.get("parts", []) if isinstance(artifact, dict) else []
|
|
122
|
+
return parts if isinstance(parts, list) else [None]
|
|
123
|
+
if kind == "a2a.task_status":
|
|
124
|
+
status = body.get("status")
|
|
125
|
+
message = status.get("message") if isinstance(status, dict) else None
|
|
126
|
+
parts = message.get("parts", []) if isinstance(message, dict) else []
|
|
127
|
+
return parts if isinstance(parts, list) else [None]
|
|
128
|
+
return []
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def too_deep(value: Any, limit: int = MAX_DEPTH) -> bool:
|
|
132
|
+
"""Whether ``value`` nests objects and arrays more than ``limit`` levels
|
|
133
|
+
(``value`` itself, if it is one, is the first). Iterative, so it can't
|
|
134
|
+
run out of stack on what it measures."""
|
|
135
|
+
stack: list[tuple[Any, int]] = [(value, 1)]
|
|
136
|
+
while stack:
|
|
137
|
+
node, depth = stack.pop()
|
|
138
|
+
if isinstance(node, dict):
|
|
139
|
+
children: Any = node.values()
|
|
140
|
+
elif isinstance(node, list):
|
|
141
|
+
children = node
|
|
142
|
+
else:
|
|
143
|
+
continue
|
|
144
|
+
if depth > limit:
|
|
145
|
+
return True
|
|
146
|
+
stack.extend((child, depth + 1) for child in children)
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
_SURROGATE = re.compile("[\ud800-\udfff]")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def unsound(value: Any, limit: int = MAX_DEPTH) -> bool:
|
|
154
|
+
"""Whether ``value`` nests past ``limit`` levels (:func:`too_deep`) or
|
|
155
|
+
holds a string, or an object key, with a lone surrogate (SPEC 7.2).
|
|
156
|
+
JSON's ``\\ud800`` escape decodes to one; no UTF-8 encoder takes it, so
|
|
157
|
+
one would fail the first store, log or adapter that writes it out.
|
|
158
|
+
Iterative, like :func:`too_deep`."""
|
|
159
|
+
stack: list[tuple[Any, int]] = [(value, 1)]
|
|
160
|
+
while stack:
|
|
161
|
+
node, depth = stack.pop()
|
|
162
|
+
if isinstance(node, str):
|
|
163
|
+
if _SURROGATE.search(node):
|
|
164
|
+
return True
|
|
165
|
+
continue
|
|
166
|
+
if isinstance(node, dict):
|
|
167
|
+
if depth > limit:
|
|
168
|
+
return True
|
|
169
|
+
stack.extend((key, depth) for key in node)
|
|
170
|
+
stack.extend((child, depth + 1) for child in node.values())
|
|
171
|
+
elif isinstance(node, list):
|
|
172
|
+
if depth > limit:
|
|
173
|
+
return True
|
|
174
|
+
stack.extend((child, depth + 1) for child in node)
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# Three linear passes: escapes go first (each backslash with the character
|
|
179
|
+
# after it), so a string is then a quote, no quotes, a quote; then strings;
|
|
180
|
+
# then everything but brackets. None of them backtracks: an unterminated
|
|
181
|
+
# string is scanned to the end once.
|
|
182
|
+
_ESCAPE = re.compile(r"\\.", re.DOTALL)
|
|
183
|
+
_JSON_STRING = re.compile(r'"[^"]*+"')
|
|
184
|
+
_NOT_BRACKET = re.compile(r"[^\[\]{}]++")
|
|
185
|
+
_STEP = {"[": 1, "{": 1, "]": -1, "}": -1}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def json_too_deep(text: str, limit: int = MAX_DEPTH) -> bool:
|
|
189
|
+
"""Whether the JSON ``text`` nests arrays and objects past ``limit``
|
|
190
|
+
levels, read without parsing it: brackets are counted outside strings,
|
|
191
|
+
in time linear in the text. Python's JSON parser recurses once per
|
|
192
|
+
level, and on a small thread stack (macOS, musl) Python 3.12 and 3.13
|
|
193
|
+
crash on a few thousand levels instead of raising ``RecursionError``.
|
|
194
|
+
On valid JSON the depth is exact. On anything else it can differ only
|
|
195
|
+
after the first place the parser refuses (a backslash outside a string,
|
|
196
|
+
an unterminated string), which the parser never gets past."""
|
|
197
|
+
brackets = _NOT_BRACKET.sub("", _JSON_STRING.sub("", _ESCAPE.sub("", text)))
|
|
198
|
+
if brackets.count("[") + brackets.count("{") <= limit:
|
|
199
|
+
return False
|
|
200
|
+
return max(accumulate(map(_STEP.__getitem__, brackets))) > limit
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def loads(text: str | bytes, limit: int = MAX_DEPTH) -> Any:
|
|
204
|
+
"""``json.loads`` for text a peer or the relay controls: UTF-8 only, and
|
|
205
|
+
nested at most ``limit`` levels, checked before the parser runs
|
|
206
|
+
(:func:`json_too_deep`). Raises ``ValueError``."""
|
|
207
|
+
if isinstance(text, bytes | bytearray):
|
|
208
|
+
text = bytes(text).decode("utf-8")
|
|
209
|
+
if json_too_deep(text, limit):
|
|
210
|
+
msg = "JSON nested too deep"
|
|
211
|
+
raise ValueError(msg)
|
|
212
|
+
return json.loads(text)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def parse_inner(plaintext: bytes | str) -> dict[str, Any] | None:
|
|
216
|
+
"""A received inner message (SPEC 7.2): a UTF-8 JSON object nested at
|
|
217
|
+
most :data:`MAX_DEPTH` levels, with no lone surrogate in any string or
|
|
218
|
+
key, else ``None``. JSON deeper than that is refused before it is
|
|
219
|
+
parsed (:func:`loads`), never an error."""
|
|
220
|
+
try:
|
|
221
|
+
inner = loads(plaintext)
|
|
222
|
+
except (ValueError, RecursionError):
|
|
223
|
+
return None
|
|
224
|
+
if not isinstance(inner, dict) or unsound(inner):
|
|
225
|
+
return None
|
|
226
|
+
return inner
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def valid_hop(value: Any) -> bool:
|
|
230
|
+
"""Whether ``value`` is a SPEC 7.2 ``hop``: an integer from 0 to 2^32 - 1."""
|
|
231
|
+
return isinstance(value, int) and not isinstance(value, bool) and 0 <= value <= U32_MAX
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _recipients_ok(value: Any) -> bool:
|
|
235
|
+
return isinstance(value, list) and len(value) <= MAX_RECIPIENTS
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def inner_problem(inner: dict[str, Any]) -> str | None:
|
|
239
|
+
"""``malformed`` if ``inner`` breaks the SPEC 7.2 bounds that keep one
|
|
240
|
+
message from costing the receiver more than its size: nesting past
|
|
241
|
+
:data:`MAX_DEPTH`, a ``hop`` outside a u32, a ``to`` that isn't a list of
|
|
242
|
+
at most :data:`MAX_RECIPIENTS` strings of at most
|
|
243
|
+
:data:`MAX_RECIPIENT_LEN` characters, or an ``addressee`` list longer than
|
|
244
|
+
that or with a longer string, or a lone surrogate in any string or key
|
|
245
|
+
(:func:`unsound`). (Any other ``addressee`` shape triggers nobody, SPEC
|
|
246
|
+
9.3.)"""
|
|
247
|
+
if not valid_hop(inner.get("hop", 0)):
|
|
248
|
+
return "malformed"
|
|
249
|
+
to = inner.get("to", ["*"])
|
|
250
|
+
if not _recipients_ok(to) or not all(
|
|
251
|
+
isinstance(r, str) and len(r) <= MAX_RECIPIENT_LEN for r in to
|
|
252
|
+
):
|
|
253
|
+
return "malformed"
|
|
254
|
+
addressee = inner.get("addressee")
|
|
255
|
+
if isinstance(addressee, list) and (
|
|
256
|
+
not _recipients_ok(addressee)
|
|
257
|
+
or any(isinstance(r, str) and len(r) > MAX_RECIPIENT_LEN for r in addressee)
|
|
258
|
+
):
|
|
259
|
+
return "malformed"
|
|
260
|
+
if unsound(inner):
|
|
261
|
+
return "malformed"
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _part_size(part: Any) -> int:
|
|
266
|
+
return len(json.dumps(part, sort_keys=True, separators=(",", ":")).encode())
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def content_problem( # noqa: PLR0911
|
|
270
|
+
inner: dict[str, Any], size: int, limits: Limits = SPEC_LIMITS
|
|
271
|
+
) -> str | None:
|
|
272
|
+
"""Why ``inner`` (``size`` bytes of canonical JSON) breaks §10.2/§7.2
|
|
273
|
+
under ``limits``: ``unknown_kind``, ``too_large``, ``malformed`` (not
|
|
274
|
+
an object, past the bounds of :func:`inner_problem`, or a control, card
|
|
275
|
+
or cancel outside its strict schema, :mod:`agentconnect.schemas`),
|
|
276
|
+
``part_kind`` or ``file_too_large``."""
|
|
277
|
+
kind = inner.get("kind")
|
|
278
|
+
if kind not in KNOWN_KINDS:
|
|
279
|
+
return "unknown_kind"
|
|
280
|
+
if size > limits.max_message_bytes:
|
|
281
|
+
return "too_large"
|
|
282
|
+
if inner_problem(inner) is not None:
|
|
283
|
+
return "malformed"
|
|
284
|
+
body = inner.get("body")
|
|
285
|
+
if not isinstance(body, dict) or not schema_ok(str(kind), body):
|
|
286
|
+
return "malformed"
|
|
287
|
+
allowed = ALLOWED_PARTS | {"file"} if limits.allow_files else ALLOWED_PARTS
|
|
288
|
+
for part in _parts_of(str(kind), body):
|
|
289
|
+
found = part_kind(part)
|
|
290
|
+
if found == "invalid":
|
|
291
|
+
return "malformed"
|
|
292
|
+
if found not in allowed:
|
|
293
|
+
return "part_kind"
|
|
294
|
+
if found == "file" and _part_size(part) > limits.max_file_bytes:
|
|
295
|
+
return "file_too_large"
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
MAX_DRAIN_EXEMPT = 1000 # rate-exempt messages per reconnect drain, all senders
|
|
300
|
+
DRAIN_RATE_MULTIPLE = 2 # ...and per identity: 2 x its sender_per_minute
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class DrainBudget:
|
|
304
|
+
"""How many messages of a reconnect drain (SPEC 8.2/10.2) skip the rates:
|
|
305
|
+
at most ``total`` per connection, and per sender at most what
|
|
306
|
+
:meth:`take` is given, so one peer's queued messages can't use up the
|
|
307
|
+
exemption."""
|
|
308
|
+
|
|
309
|
+
def __init__(self, total: int = MAX_DRAIN_EXEMPT) -> None:
|
|
310
|
+
self._total = total
|
|
311
|
+
self._used: dict[str, int] = {}
|
|
312
|
+
|
|
313
|
+
def reset(self) -> None:
|
|
314
|
+
"""A new connection: a new drain."""
|
|
315
|
+
self._used.clear()
|
|
316
|
+
|
|
317
|
+
def take(self, sender: str, cap: int) -> bool:
|
|
318
|
+
"""Count one exempt message from ``sender``; False when it is over
|
|
319
|
+
``cap`` for that sender or the connection's total is spent."""
|
|
320
|
+
if sum(self._used.values()) >= self._total or self._used.get(sender, 0) >= cap:
|
|
321
|
+
return False
|
|
322
|
+
self._used[sender] = self._used.get(sender, 0) + 1
|
|
323
|
+
return True
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
class RateLimiter:
|
|
327
|
+
"""Sliding-window counts per sender instance, identity, group and task.
|
|
328
|
+
|
|
329
|
+
Each direction has its own windows (``direction`` is ``in`` for what
|
|
330
|
+
peers send to this member, ``out`` for what it sends), so a member's own
|
|
331
|
+
sends never use up the budget it grants its peers, and peer traffic
|
|
332
|
+
never stops its own sends.
|
|
333
|
+
|
|
334
|
+
The state is bounded: windows that emptied are swept away once the map
|
|
335
|
+
grows, and past :data:`MAX_KEYS` the least recently used are evicted.
|
|
336
|
+
"""
|
|
337
|
+
|
|
338
|
+
def __init__(self, clock: Clock, max_keys: int = MAX_KEYS) -> None:
|
|
339
|
+
self._clock = clock
|
|
340
|
+
self._max_keys = max_keys
|
|
341
|
+
self._seen: OrderedDict[tuple[str, str], deque[float]] = OrderedDict()
|
|
342
|
+
self._sweep_at = SWEEP_FLOOR
|
|
343
|
+
|
|
344
|
+
def _now(self) -> float:
|
|
345
|
+
return self._clock.unix_millis() / 1000
|
|
346
|
+
|
|
347
|
+
def size(self) -> int:
|
|
348
|
+
"""How many windows are tracked."""
|
|
349
|
+
return len(self._seen)
|
|
350
|
+
|
|
351
|
+
def _count(self, key: tuple[str, str], now: float) -> deque[float]:
|
|
352
|
+
times = self._seen.get(key)
|
|
353
|
+
if times is None:
|
|
354
|
+
times = self._seen[key] = deque[float]()
|
|
355
|
+
self._seen.move_to_end(key)
|
|
356
|
+
while times and times[0] <= now - WINDOW_SECS:
|
|
357
|
+
times.popleft()
|
|
358
|
+
return times
|
|
359
|
+
|
|
360
|
+
def _sweep(self, now: float) -> None:
|
|
361
|
+
if len(self._seen) <= self._sweep_at:
|
|
362
|
+
return
|
|
363
|
+
for key in [k for k, t in self._seen.items() if not t or t[-1] <= now - WINDOW_SECS]:
|
|
364
|
+
del self._seen[key]
|
|
365
|
+
while len(self._seen) > self._max_keys:
|
|
366
|
+
self._seen.popitem(last=False)
|
|
367
|
+
self._sweep_at = max(SWEEP_FLOOR, 2 * len(self._seen))
|
|
368
|
+
|
|
369
|
+
def forget_group(self, group_id: str) -> None:
|
|
370
|
+
for key in [k for k in self._seen if k[0] == group_id]:
|
|
371
|
+
del self._seen[key]
|
|
372
|
+
|
|
373
|
+
def _windows(
|
|
374
|
+
self, group_id: str, sender: str, identity: str | None, limits: Limits, direction: str
|
|
375
|
+
) -> list[tuple[deque[float], int]]:
|
|
376
|
+
now = self._now()
|
|
377
|
+
windows = [
|
|
378
|
+
(self._count((group_id, f"{direction}\0{sender}"), now), limits.sender_per_minute),
|
|
379
|
+
(self._count((group_id, f"{direction}\0group"), now), limits.group_per_minute),
|
|
380
|
+
]
|
|
381
|
+
if identity is not None:
|
|
382
|
+
cap = limits.sender_per_minute * limits.identity_rate_multiple
|
|
383
|
+
windows.append((self._count((group_id, f"{direction}\0id\0{identity}"), now), cap))
|
|
384
|
+
return windows
|
|
385
|
+
|
|
386
|
+
@staticmethod
|
|
387
|
+
def _take(windows: list[tuple[deque[float], int]], now: float) -> bool:
|
|
388
|
+
if any(len(times) >= limit for times, limit in windows):
|
|
389
|
+
return False
|
|
390
|
+
for times, _limit in windows:
|
|
391
|
+
times.append(now)
|
|
392
|
+
return True
|
|
393
|
+
|
|
394
|
+
def allow(
|
|
395
|
+
self,
|
|
396
|
+
group_id: str,
|
|
397
|
+
sender: str,
|
|
398
|
+
limits: Limits = SPEC_LIMITS,
|
|
399
|
+
*,
|
|
400
|
+
identity: str | None = None,
|
|
401
|
+
direction: str = "in",
|
|
402
|
+
) -> bool:
|
|
403
|
+
"""Count one message from ``sender`` (an instance); False if it is
|
|
404
|
+
over a limit (and then it doesn't count). With ``identity``, all
|
|
405
|
+
instances of that identity together are also held to
|
|
406
|
+
``identity_rate_multiple`` times the per-sender rate."""
|
|
407
|
+
now = self._now()
|
|
408
|
+
ok = self._take(self._windows(group_id, sender, identity, limits, direction), now)
|
|
409
|
+
self._sweep(now)
|
|
410
|
+
return ok
|
|
411
|
+
|
|
412
|
+
def allow_inner( # noqa: PLR0913
|
|
413
|
+
self,
|
|
414
|
+
group_id: str,
|
|
415
|
+
sender: str,
|
|
416
|
+
kind: Any,
|
|
417
|
+
body: Any,
|
|
418
|
+
limits: Limits = SPEC_LIMITS,
|
|
419
|
+
*,
|
|
420
|
+
identity: str | None = None,
|
|
421
|
+
direction: str = "in",
|
|
422
|
+
requested: bool = True,
|
|
423
|
+
) -> bool:
|
|
424
|
+
"""Count one inner message against its budget (SPEC 10.2).
|
|
425
|
+
|
|
426
|
+
A task update that isn't ``requested`` (it answers no message this
|
|
427
|
+
instance sent, SPEC 9.3) counts against the message rates, in windows
|
|
428
|
+
of its own: it never triggers the agent, and an honest task for
|
|
429
|
+
another member mustn't use up its sender's messages. Any other
|
|
430
|
+
task update counts against its task's budget (per sender instance),
|
|
431
|
+
against all of the identity's task updates (``identity_rate_multiple``
|
|
432
|
+
times the task budget) and against the group's
|
|
433
|
+
(``group_task_per_minute``). The first update of a task in the window
|
|
434
|
+
also counts as an ordinary message. Anything else counts against the
|
|
435
|
+
message rates."""
|
|
436
|
+
if kind in TASK_UPDATE_KINDS and not requested:
|
|
437
|
+
return self.allow(
|
|
438
|
+
group_id, sender, limits, identity=identity, direction=f"{direction}\0context"
|
|
439
|
+
)
|
|
440
|
+
task = body.get("taskId") if kind in TASK_UPDATE_KINDS and isinstance(body, dict) else None
|
|
441
|
+
if not isinstance(task, str) or not 0 < len(task) <= MAX_TASK_ID:
|
|
442
|
+
return self.allow(group_id, sender, limits, identity=identity, direction=direction)
|
|
443
|
+
now = self._now()
|
|
444
|
+
prefix = f"{direction}\0task"
|
|
445
|
+
windows = [
|
|
446
|
+
(self._count((group_id, f"{prefix}\0{sender}\0{task}"), now), limits.task_per_minute),
|
|
447
|
+
(self._count((group_id, f"{prefix}\0group"), now), limits.group_task_per_minute),
|
|
448
|
+
]
|
|
449
|
+
if identity is not None:
|
|
450
|
+
cap = limits.task_per_minute * limits.identity_rate_multiple
|
|
451
|
+
windows.append((self._count((group_id, f"{prefix}\0id\0{identity}"), now), cap))
|
|
452
|
+
if not windows[0][0]:
|
|
453
|
+
# A task new in this window opens with an ordinary message.
|
|
454
|
+
windows += self._windows(group_id, sender, identity, limits, direction)
|
|
455
|
+
ok = self._take(windows, now)
|
|
456
|
+
self._sweep(now)
|
|
457
|
+
return ok
|
agentconnect/log.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Structured JSON logging that can't leak message content.
|
|
2
|
+
|
|
3
|
+
Rules (AGENTS.md): never log message bodies, keys, secrets or invite URLs.
|
|
4
|
+
Identifiers (group ids, mailbox ids, identities, msg_ids) are logged only as
|
|
5
|
+
:func:`hid` tags.
|
|
6
|
+
|
|
7
|
+
Every logger from :func:`get` runs one fixed processor chain, whatever the
|
|
8
|
+
embedding application did to structlog's global configuration (the default
|
|
9
|
+
console renderer prints each frame's local variables with a traceback, and
|
|
10
|
+
those locals are envelopes, plaintext and secrets). The chain:
|
|
11
|
+
|
|
12
|
+
* drops events below the level set by :func:`configure` (INFO by default);
|
|
13
|
+
* replaces the value of any field whose name suggests content
|
|
14
|
+
(:data:`REDACTED_FIELDS`) with ``"[redacted]"``;
|
|
15
|
+
* renders exceptions as a plain traceback: file, line and source text, never
|
|
16
|
+
local variables;
|
|
17
|
+
* writes one JSON object per line to ``sys.stderr`` (looked up per event).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import hashlib
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import sys
|
|
26
|
+
import traceback
|
|
27
|
+
from collections.abc import MutableMapping
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
import structlog
|
|
31
|
+
|
|
32
|
+
EventDict = MutableMapping[str, Any]
|
|
33
|
+
|
|
34
|
+
REDACTED_FIELDS = frozenset(
|
|
35
|
+
{
|
|
36
|
+
"body",
|
|
37
|
+
"card",
|
|
38
|
+
"ct",
|
|
39
|
+
"delivery",
|
|
40
|
+
"env",
|
|
41
|
+
"frame",
|
|
42
|
+
"inner",
|
|
43
|
+
"key",
|
|
44
|
+
"payload",
|
|
45
|
+
"plaintext",
|
|
46
|
+
"raw",
|
|
47
|
+
"request",
|
|
48
|
+
"secret",
|
|
49
|
+
"sig",
|
|
50
|
+
"text",
|
|
51
|
+
"token",
|
|
52
|
+
"url",
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
_LEVELS = {
|
|
57
|
+
"debug": logging.DEBUG,
|
|
58
|
+
"info": logging.INFO,
|
|
59
|
+
"warning": logging.WARNING,
|
|
60
|
+
"warn": logging.WARNING,
|
|
61
|
+
"error": logging.ERROR,
|
|
62
|
+
"exception": logging.ERROR,
|
|
63
|
+
"critical": logging.CRITICAL,
|
|
64
|
+
"fatal": logging.CRITICAL,
|
|
65
|
+
}
|
|
66
|
+
_state = {"level": logging.INFO}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def hid(value: str | bytes | None) -> str:
|
|
70
|
+
"""A short, stable tag for an identifier: the first 8 hex of SHA-256."""
|
|
71
|
+
if value is None:
|
|
72
|
+
return "-"
|
|
73
|
+
raw = value.encode() if isinstance(value, str) else value
|
|
74
|
+
return hashlib.sha256(raw).hexdigest()[:8]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _filter(_logger: Any, method: str, event: EventDict) -> EventDict:
|
|
78
|
+
if _LEVELS.get(method, logging.INFO) < _state["level"]:
|
|
79
|
+
raise structlog.DropEvent
|
|
80
|
+
return event
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _redact(_logger: Any, _method: str, event: EventDict) -> EventDict:
|
|
84
|
+
for k in event:
|
|
85
|
+
if k in REDACTED_FIELDS:
|
|
86
|
+
event[k] = "[redacted]"
|
|
87
|
+
return event
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _exc_info(_logger: Any, method: str, event: EventDict) -> EventDict:
|
|
91
|
+
"""Plain tracebacks only (no locals, no rich rendering)."""
|
|
92
|
+
exc = event.pop("exc_info", None)
|
|
93
|
+
if method == "exception" and exc is None:
|
|
94
|
+
exc = True
|
|
95
|
+
if exc:
|
|
96
|
+
info = sys.exc_info() if exc is True else exc
|
|
97
|
+
if isinstance(info, BaseException):
|
|
98
|
+
info = (type(info), info, info.__traceback__)
|
|
99
|
+
if isinstance(info, tuple) and info[0] is not None:
|
|
100
|
+
event["exception"] = "".join(traceback.format_exception(*info))
|
|
101
|
+
return event
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _render(_logger: Any, _method: str, event: EventDict) -> str:
|
|
105
|
+
return json.dumps(dict(event), default=str, sort_keys=True)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
PROCESSORS = [
|
|
109
|
+
_filter,
|
|
110
|
+
structlog.contextvars.merge_contextvars,
|
|
111
|
+
structlog.processors.add_log_level,
|
|
112
|
+
structlog.processors.TimeStamper(fmt="iso", utc=True),
|
|
113
|
+
_redact,
|
|
114
|
+
_exc_info,
|
|
115
|
+
_render,
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class _Stderr:
|
|
120
|
+
"""Writes each rendered line to the current ``sys.stderr``."""
|
|
121
|
+
|
|
122
|
+
def msg(self, message: str) -> None:
|
|
123
|
+
print(message, file=sys.stderr, flush=True)
|
|
124
|
+
|
|
125
|
+
debug = info = warning = warn = error = exception = critical = fatal = log = msg
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def configure(level: int = logging.INFO) -> None:
|
|
129
|
+
"""Set the level (``agentconnect daemon run`` calls this at start), and
|
|
130
|
+
give stdlib logging (asyncio, httpx, websockets) a plain formatter, which
|
|
131
|
+
never prints local variables."""
|
|
132
|
+
_state["level"] = level
|
|
133
|
+
logging.basicConfig(
|
|
134
|
+
level=max(level, logging.WARNING),
|
|
135
|
+
stream=sys.stderr,
|
|
136
|
+
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def get(name: str) -> Any:
|
|
141
|
+
return structlog.wrap_logger(
|
|
142
|
+
_Stderr(), processors=PROCESSORS, wrapper_class=structlog.BoundLogger
|
|
143
|
+
).bind(logger=name)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""The MCP host of the runtime (ADR-005): ``agentconnect mcp``.
|
|
2
|
+
|
|
3
|
+
A stdio MCP server that an agent tool (Gemini CLI, Cursor and other MCP
|
|
4
|
+
clients) starts per session. It runs the AgentConnect runtime in
|
|
5
|
+
its own process when the data directory is free, or attaches to the runtime
|
|
6
|
+
that holds it, so the user has nothing separate to run. Its tools are the
|
|
7
|
+
agent-scope operations only; human decisions stay CLI commands with the
|
|
8
|
+
human token. No tool exposes it, and an attached session reads ``api-token``
|
|
9
|
+
only (docs/adapters/mcp.md).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from agentconnect.mcp.backend import ToolError
|
|
13
|
+
from agentconnect.mcp.server import (
|
|
14
|
+
McpServer,
|
|
15
|
+
StdioFds,
|
|
16
|
+
claim_stdio,
|
|
17
|
+
configure,
|
|
18
|
+
default_data_dir,
|
|
19
|
+
serve,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"McpServer",
|
|
24
|
+
"StdioFds",
|
|
25
|
+
"ToolError",
|
|
26
|
+
"claim_stdio",
|
|
27
|
+
"configure",
|
|
28
|
+
"default_data_dir",
|
|
29
|
+
"serve",
|
|
30
|
+
]
|