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 ClearToolUses {
81 keep_recent_turns: usize,
83 placeholder: String,
85 },
86}
87
88pub 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#[derive(Debug, Clone)]
102pub struct CompactionConfig {
103 pub trigger: CompactionTrigger,
105 pub strategy: CompactionStrategy,
107 pub min_recent_turns: usize,
111}
112
113impl CompactionConfig {
114 pub fn new(trigger: CompactionTrigger, strategy: CompactionStrategy) -> Self {
116 Self {
117 trigger,
118 strategy,
119 min_recent_turns: 2,
120 }
121 }
122
123 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 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 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 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 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 #[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 assert!(any.should_compact(5, 0));
258 assert!(!all.should_compact(5, 0));
259 assert!(all.should_compact(5, 100));
260 }
261
262 #[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 #[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 #[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 #[test]
315 fn token_budget_drops_oldest_until_fit() {
316 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 assert!(
334 kept_tokens <= 150 || kept.len() <= 1,
335 "kept={} dropped={} tokens={}",
336 kept.len(),
337 dropped,
338 kept_tokens
339 );
340 }
341
342 #[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 #[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 for s in &kept {
375 assert!(!s.observation.is_empty());
376 }
377 }
378
379 #[test]
381 fn estimate_scales_with_content() {
382 assert!(estimate_step_tokens(&step("tool", 400)) > estimate_step_tokens(&step("tool", 40)));
383 }
384
385 #[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 assert_eq!(kept.len(), 5, "ClearToolUses must not drop steps");
405 assert_eq!(cleared, 3, "oldest 3 observations cleared");
406 assert_eq!(kept[3].observation, "x".repeat(300));
408 assert_eq!(kept[4].observation, "x".repeat(400));
409 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 assert!(!s.observation.is_empty(), "step {idx} orphaned");
430 }
431 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 let (second, c2) = config.compact(&first, 0);
451 assert_eq!(c2, 0);
452 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 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 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}