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/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
+ ]