Skip to main content

zeph_tools/
cache.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use 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
12/// Tools that must never have their results cached due to side effects.
13///
14/// Any tool with side effects (writes, state mutations, external actions) MUST be listed here.
15/// MCP-origin tools are non-cacheable by default — they are third-party and opaque — see
16/// `is_cacheable`'s `is_mcp` parameter. `memory_search` is excluded to avoid stale results
17/// after `memory_save` calls.
18static NON_CACHEABLE_TOOLS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
19    HashSet::from([
20        "bash",          // shell commands have side effects and depend on mutable state
21        "memory_save",   // writes to memory store
22        "memory_search", // results may change after memory_save; consistency > performance
23        "scheduler",     // creates/modifies scheduled tasks
24        "write",         // writes files
25    ])
26});
27
28/// Returns `true` if the tool's results can be safely cached.
29///
30/// `is_mcp` must be resolved by the caller via
31/// [`ToolDef::is_mcp_tool`](crate::registry::ToolDef::is_mcp_tool) (`server_id.is_some()`).
32/// Real MCP tool ids are `{server_id}_{name}` (`McpTool::sanitized_id`) and carry no reliable
33/// string prefix to pattern-match on here (#5712, #5733), so this function cannot infer MCP
34/// origin from `tool_name` alone. MCP-origin tools are always non-cacheable by default since
35/// they are third-party and may have unknown side effects.
36#[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/// Composite key identifying a unique tool invocation.
45#[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/// A single cached tool result with insertion timestamp.
62#[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
74/// Maximum number of entries retained in the cache at any time.
75///
76/// Long sessions accumulate tool results indefinitely without a cap, leading to unbounded memory
77/// growth. 512 entries covers typical session breadth with negligible overhead while bounding
78/// worst-case memory for long-running TUI sessions.
79const MAX_CACHE_ENTRIES: usize = 512;
80
81/// In-memory, session-scoped cache for tool results.
82///
83/// # Design
84/// - `ttl = None` means entries never expire (useful for batch/scripted sessions).
85/// - `ttl = Some(d)` means entries expire after duration `d`.
86/// - Lazy eviction: expired entries are removed on `get()`.
87/// - LRU eviction: when the entry count reaches `MAX_CACHE_ENTRIES`, the least-recently-inserted
88///   entry is evicted to bound memory growth in long sessions.
89/// - Not `Send + Sync` by design — accessed only from the agent's single-threaded loop.
90#[derive(Debug)]
91pub struct ToolResultCache {
92    entries: HashMap<CacheKey, CacheEntry>,
93    /// Insertion-order key list for LRU eviction (front = oldest).
94    insertion_order: VecDeque<CacheKey>,
95    /// `None` = never expire. `Some(d)` = expire after `d`.
96    ttl: Option<Duration>,
97    enabled: bool,
98    hits: u64,
99    misses: u64,
100}
101
102impl ToolResultCache {
103    /// Create a new cache with the given TTL and enabled state.
104    ///
105    /// `ttl = None` means entries never expire.
106    #[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    /// Look up a cached result. Returns `None` on miss or if expired.
119    ///
120    /// Expired entries are removed lazily on access.
121    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    /// Store a tool result in the cache.
139    ///
140    /// When the cache is at capacity (`MAX_CACHE_ENTRIES`), the oldest entry is evicted
141    /// before inserting the new one to prevent unbounded memory growth in long sessions.
142    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    /// Remove all entries and reset hit/miss counters.
168    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    /// Number of entries currently in the cache (including potentially expired ones).
176    #[must_use]
177    pub fn len(&self) -> usize {
178        self.entries.len()
179    }
180
181    /// Returns `true` if the cache is empty.
182    #[must_use]
183    pub fn is_empty(&self) -> bool {
184        self.entries.is_empty()
185    }
186
187    /// Total cache hits since last `clear()`.
188    #[must_use]
189    pub fn hits(&self) -> u64 {
190        self.hits
191    }
192
193    /// Total cache misses since last `clear()`.
194    #[must_use]
195    pub fn misses(&self) -> u64 {
196        self.misses
197    }
198
199    /// Whether the cache is enabled.
200    #[must_use]
201    pub fn is_enabled(&self) -> bool {
202        self.enabled
203    }
204
205    /// TTL in seconds for display (0 = never expire).
206    #[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        // Without TTL, entry should always be present
276        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        // Duration::ZERO → elapsed() > Duration::ZERO is true immediately (any nanosecond suffices).
283        // This verifies the behaviour: does not panic, and the entry is gone after get().
284        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        // Entry expired on access — None and evicted from map.
288        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        // expired entry is evicted, re-query also miss
302        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        // generate some hits/misses
311        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        // put is a no-op when disabled
328        assert!(cache.get(&key("read", 1)).is_none());
329        assert_eq!(cache.len(), 0);
330        // misses counter also stays 0 when disabled
331        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    /// #5733 regression: MCP origin must be resolved by the caller (`ToolDef::is_mcp_tool`),
344    /// not inferred from a `"mcp_"` string prefix — real MCP tool ids are `{server_id}_{name}`
345    /// (`McpTool::sanitized_id`) and never carry that prefix, so the old check silently never
346    /// matched and MCP results WERE being cached, the opposite of the intended behavior.
347    #[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        // A non-deny-listed, non-MCP tool with the exact same real-world id shape is cacheable.
352        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)); // hit
371        cache.get(&key("read", 1)); // hit
372        cache.get(&key("read", 99)); // miss
373
374        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        // Fill to capacity.
394        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        // Inserting one more should evict the oldest (hash=0).
399        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}