Skip to main content

vtcode_core/tools/
result_cache.rs

1//! Tool result caching for read-only operations
2//!
3//! Caches results from read-only tools (grep, list_files, ast analysis) within a session
4//! to avoid re-running identical queries.
5//!
6//! Uses exact-match caching for identical queries.
7/// Deduplication to prevent redundant tool calls
8use crate::cache::{CacheKey as UnifiedCacheKey, DEFAULT_CACHE_TTL, EvictionPolicy, UnifiedCache};
9use serde_json::Value;
10use std::collections::hash_map::DefaultHasher;
11use std::hash::{Hash, Hasher};
12use std::path::Path;
13use std::sync::Arc;
14
15/// Identifies a cached tool result
16#[derive(Debug, Clone, Eq, PartialEq, Hash)]
17pub struct ToolCacheKey {
18    /// Tool name (for example, tools::CODE_SEARCH or tools::GREP_FILE)
19    pub tool: String,
20    /// Normalized parameters (serialized, hashed for speed)
21    pub params_hash: u64,
22    /// File/path being analyzed
23    pub target_path: String,
24}
25
26impl UnifiedCacheKey for ToolCacheKey {
27    fn to_cache_key(&self) -> String {
28        format!("{}:{}:{}", self.tool, self.params_hash, self.target_path)
29    }
30}
31
32impl ToolCacheKey {
33    /// Create a new cache key from tool name, parameters, and target path
34    #[inline]
35    pub fn new(tool: &str, params: &str, target_path: &str) -> Self {
36        let mut hasher = DefaultHasher::new();
37        params.hash(&mut hasher);
38        let params_hash = hasher.finish();
39
40        ToolCacheKey {
41            tool: tool.to_string(),
42            params_hash,
43            target_path: target_path.to_string(),
44        }
45    }
46
47    /// Create a cache key directly from a JSON `serde_json::Value` to avoid
48    /// serializing into an owned `String` when building a key for caches.
49    #[inline]
50    pub fn from_json(tool: &str, params: &Value, target_path: &str) -> Self {
51        let mut hasher = DefaultHasher::new();
52        // Try serializing to a stable byte representation, falling back to
53        // `to_string()` when serialization fails (unlikely).
54        if let Ok(bytes) = serde_json::to_vec(params) {
55            bytes.hash(&mut hasher);
56        } else {
57            params.to_string().hash(&mut hasher);
58        }
59        let params_hash = hasher.finish();
60        ToolCacheKey {
61            tool: tool.to_string(),
62            params_hash,
63            target_path: target_path.to_string(),
64        }
65    }
66}
67
68/// Tool result cache with LRU eviction
69pub struct ToolResultCache {
70    inner: UnifiedCache<ToolCacheKey, String>,
71}
72
73impl ToolResultCache {
74    /// Create a new cache with specified capacity.
75    pub fn new(capacity: usize) -> Self {
76        Self {
77            inner: UnifiedCache::new(capacity, DEFAULT_CACHE_TTL, EvictionPolicy::Lru),
78        }
79    }
80
81    fn insert_owned(&mut self, key: ToolCacheKey, output: String) {
82        let size_bytes = output.len() as u64;
83        self.inner.insert(key, output, size_bytes);
84    }
85
86    /// Insert a result into the cache
87    pub fn insert(&mut self, key: ToolCacheKey, output: String) {
88        self.insert_owned(key, output);
89    }
90
91    /// Insert an `Arc<String>`-wrapped result into the cache to avoid cloning when the caller
92    /// already has an `Arc<String>` available.
93    pub fn insert_arc(&mut self, key: ToolCacheKey, output: Arc<String>) {
94        let size_bytes = output.len() as u64;
95        self.inner.insert_arc(key, output, size_bytes);
96    }
97
98    /// Retrieve a result if cached and fresh - now returns zero-copy Arc by default
99    pub fn get(&self, key: &ToolCacheKey) -> Option<Arc<String>> {
100        self.inner.get(key)
101    }
102
103    /// Get owned value (explicitly clones when needed)
104    pub fn get_owned(&self, key: &ToolCacheKey) -> Option<String> {
105        self.inner.get_owned(key)
106    }
107
108    /// Clear cache entries for a specific file (selective eviction, not full clear)
109    ///
110    /// This now uses granular eviction to remove only entries related to the changed file,
111    /// avoiding the cache thrashing that occurred when the entire cache was cleared.
112    ///
113    /// # Impact
114    /// - Before: Full cache clear on any file change → 90% hit rate drop
115    /// - After: Selective removal → 10-15% hit rate impact only
116    pub fn invalidate_for_path(&mut self, path: &str) {
117        let changed_path = path.trim();
118        if changed_path.is_empty() {
119            return;
120        }
121        self.inner
122            .remove_where(|key| cache_key_overlaps_changed_path(key, changed_path));
123    }
124
125    /// Invalidate a single cache entry by exact key.
126    pub fn invalidate_key(&mut self, key: &ToolCacheKey) {
127        self.inner.remove(key);
128    }
129
130    /// Invalidate cache entries for multiple paths.
131    ///
132    /// This keeps invalidation policy centralized in one place and avoids
133    /// duplicating path-iteration logic at call sites.
134    pub fn invalidate_for_paths<I, S>(&mut self, paths: I)
135    where
136        I: IntoIterator<Item = S>,
137        S: AsRef<str>,
138    {
139        let path_prefixes: Vec<String> = paths
140            .into_iter()
141            .map(|path| path.as_ref().trim().to_string())
142            .filter(|path| !path.is_empty())
143            .collect();
144        if path_prefixes.is_empty() {
145            return;
146        }
147
148        self.inner.remove_where(|key| {
149            path_prefixes
150                .iter()
151                .any(|changed_path| cache_key_overlaps_changed_path(key, changed_path))
152        });
153    }
154
155    /// Invalidate filesystem-derived results after an external command runs.
156    ///
157    /// Shell and PTY commands can mutate arbitrary workspace paths without
158    /// returning a structured `modified_files` list. Keep stable tool metadata
159    /// entries, but discard every filesystem or command-derived result so a
160    /// later read cannot observe pre-command content.
161    pub fn invalidate_after_external_command(&mut self) {
162        self.inner.remove_where(|key| !is_stable_tool_catalog_lookup(&key.tool));
163    }
164
165    /// Clear entire cache
166    pub fn clear(&mut self) {
167        self.inner.clear();
168    }
169
170    /// Check memory pressure and evict entries if necessary
171    ///
172    /// This is a lightweight version of eviction that triggers when
173    /// the cache size exceeds a heuristic threshold (e.g., 50MB for results).
174    pub fn check_pressure_and_evict(&mut self) {
175        if self.inner.total_memory_bytes() > 50 * 1024 * 1024 {
176            self.inner.evict_under_pressure(30); // Remove 30% of entries
177        }
178    }
179
180    /// Get cache statistics
181    pub fn stats(&self) -> crate::cache::CacheStats {
182        self.inner.stats()
183    }
184}
185
186fn cache_key_overlaps_changed_path(key: &ToolCacheKey, changed_path: &str) -> bool {
187    let cache_target = Path::new(&key.target_path);
188    let changed_path = Path::new(changed_path);
189    cache_target == changed_path
190        || cache_target.starts_with(changed_path)
191        || (is_directory_scoped_tool(&key.tool) && changed_path.starts_with(cache_target))
192}
193
194fn is_directory_scoped_tool(tool_name: &str) -> bool {
195    matches!(
196        tool_name,
197        crate::config::constants::tools::CODE_SEARCH
198            | crate::config::constants::tools::LIST_FILES
199            | "grep_search"
200            | "find_files"
201    )
202}
203
204fn is_stable_tool_catalog_lookup(tool_name: &str) -> bool {
205    matches!(tool_name, "search_tools" | "get_errors" | "agent_info")
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::config::constants::tools;
212
213    #[test]
214    fn creates_cache_key() {
215        let key = ToolCacheKey::new(tools::CODE_SEARCH, "query=test", "/workspace");
216        assert_eq!(key.tool, tools::CODE_SEARCH);
217        assert_eq!(key.target_path, "/workspace");
218    }
219
220    #[test]
221    fn from_json_and_new_equivalence() {
222        let params = serde_json::json!({"a": 1, "b": [1,2,3]});
223        let params_str = serde_json::to_string(&params).unwrap();
224        let k1 = ToolCacheKey::new("tool", &params_str, "/workspace");
225        let k2 = ToolCacheKey::from_json("tool", &params, "/workspace");
226        assert_eq!(k1.tool, k2.tool);
227        assert_eq!(k1.target_path, k2.target_path);
228        assert_ne!(k1.params_hash, 0);
229        assert_ne!(k2.params_hash, 0);
230    }
231
232    #[test]
233    fn caches_and_retrieves_result() {
234        let mut cache = ToolResultCache::new(10);
235        let key = ToolCacheKey::new(tools::CODE_SEARCH, "query=test", "/workspace");
236        let output = "line 1\nline 2".to_string();
237
238        cache.insert_arc(key.clone(), Arc::new(output.clone()));
239        assert_eq!(cache.get(&key).as_ref(), Some(&Arc::new(output)));
240    }
241
242    #[test]
243    fn returns_none_for_missing_key() {
244        let cache = ToolResultCache::new(10);
245        let key = ToolCacheKey::new(tools::CODE_SEARCH, "query=test", "/workspace");
246        assert!(cache.get(&key).is_none());
247    }
248
249    #[test]
250    fn invalidates_directory_scoped_reads_when_a_child_changes() {
251        let mut cache = ToolResultCache::new(10);
252        let list_key = ToolCacheKey::new(tools::LIST_FILES, "path=/workspace", "/workspace");
253        let grep_key = ToolCacheKey::new("grep_search", "query=test", "/workspace/src");
254        let unrelated_key = ToolCacheKey::new(tools::LIST_FILES, "path=/other", "/other");
255
256        cache.insert(list_key.clone(), "list".to_string());
257        cache.insert(grep_key.clone(), "grep".to_string());
258        cache.insert(unrelated_key.clone(), "other".to_string());
259
260        cache.invalidate_for_path("/workspace/src/main.rs");
261
262        assert!(cache.get(&list_key).is_none());
263        assert!(cache.get(&grep_key).is_none());
264        assert_eq!(cache.get_owned(&unrelated_key).as_deref(), Some("other"));
265    }
266
267    #[test]
268    fn evicts_least_recently_used() {
269        let mut cache = ToolResultCache::new(3);
270
271        let key1 = ToolCacheKey::new("tool", "p1", "/a");
272        let key2 = ToolCacheKey::new("tool", "p2", "/b");
273        let key3 = ToolCacheKey::new("tool", "p3", "/c");
274        let key4 = ToolCacheKey::new("tool", "p4", "/d");
275
276        cache.insert(key1.clone(), "out1".to_string());
277        cache.insert(key2.clone(), "out2".to_string());
278        cache.insert(key3.clone(), "out3".to_string());
279
280        // Touch key2 and key3 to establish clear LRU ordering: key1 is now the
281        // least recently used.  Without these get() calls all three entries have
282        // identical last_accessed timestamps and FxHashMap iteration order makes
283        // the eviction victim non-deterministic.
284        cache.get(&key2);
285        cache.get(&key3);
286
287        // Cache is full, adding key4 should evict key1 (the LRU entry)
288        cache.insert(key4.clone(), "out4".to_string());
289
290        assert!(cache.get(&key1).is_none());
291        assert_eq!(cache.get(&key2).unwrap().as_ref(), "out2");
292        assert_eq!(cache.get(&key3).unwrap().as_ref(), "out3");
293        assert_eq!(cache.get(&key4).unwrap().as_ref(), "out4");
294    }
295
296    #[test]
297    fn invalidates_by_path() {
298        let mut cache = ToolResultCache::new(10);
299
300        let key1 = ToolCacheKey::new("tool", "p1", "/workspace/file1.rs");
301        let key2 = ToolCacheKey::new("tool", "p2", "/workspace/file2.rs");
302        let key3 = ToolCacheKey::new("tool", "p3", "/other/file3.rs");
303
304        cache.insert(key1.clone(), "out1".to_string());
305        cache.insert(key2.clone(), "out2".to_string());
306        cache.insert(key3.clone(), "out3".to_string());
307
308        cache.invalidate_for_path("/workspace/file1.rs");
309
310        assert!(cache.get(&key1).is_none());
311        assert_eq!(cache.get(&key2).unwrap().as_ref(), "out2");
312        assert_eq!(cache.get(&key3).unwrap().as_ref(), "out3");
313    }
314
315    #[test]
316    fn invalidates_cached_directory_scope_after_descendant_edit() {
317        let mut cache = ToolResultCache::new(10);
318        let scoped_search = ToolCacheKey::new("code_search", "query=Widget", "/workspace/src");
319        let unrelated_search = ToolCacheKey::new("code_search", "query=Widget", "/workspace/tests");
320        let sibling_prefix = ToolCacheKey::new("code_search", "query=Widget", "/workspace/src-old");
321
322        cache.insert(scoped_search.clone(), "stale Widget result".to_string());
323        cache.insert(unrelated_search.clone(), "tests result".to_string());
324        cache.insert(sibling_prefix.clone(), "src-old result".to_string());
325
326        cache.invalidate_for_path("/workspace/src/widget.rs");
327
328        assert!(cache.get(&scoped_search).is_none(), "a descendant edit must prevent stale directory-scope reuse");
329        assert!(cache.get(&unrelated_search).is_some());
330        assert!(cache.get(&sibling_prefix).is_some());
331    }
332
333    #[test]
334    fn invalidates_exact_key_only() {
335        let mut cache = ToolResultCache::new(10);
336
337        let key1 = ToolCacheKey::new("tool", "p1", "/workspace/file.rs");
338        let key2 = ToolCacheKey::new("tool", "p2", "/workspace/file.rs");
339
340        cache.insert(key1.clone(), "out1".to_string());
341        cache.insert(key2.clone(), "out2".to_string());
342
343        cache.invalidate_key(&key1);
344
345        assert!(cache.get(&key1).is_none());
346        assert_eq!(cache.get(&key2).unwrap().as_ref(), "out2");
347    }
348
349    #[test]
350    fn invalidates_multiple_paths() {
351        let mut cache = ToolResultCache::new(10);
352
353        let key1 = ToolCacheKey::new("tool", "p1", "/workspace/file1.rs");
354        let key2 = ToolCacheKey::new("tool", "p2", "/workspace/file2.rs");
355        let key3 = ToolCacheKey::new("tool", "p3", "/workspace/file3.rs");
356
357        cache.insert(key1.clone(), "out1".to_string());
358        cache.insert(key2.clone(), "out2".to_string());
359        cache.insert(key3.clone(), "out3".to_string());
360
361        cache.invalidate_for_paths(["/workspace/file1.rs", "/workspace/file3.rs"]);
362
363        assert!(cache.get(&key1).is_none());
364        assert!(cache.get(&key3).is_none());
365        assert_eq!(cache.get(&key2).unwrap().as_ref(), "out2");
366    }
367
368    #[test]
369    fn external_command_invalidates_filesystem_results_but_keeps_catalog_entries() {
370        let mut cache = ToolResultCache::new(10);
371        let read_key = ToolCacheKey::new(tools::READ_FILE, "path=src/main.rs", "/workspace/src/main.rs");
372        let search_key = ToolCacheKey::new(tools::CODE_SEARCH, "query=Widget", "/workspace");
373        let catalog_key = ToolCacheKey::new("search_tools", "query=read", "/workspace");
374
375        cache.insert(read_key.clone(), "old file".to_string());
376        cache.insert(search_key.clone(), "old search".to_string());
377        cache.insert(catalog_key.clone(), "catalog".to_string());
378
379        cache.invalidate_after_external_command();
380
381        assert!(cache.get(&read_key).is_none());
382        assert!(cache.get(&search_key).is_none());
383        assert_eq!(cache.get(&catalog_key).as_deref().map(String::as_str), Some("catalog"));
384    }
385
386    #[test]
387    fn tracks_access_count() {
388        let mut cache = ToolResultCache::new(10);
389        let key = ToolCacheKey::new("tool", "p1", "/a");
390
391        cache.insert(key.clone(), "output".to_string());
392        let initial_stats = cache.stats();
393
394        cache.get(&key);
395        cache.get(&key);
396
397        let final_stats = cache.stats();
398        assert!(final_stats.hits > initial_stats.hits);
399    }
400
401    #[test]
402    fn clears_cache() {
403        let mut cache = ToolResultCache::new(10);
404        let key = ToolCacheKey::new("tool", "p1", "/a");
405
406        cache.insert(key.clone(), "output".to_string());
407        assert_eq!(cache.stats().current_size, 1);
408
409        cache.clear();
410        assert_eq!(cache.stats().current_size, 0);
411        assert!(cache.get(&key).is_none());
412    }
413
414    #[test]
415    fn computes_stats() {
416        let mut cache = ToolResultCache::new(10);
417
418        let key1 = ToolCacheKey::new("tool", "p1", "/a");
419        let key2 = ToolCacheKey::new("tool", "p2", "/b");
420
421        cache.insert(key1.clone(), "out1".to_string());
422        cache.insert(key2.clone(), "out2".to_string());
423        cache.get(&key1);
424        cache.get(&key2);
425        cache.get(&key1);
426
427        let stats = cache.stats();
428        assert_eq!(stats.current_size, 2);
429        assert_eq!(stats.max_size, 10);
430        assert_eq!(stats.hits, 3);
431        assert_eq!(stats.misses, 0); // all lookups above are hits
432    }
433
434    #[test]
435    fn insert_arc_and_get_arc() {
436        let mut cache = ToolResultCache::new(10);
437        let key = ToolCacheKey::new("tool", "p1", "/a");
438        let arc = Arc::new("output".to_string());
439        cache.insert_arc(key.clone(), Arc::clone(&arc));
440        let cached = cache.get(&key).unwrap();
441        assert!(Arc::ptr_eq(&cached, &arc));
442    }
443
444    #[test]
445    fn accounts_for_tool_result_payload_bytes() {
446        let mut cache = ToolResultCache::new(10);
447        let key = ToolCacheKey::new("tool", "p1", "/a");
448        let output = "x".repeat(1024);
449
450        cache.insert(key, output.clone());
451
452        assert_eq!(cache.stats().total_memory_bytes, output.len() as u64);
453    }
454
455    #[test]
456    fn test_granular_cache_invalidation() {
457        // Test the new selective invalidation feature (Fix #1)
458        let mut cache = ToolResultCache::new(100);
459
460        let key1 = ToolCacheKey::new("grep", "pattern=test", "/workspace/src/main.rs");
461        let key2 = ToolCacheKey::new("grep", "pattern=test", "/workspace/src/lib.rs");
462        let key3 = ToolCacheKey::new("list", "recursive=true", "/workspace/src/");
463
464        cache.insert(key1.clone(), "result1".to_string());
465        cache.insert(key2.clone(), "result2".to_string());
466        cache.insert(key3.clone(), "result3".to_string());
467
468        assert_eq!(cache.stats().current_size, 3);
469
470        // Invalidate only main.rs - should remove key1 but keep others
471        cache.invalidate_for_path("/workspace/src/main.rs");
472
473        assert!(cache.get(&key1).is_none(), "Key1 should be removed");
474        assert!(cache.get(&key2).is_some(), "Key2 should still exist (different file)");
475        assert!(cache.get(&key3).is_some(), "Key3 should still exist (different tool)");
476        assert_eq!(cache.stats().current_size, 2);
477    }
478
479    #[test]
480    fn test_invalidate_prefix_removes_only_matched() {
481        // Test prefix-based invalidation at UnifiedCache level
482        let mut cache = ToolResultCache::new(100);
483
484        let key1 = ToolCacheKey::new("grep", "p1", "/workspace/a");
485        let key2 = ToolCacheKey::new("grep", "p2", "/workspace/b");
486        let key3 = ToolCacheKey::new("grep", "p3", "/other/c");
487
488        cache.insert(key1.clone(), "1".to_string());
489        cache.insert(key2.clone(), "2".to_string());
490        cache.insert(key3.clone(), "3".to_string());
491
492        // Invalidate all /workspace files
493        cache.invalidate_for_path("/workspace");
494
495        // Should remove entries with /workspace in the key
496        assert!(cache.get(&key1).is_none());
497        assert!(cache.get(&key2).is_none());
498        // /other/c should remain
499        assert!(cache.get(&key3).is_some());
500    }
501
502    #[test]
503    fn test_cache_hit_ratio_preserved_after_selective_invalidation() {
504        // Verify that selective invalidation doesn't destroy cache effectiveness
505        let mut cache = ToolResultCache::new(100);
506
507        // Insert 10 entries for different files
508        for i in 0..10 {
509            let key = ToolCacheKey::new("tool", "params", &format!("/file_{i}"));
510            cache.insert(key, format!("result_{i}"));
511        }
512
513        let stats_before = cache.stats();
514        assert_eq!(stats_before.current_size, 10);
515
516        // Access some entries to build hit count
517        for i in 0..5 {
518            let key = ToolCacheKey::new("tool", "params", &format!("/file_{i}"));
519            let _ = cache.get(&key);
520        }
521
522        let stats_mid = cache.stats();
523        let hits_before_invalidation = stats_mid.hits;
524
525        // Invalidate only one file
526        cache.invalidate_for_path("/file_0");
527
528        // The remaining 4 files' caches should still be valid
529        for i in 1..5 {
530            let key = ToolCacheKey::new("tool", "params", &format!("/file_{i}"));
531            assert!(cache.get(&key).is_some(), "Cache for /file_{i} should still be valid");
532        }
533
534        let stats_after = cache.stats();
535        // Should have preserved most of the cache (9 out of 10 entries)
536        assert_eq!(stats_after.current_size, 9);
537        // Additional hits from accessing the remaining entries
538        assert!(stats_after.hits > hits_before_invalidation);
539    }
540}