Skip to main content

codetether_agent/session/helper/experimental/
dedup.rs

1//! Content-addressed deduplication of tool-result blocks.
2//!
3//! Agentic loops re-read the same file and re-run the same searches many
4//! times. Each duplicate tool output costs full input tokens on every
5//! subsequent turn. This module detects exact content duplicates via
6//! SHA-256 and replaces later copies with a short back-reference that
7//! points at the first occurrence.
8//!
9//! # Safety
10//!
11//! *No information is lost* — the model can always ask the agent to
12//! re-run the original tool call. The back-reference preserves the
13//! `tool_call_id` of the first sighting so the model (or a human
14//! auditing the transcript) can correlate the two.
15//!
16//! Only `ContentPart::ToolResult` blocks are considered; text,
17//! assistant messages, tool-call arguments, images, and thinking blocks
18//! are left untouched.
19//!
20//! # Threshold
21//!
22//! Tool outputs smaller than [`MIN_DEDUP_BYTES`] are left alone — the
23//! marker itself would be nearly as long as the content, and small
24//! outputs typically carry disambiguating structure (e.g. `"ok"` vs
25//! `"error"`).
26
27use sha2::{Digest, Sha256};
28use std::collections::HashMap;
29
30use super::ExperimentalStats;
31use crate::provider::{ContentPart, Message};
32
33/// Tool outputs shorter than this byte count are never deduplicated.
34/// Chosen so the back-reference marker is always shorter than the
35/// content it replaces.
36pub const MIN_DEDUP_BYTES: usize = 256;
37
38/// Replace duplicate tool-result contents with a back-reference marker.
39///
40/// Walks `messages` in order. The first tool result for a given content
41/// hash is kept verbatim; every subsequent identical result is replaced
42/// with a marker of the form:
43///
44/// ```text
45/// [DEDUP] identical to tool_call_id=<first-id> (<N> bytes)
46/// ```
47///
48/// # Examples
49///
50/// ```rust
51/// use codetether_agent::provider::{ContentPart, Message, Role};
52/// use codetether_agent::session::helper::experimental::dedup::dedup_tool_outputs;
53///
54/// let big = "x".repeat(1024);
55/// let mut msgs = vec![
56///     Message {
57///         role: Role::Tool,
58///         content: vec![ContentPart::ToolResult {
59///             tool_call_id: "a".into(),
60///             content: big.clone(),
61///         }],
62///     },
63///     Message {
64///         role: Role::Tool,
65///         content: vec![ContentPart::ToolResult {
66///             tool_call_id: "b".into(),
67///             content: big,
68///         }],
69///     },
70/// ];
71///
72/// let stats = dedup_tool_outputs(&mut msgs);
73/// assert_eq!(stats.dedup_hits, 1);
74/// assert!(stats.total_bytes_saved > 512);
75///
76/// // First copy is preserved intact.
77/// if let ContentPart::ToolResult { content, .. } = &msgs[0].content[0] {
78///     assert_eq!(content.len(), 1024);
79/// } else {
80///     panic!("expected tool result");
81/// }
82///
83/// // Second copy is now a back-reference pointing at call_a.
84/// if let ContentPart::ToolResult { content, .. } = &msgs[1].content[0] {
85///     assert!(content.starts_with("[DEDUP]"));
86///     assert!(content.contains("tool_call_id=a"));
87/// } else {
88///     panic!("expected tool result");
89/// }
90/// ```
91pub fn dedup_tool_outputs(messages: &mut [Message]) -> ExperimentalStats {
92    let mut seen: HashMap<[u8; 32], String> = HashMap::new();
93    let mut stats = ExperimentalStats::default();
94
95    for msg in messages.iter_mut() {
96        for part in msg.content.iter_mut() {
97            let ContentPart::ToolResult {
98                tool_call_id,
99                content,
100            } = part
101            else {
102                continue;
103            };
104            if content.len() < MIN_DEDUP_BYTES {
105                continue;
106            }
107            let hash = Sha256::digest(content.as_bytes()).into();
108            match seen.get(&hash) {
109                Some(first_id) => {
110                    let marker = format!(
111                        "[DEDUP] identical to tool_call_id={first_id} ({} bytes)",
112                        content.len()
113                    );
114                    let saved = content.len().saturating_sub(marker.len());
115                    *content = marker;
116                    stats.dedup_hits += 1;
117                    stats.total_bytes_saved += saved;
118                }
119                None => {
120                    seen.insert(hash, tool_call_id.clone());
121                }
122            }
123        }
124    }
125
126    stats
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    fn tool_msg(id: &str, content: &str) -> Message {
134        Message {
135            role: crate::provider::Role::Tool,
136            content: vec![ContentPart::ToolResult {
137                tool_call_id: id.into(),
138                content: content.into(),
139            }],
140        }
141    }
142
143    #[test]
144    fn short_outputs_are_not_deduplicated() {
145        let mut msgs = vec![tool_msg("a", "ok"), tool_msg("b", "ok")];
146        let stats = dedup_tool_outputs(&mut msgs);
147        assert_eq!(stats.dedup_hits, 0);
148    }
149
150    #[test]
151    fn distinct_outputs_are_preserved() {
152        let mut msgs = vec![
153            tool_msg("a", &"x".repeat(1024)),
154            tool_msg("b", &"y".repeat(1024)),
155        ];
156        let stats = dedup_tool_outputs(&mut msgs);
157        assert_eq!(stats.dedup_hits, 0);
158        assert_eq!(stats.total_bytes_saved, 0);
159    }
160
161    #[test]
162    fn three_way_dedup_references_first_sighting() {
163        let big = "z".repeat(2048);
164        let mut msgs = vec![
165            tool_msg("first", &big),
166            tool_msg("second", &big),
167            tool_msg("third", &big),
168        ];
169        let stats = dedup_tool_outputs(&mut msgs);
170        assert_eq!(stats.dedup_hits, 2);
171
172        for idx in [1, 2] {
173            let ContentPart::ToolResult { content, .. } = &msgs[idx].content[0] else {
174                panic!("expected tool result");
175            };
176            assert!(content.contains("tool_call_id=first"));
177        }
178    }
179}