agentainer 0.1.6 → 2.0.0

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 (44) hide show
  1. package/README.md +248 -677
  2. package/agentainer +16 -18
  3. package/agentainer.example.yaml +86 -0
  4. package/bin/agentainer.js +9 -8
  5. package/examples/brainstorm.yaml +27 -128
  6. package/examples/bug-hunt.yaml +51 -96
  7. package/examples/code-review.yaml +73 -0
  8. package/examples/debate.yaml +16 -90
  9. package/examples/incident-response.yaml +52 -109
  10. package/examples/localization.yaml +56 -123
  11. package/examples/quickstart.yaml +48 -0
  12. package/examples/research.yaml +25 -0
  13. package/examples/software-company.yaml +71 -128
  14. package/examples/tdd-pingpong.yaml +36 -68
  15. package/examples/writers-room.yaml +49 -111
  16. package/hooks/claude_stop.sh +5 -3
  17. package/hooks/codex_notify.sh +4 -3
  18. package/lib/cli.py +929 -0
  19. package/lib/config.py +247 -305
  20. package/lib/hooks.py +246 -0
  21. package/lib/lock.py +75 -0
  22. package/lib/log.py +64 -0
  23. package/lib/mail.py +634 -0
  24. package/lib/minyaml.py +1 -39
  25. package/lib/reconcile.py +473 -0
  26. package/lib/sessions.py +223 -0
  27. package/lib/supervisor.py +216 -0
  28. package/lib/telegram.py +372 -0
  29. package/lib/tmux.py +355 -0
  30. package/lib/turn.py +159 -0
  31. package/lib/ui.py +1020 -0
  32. package/llms.txt +145 -429
  33. package/package.json +9 -7
  34. package/scripts/check-deps.js +18 -61
  35. package/ui/app.js +869 -0
  36. package/ui/index.html +348 -0
  37. package/agents.example.yaml +0 -257
  38. package/examples/code-review-broadcast.yaml +0 -109
  39. package/examples/existing-repo.yaml +0 -74
  40. package/examples/multi-language-broadcast.yaml +0 -127
  41. package/examples/ping-pong.yaml +0 -89
  42. package/examples/red-team.yaml +0 -117
  43. package/examples/research-swarm.yaml +0 -129
  44. package/lib/swarm.py +0 -2461
