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}