behest_runtime/compaction/overflow.rs
1//! Compact overflow detection.
2//!
3//! Determines when the conversation context is approaching or exceeding
4//! the model's context window, triggering compaction before or after
5//! a provider turn.
6//!
7//! Ported from OpenCode V1: `packages/opencode/src/session/overflow.ts`.
8
9/// Token headroom reserved between context limit and compaction trigger.
10///
11/// This ensures the compaction LLM call itself (prompt + response) fits
12/// without overflowing. OpenCode uses 20,000 tokens.
13pub const COMPACTION_BUFFER: usize = 20_000;
14
15/// Computes the number of tokens available for conversation history.
16///
17/// The usable space is the model's context window minus:
18/// 1. The maximum output tokens the model can generate
19/// 2. Optional reserved headroom (defaults to `min(COMPACTION_BUFFER, max_output)`)
20///
21/// When `model_context` is 0 (unlimited), returns `usize::MAX`.
22///
23/// # Arguments
24/// * `model_context` — The model's maximum context window in tokens (0 = unlimited).
25/// * `max_output` — The model's maximum output tokens.
26/// * `reserved` — Explicit headroom override; `None` uses `min(COMPACTION_BUFFER, max_output)`.
27#[must_use]
28pub fn usable_tokens(model_context: u32, max_output: u32, reserved: Option<usize>) -> usize {
29 if model_context == 0 {
30 return usize::MAX;
31 }
32
33 let reserved = reserved.unwrap_or_else(|| COMPACTION_BUFFER.min(max_output as usize));
34 let context = model_context as usize;
35
36 context
37 .saturating_sub(max_output as usize)
38 .saturating_sub(reserved)
39}
40
41/// Returns `true` when the conversation history exceeds the usable context window.
42///
43/// Compaction is skipped when:
44/// - `auto_enabled` is `false`
45/// - `model_context` is 0 (no context limit)
46///
47/// # Arguments
48/// * `total_tokens` - Estimated tokens in the full message history.
49/// * `model_context` - The model's maximum context window (0 = unlimited).
50/// * `max_output` - The model's maximum output tokens.
51/// * `auto_enabled` - Whether automatic compaction is enabled.
52#[must_use]
53pub fn is_overflow(
54 total_tokens: usize,
55 model_context: u32,
56 max_output: u32,
57 auto_enabled: bool,
58) -> bool {
59 if !auto_enabled || model_context == 0 {
60 return false;
61 }
62
63 let usable = usable_tokens(model_context, max_output, None);
64 total_tokens >= usable
65}
66
67#[cfg(test)]
68#[allow(clippy::unwrap_used)]
69mod tests {
70 use super::*;
71
72 #[test]
73 fn usable_with_typical_gpt4o() {
74 // gpt-4o: 128K context, 16K output
75 let usable = usable_tokens(128_000, 16_384, None);
76 // 128_000 - 16_384 - min(20_000, 16_384) = 128_000 - 16_384 - 16_384 = 95_232
77 assert_eq!(usable, 95_232);
78 }
79
80 #[test]
81 fn usable_with_large_output() {
82 // Model with 200K context, 32K output
83 let usable = usable_tokens(200_000, 32_000, None);
84 // 200_000 - 32_000 - min(20_000, 32_000) = 200_000 - 32_000 - 20_000 = 148_000
85 assert_eq!(usable, 148_000);
86 }
87
88 #[test]
89 fn usable_with_small_output() {
90 // Model with 8K context, 4K output
91 let usable = usable_tokens(8_000, 4_000, None);
92 // 8_000 - 4_000 - min(20_000, 4_000) = 8_000 - 4_000 - 4_000 = 0
93 assert_eq!(usable, 0);
94 }
95
96 #[test]
97 fn usable_unlimited_context() {
98 assert_eq!(usable_tokens(0, 16_384, None), usize::MAX);
99 }
100
101 #[test]
102 fn usable_with_explicit_reserved() {
103 let usable = usable_tokens(128_000, 16_384, Some(10_000));
104 // 128_000 - 16_384 - 10_000 = 101_616
105 assert_eq!(usable, 101_616);
106 }
107
108 #[test]
109 fn overflow_detected() {
110 // gpt-4o: usable = 95_232
111 assert!(!is_overflow(90_000, 128_000, 16_384, true));
112 assert!(is_overflow(96_000, 128_000, 16_384, true));
113 }
114
115 #[test]
116 fn overflow_skipped_when_auto_disabled() {
117 assert!(!is_overflow(200_000, 128_000, 16_384, false));
118 }
119
120 #[test]
121 fn overflow_skipped_when_unlimited_context() {
122 assert!(!is_overflow(1_000_000, 0, 16_384, true));
123 }
124}