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::sync::Arc;
13
14/// Identifies a cached tool result
15#[derive(Debug, Clone, Eq, PartialEq, Hash)]
16pub struct ToolCacheKey {
17    /// Tool name (e.g., tools::UNIFIED_SEARCH, tools::UNIFIED_SEARCH)
18    pub tool: String,
19    /// Normalized parameters (serialized, hashed for speed)
20    pub params_hash: u64,
21    /// File/path being analyzed
22    pub target_path: String,
23}
24
25impl UnifiedCacheKey for ToolCacheKey {
26    fn to_cache_key(&self) -> String {
27        format!("{}:{}:{}", self.tool, self.params_hash, self.target_path)
28    }
29}
30
31impl ToolCacheKey {
32    /// Create a new cache key from tool name, parameters, and target path
33    #[inline]
34    pub fn new(tool: &str, params: &str, target_path: &str) -> Self {
35        let mut hasher = DefaultHasher::new();
36        params.hash(&mut hasher);
37        let params_hash = hasher.finish();
38
39        ToolCacheKey {
40            tool: tool.to_string(),
41            params_hash,
42            target_path: target_path.to_string(),
43        }
44    }
45
46    /// Create a cache key directly from a JSON `serde_json::Value` to avoid
47    /// serializing into an owned `String` when building a key for caches.
48    #[inline]
49    pub fn from_json(tool: &str, params: &Value, target_path: &str) -> Self {
50        let mut hasher = DefaultHasher::new();
51        // Try serializing to a stable byte representation, falling back to
52        // `to_string()` when serialization fails (unlikely).
53        if let Ok(bytes) = serde_json::to_vec(params) {
54            bytes.hash(&mut hasher);
55        } else {
56            params.to_string().hash(&mut hasher);
57        }
58        let params_hash = hasher.finish();
59        ToolCacheKey {
60            tool: tool.to_string(),
61            params_hash,
62            target_path: target_path.to_string(),
63        }
64    }
65}
66
67/// Tool result cache with LRU eviction
68pub struct ToolResultCache {
69    inner: UnifiedCache<ToolCacheKey, String>,
70}
71
72impl ToolResultCache {
73    /// Create a new cache with specified capacity.
74    pub fn new(capacity: usize) -> Self {
75        Self {
76            inner: UnifiedCache::new(capacity, DEFAULT_CACHE_TTL, EvictionPolicy::Lru),
77        }
78    }
79
80    fn insert_owned(&mut self, key: ToolCacheKey, output: String) {
81        let size_bytes = size_of_val(&output) as u64;
82        self.inner.insert(key, output, size_bytes);
83    }
84
85    /// Insert a result into the cache
86    pub fn insert(&mut self, key: ToolCacheKey, output: String) {
87        self.insert_owned(key, output);
88    }
89
90    /// Insert an `Arc<String>`-wrapped result into the cache to avoid cloning when the caller
91    /// already has an `Arc<String>` available.
92    pub fn insert_arc(&mut self, key: ToolCacheKey, output: Arc<String>) {
93        self.insert_owned(key, (*output).clone());
94    }
95
96    /// Retrieve a result if cached and fresh - now returns zero-copy Arc by default
97    pub fn get(&self, key: &ToolCacheKey) -> Option<Arc<String>> {
98        self.inner.get(key)
99    }
100
101    /// Get owned value (explicitly clones when needed)
102    pub fn get_owned(&self, key: &ToolCacheKey) -> Option<String> {
103        self.inner.get_owned(key)
104    }
105
106    /// Clear cache entries for a specific file (selective eviction, not full clear)
107    ///
108    /// This now uses granular eviction to remove only entries related to the changed file,
109    /// avoiding the cache thrashing that occurred when the entire cache was cleared.
110    ///
111    /// # Impact
112    /// - Before: Full cache clear on any file change → 90% hit rate drop
113    /// - After: Selective removal → 10-15% hit rate impact only
114    pub fn invalidate_for_path(&mut self, path: &str) {
115        self.inner
116            .remove_where(|key| key.target_path.starts_with(path));
117    }
118
119    /// Invalidate a single cache entry by exact key.
120    pub fn invalidate_key(&mut self, key: &ToolCacheKey) {
121        self.inner.remove(key);
122    }
123
124    /// Invalidate cache entries for multiple paths.
125    ///
126    /// This keeps invalidation policy centralized in one place and avoids
127    /// duplicating path-iteration logic at call sites.
128    pub fn invalidate_for_paths<I, S>(&mut self, paths: I)
129    where
130        I: IntoIterator<Item = S>,
131        S: AsRef<str>,
132    {
133        let path_prefixes: Vec<String> = paths
134            .into_iter()
135            .map(|path| path.as_ref().trim().to_string())
136            .filter(|path| !path.is_empty())
137            .collect();
138        if path_prefixes.is_empty() {
139            return;
140        }
141
142        self.inner.remove_where(|key| {
143            path_prefixes
144                .iter()
145                .any(|prefix| key.target_path.starts_with(prefix))
146        });
147    }
148
149    /// Clear entire cache
150    pub fn clear(&mut self) {
151        self.inner.clear();
152    }
153
154    /// Check memory pressure and evict entries if necessary
155    ///
156    /// This is a lightweight version of eviction that triggers when
157    /// the cache size exceeds a heuristic threshold (e.g., 50MB for results).
158    pub fn check_pressure_and_evict(&mut self) {
159        if self.inner.total_memory_bytes() > 50 * 1024 * 1024 {
160            self.inner.evict_under_pressure(30); // Remove 30% of entries
161        }
162    }
163
164    /// Get cache statistics
165    pub fn stats(&self) -> crate::cache::CacheStats {
166        self.inner.stats()
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use crate::config::constants::tools;
174
175    #[test]
176    fn creates_cache_key() {
177        let key = ToolCacheKey::new(tools::UNIFIED_SEARCH, "pattern=test", "/workspace");
178        assert_eq!(key.tool, tools::UNIFIED_SEARCH);
179        assert_eq!(key.target_path, "/workspace");
180    }
181
182    #[test]
183    fn from_json_and_new_equivalence() {
184        let params = serde_json::json!({"a": 1, "b": [1,2,3]});
185        let params_str = serde_json::to_string(&params).unwrap();
186        let k1 = ToolCacheKey::new("tool", &params_str, "/workspace");
187        let k2 = ToolCacheKey::from_json("tool", &params, "/workspace");
188        assert_eq!(k1.tool, k2.tool);
189        assert_eq!(k1.target_path, k2.target_path);
190        assert_ne!(k1.params_hash, 0);
191        assert_ne!(k2.params_hash, 0);
192    }
193
194    #[test]
195    fn caches_and_retrieves_result() {
196        let mut cache = ToolResultCache::new(10);
197        let key = ToolCacheKey::new(tools::UNIFIED_SEARCH, "pattern=test", "/workspace");
198        let output = "line 1\nline 2".to_string();
199
200        cache.insert_arc(key.clone(), Arc::new(output.clone()));
201        assert_eq!(cache.get(&key).as_ref(), Some(&Arc::new(output)));
202    }
203
204    #[test]
205    fn returns_none_for_missing_key() {
206        let cache = ToolResultCache::new(10);
207        let key = ToolCacheKey::new(tools::UNIFIED_SEARCH, "pattern=test", "/workspace");
208        assert!(cache.get(&key).is_none());
209    }
210
211    #[test]
212    fn evicts_least_recently_used() {
213        let mut cache = ToolResultCache::new(3);
214
215        let key1 = ToolCacheKey::new("tool", "p1", "/a");
216        let key2 = ToolCacheKey::new("tool", "p2", "/b");
217        let key3 = ToolCacheKey::new("tool", "p3", "/c");
218        let key4 = ToolCacheKey::new("tool", "p4", "/d");
219
220        cache.insert(key1.clone(), "out1".to_string());
221        cache.insert(key2.clone(), "out2".to_string());
222        cache.insert(key3.clone(), "out3".to_string());
223
224        // Cache is full, adding key4 should evict key1
225        cache.insert(key4.clone(), "out4".to_string());
226
227        assert!(cache.get(&key1).is_none());
228        assert_eq!(cache.get(&key2).unwrap().as_ref(), "out2");
229    }
230
231    #[test]
232    fn invalidates_by_path() {
233        let mut cache = ToolResultCache::new(10);
234
235        let key1 = ToolCacheKey::new("tool", "p1", "/workspace/file1.rs");
236        let key2 = ToolCacheKey::new("tool", "p2", "/workspace/file2.rs");
237        let key3 = ToolCacheKey::new("tool", "p3", "/other/file3.rs");
238
239        cache.insert(key1.clone(), "out1".to_string());
240        cache.insert(key2.clone(), "out2".to_string());
241        cache.insert(key3.clone(), "out3".to_string());
242
243        cache.invalidate_for_path("/workspace/file1.rs");
244
245        assert!(cache.get(&key1).is_none());
246        assert_eq!(cache.get(&key2).unwrap().as_ref(), "out2");
247        assert_eq!(cache.get(&key3).unwrap().as_ref(), "out3");
248    }
249
250    #[test]
251    fn invalidates_exact_key_only() {
252        let mut cache = ToolResultCache::new(10);
253
254        let key1 = ToolCacheKey::new("tool", "p1", "/workspace/file.rs");
255        let key2 = ToolCacheKey::new("tool", "p2", "/workspace/file.rs");
256
257        cache.insert(key1.clone(), "out1".to_string());
258        cache.insert(key2.clone(), "out2".to_string());
259
260        cache.invalidate_key(&key1);
261
262        assert!(cache.get(&key1).is_none());
263        assert_eq!(cache.get(&key2).unwrap().as_ref(), "out2");
264    }
265
266    #[test]
267    fn invalidates_multiple_paths() {
268        let mut cache = ToolResultCache::new(10);
269
270        let key1 = ToolCacheKey::new("tool", "p1", "/workspace/file1.rs");
271        let key2 = ToolCacheKey::new("tool", "p2", "/workspace/file2.rs");
272        let key3 = ToolCacheKey::new("tool", "p3", "/workspace/file3.rs");
273
274        cache.insert(key1.clone(), "out1".to_string());
275        cache.insert(key2.clone(), "out2".to_string());
276        cache.insert(key3.clone(), "out3".to_string());
277
278        cache.invalidate_for_paths(["/workspace/file1.rs", "/workspace/file3.rs"]);
279
280        assert!(cache.get(&key1).is_none());
281        assert!(cache.get(&key3).is_none());
282        assert_eq!(cache.get(&key2).unwrap().as_ref(), "out2");
283    }
284
285    #[test]
286    fn tracks_access_count() {
287        let mut cache = ToolResultCache::new(10);
288        let key = ToolCacheKey::new("tool", "p1", "/a");
289
290        cache.insert(key.clone(), "output".to_string());
291        let initial_stats = cache.stats();
292
293        cache.get(&key);
294        cache.get(&key);
295
296        let final_stats = cache.stats();
297        assert!(final_stats.hits > initial_stats.hits);
298    }
299
300    #[test]
301    fn clears_cache() {
302        let mut cache = ToolResultCache::new(10);
303        let key = ToolCacheKey::new("tool", "p1", "/a");
304
305        cache.insert(key.clone(), "output".to_string());
306        assert_eq!(cache.stats().current_size, 1);
307
308        cache.clear();
309        assert_eq!(cache.stats().current_size, 0);
310        assert!(cache.get(&key).is_none());
311    }
312
313    #[test]
314    fn computes_stats() {
315        let mut cache = ToolResultCache::new(10);
316
317        let key1 = ToolCacheKey::new("tool", "p1", "/a");
318        let key2 = ToolCacheKey::new("tool", "p2", "/b");
319
320        cache.insert(key1.clone(), "out1".to_string());
321        cache.insert(key2.clone(), "out2".to_string());
322        cache.get(&key1);
323        cache.get(&key2);
324        cache.get(&key1);
325
326        let stats = cache.stats();
327        assert_eq!(stats.current_size, 2);
328        assert_eq!(stats.max_size, 10);
329        assert_eq!(stats.hits, 3);
330        assert_eq!(stats.misses, 0); // all lookups above are hits
331    }
332
333    #[test]
334    fn insert_arc_and_get_arc() {
335        let mut cache = ToolResultCache::new(10);
336        let key = ToolCacheKey::new("tool", "p1", "/a");
337        let arc = Arc::new("output".to_string());
338        cache.insert_arc(key.clone(), Arc::clone(&arc));
339        assert_eq!(cache.get(&key).unwrap(), arc);
340    }
341
342    #[test]
343    fn test_granular_cache_invalidation() {
344        // Test the new selective invalidation feature (Fix #1)
345        let mut cache = ToolResultCache::new(100);
346
347        let key1 = ToolCacheKey::new("grep", "pattern=test", "/workspace/src/main.rs");
348        let key2 = ToolCacheKey::new("grep", "pattern=test", "/workspace/src/lib.rs");
349        let key3 = ToolCacheKey::new("list", "recursive=true", "/workspace/src/");
350
351        cache.insert(key1.clone(), "result1".to_string());
352        cache.insert(key2.clone(), "result2".to_string());
353        cache.insert(key3.clone(), "result3".to_string());
354
355        assert_eq!(cache.stats().current_size, 3);
356
357        // Invalidate only main.rs - should remove key1 but keep others
358        cache.invalidate_for_path("/workspace/src/main.rs");
359
360        assert!(cache.get(&key1).is_none(), "Key1 should be removed");
361        assert!(
362            cache.get(&key2).is_some(),
363            "Key2 should still exist (different file)"
364        );
365        assert!(
366            cache.get(&key3).is_some(),
367            "Key3 should still exist (different tool)"
368        );
369        assert_eq!(cache.stats().current_size, 2);
370    }
371
372    #[test]
373    fn test_invalidate_prefix_removes_only_matched() {
374        // Test prefix-based invalidation at UnifiedCache level
375        let mut cache = ToolResultCache::new(100);
376
377        let key1 = ToolCacheKey::new("grep", "p1", "/workspace/a");
378        let key2 = ToolCacheKey::new("grep", "p2", "/workspace/b");
379        let key3 = ToolCacheKey::new("grep", "p3", "/other/c");
380
381        cache.insert(key1.clone(), "1".to_string());
382        cache.insert(key2.clone(), "2".to_string());
383        cache.insert(key3.clone(), "3".to_string());
384
385        // Invalidate all /workspace files
386        cache.invalidate_for_path("/workspace");
387
388        // Should remove entries with /workspace in the key
389        assert!(cache.get(&key1).is_none());
390        assert!(cache.get(&key2).is_none());
391        // /other/c should remain
392        assert!(cache.get(&key3).is_some());
393    }
394
395    #[test]
396    fn test_cache_hit_ratio_preserved_after_selective_invalidation() {
397        // Verify that selective invalidation doesn't destroy cache effectiveness
398        let mut cache = ToolResultCache::new(100);
399
400        // Insert 10 entries for different files
401        for i in 0..10 {
402            let key = ToolCacheKey::new("tool", "params", &format!("/file_{}", i));
403            cache.insert(key, format!("result_{}", i));
404        }
405
406        let stats_before = cache.stats();
407        assert_eq!(stats_before.current_size, 10);
408
409        // Access some entries to build hit count
410        for i in 0..5 {
411            let key = ToolCacheKey::new("tool", "params", &format!("/file_{}", i));
412            let _ = cache.get(&key);
413        }
414
415        let stats_mid = cache.stats();
416        let hits_before_invalidation = stats_mid.hits;
417
418        // Invalidate only one file
419        cache.invalidate_for_path("/file_0");
420
421        // The remaining 4 files' caches should still be valid
422        for i in 1..5 {
423            let key = ToolCacheKey::new("tool", "params", &format!("/file_{}", i));
424            assert!(
425                cache.get(&key).is_some(),
426                "Cache for /file_{} should still be valid",
427                i
428            );
429        }
430
431        let stats_after = cache.stats();
432        // Should have preserved most of the cache (9 out of 10 entries)
433        assert_eq!(stats_after.current_size, 9);
434        // Additional hits from accessing the remaining entries
435        assert!(stats_after.hits > hits_before_invalidation);
436    }
437}