lean_ctx/core/context_kernel/
tool_surface.rs1use std::cmp::Reverse;
4
5use super::client_profile::ClientEfficiencyProfile;
6
7#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
9pub struct ToolSchema {
10 pub name: String,
12 pub description: String,
14 pub parameters_json: String,
16 pub token_count: usize,
18 pub priority: u8,
20 pub category: ToolCategory,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
26pub enum ToolCategory {
27 #[default]
29 Core,
30 Extended,
32 Experimental,
34 Deprecated,
36}
37
38#[derive(Debug, Clone)]
40pub struct SurfaceReduction {
41 pub original_count: usize,
43 pub reduced_count: usize,
45 pub original_tokens: usize,
47 pub reduced_tokens: usize,
49 pub tokens_saved: usize,
51 pub savings_pct: f64,
53 pub selected_tools: Vec<String>,
55 pub removed_tools: Vec<String>,
57}
58
59pub struct ToolSurfaceOptimizer {
61 max_tools: usize,
62 max_schema_tokens: usize,
63 exclude_deprecated: bool,
64}
65
66impl ToolSurfaceOptimizer {
67 #[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 #[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 #[must_use]
88 pub const fn exclude_deprecated(mut self, exclude: bool) -> Self {
89 self.exclude_deprecated = exclude;
90 self
91 }
92
93 #[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#[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#[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#[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#[must_use]
214pub const fn tool_savings_tokens(reduction: &SurfaceReduction) -> usize {
215 reduction.tokens_saved
216}
217
218#[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}