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/// Never deduplicate tool results in this many trailing messages. When
39/// a user asks the agent to re-run an identical call (e.g. "try that
40/// again"), the freshly-produced output would otherwise be replaced
41/// with a back-reference to the previous sighting, making the retry
42/// appear to produce no new information. Keeping the tail verbatim
43/// lets the model see that the retry actually happened.
44pub const KEEP_LAST_MESSAGES: usize = 8;
45
46/// Replace duplicate tool-result contents with a back-reference marker.
47///
48/// Walks `messages` in order. The first tool result for a given content
49/// hash is kept verbatim; every subsequent identical result is replaced
50/// with a marker of the form:
51///
52/// ```text
53/// [DEDUP] identical to tool_call_id=<first-id> (<N> bytes)
54/// ```
55///
56/// # Examples
57///
58/// ```rust
59/// use codetether_agent::provider::{ContentPart, Message, Role};
60/// use codetether_agent::session::helper::experimental::dedup::{
61///     dedup_tool_outputs, KEEP_LAST_MESSAGES,
62/// };
63///
64/// let big = "x".repeat(1024);
65/// let mut msgs = vec![
66///     Message {
67///         role: Role::Tool,
68///         content: vec![ContentPart::ToolResult {
69///             tool_call_id: "a".into(),
70///             content: big.clone(),
71///         }],
72///     },
73///     Message {
74///         role: Role::Tool,
75///         content: vec![ContentPart::ToolResult {
76///             tool_call_id: "b".into(),
77///             content: big,
78///         }],
79///     },
80/// ];
81/// // Push enough padding so both tool results fall outside the
82/// // trailing keep-window and are eligible for dedup.
83/// for _ in 0..KEEP_LAST_MESSAGES {
84///     msgs.push(Message {
85///         role: Role::User,
86///         content: vec![ContentPart::Text { text: "...".into() }],
87///     });
88/// }
89///
90/// let stats = dedup_tool_outputs(&mut msgs);
91/// assert_eq!(stats.dedup_hits, 1);
92/// assert!(stats.total_bytes_saved > 512);
93///
94/// // First copy is preserved intact.
95/// if let ContentPart::ToolResult { content, .. } = &msgs[0].content[0] {
96///     assert_eq!(content.len(), 1024);
97/// } else {
98///     panic!("expected tool result");
99/// }
100///
101/// // Second copy is now a back-reference pointing at call_a.
102/// if let ContentPart::ToolResult { content, .. } = &msgs[1].content[0] {
103///     assert!(content.starts_with("[DEDUP]"));
104///     assert!(content.contains("tool_call_id=a"));
105/// } else {
106///     panic!("expected tool result");
107/// }
108/// ```
109pub fn dedup_tool_outputs(messages: &mut [Message]) -> ExperimentalStats {
110    let mut seen: HashMap<[u8; 32], String> = HashMap::new();
111    let mut stats = ExperimentalStats::default();
112
113    let total = messages.len();
114    let eligible = total.saturating_sub(KEEP_LAST_MESSAGES);
115    if eligible == 0 {
116        return stats;
117    }
118
119    for msg in messages[..eligible].iter_mut() {
120        for part in msg.content.iter_mut() {
121            let ContentPart::ToolResult {
122                tool_call_id,
123                content,
124            } = part
125            else {
126                continue;
127            };
128            if content.len() < MIN_DEDUP_BYTES {
129                continue;
130            }
131            let hash = Sha256::digest(content.as_bytes()).into();
132            match seen.get(&hash) {
133                Some(first_id) => {
134                    let marker = format!(
135                        "[DEDUP] identical to tool_call_id={first_id} ({} bytes)",
136                        content.len()
137                    );
138                    let saved = content.len().saturating_sub(marker.len());
139                    *content = marker;
140                    stats.dedup_hits += 1;
141                    stats.total_bytes_saved += saved;
142                }
143                None => {
144                    seen.insert(hash, tool_call_id.clone());
145                }
146            }
147        }
148    }
149
150    stats
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    fn tool_msg(id: &str, content: &str) -> Message {
158        Message {
159            role: crate::provider::Role::Tool,
160            content: vec![ContentPart::ToolResult {
161                tool_call_id: id.into(),
162                content: content.into(),
163            }],
164        }
165    }
166
167    fn padding_msg() -> Message {
168        Message {
169            role: crate::provider::Role::User,
170            content: vec![ContentPart::Text { text: "...".into() }],
171        }
172    }
173
174    #[test]
175    fn short_outputs_are_not_deduplicated() {
176        let mut msgs = vec![tool_msg("a", "ok"), tool_msg("b", "ok")];
177        for _ in 0..KEEP_LAST_MESSAGES {
178            msgs.push(padding_msg());
179        }
180        let stats = dedup_tool_outputs(&mut msgs);
181        assert_eq!(stats.dedup_hits, 0);
182    }
183
184    #[test]
185    fn distinct_outputs_are_preserved() {
186        let mut msgs = vec![
187            tool_msg("a", &"x".repeat(1024)),
188            tool_msg("b", &"y".repeat(1024)),
189        ];
190        for _ in 0..KEEP_LAST_MESSAGES {
191            msgs.push(padding_msg());
192        }
193        let stats = dedup_tool_outputs(&mut msgs);
194        assert_eq!(stats.dedup_hits, 0);
195        assert_eq!(stats.total_bytes_saved, 0);
196    }
197
198    #[test]
199    fn three_way_dedup_references_first_sighting() {
200        let big = "z".repeat(2048);
201        let mut msgs = vec![
202            tool_msg("first", &big),
203            tool_msg("second", &big),
204            tool_msg("third", &big),
205        ];
206        for _ in 0..KEEP_LAST_MESSAGES {
207            msgs.push(padding_msg());
208        }
209        let stats = dedup_tool_outputs(&mut msgs);
210        assert_eq!(stats.dedup_hits, 2);
211
212        for idx in [1, 2] {
213            let ContentPart::ToolResult { content, .. } = &msgs[idx].content[0] else {
214                panic!("expected tool result");
215            };
216            assert!(content.contains("tool_call_id=first"));
217        }
218    }
219
220    #[test]
221    fn recent_identical_tool_result_is_not_deduplicated() {
222        // Regression: when the user asks the agent to re-run a call,
223        // the fresh output must remain verbatim. Previously this
224        // replaced the retry's content with a back-reference, making
225        // the retry appear to produce no new information.
226        let big = "r".repeat(1024);
227        let mut msgs = vec![tool_msg("old", &big), tool_msg("retry", &big)];
228        let stats = dedup_tool_outputs(&mut msgs);
229        assert_eq!(stats.dedup_hits, 0);
230        let ContentPart::ToolResult { content, .. } = &msgs[1].content[0] else {
231            panic!("expected tool result");
232        };
233        assert_eq!(content.len(), 1024);
234    }
235}