Skip to content

Commit 952403b

Browse files
author
Yogthos
committed
Merge PR #130: multi-tier auto-compaction decision engine
Note: post-response decision path is deferred (prompt_tokens wiring needed from stream pipeline). Turn-start heuristic provides diagnostic logging. Conflict resolution: merged imports, variable initialization, and outer-loop resets for storm breaker + compaction tracking coexistence.
2 parents b32abd6 + e4504c6 commit 952403b

3 files changed

Lines changed: 413 additions & 0 deletions

File tree

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
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+
}

src/agent/agent_loop/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
#![allow(unused_imports)]
2626

2727
pub mod bridge;
28+
pub mod context_manager;
2829
#[cfg(test)]
2930
mod h7_smoke;
3031
pub mod hooks;

0 commit comments

Comments
 (0)