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
@@ -0,0 +1,116 @@
//! Input/output types for the `agent_swarm` tool — one prompt template
//! expanded over a list of items into a fleet of subagents.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// The literal a `prompt_template` must contain; each expansion substitutes
/// one `items` entry for it.
pub const PROMPT_TEMPLATE_PLACEHOLDER: &str = "{{item}}";
/// Upper bound on members in one call, counting resumes.
pub const MAX_AGENT_SWARM_MEMBERS: usize = 128;
/// Input for the `agent_swarm` tool.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct AgentSwarmToolInput {
#[schemars(description = "Short description of what the whole swarm is doing (3-7 words).")]
pub description: String,
/// Subagent type every item-spawned member runs as.
#[schemars(
description = "Name of the subagent type every member runs as. Built-in types: \"general-purpose\", \"explore\", \"plan\"."
)]
#[serde(default = "default_subagent_type")]
pub subagent_type: String,
/// Prompt shared by every member; must contain `{{item}}`.
#[schemars(
description = "Prompt shared by every member. Must contain the literal {{item}}, which is replaced by each entry of `items`. Required whenever `items` is given."
)]
#[serde(default)]
pub prompt_template: Option<String>,
/// The work units. Each expands `prompt_template` into one member.
#[schemars(
description = "One entry per member: each is substituted into `prompt_template`. Give every member a distinct scope so members never edit the same file. At least 2 entries unless `resume_agent_ids` is used."
)]
#[serde(default)]
pub items: Vec<String>,
/// Continue named subagents from a previous swarm: id → follow-up prompt.
#[schemars(
description = "Continue previously spawned subagents: a map of agent_id (from an earlier agent_swarm result) to the follow-up prompt for that member."
)]
#[serde(default)]
pub resume_agent_ids: std::collections::BTreeMap<String, String>,
/// Model slug every member runs on; omitted inherits the caller's.
#[schemars(
description = "Model every member runs on. Omit to inherit the caller's current model."
)]
#[serde(default)]
pub model: Option<String>,
}
fn default_subagent_type() -> String {
"general-purpose".to_string()
}
/// How a member's run ended.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SwarmMemberOutcome {
Completed,
Failed,
Aborted,
/// Still running: it outlived the foreground await budget and the subagent
/// coordinator detached it. Distinct from `Failed` because the member is
/// alive and still writing — relaunching its item would put a second agent
/// on the same files, and resuming it is refused while it runs.
Backgrounded,
}
impl SwarmMemberOutcome {
pub fn as_str(self) -> &'static str {
match self {
Self::Completed => "completed",
Self::Failed => "failed",
Self::Aborted => "aborted",
Self::Backgrounded => "backgrounded",
}
}
/// Whether re-running this member's work is safe to suggest.
fn is_resumable(self) -> bool {
matches!(self, Self::Failed | Self::Aborted)
}
}
/// One member's contribution to the aggregate result.
#[derive(Debug, Clone)]
pub struct SwarmMemberResult {
/// The `items` entry (or the resumed agent id) this member was given —
/// what the caller needs to retry exactly the members that did not finish.
pub item: String,
/// Present once the member started; absent means it never launched.
pub agent_id: Option<String>,
pub resumed: bool,
pub outcome: SwarmMemberOutcome,
pub summary: String,
}
impl SwarmMemberResult {
/// Whether the member ever reached the backend. A member that never
/// started has nothing to resume.
pub fn started(&self) -> bool {
self.agent_id.is_some()
}
/// Whether the caller may be told to continue this member. A member that
/// is still running must not be offered: the coordinator refuses to resume
/// a live subagent, and relaunching its item duplicates its writes.
pub fn is_resumable(&self) -> bool {
self.started() && self.outcome.is_resumable()
}
}
+5
View File
@@ -1,10 +1,15 @@
//! Canonical, extensible tool types.
mod agent_swarm;
mod ext;
mod schema_utils;
pub mod serde_lenient;
mod task;
mod types;
pub use agent_swarm::{
AgentSwarmToolInput, MAX_AGENT_SWARM_MEMBERS, PROMPT_TEMPLATE_PLACEHOLDER, SwarmMemberOutcome,
SwarmMemberResult,
};
pub use ext::Extensions;
pub use schema_utils::parse_arguments_from_schema_lossy;
pub use serde_lenient::{