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/tmux.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Agentainer -- the paste/capture layer that talks to a live tmux TUI.
|
|
3
|
+
|
|
4
|
+
These functions type prompts into an agent's tmux pane, score whether the
|
|
5
|
+
paste actually landed, capture the pane, and wait for the agent's input box
|
|
6
|
+
to be live. Everything here is deterministic orchestrator code; the model
|
|
7
|
+
never touches tmux itself. See ``ProjectPlan.md`` §4-§11.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import contextlib
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import tempfile
|
|
19
|
+
import time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
try: # POSIX only, which is fine: tmux is too.
|
|
23
|
+
import fcntl
|
|
24
|
+
except ImportError: # pragma: no cover
|
|
25
|
+
fcntl = None # type: ignore
|
|
26
|
+
|
|
27
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
28
|
+
|
|
29
|
+
import config as cfgmod # noqa: E402
|
|
30
|
+
from config import Agent, ConfigError, SwarmConfig # noqa: E402
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# --------------------------------------------------------------------------
|
|
34
|
+
# small utilities
|
|
35
|
+
# --------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SwarmError(Exception):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def sleep_ms(ms: int) -> None:
|
|
43
|
+
if ms > 0:
|
|
44
|
+
time.sleep(ms / 1000.0)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# --------------------------------------------------------------------------
|
|
48
|
+
# tmux
|
|
49
|
+
# --------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def tmux(*args: str, check: bool = True, capture: bool = False) -> subprocess.CompletedProcess:
|
|
53
|
+
if not shutil.which("tmux"):
|
|
54
|
+
raise SwarmError("tmux is not installed or not on PATH")
|
|
55
|
+
return subprocess.run(
|
|
56
|
+
["tmux", *args],
|
|
57
|
+
check=check,
|
|
58
|
+
text=True,
|
|
59
|
+
stdout=subprocess.PIPE if capture else None,
|
|
60
|
+
stderr=subprocess.PIPE if capture else None,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
LOCK_TIMEOUT_S = 180
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@contextlib.contextmanager
|
|
68
|
+
def file_lock(cfg: SwarmConfig, name: str, what: str = "lock"):
|
|
69
|
+
"""An advisory cross-process lock, used to serialise access to one pane/queue.
|
|
70
|
+
|
|
71
|
+
Lock ordering, to keep it deadlock-free: queue -> pane -> turn state.
|
|
72
|
+
"""
|
|
73
|
+
if fcntl is None: # pragma: no cover
|
|
74
|
+
yield
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
cfg.run_dir.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
handle = open(cfg.run_dir / f"{name}.{what}", "w")
|
|
79
|
+
deadline = time.monotonic() + LOCK_TIMEOUT_S
|
|
80
|
+
locked = False
|
|
81
|
+
try:
|
|
82
|
+
while True:
|
|
83
|
+
try:
|
|
84
|
+
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
85
|
+
locked = True
|
|
86
|
+
break
|
|
87
|
+
except OSError:
|
|
88
|
+
if time.monotonic() > deadline:
|
|
89
|
+
warn(f"{name}: timed out waiting for the {what}; proceeding anyway")
|
|
90
|
+
break
|
|
91
|
+
time.sleep(0.05)
|
|
92
|
+
yield
|
|
93
|
+
finally:
|
|
94
|
+
if locked:
|
|
95
|
+
with contextlib.suppress(OSError):
|
|
96
|
+
fcntl.flock(handle, fcntl.LOCK_UN)
|
|
97
|
+
handle.close()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def pane_lock(cfg: SwarmConfig, session: str):
|
|
101
|
+
"""Serialise everything that types into one pane.
|
|
102
|
+
|
|
103
|
+
A paste and the Enter that submits it are two separate tmux calls. Without a
|
|
104
|
+
lock, a second sender -- another agent, or one of several subagents running
|
|
105
|
+
in parallel inside the same agent -- can paste in between them, so one Enter
|
|
106
|
+
submits two concatenated messages and the other submits nothing.
|
|
107
|
+
|
|
108
|
+
The busy check and the "mark this agent busy" write also happen under this
|
|
109
|
+
lock, so two concurrent senders cannot both observe an idle agent and both
|
|
110
|
+
deliver to it.
|
|
111
|
+
|
|
112
|
+
The lock is per recipient, so unrelated agents are still messaged in parallel.
|
|
113
|
+
"""
|
|
114
|
+
return file_lock(cfg, session, "pane.lock")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def warn(msg: str) -> None: # pragma: no cover - thin stderr helper exercised elsewhere
|
|
118
|
+
print(f"\033[33m!!\033[0m {msg}", file=sys.stderr)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def configure_tmux(cfg: SwarmConfig) -> str | None:
|
|
122
|
+
"""Set the globals the agentainer's panes inherit, before any agent pane is created.
|
|
123
|
+
|
|
124
|
+
history-limit is only consulted when a pane is spawned, and the default (2000
|
|
125
|
+
lines) is far too small to hold a long multi-agent conversation -- the user
|
|
126
|
+
attaches, tries to scroll up, and the early messages are already gone. mouse
|
|
127
|
+
mode lets the wheel scroll that backlog.
|
|
128
|
+
|
|
129
|
+
Both are global options, but a tmux server with no sessions exits immediately,
|
|
130
|
+
so `set -g` on a cold server (the normal state at `up`) does nothing. We hold
|
|
131
|
+
the server up with a throwaway session while setting them; the agent panes
|
|
132
|
+
created afterwards inherit the values. Returns the holder session name so the
|
|
133
|
+
caller can tear it down once real sessions keep the server alive; None if there
|
|
134
|
+
was nothing to configure. Best effort throughout: never block the agentainer
|
|
135
|
+
coming up.
|
|
136
|
+
"""
|
|
137
|
+
if cfg.tmux_history_limit <= 0 and not cfg.tmux_mouse:
|
|
138
|
+
return None
|
|
139
|
+
holder = f"{cfg.session_prefix}agentainer_setup"
|
|
140
|
+
tmux("new-session", "-d", "-s", holder, "sleep 86400", check=False)
|
|
141
|
+
if cfg.tmux_history_limit > 0:
|
|
142
|
+
tmux("set-option", "-g", "history-limit", str(cfg.tmux_history_limit), check=False)
|
|
143
|
+
if cfg.tmux_mouse:
|
|
144
|
+
tmux("set-option", "-g", "mouse", "on", check=False)
|
|
145
|
+
return holder
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def session_exists(session: str) -> bool:
|
|
149
|
+
try:
|
|
150
|
+
tmux("has-session", "-t", f"={session}", capture=True)
|
|
151
|
+
return True
|
|
152
|
+
except subprocess.CalledProcessError:
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
PASTE_ATTEMPTS = 2
|
|
157
|
+
VERIFY_TIMEOUT_MS = 3000
|
|
158
|
+
VERIFY_SCROLLBACK = 200
|
|
159
|
+
NEEDLE_LEN = 28
|
|
160
|
+
# Both CLIs collapse a long paste into a chip instead of showing the text:
|
|
161
|
+
# claude -> "[Pasted text #1 +36 lines]"
|
|
162
|
+
# codex -> "[Pasted Content 2580 chars]"
|
|
163
|
+
# Whitespace is stripped before matching, so this sees "Pastedtext" / "PastedContent".
|
|
164
|
+
PASTE_CHIP_RE = re.compile(r"pasted(?:text|content)", re.IGNORECASE)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def pane_text(session: str, scrollback: int = 0) -> str:
|
|
168
|
+
args = ["capture-pane", "-p", "-t", session]
|
|
169
|
+
if scrollback:
|
|
170
|
+
args[2:2] = ["-S", f"-{scrollback}"]
|
|
171
|
+
try:
|
|
172
|
+
return tmux(*args, capture=True).stdout or ""
|
|
173
|
+
except subprocess.CalledProcessError:
|
|
174
|
+
return ""
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def needle_for(body: str) -> str:
|
|
178
|
+
"""The *tail* of the text, which is what stays on screen after a paste.
|
|
179
|
+
|
|
180
|
+
Using the head would be wrong: a long prompt pushes its own first line out
|
|
181
|
+
of the pane, and the cursor -- hence the visible end of the text -- sits at
|
|
182
|
+
the bottom.
|
|
183
|
+
"""
|
|
184
|
+
return normalise(body)[-NEEDLE_LEN:]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def paste_score(session: str, needle: str) -> int:
|
|
188
|
+
"""How many times the text we are about to send already appears on screen."""
|
|
189
|
+
pane = normalise(pane_text(session, VERIFY_SCROLLBACK))
|
|
190
|
+
return pane.count(needle) + len(PASTE_CHIP_RE.findall(pane))
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def send_buffer(session: str, body: str) -> None:
|
|
194
|
+
buf = f"agentainer-{os.getpid()}-{int(time.time() * 1000)}"
|
|
195
|
+
with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as fh:
|
|
196
|
+
fh.write(body)
|
|
197
|
+
tmp = fh.name
|
|
198
|
+
try:
|
|
199
|
+
tmux("load-buffer", "-b", buf, tmp)
|
|
200
|
+
tmux("paste-buffer", "-b", buf, "-d", "-p", "-t", session)
|
|
201
|
+
finally:
|
|
202
|
+
os.unlink(tmp)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def paste_into(
|
|
206
|
+
cfg: SwarmConfig, session: str, text: str, enter: bool = True, needle: str | None = None
|
|
207
|
+
) -> bool:
|
|
208
|
+
"""Type *text* into a session's pane, confirm it arrived, then press Enter.
|
|
209
|
+
|
|
210
|
+
Bracketed paste (``paste-buffer -p``) lets a multi-line prompt land in the
|
|
211
|
+
agent's input box as one block instead of being submitted line by line.
|
|
212
|
+
|
|
213
|
+
Confirming matters: a TUI can silently discard keystrokes while it is still
|
|
214
|
+
starting up, and Claude Code does exactly that for several seconds partway
|
|
215
|
+
through boot. So we compare the pane before and after the paste, retry if
|
|
216
|
+
the text never showed up, and only press Enter once it has -- which also
|
|
217
|
+
means a retry cannot submit a half-delivered prompt.
|
|
218
|
+
"""
|
|
219
|
+
body = text.rstrip("\n")
|
|
220
|
+
if not body:
|
|
221
|
+
return False
|
|
222
|
+
if not session_exists(session):
|
|
223
|
+
raise SwarmError(f"tmux session {session!r} is not running")
|
|
224
|
+
|
|
225
|
+
with pane_lock(cfg, session):
|
|
226
|
+
return _paste_locked(cfg, session, body, enter, needle)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# Named control keys the UI may send straight into a pane. Restricting to a
|
|
230
|
+
# whitelist keeps `send-keys` from being handed an arbitrary token; these are
|
|
231
|
+
# tmux key names (see `man tmux`, KEY BINDINGS).
|
|
232
|
+
ALLOWED_KEYS = {
|
|
233
|
+
"Escape", "Enter", "Tab", "BSpace", "Space",
|
|
234
|
+
"Up", "Down", "Left", "Right",
|
|
235
|
+
"Home", "End", "PageUp", "PageDown",
|
|
236
|
+
"C-c", "C-d", "C-u", "C-l", "C-a", "C-e", "C-r", "C-z",
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def send_key(cfg: SwarmConfig, session: str, key: str) -> bool:
|
|
241
|
+
"""Send a single named control key (e.g. ``Escape``, ``C-c``) to a pane.
|
|
242
|
+
|
|
243
|
+
Unlike :func:`paste_into` this does no echo verification -- control keys
|
|
244
|
+
leave nothing in the input box to confirm against -- it just presses the key.
|
|
245
|
+
"""
|
|
246
|
+
if key not in ALLOWED_KEYS:
|
|
247
|
+
raise SwarmError(f"key {key!r} is not allowed")
|
|
248
|
+
if not session_exists(session):
|
|
249
|
+
raise SwarmError(f"tmux session {session!r} is not running")
|
|
250
|
+
with pane_lock(cfg, session):
|
|
251
|
+
tmux("send-keys", "-t", session, key)
|
|
252
|
+
return True
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _paste_locked(
|
|
256
|
+
cfg: SwarmConfig, session: str, body: str, enter: bool, needle: str | None = None
|
|
257
|
+
) -> bool:
|
|
258
|
+
needle = needle or needle_for(body)
|
|
259
|
+
delivered = False
|
|
260
|
+
|
|
261
|
+
for attempt in range(1, PASTE_ATTEMPTS + 1):
|
|
262
|
+
if attempt > 1:
|
|
263
|
+
# Best effort: clear anything a previous attempt may have left behind,
|
|
264
|
+
# so a retry cannot concatenate two copies of the prompt.
|
|
265
|
+
tmux("send-keys", "-t", session, "C-u", check=False)
|
|
266
|
+
sleep_ms(300)
|
|
267
|
+
|
|
268
|
+
before = paste_score(session, needle)
|
|
269
|
+
sleep_ms(cfg.send_delay_ms)
|
|
270
|
+
send_buffer(session, body)
|
|
271
|
+
|
|
272
|
+
deadline = time.monotonic() + VERIFY_TIMEOUT_MS / 1000.0
|
|
273
|
+
while time.monotonic() < deadline:
|
|
274
|
+
sleep_ms(200)
|
|
275
|
+
if paste_score(session, needle) > before:
|
|
276
|
+
delivered = True
|
|
277
|
+
break
|
|
278
|
+
if delivered:
|
|
279
|
+
break
|
|
280
|
+
warn(f"{session}: pasted text never appeared (attempt {attempt}/{PASTE_ATTEMPTS})")
|
|
281
|
+
|
|
282
|
+
if not delivered:
|
|
283
|
+
# Do not press Enter: if the text did arrive and we simply failed to see
|
|
284
|
+
# it, submitting now could send a mangled or duplicated prompt.
|
|
285
|
+
warn(f"{session}: could not confirm the text arrived; NOT pressing Enter")
|
|
286
|
+
return False
|
|
287
|
+
|
|
288
|
+
if enter:
|
|
289
|
+
sleep_ms(cfg.enter_delay_ms)
|
|
290
|
+
tmux("send-keys", "-t", session, "Enter")
|
|
291
|
+
return True
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# --------------------------------------------------------------------------
|
|
295
|
+
# readiness probe
|
|
296
|
+
# --------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
READY_TOKEN = "zqxswarmready"
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def normalise(text: str) -> str:
|
|
303
|
+
"""Strip all whitespace, so a TUI's line-wrapping cannot hide a needle."""
|
|
304
|
+
return re.sub(r"\s+", "", text)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def visible_pane(session: str) -> str:
|
|
308
|
+
try:
|
|
309
|
+
return tmux("capture-pane", "-p", "-t", session, capture=True).stdout or ""
|
|
310
|
+
except subprocess.CalledProcessError:
|
|
311
|
+
return ""
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def clear_token(session: str) -> None:
|
|
315
|
+
"""Backspace the readiness token back out of the composer."""
|
|
316
|
+
for _ in range(6):
|
|
317
|
+
count = normalise(visible_pane(session)).count(READY_TOKEN)
|
|
318
|
+
if not count:
|
|
319
|
+
return
|
|
320
|
+
tmux("send-keys", "-t", session, *(["BSpace"] * (count * len(READY_TOKEN))))
|
|
321
|
+
sleep_ms(300)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def wait_until_ready(cfg: SwarmConfig, agent: Agent) -> bool:
|
|
325
|
+
"""Block until the agent's TUI is actually accepting keystrokes.
|
|
326
|
+
|
|
327
|
+
A fixed sleep is not enough: Claude Code, for instance, discards input for
|
|
328
|
+
several seconds partway through its startup, and a prompt typed into that
|
|
329
|
+
window is silently lost. So we type a throwaway token until the TUI echoes
|
|
330
|
+
it back -- proof that its input box is live -- then erase it. Nothing is
|
|
331
|
+
ever submitted, because Enter is never sent.
|
|
332
|
+
"""
|
|
333
|
+
deadline = time.monotonic() + cfg.ready_timeout_ms / 1000.0
|
|
334
|
+
with pane_lock(cfg, agent.session):
|
|
335
|
+
while time.monotonic() < deadline:
|
|
336
|
+
tmux("send-keys", "-t", agent.session, "-l", READY_TOKEN, check=False)
|
|
337
|
+
sleep_ms(600)
|
|
338
|
+
if READY_TOKEN in normalise(visible_pane(agent.session)):
|
|
339
|
+
clear_token(agent.session)
|
|
340
|
+
sleep_ms(cfg.send_delay_ms)
|
|
341
|
+
return True
|
|
342
|
+
if not session_exists(agent.session):
|
|
343
|
+
return False
|
|
344
|
+
return False
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def capture_pane(cfg: SwarmConfig, agent: Agent) -> str:
|
|
348
|
+
try:
|
|
349
|
+
result = tmux(
|
|
350
|
+
"capture-pane", "-p", "-J", "-S", f"-{cfg.pane_scrollback}", "-t", agent.session,
|
|
351
|
+
capture=True,
|
|
352
|
+
)
|
|
353
|
+
except subprocess.CalledProcessError:
|
|
354
|
+
return ""
|
|
355
|
+
return result.stdout or ""
|
package/lib/turn.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Agentainer -- per-agent turn-state machinery (ported from v1 swarm.py).
|
|
3
|
+
|
|
4
|
+
v1 learned that the turn-completion signal is the system clock: when an agent
|
|
5
|
+
stops, the orchestrator sweeps its outbox, releases the next queued message, and
|
|
6
|
+
re-injects the protocol on a nudge. This module is the durable record of "is
|
|
7
|
+
*agent* mid-task right now", persisted as JSON at
|
|
8
|
+
``cfg.run_dir / f"{agent}.turn.json"`` holding ``{delivered, completed, since, by}``.
|
|
9
|
+
|
|
10
|
+
We port v1's proven turns helpers verbatim and add two v2 entry points:
|
|
11
|
+
|
|
12
|
+
* ``on_turn_finished`` -- the hook/notify entry point the CLI ``hook`` command
|
|
13
|
+
calls; it marks the turn done and returns the new state so the mailroom can
|
|
14
|
+
sweep.
|
|
15
|
+
* ``health_probe`` -- the v2 "silent-but-alive" probe (plan §8 / D17) that
|
|
16
|
+
surfaces agents whose tmux session is alive but whose turn-completion signal
|
|
17
|
+
the orchestrator cannot trust -- something v1's supervisor could not catch.
|
|
18
|
+
|
|
19
|
+
Zero runtime dependencies: stdlib + the bundled lib/ modules only.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import sys
|
|
26
|
+
import time
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
try: # POSIX only, which is fine: tmux is too.
|
|
30
|
+
import fcntl # noqa: F401 - kept for parity; lock guards the real path
|
|
31
|
+
except ImportError: # pragma: no cover
|
|
32
|
+
fcntl = None # type: ignore
|
|
33
|
+
|
|
34
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
35
|
+
|
|
36
|
+
import config as cfgmod # noqa: E402
|
|
37
|
+
from config import Agent, SwarmConfig # noqa: E402
|
|
38
|
+
from lock import file_lock # noqa: E402
|
|
39
|
+
from tmux import session_exists, warn # noqa: E402
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def turn_state(cfg: SwarmConfig, agent: str) -> dict:
|
|
43
|
+
"""Return the persisted turn state for *agent* (a name string).
|
|
44
|
+
|
|
45
|
+
A missing or corrupt file yields the empty default, so every reader sees a
|
|
46
|
+
complete record regardless of whether the agent has ever been messaged.
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
return json.loads((cfg.run_dir / f"{agent}.turn.json").read_text())
|
|
50
|
+
except (OSError, json.JSONDecodeError):
|
|
51
|
+
return {"delivered": 0, "completed": 0, "since": 0, "by": None}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def write_turn_state(cfg: SwarmConfig, agent: str, state: dict) -> None:
|
|
55
|
+
"""Persist *state* for *agent* (a name string) as JSON."""
|
|
56
|
+
cfg.run_dir.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
(cfg.run_dir / f"{agent}.turn.json").write_text(json.dumps(state))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def busy_info(cfg: SwarmConfig, agent: Agent) -> dict | None:
|
|
61
|
+
"""Return the turn state if *agent* is mid-task, else None. Not locked."""
|
|
62
|
+
if not agent.busy_check:
|
|
63
|
+
return None
|
|
64
|
+
state = turn_state(cfg, agent.name)
|
|
65
|
+
if state.get("delivered", 0) <= state.get("completed", 0):
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
age_ms = (time.time() - state.get("since", 0)) * 1000
|
|
69
|
+
if age_ms > cfg.busy_timeout_ms:
|
|
70
|
+
# The hook never fired -- crashed agent, killed CLI, capture misconfigured.
|
|
71
|
+
# Fail open rather than wedge the swarm forever.
|
|
72
|
+
warn(
|
|
73
|
+
f"{agent.name}: has looked busy for {int(age_ms / 1000)}s "
|
|
74
|
+
f"(over busy_timeout_ms); treating it as idle"
|
|
75
|
+
)
|
|
76
|
+
return None
|
|
77
|
+
state["age_s"] = int(age_ms / 1000)
|
|
78
|
+
return state
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def mark_turn_started(cfg: SwarmConfig, agent: str, sender: str) -> None:
|
|
82
|
+
"""Record that another message (from *sender*) was just delivered to *agent*.
|
|
83
|
+
|
|
84
|
+
Locked with the same ``turn.lock`` as :func:`mark_turn_finished`: this is a
|
|
85
|
+
read-modify-write of ``delivered``, and the completion hook (a separate
|
|
86
|
+
process) clamps ``completed`` concurrently. Without the shared lock the two
|
|
87
|
+
can interleave and lose an update, leaving an agent wedged "busy" or -- worse
|
|
88
|
+
-- looking idle while a message is still in flight.
|
|
89
|
+
"""
|
|
90
|
+
with file_lock(cfg, agent, "turn.lock"):
|
|
91
|
+
state = turn_state(cfg, agent)
|
|
92
|
+
state["delivered"] = state.get("delivered", 0) + 1
|
|
93
|
+
state["since"] = time.time()
|
|
94
|
+
state["by"] = sender
|
|
95
|
+
write_turn_state(cfg, agent, state)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def mark_turn_finished(cfg: SwarmConfig, agent: str) -> None:
|
|
99
|
+
"""The agent finished a turn: everything submitted so far is consumed.
|
|
100
|
+
|
|
101
|
+
Clamping (rather than incrementing) keeps the counters from drifting when a
|
|
102
|
+
CLI folds a queued message into the turn already running -- codex does this,
|
|
103
|
+
printing "messages to be submitted after next tool call". Drift would leave
|
|
104
|
+
an agent permanently "busy".
|
|
105
|
+
"""
|
|
106
|
+
with file_lock(cfg, agent, "turn.lock"):
|
|
107
|
+
state = turn_state(cfg, agent)
|
|
108
|
+
state["completed"] = state.get("delivered", 0)
|
|
109
|
+
write_turn_state(cfg, agent, state)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def busy_message(cfg: SwarmConfig, agent: Agent, state: dict) -> str:
|
|
113
|
+
"""Format a "still busy" reply (from the ``system`` mailbox) for *agent*."""
|
|
114
|
+
by = state.get("by") or "someone"
|
|
115
|
+
return (
|
|
116
|
+
f"{agent.name} is busy right now (working for {state['age_s']}s on a task "
|
|
117
|
+
f"from {by}). Please try again after some time, or put your message in the "
|
|
118
|
+
f"queue and wait for the answer:\n"
|
|
119
|
+
f" agentainer send --to {agent.name} --queue \"...\" "
|
|
120
|
+
f"# delivered automatically when {agent.name} is free\n"
|
|
121
|
+
f" agentainer send --to {agent.name} --wait \"...\" "
|
|
122
|
+
f"# block here until {agent.name} is free\n"
|
|
123
|
+
f"Meanwhile you are free to do other work."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def on_turn_finished(cfg: SwarmConfig, agent: str) -> dict:
|
|
128
|
+
"""Hook/notify entry point: mark *agent*'s turn done and return its state.
|
|
129
|
+
|
|
130
|
+
The CLI ``hook`` command calls this, then triggers the mailroom sweep. We
|
|
131
|
+
return the resulting turn-state dict so the caller can act on it (e.g. log
|
|
132
|
+
the event) without re-reading it.
|
|
133
|
+
"""
|
|
134
|
+
mark_turn_finished(cfg, agent)
|
|
135
|
+
return turn_state(cfg, agent)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def health_probe(cfg: SwarmConfig, agent: Agent) -> dict:
|
|
139
|
+
"""The v2 per-agent "silent-but-alive" probe (plan §8 / D17).
|
|
140
|
+
|
|
141
|
+
Returns a snapshot the supervisor can use to surface an agent that is alive
|
|
142
|
+
in tmux yet whose turn-completion signal the orchestrator cannot trust:
|
|
143
|
+
|
|
144
|
+
* ``session_alive`` -- tmux.session_exists(agent.session)
|
|
145
|
+
* ``capture`` -- agent.capture
|
|
146
|
+
* ``busy`` -- busy_info(cfg, agent) is not None
|
|
147
|
+
* ``idle_for_ms`` -- now - since in ms when busy, else 0
|
|
148
|
+
* ``silent_but_alive``-- session_alive AND agent.capture == "none": the
|
|
149
|
+
session is up but we have no reliable turn-completion signal, so we
|
|
150
|
+
cannot tell when (or whether) a turn actually ended.
|
|
151
|
+
"""
|
|
152
|
+
session_alive = session_exists(agent.session)
|
|
153
|
+
state = busy_info(cfg, agent)
|
|
154
|
+
busy = state is not None
|
|
155
|
+
if busy:
|
|
156
|
+
idle_for_ms = int((time.time() - state.get("since", 0)) * 1000)
|
|
157
|
+
else:
|
|
158
|
+
idle_for_ms = 0
|
|
159
|
+
capture = agent.capture
|
|
160
|
+
silent_but_alive = session_alive and capture == "none"
|
|
161
|
+
return {
|
|
162
|
+
"session_alive": session_alive,
|
|
163
|
+
"capture": capture,
|
|
164
|
+
"busy": busy,
|
|
165
|
+
"idle_for_ms": idle_for_ms,
|
|
166
|
+
"silent_but_alive": silent_but_alive,
|
|
167
|
+
}
|