Files
Kigi-CLI/crates/codegen/kigi-workspace/src/capability.rs
T
ZacharyZhang-NY 9edb8729ef feat(swarm): agent_swarm — one prompt over many items, paced as a fleet
Ports kimi-code's AgentSwarm: a `prompt_template` containing `{{item}}`
expanded over an `items` list into up to 128 subagents, run to completion
and returned as one aggregate. Kigi already exceeds upstream on planning,
verification, isolation and merge via /graph; what it lacked was cheap
immediate fan-out. Entirely client-side — no new backend surface.

The engine is three pure pieces plus a runner: `plan` validates and
expands (every fault reported before a single member starts — a
half-launched swarm is expensive to unwind), `schedule` is the launch
ramp as testable arithmetic, `run` drives it against the existing
`SubagentBackend`. Reuses the single-spawn coordinator rather than
inventing a batch API.

Load-bearing decisions, each the result of a defect found in review:

- `backgrounded` is its own outcome. A member that outlives the 600s
  foreground budget is detached by the coordinator and KEEPS RUNNING;
  reporting it as failed invites the model to relaunch its item, putting
  a second agent on the same files. It is never offered for resume.
- `InFlightGuard` cancels live members on Drop. 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 — `Cancellation` is constructed nowhere
  in the tree — so Drop is the only seam that fires.
- Retries and wall clock are both bounded. The swarm blocks the caller's
  turn, so every wait needs a ceiling it cannot argue past; stragglers at
  the deadline are reported as still-running, with their ids.
- `ToolKind::AgentSwarm` is its own variant: `TemplateRenderer`'s
  `by_kind` map holds one tool name per kind, so sharing `Task` would
  silently redirect `${{ tools.by_kind.task }}` in other tools' prompts.
- An explicitly requested model that cannot be validated is refused, as
  the task tool already does — one loud error beats `items.len()` quiet
  ones. Depth stays capped at 1: upstream's unlimited nesting is a
  hazard, not a feature.
- `SubagentResult.rate_limited` is classified where the typed ACP error
  code is still in hand; a scheduler re-deriving it from a formatted
  string would stop adapting the day the wording changed.
- Aggregate output is clamped per member (head+tail, loss stated):
  native tool output is truncated nowhere downstream.

42 agent_swarm tests. The fake backend awaits, so the concurrency and
ordering assertions can actually fail; the cap test also proves the
fixture can exceed the cap.
2026-07-27 01:22:52 -04:00

326 lines
11 KiB
Rust

