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:
ZacharyZhang-NY committed 2026-07-27 02:39:55 -04:00
1 parent 9edb8729ef
commit 16328e55f9
20 files changed
+474 -4

No files matched your search

@@ -709,6 +709,36 @@ impl SessionActor {
BuiltinAction::GoalSet { .. } => {
unreachable!("GoalSet is intercepted in handle_prompt")
}
BuiltinAction::SwarmSet { enabled } => {
let msg = self.apply_swarm_mode(
enabled.then_some(crate::session::swarm_mode::SwarmTrigger::Manual),
);
self.send_slash_command_output(&msg).await;
ok_end_turn(0, None)
}
BuiltinAction::SwarmToggle => {
let turning_on = !self.swarm_mode.get().is_active();
let msg = self.apply_swarm_mode(
turning_on.then_some(crate::session::swarm_mode::SwarmTrigger::Manual),
);
self.send_slash_command_output(&msg).await;
ok_end_turn(0, None)
}
// `/swarm <task>` is handled before dispatch (it has to seed the
// turn with the task text); reaching here means the interception
// was bypassed, so report rather than silently dropping the task.
BuiltinAction::SwarmTask { prompt } => {
tracing::warn!(
prompt_len = prompt.len(),
"/swarm <task> reached the builtin executor; the turn seam did not intercept it"
);
self.send_slash_command_output(
"Could not start a swarm turn for that task. Run `/swarm on` and send the \
task as a normal message.",
)
.await;
ok_end_turn(0, None)
}
BuiltinAction::GoalStatus => {
let current_tokens = self.chat_state_handle.get_total_tokens().await as i64;
let goal_tokens = self.goal_tokens_used(current_tokens);
@@ -1115,6 +1115,7 @@ pub(crate) async fn spawn_session_actor(
goal_tracker,
graph_enabled,
graph_tracker,
swarm_mode: std::cell::Cell::new(Default::default()),
graph_concurrency: effective_config.resolve_graph_concurrency(),
graph_node_rounds: effective_config.resolve_graph_node_rounds(),
graph_replan_cap: effective_config.resolve_graph_replan_cap(),
@@ -0,0 +1,80 @@
//! Swarm-mode seam: arming/disarming the mode and the reminder it injects.
use super::*;
use crate::session::swarm_mode::{SWARM_ENTER_REMINDER, SWARM_EXIT_REMINDER, SwarmTrigger};
impl SessionActor {
/// Arms (`Some(trigger)`) or disarms (`None`) swarm mode, injecting or
/// retracting the doctrine exactly once, and returns the line to show.
pub(crate) fn apply_swarm_mode(self: &Arc<Self>, trigger: Option<SwarmTrigger>) -> String {
let mut mode = self.swarm_mode.get();
let message = match trigger {
Some(trigger) => {
if mode.enter(trigger) {
self.inject_swarm_reminder(SWARM_ENTER_REMINDER);
}
"Swarm mode on: work will be split across a fleet of subagents. \
`/swarm off` to stop."
}
None => {
// Unconditional, unlike the automatic expiry: the doctrine
// rides the conversation and therefore survives a resume, a
// fork and a compaction that the in-memory flag does not. If
// the user explicitly asks for it off, the retraction has to
// reach the model even when this session never saw it armed.
mode.exit();
self.inject_swarm_reminder(SWARM_EXIT_REMINDER);
"Swarm mode off."
}
};
self.swarm_mode.set(mode);
message.to_string()
}
/// A turn-scoped guard that disarms a per-turn swarm mode however the turn
/// ends.
///
/// The post-loop call site is not enough: a user interrupt ABORTS the turn
/// future (`cancel_running_task` → `JoinHandle::abort`), dropping it at its
/// current await point, and several `?` paths return before the loop's end.
/// Each of those leaks a `/swarm <task>` mode into the user's next,
/// unrelated prompt. The goal engine hit the same class and compensates
/// inside the cancel path; a guard is the version that cannot be forgotten
/// at a new exit.
pub(crate) fn swarm_turn_guard(self: &Arc<Self>) -> SwarmTurnGuard {
SwarmTurnGuard {
session: self.clone(),
}
}
/// Disarms at a turn boundary when the trigger was per-turn.
pub(crate) fn expire_swarm_mode_at_turn_end(self: &Arc<Self>) {
if !self.swarm_mode.get().expires_at_turn_end() {
return;
}
let mut mode = self.swarm_mode.get();
if mode.exit() {
self.inject_swarm_reminder(SWARM_EXIT_REMINDER);
}
self.swarm_mode.set(mode);
}
/// The doctrine rides the session's existing `<system-reminder>` channel,
/// so it is tagged the same way every other reminder is and needs no
/// second injection path of its own.
fn inject_swarm_reminder(self: &Arc<Self>, text: &str) {
self.push_system_reminder(text);
}
}
/// Runs [`SessionActor::expire_swarm_mode_at_turn_end`] on every turn exit,
/// including an aborted future.
pub(crate) struct SwarmTurnGuard {
session: Arc<SessionActor>,
}
impl Drop for SwarmTurnGuard {
fn drop(&mut self) {
self.session.expire_swarm_mode_at_turn_end();
}
}
@@ -217,6 +217,10 @@ impl SessionActor {
persist_ack: Option<oneshot::Sender<()>>,
) -> PromptTurnResult {
let handle_prompt_start = std::time::Instant::now();
// Armed before anything can arm swarm mode, so a `/swarm <task>` mode
// is disarmed on EVERY exit from this turn — including the abort a
// user interrupt performs, which never reaches post-loop code.
let _swarm_turn_guard = self.swarm_turn_guard();
let prompt_length: usize = prompt_blocks
.iter()
.map(|b| match b {
@@ -301,6 +305,13 @@ impl SessionActor {
span.record("command_source", "builtin");
}
match action {
// `/swarm <task>` arms the mode for this turn only and
// sends the task as the prompt, so the doctrine is in the
// conversation before the model reads the work.
BuiltinAction::SwarmTask { prompt } => {
self.apply_swarm_mode(Some(crate::session::swarm_mode::SwarmTrigger::Task));
vec![text_block(prompt)]
}
BuiltinAction::GoalSet {
objective,
token_budget,