feat(swarm): agent_swarm — one prompt over many items, paced as a fleet

Ports kimi-code's AgentSwarm: a `prompt_template` containing `{{item}}`
expanded over an `items` list into up to 128 subagents, run to completion
and returned as one aggregate. Kigi already exceeds upstream on planning,
verification, isolation and merge via /graph; what it lacked was cheap
immediate fan-out. Entirely client-side — no new backend surface.

The engine is three pure pieces plus a runner: `plan` validates and
expands (every fault reported before a single member starts — a
half-launched swarm is expensive to unwind), `schedule` is the launch
ramp as testable arithmetic, `run` drives it against the existing
`SubagentBackend`. Reuses the single-spawn coordinator rather than
inventing a batch API.

Load-bearing decisions, each the result of a defect found in review:

- `backgrounded` is its own outcome. A member that outlives the 600s
  foreground budget is detached by the coordinator and KEEPS RUNNING;
  reporting it as failed invites the model to relaunch its item, putting
  a second agent on the same files. It is never offered for resume.
- `InFlightGuard` cancels live members on Drop. Send-now cancels the turn
  WITHOUT cancelling subagents and aborts the task; the dropped receivers
  read as "parent gone" and each child re-attaches itself. There is no
  cooperative path to use instead — `Cancellation` is constructed nowhere
  in the tree — so Drop is the only seam that fires.
- Retries and wall clock are both bounded. The swarm blocks the caller's
  turn, so every wait needs a ceiling it cannot argue past; stragglers at
  the deadline are reported as still-running, with their ids.
- `ToolKind::AgentSwarm` is its own variant: `TemplateRenderer`'s
  `by_kind` map holds one tool name per kind, so sharing `Task` would
  silently redirect `${{ tools.by_kind.task }}` in other tools' prompts.
- An explicitly requested model that cannot be validated is refused, as
  the task tool already does — one loud error beats `items.len()` quiet
  ones. Depth stays capped at 1: upstream's unlimited nesting is a
  hazard, not a feature.
- `SubagentResult.rate_limited` is classified where the typed ACP error
  code is still in hand; a scheduler re-deriving it from a formatted
  string would stop adapting the day the wording changed.
- Aggregate output is clamped per member (head+tail, loss stated):
  native tool output is truncated nowhere downstream.

42 agent_swarm tests. The fake backend awaits, so the concurrency and
ordering assertions can actually fail; the cap test also proves the
fixture can exceed the cap.
This commit is contained in:
2026-07-27 01:22:52 -04:00
parent ed8049cf77
commit 9edb8729ef
22 changed files with 2232 additions and 16 deletions
@@ -396,11 +396,14 @@ pub(crate) async fn handle_subagent_request(
let child_depth = ctx.parent_depth + 1;
if child_depth >= MAX_SUBAGENT_DEPTH {
let before = definition.tool_config.tools.len();
definition.tool_config.tools.retain(|tc| tc.kind != Some(ToolKind::Task));
definition
.tool_config
.tools
.retain(|tc| !matches!(tc.kind, Some(ToolKind::Task | ToolKind::AgentSwarm)));
if definition.tool_config.tools.len() < before {
tracing::info!(
subagent_id = % request.id, child_depth, max_depth =
MAX_SUBAGENT_DEPTH, "Stripped task tool from child at max depth"
MAX_SUBAGENT_DEPTH, "Stripped subagent-spawning tools from child at max depth"
);
}
prune_orphaned_background_task_tools(&mut definition.tool_config);
@@ -1322,6 +1325,7 @@ pub(crate) async fn handle_subagent_request(
SubagentResult {
success: false,
cancelled: true,
rate_limited: false,
error: Some(reason),
output: if final_text.is_empty() {
std::sync::Arc::from(
@@ -1359,6 +1363,7 @@ pub(crate) async fn handle_subagent_request(
SubagentResult {
success: false,
cancelled: true,
rate_limited: false,
error: Some(format!("max turns reached (limit: {limit})")),
output: if final_text.is_empty() {
std::sync::Arc::from(
@@ -1413,6 +1418,9 @@ pub(crate) async fn handle_subagent_request(
SubagentResult {
success: false,
cancelled: was_cancelled,
rate_limited: !was_cancelled
&& i32::from(e.code)
== crate::sampling::error::RATE_LIMITED_ERROR_CODE,
error: Some(
if was_cancelled {
"Subagent was cancelled".to_string()
@@ -1546,6 +1546,18 @@ impl SessionActor {
vec![],
vec![],
),
// Without an explicit arm the catch-all below titles this "Tool
// call" — for an entry that can hold the turn for the whole swarm.
ToolInput::AgentSwarm(swarm) => (
format!(
"{} ({} members)",
swarm.description,
swarm.items.len() + swarm.resume_agent_ids.len()
),
acp::ToolKind::Other,
vec![],
vec![],
),
ToolInput::EnterPlanMode(_) => (
"Plan: Enter".to_string(),
acp::ToolKind::Other,