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    calculate_token_warning_state as core_calculate_token_warning_state,
12    get_auto_compact_threshold as core_get_auto_compact_threshold,
13    get_effective_context_window_size as core_get_effective_context_window_size,
14    CompactionResult, TokenWarningState,
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    // Note: The actual compaction call to compactConversation would go here
219    // For now, return that compaction was not performed (simplified implementation)
220    let prev_failures = tracking.map(|t| t.consecutive_failures).unwrap_or(0);
221    let next_failures = prev_failures + 1;
222
223    if next_failures >= MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES as usize {
224        log::warn!(
225            "autocompact: circuit breaker tripped after {} consecutive failures — skipping future attempts this session",
226            next_failures
227        );
228    }
229
230    AutoCompactResult {
231        was_compacted: false,
232        compaction_result: None,
233        consecutive_failures: Some(next_failures),
234    }
235}
236
237/// Estimate token count for messages
238/// Simplified version - full implementation would use tokenCountWithEstimation
239fn estimate_token_count(messages: &[Message]) -> usize {
240    // Rough estimation: 4 chars per token
241    messages.iter().map(|m| m.content.len() / 4).sum()
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use crate::types::MessageRole;
248
249    #[test]
250    fn test_get_effective_context_window_size() {
251        let window = get_effective_context_window_size("claude-sonnet-4-6");
252        // Should be 200000 - 20000 = 180000
253        assert!(window > 0);
254    }
255
256    #[test]
257    fn test_get_auto_compact_threshold() {
258        let threshold = get_auto_compact_threshold("claude-sonnet-4-6");
259        // Should be 180000 - 13000 = 167000
260        let effective = get_effective_context_window_size("claude-sonnet-4-6");
261        assert!(threshold < effective);
262    }
263
264    #[test]
265    fn test_calculate_token_warning_state() {
266        let state = calculate_token_warning_state(50_000, "claude-sonnet-4-6");
267        assert!(!state.is_above_warning_threshold);
268        assert!(!state.is_above_error_threshold);
269        assert!(!state.is_above_auto_compact_threshold);
270        assert!(state.percent_left > 50.0);
271    }
272
273    #[test]
274    fn test_calculate_token_warning_state_at_threshold() {
275        let threshold = get_auto_compact_threshold("claude-sonnet-4-6");
276        let state = calculate_token_warning_state(threshold as usize, "claude-sonnet-4-6");
277        assert!(state.is_above_auto_compact_threshold);
278    }
279
280    #[test]
281    fn test_is_auto_compact_enabled_default() {
282        // Should return true by default
283        let result = is_auto_compact_enabled();
284        assert!(result || !result); // Just check it doesn't panic
285    }
286
287    #[test]
288    fn test_should_auto_compact_empty_messages() {
289        let messages: Vec<Message> = vec![];
290        let result = should_auto_compact(&messages, "claude-sonnet-4-6", None, 0);
291        // Empty messages should not trigger compaction
292        assert!(!result);
293    }
294
295    #[test]
296    fn test_should_auto_compact_forked_agent_guards() {
297        let messages: Vec<Message> = vec![];
298        // session_memory should return false
299        let result = should_auto_compact(&messages, "claude-sonnet-4-6", Some("session_memory"), 0);
300        assert!(!result);
301
302        // compact should return false
303        let result = should_auto_compact(&messages, "claude-sonnet-4-6", Some("compact"), 0);
304        assert!(!result);
305    }
306
307    #[test]
308    fn test_auto_compact_tracking_state() {
309        let state = AutoCompactTrackingState::new();
310        assert!(!state.compacted);
311        assert_eq!(state.turn_counter, 0);
312        assert!(!state.turn_id.is_empty());
313        assert_eq!(state.consecutive_failures, 0);
314    }
315
316    #[test]
317    fn test_recompaction_info_default() {
318        let info = RecompactionInfo::default();
319        assert!(!info.is_recompaction_in_chain);
320        assert_eq!(info.turns_since_previous_compact, 0);
321        assert!(info.previous_compact_turn_id.is_none());
322    }
323
324    #[test]
325    fn test_auto_compact_result_default() {
326        let result = AutoCompactResult::default();
327        assert!(!result.was_compacted);
328        assert!(result.compaction_result.is_none());
329    }
330}