Skip to main content

machi_compaction/
prune.rs

1//! Light compaction: prune tool results and strip images (W4.2).
2
3use 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/// Replace bulky tool result bodies with a short placeholder, keeping pairing.
9#[derive(Debug, Clone, Copy)]
10pub struct PruneToolResults {
11    /// Max characters kept from each tool result body.
12    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/// Drop image parts from multimodal messages (text retained).
65#[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/// Drop oldest messages via [`select_compaction_range`] (tool-safe).
103#[derive(Debug, Clone, Copy)]
104pub struct DropPrefix {
105    /// Messages to keep at the tail (including system when present).
106    pub keep_tail: usize,
107}
108
109impl DropPrefix {
110    /// Construct.
111    #[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/// Summarizing compaction: replace dropped prefix with a single summary message.
148///
149/// The `summarize` callback is synchronous so strategies stay pure-sync; hosts
150/// that call an LLM inject a precomputed summary or a blocking adapter.
151#[derive(Clone)]
152pub struct SummarizingCompaction {
153    /// Tail size to retain.
154    pub keep_tail: usize,
155    /// Build a summary for the compacted-away prefix (excluding system).
156    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}