hyperhive/hive-claude/src/policy.rs
2026-07-05 22:24:42 +02:00

134 lines
4.1 KiB
Rust

//! When a durable session should compact.
use crate::Usage;
/// Decides, after a completed turn, whether an [`crate::InfiniteSession`]
/// should proactively compact — and what to say in the optional checkpoint
/// turn that runs first. Injected by the caller so the driver stays free of
/// any app-specific policy.
pub trait CompactionPolicy {
/// Given the last turn's context [`Usage`], compact now (before the window
/// fills)?
fn should_compact(&self, usage: Usage) -> bool;
/// Prompt for a pre-compaction checkpoint turn (a chance for the agent to
/// flush durable state before detail collapses into a summary), or `None`
/// to compact without one. Default: `None`.
fn checkpoint_prompt(&self) -> Option<&str> {
None
}
}
/// Compact once the context reaches `percent` of the model window.
///
/// The window is the one the model reported this turn ([`Usage::context_window`]),
/// falling back to [`PercentPolicy::default_window`] when the turn reported
/// none (e.g. a degenerate turn with no `result` usage). `percent == 0`
/// disables proactive compaction entirely.
#[derive(Debug, Clone, Default)]
pub struct PercentPolicy {
/// Watermark as a percent of the context window (e.g. `75`). `0` disables.
pub percent: u8,
/// Window to assume when the turn didn't report one. `None` → never
/// compact until a window is observed.
pub default_window: Option<u64>,
/// Prompt for the pre-compaction checkpoint turn; `None` skips it.
pub checkpoint_prompt: Option<String>,
}
impl CompactionPolicy for PercentPolicy {
fn should_compact(&self, usage: Usage) -> bool {
if self.percent == 0 {
return false;
}
let Some(window) = usage
.context_window
.or(self.default_window)
.filter(|&w| w > 0)
else {
return false;
};
usage.context_tokens.saturating_mul(100) >= u64::from(self.percent) * window
}
fn checkpoint_prompt(&self) -> Option<&str> {
self.checkpoint_prompt.as_deref()
}
}
/// A policy that never compacts. Turns run until the session overflows and the
/// reactive path in [`crate::InfiniteSession::run`] takes over.
#[derive(Debug, Clone, Copy, Default)]
pub struct NeverCompact;
impl CompactionPolicy for NeverCompact {
fn should_compact(&self, _usage: Usage) -> bool {
false
}
}
#[cfg(test)]
mod tests {
use super::{CompactionPolicy, NeverCompact, PercentPolicy};
use crate::Usage;
fn policy(percent: u8, default_window: Option<u64>) -> PercentPolicy {
PercentPolicy {
percent,
default_window,
checkpoint_prompt: None,
}
}
#[test]
fn fires_at_or_above_watermark() {
let p = policy(75, None);
// 75% of a 200k window = 150k.
assert!(!p.should_compact(Usage {
context_tokens: 149_999,
context_window: Some(200_000),
}));
assert!(p.should_compact(Usage {
context_tokens: 150_000,
context_window: Some(200_000),
}));
}
#[test]
fn zero_percent_disables() {
assert!(!policy(0, Some(200_000)).should_compact(Usage {
context_tokens: 199_999,
context_window: Some(200_000),
}));
}
#[test]
fn falls_back_to_default_window_when_unreported() {
let p = policy(50, Some(100_000));
assert!(p.should_compact(Usage {
context_tokens: 50_000,
context_window: None,
}));
// Reported window takes precedence over the default.
assert!(!p.should_compact(Usage {
context_tokens: 50_000,
context_window: Some(200_000),
}));
}
#[test]
fn no_window_anywhere_never_fires() {
assert!(!policy(75, None).should_compact(Usage {
context_tokens: u64::MAX,
context_window: None,
}));
}
#[test]
fn never_compact_is_never() {
assert!(!NeverCompact.should_compact(Usage {
context_tokens: u64::MAX,
context_window: Some(1),
}));
}
}