Skip to content

Commit 6ec77e0

Browse files
ZD Studiosclaude
andcommitted
feat: swarm — big tasks decomposed across specialist agents running in parallel
run_team was one shallow round: up to three sequential delegations, no dependencies, and no way for an agent to consult a peer mid-task. Fine for "check X then answer", useless for "build me a thing". aios_swarm.py adds a real swarm: PLAN an agent turns the request into a DAG of subtasks, each assigned to the specialist that suits it, with explicit dependencies EXECUTE independent subtasks run CONCURRENTLY (ThreadPoolExecutor, wave by wave); a subtask starts only once its deps have produced output, and dependents receive those outputs as context CONSULT a running agent can emit `ASK <agent>: <question>`, gets a real answer back, and finishes with it — agents talking, not just output chaining SYNTHESIZE one agent reads the whole blackboard and writes the final answer Triggered automatically for big asks on brain/team via a cheap heuristic so ordinary chat never pays for a planning call, or forced with the 🐝 Swarm chip / "swarm" target. A plan with one task is returned as a plain single-agent answer rather than dressed up as a swarm. Live DAG in the dashboard's Swarm view, fed by the same activity feed, plus GET/POST /api/swarm and `swarm.enabled` config. The engine is injected with route/ask_llm/act so it never imports the hub back, which also makes it testable against a stub router. Verified that way: peak parallelism 2 on independent subtasks, correct dependency ordering, a real consult round-trip (claudecode -> opencode -> claudecode), narrated-and-fenced JSON still parsed, and a failing subtask unblocking its dependents instead of deadlocking. Two real bugs found while testing against live agents: - llm_chat's 120s timeout was too short for claude-code, whose every call cold-starts the Claude CLI. Planning is a big prompt and timed out, surfacing as "no usable plan". Now 300s, overridable via AIOS_LLM_TIMEOUT. - claude-code is an AGENT, not a chat model: given a planning prompt it replies "I'll find and read the README..." or returns an empty body, so it is an unreliable planner backend. The planner now puts its instructions in the user turn (a long system prompt full of JSON braces reliably returned empty there), retries once, and on failure reports it honestly instead of inventing a plan — and the hub falls back to run_team so a swarm can never return a blank reply. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8939fc6 commit 6ec77e0

5 files changed

Lines changed: 400 additions & 2 deletions

File tree

aios.config.example.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,12 @@ updates:
8383
# nothing executes) when an agent says SAFE. Service-tier
8484
# projects with dependency trees always wait for you.
8585

86+
swarm:
87+
enabled: true # big requests are decomposed into subtasks, run by
88+
# different specialist agents in parallel; agents can
89+
# consult each other mid-task, then one synthesizes.
90+
# Small asks are untouched (a cheap heuristic gates it).
91+
8692
watchdog:
8793
enabled: true # if an agent stops responding, auto-restart it
8894
interval: 45 # seconds between health sweeps; a healthy agent diagnoses failures

aios.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,7 @@ def service_specs(cfg: dict) -> dict:
478478
"AIOS_WATCHDOG": "1" if cfg_get(cfg, "watchdog.enabled", True) else "0",
479479
"AIOS_WATCHDOG_INTERVAL": str(cfg_get(cfg, "watchdog.interval", 45)),
480480
# supervised updates: agents review upstream changes before they land
481+
"AIOS_SWARM": "1" if cfg_get(cfg, "swarm.enabled", True) else "0",
481482
"AIOS_SUPERVISED_UPDATES": "1" if cfg_get(cfg, "updates.supervised", True) else "0",
482483
"AIOS_UPDATE_INTERVAL": str(cfg_get(cfg, "updates.check_interval", 21600)),
483484
"AIOS_AUTO_APPLY": "1" if cfg_get(cfg, "updates.auto_apply_safe", True) else "0",

aios_hub.py

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import aios_tools as tools # noqa: E402 shell + channel/skill catalogs
3636
import aios_updates as updates # noqa: E402 agent-supervised dependency updates
3737
import aios_web as web # noqa: E402 YouTube / Reddit / HN / page readers
38+
import aios_swarm as swarm # noqa: E402 decompose big tasks across agents
3839

3940
PORT = int(os.environ.get("AIOS_HUB_PORT", "8787"))
4041
ROOT = Path(os.environ.get("AIOS_ROOT", Path(__file__).resolve().parent))
@@ -115,7 +116,7 @@ def read_system_prompt() -> str:
115116

116117

117118
# Chat "targets" the AIOS API exposes as OpenAI-style models.
118-
TARGETS = ["brain", "team", "opencode", "crewai", "claudecode", "fabric", "all"]
119+
TARGETS = ["brain", "team", "swarm", "opencode", "crewai", "claudecode", "fabric", "all"]
119120

