feat(missions): Slice 3 — agent teams behind a per-mission switch, solo by default

`missions.team_engine` (0069): NULL = solo, `'claude_code'` = Claude Code agent
teams inside the mission's VM. Solo stays the default deliberately — Anthropic
measure multi-agent at 3-10x the tokens with wall-clock often LONGER, since the
benefit is thoroughness rather than speed — so a mission that said nothing does not
get a team.

In-process teammates live in the lead's process, so ONE VM hosts the whole team.
That is why this is a prompt-and-env change rather than an orchestration one: no
N-VM fan-out, no placement per teammate, no new completion path.

The lead decides its own team size and there is no flag that limits it, so the cap
(4) is stated in the prompt. The addendum also carries the two anti-patterns from
Anthropic's guidance, because they are exactly the shapes our pipeline templates
have: teammates must own DIFFERENT FILES (two in one file overwrite each other),
and one change must not be split into stages across teammates (a handoff loses
context at every step). And: wait for your teammates — a summary written before
they report is the lead's own guess.

A solo mission's prompt and env are byte-identical to before this change. That is
enforced by test, not by intention: the comparison between solo and team is only
meaningful if the solo side did not also move.

Evidence, because a team mission that forms no team is silently just a solo run
that looked fine and spent fewer tokens: a second probe counts members in
`~/.claude/teams/*/config.json` (minus the lead), reported separately from the
subagent count, and a team mission with zero teammates logs loudly with the two
likely causes. The teammate path is DOCUMENTED BUT NOT YET VERIFIED in our image,
unlike the subagent transcript path which was measured — so a zero there means "no
evidence found", and the first real team mission is what turns it into a fact.
`Option<u32>`: None means no team was asked for or the probe could not run.

