1use crate::types::AgentStep;
21
22#[derive(Debug, Clone)]
24#[non_exhaustive]
25pub enum CompactionTrigger {
26 TurnCount(usize),
28 TokenCount(usize),
32 Any(Box<CompactionTrigger>, Box<CompactionTrigger>),
34 All(Box<CompactionTrigger>, Box<CompactionTrigger>),
36}
37
38impl CompactionTrigger {
39 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#[derive(Debug, Clone)]
57#[non_exhaustive]
58pub enum CompactionStrategy {
59 SlidingWindow {
61 keep_recent_turns: usize,
63 },
64 TokenBudget {
67 max_tokens: usize,
69 keep_recent_turns: usize,
71 },
72}
73
74pub 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#[derive(Debug, Clone)]
88pub struct CompactionConfig {
89 pub trigger: CompactionTrigger,
91 pub strategy: CompactionStrategy,
93 pub min_recent_turns: usize,
97}
98
99impl CompactionConfig {
100 pub fn new(trigger: CompactionTrigger, strategy: CompactionStrategy) -> Self {
102 Self {
103 trigger,
104 strategy,
105 min_recent_turns: 2,
106 }
107 }
108
109 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 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 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 #[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 assert!(any.should_compact(5, 0));
207 assert!(!all.should_compact(5, 0));
208 assert!(all.should_compact(5, 100));
209 }
210
211 #[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 #[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 #[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 #[test]
264 fn token_budget_drops_oldest_until_fit() {
265 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 assert!(
283 kept_tokens <= 150 || kept.len() <= 1,
284 "kept={} dropped={} tokens={}",
285 kept.len(),
286 dropped,
287 kept_tokens
288 );
289 }
290
291 #[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 #[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 for s in &kept {
324 assert!(!s.observation.is_empty());
325 }
326 }
327
328 #[test]
330 fn estimate_scales_with_content() {
331 assert!(estimate_step_tokens(&step("tool", 400)) > estimate_step_tokens(&step("tool", 40)));
332 }
333}