agentainer 0.1.7 → 2.0.1
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/academic-coauthor.yaml +123 -0
- package/examples/accessibility-audit.yaml +152 -0
- package/examples/affiliate-product-reviews.yaml +106 -0
- package/examples/api-design.yaml +157 -0
- package/examples/app-store-optimization.yaml +108 -0
- package/examples/brainstorm.yaml +27 -128
- package/examples/brand-voice-style-guide.yaml +109 -0
- package/examples/bug-hunt.yaml +51 -96
- package/examples/candidate-screen.yaml +122 -0
- package/examples/case-study-writer.yaml +100 -0
- package/examples/changelog-release-notes.yaml +114 -0
- package/examples/chatbot-builder.yaml +138 -0
- package/examples/code-review.yaml +73 -0
- package/examples/comparison-guide-writer.yaml +106 -0
- package/examples/competitive-intel.yaml +126 -0
- package/examples/content-studio.yaml +91 -0
- package/examples/course-creator.yaml +133 -0
- package/examples/customer-support-triage.yaml +118 -0
- package/examples/daily-briefing.yaml +119 -0
- package/examples/data-pipeline-builder.yaml +135 -0
- package/examples/debate.yaml +16 -90
- package/examples/design-system.yaml +138 -0
- package/examples/ebook-generator.yaml +90 -0
- package/examples/ecommerce-listing-optimizer.yaml +126 -0
- package/examples/email-newsletter.yaml +103 -0
- package/examples/faq-knowledge-sync.yaml +107 -0
- package/examples/game-design.yaml +122 -0
- package/examples/glossary-term-writer.yaml +103 -0
- package/examples/incident-response.yaml +52 -109
- package/examples/knowledge-base.yaml +115 -0
- package/examples/landing-page-converter.yaml +103 -0
- package/examples/legal-contract-review.yaml +118 -0
- package/examples/linkedin-ghostwriter.yaml +93 -0
- package/examples/localization.yaml +56 -123
- package/examples/meeting-notes.yaml +111 -0
- package/examples/migration-planner.yaml +127 -0
- package/examples/onboarding-buddy.yaml +111 -0
- package/examples/performance-audit.yaml +123 -0
- package/examples/podcast-production.yaml +117 -0
- package/examples/postmortem.yaml +119 -0
- package/examples/pr-review-gate.yaml +123 -0
- package/examples/press-release-wire.yaml +96 -0
- package/examples/product-spec.yaml +107 -0
- package/examples/prompt-engineering-lab.yaml +109 -0
- package/examples/quickstart.yaml +48 -0
- package/examples/rag-builder.yaml +145 -0
- package/examples/refactor-planner.yaml +127 -0
- package/examples/research.yaml +25 -0
- package/examples/resume-tailor.yaml +116 -0
- package/examples/rfp-response.yaml +124 -0
- package/examples/sales-coach.yaml +123 -0
- package/examples/security-audit.yaml +120 -0
- package/examples/seo-audit-and-fix.yaml +138 -0
- package/examples/seo-content-factory.yaml +103 -0
- package/examples/social-media.yaml +103 -0
- package/examples/software-company.yaml +71 -128
- package/examples/startup-validator.yaml +115 -0
- package/examples/tdd-pingpong.yaml +36 -68
- package/examples/technical-documentation.yaml +112 -0
- package/examples/test-factory.yaml +114 -0
- package/examples/tutorial-howto-creator.yaml +111 -0
- package/examples/twitter-x-thread-factory.yaml +91 -0
- package/examples/white-paper-research.yaml +96 -0
- package/examples/writers-room.yaml +49 -111
- package/examples/youtube-script-studio.yaml +107 -0
- package/hooks/claude_stop.sh +5 -3
- package/hooks/codex_notify.sh +4 -3
- package/lib/cli.py +933 -0
- package/lib/config.py +267 -308
- package/lib/hooks.py +246 -0
- package/lib/lock.py +75 -0
- package/lib/log.py +64 -0
- package/lib/mail.py +699 -0
- package/lib/minyaml.py +1 -39
- package/lib/reconcile.py +544 -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 +167 -0
- package/lib/ui.py +1219 -0
- package/llms.txt +145 -429
- package/package.json +9 -7
- package/scripts/check-deps.js +18 -61
- package/ui/app.js +1136 -0
- package/ui/index.html +404 -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,699 @@
|
|
|
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 os
|
|
33
|
+
import re
|
|
34
|
+
import shutil
|
|
35
|
+
import time
|
|
36
|
+
import uuid
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
# The lib modules are imported bare (config, log, ...) because the test harness
|
|
40
|
+
# and the CLI both put ``lib/`` on ``sys.path``. Keep these imports in sync with
|
|
41
|
+
# the other lib modules.
|
|
42
|
+
import config as cfgmod # noqa: E402
|
|
43
|
+
from config import Agent, SwarmConfig # noqa: E402
|
|
44
|
+
|
|
45
|
+
import log # noqa: E402
|
|
46
|
+
import lock # noqa: E402
|
|
47
|
+
import turn # noqa: E402
|
|
48
|
+
import tmux # noqa: E402
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# --------------------------------------------------------------------------
|
|
52
|
+
# module constants
|
|
53
|
+
# --------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
# A message presented this many times without being handled is auto-archived
|
|
56
|
+
# (plan §7) so a forgetful model can never wedge the swarm.
|
|
57
|
+
AUTO_ARCHIVE_PRESENTATIONS = 5
|
|
58
|
+
|
|
59
|
+
# Runaway-loop cap: at most this many messages between any pair of agents within
|
|
60
|
+
# the sliding window below, else we drop (rate-limit) further ones. Cheap
|
|
61
|
+
# insurance against A<->B "thanks!/you're welcome!" loops.
|
|
62
|
+
RUNAWAY_CAP = 20
|
|
63
|
+
RUNAWAY_WINDOW_S = 60
|
|
64
|
+
|
|
65
|
+
# Sentinel prefix marking a periodic-ping message, so maybe_ping can detect a
|
|
66
|
+
# still-unhandled ping and avoid piling them up.
|
|
67
|
+
PING_MARKER = "ping-"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# --------------------------------------------------------------------------
|
|
71
|
+
# run-dir small-JSON helpers (presentation counts, read receipts, pings)
|
|
72
|
+
# --------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _load_run_json(cfg: SwarmConfig, name: str) -> dict:
|
|
76
|
+
"""Load a small JSON state file from ``cfg.run_dir``, returning {} if absent."""
|
|
77
|
+
p = cfg.run_dir / name
|
|
78
|
+
if p.exists():
|
|
79
|
+
try:
|
|
80
|
+
return json.loads(p.read_text())
|
|
81
|
+
except (json.JSONDecodeError, OSError):
|
|
82
|
+
return {}
|
|
83
|
+
return {}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _save_run_json(cfg: SwarmConfig, name: str, data: dict) -> None:
|
|
87
|
+
"""Persist a small JSON state file into ``cfg.run_dir``."""
|
|
88
|
+
cfg.run_dir.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
(cfg.run_dir / name).write_text(json.dumps(data))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _get_presentations(cfg: SwarmConfig, agent_name: str) -> dict:
|
|
93
|
+
return _load_run_json(cfg, f"{agent_name}.presentations.json")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _set_presentations(cfg: SwarmConfig, agent_name: str, msg_id: str, count: int) -> None:
|
|
97
|
+
_save_run_json(cfg, f"{agent_name}.presentations.json", {"msg_id": msg_id, "count": count})
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _bump_presentations(cfg: SwarmConfig, agent_name: str, msg_id: str) -> None:
|
|
101
|
+
"""Record one more presentation of *msg_id* for *agent_name* (liveness)."""
|
|
102
|
+
cur = _get_presentations(cfg, agent_name)
|
|
103
|
+
if cur.get("msg_id") == msg_id:
|
|
104
|
+
cur["count"] = cur.get("count", 0) + 1
|
|
105
|
+
else:
|
|
106
|
+
cur = {"msg_id": msg_id, "count": 1}
|
|
107
|
+
_save_run_json(cfg, f"{agent_name}.presentations.json", cur)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# --------------------------------------------------------------------------
|
|
111
|
+
# HELPERS (private)
|
|
112
|
+
# --------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def new_message_id() -> str:
|
|
116
|
+
"""Return a fresh message id, e.g. ``m-1a2b3c4d``."""
|
|
117
|
+
return "m-" + uuid.uuid4().hex[:8]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def format_header(from_, to, msg_id, time, re_=None) -> str:
|
|
121
|
+
"""Build the From/To/Id/Time/(Re) header block. The model never writes this."""
|
|
122
|
+
lines = [f"From: {from_}", f"To: {to}", f"Id: {msg_id}", f"Time: {time}"]
|
|
123
|
+
if re_ is not None:
|
|
124
|
+
lines.append(f"Re: {re_}")
|
|
125
|
+
return "\n".join(lines)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def stamp_message(body, from_, to, msg_id, re_=None) -> str:
|
|
129
|
+
"""Header + blank line + body. The orchestrator stamps every message."""
|
|
130
|
+
return format_header(from_, to, msg_id, log.now_iso(), re_) + "\n\n" + body
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def mark_read(cfg: SwarmConfig, sender: str, msg_id: str) -> None:
|
|
134
|
+
"""Record a read receipt for *msg_id* (originally from *sender*).
|
|
135
|
+
|
|
136
|
+
Best-effort: the authoritative read-state lives in the recipient's own
|
|
137
|
+
``read/`` folder + ``<agent>.read.json`` processed list, so a duplicate
|
|
138
|
+
receipt here can never wedge anything. We log it so the event is durable.
|
|
139
|
+
"""
|
|
140
|
+
data = _load_run_json(cfg, f"{sender}.readreceipts.json")
|
|
141
|
+
data[msg_id] = log.now_iso()
|
|
142
|
+
_save_run_json(cfg, f"{sender}.readreceipts.json", data)
|
|
143
|
+
log.log_event(cfg, sender, "read-receipt", id=msg_id)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def enqueue(cfg: SwarmConfig, recipient: str, text: str, msg_id: str) -> None:
|
|
147
|
+
"""Write *text* into ``cfg.queue_dir/<recipient>/<msg_id>.txt`` (the queue).
|
|
148
|
+
|
|
149
|
+
After the message is durably queued, best-effort mirror it to Telegram (a
|
|
150
|
+
no-op unless configured). Mirroring runs AFTER the write and can never raise
|
|
151
|
+
-- correctness never depends on the network (see lib/telegram.py).
|
|
152
|
+
|
|
153
|
+
We stamp the queue file with a strictly-increasing modification time (>= every
|
|
154
|
+
file already queued for this recipient) so :func:`queued_files` gives true
|
|
155
|
+
FIFO. Filesystem mtime granularity can't be relied on: some filesystems hand
|
|
156
|
+
identical mtimes to writes made in the same coarse tick, which would let
|
|
157
|
+
release order fall back to the random message-id name and starve a message
|
|
158
|
+
whose id happens to sort late. Enqueues for one recipient are serialised by
|
|
159
|
+
this same lock, so max-existing+1 is a safe monotonic clock per queue.
|
|
160
|
+
"""
|
|
161
|
+
with lock.file_lock(cfg, recipient, "mail"):
|
|
162
|
+
q = cfg.queue_dir / recipient
|
|
163
|
+
q.mkdir(parents=True, exist_ok=True)
|
|
164
|
+
path = q / f"{msg_id}.txt"
|
|
165
|
+
path.write_text(text)
|
|
166
|
+
prior = [f.stat().st_mtime_ns for f in q.iterdir() if f.is_file() and f != path]
|
|
167
|
+
stamp = max([time.time_ns()] + [p + 1 for p in prior])
|
|
168
|
+
os.utime(path, ns=(stamp, stamp))
|
|
169
|
+
import telegram # lazy: keeps mail's import graph free of the bridge
|
|
170
|
+
|
|
171
|
+
telegram.on_enqueued(cfg, recipient, text, msg_id)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def rate_limited(cfg: SwarmConfig, a: str, b: str) -> bool:
|
|
175
|
+
"""Per-pair sliding-window runaway-loop cap.
|
|
176
|
+
|
|
177
|
+
Returns True if the pair (a, b) has already exchanged >= ``RUNAWAY_CAP``
|
|
178
|
+
messages in the last ``RUNAWAY_WINDOW_S`` seconds; otherwise records this
|
|
179
|
+
one and returns False.
|
|
180
|
+
"""
|
|
181
|
+
key = "-".join(sorted([a, b]))
|
|
182
|
+
path = cfg.run_dir / f"{key}.loop.json"
|
|
183
|
+
cfg.run_dir.mkdir(parents=True, exist_ok=True)
|
|
184
|
+
now = time.time()
|
|
185
|
+
try:
|
|
186
|
+
data = json.loads(path.read_text()) if path.exists() else []
|
|
187
|
+
except (json.JSONDecodeError, OSError):
|
|
188
|
+
data = []
|
|
189
|
+
cutoff = now - RUNAWAY_WINDOW_S
|
|
190
|
+
data = [t for t in data if t >= cutoff]
|
|
191
|
+
data.append(now)
|
|
192
|
+
path.write_text(json.dumps(data))
|
|
193
|
+
return len(data) > RUNAWAY_CAP
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _take_outbox_file(cfg: SwarmConfig, sender: str, recipient: str, body: str):
|
|
197
|
+
"""Find the outbox message file for (sender -> recipient) whose content matches *body*.
|
|
198
|
+
|
|
199
|
+
Never returns the ``about.md`` contact card.
|
|
200
|
+
"""
|
|
201
|
+
mp = cfg.mail_paths(cfg.get(sender))
|
|
202
|
+
d = mp.outbox / recipient
|
|
203
|
+
if not d.exists():
|
|
204
|
+
return None
|
|
205
|
+
files = [f for f in sorted(d.iterdir()) if f.is_file() and f.name != "about.md"]
|
|
206
|
+
for f in files:
|
|
207
|
+
try:
|
|
208
|
+
if f.read_text() == body:
|
|
209
|
+
return f
|
|
210
|
+
except OSError: # pragma: no cover - defensive only
|
|
211
|
+
continue
|
|
212
|
+
return files[0] if files else None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _move_outbox_file(cfg: SwarmConfig, sender: str, recipient: str, body: str, dest: Path) -> None:
|
|
216
|
+
"""Move the matching outbox file for (sender -> recipient) into *dest*."""
|
|
217
|
+
f = _take_outbox_file(cfg, sender, recipient, body)
|
|
218
|
+
if f is None:
|
|
219
|
+
return
|
|
220
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
221
|
+
shutil.move(str(f), str(dest / f.name))
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _parse_header_field(text: str, field: str):
|
|
225
|
+
m = re.search(rf"^{re.escape(field)}:\s*(.+)$", text, re.MULTILINE)
|
|
226
|
+
return m.group(1).strip() if m else None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# --------------------------------------------------------------------------
|
|
230
|
+
# PUBLIC API
|
|
231
|
+
# --------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def init_mailboxes(cfg: SwarmConfig) -> None:
|
|
235
|
+
"""Create every agent's five mailbox folders + per-agent queue + outbox ACL folders."""
|
|
236
|
+
with lock.file_lock(cfg, "init", "mail"):
|
|
237
|
+
for agent in cfg.agents:
|
|
238
|
+
mp = cfg.mail_paths(agent)
|
|
239
|
+
for d in (mp.inbox, mp.outbox, mp.read, mp.sent, mp.failed):
|
|
240
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
241
|
+
(cfg.queue_dir / agent.name).mkdir(parents=True, exist_ok=True)
|
|
242
|
+
for peer in agent.can_talk_to:
|
|
243
|
+
(mp.outbox / peer).mkdir(parents=True, exist_ok=True)
|
|
244
|
+
write_contact_cards(cfg)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def write_contact_cards(cfg: SwarmConfig) -> None:
|
|
248
|
+
"""(Re)write every ``outbox/<peer>/about.md`` contact card.
|
|
249
|
+
|
|
250
|
+
The mere presence of a peer's folder+card IS the ACL: only agents listed in
|
|
251
|
+
``can_talk_to`` get one. For ``user`` the card's Status reflects
|
|
252
|
+
``cfg.user_available`` (available / away).
|
|
253
|
+
"""
|
|
254
|
+
for a in cfg.agents:
|
|
255
|
+
for b in cfg.agents:
|
|
256
|
+
if a.name in b.can_talk_to:
|
|
257
|
+
mp = cfg.mail_paths(b)
|
|
258
|
+
card = mp.outbox / a.name / "about.md"
|
|
259
|
+
card.parent.mkdir(parents=True, exist_ok=True)
|
|
260
|
+
card.write_text(
|
|
261
|
+
f"Name: {a.name}\nRole: {a.role}\nStatus: available\n"
|
|
262
|
+
)
|
|
263
|
+
status = "available" if cfg.user_available else "away"
|
|
264
|
+
for b in cfg.agents:
|
|
265
|
+
if "user" in b.can_talk_to:
|
|
266
|
+
mp = cfg.mail_paths(b)
|
|
267
|
+
card = mp.outbox / "user" / "about.md"
|
|
268
|
+
card.parent.mkdir(parents=True, exist_ok=True)
|
|
269
|
+
card.write_text(
|
|
270
|
+
f"Name: user\nRole: human operator\nStatus: {status}\n"
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def standby_prompt(cfg: SwarmConfig, agent) -> str:
|
|
275
|
+
"""Build the FIRST message an agent receives at ``up`` (its initialization).
|
|
276
|
+
|
|
277
|
+
The agent's standing ``role`` (its identity + how to use the mailbox) is
|
|
278
|
+
delivered up front, wrapped with an explicit STANDBY notice: no task has been
|
|
279
|
+
assigned yet, so the agent must NOT initiate any mail. This stops a proactive
|
|
280
|
+
model from spinning up at startup and mailing its peers before any real task
|
|
281
|
+
exists -- the human delivers the first task via ``agentainer send``, and the
|
|
282
|
+
normal nudge is what notifies the agent when that task lands.
|
|
283
|
+
|
|
284
|
+
Principle 3: the model is always told its exact mailbox paths, so the standby
|
|
285
|
+
states them rather than assuming the agent knows them.
|
|
286
|
+
"""
|
|
287
|
+
mp = cfg.mail_paths(agent)
|
|
288
|
+
allowed = ", ".join(p for p in agent.can_talk_to if p != "system") or "(no one yet)"
|
|
289
|
+
notice = (
|
|
290
|
+
"\n---\n"
|
|
291
|
+
"This is your initialization message. No task has been assigned to you yet.\n\n"
|
|
292
|
+
"Do NOT write any file to your outbox/ and do NOT send any message. "
|
|
293
|
+
"You will be notified (a new message will appear in your inbox) when your "
|
|
294
|
+
"first real task arrives. Until then, simply wait and take no action.\n\n"
|
|
295
|
+
"Your mailbox (for when a task arrives):\n"
|
|
296
|
+
f" inbox: {mp.inbox}\n"
|
|
297
|
+
f" outbox: {mp.outbox} (write a file into outbox/<name>/ to send)\n"
|
|
298
|
+
f" read: {mp.read} (move a handled message here)\n"
|
|
299
|
+
f"You can message: {allowed}.\n\n"
|
|
300
|
+
"HOW TO SEND: write your message as a file into outbox/<name>/ (one file per "
|
|
301
|
+
"recipient; read outbox/<name>/about.md first). The moment you have written "
|
|
302
|
+
"your outgoing mail, your TURN IS DONE -- stop and wait. The orchestrator "
|
|
303
|
+
"delivers it and will notify you (a fresh message appears in your inbox and "
|
|
304
|
+
"you'll be nudged) when the recipient replies. Never poll your inbox or run a "
|
|
305
|
+
"loop waiting for a reply; doing so just delays delivery and wedges the swarm."
|
|
306
|
+
)
|
|
307
|
+
if agent.role:
|
|
308
|
+
return agent.role.rstrip() + notice
|
|
309
|
+
return (
|
|
310
|
+
"You are an agent in a multi-agent swarm, but your standing role has not "
|
|
311
|
+
"been set.\n" + notice
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _queue_order(path: Path):
|
|
316
|
+
"""FIFO sort key for a queued message: enqueue time first, name as tie-break.
|
|
317
|
+
|
|
318
|
+
Message filenames are random ids (``m-<uuid8>.txt``), so sorting the queue
|
|
319
|
+
by *name* is a random order -- a message whose id happens to sort late gets
|
|
320
|
+
starved indefinitely as lower-sorting ids keep cutting in line. Queue files
|
|
321
|
+
are written once by ``enqueue`` and never modified, so ``st_mtime_ns`` is
|
|
322
|
+
the moment the message was enqueued: order by that to get true FIFO.
|
|
323
|
+
"""
|
|
324
|
+
return (path.stat().st_mtime_ns, path.name)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def queued_files(cfg: SwarmConfig, agent_name: str) -> list:
|
|
328
|
+
"""Return *agent_name*'s queued message files in FIFO (enqueue-time) order.
|
|
329
|
+
|
|
330
|
+
A missing queue dir (agent never received mail) is treated as empty.
|
|
331
|
+
"""
|
|
332
|
+
q = cfg.queue_dir / agent_name
|
|
333
|
+
if not q.exists():
|
|
334
|
+
return []
|
|
335
|
+
return sorted((f for f in q.iterdir() if f.is_file()), key=_queue_order)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def release_next(cfg: SwarmConfig, agent_name: str) -> bool:
|
|
339
|
+
"""Release the oldest queued message into *agent_name*'s inbox (one-at-a-time).
|
|
340
|
+
|
|
341
|
+
"Oldest" is by enqueue time (see :func:`queued_files`), not filename -- the
|
|
342
|
+
ids are random, so filename order would starve a late-sorting message.
|
|
343
|
+
|
|
344
|
+
Returns False if the inbox already holds a message (one-at-a-time) or the
|
|
345
|
+
queue is empty; True if a message was moved into the inbox. Each release
|
|
346
|
+
bumps the presentation counter used by the auto-archive fallback.
|
|
347
|
+
"""
|
|
348
|
+
agent = cfg.get(agent_name)
|
|
349
|
+
mp = cfg.mail_paths(agent)
|
|
350
|
+
inbox = mp.inbox
|
|
351
|
+
released_id = None
|
|
352
|
+
# The whole read-decide-move runs under ONE per-recipient lock. It used to
|
|
353
|
+
# check the inbox / pick files[0] OUTSIDE the lock (only the move was
|
|
354
|
+
# guarded), so two concurrent release_next(B) calls -- e.g. two agents
|
|
355
|
+
# both stop and release into B, or a hook firing during a supervisor
|
|
356
|
+
# tick -- could each observe an empty inbox, each pick files[0], and
|
|
357
|
+
# either land two messages in one inbox (one-at-a-time breached) or
|
|
358
|
+
# crash with FileNotFoundError when the other already renamed the file
|
|
359
|
+
# away. Serialising the decision under the lock removes the TOCTOU.
|
|
360
|
+
# route_outbound's enqueue already takes this same per-recipient lock,
|
|
361
|
+
# so there is no new lock-ordering edge (see config/supervisor on the
|
|
362
|
+
# queue -> pane -> turn-state discipline).
|
|
363
|
+
with lock.file_lock(cfg, agent_name, "mail"):
|
|
364
|
+
inbox.mkdir(parents=True, exist_ok=True)
|
|
365
|
+
existing = sorted(inbox.iterdir())
|
|
366
|
+
if existing:
|
|
367
|
+
# One-at-a-time: a message is already presented; count it as a presentation.
|
|
368
|
+
_bump_presentations(cfg, agent_name, existing[0].name)
|
|
369
|
+
return False
|
|
370
|
+
files = queued_files(cfg, agent_name)
|
|
371
|
+
if not files:
|
|
372
|
+
return False
|
|
373
|
+
oldest = files[0]
|
|
374
|
+
try:
|
|
375
|
+
shutil.move(str(oldest), str(inbox / oldest.name))
|
|
376
|
+
except FileNotFoundError:
|
|
377
|
+
# Another process already released this exact file (we lost the
|
|
378
|
+
# race). The inbox is populated / the file is gone -- there is
|
|
379
|
+
# nothing for us to release, so treat it as "already presented".
|
|
380
|
+
# Swallowing this keeps a lost race from crashing the caller,
|
|
381
|
+
# which -- for the supervisor tick -- would otherwise kill the
|
|
382
|
+
# liveness heartbeat.
|
|
383
|
+
return False
|
|
384
|
+
released_id = oldest.name
|
|
385
|
+
log.log_event(cfg, agent_name, "delivered", id=released_id)
|
|
386
|
+
_set_presentations(cfg, agent_name, released_id, 1)
|
|
387
|
+
return True
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def nudge(cfg: SwarmConfig, agent_name: str) -> bool:
|
|
391
|
+
"""Re-inject the protocol into *agent_name*'s pane; return ``paste_into``'s result.
|
|
392
|
+
|
|
393
|
+
The nudge states the agent's EXACT mailbox paths (the model never assumes
|
|
394
|
+
them) and lists the recipients it is allowed to message. The caller ensures
|
|
395
|
+
the agent is idle first; a paste failure just means we retry on the next tick.
|
|
396
|
+
"""
|
|
397
|
+
agent = cfg.get(agent_name)
|
|
398
|
+
mp = cfg.mail_paths(agent)
|
|
399
|
+
allowed = ", ".join(p for p in agent.can_talk_to if p != "system")
|
|
400
|
+
nudge_text = (
|
|
401
|
+
f"You have a new message in {mp.inbox}. Read it and do what it asks.\n"
|
|
402
|
+
f"When you're done, move that file to {mp.read}.\n"
|
|
403
|
+
f"To send a message, write a file into {mp.outbox}/<name>/ "
|
|
404
|
+
f"(read {mp.outbox}/<name>/about.md to see who they are and whether "
|
|
405
|
+
f"they're available). The moment you've written your outgoing mail, your "
|
|
406
|
+
f"TURN IS DONE -- stop and wait; you'll be notified (a new message + nudge) "
|
|
407
|
+
f"when the recipient replies. Do not poll your inbox or wait for the reply "
|
|
408
|
+
f"yourself; that only delays delivery.\n"
|
|
409
|
+
f"You can message: {allowed}."
|
|
410
|
+
)
|
|
411
|
+
try:
|
|
412
|
+
pasted = tmux.paste_into(cfg, agent.session, nudge_text)
|
|
413
|
+
except tmux.SwarmError:
|
|
414
|
+
# Best-effort: if the agent isn't up (or tmux is unavailable) the mail
|
|
415
|
+
# still sits in the queue and gets released on the next sweep / when the
|
|
416
|
+
# agent starts. Never crash a send because a session is missing -- a
|
|
417
|
+
# paste failure just means we retry on the next tick.
|
|
418
|
+
return False
|
|
419
|
+
if pasted:
|
|
420
|
+
# The agent was actually poked about a freshly delivered message, so it
|
|
421
|
+
# is now mid-turn. Mark the turn STARTED so busy-detection is accurate:
|
|
422
|
+
# otherwise (delivered stayed == completed after the first prompt) the
|
|
423
|
+
# supervisor treats a long but legitimate turn as idle, bumps the inbox
|
|
424
|
+
# message's presentation count every tick, and auto-archives it mid-turn
|
|
425
|
+
# -- yanking the task and corrupting the turn. The inbox holds exactly
|
|
426
|
+
# one message (one-at-a-time); its From is the task's origin.
|
|
427
|
+
sender = None
|
|
428
|
+
try:
|
|
429
|
+
msgs = sorted(f for f in mp.inbox.iterdir() if f.is_file())
|
|
430
|
+
if msgs:
|
|
431
|
+
sender = _parse_header_field(msgs[0].read_text(), "From")
|
|
432
|
+
except OSError: # pragma: no cover - defensive only
|
|
433
|
+
sender = None
|
|
434
|
+
turn.mark_turn_started(cfg, agent_name, sender or "system")
|
|
435
|
+
return pasted
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def route_outbound(cfg: SwarmConfig, sender: str, recipient: str, body: str) -> str:
|
|
439
|
+
"""Route one outbound message from *sender* to *recipient*. Returns one of
|
|
440
|
+
``delivered`` / ``bounce`` / ``rate-limited`` / ``user-held``.
|
|
441
|
+
|
|
442
|
+
The orchestrator owns all routing/ACL/state; the model only wrote the file.
|
|
443
|
+
"""
|
|
444
|
+
sender_agent = cfg.get(sender)
|
|
445
|
+
mp = cfg.mail_paths(sender_agent)
|
|
446
|
+
|
|
447
|
+
if recipient == "system":
|
|
448
|
+
# system is never a recipient -- bounce back as mail + drop to failed/.
|
|
449
|
+
system_mail(cfg, sender, "system is not a valid recipient -- your message was not delivered.")
|
|
450
|
+
_move_outbox_file(cfg, sender, "system", body, mp.failed)
|
|
451
|
+
log.log_event(cfg, sender, "bounce", to="system", reason="system-recipient")
|
|
452
|
+
return "bounce"
|
|
453
|
+
|
|
454
|
+
if recipient == "user":
|
|
455
|
+
return deliver_to_user(cfg, sender, body)
|
|
456
|
+
|
|
457
|
+
if recipient not in cfg.get(sender).can_talk_to:
|
|
458
|
+
allowed = ", ".join(x for x in cfg.get(sender).can_talk_to if x != "system")
|
|
459
|
+
system_mail(
|
|
460
|
+
cfg, sender,
|
|
461
|
+
f"Your message to {recipient} couldn't be sent -- you can message: {allowed}.",
|
|
462
|
+
)
|
|
463
|
+
_move_outbox_file(cfg, sender, recipient, body, mp.failed)
|
|
464
|
+
log.log_event(cfg, sender, "bounce", to=recipient, reason="acl")
|
|
465
|
+
return "bounce"
|
|
466
|
+
|
|
467
|
+
if rate_limited(cfg, sender, recipient):
|
|
468
|
+
_move_outbox_file(cfg, sender, recipient, body, mp.failed)
|
|
469
|
+
log.log_event(cfg, sender, "rate-limited", to=recipient)
|
|
470
|
+
return "rate-limited"
|
|
471
|
+
|
|
472
|
+
msg_id = new_message_id()
|
|
473
|
+
text = stamp_message(body, sender, recipient, msg_id)
|
|
474
|
+
enqueue(cfg, recipient, text, msg_id)
|
|
475
|
+
_move_outbox_file(cfg, sender, recipient, body, mp.sent)
|
|
476
|
+
log.log_event(cfg, sender, "route", from_=sender, to=recipient, id=msg_id)
|
|
477
|
+
return "delivered"
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def on_stop(cfg: SwarmConfig, agent_name: str) -> dict:
|
|
481
|
+
"""THE CORE: an agent stopped. Sweep its outbox, route every message, then
|
|
482
|
+
release+nudge every recipient that received mail. Returns a summary dict.
|
|
483
|
+
|
|
484
|
+
The orchestrator owns authoritative state, so even a forgetful model that
|
|
485
|
+
never moves mail to ``read/`` can't wedge the swarm.
|
|
486
|
+
"""
|
|
487
|
+
agent = cfg.get(agent_name)
|
|
488
|
+
mp = cfg.mail_paths(agent)
|
|
489
|
+
|
|
490
|
+
# 1) Snapshot the outbox under a lock, then route OUTSIDE the lock so
|
|
491
|
+
# enqueue() (which takes its own per-recipient lock) can't deadlock.
|
|
492
|
+
pending = []
|
|
493
|
+
with lock.file_lock(cfg, agent_name, "mail"):
|
|
494
|
+
if mp.outbox.exists():
|
|
495
|
+
for sub in sorted(mp.outbox.iterdir()):
|
|
496
|
+
if not sub.is_dir():
|
|
497
|
+
continue
|
|
498
|
+
recipient = sub.name
|
|
499
|
+
# Route in the order the agent WROTE the files (enqueue/delivery
|
|
500
|
+
# order follows this), not by the model's arbitrary filenames --
|
|
501
|
+
# otherwise two messages to the same peer can arrive out of order.
|
|
502
|
+
for f in sorted((c for c in sub.iterdir() if c.is_file()), key=_queue_order):
|
|
503
|
+
# about.md is the orchestrator-maintained contact card, not
|
|
504
|
+
# an outbound message -- never route or delete it.
|
|
505
|
+
if f.name == "about.md":
|
|
506
|
+
continue
|
|
507
|
+
pending.append((recipient, f.read_text(), f))
|
|
508
|
+
|
|
509
|
+
delivered = bounced = rate_limited_count = 0
|
|
510
|
+
recipients: set[str] = set()
|
|
511
|
+
|
|
512
|
+
for recipient, body, f in pending:
|
|
513
|
+
result = route_outbound(cfg, agent_name, recipient, body)
|
|
514
|
+
# route_outbound already moved the file to sent/ or failed/; remove any
|
|
515
|
+
# leftover original so it is never double-routed.
|
|
516
|
+
if f.exists():
|
|
517
|
+
f.unlink()
|
|
518
|
+
if result == "delivered":
|
|
519
|
+
delivered += 1
|
|
520
|
+
if recipient != "user":
|
|
521
|
+
recipients.add(recipient)
|
|
522
|
+
elif result == "user-held":
|
|
523
|
+
delivered += 1
|
|
524
|
+
elif result == "bounce":
|
|
525
|
+
bounced += 1
|
|
526
|
+
recipients.add(agent_name) # the sender gets the bounce as mail
|
|
527
|
+
elif result == "rate-limited":
|
|
528
|
+
rate_limited_count += 1
|
|
529
|
+
|
|
530
|
+
# 2) The turn is finished (clamps busy counters) before we release mail.
|
|
531
|
+
turn.on_turn_finished(cfg, agent_name)
|
|
532
|
+
|
|
533
|
+
# 3) Deliver mail to everyone who received/queued some (including the sender,
|
|
534
|
+
# who may have just been bounced an error). release_next is one-at-a-time
|
|
535
|
+
# and nudge only fires when a message actually landed.
|
|
536
|
+
for r in sorted(recipients):
|
|
537
|
+
if release_next(cfg, r):
|
|
538
|
+
nudge(cfg, r)
|
|
539
|
+
|
|
540
|
+
return {"delivered": delivered, "bounced": bounced, "rate_limited": rate_limited_count}
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def process_read_folder(cfg: SwarmConfig, agent_name: str) -> int:
|
|
544
|
+
"""Process *agent_name*'s ``read/`` folder: emit read receipts and run the
|
|
545
|
+
auto-archive fallback. Returns the number of new read receipts emitted."""
|
|
546
|
+
agent = cfg.get(agent_name)
|
|
547
|
+
mp = cfg.mail_paths(agent)
|
|
548
|
+
read_dir = mp.read
|
|
549
|
+
state = _load_run_json(cfg, f"{agent_name}.read.json")
|
|
550
|
+
processed = set(state.get("processed", []))
|
|
551
|
+
count = 0
|
|
552
|
+
|
|
553
|
+
if read_dir.exists():
|
|
554
|
+
for f in sorted(read_dir.iterdir()):
|
|
555
|
+
if not f.is_file():
|
|
556
|
+
continue
|
|
557
|
+
msg_id = _parse_header_field(f.read_text(), "Id")
|
|
558
|
+
if msg_id is None or msg_id in processed:
|
|
559
|
+
continue
|
|
560
|
+
sender = _parse_header_field(f.read_text(), "From") or "system"
|
|
561
|
+
mark_read(cfg, sender, msg_id)
|
|
562
|
+
processed.add(msg_id)
|
|
563
|
+
log.log_event(cfg, agent_name, "read", id=msg_id)
|
|
564
|
+
count += 1
|
|
565
|
+
|
|
566
|
+
state["processed"] = sorted(processed)
|
|
567
|
+
_save_run_json(cfg, f"{agent_name}.read.json", state)
|
|
568
|
+
|
|
569
|
+
# Auto-archive fallback (plan §7): a single message presented >= N times
|
|
570
|
+
# without being handled is moved to the archive so a forgetful model can
|
|
571
|
+
# never wedge the swarm. We only ARCHIVE here (empty the inbox); we do NOT
|
|
572
|
+
# release the next message, because the release must be paired with a nudge
|
|
573
|
+
# -- and every caller (supervisor tick, cmd_idle) runs release_next + nudge
|
|
574
|
+
# right after us. Releasing here (un-nudged) meant the freshly delivered
|
|
575
|
+
# message was never announced to the agent, so it silently climbed to its
|
|
576
|
+
# own auto-archive threshold and was discarded unread -- draining the whole
|
|
577
|
+
# queue into the archive. Leaving the inbox empty lets the caller's
|
|
578
|
+
# release+nudge deliver-and-announce the next message like any other.
|
|
579
|
+
inbox = mp.inbox
|
|
580
|
+
if inbox.exists():
|
|
581
|
+
msgs = sorted(f for f in inbox.iterdir() if f.is_file())
|
|
582
|
+
if len(msgs) == 1:
|
|
583
|
+
f = msgs[0]
|
|
584
|
+
pres = _get_presentations(cfg, agent_name)
|
|
585
|
+
# `processed` holds message CONTENT ids ("m-arc2"), so the
|
|
586
|
+
# "already handled" guard must compare the inbox message's own
|
|
587
|
+
# content id -- NOT f.name ("m-arc2.txt"), which never matches and
|
|
588
|
+
# would let a handled message be archived anyway.
|
|
589
|
+
inbox_id = _parse_header_field(f.read_text(), "Id")
|
|
590
|
+
if (
|
|
591
|
+
pres.get("msg_id") == f.name
|
|
592
|
+
and pres.get("count", 0) >= AUTO_ARCHIVE_PRESENTATIONS
|
|
593
|
+
and inbox_id not in processed
|
|
594
|
+
):
|
|
595
|
+
log.archive_message(cfg, agent_name, f)
|
|
596
|
+
|
|
597
|
+
return count
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def maybe_ping(cfg: SwarmConfig, agent_name: str) -> bool:
|
|
601
|
+
"""Inject a periodic ``system`` ping into *agent_name*'s queue, respecting
|
|
602
|
+
the three §10 guards. Returns True if a ping was injected.
|
|
603
|
+
|
|
604
|
+
Guards: (a) idle-only -- skip if busy; (b) no-pile-up -- skip if an unhandled
|
|
605
|
+
ping marker already sits in the queue/inbox; (c) cadence-is-minimum -- skip
|
|
606
|
+
unless ``periodically_ping_seconds`` has elapsed since the last ping.
|
|
607
|
+
"""
|
|
608
|
+
agent = cfg.get(agent_name)
|
|
609
|
+
if agent.periodically_ping_seconds <= 0:
|
|
610
|
+
return False
|
|
611
|
+
if turn.busy_info(cfg, agent) is not None:
|
|
612
|
+
return False
|
|
613
|
+
|
|
614
|
+
# (b) no-pile-up: a still-unhandled ping is a file whose name starts with the marker.
|
|
615
|
+
queue = cfg.queue_dir / agent_name
|
|
616
|
+
inbox = cfg.mail_paths(agent).inbox
|
|
617
|
+
for d in (queue, inbox):
|
|
618
|
+
if d.exists():
|
|
619
|
+
for f in d.iterdir():
|
|
620
|
+
if f.name.startswith(PING_MARKER):
|
|
621
|
+
return False
|
|
622
|
+
|
|
623
|
+
# (c) cadence-is-minimum.
|
|
624
|
+
state = _load_run_json(cfg, f"{agent_name}.ping.json")
|
|
625
|
+
last = state.get("last_ping", 0.0)
|
|
626
|
+
now = time.time()
|
|
627
|
+
if now - last < agent.periodically_ping_seconds:
|
|
628
|
+
return False
|
|
629
|
+
|
|
630
|
+
msg_id = PING_MARKER + uuid.uuid4().hex[:8]
|
|
631
|
+
text = stamp_message(agent.periodically_ping_message, "system", agent_name, msg_id)
|
|
632
|
+
enqueue(cfg, agent_name, text, msg_id)
|
|
633
|
+
state["last_ping"] = now
|
|
634
|
+
_save_run_json(cfg, f"{agent_name}.ping.json", state)
|
|
635
|
+
log.log_event(cfg, agent_name, "ping")
|
|
636
|
+
return True
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def deliver_to_user(cfg: SwarmConfig, sender: str, body: str) -> str:
|
|
640
|
+
"""Deliver *sender*'s message into the virtual ``user`` mailbox.
|
|
641
|
+
|
|
642
|
+
Returns ``delivered`` (user available), ``user-held`` (user away -- mail is
|
|
643
|
+
held, never bounced, and a ``system`` ack is dropped into the sender), or
|
|
644
|
+
``bounce`` (sender not allowed to message the user). The sender's outbox
|
|
645
|
+
file is moved to ``sent/`` -- the send itself always succeeds; only the
|
|
646
|
+
human's reply is deferred.
|
|
647
|
+
"""
|
|
648
|
+
mp = cfg.mail_paths(cfg.get(sender))
|
|
649
|
+
if "user" not in cfg.get(sender).can_talk_to:
|
|
650
|
+
system_mail(cfg, sender, "You can't message the user -- they're not in your can_talk_to.")
|
|
651
|
+
_move_outbox_file(cfg, sender, "user", body, mp.failed)
|
|
652
|
+
log.log_event(cfg, sender, "bounce", to="user", reason="acl")
|
|
653
|
+
return "bounce"
|
|
654
|
+
|
|
655
|
+
msg_id = new_message_id()
|
|
656
|
+
text = stamp_message(body, sender, "user", msg_id)
|
|
657
|
+
enqueue(cfg, "user", text, msg_id)
|
|
658
|
+
_move_outbox_file(cfg, sender, "user", body, mp.sent)
|
|
659
|
+
if cfg.user_available:
|
|
660
|
+
log.log_event(cfg, sender, "delivered", to="user", id=msg_id)
|
|
661
|
+
return "delivered"
|
|
662
|
+
system_mail(cfg, sender, "Delivered -- the user is away and may respond later.")
|
|
663
|
+
log.log_event(cfg, sender, "user-held", to="user", id=msg_id)
|
|
664
|
+
return "user-held"
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def send_as_user(cfg: SwarmConfig, to_agent: str, body: str) -> None:
|
|
668
|
+
"""The human/UI sends a message FROM ``user`` to *to_agent*."""
|
|
669
|
+
msg_id = new_message_id()
|
|
670
|
+
text = stamp_message(body, "user", to_agent, msg_id)
|
|
671
|
+
enqueue(cfg, to_agent, text, msg_id)
|
|
672
|
+
log.log_event(cfg, to_agent, "user-send", from_="user", id=msg_id)
|
|
673
|
+
if release_next(cfg, to_agent):
|
|
674
|
+
nudge(cfg, to_agent)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def system_mail(cfg: SwarmConfig, to_agent: str, body: str, *, kind: str = "system") -> None:
|
|
678
|
+
"""Enqueue a ``system`` message (From: system) into *to_agent*'s queue.
|
|
679
|
+
|
|
680
|
+
Used for bounces, acks, and periodic pings -- errors come back as mail so
|
|
681
|
+
the model self-corrects in-band.
|
|
682
|
+
"""
|
|
683
|
+
msg_id = new_message_id()
|
|
684
|
+
text = stamp_message(body, "system", to_agent, msg_id)
|
|
685
|
+
enqueue(cfg, to_agent, text, msg_id)
|
|
686
|
+
log.log_event(cfg, to_agent, kind, from_="system")
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def set_user_available(cfg: SwarmConfig, available: bool) -> None:
|
|
690
|
+
"""Set the user's availability toggle and rewrite the live ``user`` contact cards."""
|
|
691
|
+
cfg.user_available = available
|
|
692
|
+
for b in cfg.agents:
|
|
693
|
+
if "user" in b.can_talk_to:
|
|
694
|
+
mp = cfg.mail_paths(b)
|
|
695
|
+
card = mp.outbox / "user" / "about.md"
|
|
696
|
+
card.parent.mkdir(parents=True, exist_ok=True)
|
|
697
|
+
status = "available" if available else "away"
|
|
698
|
+
card.write_text(f"Name: user\nRole: human operator\nStatus: {status}\n")
|
|
699
|
+
log.log_event(cfg, "user", "user-available" if available else "user-away")
|