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:
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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::{
|
||||
|
||||
Reference in New Issue
Block a user