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 }
230 }
231
232 fn key(name: &str, hash: u64) -> CacheKey {
233 CacheKey::new(name, hash)
234 }
235
236 #[test]
237 fn miss_on_empty_cache() {
238 let mut cache = ToolResultCache::new(true, Some(Duration::from_mins(5)));
239 assert!(cache.get(&key("read", 1)).is_none());
240 assert_eq!(cache.misses(), 1);
241 assert_eq!(cache.hits(), 0);
242 }
243
244 #[test]
245 fn put_then_get_returns_cached() {
246 let mut cache = ToolResultCache::new(true, Some(Duration::from_mins(5)));
247 let out = make_output("file contents");
248 cache.put(key("read", 42), out.clone());
249 let result = cache.get(&key("read", 42));
250 assert!(result.is_some());
251 assert_eq!(result.unwrap().summary, "file contents");
252 assert_eq!(cache.hits(), 1);
253 assert_eq!(cache.misses(), 0);
254 }
255
256 #[test]
257 fn different_hash_is_miss() {
258 let mut cache = ToolResultCache::new(true, Some(Duration::from_mins(5)));
259 cache.put(key("read", 1), make_output("a"));
260 assert!(cache.get(&key("read", 2)).is_none());
261 }
262
263 #[test]
264 fn different_tool_name_is_miss() {
265 let mut cache = ToolResultCache::new(true, Some(Duration::from_mins(5)));
266 cache.put(key("read", 1), make_output("a"));
267 assert!(cache.get(&key("write", 1)).is_none());
268 }
269
270 #[test]
271 fn ttl_none_never_expires() {
272 let mut cache = ToolResultCache::new(true, None);
273 cache.put(key("read", 1), make_output("content"));
274 assert!(cache.get(&key("read", 1)).is_some());
276 assert_eq!(cache.hits(), 1);
277 }
278
279 #[test]
280 fn ttl_zero_duration_expires_immediately() {
281 let mut cache = ToolResultCache::new(true, Some(Duration::ZERO));
284 cache.put(key("read", 1), make_output("content"));
285 let result = cache.get(&key("read", 1));
286 assert!(
288 result.is_none(),
289 "Duration::ZERO entry must expire on first get()"
290 );
291 assert_eq!(cache.len(), 0, "expired entry must be removed from map");
292 }
293
294 #[test]
295 fn ttl_expired_returns_none() {
296 let mut cache = ToolResultCache::new(true, Some(Duration::from_millis(1)));
297 cache.put(key("read", 1), make_output("content"));
298 std::thread::sleep(Duration::from_millis(10));
299 assert!(cache.get(&key("read", 1)).is_none());
300 assert_eq!(cache.len(), 0);
302 }
303
304 #[test]
305 fn clear_removes_all_and_resets_counters() {
306 let mut cache = ToolResultCache::new(true, Some(Duration::from_mins(5)));
307 cache.put(key("read", 1), make_output("a"));
308 cache.put(key("web_scrape", 2), make_output("b"));
309 cache.get(&key("read", 1));
311 cache.get(&key("missing", 99));
312 assert_eq!(cache.hits(), 1);
313 assert_eq!(cache.misses(), 1);
314
315 cache.clear();
316 assert_eq!(cache.len(), 0);
317 assert_eq!(cache.hits(), 0);
318 assert_eq!(cache.misses(), 0);
319 assert!(cache.get(&key("read", 1)).is_none());
320 }
321
322 #[test]
323 fn disabled_cache_always_misses() {
324 let mut cache = ToolResultCache::new(false, Some(Duration::from_mins(5)));
325 cache.put(key("read", 1), make_output("content"));
326 assert!(cache.get(&key("read", 1)).is_none());
328 assert_eq!(cache.len(), 0);
329 assert_eq!(cache.misses(), 0);
331 }
332
333 #[test]
334 fn is_cacheable_returns_false_for_deny_list() {
335 assert!(!is_cacheable("bash", false));
336 assert!(!is_cacheable("memory_save", false));
337 assert!(!is_cacheable("memory_search", false));
338 assert!(!is_cacheable("scheduler", false));
339 assert!(!is_cacheable("write", false));
340 }
341
342 #[test]
347 fn is_cacheable_returns_false_for_mcp_origin() {
348 assert!(!is_cacheable("github_list_issues", true));
349 assert!(!is_cacheable("send_email", true));
350 assert!(is_cacheable("github_list_issues", false));
352 }
353
354 #[test]
355 fn is_cacheable_returns_true_for_read_only_tools() {
356 assert!(is_cacheable("read", false));
357 assert!(is_cacheable("web_scrape", false));
358 assert!(is_cacheable("search_code", false));
359 assert!(is_cacheable("load_skill", false));
360 assert!(is_cacheable("diagnostics", false));
361 }
362
363 #[test]
364 fn counter_increments_correctly() {
365 let mut cache = ToolResultCache::new(true, Some(Duration::from_mins(5)));
366 cache.put(key("read", 1), make_output("a"));
367 cache.put(key("read", 2), make_output("b"));
368
369 cache.get(&key("read", 1)); cache.get(&key("read", 1)); cache.get(&key("read", 99)); assert_eq!(cache.hits(), 2);
374 assert_eq!(cache.misses(), 1);
375 }
376
377 #[test]
378 fn ttl_secs_returns_zero_for_none() {
379 let cache = ToolResultCache::new(true, None);
380 assert_eq!(cache.ttl_secs(), 0);
381 }
382
383 #[test]
384 fn ttl_secs_returns_seconds_for_some() {
385 let cache = ToolResultCache::new(true, Some(Duration::from_mins(5)));
386 assert_eq!(cache.ttl_secs(), 300);
387 }
388
389 #[test]
390 fn lru_eviction_at_capacity() {
391 let mut cache = ToolResultCache::new(true, None);
392 for i in 0..MAX_CACHE_ENTRIES {
394 cache.put(key("read", i as u64), make_output("v"));
395 }
396 assert_eq!(cache.len(), MAX_CACHE_ENTRIES);
397 cache.put(key("read", MAX_CACHE_ENTRIES as u64), make_output("new"));
399 assert_eq!(cache.len(), MAX_CACHE_ENTRIES, "size must stay at cap");
400 assert!(
401 cache.get(&key("read", 0)).is_none(),
402 "oldest entry must be evicted"
403 );
404 assert!(
405 cache.get(&key("read", MAX_CACHE_ENTRIES as u64)).is_some(),
406 "new entry must be present"
407 );
408 }
409}