use std::sync::Arc;
use zeph_config::{CompressionConfig, StoreRoutingConfig};
use crate::budget::ContextBudget;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CompactionState {
Ready,
CompactedThisTurn {
cooldown: u8,
},
Cooling {
turns_remaining: u8,
},
Exhausted {
warned: bool,
},
}
impl CompactionState {
#[must_use]
pub fn is_compacted_this_turn(self) -> bool {
matches!(self, Self::CompactedThisTurn { .. })
}
#[must_use]
pub fn is_exhausted(self) -> bool {
matches!(self, Self::Exhausted { .. })
}
#[must_use]
pub fn cooldown_remaining(self) -> u8 {
match self {
Self::Cooling { turns_remaining } => turns_remaining,
_ => 0,
}
}
#[must_use]
pub fn advance_turn(self) -> Self {
match self {
Self::CompactedThisTurn { cooldown } if cooldown > 0 => Self::Cooling {
turns_remaining: cooldown,
},
Self::CompactedThisTurn { .. } => Self::Ready,
other => other,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CompactionTier {
None,
Soft,
Hard,
}
pub struct ContextManager {
pub budget: Option<ContextBudget>,
pub soft_compaction_threshold: f32,
pub hard_compaction_threshold: f32,
pub compaction_preserve_tail: usize,
pub prune_protect_tokens: usize,
pub compression: CompressionConfig,
pub routing: StoreRoutingConfig,
pub store_routing_provider: Option<Arc<zeph_llm::any::AnyProvider>>,
pub compaction: CompactionState,
pub compaction_cooldown_turns: u8,
pub turns_since_last_hard_compaction: Option<u64>,
}
impl ContextManager {
#[must_use]
pub fn new() -> Self {
Self {
budget: None,
soft_compaction_threshold: 0.60,
hard_compaction_threshold: 0.90,
compaction_preserve_tail: 6,
prune_protect_tokens: 40_000,
compression: CompressionConfig::default(),
routing: StoreRoutingConfig::default(),
store_routing_provider: None,
compaction: CompactionState::Ready,
compaction_cooldown_turns: 2,
turns_since_last_hard_compaction: None,
}
}
#[allow(clippy::too_many_arguments)]
pub fn apply_budget_config(
&mut self,
budget_tokens: usize,
reserve_ratio: f32,
hard_compaction_threshold: f32,
compaction_preserve_tail: usize,
prune_protect_tokens: usize,
soft_compaction_threshold: f32,
compaction_cooldown_turns: u8,
) {
if budget_tokens == 0 {
tracing::warn!("context budget is 0 — agent will have no token tracking");
}
if budget_tokens > 0 {
self.budget = Some(ContextBudget::new(budget_tokens, reserve_ratio));
}
self.hard_compaction_threshold = hard_compaction_threshold;
self.compaction_preserve_tail = compaction_preserve_tail;
self.prune_protect_tokens = prune_protect_tokens;
self.soft_compaction_threshold = soft_compaction_threshold;
self.compaction_cooldown_turns = compaction_cooldown_turns;
}
pub fn reset_compaction(&mut self) {
self.compaction = CompactionState::Ready;
self.turns_since_last_hard_compaction = None;
}
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
pub fn compaction_tier(&self, cached_tokens: u64) -> CompactionTier {
let Some(ref budget) = self.budget else {
return CompactionTier::None;
};
let used = usize::try_from(cached_tokens).unwrap_or(usize::MAX);
let max = budget.max_tokens();
let hard = (max as f32 * self.hard_compaction_threshold) as usize;
if used > hard {
tracing::debug!(
cached_tokens,
hard_threshold = hard,
"context budget check: Hard tier"
);
return CompactionTier::Hard;
}
let soft = (max as f32 * self.soft_compaction_threshold) as usize;
if used > soft {
tracing::debug!(
cached_tokens,
soft_threshold = soft,
"context budget check: Soft tier"
);
return CompactionTier::Soft;
}
tracing::debug!(
cached_tokens,
soft_threshold = soft,
"context budget check: None"
);
CompactionTier::None
}
pub fn build_router(&self) -> Box<dyn zeph_memory::AsyncMemoryRouter + Send + Sync> {
use zeph_config::StoreRoutingStrategy;
if !self.routing.enabled {
return Box::new(zeph_memory::HeuristicRouter);
}
let fallback = zeph_memory::router::parse_route_str(
&self.routing.fallback_route,
zeph_memory::MemoryRoute::Hybrid,
);
match self.routing.strategy {
StoreRoutingStrategy::Heuristic => Box::new(zeph_memory::HeuristicRouter),
StoreRoutingStrategy::Llm => {
let Some(provider) = self.store_routing_provider.clone() else {
tracing::warn!(
"store_routing: strategy=llm but no provider resolved; \
falling back to heuristic"
);
return Box::new(zeph_memory::HeuristicRouter);
};
Box::new(zeph_memory::LlmRouter::new(provider, fallback))
}
StoreRoutingStrategy::Hybrid => {
let Some(provider) = self.store_routing_provider.clone() else {
tracing::warn!(
"store_routing: strategy=hybrid but no provider resolved; \
falling back to heuristic"
);
return Box::new(zeph_memory::HeuristicRouter);
};
Box::new(zeph_memory::HybridRouter::new(
provider,
fallback,
self.routing.confidence_threshold,
))
}
}
}
#[must_use]
pub fn should_proactively_compress(&self, current_tokens: u64) -> Option<(usize, usize)> {
use zeph_config::CompressionStrategy;
if self.compaction.is_compacted_this_turn() {
return None;
}
match &self.compression.strategy {
CompressionStrategy::Proactive {
threshold_tokens,
max_summary_tokens,
} if usize::try_from(current_tokens).unwrap_or(usize::MAX) > *threshold_tokens => {
Some((*threshold_tokens, *max_summary_tokens))
}
_ => None,
}
}
}
impl Default for ContextManager {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use zeph_config::CompressionStrategy;
#[test]
fn new_defaults() {
let cm = ContextManager::new();
assert!(cm.budget.is_none());
assert!((cm.soft_compaction_threshold - 0.60).abs() < f32::EPSILON);
assert!((cm.hard_compaction_threshold - 0.90).abs() < f32::EPSILON);
assert_eq!(cm.compaction_preserve_tail, 6);
assert_eq!(cm.prune_protect_tokens, 40_000);
assert_eq!(cm.compaction, CompactionState::Ready);
}
#[test]
fn compaction_tier_no_budget() {
let cm = ContextManager::new();
assert_eq!(cm.compaction_tier(1_000_000), CompactionTier::None);
}
#[test]
fn compaction_tier_below_soft() {
let mut cm = ContextManager::new();
cm.budget = Some(ContextBudget::new(100_000, 0.1));
assert_eq!(cm.compaction_tier(50_000), CompactionTier::None);
}
#[test]
fn compaction_tier_between_soft_and_hard() {
let mut cm = ContextManager::new();
cm.budget = Some(ContextBudget::new(100_000, 0.1));
assert_eq!(cm.compaction_tier(75_000), CompactionTier::Soft);
}
#[test]
fn compaction_tier_above_hard() {
let mut cm = ContextManager::new();
cm.budget = Some(ContextBudget::new(100_000, 0.1));
assert_eq!(cm.compaction_tier(95_000), CompactionTier::Hard);
}
#[test]
fn proactive_compress_above_threshold_returns_params() {
let mut cm = ContextManager::new();
cm.compression.strategy = CompressionStrategy::Proactive {
threshold_tokens: 80_000,
max_summary_tokens: 4_000,
};
let result = cm.should_proactively_compress(90_000);
assert_eq!(result, Some((80_000, 4_000)));
}
#[test]
fn proactive_compress_blocked_if_compacted_this_turn() {
let mut cm = ContextManager::new();
cm.compression.strategy = CompressionStrategy::Proactive {
threshold_tokens: 80_000,
max_summary_tokens: 4_000,
};
cm.compaction = CompactionState::CompactedThisTurn { cooldown: 0 };
assert!(cm.should_proactively_compress(100_000).is_none());
}
#[test]
fn compaction_state_ready_is_not_compacted_this_turn() {
assert!(!CompactionState::Ready.is_compacted_this_turn());
}
#[test]
fn compaction_state_compacted_this_turn_flag() {
assert!(CompactionState::CompactedThisTurn { cooldown: 2 }.is_compacted_this_turn());
assert!(CompactionState::CompactedThisTurn { cooldown: 0 }.is_compacted_this_turn());
}
#[test]
fn compaction_state_cooling_is_not_compacted_this_turn() {
assert!(!CompactionState::Cooling { turns_remaining: 1 }.is_compacted_this_turn());
}
#[test]
fn advance_turn_compacted_with_cooldown_enters_cooling() {
let state = CompactionState::CompactedThisTurn { cooldown: 3 };
assert_eq!(
state.advance_turn(),
CompactionState::Cooling { turns_remaining: 3 }
);
}
#[test]
fn advance_turn_compacted_zero_cooldown_returns_ready() {
let state = CompactionState::CompactedThisTurn { cooldown: 0 };
assert_eq!(state.advance_turn(), CompactionState::Ready);
}
}