use std::collections::BTreeSet;
use serde_json::Value;
use crate::config::ExposureProfile;
use crate::tokens::TokenEstimation;
use super::catalog::BASE_TOOL_NAMES;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ExposureClass {
Kernel,
ByIntent,
RecoveryOnly,
OnDemand,
}
const EXPOSURE_CLASSES: &[(&str, ExposureClass)] = &[
("tool_search", 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),
("memory_fetch", 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)
}
const FILL_ORDER: &[ExposureClass] = &[
ExposureClass::ByIntent,
ExposureClass::RecoveryOnly,
ExposureClass::OnDemand,
];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ExposureSettings {
pub profile: ExposureProfile,
pub schema_budget_pct: u16,
pub max_initial_tools: usize,
}
impl Default for ExposureSettings {
fn default() -> Self {
Self {
profile: ExposureProfile::Full,
schema_budget_pct: 15,
max_initial_tools: 0,
}
}
}
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,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ExposurePlan {
pub exposed: Vec<String>,
pub hidden: Vec<String>,
pub exposed_tokens: usize,
pub budget_tokens: Option<usize>,
}
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),
),
}
}
#[must_use]
pub fn plan_exposure(
defs: &Value,
settings: &ExposureSettings,
live_budget_tokens: Option<usize>,
active: &BTreeSet<String>,
est: TokenEstimation,
) -> ExposurePlan {
let entries = defs.as_array().map(Vec::as_slice).unwrap_or(&[]);
let all_names: Vec<String> = entries
.iter()
.filter_map(|d| entry_name(d).map(str::to_owned))
.collect();
let Some(budget) = budget_tokens(settings, live_budget_tokens) else {
let exposed_tokens = crate::agentic::trim::estimate_value_tokens(defs, est);
return ExposurePlan {
exposed: all_names,
hidden: Vec::new(),
exposed_tokens,
budget_tokens: None,
};
};
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 mut exposed = Vec::new();
let mut hidden = Vec::new();
for name in all_names {
if kept.contains(&name) {
exposed.push(name);
} else {
hidden.push(name);
}
}
ExposurePlan {
exposed,
hidden,
exposed_tokens: kept_tokens,
budget_tokens: Some(budget),
}
}
#[must_use]
pub fn select_exposed(
defs: Value,
settings: &ExposureSettings,
live_budget_tokens: Option<usize>,
active: &BTreeSet<String>,
est: TokenEstimation,
) -> Value {
if settings.profile == ExposureProfile::Full {
return defs;
}
let Value::Array(arr) = defs else {
return defs;
};
let plan = plan_exposure(
&Value::Array(arr.clone()),
settings,
live_budget_tokens,
active,
est,
);
let keep: BTreeSet<&str> = plan.exposed.iter().map(String::as_str).collect();
Value::Array(
arr.into_iter()
.filter(|def| match entry_name(def) {
Some(name) => keep.contains(name),
None => true,
})
.collect(),
)
}
#[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(),
);
assert_eq!(out, defs, "Full must be bit-for-bit identity");
}
#[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());
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,
};
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 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,
};
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,
};
let out = select_exposed(
defs.clone(),
&settings,
Some(100_000),
&BTreeSet::new(),
est(),
);
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,
true,
true,
true,
true,
true,
true,
true,
true,
true,
);
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,
};
let plan = plan_exposure(&full, &settings, Some(4_000), &BTreeSet::new(), est());
assert!(
plan.exposed_tokens < full_tokens,
"exposed schema ({}) must cost less than the full catalog ({full_tokens})",
plan.exposed_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 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,
};
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);
}
}