use std::collections::BTreeSet;
use serde_json::Value;
use crate::config::ExposureProfile;
use crate::tokens::TokenEstimation;
use super::catalog::BASE_TOOL_NAMES;
pub(crate) const OPENAI_COMPATIBLE_MAX_FUNCTION_TOOLS: usize = 128;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ExposureClass {
Kernel,
ByIntent,
RecoveryOnly,
OnDemand,
}
const EXPOSURE_CLASSES: &[(&str, ExposureClass)] = &[
("tool_search", ExposureClass::Kernel),
("memory_fetch", ExposureClass::Kernel),
("resume_context", ExposureClass::RecoveryOnly),
("prompt_read", ExposureClass::RecoveryOnly),
("artifact_read", ExposureClass::RecoveryOnly),
("get_context_remaining", ExposureClass::RecoveryOnly),
("request_user_input", ExposureClass::RecoveryOnly),
("render_report", ExposureClass::RecoveryOnly),
("lifecycle", ExposureClass::ByIntent),
("save_note", ExposureClass::ByIntent),
("recall", ExposureClass::ByIntent),
("git", ExposureClass::ByIntent),
("compose_roster", ExposureClass::ByIntent),
("crew", ExposureClass::ByIntent),
("state_set", ExposureClass::ByIntent),
("state_get", ExposureClass::ByIntent),
("state_clear", ExposureClass::ByIntent),
("code_search", ExposureClass::ByIntent),
("where_is", ExposureClass::ByIntent),
("goto_definition", ExposureClass::ByIntent),
("text_search", ExposureClass::ByIntent),
("find_references", ExposureClass::ByIntent),
("find_tests", ExposureClass::ByIntent),
("find_callers", ExposureClass::ByIntent),
("find_callees", ExposureClass::ByIntent),
("find_implementations", ExposureClass::ByIntent),
("find_hierarchy", ExposureClass::ByIntent),
("inspect_type", ExposureClass::ByIntent),
("impact", ExposureClass::ByIntent),
("experience_record", ExposureClass::ByIntent),
("experience_recall", ExposureClass::ByIntent),
("update_plan", ExposureClass::ByIntent),
("plan_get", ExposureClass::ByIntent),
("enter_plan_mode", ExposureClass::ByIntent),
("exit_plan_mode", ExposureClass::ByIntent),
("select_operating_mode", ExposureClass::ByIntent),
];
#[must_use]
pub fn classify(name: &str) -> ExposureClass {
if BASE_TOOL_NAMES.contains(&name) {
return ExposureClass::Kernel;
}
EXPOSURE_CLASSES
.iter()
.find(|(n, _)| *n == name)
.map(|(_, c)| *c)
.unwrap_or(ExposureClass::OnDemand)
}
pub(crate) const WRITE_TOOL_NAMES: &[&str] = &["write_file", "edit_file", "delete_file"];
const FILL_ORDER: &[ExposureClass] = &[
ExposureClass::ByIntent,
ExposureClass::RecoveryOnly,
ExposureClass::OnDemand,
];
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ExposureSettings {
pub profile: ExposureProfile,
pub schema_budget_pct: u16,
pub max_initial_tools: usize,
pub allow_list: Option<Vec<String>>,
pub read_only: bool,
pub verbatim_system: bool,
}
impl Default for ExposureSettings {
fn default() -> Self {
Self {
profile: ExposureProfile::Full,
schema_budget_pct: 15,
max_initial_tools: 0,
allow_list: None,
read_only: false,
verbatim_system: false,
}
}
}
impl From<crate::config::ToolExposureConfig> for ExposureSettings {
fn from(c: crate::config::ToolExposureConfig) -> Self {
Self {
profile: c.profile,
schema_budget_pct: c.schema_budget_pct,
max_initial_tools: c.max_initial_tools,
allow_list: c.allow_list,
read_only: c.read_only,
verbatim_system: false,
}
}
}
impl ExposureSettings {
#[must_use]
pub fn operator_visible(&self, name: &str) -> bool {
let listed = self
.allow_list
.as_ref()
.is_none_or(|list| list.iter().any(|allowed| allowed == name));
listed && !(self.read_only && WRITE_TOOL_NAMES.contains(&name))
}
fn has_operator_filter(&self) -> bool {
self.allow_list.is_some() || self.read_only
}
#[must_use]
pub fn attenuate(&self, caveats: crate::caveats::Caveats) -> crate::caveats::Caveats {
if self.read_only {
caveats.meet(&super::read_only_clamp())
} else {
caveats
}
}
#[must_use]
pub fn render_identity<'a>(&self, identity: &'a str) -> std::borrow::Cow<'a, str> {
if !self.has_operator_filter() {
return std::borrow::Cow::Borrowed(identity);
}
let hidden_names = |text: &str| {
super::catalog::ALL_TOOL_NAMES
.iter()
.any(|name| !self.operator_visible(name) && mentions_tool(text, name))
};
let paragraphs: Vec<String> = identity
.split("\n\n")
.filter_map(|paragraph| {
let rewritten = match paragraph.find(TOOL_LINE_PREFIX) {
Some(at) => {
let (head, rest) = paragraph.split_at(at + TOOL_LINE_PREFIX.len());
let (names, tail) = rest.split_once('.').unwrap_or((rest, ""));
let kept: Vec<&str> = names
.split(',')
.map(str::trim)
.filter(|name| !name.is_empty() && self.operator_visible(name))
.collect();
format!("{head}{}.{tail}", kept.join(", "))
}
None => paragraph.to_owned(),
};
(!hidden_names(&rewritten)).then_some(rewritten)
})
.collect();
std::borrow::Cow::Owned(paragraphs.join("\n\n"))
}
}
const TOOL_LINE_PREFIX: &str = "You have tools: ";
fn mentions_tool(text: &str, tool: &str) -> bool {
let is_word = |c: char| c.is_alphanumeric() || c == '_';
text.match_indices(tool).any(|(at, _)| {
let before = text[..at].chars().next_back().is_none_or(|c| !is_word(c));
let after = text[at + tool.len()..]
.chars()
.next()
.is_none_or(|c| !is_word(c));
before && after
})
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ExposurePlan {
pub exposed: Vec<String>,
pub hidden: Vec<String>,
pub operator_hidden: Vec<String>,
}
pub(crate) const HIDDEN_SEARCH_MARK: &str =
"[schema not loaded: use tool_search with this exact name to load it]";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Promotion {
NotHidden,
Promoted,
AtCap(usize),
OperatorHidden,
}
impl Promotion {
pub(crate) fn message(self, name: &str) -> Option<String> {
match self {
Self::NotHidden => None,
Self::OperatorHidden => Some(format!(
"Tool `{name}` is not available in this session: the operator's tool policy \
excludes it, and it cannot be loaded. Use the tools you were given."
)),
Self::Promoted => Some(format!(
"Tool `{name}` did not run (schema not loaded). Its schema is loaded for your \
next request; call `{name}` with arguments that match it."
)),
Self::AtCap(cap) => Some(format!(
"Tool `{name}` did not run and could not be loaded: the tool list is at its limit \
of {cap} function tools, with no replaceable slot. Kernel tools and activations \
already queued for the next request are protected."
)),
}
}
}
#[derive(Debug, Default)]
pub(crate) struct HiddenTools {
state: std::sync::Mutex<HiddenState>,
}
#[derive(Debug, Default)]
struct HiddenState {
defs: Vec<Value>,
policy: ExposureSettings,
promoted: Vec<String>,
cap: Option<(usize, Vec<Value>)>,
}
impl HiddenTools {
fn state(&self) -> std::sync::MutexGuard<'_, HiddenState> {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
pub(crate) fn is_hidden(&self, name: &str) -> bool {
self.state()
.defs
.iter()
.any(|def| entry_name(def) == Some(name))
}
pub(crate) fn is_operator_hidden(&self, name: &str) -> bool {
!self.state().policy.operator_visible(name)
}
fn limit_to(&self, cap: usize, sent: &[Value]) {
self.state().cap = Some((cap, sent.to_vec()));
}
pub(crate) fn promote(&self, name: &str) -> Promotion {
let mut state = self.state();
if !state.policy.operator_visible(name) {
return Promotion::OperatorHidden;
}
let Some(requested) = state
.defs
.iter()
.find(|def| entry_name(def) == Some(name))
.cloned()
else {
return Promotion::NotHidden;
};
if state.promoted.iter().any(|promoted| promoted == name) {
return Promotion::Promoted;
}
let HiddenState {
defs,
promoted,
cap,
..
} = &mut *state;
if let Some((cap, sent)) = cap {
if sent.len() >= *cap {
let Some(at) = sent.iter().position(|def| {
entry_name(def).is_some_and(|name| {
classify(name) != ExposureClass::Kernel
&& !promoted.iter().any(|queued| queued == name)
})
}) else {
return Promotion::AtCap(*cap);
};
defs.push(sent.remove(at));
}
sent.push(requested);
}
promoted.push(name.to_owned());
Promotion::Promoted
}
pub(crate) fn append_promoted(&self, tools: &mut Value) -> bool {
let mut state = self.state();
if state.promoted.is_empty() {
return false;
}
let Value::Array(sent) = tools else {
return false;
};
if let Some((_, next)) = &state.cap {
sent.retain(|def| {
let name = entry_name(def);
next.iter().any(|kept| entry_name(kept) == name)
&& !state
.promoted
.iter()
.any(|queued| Some(queued.as_str()) == name)
});
}
for name in std::mem::take(&mut state.promoted) {
if let Some(at) = state
.defs
.iter()
.position(|def| entry_name(def) == Some(&name))
{
sent.push(state.defs.remove(at));
}
}
true
}
}
fn entry_name(def: &Value) -> Option<&str> {
def.get("function")
.and_then(|f| f.get("name"))
.and_then(|n| n.as_str())
}
fn budget_tokens(settings: &ExposureSettings, live_budget_tokens: Option<usize>) -> Option<usize> {
match settings.profile {
ExposureProfile::Full => None,
ExposureProfile::Auto => {
live_budget_tokens.map(|b| b.saturating_mul(settings.schema_budget_pct as usize) / 100)
}
ExposureProfile::Minimal => Some(
live_budget_tokens
.map(|b| b.saturating_mul(settings.schema_budget_pct as usize) / 100)
.unwrap_or(0),
),
}
}
fn apply_operator_filters(all_names: Vec<String>, settings: &ExposureSettings) -> ExposurePlan {
if !settings.has_operator_filter() {
return ExposurePlan {
exposed: all_names,
hidden: Vec::new(),
operator_hidden: Vec::new(),
};
}
let (exposed, operator_hidden): (Vec<String>, Vec<String>) = all_names
.into_iter()
.partition(|name| settings.operator_visible(name));
ExposurePlan {
exposed,
hidden: Vec::new(),
operator_hidden,
}
}
#[must_use]
pub fn plan_exposure(
defs: &Value,
settings: &ExposureSettings,
live_budget_tokens: Option<usize>,
active: &BTreeSet<String>,
est: TokenEstimation,
) -> ExposurePlan {
let all_names: Vec<String> = defs
.as_array()
.into_iter()
.flatten()
.filter_map(|d| entry_name(d).map(str::to_owned))
.collect();
let mut plan = apply_operator_filters(all_names, settings);
let Some(budget) = budget_tokens(settings, live_budget_tokens) else {
return plan;
};
let entries: Vec<&Value> = defs
.as_array()
.into_iter()
.flatten()
.filter(|def| entry_name(def).is_none_or(|name| settings.operator_visible(name)))
.collect();
let mut kept: BTreeSet<String> = BTreeSet::new();
let mut kept_tokens = 0usize;
for def in &entries {
let Some(name) = entry_name(def) else {
continue;
};
let mandatory = classify(name) == ExposureClass::Kernel || active.contains(name);
if mandatory && kept.insert(name.to_owned()) {
kept_tokens += crate::agentic::trim::estimate_value_tokens(def, est);
}
}
let cap = settings.max_initial_tools;
for band in FILL_ORDER {
for def in &entries {
let Some(name) = entry_name(def) else {
continue;
};
if kept.contains(name) || classify(name) != *band {
continue;
}
if cap != 0 && kept.len() >= cap {
break;
}
let cost = crate::agentic::trim::estimate_value_tokens(def, est);
if kept_tokens.saturating_add(cost) > budget {
continue;
}
kept.insert(name.to_owned());
kept_tokens += cost;
}
}
let (exposed, hidden) = std::mem::take(&mut plan.exposed)
.into_iter()
.partition(|name| kept.contains(name.as_str()));
plan.exposed = exposed;
plan.hidden = hidden;
plan
}
#[must_use]
pub fn select_exposed(
defs: Value,
settings: &ExposureSettings,
live_budget_tokens: Option<usize>,
active: &BTreeSet<String>,
est: TokenEstimation,
) -> (Value, HiddenTools) {
if settings.profile == ExposureProfile::Full
&& settings.allow_list.is_none()
&& !settings.read_only
{
return (defs, HiddenTools::default());
}
let Value::Array(arr) = defs else {
return (defs, HiddenTools::default());
};
let plan = plan_exposure(
&Value::Array(arr.clone()),
settings,
live_budget_tokens,
active,
est,
);
let hidden: BTreeSet<&str> = plan.hidden.iter().map(String::as_str).collect();
let operator: BTreeSet<&str> = plan.operator_hidden.iter().map(String::as_str).collect();
let mut exposed = Vec::new();
let mut deferred = Vec::new();
for def in arr {
match entry_name(&def) {
Some(name) if operator.contains(name) => {}
Some(name) if hidden.contains(name) => deferred.push(def),
_ => exposed.push(def),
}
}
let hidden = HiddenTools {
state: std::sync::Mutex::new(HiddenState {
defs: deferred,
policy: settings.clone(),
promoted: Vec::new(),
cap: None,
}),
};
(Value::Array(exposed), hidden)
}
#[must_use]
pub(crate) fn select_openai_compatible_tools(defs: Value, hidden: &HiddenTools) -> Value {
let Value::Array(arr) = defs else {
return defs;
};
if arr.len() <= OPENAI_COMPATIBLE_MAX_FUNCTION_TOOLS {
hidden.limit_to(OPENAI_COMPATIBLE_MAX_FUNCTION_TOOLS, &arr);
return Value::Array(arr);
}
let kernel_count = arr
.iter()
.filter_map(entry_name)
.filter(|name| classify(name) == ExposureClass::Kernel)
.count();
debug_assert!(
kernel_count <= OPENAI_COMPATIBLE_MAX_FUNCTION_TOOLS,
"kernel tool catalog exceeds the OpenAI-compatible wire envelope"
);
let mut keep = vec![false; arr.len()];
let mut kept = 0usize;
for class in [
ExposureClass::Kernel,
ExposureClass::ByIntent,
ExposureClass::RecoveryOnly,
ExposureClass::OnDemand,
] {
for (index, def) in arr.iter().enumerate() {
if kept == OPENAI_COMPATIBLE_MAX_FUNCTION_TOOLS {
break;
}
if entry_name(def).is_some_and(|name| classify(name) == class) {
keep[index] = true;
kept += 1;
}
}
}
let mut sent = Vec::with_capacity(kept);
{
let mut state = hidden.state();
for (def, keep) in arr.into_iter().zip(keep) {
if keep {
sent.push(def);
} else {
state.defs.push(def);
}
}
}
hidden.limit_to(OPENAI_COMPATIBLE_MAX_FUNCTION_TOOLS, &sent);
Value::Array(sent)
}
#[cfg(test)]
mod tests {
use super::super::catalog::ALL_TOOL_NAMES;
use super::*;
use serde_json::json;
fn tool(name: &str, desc: &str) -> Value {
json!({
"type": "function",
"function": {
"name": name,
"description": desc,
"parameters": {"type": "object", "properties": {}}
}
})
}
fn est() -> TokenEstimation {
TokenEstimation::default()
}
#[test]
fn every_known_tool_is_classified() {
for name in ALL_TOOL_NAMES.iter() {
let _ = classify(name); }
for (name, _) in EXPOSURE_CLASSES {
assert!(
ALL_TOOL_NAMES.contains(name),
"EXPOSURE_CLASSES names `{name}` which is not a known tool"
);
}
for name in ALL_TOOL_NAMES.iter() {
if BASE_TOOL_NAMES.contains(name) {
continue;
}
assert!(
EXPOSURE_CLASSES.iter().any(|(n, _)| n == name),
"tool `{name}` has no explicit exposure class — add it to EXPOSURE_CLASSES"
);
}
}
#[test]
fn base_tools_and_unknown_classify_by_convention() {
assert_eq!(classify("read_file"), ExposureClass::Kernel);
assert_eq!(classify("run_command"), ExposureClass::Kernel);
assert_eq!(classify("tool_search"), ExposureClass::Kernel);
assert_eq!(classify("git"), ExposureClass::ByIntent);
assert_eq!(classify("resume_context"), ExposureClass::RecoveryOnly);
assert_eq!(classify("github__create_issue"), ExposureClass::OnDemand);
assert_eq!(classify("totally_made_up"), ExposureClass::OnDemand);
}
#[test]
fn full_profile_is_identity() {
let defs = json!([
tool("read_file", "x"),
tool("git", "y"),
tool("impact", "z")
]);
let out = select_exposed(
defs.clone(),
&ExposureSettings::default(),
Some(100),
&BTreeSet::new(),
est(),
)
.0;
assert_eq!(out, defs, "Full must be bit-for-bit identity");
}
#[test]
fn queued_activations_keep_kernel_and_can_reactivate_evicted_schemas() {
let hidden = HiddenTools {
state: std::sync::Mutex::new(HiddenState {
defs: vec![
tool("remote__a", "a"),
tool("remote__b", "b"),
tool("remote__c", "c"),
],
..Default::default()
}),
};
let mut sent = json!([
tool("read_file", "kernel"),
tool("git", "oldest optional"),
tool("tool_search", "kernel"),
tool("impact", "optional")
]);
hidden.limit_to(4, sent.as_array().unwrap());
assert_eq!(hidden.promote("unknown__tool"), Promotion::NotHidden);
assert_eq!(hidden.promote("remote__a"), Promotion::Promoted);
assert_eq!(hidden.promote("remote__a"), Promotion::Promoted);
assert_eq!(hidden.promote("remote__b"), Promotion::Promoted);
assert_eq!(hidden.promote("remote__c"), Promotion::AtCap(4));
assert_eq!(hidden.promote("git"), Promotion::AtCap(4));
assert!(hidden.append_promoted(&mut sent));
assert_eq!(
sent,
json!([
tool("read_file", "kernel"),
tool("tool_search", "kernel"),
tool("remote__a", "a"),
tool("remote__b", "b")
])
);
assert!(hidden.is_hidden("git"));
assert!(hidden.is_hidden("impact"));
assert!(!hidden.is_hidden("remote__a"));
assert!(!hidden.append_promoted(&mut sent));
assert_eq!(hidden.promote("git"), Promotion::Promoted);
assert_eq!(hidden.promote("remote__a"), Promotion::Promoted);
assert_eq!(hidden.promote("remote__b"), Promotion::AtCap(4));
assert!(hidden.append_promoted(&mut sent));
assert_eq!(
sent,
json!([
tool("read_file", "kernel"),
tool("tool_search", "kernel"),
tool("git", "oldest optional"),
tool("remote__a", "a")
])
);
assert!(hidden.is_hidden("remote__b"));
assert!(!hidden.is_hidden("git"));
assert_eq!(hidden.promote("git"), Promotion::NotHidden);
assert!(!hidden.append_promoted(&mut sent));
}
#[test]
fn openai_wire_cap_keeps_kernel_before_optional_mcp_tools() {
let mut defs = vec![tool("optional_server__tool_000", "remote")];
defs.extend(
(1..=OPENAI_COMPATIBLE_MAX_FUNCTION_TOOLS)
.map(|i| tool(&format!("optional_server__tool_{i:03}"), "remote")),
);
defs.push(tool("run_command", "kernel"));
defs.push(tool("tool_search", "kernel"));
let out = select_openai_compatible_tools(Value::Array(defs), &HiddenTools::default());
let names = out
.as_array()
.unwrap()
.iter()
.filter_map(entry_name)
.collect::<Vec<_>>();
assert_eq!(names.len(), OPENAI_COMPATIBLE_MAX_FUNCTION_TOOLS);
assert!(names.contains(&"run_command"));
assert!(names.contains(&"tool_search"));
}
#[test]
fn auto_without_live_budget_is_identity() {
let defs = json!([tool("read_file", "x"), tool("git", "y")]);
let settings = ExposureSettings {
profile: ExposureProfile::Auto,
..Default::default()
};
let out = select_exposed(defs.clone(), &settings, None, &BTreeSet::new(), est()).0;
assert_eq!(out, defs, "no live signal => no clipping");
}
#[test]
fn auto_keeps_kernel_even_when_budget_is_tiny() {
let defs = json!([
tool("read_file", "kernel"),
tool("tool_search", "kernel"),
tool("git", "byintent"),
tool("impact", "byintent"),
]);
let settings = ExposureSettings {
profile: ExposureProfile::Auto,
schema_budget_pct: 1,
max_initial_tools: 0,
..Default::default()
};
let plan = plan_exposure(&defs, &settings, Some(10), &BTreeSet::new(), est());
assert!(plan.exposed.contains(&"read_file".to_string()));
assert!(plan.exposed.contains(&"tool_search".to_string()));
assert!(plan.hidden.contains(&"git".to_string()));
assert!(plan.hidden.contains(&"impact".to_string()));
}
#[test]
fn memory_fetch_survives_minimal_and_budget_clipped_exposure_when_authorized() {
let defs = json!([
tool("read_file", "kernel"),
tool("memory_fetch", "recover offloaded results"),
tool("git", "optional"),
]);
for (profile, budget) in [
(ExposureProfile::Minimal, None),
(ExposureProfile::Auto, Some(10)),
] {
let settings = ExposureSettings {
profile,
schema_budget_pct: 1,
max_initial_tools: 1,
..Default::default()
};
let out = select_exposed(defs.clone(), &settings, budget, &BTreeSet::new(), est()).0;
assert_eq!(
out,
json!([defs[0], defs[1]]),
"recovery must survive both schema and count budgets"
);
let unavailable = json!([defs[0], defs[2]]);
assert_eq!(
select_exposed(unavailable, &settings, budget, &BTreeSet::new(), est()).0,
json!([defs[0]])
);
}
}
#[test]
fn active_tools_are_sticky_across_budget() {
let defs = json!([tool("read_file", "kernel"), tool("git", "byintent")]);
let settings = ExposureSettings {
profile: ExposureProfile::Minimal,
schema_budget_pct: 1,
max_initial_tools: 0,
..Default::default()
};
let mut active = BTreeSet::new();
active.insert("git".to_string());
let plan = plan_exposure(&defs, &settings, Some(0), &active, est());
assert!(
plan.exposed.contains(&"git".to_string()),
"a recently-used tool must not vanish on a budget shift"
);
}
#[test]
fn auto_fills_by_budget_and_preserves_order() {
let defs = json!([
tool("read_file", "k"),
tool("git", "b"),
tool("impact", "b"),
]);
let settings = ExposureSettings {
profile: ExposureProfile::Auto,
schema_budget_pct: 100,
max_initial_tools: 0,
..Default::default()
};
let out = select_exposed(
defs.clone(),
&settings,
Some(100_000),
&BTreeSet::new(),
est(),
)
.0;
assert_eq!(out, defs);
}
#[test]
fn reduces_a_realistic_catalog_to_a_pocket_multitool() {
let full = super::super::catalog::merged_tool_definitions(
&crate::agentic::NoMcp,
true,
true,
true,
Some(&crate::caveats::Scope::All),
true,
true,
true,
true,
true,
true,
true,
true,
Default::default(),
crate::agentic::shell_dialect_sentence(false, false),
);
let full_tokens = crate::agentic::trim::estimate_value_tokens(&full, est());
let settings = ExposureSettings {
profile: ExposureProfile::Auto,
schema_budget_pct: 15,
max_initial_tools: 0,
..Default::default()
};
let plan = plan_exposure(&full, &settings, Some(4_000), &BTreeSet::new(), est());
let (exposed, _) = select_exposed(
full.clone(),
&settings,
Some(4_000),
&BTreeSet::new(),
est(),
);
let exposed_tokens = crate::agentic::trim::estimate_value_tokens(&exposed, est());
assert!(
exposed_tokens < full_tokens,
"exposed schema ({exposed_tokens}) must cost less than the full catalog ({full_tokens})"
);
assert!(
plan.exposed.iter().any(|n| n == "tool_search"),
"discovery must survive so the model can find what was hidden"
);
assert!(
!plan.hidden.is_empty(),
"a tight budget over the full catalog must hide something"
);
for def in full.as_array().unwrap() {
if let Some(name) = entry_name(def) {
if classify(name) == ExposureClass::Kernel {
assert!(
plan.exposed.iter().any(|n| n == name),
"kernel tool `{name}` must never be evicted"
);
}
}
}
}
#[test]
fn an_operator_hidden_tool_is_refused_not_promoted() {
let defs = json!([
tool("read_file", "k"),
tool("write_file", "k"),
tool("save_note", "n"),
]);
let settings = ExposureSettings {
allow_list: Some(vec!["read_file".to_string()]),
..Default::default()
};
let (exposed, hidden) = select_exposed(defs, &settings, None, &BTreeSet::new(), est());
assert_eq!(exposed.as_array().unwrap().len(), 1);
for name in ["write_file", "save_note"] {
assert_eq!(hidden.promote(name), Promotion::OperatorHidden, "{name}");
let message = hidden.promote(name).message(name).unwrap();
assert!(message.contains("not available"), "{message}");
}
let mut tools = json!([tool("read_file", "k")]);
assert!(
!hidden.append_promoted(&mut tools),
"nothing was queued, so the next request's tool list is unchanged"
);
assert_eq!(tools.as_array().unwrap().len(), 1);
}
#[test]
fn budget_deferral_never_admits_an_operator_hidden_tool() {
let defs = json!([
tool("read_file", "k"),
tool("tool_search", "k"),
tool("save_note", "n"),
]);
let allow = Some(vec!["read_file".to_string(), "tool_search".to_string()]);
for profile in [ExposureProfile::Minimal, ExposureProfile::Auto] {
let settings = ExposureSettings {
profile,
schema_budget_pct: 0,
allow_list: allow.clone(),
..Default::default()
};
let plan = plan_exposure(&defs, &settings, Some(1_000), &BTreeSet::new(), est());
assert!(
!plan.hidden.iter().any(|name| name == "save_note"),
"{profile:?}: the deferred pool is a subset of the visible set: {:?}",
plan.hidden
);
assert_eq!(
plan.operator_hidden,
vec!["save_note".to_string()],
"{profile:?}"
);
let (_, hidden) = select_exposed(
defs.clone(),
&settings,
Some(1_000),
&BTreeSet::new(),
est(),
);
assert!(!hidden.is_hidden("save_note"), "{profile:?}");
assert!(hidden.is_operator_hidden("save_note"), "{profile:?}");
assert_eq!(
hidden.promote("save_note"),
Promotion::OperatorHidden,
"{profile:?}"
);
}
}
#[test]
fn operator_policy_covers_tools_removed_before_selection() {
let defs = json!([tool("read_file", "k"), tool("tool_search", "k")]);
let settings = ExposureSettings {
read_only: true,
..Default::default()
};
let (_, hidden) = select_exposed(defs, &settings, None, &BTreeSet::new(), est());
assert!(hidden.is_operator_hidden("write_file"));
assert!(!hidden.is_operator_hidden("read_file"));
assert!(
!HiddenTools::default().is_operator_hidden("write_file"),
"no policy, no ceiling"
);
}
#[test]
fn read_only_attenuates_fs_write_and_only_narrows() {
use crate::caveats::{Caveats, CaveatsExt, Scope};
let writable = Caveats::top();
let narrowed = ExposureSettings {
read_only: true,
..Default::default()
}
.attenuate(writable.clone());
assert!(!narrowed.permits_fs_write("/w/x"), "writes are denied");
assert!(narrowed.permits_fs_read("/w/x"), "reads are untouched");
assert!(narrowed.permits_exec("cargo"), "exec is untouched");
assert!(narrowed.leq(&writable), "only narrows");
assert_eq!(
ExposureSettings::default().attenuate(writable.clone()),
writable,
"read_only = false is the identity"
);
let already_fenced = Caveats {
exec: Scope::none(),
..Caveats::top()
};
let still_fenced = ExposureSettings {
read_only: true,
..Default::default()
}
.attenuate(already_fenced);
assert!(
!still_fenced.permits_exec("cargo"),
"enabling the toggle never widens another axis"
);
}
#[test]
fn identity_prompt_names_only_operator_visible_tools() {
let soul = crate::DEFAULT_SOUL;
assert_eq!(
ExposureSettings::default().render_identity(soul),
soul,
"no operator filter → identity unchanged"
);
let read_only = ExposureSettings {
read_only: true,
..Default::default()
};
let rendered = read_only.render_identity(soul);
for hidden in WRITE_TOOL_NAMES {
assert!(
!mentions_tool(&rendered, hidden),
"read_only prompt must not name `{hidden}`:\n{rendered}"
);
}
assert!(mentions_tool(&rendered, "read_file"));
assert!(rendered.starts_with("You are newt"));
let four = ExposureSettings {
allow_list: Some(
["read_file", "write_file", "edit_file", "run_command"]
.map(String::from)
.to_vec(),
),
..Default::default()
};
let rendered = four.render_identity(soul);
let line = rendered
.lines()
.find(|l| l.contains(TOOL_LINE_PREFIX))
.expect("tool line survives");
assert!(
line.contains("You have tools: run_command, read_file, write_file, edit_file."),
"{line}"
);
assert!(!mentions_tool(&rendered, "render_report"));
assert!(!mentions_tool(&rendered, "list_dir"));
}
#[test]
fn mentions_tool_matches_whole_words_only() {
assert!(mentions_tool("call edit_file now", "edit_file"));
assert!(!mentions_tool("call edit_files now", "edit_file"));
assert!(!mentions_tool("use_edit_file", "edit_file"));
}
#[test]
fn allow_list_hides_tools_not_in_list_even_in_full_profile() {
let defs = json!([
tool("read_file", "k"),
tool("write_file", "k"),
tool("run_command", "k"),
tool("tool_search", "k"),
tool("git", "b"),
]);
let settings = ExposureSettings {
profile: ExposureProfile::Full,
allow_list: Some(vec![
"read_file".to_string(),
"run_command".to_string(),
"write_file".to_string(),
"edit_file".to_string(),
]),
..Default::default()
};
let (exposed, hidden_tools) =
select_exposed(defs, &settings, None, &BTreeSet::new(), est());
let names: Vec<_> = exposed
.as_array()
.unwrap()
.iter()
.filter_map(entry_name)
.collect();
assert!(
names.contains(&"read_file"),
"allow-listed tool must appear"
);
assert!(
names.contains(&"run_command"),
"allow-listed tool must appear"
);
assert!(
names.contains(&"write_file"),
"allow-listed tool must appear"
);
assert!(
!names.contains(&"tool_search"),
"Kernel tool not in allow_list must be hidden"
);
assert!(
!names.contains(&"git"),
"ByIntent tool not in allow_list must be hidden"
);
assert!(
hidden_tools.is_operator_hidden("tool_search"),
"an operator-excluded Kernel tool is operator-hidden"
);
assert!(
hidden_tools.is_operator_hidden("git"),
"an operator-excluded ByIntent tool is operator-hidden"
);
assert!(
!hidden_tools.is_hidden("git") && !hidden_tools.is_hidden("tool_search"),
"operator-hidden tools are not in the discoverable (budget) pool"
);
}
#[test]
fn read_only_hides_write_tools_in_full_profile() {
let defs = json!([
tool("read_file", "k"),
tool("write_file", "k"),
tool("edit_file", "k"),
tool("delete_file", "k"),
tool("run_command", "k"),
tool("list_dir", "k"),
]);
let settings = ExposureSettings {
profile: ExposureProfile::Full,
read_only: true,
..Default::default()
};
let (exposed, hidden_tools) =
select_exposed(defs, &settings, None, &BTreeSet::new(), est());
let names: Vec<_> = exposed
.as_array()
.unwrap()
.iter()
.filter_map(entry_name)
.collect();
assert!(
names.contains(&"read_file"),
"read_file must remain visible"
);
assert!(
names.contains(&"run_command"),
"run_command must remain visible"
);
assert!(names.contains(&"list_dir"), "list_dir must remain visible");
assert!(
!names.contains(&"write_file"),
"write_file must be hidden in read_only"
);
assert!(
!names.contains(&"edit_file"),
"edit_file must be hidden in read_only"
);
assert!(
!names.contains(&"delete_file"),
"delete_file must be hidden in read_only"
);
assert!(hidden_tools.is_operator_hidden("write_file"));
assert!(hidden_tools.is_operator_hidden("edit_file"));
assert!(
!hidden_tools.is_hidden("write_file"),
"a read_only exclusion is not a budget deferral"
);
}
#[test]
fn max_initial_tools_caps_additional_fill() {
let defs = json!([
tool("read_file", "k"),
tool("git", "b"),
tool("impact", "b"),
tool("code_search", "b"),
]);
let settings = ExposureSettings {
profile: ExposureProfile::Auto,
schema_budget_pct: 100,
max_initial_tools: 2,
..Default::default()
};
let plan = plan_exposure(&defs, &settings, Some(100_000), &BTreeSet::new(), est());
assert!(plan.exposed.contains(&"read_file".to_string()));
assert!(plan.exposed.len() <= 2);
}
}