Skip to main content

recursive/compact/
micro.rs

1//! No-LLM proactive pruning of old tool results by count.
2//!
3//! Runs before [`crate::compact::Compactor`] (LLM-driven compaction) so that
4//! after pruning tool results by count, the transcript may drop below the
5//! char-based compaction threshold and the expensive LLM summary is skipped
6//! entirely. See [`Microcompactor::prune`] for details.
7
8use crate::message::{Message, Role};
9
10/// Placeholder reused from `run_core::TRIM_PLACEHOLDER` semantics.
11/// Re-exported here so the microcompactor and the char-budget trim use the
12/// same marker text.
13pub const MICROCOMPACT_PLACEHOLDER: &str = "[older tool output trimmed to fit budget]";
14
15/// Minimum tool-result size worth pruning; shorter results are kept verbatim
16/// (matches `run_core::MIN_TRIM_LENGTH`).
17pub const MIN_PRUNE_LENGTH: usize = 200;
18
19/// Configuration for count-based proactive pruning of old tool results.
20///
21/// Unlike [`crate::compact::Compactor`] (LLM-driven), this compactor requires
22/// no provider access — it simply replaces the `content` of old tool-result
23/// messages with a placeholder string when the total number of tool messages
24/// exceeds a threshold.
25///
26/// # Pairing safety (invariant #8)
27///
28/// This compactor **never removes messages** — only replaces the `content`
29/// field of `Role::Tool` messages. The tool-call ↔ tool-result pairing is
30/// preserved because every `Role::Tool` message stays at its index,
31/// immediately after the `Role::Assistant` message whose `tool_calls` list
32/// its `id`.
33#[derive(Debug, Clone)]
34pub struct Microcompactor {
35    /// Prune when the number of `Role::Tool` messages exceeds this.
36    pub trigger_tool_count: usize,
37    /// Keep this many most-recent tool results verbatim.
38    pub keep_recent: usize,
39}
40
41impl Default for Microcompactor {
42    fn default() -> Self {
43        Self {
44            trigger_tool_count: 12,
45            keep_recent: 4,
46        }
47    }
48}
49
50impl Microcompactor {
51    /// Create a new `Microcompactor` with explicit trigger and keep counts.
52    pub fn new(trigger_tool_count: usize, keep_recent: usize) -> Self {
53        Self {
54            trigger_tool_count,
55            keep_recent,
56        }
57    }
58
59    /// Prune oldest tool-result contents in place. Returns the number pruned.
60    ///
61    /// Does NOT remove messages — only replaces `content` of `Role::Tool`
62    /// messages older than `keep_recent` when total tool count exceeds
63    /// `trigger_tool_count`. Tool messages shorter than `MIN_PRUNE_LENGTH`
64    /// are left untouched (not worth the placeholder swap).
65    ///
66    /// # Logic
67    ///
68    /// 1. Collect indices of all `Role::Tool` messages.
69    /// 2. If `count <= self.trigger_tool_count`, return `0`.
70    /// 3. Otherwise, the candidates for pruning are all tool messages
71    ///    **except** the last `self.keep_recent`. Iterate them oldest-first;
72    ///    for each whose `content.len() > MIN_PRUNE_LENGTH` and whose content
73    ///    is not already the placeholder, replace `content` with
74    ///    `MICROCOMPACT_PLACEHOLDER`. Stop once
75    ///    `count - pruned <= self.trigger_tool_count` (prune only enough to
76    ///    get back under the trigger — don't prune everything eligible).
77    /// 4. Return the number pruned.
78    pub fn prune(&self, messages: &mut [Message]) -> usize {
79        // 1. Collect indices of all Role::Tool messages.
80        let tool_indices: Vec<usize> = messages
81            .iter()
82            .enumerate()
83            .filter(|(_, m)| m.role == Role::Tool)
84            .map(|(i, _)| i)
85            .collect();
86
87        let count = tool_indices.len();
88
89        // 2. If count <= trigger, return 0.
90        if count <= self.trigger_tool_count {
91            return 0;
92        }
93
94        // 3. Candidates are all tool messages except the last `keep_recent`.
95        //    The eligible indices are the oldest `count - keep_recent` ones.
96        let eligible_count = count.saturating_sub(self.keep_recent);
97        // We only need to prune enough to get back under the trigger.
98        let target_prune = count.saturating_sub(self.trigger_tool_count);
99        let to_process = eligible_count.min(target_prune);
100
101        let mut pruned = 0;
102        for idx in tool_indices.iter().take(to_process) {
103            let idx = *idx;
104            let msg = &mut messages[idx];
105            // Skip if already placeholdered or too short.
106            if msg.content == MICROCOMPACT_PLACEHOLDER || msg.content.len() <= MIN_PRUNE_LENGTH {
107                continue;
108            }
109            msg.content = MICROCOMPACT_PLACEHOLDER.to_string();
110            pruned += 1;
111        }
112
113        pruned
114    }
115}
116
117/// Build a `Microcompactor` from environment-variable strings.
118///
119/// **Disabled by default.** Set `RECURSIVE_MICROCOMPACT_TRIGGER=<n>` (e.g.
120/// `40`) to enable. Disabled when unset or when the value is `0`, `off`, or
121/// `false`.
122///
123/// Rationale: the old default of 12 was too aggressive for large-context
124/// models (1 M+). At 12 tool messages the pruner fires when the session is
125/// only ~50–80 K tokens — 5–8 % of a 1 M window — causing surprising ctx
126/// gauge drops. Opt-in lets operators tune the threshold to their model size.
127///
128/// `trigger_raw` — the value of `RECURSIVE_MICROCOMPACT_TRIGGER`.
129/// `keep_raw`    — the value of `RECURSIVE_MICROCOMPACT_KEEP`.
130pub fn build_microcompactor_from_env(
131    trigger_raw: Option<&str>,
132    keep_raw: Option<&str>,
133) -> Option<Microcompactor> {
134    let trigger: usize = match trigger_raw {
135        None | Some("0") | Some("off") | Some("false") => return None, // disabled by default
136        Some(s) => s.parse::<usize>().ok().filter(|&n| n > 0)?,
137    };
138    let keep: usize = match keep_raw {
139        Some(s) => s.parse::<usize>().ok().unwrap_or(4),
140        None => 4,
141    };
142    Some(Microcompactor::new(trigger, keep))
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use crate::message::Message;
149
150    /// Helper to build a tool message with a given content length.
151    fn tool_msg(content_len: usize) -> Message {
152        Message::tool_result("call_1", "x".repeat(content_len))
153    }
154
155    // ====================================================================
156    // Core prune logic
157    // ====================================================================
158
159    #[test]
160    fn prune_noop_when_under_trigger() {
161        // 5 tool messages, trigger 12 → 0 pruned.
162        let mut msgs: Vec<Message> = (0..5).map(|_| tool_msg(300)).collect();
163        let mc = Microcompactor::new(12, 4);
164        let pruned = mc.prune(&mut msgs);
165        assert_eq!(pruned, 0);
166        // All messages should still have their original content.
167        for msg in &msgs {
168            assert_ne!(msg.content, MICROCOMPACT_PLACEHOLDER);
169        }
170    }
171
172    #[test]
173    fn prune_oldest_keeps_recent() {
174        // 15 tool messages, trigger 12, keep 4.
175        // Total = 15, trigger = 12 → we need to prune 3 to get back to 12.
176        // Eligible count = 15 - 4 = 11. Target = 15 - 12 = 3.
177        // The 3 oldest (indices 0,1,2) should be pruned.
178        let mut msgs: Vec<Message> = (0..15).map(|_| tool_msg(300)).collect();
179        let mc = Microcompactor::new(12, 4);
180        let pruned = mc.prune(&mut msgs);
181        assert_eq!(
182            pruned, 3,
183            "should prune exactly 3 to get back under trigger"
184        );
185
186        // The last 4 should be untouched (indices 11..14 after sorting).
187        for msg in msgs[11..15].iter() {
188            assert_ne!(
189                msg.content, MICROCOMPACT_PLACEHOLDER,
190                "message must not be pruned"
191            );
192        }
193        // The first 3 should be placeholdered (indices 0,1,2).
194        for msg in msgs[0..3].iter() {
195            assert_eq!(
196                msg.content, MICROCOMPACT_PLACEHOLDER,
197                "message must be pruned"
198            );
199        }
200        // Messages 3..10 should still be untouched (not placeholdered)
201        // because we stop pruning once count - pruned <= trigger.
202        for msg in msgs[3..11].iter() {
203            assert_ne!(
204                msg.content, MICROCOMPACT_PLACEHOLDER,
205                "message should not be pruned (pruning stopped at 3)"
206            );
207        }
208    }
209
210    #[test]
211    fn prune_preserves_tool_messages() {
212        // After pruning, the message count is unchanged and every pruned
213        // index is still Role::Tool (pairing intact).
214        let mut msgs: Vec<Message> = (0..15).map(|_| tool_msg(300)).collect();
215        let total_before = msgs.len();
216        let mc = Microcompactor::new(12, 4);
217        let _pruned = mc.prune(&mut msgs);
218        assert_eq!(msgs.len(), total_before, "message count must not change");
219        for msg in &msgs {
220            assert_eq!(msg.role, Role::Tool, "all messages must still be Tool role");
221        }
222    }
223
224    #[test]
225    fn prune_skips_short_results() {
226        // A tool result under MIN_PRUNE_LENGTH is not placeholdered even if
227        // it's old. Create 15 tool messages where the oldest are short.
228        let mut msgs: Vec<Message> = Vec::new();
229        // First 3 are short (under MIN_PRUNE_LENGTH).
230        for _ in 0..3 {
231            msgs.push(tool_msg(50));
232        }
233        // Next 8 are long enough.
234        for _ in 0..8 {
235            msgs.push(tool_msg(300));
236        }
237        // Last 4 are long (recent, will be kept).
238        for _ in 0..4 {
239            msgs.push(tool_msg(300));
240        }
241
242        let mc = Microcompactor::new(12, 4);
243        let pruned = mc.prune(&mut msgs);
244        // Eligible = 15 - 4 = 11, target = 15 - 12 = 3.
245        // The first 3 candidates are short (50 bytes each) → skipped.
246        // So we should prune 0 (or up to 3 from later ones if we iterate further).
247        // But the goal says: iterate oldest-first; skip short ones.
248        // With to_process = 3, indices 0,1,2 are all short → 0 pruned.
249        assert_eq!(pruned, 0, "short results should not be pruned");
250    }
251
252    #[test]
253    fn prune_skips_short_results_and_prunes_eligible_ones() {
254        // Mix of short and long: the short ones should be skipped, and only
255        // the old long ones should be pruned until the count is under trigger.
256        let mut msgs: Vec<Message> = Vec::new();
257        // First 2: short (skipped).
258        msgs.push(tool_msg(50));
259        msgs.push(tool_msg(50));
260        // Next 5: long (eligible).
261        for _ in 0..5 {
262            msgs.push(tool_msg(300));
263        }
264        // Last 4: long (recent, kept).
265        for _ in 0..4 {
266            msgs.push(tool_msg(300));
267        }
268        // Total = 11, trigger = 8, keep = 4.
269        // Eligible = 11 - 4 = 7, target = 11 - 8 = 3.
270        // to_process = min(7, 3) = 3.
271        // Candidates: indices 0 (short, skip), 1 (short, skip), 2 (long, prune ✓).
272        // So we try to prune 3, but only the long one gets pruned, count = 1.
273        let mc = Microcompactor::new(8, 4);
274        let pruned = mc.prune(&mut msgs);
275
276        // First 2 (short) should be untouched.
277        assert_ne!(msgs[0].content, MICROCOMPACT_PLACEHOLDER);
278        assert_ne!(msgs[1].content, MICROCOMPACT_PLACEHOLDER);
279        // Index 2 (long, old) should be pruned.
280        assert_eq!(msgs[2].content, MICROCOMPACT_PLACEHOLDER);
281        // Recent (last 4) should be untouched.
282        for msg in msgs[7..11].iter() {
283            assert_ne!(msg.content, MICROCOMPACT_PLACEHOLDER);
284        }
285        // We pruned at least 1 (the old long one).
286        assert!(pruned >= 1, "should prune at least 1 eligible long result");
287    }
288
289    #[test]
290    fn prune_idempotent() {
291        // Running prune twice on the same transcript does nothing the second
292        // time (already-placeholdered content is skipped).
293        let mut msgs: Vec<Message> = (0..15).map(|_| tool_msg(300)).collect();
294        let mc = Microcompactor::new(12, 4);
295        let first = mc.prune(&mut msgs);
296        assert_eq!(first, 3, "first prune should prune 3");
297
298        let second = mc.prune(&mut msgs);
299        assert_eq!(second, 0, "second prune should be no-op (idempotent)");
300    }
301
302    #[test]
303    fn prune_exact_at_trigger_no_prune() {
304        // Exactly at trigger count → no pruning.
305        let mut msgs: Vec<Message> = (0..12).map(|_| tool_msg(300)).collect();
306        let mc = Microcompactor::new(12, 4);
307        let pruned = mc.prune(&mut msgs);
308        assert_eq!(pruned, 0);
309    }
310
311    #[test]
312    fn prune_just_over_trigger_prunes_appropriate_number() {
313        // 13 tool messages, trigger 12, keep 4.
314        // count = 13 > 12 → prune.
315        // eligible = 13 - 4 = 9, target = 13 - 12 = 1.
316        // to_process = min(9, 1) = 1.
317        // Only 1 message should be pruned.
318        let mut msgs: Vec<Message> = (0..13).map(|_| tool_msg(300)).collect();
319        let mc = Microcompactor::new(12, 4);
320        let pruned = mc.prune(&mut msgs);
321        assert_eq!(pruned, 1, "13 > 12 → should prune exactly 1");
322    }
323
324    // ====================================================================
325    // build_microcompactor_from_env tests
326    // ====================================================================
327
328    #[test]
329    fn build_microcompactor_from_env_disabled_when_zero() {
330        // `0` → disabled (None).
331        assert!(build_microcompactor_from_env(Some("0"), None).is_none());
332        assert!(build_microcompactor_from_env(Some("off"), None).is_none());
333        assert!(build_microcompactor_from_env(Some("false"), None).is_none());
334    }
335
336    #[test]
337    fn build_microcompactor_from_env_disabled_by_default() {
338        // Microcompactor is OFF when env var is unset — opt-in only.
339        assert!(
340            build_microcompactor_from_env(None, None).is_none(),
341            "unset trigger must return None (disabled by default)"
342        );
343    }
344
345    #[test]
346    fn build_microcompactor_from_env_explicit_values() {
347        let mc = build_microcompactor_from_env(Some("20"), Some("6")).unwrap();
348        assert_eq!(mc.trigger_tool_count, 20);
349        assert_eq!(mc.keep_recent, 6);
350    }
351
352    #[test]
353    fn build_microcompactor_from_env_trigger_only() {
354        // keep unset → default 4.
355        let mc = build_microcompactor_from_env(Some("15"), None).unwrap();
356        assert_eq!(mc.trigger_tool_count, 15);
357        assert_eq!(mc.keep_recent, 4);
358    }
359
360    #[test]
361    fn build_microcompactor_from_env_invalid_trigger_returns_none() {
362        // Non-numeric trigger cannot be parsed → None (disabled), not fallback.
363        assert!(
364            build_microcompactor_from_env(Some("not-a-number"), None).is_none(),
365            "unparseable trigger must return None"
366        );
367    }
368}