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}
73
74/// Token estimate for one step: ~4 bytes per token over the serialized step
75/// (tool name + input + observation). Providers that do not report per-step
76/// usage leave no better signal — this mirrors the byte-length fallback used
77/// elsewhere (`TokenTrackingLLM`, `get_num_tokens`).
78pub fn estimate_step_tokens(step: &AgentStep) -> usize {
79    let input_len = match &step.action.tool_input {
80        crate::types::ToolInput::String { value } => value.len(),
81        crate::types::ToolInput::Object { value } => value.to_string().len(),
82    };
83    (step.action.tool.len() + input_len + step.observation.len()) / 4
84}
85
86/// Compaction configuration: trigger + strategy + safety floor.
87#[derive(Debug, Clone)]
88pub struct CompactionConfig {
89    /// When to compact.
90    pub trigger: CompactionTrigger,
91    /// How to compact.
92    pub strategy: CompactionStrategy,
93    /// Safety floor: never drop below this many recent steps, even if the
94    /// trigger and strategy would remove more. Prevents pathological configs
95    /// from wiping the whole history.
96    pub min_recent_turns: usize,
97}
98
99impl CompactionConfig {
100    /// Creates a config from trigger + strategy (default floor of 2).
101    pub fn new(trigger: CompactionTrigger, strategy: CompactionStrategy) -> Self {
102        Self {
103            trigger,
104            strategy,
105            min_recent_turns: 2,
106        }
107    }
108
109    /// Sets the safety floor (never keep fewer than this many steps).
110    pub fn with_min_recent_turns(mut self, min_recent_turns: usize) -> Self {
111        self.min_recent_turns = min_recent_turns;
112        self
113    }
114
115    /// Returns the retained steps and how many were dropped.
116    ///
117    /// Pure: `(kept, dropped)` with `kept.len() + dropped == steps.len()` and
118    /// `kept` a suffix of `steps` (order and pairing preserved — no orphaned
119    /// actions). A no-op returns `(the same steps, 0)` when the trigger does
120    /// not fire or the floor is already reached.
121    pub fn compact(&self, steps: &[AgentStep], tokens: usize) -> (Vec<AgentStep>, usize) {
122        if !self.trigger.should_compact(steps.len(), tokens) {
123            return (steps.to_vec(), 0);
124        }
125        let floor = self.min_recent_turns.min(steps.len());
126        let keep = match &self.strategy {
127            CompactionStrategy::SlidingWindow { keep_recent_turns } => {
128                (*keep_recent_turns).max(floor)
129            }
130            CompactionStrategy::TokenBudget {
131                max_tokens,
132                keep_recent_turns,
133            } => {
134                // Walk from the newest step backwards, accumulating the token
135                // estimate; stop at the budget (or at the keep/floor limits).
136                let mut kept_tokens = 0usize;
137                let mut kept = 0usize;
138                for step in steps.iter().rev() {
139                    if kept >= steps.len()
140                        || kept >= (*keep_recent_turns).max(floor)
141                            && kept_tokens + estimate_step_tokens(step) > *max_tokens
142                    {
143                        break;
144                    }
145                    kept_tokens += estimate_step_tokens(step);
146                    kept += 1;
147                }
148                kept.max((*keep_recent_turns).max(floor)).min(steps.len())
149            }
150        };
151        let keep = keep.min(steps.len());
152        let dropped = steps.len() - keep;
153        if dropped == 0 {
154            return (steps.to_vec(), 0);
155        }
156        (steps[steps.len() - keep..].to_vec(), dropped)
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use crate::types::{AgentAction, ToolInput};
164
165    fn step(name: &str, observation_len: usize) -> AgentStep {
166        AgentStep::new(
167            AgentAction {
168                tool: name.to_string(),
169                tool_input: ToolInput::String {
170                    value: "input".to_string(),
171                },
172                log: String::new(),
173            },
174            "x".repeat(observation_len),
175        )
176    }
177
178    #[test]
179    fn trigger_turn_count() {
180        let t = CompactionTrigger::TurnCount(3);
181        assert!(!t.should_compact(3, 0));
182        assert!(t.should_compact(4, 0));
183    }
184
185    #[test]
186    fn trigger_token_count() {
187        let t = CompactionTrigger::TokenCount(100);
188        assert!(!t.should_compact(0, 100));
189        assert!(t.should_compact(0, 101));
190    }
191
192    /// Agents that do not report tokens (tokens=0) never fire a TokenCount trigger.
193    #[test]
194    fn trigger_token_count_never_fires_without_tokens() {
195        let t = CompactionTrigger::TokenCount(0);
196        assert!(!t.should_compact(10, 0));
197    }
198
199    #[test]
200    fn trigger_any_and_all() {
201        let turn = CompactionTrigger::TurnCount(2);
202        let token = CompactionTrigger::TokenCount(10);
203        let any = CompactionTrigger::Any(Box::new(turn.clone()), Box::new(token.clone()));
204        let all = CompactionTrigger::All(Box::new(turn), Box::new(token));
205        // turns fire, tokens do not.
206        assert!(any.should_compact(5, 0));
207        assert!(!all.should_compact(5, 0));
208        assert!(all.should_compact(5, 100));
209    }
210
211    /// SlidingWindow keeps exactly the newest N steps, order preserved.
212    #[test]
213    fn sliding_window_keeps_recent_suffix() {
214        let config = CompactionConfig::new(
215            CompactionTrigger::TurnCount(2),
216            CompactionStrategy::SlidingWindow {
217                keep_recent_turns: 2,
218            },
219        )
220        .with_min_recent_turns(1);
221        let steps: Vec<AgentStep> = (0..5).map(|i| step(&format!("t{i}"), 10)).collect();
222        let (kept, dropped) = config.compact(&steps, 0);
223        assert_eq!(dropped, 3);
224        assert_eq!(kept.len(), 2);
225        assert_eq!(kept[0].action.tool, "t3", "suffix preserved");
226        assert_eq!(kept[1].action.tool, "t4");
227    }
228
229    /// Under the trigger threshold: no-op (same steps, zero dropped).
230    #[test]
231    fn no_compaction_below_trigger() {
232        let config = CompactionConfig::new(
233            CompactionTrigger::TurnCount(10),
234            CompactionStrategy::SlidingWindow {
235                keep_recent_turns: 2,
236            },
237        );
238        let steps: Vec<AgentStep> = (0..5).map(|i| step(&format!("t{i}"), 10)).collect();
239        let (kept, dropped) = config.compact(&steps, 0);
240        assert_eq!(dropped, 0);
241        assert_eq!(kept.len(), 5);
242    }
243
244    /// The safety floor wins over an aggressive strategy.
245    #[test]
246    fn min_recent_turns_floor() {
247        let config = CompactionConfig::new(
248            CompactionTrigger::TurnCount(1),
249            CompactionStrategy::SlidingWindow {
250                keep_recent_turns: 0,
251            },
252        )
253        .with_min_recent_turns(2);
254        let steps: Vec<AgentStep> = (0..6).map(|i| step(&format!("t{i}"), 10)).collect();
255        let (kept, dropped) = config.compact(&steps, 0);
256        assert_eq!(kept.len(), 2);
257        assert_eq!(dropped, 4);
258        assert_eq!(kept[1].action.tool, "t5");
259    }
260
261    /// TokenBudget drops the oldest steps until the estimate fits, keeping the
262    /// mandated minimum.
263    #[test]
264    fn token_budget_drops_oldest_until_fit() {
265        // Each step: tool "t" + "input" + 400-byte observation → ~404/4 ≈ 101 tokens.
266        let config = CompactionConfig::new(
267            CompactionTrigger::TokenCount(150),
268            CompactionStrategy::TokenBudget {
269                max_tokens: 150,
270                keep_recent_turns: 1,
271            },
272        )
273        .with_min_recent_turns(1);
274        let steps: Vec<AgentStep> = (0..5).map(|i| step(&format!("t{i}"), 400)).collect();
275        let total: usize = steps.iter().map(estimate_step_tokens).sum();
276        assert!(total > 150, "precondition: history over budget");
277
278        let (kept, dropped) = config.compact(&steps, total);
279        assert!(dropped >= 1, "over budget → drop");
280        let kept_tokens: usize = kept.iter().map(estimate_step_tokens).sum();
281        // Either within budget, or protected by the keep floor.
282        assert!(
283            kept_tokens <= 150 || kept.len() <= 1,
284            "kept={} dropped={} tokens={}",
285            kept.len(),
286            dropped,
287            kept_tokens
288        );
289    }
290
291    /// TokenBudget keeps at least `keep_recent_turns` even when each step alone
292    /// busts the budget.
293    #[test]
294    fn token_budget_respects_keep_floor() {
295        let config = CompactionConfig::new(
296            CompactionTrigger::TokenCount(10),
297            CompactionStrategy::TokenBudget {
298                max_tokens: 10,
299                keep_recent_turns: 2,
300            },
301        )
302        .with_min_recent_turns(1);
303        let steps: Vec<AgentStep> = (0..4).map(|i| step(&format!("t{i}"), 400)).collect();
304        let (kept, _) = config.compact(&steps, 500);
305        assert_eq!(kept.len(), 2, "keep floor wins over the budget");
306        assert_eq!(kept[0].action.tool, "t2");
307    }
308
309    /// Compaction never splits an action/observation pair — by construction
310    /// (`AgentStep` bundles both), but assert the invariant anyway.
311    #[test]
312    fn compaction_never_orphans_tool_results() {
313        let config = CompactionConfig::new(
314            CompactionTrigger::TurnCount(0),
315            CompactionStrategy::SlidingWindow {
316                keep_recent_turns: 3,
317            },
318        );
319        let steps: Vec<AgentStep> = (0..8).map(|i| step(&format!("t{i}"), 50)).collect();
320        let (kept, dropped) = config.compact(&steps, 0);
321        assert_eq!(kept.len() + dropped, steps.len());
322        // Every kept step has a non-empty observation paired with its action.
323        for s in &kept {
324            assert!(!s.observation.is_empty());
325        }
326    }
327
328    /// Estimate is proportional to content size.
329    #[test]
330    fn estimate_scales_with_content() {
331        assert!(estimate_step_tokens(&step("tool", 400)) > estimate_step_tokens(&step("tool", 40)));
332    }
333}