Skip to main content

lean_ctx_proxy/
dedup.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Request-local, lossless deduplication of repeated tool output.
3//!
4//! Invariant (#1980): a tool output is only ever replaced by text from which
5//! the model can reconstruct it *within the same request* — a reference to an
6//! earlier tool result that is still present verbatim, plus, for a near
7//! duplicate, the changed lines verbatim. Nothing is remembered across
8//! requests: the model is stateless, so a reference to content it saw in an
9//! earlier request points at nothing. References use the earlier result's
10//! tool-call id; outputs without one are never referenced.
11//!
12//! Under protection (`compress::protected()`) only the exact
13//! reference is written: the identical block stays in place before it, so
14//! nothing is shortened. A line delta is a shortening without a recovery
15//! copy; the near duplicate stays whole and is counted with
16//! `recovery.dedup_without_copy`.
17
18use std::collections::{HashMap, VecDeque};
19
20use serde_json::Value;
21
22/// The newest tool outputs always stay verbatim: an agent checking state
23/// (`ls`, a test run, a build) must read the fresh result itself, not a delta.
24const RECENT_TOOL_OUTPUTS: usize = 2;
25/// Earlier verbatim results a near duplicate is compared against.
26const DELTA_WINDOW: usize = 16;
27
28/// Savings produced while deduplicating one request.
29#[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
37/// An earlier tool result that stays verbatim and can therefore be referenced.
38struct Canonical {
39    id: String,
40    content: String,
41}
42
43/// Replace repeated, non-recent tool outputs in `messages` with references to
44/// an earlier, verbatim tool result in the same slice.
45///
46/// Pure function of `messages` and of whether protection holds: equal input
47/// yields equal output. A block's rewrite depends only on the blocks before it
48/// and on whether it is among the newest tool outputs, which always stay
49/// verbatim.
50pub 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    // Read once, and only when a delta is first possible: a request without
60    // a near duplicate never asks.
61    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                        // A delta is not the block: under protection it would
84                        // be a shortening without a recovery copy. The block
85                        // stays whole (and referenceable, below).
86                        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            // Stays verbatim: referenceable by later outputs when it has an id.
100            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
119/// The smallest delta stub against any windowed canonical, if it is at most
120/// half the size of `content`. Ties keep the earliest canonical.
121fn 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
130/// Describe `content` as `canonical` with one contiguous block of lines
131/// changed, carrying the new lines verbatim so the result is reconstructible.
132fn delta_stub(canonical: &Canonical, content: &str) -> Option<String> {
133    // `lines()` drops line endings, so only LF text with the same trailing
134    // newline state is reproduced exactly by "canonical with these lines".
135    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        // Only line-ending differences: not a change `lines()` can express.
162        (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
182/// "line 5 is" / "lines 5–7 are" for `count` lines after the first `prefix`.
183fn 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
219/// Visit every string tool output in `message` with its tool-call id: an
220/// OpenAI `role: "tool"` message (`tool_call_id`) or Anthropic `tool_result`
221/// blocks (`tool_use_id`). Array-shaped results are left untouched.
222fn 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    // #1980: a near duplicate used to become "[Similar to turn N, key
275    // differences: ~1 line modified]", hiding the fresh value (a rebuilt
276    // binary's date). The delta must carry the changed line verbatim.
277    #[test]
278    fn near_duplicate_keeps_the_changed_lines_verbatim() {
279        // The delta is written outside protection only: pin the mode.
280        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    // #1980: dedup state used to persist across requests, so a re-sent output
303    // matched itself and was replaced by a reference to nothing. Every
304    // request is judged on its own; a unique output stays verbatim however
305    // often the same history is sent.
306    #[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    // A delta built on `lines()` cannot express line-ending changes; such a
346    // pair must stay verbatim rather than reconstruct to the wrong bytes.
347    #[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}