Skip to main content

matrixcode_core/compress/
config.rs

1//! Compression configuration and bias settings.
2
3use anyhow::Result;
4
5// ============================================================================
6// Constants
7// ============================================================================
8
9/// Compression trigger threshold (percentage of context window).
10/// Lowered to 0.5 to compress earlier for long conversations (128K context -> 64K threshold)
11pub const DEFAULT_COMPRESSION_THRESHOLD: f64 = 0.5;
12
13/// Minimum messages to keep after compression.
14/// Increased to preserve more recent context for continuity
15pub const MIN_MESSAGES_TO_KEEP: usize = 20;
16
17/// Target ratio after compression (keep this fraction of tokens).
18pub const DEFAULT_TARGET_RATIO: f64 = 0.4;
19
20/// Default model for summarization.
21pub const DEFAULT_COMPRESSOR_MODEL: &str = "claude-3-5-haiku-20241022";
22
23// ============================================================================
24// Circuit Breaker (NEW - from Claude Code)
25// ============================================================================
26
27/// Maximum consecutive compression failures before stopping retries.
28/// Claude Code: "1,279 sessions had 50+ consecutive failures, wasting ~250K API calls/day"
29pub const MAX_CONSECUTIVE_FAILURES: u32 = 3;
30
31/// Token buffers for threshold levels (from Claude Code).
32pub const AUTOCOMPACT_BUFFER_TOKENS: u32 = 13_000;
33pub const WARNING_THRESHOLD_BUFFER_TOKENS: u32 = 20_000;
34pub const ERROR_THRESHOLD_BUFFER_TOKENS: u32 = 20_000;
35pub const MANUAL_COMPACT_BUFFER_TOKENS: u32 = 3_000;
36
37/// Time-based microcompact threshold (minutes since last assistant message).
38/// When gap exceeds this, server cache has expired - clear old tool results.
39pub const TIME_BASED_MC_GAP_THRESHOLD_MINUTES: u32 = 5;
40
41/// Message to replace cleared tool result content (from Claude Code).
42pub const TIME_BASED_MC_CLEARED_MESSAGE: &str = "[Old tool result content cleared]";
43
44// ============================================================================
45// Threshold Levels (NEW)
46// ============================================================================
47
48/// Threshold level for compression warnings.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum ThresholdLevel {
51    /// Normal - no action needed
52    Normal,
53    /// Warning - approaching limit, warn user
54    Warning,
55    /// Error - near limit, strongly suggest compact
56    Error,
57    /// Blocking - must compact before continuing
58    Blocking,
59}
60
61// ============================================================================
62// Helper Functions
63// ============================================================================
64
65/// Format token count for display.
66pub fn format_tokens(n: u32) -> String {
67    if n < 1_000 {
68        n.to_string()
69    } else if n < 10_000 {
70        format!("{:.1}K", n as f64 / 1_000.0)
71    } else {
72        format!("{:.0}K", n as f64 / 1_000.0)
73    }
74}
75
76// ============================================================================
77// Compression Bias
78// ============================================================================
79
80/// Compression bias - controls what to prioritize during compression.
81#[derive(Debug, Clone, Default)]
82pub struct CompressionBias {
83    /// Preserve tool calls and their results.
84    pub preserve_tools: bool,
85    /// Preserve thinking blocks.
86    pub preserve_thinking: bool,
87    /// Preserve user questions.
88    pub preserve_user_questions: bool,
89    /// Compact long outputs instead of removing.
90    pub compact_long_outputs: bool,
91    /// Aggressive mode - remove more content.
92    pub aggressive: bool,
93    /// Custom keywords to preserve.
94    pub preserve_keywords: Vec<String>,
95}
96
97impl CompressionBias {
98    /// Default bias - balanced preservation.
99    pub fn balanced() -> Self {
100        Self {
101            preserve_tools: true,
102            preserve_thinking: false,
103            preserve_user_questions: true,
104            compact_long_outputs: false,
105            aggressive: false,
106            preserve_keywords: vec![
107                "决定".to_string(),
108                "decision".to_string(),
109                "重要".to_string(),
110                "important".to_string(),
111                "关键".to_string(),
112                "key".to_string(),
113            ],
114        }
115    }
116
117    /// Preserve all important content.
118    pub fn preserve_important() -> Self {
119        Self {
120            preserve_tools: true,
121            preserve_thinking: true,
122            preserve_user_questions: true,
123            compact_long_outputs: true,
124            aggressive: false,
125            preserve_keywords: vec![
126                "决定".to_string(),
127                "decision".to_string(),
128                "重要".to_string(),
129                "important".to_string(),
130                "关键".to_string(),
131                "key".to_string(),
132                "完成".to_string(),
133                "done".to_string(),
134                "成功".to_string(),
135                "success".to_string(),
136            ],
137        }
138    }
139
140    /// Aggressive compression.
141    pub fn aggressive() -> Self {
142        Self {
143            preserve_tools: false,
144            preserve_thinking: false,
145            preserve_user_questions: false,
146            compact_long_outputs: false,
147            aggressive: true,
148            preserve_keywords: vec![],
149        }
150    }
151
152    /// Focus on preserving tool operations.
153    pub fn tool_focused() -> Self {
154        Self {
155            preserve_tools: true,
156            preserve_thinking: false,
157            preserve_user_questions: false,
158            compact_long_outputs: false,
159            aggressive: false,
160            preserve_keywords: vec![
161                "工具".to_string(),
162                "tool".to_string(),
163                "执行".to_string(),
164                "execute".to_string(),
165                "文件".to_string(),
166                "file".to_string(),
167            ],
168        }
169    }
170
171    /// Parse bias from a string specification.
172    pub fn parse(spec: &str) -> Result<Self> {
173        let spec = spec.trim().to_lowercase();
174
175        if spec == "balanced" || spec == "default" || spec.is_empty() {
176            return Ok(Self::balanced());
177        }
178        if spec == "aggressive" {
179            return Ok(Self::aggressive());
180        }
181        if spec == "preserve_important" || spec == "important" {
182            return Ok(Self::preserve_important());
183        }
184        if spec == "tool_focused" || spec == "tools" {
185            return Ok(Self::tool_focused());
186        }
187
188        // Parse custom specification
189        let mut bias = Self::default();
190
191        for part in spec.split_whitespace() {
192            if let Some(preserve_list) = part.strip_prefix("preserve:") {
193                for item in preserve_list.split(',') {
194                    match item.trim() {
195                        "tools" | "tool" => bias.preserve_tools = true,
196                        "thinking" | "think" => bias.preserve_thinking = true,
197                        "user" | "questions" => bias.preserve_user_questions = true,
198                        "compact" | "long" => bias.compact_long_outputs = true,
199                        _ => {}
200                    }
201                }
202            } else if let Some(keyword_list) = part.strip_prefix("keywords:") {
203                bias.preserve_keywords = keyword_list
204                    .split(',')
205                    .map(|k| k.trim().to_string())
206                    .filter(|k| !k.is_empty())
207                    .collect();
208            } else if part == "aggressive" {
209                bias.aggressive = true;
210            }
211        }
212
213        Ok(bias)
214    }
215
216    /// Format bias for display.
217    pub fn format(&self) -> String {
218        let mut parts: Vec<String> = Vec::new();
219
220        if self.preserve_tools {
221            parts.push("tools".to_string());
222        }
223        if self.preserve_thinking {
224            parts.push("thinking".to_string());
225        }
226        if self.preserve_user_questions {
227            parts.push("user".to_string());
228        }
229        if self.compact_long_outputs {
230            parts.push("compact".to_string());
231        }
232        if self.aggressive {
233            parts.push("aggressive".to_string());
234        }
235
236        if !self.preserve_keywords.is_empty() {
237            parts.push(format!("keywords:{}", self.preserve_keywords.join(",")));
238        }
239
240        if parts.is_empty() {
241            "default".to_string()
242        } else {
243            parts.join(", ")
244        }
245    }
246}
247
248// ============================================================================
249// Compression Configuration
250// ============================================================================
251
252/// Configuration for context compression.
253#[derive(Debug, Clone)]
254pub struct CompressionConfig {
255    /// Threshold (0.0-1.0) at which to trigger compression.
256    pub threshold: f64,
257    /// Maximum tokens to target after compression.
258    pub target_ratio: f64,
259    /// Minimum recent messages to always preserve.
260    pub min_preserve_messages: usize,
261    /// Whether to use AI summarization.
262    pub use_summarization: bool,
263    /// Optional model name for summarization.
264    pub compressor_model: Option<String>,
265    /// Compression bias.
266    pub bias: CompressionBias,
267}
268
269impl Default for CompressionConfig {
270    fn default() -> Self {
271        Self {
272            threshold: DEFAULT_COMPRESSION_THRESHOLD,
273            target_ratio: DEFAULT_TARGET_RATIO,
274            min_preserve_messages: MIN_MESSAGES_TO_KEEP,
275            use_summarization: true,
276            compressor_model: None,
277            bias: CompressionBias::balanced(),
278        }
279    }
280}
281
282impl CompressionConfig {
283    /// Get the compressor model name.
284    pub fn compressor_model_name(&self) -> &str {
285        self.compressor_model
286            .as_deref()
287            .unwrap_or(DEFAULT_COMPRESSOR_MODEL)
288    }
289
290    /// Calculate threshold level based on token usage.
291    /// Returns the level and percentage of context remaining.
292    pub fn calculate_threshold_level(
293        token_usage: u32,
294        context_window: u32,
295    ) -> (ThresholdLevel, u32) {
296        let percent_left = if context_window > 0 {
297            // 使用 saturating_sub 防止下溢,确保百分比在 0-100 范围内
298            let remaining = context_window.saturating_sub(token_usage);
299            ((remaining as f64 / context_window as f64 * 100.0) as u32).min(100)
300        } else {
301            0
302        };
303
304        // Calculate thresholds
305        let auto_threshold = context_window.saturating_sub(AUTOCOMPACT_BUFFER_TOKENS);
306        let warning_threshold = auto_threshold.saturating_sub(WARNING_THRESHOLD_BUFFER_TOKENS);
307        let error_threshold = auto_threshold.saturating_sub(ERROR_THRESHOLD_BUFFER_TOKENS);
308        let blocking_threshold = context_window.saturating_sub(MANUAL_COMPACT_BUFFER_TOKENS);
309
310        let level = if token_usage >= blocking_threshold {
311            ThresholdLevel::Blocking
312        } else if token_usage >= error_threshold {
313            ThresholdLevel::Error
314        } else if token_usage >= warning_threshold {
315            ThresholdLevel::Warning
316        } else {
317            ThresholdLevel::Normal
318        };
319
320        (level, percent_left)
321    }
322}
323
324// ============================================================================
325// Circuit Breaker State (NEW)
326// ============================================================================
327
328/// State for circuit breaker to prevent infinite retry loops.
329#[derive(Debug, Clone, Default)]
330pub struct CircuitBreakerState {
331    /// Number of consecutive compression failures.
332    pub consecutive_failures: u32,
333    /// Whether circuit breaker has tripped.
334    pub is_tripped: bool,
335    /// Last failure timestamp (for reset timeout).
336    pub last_failure_time: Option<u64>,
337}
338
339impl CircuitBreakerState {
340    /// Create a new circuit breaker state.
341    pub fn new() -> Self {
342        Self::default()
343    }
344
345    /// Record a failure. Returns true if circuit breaker should trip.
346    pub fn record_failure(&mut self) -> bool {
347        self.consecutive_failures += 1;
348        self.last_failure_time = Some(std::time::SystemTime::now()
349            .duration_since(std::time::UNIX_EPOCH)
350            .unwrap_or_default()
351            .as_secs());
352
353        if self.consecutive_failures >= MAX_CONSECUTIVE_FAILURES {
354            self.is_tripped = true;
355            return true;
356        }
357        false
358    }
359
360    /// Record a success. Resets failure count.
361    pub fn record_success(&mut self) {
362        self.consecutive_failures = 0;
363        self.is_tripped = false;
364        self.last_failure_time = None;
365    }
366
367    /// Check if compression should be skipped due to circuit breaker.
368    pub fn should_skip(&self) -> bool {
369        self.is_tripped
370    }
371
372    /// Reset the circuit breaker (manual override).
373    pub fn reset(&mut self) {
374        self.consecutive_failures = 0;
375        self.is_tripped = false;
376        self.last_failure_time = None;
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    #[test]
385    fn test_calculate_threshold_level_normal() {
386        // 正常情况:使用了 50% 的上下文
387        let (level, percent) = CompressionConfig::calculate_threshold_level(50_000, 100_000);
388        assert_eq!(level, ThresholdLevel::Normal);
389        assert_eq!(percent, 50);
390    }
391
392    #[test]
393    fn test_calculate_threshold_level_exceeds_window() {
394        // 关键测试:token_usage 超过 context_window
395        // 修复前会因 u32 下溢产生巨大值,修复后应为 0
396        let (level, percent) = CompressionConfig::calculate_threshold_level(120_000, 100_000);
397        assert_eq!(level, ThresholdLevel::Blocking);
398        assert_eq!(percent, 0, "百分比应为 0,不应该超过 100%");
399    }
400
401    #[test]
402    fn test_calculate_threshold_level_full_usage() {
403        // 完全用满上下文
404        let (level, percent) = CompressionConfig::calculate_threshold_level(100_000, 100_000);
405        assert_eq!(level, ThresholdLevel::Blocking);
406        assert_eq!(percent, 0);
407    }
408
409    #[test]
410    fn test_calculate_threshold_level_zero_window() {
411        // 边界情况:context_window 为 0,意味着没有可用空间
412        // 所有阈值都会变成 0,因此任何 token_usage > 0 都触发 Blocking
413        let (level, percent) = CompressionConfig::calculate_threshold_level(1000, 0);
414        assert_eq!(level, ThresholdLevel::Blocking);  // 0 空间时应该阻止
415        assert_eq!(percent, 0);
416    }
417
418    #[test]
419    fn test_calculate_threshold_level_small_remaining() {
420        // 接近上限但未超过
421        let (_level, percent) = CompressionConfig::calculate_threshold_level(99_000, 100_000);
422        assert_eq!(percent, 1);
423    }
424}