Skip to main content

koda_core/
sub_agent_cache.rs

1//! Sub-agent result caching.
2//!
3//! Caches sub-agent results keyed by `(agent_name, prompt_hash)` within a
4//! session. On cache hit, returns the previous response immediately —
5//! zero-cost retries for compaction-triggered re-planning.
6//!
7//! ## Why this exists
8//!
9//! After compaction, the model sometimes re-issues the same sub-agent
10//! call (it forgot it already ran). Without caching, this burns tokens
11//! and time re-running identical work. The cache makes this free.
12//!
13//! ## Invalidation
14//!
15//! Cache entries are invalidated when files are mutated (piggybacks on
16//! `FileReadCache` mtime tracking via a generation counter). This ensures
17//! stale results aren't served after the codebase changes.
18
19use std::collections::HashMap;
20use std::hash::{Hash, Hasher};
21use std::sync::{Arc, Mutex};
22
23/// Cache key: (agent_name, prompt_hash).
24type CacheKey = (String, u64);
25
26/// Shared sub-agent result cache.
27///
28/// Wrapped in `Arc<Mutex<>>` so parent and parallel sub-agents can share it.
29#[derive(Clone, Debug)]
30pub struct SubAgentCache {
31    inner: Arc<Mutex<CacheInner>>,
32}
33
34#[derive(Debug)]
35struct CacheInner {
36    entries: HashMap<CacheKey, CachedResult>,
37    /// Monotonically increasing counter bumped on every file mutation.
38    /// Entries stored with a stale generation are considered invalid.
39    generation: u64,
40}
41
42#[derive(Debug, Clone)]
43struct CachedResult {
44    response: String,
45    generation: u64,
46}
47
48impl SubAgentCache {
49    /// Create a new empty cache.
50    pub fn new() -> Self {
51        Self {
52            inner: Arc::new(Mutex::new(CacheInner {
53                entries: HashMap::new(),
54                generation: 0,
55            })),
56        }
57    }
58
59    /// Look up a cached result for the given agent + prompt.
60    ///
61    /// Returns `Some(response)` on cache hit (and generation is current),
62    /// `None` on miss or stale entry.
63    pub fn get(&self, agent_name: &str, prompt: &str) -> Option<String> {
64        let key = make_key(agent_name, prompt);
65        let inner = self.inner.lock().ok()?;
66        let entry = inner.entries.get(&key)?;
67        if entry.generation == inner.generation {
68            Some(entry.response.clone())
69        } else {
70            None
71        }
72    }
73
74    /// Store a sub-agent result in the cache.
75    pub fn put(&self, agent_name: &str, prompt: &str, response: &str) {
76        let key = make_key(agent_name, prompt);
77        if let Ok(mut inner) = self.inner.lock() {
78            let current_gen = inner.generation;
79            inner.entries.insert(
80                key,
81                CachedResult {
82                    response: response.to_string(),
83                    generation: current_gen,
84                },
85            );
86        }
87    }
88
89    /// Invalidate all cache entries by bumping the generation counter.
90    ///
91    /// Call this when any file mutation occurs (Write, Edit, Delete, Bash)
92    /// to ensure stale sub-agent results aren't reused.
93    pub fn invalidate(&self) {
94        if let Ok(mut inner) = self.inner.lock() {
95            inner.generation += 1;
96        }
97    }
98
99    /// Number of entries in the cache (for diagnostics/testing).
100    pub fn len(&self) -> usize {
101        self.inner
102            .lock()
103            .map(|inner| inner.entries.len())
104            .unwrap_or(0)
105    }
106
107    /// Whether the cache is empty.
108    pub fn is_empty(&self) -> bool {
109        self.len() == 0
110    }
111}
112
113impl Default for SubAgentCache {
114    fn default() -> Self {
115        Self::new()
116    }
117}
118
119/// Build the cache key from agent name + hash of the prompt.
120fn make_key(agent_name: &str, prompt: &str) -> CacheKey {
121    let mut hasher = std::collections::hash_map::DefaultHasher::new();
122    prompt.hash(&mut hasher);
123    (agent_name.to_string(), hasher.finish())
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn cache_hit_after_put() {
132        let cache = SubAgentCache::new();
133        cache.put("reviewer", "review this code", "looks good!");
134        assert_eq!(
135            cache.get("reviewer", "review this code"),
136            Some("looks good!".to_string())
137        );
138    }
139
140    #[test]
141    fn cache_miss_different_prompt() {
142        let cache = SubAgentCache::new();
143        cache.put("reviewer", "review this code", "looks good!");
144        assert_eq!(cache.get("reviewer", "review OTHER code"), None);
145    }
146
147    #[test]
148    fn cache_miss_different_agent() {
149        let cache = SubAgentCache::new();
150        cache.put("reviewer", "review this", "looks good!");
151        assert_eq!(cache.get("testgen", "review this"), None);
152    }
153
154    #[test]
155    fn invalidation_clears_stale_entries() {
156        let cache = SubAgentCache::new();
157        cache.put("reviewer", "prompt", "result");
158        assert!(cache.get("reviewer", "prompt").is_some());
159
160        cache.invalidate();
161        assert_eq!(cache.get("reviewer", "prompt"), None);
162    }
163
164    #[test]
165    fn entries_after_invalidation_are_fresh() {
166        let cache = SubAgentCache::new();
167        cache.put("reviewer", "old prompt", "old result");
168        cache.invalidate();
169        cache.put("reviewer", "new prompt", "new result");
170
171        // Old entry is stale
172        assert_eq!(cache.get("reviewer", "old prompt"), None);
173        // New entry is fresh
174        assert_eq!(
175            cache.get("reviewer", "new prompt"),
176            Some("new result".to_string())
177        );
178    }
179
180    #[test]
181    fn len_tracks_entries() {
182        let cache = SubAgentCache::new();
183        assert!(cache.is_empty());
184        cache.put("a", "p1", "r1");
185        cache.put("b", "p2", "r2");
186        assert_eq!(cache.len(), 2);
187    }
188
189    #[test]
190    fn shared_across_clones() {
191        let cache = SubAgentCache::new();
192        let clone = cache.clone();
193        cache.put("agent", "prompt", "result");
194        assert_eq!(clone.get("agent", "prompt"), Some("result".to_string()));
195    }
196}