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.
326 lines
11 KiB
Rust
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));
|
|
}
|
|
}
|