agentainer 0.1.3 → 0.1.5
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/LICENSE +21 -0
- package/README.md +156 -33
- package/examples/brainstorm.yaml +147 -0
- package/examples/code-review-broadcast.yaml +109 -0
- package/examples/debate.yaml +99 -0
- package/examples/existing-repo.yaml +2 -0
- package/examples/incident-response.yaml +140 -0
- package/examples/localization.yaml +136 -0
- package/examples/multi-language-broadcast.yaml +127 -0
- package/examples/ping-pong.yaml +89 -0
- package/examples/red-team.yaml +117 -0
- package/examples/research-swarm.yaml +9 -0
- package/examples/software-company.yaml +16 -1
- package/examples/tdd-pingpong.yaml +82 -0
- package/examples/writers-room.yaml +129 -0
- package/lib/config.py +22 -2
- package/lib/minyaml.py +10 -7
- package/lib/swarm.py +172 -7
- package/llms.txt +26 -0
- package/package.json +10 -3
package/lib/swarm.py
CHANGED
|
@@ -384,10 +384,6 @@ def pane_text(session: str, scrollback: int = 0) -> str:
|
|
|
384
384
|
return ""
|
|
385
385
|
|
|
386
386
|
|
|
387
|
-
def visible_pane(session: str) -> str:
|
|
388
|
-
return pane_text(session)
|
|
389
|
-
|
|
390
|
-
|
|
391
387
|
def needle_for(body: str) -> str:
|
|
392
388
|
"""The *tail* of the text, which is what stays on screen after a paste.
|
|
393
389
|
|
|
@@ -1318,6 +1314,131 @@ def watcher_alive(cfg: SwarmConfig, agent: Agent) -> bool:
|
|
|
1318
1314
|
return False
|
|
1319
1315
|
|
|
1320
1316
|
|
|
1317
|
+
# --------------------------------------------------------------------------
|
|
1318
|
+
# supervisor: the heartbeat the orchestrator otherwise lacks
|
|
1319
|
+
# --------------------------------------------------------------------------
|
|
1320
|
+
|
|
1321
|
+
SUPERVISOR_PID = "supervisor.pid"
|
|
1322
|
+
|
|
1323
|
+
|
|
1324
|
+
def start_supervisor(cfg: SwarmConfig, names: list[str]) -> None:
|
|
1325
|
+
"""Launch the background liveness supervisor for *names* (the agents we started)."""
|
|
1326
|
+
cfg.run_dir.mkdir(parents=True, exist_ok=True)
|
|
1327
|
+
cfg.log_dir.mkdir(parents=True, exist_ok=True)
|
|
1328
|
+
logfile = (cfg.log_dir / "supervisor.log").open("a")
|
|
1329
|
+
# Pass the config via SWARM_CONFIG rather than `-c`: the `supervise` subcommand
|
|
1330
|
+
# is an internal plumbing command and we keep its CLI surface minimal/stable.
|
|
1331
|
+
env = dict(os.environ)
|
|
1332
|
+
env["SWARM_CONFIG"] = str(cfg.path)
|
|
1333
|
+
proc = subprocess.Popen(
|
|
1334
|
+
[sys.executable, str(Path(__file__).resolve()), "supervise", *names],
|
|
1335
|
+
stdout=logfile,
|
|
1336
|
+
stderr=subprocess.STDOUT,
|
|
1337
|
+
stdin=subprocess.DEVNULL,
|
|
1338
|
+
env=env,
|
|
1339
|
+
start_new_session=True,
|
|
1340
|
+
)
|
|
1341
|
+
(cfg.run_dir / SUPERVISOR_PID).write_text(str(proc.pid))
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
def stop_supervisor(cfg: SwarmConfig) -> None:
|
|
1345
|
+
pid_file = cfg.run_dir / SUPERVISOR_PID
|
|
1346
|
+
if not pid_file.is_file():
|
|
1347
|
+
return
|
|
1348
|
+
try:
|
|
1349
|
+
pid = int(pid_file.read_text().strip())
|
|
1350
|
+
os.kill(pid, 15)
|
|
1351
|
+
except (OSError, ValueError):
|
|
1352
|
+
pass
|
|
1353
|
+
pid_file.unlink(missing_ok=True)
|
|
1354
|
+
|
|
1355
|
+
|
|
1356
|
+
def supervisor_alive(cfg: SwarmConfig) -> bool:
|
|
1357
|
+
pid_file = cfg.run_dir / SUPERVISOR_PID
|
|
1358
|
+
if not pid_file.is_file():
|
|
1359
|
+
return False
|
|
1360
|
+
try:
|
|
1361
|
+
os.kill(int(pid_file.read_text().strip()), 0)
|
|
1362
|
+
return True
|
|
1363
|
+
except (OSError, ValueError):
|
|
1364
|
+
return False
|
|
1365
|
+
|
|
1366
|
+
|
|
1367
|
+
def supervise_once(cfg: SwarmConfig, names: list[str], seen_dead: set[str]) -> None:
|
|
1368
|
+
"""One reconciliation pass over the watched agents.
|
|
1369
|
+
|
|
1370
|
+
Split out of the loop so it can be unit-tested without the timer. Reconciles
|
|
1371
|
+
two failure modes that the event-driven design otherwise never notices:
|
|
1372
|
+
|
|
1373
|
+
* a dead tmux session (CLI crashed / killed) -- mark the turn finished and
|
|
1374
|
+
warn; do NOT try to deliver into a pane that no longer exists;
|
|
1375
|
+
* a stale-busy agent (turn `delivered > completed` older than busy_timeout_ms)
|
|
1376
|
+
-- the capture that should have ended the turn never fired, so mark it
|
|
1377
|
+
finished and drain its inbox.
|
|
1378
|
+
|
|
1379
|
+
`seen_dead` is an in-memory set (the supervisor is one long-lived process)
|
|
1380
|
+
so a dead session is warned about once, not every tick.
|
|
1381
|
+
"""
|
|
1382
|
+
for name in names:
|
|
1383
|
+
agent = cfg.get(name)
|
|
1384
|
+
if not session_exists(agent.session):
|
|
1385
|
+
if name not in seen_dead:
|
|
1386
|
+
seen_dead.add(name)
|
|
1387
|
+
warn(f"{name}: tmux session gone; treating as finished")
|
|
1388
|
+
log_event(cfg, name, "dead", text="session gone")
|
|
1389
|
+
# Reconcile the turn so the agent is no longer "busy" forever, and
|
|
1390
|
+
# surface any mail that was queued to it (it cannot be delivered to
|
|
1391
|
+
# a dead pane, but the user should know it is stranded).
|
|
1392
|
+
mark_turn_finished(cfg, name)
|
|
1393
|
+
queued = queue_read(cfg, name)
|
|
1394
|
+
if queued:
|
|
1395
|
+
warn(
|
|
1396
|
+
f"{name}: {len(queued)} queued message(s) stranded "
|
|
1397
|
+
f"(recipient session is gone)"
|
|
1398
|
+
)
|
|
1399
|
+
continue
|
|
1400
|
+
seen_dead.discard(name)
|
|
1401
|
+
|
|
1402
|
+
state = turn_state(cfg, name)
|
|
1403
|
+
if state.get("delivered", 0) <= state.get("completed", 0):
|
|
1404
|
+
continue # idle or already reconciled; nothing to do
|
|
1405
|
+
age_ms = (time.time() - state.get("since", 0)) * 1000
|
|
1406
|
+
if age_ms <= cfg.busy_timeout_ms:
|
|
1407
|
+
continue # legitimately mid-turn; its own capture will end it
|
|
1408
|
+
warn(
|
|
1409
|
+
f"{name}: looked busy for {int(age_ms / 1000)}s "
|
|
1410
|
+
f"(over busy_timeout_ms); marking idle and draining its queue"
|
|
1411
|
+
)
|
|
1412
|
+
mark_turn_finished(cfg, name)
|
|
1413
|
+
try:
|
|
1414
|
+
drain_queue(cfg, agent)
|
|
1415
|
+
except SwarmError as exc:
|
|
1416
|
+
warn(f"{name}: could not drain queue: {exc}")
|
|
1417
|
+
|
|
1418
|
+
# Any turn end is an opportunity to rescue mail stranded on an agent whose own
|
|
1419
|
+
# capture never fired -- otherwise a single missed completion wedges its queue.
|
|
1420
|
+
sweep_stale_queues(cfg)
|
|
1421
|
+
|
|
1422
|
+
|
|
1423
|
+
def run_supervisor(cfg: SwarmConfig, names: list[str]) -> None:
|
|
1424
|
+
"""Background loop: the heartbeat that keeps one silent agent from wedging the swarm.
|
|
1425
|
+
|
|
1426
|
+
The swarm is otherwise purely event-driven -- progress only happens when an
|
|
1427
|
+
agent's capture fires (hook/pane) or a human sends a message. If that event
|
|
1428
|
+
never arrives, nothing wakes the loop. This polls on a timer and reconciles
|
|
1429
|
+
stale/dead state. It self-exits once every watched session is gone (after
|
|
1430
|
+
`down`), so it does not run forever.
|
|
1431
|
+
"""
|
|
1432
|
+
info("supervisor started")
|
|
1433
|
+
seen_dead: set[str] = set()
|
|
1434
|
+
while True:
|
|
1435
|
+
sleep_ms(cfg.supervise_interval_ms)
|
|
1436
|
+
if not any(session_exists(cfg.get(n).session) for n in names):
|
|
1437
|
+
info("supervisor: no watched sessions remain, exiting")
|
|
1438
|
+
return
|
|
1439
|
+
supervise_once(cfg, names, seen_dead)
|
|
1440
|
+
|
|
1441
|
+
|
|
1321
1442
|
READY_TOKEN = "zqxswarmready"
|
|
1322
1443
|
|
|
1323
1444
|
|
|
@@ -1720,6 +1841,7 @@ def cmd_up(args) -> int:
|
|
|
1720
1841
|
continue
|
|
1721
1842
|
info(f"{agent.name}: restarting")
|
|
1722
1843
|
stop_watcher(cfg, agent)
|
|
1844
|
+
stop_supervisor(cfg)
|
|
1723
1845
|
tmux("kill-session", "-t", f"={agent.session}", check=False, capture=True)
|
|
1724
1846
|
|
|
1725
1847
|
resume_cmd = None
|
|
@@ -1801,11 +1923,28 @@ def cmd_up(args) -> int:
|
|
|
1801
1923
|
if agent.capture == "pane":
|
|
1802
1924
|
start_watcher(cfg, agent)
|
|
1803
1925
|
|
|
1926
|
+
# The supervisor is the heartbeat the event-driven design lacks: it reconciles
|
|
1927
|
+
# dead/stale agents on a timer so one silent agent cannot wedge the swarm.
|
|
1928
|
+
if cfg.supervise and started:
|
|
1929
|
+
start_supervisor(cfg, [a.name for a in started])
|
|
1930
|
+
|
|
1804
1931
|
print()
|
|
1805
1932
|
info(f"swarm {cfg.name!r} is up with {len(started)} agent(s)")
|
|
1806
1933
|
info(f"attach with: tmux attach -t {started[0].session}")
|
|
1807
1934
|
info(f"or: {SWARM_HOME / 'agentainer'} attach {started[0].name}")
|
|
1808
1935
|
|
|
1936
|
+
if resumed:
|
|
1937
|
+
example = sorted(resumed)[0]
|
|
1938
|
+
print()
|
|
1939
|
+
info(
|
|
1940
|
+
"resumed agents are reattached to their previous conversation but sit "
|
|
1941
|
+
"idle at their prompt — they will NOT auto-continue on their own."
|
|
1942
|
+
)
|
|
1943
|
+
info(
|
|
1944
|
+
"to make one pick up where it left off, nudge it, e.g.: "
|
|
1945
|
+
f"agentainer send --to {example} \"Please continue from where you left off.\""
|
|
1946
|
+
)
|
|
1947
|
+
|
|
1809
1948
|
if args.attach:
|
|
1810
1949
|
os.execvp("tmux", ["tmux", "attach", "-t", started[0].session])
|
|
1811
1950
|
return 0
|
|
@@ -1813,6 +1952,10 @@ def cmd_up(args) -> int:
|
|
|
1813
1952
|
|
|
1814
1953
|
def cmd_down(args) -> int:
|
|
1815
1954
|
cfg = cfgmod.load(args.config)
|
|
1955
|
+
# Stop the supervisor first (it is not per-agent); if only some agents are
|
|
1956
|
+
# being stopped, the supervisor self-exits once no watched sessions remain.
|
|
1957
|
+
if not args.only:
|
|
1958
|
+
stop_supervisor(cfg)
|
|
1816
1959
|
for agent in select_agents(cfg, args.only):
|
|
1817
1960
|
stop_watcher(cfg, agent)
|
|
1818
1961
|
if session_exists(agent.session):
|
|
@@ -1870,6 +2013,9 @@ def cmd_status(args) -> int:
|
|
|
1870
2013
|
cells = [str(c).ljust(widths[i]) for i, c in enumerate(row)]
|
|
1871
2014
|
cells[2] = f"{colour}{cells[2]}\033[0m"
|
|
1872
2015
|
print(" ".join(cells))
|
|
2016
|
+
sup = "alive" if supervisor_alive(cfg) else "down"
|
|
2017
|
+
print(f"\nsupervisor: {sup} (reconciles dead/stale agents every "
|
|
2018
|
+
f"{cfg.supervise_interval_ms}ms)")
|
|
1873
2019
|
return 0
|
|
1874
2020
|
|
|
1875
2021
|
|
|
@@ -2117,7 +2263,24 @@ def cmd_validate(args) -> int:
|
|
|
2117
2263
|
|
|
2118
2264
|
def cmd_watch(args) -> int:
|
|
2119
2265
|
cfg = cfgmod.load(args.config)
|
|
2120
|
-
|
|
2266
|
+
agent = cfg.get(args.agent)
|
|
2267
|
+
if agent.capture != "pane":
|
|
2268
|
+
die(
|
|
2269
|
+
f"{agent.name}: capture={agent.capture}, nothing to watch. The pane "
|
|
2270
|
+
f"watcher is only for pane-capture agents (gemini/hermes); "
|
|
2271
|
+
f"{agent.type} agents detect turn completion via "
|
|
2272
|
+
f"{'a Stop hook' if agent.capture == 'hook' else 'no capture at all'}."
|
|
2273
|
+
)
|
|
2274
|
+
if not session_exists(agent.session):
|
|
2275
|
+
die(f"{agent.name}: session {agent.session!r} is not running (start it with `up`).")
|
|
2276
|
+
run_watcher(cfg, agent)
|
|
2277
|
+
return 0
|
|
2278
|
+
|
|
2279
|
+
|
|
2280
|
+
def cmd_supervise(args) -> int:
|
|
2281
|
+
cfg = cfgmod.load(args.config) if args.config else cfgmod.load(default_config())
|
|
2282
|
+
names = list(args.names) or [a.name for a in cfg.agents]
|
|
2283
|
+
run_supervisor(cfg, names)
|
|
2121
2284
|
return 0
|
|
2122
2285
|
|
|
2123
2286
|
|
|
@@ -2255,6 +2418,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2255
2418
|
p_watch = add("watch", cmd_watch, "internal: poll an agent's tmux pane for completed turns")
|
|
2256
2419
|
p_watch.add_argument("agent")
|
|
2257
2420
|
|
|
2421
|
+
p_sup = add("supervise", cmd_supervise, "internal: background liveness watchdog")
|
|
2422
|
+
p_sup.add_argument("names", nargs="*", help="agents to watch (default: all)")
|
|
2423
|
+
|
|
2258
2424
|
return parser
|
|
2259
2425
|
|
|
2260
2426
|
|
|
@@ -2275,8 +2441,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
2275
2441
|
die(f"command failed: {exc}")
|
|
2276
2442
|
except KeyboardInterrupt:
|
|
2277
2443
|
return 130
|
|
2278
|
-
return 0
|
|
2279
2444
|
|
|
2280
2445
|
|
|
2281
|
-
if __name__ == "__main__":
|
|
2446
|
+
if __name__ == "__main__": # pragma: no cover - exercised by running the module as a script
|
|
2282
2447
|
sys.exit(main())
|
package/llms.txt
CHANGED
|
@@ -275,6 +275,32 @@ stops at `max_forward_hops`. A message you send with `swarm send` resets it to z
|
|
|
275
275
|
reproduce → diagnose → fix → verify.
|
|
276
276
|
- `examples/existing-repo.yaml` — two agents pairing inside one existing checkout,
|
|
277
277
|
using `create_workdirs: false` so a wrong path fails instead of being created.
|
|
278
|
+
- `examples/red-team.yaml` — adversarial triad: an attacker and defender argue
|
|
279
|
+
through a referee over one existing checkout.
|
|
280
|
+
- `examples/debate.yaml` — two advocates argue opposite sides; a judge
|
|
281
|
+
cross-examines and rules.
|
|
282
|
+
- `examples/writers-room.yaml` — an editor drives a writer, fact-checker and
|
|
283
|
+
critic to a finished article.
|
|
284
|
+
- `examples/incident-response.yaml` — a commander coordinates an investigator,
|
|
285
|
+
responder and scribe under time pressure (hub and spoke).
|
|
286
|
+
- `examples/tdd-pingpong.yaml` — a tester and coder grow a feature test-first,
|
|
287
|
+
one test at a time, via `forward_responses_to`.
|
|
288
|
+
- `examples/brainstorm.yaml` — three idea-generators fan out from a facilitator,
|
|
289
|
+
then a synthesiser ranks them.
|
|
290
|
+
- `examples/localization.yaml` — translate → review → back-check, auto-forwarded
|
|
291
|
+
via `forward_responses_to`; a `NEEDS ANOTHER PASS` verdict loops back to
|
|
292
|
+
the translator with a `<swarm-send>` block.
|
|
293
|
+
- `examples/multi-language-broadcast.yaml` — one translator broadcasts a source to
|
|
294
|
+
N language reviewers; a consolidator compiles and broadcasts back. Built on
|
|
295
|
+
`broadcast`.
|
|
296
|
+
- `examples/code-review-broadcast.yaml` — a coordinator broadcasts a PR to
|
|
297
|
+
specialist reviewers (correctness/security/perf); each replies to the
|
|
298
|
+
coordinator, who synthesizes. Built on `broadcast`.
|
|
299
|
+
- `examples/ping-pong.yaml` — two agents trade a unit of work, each broadcast
|
|
300
|
+
naming who is up next (round-robin via `broadcast`, distinct from
|
|
301
|
+
tdd-pingpong's fixed `forward_responses_to`).
|
|
302
|
+
- `agents.example.yaml` — the fully annotated reference config; every key is
|
|
303
|
+
documented inline.
|
|
278
304
|
|
|
279
305
|
## Recipes
|
|
280
306
|
|
package/package.json
CHANGED
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentainer",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.5",
|
|
4
|
+
"description": "Zero-dependency multi-agent orchestrator: run a team of AI coding agents (Claude Code, Codex, Gemini, Hermes) side by side in tmux with a YAML-defined ACL.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|
|
7
|
+
"ai-agents",
|
|
7
8
|
"ai",
|
|
9
|
+
"llm",
|
|
10
|
+
"agentic",
|
|
11
|
+
"claude-code",
|
|
8
12
|
"claude",
|
|
9
13
|
"codex",
|
|
10
14
|
"gemini",
|
|
15
|
+
"hermes",
|
|
11
16
|
"tmux",
|
|
12
17
|
"orchestration",
|
|
13
18
|
"multi-agent",
|
|
14
|
-
"swarm"
|
|
19
|
+
"swarm",
|
|
20
|
+
"prompt-routing",
|
|
21
|
+
"python"
|
|
15
22
|
],
|
|
16
23
|
"homepage": "https://github.com/mehmetcanfarsak/AgentSwarm#readme",
|
|
17
24
|
"bugs": {
|