Skip to main content

talos_agent/compaction/
engine.rs

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
14/// Applies 5-layer context compaction when context nears the model token limit.
15///
16/// The compactor is stateless except for the circuit breaker counter, which
17/// tracks consecutive failures across invocations.
18///
19/// # Example
20///
21/// ```no_run
22/// use talos_agent::compaction::Compactor;
23/// use talos_agent::token::TokenEstimator;
24/// use talos_core::message::Message;
25/// # use talos_core::provider::{LanguageModel, ProviderResult, Receiver};
26/// # use talos_core::message::AgentEvent;
27/// # struct MyModel;
28/// # #[async_trait::async_trait]
29/// # impl LanguageModel for MyModel {
30/// #     async fn stream(&self, _: &[Message]) -> ProviderResult<Receiver<AgentEvent>> { unimplemented!() }
31/// # }
32/// # async fn example() {
33/// let estimator = TokenEstimator::new();
34/// let mut compactor = Compactor::new(estimator, 128_000);
35///
36/// let messages = vec![Message::User { content: "Hello!".into() }];
37/// if compactor.should_compact(&messages) {
38///     let provider: &dyn LanguageModel = &MyModel;
39///     let compacted = compactor.compact(messages, provider).await.unwrap();
40/// }
41/// # }
42/// ```
43pub struct Compactor {
44    /// Estimates token counts for messages.
45    token_estimator: TokenEstimator,
46    /// Maximum token limit of the target model.
47    model_limit: u32,
48    /// Trigger threshold as a fraction of model_limit (default: 0.8).
49    trigger_threshold: f32,
50    /// Consecutive compaction failure counter for circuit breaker.
51    consecutive_failures: AtomicUsize,
52}
53
54impl Compactor {
55    /// Creates a new compactor with the given token estimator and model limit.
56    ///
57    /// The trigger threshold defaults to 0.8 (80% of model_limit).
58    ///
59    /// # Arguments
60    ///
61    /// * `token_estimator` — The token estimator for measuring context size.
62    /// * `model_limit` — Maximum token limit of the target language model.
63    #[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    /// Sets the trigger threshold (fraction of model_limit that triggers compaction).
74    ///
75    /// # Arguments
76    ///
77    /// * `threshold` — A value between 0.0 and 1.0. Default is 0.8.
78    #[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    /// Checks whether compaction should be triggered for the given messages.
85    ///
86    /// Returns `true` if the estimated token usage exceeds `model_limit * trigger_threshold`.
87    ///
88    /// # Arguments
89    ///
90    /// * `messages` — The current conversation messages.
91    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    /// Applies compaction layers to reduce context size.
98    ///
99    /// Layers are applied in order (budget → trim → microcompact → collapse → autocompact),
100    /// stopping as soon as the context fits within the model limit.
101    ///
102    /// The last 10 turns are always preserved verbatim.
103    ///
104    /// # Arguments
105    ///
106    /// * `messages` — The current conversation messages (consumed).
107    /// * `provider` — The language model provider for LLM-based summarization.
108    ///
109    /// # Errors
110    ///
111    /// Returns [`CompactionError::CircuitBreakerTripped`] if the circuit breaker
112    /// has tripped due to repeated failures.
113    ///
114    /// Returns [`CompactionError::CompactionFailed`] if all layers were applied
115    /// but the context still exceeds the limit.
116    ///
117    /// Returns [`CompactionError::ProviderError`] if the LLM provider fails
118    /// during summarization.
119    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(&current) {
132            self.consecutive_failures.store(0, Ordering::SeqCst);
133            return Ok(current);
134        }
135
136        current = self.apply_trim(current);
137        if self.fits(&current) {
138            self.consecutive_failures.store(0, Ordering::SeqCst);
139            return Ok(current);
140        }
141
142        current = self.apply_microcompact(current);
143        if self.fits(&current) {
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(&current) {
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(&current) {
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    /// Layer 1: Cap tool result sizes to max 4000 chars each.
180    ///
181    /// Truncates tool results exceeding [`MAX_TOOL_RESULT_CHARS`] characters,
182    /// appending [`TRUNCATION_SUFFIX`].
183    #[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    /// Layer 2: Remove tool results from turns older than 20.
209    ///
210    /// Counts turns from the start of the conversation. Tool results belonging
211    /// to turns beyond [`TRIM_TURN_THRESHOLD`] are removed (replaced with empty content).
212    #[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    /// Layer 3: Keep only the last tool result for each tool call ID.
255    ///
256    /// Iterates through messages and for each `tool_use_id`, only preserves
257    /// the most recent (last occurring) tool result. Earlier duplicates are
258    /// replaced with empty content.
259    #[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    /// Layer 4: Summarize turns older than 10 into a single summary message.
292    ///
293    /// Uses the LLM to generate a concise summary of old turns. The last
294    /// [`PRESERVED_TURNS`] turns are preserved verbatim.
295    ///
296    /// # Errors
297    ///
298    /// Returns [`CompactionError::ProviderError`] if the LLM provider fails.
299    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    /// Layer 5: Use LLM to summarize the entire conversation history.
330    ///
331    /// Preserves the last [`PRESERVED_TURNS`] turns verbatim and summarizes
332    /// everything before them.
333    ///
334    /// # Errors
335    ///
336    /// Returns [`CompactionError::ProviderError`] if the LLM provider fails.
337    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    /// Uses the LLM to summarize a set of messages.
364    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    /// Checks if the current messages fit within the model limit.
399    fn fits(&self, messages: &[Message]) -> bool {
400        let estimated = self.token_estimator.estimate(messages);
401        estimated <= self.model_limit
402    }
403
404    /// Apply deterministic layers 1-3 (budget, trim, microcompact) and return status.
405    ///
406    /// Safe at any boundary (pre-turn, manual) because it does not invoke the
407    /// LLM. If deterministic layers are insufficient, the status reports
408    /// `Skipped` with the reason — the caller can then decide whether to
409    /// escalate to [`compact`](Self::compact) (which uses LLM layers 4-5).
410    #[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(&current) {
422            let tokens_after = self.token_estimator.estimate(&current);
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(&current) {
436            let tokens_after = self.token_estimator.estimate(&current);
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(&current);
450
451        if self.fits(&current) {
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    /// Manual compaction trigger that returns status without exposing hidden output.
472    ///
473    /// Checks the trigger threshold first. If the context fits, returns
474    /// `Skipped`. If the circuit breaker is tripped, returns `Failed`.
475    /// Otherwise delegates to [`compact`](Self::compact) and wraps the result.
476    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    /// Records a compaction failure for the circuit breaker.
516    fn record_failure(&self) {
517        self.consecutive_failures.fetch_add(1, Ordering::SeqCst);
518    }
519
520    /// Returns the current consecutive failure count (for testing).
521    #[cfg(test)]
522    pub(super) fn failure_count(&self) -> usize {
523        self.consecutive_failures.load(Ordering::SeqCst)
524    }
525}
526
527/// Counts the number of turns in a message list.
528///
529/// A turn is counted each time a `User` message appears.
530fn count_turns(messages: &[Message]) -> usize {
531    messages
532        .iter()
533        .filter(|m| matches!(m, Message::User { .. }))
534        .count()
535}
536
537/// Splits messages at the given turn boundary (counting from the end).
538///
539/// Returns `(old_messages, recent_messages)` where `recent_messages` contains
540/// the last `turns_from_end` turns.
541fn 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
568/// Converts messages to a plain text representation for LLM summarization.
569fn 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}