1use serde::{Deserialize, Serialize};
4
5use super::{ContextBudget, ModelContextLimits};
6
7#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum CompactionMode {
11 Off,
13 #[default]
15 Manual,
16 Auto,
18}
19
20impl CompactionMode {
21 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#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum CompactionReview {
35 Always,
37 #[default]
39 Auto,
40 Never,
42}
43
44#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
46#[serde(default, deny_unknown_fields)]
47pub struct ContextConfig {
48 pub compaction: CompactionConfig,
50}
51
52#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
54#[serde(default, deny_unknown_fields)]
55pub struct CompactionConfig {
56 pub mode: CompactionMode,
58 pub review: CompactionReview,
60}
61
62#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
64pub enum CompactionRisk {
65 #[default]
67 Low,
68 High,
70}
71
72#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
74pub struct CompactionRiskSignals {
75 pub has_tool_output_or_diff: bool,
77 pub has_failure_or_permission: bool,
79 pub has_correction_or_unresolved_work: bool,
81}
82
83impl CompactionRiskSignals {
84 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
96pub struct CompactionPolicy {
97 pub mode: CompactionMode,
99 pub review: CompactionReview,
101}
102
103#[derive(Clone, Debug, Eq, PartialEq)]
108pub struct ManualCompactionRequest {
109 pub model: String,
112 pub prompt: String,
114 pub recovery_handle: String,
116}
117
118pub 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 pub fn from_config(config: &CompactionConfig) -> Self {
150 Self { mode: config.mode, review: config.review }
151 }
152
153 pub fn allows_manual(self) -> bool {
155 !matches!(self.mode, CompactionMode::Off)
156 }
157
158 pub fn should_auto_compact(self, budget: &ContextBudget) -> bool {
163 matches!(self.mode, CompactionMode::Auto) && budget.exceeds_auto_compaction()
164 }
165
166 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
183pub enum AutoCompactionDecision {
184 Send,
186 Compact,
190}
191
192pub 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}