Skip to main content

ai_agent/services/compact/
microcompact.rs

1// Source: ~/claudecode/openclaudecode/src/services/compact/microCompact.ts
2//! Micro-compact module for proactive tool result eviction.
3//!
4//! Three micro-compact paths (in priority order):
5//! 1. Time-based trigger: clears old tool results after idle gap
6//! 2. Cached microcompact: API cache editing without invalidating prefix
7//! 3. Legacy path: content-clear (removed, falls through)
8
9use crate::compact::strip_images_from_messages;
10use crate::tools::config_tools::{
11    BASH_TOOL_NAME, FILE_EDIT_TOOL_NAME, FILE_READ_TOOL_NAME, FILE_WRITE_TOOL_NAME, GLOB_TOOL_NAME,
12    GREP_TOOL_NAME, NOTEBOOK_EDIT_TOOL_NAME, POWERSHELL_TOOL_NAME, WEB_FETCH_TOOL_NAME,
13    WEB_SEARCH_TOOL_NAME,
14};
15use crate::types::Message;
16use crate::utils::env_utils;
17use std::collections::HashSet;
18use std::sync::Mutex;
19
20/// Message shown when tool result content is cleared
21pub const TIME_BASED_MC_CLEARED_MESSAGE: &str = "[Old tool result content cleared]";
22
23/// Maximum tokens for images/documents
24pub const IMAGE_MAX_TOKEN_SIZE: usize = 2000;
25
26/// Tools whose results are compactable
27fn compactable_tools() -> HashSet<&'static str> {
28    let mut set = HashSet::new();
29    set.insert(FILE_READ_TOOL_NAME);
30    set.insert(BASH_TOOL_NAME);
31    set.insert(POWERSHELL_TOOL_NAME);
32    set.insert(GREP_TOOL_NAME);
33    set.insert(GLOB_TOOL_NAME);
34    set.insert(WEB_SEARCH_TOOL_NAME);
35    set.insert(WEB_FETCH_TOOL_NAME);
36    set.insert(FILE_EDIT_TOOL_NAME);
37    set.insert(FILE_WRITE_TOOL_NAME);
38    set.insert(NOTEBOOK_EDIT_TOOL_NAME);
39    set
40}
41
42// --- Time-based microcompact state ---
43
44/// Evaluate whether the time-based trigger should fire.
45/// Returns the measured gap (minutes since last assistant message) when the
46/// trigger fires, or None when it doesn't.
47pub fn evaluate_time_based_trigger(messages: &[Message]) -> Option<TimeBasedTriggerResult> {
48    let config = crate::services::compact::time_based_mc_config::get_time_based_mc_config();
49
50    if !config.enabled {
51        return None;
52    }
53
54    // Find last assistant message timestamp
55    let last_assistant = messages
56        .iter()
57        .rev()
58        .find(|m| matches!(m.role, crate::types::MessageRole::Assistant));
59
60    let Some(last_msg) = last_assistant else {
61        return None;
62    };
63
64    // Get timestamp from message - use current time since Message doesn't have timestamp field
65    // The original TypeScript used message.timestamp which is not available in api_types::Message
66    let now_ms = chrono::Utc::now().timestamp_millis() as i64;
67    let last_ts = now_ms;
68    let gap_minutes = ((now_ms - last_ts) as f64 / 60_000.0).abs();
69
70    if !gap_minutes.is_finite() || gap_minutes < config.gap_threshold_minutes as f64 {
71        return None;
72    }
73
74    Some(TimeBasedTriggerResult {
75        gap_minutes,
76        config,
77    })
78}
79
80pub struct TimeBasedTriggerResult {
81    pub gap_minutes: f64,
82    pub config: crate::services::compact::time_based_mc_config::TimeBasedMCConfig,
83}
84
85/// Collect compactable tool_use IDs from messages, in encounter order
86pub fn collect_compactable_tool_ids(messages: &[Message]) -> Vec<String> {
87    let compactable = compactable_tools();
88    let mut ids = Vec::new();
89
90    for msg in messages {
91        if let crate::types::MessageRole::Assistant = msg.role {
92            // Tool calls are in the tool_calls field
93            if let Some(tool_calls) = &msg.tool_calls {
94                for tc in tool_calls {
95                    if compactable.contains(tc.name.as_str()) {
96                        ids.push(tc.id.clone());
97                    }
98                }
99            }
100        }
101    }
102
103    ids
104}
105
106/// Time-based microcompact: when the gap since the last assistant message
107/// exceeds the configured threshold, content-clear all but the most recent N
108/// compactable tool results.
109pub fn maybe_time_based_microcompact(messages: &mut [Message]) -> Option<TimeBasedMCResult> {
110    let trigger = evaluate_time_based_trigger(messages)?;
111    let config = trigger.config;
112
113    let compactable_ids = collect_compactable_tool_ids(messages);
114
115    // Floor at 1: always keep at least the last tool result
116    let keep_recent = config.keep_recent.max(1);
117    let keep_set: HashSet<String> = compactable_ids
118        .iter()
119        .rev()
120        .take(keep_recent)
121        .cloned()
122        .collect();
123    let clear_set: HashSet<String> = compactable_ids
124        .iter()
125        .filter(|id| !keep_set.contains(*id))
126        .cloned()
127        .collect();
128
129    if clear_set.is_empty() {
130        return None;
131    }
132
133    let mut tokens_saved = 0;
134
135    for msg in messages.iter_mut() {
136        // Tool results have content that can be cleared
137        if let crate::types::MessageRole::Tool = msg.role {
138            if let Some(tool_call_id) = &msg.tool_call_id {
139                if clear_set.contains(tool_call_id) && msg.content != TIME_BASED_MC_CLEARED_MESSAGE
140                {
141                    tokens_saved += crate::compact::rough_token_count_estimation(&msg.content, 4.0);
142                    msg.content = TIME_BASED_MC_CLEARED_MESSAGE.to_string();
143                }
144            }
145        }
146    }
147
148    if tokens_saved == 0 {
149        return None;
150    }
151
152    log::debug!(
153        "[TIME-BASED MC] gap {:.0}min > {}min, cleared {} tool results (~{} tokens), kept last {}",
154        trigger.gap_minutes,
155        config.gap_threshold_minutes,
156        clear_set.len(),
157        tokens_saved,
158        keep_recent
159    );
160
161    // Reset microcompact state since we changed content
162    reset_microcompact_state();
163
164    Some(TimeBasedMCResult {
165        tokens_saved,
166        tools_cleared: clear_set.len(),
167    })
168}
169
170pub struct TimeBasedMCResult {
171    pub tokens_saved: usize,
172    pub tools_cleared: usize,
173}
174
175// --- Cached microcompact state (stub for now) ---
176
177/// Cached microcompact state - tracks tool results registered on prior turns
178struct CachedMCState {
179    registered_tools: HashSet<String>,
180    tool_order: Vec<String>,
181    deleted_refs: HashSet<String>,
182    pinned_edits: Vec<PinnedCacheEdit>,
183}
184
185struct PinnedCacheEdit {
186    user_message_index: usize,
187    block: serde_json::Value,
188}
189
190static CACHED_MC_STATE: Mutex<Option<CachedMCState>> = Mutex::new(None);
191static PENDING_CACHE_EDITS: Mutex<Option<serde_json::Value>> = Mutex::new(None);
192static MICROCMPACT_STATE_RESET: Mutex<bool> = Mutex::new(false);
193
194/// Reset microcompact state - called after compaction
195pub fn reset_microcompact_state() {
196    if let Ok(mut state) = CACHED_MC_STATE.lock() {
197        *state = None;
198    }
199    if let Ok(mut pending) = PENDING_CACHE_EDITS.lock() {
200        *pending = None;
201    }
202    if let Ok(mut flag) = MICROCMPACT_STATE_RESET.lock() {
203        *flag = true;
204    }
205    log::debug!("[microcompact] State reset");
206}
207
208/// Get new pending cache edits to be included in the next API request.
209/// Returns None if there are no new pending edits.
210pub fn consume_pending_cache_edits() -> Option<serde_json::Value> {
211    PENDING_CACHE_EDITS.lock().ok().and_then(|mut p| p.take())
212}
213
214/// Calculate tool result tokens
215pub fn calculate_tool_result_tokens(content: &str) -> usize {
216    crate::compact::rough_token_count_estimation(content, 4.0)
217}
218
219/// Estimate token count for messages
220pub fn estimate_message_tokens(messages: &[Message]) -> usize {
221    let mut total = 0;
222
223    for msg in messages {
224        match &msg.role {
225            crate::types::MessageRole::User | crate::types::MessageRole::Assistant => {
226                total += crate::compact::rough_token_count_estimation(&msg.content, 4.0);
227            }
228            crate::types::MessageRole::Tool => {
229                // Tool results are JSON, more token-efficient: 2 chars/token
230                total += msg.content.len() / 2;
231            }
232            crate::types::MessageRole::System => {
233                total += crate::compact::rough_token_count_estimation(&msg.content, 4.0);
234            }
235        }
236    }
237
238    // Pad estimate by 4/3 to be conservative
239    (total as f64 * (4.0 / 3.0)).ceil() as usize
240}
241
242/// Process messages to truncate large tool results (413 prevention)
243pub fn microcompact_messages(messages: &mut [Message]) {
244    // First, try time-based microcompact
245    if let Some(_result) = maybe_time_based_microcompact(messages) {
246        return;
247    }
248
249    // Fallback: truncate individual oversized tool results
250    for msg in messages.iter_mut() {
251        if let crate::types::MessageRole::Tool = &msg.role {
252            if msg.content.len() > 16_000 {
253                let tool_name = msg.tool_call_id.as_deref().unwrap_or("Tool");
254                msg.content = truncate_tool_result_content(&msg.content, tool_name);
255            }
256        }
257    }
258}
259
260/// Check if messages need microcompact (rough estimation)
261pub fn needs_microcompact(messages: &[Message], threshold: usize) -> bool {
262    let total_tool_chars: usize = messages
263        .iter()
264        .filter(|m| matches!(m.role, crate::types::MessageRole::Tool))
265        .map(|m| m.content.len())
266        .sum();
267
268    let estimated_tokens = total_tool_chars / 4;
269    estimated_tokens > threshold
270}
271
272/// Truncate tool result content if it's too large
273pub fn truncate_tool_result_content(content: &str, tool_name: &str) -> String {
274    const MAX_TOOL_RESULT_CHARS: usize = 16_000;
275    const MAX_GLOB_RESULTS: usize = 100;
276
277    if tool_name == "Glob" {
278        let total_lines = content.lines().count();
279        if total_lines <= MAX_GLOB_RESULTS {
280            return content.to_string();
281        }
282        let lines: Vec<&str> = content.lines().take(MAX_GLOB_RESULTS).collect();
283        let truncated = lines.join("\n");
284        return format!(
285            "{}\n\n... ({} more files not shown. Use more specific glob patterns to reduce results)",
286            truncated,
287            total_lines.saturating_sub(MAX_GLOB_RESULTS)
288        );
289    }
290
291    if content.len() <= MAX_TOOL_RESULT_CHARS {
292        return content.to_string();
293    }
294
295    let chars: Vec<char> = content.chars().take(MAX_TOOL_RESULT_CHARS).collect();
296    format!(
297        "{}\n\n... (truncated {} characters)",
298        chars.into_iter().collect::<String>(),
299        content.len().saturating_sub(MAX_TOOL_RESULT_CHARS)
300    )
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    #[test]
308    fn test_collect_compactable_tool_ids() {
309        let messages = vec![
310            Message {
311                role: crate::types::MessageRole::Assistant,
312                content: String::new(),
313                tool_calls: Some(vec![crate::types::ToolCall {
314                    id: "call_1".to_string(),
315                    r#type: "function".to_string(),
316                    name: FILE_READ_TOOL_NAME.to_string(),
317                    arguments: serde_json::json!({}),
318                }]),
319                ..Default::default()
320            },
321            Message {
322                role: crate::types::MessageRole::Assistant,
323                content: String::new(),
324                tool_calls: Some(vec![crate::types::ToolCall {
325                    id: "call_2".to_string(),
326                    r#type: "function".to_string(),
327                    name: "SomeOtherTool".to_string(),
328                    arguments: serde_json::json!({}),
329                }]),
330                ..Default::default()
331            },
332        ];
333
334        let ids = collect_compactable_tool_ids(&messages);
335        assert_eq!(ids.len(), 1);
336        assert_eq!(ids[0], "call_1");
337    }
338
339    #[test]
340    fn test_truncate_tool_result_small() {
341        let content = "small content";
342        let result = truncate_tool_result_content(content, "Read");
343        assert_eq!(result, "small content");
344    }
345
346    #[test]
347    fn test_truncate_tool_result_large() {
348        let content = "x".repeat(20000);
349        let result = truncate_tool_result_content(&content, "Read");
350        assert!(result.len() < content.len());
351        assert!(result.contains("truncated"));
352    }
353
354    #[test]
355    fn test_estimate_message_tokens() {
356        let messages = vec![Message {
357            role: crate::types::MessageRole::User,
358            content: "Hello, this is a test message".to_string(),
359            ..Default::default()
360        }];
361        let tokens = estimate_message_tokens(&messages);
362        assert!(tokens > 0);
363    }
364
365    #[test]
366    fn test_reset_microcompact_state() {
367        reset_microcompact_state();
368        // Should not panic
369        assert!(*MICROCMPACT_STATE_RESET.lock().unwrap());
370    }
371
372    #[test]
373    fn test_calculate_tool_result_tokens() {
374        let content = "test content";
375        let tokens = calculate_tool_result_tokens(content);
376        assert!(tokens > 0);
377    }
378}