1use std::sync::atomic::{AtomicUsize, Ordering};
2
3use talos_core::message::{AgentEvent, Message, MessageToolResult};
4use talos_core::provider::LanguageModel;
5
6use crate::token::TokenEstimator;
7
8use super::constants::{
9 CIRCUIT_BREAKER_THRESHOLD, COLLAPSE_TURN_THRESHOLD, MAX_TOOL_RESULT_CHARS, PRESERVED_TURNS,
10 TRIM_TURN_THRESHOLD, TRUNCATION_SUFFIX,
11};
12use super::{CompactionError, CompactionResult, CompactionStatus};
13
14pub struct Compactor {
44 token_estimator: TokenEstimator,
46 model_limit: u32,
48 trigger_threshold: f32,
50 consecutive_failures: AtomicUsize,
52}
53
54impl Compactor {
55 #[must_use]
64 pub fn new(token_estimator: TokenEstimator, model_limit: u32) -> Self {
65 Self {
66 token_estimator,
67 model_limit,
68 trigger_threshold: 0.8,
69 consecutive_failures: AtomicUsize::new(0),
70 }
71 }
72
73 #[must_use]
79 pub fn with_threshold(mut self, threshold: f32) -> Self {
80 self.trigger_threshold = threshold.clamp(0.0, 1.0);
81 self
82 }
83
84 pub fn should_compact(&self, messages: &[Message]) -> bool {
92 let estimated = self.token_estimator.estimate(messages);
93 let threshold_tokens = (self.model_limit as f32 * self.trigger_threshold) as u32;
94 estimated > threshold_tokens
95 }
96
97 pub async fn compact(
120 &mut self,
121 messages: Vec<Message>,
122 provider: &dyn LanguageModel,
123 ) -> CompactionResult<Vec<Message>> {
124 if self.consecutive_failures.load(Ordering::SeqCst) >= CIRCUIT_BREAKER_THRESHOLD {
125 return Err(CompactionError::CircuitBreakerTripped);
126 }
127
128 let mut current = messages;
129
130 current = self.apply_budget(current);
131 if self.fits(¤t) {
132 self.consecutive_failures.store(0, Ordering::SeqCst);
133 return Ok(current);
134 }
135
136 current = self.apply_trim(current);
137 if self.fits(¤t) {
138 self.consecutive_failures.store(0, Ordering::SeqCst);
139 return Ok(current);
140 }
141
142 current = self.apply_microcompact(current);
143 if self.fits(¤t) {
144 self.consecutive_failures.store(0, Ordering::SeqCst);
145 return Ok(current);
146 }
147
148 current = match self.apply_collapse(current, provider).await {
149 Ok(msgs) => msgs,
150 Err(e) => {
151 self.record_failure();
152 return Err(e);
153 }
154 };
155 if self.fits(¤t) {
156 self.consecutive_failures.store(0, Ordering::SeqCst);
157 return Ok(current);
158 }
159
160 current = match self.apply_autocompact(current, provider).await {
161 Ok(msgs) => msgs,
162 Err(e) => {
163 self.record_failure();
164 return Err(e);
165 }
166 };
167
168 if self.fits(¤t) {
169 self.consecutive_failures.store(0, Ordering::SeqCst);
170 Ok(current)
171 } else {
172 self.record_failure();
173 Err(CompactionError::CompactionFailed(
174 "all compaction layers applied but context still exceeds limit".into(),
175 ))
176 }
177 }
178
179 #[must_use]
184 pub fn apply_budget(&self, messages: Vec<Message>) -> Vec<Message> {
185 messages
186 .into_iter()
187 .map(|msg| match msg {
188 Message::Tool { result } => {
189 if result.content.chars().count() > MAX_TOOL_RESULT_CHARS {
190 let mut truncated: String =
191 result.content.chars().take(MAX_TOOL_RESULT_CHARS).collect();
192 truncated.push_str(TRUNCATION_SUFFIX);
193 Message::Tool {
194 result: MessageToolResult {
195 content: truncated,
196 ..result
197 },
198 }
199 } else {
200 Message::Tool { result }
201 }
202 }
203 other => other,
204 })
205 .collect()
206 }
207
208 #[must_use]
213 pub fn apply_trim(&self, messages: Vec<Message>) -> Vec<Message> {
214 let total_turns = count_turns(&messages);
215 if total_turns <= TRIM_TURN_THRESHOLD {
216 return messages;
217 }
218
219 let turns_to_trim = total_turns - TRIM_TURN_THRESHOLD;
220 let mut current_turn: usize = 0;
221 let mut in_trimmed_turn = false;
222
223 messages
224 .into_iter()
225 .map(|msg| {
226 if matches!(&msg, Message::User { .. }) {
227 if current_turn > 0 {
228 in_trimmed_turn = false;
229 }
230 current_turn += 1;
231 if current_turn <= turns_to_trim {
232 in_trimmed_turn = true;
233 }
234 }
235
236 if in_trimmed_turn && matches!(&msg, Message::Tool { .. }) {
237 if let Message::Tool { result } = msg {
238 Message::Tool {
239 result: MessageToolResult {
240 content: String::new(),
241 ..result
242 },
243 }
244 } else {
245 msg
246 }
247 } else {
248 msg
249 }
250 })
251 .collect()
252 }
253
254 #[must_use]
260 pub fn apply_microcompact(&self, messages: Vec<Message>) -> Vec<Message> {
261 let mut last_occurrence: std::collections::HashMap<String, usize> =
262 std::collections::HashMap::new();
263 for (i, msg) in messages.iter().enumerate() {
264 if let Message::Tool { result } = msg {
265 last_occurrence.insert(result.tool_use_id.clone(), i);
266 }
267 }
268
269 messages
270 .into_iter()
271 .enumerate()
272 .map(|(i, msg)| {
273 if let Message::Tool { result } = msg {
274 if last_occurrence.get(&result.tool_use_id) == Some(&i) {
275 Message::Tool { result }
276 } else {
277 Message::Tool {
278 result: MessageToolResult {
279 content: String::new(),
280 ..result
281 },
282 }
283 }
284 } else {
285 msg
286 }
287 })
288 .collect()
289 }
290
291 pub async fn apply_collapse(
300 &self,
301 messages: Vec<Message>,
302 provider: &dyn LanguageModel,
303 ) -> CompactionResult<Vec<Message>> {
304 let total_turns = count_turns(&messages);
305 if total_turns <= COLLAPSE_TURN_THRESHOLD {
306 return Ok(messages);
307 }
308
309 let (old_messages, recent_messages) = split_at_turn(&messages, COLLAPSE_TURN_THRESHOLD);
310 if old_messages.is_empty() {
311 return Ok(messages);
312 }
313
314 let summary = self.summarize_with_llm(&old_messages, provider).await?;
315
316 let mut result = Vec::with_capacity(1 + recent_messages.len());
317 result.push(Message::User {
318 content: format!(
319 "[Conversation summary of {} earlier turns]\n{}",
320 total_turns - COLLAPSE_TURN_THRESHOLD,
321 summary
322 ),
323 });
324 result.extend(recent_messages);
325
326 Ok(result)
327 }
328
329 pub async fn apply_autocompact(
338 &self,
339 messages: Vec<Message>,
340 provider: &dyn LanguageModel,
341 ) -> CompactionResult<Vec<Message>> {
342 let total_turns = count_turns(&messages);
343 if total_turns <= PRESERVED_TURNS {
344 return Ok(messages);
345 }
346
347 let (old_messages, recent_messages) = split_at_turn(&messages, PRESERVED_TURNS);
348 if old_messages.is_empty() {
349 return Ok(messages);
350 }
351
352 let summary = self.summarize_with_llm(&old_messages, provider).await?;
353
354 let mut result = Vec::with_capacity(1 + recent_messages.len());
355 result.push(Message::User {
356 content: format!("[Full conversation summary]\n{}", summary),
357 });
358 result.extend(recent_messages);
359
360 Ok(result)
361 }
362
363 async fn summarize_with_llm(
365 &self,
366 messages: &[Message],
367 provider: &dyn LanguageModel,
368 ) -> CompactionResult<String> {
369 let conversation_text = messages_to_text(messages);
370 let prompt_messages = vec![Message::User {
371 content: format!(
372 "Summarize the following conversation concisely. \
373 Preserve key decisions, tool call outcomes, and important context. \
374 Keep the summary under 500 words.\n\n\
375 Conversation:\n{conversation_text}"
376 ),
377 }];
378
379 let mut rx = provider
380 .stream(&prompt_messages)
381 .await
382 .map_err(|e| CompactionError::ProviderError(e.to_string()))?;
383
384 let mut summary = String::new();
385 while let Some(event) = rx.recv().await {
386 if let AgentEvent::TextDelta { delta } = event {
387 summary.push_str(&delta);
388 }
389 }
390
391 if summary.is_empty() {
392 summary = "[No summary generated]".into();
393 }
394
395 Ok(summary)
396 }
397
398 fn fits(&self, messages: &[Message]) -> bool {
400 let estimated = self.token_estimator.estimate(messages);
401 estimated <= self.model_limit
402 }
403
404 #[must_use]
411 pub fn compact_deterministic(
412 &self,
413 messages: Vec<Message>,
414 ) -> (Vec<Message>, CompactionStatus) {
415 let tokens_before = self.token_estimator.estimate(&messages);
416 let mut current = messages;
417 let mut layers = Vec::new();
418
419 current = self.apply_budget(current);
420 layers.push("budget");
421 if self.fits(¤t) {
422 let tokens_after = self.token_estimator.estimate(¤t);
423 return (
424 current,
425 CompactionStatus::Applied {
426 layers_applied: layers,
427 tokens_before,
428 tokens_after,
429 },
430 );
431 }
432
433 current = self.apply_trim(current);
434 layers.push("trim");
435 if self.fits(¤t) {
436 let tokens_after = self.token_estimator.estimate(¤t);
437 return (
438 current,
439 CompactionStatus::Applied {
440 layers_applied: layers,
441 tokens_before,
442 tokens_after,
443 },
444 );
445 }
446
447 current = self.apply_microcompact(current);
448 layers.push("microcompact");
449 let tokens_after = self.token_estimator.estimate(¤t);
450
451 if self.fits(¤t) {
452 (
453 current,
454 CompactionStatus::Applied {
455 layers_applied: layers,
456 tokens_before,
457 tokens_after,
458 },
459 )
460 } else {
461 (
462 current,
463 CompactionStatus::Skipped {
464 reason: "deterministic layers insufficient; LLM layers required",
465 tokens_current: tokens_after,
466 },
467 )
468 }
469 }
470
471 pub async fn manual_compact(
477 &mut self,
478 messages: Vec<Message>,
479 provider: &dyn LanguageModel,
480 ) -> (Vec<Message>, CompactionStatus) {
481 if !self.should_compact(&messages) {
482 let tokens = self.token_estimator.estimate(&messages);
483 return (
484 messages,
485 CompactionStatus::Skipped {
486 reason: "below trigger threshold",
487 tokens_current: tokens,
488 },
489 );
490 }
491
492 let tokens_before = self.token_estimator.estimate(&messages);
493
494 match self.compact(messages.clone(), provider).await {
495 Ok(compacted) => {
496 let tokens_after = self.token_estimator.estimate(&compacted);
497 (
498 compacted,
499 CompactionStatus::Applied {
500 layers_applied: vec!["manual"],
501 tokens_before,
502 tokens_after,
503 },
504 )
505 }
506 Err(e) => (
507 messages,
508 CompactionStatus::Failed {
509 error: e.to_string(),
510 },
511 ),
512 }
513 }
514
515 fn record_failure(&self) {
517 self.consecutive_failures.fetch_add(1, Ordering::SeqCst);
518 }
519
520 #[cfg(test)]
522 pub(super) fn failure_count(&self) -> usize {
523 self.consecutive_failures.load(Ordering::SeqCst)
524 }
525}
526
527fn count_turns(messages: &[Message]) -> usize {
531 messages
532 .iter()
533 .filter(|m| matches!(m, Message::User { .. }))
534 .count()
535}
536
537fn split_at_turn(messages: &[Message], turns_from_end: usize) -> (Vec<Message>, Vec<Message>) {
542 let total_turns = count_turns(messages);
543 if total_turns <= turns_from_end {
544 return (Vec::new(), messages.to_vec());
545 }
546
547 let turns_to_keep = turns_from_end;
548 let turns_to_skip = total_turns - turns_to_keep;
549
550 let mut current_turn: usize = 0;
551 let mut split_idx = 0;
552
553 for (i, msg) in messages.iter().enumerate() {
554 if matches!(msg, Message::User { .. }) {
555 current_turn += 1;
556 if current_turn > turns_to_skip {
557 split_idx = i;
558 break;
559 }
560 }
561 }
562
563 let old = messages[..split_idx].to_vec();
564 let recent = messages[split_idx..].to_vec();
565 (old, recent)
566}
567
568fn messages_to_text(messages: &[Message]) -> String {
570 messages
571 .iter()
572 .map(|msg| match msg {
573 Message::User { content } => format!("User: {content}"),
574 Message::System { content, .. } => format!("System: {content}"),
575 Message::Context { content } => format!("Context: {content}"),
576 Message::Assistant {
577 content,
578 tool_calls,
579 ..
580 } => {
581 let mut text = format!("Assistant: {content}");
582 for tc in tool_calls {
583 text.push_str(&format!("\n [Tool call: {}({})]", tc.name, tc.input));
584 }
585 text
586 }
587 Message::Tool { result } => {
588 format!("Tool result ({}): {}", result.tool_use_id, result.content)
589 }
590 Message::Multimodal { parts } => {
591 let text: String = parts
592 .iter()
593 .filter_map(|p| match p {
594 talos_core::message::ContentPart::Text { text } => Some(text.as_str()),
595 _ => None,
596 })
597 .collect::<Vec<_>>()
598 .join("\n");
599 format!("User: {text}")
600 }
601 })
602 .collect::<Vec<_>>()
603 .join("\n\n")
604}