feat(swarm): /swarm mode — a standing instruction to fan work out
The `agent_swarm` tool works with the mode off; what the mode adds is the doctrine — decompose finely, give every member a disjoint scope, do not do the work yourself. `/swarm`, `/swarm on|off`, `/swarm <task>`, gated on the tool actually being in the toolset. Two triggers, not upstream's three. Upstream's `tool` trigger exists so invoking the tool makes the doctrine appear; kigi's tool carries its own description and works with the mode off, so that trigger would add a state only the tool can reach. `Manual` persists until switched off, `Task` expires at turn end — both are real user intents. Load-bearing decisions, each from a defect found in review: - Expiry is a DROP guard, not a post-loop call. A user interrupt aborts the turn future rather than cancelling it, so post-loop code never runs; three `?` paths skip it too. Either way a `/swarm <task>` mode would leak into the next, unrelated prompt. - `enter` is a total no-op while armed, so the `/swarm <task>` shorthand cannot downgrade a standing `/swarm on` into a per-turn mode that then disarms itself. This matches upstream; the first cut diverged, and the test asserting the divergence was inverted with the fix. - An explicit `/swarm off` retracts UNCONDITIONALLY. The doctrine rides the conversation and so survives a resume, a fork and a compaction that an in-memory flag does not; trusting the flag left a session reading "off" with the instruction still steering and no way to clear it. That also retires the `reminder_live` field, whose doc comment claimed to know something the code cannot. - The mode is deliberately NOT persisted, unlike /goal and /graph: those strand real work when lost, this is a prompt hint whose recovery is retyping one command. Recorded in AGENTS.md so the omission reads as a decision, not a gap. - The pre-session gate is `subagents_enabled`, not a hardcoded `true`: the builder strips `agent_swarm` wherever it strips `task`, and advertising it then offers a menu entry that resolves to literal text. - The exit reminder is as emphatic as the doctrine it revokes; a single weak clause is the likelier of the two to be summarised away. The doctrine rides the existing `push_system_reminder` channel rather than a second injection path of its own.
This commit is contained in:
1 parent
9edb8729ef
commit
16328e55f9
20 files changed
+474
-4
No files matched your search
@@ -45,6 +45,10 @@ pub(crate) enum BuiltinGate {
|
||||
/// available (graph nodes execute as goals, so `/graph` needs
|
||||
/// everything `/goal` needs).
|
||||
Graph,
|
||||
/// The `agent_swarm` tool is in the session toolset. The mode is only a
|
||||
/// standing instruction to use that tool, so without it the command would
|
||||
/// advertise a doctrine the model has no way to follow.
|
||||
Swarm,
|
||||
}
|
||||
|
||||
/// All built-in slash commands. Order here = display order in autocomplete.
|
||||
@@ -303,6 +307,26 @@ pub(super) const BUILTIN_COMMANDS: &[BuiltinCommand] = &[
|
||||
}
|
||||
},
|
||||
},
|
||||
BuiltinCommand {
|
||||
name: "swarm",
|
||||
description: "Delegate aggressively: split the work across a fleet of subagents",
|
||||
argument_hint: Some("[on | off | <task>]"),
|
||||
aliases: &[],
|
||||
gate: BuiltinGate::Swarm,
|
||||
resolve: |args| {
|
||||
let trimmed = args.trim();
|
||||
match trimmed.to_lowercase().as_str() {
|
||||
"on" => BuiltinAction::SwarmSet { enabled: true },
|
||||
"off" => BuiltinAction::SwarmSet { enabled: false },
|
||||
"" => BuiltinAction::SwarmToggle,
|
||||
// Anything else is the task itself: arm the mode for exactly
|
||||
// this turn and send the text as the prompt.
|
||||
_ => BuiltinAction::SwarmTask {
|
||||
prompt: trimmed.to_string(),
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/// Split a trailing `--budget <tokens>` flag off a `/goal` objective.
|
||||
@@ -437,6 +461,8 @@ pub(crate) struct CommandAvailability {
|
||||
/// `/graph` gate: the graph feature flag AND the goal harness (nodes
|
||||
/// execute as goals) are both available.
|
||||
pub graph: bool,
|
||||
/// `/swarm` gate: the `agent_swarm` tool is in the active toolset.
|
||||
pub swarm: bool,
|
||||
}
|
||||
|
||||
impl CommandAvailability {
|
||||
@@ -452,6 +478,7 @@ impl CommandAvailability {
|
||||
BuiltinGate::Plugins => self.plugins,
|
||||
BuiltinGate::Goal => self.goal,
|
||||
BuiltinGate::Graph => self.graph,
|
||||
BuiltinGate::Swarm => self.swarm,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,6 +495,7 @@ impl CommandAvailability {
|
||||
plugins: true,
|
||||
goal: true,
|
||||
graph: true,
|
||||
swarm: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -705,6 +733,17 @@ pub(super) enum BuiltinAction {
|
||||
token_budget: Option<i64>,
|
||||
},
|
||||
GoalStatus,
|
||||
/// `/swarm on|off` — arm or disarm the standing delegate-aggressively
|
||||
/// instruction. Survives turns until switched off.
|
||||
SwarmSet {
|
||||
enabled: bool,
|
||||
},
|
||||
/// `/swarm` with no argument.
|
||||
SwarmToggle,
|
||||
/// `/swarm <task>` — arm for this turn only, then send `prompt`.
|
||||
SwarmTask {
|
||||
prompt: String,
|
||||
},
|
||||
GoalPause,
|
||||
GoalResume,
|
||||
GoalClear,
|
||||
@@ -757,6 +796,9 @@ impl BuiltinAction {
|
||||
| BuiltinAction::GraphPause
|
||||
| BuiltinAction::GraphResume { .. }
|
||||
| BuiltinAction::GraphClear => "graph",
|
||||
BuiltinAction::SwarmSet { .. }
|
||||
| BuiltinAction::SwarmToggle
|
||||
| BuiltinAction::SwarmTask { .. } => "swarm",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -795,6 +837,8 @@ impl BuiltinAction {
|
||||
| BuiltinAction::GraphShow
|
||||
| BuiltinAction::GraphPause
|
||||
| BuiltinAction::GraphClear => false,
|
||||
BuiltinAction::SwarmToggle => false,
|
||||
BuiltinAction::SwarmSet { .. } | BuiltinAction::SwarmTask { .. } => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1584,6 +1628,7 @@ mod tests {
|
||||
"feedback",
|
||||
"goal",
|
||||
"graph",
|
||||
"swarm",
|
||||
"loop",
|
||||
"commit",
|
||||
"deploy",
|
||||
@@ -1694,6 +1739,51 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Without the `agent_swarm` tool the mode has nothing to steer toward, so
|
||||
/// the command must fall through as ordinary prompt text rather than
|
||||
/// arming a doctrine the model cannot act on.
|
||||
#[test]
|
||||
fn swarm_does_not_resolve_when_gate_off() {
|
||||
let availability = CommandAvailability {
|
||||
swarm: false,
|
||||
..CommandAvailability::all_enabled()
|
||||
};
|
||||
assert!(
|
||||
resolve(
|
||||
vec![text_block("/swarm on")],
|
||||
&[],
|
||||
availability,
|
||||
SkillSlashRewrite::default(),
|
||||
)
|
||||
.is_ok(),
|
||||
"expected pass-through (Ok), got an outcome",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swarm_resolves_each_form_to_its_own_action() {
|
||||
assert!(matches!(
|
||||
resolve_builtin("swarm", "on").expect("/swarm on must resolve"),
|
||||
BuiltinAction::SwarmSet { enabled: true }
|
||||
));
|
||||
assert!(matches!(
|
||||
resolve_builtin("swarm", "off").expect("/swarm off must resolve"),
|
||||
BuiltinAction::SwarmSet { enabled: false }
|
||||
));
|
||||
assert!(matches!(
|
||||
resolve_builtin("swarm", "").expect("bare /swarm must resolve"),
|
||||
BuiltinAction::SwarmToggle
|
||||
));
|
||||
// Anything else is the task, NOT an unknown subcommand: mis-parsing it
|
||||
// would silently drop the user's work instead of running it.
|
||||
match resolve_builtin("swarm", "split the auth refactor")
|
||||
.expect("/swarm <task> must resolve")
|
||||
{
|
||||
BuiltinAction::SwarmTask { prompt } => assert_eq!(prompt, "split the auth refactor"),
|
||||
other => panic!("expected SwarmTask, got {}", other.command_name()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_resolves_subcommands_and_budget() {
|
||||
let set = resolve_builtin("graph", "ship the feature --budget 5000")
|
||||
|
||||
Reference in new issue
Block a user