Skip to main content

ai_agent/services/compact/
auto_compact.rs

1// Source: /data/home/swei/claudecode/openclaudecode/src/services/compact/autoCompact.ts
2//! Auto-compact module for automatic context compaction.
3//!
4//! This module provides auto-compaction logic that triggers when conversation
5//! context approaches the token limit. It reuses core compaction functions
6//! from the parent compact.rs module.
7//!
8//! Translated from TypeScript autoCompact.ts
9
10use crate::compact::{
11    CompactionResult, TokenWarningState,
12    calculate_token_warning_state as core_calculate_token_warning_state,
13    get_auto_compact_threshold as core_get_auto_compact_threshold,
14    get_effective_context_window_size as core_get_effective_context_window_size,
15};
16use crate::types::Message;
17use crate::utils::env_utils::is_env_truthy;
18
19/// Diagnosis context passed from autoCompactIfNeeded into compactConversation.
20/// Lets the tengu_compact event disambiguate same-chain loops (H2) from
21/// cross-agent (H1/H5) and manual-vs-auto (H3) compactions without joins.
22/// Translated from: RecompactionInfo in autoCompact.ts
23#[derive(Debug, Clone, Default)]
24pub struct RecompactionInfo {
25    pub is_recompaction_in_chain: bool,
26    pub turns_since_previous_compact: i32,
27    pub previous_compact_turn_id: Option<String>,
28    pub auto_compact_threshold: usize,
29    pub query_source: Option<String>,
30}
31
32/// Result from autoCompactIfNeeded
33/// Translated from: autoCompactIfNeeded return type in autoCompact.ts
34#[derive(Debug, Clone, Default)]
35pub struct AutoCompactResult {
36    pub was_compacted: bool,
37    pub compaction_result: Option<CompactionResult>,
38    pub consecutive_failures: Option<usize>,
39}
40
41/// Auto-compact tracking state
42/// Translated from: AutoCompactTrackingState in autoCompact.ts
43#[derive(Debug, Clone, Default)]
44pub struct AutoCompactTrackingState {
45    pub compacted: bool,
46    pub turn_counter: usize,
47    /// Unique ID per turn
48    pub turn_id: String,
49    /// Consecutive autocompact failures. Reset on success.
50    /// Used as a circuit breaker to stop retrying when the context is
51    /// irrecoverably over the limit (e.g., prompt_too_long).
52    pub consecutive_failures: usize,
53}
54
55impl AutoCompactTrackingState {
56    pub fn new() -> Self {
57        Self {
58            compacted: false,
59            turn_counter: 0,
60            turn_id: uuid::Uuid::new_v4().to_string(),
61            consecutive_failures: 0,
62        }
63    }
64}
65
66// Re-export constants from compact.rs for convenience
67pub use crate::compact::{
68    AUTOCOMPACT_BUFFER_TOKENS, ERROR_THRESHOLD_BUFFER_TOKENS, MANUAL_COMPACT_BUFFER_TOKENS,
69    MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES,
70};
71
72/// Get effective context window size (total - output reserve)
73/// Translated from: getEffectiveContextWindowSize in autoCompact.ts
74/// This reuses the core function and converts to usize for compatibility
75pub fn get_effective_context_window_size(model: &str) -> usize {
76    core_get_effective_context_window_size(model) as usize
77}
78
79/// Get auto-compact threshold (when to trigger compaction)
80/// Translated from: getAutoCompactThreshold in autoCompact.ts
81/// This reuses the core function and converts to usize for compatibility
82pub fn get_auto_compact_threshold(model: &str) -> usize {
83    core_get_auto_compact_threshold(model) as usize
84}
85
86/// Calculate token warning state
87/// Translated from: calculateTokenWarningState in autoCompact.ts
88/// This reuses the core function
89pub fn calculate_token_warning_state(token_usage: usize, model: &str) -> TokenWarningState {
90    core_calculate_token_warning_state(token_usage as u32, model)
91}
92
93/// Check if auto-compact is enabled
94/// Translated from: isAutoCompactEnabled in autoCompact.ts
95pub fn is_auto_compact_enabled() -> bool {
96    if is_env_truthy(Some("DISABLE_COMPACT")) {
97        return false;
98    }
99    // Allow disabling just auto-compact (keeps manual /compact working)
100    if is_env_truthy(Some("DISABLE_AUTO_COMPACT")) {
101        return false;
102    }
103    // Check if user has disabled auto-compact in their settings
104    // In the full implementation, this would check getGlobalConfig().autoCompactEnabled
105    // For now, default to true
106    true
107}
108
109/// Check if query source is a forked agent that would deadlock
110fn is_forked_agent_query_source(query_source: Option<&str>) -> bool {
111    matches!(query_source, Some("session_memory") | Some("compact"))
112}
113
114/// Check if query source is marble_origami (ctx-agent)
115fn is_marble_origami_query_source(query_source: Option<&str>) -> bool {
116    matches!(query_source, Some("marble_origami"))
117}
118
119/// Check if auto-compact should run
120/// Translated from: shouldAutoCompact in autoCompact.ts
121pub fn should_auto_compact(
122    messages: &[Message],
123    model: &str,
124    query_source: Option<&str>,
125    snip_tokens_freed: usize,
126) -> bool {
127    // Recursion guards. session_memory and compact are forked agents that
128    // would deadlock.
129    if is_forked_agent_query_source(query_source) {
130        return false;
131    }
132
133    // marble_origami is the ctx-agent — if ITS context blows up and
134    // autocompact fires, runPostCompactCleanup calls resetContextCollapse()
135    // which destroys the MAIN thread's committed log
136    // Feature gate: CONTEXT_COLLAPSE - for now skip this check
137
138    if !is_auto_compact_enabled() {
139        return false;
140    }
141
142    // Feature gate: REACTIVE_COMPACT - suppress proactive autocompact
143    // In full implementation, check getFeatureValue_CACHED_MAY_BE_STALE('tengu_cobalt_raccoon', false)
144    // For now, skip this feature gate
145
146    // Feature gate: CONTEXT_COLLAPSE
147    // In full implementation, check isContextCollapseEnabled()
148    // For now, skip this feature gate
149
150    // Calculate token count
151    let token_count = estimate_token_count(messages).saturating_sub(snip_tokens_freed);
152    let threshold = get_auto_compact_threshold(model);
153    let effective_window = get_effective_context_window_size(model);
154
155    log::debug!(
156        "autocompact: tokens={} threshold={} effective_window={}{}",
157        token_count,
158        threshold,
159        effective_window,
160        if snip_tokens_freed > 0 {
161            format!(" snipFreed={}", snip_tokens_freed)
162        } else {
163            String::new()
164        }
165    );
166
167    let state = calculate_token_warning_state(token_count, model);
168    state.is_above_auto_compact_threshold
169}
170
171/// Perform auto-compaction if needed
172/// Translated from: autoCompactIfNeeded in autoCompact.ts
173pub async fn auto_compact_if_needed(
174    messages: &[Message],
175    model: &str,
176    query_source: Option<&str>,
177    tracking: Option<&AutoCompactTrackingState>,
178    snip_tokens_freed: usize,
179) -> AutoCompactResult {
180    // Check if compact is disabled
181    if is_env_truthy(Some("DISABLE_COMPACT")) {
182        return AutoCompactResult::default();
183    }
184
185    // Circuit breaker: stop retrying after N consecutive failures.
186    // Without this, sessions where context is irrecoverably over the limit
187    // hammer the API with doomed compaction attempts on every turn.
188    if let Some(t) = tracking {
189        if t.consecutive_failures >= MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES as usize {
190            return AutoCompactResult::default();
191        }
192    }
193
194    let should_compact = should_auto_compact(messages, model, query_source, snip_tokens_freed);
195
196    if !should_compact {
197        return AutoCompactResult::default();
198    }
199
200    // Build recompaction info
201    let recompaction_info = RecompactionInfo {
202        is_recompaction_in_chain: tracking.map(|t| t.compacted).unwrap_or(false),
203        turns_since_previous_compact: tracking.map(|t| t.turn_counter as i32).unwrap_or(-1),
204        previous_compact_turn_id: tracking.map(|t| t.turn_id.clone()),
205        auto_compact_threshold: get_auto_compact_threshold(model),
206        query_source: query_source.map(|s| s.to_string()),
207    };
208
209    log::debug!(
210        "autocompact: triggering compaction with recompaction_info: {:?}",
211        recompaction_info
212    );
213
214    // EXPERIMENT: Try session memory compaction first
215    // In full implementation: trySessionMemoryCompaction(messages, agentId, recompactionInfo.autoCompactThreshold)
216    // For now, skip session memory compaction
217
218    // Call the actual compaction logic
219    let token_count = estimate_token_count(messages);
220    let effective_window = get_effective_context_window_size(model);
221
222    // Target: compact down to about 60% of the effective window to leave room
223    let target_tokens = (effective_window as f64 * 0.6) as u64;
224
225    let options = crate::services::compact::compact::CompactOptions {
226        max_tokens: Some(target_tokens),
227        direction: crate::services::compact::compact::CompactDirection::Smart,
228        create_boundary: true,
229        system_prompt: None,
230    };
231
232    match crate::services::compact::compact::compact_messages(messages, options).await {
233        Ok(compact_result) => {
234            if compact_result.success {
235                log::info!(
236                    "autocompact: compacted {} messages ({} -> {} tokens, removed {} messages)",
237                    messages.len(),
238                    compact_result.tokens_before,
239                    compact_result.tokens_after,
240                    compact_result.messages_removed
241                );
242                // Build a boundary marker message for the compaction result
243                let boundary_marker = crate::types::Message {
244                    role: crate::types::MessageRole::User,
245                    content: format!("[Conversation was compacted. {} messages summarized to free up context space.]", compact_result.messages_removed),
246                    attachments: None,
247                    tool_call_id: None,
248                    tool_calls: None,
249                    is_error: None,
250                    is_meta: Some(true),
251                    is_api_error_message: None,
252                    error_details: None,
253                    uuid: None,
254                };
255                let summary_messages = if !compact_result.summary.is_empty() {
256                    vec![crate::types::Message {
257                        role: crate::types::MessageRole::Assistant,
258                        content: compact_result.summary,
259                        attachments: None,
260                        tool_call_id: None,
261                        tool_calls: None,
262                        is_error: None,
263                        is_meta: None,
264                        is_api_error_message: None,
265                        error_details: None,
266                        uuid: None,
267                    }]
268                } else {
269                    vec![]
270                };
271                AutoCompactResult {
272                    was_compacted: true,
273                    compaction_result: Some(CompactionResult {
274                        boundary_marker,
275                        summary_messages,
276                        messages_to_keep: Some(compact_result.messages_to_keep),
277                        attachments: vec![],
278                        pre_compact_token_count: compact_result.tokens_before as u32,
279                        post_compact_token_count: compact_result.tokens_after as u32,
280                        true_post_compact_token_count: None,
281                        compaction_usage: None,
282                    }),
283                    consecutive_failures: Some(0), // Reset on success
284                }
285            } else {
286                log::warn!(
287                    "autocompact: compaction failed: {:?}",
288                    compact_result.error
289                );
290                let prev_failures = tracking.map(|t| t.consecutive_failures).unwrap_or(0);
291                let next_failures = prev_failures + 1;
292                if next_failures >= MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES as usize {
293                    log::warn!(
294                        "autocompact: circuit breaker tripped after {} consecutive failures — skipping future attempts this session",
295                        next_failures
296                    );
297                }
298                AutoCompactResult {
299                    was_compacted: false,
300                    compaction_result: None,
301                    consecutive_failures: Some(next_failures),
302                }
303            }
304        }
305        Err(e) => {
306            log::error!("autocompact: compaction error: {}", e);
307            let prev_failures = tracking.map(|t| t.consecutive_failures).unwrap_or(0);
308            let next_failures = prev_failures + 1;
309            if next_failures >= MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES as usize {
310                log::warn!(
311                    "autocompact: circuit breaker tripped after {} consecutive failures — skipping future attempts this session",
312                    next_failures
313                );
314            }
315            AutoCompactResult {
316                was_compacted: false,
317                compaction_result: None,
318                consecutive_failures: Some(next_failures),
319            }
320        }
321    }
322}
323
324/// Estimate token count for messages
325/// Simplified version - full implementation would use tokenCountWithEstimation
326fn estimate_token_count(messages: &[Message]) -> usize {
327    // Rough estimation: 4 chars per token
328    messages.iter().map(|m| m.content.len() / 4).sum()
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    use crate::types::MessageRole;
335
336    #[test]
337    fn test_get_effective_context_window_size() {
338        let window = get_effective_context_window_size("claude-sonnet-4-6");
339        // Should be 200000 - 20000 = 180000
340        assert!(window > 0);
341    }
342
343    #[test]
344    fn test_get_auto_compact_threshold() {
345        let threshold = get_auto_compact_threshold("claude-sonnet-4-6");
346        // Should be 180000 - 13000 = 167000
347        let effective = get_effective_context_window_size("claude-sonnet-4-6");
348        assert!(threshold < effective);
349    }
350
351    #[test]
352    fn test_calculate_token_warning_state() {
353        let state = calculate_token_warning_state(50_000, "claude-sonnet-4-6");
354        assert!(!state.is_above_warning_threshold);
355        assert!(!state.is_above_error_threshold);
356        assert!(!state.is_above_auto_compact_threshold);
357        assert!(state.percent_left > 50.0);
358    }
359
360    #[test]
361    fn test_calculate_token_warning_state_at_threshold() {
362        let threshold = get_auto_compact_threshold("claude-sonnet-4-6");
363        let state = calculate_token_warning_state(threshold as usize, "claude-sonnet-4-6");
364        assert!(state.is_above_auto_compact_threshold);
365    }
366
367    #[test]
368    fn test_is_auto_compact_enabled_default() {
369        // Should return true by default
370        let result = is_auto_compact_enabled();
371        assert!(result || !result); // Just check it doesn't panic
372    }
373
374    #[test]
375    fn test_should_auto_compact_empty_messages() {
376        let messages: Vec<Message> = vec![];
377        let result = should_auto_compact(&messages, "claude-sonnet-4-6", None, 0);
378        // Empty messages should not trigger compaction
379        assert!(!result);
380    }
381
382    #[test]
383    fn test_should_auto_compact_forked_agent_guards() {
384        let messages: Vec<Message> = vec![];
385        // session_memory should return false
386        let result = should_auto_compact(&messages, "claude-sonnet-4-6", Some("session_memory"), 0);
387        assert!(!result);
388
389        // compact should return false
390        let result = should_auto_compact(&messages, "claude-sonnet-4-6", Some("compact"), 0);
391        assert!(!result);
392    }
393
394    #[test]
395    fn test_auto_compact_tracking_state() {
396        let state = AutoCompactTrackingState::new();
397        assert!(!state.compacted);
398        assert_eq!(state.turn_counter, 0);
399        assert!(!state.turn_id.is_empty());
400        assert_eq!(state.consecutive_failures, 0);
401    }
402
403    #[test]
404    fn test_recompaction_info_default() {
405        let info = RecompactionInfo::default();
406        assert!(!info.is_recompaction_in_chain);
407        assert_eq!(info.turns_since_previous_compact, 0);
408        assert!(info.previous_compact_turn_id.is_none());
409    }
410
411    #[test]
412    fn test_auto_compact_result_default() {
413        let result = AutoCompactResult::default();
414        assert!(!result.was_compacted);
415        assert!(result.compaction_result.is_none());
416    }
417}