ai_agent/services/compact/
auto_compact.rs1use 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#[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 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
237fn estimate_token_count(messages: &[Message]) -> usize {
240 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 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 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 let result = is_auto_compact_enabled();
284 assert!(result || !result); }
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 assert!(!result);
293 }
294
295 #[test]
296 fn test_should_auto_compact_forked_agent_guards() {
297 let messages: Vec<Message> = vec![];
298 let result = should_auto_compact(&messages, "claude-sonnet-4-6", Some("session_memory"), 0);
300 assert!(!result);
301
302 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}