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/minyaml.py
CHANGED
|
@@ -37,10 +37,6 @@ _KEY_RE = re.compile(
|
|
|
37
37
|
|
|
38
38
|
_BLOCK_RE = re.compile(r"^([|>])([+-]?)(\d*)$")
|
|
39
39
|
|
|
40
|
-
# Only plain base-10 numbers are coerced. Leaving leading-zero ("010"), hex/octal
|
|
41
|
-
# ("0x1F"), bare-exponent ("1e3") and underscore ("1_000") tokens as strings keeps
|
|
42
|
-
# the builtin parser from silently inventing a value that PyYAML would not -- the
|
|
43
|
-
# ambiguous octal/exponent cases are exactly where the two would otherwise disagree.
|
|
44
40
|
_INT_RE = re.compile(r"^[-+]?(?:0|[1-9][0-9]*)$")
|
|
45
41
|
_FLOAT_RE = re.compile(r"^[-+]?(?:[0-9]*\.[0-9]+|[0-9]+\.[0-9]*)(?:[eE][-+][0-9]+)?$")
|
|
46
42
|
|
|
@@ -65,13 +61,7 @@ def load(text: str):
|
|
|
65
61
|
return value if value is not None else {}
|
|
66
62
|
|
|
67
63
|
|
|
68
|
-
# --------------------------------------------------------------------------
|
|
69
|
-
# scalar handling
|
|
70
|
-
# --------------------------------------------------------------------------
|
|
71
|
-
|
|
72
|
-
|
|
73
64
|
def _strip_comment(s: str) -> str:
|
|
74
|
-
"""Remove a trailing ``# comment``, respecting quotes and brackets."""
|
|
75
65
|
out = []
|
|
76
66
|
quote = None
|
|
77
67
|
prev = ""
|
|
@@ -92,7 +82,6 @@ def _strip_comment(s: str) -> str:
|
|
|
92
82
|
|
|
93
83
|
|
|
94
84
|
def _split_flow(body: str) -> list[str]:
|
|
95
|
-
"""Split ``a, b, [c, d]`` on top-level commas."""
|
|
96
85
|
parts, depth, quote, cur = [], 0, None, []
|
|
97
86
|
for ch in body:
|
|
98
87
|
if quote:
|
|
@@ -117,7 +106,6 @@ def _split_flow(body: str) -> list[str]:
|
|
|
117
106
|
|
|
118
107
|
|
|
119
108
|
def _is_balanced(s: str) -> bool:
|
|
120
|
-
"""True once every ``[``/``{`` opened in *s* has been closed."""
|
|
121
109
|
depth, quote = 0, None
|
|
122
110
|
for ch in s:
|
|
123
111
|
if quote:
|
|
@@ -139,12 +127,6 @@ _DQ_ESCAPES = {
|
|
|
139
127
|
|
|
140
128
|
|
|
141
129
|
def _unescape_double(body: str) -> str:
|
|
142
|
-
"""Resolve backslash escapes in a double-quoted scalar.
|
|
143
|
-
|
|
144
|
-
A plain ``bytes.decode("unicode_escape")`` is latin-1 based and mangles any
|
|
145
|
-
non-ASCII character (``"café"`` -> ``"café"``), so escapes are expanded by
|
|
146
|
-
hand and every other character -- including multi-byte UTF-8 -- is kept as-is.
|
|
147
|
-
"""
|
|
148
130
|
out: list[str] = []
|
|
149
131
|
i, n = 0, len(body)
|
|
150
132
|
while i < n:
|
|
@@ -220,11 +202,6 @@ def _scalar(raw: str):
|
|
|
220
202
|
return s
|
|
221
203
|
|
|
222
204
|
|
|
223
|
-
# --------------------------------------------------------------------------
|
|
224
|
-
# block parser
|
|
225
|
-
# --------------------------------------------------------------------------
|
|
226
|
-
|
|
227
|
-
|
|
228
205
|
def _indent_of(line: str) -> int:
|
|
229
206
|
return len(line) - len(line.lstrip(" "))
|
|
230
207
|
|
|
@@ -239,10 +216,7 @@ class _Parser:
|
|
|
239
216
|
self.lines = lines
|
|
240
217
|
self.i = 0
|
|
241
218
|
|
|
242
|
-
# -- cursor helpers ----------------------------------------------------
|
|
243
|
-
|
|
244
219
|
def peek(self):
|
|
245
|
-
"""Return (index, indent, stripped) of the next meaningful line."""
|
|
246
220
|
while self.i < len(self.lines) and _is_blank(self.lines[self.i]):
|
|
247
221
|
self.i += 1
|
|
248
222
|
if self.i >= len(self.lines):
|
|
@@ -256,10 +230,6 @@ class _Parser:
|
|
|
256
230
|
raise YAMLError(f"unexpected content at indent {indent}: {text!r}")
|
|
257
231
|
|
|
258
232
|
def finish_flow(self, first: str):
|
|
259
|
-
"""Parse a flow collection, consuming continuation lines until balanced.
|
|
260
|
-
|
|
261
|
-
The cursor must already sit *after* the line ``first`` came from.
|
|
262
|
-
"""
|
|
263
233
|
buf = _strip_comment(first)
|
|
264
234
|
while not _is_balanced(buf):
|
|
265
235
|
if self.i >= len(self.lines):
|
|
@@ -271,8 +241,6 @@ class _Parser:
|
|
|
271
241
|
buf += " " + _strip_comment(line.strip())
|
|
272
242
|
return _scalar(buf)
|
|
273
243
|
|
|
274
|
-
# -- grammar -----------------------------------------------------------
|
|
275
|
-
|
|
276
244
|
def parse(self, indent: int | None = None):
|
|
277
245
|
head = self.peek()
|
|
278
246
|
if head is None:
|
|
@@ -284,14 +252,11 @@ class _Parser:
|
|
|
284
252
|
return None
|
|
285
253
|
if text == "-" or text.startswith("- "):
|
|
286
254
|
return self.parse_seq(cur_indent)
|
|
287
|
-
# Check flow collections before the key regex: `{name: a, type: b}` would
|
|
288
|
-
# otherwise look like a block mapping with the key `{name`.
|
|
289
255
|
if text.startswith(("{", "[")):
|
|
290
256
|
self.i += 1
|
|
291
257
|
return self.finish_flow(text)
|
|
292
258
|
if _KEY_RE.match(text):
|
|
293
259
|
return self.parse_map(cur_indent)
|
|
294
|
-
# A lone scalar (only reachable for sequence item bodies).
|
|
295
260
|
self.i += 1
|
|
296
261
|
return _scalar(text)
|
|
297
262
|
|
|
@@ -325,7 +290,6 @@ class _Parser:
|
|
|
325
290
|
if nxt and nxt[1] > indent:
|
|
326
291
|
out[key] = self.parse(nxt[1])
|
|
327
292
|
elif nxt and nxt[1] == indent and nxt[2].startswith(("-", "- ")):
|
|
328
|
-
# Sequences may sit at the same indent as their key.
|
|
329
293
|
out[key] = self.parse_seq(indent)
|
|
330
294
|
else:
|
|
331
295
|
out[key] = None
|
|
@@ -342,8 +306,6 @@ class _Parser:
|
|
|
342
306
|
break
|
|
343
307
|
|
|
344
308
|
line = self.lines[idx]
|
|
345
|
-
# Rewrite "- foo" into " foo" so the item body is a normal block
|
|
346
|
-
# that starts at the column right after the dash.
|
|
347
309
|
body_first = line[:indent] + " " + line[indent + 1 :]
|
|
348
310
|
self.i += 1
|
|
349
311
|
|
|
@@ -402,7 +364,7 @@ class _Parser:
|
|
|
402
364
|
folded.append(" ".join(buf))
|
|
403
365
|
buf = []
|
|
404
366
|
folded.append("")
|
|
405
|
-
elif line.startswith(" "):
|
|
367
|
+
elif line.startswith(" "):
|
|
406
368
|
if buf:
|
|
407
369
|
folded.append(" ".join(buf))
|
|
408
370
|
buf = []
|
package/lib/reconcile.py
ADDED
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Agentainer -- P4 dynamic reconcile (add / remove / edit agents at runtime).
|
|
3
|
+
|
|
4
|
+
This module closes the loop opened by ``up``: it makes the running swarm match
|
|
5
|
+
the config *without* a full teardown. The orchestrator owns authoritative state,
|
|
6
|
+
so reconcile is the only place that starts/stops sessions to match the YAML.
|
|
7
|
+
|
|
8
|
+
Three operations, all driven from the tested core (no new orchestration logic):
|
|
9
|
+
|
|
10
|
+
* ``diff(cfg)`` -- compare configured agents to running tmux sessions.
|
|
11
|
+
* ``reconcile(cfg, ...)`` -- start agents missing from the running set, stop
|
|
12
|
+
sessions that are no longer in the config.
|
|
13
|
+
* ``add_agent`` / ``remove_agent`` / ``edit_agent`` -- mutate the YAML on disk
|
|
14
|
+
(a minimal stdlib emitter, so it works with OR
|
|
15
|
+
without PyYAML), then return a re-loaded config so
|
|
16
|
+
the caller can ``reconcile`` the change into effect.
|
|
17
|
+
|
|
18
|
+
The HTTP UI and the CLI both call these; the UI is the human-facing control
|
|
19
|
+
plane that triggers them.
|
|
20
|
+
|
|
21
|
+
Hard invariants (see CLAUDE.md + ProjectPlan.md §24):
|
|
22
|
+
* Zero runtime deps. The YAML writer below never imports PyYAML -- it only
|
|
23
|
+
*reads* via PyYAML when present and falls back to ``minyaml`` otherwise, but
|
|
24
|
+
it always *writes* with the bundled emitter so the no-PyYAML path stays live.
|
|
25
|
+
* ``can_talk_to`` stays a cooperative ACL -- reconcile never relaxes it.
|
|
26
|
+
* Reconcile is best-effort about tmux: a session that won't start is reported,
|
|
27
|
+
not fatal.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import re
|
|
33
|
+
import sys
|
|
34
|
+
import time
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
_LIB = Path(__file__).resolve().parent
|
|
38
|
+
if str(_LIB) not in sys.path:
|
|
39
|
+
sys.path.insert(0, str(_LIB))
|
|
40
|
+
|
|
41
|
+
import config as cfgmod # noqa: E402
|
|
42
|
+
from config import ConfigError # noqa: E402
|
|
43
|
+
import tmux # noqa: E402
|
|
44
|
+
import mail # noqa: E402
|
|
45
|
+
import hooks # noqa: E402
|
|
46
|
+
import turn # noqa: E402
|
|
47
|
+
import log # noqa: E402
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# --------------------------------------------------------------------------
|
|
51
|
+
# logging (self-contained; mirrors cli.info/warn so reconcile has no cli dep)
|
|
52
|
+
# --------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def info(msg: str) -> None:
|
|
56
|
+
print(f":: {msg}", file=sys.stderr)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def warn(msg: str) -> None:
|
|
60
|
+
print(f"!! {msg}", file=sys.stderr)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# --------------------------------------------------------------------------
|
|
64
|
+
# YAML read / write (write path is stdlib-only, no PyYAML)
|
|
65
|
+
# --------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def have_yaml() -> bool:
|
|
69
|
+
"""True iff PyYAML is importable (used only for *reading*)."""
|
|
70
|
+
try:
|
|
71
|
+
import yaml # noqa: F401
|
|
72
|
+
|
|
73
|
+
return True
|
|
74
|
+
except Exception:
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_raw(path) -> dict:
|
|
79
|
+
"""Parse *path* into a plain dict using PyYAML if present, else minyaml."""
|
|
80
|
+
text = Path(path).read_text()
|
|
81
|
+
if have_yaml():
|
|
82
|
+
import yaml
|
|
83
|
+
|
|
84
|
+
return yaml.safe_load(text) or {}
|
|
85
|
+
import minyaml
|
|
86
|
+
|
|
87
|
+
return minyaml.load(text) or {}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _scalar(v) -> str:
|
|
91
|
+
"""Render a scalar for the stdlib YAML emitter."""
|
|
92
|
+
if v is None:
|
|
93
|
+
return "null"
|
|
94
|
+
if isinstance(v, bool):
|
|
95
|
+
return "true" if v else "false"
|
|
96
|
+
if isinstance(v, (int, float)):
|
|
97
|
+
return str(v)
|
|
98
|
+
s = str(v)
|
|
99
|
+
if s == "":
|
|
100
|
+
return '""'
|
|
101
|
+
# Quote anything that isn't a safe bare token (so "a,b" stays a string, etc).
|
|
102
|
+
if re.fullmatch(r"[A-Za-z0-9_./@:+-]+", s):
|
|
103
|
+
return s
|
|
104
|
+
return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _dump(data, indent: int = 0) -> str:
|
|
108
|
+
"""Minimal stdlib YAML serializer for Agentainer configs.
|
|
109
|
+
|
|
110
|
+
Handles exactly the shapes Agentainer configs use: nested mappings, lists of
|
|
111
|
+
scalars, and lists of mappings (the ``agents:`` block). It is intentionally
|
|
112
|
+
small -- the config schema is closed, so we don't need a general emitter.
|
|
113
|
+
"""
|
|
114
|
+
pad = " " * indent
|
|
115
|
+
lines: list[str] = []
|
|
116
|
+
if isinstance(data, dict):
|
|
117
|
+
for k, v in data.items():
|
|
118
|
+
if isinstance(v, dict):
|
|
119
|
+
if v:
|
|
120
|
+
lines.append(f"{pad}{k}:")
|
|
121
|
+
lines.append(_dump(v, indent + 1))
|
|
122
|
+
else:
|
|
123
|
+
lines.append(f"{pad}{k}: {{}}")
|
|
124
|
+
elif isinstance(v, list):
|
|
125
|
+
if v:
|
|
126
|
+
lines.append(f"{pad}{k}:")
|
|
127
|
+
lines.append(_dump(v, indent + 1))
|
|
128
|
+
else:
|
|
129
|
+
lines.append(f"{pad}{k}: []")
|
|
130
|
+
else:
|
|
131
|
+
lines.append(f"{pad}{k}: {_scalar(v)}")
|
|
132
|
+
elif isinstance(data, list):
|
|
133
|
+
for item in data:
|
|
134
|
+
if isinstance(item, dict) and item:
|
|
135
|
+
sub = _dump(item, indent + 1).splitlines()
|
|
136
|
+
sub_indent = " " * (indent + 1)
|
|
137
|
+
first = True
|
|
138
|
+
for line in sub:
|
|
139
|
+
if first:
|
|
140
|
+
# "- key: val" at the list level, drop one indent.
|
|
141
|
+
lines.append(pad + "- " + line[len(sub_indent):])
|
|
142
|
+
first = False
|
|
143
|
+
else:
|
|
144
|
+
lines.append(line)
|
|
145
|
+
else:
|
|
146
|
+
lines.append(f"{pad}- {_scalar(item)}")
|
|
147
|
+
return "\n".join(lines)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def write_raw(path, data: dict) -> None:
|
|
151
|
+
"""Serialize *data* to *path* with the stdlib emitter (no PyYAML needed)."""
|
|
152
|
+
Path(path).write_text(_dump(data) + "\n")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# --------------------------------------------------------------------------
|
|
156
|
+
# config mutation
|
|
157
|
+
# --------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _commit(cfg, raw: dict) -> "cfgmod.SwarmConfig":
|
|
161
|
+
"""Persist *raw* to the config path, then validate by reloading it.
|
|
162
|
+
|
|
163
|
+
If the reload fails -- the edit produced an invalid config (e.g. a
|
|
164
|
+
can_talk_to reference to an agent that no longer exists) -- restore the
|
|
165
|
+
previous file and re-raise, so a REJECTED edit never leaves an unloadable
|
|
166
|
+
config on disk. Without this, every mutator wrote first and validated
|
|
167
|
+
second, and a rejected edit corrupted agentainer.yaml, breaking every later
|
|
168
|
+
command and the UI.
|
|
169
|
+
"""
|
|
170
|
+
path = Path(cfg.path)
|
|
171
|
+
prev = path.read_text() if path.exists() else None
|
|
172
|
+
write_raw(path, raw)
|
|
173
|
+
try:
|
|
174
|
+
return cfgmod.load(path)
|
|
175
|
+
except Exception:
|
|
176
|
+
if prev is None: # pragma: no cover - a mutator always starts from an existing config
|
|
177
|
+
path.unlink(missing_ok=True)
|
|
178
|
+
else:
|
|
179
|
+
path.write_text(prev)
|
|
180
|
+
raise
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _coerce_field(key: str, value: str):
|
|
184
|
+
"""Turn a CLI string into the right Python value for *key*.
|
|
185
|
+
|
|
186
|
+
``can_talk_to`` becomes a list (``*`` stays the wildcard string). Numeric
|
|
187
|
+
fields parse to int; ``true``/``false`` to bool; everything else stays str.
|
|
188
|
+
"""
|
|
189
|
+
if key == "can_talk_to":
|
|
190
|
+
if value.strip() == "*":
|
|
191
|
+
return "*"
|
|
192
|
+
return [p.strip() for p in value.split(",") if p.strip()]
|
|
193
|
+
low = value.strip().lower()
|
|
194
|
+
if low in ("true", "false"):
|
|
195
|
+
return low == "true"
|
|
196
|
+
if re.fullmatch(r"-?\d+", value.strip()):
|
|
197
|
+
return int(value.strip())
|
|
198
|
+
if re.fullmatch(r"-?\d+\.\d+", value.strip()):
|
|
199
|
+
return float(value.strip())
|
|
200
|
+
return value
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def add_agent(cfg, name, type_, command, can_talk_to, role="", workdir=None, **extra) -> "cfgmod.SwarmConfig":
|
|
204
|
+
"""Append *name* to the config on disk and return a re-loaded SwarmConfig.
|
|
205
|
+
|
|
206
|
+
Raises ``ValueError`` if the agent already exists. ``can_talk_to`` may be a
|
|
207
|
+
list or the ``"*"`` wildcard string.
|
|
208
|
+
"""
|
|
209
|
+
raw = load_raw(cfg.path)
|
|
210
|
+
agents = list(raw.get("agents") or [])
|
|
211
|
+
if any(str(a.get("name")) == name for a in agents):
|
|
212
|
+
raise ValueError(f"agent {name!r} already exists")
|
|
213
|
+
|
|
214
|
+
entry = {
|
|
215
|
+
"name": name,
|
|
216
|
+
"type": type_,
|
|
217
|
+
"command": command,
|
|
218
|
+
"can_talk_to": can_talk_to,
|
|
219
|
+
}
|
|
220
|
+
if role:
|
|
221
|
+
entry["role"] = role
|
|
222
|
+
if workdir:
|
|
223
|
+
entry["workdir"] = str(workdir)
|
|
224
|
+
for k, v in extra.items():
|
|
225
|
+
entry[k] = v
|
|
226
|
+
agents.append(entry)
|
|
227
|
+
raw["agents"] = agents
|
|
228
|
+
return _commit(cfg, raw)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def remove_agent(cfg, name) -> "cfgmod.SwarmConfig":
|
|
232
|
+
"""Drop *name* from the config on disk and return a re-loaded SwarmConfig.
|
|
233
|
+
|
|
234
|
+
Raises ``ValueError`` if the agent is not present. The caller is expected to
|
|
235
|
+
reconcile afterwards to tear down the now-orphaned session.
|
|
236
|
+
"""
|
|
237
|
+
raw = load_raw(cfg.path)
|
|
238
|
+
agents = list(raw.get("agents") or [])
|
|
239
|
+
kept = [a for a in agents if str(a.get("name")) != name]
|
|
240
|
+
if len(kept) == len(agents):
|
|
241
|
+
raise ValueError(f"agent {name!r} not found")
|
|
242
|
+
# Strip the removed agent from every remaining agent's can_talk_to. Config
|
|
243
|
+
# load validates that every peer exists (config.py), so a lingering
|
|
244
|
+
# reference would make the reload below raise -- after we've already written
|
|
245
|
+
# the file -- leaving an unloadable config on disk that breaks every later
|
|
246
|
+
# command and the UI. A "*" wildcard needs no cleanup: load re-expands it to
|
|
247
|
+
# whatever agents remain.
|
|
248
|
+
for a in kept:
|
|
249
|
+
talk = a.get("can_talk_to")
|
|
250
|
+
if isinstance(talk, list):
|
|
251
|
+
a["can_talk_to"] = [p for p in talk if str(p) != name]
|
|
252
|
+
raw["agents"] = kept
|
|
253
|
+
return _commit(cfg, raw)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def edit_swarm(cfg, **fields) -> "cfgmod.SwarmConfig":
|
|
257
|
+
"""Update swarm-level settings on disk and return a re-loaded SwarmConfig.
|
|
258
|
+
|
|
259
|
+
Values arrive already typed (bools/ints/strings from the JSON control plane),
|
|
260
|
+
so -- unlike ``edit_agent`` -- they are written through verbatim. Writing uses
|
|
261
|
+
the stdlib emitter so the no-PyYAML path stays live.
|
|
262
|
+
"""
|
|
263
|
+
raw = load_raw(cfg.path)
|
|
264
|
+
swarm = dict(raw.get("swarm") or {})
|
|
265
|
+
for k, v in fields.items():
|
|
266
|
+
swarm[k] = v
|
|
267
|
+
raw["swarm"] = swarm
|
|
268
|
+
return _commit(cfg, raw)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def edit_telegram(cfg, **fields) -> "cfgmod.SwarmConfig":
|
|
272
|
+
"""Update the top-level ``telegram:`` block on disk; return a reloaded config.
|
|
273
|
+
|
|
274
|
+
Values arrive already typed from the JSON control plane, so they are written
|
|
275
|
+
through verbatim (the stdlib emitter keeps the no-PyYAML path live).
|
|
276
|
+
"""
|
|
277
|
+
raw = load_raw(cfg.path)
|
|
278
|
+
tg = dict(raw.get("telegram") or {})
|
|
279
|
+
for k, v in fields.items():
|
|
280
|
+
tg[k] = v
|
|
281
|
+
raw["telegram"] = tg
|
|
282
|
+
return _commit(cfg, raw)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def apply_template(cfg, agents, defaults=None) -> list:
|
|
286
|
+
"""Seed an EMPTY config on disk with a template's ``agents`` (+ ``defaults``).
|
|
287
|
+
|
|
288
|
+
Onboarding helper: copies an example swarm's agent list into the current
|
|
289
|
+
``agentainer.yaml`` when it has no agents yet, and pulls in the template's
|
|
290
|
+
``defaults`` block if the target has none. Returns the added agent names.
|
|
291
|
+
Raises ``ValueError`` if the config already has agents (templates seed a
|
|
292
|
+
fresh swarm rather than merging into a live one).
|
|
293
|
+
"""
|
|
294
|
+
raw = load_raw(cfg.path)
|
|
295
|
+
if raw.get("agents"):
|
|
296
|
+
raise ValueError("swarm already has agents")
|
|
297
|
+
if defaults and not raw.get("defaults"):
|
|
298
|
+
raw["defaults"] = dict(defaults)
|
|
299
|
+
raw["agents"] = list(agents)
|
|
300
|
+
write_raw(cfg.path, raw)
|
|
301
|
+
return [str(a.get("name")) for a in agents if a.get("name")]
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def edit_agent(cfg, name, **fields) -> "cfgmod.SwarmConfig":
|
|
305
|
+
"""Update *name*'s fields on disk and return a re-loaded SwarmConfig.
|
|
306
|
+
|
|
307
|
+
Field values are coerced by ``_coerce_field`` (so ``--set can_talk_to=a,b``
|
|
308
|
+
becomes a list, ``--set boot_delay_ms=500`` becomes an int). Unknown agents
|
|
309
|
+
raise ``ValueError``.
|
|
310
|
+
"""
|
|
311
|
+
raw = load_raw(cfg.path)
|
|
312
|
+
found = False
|
|
313
|
+
for a in raw.get("agents") or []:
|
|
314
|
+
if str(a.get("name")) == name:
|
|
315
|
+
for k, v in fields.items():
|
|
316
|
+
a[k] = _coerce_field(k, str(v))
|
|
317
|
+
found = True
|
|
318
|
+
break
|
|
319
|
+
if not found:
|
|
320
|
+
raise ValueError(f"agent {name!r} not found")
|
|
321
|
+
return _commit(cfg, raw)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# --------------------------------------------------------------------------
|
|
325
|
+
# reconcile (the runtime diff)
|
|
326
|
+
# --------------------------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _running_sessions(prefix: str) -> list[str]:
|
|
330
|
+
"""Names of live tmux sessions whose name starts with *prefix*."""
|
|
331
|
+
try:
|
|
332
|
+
out = tmux.tmux(
|
|
333
|
+
"list-sessions", "-F", "#{session_name}", check=False, capture=True
|
|
334
|
+
).stdout or ""
|
|
335
|
+
except Exception:
|
|
336
|
+
return []
|
|
337
|
+
return [s.strip() for s in out.splitlines() if s.strip().startswith(prefix)]
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _agent_for_session(cfg, session_name: str):
|
|
341
|
+
"""Map a running session name back to a configured agent, or None."""
|
|
342
|
+
cand = session_name[len(cfg.session_prefix):]
|
|
343
|
+
return cfg.get(cand) if cand in cfg.names() else None
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def diff(cfg) -> dict:
|
|
347
|
+
"""Compare configured agents to running tmux sessions.
|
|
348
|
+
|
|
349
|
+
Returns ``{configured, running, missing, extra}`` -- the agent names that
|
|
350
|
+
are configured, currently running, configured-but-not-running (missing), and
|
|
351
|
+
running-but-not-configured (extra sessions to stop).
|
|
352
|
+
"""
|
|
353
|
+
running = [a.name for a in cfg.agents if tmux.session_exists(a.session)]
|
|
354
|
+
configured = [a.name for a in cfg.agents]
|
|
355
|
+
extras = [
|
|
356
|
+
s for s in _running_sessions(cfg.session_prefix) if _agent_for_session(cfg, s) is None
|
|
357
|
+
]
|
|
358
|
+
missing = [n for n in configured if n not in running]
|
|
359
|
+
return {
|
|
360
|
+
"configured": sorted(configured),
|
|
361
|
+
"running": sorted(running),
|
|
362
|
+
"missing": sorted(missing),
|
|
363
|
+
"extra": sorted(extras),
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _start_agent(cfg, agent, start_fn) -> None:
|
|
368
|
+
"""Bring *agent* up: ensure dirs, then launch via *start_fn* (launch_agent_full)."""
|
|
369
|
+
for directory in (cfg.runtime, cfg.log_dir, cfg.queue_dir, cfg.run_dir):
|
|
370
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
371
|
+
mail.init_mailboxes(cfg)
|
|
372
|
+
start_fn(cfg, agent, None)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def reconcile(cfg, *, start_missing: bool = True, stop_extra: bool = True, _start_fn=None) -> dict:
|
|
376
|
+
"""Make the running swarm match *cfg*.
|
|
377
|
+
|
|
378
|
+
Starts agents that are configured but not running, and stops tmux sessions
|
|
379
|
+
that are running but no longer configured. Returns a summary dict.
|
|
380
|
+
|
|
381
|
+
``_start_fn`` is injectable (defaults to ``cli.launch_agent_full``) so tests
|
|
382
|
+
can observe the start path without a real tmux session.
|
|
383
|
+
"""
|
|
384
|
+
start_fn = _start_fn
|
|
385
|
+
if start_fn is None:
|
|
386
|
+
import cli as _cli # lazy: avoid an import cycle with cli <-> reconcile
|
|
387
|
+
|
|
388
|
+
start_fn = _cli.launch_agent_full
|
|
389
|
+
|
|
390
|
+
d = diff(cfg)
|
|
391
|
+
started: list[str] = []
|
|
392
|
+
stopped: list[str] = []
|
|
393
|
+
|
|
394
|
+
if start_missing:
|
|
395
|
+
for name in d["missing"]:
|
|
396
|
+
agent = cfg.get(name)
|
|
397
|
+
_start_agent(cfg, agent, start_fn)
|
|
398
|
+
started.append(name)
|
|
399
|
+
info(f"reconcile: started {name}")
|
|
400
|
+
|
|
401
|
+
if stop_extra:
|
|
402
|
+
for session_name in d["extra"]:
|
|
403
|
+
tmux.tmux("kill-session", "-t", f"={session_name}", check=False, capture=True)
|
|
404
|
+
stopped.append(session_name)
|
|
405
|
+
info(f"reconcile: stopped extra session {session_name}")
|
|
406
|
+
|
|
407
|
+
return {
|
|
408
|
+
"started": started,
|
|
409
|
+
"stopped": stopped,
|
|
410
|
+
"running": d["running"],
|
|
411
|
+
"missing": d["missing"],
|
|
412
|
+
"extra": d["extra"],
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def start_one(cfg, name: str, *, _start_fn=None) -> bool:
|
|
417
|
+
"""Bring a single configured agent up if its tmux session isn't running.
|
|
418
|
+
|
|
419
|
+
Returns True if the agent was (re)launched, False if it was already running.
|
|
420
|
+
Raises ConfigError via ``cfg.get`` for an unknown name.
|
|
421
|
+
"""
|
|
422
|
+
agent = cfg.get(name)
|
|
423
|
+
if tmux.session_exists(agent.session):
|
|
424
|
+
return False
|
|
425
|
+
start_fn = _start_fn
|
|
426
|
+
if start_fn is None:
|
|
427
|
+
import cli as _cli # lazy: avoid an import cycle with cli <-> reconcile
|
|
428
|
+
|
|
429
|
+
start_fn = _cli.launch_agent_full
|
|
430
|
+
_start_agent(cfg, agent, start_fn)
|
|
431
|
+
info(f"start_one: started {name}")
|
|
432
|
+
return True
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def stop_one(cfg, name: str) -> bool:
|
|
436
|
+
"""Kill a single agent's tmux session if it's running (config is untouched).
|
|
437
|
+
|
|
438
|
+
Returns True if a running session was killed, False if it was already down.
|
|
439
|
+
Raises ConfigError via ``cfg.get`` for an unknown name.
|
|
440
|
+
"""
|
|
441
|
+
agent = cfg.get(name)
|
|
442
|
+
if not tmux.session_exists(agent.session):
|
|
443
|
+
return False
|
|
444
|
+
tmux.tmux("kill-session", "-t", f"={agent.session}", check=False, capture=True)
|
|
445
|
+
info(f"stop_one: stopped {name}")
|
|
446
|
+
return True
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def start_all(cfg, *, _start_fn=None) -> list:
|
|
450
|
+
"""Start every configured-but-not-running agent; return the started names.
|
|
451
|
+
|
|
452
|
+
A thin wrapper over ``reconcile`` (start-only) so the UI's "Start all" button
|
|
453
|
+
has one authoritative launch path. ``_start_fn`` is injectable for tests.
|
|
454
|
+
"""
|
|
455
|
+
return reconcile(cfg, start_missing=True, stop_extra=False, _start_fn=_start_fn)["started"]
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def stop_all(cfg) -> list:
|
|
459
|
+
"""Kill every running agent session (config untouched); return stopped names.
|
|
460
|
+
|
|
461
|
+
Mirrors ``stop_one`` across the whole swarm for the UI's "Stop all" button;
|
|
462
|
+
agents that are already down are skipped.
|
|
463
|
+
"""
|
|
464
|
+
stopped: list[str] = []
|
|
465
|
+
for a in cfg.agents:
|
|
466
|
+
if tmux.session_exists(a.session):
|
|
467
|
+
tmux.tmux("kill-session", "-t", f"={a.session}", check=False, capture=True)
|
|
468
|
+
stopped.append(a.name)
|
|
469
|
+
info(f"stop_all: stopped {a.name}")
|
|
470
|
+
return stopped
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
# --------------------------------------------------------------------------
|
|
474
|
+
# CLI handlers
|
|
475
|
+
# --------------------------------------------------------------------------
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _parse_can_talk_to(raw: str):
|
|
479
|
+
raw = (raw or "").strip()
|
|
480
|
+
if raw == "*" or raw == "":
|
|
481
|
+
return raw or []
|
|
482
|
+
return [p.strip() for p in raw.split(",") if p.strip()]
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def cmd_add(args) -> int:
|
|
486
|
+
cfg = cfgmod.load(args.config)
|
|
487
|
+
can_talk_to = _parse_can_talk_to(args.can_talk_to)
|
|
488
|
+
new_cfg = add_agent(
|
|
489
|
+
cfg,
|
|
490
|
+
args.name,
|
|
491
|
+
args.type,
|
|
492
|
+
args.command,
|
|
493
|
+
can_talk_to,
|
|
494
|
+
role=args.role or "",
|
|
495
|
+
workdir=args.workdir,
|
|
496
|
+
)
|
|
497
|
+
result = reconcile(new_cfg)
|
|
498
|
+
info(f"added agent {args.name!r}; reconcile started {result['started']} stopped {result['stopped']}")
|
|
499
|
+
return 0
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def cmd_remove(args) -> int:
|
|
503
|
+
cfg = cfgmod.load(args.config)
|
|
504
|
+
if args.name not in cfg.names():
|
|
505
|
+
warn(f"agent {args.name!r} not found in config")
|
|
506
|
+
return 1
|
|
507
|
+
# Stop the session first so it isn't orphaned, then drop it from the config.
|
|
508
|
+
agent = cfg.get(args.name)
|
|
509
|
+
if tmux.session_exists(agent.session):
|
|
510
|
+
tmux.tmux("kill-session", "-t", f"={agent.session}", check=False, capture=True)
|
|
511
|
+
info(f"stopped {args.name}")
|
|
512
|
+
new_cfg = remove_agent(cfg, args.name)
|
|
513
|
+
result = reconcile(new_cfg)
|
|
514
|
+
info(f"removed agent {args.name!r}; reconcile stopped {result['stopped']}")
|
|
515
|
+
return 0
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def cmd_edit(args) -> int:
|
|
519
|
+
cfg = cfgmod.load(args.config)
|
|
520
|
+
fields = {}
|
|
521
|
+
for pair in args.set or []:
|
|
522
|
+
if "=" not in pair:
|
|
523
|
+
warn(f"--set value {pair!r} is not key=value; skipping")
|
|
524
|
+
continue
|
|
525
|
+
k, v = pair.split("=", 1)
|
|
526
|
+
fields[k.strip()] = v
|
|
527
|
+
if not fields:
|
|
528
|
+
warn("edit: no --set key=value pairs given")
|
|
529
|
+
return 1
|
|
530
|
+
new_cfg = edit_agent(cfg, args.name, **fields)
|
|
531
|
+
result = reconcile(new_cfg)
|
|
532
|
+
info(f"edited {args.name!r}; reconcile started {result['started']} stopped {result['stopped']}")
|
|
533
|
+
return 0
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def cmd_reconcile(args) -> int:
|
|
537
|
+
cfg = cfgmod.load(args.config)
|
|
538
|
+
result = reconcile(cfg)
|
|
539
|
+
info(
|
|
540
|
+
f"reconcile: running={result['running']} "
|
|
541
|
+
f"started={result['started']} stopped={result['stopped']} "
|
|
542
|
+
f"missing={result['missing']} extra={result['extra']}"
|
|
543
|
+
)
|
|
544
|
+
return 0
|