agentainer 0.1.7 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +248 -677
- package/agentainer +16 -18
- package/agentainer.example.yaml +86 -0
- package/bin/agentainer.js +9 -8
- package/examples/brainstorm.yaml +27 -128
- package/examples/bug-hunt.yaml +51 -96
- package/examples/code-review.yaml +73 -0
- package/examples/debate.yaml +16 -90
- package/examples/incident-response.yaml +52 -109
- package/examples/localization.yaml +56 -123
- package/examples/quickstart.yaml +48 -0
- package/examples/research.yaml +25 -0
- package/examples/software-company.yaml +71 -128
- package/examples/tdd-pingpong.yaml +36 -68
- package/examples/writers-room.yaml +49 -111
- package/hooks/claude_stop.sh +5 -3
- package/hooks/codex_notify.sh +4 -3
- package/lib/cli.py +929 -0
- package/lib/config.py +247 -305
- package/lib/hooks.py +246 -0
- package/lib/lock.py +75 -0
- package/lib/log.py +64 -0
- package/lib/mail.py +634 -0
- package/lib/minyaml.py +1 -39
- package/lib/reconcile.py +473 -0
- package/lib/sessions.py +223 -0
- package/lib/supervisor.py +216 -0
- package/lib/telegram.py +372 -0
- package/lib/tmux.py +355 -0
- package/lib/turn.py +159 -0
- package/lib/ui.py +1020 -0
- package/llms.txt +145 -429
- package/package.json +9 -7
- package/scripts/check-deps.js +18 -61
- package/ui/app.js +869 -0
- package/ui/index.html +348 -0
- package/agents.example.yaml +0 -257
- package/examples/code-review-broadcast.yaml +0 -109
- package/examples/existing-repo.yaml +0 -74
- package/examples/multi-language-broadcast.yaml +0 -127
- package/examples/ping-pong.yaml +0 -89
- package/examples/red-team.yaml +0 -117
- package/examples/research-swarm.yaml +0 -129
- package/lib/swarm.py +0 -2461
package/lib/config.py
CHANGED
|
@@ -1,4 +1,21 @@
|
|
|
1
|
-
"""Load, normalise and validate an Agentainer YAML config.
|
|
1
|
+
"""Load, normalise and validate an Agentainer YAML config.
|
|
2
|
+
|
|
3
|
+
This is the contract anchor every other module imports. It ports v1's proven
|
|
4
|
+
config loader but adapts the schema to the v2 file-based mail model:
|
|
5
|
+
|
|
6
|
+
* the agent's standing instructions are now ``role`` (``first_prompt`` is a
|
|
7
|
+
deprecated alias);
|
|
8
|
+
* the XML-envelope comms/reply-reminder machinery is gone -- v2 is plain
|
|
9
|
+
natural-language mail files read/written by the model;
|
|
10
|
+
* per-agent ``mail_dir`` selects where the four mailbox folders live, with
|
|
11
|
+
automatic namespacing when two agents share one workspace (see ``mail_paths``);
|
|
12
|
+
* ``user``/``system`` are reserved virtual mailboxes, never agent names;
|
|
13
|
+
* new ``type`` <-> ``command`` mismatch detection prevents the silent-deadlock
|
|
14
|
+
footgun carried forward from v1.
|
|
15
|
+
|
|
16
|
+
The orchestrator owns all routing/ACL/state; the model only reads and writes
|
|
17
|
+
natural-language files. See ProjectPlan.md (§4-§16, §24, §29).
|
|
18
|
+
"""
|
|
2
19
|
|
|
3
20
|
from __future__ import annotations
|
|
4
21
|
|
|
@@ -6,6 +23,7 @@ import os
|
|
|
6
23
|
import re
|
|
7
24
|
from dataclasses import dataclass, field
|
|
8
25
|
from pathlib import Path
|
|
26
|
+
from types import SimpleNamespace
|
|
9
27
|
from typing import Any
|
|
10
28
|
|
|
11
29
|
try: # pragma: no cover - exercised by whichever branch is installed
|
|
@@ -30,6 +48,12 @@ class ConfigError(Exception):
|
|
|
30
48
|
pass
|
|
31
49
|
|
|
32
50
|
|
|
51
|
+
# The four real coding-agent CLIs. A `command` that launches a *different* one
|
|
52
|
+
# than `type` implies will never fire its turn-completion signal and wedges the
|
|
53
|
+
# agent. Used by the mismatch detector below. A mock command (e.g.
|
|
54
|
+
# `bash -c 'while true; do read ...'`) contains none of these tokens and passes.
|
|
55
|
+
CLI_TOKENS = ("claude", "codex", "gemini", "hermes")
|
|
56
|
+
|
|
33
57
|
# Built-in knowledge about each supported coding agent. `capture` says how we
|
|
34
58
|
# learn that the agent finished a turn:
|
|
35
59
|
# hook -- the CLI can call an external program on turn completion
|
|
@@ -64,137 +88,8 @@ BUILTIN_AGENT_TYPES: dict[str, dict[str, Any]] = {
|
|
|
64
88
|
|
|
65
89
|
VALID_CAPTURE = ("hook", "pane", "none", "auto")
|
|
66
90
|
|
|
67
|
-
VALID_MESSAGE_FORMATS = ("tagged", "plain")
|
|
68
|
-
|
|
69
91
|
NAME_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_-]*$")
|
|
70
92
|
|
|
71
|
-
DEFAULT_COMMS_TEMPLATE = """\
|
|
72
|
-
---
|
|
73
|
-
## Inter-agent communication protocol
|
|
74
|
-
|
|
75
|
-
You are the agent **{agent}** on the **{swarm}** team.
|
|
76
|
-
Agents you are allowed to message: {peers}
|
|
77
|
-
|
|
78
|
-
### Receiving
|
|
79
|
-
|
|
80
|
-
Messages arrive in your prompt inside a tag, so you can always tell where one
|
|
81
|
-
starts and ends:
|
|
82
|
-
|
|
83
|
-
<swarm-message from="SOMEONE" id="m-1a2b3c">
|
|
84
|
-
the message body,
|
|
85
|
-
which may span many lines
|
|
86
|
-
</swarm-message>
|
|
87
|
-
|
|
88
|
-
Note the `id`. Quote it as `reply-to` when you answer, so the other agent knows
|
|
89
|
-
which of its questions you are answering. Every message is also archived under
|
|
90
|
-
`{inbox}` if you need to re-read one.
|
|
91
|
-
|
|
92
|
-
### Sending
|
|
93
|
-
|
|
94
|
-
Sending is a deliberate act, not something every turn needs. **Most of your turns
|
|
95
|
-
will contain no tag at all** -- you think, run commands, and work in your own
|
|
96
|
-
directory in plain text, exactly as you normally would. Write a message block ONLY
|
|
97
|
-
when you actually want to hand something to another agent: a question, a result, or
|
|
98
|
-
a handoff. When you have nothing to send, send nothing.
|
|
99
|
-
|
|
100
|
-
When you do want to send, write this block **in your reply**. It is read as soon as
|
|
101
|
-
your turn ends, then delivered. Do not escape or quote the body -- write it
|
|
102
|
-
literally, across as many lines as you need:
|
|
103
|
-
|
|
104
|
-
<swarm-send to="AGENT_NAME" reply-to="m-1a2b3c">
|
|
105
|
-
Your message. Multiple lines, code blocks and quotes are all fine.
|
|
106
|
-
</swarm-send>
|
|
107
|
-
|
|
108
|
-
To reach everyone you are allowed to talk to, use `<swarm-broadcast>` with no `to`.
|
|
109
|
-
|
|
110
|
-
The `reply-to` attribute is optional; drop it when you are starting a new thread.
|
|
111
|
-
Quoting it also marks your message as an answer, so the other agent is not
|
|
112
|
-
chased for a reply to it. For an announcement that needs no answer, either use
|
|
113
|
-
`<swarm-broadcast>` or add `expects-reply="false"`.
|
|
114
|
-
|
|
115
|
-
You may also send from your shell, which is useful mid-task rather than at the
|
|
116
|
-
end of a turn (but you must then quote the text yourself):
|
|
117
|
-
|
|
118
|
-
swarm send --to AGENT_NAME "MESSAGE TEXT"
|
|
119
|
-
swarm broadcast "MESSAGE TEXT"
|
|
120
|
-
|
|
121
|
-
### When the other agent is busy
|
|
122
|
-
|
|
123
|
-
An agent already working on a task will refuse your message. That is normal. A
|
|
124
|
-
tagged `<swarm-send>` is queued for them automatically. From the shell you choose:
|
|
125
|
-
|
|
126
|
-
swarm send --to AGENT_NAME --queue "MESSAGE TEXT" # delivered when they are free
|
|
127
|
-
swarm send --to AGENT_NAME --wait "MESSAGE TEXT" # block until they are free
|
|
128
|
-
swarm queue AGENT_NAME # see what is waiting for them
|
|
129
|
-
|
|
130
|
-
Prefer queueing, then get on with other work. Never spin in a retry loop.
|
|
131
|
-
|
|
132
|
-
### Rules
|
|
133
|
-
|
|
134
|
-
* `AGENT_NAME`, `MESSAGE TEXT` and `m-1a2b3c` above are placeholders. Replace
|
|
135
|
-
them with a real agent from the list and what you actually want to say. A
|
|
136
|
-
`<swarm-send>` addressed to a name that is not an agent is discarded.
|
|
137
|
-
* Only message the agents listed above. Messaging anyone else will be refused.
|
|
138
|
-
* Keep messages self-contained -- the other agent does not see your screen,
|
|
139
|
-
your files, or your reasoning.
|
|
140
|
-
* Do not message an agent merely to acknowledge. Send when you have a question,
|
|
141
|
-
a result, or a handoff.
|
|
142
|
-
* Write a full tag block only when you actually want to send. A complete
|
|
143
|
-
`<swarm-send to="...">...</swarm-send>` you write is delivered, so do not paste
|
|
144
|
-
a filled-in example to illustrate a point -- it would be sent for real. Naming
|
|
145
|
-
the tag in passing while you explain your plan is fine.
|
|
146
|
-
* A turn with no tag simply sends nothing, which is normal and expected. The one
|
|
147
|
-
exception: if another agent asked YOU something and your turn ends with no
|
|
148
|
-
block, your answer reaches nobody, so you get a single reminder. Never add a
|
|
149
|
-
block just to satisfy the reminder -- only to actually answer.
|
|
150
|
-
"""
|
|
151
|
-
|
|
152
|
-
DEFAULT_REPLY_REMINDER_TEMPLATE = """\
|
|
153
|
-
Your last turn sent no message to anyone, and {sender} is waiting on your answer
|
|
154
|
-
to message {id}.
|
|
155
|
-
|
|
156
|
-
{problems}
|
|
157
|
-
|
|
158
|
-
Anything you write outside a message block stays on your own screen. The other
|
|
159
|
-
agent cannot see your reasoning, your files, or your reply. To actually send it,
|
|
160
|
-
put your answer inside a block exactly like this:
|
|
161
|
-
|
|
162
|
-
<swarm-send to="{sender}" reply-to="{id}">
|
|
163
|
-
your answer here, over as many lines as you need
|
|
164
|
-
</swarm-send>
|
|
165
|
-
|
|
166
|
-
Agents you may message: {peers}
|
|
167
|
-
|
|
168
|
-
If you already wrote the answer, write it again inside that block. If you
|
|
169
|
-
deliberately have nothing to send, ignore this -- you will not be reminded again.
|
|
170
|
-
"""
|
|
171
|
-
|
|
172
|
-
DEFAULT_SEND_FAILED_TEMPLATE = """\
|
|
173
|
-
Your last turn tried to send a message, but it could not be delivered.
|
|
174
|
-
|
|
175
|
-
{problems}
|
|
176
|
-
|
|
177
|
-
Fix it and send again. The block must look exactly like this, closing tag included:
|
|
178
|
-
|
|
179
|
-
<swarm-send to="AGENT_NAME">
|
|
180
|
-
your message here
|
|
181
|
-
</swarm-send>
|
|
182
|
-
|
|
183
|
-
Agents you may message: {peers}
|
|
184
|
-
|
|
185
|
-
You will not be reminded again.
|
|
186
|
-
"""
|
|
187
|
-
|
|
188
|
-
DEFAULT_TASK_NOTICE_TEMPLATE = """\
|
|
189
|
-
---
|
|
190
|
-
## Standby
|
|
191
|
-
|
|
192
|
-
Do not start any work yet. Your actual task will be sent to you in the **next**
|
|
193
|
-
prompt. For now, reply with one plain sentence confirming you understood your role
|
|
194
|
-
-- no message block and no tags, this confirmation is just for your own screen and
|
|
195
|
-
is not sent to anyone -- then wait.
|
|
196
|
-
"""
|
|
197
|
-
|
|
198
93
|
|
|
199
94
|
@dataclass
|
|
200
95
|
class Agent:
|
|
@@ -205,19 +100,34 @@ class Agent:
|
|
|
205
100
|
session: str
|
|
206
101
|
capture: str
|
|
207
102
|
boot_delay_ms: int
|
|
208
|
-
|
|
209
|
-
can_talk_to: list[str]
|
|
210
|
-
|
|
103
|
+
role: str
|
|
104
|
+
can_talk_to: list[str]
|
|
105
|
+
mail_dir: Path
|
|
106
|
+
periodically_ping_seconds: int = 0
|
|
107
|
+
periodically_ping_message: str = ""
|
|
108
|
+
resume_args: str | None = None
|
|
109
|
+
resume_command: str | None = None
|
|
211
110
|
env: dict[str, str] = field(default_factory=dict)
|
|
212
|
-
append_peers_prompt: bool = True
|
|
213
|
-
append_task_notice: bool = False
|
|
214
|
-
ready_probe: bool = True
|
|
215
111
|
create_workdir: bool = True
|
|
112
|
+
ready_probe: bool = True
|
|
216
113
|
busy_check: bool = True
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class TelegramConfig:
|
|
118
|
+
"""Optional Telegram bridge settings (parsed from a top-level ``telegram:``).
|
|
119
|
+
|
|
120
|
+
Off by default. ``mirror`` is ``"*"`` (mirror every agent's mail) or a list of
|
|
121
|
+
agent names. Mail addressed to ``user`` is mirrored per ``mirror_user`` so the
|
|
122
|
+
human stays reachable even while "away"; ``system`` noise is off by default.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
enabled: bool = False
|
|
126
|
+
bot_token: str = ""
|
|
127
|
+
chat_id: str = ""
|
|
128
|
+
mirror: Any = "*"
|
|
129
|
+
mirror_user: bool = True
|
|
130
|
+
mirror_system: bool = False
|
|
221
131
|
|
|
222
132
|
|
|
223
133
|
@dataclass
|
|
@@ -227,45 +137,54 @@ class SwarmConfig:
|
|
|
227
137
|
root: Path
|
|
228
138
|
session_prefix: str
|
|
229
139
|
agents: list[Agent]
|
|
140
|
+
# Paste-timing knobs consumed by lib/tmux.py (keep in sync with that module).
|
|
230
141
|
enter_delay_ms: int = 250
|
|
231
142
|
send_delay_ms: int = 150
|
|
232
|
-
|
|
143
|
+
supervise: bool = True
|
|
144
|
+
supervise_interval_ms: int = 15000
|
|
233
145
|
ready_timeout_ms: int = 60000
|
|
234
146
|
busy_timeout_ms: int = 900000
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
resume
|
|
238
|
-
|
|
239
|
-
|
|
147
|
+
# Resume is ON by default: `up` reattaches each agent to the conversation
|
|
148
|
+
# recorded in .agentainer/sessions.yaml. Opt out with `swarm.resume: false`
|
|
149
|
+
# in the config, or `agentainer up --no-resume`, or `agentainer remove-session`.
|
|
150
|
+
resume: bool = True
|
|
151
|
+
user_available: bool = False
|
|
240
152
|
pane_idle_ms: int = 2500
|
|
241
153
|
pane_poll_ms: int = 700
|
|
242
154
|
pane_scrollback: int = 400
|
|
243
155
|
tmux_history_limit: int = 50000
|
|
244
156
|
tmux_mouse: bool = True
|
|
245
|
-
|
|
246
|
-
supervise_interval_ms: int = 15000
|
|
157
|
+
telegram: "TelegramConfig" = field(default_factory=TelegramConfig)
|
|
247
158
|
warnings: list[str] = field(default_factory=list)
|
|
248
159
|
|
|
160
|
+
def __post_init__(self) -> None:
|
|
161
|
+
# Set of resolved workdirs shared by 2+ agents. Computed up front so
|
|
162
|
+
# `mail_paths` can namespace mailbox folders to avoid collisions.
|
|
163
|
+
counts: dict[Path, int] = {}
|
|
164
|
+
for agent in self.agents:
|
|
165
|
+
key = agent.workdir.resolve()
|
|
166
|
+
counts[key] = counts.get(key, 0) + 1
|
|
167
|
+
self._shared: set[Path] = {d for d, n in counts.items() if n > 1}
|
|
168
|
+
|
|
249
169
|
@property
|
|
250
170
|
def runtime(self) -> Path:
|
|
251
|
-
|
|
171
|
+
"""Orchestrator-private state (logs, queue, run, sessions)."""
|
|
172
|
+
return self.root / ".agentainer"
|
|
252
173
|
|
|
253
174
|
@property
|
|
254
175
|
def log_dir(self) -> Path:
|
|
255
176
|
return self.runtime / "logs"
|
|
256
177
|
|
|
257
178
|
@property
|
|
258
|
-
def
|
|
259
|
-
|
|
179
|
+
def queue_dir(self) -> Path:
|
|
180
|
+
# Per-agent pending-message buffer (one-at-a-time release lives here).
|
|
181
|
+
# Not created by the config layer -- just the authoritative path.
|
|
182
|
+
return self.runtime / "queue"
|
|
260
183
|
|
|
261
184
|
@property
|
|
262
185
|
def run_dir(self) -> Path:
|
|
263
186
|
return self.runtime / "run"
|
|
264
187
|
|
|
265
|
-
@property
|
|
266
|
-
def bin_dir(self) -> Path:
|
|
267
|
-
return self.runtime / "bin"
|
|
268
|
-
|
|
269
188
|
@property
|
|
270
189
|
def sessions_file(self) -> Path:
|
|
271
190
|
"""Where each agent's conversation id is recorded, so `up --resume` works."""
|
|
@@ -281,6 +200,24 @@ class SwarmConfig:
|
|
|
281
200
|
def names(self) -> list[str]:
|
|
282
201
|
return [a.name for a in self.agents]
|
|
283
202
|
|
|
203
|
+
def mail_paths(self, agent: Agent) -> SimpleNamespace:
|
|
204
|
+
"""Resolve the five mailbox folders for *agent* (plan §16).
|
|
205
|
+
|
|
206
|
+
Base is ``agent.mail_dir``. When the agent's workdir is shared by more
|
|
207
|
+
than one agent, every folder is prefixed with ``<name>-`` to avoid
|
|
208
|
+
collisions. The model never sees this -- every nudge/first-prompt is
|
|
209
|
+
handed the exact computed paths.
|
|
210
|
+
"""
|
|
211
|
+
base = agent.mail_dir
|
|
212
|
+
prefix = agent.name + "-" if agent.workdir.resolve() in self._shared else ""
|
|
213
|
+
return SimpleNamespace(
|
|
214
|
+
inbox=base / (prefix + "inbox"),
|
|
215
|
+
outbox=base / (prefix + "outbox"),
|
|
216
|
+
read=base / (prefix + "read"),
|
|
217
|
+
sent=base / (prefix + "sent"),
|
|
218
|
+
failed=base / (prefix + "failed"),
|
|
219
|
+
)
|
|
220
|
+
|
|
284
221
|
|
|
285
222
|
def _as_list(value: Any, ctx: str) -> list[str]:
|
|
286
223
|
if value is None:
|
|
@@ -308,13 +245,56 @@ def _as_str_map(value: Any, ctx: str) -> dict[str, str]:
|
|
|
308
245
|
return {str(k): str(v) for k, v in value.items()}
|
|
309
246
|
|
|
310
247
|
|
|
248
|
+
def _parse_mirror(value: Any) -> Any:
|
|
249
|
+
"""Normalise ``telegram.mirror``: ``*``/``all``/None -> ``"*"``; else a list."""
|
|
250
|
+
if value is None:
|
|
251
|
+
return "*"
|
|
252
|
+
if isinstance(value, str):
|
|
253
|
+
return "*" if value.strip().lower() in ("*", "all") else [value]
|
|
254
|
+
if isinstance(value, list):
|
|
255
|
+
return [str(v) for v in value]
|
|
256
|
+
raise ConfigError("telegram.mirror: expected '*', 'all', or a list of agent names")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _load_telegram(data: dict) -> "TelegramConfig":
|
|
260
|
+
raw = data.get("telegram") or {}
|
|
261
|
+
if not isinstance(raw, dict):
|
|
262
|
+
raise ConfigError("`telegram:` must be a mapping")
|
|
263
|
+
return TelegramConfig(
|
|
264
|
+
enabled=_as_bool(raw.get("enabled"), False, "telegram.enabled"),
|
|
265
|
+
bot_token=str(raw.get("bot_token") or ""),
|
|
266
|
+
chat_id=str(raw.get("chat_id") or ""),
|
|
267
|
+
mirror=_parse_mirror(raw.get("mirror")),
|
|
268
|
+
mirror_user=_as_bool(raw.get("mirror_user"), True, "telegram.mirror_user"),
|
|
269
|
+
mirror_system=_as_bool(raw.get("mirror_system"), False, "telegram.mirror_system"),
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _expand_path(raw: Any, ctx: str, name: str, root: Path, swarm_name: str,
|
|
274
|
+
atype: str, cfg_parent: Path) -> Path:
|
|
275
|
+
"""Expand an optional {name}/{root}/{swarm}/{type} placeholder and resolve
|
|
276
|
+
the result relative to the config file's parent directory."""
|
|
277
|
+
text = str(raw)
|
|
278
|
+
try:
|
|
279
|
+
expanded = text.format(name=name, root=str(root), swarm=swarm_name, type=atype)
|
|
280
|
+
except (KeyError, IndexError) as exc:
|
|
281
|
+
raise ConfigError(
|
|
282
|
+
f"{ctx}: unknown placeholder in {raw!r}: {exc}. "
|
|
283
|
+
"Available: {name} {root} {swarm} {type}"
|
|
284
|
+
) from exc
|
|
285
|
+
path = Path(os.path.expanduser(expanded))
|
|
286
|
+
if not path.is_absolute():
|
|
287
|
+
path = (cfg_parent / path).resolve()
|
|
288
|
+
return path
|
|
289
|
+
|
|
290
|
+
|
|
311
291
|
def load(path: str | os.PathLike) -> SwarmConfig:
|
|
312
292
|
cfg_path = Path(path).expanduser().resolve()
|
|
313
293
|
if not cfg_path.is_file():
|
|
314
294
|
raise ConfigError(
|
|
315
295
|
f"config file not found: {cfg_path}\n"
|
|
316
|
-
" Create one with: cp
|
|
317
|
-
" Or point at it: agentainer -c /path/to/
|
|
296
|
+
" Create one with: cp agentainer.example.yaml agentainer.yaml\n"
|
|
297
|
+
" Or point at it: agentainer -c /path/to/agentainer.yaml up"
|
|
318
298
|
)
|
|
319
299
|
|
|
320
300
|
try:
|
|
@@ -333,12 +313,6 @@ def load(path: str | os.PathLike) -> SwarmConfig:
|
|
|
333
313
|
if not isinstance(defaults, dict):
|
|
334
314
|
raise ConfigError("`defaults:` must be a mapping")
|
|
335
315
|
|
|
336
|
-
templates = data.get("templates") or {}
|
|
337
|
-
comms_tpl = templates.get("comms") or DEFAULT_COMMS_TEMPLATE
|
|
338
|
-
notice_tpl = templates.get("task_notice") or DEFAULT_TASK_NOTICE_TEMPLATE
|
|
339
|
-
reminder_tpl = templates.get("reply_reminder") or DEFAULT_REPLY_REMINDER_TEMPLATE
|
|
340
|
-
failed_tpl = templates.get("send_failed") or DEFAULT_SEND_FAILED_TEMPLATE
|
|
341
|
-
|
|
342
316
|
# Agent type registry: built-ins, overridable and extensible from YAML.
|
|
343
317
|
types: dict[str, dict[str, Any]] = {
|
|
344
318
|
k: dict(v) for k, v in BUILTIN_AGENT_TYPES.items()
|
|
@@ -357,23 +331,20 @@ def load(path: str | os.PathLike) -> SwarmConfig:
|
|
|
357
331
|
swarm_name = str(swarm.get("name") or cfg_path.stem)
|
|
358
332
|
create_workdirs = _as_bool(swarm.get("create_workdirs"), True, "swarm.create_workdirs")
|
|
359
333
|
|
|
360
|
-
message_format = str(swarm.get("message_format") or "tagged")
|
|
361
|
-
if message_format not in VALID_MESSAGE_FORMATS:
|
|
362
|
-
raise ConfigError(
|
|
363
|
-
f"swarm.message_format must be one of {', '.join(VALID_MESSAGE_FORMATS)}"
|
|
364
|
-
)
|
|
365
|
-
|
|
366
334
|
raw_agents = data.get("agents")
|
|
367
335
|
if not raw_agents:
|
|
368
336
|
raise ConfigError("`agents:` must contain at least one agent")
|
|
369
337
|
if not isinstance(raw_agents, list):
|
|
370
338
|
raise ConfigError("`agents:` must be a list")
|
|
371
339
|
|
|
340
|
+
# Default mailbox base, if any (overridable per agent).
|
|
341
|
+
default_mail_dir_raw = defaults.get("mail_dir")
|
|
342
|
+
|
|
372
343
|
# Pass 1: materialise agents without resolving peer references.
|
|
373
344
|
agents: list[Agent] = []
|
|
374
345
|
seen: set[str] = set()
|
|
375
|
-
# Warnings collected while resolving each agent (
|
|
376
|
-
|
|
346
|
+
# Warnings collected while resolving each agent (capture upgrades + deprecations).
|
|
347
|
+
agent_warnings: list[str] = []
|
|
377
348
|
for index, raw in enumerate(raw_agents):
|
|
378
349
|
if not isinstance(raw, dict):
|
|
379
350
|
raise ConfigError(f"agents[{index}]: must be a mapping")
|
|
@@ -385,6 +356,11 @@ def load(path: str | os.PathLike) -> SwarmConfig:
|
|
|
385
356
|
f"agent {name!r}: name must match {NAME_RE.pattern} "
|
|
386
357
|
"(it is used as a tmux session name and a directory name)"
|
|
387
358
|
)
|
|
359
|
+
if name in ("user", "system"):
|
|
360
|
+
raise ConfigError(
|
|
361
|
+
f"agent {name!r}: user and system are reserved virtual mailboxes "
|
|
362
|
+
"and cannot be agent names"
|
|
363
|
+
)
|
|
388
364
|
if name in seen:
|
|
389
365
|
raise ConfigError(f"duplicate agent name: {name!r}")
|
|
390
366
|
seen.add(name)
|
|
@@ -402,9 +378,23 @@ def load(path: str | os.PathLike) -> SwarmConfig:
|
|
|
402
378
|
if not command:
|
|
403
379
|
raise ConfigError(f"agent {name!r}: no `command` and type {atype!r} has none")
|
|
404
380
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
)
|
|
381
|
+
# type <-> command mismatch: a command that launches a DIFFERENT agent
|
|
382
|
+
# CLI than `type` implies will never fire its turn-completion signal and
|
|
383
|
+
# pins the agent "busy" forever. Lenient for key-free mocks (word-boundaries).
|
|
384
|
+
if atype in CLI_TOKENS:
|
|
385
|
+
cmd_lower = str(command).lower()
|
|
386
|
+
for token in CLI_TOKENS:
|
|
387
|
+
if token == atype:
|
|
388
|
+
continue
|
|
389
|
+
if re.search(r"\b" + re.escape(token) + r"\b", cmd_lower):
|
|
390
|
+
raise ConfigError(
|
|
391
|
+
f"agent {name!r}: type: {atype} but command launches "
|
|
392
|
+
f"{token!r} ({command!r}). The command must launch the same "
|
|
393
|
+
f"agent CLI as `type`, or the turn-completion signal will "
|
|
394
|
+
f"never fire and the agent will hang. Fix `command` or `type`."
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
capture = str(raw.get("capture") or defaults.get("capture") or "auto")
|
|
408
398
|
if capture not in VALID_CAPTURE:
|
|
409
399
|
raise ConfigError(
|
|
410
400
|
f"agent {name!r}: capture must be one of {', '.join(VALID_CAPTURE)}"
|
|
@@ -414,15 +404,13 @@ def load(path: str | os.PathLike) -> SwarmConfig:
|
|
|
414
404
|
# capture: none on a type that HAS a completion hook (claude/codex) removes
|
|
415
405
|
# the agent's only turn-completion signal and leaves the orchestrator blind
|
|
416
406
|
# to a silent turn -- which can wedge the whole swarm. Auto-upgrade to the
|
|
417
|
-
# type's natural capture so the hook keeps the orchestrator informed
|
|
418
|
-
# re-enables busy_check / tag parsing / reply reminders, force-disabled for
|
|
419
|
-
# capture: none below). gemini/hermes deliberately keep capture: none.
|
|
407
|
+
# type's natural capture so the hook keeps the orchestrator informed.
|
|
420
408
|
if capture == "none" and str(tconf.get("capture")) == "hook":
|
|
421
409
|
capture = "hook"
|
|
422
|
-
|
|
410
|
+
agent_warnings.append(
|
|
423
411
|
f"agent {name!r}: capture: none on a {atype} agent gives the "
|
|
424
412
|
f"orchestrator no turn-completion signal -- auto-upgraded to "
|
|
425
|
-
f"capture: hook.
|
|
413
|
+
f"capture: hook."
|
|
426
414
|
)
|
|
427
415
|
|
|
428
416
|
boot = raw.get("boot_delay_ms")
|
|
@@ -431,38 +419,51 @@ def load(path: str | os.PathLike) -> SwarmConfig:
|
|
|
431
419
|
if boot is None:
|
|
432
420
|
boot = tconf.get("boot_delay_ms", 5000)
|
|
433
421
|
|
|
434
|
-
|
|
422
|
+
# Standing instructions. `role` is the v2 field; `first_prompt` is a
|
|
423
|
+
# deprecated alias (warn). `first_prompt_file` is likewise deprecated.
|
|
424
|
+
role = raw.get("role")
|
|
435
425
|
prompt_file = raw.get("first_prompt_file")
|
|
426
|
+
if prompt_file and (role is not None or raw.get("first_prompt") is not None):
|
|
427
|
+
raise ConfigError(
|
|
428
|
+
f"agent {name!r}: set either `role`/`first_prompt` or "
|
|
429
|
+
"`first_prompt_file`, not both"
|
|
430
|
+
)
|
|
436
431
|
if prompt_file:
|
|
437
|
-
if first_prompt:
|
|
438
|
-
raise ConfigError(
|
|
439
|
-
f"agent {name!r}: set either `first_prompt` or `first_prompt_file`, not both"
|
|
440
|
-
)
|
|
441
432
|
fp = Path(os.path.expanduser(str(prompt_file)))
|
|
442
433
|
if not fp.is_absolute():
|
|
443
434
|
fp = cfg_path.parent / fp
|
|
444
435
|
if not fp.is_file():
|
|
445
436
|
raise ConfigError(f"agent {name!r}: first_prompt_file not found: {fp}")
|
|
446
|
-
|
|
447
|
-
|
|
437
|
+
role = fp.read_text()
|
|
438
|
+
if "first_prompt" in raw:
|
|
439
|
+
agent_warnings.append(
|
|
440
|
+
f"agent {name!r}: `first_prompt` is deprecated; use `role`"
|
|
441
|
+
)
|
|
442
|
+
if "first_prompt_file" in raw:
|
|
443
|
+
agent_warnings.append(
|
|
444
|
+
f"agent {name!r}: `first_prompt_file` is deprecated; use `role` "
|
|
445
|
+
"(write the text directly, or read the file yourself)"
|
|
446
|
+
)
|
|
447
|
+
role = (role or raw.get("first_prompt") or "").strip()
|
|
448
448
|
|
|
449
449
|
workdir_raw = raw.get("workdir") or defaults.get("workdir")
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
workdir
|
|
450
|
+
workdir = (
|
|
451
|
+
_expand_path(workdir_raw, f"agent {name}: workdir", name, root,
|
|
452
|
+
swarm_name, atype, cfg_path.parent)
|
|
453
|
+
if workdir_raw
|
|
454
|
+
else root / name
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
# Mailbox base: per-agent mail_dir overrides the global default, both
|
|
458
|
+
# resolved relative to the config file's parent. Default = the workdir,
|
|
459
|
+
# so by default the four folders live inside the workspace.
|
|
460
|
+
mail_dir_raw = raw.get("mail_dir") or default_mail_dir_raw
|
|
461
|
+
mail_dir = (
|
|
462
|
+
_expand_path(mail_dir_raw, f"agent {name}: mail_dir", name, root,
|
|
463
|
+
swarm_name, atype, cfg_path.parent)
|
|
464
|
+
if mail_dir_raw
|
|
465
|
+
else workdir
|
|
466
|
+
)
|
|
466
467
|
|
|
467
468
|
create_workdir = _as_bool(
|
|
468
469
|
raw.get("create_workdir", defaults.get("create_workdir", create_workdirs)),
|
|
@@ -470,6 +471,16 @@ def load(path: str | os.PathLike) -> SwarmConfig:
|
|
|
470
471
|
f"agent {name}: create_workdir",
|
|
471
472
|
)
|
|
472
473
|
|
|
474
|
+
ping_seconds = raw.get("periodically_ping_seconds")
|
|
475
|
+
if ping_seconds is None:
|
|
476
|
+
ping_seconds = defaults.get("periodically_ping_seconds")
|
|
477
|
+
ping_seconds = int(ping_seconds or 0)
|
|
478
|
+
ping_message = (
|
|
479
|
+
raw.get("periodically_ping_message")
|
|
480
|
+
or defaults.get("periodically_ping_message")
|
|
481
|
+
or ""
|
|
482
|
+
)
|
|
483
|
+
|
|
473
484
|
env = dict(_as_str_map(defaults.get("env"), "defaults.env"))
|
|
474
485
|
env.update(_as_str_map(tconf.get("env"), f"agent_types.{atype}.env"))
|
|
475
486
|
env.update(_as_str_map(raw.get("env"), f"agent {name}: env"))
|
|
@@ -483,109 +494,65 @@ def load(path: str | os.PathLike) -> SwarmConfig:
|
|
|
483
494
|
session=f"{prefix}{name}",
|
|
484
495
|
capture=capture,
|
|
485
496
|
boot_delay_ms=int(boot),
|
|
486
|
-
|
|
497
|
+
role=role,
|
|
487
498
|
can_talk_to=_as_list(
|
|
488
499
|
raw.get("can_talk_to", defaults.get("can_talk_to")),
|
|
489
500
|
f"agent {name}: can_talk_to",
|
|
490
501
|
),
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
)
|
|
502
|
+
mail_dir=mail_dir,
|
|
503
|
+
periodically_ping_seconds=ping_seconds,
|
|
504
|
+
periodically_ping_message=str(ping_message),
|
|
505
|
+
resume_args=raw.get("resume_args")
|
|
506
|
+
or defaults.get("resume_args")
|
|
507
|
+
or tconf.get("resume_args"),
|
|
508
|
+
resume_command=raw.get("resume_command")
|
|
509
|
+
or defaults.get("resume_command")
|
|
510
|
+
or tconf.get("resume_command"),
|
|
495
511
|
env=env,
|
|
496
512
|
create_workdir=create_workdir,
|
|
497
513
|
# Busy tracking needs a "turn finished" signal, which only exists
|
|
498
514
|
# when the agent is captured. capture: none => always accept mail.
|
|
499
|
-
busy_check=_as_bool(
|
|
500
|
-
raw.get("busy_check", defaults.get("busy_check")),
|
|
501
|
-
True,
|
|
502
|
-
f"agent {name}: busy_check",
|
|
503
|
-
)
|
|
504
|
-
and capture != "none",
|
|
505
|
-
# Routing <swarm-send> blocks means reading what the agent said,
|
|
506
|
-
# which is exactly what capture provides.
|
|
507
|
-
parse_outbound_tags=_as_bool(
|
|
508
|
-
raw.get("parse_outbound_tags", defaults.get("parse_outbound_tags")),
|
|
509
|
-
True,
|
|
510
|
-
f"agent {name}: parse_outbound_tags",
|
|
511
|
-
)
|
|
512
|
-
and capture != "none"
|
|
513
|
-
and message_format == "tagged",
|
|
514
|
-
# Reminding an agent to use the tags only makes sense if we read
|
|
515
|
-
# them back and can see that it did not.
|
|
516
|
-
reply_reminder=_as_bool(
|
|
517
|
-
raw.get("reply_reminder", defaults.get("reply_reminder")),
|
|
518
|
-
True,
|
|
519
|
-
f"agent {name}: reply_reminder",
|
|
520
|
-
),
|
|
521
|
-
resume_args=raw.get("resume_args") or defaults.get("resume_args") or tconf.get("resume_args"),
|
|
522
|
-
resume_command=raw.get("resume_command") or defaults.get("resume_command") or tconf.get("resume_command"),
|
|
523
515
|
ready_probe=_as_bool(
|
|
524
516
|
raw.get("ready_probe", defaults.get("ready_probe")),
|
|
525
517
|
True,
|
|
526
518
|
f"agent {name}: ready_probe",
|
|
527
519
|
),
|
|
528
|
-
|
|
529
|
-
raw.get(
|
|
530
|
-
"append_agents_that_you_can_talk_to_prompt",
|
|
531
|
-
defaults.get("append_agents_that_you_can_talk_to_prompt"),
|
|
532
|
-
),
|
|
520
|
+
busy_check=_as_bool(
|
|
521
|
+
raw.get("busy_check", defaults.get("busy_check")),
|
|
533
522
|
True,
|
|
534
|
-
f"agent {name}:
|
|
535
|
-
)
|
|
536
|
-
|
|
537
|
-
raw.get(
|
|
538
|
-
"in_first_prompt_append_your_task_will_be_sent_in_the_next_prompt",
|
|
539
|
-
defaults.get(
|
|
540
|
-
"in_first_prompt_append_your_task_will_be_sent_in_the_next_prompt"
|
|
541
|
-
),
|
|
542
|
-
),
|
|
543
|
-
False,
|
|
544
|
-
f"agent {name}: in_first_prompt_append_your_task_will_be_sent_in_the_next_prompt",
|
|
545
|
-
),
|
|
523
|
+
f"agent {name}: busy_check",
|
|
524
|
+
)
|
|
525
|
+
and capture != "none",
|
|
546
526
|
)
|
|
547
527
|
)
|
|
548
528
|
|
|
549
529
|
all_names = [a.name for a in agents]
|
|
550
530
|
|
|
551
|
-
# Pass 2: expand wildcards and validate the communication graph.
|
|
531
|
+
# Pass 2: expand wildcards and validate the communication graph. `user` is a
|
|
532
|
+
# permitted virtual recipient; `system` is orchestrator-only and may never be
|
|
533
|
+
# addressed by an agent; everything else must name a real agent.
|
|
552
534
|
for agent in agents:
|
|
553
535
|
if "*" in agent.can_talk_to:
|
|
554
536
|
agent.can_talk_to = [n for n in all_names if n != agent.name]
|
|
555
537
|
for peer in agent.can_talk_to:
|
|
556
|
-
if peer
|
|
538
|
+
if peer == "system":
|
|
557
539
|
raise ConfigError(
|
|
558
|
-
f"agent {agent.name!r}:
|
|
540
|
+
f"agent {agent.name!r}: `system` is a reserved orchestrator "
|
|
541
|
+
"mailbox and can never be a recipient -- remove it from "
|
|
542
|
+
"can_talk_to"
|
|
559
543
|
)
|
|
560
|
-
if peer ==
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
if "*" in agent.forward_responses_to:
|
|
564
|
-
agent.forward_responses_to = list(agent.can_talk_to)
|
|
565
|
-
for peer in agent.forward_responses_to:
|
|
544
|
+
if peer == "user":
|
|
545
|
+
continue
|
|
566
546
|
if peer not in all_names:
|
|
567
547
|
raise ConfigError(
|
|
568
|
-
f"agent {agent.name!r}:
|
|
548
|
+
f"agent {agent.name!r}: can_talk_to references unknown agent "
|
|
549
|
+
f"{peer!r}"
|
|
569
550
|
)
|
|
570
|
-
if peer
|
|
571
|
-
raise ConfigError(
|
|
572
|
-
f"agent {agent.name!r}: forward_responses_to includes {peer!r}, "
|
|
573
|
-
"which is not in its can_talk_to list"
|
|
574
|
-
)
|
|
575
|
-
if agent.forward_responses_to and agent.capture == "none":
|
|
576
|
-
raise ConfigError(
|
|
577
|
-
f"agent {agent.name!r}: forward_responses_to needs capture to be enabled "
|
|
578
|
-
"(set capture: hook or capture: pane)"
|
|
579
|
-
)
|
|
551
|
+
if peer == agent.name:
|
|
552
|
+
raise ConfigError(f"agent {agent.name!r}: cannot be in its own can_talk_to")
|
|
580
553
|
|
|
581
|
-
# Pass 2b: working directories. They may be auto-created under `root`, or
|
|
582
|
-
# point at an existing project -- possibly one shared by several agents.
|
|
583
554
|
warnings: list[str] = []
|
|
584
|
-
warnings.extend(
|
|
585
|
-
for agent in agents:
|
|
586
|
-
# No tag parsing means no way to know whether it replied.
|
|
587
|
-
if not agent.parse_outbound_tags:
|
|
588
|
-
agent.reply_reminder = False
|
|
555
|
+
warnings.extend(agent_warnings)
|
|
589
556
|
|
|
590
557
|
for agent in agents:
|
|
591
558
|
if agent.workdir.exists() and not agent.workdir.is_dir():
|
|
@@ -618,14 +585,12 @@ def load(path: str | os.PathLike) -> SwarmConfig:
|
|
|
618
585
|
agents=agents,
|
|
619
586
|
enter_delay_ms=int(swarm.get("enter_delay_ms", 250)),
|
|
620
587
|
send_delay_ms=int(swarm.get("send_delay_ms", 150)),
|
|
621
|
-
max_forward_hops=int(swarm.get("max_forward_hops", 3)),
|
|
622
588
|
ready_timeout_ms=int(swarm.get("ready_timeout_ms", 60000)),
|
|
623
589
|
busy_timeout_ms=int(swarm.get("busy_timeout_ms", 900000)),
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
send_failed_template=failed_tpl,
|
|
590
|
+
resume=_as_bool(swarm.get("resume"), True, "swarm.resume"),
|
|
591
|
+
user_available=_as_bool(
|
|
592
|
+
swarm.get("user_available"), False, "swarm.user_available"
|
|
593
|
+
),
|
|
629
594
|
pane_idle_ms=int(swarm.get("pane_idle_ms", 2500)),
|
|
630
595
|
pane_poll_ms=int(swarm.get("pane_poll_ms", 700)),
|
|
631
596
|
pane_scrollback=int(swarm.get("pane_scrollback", 400)),
|
|
@@ -633,29 +598,6 @@ def load(path: str | os.PathLike) -> SwarmConfig:
|
|
|
633
598
|
tmux_mouse=_as_bool(swarm.get("tmux_mouse"), True, "swarm.tmux_mouse"),
|
|
634
599
|
supervise=_as_bool(swarm.get("supervise"), True, "swarm.supervise"),
|
|
635
600
|
supervise_interval_ms=int(swarm.get("supervise_interval_ms", 15000)),
|
|
601
|
+
telegram=_load_telegram(data),
|
|
636
602
|
)
|
|
637
|
-
|
|
638
|
-
# Pass 3: build the full first prompt for each agent.
|
|
639
|
-
for agent in agents:
|
|
640
|
-
cfg_get = {
|
|
641
|
-
"agent": agent.name,
|
|
642
|
-
"swarm": cfg.name,
|
|
643
|
-
"prefix": cfg.session_prefix,
|
|
644
|
-
"peers": ", ".join(agent.can_talk_to) or "none (you are isolated)",
|
|
645
|
-
"inbox": str(cfg.inbox_dir / agent.name),
|
|
646
|
-
"workdir": str(agent.workdir),
|
|
647
|
-
}
|
|
648
|
-
parts = [agent.first_prompt] if agent.first_prompt else []
|
|
649
|
-
try:
|
|
650
|
-
if agent.append_peers_prompt:
|
|
651
|
-
parts.append(comms_tpl.format(**cfg_get).strip())
|
|
652
|
-
if agent.append_task_notice:
|
|
653
|
-
parts.append(notice_tpl.format(**cfg_get).strip())
|
|
654
|
-
except (KeyError, IndexError, ValueError) as exc:
|
|
655
|
-
raise ConfigError(
|
|
656
|
-
f"agent {agent.name!r}: a template placeholder is not recognised: {exc}. "
|
|
657
|
-
f"Available: {', '.join(sorted(cfg_get))}"
|
|
658
|
-
) from exc
|
|
659
|
-
agent.first_prompt = "\n\n".join(p.strip() for p in parts if p.strip())
|
|
660
|
-
|
|
661
603
|
return cfg
|