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}