1use std::fmt::Write as _;
15
16use zeph_common::llm_response::extract_json_array_slice;
17use zeph_llm::LlmError;
18use zeph_llm::provider::{LlmProvider, Message, Role};
19
20use crate::tool::McpTool;
21
22#[derive(Debug, Clone)]
30enum CachedResult {
31 Ok(Vec<McpTool>),
32 Failed,
34}
35
36#[derive(Debug, Default, Clone)]
64pub struct PruningCache {
65 key: Option<(u64, u64)>,
66 result: Option<CachedResult>,
67}
68
69enum CacheLookup<'a> {
71 Hit(&'a [McpTool]),
73 NegativeHit,
75 Miss,
77}
78
79impl PruningCache {
80 #[must_use]
82 pub fn new() -> Self {
83 Self::default()
84 }
85
86 pub fn reset(&mut self) {
91 self.key = None;
92 self.result = None;
93 }
94
95 fn lookup(&self, msg_hash: u64, tool_hash: u64) -> CacheLookup<'_> {
96 match (&self.key, &self.result) {
97 (Some(k), Some(CachedResult::Ok(tools))) if *k == (msg_hash, tool_hash) => {
98 CacheLookup::Hit(tools)
99 }
100 (Some(k), Some(CachedResult::Failed)) if *k == (msg_hash, tool_hash) => {
101 CacheLookup::NegativeHit
102 }
103 _ => CacheLookup::Miss,
104 }
105 }
106
107 fn insert_ok(&mut self, msg_hash: u64, tool_hash: u64, tools: Vec<McpTool>) {
108 self.key = Some((msg_hash, tool_hash));
109 self.result = Some(CachedResult::Ok(tools));
110 }
111
112 fn insert_failed(&mut self, msg_hash: u64, tool_hash: u64) {
113 self.key = Some((msg_hash, tool_hash));
114 self.result = Some(CachedResult::Failed);
115 }
116}
117
118#[must_use]
124pub fn content_hash(s: &str) -> u64 {
125 let hash = blake3::hash(s.as_bytes());
126 u64::from_le_bytes(hash.as_bytes()[..8].try_into().expect("blake3 >= 8 bytes"))
127}
128
129#[must_use]
146pub fn tool_list_hash(tools: &[McpTool]) -> u64 {
147 let mut hasher = blake3::Hasher::new();
148 let mut sorted: Vec<&McpTool> = tools.iter().collect();
149 sorted.sort_by(|a, b| a.server_id.cmp(&b.server_id).then(a.name.cmp(&b.name)));
150 for tool in sorted {
151 hasher.update(tool.server_id.as_bytes());
152 hasher.update(b"\0");
153 hasher.update(tool.name.as_bytes());
154 hasher.update(b"\0");
155 hasher.update(tool.description.as_bytes());
156 hasher.update(b"\0");
157 match serde_json::to_vec(&tool.input_schema) {
158 Ok(schema_bytes) => {
159 hasher.update(&schema_bytes);
160 }
161 Err(_) => {
162 hasher.update(b"\x00");
163 }
164 }
165 hasher.update(b"\x01");
167 }
168 let hash = hasher.finalize();
169 u64::from_le_bytes(hash.as_bytes()[..8].try_into().expect("blake3 >= 8 bytes"))
170}
171
172#[cfg_attr(
191 feature = "profiling",
192 tracing::instrument(name = "mcp.pruning.prune_tools_cached", skip_all)
193)]
194pub async fn prune_tools_cached<P: LlmProvider>(
195 cache: &mut PruningCache,
196 all_tools: &[McpTool],
197 task_context: &str,
198 params: &PruningParams,
199 provider: &P,
200) -> Result<Vec<McpTool>, PruningError> {
201 let msg_hash = content_hash(task_context);
202 let tl_hash = tool_list_hash(all_tools);
203
204 match cache.lookup(msg_hash, tl_hash) {
205 CacheLookup::Hit(cached) => return Ok(cached.to_vec()),
206 CacheLookup::NegativeHit => {
207 tracing::warn!("pruning cache: negative hit, returning all tools without LLM call");
210 return Ok(all_tools.to_vec());
211 }
212 CacheLookup::Miss => {}
213 }
214
215 match prune_tools(all_tools, task_context, params, provider).await {
216 Ok(result) => {
217 cache.insert_ok(msg_hash, tl_hash, result.clone());
218 Ok(result)
219 }
220 Err(e) => {
221 cache.insert_failed(msg_hash, tl_hash);
222 Err(e)
223 }
224 }
225}
226
227#[non_exhaustive]
229#[derive(Debug, thiserror::Error)]
230pub enum PruningError {
231 #[error("pruning LLM call failed: {0}")]
233 LlmError(#[from] LlmError),
234 #[error("failed to parse pruning response as JSON array of tool names")]
236 ParseError,
237}
238
239#[derive(Debug, Clone)]
245pub struct PruningParams {
246 pub max_tools: usize,
248 pub min_tools_to_prune: usize,
250 pub always_include: Vec<String>,
257}
258
259impl Default for PruningParams {
260 fn default() -> Self {
261 Self {
262 max_tools: 15,
263 min_tools_to_prune: 10,
264 always_include: Vec::new(),
265 }
266 }
267}
268
269#[cfg_attr(
286 feature = "profiling",
287 tracing::instrument(name = "mcp.pruning.prune_tools", skip_all)
288)]
289pub async fn prune_tools<P: LlmProvider>(
290 all_tools: &[McpTool],
291 task_context: &str,
292 params: &PruningParams,
293 provider: &P,
294) -> Result<Vec<McpTool>, PruningError> {
295 if all_tools.len() < params.min_tools_to_prune {
296 return Ok(all_tools.to_vec());
297 }
298
299 let (pinned, candidates): (Vec<_>, Vec<_>) = all_tools
301 .iter()
302 .partition(|t| params.always_include.iter().any(|a| a == &t.name));
303
304 let tool_list = candidates.iter().fold(String::new(), |mut acc, t| {
308 let name = sanitize_tool_name(&t.name);
309 let desc = sanitize_tool_description(&t.description);
310 let _ = writeln!(acc, "- {name}: {desc}");
311 acc
312 });
313
314 let prompt = format!(
315 "Return a JSON array of tool names that are relevant to the task below.\n\
316 Return ONLY the JSON array, no explanation, no markdown.\n\n\
317 Task: {task_context}\n\n\
318 Available tools:\n{tool_list}"
319 );
320
321 let messages = vec![Message::from_legacy(Role::User, prompt)];
322 let response = provider.chat(&messages).await?;
323
324 let relevant_names = parse_name_array(&response)?;
326
327 let mut result: Vec<McpTool> = pinned.into_iter().cloned().collect();
330 let mut candidates_added: usize = 0;
331 for tool in &candidates {
332 if params.max_tools > 0 && candidates_added >= params.max_tools {
334 break;
335 }
336 if relevant_names.iter().any(|n| n == &tool.name) {
337 result.push((*tool).clone());
338 candidates_added += 1;
339 }
340 }
341
342 Ok(result)
343}
344
345fn sanitize_tool_name(name: &str) -> String {
349 name.chars().filter(|c| !c.is_control()).take(64).collect()
350}
351
352fn sanitize_tool_description(desc: &str) -> String {
356 desc.chars().filter(|c| !c.is_control()).take(200).collect()
357}
358
359fn parse_name_array(response: &str) -> Result<Vec<String>, PruningError> {
363 let stripped = response
365 .lines()
366 .filter(|l| !l.trim_start().starts_with("```"))
367 .collect::<Vec<_>>()
368 .join("\n");
369
370 let json_fragment = extract_json_array_slice(&stripped).ok_or(PruningError::ParseError)?;
372 let names: Vec<String> =
373 serde_json::from_str(json_fragment).map_err(|_| PruningError::ParseError)?;
374 Ok(names)
375}
376
377#[cfg(test)]
378mod tests {
379 use std::assert_matches;
380 use zeph_llm::mock::MockProvider;
381
382 use super::*;
383
384 fn make_tool(name: &str, description: &str) -> McpTool {
385 McpTool {
386 server_id: "test".into(),
387 name: name.into(),
388 description: description.into(),
389 input_schema: serde_json::Value::Null,
390 output_schema: None,
391 security_meta: crate::tool::ToolSecurityMeta::default(),
392 }
393 }
394
395 fn make_tool_with_server(server_id: &str, name: &str, description: &str) -> McpTool {
396 McpTool {
397 server_id: server_id.into(),
398 name: name.into(),
399 description: description.into(),
400 input_schema: serde_json::Value::Null,
401 output_schema: None,
402 security_meta: crate::tool::ToolSecurityMeta::default(),
403 }
404 }
405
406 fn params_with_max(max_tools: usize) -> PruningParams {
408 PruningParams {
409 max_tools,
410 min_tools_to_prune: 1,
411 always_include: Vec::new(),
412 }
413 }
414
415 #[test]
416 fn parse_plain_array() {
417 let names = parse_name_array(r#"["bash", "read", "write"]"#).unwrap();
418 assert_eq!(names, vec!["bash", "read", "write"]);
419 }
420
421 #[test]
422 fn parse_array_with_markdown_fences() {
423 let input = "```json\n[\"bash\", \"read\"]\n```";
424 let names = parse_name_array(input).unwrap();
425 assert_eq!(names, vec!["bash", "read"]);
426 }
427
428 #[test]
429 fn parse_array_with_preamble() {
430 let input = "Here are the relevant tools:\n[\"bash\", \"read\"]";
431 let names = parse_name_array(input).unwrap();
432 assert_eq!(names, vec!["bash", "read"]);
433 }
434
435 #[test]
436 fn parse_empty_array() {
437 let names = parse_name_array("[]").unwrap();
438 assert!(names.is_empty());
439 }
440
441 #[test]
442 fn parse_invalid_returns_error() {
443 assert!(parse_name_array("not json").is_err());
444 assert!(parse_name_array("").is_err());
445 assert!(parse_name_array("{\"key\": \"val\"}").is_err());
446 }
447
448 #[tokio::test]
451 async fn below_min_detected_early_return() {
452 let tools: Vec<McpTool> = (0..5).map(|i| make_tool(&format!("t{i}"), "d")).collect();
453 let provider = MockProvider::failing();
456 let params = PruningParams {
457 max_tools: 0,
458 min_tools_to_prune: 10, always_include: Vec::new(),
460 };
461
462 let result = prune_tools(&tools, "task", ¶ms, &provider)
463 .await
464 .unwrap();
465 assert_eq!(result.len(), 5, "all tools returned when below threshold");
466 }
467
468 #[tokio::test]
469 async fn always_include_pinned() {
470 let tools = vec![
471 make_tool("pinned", "always here"),
472 make_tool("candidate_a", "desc a"),
473 make_tool("candidate_b", "desc b"),
474 ];
475 let provider = MockProvider::with_responses(vec![r#"["candidate_a"]"#.into()]);
477 let params = PruningParams {
478 max_tools: 0,
479 min_tools_to_prune: 1,
480 always_include: vec!["pinned".into()],
481 };
482
483 let result = prune_tools(&tools, "task", ¶ms, &provider)
484 .await
485 .unwrap();
486 assert!(
487 result.iter().any(|t| t.name == "pinned"),
488 "pinned must survive pruning"
489 );
490 assert!(result.iter().any(|t| t.name == "candidate_a"));
491 }
492
493 #[tokio::test]
495 async fn always_include_matches_bare_name_across_servers() {
496 let tools = vec![
497 make_tool_with_server("server_a", "search", "search on A"),
498 make_tool_with_server("server_b", "search", "search on B"),
499 make_tool_with_server("server_a", "other", "other tool"),
500 ];
501 let provider = MockProvider::with_responses(vec![r#"["other"]"#.into()]);
503 let params = PruningParams {
504 max_tools: 0,
505 min_tools_to_prune: 1,
506 always_include: vec!["search".into()],
507 };
508
509 let result = prune_tools(&tools, "task", ¶ms, &provider)
510 .await
511 .unwrap();
512 assert_eq!(result.len(), 3, "both search tools + other must be present");
513 let search_count = result.iter().filter(|t| t.name == "search").count();
514 assert_eq!(
515 search_count, 2,
516 "both server_a:search and server_b:search must be pinned"
517 );
518 assert!(result.iter().any(|t| t.name == "other"));
519 }
520
521 #[tokio::test]
522 async fn max_tools_cap_respected() {
523 let tools: Vec<McpTool> = (0..5).map(|i| make_tool(&format!("t{i}"), "d")).collect();
524 let names_json = r#"["t0","t1","t2","t3","t4"]"#;
526 let provider = MockProvider::with_responses(vec![names_json.into()]);
527
528 let result = prune_tools(&tools, "task", ¶ms_with_max(2), &provider)
529 .await
530 .unwrap();
531 assert_eq!(
532 result.len(),
533 2,
534 "max_tools=2 must cap LLM-selected candidates"
535 );
536 }
537
538 #[tokio::test]
539 async fn llm_failure_propagates() {
540 let tools: Vec<McpTool> = (0..3).map(|i| make_tool(&format!("t{i}"), "d")).collect();
541 let provider = MockProvider::failing();
542 let result = prune_tools(&tools, "task", ¶ms_with_max(0), &provider).await;
543 assert_matches!(result, Err(PruningError::LlmError(_)));
544 }
545
546 #[tokio::test]
547 async fn parse_error_propagates() {
548 let tools: Vec<McpTool> = (0..3).map(|i| make_tool(&format!("t{i}"), "d")).collect();
549 let provider = MockProvider::with_responses(vec!["not valid json at all".into()]);
550 let result = prune_tools(&tools, "task", ¶ms_with_max(0), &provider).await;
551 assert_matches!(result, Err(PruningError::ParseError));
552 }
553
554 #[tokio::test]
555 async fn max_tools_zero_means_no_cap() {
556 let tools: Vec<McpTool> = (0..5)
557 .map(|i| make_tool(&format!("tool{i}"), "desc"))
558 .collect();
559 let names_json = r#"["tool0","tool1","tool2","tool3","tool4"]"#;
560 let provider = MockProvider::with_responses(vec![names_json.into()]);
561 let params = params_with_max(0);
562
563 let result = prune_tools(&tools, "any task", ¶ms, &provider)
564 .await
565 .unwrap();
566 assert_eq!(result.len(), 5, "max_tools=0 must not cap the result");
567 }
568
569 #[test]
570 fn description_sanitization_strips_control_chars_and_caps() {
571 let desc = "line1\nline2\tinject";
573 let sanitized = sanitize_tool_description(desc);
574 assert!(!sanitized.contains('\n'));
575 assert!(!sanitized.contains('\t'));
576
577 let long_desc = "x".repeat(300);
579 assert_eq!(sanitize_tool_description(&long_desc).len(), 200);
580
581 let long_name = "a".repeat(100);
583 assert_eq!(sanitize_tool_name(&long_name).len(), 64);
584 }
585
586 #[tokio::test]
587 async fn always_include_bypasses_max_tools_cap() {
588 let tools = vec![
590 make_tool("pinned", "always here"),
591 make_tool("candidate_a", "desc a"),
592 make_tool("candidate_b", "desc b"),
593 ];
594 let provider =
595 MockProvider::with_responses(vec![r#"["candidate_a","candidate_b"]"#.into()]);
596 let params = PruningParams {
597 max_tools: 1,
598 min_tools_to_prune: 1,
599 always_include: vec!["pinned".into()],
600 };
601
602 let result = prune_tools(&tools, "task", ¶ms, &provider)
603 .await
604 .unwrap();
605
606 assert!(
608 result.iter().any(|t| t.name == "pinned"),
609 "pinned tool must bypass cap"
610 );
611 assert_eq!(result.len(), 2);
613 }
614
615 #[tokio::test]
618 async fn cache_positive_hit() {
619 let tools: Vec<McpTool> = (0..2).map(|i| make_tool(&format!("t{i}"), "d")).collect();
622 let provider = MockProvider::with_responses(vec![r#"["t0","t1"]"#.into()]);
623 let params = params_with_max(0);
624 let mut cache = PruningCache::new();
625
626 let r1 = prune_tools_cached(&mut cache, &tools, "query", ¶ms, &provider)
627 .await
628 .unwrap();
629 let r2 = prune_tools_cached(&mut cache, &tools, "query", ¶ms, &provider)
630 .await
631 .unwrap();
632
633 assert_eq!(r1.len(), 2);
634 assert_eq!(r1.len(), r2.len(), "cache hit must return same result");
635 }
636
637 #[tokio::test]
638 async fn cache_miss_on_message_change() {
639 let tools: Vec<McpTool> = (0..2).map(|i| make_tool(&format!("t{i}"), "d")).collect();
640 let provider =
641 MockProvider::with_responses(vec![r#"["t0","t1"]"#.into(), r#"["t0"]"#.into()]);
642 let params = params_with_max(0);
643 let mut cache = PruningCache::new();
644
645 let r1 = prune_tools_cached(&mut cache, &tools, "query_a", ¶ms, &provider)
646 .await
647 .unwrap();
648 let r2 = prune_tools_cached(&mut cache, &tools, "query_b", ¶ms, &provider)
649 .await
650 .unwrap();
651
652 assert_eq!(r1.len(), 2, "first call returns both tools");
653 assert_eq!(
654 r2.len(),
655 1,
656 "different message triggers cache miss and LLM call"
657 );
658 }
659
660 #[tokio::test]
661 async fn cache_miss_on_tool_list_change() {
662 let tools1: Vec<McpTool> = (0..2).map(|i| make_tool(&format!("t{i}"), "d")).collect();
663 let mut tools2 = tools1.clone();
664 tools2.push(make_tool("t2", "new tool"));
665
666 let provider = MockProvider::with_responses(vec![
667 r#"["t0","t1"]"#.into(),
668 r#"["t0","t1","t2"]"#.into(),
669 ]);
670 let params = params_with_max(0);
671 let mut cache = PruningCache::new();
672
673 let r1 = prune_tools_cached(&mut cache, &tools1, "query", ¶ms, &provider)
674 .await
675 .unwrap();
676 let r2 = prune_tools_cached(&mut cache, &tools2, "query", ¶ms, &provider)
677 .await
678 .unwrap();
679
680 assert_eq!(r1.len(), 2);
681 assert_eq!(r2.len(), 3, "new tool triggers cache miss");
682 }
683
684 #[tokio::test]
685 async fn cache_negative_hit_skips_llm() {
686 let tools: Vec<McpTool> = (0..2).map(|i| make_tool(&format!("t{i}"), "d")).collect();
687 let provider = MockProvider::failing();
688 let params = params_with_max(0);
689 let mut cache = PruningCache::new();
690
691 let r1 = prune_tools_cached(&mut cache, &tools, "query", ¶ms, &provider).await;
693 assert!(r1.is_err(), "first call must propagate LLM error");
694
695 let r2 = prune_tools_cached(&mut cache, &tools, "query", ¶ms, &provider)
698 .await
699 .unwrap();
700 assert_eq!(r2.len(), 2, "negative cache hit must return all tools");
701 }
702
703 #[tokio::test]
704 async fn cache_negative_hit_clears_on_reset() {
705 let tools: Vec<McpTool> = (0..2).map(|i| make_tool(&format!("t{i}"), "d")).collect();
706 let provider = MockProvider::with_responses(vec![r#"["t0","t1"]"#.into()])
708 .with_errors(vec![zeph_llm::LlmError::Other("simulated failure".into())]);
709 let params = params_with_max(0);
710 let mut cache = PruningCache::new();
711
712 let r1 = prune_tools_cached(&mut cache, &tools, "query", ¶ms, &provider).await;
714 assert!(r1.is_err());
715
716 cache.reset();
718
719 let r2 = prune_tools_cached(&mut cache, &tools, "query", ¶ms, &provider)
721 .await
722 .unwrap();
723 assert_eq!(r2.len(), 2, "after reset the LLM must be retried");
724 }
725}