Skip to main content

heartbit_core/agent/
cache.rs

1//! LLM response caching for deterministic replay and cost reduction.
2
3use parking_lot::Mutex;
4
5use crate::llm::types::{CompletionResponse, Message};
6use crate::util::fnv1a_hash;
7
8/// LRU cache for LLM completion responses.
9///
10/// Thread-safe via `parking_lot::Mutex` (never held across `.await`).
11/// Entries are keyed by FNV-1a hash of (system_prompt, messages, sorted tool names).
12///
13/// Uses a `Vec` with move-to-front on hit and eviction from back, giving O(n)
14/// operations per access. This is efficient for typical capacities (10–100).
15/// For very large caches (1000+), consider an alternative implementation.
16///
17/// `parking_lot::Mutex` is adopted on this hot path (every cached LLM call) for
18/// ~2× faster acquisition vs. `std::sync::Mutex`; see T2 in
19/// `tasks/performance-audit-heartbit-core-2026-05-06.md`.
20pub struct ResponseCache {
21    entries: Mutex<Vec<(u64, CompletionResponse)>>,
22    capacity: usize,
23}
24
25impl std::fmt::Debug for ResponseCache {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        f.debug_struct("ResponseCache")
28            .field("capacity", &self.capacity)
29            .field("len", &self.entries.lock().len())
30            .finish()
31    }
32}
33
34impl ResponseCache {
35    /// Create a new cache with the given maximum number of entries.
36    pub fn new(capacity: usize) -> Self {
37        Self {
38            entries: Mutex::new(Vec::with_capacity(capacity)),
39            capacity,
40        }
41    }
42
43    /// Look up a cached response by key. On hit, moves the entry to the front (LRU).
44    pub fn get(&self, key: u64) -> Option<CompletionResponse> {
45        let mut entries = self.entries.lock();
46        if let Some(pos) = entries.iter().position(|(k, _)| *k == key) {
47            let entry = entries.remove(pos);
48            let response = entry.1.clone();
49            entries.insert(0, entry);
50            Some(response)
51        } else {
52            None
53        }
54    }
55
56    /// Insert a response into the cache. Evicts the least-recently-used entry
57    /// if at capacity.
58    pub fn put(&self, key: u64, response: CompletionResponse) {
59        let mut entries = self.entries.lock();
60        // Remove existing entry with same key (will be re-inserted at front)
61        if let Some(pos) = entries.iter().position(|(k, _)| *k == key) {
62            entries.remove(pos);
63        }
64        // Evict from back if at capacity
65        if entries.len() >= self.capacity {
66            entries.pop();
67        }
68        entries.insert(0, (key, response));
69    }
70
71    /// Compute a cache key from the request components.
72    ///
73    /// Uses FNV-1a hash of system prompt, serialized messages, and sorted tool names.
74    ///
75    /// **Backward-compatible** with single-tenant code: prefer
76    /// [`ResponseCache::compute_key_scoped`] when the runner is shared across
77    /// tenants/users (F-AGENT-3).
78    pub fn compute_key(system_prompt: &str, messages: &[Message], tool_names: &[&str]) -> u64 {
79        Self::compute_key_scoped(system_prompt, messages, tool_names, None)
80    }
81
82    /// Compute a cache key including a tenant/user namespace.
83    ///
84    /// SECURITY (F-AGENT-3): when a single `AgentRunner` is shared across
85    /// tenants (typical daemon deployment), the cache key MUST disambiguate
86    /// otherwise-identical requests — otherwise tenant A's cached response
87    /// could be served to tenant B if their system_prompt + messages happened
88    /// to coincide. Pass `Some("{tenant_id}:{user_id}")` (or any unique
89    /// namespace string) to scope the cache.
90    pub fn compute_key_scoped(
91        system_prompt: &str,
92        messages: &[Message],
93        tool_names: &[&str],
94        namespace: Option<&str>,
95    ) -> u64 {
96        let mut sorted_names: Vec<&str> = tool_names.to_vec();
97        sorted_names.sort();
98
99        let messages_json = serde_json::to_string(messages).expect("messages serialize infallibly");
100
101        let mut data = Vec::new();
102        if let Some(ns) = namespace {
103            data.extend_from_slice(b"ns=");
104            data.extend_from_slice(ns.as_bytes());
105            data.push(0);
106        }
107        data.extend_from_slice(system_prompt.as_bytes());
108        data.push(0); // separator
109        data.extend_from_slice(messages_json.as_bytes());
110        data.push(0); // separator
111        for name in &sorted_names {
112            data.extend_from_slice(name.as_bytes());
113            data.push(0);
114        }
115
116        fnv1a_hash(&data)
117    }
118
119    /// Remove all entries from the cache.
120    pub fn clear(&self) {
121        self.entries.lock().clear();
122    }
123
124    /// Number of entries currently in the cache.
125    pub fn len(&self) -> usize {
126        self.entries.lock().len()
127    }
128
129    /// Returns true if the cache contains no entries.
130    pub fn is_empty(&self) -> bool {
131        self.entries.lock().is_empty()
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use crate::llm::types::{ContentBlock, Message, StopReason, TokenUsage};
139
140    fn make_response(text: &str) -> CompletionResponse {
141        CompletionResponse {
142            content: vec![ContentBlock::Text { text: text.into() }],
143            stop_reason: StopReason::EndTurn,
144            reasoning: None,
145            usage: TokenUsage::default(),
146            model: None,
147        }
148    }
149
150    #[test]
151    fn cache_stores_and_retrieves() {
152        let cache = ResponseCache::new(10);
153        let response = make_response("hello");
154        let key = 42;
155        cache.put(key, response.clone());
156
157        let cached = cache.get(key);
158        assert!(cached.is_some());
159        assert_eq!(cached.unwrap().text(), "hello");
160    }
161
162    #[test]
163    fn cache_miss_returns_none() {
164        let cache = ResponseCache::new(10);
165        assert!(cache.get(999).is_none());
166    }
167
168    #[test]
169    fn lru_eviction() {
170        let cache = ResponseCache::new(3);
171        cache.put(1, make_response("one"));
172        cache.put(2, make_response("two"));
173        cache.put(3, make_response("three"));
174
175        // Cache is full. Insert a 4th entry — should evict key 1 (oldest).
176        cache.put(4, make_response("four"));
177        assert_eq!(cache.len(), 3);
178        assert!(cache.get(1).is_none());
179        assert!(cache.get(2).is_some());
180        assert!(cache.get(3).is_some());
181        assert!(cache.get(4).is_some());
182    }
183
184    #[test]
185    fn lru_access_refreshes_order() {
186        let cache = ResponseCache::new(3);
187        cache.put(1, make_response("one"));
188        cache.put(2, make_response("two"));
189        cache.put(3, make_response("three"));
190
191        // Access key 1 to move it to front
192        let _ = cache.get(1);
193
194        // Insert key 4 — should evict key 2 (now oldest)
195        cache.put(4, make_response("four"));
196        assert!(cache.get(1).is_some());
197        assert!(cache.get(2).is_none());
198        assert!(cache.get(3).is_some());
199        assert!(cache.get(4).is_some());
200    }
201
202    #[test]
203    fn compute_key_deterministic() {
204        let msgs = vec![Message::user("hello")];
205        let tools = vec!["search", "read"];
206
207        let key1 = ResponseCache::compute_key("system", &msgs, &tools);
208        let key2 = ResponseCache::compute_key("system", &msgs, &tools);
209        assert_eq!(key1, key2);
210    }
211
212    #[test]
213    fn compute_key_different_for_different_inputs() {
214        let msgs_a = vec![Message::user("hello")];
215        let msgs_b = vec![Message::user("world")];
216        let tools = vec!["search"];
217
218        let key_a = ResponseCache::compute_key("system", &msgs_a, &tools);
219        let key_b = ResponseCache::compute_key("system", &msgs_b, &tools);
220        assert_ne!(key_a, key_b);
221
222        // Different system prompt
223        let key_c = ResponseCache::compute_key("other", &msgs_a, &tools);
224        assert_ne!(key_a, key_c);
225
226        // Different tools
227        let key_d = ResponseCache::compute_key("system", &msgs_a, &["write"]);
228        assert_ne!(key_a, key_d);
229    }
230
231    #[test]
232    fn compute_key_tool_order_independent() {
233        let msgs = vec![Message::user("hi")];
234        let key1 = ResponseCache::compute_key("sys", &msgs, &["a", "b", "c"]);
235        let key2 = ResponseCache::compute_key("sys", &msgs, &["c", "a", "b"]);
236        assert_eq!(key1, key2);
237    }
238
239    #[test]
240    fn clear_empties_cache() {
241        let cache = ResponseCache::new(10);
242        cache.put(1, make_response("one"));
243        cache.put(2, make_response("two"));
244        assert_eq!(cache.len(), 2);
245
246        cache.clear();
247        assert!(cache.is_empty());
248        assert_eq!(cache.len(), 0);
249    }
250
251    #[test]
252    fn put_overwrites_existing_key() {
253        let cache = ResponseCache::new(10);
254        cache.put(1, make_response("first"));
255        cache.put(1, make_response("second"));
256
257        assert_eq!(cache.len(), 1);
258        let cached = cache.get(1).unwrap();
259        assert_eq!(cached.text(), "second");
260    }
261
262    #[test]
263    fn thread_safety() {
264        use std::sync::Arc;
265
266        let cache = Arc::new(ResponseCache::new(100));
267        let mut handles = vec![];
268
269        for i in 0..10 {
270            let cache = cache.clone();
271            handles.push(std::thread::spawn(move || {
272                for j in 0..100 {
273                    let key = (i * 100 + j) as u64;
274                    cache.put(key, make_response(&format!("resp-{key}")));
275                    let _ = cache.get(key);
276                }
277            }));
278        }
279
280        for h in handles {
281            h.join().expect("thread panicked");
282        }
283
284        assert!(cache.len() <= 100);
285    }
286
287    /// SECURITY (F-AGENT-3): identical (system_prompt, messages, tools) under
288    /// different tenant namespaces MUST produce different cache keys. Without
289    /// this, a daemon shared across tenants could leak tenant A's cached
290    /// response to tenant B when their inputs coincide.
291    #[test]
292    fn compute_key_scoped_differs_per_tenant() {
293        let msgs = vec![Message::user("hello")];
294        let key_a = ResponseCache::compute_key_scoped("sys", &msgs, &["a"], Some("tenant-a:user1"));
295        let key_b = ResponseCache::compute_key_scoped("sys", &msgs, &["a"], Some("tenant-b:user1"));
296        let key_unscoped = ResponseCache::compute_key("sys", &msgs, &["a"]);
297        assert_ne!(
298            key_a, key_b,
299            "different tenants must produce different keys"
300        );
301        assert_ne!(
302            key_a, key_unscoped,
303            "scoped key must differ from unscoped key"
304        );
305    }
306
307    /// SECURITY (F-AGENT-3): same tenant + same input → same key (cache hit).
308    /// Confirms scoping does not break the deterministic-replay property.
309    #[test]
310    fn compute_key_scoped_stable_for_same_tenant() {
311        let msgs = vec![Message::user("hello")];
312        let key1 = ResponseCache::compute_key_scoped("sys", &msgs, &["a"], Some("tenant-a:user1"));
313        let key2 = ResponseCache::compute_key_scoped("sys", &msgs, &["a"], Some("tenant-a:user1"));
314        assert_eq!(key1, key2);
315    }
316}