1use 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#[derive(Debug, Clone, Eq, PartialEq, Hash)]
17pub struct ToolCacheKey {
18 pub tool: String,
20 pub params_hash: u64,
22 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 #[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 #[inline]
50 pub fn from_json(tool: &str, params: &Value, target_path: &str) -> Self {
51 let mut hasher = DefaultHasher::new();
52 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
68pub struct ToolResultCache {
70 inner: UnifiedCache<ToolCacheKey, String>,
71}
72
73impl ToolResultCache {
74 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 pub fn insert(&mut self, key: ToolCacheKey, output: String) {
88 self.insert_owned(key, output);
89 }
90
91 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 pub fn get(&self, key: &ToolCacheKey) -> Option<Arc<String>> {
100 self.inner.get(key)
101 }
102
103 pub fn get_owned(&self, key: &ToolCacheKey) -> Option<String> {
105 self.inner.get_owned(key)
106 }
107
108 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 pub fn invalidate_key(&mut self, key: &ToolCacheKey) {
127 self.inner.remove(key);
128 }
129
130 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 pub fn invalidate_after_external_command(&mut self) {
162 self.inner.remove_where(|key| !is_stable_tool_catalog_lookup(&key.tool));
163 }
164
165 pub fn clear(&mut self) {
167 self.inner.clear();
168 }
169
170 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); }
178 }
179
180 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(¶ms).unwrap();
224 let k1 = ToolCacheKey::new("tool", ¶ms_str, "/workspace");
225 let k2 = ToolCacheKey::from_json("tool", ¶ms, "/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 cache.get(&key2);
285 cache.get(&key3);
286
287 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); }
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 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 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 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 cache.invalidate_for_path("/workspace");
494
495 assert!(cache.get(&key1).is_none());
497 assert!(cache.get(&key2).is_none());
498 assert!(cache.get(&key3).is_some());
500 }
501
502 #[test]
503 fn test_cache_hit_ratio_preserved_after_selective_invalidation() {
504 let mut cache = ToolResultCache::new(100);
506
507 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 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 cache.invalidate_for_path("/file_0");
527
528 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 assert_eq!(stats_after.current_size, 9);
537 assert!(stats_after.hits > hits_before_invalidation);
539 }
540}