Skip to main content

deepstrike_core/context/
config.rs

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