1use std::collections::{HashMap, HashSet, VecDeque};
5use std::sync::LazyLock;
6use std::time::{Duration, Instant};
7
8use zeph_common::ToolName;
9
10use crate::executor::ToolOutput;
11
12static NON_CACHEABLE_TOOLS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
19 HashSet::from([
20 "bash", "memory_save", "memory_search", "scheduler", "write", ])
26});
27
28#[must_use]
37pub fn is_cacheable(tool_name: &str, is_mcp: bool) -> bool {
38 if is_mcp {
39 return false;
40 }
41 !NON_CACHEABLE_TOOLS.contains(tool_name)
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Hash)]
46pub struct CacheKey {
47 pub tool_name: ToolName,
48 pub args_hash: u64,
49}
50
51impl CacheKey {
52 #[must_use]
53 pub fn new(tool_name: impl Into<ToolName>, args_hash: u64) -> Self {
54 Self {
55 tool_name: tool_name.into(),
56 args_hash,
57 }
58 }
59}
60
61#[derive(Debug, Clone)]
63pub struct CacheEntry {
64 pub output: ToolOutput,
65 pub inserted_at: Instant,
66}
67
68impl CacheEntry {
69 fn is_expired(&self, ttl: Duration) -> bool {
70 self.inserted_at.elapsed() > ttl
71 }
72}
73
74const MAX_CACHE_ENTRIES: usize = 512;
80
81#[derive(Debug)]
91pub struct ToolResultCache {
92 entries: HashMap<CacheKey, CacheEntry>,
93 insertion_order: VecDeque<CacheKey>,
95 ttl: Option<Duration>,
97 enabled: bool,
98 hits: u64,
99 misses: u64,
100}
101
102impl ToolResultCache {
103 #[must_use]
107 pub fn new(enabled: bool, ttl: Option<Duration>) -> Self {
108 Self {
109 entries: HashMap::new(),
110 insertion_order: VecDeque::new(),
111 ttl,
112 enabled,
113 hits: 0,
114 misses: 0,
115 }
116 }
117
118 pub fn get(&mut self, key: &CacheKey) -> Option<ToolOutput> {
122 if !self.enabled {
123 return None;
124 }
125 if let Some(entry) = self.entries.get(key) {
126 if self.ttl.is_some_and(|ttl| entry.is_expired(ttl)) {
127 self.entries.remove(key);
128 return None;
129 }
130 let output = entry.output.clone();
131 self.hits += 1;
132 return Some(output);
133 }
134 self.misses += 1;
135 None
136 }
137
138 pub fn put(&mut self, key: CacheKey, output: ToolOutput) {
143 if !self.enabled {
144 return;
145 }
146 if self.entries.len() >= MAX_CACHE_ENTRIES
147 && let Some(oldest_key) = self.insertion_order.pop_front()
148 {
149 self.entries.remove(&oldest_key);
150 tracing::debug!(
151 tool = %oldest_key.tool_name,
152 args_hash = oldest_key.args_hash,
153 "tool cache: evicted oldest entry (LRU cap {})",
154 MAX_CACHE_ENTRIES
155 );
156 }
157 self.insertion_order.push_back(key.clone());
158 self.entries.insert(
159 key,
160 CacheEntry {
161 output,
162 inserted_at: Instant::now(),
163 },
164 );
165 }
166
167 pub fn clear(&mut self) {
169 self.entries.clear();
170 self.insertion_order.clear();
171 self.hits = 0;
172 self.misses = 0;
173 }
174
175 #[must_use]
177 pub fn len(&self) -> usize {
178 self.entries.len()
179 }
180
181 #[must_use]
183 pub fn is_empty(&self) -> bool {
184 self.entries.is_empty()
185 }
186
187 #[must_use]
189 pub fn hits(&self) -> u64 {
190 self.hits
191 }
192
193 #[must_use]
195 pub fn misses(&self) -> u64 {
196 self.misses
197 }
198
199 #[must_use]
201 pub fn is_enabled(&self) -> bool {
202 self.enabled
203 }
204
205 #[must_use]
207 pub fn ttl_secs(&self) -> u64 {
208 self.ttl.map_or(0, |d| d.as_secs())
209 }
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215 use crate::ToolName;
216
217 fn make_output(summary: &str) -> ToolOutput {
218 ToolOutput {
219 tool_name: ToolName::new("test"),
220 summary: summary.to_owned(),
221 blocks_executed: 1,
222 filter_stats: None,
223 diff: None,
224 streamed: false,
225 terminal_id: None,
226 locations: None,
227 raw_response: None,
228 claim_source: None,
229 ..Default::default()
230 }
231 }
232
233 fn key(name: &str, hash: u64) -> CacheKey {
234 CacheKey::new(name, hash)
235 }
236
237 #[test]
238 fn miss_on_empty_cache() {
239 let mut cache = ToolResultCache::new(true, Some(Duration::from_mins(5)));
240 assert!(cache.get(&key("read", 1)).is_none());
241 assert_eq!(cache.misses(), 1);
242 assert_eq!(cache.hits(), 0);
243 }
244
245 #[test]
246 fn put_then_get_returns_cached() {
247 let mut cache = ToolResultCache::new(true, Some(Duration::from_mins(5)));
248 let out = make_output("file contents");
249 cache.put(key("read", 42), out.clone());
250 let result = cache.get(&key("read", 42));
251 assert!(result.is_some());
252 assert_eq!(result.unwrap().summary, "file contents");
253 assert_eq!(cache.hits(), 1);
254 assert_eq!(cache.misses(), 0);
255 }
256
257 #[test]
258 fn different_hash_is_miss() {
259 let mut cache = ToolResultCache::new(true, Some(Duration::from_mins(5)));
260 cache.put(key("read", 1), make_output("a"));
261 assert!(cache.get(&key("read", 2)).is_none());
262 }
263
264 #[test]
265 fn different_tool_name_is_miss() {
266 let mut cache = ToolResultCache::new(true, Some(Duration::from_mins(5)));
267 cache.put(key("read", 1), make_output("a"));
268 assert!(cache.get(&key("write", 1)).is_none());
269 }
270
271 #[test]
272 fn ttl_none_never_expires() {
273 let mut cache = ToolResultCache::new(true, None);
274 cache.put(key("read", 1), make_output("content"));
275 assert!(cache.get(&key("read", 1)).is_some());
277 assert_eq!(cache.hits(), 1);
278 }
279
280 #[test]
281 fn ttl_zero_duration_expires_immediately() {
282 let mut cache = ToolResultCache::new(true, Some(Duration::ZERO));
285 cache.put(key("read", 1), make_output("content"));
286 let result = cache.get(&key("read", 1));
287 assert!(
289 result.is_none(),
290 "Duration::ZERO entry must expire on first get()"
291 );
292 assert_eq!(cache.len(), 0, "expired entry must be removed from map");
293 }
294
295 #[test]
296 fn ttl_expired_returns_none() {
297 let mut cache = ToolResultCache::new(true, Some(Duration::from_millis(1)));
298 cache.put(key("read", 1), make_output("content"));
299 std::thread::sleep(Duration::from_millis(10));
300 assert!(cache.get(&key("read", 1)).is_none());
301 assert_eq!(cache.len(), 0);
303 }
304
305 #[test]
306 fn clear_removes_all_and_resets_counters() {
307 let mut cache = ToolResultCache::new(true, Some(Duration::from_mins(5)));
308 cache.put(key("read", 1), make_output("a"));
309 cache.put(key("web_scrape", 2), make_output("b"));
310 cache.get(&key("read", 1));
312 cache.get(&key("missing", 99));
313 assert_eq!(cache.hits(), 1);
314 assert_eq!(cache.misses(), 1);
315
316 cache.clear();
317 assert_eq!(cache.len(), 0);
318 assert_eq!(cache.hits(), 0);
319 assert_eq!(cache.misses(), 0);
320 assert!(cache.get(&key("read", 1)).is_none());
321 }
322
323 #[test]
324 fn disabled_cache_always_misses() {
325 let mut cache = ToolResultCache::new(false, Some(Duration::from_mins(5)));
326 cache.put(key("read", 1), make_output("content"));
327 assert!(cache.get(&key("read", 1)).is_none());
329 assert_eq!(cache.len(), 0);
330 assert_eq!(cache.misses(), 0);
332 }
333
334 #[test]
335 fn is_cacheable_returns_false_for_deny_list() {
336 assert!(!is_cacheable("bash", false));
337 assert!(!is_cacheable("memory_save", false));
338 assert!(!is_cacheable("memory_search", false));
339 assert!(!is_cacheable("scheduler", false));
340 assert!(!is_cacheable("write", false));
341 }
342
343 #[test]
348 fn is_cacheable_returns_false_for_mcp_origin() {
349 assert!(!is_cacheable("github_list_issues", true));
350 assert!(!is_cacheable("send_email", true));
351 assert!(is_cacheable("github_list_issues", false));
353 }
354
355 #[test]
356 fn is_cacheable_returns_true_for_read_only_tools() {
357 assert!(is_cacheable("read", false));
358 assert!(is_cacheable("web_scrape", false));
359 assert!(is_cacheable("search_code", false));
360 assert!(is_cacheable("load_skill", false));
361 assert!(is_cacheable("diagnostics", false));
362 }
363
364 #[test]
365 fn counter_increments_correctly() {
366 let mut cache = ToolResultCache::new(true, Some(Duration::from_mins(5)));
367 cache.put(key("read", 1), make_output("a"));
368 cache.put(key("read", 2), make_output("b"));
369
370 cache.get(&key("read", 1)); cache.get(&key("read", 1)); cache.get(&key("read", 99)); assert_eq!(cache.hits(), 2);
375 assert_eq!(cache.misses(), 1);
376 }
377
378 #[test]
379 fn ttl_secs_returns_zero_for_none() {
380 let cache = ToolResultCache::new(true, None);
381 assert_eq!(cache.ttl_secs(), 0);
382 }
383
384 #[test]
385 fn ttl_secs_returns_seconds_for_some() {
386 let cache = ToolResultCache::new(true, Some(Duration::from_mins(5)));
387 assert_eq!(cache.ttl_secs(), 300);
388 }
389
390 #[test]
391 fn lru_eviction_at_capacity() {
392 let mut cache = ToolResultCache::new(true, None);
393 for i in 0..MAX_CACHE_ENTRIES {
395 cache.put(key("read", i as u64), make_output("v"));
396 }
397 assert_eq!(cache.len(), MAX_CACHE_ENTRIES);
398 cache.put(key("read", MAX_CACHE_ENTRIES as u64), make_output("new"));
400 assert_eq!(cache.len(), MAX_CACHE_ENTRIES, "size must stay at cap");
401 assert!(
402 cache.get(&key("read", 0)).is_none(),
403 "oldest entry must be evicted"
404 );
405 assert!(
406 cache.get(&key("read", MAX_CACHE_ENTRIES as u64)).is_some(),
407 "new entry must be present"
408 );
409 }
410}