120121
MEMORY_FILE = ROOT / ".aios" / "memory.json"
121122
USAGE_FILE = ROOT / ".aios" / "usage.json"
@@ -320,7 +321,11 @@ def llm_chat(messages: list, system: str | None = None) -> str:
320321
headers={"Content-Type": "application/json", "Authorization": f"Bearer {key}",
321322
"HTTP-Referer": "https://github.com/ZDStudios/AIOS", "X-Title": "The AI OS"})
322323
try:
323-
with urllib.request.urlopen(req, timeout=120) as r:
324+
# Generous: when the provider is claude-code, every call shells out to the
325+
# Claude CLI, which cold-starts slowly. Swarm planning is a big prompt and
326+
# was timing out at 120s, which surfaced as "no usable plan".
327+
with urllib.request.urlopen(req, timeout=int(
328+
os.environ.get("AIOS_LLM_TIMEOUT", "300"))) as r:
324329
data = json.loads(r.read())
325330
return data["choices"][0]["message"]["content"]
326331
except urllib.error.HTTPError as e:
@@ -621,6 +626,42 @@ def maybe_pin_to_canvas(user_msg: str, reply: str) -> str:
621626
return title
622627

623628

629+
# --------------------------------------------------------------------------- #
630+
# Swarm — a big request becomes a DAG of subtasks run by different specialists #
631+
# in parallel, able to consult each other, then synthesized into one answer. #
632+
# --------------------------------------------------------------------------- #
633+
SWARM_FILE = ROOT / ".aios" / "swarm.json"
634+
635+
636+
def swarm_enabled() -> bool:
637+
return os.environ.get("AIOS_SWARM", "1") == "1"
638+
639+
640+
def swarm_state() -> dict:
641+
return _load_json(SWARM_FILE, {})
642+
643+
644+
def run_swarm(message: str) -> dict:
645+
"""Bind the hub's router + activity feed into the swarm engine, and publish
646+
live progress so the dashboard can watch the DAG fill in."""
647+
def _publish(snap):
648+
_save_json(SWARM_FILE, {**snap, "message": message[:400], "ts": time.time()})
649+
650+
r = swarm.run(
651+
message,
652+
route=lambda agent, prompt: route(agent, prompt),
653+
ask_llm=lambda msgs, system=None: llm_chat(msgs, system=system),
654+
act=act,
655+
on_update=_publish,
656+
)
657+
_save_json(SWARM_FILE, {"summary": r.get("summary", ""), "tasks": r.get("tasks", []),
658+
"message": message[:400], "ts": time.time(),
659+
"elapsed": r.get("elapsed"), "finished": True})
660+
if r.get("swarm"):
661+
brain.audit("swarm", "run", f"{len(r.get('tasks', []))} subtasks: {message[:120]}")
662+
return r
663+
664+
624665
def route(target: str, message: str, history: list | None = None) -> str:
625666
history = history or []
626667
target = (target or "brain").lower()
@@ -645,7 +686,20 @@ def route(target: str, message: str, history: list | None = None) -> str:
645686
if txt.strip():
646687
return run_fabric(pat.strip(), txt.strip())
647688
return run_fabric("summarize", message)
689+
if target == "swarm":
690+
r = run_swarm(message)
691+
# A swarm that couldn't plan must not hand back an empty string — fall
692+
# back to the team, which is a single round but always produces an answer.
693+
return r.get("reply") or run_team(message, history)
648694
if target in ("team", "auto", "merge"):
695+
# A genuinely big ask gets the swarm — parallel specialists that can
696+
# consult each other — rather than run_team's single shallow round.
697+
if swarm_enabled() and swarm.looks_big(message):
698+
act("plan", "Big task detected — deploying a swarm of agents")
699+
r = run_swarm(message)
700+
if r.get("swarm") and r.get("reply"):
701+
return r["reply"]
702+
act("note", "Swarm unavailable — using the team instead.")
649703
return run_team(message, history)
650704
if target == "all":
651705
parts = []
@@ -1446,6 +1500,8 @@ def do_GET(self):
14461500
elif self.path == "/api/activity":
14471501
self._send(200, activity(self._query.get("turn", [""])[0],
14481502
int(self._query.get("since", ["0"])[0] or 0)))
1503+
elif self.path == "/api/swarm":
1504+
self._send(200, {**swarm_state(), "enabled": swarm_enabled()})
14491505
elif self.path == "/api/dashboard":
14501506
self._send(200, dashboard_cfg())
14511507
elif self.path == "/api/updates":
@@ -1692,6 +1748,16 @@ def do_POST(self):
16921748
brain.audit(payload.get("by", "agent"), "web." + op,
16931749
f"{q[:120]} -> ok={r.get('ok')}")
16941750
self._send(200, r)
1751+
elif self.path == "/api/swarm":
1752+
msg = payload.get("message", "")
1753+
if not msg:
1754+
self._send(400, {"error": "no message"})
1755+
else:
1756+
act_begin(payload.get("turn", ""))
1757+
try:
1758+
self._send(200, run_swarm(msg))
1759+
finally:
1760+
act_end()
16951761
elif self.path == "/api/dashboard":
16961762
self._send(200, dashboard_update(payload))
16971763
elif self.path == "/api/updates":

0 commit comments

Comments
 (0)