Skip to main content

zeph_mcp/
pruning.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Dynamic MCP tool pruning for context optimization (#2204).
5//!
6//! The `prune_tools` free function filters a list of MCP tools to only those relevant
7//! to the current task, using an LLM call with a fast/cheap model. This reduces context
8//! usage and improves tool selection accuracy when MCP servers expose many tools.
9//!
10//! `zeph-mcp` does not depend on `zeph-config` (circular dependency: zeph-config ->
11//! zeph-mcp). Callers in `zeph-core` convert `ToolPruningConfig` into `PruningParams`
12//! before calling `prune_tools`.
13
14use 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// ── Per-message pruning cache (#2298) ────────────────────────────────────────
23
24/// Cached outcome stored by [`PruningCache`].
25///
26/// [`Ok`] holds the previously-computed pruned tool list; [`Failed`] is a
27/// sentinel written when the LLM call failed, so subsequent lookups with the
28/// same key return the all-tools fallback without retrying the LLM.
29#[derive(Debug, Clone)]
30enum CachedResult {
31    Ok(Vec<McpTool>),
32    /// LLM call failed; caller should use the full tool list.
33    Failed,
34}
35
36/// Per-message cache for MCP tool pruning results.
37///
38/// Stores at most one entry keyed on `(message_content_hash, tool_list_hash)`.
39/// A cache miss triggers an LLM call; a hit returns the stored result
40/// immediately.  Negative entries (`Failed`) prevent retry storms when the
41/// pruning LLM is transiently unavailable.
42///
43/// # Cache contract
44///
45/// `PruningCache` returns previously-computed pruning results keyed on
46/// `(message_content_hash, tool_list_hash)`.
47///
48/// `tool_list_hash` includes: `server_id`, `name`, `description`, and
49/// `input_schema` for every tool.  Any change to tool metadata (not just the
50/// name set) produces a different hash and causes a cache miss.
51///
52/// `PruningCache::reset()` is additionally called on:
53/// - New user message (top of `process_user_message_inner`)
54/// - `tools/list_changed` notification (in `check_tool_refresh`)
55/// - Manual `/mcp add` or `/mcp remove` commands
56///
57/// `PruningParams` is **not** part of the cache key.  Callers must not change
58/// `PruningParams` within a single user turn; this invariant holds because
59/// params are derived from `ToolPruningConfig`, which is stable within a turn
60/// (config changes trigger a full agent rebuild, not a mid-turn param swap).
61///
62/// Designed for single-owner use (`&mut` on `Agent`). Not thread-safe.
63#[derive(Debug, Default, Clone)]
64pub struct PruningCache {
65    key: Option<(u64, u64)>,
66    result: Option<CachedResult>,
67}
68
69/// Outcome of a [`PruningCache::lookup`] call.
70enum CacheLookup<'a> {
71    /// Positive hit: pruned tool slice from a previous successful call.
72    Hit(&'a [McpTool]),
73    /// Negative hit: LLM previously failed; caller should use the full tool list.
74    NegativeHit,
75    /// No entry for this key.
76    Miss,
77}
78
79impl PruningCache {
80    /// Create a new, empty cache.
81    #[must_use]
82    pub fn new() -> Self {
83        Self::default()
84    }
85
86    /// Clear the cached entry.
87    ///
88    /// Must be called at the start of each user turn and whenever the MCP tool
89    /// list changes (via notification, `/mcp add`, or `/mcp remove`).
90    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/// Compute a `u64` hash of a string using blake3 (first 8 bytes, little-endian).
119///
120/// # Panics
121///
122/// Never panics in practice: blake3 always produces at least 8 bytes of output.
123#[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/// Compute a `u64` hash of the full tool list metadata using blake3.
130///
131/// Hashes `server_id`, `name`, `description`, and `input_schema` for every
132/// tool, sorted by qualified name (`server_id` then `name`) for deterministic
133/// ordering regardless of list order.
134///
135/// **`BTreeMap` assumption**: `serde_json::to_vec` produces deterministic output
136/// because `serde_json::Map` defaults to `BTreeMap`-backed storage (sorted
137/// keys).  If the `preserve_order` feature of `serde_json` is ever enabled
138/// (switching `Map` to `IndexMap`), key order becomes insertion-order and
139/// hashing becomes non-deterministic.  Should `preserve_order` be needed,
140/// sort `Map` keys before serialising here.
141///
142/// # Panics
143///
144/// Never panics in practice: blake3 always produces at least 8 bytes of output.
145#[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        // Tool separator — prevents adjacent-field collisions.
166        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/// Cache-aware wrapper around [`prune_tools`].
173///
174/// On a **positive cache hit**: returns the previously-computed pruned list
175/// without an LLM call.
176///
177/// On a **negative cache hit** (LLM previously failed for this key): returns
178/// `Ok(all_tools.to_vec())` without retrying the LLM, avoiding retry storms
179/// when the pruning LLM is transiently unavailable.
180///
181/// On a **cache miss**: calls [`prune_tools`], stores the result (success or
182/// failure), and returns.  On LLM failure the negative sentinel is cached and
183/// `Err(PruningError)` is returned so the caller can log and fall back.
184///
185/// # Errors
186///
187/// Propagates `PruningError` from [`prune_tools`] on the first (uncached) LLM
188/// failure.  Subsequent calls with the same key return `Ok(all_tools.to_vec())`
189/// from the negative cache entry.
190#[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            // Negative cache hit: LLM previously failed for this key.
208            // Return all tools as fallback without retrying to avoid retry storms.
209            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/// Errors that can occur during tool pruning.
228#[non_exhaustive]
229#[derive(Debug, thiserror::Error)]
230pub enum PruningError {
231    /// LLM call failed.
232    #[error("pruning LLM call failed: {0}")]
233    LlmError(#[from] LlmError),
234    /// Could not extract a valid JSON array from the LLM response.
235    #[error("failed to parse pruning response as JSON array of tool names")]
236    ParseError,
237}
238
239/// Parameters for the `prune_tools` function.
240///
241/// Mirrors `zeph_config::ToolPruningConfig` but lives in `zeph-mcp` to avoid a
242/// circular crate dependency (`zeph-config` → `zeph-mcp`). Callers in `zeph-core`
243/// convert from `ToolPruningConfig`.
244#[derive(Debug, Clone)]
245pub struct PruningParams {
246    /// Maximum number of MCP tools to include after pruning.
247    pub max_tools: usize,
248    /// Minimum number of MCP tools below which pruning is skipped.
249    pub min_tools_to_prune: usize,
250    /// Tool names that are never pruned (always included).
251    ///
252    /// Matches on bare tool `name` (not qualified `server_id:name`).  When two
253    /// MCP servers expose a tool with the same name, both instances are pinned.
254    /// This is intentional: the config is user-facing and users specify tool
255    /// names, not server-qualified identifiers.
256    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/// Prune MCP tools to those relevant to the current task.
270///
271/// Returns a filtered subset of `all_tools` based on the LLM's assessment of relevance
272/// to `task_context`. Tools listed in `params.always_include` bypass the LLM filter.
273///
274/// # Behavior
275///
276/// - If `all_tools.len() < params.min_tools_to_prune`, returns `Ok(all_tools.to_vec())`.
277/// - On LLM failure or parse failure, returns `Err(PruningError)` — the caller should
278///   fall back to the full tool list and log at `WARN` level.
279/// - Result is capped at `params.max_tools` total tools. `max_tools == 0` means no cap.
280///
281/// # Errors
282///
283/// Returns `PruningError::LlmError` if the provider call fails.
284/// Returns `PruningError::ParseError` if the response cannot be parsed as a JSON array.
285#[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    // Partition: always-include tools bypass the LLM filter.
300    let (pinned, candidates): (Vec<_>, Vec<_>) = all_tools
301        .iter()
302        .partition(|t| params.always_include.iter().any(|a| a == &t.name));
303
304    // Build the pruning prompt.
305    // Sanitize tool names and descriptions before interpolation to prevent prompt injection
306    // from attacker-controlled MCP servers.
307    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    // Parse: strip markdown fences, find first `[` to last `]`.
325    let relevant_names = parse_name_array(&response)?;
326
327    // always_include tools are added unconditionally and bypass the max_tools cap;
328    // max_tools applies only to LLM-selected candidates.
329    let mut result: Vec<McpTool> = pinned.into_iter().cloned().collect();
330    let mut candidates_added: usize = 0;
331    for tool in &candidates {
332        // max_tools == 0 means no cap on LLM-selected candidates.
333        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
345/// Sanitize a tool name before interpolating into an LLM prompt.
346///
347/// Strips control characters and caps at 64 characters.
348fn sanitize_tool_name(name: &str) -> String {
349    name.chars().filter(|c| !c.is_control()).take(64).collect()
350}
351
352/// Sanitize a tool description before interpolating into an LLM prompt.
353///
354/// Strips control characters and caps at 200 characters.
355fn sanitize_tool_description(desc: &str) -> String {
356    desc.chars().filter(|c| !c.is_control()).take(200).collect()
357}
358
359/// Extract tool names from an LLM response expected to contain a JSON array of strings.
360///
361/// Handles markdown code fences (` ```json ... ``` `) and leading/trailing whitespace.
362fn parse_name_array(response: &str) -> Result<Vec<String>, PruningError> {
363    // Strip markdown code fence lines.
364    let stripped = response
365        .lines()
366        .filter(|l| !l.trim_start().starts_with("```"))
367        .collect::<Vec<_>>()
368        .join("\n");
369
370    // Find the first `[` and last `]` to isolate the JSON array.
371    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    /// Build params with low `min_tools_to_prune` so tests aren't skipped early.
407    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    // Replaced below_min_detected tautology (#2300): call prune_tools with a failing
449    // mock to verify the early-return path fires before the LLM is ever contacted.
450    #[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        // MockProvider::failing() would panic on any LLM call — if prune_tools invokes it,
454        // the test will error rather than pass.
455        let provider = MockProvider::failing();
456        let params = PruningParams {
457            max_tools: 0,
458            min_tools_to_prune: 10, // 5 tools < 10 → early return before LLM
459            always_include: Vec::new(),
460        };
461
462        let result = prune_tools(&tools, "task", &params, &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        // LLM returns only candidate_a; pinned must still appear.
476        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", &params, &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    /// S4: `always_include` pins tools by bare name across multiple servers.
494    #[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        // LLM returns only "other"; both "search" instances should still be pinned.
502        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", &params, &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        // LLM returns all 5 as relevant; max_tools=2 must cap candidates.
525        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", &params_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", &params_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", &params_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", &params, &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        // Newline and tab are control characters.
572        let desc = "line1\nline2\tinject";
573        let sanitized = sanitize_tool_description(desc);
574        assert!(!sanitized.contains('\n'));
575        assert!(!sanitized.contains('\t'));
576
577        // Cap at 200 characters.
578        let long_desc = "x".repeat(300);
579        assert_eq!(sanitize_tool_description(&long_desc).len(), 200);
580
581        // Name capped at 64 characters.
582        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        // max_tools=1 — only 1 candidate from LLM allowed; but always_include adds unconditionally.
589        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", &params, &provider)
603            .await
604            .unwrap();
605
606        // "pinned" is always present regardless of max_tools.
607        assert!(
608            result.iter().any(|t| t.name == "pinned"),
609            "pinned tool must bypass cap"
610        );
611        // Only 1 candidate slot remains after pinned bypasses cap; total = 1 (pinned) + 1 (candidate).
612        assert_eq!(result.len(), 2);
613    }
614
615    // ── PruningCache tests (#2298, #2300) ────────────────────────────────────
616
617    #[tokio::test]
618    async fn cache_positive_hit() {
619        // Two tools to exceed min_tools_to_prune=1; MockProvider has exactly one response.
620        // The second call must succeed from cache without consuming the (empty) response queue.
621        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", &params, &provider)
627            .await
628            .unwrap();
629        let r2 = prune_tools_cached(&mut cache, &tools, "query", &params, &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", &params, &provider)
646            .await
647            .unwrap();
648        let r2 = prune_tools_cached(&mut cache, &tools, "query_b", &params, &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", &params, &provider)
674            .await
675            .unwrap();
676        let r2 = prune_tools_cached(&mut cache, &tools2, "query", &params, &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        // First call: LLM fails → error is returned and negative entry is cached.
692        let r1 = prune_tools_cached(&mut cache, &tools, "query", &params, &provider).await;
693        assert!(r1.is_err(), "first call must propagate LLM error");
694
695        // Second call: negative cache hit → returns all tools without calling LLM.
696        // MockProvider::failing() would panic on a second LLM call, proving cache is used.
697        let r2 = prune_tools_cached(&mut cache, &tools, "query", &params, &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        // Fail on the first LLM call; succeed on the second (after cache.reset()).
707        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        // First call: LLM fails → negative entry cached.
713        let r1 = prune_tools_cached(&mut cache, &tools, "query", &params, &provider).await;
714        assert!(r1.is_err());
715
716        // Reset clears the negative entry.
717        cache.reset();
718
719        // After reset the LLM is retried; the queued success response is now returned.
720        let r2 = prune_tools_cached(&mut cache, &tools, "query", &params, &provider)
721            .await
722            .unwrap();
723        assert_eq!(r2.len(), 2, "after reset the LLM must be retried");
724    }
725}