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:
@@ -711,9 +711,19 @@ impl AgentBuilder {
|
||||
kigi_tools::types::tool::ToolNamespace::Kigi,
|
||||
"task"
|
||||
);
|
||||
// The swarm is a fan-out of subagent spawns, so it lives and dies with
|
||||
// the task tool: any condition that leaves no subagent to spawn leaves
|
||||
// the swarm with nothing to fan out to.
|
||||
let swarm_tool_id = format!(
|
||||
"{}:{}",
|
||||
kigi_tools::types::tool::ToolNamespace::Kigi,
|
||||
"agent_swarm"
|
||||
);
|
||||
let mut task_stripped = false;
|
||||
if !self.subagents_enabled {
|
||||
tool_config.tools.retain(|tc| tc.id != task_tool_id);
|
||||
tool_config
|
||||
.tools
|
||||
.retain(|tc| tc.id != task_tool_id && tc.id != swarm_tool_id);
|
||||
task_stripped = true;
|
||||
} else {
|
||||
let subagents = crate::discovery::all_subagents_with_plugins(
|
||||
@@ -722,7 +732,9 @@ impl AgentBuilder {
|
||||
self.plugin_registry.as_deref(),
|
||||
);
|
||||
if subagents.is_empty() {
|
||||
tool_config.tools.retain(|tc| tc.id != task_tool_id);
|
||||
tool_config
|
||||
.tools
|
||||
.retain(|tc| tc.id != task_tool_id && tc.id != swarm_tool_id);
|
||||
task_stripped = true;
|
||||
} else if self.prompt_audience == crate::prompt::context::PromptAudience::Subagent {
|
||||
if let Some(task_tc) = tool_config
|
||||
@@ -810,7 +822,13 @@ impl AgentBuilder {
|
||||
.tools
|
||||
.iter()
|
||||
.any(|t| AGENT_TASK_CLASSIFIER_RE.is_match(t));
|
||||
let task_deps = ["task", "get_task_output", "kill_task", "wait_tasks"];
|
||||
let task_deps = [
|
||||
"task",
|
||||
"agent_swarm",
|
||||
"get_task_output",
|
||||
"kill_task",
|
||||
"wait_tasks",
|
||||
];
|
||||
let registered_tool_ids = tool_bridge_builder.known_tool_ids();
|
||||
let present_kinds: std::collections::HashSet<ToolKind> =
|
||||
tool_config.tools.iter().filter_map(|tc| tc.kind).collect();
|
||||
@@ -925,7 +943,13 @@ impl AgentBuilder {
|
||||
}
|
||||
}
|
||||
if definition.allowed_subagent_types.as_deref() == Some(&[]) {
|
||||
let task_deps = ["task", "get_task_output", "kill_task", "wait_tasks"];
|
||||
let task_deps = [
|
||||
"task",
|
||||
"agent_swarm",
|
||||
"get_task_output",
|
||||
"kill_task",
|
||||
"wait_tasks",
|
||||
];
|
||||
tool_config
|
||||
.tools
|
||||
.retain(|tc| !task_deps.contains(&short_tool_name(&tc.id)));
|
||||
@@ -1601,6 +1625,11 @@ mod tests {
|
||||
has_task, *subagents,
|
||||
"[{label}] spawn_subagent presence should match subagents_enabled={subagents}; got tools: {names:?}"
|
||||
);
|
||||
let has_swarm = names.contains(&"agent_swarm");
|
||||
assert_eq!(
|
||||
has_swarm, *subagents,
|
||||
"[{label}] agent_swarm fans out to subagents, so it must follow the same gate as spawn_subagent; got tools: {names:?}"
|
||||
);
|
||||
assert!(
|
||||
names.contains(&"enter_plan_mode"),
|
||||
"[{label}] enter_plan_mode must always be present (TUI plan-mode keybind needs it); got tools: {names:?}"
|
||||
|
||||
@@ -154,6 +154,11 @@ fn task_tool_config() -> ToolConfig {
|
||||
.with_name("spawn_subagent")
|
||||
.with_param_rename("run_in_background", "background")
|
||||
}
|
||||
/// Swarm tool. Keeps its registry name: unlike `task` it has no CLI-specific
|
||||
/// alias, and the name is what the model is told to reuse for `resume_agent_ids`.
|
||||
fn agent_swarm_tool_config() -> ToolConfig {
|
||||
ToolConfig::from(&kigi::AgentSwarmTool)
|
||||
}
|
||||
/// Task output tool renamed for clarity:
|
||||
/// `get_task_output` → `get_command_or_subagent_output`.
|
||||
fn task_output_tool_config() -> ToolConfig {
|
||||
@@ -270,6 +275,7 @@ fn default_kigi_toolset() -> ToolServerConfig {
|
||||
task_output_tool_config(),
|
||||
wait_tasks_tool_config(),
|
||||
task_tool_config(),
|
||||
agent_swarm_tool_config(),
|
||||
(&kigi::SchedulerCreateTool).into(),
|
||||
(&kigi::SchedulerDeleteTool).into(),
|
||||
(&kigi::SchedulerListTool).into(),
|
||||
@@ -318,6 +324,7 @@ pub fn kigi_hashline_toolset(
|
||||
task_output_tool_config(),
|
||||
wait_tasks_tool_config(),
|
||||
task_tool_config(),
|
||||
agent_swarm_tool_config(),
|
||||
(&kigi::WebSearchTool).into(),
|
||||
(&kigi::SchedulerCreateTool).into(),
|
||||
(&kigi::SchedulerDeleteTool).into(),
|
||||
@@ -400,6 +407,7 @@ fn kigi_plan_toolset() -> ToolServerConfig {
|
||||
(&kigi::TodoWriteTool).into(),
|
||||
task_output_tool_config(),
|
||||
task_tool_config(),
|
||||
agent_swarm_tool_config(),
|
||||
(&kigi::SchedulerCreateTool).into(),
|
||||
(&kigi::SchedulerDeleteTool).into(),
|
||||
(&kigi::SchedulerListTool).into(),
|
||||
@@ -428,6 +436,7 @@ fn orchestrator_toolset() -> ToolServerConfig {
|
||||
(&kigi::ListDirTool).into(),
|
||||
(&kigi::GrepTool).into(),
|
||||
task_tool_config(),
|
||||
agent_swarm_tool_config(),
|
||||
task_output_tool_config(),
|
||||
wait_tasks_tool_config(),
|
||||
kill_task_tool_config(),
|
||||
@@ -497,6 +506,7 @@ fn kigi_ask_user_toolset() -> ToolServerConfig {
|
||||
task_output_tool_config(),
|
||||
wait_tasks_tool_config(),
|
||||
task_tool_config(),
|
||||
agent_swarm_tool_config(),
|
||||
(&kigi::SchedulerCreateTool).into(),
|
||||
(&kigi::SchedulerDeleteTool).into(),
|
||||
(&kigi::SchedulerListTool).into(),
|
||||
|
||||
Reference in New Issue
Block a user