1use machi_types::{ContentPart, MachiError, Message, Role};
4
5use crate::select::{apply_range, select_compaction_range, tool_pair_invariant_holds};
6use crate::strategy::{CompactionOutcome, CompactionStrategy};
7
8#[derive(Debug, Clone, Copy)]
10pub struct PruneToolResults {
11 pub max_chars: usize,
13}
14
15impl Default for PruneToolResults {
16 fn default() -> Self {
17 Self { max_chars: 200 }
18 }
19}
20
21impl CompactionStrategy for PruneToolResults {
22 fn name(&self) -> &'static str {
23 "prune_tool_results"
24 }
25
26 fn should_compact(&self, messages: &[Message], _token_estimate: u64) -> bool {
27 messages
28 .iter()
29 .any(|m| m.role == Role::Tool && m.text().chars().count() > self.max_chars)
30 }
31
32 fn compact(&self, messages: Vec<Message>) -> Result<CompactionOutcome, MachiError> {
33 let max = self.max_chars;
34 let mut changed = false;
35 let out: Vec<Message> = messages
36 .into_iter()
37 .map(|mut m| {
38 if m.role != Role::Tool {
39 return m;
40 }
41 let t = m.text();
42 if t.chars().count() <= max {
43 return m;
44 }
45 let head: String = t.chars().take(max).collect();
46 m.content = Some(format!("{head}…[pruned]"));
47 m.parts.clear();
48 changed = true;
49 m
50 })
51 .collect();
52 debug_assert!(
53 tool_pair_invariant_holds(&out),
54 "prune_tool_results must preserve tool-pair invariant"
55 );
56 Ok(CompactionOutcome {
57 messages: out,
58 changed,
59 strategy: self.name(),
60 })
61 }
62}
63
64#[derive(Debug, Clone, Copy, Default)]
66pub struct StripImages;
67
68impl CompactionStrategy for StripImages {
69 fn name(&self) -> &'static str {
70 "strip_images"
71 }
72
73 fn should_compact(&self, messages: &[Message], _token_estimate: u64) -> bool {
74 messages.iter().any(|m| {
75 m.parts
76 .iter()
77 .any(|p| matches!(p, ContentPart::Image { .. }))
78 })
79 }
80
81 fn compact(&self, messages: Vec<Message>) -> Result<CompactionOutcome, MachiError> {
82 let mut changed = false;
83 let out: Vec<Message> = messages
84 .into_iter()
85 .map(|mut m| {
86 let before = m.parts.len();
87 m.parts.retain(|p| !matches!(p, ContentPart::Image { .. }));
88 if m.parts.len() != before {
89 changed = true;
90 }
91 m
92 })
93 .collect();
94 Ok(CompactionOutcome {
95 messages: out,
96 changed,
97 strategy: self.name(),
98 })
99 }
100}
101
102#[derive(Debug, Clone, Copy)]
104pub struct DropPrefix {
105 pub keep_tail: usize,
107}
108
109impl DropPrefix {
110 #[must_use]
112 pub const fn new(keep_tail: usize) -> Self {
113 Self { keep_tail }
114 }
115}
116
117impl CompactionStrategy for DropPrefix {
118 fn name(&self) -> &'static str {
119 "drop_prefix"
120 }
121
122 fn should_compact(&self, messages: &[Message], _token_estimate: u64) -> bool {
123 messages.len() > self.keep_tail && self.keep_tail > 0
124 }
125
126 fn compact(&self, messages: Vec<Message>) -> Result<CompactionOutcome, MachiError> {
127 let Some(range) = select_compaction_range(&messages, self.keep_tail) else {
128 return Ok(CompactionOutcome {
129 messages,
130 changed: false,
131 strategy: self.name(),
132 });
133 };
134 let out = apply_range(messages, range, None);
135 debug_assert!(
136 tool_pair_invariant_holds(&out),
137 "drop_prefix must preserve tool-pair invariant"
138 );
139 Ok(CompactionOutcome {
140 messages: out,
141 changed: true,
142 strategy: self.name(),
143 })
144 }
145}
146
147#[derive(Clone)]
152pub struct SummarizingCompaction {
153 pub keep_tail: usize,
155 pub summarize: std::sync::Arc<dyn Fn(Vec<Message>) -> Result<String, MachiError> + Send + Sync>,
157}
158
159impl std::fmt::Debug for SummarizingCompaction {
160 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161 f.debug_struct("SummarizingCompaction")
162 .field("keep_tail", &self.keep_tail)
163 .finish_non_exhaustive()
164 }
165}
166
167impl CompactionStrategy for SummarizingCompaction {
168 fn name(&self) -> &'static str {
169 "summarizing"
170 }
171
172 fn should_compact(&self, messages: &[Message], _token_estimate: u64) -> bool {
173 messages.len() > self.keep_tail && self.keep_tail > 0
174 }
175
176 fn compact(&self, messages: Vec<Message>) -> Result<CompactionOutcome, MachiError> {
177 let Some(range) = select_compaction_range(&messages, self.keep_tail) else {
178 return Ok(CompactionOutcome {
179 messages,
180 changed: false,
181 strategy: self.name(),
182 });
183 };
184 let split = range.split_idx.min(messages.len());
185 let prefix: Vec<Message> = messages
186 .get(..split)
187 .unwrap_or(&[])
188 .iter()
189 .filter(|m| m.role != Role::System)
190 .cloned()
191 .collect();
192 let summary_text = (self.summarize)(prefix)?;
193 let summary = Message::user(format!("[conversation summary]\n{summary_text}"));
194 let out = apply_range(messages, range, Some(summary));
195 debug_assert!(
196 tool_pair_invariant_holds(&out),
197 "summarizing compaction must preserve tool-pair invariant"
198 );
199 Ok(CompactionOutcome {
200 messages: out,
201 changed: true,
202 strategy: self.name(),
203 })
204 }
205}
206
207#[cfg(test)]
208#[allow(clippy::expect_used, reason = "unit tests")]
209mod tests {
210 use super::*;
211 use std::sync::Arc;
212
213 #[test]
214 fn prune_shortens_tool_bodies() {
215 let s = PruneToolResults { max_chars: 5 };
216 let call = machi_types::ToolCall {
217 id: machi_types::ToolCallId::new("c").expect("id"),
218 name: "t".into(),
219 arguments: serde_json::json!({}),
220 };
221 let out = s
222 .compact(vec![
223 Message::user("u"),
224 Message::assistant_tools(vec![call]),
225 Message::tool_result(
226 machi_types::ToolCallId::new("c").expect("id"),
227 "t",
228 "1234567890",
229 ),
230 ])
231 .expect("ok");
232 assert!(out.changed, "tool body should be truncated");
233 let tool_msg = out.messages.get(2).expect("tool message");
234 assert!(
235 tool_msg.text().contains("pruned"),
236 "expected pruned marker, got {:?}",
237 tool_msg.text()
238 );
239 assert!(
240 tool_pair_invariant_holds(&out.messages),
241 "tool-pair invariant after prune"
242 );
243 }
244
245 #[test]
246 fn summarizing_inserts_summary() {
247 let s = SummarizingCompaction {
248 keep_tail: 2,
249 summarize: Arc::new(|msgs| Ok(format!("n={}", msgs.len()))),
250 };
251 let out = s
252 .compact(vec![
253 Message::system("s"),
254 Message::user("1"),
255 Message::user("2"),
256 Message::user("3"),
257 Message::user("4"),
258 ])
259 .expect("ok");
260 assert!(out.changed, "summarizing should drop prefix");
261 assert!(
262 out.messages
263 .iter()
264 .any(|m| m.text().contains("conversation summary")),
265 "summary message missing from {:?}",
266 out.messages.iter().map(Message::text).collect::<Vec<_>>()
267 );
268 }
269}