Skip to main content

lean_ctx/core/context_kernel/
tool_surface.rs

1//! Budget-aware reduction of tool schemas exposed to clients.
2
3use std::cmp::Reverse;
4
5use super::client_profile::ClientEfficiencyProfile;
6
7/// Schema metadata for one client-visible tool.
8#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
9pub struct ToolSchema {
10    /// Tool name exposed to the client.
11    pub name: String,
12    /// Human-readable explanation of the tool.
13    pub description: String,
14    /// JSON-encoded parameter schema.
15    pub parameters_json: String,
16    /// Estimated tokens consumed by the complete schema.
17    pub token_count: usize,
18    /// Selection priority, where larger values rank first.
19    pub priority: u8,
20    /// Stability tier used to filter the tool surface.
21    pub category: ToolCategory,
22}
23
24/// Stability tier for a client-visible tool.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
26pub enum ToolCategory {
27    /// Tool belongs to the essential supported surface.
28    #[default]
29    Core,
30    /// Tool belongs to the optional supported surface.
31    Extended,
32    /// Tool is available for evaluation.
33    Experimental,
34    /// Tool is retained only for compatibility.
35    Deprecated,
36}
37
38/// Metrics and tool names produced by surface optimization.
39#[derive(Debug, Clone)]
40pub struct SurfaceReduction {
41    /// Number of schemas before optimization.
42    pub original_count: usize,
43    /// Number of selected schemas.
44    pub reduced_count: usize,
45    /// Combined schema tokens before optimization.
46    pub original_tokens: usize,
47    /// Combined tokens for selected schemas.
48    pub reduced_tokens: usize,
49    /// Tokens removed from the client-visible surface.
50    pub tokens_saved: usize,
51    /// Percentage of original schema tokens removed.
52    pub savings_pct: f64,
53    /// Selected tool names in selection order.
54    pub selected_tools: Vec<String>,
55    /// Removed tool names in original order.
56    pub removed_tools: Vec<String>,
57}
58
59/// Applies count, token, and lifecycle limits to a tool surface.
60pub struct ToolSurfaceOptimizer {
61    max_tools: usize,
62    max_schema_tokens: usize,
63    exclude_deprecated: bool,
64}
65
66impl ToolSurfaceOptimizer {
67    /// Creates an optimizer that excludes deprecated tools by default.
68    #[must_use]
69    pub const fn new(max_tools: usize, max_schema_tokens: usize) -> Self {
70        Self {
71            max_tools,
72            max_schema_tokens,
73            exclude_deprecated: true,
74        }
75    }
76
77    /// Creates an optimizer using the tool budget advertised by a client profile.
78    #[must_use]
79    pub fn from_profile(profile: &ClientEfficiencyProfile) -> Self {
80        Self::new(
81            profile.tool_budget.max_tools,
82            profile.tool_budget.max_schema_tokens,
83        )
84    }
85
86    /// Configures whether deprecated tools are removed before selection.
87    #[must_use]
88    pub const fn exclude_deprecated(mut self, exclude: bool) -> Self {
89        self.exclude_deprecated = exclude;
90        self
91    }
92
93    /// Selects the highest-priority schemas that fit both configured budgets.
94    #[must_use]
95    pub fn optimize(&self, schemas: &[ToolSchema]) -> SurfaceReduction {
96        let original_tokens: usize = schemas.iter().map(|schema| schema.token_count).sum();
97        let mut ranked = schemas
98            .iter()
99            .enumerate()
100            .filter(|(_, schema)| {
101                !self.exclude_deprecated || schema.category != ToolCategory::Deprecated
102            })
103            .collect::<Vec<_>>();
104        ranked.sort_unstable_by_key(|(_, schema)| (Reverse(schema.priority), schema.token_count));
105
106        let mut selected = vec![false; schemas.len()];
107        let mut selected_tools = Vec::with_capacity(self.max_tools.min(ranked.len()));
108        let mut reduced_tokens = 0usize;
109        for (index, schema) in ranked {
110            if selected_tools.len() == self.max_tools {
111                break;
112            }
113            if reduced_tokens.saturating_add(schema.token_count) <= self.max_schema_tokens {
114                reduced_tokens += schema.token_count;
115                selected[index] = true;
116                selected_tools.push(schema.name.clone());
117            }
118        }
119
120        let removed_tools = schemas
121            .iter()
122            .enumerate()
123            .filter(|(index, _)| !selected[*index])
124            .map(|(_, schema)| schema.name.clone())
125            .collect();
126        let tokens_saved = original_tokens.saturating_sub(reduced_tokens);
127        let savings_pct = if original_tokens == 0 {
128            0.0
129        } else {
130            tokens_saved as f64 * 100.0 / original_tokens as f64
131        };
132
133        SurfaceReduction {
134            original_count: schemas.len(),
135            reduced_count: selected_tools.len(),
136            original_tokens,
137            reduced_tokens,
138            tokens_saved,
139            savings_pct,
140            selected_tools,
141            removed_tools,
142        }
143    }
144}
145
146/// Returns a compact copy of a tool schema and recalculates its token estimate.
147#[must_use]
148pub fn compress_schema(schema: &ToolSchema) -> ToolSchema {
149    let description = schema.description.chars().take(100).collect::<String>();
150    let parameters_json = strip_json_whitespace(&schema.parameters_json);
151    let token_count = (schema.name.len() + description.len() + parameters_json.len()).div_ceil(4);
152    ToolSchema {
153        name: schema.name.clone(),
154        description,
155        parameters_json,
156        token_count,
157        priority: schema.priority,
158        category: schema.category,
159    }
160}
161
162/// Formats headline surface-reduction metrics for logs and diagnostics.
163#[must_use]
164pub fn format_reduction_summary(reduction: &SurfaceReduction) -> String {
165    format!(
166        "Reduced {}→{} tools, saved {} tokens ({:.1}%)",
167        reduction.original_count,
168        reduction.reduced_count,
169        reduction.tokens_saved,
170        reduction.savings_pct
171    )
172}
173
174fn strip_json_whitespace(json: &str) -> String {
175    let mut compact = String::with_capacity(json.len());
176    let mut in_string = false;
177    let mut escaped = false;
178    for character in json.chars() {
179        if in_string {
180            compact.push(character);
181            if escaped {
182                escaped = false;
183            } else if character == '\\' {
184                escaped = true;
185            } else if character == '"' {
186                in_string = false;
187            }
188        } else if character == '"' {
189            in_string = true;
190            compact.push(character);
191        } else if !character.is_whitespace() {
192            compact.push(character);
193        }
194    }
195    compact
196}
197
198/// Bridge: optimize MCP tool schemas for the current request profile.
199///
200/// Called by the MCP server to reduce tool schema tokens based on
201/// the client's efficiency profile and broker decisions.
202#[must_use]
203pub fn optimize_for_request(
204    headers: &[(String, String)],
205    schemas: &[ToolSchema],
206) -> SurfaceReduction {
207    let profile = super::client_profile::detect_from_headers(headers);
208    let optimizer = ToolSurfaceOptimizer::from_profile(&profile);
209    optimizer.optimize(schemas)
210}
211
212/// Returns the token savings from tool surface optimization.
213#[must_use]
214pub const fn tool_savings_tokens(reduction: &SurfaceReduction) -> usize {
215    reduction.tokens_saved
216}
217
218/// Returns true if tool surface optimization would save significant tokens.
219#[must_use]
220pub fn should_optimize_tools(headers: &[(String, String)], tool_count: usize) -> bool {
221    let profile = super::client_profile::detect_from_headers(headers);
222    tool_count > profile.tool_budget.max_tools
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use crate::core::context_kernel::client_profile::{ProfileBuilder, ToolBudget};
229
230    fn schema(name: &str, priority: u8, tokens: usize, category: ToolCategory) -> ToolSchema {
231        ToolSchema {
232            name: name.to_owned(),
233            description: "description".to_owned(),
234            parameters_json: r#"{ "type": "object" }"#.to_owned(),
235            token_count: tokens,
236            priority,
237            category,
238        }
239    }
240
241    #[test]
242    fn optimize_within_budget() {
243        let schemas = (0..10)
244            .map(|index| schema(&format!("tool-{index}"), index, 10, ToolCategory::Core))
245            .collect::<Vec<_>>();
246        assert_eq!(
247            ToolSurfaceOptimizer::new(5, 100)
248                .optimize(&schemas)
249                .reduced_count,
250            5
251        );
252    }
253
254    #[test]
255    fn optimize_by_priority() {
256        let schemas = vec![
257            schema("low", 1, 10, ToolCategory::Core),
258            schema("high", 9, 10, ToolCategory::Core),
259        ];
260        assert_eq!(
261            ToolSurfaceOptimizer::new(1, 10)
262                .optimize(&schemas)
263                .selected_tools,
264            ["high"]
265        );
266    }
267
268    #[test]
269    fn optimize_excludes_deprecated() {
270        let schemas = vec![schema("old", 10, 1, ToolCategory::Deprecated)];
271        let reduction = ToolSurfaceOptimizer::new(1, 10).optimize(&schemas);
272        assert!(reduction.selected_tools.is_empty());
273        assert_eq!(reduction.removed_tools, ["old"]);
274    }
275
276    #[test]
277    fn optimize_respects_token_budget() {
278        let schemas = vec![
279            schema("a", 3, 6, ToolCategory::Core),
280            schema("b", 2, 5, ToolCategory::Core),
281            schema("c", 1, 4, ToolCategory::Core),
282        ];
283        let reduction = ToolSurfaceOptimizer::new(3, 10).optimize(&schemas);
284        assert!(reduction.reduced_tokens <= 10);
285        assert_eq!(reduction.selected_tools, ["a", "c"]);
286    }
287
288    #[test]
289    fn compress_strips_whitespace() {
290        let compact = compress_schema(&schema("a", 1, 10, ToolCategory::Core));
291        assert_eq!(compact.parameters_json, r#"{"type":"object"}"#);
292    }
293
294    #[test]
295    fn compress_truncates_description() {
296        let mut input = schema("a", 1, 100, ToolCategory::Core);
297        input.description = "é".repeat(101);
298        assert_eq!(compress_schema(&input).description.chars().count(), 100);
299    }
300
301    #[test]
302    fn from_profile_uses_budget() {
303        let profile = ProfileBuilder::new("client")
304            .tool_budget(ToolBudget {
305                max_tools: 1,
306                max_schema_tokens: 5,
307            })
308            .build();
309        let optimizer = ToolSurfaceOptimizer::from_profile(&profile);
310        assert_eq!(optimizer.max_tools, 1);
311        assert_eq!(optimizer.max_schema_tokens, 5);
312    }
313
314    #[test]
315    fn savings_pct_correct() {
316        let schemas = vec![
317            schema("a", 2, 25, ToolCategory::Core),
318            schema("b", 1, 75, ToolCategory::Core),
319        ];
320        let reduction = ToolSurfaceOptimizer::new(1, 100).optimize(&schemas);
321        assert_eq!(reduction.tokens_saved, 75);
322        assert!((reduction.savings_pct - 75.0).abs() < f64::EPSILON);
323    }
324
325    #[test]
326    fn optimize_for_request_reduces() {
327        let schemas = (0..20)
328            .map(|index| schema(&format!("tool-{index}"), 1, 1_000, ToolCategory::Core))
329            .collect::<Vec<_>>();
330        let reduction = optimize_for_request(&[], &schemas);
331        assert!(reduction.reduced_count < schemas.len());
332        assert!(tool_savings_tokens(&reduction) > 0);
333    }
334
335    #[test]
336    fn should_optimize_over_budget() {
337        assert!(should_optimize_tools(&[], 65));
338    }
339
340    #[test]
341    fn should_optimize_under_budget() {
342        assert!(!should_optimize_tools(&[], 3));
343    }
344}