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/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,57 @@ 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 mail_dirs shared by 2+ agents. Computed up front so
|
|
162
|
+
# `mail_paths` can namespace mailbox folders to avoid collisions. Keyed on
|
|
163
|
+
# mail_dir (where the four folders actually live), NOT workdir: two agents
|
|
164
|
+
# can point a shared `mail_dir` at one place while keeping distinct
|
|
165
|
+
# workdirs, and without this they would silently share one `inbox/`.
|
|
166
|
+
counts: dict[Path, int] = {}
|
|
167
|
+
for agent in self.agents:
|
|
168
|
+
key = agent.mail_dir.resolve()
|
|
169
|
+
counts[key] = counts.get(key, 0) + 1
|
|
170
|
+
self._shared: set[Path] = {d for d, n in counts.items() if n > 1}
|
|
171
|
+
|
|
249
172
|
@property
|
|
250
173
|
def runtime(self) -> Path:
|
|
251
|
-
|
|
174
|
+
"""Orchestrator-private state (logs, queue, run, sessions)."""
|
|
175
|
+
return self.root / ".agentainer"
|
|
252
176
|
|
|
253
177
|
@property
|
|
254
178
|
def log_dir(self) -> Path:
|
|
255
179
|
return self.runtime / "logs"
|
|
256
180
|
|
|
257
181
|
@property
|
|
258
|
-
def
|
|
259
|
-
|
|
182
|
+
def queue_dir(self) -> Path:
|
|
183
|
+
# Per-agent pending-message buffer (one-at-a-time release lives here).
|
|
184
|
+
# Not created by the config layer -- just the authoritative path.
|
|
185
|
+
return self.runtime / "queue"
|
|
260
186
|
|
|
261
187
|
@property
|
|
262
188
|
def run_dir(self) -> Path:
|
|
263
189
|
return self.runtime / "run"
|
|
264
190
|
|
|
265
|
-
@property
|
|
266
|
-
def bin_dir(self) -> Path:
|
|
267
|
-
return self.runtime / "bin"
|
|
268
|
-
|
|
269
191
|
@property
|
|
270
192
|
def sessions_file(self) -> Path:
|
|
271
193
|
"""Where each agent's conversation id is recorded, so `up --resume` works."""
|
|
@@ -281,6 +203,25 @@ class SwarmConfig:
|
|
|
281
203
|
def names(self) -> list[str]:
|
|
282
204
|
return [a.name for a in self.agents]
|
|
283
205
|
|
|
206
|
+
def mail_paths(self, agent: Agent) -> SimpleNamespace:
|
|
207
|
+
"""Resolve the five mailbox folders for *agent* (plan §16).
|
|
208
|
+
|
|
209
|
+
Base is ``agent.mail_dir``. When that mail_dir is shared by more than one
|
|
210
|
+
agent (whether because they share a workdir and inherit the default, or
|
|
211
|
+
because they explicitly point at the same directory), every folder is
|
|
212
|
+
prefixed with ``<name>-`` to avoid collisions. The model never sees this
|
|
213
|
+
-- every nudge/first-prompt is handed the exact computed paths.
|
|
214
|
+
"""
|
|
215
|
+
base = agent.mail_dir
|
|
216
|
+
prefix = agent.name + "-" if agent.mail_dir.resolve() in self._shared else ""
|
|
217
|
+
return SimpleNamespace(
|
|
218
|
+
inbox=base / (prefix + "inbox"),
|
|
219
|
+
outbox=base / (prefix + "outbox"),
|
|
220
|
+
read=base / (prefix + "read"),
|
|
221
|
+
sent=base / (prefix + "sent"),
|
|
222
|
+
failed=base / (prefix + "failed"),
|
|
223
|
+
)
|
|
224
|
+
|
|
284
225
|
|
|
285
226
|
def _as_list(value: Any, ctx: str) -> list[str]:
|
|
286
227
|
if value is None:
|
|
@@ -308,13 +249,56 @@ def _as_str_map(value: Any, ctx: str) -> dict[str, str]:
|
|
|
308
249
|
return {str(k): str(v) for k, v in value.items()}
|
|
309
250
|
|
|
310
251
|
|
|
252
|
+
def _parse_mirror(value: Any) -> Any:
|
|
253
|
+
"""Normalise ``telegram.mirror``: ``*``/``all``/None -> ``"*"``; else a list."""
|
|
254
|
+
if value is None:
|
|
255
|
+
return "*"
|
|
256
|
+
if isinstance(value, str):
|
|
257
|
+
return "*" if value.strip().lower() in ("*", "all") else [value]
|
|
258
|
+
if isinstance(value, list):
|
|
259
|
+
return [str(v) for v in value]
|
|
260
|
+
raise ConfigError("telegram.mirror: expected '*', 'all', or a list of agent names")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _load_telegram(data: dict) -> "TelegramConfig":
|
|
264
|
+
raw = data.get("telegram") or {}
|
|
265
|
+
if not isinstance(raw, dict):
|
|
266
|
+
raise ConfigError("`telegram:` must be a mapping")
|
|
267
|
+
return TelegramConfig(
|
|
268
|
+
enabled=_as_bool(raw.get("enabled"), False, "telegram.enabled"),
|
|
269
|
+
bot_token=str(raw.get("bot_token") or ""),
|
|
270
|
+
chat_id=str(raw.get("chat_id") or ""),
|
|
271
|
+
mirror=_parse_mirror(raw.get("mirror")),
|
|
272
|
+
mirror_user=_as_bool(raw.get("mirror_user"), True, "telegram.mirror_user"),
|
|
273
|
+
mirror_system=_as_bool(raw.get("mirror_system"), False, "telegram.mirror_system"),
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _expand_path(raw: Any, ctx: str, name: str, root: Path, swarm_name: str,
|
|
278
|
+
atype: str, cfg_parent: Path) -> Path:
|
|
279
|
+
"""Expand an optional {name}/{root}/{swarm}/{type} placeholder and resolve
|
|
280
|
+
the result relative to the config file's parent directory."""
|
|
281
|
+
text = str(raw)
|
|
282
|
+
try:
|
|
283
|
+
expanded = text.format(name=name, root=str(root), swarm=swarm_name, type=atype)
|
|
284
|
+
except (KeyError, IndexError) as exc:
|
|
285
|
+
raise ConfigError(
|
|
286
|
+
f"{ctx}: unknown placeholder in {raw!r}: {exc}. "
|
|
287
|
+
"Available: {name} {root} {swarm} {type}"
|
|
288
|
+
) from exc
|
|
289
|
+
path = Path(os.path.expanduser(expanded))
|
|
290
|
+
if not path.is_absolute():
|
|
291
|
+
path = (cfg_parent / path).resolve()
|
|
292
|
+
return path
|
|
293
|
+
|
|
294
|
+
|
|
311
295
|
def load(path: str | os.PathLike) -> SwarmConfig:
|
|
312
296
|
cfg_path = Path(path).expanduser().resolve()
|
|
313
297
|
if not cfg_path.is_file():
|
|
314
298
|
raise ConfigError(
|
|
315
299
|
f"config file not found: {cfg_path}\n"
|
|
316
|
-
" Create one with: cp
|
|
317
|
-
" Or point at it: agentainer -c /path/to/
|
|
300
|
+
" Create one with: cp agentainer.example.yaml agentainer.yaml\n"
|
|
301
|
+
" Or point at it: agentainer -c /path/to/agentainer.yaml up"
|
|
318
302
|
)
|
|
319
303
|
|
|
320
304
|
try:
|
|
@@ -333,12 +317,6 @@ def load(path: str | os.PathLike) -> SwarmConfig:
|
|
|
333
317
|
if not isinstance(defaults, dict):
|
|
334
318
|
raise ConfigError("`defaults:` must be a mapping")
|
|
335
319
|
|
|
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
320
|
# Agent type registry: built-ins, overridable and extensible from YAML.
|
|
343
321
|
types: dict[str, dict[str, Any]] = {
|
|
344
322
|
k: dict(v) for k, v in BUILTIN_AGENT_TYPES.items()
|
|
@@ -357,23 +335,23 @@ def load(path: str | os.PathLike) -> SwarmConfig:
|
|
|
357
335
|
swarm_name = str(swarm.get("name") or cfg_path.stem)
|
|
358
336
|
create_workdirs = _as_bool(swarm.get("create_workdirs"), True, "swarm.create_workdirs")
|
|
359
337
|
|
|
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
338
|
raw_agents = data.get("agents")
|
|
367
|
-
if
|
|
368
|
-
|
|
339
|
+
if raw_agents is None:
|
|
340
|
+
# An empty swarm is valid: `agentainer up` brings up just the orchestrator
|
|
341
|
+
# + UI, and the operator seeds agents from a template / the Settings editor
|
|
342
|
+
# (P4). This also lets the UI remove the last agent without wedging.
|
|
343
|
+
raw_agents = []
|
|
369
344
|
if not isinstance(raw_agents, list):
|
|
370
345
|
raise ConfigError("`agents:` must be a list")
|
|
371
346
|
|
|
347
|
+
# Default mailbox base, if any (overridable per agent).
|
|
348
|
+
default_mail_dir_raw = defaults.get("mail_dir")
|
|
349
|
+
|
|
372
350
|
# Pass 1: materialise agents without resolving peer references.
|
|
373
351
|
agents: list[Agent] = []
|
|
374
352
|
seen: set[str] = set()
|
|
375
|
-
# Warnings collected while resolving each agent (
|
|
376
|
-
|
|
353
|
+
# Warnings collected while resolving each agent (capture upgrades + deprecations).
|
|
354
|
+
agent_warnings: list[str] = []
|
|
377
355
|
for index, raw in enumerate(raw_agents):
|
|
378
356
|
if not isinstance(raw, dict):
|
|
379
357
|
raise ConfigError(f"agents[{index}]: must be a mapping")
|
|
@@ -385,6 +363,11 @@ def load(path: str | os.PathLike) -> SwarmConfig:
|
|
|
385
363
|
f"agent {name!r}: name must match {NAME_RE.pattern} "
|
|
386
364
|
"(it is used as a tmux session name and a directory name)"
|
|
387
365
|
)
|
|
366
|
+
if name in ("user", "system"):
|
|
367
|
+
raise ConfigError(
|
|
368
|
+
f"agent {name!r}: user and system are reserved virtual mailboxes "
|
|
369
|
+
"and cannot be agent names"
|
|
370
|
+
)
|
|
388
371
|
if name in seen:
|
|
389
372
|
raise ConfigError(f"duplicate agent name: {name!r}")
|
|
390
373
|
seen.add(name)
|
|
@@ -402,9 +385,23 @@ def load(path: str | os.PathLike) -> SwarmConfig:
|
|
|
402
385
|
if not command:
|
|
403
386
|
raise ConfigError(f"agent {name!r}: no `command` and type {atype!r} has none")
|
|
404
387
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
)
|
|
388
|
+
# type <-> command mismatch: a command that launches a DIFFERENT agent
|
|
389
|
+
# CLI than `type` implies will never fire its turn-completion signal and
|
|
390
|
+
# pins the agent "busy" forever. Lenient for key-free mocks (word-boundaries).
|
|
391
|
+
if atype in CLI_TOKENS:
|
|
392
|
+
cmd_lower = str(command).lower()
|
|
393
|
+
for token in CLI_TOKENS:
|
|
394
|
+
if token == atype:
|
|
395
|
+
continue
|
|
396
|
+
if re.search(r"\b" + re.escape(token) + r"\b", cmd_lower):
|
|
397
|
+
raise ConfigError(
|
|
398
|
+
f"agent {name!r}: type: {atype} but command launches "
|
|
399
|
+
f"{token!r} ({command!r}). The command must launch the same "
|
|
400
|
+
f"agent CLI as `type`, or the turn-completion signal will "
|
|
401
|
+
f"never fire and the agent will hang. Fix `command` or `type`."
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
capture = str(raw.get("capture") or defaults.get("capture") or "auto")
|
|
408
405
|
if capture not in VALID_CAPTURE:
|
|
409
406
|
raise ConfigError(
|
|
410
407
|
f"agent {name!r}: capture must be one of {', '.join(VALID_CAPTURE)}"
|
|
@@ -414,15 +411,13 @@ def load(path: str | os.PathLike) -> SwarmConfig:
|
|
|
414
411
|
# capture: none on a type that HAS a completion hook (claude/codex) removes
|
|
415
412
|
# the agent's only turn-completion signal and leaves the orchestrator blind
|
|
416
413
|
# 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.
|
|
414
|
+
# type's natural capture so the hook keeps the orchestrator informed.
|
|
420
415
|
if capture == "none" and str(tconf.get("capture")) == "hook":
|
|
421
416
|
capture = "hook"
|
|
422
|
-
|
|
417
|
+
agent_warnings.append(
|
|
423
418
|
f"agent {name!r}: capture: none on a {atype} agent gives the "
|
|
424
419
|
f"orchestrator no turn-completion signal -- auto-upgraded to "
|
|
425
|
-
f"capture: hook.
|
|
420
|
+
f"capture: hook."
|
|
426
421
|
)
|
|
427
422
|
|
|
428
423
|
boot = raw.get("boot_delay_ms")
|
|
@@ -431,38 +426,51 @@ def load(path: str | os.PathLike) -> SwarmConfig:
|
|
|
431
426
|
if boot is None:
|
|
432
427
|
boot = tconf.get("boot_delay_ms", 5000)
|
|
433
428
|
|
|
434
|
-
|
|
429
|
+
# Standing instructions. `role` is the v2 field; `first_prompt` is a
|
|
430
|
+
# deprecated alias (warn). `first_prompt_file` is likewise deprecated.
|
|
431
|
+
role = raw.get("role")
|
|
435
432
|
prompt_file = raw.get("first_prompt_file")
|
|
433
|
+
if prompt_file and (role is not None or raw.get("first_prompt") is not None):
|
|
434
|
+
raise ConfigError(
|
|
435
|
+
f"agent {name!r}: set either `role`/`first_prompt` or "
|
|
436
|
+
"`first_prompt_file`, not both"
|
|
437
|
+
)
|
|
436
438
|
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
439
|
fp = Path(os.path.expanduser(str(prompt_file)))
|
|
442
440
|
if not fp.is_absolute():
|
|
443
441
|
fp = cfg_path.parent / fp
|
|
444
442
|
if not fp.is_file():
|
|
445
443
|
raise ConfigError(f"agent {name!r}: first_prompt_file not found: {fp}")
|
|
446
|
-
|
|
447
|
-
|
|
444
|
+
role = fp.read_text()
|
|
445
|
+
if "first_prompt" in raw:
|
|
446
|
+
agent_warnings.append(
|
|
447
|
+
f"agent {name!r}: `first_prompt` is deprecated; use `role`"
|
|
448
|
+
)
|
|
449
|
+
if "first_prompt_file" in raw:
|
|
450
|
+
agent_warnings.append(
|
|
451
|
+
f"agent {name!r}: `first_prompt_file` is deprecated; use `role` "
|
|
452
|
+
"(write the text directly, or read the file yourself)"
|
|
453
|
+
)
|
|
454
|
+
role = (role or raw.get("first_prompt") or "").strip()
|
|
448
455
|
|
|
449
456
|
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
|
|
457
|
+
workdir = (
|
|
458
|
+
_expand_path(workdir_raw, f"agent {name}: workdir", name, root,
|
|
459
|
+
swarm_name, atype, cfg_path.parent)
|
|
460
|
+
if workdir_raw
|
|
461
|
+
else root / name
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
# Mailbox base: per-agent mail_dir overrides the global default, both
|
|
465
|
+
# resolved relative to the config file's parent. Default = the workdir,
|
|
466
|
+
# so by default the four folders live inside the workspace.
|
|
467
|
+
mail_dir_raw = raw.get("mail_dir") or default_mail_dir_raw
|
|
468
|
+
mail_dir = (
|
|
469
|
+
_expand_path(mail_dir_raw, f"agent {name}: mail_dir", name, root,
|
|
470
|
+
swarm_name, atype, cfg_path.parent)
|
|
471
|
+
if mail_dir_raw
|
|
472
|
+
else workdir
|
|
473
|
+
)
|
|
466
474
|
|
|
467
475
|
create_workdir = _as_bool(
|
|
468
476
|
raw.get("create_workdir", defaults.get("create_workdir", create_workdirs)),
|
|
@@ -470,6 +478,16 @@ def load(path: str | os.PathLike) -> SwarmConfig:
|
|
|
470
478
|
f"agent {name}: create_workdir",
|
|
471
479
|
)
|
|
472
480
|
|
|
481
|
+
ping_seconds = raw.get("periodically_ping_seconds")
|
|
482
|
+
if ping_seconds is None:
|
|
483
|
+
ping_seconds = defaults.get("periodically_ping_seconds")
|
|
484
|
+
ping_seconds = int(ping_seconds or 0)
|
|
485
|
+
ping_message = (
|
|
486
|
+
raw.get("periodically_ping_message")
|
|
487
|
+
or defaults.get("periodically_ping_message")
|
|
488
|
+
or ""
|
|
489
|
+
)
|
|
490
|
+
|
|
473
491
|
env = dict(_as_str_map(defaults.get("env"), "defaults.env"))
|
|
474
492
|
env.update(_as_str_map(tconf.get("env"), f"agent_types.{atype}.env"))
|
|
475
493
|
env.update(_as_str_map(raw.get("env"), f"agent {name}: env"))
|
|
@@ -483,109 +501,75 @@ def load(path: str | os.PathLike) -> SwarmConfig:
|
|
|
483
501
|
session=f"{prefix}{name}",
|
|
484
502
|
capture=capture,
|
|
485
503
|
boot_delay_ms=int(boot),
|
|
486
|
-
|
|
504
|
+
role=role,
|
|
487
505
|
can_talk_to=_as_list(
|
|
488
506
|
raw.get("can_talk_to", defaults.get("can_talk_to")),
|
|
489
507
|
f"agent {name}: can_talk_to",
|
|
490
508
|
),
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
)
|
|
509
|
+
mail_dir=mail_dir,
|
|
510
|
+
periodically_ping_seconds=ping_seconds,
|
|
511
|
+
periodically_ping_message=str(ping_message),
|
|
512
|
+
resume_args=raw.get("resume_args")
|
|
513
|
+
or defaults.get("resume_args")
|
|
514
|
+
or tconf.get("resume_args"),
|
|
515
|
+
resume_command=raw.get("resume_command")
|
|
516
|
+
or defaults.get("resume_command")
|
|
517
|
+
or tconf.get("resume_command"),
|
|
495
518
|
env=env,
|
|
496
519
|
create_workdir=create_workdir,
|
|
497
520
|
# Busy tracking needs a "turn finished" signal, which only exists
|
|
498
521
|
# 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
522
|
ready_probe=_as_bool(
|
|
524
523
|
raw.get("ready_probe", defaults.get("ready_probe")),
|
|
525
524
|
True,
|
|
526
525
|
f"agent {name}: ready_probe",
|
|
527
526
|
),
|
|
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
|
-
),
|
|
527
|
+
busy_check=_as_bool(
|
|
528
|
+
raw.get("busy_check", defaults.get("busy_check")),
|
|
533
529
|
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
|
-
),
|
|
530
|
+
f"agent {name}: busy_check",
|
|
531
|
+
)
|
|
532
|
+
and capture != "none",
|
|
546
533
|
)
|
|
547
534
|
)
|
|
548
535
|
|
|
549
536
|
all_names = [a.name for a in agents]
|
|
550
537
|
|
|
551
|
-
# Pass 2: expand wildcards and validate the communication graph.
|
|
538
|
+
# Pass 2: expand wildcards and validate the communication graph. `user` is a
|
|
539
|
+
# permitted virtual recipient; `system` is orchestrator-only and may never be
|
|
540
|
+
# addressed by an agent; everything else must name a real agent.
|
|
552
541
|
for agent in agents:
|
|
553
542
|
if "*" in agent.can_talk_to:
|
|
554
|
-
|
|
543
|
+
# `*` means "every other agent". Preserve any other entries listed
|
|
544
|
+
# alongside it (notably `user`, which `*` does not cover) instead of
|
|
545
|
+
# replacing the whole list -- dropping an explicit recipient would
|
|
546
|
+
# deny mail the config plainly granted.
|
|
547
|
+
expanded = [n for n in all_names if n != agent.name]
|
|
548
|
+
extras = [
|
|
549
|
+
p
|
|
550
|
+
for p in agent.can_talk_to
|
|
551
|
+
if p != "*" and p != agent.name and p not in expanded
|
|
552
|
+
]
|
|
553
|
+
agent.can_talk_to = expanded + extras
|
|
555
554
|
for peer in agent.can_talk_to:
|
|
556
|
-
if peer
|
|
555
|
+
if peer == "system":
|
|
557
556
|
raise ConfigError(
|
|
558
|
-
f"agent {agent.name!r}:
|
|
557
|
+
f"agent {agent.name!r}: `system` is a reserved orchestrator "
|
|
558
|
+
"mailbox and can never be a recipient -- remove it from "
|
|
559
|
+
"can_talk_to"
|
|
559
560
|
)
|
|
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:
|
|
561
|
+
if peer == "user":
|
|
562
|
+
continue
|
|
566
563
|
if peer not in all_names:
|
|
567
564
|
raise ConfigError(
|
|
568
|
-
f"agent {agent.name!r}:
|
|
565
|
+
f"agent {agent.name!r}: can_talk_to references unknown agent "
|
|
566
|
+
f"{peer!r}"
|
|
569
567
|
)
|
|
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
|
-
)
|
|
568
|
+
if peer == agent.name:
|
|
569
|
+
raise ConfigError(f"agent {agent.name!r}: cannot be in its own can_talk_to")
|
|
580
570
|
|
|
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
571
|
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
|
|
572
|
+
warnings.extend(agent_warnings)
|
|
589
573
|
|
|
590
574
|
for agent in agents:
|
|
591
575
|
if agent.workdir.exists() and not agent.workdir.is_dir():
|
|
@@ -618,14 +602,12 @@ def load(path: str | os.PathLike) -> SwarmConfig:
|
|
|
618
602
|
agents=agents,
|
|
619
603
|
enter_delay_ms=int(swarm.get("enter_delay_ms", 250)),
|
|
620
604
|
send_delay_ms=int(swarm.get("send_delay_ms", 150)),
|
|
621
|
-
max_forward_hops=int(swarm.get("max_forward_hops", 3)),
|
|
622
605
|
ready_timeout_ms=int(swarm.get("ready_timeout_ms", 60000)),
|
|
623
606
|
busy_timeout_ms=int(swarm.get("busy_timeout_ms", 900000)),
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
send_failed_template=failed_tpl,
|
|
607
|
+
resume=_as_bool(swarm.get("resume"), True, "swarm.resume"),
|
|
608
|
+
user_available=_as_bool(
|
|
609
|
+
swarm.get("user_available"), False, "swarm.user_available"
|
|
610
|
+
),
|
|
629
611
|
pane_idle_ms=int(swarm.get("pane_idle_ms", 2500)),
|
|
630
612
|
pane_poll_ms=int(swarm.get("pane_poll_ms", 700)),
|
|
631
613
|
pane_scrollback=int(swarm.get("pane_scrollback", 400)),
|
|
@@ -633,29 +615,6 @@ def load(path: str | os.PathLike) -> SwarmConfig:
|
|
|
633
615
|
tmux_mouse=_as_bool(swarm.get("tmux_mouse"), True, "swarm.tmux_mouse"),
|
|
634
616
|
supervise=_as_bool(swarm.get("supervise"), True, "swarm.supervise"),
|
|
635
617
|
supervise_interval_ms=int(swarm.get("supervise_interval_ms", 15000)),
|
|
618
|
+
telegram=_load_telegram(data),
|
|
636
619
|
)
|
|
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
620
|
return cfg
|