agentainer 2.0.0 → 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/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/brand-voice-style-guide.yaml +109 -0
- 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/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/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/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/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/rag-builder.yaml +145 -0
- package/examples/refactor-planner.yaml +127 -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/startup-validator.yaml +115 -0
- 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/youtube-script-studio.yaml +107 -0
- package/lib/cli.py +6 -2
- package/lib/config.py +28 -11
- package/lib/mail.py +78 -13
- package/lib/reconcile.py +80 -9
- package/lib/turn.py +14 -6
- package/lib/ui.py +212 -13
- package/package.json +1 -1
- package/ui/app.js +290 -23
- package/ui/index.html +58 -2
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# =============================================================================
|
|
2
|
+
# 📄 White-paper research -- a hub researcher gathers B2B source material, then
|
|
3
|
+
# an analyst synthesizes it, a writer drafts the paper, and a design brief is
|
|
4
|
+
# produced for layout. Turns a fuzzy topic into a publishable white-paper package.
|
|
5
|
+
#
|
|
6
|
+
# cp examples/white-paper-research.yaml my-paper.yaml
|
|
7
|
+
# agentainer up -c my-paper.yaml
|
|
8
|
+
# agentainer send -c my-paper.yaml --to topic_researcher "White paper on zero-trust security for mid-market SaaS."
|
|
9
|
+
# agentainer down -c my-paper.yaml
|
|
10
|
+
#
|
|
11
|
+
# The communication graph is a hub-and-spoke: the researcher is the only agent
|
|
12
|
+
# that talks to the human and the only one that reaches the three specialists,
|
|
13
|
+
# so raw sources are gathered once and fanned out to synthesis, drafting and
|
|
14
|
+
# design instead of each specialist re-researching the topic.
|
|
15
|
+
#
|
|
16
|
+
# user <──> topic_researcher (the hub)
|
|
17
|
+
# / | \
|
|
18
|
+
# / | \
|
|
19
|
+
# analyst draft_writer design_brief_writer
|
|
20
|
+
# ...the three specialists never talk to each other; everything flows
|
|
21
|
+
# through the researcher, who owns the topic and the source material.
|
|
22
|
+
#
|
|
23
|
+
# Key-free: swap each `command` for a mock bash loop and the whole pipeline
|
|
24
|
+
# routes mail with NO API keys. The `command` lines below launch the real CLIs.
|
|
25
|
+
# =============================================================================
|
|
26
|
+
|
|
27
|
+
swarm:
|
|
28
|
+
name: white-paper-research
|
|
29
|
+
root: ./white-paper-research-workspace
|
|
30
|
+
|
|
31
|
+
defaults:
|
|
32
|
+
capture: none # tightened per agent below
|
|
33
|
+
can_talk_to: [] # default ACL is "talk to no one"; set per agent
|
|
34
|
+
|
|
35
|
+
agents:
|
|
36
|
+
- name: topic_researcher
|
|
37
|
+
type: claude
|
|
38
|
+
can_talk_to: [analyst, draft_writer, design_brief_writer, user]
|
|
39
|
+
command: "claude --dangerously-skip-permissions"
|
|
40
|
+
role: |
|
|
41
|
+
You are the TOPIC RESEARCHER and hub for a white-paper project. You take a
|
|
42
|
+
B2B topic from the user, scope the angle and audience, and gather the raw
|
|
43
|
+
source material: market data, competitive landscape, standards, customer
|
|
44
|
+
pain points, and credible citations. You do NOT write the paper -- you own
|
|
45
|
+
the facts and the brief.
|
|
46
|
+
Run it like this: (1) restate the topic as a one-paragraph scope + the
|
|
47
|
+
target reader + 3-5 key questions the paper must answer; (2) collect and
|
|
48
|
+
organize sources and findings into a research pack; (3) send the pack to
|
|
49
|
+
the analyst for synthesis; (4) once the analyst returns a thesis and
|
|
50
|
+
outline, brief the draft_writer to write and the design_brief_writer to
|
|
51
|
+
spec the layout; (5) return the finished package to the user.
|
|
52
|
+
MAILBOX: when a message lands in your inbox/, read it and act; when done,
|
|
53
|
+
move it to read/. To send, write a file into outbox/<name>/ (read
|
|
54
|
+
outbox/<name>/about.md first) and finish your turn. You may message the
|
|
55
|
+
agents in your can_talk_to.
|
|
56
|
+
|
|
57
|
+
- name: analyst
|
|
58
|
+
type: claude
|
|
59
|
+
can_talk_to: [topic_researcher]
|
|
60
|
+
command: "claude --dangerously-skip-permissions"
|
|
61
|
+
role: |
|
|
62
|
+
You are the ANALYST. Given the researcher's source pack, turn raw findings
|
|
63
|
+
into a defensible argument: identify the central thesis, the 3-5 supporting
|
|
64
|
+
points, the counter-arguments to pre-empt, and a section-by-section outline
|
|
65
|
+
for the white paper. Separate what the sources prove from what is
|
|
66
|
+
inference; flag any claim that needs a stronger citation. Write your
|
|
67
|
+
synthesis and outline, then report back to the topic_researcher. If the
|
|
68
|
+
source pack is thin or contradictory, say so and ask for more -- do not
|
|
69
|
+
paper over gaps.
|
|
70
|
+
|
|
71
|
+
- name: draft_writer
|
|
72
|
+
type: codex
|
|
73
|
+
can_talk_to: [topic_researcher]
|
|
74
|
+
command: "codex --yolo"
|
|
75
|
+
role: |
|
|
76
|
+
You are the DRAFT WRITER. Given the analyst's thesis and outline (relayed
|
|
77
|
+
by the researcher) plus the source pack, write the white-paper draft:
|
|
78
|
+
executive summary, body sections following the outline, and a conclusion
|
|
79
|
+
with a clear call to action. Write for a B2B decision-maker -- authoritative,
|
|
80
|
+
concrete, jargon-checked -- and cite sources inline as the analyst mapped
|
|
81
|
+
them. Do not invent statistics; if a section lacks support, ask the
|
|
82
|
+
researcher rather than filling the gap. Send the completed draft back to
|
|
83
|
+
the topic_researcher.
|
|
84
|
+
|
|
85
|
+
- name: design_brief_writer
|
|
86
|
+
type: claude
|
|
87
|
+
can_talk_to: [topic_researcher]
|
|
88
|
+
command: "claude --dangerously-skip-permissions"
|
|
89
|
+
role: |
|
|
90
|
+
You are the DESIGN BRIEF WRITER. Given the outline and draft (relayed by
|
|
91
|
+
the researcher), produce a design brief a layout designer can execute:
|
|
92
|
+
recommended page count and structure, where pull-quotes and callouts go,
|
|
93
|
+
which data points become charts or infographics (and the chart type),
|
|
94
|
+
cover concept, and a tone/visual-style note aligned to the target reader.
|
|
95
|
+
You specify the visual plan; you do not design pixels. Send the brief back
|
|
96
|
+
to the topic_researcher.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# =============================================================================
|
|
2
|
+
# 🎬 YouTube script studio -- turn a video topic into a full publish-ready
|
|
3
|
+
# package: script -> title/thumbnail copy -> description + tags + chapters.
|
|
4
|
+
#
|
|
5
|
+
# cp examples/youtube-script-studio.yaml my-studio.yaml
|
|
6
|
+
# agentainer up -c my-studio.yaml
|
|
7
|
+
# agentainer send -c my-studio.yaml --to producer "Make a 10-min video: 'Why your sourdough won't rise'."
|
|
8
|
+
# agentainer down -c my-studio.yaml
|
|
9
|
+
#
|
|
10
|
+
# The producer is the hub: it briefs a researcher (hooks + retention patterns),
|
|
11
|
+
# a scriptwriter (the actual script), and a metadata_writer (title, thumbnail
|
|
12
|
+
# copy, description, tags, chapters). Script and metadata are written as peers so
|
|
13
|
+
# titles/chapters track the real script -- but the producer stays the only agent
|
|
14
|
+
# that talks to you.
|
|
15
|
+
#
|
|
16
|
+
# make a video X
|
|
17
|
+
# user ─────────────▶ producer ◀───────────────────────┐
|
|
18
|
+
# (package) ◀──────┼──────────────┬───────────┐ │
|
|
19
|
+
# ▼ ▼ ▼ │
|
|
20
|
+
# researcher scriptwriter metadata_writer
|
|
21
|
+
# └────peer────┘
|
|
22
|
+
# ...producer <--> everyone; scriptwriter <--> metadata_writer; user via hub only.
|
|
23
|
+
#
|
|
24
|
+
# Key-free / no API keys: every `command` below is a placeholder launch line for a
|
|
25
|
+
# real coding-agent CLI. Swap each for a mock bash loop and the whole studio comes
|
|
26
|
+
# up and routes mail with NO API keys -- the mechanics are identical.
|
|
27
|
+
# =============================================================================
|
|
28
|
+
|
|
29
|
+
swarm:
|
|
30
|
+
name: youtube-script-studio
|
|
31
|
+
root: ./youtube-script-studio-workspace
|
|
32
|
+
|
|
33
|
+
defaults:
|
|
34
|
+
capture: none # tightened per agent (pane for gemini/hermes)
|
|
35
|
+
can_talk_to: [] # tightened per agent below
|
|
36
|
+
|
|
37
|
+
agents:
|
|
38
|
+
- name: producer
|
|
39
|
+
type: claude
|
|
40
|
+
workdir: "{root}/repo"
|
|
41
|
+
can_talk_to: [researcher, scriptwriter, metadata_writer, user]
|
|
42
|
+
command: "claude --dangerously-skip-permissions"
|
|
43
|
+
capture: none
|
|
44
|
+
role: |
|
|
45
|
+
You are the PRODUCER, the hub of a YouTube video studio. You turn the
|
|
46
|
+
user's topic into a publish-ready package and are the ONLY agent who talks
|
|
47
|
+
to the user. You do not write the script yourself; you set the angle,
|
|
48
|
+
audience, target length, and the promise of the video, then coordinate.
|
|
49
|
+
Your team: researcher (hook ideas + retention/pacing patterns for this
|
|
50
|
+
topic), scriptwriter (writes the full script), metadata_writer (title
|
|
51
|
+
options, thumbnail copy, description, tags, chapter markers).
|
|
52
|
+
Run it like this: (1) restate the topic as a one-paragraph brief -- angle,
|
|
53
|
+
audience, length, the single promise -- and send it to the researcher
|
|
54
|
+
first; (2) pass the research to the scriptwriter to draft the script;
|
|
55
|
+
(3) have the metadata_writer build titles/thumbnail/description/tags/
|
|
56
|
+
chapters against the FINAL script; (4) assemble everything and send the
|
|
57
|
+
finished package to the user.
|
|
58
|
+
MAILBOX: when a message lands in your inbox/, read it and act; when done,
|
|
59
|
+
move it to read/. To send, write a file into outbox/<name>/ (read
|
|
60
|
+
outbox/<name>/about.md first to see who they are and whether they are
|
|
61
|
+
available) and finish your turn. You may only message the agents in your
|
|
62
|
+
can_talk_to list.
|
|
63
|
+
|
|
64
|
+
- name: researcher
|
|
65
|
+
type: gemini
|
|
66
|
+
workdir: "{root}/repo"
|
|
67
|
+
can_talk_to: [producer]
|
|
68
|
+
command: "gemini --yolo"
|
|
69
|
+
capture: pane
|
|
70
|
+
role: |
|
|
71
|
+
You are the RESEARCHER. Given the producer's brief, find what makes THIS
|
|
72
|
+
topic watchable: 3-5 strong hook openers, the questions the audience
|
|
73
|
+
actually types, the moments where viewers drop off and how to hold them
|
|
74
|
+
(pattern interrupts, open loops, payoff placement), and any facts or
|
|
75
|
+
angles worth including. You do not write the script -- you hand the
|
|
76
|
+
producer a tight research brief they can pass to the scriptwriter. Report
|
|
77
|
+
only to the producer.
|
|
78
|
+
|
|
79
|
+
- name: scriptwriter
|
|
80
|
+
type: claude
|
|
81
|
+
workdir: "{root}/repo"
|
|
82
|
+
can_talk_to: [producer, metadata_writer]
|
|
83
|
+
command: "claude --dangerously-skip-permissions"
|
|
84
|
+
capture: none
|
|
85
|
+
role: |
|
|
86
|
+
You are the SCRIPTWRITER. Given the producer's brief and the researcher's
|
|
87
|
+
findings, write the full video SCRIPT.md: a cold-open hook in the first 15
|
|
88
|
+
seconds, a clear through-line, spoken-word narration (not bullet points),
|
|
89
|
+
B-roll / on-screen cues in brackets, and a call to action. Match the
|
|
90
|
+
target length and audience. When the script is solid, send a copy straight
|
|
91
|
+
to the metadata_writer so titles and chapters track the real script, and
|
|
92
|
+
report the draft to the producer.
|
|
93
|
+
|
|
94
|
+
- name: metadata_writer
|
|
95
|
+
type: codex
|
|
96
|
+
workdir: "{root}/repo"
|
|
97
|
+
can_talk_to: [producer, scriptwriter]
|
|
98
|
+
command: "codex --yolo"
|
|
99
|
+
capture: none
|
|
100
|
+
role: |
|
|
101
|
+
You are the METADATA_WRITER. Working from the FINAL script, produce the
|
|
102
|
+
packaging that gets the video found and clicked: 5-8 title options (front-
|
|
103
|
+
load the keyword, keep them honest), thumbnail copy (2-4 punchy words),
|
|
104
|
+
an SEO description with the payoff in the first two lines, a tag list, and
|
|
105
|
+
timestamped chapter markers derived from the script's sections. If the
|
|
106
|
+
script is missing a section you need for a chapter, ask the scriptwriter
|
|
107
|
+
directly; deliver the finished packaging to the producer.
|
package/lib/cli.py
CHANGED
|
@@ -555,8 +555,7 @@ def cmd_queue(args) -> int:
|
|
|
555
555
|
dropped += 1
|
|
556
556
|
info(f"{agent.name}: dropped {dropped} queued message(s)")
|
|
557
557
|
return 0
|
|
558
|
-
|
|
559
|
-
items = sorted(f for f in q.iterdir() if f.is_file()) if q.is_dir() else []
|
|
558
|
+
items = mail.queued_files(cfg, agent.name)
|
|
560
559
|
state = turn.busy_info(cfg, agent)
|
|
561
560
|
status = f"busy for {state['age_s']}s (task from {state['by']})" if state else "idle"
|
|
562
561
|
print(f"{agent.name}: {status}, {len(items)} message(s) queued")
|
|
@@ -574,6 +573,11 @@ def cmd_idle(args) -> int:
|
|
|
574
573
|
info(f"{agent.name}: marked idle")
|
|
575
574
|
if not args.no_drain:
|
|
576
575
|
mail.process_read_folder(cfg, agent.name)
|
|
576
|
+
# process_read_folder only archives an over-presented message; pair the
|
|
577
|
+
# release of the next queued message with a nudge, exactly as the
|
|
578
|
+
# supervisor tick does, so the escape hatch actually re-announces mail.
|
|
579
|
+
if mail.release_next(cfg, agent.name):
|
|
580
|
+
mail.nudge(cfg, agent.name)
|
|
577
581
|
return 0
|
|
578
582
|
|
|
579
583
|
|
package/lib/config.py
CHANGED
|
@@ -158,11 +158,14 @@ class SwarmConfig:
|
|
|
158
158
|
warnings: list[str] = field(default_factory=list)
|
|
159
159
|
|
|
160
160
|
def __post_init__(self) -> None:
|
|
161
|
-
# Set of resolved
|
|
162
|
-
# `mail_paths` can namespace mailbox folders to avoid collisions.
|
|
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/`.
|
|
163
166
|
counts: dict[Path, int] = {}
|
|
164
167
|
for agent in self.agents:
|
|
165
|
-
key = agent.
|
|
168
|
+
key = agent.mail_dir.resolve()
|
|
166
169
|
counts[key] = counts.get(key, 0) + 1
|
|
167
170
|
self._shared: set[Path] = {d for d, n in counts.items() if n > 1}
|
|
168
171
|
|
|
@@ -203,13 +206,14 @@ class SwarmConfig:
|
|
|
203
206
|
def mail_paths(self, agent: Agent) -> SimpleNamespace:
|
|
204
207
|
"""Resolve the five mailbox folders for *agent* (plan §16).
|
|
205
208
|
|
|
206
|
-
Base is ``agent.mail_dir``. When
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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.
|
|
210
214
|
"""
|
|
211
215
|
base = agent.mail_dir
|
|
212
|
-
prefix = agent.name + "-" if agent.
|
|
216
|
+
prefix = agent.name + "-" if agent.mail_dir.resolve() in self._shared else ""
|
|
213
217
|
return SimpleNamespace(
|
|
214
218
|
inbox=base / (prefix + "inbox"),
|
|
215
219
|
outbox=base / (prefix + "outbox"),
|
|
@@ -332,8 +336,11 @@ def load(path: str | os.PathLike) -> SwarmConfig:
|
|
|
332
336
|
create_workdirs = _as_bool(swarm.get("create_workdirs"), True, "swarm.create_workdirs")
|
|
333
337
|
|
|
334
338
|
raw_agents = data.get("agents")
|
|
335
|
-
if
|
|
336
|
-
|
|
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 = []
|
|
337
344
|
if not isinstance(raw_agents, list):
|
|
338
345
|
raise ConfigError("`agents:` must be a list")
|
|
339
346
|
|
|
@@ -533,7 +540,17 @@ def load(path: str | os.PathLike) -> SwarmConfig:
|
|
|
533
540
|
# addressed by an agent; everything else must name a real agent.
|
|
534
541
|
for agent in agents:
|
|
535
542
|
if "*" in agent.can_talk_to:
|
|
536
|
-
|
|
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
|
|
537
554
|
for peer in agent.can_talk_to:
|
|
538
555
|
if peer == "system":
|
|
539
556
|
raise ConfigError(
|
package/lib/mail.py
CHANGED
|
@@ -29,6 +29,7 @@ Branding: "swarm" is retired -- it's Agentainer everywhere (decision D21).
|
|
|
29
29
|
from __future__ import annotations
|
|
30
30
|
|
|
31
31
|
import json
|
|
32
|
+
import os
|
|
32
33
|
import re
|
|
33
34
|
import shutil
|
|
34
35
|
import time
|
|
@@ -148,11 +149,23 @@ def enqueue(cfg: SwarmConfig, recipient: str, text: str, msg_id: str) -> None:
|
|
|
148
149
|
After the message is durably queued, best-effort mirror it to Telegram (a
|
|
149
150
|
no-op unless configured). Mirroring runs AFTER the write and can never raise
|
|
150
151
|
-- correctness never depends on the network (see lib/telegram.py).
|
|
152
|
+
|
|
153
|
+
We stamp the queue file with a strictly-increasing modification time (>= every
|
|
154
|
+
file already queued for this recipient) so :func:`queued_files` gives true
|
|
155
|
+
FIFO. Filesystem mtime granularity can't be relied on: some filesystems hand
|
|
156
|
+
identical mtimes to writes made in the same coarse tick, which would let
|
|
157
|
+
release order fall back to the random message-id name and starve a message
|
|
158
|
+
whose id happens to sort late. Enqueues for one recipient are serialised by
|
|
159
|
+
this same lock, so max-existing+1 is a safe monotonic clock per queue.
|
|
151
160
|
"""
|
|
152
161
|
with lock.file_lock(cfg, recipient, "mail"):
|
|
153
162
|
q = cfg.queue_dir / recipient
|
|
154
163
|
q.mkdir(parents=True, exist_ok=True)
|
|
155
|
-
|
|
164
|
+
path = q / f"{msg_id}.txt"
|
|
165
|
+
path.write_text(text)
|
|
166
|
+
prior = [f.stat().st_mtime_ns for f in q.iterdir() if f.is_file() and f != path]
|
|
167
|
+
stamp = max([time.time_ns()] + [p + 1 for p in prior])
|
|
168
|
+
os.utime(path, ns=(stamp, stamp))
|
|
156
169
|
import telegram # lazy: keeps mail's import graph free of the bridge
|
|
157
170
|
|
|
158
171
|
telegram.on_enqueued(cfg, recipient, text, msg_id)
|
|
@@ -299,9 +312,35 @@ def standby_prompt(cfg: SwarmConfig, agent) -> str:
|
|
|
299
312
|
)
|
|
300
313
|
|
|
301
314
|
|
|
315
|
+
def _queue_order(path: Path):
|
|
316
|
+
"""FIFO sort key for a queued message: enqueue time first, name as tie-break.
|
|
317
|
+
|
|
318
|
+
Message filenames are random ids (``m-<uuid8>.txt``), so sorting the queue
|
|
319
|
+
by *name* is a random order -- a message whose id happens to sort late gets
|
|
320
|
+
starved indefinitely as lower-sorting ids keep cutting in line. Queue files
|
|
321
|
+
are written once by ``enqueue`` and never modified, so ``st_mtime_ns`` is
|
|
322
|
+
the moment the message was enqueued: order by that to get true FIFO.
|
|
323
|
+
"""
|
|
324
|
+
return (path.stat().st_mtime_ns, path.name)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def queued_files(cfg: SwarmConfig, agent_name: str) -> list:
|
|
328
|
+
"""Return *agent_name*'s queued message files in FIFO (enqueue-time) order.
|
|
329
|
+
|
|
330
|
+
A missing queue dir (agent never received mail) is treated as empty.
|
|
331
|
+
"""
|
|
332
|
+
q = cfg.queue_dir / agent_name
|
|
333
|
+
if not q.exists():
|
|
334
|
+
return []
|
|
335
|
+
return sorted((f for f in q.iterdir() if f.is_file()), key=_queue_order)
|
|
336
|
+
|
|
337
|
+
|
|
302
338
|
def release_next(cfg: SwarmConfig, agent_name: str) -> bool:
|
|
303
339
|
"""Release the oldest queued message into *agent_name*'s inbox (one-at-a-time).
|
|
304
340
|
|
|
341
|
+
"Oldest" is by enqueue time (see :func:`queued_files`), not filename -- the
|
|
342
|
+
ids are random, so filename order would starve a late-sorting message.
|
|
343
|
+
|
|
305
344
|
Returns False if the inbox already holds a message (one-at-a-time) or the
|
|
306
345
|
queue is empty; True if a message was moved into the inbox. Each release
|
|
307
346
|
bumps the presentation counter used by the auto-archive fallback.
|
|
@@ -328,10 +367,7 @@ def release_next(cfg: SwarmConfig, agent_name: str) -> bool:
|
|
|
328
367
|
# One-at-a-time: a message is already presented; count it as a presentation.
|
|
329
368
|
_bump_presentations(cfg, agent_name, existing[0].name)
|
|
330
369
|
return False
|
|
331
|
-
|
|
332
|
-
# q may not exist yet for an agent that has never received mail;
|
|
333
|
-
# iterdir() would raise, so treat a missing queue as empty.
|
|
334
|
-
files = sorted(f for f in q.iterdir() if f.is_file()) if q.exists() else []
|
|
370
|
+
files = queued_files(cfg, agent_name)
|
|
335
371
|
if not files:
|
|
336
372
|
return False
|
|
337
373
|
oldest = files[0]
|
|
@@ -373,13 +409,30 @@ def nudge(cfg: SwarmConfig, agent_name: str) -> bool:
|
|
|
373
409
|
f"You can message: {allowed}."
|
|
374
410
|
)
|
|
375
411
|
try:
|
|
376
|
-
|
|
412
|
+
pasted = tmux.paste_into(cfg, agent.session, nudge_text)
|
|
377
413
|
except tmux.SwarmError:
|
|
378
414
|
# Best-effort: if the agent isn't up (or tmux is unavailable) the mail
|
|
379
415
|
# still sits in the queue and gets released on the next sweep / when the
|
|
380
416
|
# agent starts. Never crash a send because a session is missing -- a
|
|
381
417
|
# paste failure just means we retry on the next tick.
|
|
382
418
|
return False
|
|
419
|
+
if pasted:
|
|
420
|
+
# The agent was actually poked about a freshly delivered message, so it
|
|
421
|
+
# is now mid-turn. Mark the turn STARTED so busy-detection is accurate:
|
|
422
|
+
# otherwise (delivered stayed == completed after the first prompt) the
|
|
423
|
+
# supervisor treats a long but legitimate turn as idle, bumps the inbox
|
|
424
|
+
# message's presentation count every tick, and auto-archives it mid-turn
|
|
425
|
+
# -- yanking the task and corrupting the turn. The inbox holds exactly
|
|
426
|
+
# one message (one-at-a-time); its From is the task's origin.
|
|
427
|
+
sender = None
|
|
428
|
+
try:
|
|
429
|
+
msgs = sorted(f for f in mp.inbox.iterdir() if f.is_file())
|
|
430
|
+
if msgs:
|
|
431
|
+
sender = _parse_header_field(msgs[0].read_text(), "From")
|
|
432
|
+
except OSError: # pragma: no cover - defensive only
|
|
433
|
+
sender = None
|
|
434
|
+
turn.mark_turn_started(cfg, agent_name, sender or "system")
|
|
435
|
+
return pasted
|
|
383
436
|
|
|
384
437
|
|
|
385
438
|
def route_outbound(cfg: SwarmConfig, sender: str, recipient: str, body: str) -> str:
|
|
@@ -443,9 +496,10 @@ def on_stop(cfg: SwarmConfig, agent_name: str) -> dict:
|
|
|
443
496
|
if not sub.is_dir():
|
|
444
497
|
continue
|
|
445
498
|
recipient = sub.name
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
499
|
+
# Route in the order the agent WROTE the files (enqueue/delivery
|
|
500
|
+
# order follows this), not by the model's arbitrary filenames --
|
|
501
|
+
# otherwise two messages to the same peer can arrive out of order.
|
|
502
|
+
for f in sorted((c for c in sub.iterdir() if c.is_file()), key=_queue_order):
|
|
449
503
|
# about.md is the orchestrator-maintained contact card, not
|
|
450
504
|
# an outbound message -- never route or delete it.
|
|
451
505
|
if f.name == "about.md":
|
|
@@ -513,21 +567,32 @@ def process_read_folder(cfg: SwarmConfig, agent_name: str) -> int:
|
|
|
513
567
|
_save_run_json(cfg, f"{agent_name}.read.json", state)
|
|
514
568
|
|
|
515
569
|
# Auto-archive fallback (plan §7): a single message presented >= N times
|
|
516
|
-
# without being handled is moved to the archive
|
|
517
|
-
#
|
|
570
|
+
# without being handled is moved to the archive so a forgetful model can
|
|
571
|
+
# never wedge the swarm. We only ARCHIVE here (empty the inbox); we do NOT
|
|
572
|
+
# release the next message, because the release must be paired with a nudge
|
|
573
|
+
# -- and every caller (supervisor tick, cmd_idle) runs release_next + nudge
|
|
574
|
+
# right after us. Releasing here (un-nudged) meant the freshly delivered
|
|
575
|
+
# message was never announced to the agent, so it silently climbed to its
|
|
576
|
+
# own auto-archive threshold and was discarded unread -- draining the whole
|
|
577
|
+
# queue into the archive. Leaving the inbox empty lets the caller's
|
|
578
|
+
# release+nudge deliver-and-announce the next message like any other.
|
|
518
579
|
inbox = mp.inbox
|
|
519
580
|
if inbox.exists():
|
|
520
581
|
msgs = sorted(f for f in inbox.iterdir() if f.is_file())
|
|
521
582
|
if len(msgs) == 1:
|
|
522
583
|
f = msgs[0]
|
|
523
584
|
pres = _get_presentations(cfg, agent_name)
|
|
585
|
+
# `processed` holds message CONTENT ids ("m-arc2"), so the
|
|
586
|
+
# "already handled" guard must compare the inbox message's own
|
|
587
|
+
# content id -- NOT f.name ("m-arc2.txt"), which never matches and
|
|
588
|
+
# would let a handled message be archived anyway.
|
|
589
|
+
inbox_id = _parse_header_field(f.read_text(), "Id")
|
|
524
590
|
if (
|
|
525
591
|
pres.get("msg_id") == f.name
|
|
526
592
|
and pres.get("count", 0) >= AUTO_ARCHIVE_PRESENTATIONS
|
|
527
|
-
and
|
|
593
|
+
and inbox_id not in processed
|
|
528
594
|
):
|
|
529
595
|
log.archive_message(cfg, agent_name, f)
|
|
530
|
-
release_next(cfg, agent_name)
|
|
531
596
|
|
|
532
597
|
return count
|
|
533
598
|
|
package/lib/reconcile.py
CHANGED
|
@@ -157,6 +157,29 @@ def write_raw(path, data: dict) -> None:
|
|
|
157
157
|
# --------------------------------------------------------------------------
|
|
158
158
|
|
|
159
159
|
|
|
160
|
+
def _commit(cfg, raw: dict) -> "cfgmod.SwarmConfig":
|
|
161
|
+
"""Persist *raw* to the config path, then validate by reloading it.
|
|
162
|
+
|
|
163
|
+
If the reload fails -- the edit produced an invalid config (e.g. a
|
|
164
|
+
can_talk_to reference to an agent that no longer exists) -- restore the
|
|
165
|
+
previous file and re-raise, so a REJECTED edit never leaves an unloadable
|
|
166
|
+
config on disk. Without this, every mutator wrote first and validated
|
|
167
|
+
second, and a rejected edit corrupted agentainer.yaml, breaking every later
|
|
168
|
+
command and the UI.
|
|
169
|
+
"""
|
|
170
|
+
path = Path(cfg.path)
|
|
171
|
+
prev = path.read_text() if path.exists() else None
|
|
172
|
+
write_raw(path, raw)
|
|
173
|
+
try:
|
|
174
|
+
return cfgmod.load(path)
|
|
175
|
+
except Exception:
|
|
176
|
+
if prev is None: # pragma: no cover - a mutator always starts from an existing config
|
|
177
|
+
path.unlink(missing_ok=True)
|
|
178
|
+
else:
|
|
179
|
+
path.write_text(prev)
|
|
180
|
+
raise
|
|
181
|
+
|
|
182
|
+
|
|
160
183
|
def _coerce_field(key: str, value: str):
|
|
161
184
|
"""Turn a CLI string into the right Python value for *key*.
|
|
162
185
|
|
|
@@ -202,8 +225,7 @@ def add_agent(cfg, name, type_, command, can_talk_to, role="", workdir=None, **e
|
|
|
202
225
|
entry[k] = v
|
|
203
226
|
agents.append(entry)
|
|
204
227
|
raw["agents"] = agents
|
|
205
|
-
|
|
206
|
-
return cfgmod.load(cfg.path)
|
|
228
|
+
return _commit(cfg, raw)
|
|
207
229
|
|
|
208
230
|
|
|
209
231
|
def remove_agent(cfg, name) -> "cfgmod.SwarmConfig":
|
|
@@ -217,9 +239,18 @@ def remove_agent(cfg, name) -> "cfgmod.SwarmConfig":
|
|
|
217
239
|
kept = [a for a in agents if str(a.get("name")) != name]
|
|
218
240
|
if len(kept) == len(agents):
|
|
219
241
|
raise ValueError(f"agent {name!r} not found")
|
|
242
|
+
# Strip the removed agent from every remaining agent's can_talk_to. Config
|
|
243
|
+
# load validates that every peer exists (config.py), so a lingering
|
|
244
|
+
# reference would make the reload below raise -- after we've already written
|
|
245
|
+
# the file -- leaving an unloadable config on disk that breaks every later
|
|
246
|
+
# command and the UI. A "*" wildcard needs no cleanup: load re-expands it to
|
|
247
|
+
# whatever agents remain.
|
|
248
|
+
for a in kept:
|
|
249
|
+
talk = a.get("can_talk_to")
|
|
250
|
+
if isinstance(talk, list):
|
|
251
|
+
a["can_talk_to"] = [p for p in talk if str(p) != name]
|
|
220
252
|
raw["agents"] = kept
|
|
221
|
-
|
|
222
|
-
return cfgmod.load(cfg.path)
|
|
253
|
+
return _commit(cfg, raw)
|
|
223
254
|
|
|
224
255
|
|
|
225
256
|
def edit_swarm(cfg, **fields) -> "cfgmod.SwarmConfig":
|
|
@@ -234,8 +265,7 @@ def edit_swarm(cfg, **fields) -> "cfgmod.SwarmConfig":
|
|
|
234
265
|
for k, v in fields.items():
|
|
235
266
|
swarm[k] = v
|
|
236
267
|
raw["swarm"] = swarm
|
|
237
|
-
|
|
238
|
-
return cfgmod.load(cfg.path)
|
|
268
|
+
return _commit(cfg, raw)
|
|
239
269
|
|
|
240
270
|
|
|
241
271
|
def edit_telegram(cfg, **fields) -> "cfgmod.SwarmConfig":
|
|
@@ -249,8 +279,26 @@ def edit_telegram(cfg, **fields) -> "cfgmod.SwarmConfig":
|
|
|
249
279
|
for k, v in fields.items():
|
|
250
280
|
tg[k] = v
|
|
251
281
|
raw["telegram"] = tg
|
|
282
|
+
return _commit(cfg, raw)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def apply_template(cfg, agents, defaults=None) -> list:
|
|
286
|
+
"""Seed an EMPTY config on disk with a template's ``agents`` (+ ``defaults``).
|
|
287
|
+
|
|
288
|
+
Onboarding helper: copies an example swarm's agent list into the current
|
|
289
|
+
``agentainer.yaml`` when it has no agents yet, and pulls in the template's
|
|
290
|
+
``defaults`` block if the target has none. Returns the added agent names.
|
|
291
|
+
Raises ``ValueError`` if the config already has agents (templates seed a
|
|
292
|
+
fresh swarm rather than merging into a live one).
|
|
293
|
+
"""
|
|
294
|
+
raw = load_raw(cfg.path)
|
|
295
|
+
if raw.get("agents"):
|
|
296
|
+
raise ValueError("swarm already has agents")
|
|
297
|
+
if defaults and not raw.get("defaults"):
|
|
298
|
+
raw["defaults"] = dict(defaults)
|
|
299
|
+
raw["agents"] = list(agents)
|
|
252
300
|
write_raw(cfg.path, raw)
|
|
253
|
-
return
|
|
301
|
+
return [str(a.get("name")) for a in agents if a.get("name")]
|
|
254
302
|
|
|
255
303
|
|
|
256
304
|
def edit_agent(cfg, name, **fields) -> "cfgmod.SwarmConfig":
|
|
@@ -270,8 +318,7 @@ def edit_agent(cfg, name, **fields) -> "cfgmod.SwarmConfig":
|
|
|
270
318
|
break
|
|
271
319
|
if not found:
|
|
272
320
|
raise ValueError(f"agent {name!r} not found")
|
|
273
|
-
|
|
274
|
-
return cfgmod.load(cfg.path)
|
|
321
|
+
return _commit(cfg, raw)
|
|
275
322
|
|
|
276
323
|
|
|
277
324
|
# --------------------------------------------------------------------------
|
|
@@ -399,6 +446,30 @@ def stop_one(cfg, name: str) -> bool:
|
|
|
399
446
|
return True
|
|
400
447
|
|
|
401
448
|
|
|
449
|
+
def start_all(cfg, *, _start_fn=None) -> list:
|
|
450
|
+
"""Start every configured-but-not-running agent; return the started names.
|
|
451
|
+
|
|
452
|
+
A thin wrapper over ``reconcile`` (start-only) so the UI's "Start all" button
|
|
453
|
+
has one authoritative launch path. ``_start_fn`` is injectable for tests.
|
|
454
|
+
"""
|
|
455
|
+
return reconcile(cfg, start_missing=True, stop_extra=False, _start_fn=_start_fn)["started"]
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def stop_all(cfg) -> list:
|
|
459
|
+
"""Kill every running agent session (config untouched); return stopped names.
|
|
460
|
+
|
|
461
|
+
Mirrors ``stop_one`` across the whole swarm for the UI's "Stop all" button;
|
|
462
|
+
agents that are already down are skipped.
|
|
463
|
+
"""
|
|
464
|
+
stopped: list[str] = []
|
|
465
|
+
for a in cfg.agents:
|
|
466
|
+
if tmux.session_exists(a.session):
|
|
467
|
+
tmux.tmux("kill-session", "-t", f"={a.session}", check=False, capture=True)
|
|
468
|
+
stopped.append(a.name)
|
|
469
|
+
info(f"stop_all: stopped {a.name}")
|
|
470
|
+
return stopped
|
|
471
|
+
|
|
472
|
+
|
|
402
473
|
# --------------------------------------------------------------------------
|
|
403
474
|
# CLI handlers
|
|
404
475
|
# --------------------------------------------------------------------------
|
package/lib/turn.py
CHANGED
|
@@ -79,12 +79,20 @@ def busy_info(cfg: SwarmConfig, agent: Agent) -> dict | None:
|
|
|
79
79
|
|
|
80
80
|
|
|
81
81
|
def mark_turn_started(cfg: SwarmConfig, agent: str, sender: str) -> None:
|
|
82
|
-
"""Record that another message (from *sender*) was just delivered to *agent*.
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
82
|
+
"""Record that another message (from *sender*) was just delivered to *agent*.
|
|
83
|
+
|
|
84
|
+
Locked with the same ``turn.lock`` as :func:`mark_turn_finished`: this is a
|
|
85
|
+
read-modify-write of ``delivered``, and the completion hook (a separate
|
|
86
|
+
process) clamps ``completed`` concurrently. Without the shared lock the two
|
|
87
|
+
can interleave and lose an update, leaving an agent wedged "busy" or -- worse
|
|
88
|
+
-- looking idle while a message is still in flight.
|
|
89
|
+
"""
|
|
90
|
+
with file_lock(cfg, agent, "turn.lock"):
|
|
91
|
+
state = turn_state(cfg, agent)
|
|
92
|
+
state["delivered"] = state.get("delivered", 0) + 1
|
|
93
|
+
state["since"] = time.time()
|
|
94
|
+
state["by"] = sender
|
|
95
|
+
write_turn_state(cfg, agent, state)
|
|
88
96
|
|
|
89
97
|
|
|
90
98
|
def mark_turn_finished(cfg: SwarmConfig, agent: str) -> None:
|