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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user