1use std::collections::{HashMap, VecDeque};
19
20use serde_json::Value;
21
22const RECENT_TOOL_OUTPUTS: usize = 2;
25const DELTA_WINDOW: usize = 16;
27
28#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
30pub struct DedupStats {
31 pub chunks_seen: usize,
32 pub exact_deduped: usize,
33 pub delta_deduped: usize,
34 pub tokens_saved: usize,
35}
36
37struct Canonical {
39 id: String,
40 content: String,
41}
42
43pub fn dedup_tool_outputs(messages: &mut [Value]) -> DedupStats {
51 let chunk_count: usize = messages.iter().map(tool_output_count).sum();
52 let mut stats = DedupStats {
53 chunks_seen: chunk_count,
54 ..DedupStats::default()
55 };
56 let mut by_hash: HashMap<blake3::Hash, String> = HashMap::new();
57 let mut window: VecDeque<Canonical> = VecDeque::with_capacity(DELTA_WINDOW);
58 let mut chunk_index = 0;
59 let mut protection: Option<bool> = None;
62
63 for message in messages.iter_mut() {
64 visit_tool_outputs(message, &mut |id, content| {
65 let is_recent = chunk_index + RECENT_TOOL_OUTPUTS >= chunk_count;
66 chunk_index += 1;
67 let hash = blake3::hash(content.as_bytes());
68
69 if !is_recent {
70 let replacement = by_hash
71 .get(&hash)
72 .map(String::as_str)
73 .map(exact_stub)
74 .filter(|stub| estimate_tokens(stub) < estimate_tokens(content));
75 if let Some(stub) = replacement {
76 stats.exact_deduped += 1;
77 stats.tokens_saved += estimate_tokens(content) - estimate_tokens(&stub);
78 *content = stub;
79 return;
80 }
81 if let Some(stub) = best_delta_stub(&window, content) {
82 if *protection.get_or_insert_with(super::compress::protected) {
83 super::compress::note_kept_whole(
87 content,
88 super::compress::DEDUP_WITHOUT_COPY,
89 );
90 } else {
91 stats.delta_deduped += 1;
92 stats.tokens_saved += estimate_tokens(content) - estimate_tokens(&stub);
93 *content = stub;
94 return;
95 }
96 }
97 }
98
99 if let Some(id) = id {
101 by_hash.entry(hash).or_insert_with(|| id.to_owned());
102 if window.len() == DELTA_WINDOW {
103 window.pop_front();
104 }
105 window.push_back(Canonical {
106 id: id.to_owned(),
107 content: content.clone(),
108 });
109 }
110 });
111 }
112 stats
113}
114
115fn exact_stub(canonical_id: &str) -> String {
116 format!("[lean-ctx: identical to tool result {canonical_id} above]")
117}
118
119fn best_delta_stub(window: &VecDeque<Canonical>, content: &str) -> Option<String> {
122 let budget = estimate_tokens(content) / 2;
123 window
124 .iter()
125 .filter_map(|canonical| delta_stub(canonical, content))
126 .filter(|stub| estimate_tokens(stub) <= budget)
127 .min_by_key(String::len)
128}
129
130fn delta_stub(canonical: &Canonical, content: &str) -> Option<String> {
133 if canonical.content.contains('\r')
136 || content.contains('\r')
137 || canonical.content.ends_with('\n') != content.ends_with('\n')
138 {
139 return None;
140 }
141 let old: Vec<&str> = canonical.content.lines().collect();
142 let new: Vec<&str> = content.lines().collect();
143 let prefix = old
144 .iter()
145 .zip(&new)
146 .take_while(|(old_line, new_line)| old_line == new_line)
147 .count();
148 let max_suffix = old.len().min(new.len()) - prefix;
149 let suffix = old
150 .iter()
151 .rev()
152 .zip(new.iter().rev())
153 .take(max_suffix)
154 .take_while(|(old_line, new_line)| old_line == new_line)
155 .count();
156 let removed = &old[prefix..old.len() - suffix];
157 let inserted = &new[prefix..new.len() - suffix];
158 let id = &canonical.id;
159
160 let change = match (removed.is_empty(), inserted.is_empty()) {
161 (true, true) => return None,
163 (true, false) if prefix == 0 => "these lines are inserted at the start".to_owned(),
164 (true, false) => format!("these lines are inserted after line {prefix}"),
165 (false, true) => {
166 return Some(format!(
167 "[lean-ctx: tool result {id} above, except {} removed]",
168 line_span(prefix, removed.len(), "is", "are")
169 ));
170 }
171 (false, false) => format!(
172 "{} replaced by",
173 line_span(prefix, removed.len(), "is", "are")
174 ),
175 };
176 Some(format!(
177 "[lean-ctx: tool result {id} above, except {change}:\n{}\n]",
178 inserted.join("\n")
179 ))
180}
181
182fn line_span(prefix: usize, count: usize, singular: &str, plural: &str) -> String {
184 let first = prefix + 1;
185 if count == 1 {
186 format!("line {first} {singular}")
187 } else {
188 format!("lines {first}–{} {plural}", prefix + count)
189 }
190}
191
192fn estimate_tokens(content: &str) -> usize {
193 content.len().div_ceil(4)
194}
195
196fn tool_output_count(message: &Value) -> usize {
197 if is_tool_message(message) {
198 return usize::from(message.get("content").is_some_and(Value::is_string));
199 }
200 message
201 .get("content")
202 .and_then(Value::as_array)
203 .map_or(0, |blocks| {
204 blocks
205 .iter()
206 .filter(|block| {
207 block.get("tool_use_id").is_some()
208 && block.get("content").is_some_and(Value::is_string)
209 })
210 .count()
211 })
212}
213
214fn is_tool_message(message: &Value) -> bool {
215 message.get("role").and_then(Value::as_str) == Some("tool")
216 || message.get("tool_use_id").is_some()
217}
218
219fn visit_tool_outputs(message: &mut Value, visit: &mut impl FnMut(Option<&str>, &mut String)) {
223 if is_tool_message(message) {
224 let id = ["tool_call_id", "tool_use_id"]
225 .iter()
226 .find_map(|key| message.get(*key).and_then(Value::as_str))
227 .map(str::to_owned);
228 if let Some(Value::String(content)) = message.get_mut("content") {
229 visit(id.as_deref(), content);
230 }
231 return;
232 }
233
234 let Some(blocks) = message.get_mut("content").and_then(Value::as_array_mut) else {
235 return;
236 };
237 for block in blocks {
238 let Some(id) = block
239 .get("tool_use_id")
240 .and_then(Value::as_str)
241 .map(str::to_owned)
242 else {
243 continue;
244 };
245 if let Some(Value::String(content)) = block.get_mut("content") {
246 visit(Some(&id), content);
247 }
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::dedup_tool_outputs;
254 use serde_json::{Value, json};
255
256 fn tool(id: &str, content: &str) -> Value {
257 json!({"role": "tool", "tool_call_id": id, "content": content})
258 }
259
260 fn content(message: &Value) -> &str {
261 message["content"].as_str().unwrap()
262 }
263
264 fn ls_output(date: &str) -> String {
265 let mut lines: Vec<String> = (0..30)
266 .map(|index| {
267 format!("-rw-r--r-- 1 dev staff {index:>6} Oct 1 09:00 src/file_{index}.rs")
268 })
269 .collect();
270 lines[12] = format!("-rwxr-xr-x 1 dev staff 52428800 {date} target/release/lean-ctx");
271 lines.join("\n")
272 }
273
274 #[test]
278 fn near_duplicate_keeps_the_changed_lines_verbatim() {
279 let _data = lean_ctx::core::data_dir::isolated_data_dir();
281 let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
282 let before = ls_output("Oct 1 09:00");
283 let after = ls_output("Oct 2 18:53");
284 let mut messages = vec![
285 tool("call_1", &before),
286 tool("call_2", &after),
287 tool("call_3", "recent one"),
288 tool("call_4", "recent two"),
289 ];
290
291 let stats = dedup_tool_outputs(&mut messages);
292
293 assert_eq!(content(&messages[0]), before);
294 assert_eq!(
295 content(&messages[1]),
296 "[lean-ctx: tool result call_1 above, except line 13 is replaced by:\n\
297 -rwxr-xr-x 1 dev staff 52428800 Oct 2 18:53 target/release/lean-ctx\n]"
298 );
299 assert_eq!(stats.delta_deduped, 1);
300 }
301
302 #[test]
307 fn resent_history_never_references_content_absent_from_the_request() {
308 let unique = "unique tool output ".repeat(40);
309 let request = vec![
310 tool("call_1", &unique),
311 tool("call_2", "recent one"),
312 tool("call_3", "recent two"),
313 ];
314 for _ in 0..2 {
315 let mut resent = request.clone();
316 assert_eq!(dedup_tool_outputs(&mut resent).tokens_saved, 0);
317 assert_eq!(resent, request);
318 }
319 }
320
321 #[test]
322 fn exact_duplicate_references_the_earlier_result_by_id() {
323 let repeated = "cargo test: 812 passed\n".repeat(20);
324 let mut messages = vec![
325 json!({"role": "user", "content": [
326 {"type": "tool_result", "tool_use_id": "toolu_a", "content": repeated}
327 ]}),
328 json!({"role": "user", "content": [
329 {"type": "tool_result", "tool_use_id": "toolu_b", "content": repeated}
330 ]}),
331 tool("call_3", "recent one"),
332 tool("call_4", "recent two"),
333 ];
334
335 let stats = dedup_tool_outputs(&mut messages);
336
337 assert_eq!(messages[0]["content"][0]["content"], json!(repeated));
338 assert_eq!(
339 messages[1]["content"][0]["content"],
340 json!("[lean-ctx: identical to tool result toolu_a above]")
341 );
342 assert_eq!(stats.exact_deduped, 1);
343 }
344
345 #[test]
348 fn line_ending_differences_are_never_described_as_a_delta() {
349 let lf = ls_output("Oct 1 09:00");
350 for changed in [
351 ls_output("Oct 2 18:53").replace('\n', "\r\n"),
352 format!("{}\n", ls_output("Oct 2 18:53")),
353 ] {
354 let mut messages = vec![
355 tool("call_1", &lf),
356 tool("call_2", &changed),
357 tool("call_3", "recent one"),
358 tool("call_4", "recent two"),
359 ];
360 assert_eq!(dedup_tool_outputs(&mut messages).delta_deduped, 0);
361 assert_eq!(content(&messages[1]), changed);
362 }
363 }
364
365 #[test]
366 fn outputs_without_an_id_are_never_referenced() {
367 let repeated = "same output ".repeat(40);
368 let mut messages = vec![
369 json!({"role": "tool", "content": repeated}),
370 tool("call_2", &repeated),
371 tool("call_3", "recent one"),
372 tool("call_4", "recent two"),
373 ];
374 let original = messages.clone();
375
376 assert_eq!(dedup_tool_outputs(&mut messages).tokens_saved, 0);
377 assert_eq!(messages, original);
378 }
379}