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.
- package/README.md +248 -677
- package/agentainer +16 -18
- package/agentainer.example.yaml +86 -0
- package/bin/agentainer.js +9 -8
- package/examples/brainstorm.yaml +27 -128
- package/examples/bug-hunt.yaml +51 -96
- package/examples/code-review.yaml +73 -0
- package/examples/debate.yaml +16 -90
- package/examples/incident-response.yaml +52 -109
- package/examples/localization.yaml +56 -123
- package/examples/quickstart.yaml +48 -0
- package/examples/research.yaml +25 -0
- package/examples/software-company.yaml +71 -128
- package/examples/tdd-pingpong.yaml +36 -68
- package/examples/writers-room.yaml +49 -111
- package/hooks/claude_stop.sh +5 -3
- package/hooks/codex_notify.sh +4 -3
- package/lib/cli.py +929 -0
- package/lib/config.py +247 -305
- package/lib/hooks.py +246 -0
- package/lib/lock.py +75 -0
- package/lib/log.py +64 -0
- package/lib/mail.py +634 -0
- package/lib/minyaml.py +1 -39
- package/lib/reconcile.py +473 -0
- package/lib/sessions.py +223 -0
- package/lib/supervisor.py +216 -0
- package/lib/telegram.py +372 -0
- package/lib/tmux.py +355 -0
- package/lib/turn.py +159 -0
- package/lib/ui.py +1020 -0
- package/llms.txt +145 -429
- package/package.json +9 -7
- package/scripts/check-deps.js +18 -61
- package/ui/app.js +869 -0
- package/ui/index.html +348 -0
- package/agents.example.yaml +0 -257
- package/examples/code-review-broadcast.yaml +0 -109
- package/examples/existing-repo.yaml +0 -74
- package/examples/multi-language-broadcast.yaml +0 -127
- package/examples/ping-pong.yaml +0 -89
- package/examples/red-team.yaml +0 -117
- package/examples/research-swarm.yaml +0 -129
- 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")
|