package/lib/mail.py ADDED
@@ -0,0 +1,634 @@
1
+ #!/usr/bin/env python3
2
+ """Agentainer -- the file-based MAILROOM (the heart of v2).
3
+
4
+ This module replaces v1's XML-envelope-in-prose messaging. The agent's entire
5
+ world is two verbs (read a file, write a file) and four folders (inbox /
6
+ outbox / read / sent). The model only reads and writes natural-language files;
7
+ everything hard -- routing, ACL, message IDs, threading, read-state, queueing,
8
+ retries, availability, the durable log -- is deterministic orchestrator code in
9
+ this file. See ``ProjectPlan.md`` (§4-§14, §24).
10
+
11
+ Design, in one screen:
12
+
13
+ * ``inbox/`` holds EXACTLY ONE message at a time (one-at-a-time release). The
14
+ rest wait in ``cfg.queue_dir/<agent>/``.
15
+ * ``outbox/<name>/about.md`` is the orchestrator-maintained contact card; its
16
+ mere presence IS the ACL (only peers in ``can_talk_to`` get a folder).
17
+ * Read-state is orchestrator-owned; moving a message to ``read/`` is a
18
+ best-effort receipt; an auto-archive fallback guarantees liveness so a
19
+ forgetful model can never wedge the swarm.
20
+ * ``system`` = orchestrator voice (no folder; just enqueue + log). ``user`` =
21
+ virtual human mailbox with an ACL gate + an availability toggle (default OFF)
22
+ that HOLDS mail (never bounces) + sends a ``system`` ack.
23
+
24
+ Zero runtime dependencies: Python stdlib + the bundled lib/ modules only.
25
+
26
+ Branding: "swarm" is retired -- it's Agentainer everywhere (decision D21).
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import re
33
+ import shutil
34
+ import time
35
+ import uuid
36
+ from pathlib import Path
37
+
38
+ # The lib modules are imported bare (config, log, ...) because the test harness
39
+ # and the CLI both put ``lib/`` on ``sys.path``. Keep these imports in sync with
40
+ # the other lib modules.
41
+ import config as cfgmod # noqa: E402
42
+ from config import Agent, SwarmConfig # noqa: E402
43
+
44
+ import log # noqa: E402
45
+ import lock # noqa: E402
46
+ import turn # noqa: E402
47
+ import tmux # noqa: E402
48
+
49
+
50
+ # --------------------------------------------------------------------------
51
+ # module constants
52
+ # --------------------------------------------------------------------------
53
+
54
+ # A message presented this many times without being handled is auto-archived
55
+ # (plan §7) so a forgetful model can never wedge the swarm.
56
+ AUTO_ARCHIVE_PRESENTATIONS = 5
57
+
58
+ # Runaway-loop cap: at most this many messages between any pair of agents within
59
+ # the sliding window below, else we drop (rate-limit) further ones. Cheap
60
+ # insurance against A<->B "thanks!/you're welcome!" loops.
61
+ RUNAWAY_CAP = 20
62
+ RUNAWAY_WINDOW_S = 60
63
+
64
+ # Sentinel prefix marking a periodic-ping message, so maybe_ping can detect a
65
+ # still-unhandled ping and avoid piling them up.
66
+ PING_MARKER = "ping-"
67
+
68
+
69
+ # --------------------------------------------------------------------------
70
+ # run-dir small-JSON helpers (presentation counts, read receipts, pings)
71
+ # --------------------------------------------------------------------------
72
+
73
+
74
+ def _load_run_json(cfg: SwarmConfig, name: str) -> dict:
75
+ """Load a small JSON state file from ``cfg.run_dir``, returning {} if absent."""
76
+ p = cfg.run_dir / name
77
+ if p.exists():
78
+ try:
79
+ return json.loads(p.read_text())
80
+ except (json.JSONDecodeError, OSError):
81
+ return {}
82
+ return {}
83
+
84
+
85
+ def _save_run_json(cfg: SwarmConfig, name: str, data: dict) -> None:
86
+ """Persist a small JSON state file into ``cfg.run_dir``."""
87
+ cfg.run_dir.mkdir(parents=True, exist_ok=True)
88
+ (cfg.run_dir / name).write_text(json.dumps(data))
89
+
90
+
91
+ def _get_presentations(cfg: SwarmConfig, agent_name: str) -> dict:
92
+ return _load_run_json(cfg, f"{agent_name}.presentations.json")
93
+
94
+
95
+ def _set_presentations(cfg: SwarmConfig, agent_name: str, msg_id: str, count: int) -> None:
96
+ _save_run_json(cfg, f"{agent_name}.presentations.json", {"msg_id": msg_id, "count": count})
97
+
98
+
99
+ def _bump_presentations(cfg: SwarmConfig, agent_name: str, msg_id: str) -> None:
100
+ """Record one more presentation of *msg_id* for *agent_name* (liveness)."""
101
+ cur = _get_presentations(cfg, agent_name)
102
+ if cur.get("msg_id") == msg_id:
103
+ cur["count"] = cur.get("count", 0) + 1
104
+ else:
105
+ cur = {"msg_id": msg_id, "count": 1}
106
+ _save_run_json(cfg, f"{agent_name}.presentations.json", cur)
107
+
108
+
109
+ # --------------------------------------------------------------------------
110
+ # HELPERS (private)
111
+ # --------------------------------------------------------------------------
112
+
113
+
114
+ def new_message_id() -> str:
115
+ """Return a fresh message id, e.g. ``m-1a2b3c4d``."""
116
+ return "m-" + uuid.uuid4().hex[:8]
117
+
118
+
119
+ def format_header(from_, to, msg_id, time, re_=None) -> str:
120
+ """Build the From/To/Id/Time/(Re) header block. The model never writes this."""
121
+ lines = [f"From: {from_}", f"To: {to}", f"Id: {msg_id}", f"Time: {time}"]
122
+ if re_ is not None:
123
+ lines.append(f"Re: {re_}")
124
+ return "\n".join(lines)
125
+
126
+
127
+ def stamp_message(body, from_, to, msg_id, re_=None) -> str:
128
+ """Header + blank line + body. The orchestrator stamps every message."""
129
+ return format_header(from_, to, msg_id, log.now_iso(), re_) + "\n\n" + body
130
+
131
+
132
+ def mark_read(cfg: SwarmConfig, sender: str, msg_id: str) -> None:
133
+ """Record a read receipt for *msg_id* (originally from *sender*).
134
+
135
+ Best-effort: the authoritative read-state lives in the recipient's own
136
+ ``read/`` folder + ``<agent>.read.json`` processed list, so a duplicate
137
+ receipt here can never wedge anything. We log it so the event is durable.
138
+ """
139
+ data = _load_run_json(cfg, f"{sender}.readreceipts.json")
140
+ data[msg_id] = log.now_iso()
141
+ _save_run_json(cfg, f"{sender}.readreceipts.json", data)
142
+ log.log_event(cfg, sender, "read-receipt", id=msg_id)
143
+
144
+
145
+ def enqueue(cfg: SwarmConfig, recipient: str, text: str, msg_id: str) -> None:
146
+ """Write *text* into ``cfg.queue_dir/<recipient>/<msg_id>.txt`` (the queue).
147
+
148
+ After the message is durably queued, best-effort mirror it to Telegram (a
149
+ no-op unless configured). Mirroring runs AFTER the write and can never raise
150
+ -- correctness never depends on the network (see lib/telegram.py).
151
+ """
152
+ with lock.file_lock(cfg, recipient, "mail"):
153
+ q = cfg.queue_dir / recipient
154
+ q.mkdir(parents=True, exist_ok=True)
155
+ (q / f"{msg_id}.txt").write_text(text)
156
+ import telegram # lazy: keeps mail's import graph free of the bridge
157
+
158
+ telegram.on_enqueued(cfg, recipient, text, msg_id)
159
+
160
+
161
+ def rate_limited(cfg: SwarmConfig, a: str, b: str) -> bool:
162
+ """Per-pair sliding-window runaway-loop cap.
163
+
164
+ Returns True if the pair (a, b) has already exchanged >= ``RUNAWAY_CAP``
165
+ messages in the last ``RUNAWAY_WINDOW_S`` seconds; otherwise records this
166
+ one and returns False.
167
+ """
168
+ key = "-".join(sorted([a, b]))
169
+ path = cfg.run_dir / f"{key}.loop.json"
170
+ cfg.run_dir.mkdir(parents=True, exist_ok=True)
171
+ now = time.time()
172
+ try:
173
+ data = json.loads(path.read_text()) if path.exists() else []
174
+ except (json.JSONDecodeError, OSError):
175
+ data = []
176
+ cutoff = now - RUNAWAY_WINDOW_S
177
+ data = [t for t in data if t >= cutoff]
178
+ data.append(now)
179
+ path.write_text(json.dumps(data))
180
+ return len(data) > RUNAWAY_CAP
181
+
182
+
183
+ def _take_outbox_file(cfg: SwarmConfig, sender: str, recipient: str, body: str):
184
+ """Find the outbox message file for (sender -> recipient) whose content matches *body*.
185
+
186
+ Never returns the ``about.md`` contact card.
187
+ """
188
+ mp = cfg.mail_paths(cfg.get(sender))
189
+ d = mp.outbox / recipient
190
+ if not d.exists():
191
+ return None
192
+ files = [f for f in sorted(d.iterdir()) if f.is_file() and f.name != "about.md"]
193
+ for f in files:
194
+ try:
195
+ if f.read_text() == body:
196
+ return f
197
+ except OSError: # pragma: no cover - defensive only
198
+ continue
199
+ return files[0] if files else None
200
+
201
+
202
+ def _move_outbox_file(cfg: SwarmConfig, sender: str, recipient: str, body: str, dest: Path) -> None:
203
+ """Move the matching outbox file for (sender -> recipient) into *dest*."""
204
+ f = _take_outbox_file(cfg, sender, recipient, body)
205
+ if f is None:
206
+ return
207
+ dest.mkdir(parents=True, exist_ok=True)
208
+ shutil.move(str(f), str(dest / f.name))
209
+
210
+
211
+ def _parse_header_field(text: str, field: str):
212
+ m = re.search(rf"^{re.escape(field)}:\s*(.+)$", text, re.MULTILINE)
213
+ return m.group(1).strip() if m else None
214
+
215
+
216
+ # --------------------------------------------------------------------------
217
+ # PUBLIC API
218
+ # --------------------------------------------------------------------------
219
+
220
+
221
+ def init_mailboxes(cfg: SwarmConfig) -> None:
222
+ """Create every agent's five mailbox folders + per-agent queue + outbox ACL folders."""
223
+ with lock.file_lock(cfg, "init", "mail"):
224
+ for agent in cfg.agents:
225
+ mp = cfg.mail_paths(agent)
226
+ for d in (mp.inbox, mp.outbox, mp.read, mp.sent, mp.failed):
227
+ d.mkdir(parents=True, exist_ok=True)
228
+ (cfg.queue_dir / agent.name).mkdir(parents=True, exist_ok=True)
229
+ for peer in agent.can_talk_to:
230
+ (mp.outbox / peer).mkdir(parents=True, exist_ok=True)
231
+ write_contact_cards(cfg)
232
+
233
+
234
+ def write_contact_cards(cfg: SwarmConfig) -> None:
235
+ """(Re)write every ``outbox/<peer>/about.md`` contact card.
236
+
237
+ The mere presence of a peer's folder+card IS the ACL: only agents listed in
238
+ ``can_talk_to`` get one. For ``user`` the card's Status reflects
239
+ ``cfg.user_available`` (available / away).
240
+ """
241
+ for a in cfg.agents:
242
+ for b in cfg.agents:
243
+ if a.name in b.can_talk_to:
244
+ mp = cfg.mail_paths(b)
245
+ card = mp.outbox / a.name / "about.md"
246
+ card.parent.mkdir(parents=True, exist_ok=True)
247
+ card.write_text(
248
+ f"Name: {a.name}\nRole: {a.role}\nStatus: available\n"
249
+ )
250
+ status = "available" if cfg.user_available else "away"
251
+ for b in cfg.agents:
252
+ if "user" in b.can_talk_to:
253
+ mp = cfg.mail_paths(b)
254
+ card = mp.outbox / "user" / "about.md"
255
+ card.parent.mkdir(parents=True, exist_ok=True)
256
+ card.write_text(
257
+ f"Name: user\nRole: human operator\nStatus: {status}\n"
258
+ )
259
+
260
+
261
+ def standby_prompt(cfg: SwarmConfig, agent) -> str:
262
+ """Build the FIRST message an agent receives at ``up`` (its initialization).
263
+
264
+ The agent's standing ``role`` (its identity + how to use the mailbox) is
265
+ delivered up front, wrapped with an explicit STANDBY notice: no task has been
266
+ assigned yet, so the agent must NOT initiate any mail. This stops a proactive
267
+ model from spinning up at startup and mailing its peers before any real task
268
+ exists -- the human delivers the first task via ``agentainer send``, and the
269
+ normal nudge is what notifies the agent when that task lands.
270
+
271
+ Principle 3: the model is always told its exact mailbox paths, so the standby
272
+ states them rather than assuming the agent knows them.
273
+ """
274
+ mp = cfg.mail_paths(agent)
275
+ allowed = ", ".join(p for p in agent.can_talk_to if p != "system") or "(no one yet)"
276
+ notice = (
277
+ "\n---\n"
278
+ "This is your initialization message. No task has been assigned to you yet.\n\n"
279
+ "Do NOT write any file to your outbox/ and do NOT send any message. "
280
+ "You will be notified (a new message will appear in your inbox) when your "
281
+ "first real task arrives. Until then, simply wait and take no action.\n\n"
282
+ "Your mailbox (for when a task arrives):\n"
283
+ f" inbox: {mp.inbox}\n"
284
+ f" outbox: {mp.outbox} (write a file into outbox/<name>/ to send)\n"
285
+ f" read: {mp.read} (move a handled message here)\n"
286
+ f"You can message: {allowed}.\n\n"
287
+ "HOW TO SEND: write your message as a file into outbox/<name>/ (one file per "
288
+ "recipient; read outbox/<name>/about.md first). The moment you have written "
289
+ "your outgoing mail, your TURN IS DONE -- stop and wait. The orchestrator "
290
+ "delivers it and will notify you (a fresh message appears in your inbox and "
291
+ "you'll be nudged) when the recipient replies. Never poll your inbox or run a "
292
+ "loop waiting for a reply; doing so just delays delivery and wedges the swarm."
293
+ )
294
+ if agent.role:
295
+ return agent.role.rstrip() + notice
296
+ return (
297
+ "You are an agent in a multi-agent swarm, but your standing role has not "
298
+ "been set.\n" + notice
299
+ )
300
+
301
+
302
+ def release_next(cfg: SwarmConfig, agent_name: str) -> bool:
303
+ """Release the oldest queued message into *agent_name*'s inbox (one-at-a-time).
304
+
305
+ Returns False if the inbox already holds a message (one-at-a-time) or the
306
+ queue is empty; True if a message was moved into the inbox. Each release
307
+ bumps the presentation counter used by the auto-archive fallback.
308
+ """
309
+ agent = cfg.get(agent_name)
310
+ mp = cfg.mail_paths(agent)
311
+ inbox = mp.inbox
312
+ released_id = None
313
+ # The whole read-decide-move runs under ONE per-recipient lock. It used to
314
+ # check the inbox / pick files[0] OUTSIDE the lock (only the move was
315
+ # guarded), so two concurrent release_next(B) calls -- e.g. two agents
316
+ # both stop and release into B, or a hook firing during a supervisor
317
+ # tick -- could each observe an empty inbox, each pick files[0], and
318
+ # either land two messages in one inbox (one-at-a-time breached) or
319
+ # crash with FileNotFoundError when the other already renamed the file
320
+ # away. Serialising the decision under the lock removes the TOCTOU.
321
+ # route_outbound's enqueue already takes this same per-recipient lock,
322
+ # so there is no new lock-ordering edge (see config/supervisor on the
323
+ # queue -> pane -> turn-state discipline).
324
+ with lock.file_lock(cfg, agent_name, "mail"):
325
+ inbox.mkdir(parents=True, exist_ok=True)
326
+ existing = sorted(inbox.iterdir())
327
+ if existing:
328
+ # One-at-a-time: a message is already presented; count it as a presentation.
329
+ _bump_presentations(cfg, agent_name, existing[0].name)
330
+ return False
331
+ q = cfg.queue_dir / agent_name
332
+ # q may not exist yet for an agent that has never received mail;
333
+ # iterdir() would raise, so treat a missing queue as empty.
334
+ files = sorted(f for f in q.iterdir() if f.is_file()) if q.exists() else []
335
+ if not files:
336
+ return False
337
+ oldest = files[0]
338
+ try:
339
+ shutil.move(str(oldest), str(inbox / oldest.name))
340
+ except FileNotFoundError:
341
+ # Another process already released this exact file (we lost the
342
+ # race). The inbox is populated / the file is gone -- there is
343
+ # nothing for us to release, so treat it as "already presented".
344
+ # Swallowing this keeps a lost race from crashing the caller,
345
+ # which -- for the supervisor tick -- would otherwise kill the
346
+ # liveness heartbeat.
347
+ return False
348
+ released_id = oldest.name
349
+ log.log_event(cfg, agent_name, "delivered", id=released_id)
350
+ _set_presentations(cfg, agent_name, released_id, 1)
351
+ return True
352
+
353
+
354
+ def nudge(cfg: SwarmConfig, agent_name: str) -> bool:
355
+ """Re-inject the protocol into *agent_name*'s pane; return ``paste_into``'s result.
356
+
357
+ The nudge states the agent's EXACT mailbox paths (the model never assumes
358
+ them) and lists the recipients it is allowed to message. The caller ensures
359
+ the agent is idle first; a paste failure just means we retry on the next tick.
360
+ """
361
+ agent = cfg.get(agent_name)
362
+ mp = cfg.mail_paths(agent)
363
+ allowed = ", ".join(p for p in agent.can_talk_to if p != "system")
364
+ nudge_text = (
365
+ f"You have a new message in {mp.inbox}. Read it and do what it asks.\n"
366
+ f"When you're done, move that file to {mp.read}.\n"
367
+ f"To send a message, write a file into {mp.outbox}/<name>/ "
368
+ f"(read {mp.outbox}/<name>/about.md to see who they are and whether "
369
+ f"they're available). The moment you've written your outgoing mail, your "
370
+ f"TURN IS DONE -- stop and wait; you'll be notified (a new message + nudge) "
371
+ f"when the recipient replies. Do not poll your inbox or wait for the reply "
372
+ f"yourself; that only delays delivery.\n"
373
+ f"You can message: {allowed}."
374
+ )
375
+ try:
376
+ return tmux.paste_into(cfg, agent.session, nudge_text)
377
+ except tmux.SwarmError:
378
+ # Best-effort: if the agent isn't up (or tmux is unavailable) the mail
379
+ # still sits in the queue and gets released on the next sweep / when the
380
+ # agent starts. Never crash a send because a session is missing -- a
381
+ # paste failure just means we retry on the next tick.
382
+ return False
383
+
384
+
385
+ def route_outbound(cfg: SwarmConfig, sender: str, recipient: str, body: str) -> str:
386
+ """Route one outbound message from *sender* to *recipient*. Returns one of
387
+ ``delivered`` / ``bounce`` / ``rate-limited`` / ``user-held``.
388
+
389
+ The orchestrator owns all routing/ACL/state; the model only wrote the file.
390
+ """
391
+ sender_agent = cfg.get(sender)
392
+ mp = cfg.mail_paths(sender_agent)
393
+
394
+ if recipient == "system":
395
+ # system is never a recipient -- bounce back as mail + drop to failed/.
396
+ system_mail(cfg, sender, "system is not a valid recipient -- your message was not delivered.")
397
+ _move_outbox_file(cfg, sender, "system", body, mp.failed)
398
+ log.log_event(cfg, sender, "bounce", to="system", reason="system-recipient")
399
+ return "bounce"
400
+
401
+ if recipient == "user":
402
+ return deliver_to_user(cfg, sender, body)
403
+
404
+ if recipient not in cfg.get(sender).can_talk_to:
405
+ allowed = ", ".join(x for x in cfg.get(sender).can_talk_to if x != "system")
406
+ system_mail(
407
+ cfg, sender,
408
+ f"Your message to {recipient} couldn't be sent -- you can message: {allowed}.",
409
+ )
410
+ _move_outbox_file(cfg, sender, recipient, body, mp.failed)
411
+ log.log_event(cfg, sender, "bounce", to=recipient, reason="acl")
412
+ return "bounce"
413
+
414
+ if rate_limited(cfg, sender, recipient):
415
+ _move_outbox_file(cfg, sender, recipient, body, mp.failed)
416
+ log.log_event(cfg, sender, "rate-limited", to=recipient)
417
+ return "rate-limited"
418
+
419
+ msg_id = new_message_id()
420
+ text = stamp_message(body, sender, recipient, msg_id)
421
+ enqueue(cfg, recipient, text, msg_id)
422
+ _move_outbox_file(cfg, sender, recipient, body, mp.sent)
423
+ log.log_event(cfg, sender, "route", from_=sender, to=recipient, id=msg_id)
424
+ return "delivered"
425
+
426
+
427
+ def on_stop(cfg: SwarmConfig, agent_name: str) -> dict:
428
+ """THE CORE: an agent stopped. Sweep its outbox, route every message, then
429
+ release+nudge every recipient that received mail. Returns a summary dict.
430
+
431
+ The orchestrator owns authoritative state, so even a forgetful model that
432
+ never moves mail to ``read/`` can't wedge the swarm.
433
+ """
434
+ agent = cfg.get(agent_name)
435
+ mp = cfg.mail_paths(agent)
436
+
437
+ # 1) Snapshot the outbox under a lock, then route OUTSIDE the lock so
438
+ # enqueue() (which takes its own per-recipient lock) can't deadlock.
439
+ pending = []
440
+ with lock.file_lock(cfg, agent_name, "mail"):
441
+ if mp.outbox.exists():
442
+ for sub in sorted(mp.outbox.iterdir()):
443
+ if not sub.is_dir():
444
+ continue
445
+ recipient = sub.name
446
+ for f in sorted(sub.iterdir()):
447
+ if not f.is_file():
448
+ continue
449
+ # about.md is the orchestrator-maintained contact card, not
450
+ # an outbound message -- never route or delete it.
451
+ if f.name == "about.md":
452
+ continue
453
+ pending.append((recipient, f.read_text(), f))
454
+
455
+ delivered = bounced = rate_limited_count = 0
456
+ recipients: set[str] = set()
457
+
458
+ for recipient, body, f in pending:
459
+ result = route_outbound(cfg, agent_name, recipient, body)
460
+ # route_outbound already moved the file to sent/ or failed/; remove any
461
+ # leftover original so it is never double-routed.
462
+ if f.exists():
463
+ f.unlink()
464
+ if result == "delivered":
465
+ delivered += 1
466
+ if recipient != "user":
467
+ recipients.add(recipient)
468
+ elif result == "user-held":
469
+ delivered += 1
470
+ elif result == "bounce":
471
+ bounced += 1
472
+ recipients.add(agent_name) # the sender gets the bounce as mail
473
+ elif result == "rate-limited":
474
+ rate_limited_count += 1
475
+
476
+ # 2) The turn is finished (clamps busy counters) before we release mail.
477
+ turn.on_turn_finished(cfg, agent_name)
478
+
479
+ # 3) Deliver mail to everyone who received/queued some (including the sender,
480
+ # who may have just been bounced an error). release_next is one-at-a-time
481
+ # and nudge only fires when a message actually landed.
482
+ for r in sorted(recipients):
483
+ if release_next(cfg, r):
484
+ nudge(cfg, r)
485
+
486
+ return {"delivered": delivered, "bounced": bounced, "rate_limited": rate_limited_count}
487
+
488
+
489
+ def process_read_folder(cfg: SwarmConfig, agent_name: str) -> int:
490
+ """Process *agent_name*'s ``read/`` folder: emit read receipts and run the
491
+ auto-archive fallback. Returns the number of new read receipts emitted."""
492
+ agent = cfg.get(agent_name)
493
+ mp = cfg.mail_paths(agent)
494
+ read_dir = mp.read
495
+ state = _load_run_json(cfg, f"{agent_name}.read.json")
496
+ processed = set(state.get("processed", []))
497
+ count = 0
498
+
499
+ if read_dir.exists():
500
+ for f in sorted(read_dir.iterdir()):
501
+ if not f.is_file():
502
+ continue
503
+ msg_id = _parse_header_field(f.read_text(), "Id")
504
+ if msg_id is None or msg_id in processed:
505
+ continue
506
+ sender = _parse_header_field(f.read_text(), "From") or "system"
507
+ mark_read(cfg, sender, msg_id)
508
+ processed.add(msg_id)
509
+ log.log_event(cfg, agent_name, "read", id=msg_id)
510
+ count += 1
511
+
512
+ state["processed"] = sorted(processed)
513
+ _save_run_json(cfg, f"{agent_name}.read.json", state)
514
+
515
+ # Auto-archive fallback (plan §7): a single message presented >= N times
516
+ # without being handled is moved to the archive and the queue advances, so a
517
+ # forgetful model can never wedge the swarm.
518
+ inbox = mp.inbox
519
+ if inbox.exists():
520
+ msgs = sorted(f for f in inbox.iterdir() if f.is_file())
521
+ if len(msgs) == 1:
522
+ f = msgs[0]
523
+ pres = _get_presentations(cfg, agent_name)
524
+ if (
525
+ pres.get("msg_id") == f.name
526
+ and pres.get("count", 0) >= AUTO_ARCHIVE_PRESENTATIONS
527
+ and f.name not in processed
528
+ ):
529
+ log.archive_message(cfg, agent_name, f)
530
+ release_next(cfg, agent_name)
531
+
532
+ return count
533
+
534
+
535
+ def maybe_ping(cfg: SwarmConfig, agent_name: str) -> bool:
536
+ """Inject a periodic ``system`` ping into *agent_name*'s queue, respecting
537
+ the three §10 guards. Returns True if a ping was injected.
538
+
539
+ Guards: (a) idle-only -- skip if busy; (b) no-pile-up -- skip if an unhandled
540
+ ping marker already sits in the queue/inbox; (c) cadence-is-minimum -- skip
541
+ unless ``periodically_ping_seconds`` has elapsed since the last ping.
542
+ """
543
+ agent = cfg.get(agent_name)
544
+ if agent.periodically_ping_seconds <= 0:
545
+ return False
546
+ if turn.busy_info(cfg, agent) is not None:
547
+ return False
548
+
549
+ # (b) no-pile-up: a still-unhandled ping is a file whose name starts with the marker.
550
+ queue = cfg.queue_dir / agent_name
551
+ inbox = cfg.mail_paths(agent).inbox
552
+ for d in (queue, inbox):
553
+ if d.exists():
554
+ for f in d.iterdir():
555
+ if f.name.startswith(PING_MARKER):
556
+ return False
557
+
558
+ # (c) cadence-is-minimum.
559
+ state = _load_run_json(cfg, f"{agent_name}.ping.json")
560
+ last = state.get("last_ping", 0.0)
561
+ now = time.time()
562
+ if now - last < agent.periodically_ping_seconds:
563
+ return False
564
+
565
+ msg_id = PING_MARKER + uuid.uuid4().hex[:8]
566
+ text = stamp_message(agent.periodically_ping_message, "system", agent_name, msg_id)
567
+ enqueue(cfg, agent_name, text, msg_id)
568
+ state["last_ping"] = now
569
+ _save_run_json(cfg, f"{agent_name}.ping.json", state)
570
+ log.log_event(cfg, agent_name, "ping")
571
+ return True
572
+
573
+
574
+ def deliver_to_user(cfg: SwarmConfig, sender: str, body: str) -> str:
575
+ """Deliver *sender*'s message into the virtual ``user`` mailbox.
576
+
577
+ Returns ``delivered`` (user available), ``user-held`` (user away -- mail is
578
+ held, never bounced, and a ``system`` ack is dropped into the sender), or
579
+ ``bounce`` (sender not allowed to message the user). The sender's outbox
580
+ file is moved to ``sent/`` -- the send itself always succeeds; only the
581
+ human's reply is deferred.
582
+ """
583
+ mp = cfg.mail_paths(cfg.get(sender))
584
+ if "user" not in cfg.get(sender).can_talk_to:
585
+ system_mail(cfg, sender, "You can't message the user -- they're not in your can_talk_to.")
586
+ _move_outbox_file(cfg, sender, "user", body, mp.failed)
587
+ log.log_event(cfg, sender, "bounce", to="user", reason="acl")
588
+ return "bounce"
589
+
590
+ msg_id = new_message_id()
591
+ text = stamp_message(body, sender, "user", msg_id)
592
+ enqueue(cfg, "user", text, msg_id)
593
+ _move_outbox_file(cfg, sender, "user", body, mp.sent)
594
+ if cfg.user_available:
595
+ log.log_event(cfg, sender, "delivered", to="user", id=msg_id)
596
+ return "delivered"
597
+ system_mail(cfg, sender, "Delivered -- the user is away and may respond later.")
598
+ log.log_event(cfg, sender, "user-held", to="user", id=msg_id)
599
+ return "user-held"
600
+
601
+
602
+ def send_as_user(cfg: SwarmConfig, to_agent: str, body: str) -> None:
603
+ """The human/UI sends a message FROM ``user`` to *to_agent*."""
604
+ msg_id = new_message_id()
605
+ text = stamp_message(body, "user", to_agent, msg_id)
606
+ enqueue(cfg, to_agent, text, msg_id)
607
+ log.log_event(cfg, to_agent, "user-send", from_="user", id=msg_id)
608
+ if release_next(cfg, to_agent):
609
+ nudge(cfg, to_agent)
610
+
611
+
612
+ def system_mail(cfg: SwarmConfig, to_agent: str, body: str, *, kind: str = "system") -> None:
613
+ """Enqueue a ``system`` message (From: system) into *to_agent*'s queue.
614
+
615
+ Used for bounces, acks, and periodic pings -- errors come back as mail so
616
+ the model self-corrects in-band.
617
+ """
618
+ msg_id = new_message_id()
619
+ text = stamp_message(body, "system", to_agent, msg_id)
620
+ enqueue(cfg, to_agent, text, msg_id)
621
+ log.log_event(cfg, to_agent, kind, from_="system")
622
+
623
+
624
+ def set_user_available(cfg: SwarmConfig, available: bool) -> None:
625
+ """Set the user's availability toggle and rewrite the live ``user`` contact cards."""
626
+ cfg.user_available = available
627
+ for b in cfg.agents:
628
+ if "user" in b.can_talk_to:
629
+ mp = cfg.mail_paths(b)
630
+ card = mp.outbox / "user" / "about.md"
631
+ card.parent.mkdir(parents=True, exist_ok=True)
632
+ status = "available" if available else "away"
633
+ card.write_text(f"Name: user\nRole: human operator\nStatus: {status}\n")
634
+ log.log_event(cfg, "user", "user-available" if available else "user-away")