use crate::types::AgentStep;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum CompactionTrigger {
TurnCount(usize),
TokenCount(usize),
Any(Box<CompactionTrigger>, Box<CompactionTrigger>),
All(Box<CompactionTrigger>, Box<CompactionTrigger>),
}
impl CompactionTrigger {
pub fn should_compact(&self, turns: usize, tokens: usize) -> bool {
match self {
CompactionTrigger::TurnCount(limit) => turns > *limit,
CompactionTrigger::TokenCount(limit) => tokens > *limit,
CompactionTrigger::Any(a, b) => {
a.should_compact(turns, tokens) || b.should_compact(turns, tokens)
}
CompactionTrigger::All(a, b) => {
a.should_compact(turns, tokens) && b.should_compact(turns, tokens)
}
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum CompactionStrategy {
SlidingWindow {
keep_recent_turns: usize,
},
TokenBudget {
max_tokens: usize,
keep_recent_turns: usize,
},
ClearToolUses {
keep_recent_turns: usize,
placeholder: String,
},
}
pub fn estimate_step_tokens(step: &AgentStep) -> usize {
let input_len = match &step.action.tool_input {
crate::types::ToolInput::String { value } => value.len(),
crate::types::ToolInput::Object { value } => value.to_string().len(),
};
(step.action.tool.len() + input_len + step.observation.len()) / 4
}
#[derive(Debug, Clone)]
pub struct CompactionConfig {
pub trigger: CompactionTrigger,
pub strategy: CompactionStrategy,
pub min_recent_turns: usize,
}
impl CompactionConfig {
pub fn new(trigger: CompactionTrigger, strategy: CompactionStrategy) -> Self {
Self {
trigger,
strategy,
min_recent_turns: 2,
}
}
pub fn with_min_recent_turns(mut self, min_recent_turns: usize) -> Self {
self.min_recent_turns = min_recent_turns;
self
}
pub fn compact(&self, steps: &[AgentStep], tokens: usize) -> (Vec<AgentStep>, usize) {
if !self.trigger.should_compact(steps.len(), tokens) {
return (steps.to_vec(), 0);
}
let floor = self.min_recent_turns.min(steps.len());
if let CompactionStrategy::ClearToolUses {
keep_recent_turns,
placeholder,
} = &self.strategy
{
return self.clear_tool_uses(steps, *keep_recent_turns, placeholder);
}
let keep = match &self.strategy {
CompactionStrategy::SlidingWindow { keep_recent_turns } => {
(*keep_recent_turns).max(floor)
}
CompactionStrategy::TokenBudget {
max_tokens,
keep_recent_turns,
} => {
let mut kept_tokens = 0usize;
let mut kept = 0usize;
for step in steps.iter().rev() {
if kept >= steps.len()
|| kept >= (*keep_recent_turns).max(floor)
&& kept_tokens + estimate_step_tokens(step) > *max_tokens
{
break;
}
kept_tokens += estimate_step_tokens(step);
kept += 1;
}
kept.max((*keep_recent_turns).max(floor)).min(steps.len())
}
CompactionStrategy::ClearToolUses { .. } => unreachable!("handled above"),
};
let keep = keep.min(steps.len());
let dropped = steps.len() - keep;
if dropped == 0 {
return (steps.to_vec(), 0);
}
(steps[steps.len() - keep..].to_vec(), dropped)
}
fn clear_tool_uses(
&self,
steps: &[AgentStep],
keep_recent_turns: usize,
placeholder: &str,
) -> (Vec<AgentStep>, usize) {
if steps.is_empty() || keep_recent_turns >= steps.len() {
return (steps.to_vec(), 0);
}
let mut out = steps.to_vec();
let mut cleared = 0usize;
let clear_count = steps.len() - keep_recent_turns;
for step in out.iter_mut().take(clear_count) {
if step.observation != placeholder {
step.observation = placeholder.to_string();
cleared += 1;
}
}
(out, cleared)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{AgentAction, ToolInput};
fn step(name: &str, observation_len: usize) -> AgentStep {
AgentStep::new(
AgentAction {
tool: name.to_string(),
tool_input: ToolInput::String {
value: "input".to_string(),
},
log: String::new(),
},
"x".repeat(observation_len),
)
}
#[test]
fn trigger_turn_count() {
let t = CompactionTrigger::TurnCount(3);
assert!(!t.should_compact(3, 0));
assert!(t.should_compact(4, 0));
}
#[test]
fn trigger_token_count() {
let t = CompactionTrigger::TokenCount(100);
assert!(!t.should_compact(0, 100));
assert!(t.should_compact(0, 101));
}
#[test]
fn trigger_token_count_never_fires_without_tokens() {
let t = CompactionTrigger::TokenCount(0);
assert!(!t.should_compact(10, 0));
}
#[test]
fn trigger_any_and_all() {
let turn = CompactionTrigger::TurnCount(2);
let token = CompactionTrigger::TokenCount(10);
let any = CompactionTrigger::Any(Box::new(turn.clone()), Box::new(token.clone()));
let all = CompactionTrigger::All(Box::new(turn), Box::new(token));
assert!(any.should_compact(5, 0));
assert!(!all.should_compact(5, 0));
assert!(all.should_compact(5, 100));
}
#[test]
fn sliding_window_keeps_recent_suffix() {
let config = CompactionConfig::new(
CompactionTrigger::TurnCount(2),
CompactionStrategy::SlidingWindow {
keep_recent_turns: 2,
},
)
.with_min_recent_turns(1);
let steps: Vec<AgentStep> = (0..5).map(|i| step(&format!("t{i}"), 10)).collect();
let (kept, dropped) = config.compact(&steps, 0);
assert_eq!(dropped, 3);
assert_eq!(kept.len(), 2);
assert_eq!(kept[0].action.tool, "t3", "suffix preserved");
assert_eq!(kept[1].action.tool, "t4");
}
#[test]
fn no_compaction_below_trigger() {
let config = CompactionConfig::new(
CompactionTrigger::TurnCount(10),
CompactionStrategy::SlidingWindow {
keep_recent_turns: 2,
},
);
let steps: Vec<AgentStep> = (0..5).map(|i| step(&format!("t{i}"), 10)).collect();
let (kept, dropped) = config.compact(&steps, 0);
assert_eq!(dropped, 0);
assert_eq!(kept.len(), 5);
}
#[test]
fn min_recent_turns_floor() {
let config = CompactionConfig::new(
CompactionTrigger::TurnCount(1),
CompactionStrategy::SlidingWindow {
keep_recent_turns: 0,
},
)
.with_min_recent_turns(2);
let steps: Vec<AgentStep> = (0..6).map(|i| step(&format!("t{i}"), 10)).collect();
let (kept, dropped) = config.compact(&steps, 0);
assert_eq!(kept.len(), 2);
assert_eq!(dropped, 4);
assert_eq!(kept[1].action.tool, "t5");
}
#[test]
fn token_budget_drops_oldest_until_fit() {
let config = CompactionConfig::new(
CompactionTrigger::TokenCount(150),
CompactionStrategy::TokenBudget {
max_tokens: 150,
keep_recent_turns: 1,
},
)
.with_min_recent_turns(1);
let steps: Vec<AgentStep> = (0..5).map(|i| step(&format!("t{i}"), 400)).collect();
let total: usize = steps.iter().map(estimate_step_tokens).sum();
assert!(total > 150, "precondition: history over budget");
let (kept, dropped) = config.compact(&steps, total);
assert!(dropped >= 1, "over budget → drop");
let kept_tokens: usize = kept.iter().map(estimate_step_tokens).sum();
assert!(
kept_tokens <= 150 || kept.len() <= 1,
"kept={} dropped={} tokens={}",
kept.len(),
dropped,
kept_tokens
);
}
#[test]
fn token_budget_respects_keep_floor() {
let config = CompactionConfig::new(
CompactionTrigger::TokenCount(10),
CompactionStrategy::TokenBudget {
max_tokens: 10,
keep_recent_turns: 2,
},
)
.with_min_recent_turns(1);
let steps: Vec<AgentStep> = (0..4).map(|i| step(&format!("t{i}"), 400)).collect();
let (kept, _) = config.compact(&steps, 500);
assert_eq!(kept.len(), 2, "keep floor wins over the budget");
assert_eq!(kept[0].action.tool, "t2");
}
#[test]
fn compaction_never_orphans_tool_results() {
let config = CompactionConfig::new(
CompactionTrigger::TurnCount(0),
CompactionStrategy::SlidingWindow {
keep_recent_turns: 3,
},
);
let steps: Vec<AgentStep> = (0..8).map(|i| step(&format!("t{i}"), 50)).collect();
let (kept, dropped) = config.compact(&steps, 0);
assert_eq!(kept.len() + dropped, steps.len());
for s in &kept {
assert!(!s.observation.is_empty());
}
}
#[test]
fn estimate_scales_with_content() {
assert!(estimate_step_tokens(&step("tool", 400)) > estimate_step_tokens(&step("tool", 40)));
}
#[test]
fn clear_tool_uses_replaces_old_observations_keeps_recent() {
let config = CompactionConfig::new(
CompactionTrigger::TurnCount(2),
CompactionStrategy::ClearToolUses {
keep_recent_turns: 2,
placeholder: "[cleared]".into(),
},
);
let steps: Vec<AgentStep> = (0..5)
.map(|i| step(&format!("t{i}"), i as usize * 100))
.collect();
let (kept, cleared) = config.compact(&steps, 0);
assert_eq!(kept.len(), 5, "ClearToolUses must not drop steps");
assert_eq!(cleared, 3, "oldest 3 observations cleared");
assert_eq!(kept[3].observation, "x".repeat(300));
assert_eq!(kept[4].observation, "x".repeat(400));
for s in &kept[..3] {
assert_eq!(s.observation, "[cleared]");
}
}
#[test]
fn clear_tool_uses_never_orphans_actions() {
let config = CompactionConfig::new(
CompactionTrigger::TurnCount(0),
CompactionStrategy::ClearToolUses {
keep_recent_turns: 1,
placeholder: "[cleared]".into(),
},
);
let steps: Vec<AgentStep> = (0..7).map(|i| step(&format!("t{i}"), 50)).collect();
let (kept, _) = config.compact(&steps, 0);
assert_eq!(kept.len(), steps.len());
for (idx, s) in kept.iter().enumerate() {
assert!(!s.observation.is_empty(), "step {idx} orphaned");
}
for (idx, s) in kept.iter().enumerate() {
assert_eq!(s.action.tool, format!("t{idx}"));
}
}
#[test]
fn clear_tool_uses_is_idempotent() {
let config = CompactionConfig::new(
CompactionTrigger::TurnCount(0),
CompactionStrategy::ClearToolUses {
keep_recent_turns: 2,
placeholder: "[cleared]".into(),
},
);
let steps: Vec<AgentStep> = (0..5).map(|i| step(&format!("t{i}"), 50)).collect();
let (first, c1) = config.compact(&steps, 0);
assert_eq!(c1, 3);
let (second, c2) = config.compact(&first, 0);
assert_eq!(c2, 0);
fn obs(v: &[AgentStep]) -> Vec<&str> {
v.iter().map(|s| s.observation.as_str()).collect()
}
assert_eq!(obs(&second), obs(&first));
}
#[test]
fn clear_tool_uses_keeps_everything_when_under_keep() {
let config = CompactionConfig::new(
CompactionTrigger::TurnCount(10),
CompactionStrategy::ClearToolUses {
keep_recent_turns: 3,
placeholder: "[cleared]".into(),
},
);
let steps: Vec<AgentStep> = (0..5).map(|i| step(&format!("t{i}"), 50)).collect();
let (kept, cleared) = config.compact(&steps, 0);
assert_eq!(cleared, 0);
let same = kept
.iter()
.zip(steps.iter())
.all(|(a, b)| a.observation == b.observation && a.action.tool == b.action.tool);
assert!(same);
}
}