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:
2026-07-27 02:39:55 -04:00
parent 9edb8729ef
commit 16328e55f9
20 changed files with 474 additions and 4 deletions
@@ -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};