agentainer 0.1.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 +603 -0
- package/agents.example.yaml +257 -0
- package/bin/agentainer.js +74 -0
- package/examples/bug-hunt.yaml +112 -0
- package/examples/existing-repo.yaml +72 -0
- package/examples/research-swarm.yaml +120 -0
- package/examples/software-company.yaml +152 -0
- package/hooks/claude_stop.sh +17 -0
- package/hooks/codex_notify.sh +16 -0
- package/lib/config.py +641 -0
- package/lib/minyaml.py +376 -0
- package/lib/swarm.py +2277 -0
- package/llms.txt +405 -0
- package/package.json +52 -0
- package/scripts/check-deps.js +76 -0
- package/swarm.sh +43 -0
package/lib/config.py
ADDED
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
"""Load, normalise and validate an AgentSwarm YAML config."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
try: # pragma: no cover - exercised by whichever branch is installed
|
|
12
|
+
import yaml as _yaml
|
|
13
|
+
|
|
14
|
+
def _parse_yaml(text: str):
|
|
15
|
+
return _yaml.safe_load(text)
|
|
16
|
+
|
|
17
|
+
except ImportError: # pragma: no cover
|
|
18
|
+
import minyaml as _yaml # type: ignore
|
|
19
|
+
|
|
20
|
+
def _parse_yaml(text: str):
|
|
21
|
+
return _yaml.load(text)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_yaml(text: str):
|
|
25
|
+
"""Parse YAML with PyYAML if present, otherwise the bundled subset parser."""
|
|
26
|
+
return _parse_yaml(text)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ConfigError(Exception):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Built-in knowledge about each supported coding agent. `capture` says how we
|
|
34
|
+
# learn that the agent finished a turn:
|
|
35
|
+
# hook -- the CLI can call an external program on turn completion
|
|
36
|
+
# pane -- no such facility; we poll the tmux pane and diff it
|
|
37
|
+
# none -- do not capture at all
|
|
38
|
+
BUILTIN_AGENT_TYPES: dict[str, dict[str, Any]] = {
|
|
39
|
+
"claude": {
|
|
40
|
+
"command": "claude --dangerously-skip-permissions",
|
|
41
|
+
"capture": "hook",
|
|
42
|
+
"boot_delay_ms": 3000,
|
|
43
|
+
# Appended to `command` by `up --resume`. {session_id} is the recorded id.
|
|
44
|
+
"resume_args": "--resume {session_id}",
|
|
45
|
+
},
|
|
46
|
+
"codex": {
|
|
47
|
+
"command": "codex --yolo",
|
|
48
|
+
"capture": "hook",
|
|
49
|
+
"boot_delay_ms": 3000,
|
|
50
|
+
"resume_args": "resume {session_id}",
|
|
51
|
+
},
|
|
52
|
+
"gemini": {
|
|
53
|
+
"command": "gemini --yolo",
|
|
54
|
+
"capture": "pane",
|
|
55
|
+
"boot_delay_ms": 4000,
|
|
56
|
+
# No session id is recoverable from a scraped pane, so no resume recipe.
|
|
57
|
+
},
|
|
58
|
+
"hermes": {
|
|
59
|
+
"command": "hermes",
|
|
60
|
+
"capture": "pane",
|
|
61
|
+
"boot_delay_ms": 3000,
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
VALID_CAPTURE = ("hook", "pane", "none", "auto")
|
|
66
|
+
|
|
67
|
+
VALID_MESSAGE_FORMATS = ("tagged", "plain")
|
|
68
|
+
|
|
69
|
+
NAME_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_-]*$")
|
|
70
|
+
|
|
71
|
+
DEFAULT_COMMS_TEMPLATE = """\
|
|
72
|
+
---
|
|
73
|
+
## Swarm communication protocol
|
|
74
|
+
|
|
75
|
+
You are the agent **{agent}** in the "{swarm}" swarm.
|
|
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 tells the swarm your message is 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
|
+
|
|
199
|
+
@dataclass
|
|
200
|
+
class Agent:
|
|
201
|
+
name: str
|
|
202
|
+
type: str
|
|
203
|
+
command: str
|
|
204
|
+
workdir: Path
|
|
205
|
+
session: str
|
|
206
|
+
capture: str
|
|
207
|
+
boot_delay_ms: int
|
|
208
|
+
first_prompt: str
|
|
209
|
+
can_talk_to: list[str] = field(default_factory=list)
|
|
210
|
+
forward_responses_to: list[str] = field(default_factory=list)
|
|
211
|
+
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
|
+
create_workdir: bool = True
|
|
216
|
+
busy_check: bool = True
|
|
217
|
+
parse_outbound_tags: bool = True
|
|
218
|
+
reply_reminder: bool = True
|
|
219
|
+
resume_args: str | None = None
|
|
220
|
+
resume_command: str | None = None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@dataclass
|
|
224
|
+
class SwarmConfig:
|
|
225
|
+
path: Path
|
|
226
|
+
name: str
|
|
227
|
+
root: Path
|
|
228
|
+
session_prefix: str
|
|
229
|
+
agents: list[Agent]
|
|
230
|
+
enter_delay_ms: int = 250
|
|
231
|
+
send_delay_ms: int = 150
|
|
232
|
+
max_forward_hops: int = 3
|
|
233
|
+
ready_timeout_ms: int = 60000
|
|
234
|
+
busy_timeout_ms: int = 900000
|
|
235
|
+
message_format: str = "tagged"
|
|
236
|
+
max_reply_reminders: int = 1
|
|
237
|
+
resume: bool = False
|
|
238
|
+
reply_reminder_template: str = DEFAULT_REPLY_REMINDER_TEMPLATE
|
|
239
|
+
send_failed_template: str = DEFAULT_SEND_FAILED_TEMPLATE
|
|
240
|
+
pane_idle_ms: int = 2500
|
|
241
|
+
pane_poll_ms: int = 700
|
|
242
|
+
pane_scrollback: int = 400
|
|
243
|
+
tmux_history_limit: int = 50000
|
|
244
|
+
tmux_mouse: bool = True
|
|
245
|
+
warnings: list[str] = field(default_factory=list)
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def runtime(self) -> Path:
|
|
249
|
+
return self.root / ".swarm"
|
|
250
|
+
|
|
251
|
+
@property
|
|
252
|
+
def log_dir(self) -> Path:
|
|
253
|
+
return self.runtime / "logs"
|
|
254
|
+
|
|
255
|
+
@property
|
|
256
|
+
def inbox_dir(self) -> Path:
|
|
257
|
+
return self.runtime / "inbox"
|
|
258
|
+
|
|
259
|
+
@property
|
|
260
|
+
def run_dir(self) -> Path:
|
|
261
|
+
return self.runtime / "run"
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def bin_dir(self) -> Path:
|
|
265
|
+
return self.runtime / "bin"
|
|
266
|
+
|
|
267
|
+
@property
|
|
268
|
+
def sessions_file(self) -> Path:
|
|
269
|
+
"""Where each agent's conversation id is recorded, so `up --resume` works."""
|
|
270
|
+
return self.runtime / "sessions.yaml"
|
|
271
|
+
|
|
272
|
+
def get(self, name: str) -> Agent:
|
|
273
|
+
for agent in self.agents:
|
|
274
|
+
if agent.name == name:
|
|
275
|
+
return agent
|
|
276
|
+
known = ", ".join(a.name for a in self.agents)
|
|
277
|
+
raise ConfigError(f"unknown agent {name!r} (known agents: {known})")
|
|
278
|
+
|
|
279
|
+
def names(self) -> list[str]:
|
|
280
|
+
return [a.name for a in self.agents]
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _as_list(value: Any, ctx: str) -> list[str]:
|
|
284
|
+
if value is None:
|
|
285
|
+
return []
|
|
286
|
+
if isinstance(value, str):
|
|
287
|
+
return [value]
|
|
288
|
+
if isinstance(value, list):
|
|
289
|
+
return [str(v) for v in value]
|
|
290
|
+
raise ConfigError(f"{ctx}: expected a string or a list, got {type(value).__name__}")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _as_bool(value: Any, default: bool, ctx: str) -> bool:
|
|
294
|
+
if value is None:
|
|
295
|
+
return default
|
|
296
|
+
if isinstance(value, bool):
|
|
297
|
+
return value
|
|
298
|
+
raise ConfigError(f"{ctx}: expected true/false, got {value!r}")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _as_str_map(value: Any, ctx: str) -> dict[str, str]:
|
|
302
|
+
if value is None:
|
|
303
|
+
return {}
|
|
304
|
+
if not isinstance(value, dict):
|
|
305
|
+
raise ConfigError(f"{ctx}: expected a mapping")
|
|
306
|
+
return {str(k): str(v) for k, v in value.items()}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def load(path: str | os.PathLike) -> SwarmConfig:
|
|
310
|
+
cfg_path = Path(path).expanduser().resolve()
|
|
311
|
+
if not cfg_path.is_file():
|
|
312
|
+
raise ConfigError(
|
|
313
|
+
f"config file not found: {cfg_path}\n"
|
|
314
|
+
" Create one with: cp agents.example.yaml agents.yaml\n"
|
|
315
|
+
" Or point at it: swarm.sh -c /path/to/swarm.yaml up"
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
data = _parse_yaml(cfg_path.read_text())
|
|
320
|
+
except Exception as exc: # noqa: BLE001 - surface parser errors verbatim
|
|
321
|
+
raise ConfigError(f"could not parse {cfg_path}: {exc}") from exc
|
|
322
|
+
|
|
323
|
+
if not isinstance(data, dict):
|
|
324
|
+
raise ConfigError(f"{cfg_path}: top level must be a mapping")
|
|
325
|
+
|
|
326
|
+
swarm = data.get("swarm") or {}
|
|
327
|
+
if not isinstance(swarm, dict):
|
|
328
|
+
raise ConfigError("`swarm:` must be a mapping")
|
|
329
|
+
|
|
330
|
+
defaults = data.get("defaults") or {}
|
|
331
|
+
if not isinstance(defaults, dict):
|
|
332
|
+
raise ConfigError("`defaults:` must be a mapping")
|
|
333
|
+
|
|
334
|
+
templates = data.get("templates") or {}
|
|
335
|
+
comms_tpl = templates.get("comms") or DEFAULT_COMMS_TEMPLATE
|
|
336
|
+
notice_tpl = templates.get("task_notice") or DEFAULT_TASK_NOTICE_TEMPLATE
|
|
337
|
+
reminder_tpl = templates.get("reply_reminder") or DEFAULT_REPLY_REMINDER_TEMPLATE
|
|
338
|
+
failed_tpl = templates.get("send_failed") or DEFAULT_SEND_FAILED_TEMPLATE
|
|
339
|
+
|
|
340
|
+
# Agent type registry: built-ins, overridable and extensible from YAML.
|
|
341
|
+
types: dict[str, dict[str, Any]] = {
|
|
342
|
+
k: dict(v) for k, v in BUILTIN_AGENT_TYPES.items()
|
|
343
|
+
}
|
|
344
|
+
for tname, tconf in (data.get("agent_types") or {}).items():
|
|
345
|
+
if not isinstance(tconf, dict):
|
|
346
|
+
raise ConfigError(f"agent_types.{tname}: must be a mapping")
|
|
347
|
+
types.setdefault(tname, {}).update(tconf)
|
|
348
|
+
|
|
349
|
+
root_raw = swarm.get("root") or "./workspace"
|
|
350
|
+
root = Path(os.path.expanduser(str(root_raw)))
|
|
351
|
+
if not root.is_absolute():
|
|
352
|
+
root = (cfg_path.parent / root).resolve()
|
|
353
|
+
|
|
354
|
+
prefix = str(swarm.get("session_prefix") or "")
|
|
355
|
+
swarm_name = str(swarm.get("name") or cfg_path.stem)
|
|
356
|
+
create_workdirs = _as_bool(swarm.get("create_workdirs"), True, "swarm.create_workdirs")
|
|
357
|
+
|
|
358
|
+
message_format = str(swarm.get("message_format") or "tagged")
|
|
359
|
+
if message_format not in VALID_MESSAGE_FORMATS:
|
|
360
|
+
raise ConfigError(
|
|
361
|
+
f"swarm.message_format must be one of {', '.join(VALID_MESSAGE_FORMATS)}"
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
raw_agents = data.get("agents")
|
|
365
|
+
if not raw_agents:
|
|
366
|
+
raise ConfigError("`agents:` must contain at least one agent")
|
|
367
|
+
if not isinstance(raw_agents, list):
|
|
368
|
+
raise ConfigError("`agents:` must be a list")
|
|
369
|
+
|
|
370
|
+
# Pass 1: materialise agents without resolving peer references.
|
|
371
|
+
agents: list[Agent] = []
|
|
372
|
+
seen: set[str] = set()
|
|
373
|
+
for index, raw in enumerate(raw_agents):
|
|
374
|
+
if not isinstance(raw, dict):
|
|
375
|
+
raise ConfigError(f"agents[{index}]: must be a mapping")
|
|
376
|
+
name = str(raw.get("name") or "").strip()
|
|
377
|
+
if not name:
|
|
378
|
+
raise ConfigError(f"agents[{index}]: missing `name`")
|
|
379
|
+
if not NAME_RE.match(name):
|
|
380
|
+
raise ConfigError(
|
|
381
|
+
f"agent {name!r}: name must match {NAME_RE.pattern} "
|
|
382
|
+
"(it is used as a tmux session name and a directory name)"
|
|
383
|
+
)
|
|
384
|
+
if name in seen:
|
|
385
|
+
raise ConfigError(f"duplicate agent name: {name!r}")
|
|
386
|
+
seen.add(name)
|
|
387
|
+
|
|
388
|
+
atype = str(raw.get("type") or defaults.get("type") or "claude")
|
|
389
|
+
if atype not in types:
|
|
390
|
+
raise ConfigError(
|
|
391
|
+
f"agent {name!r}: unknown type {atype!r}. "
|
|
392
|
+
f"Known types: {', '.join(sorted(types))}. "
|
|
393
|
+
"Define new ones under `agent_types:`."
|
|
394
|
+
)
|
|
395
|
+
tconf = types[atype]
|
|
396
|
+
|
|
397
|
+
command = raw.get("command") or tconf.get("command")
|
|
398
|
+
if not command:
|
|
399
|
+
raise ConfigError(f"agent {name!r}: no `command` and type {atype!r} has none")
|
|
400
|
+
|
|
401
|
+
capture = str(
|
|
402
|
+
raw.get("capture") or defaults.get("capture") or "auto"
|
|
403
|
+
)
|
|
404
|
+
if capture not in VALID_CAPTURE:
|
|
405
|
+
raise ConfigError(
|
|
406
|
+
f"agent {name!r}: capture must be one of {', '.join(VALID_CAPTURE)}"
|
|
407
|
+
)
|
|
408
|
+
if capture == "auto":
|
|
409
|
+
capture = str(tconf.get("capture") or "pane")
|
|
410
|
+
|
|
411
|
+
boot = raw.get("boot_delay_ms")
|
|
412
|
+
if boot is None:
|
|
413
|
+
boot = defaults.get("boot_delay_ms")
|
|
414
|
+
if boot is None:
|
|
415
|
+
boot = tconf.get("boot_delay_ms", 5000)
|
|
416
|
+
|
|
417
|
+
first_prompt = raw.get("first_prompt")
|
|
418
|
+
prompt_file = raw.get("first_prompt_file")
|
|
419
|
+
if prompt_file:
|
|
420
|
+
if first_prompt:
|
|
421
|
+
raise ConfigError(
|
|
422
|
+
f"agent {name!r}: set either `first_prompt` or `first_prompt_file`, not both"
|
|
423
|
+
)
|
|
424
|
+
fp = Path(os.path.expanduser(str(prompt_file)))
|
|
425
|
+
if not fp.is_absolute():
|
|
426
|
+
fp = cfg_path.parent / fp
|
|
427
|
+
if not fp.is_file():
|
|
428
|
+
raise ConfigError(f"agent {name!r}: first_prompt_file not found: {fp}")
|
|
429
|
+
first_prompt = fp.read_text()
|
|
430
|
+
first_prompt = (first_prompt or "").strip()
|
|
431
|
+
|
|
432
|
+
workdir_raw = raw.get("workdir") or defaults.get("workdir")
|
|
433
|
+
if workdir_raw:
|
|
434
|
+
# {name}, {root} and {swarm} let one `defaults.workdir` serve every agent.
|
|
435
|
+
try:
|
|
436
|
+
expanded = str(workdir_raw).format(
|
|
437
|
+
name=name, root=str(root), swarm=swarm_name, type=atype
|
|
438
|
+
)
|
|
439
|
+
except (KeyError, IndexError) as exc:
|
|
440
|
+
raise ConfigError(
|
|
441
|
+
f"agent {name!r}: unknown placeholder in workdir {workdir_raw!r}: {exc}. "
|
|
442
|
+
"Available: {name} {root} {swarm} {type}"
|
|
443
|
+
) from exc
|
|
444
|
+
workdir = Path(os.path.expanduser(expanded))
|
|
445
|
+
if not workdir.is_absolute():
|
|
446
|
+
workdir = (cfg_path.parent / workdir).resolve()
|
|
447
|
+
else:
|
|
448
|
+
workdir = root / name
|
|
449
|
+
|
|
450
|
+
create_workdir = _as_bool(
|
|
451
|
+
raw.get("create_workdir", defaults.get("create_workdir", create_workdirs)),
|
|
452
|
+
True,
|
|
453
|
+
f"agent {name}: create_workdir",
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
env = dict(_as_str_map(defaults.get("env"), "defaults.env"))
|
|
457
|
+
env.update(_as_str_map(tconf.get("env"), f"agent_types.{atype}.env"))
|
|
458
|
+
env.update(_as_str_map(raw.get("env"), f"agent {name}: env"))
|
|
459
|
+
|
|
460
|
+
agents.append(
|
|
461
|
+
Agent(
|
|
462
|
+
name=name,
|
|
463
|
+
type=atype,
|
|
464
|
+
command=str(command),
|
|
465
|
+
workdir=workdir,
|
|
466
|
+
session=f"{prefix}{name}",
|
|
467
|
+
capture=capture,
|
|
468
|
+
boot_delay_ms=int(boot),
|
|
469
|
+
first_prompt=first_prompt,
|
|
470
|
+
can_talk_to=_as_list(
|
|
471
|
+
raw.get("can_talk_to", defaults.get("can_talk_to")),
|
|
472
|
+
f"agent {name}: can_talk_to",
|
|
473
|
+
),
|
|
474
|
+
forward_responses_to=_as_list(
|
|
475
|
+
raw.get("forward_responses_to", defaults.get("forward_responses_to")),
|
|
476
|
+
f"agent {name}: forward_responses_to",
|
|
477
|
+
),
|
|
478
|
+
env=env,
|
|
479
|
+
create_workdir=create_workdir,
|
|
480
|
+
# Busy tracking needs a "turn finished" signal, which only exists
|
|
481
|
+
# when the agent is captured. capture: none => always accept mail.
|
|
482
|
+
busy_check=_as_bool(
|
|
483
|
+
raw.get("busy_check", defaults.get("busy_check")),
|
|
484
|
+
True,
|
|
485
|
+
f"agent {name}: busy_check",
|
|
486
|
+
)
|
|
487
|
+
and capture != "none",
|
|
488
|
+
# Routing <swarm-send> blocks means reading what the agent said,
|
|
489
|
+
# which is exactly what capture provides.
|
|
490
|
+
parse_outbound_tags=_as_bool(
|
|
491
|
+
raw.get("parse_outbound_tags", defaults.get("parse_outbound_tags")),
|
|
492
|
+
True,
|
|
493
|
+
f"agent {name}: parse_outbound_tags",
|
|
494
|
+
)
|
|
495
|
+
and capture != "none"
|
|
496
|
+
and message_format == "tagged",
|
|
497
|
+
# Reminding an agent to use the tags only makes sense if we read
|
|
498
|
+
# them back and can see that it did not.
|
|
499
|
+
reply_reminder=_as_bool(
|
|
500
|
+
raw.get("reply_reminder", defaults.get("reply_reminder")),
|
|
501
|
+
True,
|
|
502
|
+
f"agent {name}: reply_reminder",
|
|
503
|
+
),
|
|
504
|
+
resume_args=raw.get("resume_args", tconf.get("resume_args")),
|
|
505
|
+
resume_command=raw.get("resume_command", tconf.get("resume_command")),
|
|
506
|
+
ready_probe=_as_bool(
|
|
507
|
+
raw.get("ready_probe", defaults.get("ready_probe")),
|
|
508
|
+
True,
|
|
509
|
+
f"agent {name}: ready_probe",
|
|
510
|
+
),
|
|
511
|
+
append_peers_prompt=_as_bool(
|
|
512
|
+
raw.get(
|
|
513
|
+
"append_agents_that_you_can_talk_to_prompt",
|
|
514
|
+
defaults.get("append_agents_that_you_can_talk_to_prompt"),
|
|
515
|
+
),
|
|
516
|
+
True,
|
|
517
|
+
f"agent {name}: append_agents_that_you_can_talk_to_prompt",
|
|
518
|
+
),
|
|
519
|
+
append_task_notice=_as_bool(
|
|
520
|
+
raw.get(
|
|
521
|
+
"in_first_prompt_append_your_task_will_be_sent_in_the_next_prompt",
|
|
522
|
+
defaults.get(
|
|
523
|
+
"in_first_prompt_append_your_task_will_be_sent_in_the_next_prompt"
|
|
524
|
+
),
|
|
525
|
+
),
|
|
526
|
+
False,
|
|
527
|
+
f"agent {name}: in_first_prompt_append_your_task_will_be_sent_in_the_next_prompt",
|
|
528
|
+
),
|
|
529
|
+
)
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
all_names = [a.name for a in agents]
|
|
533
|
+
|
|
534
|
+
# Pass 2: expand wildcards and validate the communication graph.
|
|
535
|
+
for agent in agents:
|
|
536
|
+
if "*" in agent.can_talk_to:
|
|
537
|
+
agent.can_talk_to = [n for n in all_names if n != agent.name]
|
|
538
|
+
for peer in agent.can_talk_to:
|
|
539
|
+
if peer not in all_names:
|
|
540
|
+
raise ConfigError(
|
|
541
|
+
f"agent {agent.name!r}: can_talk_to references unknown agent {peer!r}"
|
|
542
|
+
)
|
|
543
|
+
if peer == agent.name:
|
|
544
|
+
raise ConfigError(f"agent {agent.name!r}: cannot be in its own can_talk_to")
|
|
545
|
+
|
|
546
|
+
if "*" in agent.forward_responses_to:
|
|
547
|
+
agent.forward_responses_to = list(agent.can_talk_to)
|
|
548
|
+
for peer in agent.forward_responses_to:
|
|
549
|
+
if peer not in all_names:
|
|
550
|
+
raise ConfigError(
|
|
551
|
+
f"agent {agent.name!r}: forward_responses_to references unknown agent {peer!r}"
|
|
552
|
+
)
|
|
553
|
+
if peer not in agent.can_talk_to:
|
|
554
|
+
raise ConfigError(
|
|
555
|
+
f"agent {agent.name!r}: forward_responses_to includes {peer!r}, "
|
|
556
|
+
"which is not in its can_talk_to list"
|
|
557
|
+
)
|
|
558
|
+
if agent.forward_responses_to and agent.capture == "none":
|
|
559
|
+
raise ConfigError(
|
|
560
|
+
f"agent {agent.name!r}: forward_responses_to needs capture to be enabled "
|
|
561
|
+
"(set capture: hook or capture: pane)"
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
# Pass 2b: working directories. They may be auto-created under `root`, or
|
|
565
|
+
# point at an existing project -- possibly one shared by several agents.
|
|
566
|
+
warnings: list[str] = []
|
|
567
|
+
for agent in agents:
|
|
568
|
+
# No tag parsing means no way to know whether it replied.
|
|
569
|
+
if not agent.parse_outbound_tags:
|
|
570
|
+
agent.reply_reminder = False
|
|
571
|
+
|
|
572
|
+
for agent in agents:
|
|
573
|
+
if agent.workdir.exists() and not agent.workdir.is_dir():
|
|
574
|
+
raise ConfigError(
|
|
575
|
+
f"agent {agent.name!r}: workdir is not a directory: {agent.workdir}"
|
|
576
|
+
)
|
|
577
|
+
if not agent.workdir.exists() and not agent.create_workdir:
|
|
578
|
+
raise ConfigError(
|
|
579
|
+
f"agent {agent.name!r}: workdir does not exist: {agent.workdir}\n"
|
|
580
|
+
" Create it yourself, or allow AgentSwarm to: create_workdir: true"
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
shared: dict[Path, list[str]] = {}
|
|
584
|
+
for agent in agents:
|
|
585
|
+
shared.setdefault(agent.workdir.resolve(), []).append(agent.name)
|
|
586
|
+
for directory, names in shared.items():
|
|
587
|
+
if len(names) > 1:
|
|
588
|
+
warnings.append(
|
|
589
|
+
f"agents {', '.join(names)} share the working directory {directory} -- "
|
|
590
|
+
"they can overwrite each other's files, and a shared git checkout will "
|
|
591
|
+
"interleave their commits"
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
cfg = SwarmConfig(
|
|
595
|
+
path=cfg_path,
|
|
596
|
+
name=swarm_name,
|
|
597
|
+
warnings=warnings,
|
|
598
|
+
root=root,
|
|
599
|
+
session_prefix=prefix,
|
|
600
|
+
agents=agents,
|
|
601
|
+
enter_delay_ms=int(swarm.get("enter_delay_ms", 250)),
|
|
602
|
+
send_delay_ms=int(swarm.get("send_delay_ms", 150)),
|
|
603
|
+
max_forward_hops=int(swarm.get("max_forward_hops", 3)),
|
|
604
|
+
ready_timeout_ms=int(swarm.get("ready_timeout_ms", 60000)),
|
|
605
|
+
busy_timeout_ms=int(swarm.get("busy_timeout_ms", 900000)),
|
|
606
|
+
message_format=message_format,
|
|
607
|
+
max_reply_reminders=int(swarm.get("max_reply_reminders", 1)),
|
|
608
|
+
resume=_as_bool(swarm.get("resume"), False, "swarm.resume"),
|
|
609
|
+
reply_reminder_template=reminder_tpl,
|
|
610
|
+
send_failed_template=failed_tpl,
|
|
611
|
+
pane_idle_ms=int(swarm.get("pane_idle_ms", 2500)),
|
|
612
|
+
pane_poll_ms=int(swarm.get("pane_poll_ms", 700)),
|
|
613
|
+
pane_scrollback=int(swarm.get("pane_scrollback", 400)),
|
|
614
|
+
tmux_history_limit=int(swarm.get("tmux_history_limit", 50000)),
|
|
615
|
+
tmux_mouse=_as_bool(swarm.get("tmux_mouse"), True, "swarm.tmux_mouse"),
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
# Pass 3: build the full first prompt for each agent.
|
|
619
|
+
for agent in agents:
|
|
620
|
+
cfg_get = {
|
|
621
|
+
"agent": agent.name,
|
|
622
|
+
"swarm": cfg.name,
|
|
623
|
+
"prefix": cfg.session_prefix,
|
|
624
|
+
"peers": ", ".join(agent.can_talk_to) or "none (you are isolated)",
|
|
625
|
+
"inbox": str(cfg.inbox_dir / agent.name),
|
|
626
|
+
"workdir": str(agent.workdir),
|
|
627
|
+
}
|
|
628
|
+
parts = [agent.first_prompt] if agent.first_prompt else []
|
|
629
|
+
try:
|
|
630
|
+
if agent.append_peers_prompt:
|
|
631
|
+
parts.append(comms_tpl.format(**cfg_get).strip())
|
|
632
|
+
if agent.append_task_notice:
|
|
633
|
+
parts.append(notice_tpl.format(**cfg_get).strip())
|
|
634
|
+
except (KeyError, IndexError, ValueError) as exc:
|
|
635
|
+
raise ConfigError(
|
|
636
|
+
f"agent {agent.name!r}: a template placeholder is not recognised: {exc}. "
|
|
637
|
+
f"Available: {', '.join(sorted(cfg_get))}"
|
|
638
|
+
) from exc
|
|
639
|
+
agent.first_prompt = "\n\n".join(p.strip() for p in parts if p.strip())
|
|
640
|
+
|
|
641
|
+
return cfg
|