Hooks (`TaskCompleted` / `TeammateIdle` exit 2, which would move `done_when` from
post-hoc into the agent's own loop) are the highest-value part of this slice and
are deliberately NOT here — they deserve their own pass rather than a rushed tail.

482 tests pass, clippy clean.
This commit is contained in:
Omar Sobh
2026-08-06 07:08:15 -07:00
parent c840688adb
commit cb48f7ff3b
5 changed files with 201 additions and 6 deletions
+159 -2
View File
@@ -61,6 +61,37 @@ fn vm_id_for(phase_id: Uuid, iteration: i32) -> String {
format!("m-{}-{}", &phase_id.simple().to_string()[..12], iteration) format!("m-{}-{}", &phase_id.simple().to_string()[..12], iteration)
} }
/// Ceiling on teammates, stated in the prompt.
///
/// Anthropic's own guidance is 3-5, and their research system uses a lead plus
/// 3-5 subagents. The cap is in the prompt rather than enforced by us because the
/// lead is the thing that decides team size — there is no flag that limits it —
/// so the honest options are to tell it the budget or to not know what it spent.
const MAX_TEAMMATES: u32 = 4;
/// Whether this mission asked for a Claude Code agent team.
fn wants_claude_code_team(engine: Option<&str>) -> bool {
matches!(engine, Some("claude_code"))
}
/// Environment that turns agent teams on. Empty for a solo mission.
///
/// Experimental and disabled by default in the CLI, so nothing changes for a
/// mission that did not ask. Kept separate from
/// [`crate::mission_runtime::microvm_provider_env`] so the credential path stays
/// exactly as narrow as it is — that function is subscription-only by
/// construction, and widening it to carry feature flags is how an API key ends up
/// back in a VM.
fn team_env(engine: Option<&str>) -> Vec<(String, String)> {
if !wants_claude_code_team(engine) {
return Vec::new();
}
vec![(
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS".to_string(),
"1".to_string(),
)]
}
/// What the agent is told. Deliberately not `session_executor::session_prompt`: /// What the agent is told. Deliberately not `session_executor::session_prompt`:
/// that one instructs a push, and on this path pushing is the host's job. /// that one instructs a push, and on this path pushing is the host's job.
fn vm_prompt(task: &str) -> String { fn vm_prompt(task: &str) -> String {
@@ -94,6 +125,27 @@ fn vm_prompt(task: &str) -> String {
) )
} }
/// The extra paragraph a team mission gets.
///
/// Separate from [`vm_prompt`] so a solo mission's prompt is byte-identical to
/// what it was before teams existed — the comparison between solo and team is
/// only meaningful if the solo side did not also change.
fn team_prompt_addendum() -> String {
format!(
"\nWORKING AS A TEAM\n\
You may spawn teammates — independent Claude Code sessions that share a \
task list with you and can message each other. Use at most \
{MAX_TEAMMATES}, and fewer when fewer will do: teammates cost tokens \
and coordination, and three focused ones beat five scattered ones.\n\
Split the work so each teammate owns DIFFERENT FILES. Two teammates \
editing one file overwrite each other. Do not split one file's work into \
stages across teammates — a handoff loses context, and sequential phases \
of the same change are better done by one agent in one pass.\n\
Wait for your teammates to finish before you conclude. Their findings are \
the point; a summary written before they report is your own guess.\n"
)
}
/// Shell-quote for `sh -c`. The guest agent runs one command string, and a task /// Shell-quote for `sh -c`. The guest agent runs one command string, and a task
/// description contains apostrophes, quotes and newlines as a matter of course. /// description contains apostrophes, quotes and newlines as a matter of course.
fn shell_quote(s: &str) -> String { fn shell_quote(s: &str) -> String {
@@ -205,6 +257,21 @@ fn agent_definitions() -> serde_json::Value {
const SUBAGENT_PROBE: &str = const SUBAGENT_PROBE: &str =
"ls -1 /root/.claude/projects/*/*/subagents/*.jsonl 2>/dev/null | wc -l"; "ls -1 /root/.claude/projects/*/*/subagents/*.jsonl 2>/dev/null | wc -l";
/// How many TEAMMATES the lead spawned, from the team config it maintains.
///
/// Teammates are not subagents: they are separate Claude Code sessions sharing a
/// task list, and the documented state lives at
/// `~/.claude/teams/{team}/config.json` with a `members` array (the lead itself is
/// one member, carrying agent type `team-lead`). Counting members and subtracting
/// the lead gives teammates.
///
/// Documented but NOT yet verified in our image, unlike the subagent transcript
/// path — so a zero here means "no evidence found", and the first real team
/// mission is what turns this into a fact. It is reported rather than asserted for
/// that reason.
const TEAMMATE_PROBE: &str = "cat /root/.claude/teams/*/config.json 2>/dev/null \
| tr ',' '\\n' | grep -c '\"name\"' || true";
/// The command that runs the agent in the guest. /// The command that runs the agent in the guest.
fn agent_command(prompt: &str) -> String { fn agent_command(prompt: &str) -> String {
// --permission-mode acceptEdits, matching the container path: the VM IS the // --permission-mode acceptEdits, matching the container path: the VM IS the
@@ -243,6 +310,9 @@ pub struct VmOutcome {
/// while never spawning one reads identically in its summary; this does not. /// while never spawning one reads identically in its summary; this does not.
/// `None` means the probe could not run, which is distinct from zero. /// `None` means the probe could not run, which is distinct from zero.
pub subagents: Option<u32>, pub subagents: Option<u32>,
/// Teammates the lead spawned, for a mission that asked for a team. `None`
/// when no team was requested, or when the probe could not run.
pub teammates: Option<u32>,
} }
/// Boot a VM, run the phase in it, collect the result, and destroy it. /// Boot a VM, run the phase in it, collect the result, and destroy it.
@@ -259,7 +329,7 @@ pub async fn run_phase_in_vm(hub: &NodeHub, p: VmPhase<'_>) -> Result<VmOutcome,
// From here on every early return must still destroy the VM, so the work is // From here on every early return must still destroy the VM, so the work is
// one call whose result is held while teardown runs unconditionally. // one call whose result is held while teardown runs unconditionally.
let outcome = run_inside(&vm, &created, p.task, p.repo, &env).await; let outcome = run_inside(&vm, &created, p.task, p.repo, &env, p.team_engine).await;
if let Err(e) = vm.destroy().await { if let Err(e) = vm.destroy().await {
// Not fatal to the phase — the work may already be collected — but loud, // Not fatal to the phase — the work may already be collected — but loud,
@@ -288,6 +358,9 @@ pub struct VmPhase<'a> {
/// The host checkout, injected as a tar and collected back over the same /// The host checkout, injected as a tar and collected back over the same
/// path so `mission_delivery` needs no change. /// path so `mission_delivery` needs no change.
pub repo: &'a std::path::Path, pub repo: &'a std::path::Path,
/// `missions.team_engine` — `Some("claude_code")` asks the lead to form a
/// team. `None` is solo, which is the default.
pub team_engine: Option<&'a str>,
} }
async fn run_inside( async fn run_inside(
@@ -296,6 +369,7 @@ async fn run_inside(
task: &str, task: &str,
repo: &std::path::Path, repo: &std::path::Path,
env: &[(String, String)], env: &[(String, String)],
engine: Option<&str>,
) -> Result<VmOutcome, String> { ) -> Result<VmOutcome, String> {
// An agent CLI cannot reach its API without the tunnel, and a turn without // An agent CLI cannot reach its API without the tunnel, and a turn without
// egress does not fail — it hangs, or reports a network error the operator // egress does not fail — it hangs, or reports a network error the operator
@@ -336,8 +410,17 @@ async fn run_inside(
)); ));
} }
// The team addendum is appended only when the mission asked, so a solo
// mission's prompt is byte-identical to what it was before teams existed.
let prompt = match wants_claude_code_team(engine) {
true => format!("{}{}", vm_prompt(task), team_prompt_addendum()),
false => vm_prompt(task),
};
let mut turn_env = env.to_vec();
turn_env.extend(team_env(engine));
let out = vm let out = vm
.exec(&agent_command(&vm_prompt(task)), None, TURN_SECS, env) .exec(&agent_command(&prompt), None, TURN_SECS, &turn_env)
.await?; .await?;
// Ask the guest how many subagents ran, before collecting: the transcripts // Ask the guest how many subagents ran, before collecting: the transcripts
@@ -351,6 +434,29 @@ async fn run_inside(
None None
} }
}; };
// Teammates, when a team was asked for. A team mission that formed no team is
// silently solo otherwise — it would still deliver, still look fine, and the
// only difference from a solo run would be the tokens it did not spend.
let teammates = match wants_claude_code_team(engine) {
false => None,
true => match vm.exec(TEAMMATE_PROBE, None, 60, &[]).await {
// `members` includes the lead, so teammates = members - 1.
Ok(p) => p.stdout.trim().parse::<u32>().ok().map(|n| n.saturating_sub(1)),
Err(e) => {
eprintln!("microvm_executor: teammate probe failed on {}: {e}", vm.vm_id());
None
}
},
};
if wants_claude_code_team(engine) && teammates.unwrap_or(0) == 0 {
eprintln!(
"microvm_executor: {} asked for an agent team and no teammates were found — \
it ran SOLO. Agent teams are experimental; check that \
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS reached the guest and that the \
team-config path is what this CLI version writes.",
vm.vm_id()
);
}
// Collect regardless of the agent's exit code. A turn that failed partway // Collect regardless of the agent's exit code. A turn that failed partway
// still wrote files, and throwing them away because the CLI exited non-zero // still wrote files, and throwing them away because the CLI exited non-zero
@@ -380,6 +486,7 @@ async fn run_inside(
rc: out.rc, rc: out.rc,
collected, collected,
subagents, subagents,
teammates,
}) })
} }
@@ -541,6 +648,56 @@ mod tests {
assert!(agent_command(&vm_prompt("task")).contains(&format!("--agents {quoted}"))); assert!(agent_command(&vm_prompt("task")).contains(&format!("--agents {quoted}")));
} }
/// Solo is the default, and asking for a team must be explicit. Multi-agent
/// costs 3-10x the tokens and is often slower, so a mission that said nothing
/// must not get one.
#[test]
fn only_an_explicit_request_forms_a_team() {
assert!(wants_claude_code_team(Some("claude_code")));
for engine in [None, Some(""), Some("zeroclaw"), Some("solo"), Some("CLAUDE_CODE")] {
assert!(!wants_claude_code_team(engine), "{engine:?} must not form a team");
}
}
/// The flag is what enables agent teams in the CLI, and it must not leak into
/// a solo mission — a solo run has to stay byte-identical to what it was
/// before teams existed, or the comparison between the two is meaningless.
#[test]
fn the_team_flag_reaches_only_a_team_mission() {
let on = team_env(Some("claude_code"));
assert_eq!(on.len(), 1);
assert_eq!(on[0].0, "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS");
assert_eq!(on[0].1, "1");
assert!(team_env(None).is_empty(), "a solo mission gets no team flag");
assert!(team_env(Some("zeroclaw")).is_empty());
}
/// A solo prompt must not mention teammates, and a team prompt must state the
/// cap — the lead decides its own size and there is no flag that limits it, so
/// the prompt is the only place the budget can be said.
#[test]
fn the_team_addendum_states_the_cap_and_stays_out_of_solo_prompts() {
let solo = vm_prompt("t");
assert!(!solo.to_lowercase().contains("teammate"), "{solo}");
let addendum = team_prompt_addendum();
assert!(addendum.contains(&MAX_TEAMMATES.to_string()), "{addendum}");
// The anti-patterns worth naming, per Anthropic's own guidance: two
// teammates in one file overwrite each other, and splitting one change
// into stages across teammates loses context at every handoff.
assert!(addendum.contains("DIFFERENT FILES"), "{addendum}");
assert!(addendum.to_lowercase().contains("wait for your teammates"), "{addendum}");
}
/// The teammate probe reads the team config, not the subagent transcripts —
/// teammates and subagents are different things and counting one as the other
/// would report a team that never formed.
#[test]
fn the_two_fan_out_probes_look_in_different_places() {
assert!(SUBAGENT_PROBE.contains("subagents"));
assert!(TEAMMATE_PROBE.contains("teams"));
assert!(!TEAMMATE_PROBE.contains("subagents"));
}
/// The agent must not be told to push: delivery is host-side, and the guest /// The agent must not be told to push: delivery is host-side, and the guest
/// deliberately holds no forge credentials. /// deliberately holds no forge credentials.
#[test] #[test]
+16 -2
View File
@@ -255,7 +255,7 @@ async fn start_pending_phases(
-- Where and how this mission executes. `runtime_kind` decides -- Where and how this mission executes. `runtime_kind` decides
-- which executor takes the phase; without it 'microvm' is a -- which executor takes the phase; without it 'microvm' is a
-- value the placement code honours and nothing reads. -- value the placement code honours and nothing reads.
m.runtime_kind, m.backend, m.target_node_id m.runtime_kind, m.backend, m.target_node_id, m.team_engine
FROM mission_phases mp FROM mission_phases mp
JOIN missions m ON m.id = mp.mission_id JOIN missions m ON m.id = mp.mission_id
WHERE mp.status = 'pending' WHERE mp.status = 'pending'
@@ -284,6 +284,7 @@ async fn start_pending_phases(
let runtime_kind: String = row.get("runtime_kind"); let runtime_kind: String = row.get("runtime_kind");
let backend: Option<String> = row.get("backend"); let backend: Option<String> = row.get("backend");
let target_node_id: Option<Uuid> = row.get("target_node_id"); let target_node_id: Option<Uuid> = row.get("target_node_id");
let team_engine: Option<String> = row.get("team_engine");
if let Err(e) = launch_phase( if let Err(e) = launch_phase(
pool, pool,
@@ -300,6 +301,7 @@ async fn start_pending_phases(
runtime_kind: &runtime_kind, runtime_kind: &runtime_kind,
backend: backend.as_deref(), backend: backend.as_deref(),
target_node_id, target_node_id,
team_engine: team_engine.as_deref(),
}, },
) )
.await .await
@@ -337,6 +339,8 @@ struct PhaseLaunch<'a> {
/// Set by `mission_orchestrator` at launch. On the microVM path it is where /// Set by `mission_orchestrator` at launch. On the microVM path it is where
/// the VM boots, and it is not optional there. /// the VM boots, and it is not optional there.
target_node_id: Option<Uuid>, target_node_id: Option<Uuid>,
/// `missions.team_engine`. NULL = solo.
team_engine: Option<&'a str>,
} }
async fn launch_phase( async fn launch_phase(
@@ -358,6 +362,7 @@ async fn launch_phase(
runtime_kind: _, runtime_kind: _,
backend: _, backend: _,
target_node_id: _, target_node_id: _,
team_engine: _,
} = p; } = p;
// Which team purposes should execute this phase. // Which team purposes should execute this phase.
let purposes: &[&str] = match kind { let purposes: &[&str] = match kind {
@@ -529,6 +534,7 @@ async fn launch_phase(
&task, &task,
p.backend, p.backend,
p.target_node_id, p.target_node_id,
p.team_engine,
) )
.await; .await;
} }
@@ -627,6 +633,7 @@ async fn launch_microvm_phase(
task: &str, task: &str,
backend: Option<&str>, backend: Option<&str>,
target_node_id: Option<Uuid>, target_node_id: Option<Uuid>,
team_engine: Option<&str>,
) -> Result<(), String> { ) -> Result<(), String> {
sqlx::query( sqlx::query(
"DELETE FROM topology_runs "DELETE FROM topology_runs
@@ -668,6 +675,7 @@ async fn launch_microvm_phase(
let repo = crate::mission_workspace::checkout_path(mission_id); let repo = crate::mission_workspace::checkout_path(mission_id);
let task = task.to_string(); let task = task.to_string();
let backend = backend.map(str::to_string); let backend = backend.map(str::to_string);
let team_engine = team_engine.map(str::to_string);
let pool2 = pool.clone(); let pool2 = pool.clone();
let hub = hub.clone(); let hub = hub.clone();
tokio::spawn(async move { tokio::spawn(async move {
@@ -698,6 +706,7 @@ async fn launch_microvm_phase(
task: &task, task: &task,
backend: backend.as_deref(), backend: backend.as_deref(),
repo: &repo, repo: &repo,
team_engine: team_engine.as_deref(),
}, },
) )
.await .await
@@ -712,6 +721,11 @@ async fn launch_microvm_phase(
Ok(o) => o.subagents.map(|n| n.to_string()).unwrap_or_else(|| "?".into()), Ok(o) => o.subagents.map(|n| n.to_string()).unwrap_or_else(|| "?".into()),
Err(_) => "-".into(), Err(_) => "-".into(),
}; };
// Only meaningful for a mission that asked for a team; "-" otherwise.
let teammates = match &outcome {
Ok(o) => o.teammates.map(|n| n.to_string()).unwrap_or_else(|| "-".into()),
Err(_) => "-".into(),
};
let (status, note) = match outcome { let (status, note) = match outcome {
Ok(o) if o.rc == 0 && o.collected => ("completed", o.summary), Ok(o) if o.rc == 0 && o.collected => ("completed", o.summary),
// A turn that ran and could not be collected is a failure even when // A turn that ran and could not be collected is a failure even when
@@ -726,7 +740,7 @@ async fn launch_microvm_phase(
}; };
eprintln!( eprintln!(
"phase_runner: microvm phase {phase_id} of mission {mission_id} → {status} \ "phase_runner: microvm phase {phase_id} of mission {mission_id} → {status} \
(subagents: {subagents}) — {}", (subagents: {subagents}, teammates: {teammates}) — {}",
note.chars().take(300).collect::<String>() note.chars().take(300).collect::<String>()
); );
if let Err(e) = sqlx::query( if let Err(e) = sqlx::query(
+4
View File
@@ -45,6 +45,9 @@ pub struct CreateMissionRequest {
/// `glm:glm-4.7`. Omit to use the deployment default; send `""` to opt out of /// `glm:glm-4.7`. Omit to use the deployment default; send `""` to opt out of
/// independent validation and judge with the house model. /// independent validation and judge with the house model.
pub validator_model: Option<String>, pub validator_model: Option<String>,
/// Team engine: `"claude_code"` asks the mission's agent to form a team.
/// Omit for solo, which is the default and much cheaper.
pub team_engine: Option<String>,
} }
fn default_schedule() -> Value { fn default_schedule() -> Value {
@@ -290,6 +293,7 @@ pub async fn create(
target_node_id: body.target_node_id, target_node_id: body.target_node_id,
backend: body.backend.as_deref(), backend: body.backend.as_deref(),
validator_model: body.validator_model.as_deref(), validator_model: body.validator_model.as_deref(),
team_engine: body.team_engine.as_deref(),
phases: phases_for_create( phases: phases_for_create(
crate::workflow_registry::get(body.template_kind.trim()), crate::workflow_registry::get(body.template_kind.trim()),
body.phases, body.phases,
+5 -2
View File
@@ -140,6 +140,8 @@ pub struct NewMission<'a> {
/// Independent validator for this mission's verdicts. `None` = deployment /// Independent validator for this mission's verdicts. `None` = deployment
/// default; `Some("")` = explicitly none. See migration 0068. /// default; `Some("")` = explicitly none. See migration 0068.
pub validator_model: Option<&'a str>, pub validator_model: Option<&'a str>,
/// `"claude_code"` to ask for an agent team; `None` = solo. See 0069.
pub team_engine: Option<&'a str>,
pub phases: Vec<NewMissionPhase>, pub phases: Vec<NewMissionPhase>,
} }
@@ -168,9 +170,9 @@ pub async fn insert(pool: &PgPool, m: NewMission<'_>) -> Result<Uuid, DbError> {
"INSERT INTO missions "INSERT INTO missions
(id, workspace_id, title, template_kind, team_id, (id, workspace_id, title, template_kind, team_id,
team_template_id, repo_id, schedule, status, description, config, team_template_id, repo_id, schedule, status, description, config,
runtime_kind, target_node_id, backend, validator_model) runtime_kind, target_node_id, backend, validator_model, team_engine)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'draft',$9,$10, VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'draft',$9,$10,
COALESCE($11,'zeroclaw'),$12,$13,$14)", COALESCE($11,'zeroclaw'),$12,$13,$14,$15)",
) )
.bind(mission_id) .bind(mission_id)
.bind(m.workspace_id) .bind(m.workspace_id)
@@ -186,6 +188,7 @@ pub async fn insert(pool: &PgPool, m: NewMission<'_>) -> Result<Uuid, DbError> {
.bind(m.target_node_id) .bind(m.target_node_id)
.bind(m.backend) .bind(m.backend)
.bind(m.validator_model) .bind(m.validator_model)
.bind(m.team_engine)
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
+17
View File
@@ -0,0 +1,17 @@
-- Which team engine a mission uses, or none.
--
-- NULL solo — one agent, no fan-out beyond the subagents it chooses.
-- The default, deliberately: Anthropic measure multi-agent at
-- 3-10x the tokens of a single session, with wall-clock often
-- LONGER, since the benefit is thoroughness rather than speed.
-- 'claude_code' Claude Code agent teams inside the mission's VM. The lead
-- decides its own team size; in-process teammates live in the
-- lead's process, so one VM hosts the whole team.
-- 'zeroclaw' the existing graph engine (tier='team'), whose assets are
-- durability and per-node heterogeneity.
--
-- No CHECK constraint. Two engines are selectable today and the composed shape
-- (a ZeroClaw graph whose leaves are Claude-Code VMs) will add a third name; a
-- constraint here would mean a migration to learn a new word.
ALTER TABLE missions
ADD COLUMN IF NOT EXISTS team_engine text;