ai_agent/services/compact/
microcompact.rs1use 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
20pub const TIME_BASED_MC_CLEARED_MESSAGE: &str = "[Old tool result content cleared]";
22
23pub const IMAGE_MAX_TOKEN_SIZE: usize = 2000;
25
26fn 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
42pub 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 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 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
85pub 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 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
106pub 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 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 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();
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
175struct 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
194pub 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
208pub fn consume_pending_cache_edits() -> Option<serde_json::Value> {
211 PENDING_CACHE_EDITS.lock().ok().and_then(|mut p| p.take())
212}
213
214pub fn calculate_tool_result_tokens(content: &str) -> usize {
216 crate::compact::rough_token_count_estimation(content, 4.0)
217}
218
219pub 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 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 (total as f64 * (4.0 / 3.0)).ceil() as usize
240}
241
242pub fn microcompact_messages(messages: &mut [Message]) {
244 if let Some(_result) = maybe_time_based_microcompact(messages) {
246 return;
247 }
248
249 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
260pub 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
272pub 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 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}