From 16328e55f9e0fa774cd402af5ee9596b21f6fea7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 27 Jul 2026 02:39:55 -0400 Subject: [PATCH] =?UTF-8?q?feat(swarm):=20/swarm=20mode=20=E2=80=94=20a=20?= =?UTF-8?q?standing=20instruction=20to=20fan=20work=20out?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `, 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 ` mode would leak into the next, unrelated prompt. - `enter` is a total no-op while armed, so the `/swarm ` 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. --- AGENTS.md | 50 +++++ .../src/agent/mvp_agent/agent_ops.rs | 5 + .../kigi-shell/src/session/acp_session.rs | 8 + .../session/acp_session_impl/slash_exec.rs | 30 +++ .../src/session/acp_session_impl/spawn.rs | 1 + .../src/session/acp_session_impl/swarm.rs | 80 ++++++++ .../src/session/acp_session_impl/turn.rs | 11 ++ .../cancel_running_task_tests.rs | 4 + .../acp_session_tests/idle_resume_tests.rs | 1 + .../inline_auto_compact_flow_tests.rs | 3 + .../acp_session_tests/memory_config_tests.rs | 1 + .../replay_buffer_send_update_tests.rs | 1 + .../src/session/acp_session_tests/support.rs | 1 + .../kigi-shell/src/session/compaction.rs | 1 + crates/codegen/kigi-shell/src/session/mod.rs | 1 + .../kigi-shell/src/session/slash_commands.rs | 90 +++++++++ .../kigi-shell/src/session/swarm_mode.rs | 178 ++++++++++++++++++ .../implementations/kigi/agent_swarm/mod.rs | 2 +- .../implementations/kigi/agent_swarm/tool.rs | 8 +- .../src/implementations/kigi/mod.rs | 2 +- 20 files changed, 474 insertions(+), 4 deletions(-) create mode 100644 crates/codegen/kigi-shell/src/session/acp_session_impl/swarm.rs create mode 100644 crates/codegen/kigi-shell/src/session/swarm_mode.rs diff --git a/AGENTS.md b/AGENTS.md index 9bf7d49..598a31b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -164,6 +164,56 @@ edges stay deterministic Rust. The harness appends a terminal the replan cap; `{"ops": []}` is a respected free no-op; failures degrade. +## Swarm (`agent_swarm` tool + `/swarm` mode) + +Cheap, unstructured fan-out — the one thing `/graph` does not offer. One +`prompt_template` containing `{{item}}` is expanded over an `items` list +into up to `MAX_AGENT_SWARM_MEMBERS` (128) ordinary subagents, run to +completion, and returned as ONE aggregate. Ported from kimi-code; entirely +client-side, no backend surface. + +- Engine (`kigi-tools/.../kigi/agent_swarm/`): `plan.rs` validates and + expands (every fault is reported BEFORE a member starts; expanded + prompts must be pairwise distinct), `schedule.rs` is the launch ramp as + pure arithmetic (5 immediate, then 1 per 700ms; capacity shrinks on a + provider rate limit and recovers after a quiet window; + `KIGI_AGENT_SWARM_MAX_CONCURRENCY` caps it and a malformed value is a + hard error), `run.rs` drives it against the EXISTING single-spawn + `SubagentBackend`, `tool.rs` is the tool. +- `SwarmMemberOutcome::Backgrounded` is load-bearing: a member that + outlives the 600s foreground budget is detached by the coordinator and + KEEPS RUNNING. It is never reported `Failed` and never offered for + resume — relaunching its item would put a second agent on the same + files. Members share the caller's tree with NO isolation; the + distinct-prompt rule is the only thing keeping them apart. +- `InFlightGuard` (run.rs) cancels live members when the runner's future + is dropped. 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 — + `kigi_tool_runtime::Cancellation` is constructed nowhere. +- Both retries (`MAX_RATE_LIMIT_RETRIES`) and wall clock + (`MAX_SWARM_RUNTIME`) are bounded: the swarm blocks the caller's turn, + so every wait needs a ceiling. Stragglers at the deadline are reported + as still-running WITH their ids. +- `ToolKind::AgentSwarm` is its own variant because `TemplateRenderer`'s + `by_kind` map holds ONE tool name per kind — sharing `Task` would + silently redirect `${{ tools.by_kind.task }}` in other tools' prompts. + It gates exactly as `Task` does (`capability.rs`), and `builder.rs` + strips it wherever `task` is stripped. `MAX_SUBAGENT_DEPTH` stays 1: + upstream's unlimited nesting is a hazard, not a feature. +- `/swarm | /swarm on|off | /swarm ` (`BuiltinGate::Swarm` = the + tool is in the toolset) arms a doctrine reminder via the existing + `push_system_reminder`. Two triggers only: `Manual` persists until + switched off, `Task` auto-expires at turn end via `SwarmTurnGuard` — a + DROP guard, because a user interrupt aborts the turn future and never + reaches post-loop code. `enter` is a total no-op while armed so the + `/swarm ` shorthand cannot downgrade a standing `/swarm on`. +- The mode is deliberately NOT persisted (unlike `/goal` and `/graph`, + which strand real work if lost): it is a prompt hint whose recovery is + retyping one command. The injected doctrine IS durable, so an explicit + `/swarm off` retracts UNCONDITIONALLY — a resumed or compacted session + can read as "off" with the instruction still in context. + ## Provider registry & API-key auth (post-0.1.3 expansion) - The platform registry is compiled-in spec rows in `kigi-models` diff --git a/crates/codegen/kigi-shell/src/agent/mvp_agent/agent_ops.rs b/crates/codegen/kigi-shell/src/agent/mvp_agent/agent_ops.rs index ba11c0b..51bb5bf 100644 --- a/crates/codegen/kigi-shell/src/agent/mvp_agent/agent_ops.rs +++ b/crates/codegen/kigi-shell/src/agent/mvp_agent/agent_ops.rs @@ -357,6 +357,11 @@ impl MvpAgent { // time, so advertise pre-session; the in-session path // re-checks the live toolset. graph: goal && self.cfg.borrow().resolve_graph().value, + // Tool-dependent, so fail closed like every other tool gate: the + // builder strips `agent_swarm` whenever subagents are unavailable, + // and advertising it then offers a menu entry that resolves to + // literal prompt text. + swarm: self.cfg.borrow().subagents_enabled, ..crate::session::slash_commands::CommandAvailability::default() } } diff --git a/crates/codegen/kigi-shell/src/session/acp_session.rs b/crates/codegen/kigi-shell/src/session/acp_session.rs index 5930610..a3dc2b4 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session.rs @@ -121,6 +121,8 @@ mod model_switch; mod prompt_queue; #[path = "acp_session_impl/slash_exec.rs"] mod slash_exec; +#[path = "acp_session_impl/swarm.rs"] +mod swarm; use super::PromptOrigin; use super::acp_types; use super::chat_persistence; @@ -616,6 +618,9 @@ pub(crate) struct SessionActor { /// layered over the goal engine. Modeled after `goal_tracker` above; /// all graph state logic lives in `graph_tracker.rs`. pub(crate) graph_tracker: Arc>, + /// Swarm mode: a standing instruction to fan work out, independent of the + /// goal/graph engines — it steers tool choice, it does not drive turns. + pub(crate) swarm_mode: std::cell::Cell, /// Max graph nodes running concurrently (1 = serial G0 behavior). /// Cached at actor construction from `resolve_graph_concurrency`. pub(crate) graph_concurrency: u32, @@ -1033,6 +1038,9 @@ impl SessionActor { // Graph rides the goal harness: nodes execute as goals, so // `/graph` is only real when `/goal` is. graph: self.graph_enabled && goal, + swarm: tool_names + .iter() + .any(|n| n == kigi_tools::implementations::kigi::AGENT_SWARM_TOOL_NAME), } } /// Names of every tool registered with the session's tool bridge. diff --git a/crates/codegen/kigi-shell/src/session/acp_session_impl/slash_exec.rs b/crates/codegen/kigi-shell/src/session/acp_session_impl/slash_exec.rs index 0631405..35625b9 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_impl/slash_exec.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_impl/slash_exec.rs @@ -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 ` 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 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); diff --git a/crates/codegen/kigi-shell/src/session/acp_session_impl/spawn.rs b/crates/codegen/kigi-shell/src/session/acp_session_impl/spawn.rs index 468760e..1d3b103 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_impl/spawn.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_impl/spawn.rs @@ -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(), diff --git a/crates/codegen/kigi-shell/src/session/acp_session_impl/swarm.rs b/crates/codegen/kigi-shell/src/session/acp_session_impl/swarm.rs new file mode 100644 index 0000000..ad458b2 --- /dev/null +++ b/crates/codegen/kigi-shell/src/session/acp_session_impl/swarm.rs @@ -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, trigger: Option) -> 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 ` 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) -> 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) { + 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 `` 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, 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, +} + +impl Drop for SwarmTurnGuard { + fn drop(&mut self) { + self.session.expire_swarm_mode_at_turn_end(); + } +} diff --git a/crates/codegen/kigi-shell/src/session/acp_session_impl/turn.rs b/crates/codegen/kigi-shell/src/session/acp_session_impl/turn.rs index 339f3a1..102c03f 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_impl/turn.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_impl/turn.rs @@ -217,6 +217,10 @@ impl SessionActor { persist_ack: Option>, ) -> PromptTurnResult { let handle_prompt_start = std::time::Instant::now(); + // Armed before anything can arm swarm mode, so a `/swarm ` 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 ` 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, diff --git a/crates/codegen/kigi-shell/src/session/acp_session_tests/cancel_running_task_tests.rs b/crates/codegen/kigi-shell/src/session/acp_session_tests/cancel_running_task_tests.rs index 00a6a25..ce5f952 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_tests/cancel_running_task_tests.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_tests/cancel_running_task_tests.rs @@ -106,6 +106,7 @@ async fn persist_ack_waits_for_disk_flush_before_success() { tokio_util::sync::CancellationToken::new(), ); let actor = Arc::new(SessionActor { + swarm_mode: std::cell::Cell::new(Default::default()), session_info, auth_method_id: test_auth_method_id("test-auth"), model_auth_facts: std::cell::RefCell::new(None), @@ -559,6 +560,7 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history() }; let (event_tx, _event_rx) = tokio::sync::mpsc::unbounded_channel::(); let actor = Arc::new(SessionActor { + swarm_mode: std::cell::Cell::new(Default::default()), session_info: session_info.clone(), auth_method_id: test_auth_method_id("test-auth"), model_auth_facts: std::cell::RefCell::new(None), @@ -820,6 +822,7 @@ async fn cancel_running_task_teardown_clears_running_and_pending_work() { ) .await; let actor = SessionActor { + swarm_mode: std::cell::Cell::new(Default::default()), session_info: SessionInfo { id: acp::SessionId::new("test-cancel"), cwd: cwd.as_str().to_string(), @@ -1813,6 +1816,7 @@ async fn cancel_propagates_to_sampler_handle_so_no_further_emission() { ) .await; let actor = SessionActor { + swarm_mode: std::cell::Cell::new(Default::default()), session_info: SessionInfo { id: acp::SessionId::new("test-cancel-sampler"), cwd: cwd.as_str().to_string(), diff --git a/crates/codegen/kigi-shell/src/session/acp_session_tests/idle_resume_tests.rs b/crates/codegen/kigi-shell/src/session/acp_session_tests/idle_resume_tests.rs index 4dece7f..048867e 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_tests/idle_resume_tests.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_tests/idle_resume_tests.rs @@ -115,6 +115,7 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() { }); tokio::time::sleep(std::time::Duration::from_millis(50)).await; let actor = SessionActor { + swarm_mode: std::cell::Cell::new(Default::default()), session_info: SessionInfo { id: acp::SessionId::new("test-idle-resume"), cwd: cwd.as_str().to_string(), diff --git a/crates/codegen/kigi-shell/src/session/acp_session_tests/inline_auto_compact_flow_tests.rs b/crates/codegen/kigi-shell/src/session/acp_session_tests/inline_auto_compact_flow_tests.rs index 5073d8b..b063a1a 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_tests/inline_auto_compact_flow_tests.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_tests/inline_auto_compact_flow_tests.rs @@ -64,6 +64,7 @@ async fn create_test_actor( ); chat_state_handle.record_token_usage(total_tokens); SessionActor { + swarm_mode: std::cell::Cell::new(Default::default()), session_info: SessionInfo { id: acp::SessionId::new("test-auto-compact"), cwd: cwd.as_str().to_string(), @@ -493,6 +494,7 @@ async fn create_test_actor_with_memory( .as_ref() .map_or_else(Default::default, |mc| mc.initial_injection.clone()); SessionActor { + swarm_mode: std::cell::Cell::new(Default::default()), session_info: SessionInfo { id: acp::SessionId::new("test-memory"), cwd: cwd.as_str().to_string(), @@ -1235,6 +1237,7 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() { }); tokio::time::sleep(std::time::Duration::from_millis(50)).await; let actor = SessionActor { + swarm_mode: std::cell::Cell::new(Default::default()), session_info: SessionInfo { id: acp::SessionId::new("test-idle-resume"), cwd: cwd.as_str().to_string(), diff --git a/crates/codegen/kigi-shell/src/session/acp_session_tests/memory_config_tests.rs b/crates/codegen/kigi-shell/src/session/acp_session_tests/memory_config_tests.rs index 1d79aaa..87ef79c 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_tests/memory_config_tests.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_tests/memory_config_tests.rs @@ -119,6 +119,7 @@ async fn create_test_actor_with_memory( .as_ref() .map_or_else(Default::default, |mc| mc.initial_injection.clone()); SessionActor { + swarm_mode: std::cell::Cell::new(Default::default()), session_info: SessionInfo { id: acp::SessionId::new("test-memory"), cwd: cwd.as_str().to_string(), diff --git a/crates/codegen/kigi-shell/src/session/acp_session_tests/replay_buffer_send_update_tests.rs b/crates/codegen/kigi-shell/src/session/acp_session_tests/replay_buffer_send_update_tests.rs index c6b2874..28cd311 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_tests/replay_buffer_send_update_tests.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_tests/replay_buffer_send_update_tests.rs @@ -72,6 +72,7 @@ pub(super) async fn make_replay_send_update_fixture() -> ReplaySendUpdateFixture }); let (event_tx, event_rx) = mpsc::unbounded_channel::(); let actor = SessionActor { + swarm_mode: std::cell::Cell::new(Default::default()), session_info: SessionInfo { id: acp::SessionId::new("test-session"), cwd: cwd.as_str().to_string(), diff --git a/crates/codegen/kigi-shell/src/session/acp_session_tests/support.rs b/crates/codegen/kigi-shell/src/session/acp_session_tests/support.rs index e787345..536f5af 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_tests/support.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_tests/support.rs @@ -182,6 +182,7 @@ pub(crate) async fn create_test_actor_ex( chat_state_handle.record_token_usage(total_tokens); let (goal_update_tx, goal_update_rx) = tokio::sync::mpsc::unbounded_channel(); let actor = SessionActor { + swarm_mode: std::cell::Cell::new(Default::default()), session_info: SessionInfo { id: acp::SessionId::new("test-actor"), cwd: cwd.as_str().to_string(), diff --git a/crates/codegen/kigi-shell/src/session/compaction.rs b/crates/codegen/kigi-shell/src/session/compaction.rs index ddd279b..fc71ad6 100644 --- a/crates/codegen/kigi-shell/src/session/compaction.rs +++ b/crates/codegen/kigi-shell/src/session/compaction.rs @@ -2154,6 +2154,7 @@ mod inline_auto_compact_flow_tests { ); chat_state_handle.record_token_usage(total_tokens); SessionActor { + swarm_mode: std::cell::Cell::new(Default::default()), session_info: SessionInfo { id: acp::SessionId::new("test-auto-compact"), cwd: cwd.as_str().to_string(), diff --git a/crates/codegen/kigi-shell/src/session/mod.rs b/crates/codegen/kigi-shell/src/session/mod.rs index 1b61551..8c9c6a8 100644 --- a/crates/codegen/kigi-shell/src/session/mod.rs +++ b/crates/codegen/kigi-shell/src/session/mod.rs @@ -329,6 +329,7 @@ pub(crate) mod slash_commands; pub mod storage; pub(crate) mod streaming_capture; pub(crate) mod summary; +pub mod swarm_mode; pub(crate) mod telemetry; pub mod tool_index; pub(crate) mod turn_completion; diff --git a/crates/codegen/kigi-shell/src/session/slash_commands.rs b/crates/codegen/kigi-shell/src/session/slash_commands.rs index 7a2a421..356e160 100644 --- a/crates/codegen/kigi-shell/src/session/slash_commands.rs +++ b/crates/codegen/kigi-shell/src/session/slash_commands.rs @@ -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 | ]"), + 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 ` 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, }, 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 ` — 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 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") diff --git a/crates/codegen/kigi-shell/src/session/swarm_mode.rs b/crates/codegen/kigi-shell/src/session/swarm_mode.rs new file mode 100644 index 0000000..e07b6a8 --- /dev/null +++ b/crates/codegen/kigi-shell/src/session/swarm_mode.rs @@ -0,0 +1,178 @@ +//! Swarm mode: a standing instruction to split work across a fleet. +//! +//! 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. Kept as pure state so the turn loop decides when to +//! inject and the session decides when to persist. + +use serde::{Deserialize, Serialize}; + +/// Why the mode is on, which is what decides when it turns off. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SwarmTrigger { + /// `/swarm on` (or a bare `/swarm` toggle): stays until switched off. + Manual, + /// `/swarm `: armed for exactly that turn. + Task, +} + +/// The session's swarm-mode state. `None` means off. +/// +/// Deliberately NOT persisted, unlike `/goal` and `/graph`: those drive +/// autonomous multi-turn work that is stranded if it is lost, whereas this is +/// a prompt hint whose worst-case recovery is typing `/swarm on` again. The +/// injected doctrine IS durable (it rides the conversation), so a resumed +/// session can read as "off" with the instruction still in context — which is +/// exactly why an explicit `/swarm off` always retracts (see +/// [`SessionActor::apply_swarm_mode`]) rather than trusting a remembered flag +/// that a restore, a compaction or a fork can each falsify. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct SwarmMode { + trigger: Option, +} + +impl SwarmMode { + pub fn is_active(self) -> bool { + self.trigger.is_some() + } + + pub fn trigger(self) -> Option { + self.trigger + } + + /// Arms the mode, and does NOTHING if it is already armed. + /// + /// Total no-op, not just "don't re-inject": overwriting the trigger would + /// let the `/swarm ` shorthand downgrade a standing `/swarm on` into + /// a per-turn mode, which then disarms itself at that turn's end — the + /// user's deliberate choice, silently undone. + /// + /// Returns whether the caller should inject the enter reminder. + pub fn enter(&mut self, trigger: SwarmTrigger) -> bool { + if self.trigger.is_some() { + return false; + } + self.trigger = Some(trigger); + true + } + + /// Disarms. Returns whether the mode had been armed — which is what an + /// AUTOMATIC expiry keys its retraction off. An explicit `/swarm off` must + /// retract regardless (see the type docs). + pub fn exit(&mut self) -> bool { + self.trigger.take().is_some() + } + + /// Whether a turn ending now should disarm the mode. + /// + /// Only the `Task` trigger auto-exits: `/swarm on` is a standing choice the + /// user made and a turn boundary is not a reason to undo it. + pub fn expires_at_turn_end(self) -> bool { + self.trigger == Some(SwarmTrigger::Task) + } +} + +/// The doctrine injected when the mode is armed. +/// +/// Deliberately short: it is re-read on every turn it is live, and the tool's +/// own description already carries the mechanics. +pub const SWARM_ENTER_REMINDER: &str = "\ +Swarm mode is on. Explore only as far as you must to identify the work, then \ +split it: use the agent_swarm tool with one item per independent scope rather \ +than doing the work yourself. Decompose finely — do not try to conserve \ +members. Every member must own a disjoint scope; members share one working \ +tree, so two members told to touch the same file will corrupt each other. \ +Read-only scopes may overlap. If the work genuinely does not split, say so and \ +carry on alone."; + +/// Injected when the mode is switched off mid-conversation, so the earlier +/// doctrine does not keep steering the model. +/// Deliberately as emphatic as the enter doctrine it revokes: a one-line +/// "mode is off" is the weaker of the two texts in context and the likelier to +/// be summarised away, leaving the fan-out directives still steering. +pub const SWARM_EXIT_REMINDER: &str = "\ +Swarm mode is off. The swarm instructions above no longer apply — you are not \ +required to split work across subagents, and you should not decompose a task \ +just because they said to. Decide how to approach each new request from the \ +request itself. Delegate only where it clearly helps."; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_fresh_session_has_the_mode_off() { + let mode = SwarmMode::default(); + assert!(!mode.is_active()); + assert!(!mode.expires_at_turn_end()); + } + + #[test] + fn arming_asks_for_the_doctrine_once_not_once_per_command() { + let mut mode = SwarmMode::default(); + assert!(mode.enter(SwarmTrigger::Manual), "first arm injects"); + assert!( + !mode.enter(SwarmTrigger::Manual), + "a repeated /swarm on must not stack a second copy" + ); + assert!(mode.is_active()); + } + + #[test] + fn only_the_task_trigger_expires_at_a_turn_boundary() { + let mut manual = SwarmMode::default(); + manual.enter(SwarmTrigger::Manual); + assert!( + !manual.expires_at_turn_end(), + "/swarm on is a standing choice, not a per-turn one" + ); + + let mut task = SwarmMode::default(); + task.enter(SwarmTrigger::Task); + assert!(task.expires_at_turn_end()); + } + + #[test] + fn exiting_retracts_exactly_once() { + let mut mode = SwarmMode::default(); + mode.enter(SwarmTrigger::Manual); + assert!(mode.exit(), "the live doctrine must be retracted"); + assert!(!mode.is_active()); + assert!( + !mode.exit(), + "a second /swarm off has nothing left to retract" + ); + } + + /// `/swarm ` under a standing `/swarm on` must seed the turn and + /// nothing more — it must not convert the standing mode into a per-turn + /// one that disarms itself when that turn ends. + #[test] + fn the_task_shorthand_never_downgrades_a_standing_mode() { + let mut mode = SwarmMode::default(); + mode.enter(SwarmTrigger::Manual); + assert!( + !mode.enter(SwarmTrigger::Task), + "the doctrine is already in the conversation" + ); + assert_eq!(mode.trigger(), Some(SwarmTrigger::Manual)); + assert!( + !mode.expires_at_turn_end(), + "the user's standing /swarm on must survive the turn" + ); + } + + /// An automatic expiry has nothing to retract once the mode is already + /// off; only an explicit `/swarm off` retracts unconditionally, and that + /// rule lives at the call site, not here. + #[test] + fn exit_reports_whether_it_actually_disarmed_something() { + let mut armed = SwarmMode::default(); + armed.enter(SwarmTrigger::Task); + assert!(armed.exit()); + + let mut idle = SwarmMode::default(); + assert!(!idle.exit()); + } +} diff --git a/crates/codegen/kigi-tools/src/implementations/kigi/agent_swarm/mod.rs b/crates/codegen/kigi-tools/src/implementations/kigi/agent_swarm/mod.rs index e08d013..6bb3c08 100644 --- a/crates/codegen/kigi-tools/src/implementations/kigi/agent_swarm/mod.rs +++ b/crates/codegen/kigi-tools/src/implementations/kigi/agent_swarm/mod.rs @@ -4,4 +4,4 @@ pub mod run; pub mod schedule; pub mod tool; -pub use tool::AgentSwarmTool; +pub use tool::{AGENT_SWARM_TOOL_NAME, AgentSwarmTool}; diff --git a/crates/codegen/kigi-tools/src/implementations/kigi/agent_swarm/tool.rs b/crates/codegen/kigi-tools/src/implementations/kigi/agent_swarm/tool.rs index f1f1f26..1c55665 100644 --- a/crates/codegen/kigi-tools/src/implementations/kigi/agent_swarm/tool.rs +++ b/crates/codegen/kigi-tools/src/implementations/kigi/agent_swarm/tool.rs @@ -31,6 +31,10 @@ For a single item, use the subagent (task) tool instead. To continue members fro swarm, pass `resume_agent_ids` mapping the agent_id values from that swarm's result to a \ follow-up prompt."; +/// Registry name, so gating code matches on one definition rather than a +/// literal that can drift from the tool id. +pub const AGENT_SWARM_TOOL_NAME: &str = "agent_swarm"; + #[derive(Debug, Default)] pub struct AgentSwarmTool; @@ -66,14 +70,14 @@ impl kigi_tool_runtime::Tool for AgentSwarmTool { type Output = ToolOutput; fn id(&self) -> kigi_tool_protocol::ToolId { - kigi_tool_protocol::ToolId::new("agent_swarm").expect("valid tool id") + kigi_tool_protocol::ToolId::new(AGENT_SWARM_TOOL_NAME).expect("valid tool id") } fn description( &self, _ctx: &::kigi_tool_runtime::ListToolsContext, ) -> kigi_tool_types::ToolDescription { - kigi_tool_types::ToolDescription::new("agent_swarm", DESCRIPTION) + kigi_tool_types::ToolDescription::new(AGENT_SWARM_TOOL_NAME, DESCRIPTION) } fn capabilities(&self) -> kigi_tool_protocol::ToolCapabilities { diff --git a/crates/codegen/kigi-tools/src/implementations/kigi/mod.rs b/crates/codegen/kigi-tools/src/implementations/kigi/mod.rs index 78b5723..6551add 100644 --- a/crates/codegen/kigi-tools/src/implementations/kigi/mod.rs +++ b/crates/codegen/kigi-tools/src/implementations/kigi/mod.rs @@ -22,7 +22,7 @@ pub mod todo; pub mod update_goal; pub mod web_fetch; pub mod web_search; -pub use agent_swarm::AgentSwarmTool; +pub use agent_swarm::{AGENT_SWARM_TOOL_NAME, AgentSwarmTool}; pub use ask_user_question::AskUserQuestionTool; pub use bash::BashTool; pub use deploy_app::{AppBuilderDeployerConfig, DEPLOY_APP_TOOL_NAME};