//! Capability-mode filtering for session toolsets.
use kigi_tools::registry::types::{ToolConfig, ToolServerConfig};
use kigi_tools::types::tool::ToolKind;
/// Capability mode applied to a session's toolset.
///
/// A partial order is defined via [`CapabilityMode::is_subset_of`]:
/// `ReadOnly < ReadWrite < All` and `ReadOnly < Execute < All`.
/// `ReadWrite` and `Execute` are *incomparable* (neither is a subset
/// of the other). `fork_session` enforces `child <= parent`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CapabilityMode {
/// Reading and searching only. No edits, no shell, no background tasks.
ReadOnly,
/// Read + edit. No shell execution.
ReadWrite,
/// Read + shell execution + background-task control. No edits.
Execute,
/// Every tool kind allowed.
All,
}
impl Default for CapabilityMode {
/// Defaults to [`CapabilityMode::ReadWrite`] (subagent default;
/// the main/root session is always `All`).
fn default() -> Self {
Self::ReadWrite
}
}
impl CapabilityMode {
/// Filter `config.tools` by capability mode, returning a copy with
/// disallowed tools dropped.
///
/// Tools whose `kind` is `None` (baseline, e.g. ad-hoc tools
/// declared via `ToolConfig::simple`) are preserved across all
/// modes. **MCP-origin** `kind: None` tools are NOT preserved by
/// this method; see `resolve_session_toolset` for the asymmetric
/// handling.
pub fn filter(self, config: &ToolServerConfig) -> ToolServerConfig {
let kept: Vec<ToolConfig> = config
.tools
.iter()
.filter(|tool| match tool.kind {
None => true,
Some(kind) => kind_allowed(self, kind),
})
.cloned()
.collect();
ToolServerConfig {
tools: kept,
behavior_preset: config.behavior_preset.clone(),
}
}
/// Whether every kind allowed by `self` is also allowed by `other`.
/// Used by `fork_session` to reject capability widening.
pub fn is_subset_of(self, other: CapabilityMode) -> bool {
for kind in ALL_TOOL_KINDS {
if kind_allowed(self, *kind) && !kind_allowed(other, *kind) {
return false;
}
}
true
}
}
/// Every `ToolKind` variant. Used by `is_subset_of` and by parameterised
/// tests. When a new variant is added to `ToolKind`, the compile-time
/// assertion below fires so it can't be silently omitted.
pub(crate) const ALL_TOOL_KINDS: &[ToolKind] = &[
ToolKind::Read,
ToolKind::Edit,
ToolKind::Delete,
ToolKind::ListDir,
ToolKind::Write,
ToolKind::Move,
ToolKind::Search,
ToolKind::Lsp,
ToolKind::Execute,
ToolKind::Plan,
ToolKind::WebSearch,
ToolKind::WebFetch,
ToolKind::BackgroundTaskAction,
ToolKind::WaitTasksAction,
ToolKind::KillTaskAction,
ToolKind::List,
ToolKind::Skill,
ToolKind::MemorySearch,
ToolKind::MemoryGet,
ToolKind::Task,
ToolKind::AgentSwarm,
ToolKind::EnterPlan,
ToolKind::ExitPlan,
ToolKind::AskUser,
ToolKind::DeployApp,
ToolKind::SearchTool,
ToolKind::UseTool,
ToolKind::Monitor,
ToolKind::GoalUpdate,
ToolKind::Other,
];
// Compile-time guard: if a new `ToolKind` variant is added but not listed in
// `ALL_TOOL_KINDS`, this assertion fails.
const _: () = assert!(
ALL_TOOL_KINDS.len() == ToolKind::VARIANT_COUNT,
"ALL_TOOL_KINDS is out of sync with ToolKind — add the new variant"
);
/// Maps `(CapabilityMode, ToolKind)` -> kept-or-dropped.
///
/// This `match` is deliberately exhaustive: when `ToolKind` gains a
/// new variant the compiler errors here, forcing a triage decision.
pub(crate) fn kind_allowed(mode: CapabilityMode, kind: ToolKind) -> bool {
use CapabilityMode as M;
use ToolKind::*;
if matches!(mode, M::All) {
return true;
}
match kind {
// Meta tools: always allowed.
Plan | EnterPlan | ExitPlan | AskUser | Skill | SearchTool | GoalUpdate => true,
// Read class.
Read | MemoryGet | MemorySearch => {
matches!(mode, M::ReadOnly | M::ReadWrite | M::Execute)
}
// Search class.
Search | WebSearch | WebFetch => {
matches!(mode, M::ReadOnly | M::ReadWrite | M::Execute)
}
// Inspect class.
Lsp | ListDir | List => matches!(mode, M::ReadOnly | M::ReadWrite | M::Execute),
// Edit class.
Edit | Write | Delete | Move | DeployApp => matches!(mode, M::ReadWrite),
// Bash / shell.
Execute => matches!(mode, M::Execute),
// Process control (background tasks, monitors). A swarm is a fan-out of
// subagent spawns, so it gates exactly as `Task` does.
BackgroundTaskAction | WaitTasksAction | KillTaskAction | Task | AgentSwarm | Monitor => {
matches!(mode, M::Execute)
}
// Integration dispatch.
UseTool => matches!(mode, M::ReadWrite | M::Execute),
// Catch-all -- only `All` mode keeps it (early-return above).
Other => false,
}
}
// Tests
#[cfg(test)]
mod tests {
use super::*;
use crate::session::tool_config::test_support;
use kigi_tools::types::tool::ToolKind;
fn make_cfg(tools: Vec<ToolConfig>) -> ToolServerConfig {
ToolServerConfig {
tools,
behavior_preset: None,
}
}
#[test]
fn capability_mode_filter_table_is_exhaustive_per_kind() {
for &mode in &[
CapabilityMode::ReadOnly,
CapabilityMode::ReadWrite,
CapabilityMode::Execute,
CapabilityMode::All,
] {
for &kind in ALL_TOOL_KINDS {
let id = format!("kind_{kind:?}");
let cfg = make_cfg(vec![test_support::tc(&id, Some(kind))]);
let out = mode.filter(&cfg);
let expected_present = kind_allowed(mode, kind);
let actually_present = out.tools.iter().any(|t| t.id == id);
assert_eq!(
actually_present, expected_present,
"({mode:?}, {kind:?}): expected present={expected_present}, got {actually_present}"
);
}
}
}
#[test]
fn capability_mode_filter_anchored_membership() {
let cfg = make_cfg(vec![
test_support::tc("read", Some(ToolKind::Read)),
test_support::tc("search", Some(ToolKind::Search)),
test_support::tc("inspect", Some(ToolKind::Lsp)),
test_support::tc("edit", Some(ToolKind::Edit)),
test_support::tc("write", Some(ToolKind::Write)),
test_support::tc("bash", Some(ToolKind::Execute)),
test_support::tc("bg", Some(ToolKind::BackgroundTaskAction)),
test_support::tc("plan", Some(ToolKind::Plan)),
test_support::tc("ask", Some(ToolKind::AskUser)),
test_support::tc("other", Some(ToolKind::Other)),
]);
let names = |c: &ToolServerConfig| -> Vec<String> {
c.tools.iter().map(|t| t.id.clone()).collect()
};
let ro = CapabilityMode::ReadOnly.filter(&cfg);
assert_eq!(names(&ro), vec!["read", "search", "inspect", "plan", "ask"]);
let rw = CapabilityMode::ReadWrite.filter(&cfg);
assert_eq!(
names(&rw),
vec!["read", "search", "inspect", "edit", "write", "plan", "ask"]
);
let ex = CapabilityMode::Execute.filter(&cfg);
assert_eq!(
names(&ex),
vec!["read", "search", "inspect", "bash", "bg", "plan", "ask"]
);
let all = CapabilityMode::All.filter(&cfg);
assert_eq!(
names(&all),
vec![
"read", "search", "inspect", "edit", "write", "bash", "bg", "plan", "ask", "other"
]
);
}
#[test]
fn capability_mode_baseline_kind_none_always_kept_via_filter() {
let cfg = make_cfg(vec![
test_support::tc("baseline.opaque", None),
test_support::tc("baseline.also_opaque", None),
test_support::tc("edit_dropped", Some(ToolKind::Edit)),
]);
for mode in [
CapabilityMode::ReadOnly,
CapabilityMode::ReadWrite,
CapabilityMode::Execute,
CapabilityMode::All,
] {
let filtered = mode.filter(&cfg);
let ids: Vec<&str> = filtered.tools.iter().map(|t| t.id.as_str()).collect();
assert!(
ids.contains(&"baseline.opaque"),
"kind: None tool dropped under {mode:?}: {ids:?}"
);
assert!(
ids.contains(&"baseline.also_opaque"),
"kind: None tool dropped under {mode:?}: {ids:?}"
);
}
}
#[test]
fn capability_mode_preserves_behavior_preset_across_all_modes() {
let mut cfg = make_cfg(vec![test_support::tc("read", Some(ToolKind::Read))]);
cfg.behavior_preset = Some("current".to_owned());
for mode in [
CapabilityMode::ReadOnly,
CapabilityMode::ReadWrite,
CapabilityMode::Execute,
CapabilityMode::All,
] {
let out = mode.filter(&cfg);
assert_eq!(
out.behavior_preset.as_deref(),
Some("current"),
"behavior_preset lost under {mode:?}"
);
}
}
// is_subset_of partial order
#[test]
fn capability_mode_is_subset_of_reflexive() {
for &m in &[
CapabilityMode::ReadOnly,
CapabilityMode::ReadWrite,
CapabilityMode::Execute,
CapabilityMode::All,
] {
assert!(m.is_subset_of(m), "{m:?} must be a subset of itself");
}
}
#[test]
fn capability_mode_is_subset_of_strict_chains() {
assert!(CapabilityMode::ReadOnly.is_subset_of(CapabilityMode::ReadWrite));
assert!(CapabilityMode::ReadOnly.is_subset_of(CapabilityMode::All));
assert!(CapabilityMode::ReadWrite.is_subset_of(CapabilityMode::All));
assert!(CapabilityMode::ReadOnly.is_subset_of(CapabilityMode::Execute));
assert!(CapabilityMode::Execute.is_subset_of(CapabilityMode::All));
}
#[test]
fn capability_mode_is_subset_of_widening_rejected() {
assert!(!CapabilityMode::All.is_subset_of(CapabilityMode::ReadOnly));
assert!(!CapabilityMode::ReadWrite.is_subset_of(CapabilityMode::ReadOnly));
assert!(!CapabilityMode::Execute.is_subset_of(CapabilityMode::ReadOnly));
assert!(!CapabilityMode::All.is_subset_of(CapabilityMode::ReadWrite));
assert!(!CapabilityMode::All.is_subset_of(CapabilityMode::Execute));
}
#[test]
fn capability_mode_is_subset_of_incomparable_pairs() {
assert!(!CapabilityMode::ReadWrite.is_subset_of(CapabilityMode::Execute));
assert!(!CapabilityMode::Execute.is_subset_of(CapabilityMode::ReadWrite));
}
}