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)
}
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(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),
}
impl Promotion {
pub(crate) fn message(self, name: &str) -> Option<String> {
match self {
Self::NotHidden => None,
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>,
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))
}
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();
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),
),
}
}
#[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 {
return ExposurePlan {
exposed: all_names,
hidden: Vec::new(),
};
};
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 }
}
#[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 {
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 (hidden, exposed): (Vec<Value>, Vec<Value>) = arr
.into_iter()
.partition(|def| entry_name(def).is_some_and(|name| hidden.contains(name)));
let hidden = HiddenTools {
state: std::sync::Mutex::new(HiddenState {
defs: hidden,
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,
};
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,
};
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,
};
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(),
)
.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,
};
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 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);
}
}