Skip to main content

lc_agents/executor/
compaction.rs

1// lc-agents/src/executor/compaction.rs
2//! Context compaction for long-running agent loops (0.21.0 S6.1).
3//!
4//! Long sessions accumulate `intermediate_steps` until the context window is
5//! wasted on stale tool observations (context rot). Compaction drops the
6//! oldest steps — **always at whole-step boundaries** so the model never sees
7//! an orphaned action without its observation (each `AgentStep` bundles the
8//! action + observation, which is the atomic unit here).
9//!
10//! Follows the [`super::budget::BudgetConfig`] discipline:
11//! - configuration is all-off by default (`None` on the executor = no
12//!   compaction, zero behavior change);
13//! - the same semantics run in the invoke and stream paths;
14//! - trigger and strategy are pure functions, unit-testable without an agent.
15//!
16//! `RecursiveSummarization` (LLM-summarize dropped turns into a synthetic
17//! prefix) is deliberately NOT in this version — it needs an LLM call, cost
18//! accounting and quality tuning. The strategy enum keeps room for it.
19
20use crate::types::AgentStep;
21
22/// When to compact. Checked before every `plan()` round.
23#[derive(Debug, Clone)]
24#[non_exhaustive]
25pub enum CompactionTrigger {
26    /// Compact when the number of accumulated steps (turns) exceeds `turns`.
27    TurnCount(usize),
28    /// Compact when the cumulative reported token usage exceeds `tokens`.
29    /// Agents that do not report tokens never trigger this variant (use
30    /// `TurnCount` or pair with `Any`).
31    TokenCount(usize),
32    /// Compact when either sub-trigger fires.
33    Any(Box<CompactionTrigger>, Box<CompactionTrigger>),
34    /// Compact when both sub-triggers fire.
35    All(Box<CompactionTrigger>, Box<CompactionTrigger>),
36}
37
38impl CompactionTrigger {
39    /// Whether the trigger fires at `(turns, tokens)`.
40    pub fn should_compact(&self, turns: usize, tokens: usize) -> bool {
41        match self {
42            CompactionTrigger::TurnCount(limit) => turns > *limit,
43            CompactionTrigger::TokenCount(limit) => tokens > *limit,
44            CompactionTrigger::Any(a, b) => {
45                a.should_compact(turns, tokens) || b.should_compact(turns, tokens)
46            }
47            CompactionTrigger::All(a, b) => {
48                a.should_compact(turns, tokens) && b.should_compact(turns, tokens)
49            }
50        }
51    }
52}
53
54/// How to compact. All strategies drop the oldest turns and keep at least
55/// [`CompactionConfig::min_recent_turns`] recent ones.
56#[derive(Debug, Clone)]
57#[non_exhaustive]
58pub enum CompactionStrategy {
59    /// Keep only the most recent `keep_recent_turns` steps (sliding window).
60    SlidingWindow {
61        /// Number of recent steps to keep.
62        keep_recent_turns: usize,
63    },
64    /// Drop the oldest steps until the estimated token footprint is within
65    /// `max_tokens`, but never below `keep_recent_turns` steps.
66    TokenBudget {
67        /// Estimated token ceiling for the retained history.
68        max_tokens: usize,
69        /// Number of recent steps always kept, even over budget.
70        keep_recent_turns: usize,
71    },
72    /// ClearToolUses (C2, v0.22.1 §S8): trim context without dropping turns.
73    ///
74    /// Replaces the tool observation text of steps older than the most recent
75    /// `keep_recent_turns` with a short `placeholder`. Every step is retained —
76    /// history length is preserved and no action is orphaned from its
77    /// (placeholder) observation, so the model still sees the full k-ary
78    /// sequence of tool calls, just without the bulky results. Idempotent:
79    /// already-clear observations are left untouched on later compactions.
80    ClearToolUses {
81        /// Number of most recent steps whose observations stay intact.
82        keep_recent_turns: usize,
83        /// Placeholder text inserted in place of cleared observations.
84        placeholder: String,
85    },
86}
87
88/// Token estimate for one step: ~4 bytes per token over the serialized step
89/// (tool name + input + observation). Providers that do not report per-step
90/// usage leave no better signal — this mirrors the byte-length fallback used
91/// elsewhere (`TokenTrackingLLM`, `get_num_tokens`).
92pub fn estimate_step_tokens(step: &AgentStep) -> usize {
93    let input_len = match &step.action.tool_input {
94        crate::types::ToolInput::String { value } => value.len(),
95        crate::types::ToolInput::Object { value } => value.to_string().len(),
96    };
97    (step.action.tool.len() + input_len + step.observation.len()) / 4
98}
99
100/// Compaction configuration: trigger + strategy + safety floor.
101#[derive(Debug, Clone)]
102pub struct CompactionConfig {
103    /// When to compact.
104    pub trigger: CompactionTrigger,
105    /// How to compact.
106    pub strategy: CompactionStrategy,
107    /// Safety floor: never drop below this many recent steps, even if the
108    /// trigger and strategy would remove more. Prevents pathological configs
109    /// from wiping the whole history.
110    pub min_recent_turns: usize,
111}
112
113impl CompactionConfig {
114    /// Creates a config from trigger + strategy (default floor of 2).
115    pub fn new(trigger: CompactionTrigger, strategy: CompactionStrategy) -> Self {
116        Self {
117            trigger,
118            strategy,
119            min_recent_turns: 2,
120        }
121    }
122
123    /// Sets the safety floor (never keep fewer than this many steps).
124    pub fn with_min_recent_turns(mut self, min_recent_turns: usize) -> Self {
125        self.min_recent_turns = min_recent_turns;
126        self
127    }
128
129    /// Returns the retained steps and how many were dropped.
130    ///
131    /// Pure: `(kept, dropped)` with `kept.len() + dropped == steps.len()` and
132    /// `kept` a suffix of `steps` (order and pairing preserved — no orphaned
133    /// actions). A no-op returns `(the same steps, 0)` when the trigger does
134    /// not fire or the floor is already reached.
135    pub fn compact(&self, steps: &[AgentStep], tokens: usize) -> (Vec<AgentStep>, usize) {
136        if !self.trigger.should_compact(steps.len(), tokens) {
137            return (steps.to_vec(), 0);
138        }
139        let floor = self.min_recent_turns.min(steps.len());
140
141        // ClearToolUses doesn't drop — it rewrites observations in place and returns the full
142        // history, so handle it before the drop-oriented strategies.
143        if let CompactionStrategy::ClearToolUses {
144            keep_recent_turns,
145            placeholder,
146        } = &self.strategy
147        {
148            return self.clear_tool_uses(steps, *keep_recent_turns, placeholder);
149        }
150
151        let keep = match &self.strategy {
152            CompactionStrategy::SlidingWindow { keep_recent_turns } => {
153                (*keep_recent_turns).max(floor)
154            }
155            CompactionStrategy::TokenBudget {
156                max_tokens,
157                keep_recent_turns,
158            } => {
159                // Walk from the newest step backwards, accumulating the token
160                // estimate; stop at the budget (or at the keep/floor limits).
161                let mut kept_tokens = 0usize;
162                let mut kept = 0usize;
163                for step in steps.iter().rev() {
164                    if kept >= steps.len()
165                        || kept >= (*keep_recent_turns).max(floor)
166                            && kept_tokens + estimate_step_tokens(step) > *max_tokens
167                    {
168                        break;
169                    }
170                    kept_tokens += estimate_step_tokens(step);
171                    kept += 1;
172                }
173                kept.max((*keep_recent_turns).max(floor)).min(steps.len())
174            }
175            CompactionStrategy::ClearToolUses { .. } => unreachable!("handled above"),
176        };
177        let keep = keep.min(steps.len());
178        let dropped = steps.len() - keep;
179        if dropped == 0 {
180            return (steps.to_vec(), 0);
181        }
182        (steps[steps.len() - keep..].to_vec(), dropped)
183    }
184
185    /// C2: replace tool observations older than the most recent `keep_recent_turns` with a
186    /// placeholder. Returns the full (unchanged-length) history and the count of observations
187    /// actually rewritten (already-clear ones are skipped). No step is orphaned: every clear
188    /// keeps its action + a (placeholder) observation.
189    fn clear_tool_uses(
190        &self,
191        steps: &[AgentStep],
192        keep_recent_turns: usize,
193        placeholder: &str,
194    ) -> (Vec<AgentStep>, usize) {
195        if steps.is_empty() || keep_recent_turns >= steps.len() {
196            return (steps.to_vec(), 0);
197        }
198        let mut out = steps.to_vec();
199        let mut cleared = 0usize;
200        let clear_count = steps.len() - keep_recent_turns;
201        for step in out.iter_mut().take(clear_count) {
202            if step.observation != placeholder {
203                step.observation = placeholder.to_string();
204                cleared += 1;
205            }
206        }
207        (out, cleared)
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use crate::types::{AgentAction, ToolInput};
215
216    fn step(name: &str, observation_len: usize) -> AgentStep {
217        AgentStep::new(
218            AgentAction {
219                tool: name.to_string(),
220                tool_input: ToolInput::String {
221                    value: "input".to_string(),
222                },
223                log: String::new(),
224            },
225            "x".repeat(observation_len),
226        )
227    }
228
229    #[test]
230    fn trigger_turn_count() {
231        let t = CompactionTrigger::TurnCount(3);
232        assert!(!t.should_compact(3, 0));
233        assert!(t.should_compact(4, 0));
234    }
235
236    #[test]
237    fn trigger_token_count() {
238        let t = CompactionTrigger::TokenCount(100);
239        assert!(!t.should_compact(0, 100));
240        assert!(t.should_compact(0, 101));
241    }
242
243    /// Agents that do not report tokens (tokens=0) never fire a TokenCount trigger.
244    #[test]
245    fn trigger_token_count_never_fires_without_tokens() {
246        let t = CompactionTrigger::TokenCount(0);
247        assert!(!t.should_compact(10, 0));
248    }
249
250    #[test]
251    fn trigger_any_and_all() {
252        let turn = CompactionTrigger::TurnCount(2);
253        let token = CompactionTrigger::TokenCount(10);
254        let any = CompactionTrigger::Any(Box::new(turn.clone()), Box::new(token.clone()));
255        let all = CompactionTrigger::All(Box::new(turn), Box::new(token));
256        // turns fire, tokens do not.
257        assert!(any.should_compact(5, 0));
258        assert!(!all.should_compact(5, 0));
259        assert!(all.should_compact(5, 100));
260    }
261
262    /// SlidingWindow keeps exactly the newest N steps, order preserved.
263    #[test]
264    fn sliding_window_keeps_recent_suffix() {
265        let config = CompactionConfig::new(
266            CompactionTrigger::TurnCount(2),
267            CompactionStrategy::SlidingWindow {
268                keep_recent_turns: 2,
269            },
270        )
271        .with_min_recent_turns(1);
272        let steps: Vec<AgentStep> = (0..5).map(|i| step(&format!("t{i}"), 10)).collect();
273        let (kept, dropped) = config.compact(&steps, 0);
274        assert_eq!(dropped, 3);
275        assert_eq!(kept.len(), 2);
276        assert_eq!(kept[0].action.tool, "t3", "suffix preserved");
277        assert_eq!(kept[1].action.tool, "t4");
278    }
279
280    /// Under the trigger threshold: no-op (same steps, zero dropped).
281    #[test]
282    fn no_compaction_below_trigger() {
283        let config = CompactionConfig::new(
284            CompactionTrigger::TurnCount(10),
285            CompactionStrategy::SlidingWindow {
286                keep_recent_turns: 2,
287            },
288        );
289        let steps: Vec<AgentStep> = (0..5).map(|i| step(&format!("t{i}"), 10)).collect();
290        let (kept, dropped) = config.compact(&steps, 0);
291        assert_eq!(dropped, 0);
292        assert_eq!(kept.len(), 5);
293    }
294
295    /// The safety floor wins over an aggressive strategy.
296    #[test]
297    fn min_recent_turns_floor() {
298        let config = CompactionConfig::new(
299            CompactionTrigger::TurnCount(1),
300            CompactionStrategy::SlidingWindow {
301                keep_recent_turns: 0,
302            },
303        )
304        .with_min_recent_turns(2);
305        let steps: Vec<AgentStep> = (0..6).map(|i| step(&format!("t{i}"), 10)).collect();
306        let (kept, dropped) = config.compact(&steps, 0);
307        assert_eq!(kept.len(), 2);
308        assert_eq!(dropped, 4);
309        assert_eq!(kept[1].action.tool, "t5");
310    }
311
312    /// TokenBudget drops the oldest steps until the estimate fits, keeping the
313    /// mandated minimum.
314    #[test]
315    fn token_budget_drops_oldest_until_fit() {
316        // Each step: tool "t" + "input" + 400-byte observation → ~404/4 ≈ 101 tokens.
317        let config = CompactionConfig::new(
318            CompactionTrigger::TokenCount(150),
319            CompactionStrategy::TokenBudget {
320                max_tokens: 150,
321                keep_recent_turns: 1,
322            },
323        )
324        .with_min_recent_turns(1);
325        let steps: Vec<AgentStep> = (0..5).map(|i| step(&format!("t{i}"), 400)).collect();
326        let total: usize = steps.iter().map(estimate_step_tokens).sum();
327        assert!(total > 150, "precondition: history over budget");
328
329        let (kept, dropped) = config.compact(&steps, total);
330        assert!(dropped >= 1, "over budget → drop");
331        let kept_tokens: usize = kept.iter().map(estimate_step_tokens).sum();
332        // Either within budget, or protected by the keep floor.
333        assert!(
334            kept_tokens <= 150 || kept.len() <= 1,
335            "kept={} dropped={} tokens={}",
336            kept.len(),
337            dropped,
338            kept_tokens
339        );
340    }
341
342    /// TokenBudget keeps at least `keep_recent_turns` even when each step alone
343    /// busts the budget.
344    #[test]
345    fn token_budget_respects_keep_floor() {
346        let config = CompactionConfig::new(
347            CompactionTrigger::TokenCount(10),
348            CompactionStrategy::TokenBudget {
349                max_tokens: 10,
350                keep_recent_turns: 2,
351            },
352        )
353        .with_min_recent_turns(1);
354        let steps: Vec<AgentStep> = (0..4).map(|i| step(&format!("t{i}"), 400)).collect();
355        let (kept, _) = config.compact(&steps, 500);
356        assert_eq!(kept.len(), 2, "keep floor wins over the budget");
357        assert_eq!(kept[0].action.tool, "t2");
358    }
359
360    /// Compaction never splits an action/observation pair — by construction
361    /// (`AgentStep` bundles both), but assert the invariant anyway.
362    #[test]
363    fn compaction_never_orphans_tool_results() {
364        let config = CompactionConfig::new(
365            CompactionTrigger::TurnCount(0),
366            CompactionStrategy::SlidingWindow {
367                keep_recent_turns: 3,
368            },
369        );
370        let steps: Vec<AgentStep> = (0..8).map(|i| step(&format!("t{i}"), 50)).collect();
371        let (kept, dropped) = config.compact(&steps, 0);
372        assert_eq!(kept.len() + dropped, steps.len());
373        // Every kept step has a non-empty observation paired with its action.
374        for s in &kept {
375            assert!(!s.observation.is_empty());
376        }
377    }
378
379    /// Estimate is proportional to content size.
380    #[test]
381    fn estimate_scales_with_content() {
382        assert!(estimate_step_tokens(&step("tool", 400)) > estimate_step_tokens(&step("tool", 40)));
383    }
384
385    // ---------------------------------------------------------------------
386    // C2: ClearToolUses
387    // ---------------------------------------------------------------------
388
389    #[test]
390    fn clear_tool_uses_replaces_old_observations_keeps_recent() {
391        let config = CompactionConfig::new(
392            CompactionTrigger::TurnCount(2),
393            CompactionStrategy::ClearToolUses {
394                keep_recent_turns: 2,
395                placeholder: "[cleared]".into(),
396            },
397        );
398        let steps: Vec<AgentStep> = (0..5)
399            .map(|i| step(&format!("t{i}"), i as usize * 100))
400            .collect();
401
402        let (kept, cleared) = config.compact(&steps, 0);
403        // history length unchanged — nothing is dropped
404        assert_eq!(kept.len(), 5, "ClearToolUses must not drop steps");
405        assert_eq!(cleared, 3, "oldest 3 observations cleared");
406        // recent two observations intact
407        assert_eq!(kept[3].observation, "x".repeat(300));
408        assert_eq!(kept[4].observation, "x".repeat(400));
409        // older observations replaced by the placeholder
410        for s in &kept[..3] {
411            assert_eq!(s.observation, "[cleared]");
412        }
413    }
414
415    #[test]
416    fn clear_tool_uses_never_orphans_actions() {
417        let config = CompactionConfig::new(
418            CompactionTrigger::TurnCount(0),
419            CompactionStrategy::ClearToolUses {
420                keep_recent_turns: 1,
421                placeholder: "[cleared]".into(),
422            },
423        );
424        let steps: Vec<AgentStep> = (0..7).map(|i| step(&format!("t{i}"), 50)).collect();
425        let (kept, _) = config.compact(&steps, 0);
426        assert_eq!(kept.len(), steps.len());
427        for (idx, s) in kept.iter().enumerate() {
428            // action preserved with a non-empty observation (intact or placeholder)
429            assert!(!s.observation.is_empty(), "step {idx} orphaned");
430        }
431        // actions themselves untouched (order + names preserved)
432        for (idx, s) in kept.iter().enumerate() {
433            assert_eq!(s.action.tool, format!("t{idx}"));
434        }
435    }
436
437    #[test]
438    fn clear_tool_uses_is_idempotent() {
439        let config = CompactionConfig::new(
440            CompactionTrigger::TurnCount(0),
441            CompactionStrategy::ClearToolUses {
442                keep_recent_turns: 2,
443                placeholder: "[cleared]".into(),
444            },
445        );
446        let steps: Vec<AgentStep> = (0..5).map(|i| step(&format!("t{i}"), 50)).collect();
447        let (first, c1) = config.compact(&steps, 0);
448        assert_eq!(c1, 3);
449        // compacting the already-cleared history clears nothing new
450        let (second, c2) = config.compact(&first, 0);
451        assert_eq!(c2, 0);
452        // history is byte-identical after the idempotent second pass
453        fn obs(v: &[AgentStep]) -> Vec<&str> {
454            v.iter().map(|s| s.observation.as_str()).collect()
455        }
456        assert_eq!(obs(&second), obs(&first));
457    }
458
459    #[test]
460    fn clear_tool_uses_keeps_everything_when_under_keep() {
461        let config = CompactionConfig::new(
462            CompactionTrigger::TurnCount(10),
463            CompactionStrategy::ClearToolUses {
464                keep_recent_turns: 3,
465                placeholder: "[cleared]".into(),
466            },
467        );
468        // trigger (TurnCount 10) doesn't fire → no-op like other strategies
469        let steps: Vec<AgentStep> = (0..5).map(|i| step(&format!("t{i}"), 50)).collect();
470        let (kept, cleared) = config.compact(&steps, 0);
471        assert_eq!(cleared, 0);
472        // byte-identical pass-through, nothing rewritten
473        let same = kept
474            .iter()
475            .zip(steps.iter())
476            .all(|(a, b)| a.observation == b.observation && a.action.tool == b.action.tool);
477        assert!(same);
478    }
479}