use std::collections::BTreeSet;
use super::config::AgentAutonomy;
use super::preset::ChildPreset;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChildToolCapability {
ReadOnly,
Write,
Preset(&'static str),
}
impl ChildToolCapability {
pub fn label(&self) -> String {
match self {
Self::ReadOnly => "read_only".to_string(),
Self::Write => "write".to_string(),
Self::Preset(name) => format!("preset:{name}"),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct CapabilityResolution {
pub excluded_tools: BTreeSet<String>,
pub tool_names: Option<BTreeSet<String>>,
pub role_prompt: Option<String>,
pub max_turns: Option<u32>,
pub degraded_reason: Option<String>,
}
pub fn resolve_capability(
requested: Option<&ChildToolCapability>,
autonomy: AgentAutonomy,
allow_child_write: bool,
child_excluded_tools: &[String],
write_tools: &[String],
) -> CapabilityResolution {
let base: BTreeSet<String> = if autonomy == AgentAutonomy::Manual {
child_excluded_tools
.iter()
.chain(write_tools)
.cloned()
.collect()
} else {
child_excluded_tools.iter().cloned().collect()
};
let Some(cap) = requested else {
return CapabilityResolution {
excluded_tools: base,
..Default::default()
};
};
let (tool_names, role_prompt, max_turns, preset_wants_write) = match cap {
ChildToolCapability::Preset(name) => match ChildPreset::by_name(name) {
Some(preset) => {
let wants = preset
.config
.tool_names
.as_ref()
.map(|set| set.iter().any(|t| write_tools.contains(t)))
.unwrap_or(false);
(
preset.config.tool_names.clone(),
preset.config.system_prompt.clone(),
preset.config.max_turns,
wants,
)
}
None => (None, None, None, false),
},
_ => (None, None, None, false),
};
let wants_write = match cap {
ChildToolCapability::Write => true,
ChildToolCapability::Preset(_) => preset_wants_write,
ChildToolCapability::ReadOnly => false,
};
let permitted = wants_write && autonomy == AgentAutonomy::Auto && allow_child_write;
let excluded_tools = if permitted {
base.into_iter()
.filter(|t| !write_tools.contains(t))
.collect()
} else {
let mut set = base;
for t in write_tools {
set.insert(t.clone());
}
set
};
let degraded_reason = if !wants_write {
None
} else if autonomy == AgentAutonomy::Manual {
Some("deployment autonomy is Manual; children are hard read-only".to_string())
} else if !allow_child_write {
Some("allow_child_write=false; request degraded to read-only".to_string())
} else {
None };
CapabilityResolution {
excluded_tools,
tool_names,
role_prompt,
max_turns,
degraded_reason,
}
}
pub fn read_only_nudge(
autonomy: AgentAutonomy,
child_read_only: bool,
excluded_tools: &BTreeSet<String>,
write_tools: &[String],
) -> bool {
autonomy == AgentAutonomy::Manual
|| child_read_only
|| write_tools.iter().all(|t| excluded_tools.contains(t))
}
#[cfg(test)]
mod tests {
use super::*;
fn excluded(items: &[&str]) -> Vec<String> {
items.iter().map(|s| s.to_string()).collect()
}
const WRITE_TOOLS: &[&str] = &["write_file", "edit_file", "execute_command"];
fn write_tools() -> Vec<String> {
excluded(WRITE_TOOLS)
}
#[test]
fn none_capability_keeps_legacy_base_set() {
let res = resolve_capability(None, AgentAutonomy::Auto, false, &[], &write_tools());
assert!(res.excluded_tools.is_empty());
assert!(res.tool_names.is_none());
assert!(res.degraded_reason.is_none());
}
#[test]
fn none_capability_manual_merges_write_tools() {
let res = resolve_capability(None, AgentAutonomy::Manual, false, &[], &write_tools());
for t in WRITE_TOOLS {
assert!(res.excluded_tools.contains(*t));
}
}
#[test]
fn read_only_forces_write_tools_into_exclusion() {
let res = resolve_capability(
Some(&ChildToolCapability::ReadOnly),
AgentAutonomy::Auto,
true,
&[],
&write_tools(),
);
for t in WRITE_TOOLS {
assert!(res.excluded_tools.contains(*t), "{t} must be excluded");
}
}
#[test]
fn write_permitted_exempt_write_tools() {
let table = excluded(&["write_file", "task_output"]);
let res = resolve_capability(
Some(&ChildToolCapability::Write),
AgentAutonomy::Auto,
true,
&table,
&write_tools(),
);
assert!(res.excluded_tools.contains("task_output"));
assert!(!res.excluded_tools.contains("write_file"));
assert!(!res.excluded_tools.contains("edit_file"));
assert!(!res.excluded_tools.contains("execute_command"));
assert!(res.degraded_reason.is_none());
assert!(res.tool_names.is_none(), "write 不产生白名单");
}
#[test]
fn write_denied_by_flag_degrades_with_reason() {
let res = resolve_capability(
Some(&ChildToolCapability::Write),
AgentAutonomy::Auto,
false,
&[],
&write_tools(),
);
for t in WRITE_TOOLS {
assert!(res.excluded_tools.contains(*t));
}
let why = res.degraded_reason.expect("must carry degraded reason");
assert!(
why.contains("allow_child_write"),
"reason should name the flag: {why}"
);
}
#[test]
fn write_denied_by_manual_autonomy() {
let res = resolve_capability(
Some(&ChildToolCapability::Write),
AgentAutonomy::Manual,
true,
&[],
&write_tools(),
);
for t in WRITE_TOOLS {
assert!(res.excluded_tools.contains(*t));
}
let why = res.degraded_reason.expect("Manual denial must explain");
assert!(why.contains("Manual"), "{why}");
}
#[test]
fn preset_with_write_tools_permitted_expands() {
let res = resolve_capability(
Some(&ChildToolCapability::Preset("coder")),
AgentAutonomy::Auto,
true,
&[],
&write_tools(),
);
let whitelist = res.tool_names.expect("coder preset carries whitelist");
assert!(whitelist.contains("write_file"));
assert!(whitelist.contains("read_file"));
assert!(!res.excluded_tools.contains("write_file"));
assert!(res.role_prompt.is_some());
assert_eq!(res.max_turns, Some(64));
assert!(res.degraded_reason.is_none());
}
#[test]
fn preset_with_write_tools_denied_degrades() {
let res = resolve_capability(
Some(&ChildToolCapability::Preset("tester")),
AgentAutonomy::Auto,
false,
&[],
&write_tools(),
);
assert!(res.tool_names.is_some(), "preset 角色保留");
assert!(res.degraded_reason.is_some());
for t in WRITE_TOOLS {
assert!(res.excluded_tools.contains(*t));
}
}
#[test]
fn read_only_preset_forces_read_only_exclusion() {
let res = resolve_capability(
Some(&ChildToolCapability::Preset("researcher")),
AgentAutonomy::Auto,
true,
&[],
&write_tools(),
);
for t in WRITE_TOOLS {
assert!(res.excluded_tools.contains(*t));
}
assert!(res.degraded_reason.is_none(), "只读请求无降级");
assert_eq!(res.max_turns, Some(32));
}
#[test]
fn labels_are_stable() {
assert_eq!(ChildToolCapability::ReadOnly.label(), "read_only");
assert_eq!(ChildToolCapability::Write.label(), "write");
assert_eq!(ChildToolCapability::Preset("coder").label(), "preset:coder");
}
#[test]
fn nudge_three_conditions() {
let read_only_excluded: BTreeSet<String> =
WRITE_TOOLS.iter().map(|s| s.to_string()).collect();
let write_exempted: BTreeSet<String> = ["task_output".to_string()].into_iter().collect();
assert!(super::read_only_nudge(
AgentAutonomy::Manual,
false,
&write_exempted,
&write_tools()
));
assert!(super::read_only_nudge(
AgentAutonomy::Auto,
true,
&write_exempted,
&write_tools()
));
assert!(super::read_only_nudge(
AgentAutonomy::Auto,
false,
&read_only_excluded,
&write_tools()
));
assert!(!super::read_only_nudge(
AgentAutonomy::Auto,
false,
&write_exempted,
&write_tools()
));
}
}