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:
@@ -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 <task>` (`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 <task>` 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`
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<parking_lot::Mutex<crate::session::graph_tracker::GraphTracker>>,
|
||||
/// 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<crate::session::swarm_mode::SwarmMode>,
|
||||
/// 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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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::<SessionEvent>();
|
||||
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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+3
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+1
@@ -72,6 +72,7 @@ pub(super) async fn make_replay_send_update_fixture() -> ReplaySendUpdateFixture
|
||||
});
|
||||
let (event_tx, event_rx) = mpsc::unbounded_channel::<SessionEvent>();
|
||||
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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 <task>`: 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<SwarmTrigger>,
|
||||
}
|
||||
|
||||
impl SwarmMode {
|
||||
pub fn is_active(self) -> bool {
|
||||
self.trigger.is_some()
|
||||
}
|
||||
|
||||
pub fn trigger(self) -> Option<SwarmTrigger> {
|
||||
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 <task>` 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 <task>` 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());
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user