|
| 1 | +//! Multi-tier auto-compaction decision engine. |
| 2 | +//! |
| 3 | +//! Faithful port of `DeepSeek-Reasonix/src/context-manager.ts` (345 lines). |
| 4 | +//! |
| 5 | +//! Five threshold tiers govern when and how aggressively the loop |
| 6 | +//! folds older context into a summary: |
| 7 | +//! |
| 8 | +//! 1. Turn-start fold (90%) — before first API call, catches terminal |
| 9 | +//! prior turn, session restore, huge user paste |
| 10 | +//! 2. Post-response fold (75%) — normal growth → fold into summary |
| 11 | +//! 3. Aggressive fold (78%) — normal fold didn't buy enough headroom |
| 12 | +//! → use half the tail budget |
| 13 | +//! 4. Exit-with-summary (80%) — defense in depth: force final summary |
| 14 | +//! and end the turn |
| 15 | +//! 5. Min-savings check (30%) — skip fold if head wouldn't shrink log |
| 16 | +//! enough |
| 17 | +//! |
| 18 | +//! Each threshold is a fraction of the model's context window |
| 19 | +//! (`ctx_max`). The decision is made against `prompt_tokens` from |
| 20 | +//! the API usage response, or a local estimate before the call. |
| 21 | +
|
| 22 | +use serde::Serialize; |
| 23 | + |
| 24 | +// ================================================================ |
| 25 | +// Threshold constants — port of context-manager.ts:27-43 |
| 26 | +// ================================================================ |
| 27 | + |
| 28 | +/// Auto-fold when a turn's response shows promptTokens above |
| 29 | +/// this fraction of ctxMax. |
| 30 | +pub const HISTORY_FOLD_THRESHOLD: f64 = 0.75; |
| 31 | + |
| 32 | +/// Tail budget after a normal fold, as a fraction of ctxMax. |
| 33 | +pub const HISTORY_FOLD_TAIL_FRACTION: f64 = 0.2; |
| 34 | + |
| 35 | +/// Above this fraction the normal fold's tail budget didn't |
| 36 | +/// buy enough headroom — fold harder. |
| 37 | +pub const HISTORY_FOLD_AGGRESSIVE_THRESHOLD: f64 = 0.78; |
| 38 | + |
| 39 | +/// Tail budget after an aggressive fold — half the normal one, |
| 40 | +/// sacrifices recent context for headroom. |
| 41 | +pub const HISTORY_FOLD_AGGRESSIVE_TAIL_FRACTION: f64 = 0.1; |
| 42 | + |
| 43 | +/// Skip the fold if the head wouldn't shrink the log by at |
| 44 | +/// least this fraction. |
| 45 | +pub const HISTORY_FOLD_MIN_SAVINGS_FRACTION: f64 = 0.3; |
| 46 | + |
| 47 | +/// Above this fraction we exit the turn with a summary instead |
| 48 | +/// of folding (defense in depth). |
| 49 | +pub const FORCE_SUMMARY_THRESHOLD: f64 = 0.8; |
| 50 | + |
| 51 | +/// Turn-start local estimate above this fraction triggers a |
| 52 | +/// pre-iter fold. Covers cases the post-response fold can't |
| 53 | +/// (terminal prior turn, fresh session restore, huge user |
| 54 | +/// paste). |
| 55 | +pub const TURN_START_FOLD_THRESHOLD: f64 = 0.9; |
| 56 | + |
| 57 | +/// Hard deadline for fold summary requests (seconds). |
| 58 | +pub const FOLD_SUMMARY_TIMEOUT_SECS: u64 = 15; |
| 59 | + |
| 60 | +// ================================================================ |
| 61 | +// Data types — port of context-manager.ts:67-85 |
| 62 | +// ================================================================ |
| 63 | + |
| 64 | +/// What action the context manager recommends. |
| 65 | +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] |
| 66 | +#[serde(rename_all = "kebab-case")] |
| 67 | +pub enum PostUsageDecisionKind { |
| 68 | + /// Context is within healthy limits — carry on. |
| 69 | + None, |
| 70 | + /// Fold older messages into a summary; keep the tail. |
| 71 | + Fold, |
| 72 | + /// Exceeded even the exit-with-summary threshold — force |
| 73 | + /// a final summary before ending the turn. |
| 74 | + ExitWithSummary, |
| 75 | +} |
| 76 | + |
| 77 | +/// Decision after a turn's response. |
| 78 | +#[derive(Debug, Clone, Copy)] |
| 79 | +pub struct PostUsageDecision { |
| 80 | + pub kind: PostUsageDecisionKind, |
| 81 | + pub prompt_tokens: u64, |
| 82 | + pub ctx_max: u64, |
| 83 | + pub ratio: f64, |
| 84 | + /// Token budget for the recent tail when kind is Fold. |
| 85 | + /// Smaller in the aggressive band. |
| 86 | + pub tail_budget: Option<u64>, |
| 87 | + /// True when this fold is in the aggressive band (78%-80%). |
| 88 | + pub aggressive: bool, |
| 89 | +} |
| 90 | + |
| 91 | +/// Result of an attempted fold. |
| 92 | +#[derive(Debug, Clone)] |
| 93 | +pub struct FoldResult { |
| 94 | + pub folded: bool, |
| 95 | + pub before_messages: usize, |
| 96 | + pub after_messages: usize, |
| 97 | + pub summary_chars: usize, |
| 98 | +} |
| 99 | + |
| 100 | +/// Turn-start estimate result. |
| 101 | +#[derive(Debug, Clone, Copy)] |
| 102 | +pub struct TurnStartEstimate { |
| 103 | + pub estimate_tokens: u64, |
| 104 | + pub ctx_max: u64, |
| 105 | + pub ratio: f64, |
| 106 | +} |
| 107 | + |
| 108 | +// ================================================================ |
| 109 | +// Decision logic — port of context-manager.ts:134-177 |
| 110 | +// ================================================================ |
| 111 | + |
| 112 | +/// Decide what to do after a turn's response — fold, exit with |
| 113 | +/// summary, or carry on. Port of `ContextManager.decideAfterUsage` |
| 114 | +/// (context-manager.ts:134-165). |
| 115 | +/// |
| 116 | +/// `prompt_tokens`: the prompt_tokens value from the API usage |
| 117 | +/// response. If `None`, the decision is `None` (no usage data). |
| 118 | +/// `ctx_max`: the model's context window size in tokens. |
| 119 | +/// `already_folded_this_turn`: true if we already folded earlier |
| 120 | +/// in this turn (prevents double-fold). |
| 121 | +pub fn decide_after_usage( |
| 122 | + prompt_tokens: Option<u64>, |
| 123 | + ctx_max: u64, |
| 124 | + already_folded_this_turn: bool, |
| 125 | +) -> PostUsageDecision { |
| 126 | + let Some(prompt_tokens) = prompt_tokens else { |
| 127 | + return PostUsageDecision { |
| 128 | + kind: PostUsageDecisionKind::None, |
| 129 | + prompt_tokens: 0, |
| 130 | + ctx_max, |
| 131 | + ratio: 0.0, |
| 132 | + tail_budget: None, |
| 133 | + aggressive: false, |
| 134 | + }; |
| 135 | + }; |
| 136 | + let ratio = prompt_tokens as f64 / ctx_max as f64; |
| 137 | + |
| 138 | + if ratio > FORCE_SUMMARY_THRESHOLD { |
| 139 | + return PostUsageDecision { |
| 140 | + kind: PostUsageDecisionKind::ExitWithSummary, |
| 141 | + prompt_tokens, |
| 142 | + ctx_max, |
| 143 | + ratio, |
| 144 | + tail_budget: None, |
| 145 | + aggressive: false, |
| 146 | + }; |
| 147 | + } |
| 148 | + |
| 149 | + if already_folded_this_turn { |
| 150 | + return PostUsageDecision { |
| 151 | + kind: PostUsageDecisionKind::None, |
| 152 | + prompt_tokens, |
| 153 | + ctx_max, |
| 154 | + ratio, |
| 155 | + tail_budget: None, |
| 156 | + aggressive: false, |
| 157 | + }; |
| 158 | + } |
| 159 | + |
| 160 | + if ratio > HISTORY_FOLD_AGGRESSIVE_THRESHOLD { |
| 161 | + return PostUsageDecision { |
| 162 | + kind: PostUsageDecisionKind::Fold, |
| 163 | + prompt_tokens, |
| 164 | + ctx_max, |
| 165 | + ratio, |
| 166 | + tail_budget: Some((ctx_max as f64 * HISTORY_FOLD_AGGRESSIVE_TAIL_FRACTION) as u64), |
| 167 | + aggressive: true, |
| 168 | + }; |
| 169 | + } |
| 170 | + |
| 171 | + if ratio > HISTORY_FOLD_THRESHOLD { |
| 172 | + return PostUsageDecision { |
| 173 | + kind: PostUsageDecisionKind::Fold, |
| 174 | + prompt_tokens, |
| 175 | + ctx_max, |
| 176 | + ratio, |
| 177 | + tail_budget: Some((ctx_max as f64 * HISTORY_FOLD_TAIL_FRACTION) as u64), |
| 178 | + aggressive: false, |
| 179 | + }; |
| 180 | + } |
| 181 | + |
| 182 | + PostUsageDecision { |
| 183 | + kind: PostUsageDecisionKind::None, |
| 184 | + prompt_tokens, |
| 185 | + ctx_max, |
| 186 | + ratio, |
| 187 | + tail_budget: None, |
| 188 | + aggressive: false, |
| 189 | + } |
| 190 | +} |
| 191 | + |
| 192 | +/// Turn-start estimate vs ctxMax. Caller folds if the ratio |
| 193 | +/// crosses TURN_START_FOLD_THRESHOLD. Port of |
| 194 | +/// `ContextManager.estimateTurnStart` |
| 195 | +/// (context-manager.ts:167-177). |
| 196 | +/// |
| 197 | +/// `estimate_tokens`: a local estimate of total request tokens |
| 198 | +/// (messages + tools + system prompt). |
| 199 | +/// `ctx_max`: the model's context window size in tokens. |
| 200 | +pub fn estimate_turn_start(estimate_tokens: u64, ctx_max: u64) -> TurnStartEstimate { |
| 201 | + TurnStartEstimate { |
| 202 | + estimate_tokens, |
| 203 | + ctx_max, |
| 204 | + ratio: estimate_tokens as f64 / ctx_max as f64, |
| 205 | + } |
| 206 | +} |
| 207 | + |
| 208 | +#[cfg(test)] |
| 209 | +mod tests { |
| 210 | + use super::*; |
| 211 | + |
| 212 | + // ============================================================ |
| 213 | + // decide_after_usage |
| 214 | + // ============================================================ |
| 215 | + |
| 216 | + #[test] |
| 217 | + fn no_usage_data_returns_none() { |
| 218 | + let d = decide_after_usage(None, 128_000, false); |
| 219 | + assert_eq!(d.kind, PostUsageDecisionKind::None); |
| 220 | + assert_eq!(d.ratio, 0.0); |
| 221 | + } |
| 222 | + |
| 223 | + #[test] |
| 224 | + fn below_threshold_returns_none() { |
| 225 | + // 50K out of 128K = ~39% → below 75% threshold |
| 226 | + let d = decide_after_usage(Some(50_000), 128_000, false); |
| 227 | + assert_eq!(d.kind, PostUsageDecisionKind::None); |
| 228 | + } |
| 229 | + |
| 230 | + #[test] |
| 231 | + fn above_75pct_triggers_fold() { |
| 232 | + // 98K out of 128K = ~76.5% → above 75%, below 78% |
| 233 | + let d = decide_after_usage(Some(98_000), 128_000, false); |
| 234 | + assert_eq!(d.kind, PostUsageDecisionKind::Fold); |
| 235 | + assert!(!d.aggressive); |
| 236 | + // Tail budget: 20% of 128K = 25600 |
| 237 | + assert_eq!(d.tail_budget, Some(25600)); |
| 238 | + } |
| 239 | + |
| 240 | + #[test] |
| 241 | + fn above_78pct_triggers_aggressive_fold() { |
| 242 | + // 101K out of 128K = ~78.9% → above 78% |
| 243 | + let d = decide_after_usage(Some(101_000), 128_000, false); |
| 244 | + assert_eq!(d.kind, PostUsageDecisionKind::Fold); |
| 245 | + assert!(d.aggressive); |
| 246 | + // Aggressive tail budget: 10% of 128K = 12800 |
| 247 | + assert_eq!(d.tail_budget, Some(12800)); |
| 248 | + } |
| 249 | + |
| 250 | + #[test] |
| 251 | + fn above_80pct_triggers_exit_with_summary() { |
| 252 | + // 105K out of 128K = ~82% → above 80% |
| 253 | + let d = decide_after_usage(Some(105_000), 128_000, false); |
| 254 | + assert_eq!(d.kind, PostUsageDecisionKind::ExitWithSummary); |
| 255 | + } |
| 256 | + |
| 257 | + #[test] |
| 258 | + fn already_folded_prevents_double_fold() { |
| 259 | + // Even though ratio is above 75%, we don't fold again |
| 260 | + let d = decide_after_usage(Some(100_000), 128_000, true); |
| 261 | + assert_eq!(d.kind, PostUsageDecisionKind::None); |
| 262 | + } |
| 263 | + |
| 264 | + #[test] |
| 265 | + fn already_folded_does_not_prevent_exit_with_summary() { |
| 266 | + // Above 80% still triggers exit even if already folded |
| 267 | + let d = decide_after_usage(Some(105_000), 128_000, true); |
| 268 | + assert_eq!(d.kind, PostUsageDecisionKind::ExitWithSummary); |
| 269 | + } |
| 270 | + |
| 271 | + #[test] |
| 272 | + fn zero_ctx_max_handled_gracefully() { |
| 273 | + // ratio would be infinity, but comparison still works |
| 274 | + let d = decide_after_usage(Some(1000), 0, false); |
| 275 | + // ratio > FORCE_SUMMARY_THRESHOLD → ExitWithSummary |
| 276 | + assert_eq!(d.kind, PostUsageDecisionKind::ExitWithSummary); |
| 277 | + } |
| 278 | + |
| 279 | + // ============================================================ |
| 280 | + // estimate_turn_start |
| 281 | + // ============================================================ |
| 282 | + |
| 283 | + #[test] |
| 284 | + fn estimate_below_threshold() { |
| 285 | + let e = estimate_turn_start(50_000, 128_000); |
| 286 | + assert!(e.ratio < TURN_START_FOLD_THRESHOLD); |
| 287 | + assert_eq!(e.ctx_max, 128_000); |
| 288 | + } |
| 289 | + |
| 290 | + #[test] |
| 291 | + fn estimate_above_threshold() { |
| 292 | + let e = estimate_turn_start(120_000, 128_000); |
| 293 | + assert!(e.ratio > TURN_START_FOLD_THRESHOLD); |
| 294 | + } |
| 295 | + |
| 296 | + #[test] |
| 297 | + fn estimate_at_boundary() { |
| 298 | + let boundary = (128_000.0 * TURN_START_FOLD_THRESHOLD) as u64; |
| 299 | + let e = estimate_turn_start(boundary, 128_000); |
| 300 | + // At exactly the threshold — caller decides whether to fold |
| 301 | + assert!((e.ratio - TURN_START_FOLD_THRESHOLD).abs() < 0.001); |
| 302 | + } |
| 303 | + |
| 304 | + // ============================================================ |
| 305 | + // Threshold constant sanity |
| 306 | + // ============================================================ |
| 307 | + |
| 308 | + #[test] |
| 309 | + fn thresholds_are_strictly_ordered() { |
| 310 | + assert!(FORCE_SUMMARY_THRESHOLD > HISTORY_FOLD_AGGRESSIVE_THRESHOLD); |
| 311 | + assert!(HISTORY_FOLD_AGGRESSIVE_THRESHOLD > HISTORY_FOLD_THRESHOLD); |
| 312 | + assert!(HISTORY_FOLD_THRESHOLD > HISTORY_FOLD_MIN_SAVINGS_FRACTION); |
| 313 | + } |
| 314 | + |
| 315 | + #[test] |
| 316 | + fn aggressive_tail_is_smaller_than_normal_tail() { |
| 317 | + assert!(HISTORY_FOLD_AGGRESSIVE_TAIL_FRACTION < HISTORY_FOLD_TAIL_FRACTION); |
| 318 | + } |
| 319 | +} |
0 commit comments