Skip to main content

llm_stack/tool/cache/
mod.rs

1//! Result caching for tool output.
2//!
3//! Large tool results are stored out-of-context and replaced with a compact
4//! summary + preview. Agents retrieve slices on demand via a `result_cache`
5//! tool, keeping the context window small while preserving full data access.
6//!
7//! # Architecture
8//!
9//! ```text
10//! ToolResultProcessor → ResultCache::store() → CacheBackend (Text, …)
11//!                                  ↕
12//!                     result_cache tool → CacheBackend::execute()
13//! ```
14//!
15//! The [`CacheBackend`] trait is the extension point. Each backend knows how
16//! to store one kind of data and expose operations on it. The [`ResultCache`]
17//! manages entries, expiration, and disk budget.
18
19use std::collections::HashMap;
20use std::fmt;
21use std::path::{Path, PathBuf};
22use std::time::{Duration, Instant};
23
24mod text;
25
26pub use text::TextBackend;
27
28// ── Backend trait ────────────────────────────────────────────────────
29
30/// The kind of backend storing a cached result.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum BackendKind {
33    /// Plain-text file with line-oriented operations.
34    Text,
35}
36
37impl fmt::Display for BackendKind {
38    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39        match self {
40            Self::Text => write!(f, "text"),
41        }
42    }
43}
44
45/// Operations that can be performed on a cached result.
46#[derive(Debug, Clone)]
47pub enum CacheOp {
48    /// Read a range of lines (1-indexed, inclusive).
49    Read {
50        /// First line to read (1-indexed).
51        start: usize,
52        /// Last line to read (1-indexed, inclusive).
53        end: usize,
54    },
55    /// Search for a regex pattern, returning matches with context.
56    Grep {
57        /// Regex pattern to search for.
58        pattern: String,
59        /// Number of context lines around each match.
60        context_lines: usize,
61    },
62    /// Return the first N lines.
63    Head {
64        /// Number of lines to return.
65        lines: usize,
66    },
67    /// Return the last N lines.
68    Tail {
69        /// Number of lines to return.
70        lines: usize,
71    },
72    /// Return statistics about the cached data.
73    Stats,
74}
75
76/// Statistics about a cached result.
77#[derive(Debug, Clone)]
78pub struct CacheStats {
79    /// Total number of lines (for text) or rows (for tabular data).
80    pub line_count: usize,
81    /// Size on disk in bytes.
82    pub disk_bytes: u64,
83    /// Human-readable summary (e.g. "1,234 lines, 56 KB").
84    pub summary: String,
85}
86
87/// Errors from cache operations.
88#[derive(Debug, thiserror::Error)]
89pub enum CacheError {
90    /// An I/O error occurred reading or writing cache data.
91    #[error("I/O error: {0}")]
92    Io(#[from] std::io::Error),
93
94    /// A grep pattern failed to compile as a regex.
95    #[error("invalid regex pattern: {0}")]
96    InvalidPattern(String),
97
98    /// The requested cache entry does not exist.
99    #[error("cache entry not found: {ref_id}")]
100    NotFound {
101        /// The reference ID that was not found.
102        ref_id: String,
103    },
104
105    /// The requested cache entry has expired.
106    #[error("cache entry expired: {ref_id}")]
107    Expired {
108        /// The reference ID that expired.
109        ref_id: String,
110    },
111
112    /// The requested line range is outside the cached data.
113    #[error("line range out of bounds: requested {start}..{end}, have {total} lines")]
114    OutOfBounds {
115        /// Requested start line.
116        start: usize,
117        /// Requested end line.
118        end: usize,
119        /// Actual line count.
120        total: usize,
121    },
122}
123
124/// A backend that stores and operates on one cached result.
125///
126/// Implementations are created by [`ResultCache::store`] and live for the
127/// lifetime of the cache entry. Each backend owns its backing storage
128/// (file, buffer, etc.).
129///
130/// # Extending
131///
132/// To add a new backend (e.g. Parquet/Arrow for tabular data):
133///
134/// 1. Implement `CacheBackend` with the new storage format
135/// 2. Add a variant to [`BackendKind`]
136/// 3. Update [`ResultCache::store`] to select the new backend
137pub trait CacheBackend: Send + Sync {
138    /// What kind of backend this is.
139    fn kind(&self) -> BackendKind;
140
141    /// Execute an operation on the cached data.
142    fn execute(&self, op: CacheOp) -> Result<String, CacheError>;
143
144    /// Statistics about the cached data.
145    fn stats(&self) -> Result<CacheStats, CacheError>;
146
147    /// A short preview of the data (first N lines/rows).
148    fn preview(&self, max_lines: usize) -> Result<String, CacheError>;
149
150    /// Size on disk in bytes.
151    fn disk_bytes(&self) -> Result<u64, CacheError>;
152}
153
154// ── Cache entry ──────────────────────────────────────────────────────
155
156/// A single cached tool result.
157pub struct CacheEntry {
158    /// Unique reference ID (e.g. `ref_0001`).
159    pub ref_id: String,
160    /// The backend that stores and operates on this result.
161    pub backend: Box<dyn CacheBackend>,
162    /// Which tool produced this result.
163    pub tool_name: String,
164    /// When the entry was created.
165    pub created_at: Instant,
166    /// When the entry expires and can be evicted.
167    pub expires_at: Instant,
168}
169
170impl fmt::Debug for CacheEntry {
171    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172        f.debug_struct("CacheEntry")
173            .field("ref_id", &self.ref_id)
174            .field("tool_name", &self.tool_name)
175            .field("kind", &self.backend.kind())
176            .finish_non_exhaustive()
177    }
178}
179
180// ── Configuration ────────────────────────────────────────────────────
181
182/// Configuration for the result cache.
183#[derive(Debug, Clone)]
184pub struct ResultCacheConfig {
185    /// How long entries survive before expiration.
186    pub ttl: Duration,
187    /// Maximum total disk usage across all entries.
188    pub max_disk_bytes: u64,
189    /// Number of preview lines to include in the context summary.
190    pub preview_lines: usize,
191}
192
193impl Default for ResultCacheConfig {
194    fn default() -> Self {
195        Self {
196            ttl: Duration::from_secs(30 * 60), // 30 minutes
197            max_disk_bytes: 100 * 1024 * 1024, // 100 MB
198            preview_lines: 20,
199        }
200    }
201}
202
203// ── ResultCache ──────────────────────────────────────────────────────
204
205/// Manages cached tool results with expiration and disk budgets.
206///
207/// The cache stores large tool outputs on disk and provides agents with
208/// random-access operations (read, grep, head, tail, stats) via
209/// [`CacheBackend`] implementations.
210pub struct ResultCache {
211    entries: HashMap<String, CacheEntry>,
212    config: ResultCacheConfig,
213    base_dir: PathBuf,
214    next_id: u64,
215}
216
217impl fmt::Debug for ResultCache {
218    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
219        f.debug_struct("ResultCache")
220            .field("entries", &self.entries.len())
221            .field("base_dir", &self.base_dir)
222            .finish_non_exhaustive()
223    }
224}
225
226impl ResultCache {
227    /// Create a new cache rooted at `base_dir`.
228    ///
229    /// The directory is created if it doesn't exist.
230    ///
231    /// # Errors
232    ///
233    /// Returns `CacheError::Io` if the directory can't be created.
234    pub fn new(
235        base_dir: impl Into<PathBuf>,
236        config: ResultCacheConfig,
237    ) -> Result<Self, CacheError> {
238        let base_dir = base_dir.into();
239        std::fs::create_dir_all(&base_dir)?;
240        Ok(Self {
241            entries: HashMap::new(),
242            config,
243            base_dir,
244            next_id: 0,
245        })
246    }
247
248    /// Store a tool result, returning the reference ID.
249    ///
250    /// The `backend_kind` determines which backend stores the data.
251    /// Currently only [`BackendKind::Text`] is supported.
252    ///
253    /// # Errors
254    ///
255    /// Returns `CacheError::Io` if writing to disk fails.
256    pub fn store(
257        &mut self,
258        tool_name: &str,
259        content: &str,
260        backend_kind: BackendKind,
261    ) -> Result<String, CacheError> {
262        let ref_id = self.generate_ref_id();
263        let now = Instant::now();
264
265        let backend: Box<dyn CacheBackend> = match backend_kind {
266            BackendKind::Text => {
267                let path = self.base_dir.join(format!("{ref_id}.txt"));
268                Box::new(TextBackend::store(content, &path)?)
269            }
270        };
271
272        let entry = CacheEntry {
273            ref_id: ref_id.clone(),
274            backend,
275            tool_name: tool_name.to_string(),
276            created_at: now,
277            expires_at: now + self.config.ttl,
278        };
279
280        self.entries.insert(ref_id.clone(), entry);
281        Ok(ref_id)
282    }
283
284    /// Get a cache entry by reference ID.
285    pub fn get(&self, ref_id: &str) -> Result<&CacheEntry, CacheError> {
286        let entry = self
287            .entries
288            .get(ref_id)
289            .ok_or_else(|| CacheError::NotFound {
290                ref_id: ref_id.to_string(),
291            })?;
292
293        if Instant::now() >= entry.expires_at {
294            return Err(CacheError::Expired {
295                ref_id: ref_id.to_string(),
296            });
297        }
298
299        Ok(entry)
300    }
301
302    /// Execute an operation on a cached entry.
303    pub fn execute_op(&self, ref_id: &str, op: CacheOp) -> Result<String, CacheError> {
304        let entry = self.get(ref_id)?;
305        entry.backend.execute(op)
306    }
307
308    /// Remove all expired entries, returning the count removed.
309    pub fn evict_expired(&mut self) -> usize {
310        let now = Instant::now();
311        let expired: Vec<String> = self
312            .entries
313            .iter()
314            .filter(|(_, e)| now >= e.expires_at)
315            .map(|(k, _)| k.clone())
316            .collect();
317
318        let count = expired.len();
319        for ref_id in &expired {
320            if let Some(entry) = self.entries.remove(ref_id) {
321                // Best-effort cleanup of backing files
322                self.cleanup_entry(&entry);
323            }
324        }
325        count
326    }
327
328    /// Total disk bytes across all entries.
329    pub fn total_bytes(&self) -> u64 {
330        self.entries
331            .values()
332            .filter_map(|e| e.backend.disk_bytes().ok())
333            .sum()
334    }
335
336    /// Number of active entries.
337    pub fn len(&self) -> usize {
338        self.entries.len()
339    }
340
341    /// Whether the cache is empty.
342    pub fn is_empty(&self) -> bool {
343        self.entries.is_empty()
344    }
345
346    /// Iterates over all cache entries.
347    ///
348    /// Returns `(ref_id, entry)` pairs in arbitrary order.
349    pub fn iter(&self) -> impl Iterator<Item = (&str, &CacheEntry)> {
350        self.entries.iter().map(|(k, v)| (k.as_str(), v))
351    }
352
353    /// The configured number of preview lines.
354    pub fn preview_lines(&self) -> usize {
355        self.config.preview_lines
356    }
357
358    /// The base directory for cache files.
359    pub fn base_dir(&self) -> &Path {
360        &self.base_dir
361    }
362
363    fn generate_ref_id(&mut self) -> String {
364        self.next_id += 1;
365        format!("ref_{:04x}", self.next_id)
366    }
367
368    fn cleanup_entry(&self, entry: &CacheEntry) {
369        match entry.backend.kind() {
370            BackendKind::Text => {
371                let path = self.base_dir.join(format!("{}.txt", entry.ref_id));
372                let _ = std::fs::remove_file(path);
373            }
374        }
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381
382    fn test_cache(dir: &Path) -> ResultCache {
383        ResultCache::new(dir, ResultCacheConfig::default()).unwrap()
384    }
385
386    #[test]
387    fn test_store_and_get() {
388        let dir = tempfile::tempdir().unwrap();
389        let mut cache = test_cache(dir.path());
390
391        let ref_id = cache
392            .store("db_sql", "line1\nline2\nline3", BackendKind::Text)
393            .unwrap();
394        assert!(ref_id.starts_with("ref_"));
395
396        let entry = cache.get(&ref_id).unwrap();
397        assert_eq!(entry.tool_name, "db_sql");
398        assert_eq!(entry.backend.kind(), BackendKind::Text);
399    }
400
401    #[test]
402    fn test_get_not_found() {
403        let dir = tempfile::tempdir().unwrap();
404        let cache = test_cache(dir.path());
405
406        let result = cache.get("ref_nonexistent");
407        assert!(matches!(result, Err(CacheError::NotFound { .. })));
408    }
409
410    #[test]
411    fn test_execute_op() {
412        let dir = tempfile::tempdir().unwrap();
413        let mut cache = test_cache(dir.path());
414
415        let ref_id = cache
416            .store("test", "alpha\nbeta\ngamma\ndelta", BackendKind::Text)
417            .unwrap();
418
419        let result = cache
420            .execute_op(&ref_id, CacheOp::Head { lines: 2 })
421            .unwrap();
422        assert_eq!(result, "alpha\nbeta");
423    }
424
425    #[test]
426    fn test_evict_expired() {
427        let dir = tempfile::tempdir().unwrap();
428        let config = ResultCacheConfig {
429            ttl: Duration::from_millis(1),
430            ..Default::default()
431        };
432        let mut cache = ResultCache::new(dir.path(), config).unwrap();
433
434        cache.store("test", "data", BackendKind::Text).unwrap();
435        assert_eq!(cache.len(), 1);
436
437        // Wait for expiry
438        std::thread::sleep(Duration::from_millis(10));
439
440        let evicted = cache.evict_expired();
441        assert_eq!(evicted, 1);
442        assert!(cache.is_empty());
443    }
444
445    #[test]
446    fn test_expired_entry_returns_error() {
447        let dir = tempfile::tempdir().unwrap();
448        let config = ResultCacheConfig {
449            ttl: Duration::from_millis(1),
450            ..Default::default()
451        };
452        let mut cache = ResultCache::new(dir.path(), config).unwrap();
453
454        let ref_id = cache.store("test", "data", BackendKind::Text).unwrap();
455
456        std::thread::sleep(Duration::from_millis(10));
457
458        assert!(matches!(
459            cache.get(&ref_id),
460            Err(CacheError::Expired { .. })
461        ));
462    }
463
464    #[test]
465    fn test_total_bytes() {
466        let dir = tempfile::tempdir().unwrap();
467        let mut cache = test_cache(dir.path());
468
469        cache
470            .store("test", "hello world", BackendKind::Text)
471            .unwrap();
472        assert!(cache.total_bytes() > 0);
473    }
474
475    #[test]
476    fn test_multiple_entries() {
477        let dir = tempfile::tempdir().unwrap();
478        let mut cache = test_cache(dir.path());
479
480        let r1 = cache.store("t1", "data1", BackendKind::Text).unwrap();
481        let r2 = cache.store("t2", "data2", BackendKind::Text).unwrap();
482
483        assert_ne!(r1, r2);
484        assert_eq!(cache.len(), 2);
485
486        assert_eq!(cache.get(&r1).unwrap().tool_name, "t1");
487        assert_eq!(cache.get(&r2).unwrap().tool_name, "t2");
488    }
489
490    #[test]
491    fn test_iter_returns_all_entries() {
492        let dir = tempfile::tempdir().unwrap();
493        let mut cache = test_cache(dir.path());
494
495        let r1 = cache
496            .store("db_sql", "SELECT 1", BackendKind::Text)
497            .unwrap();
498        let r2 = cache
499            .store("web_search", "results here", BackendKind::Text)
500            .unwrap();
501        let r3 = cache
502            .store("db_sql", "SELECT 2", BackendKind::Text)
503            .unwrap();
504
505        let entries: HashMap<String, String> = cache
506            .iter()
507            .map(|(ref_id, entry)| (ref_id.to_string(), entry.tool_name.clone()))
508            .collect();
509
510        assert_eq!(entries.len(), 3);
511        assert_eq!(entries[&r1], "db_sql");
512        assert_eq!(entries[&r2], "web_search");
513        assert_eq!(entries[&r3], "db_sql");
514    }
515
516    #[test]
517    fn test_backend_kind_display() {
518        assert_eq!(format!("{}", BackendKind::Text), "text");
519    }
520
521    #[test]
522    fn test_cache_debug() {
523        let dir = tempfile::tempdir().unwrap();
524        let cache = test_cache(dir.path());
525        let debug = format!("{cache:?}");
526        assert!(debug.contains("ResultCache"));
527    }
528
529    #[test]
530    fn test_entry_debug() {
531        let dir = tempfile::tempdir().unwrap();
532        let mut cache = test_cache(dir.path());
533        let ref_id = cache.store("tool", "data", BackendKind::Text).unwrap();
534        let entry = cache.get(&ref_id).unwrap();
535        let debug = format!("{entry:?}");
536        assert!(debug.contains("CacheEntry"));
537        assert!(debug.contains(&ref_id));
538    }
539
540    #[test]
541    fn test_config_default() {
542        let config = ResultCacheConfig::default();
543        assert_eq!(config.ttl, Duration::from_secs(30 * 60));
544        assert_eq!(config.max_disk_bytes, 100 * 1024 * 1024);
545        assert_eq!(config.preview_lines, 20);
546    }
547
548    #[test]
549    fn test_evict_cleans_files() {
550        let dir = tempfile::tempdir().unwrap();
551        let config = ResultCacheConfig {
552            ttl: Duration::from_millis(1),
553            ..Default::default()
554        };
555        let mut cache = ResultCache::new(dir.path(), config).unwrap();
556
557        let ref_id = cache.store("test", "data", BackendKind::Text).unwrap();
558        let file_path = dir.path().join(format!("{ref_id}.txt"));
559        assert!(file_path.exists());
560
561        std::thread::sleep(Duration::from_millis(10));
562        cache.evict_expired();
563
564        assert!(!file_path.exists());
565    }
566}