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/policy.py ADDED
@@ -0,0 +1,1066 @@
1
+ """The safety policy: one decision for every message, made before any adapter
2
+ sees it (SPEC §10).
3
+
4
+ Encryption keeps the relay out. It does nothing about a member that is
5
+ malicious or confused, so every decrypted message goes through
6
+ :meth:`SafetyPolicy.inbound` and every message the agent sends through
7
+ :meth:`SafetyPolicy.outbound`. Each returns a :class:`Decision`:
8
+
9
+ * ``deliver``: hand it to the adapter, triggering the agent;
10
+ * ``deliver_as_context``: hand it over with ``addressed_to_me: false``
11
+ (not for this member, or past the loop guard, SPEC §9.3 and §10.3);
12
+ * ``hold_for_approval``: keep it until the human approves it with the human
13
+ token (``require_approval``), or drop it when the hold expires;
14
+ * ``drop``: never deliver (or send) it, with a reason.
15
+
16
+ The daemon calls these in exactly one place each (``Engine._make_delivery``
17
+ when a message is received, ``Engine.queue_message`` when one is sent) and
18
+ records the decision with the message. Adapters only ever see what was
19
+ recorded as delivered, and adapters can only send through
20
+ ``Engine.adapter_send``, so neither direction can bypass the policy.
21
+
22
+ Settings come from ``[policy]`` in ``config.toml`` (:data:`KNOBS`), with
23
+ per-group overrides and, for the limits, per-sender overrides, which the
24
+ human sets through the local API (``POST /v0/groups/{gid}/safety``) or
25
+ ``agentconnect safety set``. The §10.4 verification gate (``require_verified``)
26
+ is part of the group's MLS policy instead, and holds deliveries until the
27
+ human has verified every member (:func:`verification_holds`).
28
+
29
+ Every decision is written to the audit log: who, when, kind, size, decision
30
+ and reason, never the body. With ``transcript`` on, the group's delivered
31
+ and sent messages are also kept in full. Both live in the encrypted daemon
32
+ database with bounded retention.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import enum
38
+ import math
39
+ from collections import OrderedDict, deque
40
+ from collections.abc import Callable, Mapping
41
+ from dataclasses import dataclass, field, fields, replace
42
+ from typing import TYPE_CHECKING, Any
43
+
44
+ from agentconnect.clock import Clock, unix_seconds
45
+ from agentconnect.limits import (
46
+ DEFAULT_MAX_FILE_BYTES,
47
+ GROUP_PER_MINUTE,
48
+ GROUP_TASK_PER_MINUTE,
49
+ IDENTITY_RATE_MULTIPLE,
50
+ MAX_MESSAGE_BYTES,
51
+ MAX_TASK_ID,
52
+ SENDER_PER_MINUTE,
53
+ TASK_PER_MINUTE,
54
+ TASK_UPDATE_KINDS,
55
+ Limits,
56
+ RateLimiter,
57
+ content_problem,
58
+ )
59
+ from agentconnect.schemas import NON_CONTENT_KINDS
60
+
61
+ if TYPE_CHECKING:
62
+ from agentconnect.store import Store
63
+
64
+ # SPEC 7.3: controls only a human-originated message may carry.
65
+ HUMAN_ONLY_KINDS = frozenset({"control.resume"})
66
+ # What require_approval holds: content from (or for) an agent. Controls and
67
+ # Agent Cards carry no content (agentconnect.schemas) and pass, so membership,
68
+ # the loop guard and capabilities keep working.
69
+ HOLDABLE_KINDS = frozenset(
70
+ {"a2a.message", "a2a.task_status", "a2a.task_artifact", "a2a.task_cancel"}
71
+ )
72
+ # Drop reasons that count against the sender (an unknown kind doesn't: SPEC
73
+ # 7.2 makes ignoring it the forward-compatible behaviour).
74
+ VIOLATIONS = frozenset(
75
+ {
76
+ "too_large",
77
+ "malformed",
78
+ "part_kind",
79
+ "file_too_large",
80
+ "rate_limited",
81
+ "not_human",
82
+ "not_owner",
83
+ "hold_overflow",
84
+ "bad_signature",
85
+ # A message that failed in the receiver's own handling (SPEC 7.2).
86
+ "processing_failed",
87
+ }
88
+ )
89
+ APPROVAL_MODES = ("off", "inbound", "outbound", "both")
90
+ DAY_SECS = 86_400
91
+ PURGE_EVERY_SECS = 60
92
+ MAX_AUDIT_ROWS = 100_000
93
+ DEFAULT_TRANSCRIPT_MAX_ROWS = 10_000
94
+ DEFAULT_TRANSCRIPT_MAX_BYTES = 64 * 1024 * 1024
95
+ DEFAULT_TRANSCRIPT_SENDER_ROWS = 1000
96
+ DEFAULT_TRANSCRIPT_GROUP_BYTES = 16 * 1024 * 1024
97
+ DEFAULT_AUDIT_GROUP_ROWS = 20_000
98
+ DEFAULT_AUDIT_SENDER_ROWS = 2000
99
+ DEFAULT_MAX_HELD_PER_GROUP = 100
100
+ DEFAULT_MAX_HELD_PER_SENDER = 20
101
+ DEFAULT_MAX_HELD_BYTES = 8 * 1024 * 1024
102
+ DEFAULT_MAX_HELD_SENDER_BYTES = 1024 * 1024
103
+ DEFAULT_TASK_WINDOW_SECS = 600
104
+ MAX_TASK_WINDOW_SECS = 3600
105
+ TASK_DECISION_DAYS = 7 # decided tasks are forgotten after a week untouched
106
+ MAX_TRACKED = 10_000 # (group, sender) violation counters kept at most
107
+ DEFAULT_VIOLATION_THRESHOLD = 10
108
+ DEFAULT_VIOLATION_WINDOW_SECS = 600
109
+ DEFAULT_AUDIT_RETENTION_DAYS = 30
110
+ MAX_PENDING_KNOCKS = 32 # SPEC 6.6.3 step 7, per door
111
+
112
+
113
+ class Verdict(enum.StrEnum):
114
+ DELIVER = "deliver"
115
+ DELIVER_AS_CONTEXT = "deliver_as_context"
116
+ HOLD_FOR_APPROVAL = "hold_for_approval"
117
+ DROP = "drop"
118
+
119
+
120
+ @dataclass(frozen=True)
121
+ class Decision:
122
+ """What happens to one message, and why (``reason`` is empty for a plain
123
+ ``deliver``)."""
124
+
125
+ verdict: Verdict
126
+ reason: str = ""
127
+
128
+ @property
129
+ def delivered(self) -> bool:
130
+ """Whether the adapter gets it now (triggering or as context)."""
131
+ return self.verdict in (Verdict.DELIVER, Verdict.DELIVER_AS_CONTEXT)
132
+
133
+
134
+ DELIVER = Decision(Verdict.DELIVER)
135
+
136
+
137
+ def as_context(reason: str) -> Decision:
138
+ return Decision(Verdict.DELIVER_AS_CONTEXT, reason)
139
+
140
+
141
+ def hold(reason: str) -> Decision:
142
+ return Decision(Verdict.HOLD_FOR_APPROVAL, reason)
143
+
144
+
145
+ def drop(reason: str) -> Decision:
146
+ return Decision(Verdict.DROP, reason)
147
+
148
+
149
+ class PolicyError(ValueError):
150
+ """An invalid setting."""
151
+
152
+
153
+ @dataclass(frozen=True)
154
+ class Knob:
155
+ """One setting: its ``[policy]`` key, type, default and bounds."""
156
+
157
+ name: str
158
+ kind: type
159
+ default: Any
160
+ doc: str
161
+ lo: int = 0
162
+ hi: int = 0
163
+ choices: tuple[str, ...] = ()
164
+ per_sender: bool = False # may be overridden for one sender
165
+
166
+ def check(self, value: Any) -> Any:
167
+ """``value`` if it is valid for this knob, else :class:`PolicyError`."""
168
+ if not isinstance(value, self.kind) or (self.kind is int and isinstance(value, bool)):
169
+ msg = f"{self.name} must be {self.kind.__name__}"
170
+ raise PolicyError(msg)
171
+ if self.kind is int and not self.lo <= value <= self.hi:
172
+ msg = f"{self.name} must be {self.lo}..{self.hi}"
173
+ raise PolicyError(msg)
174
+ if self.choices and value not in self.choices:
175
+ msg = f"{self.name} must be one of {', '.join(self.choices)}"
176
+ raise PolicyError(msg)
177
+ return value
178
+
179
+
180
+ KNOBS: tuple[Knob, ...] = (
181
+ Knob(
182
+ "max_message_bytes",
183
+ int,
184
+ MAX_MESSAGE_BYTES,
185
+ "Largest inner message accepted, in bytes (1024 to 262144, the SPEC 10.2 default; "
186
+ "never more).",
187
+ 1024,
188
+ MAX_MESSAGE_BYTES,
189
+ per_sender=True,
190
+ ),
191
+ Knob(
192
+ "sender_per_minute",
193
+ int,
194
+ SENDER_PER_MINUTE,
195
+ "Messages a minute accepted from one sender instance (1 to 30, the SPEC 10.2 "
196
+ "default; never more).",
197
+ 1,
198
+ SENDER_PER_MINUTE,
199
+ per_sender=True,
200
+ ),
201
+ Knob(
202
+ "group_per_minute",
203
+ int,
204
+ GROUP_PER_MINUTE,
205
+ "Messages a minute accepted from the whole group (1 to 120, the SPEC 10.2 "
206
+ "default; never more).",
207
+ 1,
208
+ GROUP_PER_MINUTE,
209
+ ),
210
+ Knob(
211
+ "identity_rate_multiple",
212
+ int,
213
+ IDENTITY_RATE_MULTIPLE,
214
+ "All instances of one identity together may send this many times "
215
+ "`sender_per_minute` (1 to 3, the SPEC 10.2 default), so replicas can't multiply "
216
+ "an identity's budget.",
217
+ 1,
218
+ IDENTITY_RATE_MULTIPLE,
219
+ ),
220
+ Knob(
221
+ "task_per_minute",
222
+ int,
223
+ TASK_PER_MINUTE,
224
+ "Updates a minute accepted for one task of one sender instance (1 to 300, the "
225
+ "SPEC 10.2 default; never more). All of one identity's task updates together "
226
+ "may be `identity_rate_multiple` times this.",
227
+ 1,
228
+ TASK_PER_MINUTE,
229
+ per_sender=True,
230
+ ),
231
+ Knob(
232
+ "group_task_per_minute",
233
+ int,
234
+ GROUP_TASK_PER_MINUTE,
235
+ "Task updates a minute accepted from the whole group (1 to 1200, the SPEC 10.2 "
236
+ "default; never more).",
237
+ 1,
238
+ GROUP_TASK_PER_MINUTE,
239
+ ),
240
+ Knob(
241
+ "allow_files",
242
+ bool,
243
+ False,
244
+ "Accept A2A `file` parts. Off by default: only `text` and `data` parts pass.",
245
+ per_sender=True,
246
+ ),
247
+ Knob(
248
+ "max_file_bytes",
249
+ int,
250
+ DEFAULT_MAX_FILE_BYTES,
251
+ "Largest `file` part accepted when `allow_files` is on, in bytes of its JSON "
252
+ "(1 to 262144).",
253
+ 1,
254
+ MAX_MESSAGE_BYTES,
255
+ per_sender=True,
256
+ ),
257
+ Knob(
258
+ "frame_text",
259
+ bool,
260
+ True,
261
+ "Ask text-oriented consumers to show the SPEC 10.1 notice in front of the text "
262
+ "(`frame_text` in webhook and SSE payloads; the SDK's `msg.text`).",
263
+ ),
264
+ Knob(
265
+ "require_approval",
266
+ str,
267
+ "off",
268
+ "Hold agent messages until the human approves them: `off`, `inbound`, `outbound` "
269
+ "or `both`.",
270
+ choices=APPROVAL_MODES,
271
+ ),
272
+ Knob(
273
+ "approval_ttl_secs",
274
+ int,
275
+ 3600,
276
+ "How long a held message waits for approval before it is dropped (60 to 604800).",
277
+ 60,
278
+ 604_800,
279
+ ),
280
+ Knob(
281
+ "max_held_per_group",
282
+ int,
283
+ DEFAULT_MAX_HELD_PER_GROUP,
284
+ "Messages that may wait for approval in one group at a time (1 to 100). Past it, "
285
+ "another is dropped, not held, and counts as a violation.",
286
+ 1,
287
+ DEFAULT_MAX_HELD_PER_GROUP,
288
+ ),
289
+ Knob(
290
+ "max_held_per_sender",
291
+ int,
292
+ DEFAULT_MAX_HELD_PER_SENDER,
293
+ "Messages from one sender instance that may wait for approval at a time (1 to 20).",
294
+ 1,
295
+ DEFAULT_MAX_HELD_PER_SENDER,
296
+ ),
297
+ Knob(
298
+ "max_held_bytes",
299
+ int,
300
+ DEFAULT_MAX_HELD_BYTES,
301
+ "Bytes of messages that may wait for approval in one group at a time "
302
+ "(1024 to 8388608, 8 MiB).",
303
+ 1024,
304
+ DEFAULT_MAX_HELD_BYTES,
305
+ ),
306
+ Knob(
307
+ "max_held_bytes_per_sender",
308
+ int,
309
+ DEFAULT_MAX_HELD_SENDER_BYTES,
310
+ "Bytes one sender identity may have waiting for approval in a group (1024 to "
311
+ "1048576, 1 MiB), and never more than its share of `max_held_bytes` among the "
312
+ "senders waiting.",
313
+ 1024,
314
+ DEFAULT_MAX_HELD_SENDER_BYTES,
315
+ ),
316
+ Knob(
317
+ "task_approval_window_secs",
318
+ int,
319
+ DEFAULT_TASK_WINDOW_SECS,
320
+ "How long approving a peer task's first update lets its later inbound updates "
321
+ "through (1 to 3600); the task's end closes it sooner.",
322
+ 1,
323
+ MAX_TASK_WINDOW_SECS,
324
+ ),
325
+ Knob(
326
+ "violation_threshold",
327
+ int,
328
+ DEFAULT_VIOLATION_THRESHOLD,
329
+ "Policy violations by one sender within `violation_window_secs` that raise a "
330
+ "`repeated_violations` event (1 to 1000).",
331
+ 1,
332
+ 1000,
333
+ ),
334
+ Knob(
335
+ "violation_window_secs",
336
+ int,
337
+ DEFAULT_VIOLATION_WINDOW_SECS,
338
+ "The window violations are counted over (60 to 86400).",
339
+ 60,
340
+ 86_400,
341
+ ),
342
+ Knob(
343
+ "transcript",
344
+ bool,
345
+ False,
346
+ "Keep a full transcript of the messages delivered and sent, in the encrypted "
347
+ "daemon database.",
348
+ ),
349
+ Knob(
350
+ "transcript_retention_days",
351
+ int,
352
+ 7,
353
+ "Days a transcript entry is kept (1 to 365).",
354
+ 1,
355
+ 365,
356
+ ),
357
+ Knob(
358
+ "transcript_max_rows",
359
+ int,
360
+ DEFAULT_TRANSCRIPT_MAX_ROWS,
361
+ "Transcript entries a group keeps at most (1 to 10000); the oldest go first.",
362
+ 1,
363
+ DEFAULT_TRANSCRIPT_MAX_ROWS,
364
+ ),
365
+ Knob(
366
+ "transcript_max_rows_per_sender",
367
+ int,
368
+ DEFAULT_TRANSCRIPT_SENDER_ROWS,
369
+ "Transcript entries one sender keeps in a group at most (1 to 1000), so a flood "
370
+ "pushes out only its own.",
371
+ 1,
372
+ DEFAULT_TRANSCRIPT_SENDER_ROWS,
373
+ ),
374
+ Knob(
375
+ "transcript_max_group_bytes",
376
+ int,
377
+ DEFAULT_TRANSCRIPT_GROUP_BYTES,
378
+ "Bytes a group's transcript keeps at most (65536 to 16777216, 16 MiB).",
379
+ 65536,
380
+ DEFAULT_TRANSCRIPT_GROUP_BYTES,
381
+ ),
382
+ )
383
+ KNOB = {k.name: k for k in KNOBS}
384
+ LIMIT_FIELDS = tuple(f.name for f in fields(Limits))
385
+
386
+
387
+ @dataclass(frozen=True)
388
+ class Settings:
389
+ """The effective settings of one group. ``senders`` holds per-sender
390
+ overrides of the limits (identity to ``{knob: value}``)."""
391
+
392
+ max_message_bytes: int = MAX_MESSAGE_BYTES
393
+ sender_per_minute: int = SENDER_PER_MINUTE
394
+ group_per_minute: int = GROUP_PER_MINUTE
395
+ identity_rate_multiple: int = IDENTITY_RATE_MULTIPLE
396
+ task_per_minute: int = TASK_PER_MINUTE
397
+ group_task_per_minute: int = GROUP_TASK_PER_MINUTE
398
+ allow_files: bool = False
399
+ max_file_bytes: int = DEFAULT_MAX_FILE_BYTES
400
+ frame_text: bool = True
401
+ require_approval: str = "off"
402
+ approval_ttl_secs: int = 3600
403
+ max_held_per_group: int = DEFAULT_MAX_HELD_PER_GROUP
404
+ max_held_per_sender: int = DEFAULT_MAX_HELD_PER_SENDER
405
+ max_held_bytes: int = DEFAULT_MAX_HELD_BYTES
406
+ max_held_bytes_per_sender: int = DEFAULT_MAX_HELD_SENDER_BYTES
407
+ task_approval_window_secs: int = DEFAULT_TASK_WINDOW_SECS
408
+ violation_threshold: int = DEFAULT_VIOLATION_THRESHOLD
409
+ violation_window_secs: int = DEFAULT_VIOLATION_WINDOW_SECS
410
+ transcript: bool = False
411
+ transcript_retention_days: int = 7
412
+ transcript_max_rows: int = DEFAULT_TRANSCRIPT_MAX_ROWS
413
+ transcript_max_rows_per_sender: int = DEFAULT_TRANSCRIPT_SENDER_ROWS
414
+ transcript_max_group_bytes: int = DEFAULT_TRANSCRIPT_GROUP_BYTES
415
+ senders: Mapping[str, Mapping[str, Any]] = field(default_factory=dict)
416
+
417
+ @staticmethod
418
+ def of(values: Mapping[str, Any]) -> Settings:
419
+ """Settings from ``{knob: value}`` (unknown names are an error)."""
420
+ return Settings(**check_changes(values, sender=False, clear=False))
421
+
422
+ def limits(self, sender: str | None = None) -> Limits:
423
+ """The §10.2 limits for ``sender`` (its overrides applied)."""
424
+ values: dict[str, Any] = {name: getattr(self, name) for name in LIMIT_FIELDS}
425
+ if sender is not None:
426
+ values.update(self.senders.get(sender, {}))
427
+ return Limits(**values)
428
+
429
+ @property
430
+ def approve_inbound(self) -> bool:
431
+ return self.require_approval in ("inbound", "both")
432
+
433
+ @property
434
+ def approve_outbound(self) -> bool:
435
+ return self.require_approval in ("outbound", "both")
436
+
437
+ def as_json(self) -> dict[str, Any]:
438
+ out: dict[str, Any] = {k.name: getattr(self, k.name) for k in KNOBS}
439
+ out["senders"] = {s: dict(v) for s, v in self.senders.items()}
440
+ return out
441
+
442
+ def merged(self, overrides: Mapping[str, Any]) -> Settings:
443
+ """These settings with a group's stored overrides applied."""
444
+ values = {k: v for k, v in overrides.items() if k in KNOB}
445
+ senders = overrides.get("senders")
446
+ return replace(
447
+ self,
448
+ **values,
449
+ senders={str(s): dict(v) for s, v in senders.items()}
450
+ if isinstance(senders, dict)
451
+ else {},
452
+ )
453
+
454
+
455
+ def check_changes(raw: Mapping[str, Any], *, sender: bool, clear: bool = True) -> dict[str, Any]:
456
+ """Validate ``{knob: value}``; ``None`` (with ``clear``) removes an
457
+ override. With ``sender``, only the per-sender knobs are allowed."""
458
+ out: dict[str, Any] = {}
459
+ for name, value in raw.items():
460
+ knob = KNOB.get(name)
461
+ if knob is None:
462
+ msg = f"unknown setting {name!r}"
463
+ raise PolicyError(msg)
464
+ if sender and not knob.per_sender:
465
+ msg = f"{name} can't be set for one sender"
466
+ raise PolicyError(msg)
467
+ out[name] = None if value is None and clear else knob.check(value)
468
+ return out
469
+
470
+
471
+ def apply_changes(
472
+ overrides: Mapping[str, Any], changes: Mapping[str, Any], sender: str | None
473
+ ) -> dict[str, Any]:
474
+ """A group's stored overrides after ``changes`` (validated by
475
+ :func:`check_changes`) for the group or for ``sender``."""
476
+ out = {k: v for k, v in overrides.items() if k != "senders"}
477
+ senders = {str(s): dict(v) for s, v in dict(overrides.get("senders") or {}).items()}
478
+ target = out if sender is None else senders.setdefault(sender, {})
479
+ for name, value in changes.items():
480
+ if value is None:
481
+ target.pop(name, None)
482
+ else:
483
+ target[name] = value
484
+ senders = {s: v for s, v in senders.items() if v}
485
+ if senders:
486
+ out["senders"] = senders
487
+ return out
488
+
489
+
490
+ def task_of(inner: dict[str, Any]) -> str | None:
491
+ """The ``taskId`` of a task status or artifact update, else None."""
492
+ body = inner.get("body")
493
+ task = body.get("taskId") if isinstance(body, dict) else None
494
+ if (
495
+ inner.get("kind") in TASK_UPDATE_KINDS
496
+ and isinstance(task, str)
497
+ and 0 < len(task) <= MAX_TASK_ID
498
+ ):
499
+ return task
500
+ return None
501
+
502
+
503
+ TERMINAL_STATES = frozenset(
504
+ {
505
+ "TASK_STATE_COMPLETED",
506
+ "TASK_STATE_FAILED",
507
+ "TASK_STATE_CANCELED",
508
+ "TASK_STATE_REJECTED",
509
+ "completed",
510
+ "failed",
511
+ "canceled",
512
+ "rejected",
513
+ }
514
+ )
515
+
516
+
517
+ INTERRUPTED_STATES = frozenset(
518
+ {"TASK_STATE_INPUT_REQUIRED", "TASK_STATE_AUTH_REQUIRED", "input-required", "auth-required"}
519
+ )
520
+
521
+
522
+ def task_state(inner: dict[str, Any]) -> str | None:
523
+ """The state a task status update reports, in its A2A v1.0 name
524
+ (``TASK_STATE_COMPLETED``; an A2A 0.x name such as ``completed`` is
525
+ mapped to it), else ``None``."""
526
+ body = inner.get("body")
527
+ status = body.get("status") if isinstance(body, dict) else None
528
+ state = status.get("state") if isinstance(status, dict) else None
529
+ if inner.get("kind") != "a2a.task_status" or not isinstance(state, str) or not state:
530
+ return None
531
+ if state.startswith("TASK_STATE_"):
532
+ return state
533
+ return "TASK_STATE_" + str(state).upper().replace("-", "_")
534
+
535
+
536
+ def acts_on(state: str | None) -> bool:
537
+ """Whether a requester acts on, or learns the outcome from, a task
538
+ entering ``state`` (SPEC 9.3): input or auth required, or an end."""
539
+ return state is not None and (state in INTERRUPTED_STATES or state in TERMINAL_STATES)
540
+
541
+
542
+ def task_ended(inner: dict[str, Any]) -> bool:
543
+ """Whether a task status update reports a terminal state."""
544
+ body = inner.get("body")
545
+ status = body.get("status") if isinstance(body, dict) else None
546
+ state = status.get("state") if isinstance(status, dict) else None
547
+ return bool(inner.get("kind") == "a2a.task_status" and state in TERMINAL_STATES)
548
+
549
+
550
+ def verification_holds(require_verified: bool, trust_state: Mapping[str, Any]) -> bool:
551
+ """SPEC 10.4: with ``require_verified``, deliveries wait until
552
+ ``trust_state.self`` is ``verified``."""
553
+ return require_verified and str(trust_state.get("self")) != "verified"
554
+
555
+
556
+ @dataclass(frozen=True)
557
+ class Inbound:
558
+ """One received inner message, as the policy sees it."""
559
+
560
+ group_id: str
561
+ sender: str # the authenticated identity
562
+ instance: str # its instance (SPEC 3.3): rates count per instance
563
+ inner: dict[str, Any]
564
+ size: int # bytes of the inner message's JSON
565
+ owner: str | None # the group's owner (SPEC 7.3: only it may close)
566
+ hop: int
567
+ max_turns: int
568
+ addressed: bool # the floor rules trigger this instance (SPEC 9.3)
569
+ replayed: bool = False # a journal replay: counted when first received
570
+ drain_exempt: Callable[[], bool] = lambda: False # SPEC 8.2/10.2
571
+ card_changed: bool = False # an Agent Card that differs from the one kept
572
+ card_problem: str = "" # an unusable card: malformed (unparsable), bad_signature
573
+ # A task update answering a message this instance sent (SPEC 9.3): only
574
+ # such an update triggers the agent and has the task budget.
575
+ requested: bool = False
576
+ # A requested task update that doesn't move its task to a state the
577
+ # requester acts on (SPEC 9.3): progress, delivered as context.
578
+ progress: bool = False
579
+ # A requested task update entering a state the requester acts on, for the
580
+ # first time for its request (SPEC 9.3): at most three per request, so it
581
+ # is never refused for the task's budget, which its progress used.
582
+ trigger: bool = False
583
+ admitted: bool = False # SafetyPolicy.admit passed it already
584
+
585
+ @property
586
+ def kind(self) -> str:
587
+ return str(self.inner.get("kind"))
588
+
589
+ @property
590
+ def origin(self) -> str:
591
+ return "human" if self.inner.get("origin") == "human" else "agent"
592
+
593
+
594
+ @dataclass(frozen=True)
595
+ class Outbound:
596
+ """One inner message this instance is about to queue."""
597
+
598
+ group_id: str
599
+ sender: str
600
+ instance: str
601
+ inner: dict[str, Any]
602
+ size: int
603
+ internal: bool # the daemon's own (Agent Cards, loop-guard pauses)
604
+ # A floor grant the daemon checked the role for (SPEC 9.3): a control
605
+ # the agent may send, counted against its rate like its messages.
606
+ grant: bool = False
607
+ # This instance's task entering a state its requester acts on, for the
608
+ # first time for the request it answers (SPEC 9.3): never refused for
609
+ # the task's budget, so the requester always learns the outcome.
610
+ trigger: bool = False
611
+
612
+ @property
613
+ def kind(self) -> str:
614
+ return str(self.inner.get("kind"))
615
+
616
+
617
+ @dataclass(frozen=True)
618
+ class KnockInbound:
619
+ """A knock that passed the owner's checks 1-5 and the replay ledger
620
+ (SPEC 6.6.3), as the policy sees it: who, on which door, and how many
621
+ knocks already wait for the human. Never its name or note."""
622
+
623
+ identity: str # the knocker, authenticated by the knock's KeyPackage
624
+ door_id: str
625
+ pending_on_door: int # knocks on this door waiting for the human
626
+ pending_for_identity: int # this identity's waiting knocks on the listing
627
+ max_pending_per_door: int # knock.max_pending_per_door (at most 32)
628
+
629
+
630
+ #: Every verdict :meth:`SafetyPolicy.knock` can give. There is no delivering
631
+ #: verdict: a knock is held for the human or dropped (SPEC 6.6.4).
632
+ KNOCK_VERDICTS = frozenset({Verdict.HOLD_FOR_APPROVAL, Verdict.DROP})
633
+
634
+
635
+ class SafetyPolicy:
636
+ """The policy for every group of one daemon."""
637
+
638
+ def __init__( # noqa: PLR0913
639
+ self,
640
+ defaults: Settings,
641
+ store: Store,
642
+ clock: Clock,
643
+ *,
644
+ audit_retention_days: int = DEFAULT_AUDIT_RETENTION_DAYS,
645
+ transcript_max_bytes: int = DEFAULT_TRANSCRIPT_MAX_BYTES,
646
+ audit_group_rows: int = DEFAULT_AUDIT_GROUP_ROWS,
647
+ audit_sender_rows: int = DEFAULT_AUDIT_SENDER_ROWS,
648
+ ) -> None:
649
+ self.defaults = defaults
650
+ self.store = store
651
+ self.clock = clock
652
+ self.audit_retention_days = audit_retention_days
653
+ self.transcript_max_bytes = transcript_max_bytes
654
+ self.audit_group_rows = audit_group_rows
655
+ self.audit_sender_rows = audit_sender_rows
656
+ self.limiter = RateLimiter(clock)
657
+ # Bounded: pruned by purge(), dropped with their group, and at most
658
+ # MAX_TRACKED senders (least recently seen go first).
659
+ self._violations: OrderedDict[tuple[str, str], deque[int]] = OrderedDict()
660
+ self._alerted: dict[tuple[str, str], float] = {}
661
+ self._purged_at = -math.inf
662
+
663
+ def _now(self) -> int:
664
+ return unix_seconds(self.clock)
665
+
666
+ # -- settings -----------------------------------------------------------
667
+
668
+ def settings(self, group_id: str) -> Settings:
669
+ return self.defaults.merged(self.store.safety_overrides(group_id))
670
+
671
+ def overrides(self, group_id: str) -> dict[str, Any]:
672
+ return self.store.safety_overrides(group_id)
673
+
674
+ def update(
675
+ self, group_id: str, changes: Mapping[str, Any], sender: str | None = None
676
+ ) -> Settings:
677
+ """Change a group's overrides (``None`` clears one); for ``sender``
678
+ only the limits. Raises :class:`PolicyError`."""
679
+ checked = check_changes(changes, sender=sender is not None)
680
+ merged = apply_changes(self.overrides(group_id), checked, sender)
681
+ # Each value was checked on its own (check_changes); merging doesn't
682
+ # check again.
683
+ settings = self.defaults.merged(merged)
684
+ self.store.set_safety_overrides(group_id, merged)
685
+ return settings
686
+
687
+ # -- decisions ----------------------------------------------------------
688
+
689
+ def admit(self, m: Inbound) -> Decision | None:
690
+ """SPEC 10.2 before anything else reads a received message: its size,
691
+ shape and parts, then the sender's rates, which count it (except
692
+ replays and the reconnect drain). A drop, or ``None``. It comes first
693
+ so that a message that fails later in its handling was counted
694
+ against its sender all the same (SPEC 7.2)."""
695
+ limits = self.settings(m.group_id).limits(m.sender)
696
+ problem = content_problem(m.inner, m.size, limits)
697
+ if problem is not None:
698
+ return drop(problem)
699
+ unrequested = m.kind in TASK_UPDATE_KINDS and not m.requested
700
+ if (
701
+ not m.replayed
702
+ # A trigger that will reach the agent is never lost to the
703
+ # budget its own progress used (SPEC 10.2).
704
+ and not (m.trigger and m.addressed and m.hop <= m.max_turns)
705
+ and not m.drain_exempt()
706
+ and not self.limiter.allow_inner(
707
+ m.group_id,
708
+ m.instance,
709
+ m.kind,
710
+ m.inner.get("body"),
711
+ limits,
712
+ identity=m.sender,
713
+ requested=not unrequested,
714
+ )
715
+ ):
716
+ # Another member's task may be honest at any rate its daemon
717
+ # allows: dropping its progress here is no violation. It never
718
+ # triggers, so a flood costs only a bounded audit share.
719
+ return drop("context_rate_limited" if unrequested else "rate_limited")
720
+ return None
721
+
722
+ def inbound(self, m: Inbound) -> Decision: # noqa: PLR0911, PLR0912
723
+ """The decision for a received message: :meth:`admit` first, unless
724
+ it passed already (``m.admitted``), then the rest of the policy."""
725
+ refused = None if m.admitted else self.admit(m)
726
+ if refused is not None:
727
+ return refused
728
+ settings = self.settings(m.group_id)
729
+ unrequested = m.kind in TASK_UPDATE_KINDS and not m.requested
730
+ if m.card_problem:
731
+ return drop(m.card_problem) # SPEC 10.5: not used, and held against it
732
+ if m.kind in HUMAN_ONLY_KINDS and m.origin != "human":
733
+ return drop("not_human") # SPEC 7.3: an agent can't resume
734
+ if m.kind == "control.close" and m.sender != m.owner:
735
+ return drop("not_owner") # SPEC 7.3: only the owner may close
736
+ if m.kind == "a2a.agent_card" and m.card_changed and settings.approve_inbound:
737
+ # A changed card's text reaches the agent: held like content.
738
+ decision = self._hold(
739
+ m.group_id,
740
+ sender=m.sender,
741
+ instance=m.instance,
742
+ inner=m.inner,
743
+ size=m.size,
744
+ settings=settings,
745
+ )
746
+ if decision is not None and decision.verdict is not Verdict.HOLD_FOR_APPROVAL:
747
+ return decision
748
+ if decision is not None:
749
+ return Decision(Verdict.HOLD_FOR_APPROVAL, "card_changed")
750
+ if m.kind == "a2a.agent_card" and m.card_changed:
751
+ # Not held (approve_inbound off): its text still reaches the
752
+ # agent, as context only. The audit log keeps it with content.
753
+ return as_context("card_changed")
754
+ if m.kind in NON_CONTENT_KINDS:
755
+ # Controls and cards carry no content (agentconnect.schemas) and
756
+ # never trigger the agent; they are context only.
757
+ return as_context("not_content")
758
+ if settings.approve_inbound and m.kind in HOLDABLE_KINDS:
759
+ decision = self._hold(
760
+ m.group_id,
761
+ sender=m.sender,
762
+ instance=m.instance,
763
+ inner=m.inner,
764
+ size=m.size,
765
+ settings=settings,
766
+ trigger=m.trigger,
767
+ )
768
+ if (
769
+ (unrequested or m.trigger)
770
+ and decision is not None
771
+ and decision.reason == "hold_overflow"
772
+ ):
773
+ # Another member's task, or a task's end, held for the human:
774
+ # a full queue is not that sender's violation.
775
+ decision = drop("hold_queue_full")
776
+ if decision is not None:
777
+ return decision
778
+ if m.hop > m.max_turns:
779
+ return as_context("loop_guard") # SPEC 10.3: don't trigger the agent
780
+ if unrequested:
781
+ return as_context("not_requested") # SPEC 9.3: no task we asked for
782
+ if m.progress:
783
+ return as_context("task_progress") # SPEC 9.3: nothing to act on yet
784
+ if not m.addressed:
785
+ return as_context("not_addressed") # SPEC 9.3
786
+ return DELIVER
787
+
788
+ def outbound(self, m: Outbound) -> Decision:
789
+ """The decision for a message the agent (or the human) sends. Counts
790
+ it against this sender's rate unless it is the daemon's own."""
791
+ settings = self.settings(m.group_id)
792
+ limits = settings.limits(m.sender)
793
+ problem = content_problem(m.inner, m.size, limits)
794
+ if problem is not None:
795
+ return drop(problem)
796
+ human = m.inner.get("origin") == "human"
797
+ if m.kind in NON_CONTENT_KINDS and not m.internal and not human and not m.grant:
798
+ # Cards and controls come from the daemon or the human, never
799
+ # from the agent: it can't use them to carry content.
800
+ return drop("not_for_agents")
801
+ # The human's own controls (resume above all, to clear the loop
802
+ # guard) are never rate limited; nothing else shares this window.
803
+ exempt = m.internal or m.trigger or (human and m.kind.startswith("control."))
804
+ if not exempt and not self.limiter.allow_inner(
805
+ m.group_id,
806
+ m.instance,
807
+ m.kind,
808
+ m.inner.get("body"),
809
+ limits,
810
+ identity=m.sender,
811
+ direction="out",
812
+ ):
813
+ return drop("rate_limited")
814
+ if not m.internal and not human and settings.approve_outbound and m.kind in HOLDABLE_KINDS:
815
+ decision = self._hold(
816
+ m.group_id,
817
+ sender=m.sender,
818
+ instance=m.instance,
819
+ inner=m.inner,
820
+ size=m.size,
821
+ settings=settings,
822
+ direction="out",
823
+ )
824
+ if decision is not None:
825
+ return decision
826
+ return DELIVER
827
+
828
+ @staticmethod
829
+ def knock(m: KnockInbound) -> Decision:
830
+ """The decision for a verified knock (SPEC 6.6.3 steps 6-8): hold it
831
+ for the human, or drop it. Nothing here can approve one: there is no
832
+ automatic approval of knocks (SPEC 6.6.4), and ``auto_approve`` is
833
+ about joins only. The limits are the SPEC's or lower."""
834
+ if m.pending_for_identity > 0:
835
+ return drop("knock_identity_pending")
836
+ if m.pending_on_door >= min(m.max_pending_per_door, MAX_PENDING_KNOCKS):
837
+ return drop("knock_door_full")
838
+ return hold("knock")
839
+
840
+ def audit_knock(
841
+ self,
842
+ *,
843
+ sender: str,
844
+ msg_id: str,
845
+ size: int,
846
+ decision: str,
847
+ reason: str = "",
848
+ ) -> None:
849
+ """One metadata-only audit row for a knock (no group yet): who (the
850
+ knocker's identity once its knock verified, else ``mailbox:<id>``),
851
+ the envelope's ``msg_id``, the sealed size and the decision. Never
852
+ the knock's name, note or door."""
853
+ self.audit(
854
+ "",
855
+ "in",
856
+ sender=sender,
857
+ msg_id=msg_id,
858
+ kind="knock",
859
+ size=size,
860
+ decision=decision,
861
+ reason=reason,
862
+ )
863
+
864
+ def _hold( # noqa: PLR0913
865
+ self,
866
+ group_id: str,
867
+ *,
868
+ sender: str,
869
+ instance: str,
870
+ inner: dict[str, Any],
871
+ size: int,
872
+ settings: Settings,
873
+ direction: str = "in",
874
+ trigger: bool = False,
875
+ ) -> Decision | None:
876
+ """What ``require_approval`` does with one message: hold it, drop it,
877
+ or nothing (``None``: a peer's task the human approved).
878
+
879
+ **Tasks, inbound only.** A peer's task is decided on its first update.
880
+ While that waits, later updates are dropped (``task_pending``), except
881
+ a ``trigger`` (the task asking for input or ending, SPEC 9.3): it is
882
+ held behind the first and follows the human's decision on the task.
883
+ Approving it lets the task's inbound updates through until
884
+ ``task_approval_window_secs`` have passed or the task ends; rejecting it (or
885
+ letting it expire) drops them (``task_rejected``) for as long. After
886
+ that window the next update is a new first update. Nothing covers the
887
+ agent's own sends: each is held.
888
+
889
+ **Caps, per direction.** At most ``max_held_per_sender`` per sender
890
+ instance and per identity (all its instances), and per identity at
891
+ most its byte share, ``max_held_bytes_per_sender`` or
892
+ ``max_held_bytes`` divided by the senders waiting, whichever is
893
+ smaller: past either the message is dropped as ``hold_overflow``, a
894
+ violation. A sender holding nothing is always within its share, so
895
+ its first message is never a violation, however small the share has
896
+ become. When the group's
897
+ queue (``max_held_per_group``, ``max_held_bytes``) is full, the
898
+ message is dropped too, but only a sender holding something, and at
899
+ least its fair share (the group cap divided by the senders waiting,
900
+ at most the per-sender cap), is charged (``hold_overflow``); anyone
901
+ else gets ``hold_queue_full``, which isn't a violation."""
902
+ task = task_of(inner) if direction == "in" else None
903
+ now = self._now()
904
+ queued = False
905
+ if task is not None:
906
+ decided = self.store.task_decision(group_id, sender, task)
907
+ state, at = decided if decided is not None else ("", -math.inf)
908
+ if now - at < settings.task_approval_window_secs:
909
+ if state == "approved":
910
+ if task_ended(inner):
911
+ self.store.forget_task(group_id, sender, task)
912
+ return None
913
+ if state == "pending" and not trigger:
914
+ return drop("task_pending")
915
+ queued = state == "pending"
916
+ if state == "rejected":
917
+ return drop("task_rejected")
918
+ total, mine, ours, held_bytes, senders, our_bytes = self.store.held_load(
919
+ group_id, direction, instance, sender
920
+ )
921
+ cap = settings.max_held_per_sender
922
+ waiting = max(senders + (0 if ours else 1), 1)
923
+ byte_share = min(settings.max_held_bytes_per_sender, settings.max_held_bytes // waiting)
924
+ # A sender holding nothing is within its share, whatever the share
925
+ # has shrunk to: the group caps below still bound the queue.
926
+ if mine >= cap or ours >= cap or (ours and our_bytes + size > byte_share):
927
+ return drop("hold_overflow")
928
+ if total >= settings.max_held_per_group or held_bytes + size > settings.max_held_bytes:
929
+ share = min(settings.max_held_per_group // waiting, cap)
930
+ return drop("hold_overflow" if ours and ours >= share else "hold_queue_full")
931
+ if task is not None and not queued:
932
+ self.store.set_task_state(group_id, sender, task, "pending", now)
933
+ return hold("require_approval")
934
+
935
+ def decide_task(self, group_id: str, sender: str, task: str, *, approved: bool) -> None:
936
+ """Record the human's decision on a task's first update."""
937
+ state = "approved" if approved else "rejected"
938
+ self.store.set_task_state(group_id, sender, task, state, self._now())
939
+
940
+ # -- violations -----------------------------------------------------------
941
+
942
+ def violation(self, group_id: str, sender: str, reason: str) -> dict[str, Any] | None:
943
+ """Count a drop against ``sender``. Returns the data of a
944
+ ``repeated_violations`` event when the count reaches the threshold
945
+ (at most once per window and sender), else ``None``."""
946
+ if reason not in VIOLATIONS:
947
+ return None
948
+ settings = self.settings(group_id)
949
+ now = self._now()
950
+ window = settings.violation_window_secs
951
+ key = (group_id, sender)
952
+ times = self._violations.setdefault(key, deque())
953
+ self._violations.move_to_end(key)
954
+ while len(self._violations) > MAX_TRACKED:
955
+ old, _times = self._violations.popitem(last=False)
956
+ self._alerted.pop(old, None)
957
+ while times and times[0] <= now - window:
958
+ times.popleft()
959
+ times.append(now)
960
+ if len(times) < settings.violation_threshold:
961
+ return None
962
+ if now - self._alerted.get(key, -math.inf) < window:
963
+ return None
964
+ self._alerted[key] = now
965
+ return {
966
+ "sender": sender,
967
+ "count": len(times),
968
+ "threshold": settings.violation_threshold,
969
+ "window_secs": window,
970
+ "last_reason": reason,
971
+ }
972
+
973
+ def tracked_senders(self) -> int:
974
+ """How many (group, sender) violation counters are kept."""
975
+ return len(self._violations)
976
+
977
+ def forget_group(self, group_id: str) -> None:
978
+ """Drop a group's rate windows and violation counters (it ended)."""
979
+ self.limiter.forget_group(group_id)
980
+ for key in [k for k in self._violations if k[0] == group_id]:
981
+ del self._violations[key]
982
+ self._alerted.pop(key, None)
983
+
984
+ def _prune_violations(self, now: int) -> None:
985
+ windows: dict[str, int] = {}
986
+ for key, times in list(self._violations.items()):
987
+ if key[0] not in windows:
988
+ windows[key[0]] = self.settings(key[0]).violation_window_secs
989
+ window = windows[key[0]]
990
+ while times and times[0] <= now - window:
991
+ times.popleft()
992
+ if not times and now - self._alerted.get(key, -math.inf) >= window:
993
+ del self._violations[key]
994
+ self._alerted.pop(key, None)
995
+
996
+ def violations(self, group_id: str, sender: str) -> int:
997
+ """Violations by ``sender`` in the current window."""
998
+ times = self._violations.get((group_id, sender), deque())
999
+ window = self.settings(group_id).violation_window_secs
1000
+ return sum(1 for t in times if t > self._now() - window)
1001
+
1002
+ # -- audit log and transcript ------------------------------------------------
1003
+
1004
+ def audit( # noqa: PLR0913
1005
+ self,
1006
+ group_id: str,
1007
+ direction: str,
1008
+ *,
1009
+ sender: str,
1010
+ msg_id: str,
1011
+ kind: str,
1012
+ size: int,
1013
+ decision: str,
1014
+ reason: str = "",
1015
+ ) -> None:
1016
+ """One metadata-only audit row: never the body."""
1017
+ self.store.audit_add(
1018
+ group_id,
1019
+ at=self._now(),
1020
+ direction=direction,
1021
+ sender=sender,
1022
+ msg_id=msg_id,
1023
+ kind=kind,
1024
+ size=size,
1025
+ decision=decision,
1026
+ reason=reason,
1027
+ keep=MAX_AUDIT_ROWS,
1028
+ keep_group=self.audit_group_rows,
1029
+ keep_sender=self.audit_sender_rows,
1030
+ )
1031
+
1032
+ def transcribe(
1033
+ self, group_id: str, direction: str, sender: str, msg_id: str, inner: dict[str, Any]
1034
+ ) -> None:
1035
+ """Keep the full message, if the group has ``transcript`` on."""
1036
+ settings = self.settings(group_id)
1037
+ if settings.transcript:
1038
+ self.store.transcript_add(
1039
+ group_id,
1040
+ at=self._now(),
1041
+ direction=direction,
1042
+ sender=sender,
1043
+ msg_id=msg_id,
1044
+ kind=str(inner.get("kind")),
1045
+ inner=inner,
1046
+ max_rows=settings.transcript_max_rows,
1047
+ max_sender_rows=settings.transcript_max_rows_per_sender,
1048
+ max_group_bytes=settings.transcript_max_group_bytes,
1049
+ max_bytes=self.transcript_max_bytes,
1050
+ )
1051
+
1052
+ def purge(self) -> None:
1053
+ """Bounded retention: audit rows older than ``audit_retention_days``
1054
+ (and beyond the newest :data:`MAX_AUDIT_ROWS`), transcript entries
1055
+ older than their group's ``transcript_retention_days``. Runs at most
1056
+ once a minute of daemon time."""
1057
+ now = self._now()
1058
+ if now - self._purged_at < PURGE_EVERY_SECS:
1059
+ return
1060
+ self._purged_at = now
1061
+ self._prune_violations(now)
1062
+ self.store.task_states_purge(now - TASK_DECISION_DAYS * DAY_SECS)
1063
+ self.store.audit_purge(now - self.audit_retention_days * DAY_SECS, MAX_AUDIT_ROWS)
1064
+ for group_id in self.store.transcript_groups():
1065
+ days = self.settings(group_id).transcript_retention_days
1066
+ self.store.transcript_purge(group_id, now - days * DAY_SECS)