Skip to main content

lean_ctx/proxy/
dedup.rs

1//! Session-scoped cache for deduplicating compressed tool results.
2
3use 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
11/// Thread-safe cache of compressed tool results for one proxy session.
12pub 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
27/// A prior tool result that can be represented by a compact stub.
28pub 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    /// Check whether this exact content was already compressed in this session.
45    #[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    /// Insert a tool result after compression.
70    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    /// Advance the session's API-request turn counter.
100    pub fn advance_turn(&self) {
101        self.current_turn.fetch_add(1, Ordering::Relaxed);
102    }
103
104    /// Return the current session turn number.
105    #[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}