Skip to main content

deepstrike_core/context/
config.rs

1/// All compression and context management parameters expressed as fractions of
2/// `max_tokens`. This is the single control surface for the compression pipeline:
3/// changing `max_tokens` (e.g. switching model) rescales every derived limit
4/// automatically with no other configuration change required.
5///
6/// Invariant: snip < micro < collapse < auto < renewal (strictly increasing).
7#[derive(Debug, Clone)]
8pub struct ContextConfig {
9    // ── Pressure thresholds ─────────────────────────────────────────────────
10    pub snip_threshold: f64,
11    pub micro_threshold: f64,
12    pub collapse_threshold: f64,
13    pub auto_threshold: f64,
14    pub renewal_threshold: f64,
15
16    // ── Post-compression target ──────────────────────────────────────────────
17    /// Target rho after any compression pass. Must be < snip_threshold.
18    pub target_after_compress: f64,
19
20    // ── Per-compactor ratios ─────────────────────────────────────────────────
21    /// Fraction of max_tokens any single message may occupy after SnipCompact.
22    /// Messages smaller than this are never touched.
23    pub snip_per_msg_ratio: f64,
24
25    // ── Renewal ──────────────────────────────────────────────────────────────
26    /// Fraction of max_tokens worth of history tokens to carry across renewal.
27    /// Renewal stops carrying messages once this token budget is exhausted.
28    pub carryover_ratio: f64,
29
30    // ── Recovery / repair ────────────────────────────────────────────────────
31    /// Maximum fraction of max_tokens a recovery/replay payload may occupy.
32    pub recovery_content_ratio: f64,
33
34    /// Recent history messages always kept during render.
35    pub preserve_recent_msgs: usize,
36
37    /// Number of most-recent turns (user+assistant pairs) preserved by
38    /// CollapseCompactor and AutoCompactor. Each turn = 2 messages, so
39    /// the actual message count kept is `preserve_recent_turns * 2`.
40    /// Must be ≥ 1. Default: 2 (= 4 messages).
41    pub preserve_recent_turns: usize,
42
43    // ── Noise reduction ──────────────────────────────────────────────────────
44    /// Use verbose internal control notes (e.g. "[SYSTEM] Transaction rollback: …").
45    /// Defaults to false; uses concise natural-language notes instead.
46    pub verbose_control_notes: bool,
47
48    /// Collapse the *narration* text of OLD assistant turns (those past the
49    /// `preserve_recent_msgs` window that also carry tool calls) to a short stub at render time —
50    /// non-destructively (the full text stays in `partitions.history`). The model's user-facing
51    /// preamble ("好的,我来…先X") has no value once it has aged out of the recent window, but
52    /// re-feeding it verbatim every turn primes the model to keep emitting the same preamble (an
53    /// in-context repetition trap). Tool calls and pairing are untouched; current progress lives in
54    /// the TASK STATE turn. Defaults to true.
55    pub collapse_assistant_narration: bool,
56
57    // ── Layer 3: Time-based decay ───────────────────────────────────────
58
59    /// Minutes of inactivity before triggering Micro-Compact (Layer 3).
60    /// Defaults to 60 minutes — assumes Prompt Cache has expired by then.
61    pub micro_compact_idle_minutes: u32,
62
63    /// Number of recent tool results to preserve during Micro-Compact.
64    pub preserved_tool_results: usize,
65
66    // ── Layer 5: Auto-Compact buffer ─────────────────────────────────────
67
68    /// Buffer size for Auto-Compact trigger (Layer 5).
69    /// Trigger threshold = max_tokens - autocompact_buffer.
70    /// Defaults to 13K tokens (p99.99 of summarizer output length + safety margin).
71    pub autocompact_buffer: u32,
72
73    // ── Layer 1: Large-result spool ──────────────────────────────────────
74
75    /// Byte size above which a single tool result is spooled (Layer 1): the kernel
76    /// keeps only a preview in context and emits `LargeResultSpooled` for the SDK to
77    /// persist the full content to disk. Default: 50 KiB. `0` disables spooling.
78    pub spool_threshold_bytes: u32,
79
80    /// Preview byte budget kept in context when a tool result is spooled. Default: 2 KiB.
81    pub spool_preview_bytes: u32,
82
83    // ── K2: knowledge budget ─────────────────────────────────────────────
84
85    /// Max share of `max_tokens` the knowledge partition may occupy. Exceeding it emits a
86    /// `KnowledgeBudgetExceeded` observation (once per cache generation) and marks the OLDEST
87    /// unpinned, non-skill entries for eviction at the next compaction/renewal boundary until the
88    /// projected usage fits. Pinned entries and `skill:`-keyed pins are never budget-evicted
89    /// (skills are governed by deactivation/lease, not the budget). `0.0` disables (no cap).
90    /// Default: 0.25.
91    pub knowledge_budget_ratio: f64,
92}
93
94impl Default for ContextConfig {
95    fn default() -> Self {
96        Self {
97            snip_threshold: 0.70,
98            micro_threshold: 0.80,
99            collapse_threshold: 0.90,
100            auto_threshold: 0.95,
101            renewal_threshold: 0.98,
102            target_after_compress: 0.65,
103            snip_per_msg_ratio: 0.05,
104            carryover_ratio: 0.05,
105            recovery_content_ratio: 0.25,
106            preserve_recent_msgs: 4,
107            preserve_recent_turns: 2,
108            verbose_control_notes: false,
109            collapse_assistant_narration: true,
110            micro_compact_idle_minutes: 60,
111            preserved_tool_results: 5,
112            autocompact_buffer: 13_000,
113            spool_threshold_bytes: 50 * 1024,
114            spool_preview_bytes: 2 * 1024,
115            knowledge_budget_ratio: 0.25,
116        }
117    }
118}
119
120impl ContextConfig {
121    /// Token budget to target after a compression pass.
122    pub fn target_tokens(&self, max_tokens: u32) -> u32 {
123        (max_tokens as f64 * self.target_after_compress) as u32
124    }
125
126    /// Per-message token cap used by SnipCompact.
127    /// Floor of 50 ensures very small context windows still get useful output.
128    pub fn snip_per_msg_tokens(&self, max_tokens: u32) -> u32 {
129        ((max_tokens as f64 * self.snip_per_msg_ratio) as u32).max(50)
130    }
131
132    /// Token budget for history carryover across renewal.
133    pub fn carryover_tokens(&self, max_tokens: u32) -> u32 {
134        ((max_tokens as f64 * self.carryover_ratio) as u32).max(100)
135    }
136
137    /// Token cap for a single recovery/replay payload.
138    pub fn recovery_content_tokens(&self, max_tokens: u32) -> u32 {
139        (max_tokens as f64 * self.recovery_content_ratio) as u32
140    }
141
142    /// Auto-Compact trigger threshold (Layer 5).
143    /// Returns `max_tokens - autocompact_buffer` (absolute value).
144    pub fn autocompact_threshold(&self, max_tokens: u32) -> u32 {
145        max_tokens.saturating_sub(self.autocompact_buffer)
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn noise_reduction_defaults_to_quiet() {
155        let c = ContextConfig::default();
156        assert!(!c.verbose_control_notes, "verbose notes should be off by default");
157    }
158
159    #[test]
160    fn default_thresholds_strictly_increasing() {
161        let c = ContextConfig::default();
162        assert!(c.snip_threshold < c.micro_threshold);
163        assert!(c.micro_threshold < c.collapse_threshold);
164        assert!(c.collapse_threshold < c.auto_threshold);
165        assert!(c.auto_threshold < c.renewal_threshold);
166    }
167
168    #[test]
169    fn target_after_compress_below_snip_threshold() {
170        let c = ContextConfig::default();
171        assert!(c.target_after_compress < c.snip_threshold);
172    }
173
174    #[test]
175    fn derived_limits_scale_with_max_tokens() {
176        let c = ContextConfig::default();
177        let small = 8_000u32;
178        let large = 200_000u32;
179        let ratio = c.snip_per_msg_tokens(large) as f64 / c.snip_per_msg_tokens(small) as f64;
180        assert!((ratio - 25.0).abs() < 1.0, "expected ~25×, got {ratio}");
181    }
182
183    #[test]
184    fn small_context_window_has_floor() {
185        let c = ContextConfig::default();
186        assert!(c.snip_per_msg_tokens(100) >= 50);
187        assert!(c.carryover_tokens(100) >= 100);
188    }
189}