ai_agent/services/compact/
auto_compact.rs1use 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#[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#[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#[derive(Debug, Clone, Default)]
44pub struct AutoCompactTrackingState {
45 pub compacted: bool,
46 pub turn_counter: usize,
47 pub turn_id: String,
49 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
66pub use crate::compact::{
68 AUTOCOMPACT_BUFFER_TOKENS, ERROR_THRESHOLD_BUFFER_TOKENS, MANUAL_COMPACT_BUFFER_TOKENS,
69 MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES,
70};
71
72pub fn get_effective_context_window_size(model: &str) -> usize {
76 core_get_effective_context_window_size(model) as usize
77}
78
79pub fn get_auto_compact_threshold(model: &str) -> usize {
83 core_get_auto_compact_threshold(model) as usize
84}
85
86pub fn calculate_token_warning_state(token_usage: usize, model: &str) -> TokenWarningState {
90 core_calculate_token_warning_state(token_usage as u32, model)
91}
92
93pub fn is_auto_compact_enabled() -> bool {
96 if is_env_truthy(Some("DISABLE_COMPACT")) {
97 return false;
98 }
99 if is_env_truthy(Some("DISABLE_AUTO_COMPACT")) {
101 return false;
102 }
103 true
107}
108
109fn is_forked_agent_query_source(query_source: Option<&str>) -> bool {
111 matches!(query_source, Some("session_memory") | Some("compact"))
112}
113
114fn is_marble_origami_query_source(query_source: Option<&str>) -> bool {
116 matches!(query_source, Some("marble_origami"))
117}
118
119pub fn should_auto_compact(
122 messages: &[Message],
123 model: &str,
124 query_source: Option<&str>,
125 snip_tokens_freed: usize,
126) -> bool {
127 if is_forked_agent_query_source(query_source) {
130 return false;
131 }
132
133 if !is_auto_compact_enabled() {
139 return false;
140 }
141
142 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
171pub 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 if is_env_truthy(Some("DISABLE_COMPACT")) {
182 return AutoCompactResult::default();
183 }
184
185 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 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 let token_count = estimate_token_count(messages);
220 let effective_window = get_effective_context_window_size(model);
221
222 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 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), }
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
324fn estimate_token_count(messages: &[Message]) -> usize {
327 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 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 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 let result = is_auto_compact_enabled();
371 assert!(result || !result); }
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 assert!(!result);
380 }
381
382 #[test]
383 fn test_should_auto_compact_forked_agent_guards() {
384 let messages: Vec<Message> = vec![];
385 let result = should_auto_compact(&messages, "claude-sonnet-4-6", Some("session_memory"), 0);
387 assert!(!result);
388
389 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}