Skip to main content

thndrs_agent/context/
compaction.rs

1//! Pure compaction configuration, pressure policy, and risk classification.
2
3use serde::{Deserialize, Serialize};
4
5use super::{ContextBudget, ModelContextLimits};
6
7/// User-selected compaction behavior.
8#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum CompactionMode {
11    /// Never compact automatically or in response to `/compact`.
12    Off,
13    /// Compact only after an idle user explicitly requests `/compact`.
14    #[default]
15    Manual,
16    /// Compact automatically when the post-selection context is under pressure.
17    Auto,
18}
19
20impl CompactionMode {
21    /// Stable lowercase label for diagnostics and user-facing context health.
22    pub fn label(self) -> &'static str {
23        match self {
24            Self::Off => "off",
25            Self::Manual => "manual",
26            Self::Auto => "auto",
27        }
28    }
29}
30
31/// Review policy for a proposed compaction summary.
32#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum CompactionReview {
35    /// Require review for every compaction.
36    Always,
37    /// Require review only for a high-risk covered range.
38    #[default]
39    Auto,
40    /// Never require review.
41    Never,
42}
43
44/// Context-control configuration parsed from `[context]`.
45#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
46#[serde(default, deny_unknown_fields)]
47pub struct ContextConfig {
48    /// Compaction-specific options.
49    pub compaction: CompactionConfig,
50}
51
52/// Compaction configuration parsed from `[context.compaction]`.
53#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
54#[serde(default, deny_unknown_fields)]
55pub struct CompactionConfig {
56    /// Whether compaction is disabled, manually requested, or automatic.
57    pub mode: CompactionMode,
58    /// When a generated summary needs user review before becoming active.
59    pub review: CompactionReview,
60}
61
62/// Risk level of material a summary would replace.
63#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
64pub enum CompactionRisk {
65    /// No detail requiring explicit review was detected.
66    #[default]
67    Low,
68    /// The range contains operational or unresolved details.
69    High,
70}
71
72/// Inputs considered when classifying a compaction range.
73#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
74pub struct CompactionRiskSignals {
75    /// The range contains tool output or a patch/diff.
76    pub has_tool_output_or_diff: bool,
77    /// The range contains an error, permission prompt, or failed command.
78    pub has_failure_or_permission: bool,
79    /// The range contains a correction or unfinished work.
80    pub has_correction_or_unresolved_work: bool,
81}
82
83impl CompactionRiskSignals {
84    /// Classify the signals conservatively.
85    pub fn classify(self) -> CompactionRisk {
86        if self.has_tool_output_or_diff || self.has_failure_or_permission || self.has_correction_or_unresolved_work {
87            CompactionRisk::High
88        } else {
89            CompactionRisk::Low
90        }
91    }
92}
93
94/// Pure decisions made before a compaction request is sent.
95#[derive(Clone, Copy, Debug, Eq, PartialEq)]
96pub struct CompactionPolicy {
97    /// Selected compaction mode.
98    pub mode: CompactionMode,
99    /// Selected review policy.
100    pub review: CompactionReview,
101}
102
103/// A configured-model request for one manual compaction.
104///
105/// The source text is deliberately held only in the running process. Callers persist the
106/// resulting summary and recovery handle, never this request body.
107#[derive(Clone, Debug, Eq, PartialEq)]
108pub struct ManualCompactionRequest {
109    /// The selected configured model; manual compaction never substitutes a
110    /// local summarizer.
111    pub model: String,
112    /// Prompt sent to that configured model.
113    pub prompt: String,
114    /// Handle for reopening the original session material after success.
115    pub recovery_handle: String,
116}
117
118/// Build a manual-compaction request without changing active context.
119///
120/// This is intentionally a prepare step: a provider error simply discards the
121/// request and leaves the caller's current projection untouched.
122pub fn prepare_manual_compaction(
123    policy: CompactionPolicy, model: &str, source_text: &str, recovery_handle: &str,
124) -> Result<ManualCompactionRequest, String> {
125    if !policy.allows_manual() {
126        return Err("compaction is disabled by context.compaction.mode".to_string());
127    }
128    if model.trim().is_empty() {
129        return Err("manual compaction requires a configured model".to_string());
130    }
131    if source_text.trim().is_empty() {
132        return Err("there is no active context to compact".to_string());
133    }
134    if recovery_handle.trim().is_empty() {
135        return Err("manual compaction requires a recovery handle".to_string());
136    }
137
138    Ok(ManualCompactionRequest {
139        model: model.to_string(),
140        prompt: format!(
141            "Summarize the following prior work for continuation. Preserve decisions, changed files, failures, permissions, corrections, and unresolved work. Do not invent results.\n\n<source_context>\n{source_text}\n</source_context>"
142        ),
143        recovery_handle: recovery_handle.to_string(),
144    })
145}
146
147impl CompactionPolicy {
148    /// Build a policy from parsed configuration.
149    pub fn from_config(config: &CompactionConfig) -> Self {
150        Self { mode: config.mode, review: config.review }
151    }
152
153    /// Whether an idle `/compact` request may start a configured-model summary.
154    pub fn allows_manual(self) -> bool {
155        !matches!(self.mode, CompactionMode::Off)
156    }
157
158    /// Whether auto-compaction is eligible after normal selection.
159    ///
160    /// The strict comparison deliberately preserves the specified 92% boundary:
161    /// exactly 92% does not compact; values above it may compact.
162    pub fn should_auto_compact(self, budget: &ContextBudget) -> bool {
163        matches!(self.mode, CompactionMode::Auto) && budget.exceeds_auto_compaction()
164    }
165
166    /// Whether a proposed summary requires user review before it becomes active.
167    pub fn requires_review(self, risk: CompactionRisk) -> bool {
168        match self.review {
169            CompactionReview::Always => true,
170            CompactionReview::Auto => matches!(risk, CompactionRisk::High),
171            CompactionReview::Never => false,
172        }
173    }
174}
175
176/// Outcome of the preflight pressure check run before a main provider request.
177///
178/// Auto-compaction is a preflight gate ([plan](crate::context)): when a
179/// submitted turn would exceed the context policy and `mode = "auto"` permits
180/// compaction, the turn stops before the provider request, compacts with the
181/// configured model, rebuilds context, and restarts the same user turn.
182#[derive(Clone, Copy, Debug, Eq, PartialEq)]
183pub enum AutoCompactionDecision {
184    /// The upcoming request fits the policy; send it to the provider.
185    Send,
186    /// The upcoming request is oversized and auto-compaction may compact first.
187    ///
188    /// The known-oversized request must never be sent to the main provider.
189    Compact,
190}
191
192/// Decide whether an upcoming provider request needs auto-compaction first.
193///
194/// `prompt_token_estimate` is the conservative token estimate of the full
195/// prompt that would be sent (system + context + user turn). The decision is
196/// pure: given the policy, resolved model limits, and the estimate, it
197/// returns [`AutoCompactionDecision::Compact`] only when auto mode is enabled
198/// and the estimate exceeds the auto-compaction threshold.
199///
200/// Uses [`ModelContextLimits::auto_compaction_threshold`] directly so the
201/// 92% boundary stays consistent with budget-based pressure checks.
202pub fn preflight_auto_compaction(
203    policy: CompactionPolicy, limits: &ModelContextLimits, prompt_token_estimate: u64,
204) -> AutoCompactionDecision {
205    if matches!(policy.mode, CompactionMode::Auto) && prompt_token_estimate > limits.auto_compaction_threshold() {
206        AutoCompactionDecision::Compact
207    } else {
208        AutoCompactionDecision::Send
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use crate::context::{ContextBudget, ModelContextLimits, ModelLimitConfidence, ModelLimitSource};
216
217    fn budget(used: u64) -> ContextBudget {
218        let limits = ModelContextLimits {
219            provider: "test".to_string(),
220            model: "test".to_string(),
221            context_window: 101_024,
222            max_completion_tokens: 0,
223            recommended_completion_tokens: 0,
224            source: ModelLimitSource::Fallback,
225            confidence: ModelLimitConfidence::Conservative,
226        };
227        ContextBudget { limits, available_input: 100_000, target: 80_000, auto_compaction_threshold: 92_000, used }
228    }
229
230    #[test]
231    fn defaults_are_manual_and_auto_review() {
232        let config: ContextConfig = serde_json::from_str(r#"{"compaction":{}}"#).expect("parse config");
233        assert_eq!(config.compaction.mode, CompactionMode::Manual);
234        assert_eq!(config.compaction.review, CompactionReview::Auto);
235    }
236
237    #[test]
238    fn config_accepts_each_mode_and_review_choice() {
239        for mode in ["off", "manual", "auto"] {
240            for review in ["always", "auto", "never"] {
241                let config: ContextConfig =
242                    serde_json::from_str(&format!(r#"{{"compaction":{{"mode":"{mode}","review":"{review}"}}}}"#))
243                        .expect("parse config");
244                assert_eq!(
245                    config.compaction.mode,
246                    match mode {
247                        "off" => CompactionMode::Off,
248                        "manual" => CompactionMode::Manual,
249                        _ => CompactionMode::Auto,
250                    }
251                );
252                assert_eq!(
253                    config.compaction.review,
254                    match review {
255                        "always" => CompactionReview::Always,
256                        "auto" => CompactionReview::Auto,
257                        _ => CompactionReview::Never,
258                    }
259                );
260            }
261        }
262    }
263
264    #[test]
265    fn auto_compaction_starts_only_above_92_percent() {
266        let policy = CompactionPolicy { mode: CompactionMode::Auto, review: CompactionReview::Auto };
267        assert!(!policy.should_auto_compact(&budget(92_000)));
268        assert!(policy.should_auto_compact(&budget(92_001)));
269        assert!(
270            !CompactionPolicy { mode: CompactionMode::Manual, review: CompactionReview::Auto }
271                .should_auto_compact(&budget(100_000))
272        );
273    }
274
275    #[test]
276    fn high_risk_covers_every_required_signal() {
277        for signals in [
278            CompactionRiskSignals { has_tool_output_or_diff: true, ..Default::default() },
279            CompactionRiskSignals { has_failure_or_permission: true, ..Default::default() },
280            CompactionRiskSignals { has_correction_or_unresolved_work: true, ..Default::default() },
281        ] {
282            assert_eq!(signals.classify(), CompactionRisk::High);
283        }
284        assert_eq!(CompactionRiskSignals::default().classify(), CompactionRisk::Low);
285    }
286
287    #[test]
288    fn review_policy_respects_all_choices() {
289        assert!(
290            CompactionPolicy { mode: CompactionMode::Manual, review: CompactionReview::Always }
291                .requires_review(CompactionRisk::Low)
292        );
293        assert!(
294            CompactionPolicy { mode: CompactionMode::Manual, review: CompactionReview::Auto }
295                .requires_review(CompactionRisk::High)
296        );
297        assert!(
298            !CompactionPolicy { mode: CompactionMode::Manual, review: CompactionReview::Auto }
299                .requires_review(CompactionRisk::Low)
300        );
301        assert!(
302            !CompactionPolicy { mode: CompactionMode::Manual, review: CompactionReview::Never }
303                .requires_review(CompactionRisk::High)
304        );
305    }
306
307    #[test]
308    fn manual_request_uses_the_configured_model_and_is_non_mutating_on_error() {
309        let policy = CompactionPolicy { mode: CompactionMode::Manual, review: CompactionReview::Auto };
310        let request = prepare_manual_compaction(policy, "provider/model", "fixed the parser", "session:12..47")
311            .expect("prepare request");
312        assert_eq!(request.model, "provider/model");
313        assert_eq!(request.recovery_handle, "session:12..47");
314        assert!(request.prompt.contains("fixed the parser"));
315        assert!(prepare_manual_compaction(policy, "", "fixed the parser", "session:12..47").is_err());
316        assert!(prepare_manual_compaction(policy, "provider/model", "", "session:12..47").is_err());
317    }
318
319    fn limits_for(context_window: u64) -> ModelContextLimits {
320        ModelContextLimits {
321            provider: "test".to_string(),
322            model: "test".to_string(),
323            context_window,
324            max_completion_tokens: 1_024,
325            recommended_completion_tokens: 512,
326            source: ModelLimitSource::Fallback,
327            confidence: ModelLimitConfidence::Conservative,
328        }
329    }
330
331    #[test]
332    fn preflight_compacts_only_in_auto_mode_above_threshold() {
333        let auto = CompactionPolicy { mode: CompactionMode::Auto, review: CompactionReview::Auto };
334        let manual = CompactionPolicy { mode: CompactionMode::Manual, review: CompactionReview::Auto };
335        let off = CompactionPolicy { mode: CompactionMode::Off, review: CompactionReview::Auto };
336        let limits = limits_for(101_024);
337        let threshold = limits.auto_compaction_threshold();
338        assert_eq!(
339            preflight_auto_compaction(auto, &limits, threshold),
340            AutoCompactionDecision::Send
341        );
342        assert_eq!(
343            preflight_auto_compaction(auto, &limits, threshold + 1),
344            AutoCompactionDecision::Compact
345        );
346        assert_eq!(
347            preflight_auto_compaction(manual, &limits, threshold + 1),
348            AutoCompactionDecision::Send,
349            "manual mode never auto-compacts"
350        );
351        assert_eq!(
352            preflight_auto_compaction(off, &limits, threshold + 1),
353            AutoCompactionDecision::Send,
354            "off mode never auto-compacts"
355        );
356    }
357}