1use dashmap::DashMap;
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::time::Instant;
6
7const MAX_ENTRIES: usize = 256;
8const TTL_SECS: u64 = 1800;
9const FIRST_LINE_MAX: usize = 120;
10
11pub struct ToolResultCache {
13 entries: DashMap<u64, CacheEntry>,
14 current_turn: AtomicU64,
15 #[allow(dead_code)]
16 created_at: Instant,
17}
18
19struct CacheEntry {
20 turn_seen: u64,
21 token_count: usize,
22 first_line: String,
23 ccr_handle: Option<String>,
24 inserted_at: Instant,
25}
26
27pub struct DedupHit {
29 pub turn_seen: u64,
30 pub tokens_saved: usize,
31 pub stub: String,
32}
33
34impl ToolResultCache {
35 #[must_use]
36 pub fn new() -> Self {
37 Self {
38 entries: DashMap::new(),
39 current_turn: AtomicU64::new(0),
40 created_at: Instant::now(),
41 }
42 }
43
44 #[must_use]
46 pub fn check(&self, tool_name: &str, content: &str) -> Option<DedupHit> {
47 let key = cache_key(tool_name, content);
48 let entry = self.entries.get(&key)?;
49 if entry.inserted_at.elapsed().as_secs() > TTL_SECS {
50 drop(entry);
51 self.entries.remove(&key);
52 return None;
53 }
54
55 let mut stub = format!(
56 "[unchanged since turn {} — {} tokens elided]\n{}...",
57 entry.turn_seen, entry.token_count, entry.first_line
58 );
59 if let Some(ccr_handle) = &entry.ccr_handle {
60 stub.push_str(&format!("\n[lean-ctx: full content at {ccr_handle}]"));
61 }
62 Some(DedupHit {
63 turn_seen: entry.turn_seen,
64 tokens_saved: entry.token_count,
65 stub,
66 })
67 }
68
69 pub fn insert(
71 &self,
72 tool_name: &str,
73 content: &str,
74 token_count: usize,
75 ccr_handle: Option<String>,
76 ) {
77 if self.entries.len() >= MAX_ENTRIES
78 && let Some(oldest_key) = self
79 .entries
80 .iter()
81 .min_by_key(|entry| entry.inserted_at)
82 .map(|entry| *entry.key())
83 {
84 self.entries.remove(&oldest_key);
85 }
86
87 self.entries.insert(
88 cache_key(tool_name, content),
89 CacheEntry {
90 turn_seen: self.turn(),
91 token_count,
92 first_line: preview_line(content),
93 ccr_handle,
94 inserted_at: Instant::now(),
95 },
96 );
97 }
98
99 pub fn advance_turn(&self) {
101 self.current_turn.fetch_add(1, Ordering::Relaxed);
102 }
103
104 #[must_use]
106 pub fn turn(&self) -> u64 {
107 self.current_turn.load(Ordering::Relaxed)
108 }
109}
110
111impl Default for ToolResultCache {
112 fn default() -> Self {
113 Self::new()
114 }
115}
116
117fn cache_key(tool_name: &str, content: &str) -> u64 {
118 let mut hasher = blake3::Hasher::new();
119 hasher.update(tool_name.as_bytes());
120 hasher.update(b"\0");
121 hasher.update(content.as_bytes());
122 let hash = hasher.finalize();
123 let mut bytes = [0; 8];
124 bytes.copy_from_slice(&hash.as_bytes()[..8]);
125 u64::from_le_bytes(bytes)
126}
127
128fn preview_line(content: &str) -> String {
129 content
130 .lines()
131 .next()
132 .unwrap_or_default()
133 .chars()
134 .take(FIRST_LINE_MAX)
135 .collect()
136}
137
138#[cfg(test)]
139mod tests {
140 use super::{CacheEntry, FIRST_LINE_MAX, MAX_ENTRIES, ToolResultCache, cache_key};
141 use std::time::{Duration, Instant};
142
143 #[test]
144 fn insert_then_check_returns_hit() {
145 let cache = ToolResultCache::new();
146 cache.insert("ctx_read", "source contents", 42, None);
147
148 let hit = cache
149 .check("ctx_read", "source contents")
150 .expect("cache hit");
151 assert_eq!(hit.turn_seen, 0);
152 assert_eq!(hit.tokens_saved, 42);
153 }
154
155 #[test]
156 fn check_miss_returns_none() {
157 let cache = ToolResultCache::new();
158 assert!(cache.check("ctx_read", "new contents").is_none());
159 }
160
161 #[test]
162 fn eviction_at_max_entries() {
163 let cache = ToolResultCache::new();
164 for index in 0..MAX_ENTRIES {
165 cache.insert("ctx_read", &format!("content-{index}"), 1, None);
166 }
167 cache.insert("ctx_read", "newest", 1, None);
168
169 assert_eq!(cache.entries.len(), MAX_ENTRIES);
170 assert!(cache.check("ctx_read", "content-0").is_none());
171 assert!(cache.check("ctx_read", "newest").is_some());
172 }
173
174 #[test]
175 fn ttl_expiry_returns_none() {
176 let cache = ToolResultCache::new();
177 let key = cache_key("ctx_read", "expired");
178 cache.entries.insert(
179 key,
180 CacheEntry {
181 turn_seen: 0,
182 token_count: 1,
183 first_line: "expired".to_string(),
184 ccr_handle: None,
185 inserted_at: Instant::now()
186 .checked_sub(Duration::from_secs(1801))
187 .unwrap(),
188 },
189 );
190
191 assert!(cache.check("ctx_read", "expired").is_none());
192 assert!(!cache.entries.contains_key(&key));
193 }
194
195 #[test]
196 fn advance_turn_increments() {
197 let cache = ToolResultCache::new();
198 cache.advance_turn();
199 cache.advance_turn();
200 assert_eq!(cache.turn(), 2);
201 }
202
203 #[test]
204 fn different_tool_names_produce_different_keys() {
205 assert_ne!(
206 cache_key("ctx_read", "content"),
207 cache_key("ctx_shell", "content")
208 );
209 }
210
211 #[test]
212 fn stub_format_includes_turn_and_tokens() {
213 let cache = ToolResultCache::new();
214 cache.advance_turn();
215 cache.insert(
216 "ctx_read",
217 "first line\nremaining",
218 17,
219 Some("ccr://result".to_string()),
220 );
221
222 let hit = cache
223 .check("ctx_read", "first line\nremaining")
224 .expect("cache hit");
225 assert_eq!(
226 hit.stub,
227 "[unchanged since turn 1 — 17 tokens elided]\nfirst line...\n[lean-ctx: full content at ccr://result]"
228 );
229 }
230
231 #[test]
232 fn preview_line_is_character_limited() {
233 let cache = ToolResultCache::new();
234 let content = "x".repeat(FIRST_LINE_MAX + 1);
235 cache.insert("ctx_read", &content, 1, None);
236
237 let hit = cache.check("ctx_read", &content).expect("cache hit");
238 assert_eq!(hit.stub.matches('x').count(), FIRST_LINE_MAX);
239 }
240}