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/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