agent_core/controller/session/
compactor.rs

1use std::collections::{HashMap, HashSet};
2use std::future::Future;
3use std::pin::Pin;
4use std::time::Duration;
5
6use thiserror::Error;
7
8use crate::client::models::{Message as LLMMessage, MessageOptions};
9use crate::client::LLMClient;
10
11use crate::controller::types::{ContentBlock, Message, TextBlock, TurnId, UserMessage};
12
13/// Error type for compactor configuration.
14#[derive(Error, Debug)]
15pub enum CompactorConfigError {
16    /// Invalid threshold value.
17    #[error("Invalid threshold {0}: must be between 0.0 and 1.0 (exclusive)")]
18    InvalidThreshold(f64),
19
20    /// Invalid keep_recent_turns value.
21    #[error("Invalid keep_recent_turns {0}: must be at least 1")]
22    InvalidKeepRecentTurns(usize),
23}
24
25/// Defines how tool results are handled during compaction.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum ToolCompaction {
28    /// Replace tool results with summaries (uses CompactSummary if available).
29    Summarize,
30    /// Replace tool result content with a redaction notice.
31    Redact,
32}
33
34impl std::fmt::Display for ToolCompaction {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        match self {
37            ToolCompaction::Summarize => write!(f, "summarize"),
38            ToolCompaction::Redact => write!(f, "redact"),
39        }
40    }
41}
42
43/// Result of a compaction operation.
44#[derive(Debug, Clone, Default)]
45pub struct CompactionResult {
46    /// Number of tool results that were summarized.
47    pub tool_results_summarized: usize,
48    /// Number of tool results that were redacted.
49    pub tool_results_redacted: usize,
50    /// Number of turns that were compacted.
51    pub turns_compacted: usize,
52}
53
54impl CompactionResult {
55    /// Total number of tool results compacted.
56    pub fn total_compacted(&self) -> usize {
57        self.tool_results_summarized + self.tool_results_redacted
58    }
59}
60
61/// Error type for async compaction operations.
62#[derive(Debug)]
63pub enum CompactionError {
64    /// LLM call failed during summarization.
65    LLMError(String),
66    /// Timeout during summarization.
67    Timeout,
68    /// Configuration error.
69    ConfigError(String),
70}
71
72impl std::fmt::Display for CompactionError {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        match self {
75            CompactionError::LLMError(msg) => write!(f, "LLM error: {}", msg),
76            CompactionError::Timeout => write!(f, "compaction timed out"),
77            CompactionError::ConfigError(msg) => write!(f, "config error: {}", msg),
78        }
79    }
80}
81
82impl std::error::Error for CompactionError {}
83
84/// Trait for conversation compaction strategies.
85/// Implementations decide when and how to compact conversation history
86/// to reduce token usage while preserving important context.
87pub trait Compactor: Send + Sync {
88    /// Returns true if compaction should occur before this LLM call.
89    ///
90    /// # Arguments
91    /// * `context_used` - Current number of tokens in the conversation context
92    /// * `context_limit` - Maximum context size for the model
93    fn should_compact(&self, context_used: i64, context_limit: i32) -> bool;
94
95    /// Performs synchronous compaction on the conversation.
96    /// Returns the compaction statistics.
97    ///
98    /// # Arguments
99    /// * `conversation` - The conversation messages to compact
100    /// * `compact_summaries` - Map of tool_use_id to pre-computed compact summaries
101    fn compact(
102        &self,
103        conversation: &mut Vec<Message>,
104        compact_summaries: &HashMap<String, String>,
105    ) -> CompactionResult;
106
107    /// Returns true if this compactor requires async compaction.
108    /// When true, the caller should use `AsyncCompactor::compact_async()` instead of `compact()`.
109    fn is_async(&self) -> bool {
110        false
111    }
112}
113
114/// Trait for async compaction strategies that require LLM calls.
115/// Implementors must also implement `Compactor` with `is_async() -> true`.
116pub trait AsyncCompactor: Compactor {
117    /// Performs async compaction that may involve LLM calls.
118    /// Returns the new conversation and compaction statistics.
119    ///
120    /// Unlike sync `compact()` which modifies in place, this returns a new conversation
121    /// because async compaction may replace multiple messages with a single summary.
122    fn compact_async<'a>(
123        &'a self,
124        conversation: Vec<Message>,
125        compact_summaries: &'a HashMap<String, String>,
126    ) -> Pin<Box<dyn Future<Output = Result<(Vec<Message>, CompactionResult), CompactionError>> + Send + 'a>>;
127}
128
129/// Compacts when context usage exceeds a threshold.
130/// It identifies turns to keep (most recent N) and applies the configured
131/// compaction strategy to older tool results.
132pub struct ThresholdCompactor {
133    /// Context utilization ratio (0.0-1.0) that triggers compaction.
134    /// For example, 0.75 means compact when 75% of context is used.
135    threshold: f64,
136
137    /// Number of recent turns to preserve during compaction.
138    /// A turn includes user message, assistant response, tool calls, and tool results.
139    keep_recent_turns: usize,
140
141    /// Strategy for handling old tool results.
142    tool_compaction: ToolCompaction,
143}
144
145impl ThresholdCompactor {
146    /// Creates a new threshold compactor.
147    ///
148    /// # Arguments
149    /// * `threshold` - Context utilization ratio (0.0-1.0, exclusive)
150    /// * `keep_recent_turns` - Number of recent turns to preserve
151    /// * `tool_compaction` - Strategy for handling old tool results
152    ///
153    /// # Returns
154    /// Error if parameters are invalid.
155    pub fn new(
156        threshold: f64,
157        keep_recent_turns: usize,
158        tool_compaction: ToolCompaction,
159    ) -> Result<Self, CompactorConfigError> {
160        if threshold <= 0.0 || threshold >= 1.0 {
161            return Err(CompactorConfigError::InvalidThreshold(threshold));
162        }
163
164        Ok(Self {
165            threshold,
166            keep_recent_turns,
167            tool_compaction,
168        })
169    }
170
171    /// Extract unique turn IDs in order of first appearance.
172    fn unique_turn_ids(&self, conversation: &[Message]) -> Vec<TurnId> {
173        let mut seen = HashSet::new();
174        let mut ids = Vec::new();
175
176        for msg in conversation {
177            let turn_id = msg.turn_id();
178            if seen.insert(turn_id.clone()) {
179                ids.push(turn_id.clone());
180            }
181        }
182
183        ids
184    }
185
186    /// Compact tool results in a single message.
187    fn compact_message(
188        &self,
189        msg: &mut Message,
190        compact_summaries: &HashMap<String, String>,
191    ) -> (usize, usize) {
192        let mut summarized = 0;
193        let mut redacted = 0;
194
195        for block in msg.content_mut() {
196            if let ContentBlock::ToolResult(tool_result) = block {
197                match self.tool_compaction {
198                    ToolCompaction::Summarize => {
199                        // Use pre-computed summary if available
200                        if let Some(summary) = compact_summaries.get(&tool_result.tool_use_id) {
201                            tool_result.content = summary.clone();
202                            summarized += 1;
203                            tracing::debug!(
204                                tool_use_id = %tool_result.tool_use_id,
205                                "Tool result summarized"
206                            );
207                        }
208                        // If no summary available, keep original content
209                    }
210                    ToolCompaction::Redact => {
211                        tool_result.content =
212                            "[Tool result redacted during compaction]".to_string();
213                        redacted += 1;
214                        tracing::debug!(
215                            tool_use_id = %tool_result.tool_use_id,
216                            "Tool result redacted"
217                        );
218                    }
219                }
220            }
221        }
222
223        (summarized, redacted)
224    }
225}
226
227impl Compactor for ThresholdCompactor {
228    fn should_compact(&self, context_used: i64, context_limit: i32) -> bool {
229        if context_limit == 0 {
230            return false;
231        }
232
233        let utilization = context_used as f64 / context_limit as f64;
234        let should_compact = utilization > self.threshold;
235
236        if should_compact {
237            tracing::info!(
238                utilization = utilization,
239                threshold = self.threshold,
240                context_used,
241                context_limit,
242                "Compaction triggered - context utilization exceeded threshold"
243            );
244        }
245
246        should_compact
247    }
248
249    fn compact(
250        &self,
251        conversation: &mut Vec<Message>,
252        compact_summaries: &HashMap<String, String>,
253    ) -> CompactionResult {
254        if conversation.is_empty() {
255            return CompactionResult::default();
256        }
257
258        // Find unique turn IDs in order
259        let turn_ids = self.unique_turn_ids(conversation);
260
261        // If fewer turns than keep_recent_turns, nothing to compact
262        if turn_ids.len() <= self.keep_recent_turns {
263            tracing::debug!(
264                total_turns = turn_ids.len(),
265                keep_recent = self.keep_recent_turns,
266                "Skipping compaction - not enough turns"
267            );
268            return CompactionResult::default();
269        }
270
271        // Identify turns to keep (most recent N)
272        let start_idx = turn_ids.len() - self.keep_recent_turns;
273        let turns_to_keep: HashSet<_> = turn_ids[start_idx..].iter().cloned().collect();
274        let turns_compacted = start_idx;
275
276        tracing::info!(
277            total_turns = turn_ids.len(),
278            keep_recent = self.keep_recent_turns,
279            compacting_turns = turns_compacted,
280            tool_compaction_strategy = %self.tool_compaction,
281            "Starting conversation compaction"
282        );
283
284        // Compact older messages
285        let mut total_summarized = 0;
286        let mut total_redacted = 0;
287
288        for msg in conversation.iter_mut() {
289            let turn_id = msg.turn_id();
290            if turns_to_keep.contains(turn_id) {
291                continue; // Keep recent turns intact
292            }
293
294            let (summarized, redacted) = self.compact_message(msg, compact_summaries);
295            total_summarized += summarized;
296            total_redacted += redacted;
297        }
298
299        tracing::info!(
300            tool_results_summarized = total_summarized,
301            tool_results_redacted = total_redacted,
302            turns_compacted,
303            "Conversation compaction completed"
304        );
305
306        CompactionResult {
307            tool_results_summarized: total_summarized,
308            tool_results_redacted: total_redacted,
309            turns_compacted,
310        }
311    }
312}
313
314// ============================================================================
315// LLM-Based Compaction
316// ============================================================================
317
318/// Default system prompt for conversation summarization.
319pub const DEFAULT_SUMMARY_SYSTEM_PROMPT: &str = r#"You are a conversation summarizer. Your task is to create a concise summary of the conversation history provided.
320
321Guidelines:
322- Capture the key topics discussed, decisions made, and important context
323- Preserve any technical details, file paths, code snippets, or specific values that would be needed to continue the conversation
324- Include the user's original goals and any progress made toward them
325- Note any pending tasks or unresolved questions
326- Keep the summary focused and actionable
327- Format the summary as a narrative that provides context for continuing the conversation
328
329Respond with only the summary, no additional commentary."#;
330
331/// Default maximum tokens for summary responses.
332pub const DEFAULT_MAX_SUMMARY_TOKENS: i64 = 2048;
333
334/// Default timeout for summarization LLM calls (60 seconds).
335pub const DEFAULT_SUMMARY_TIMEOUT: Duration = Duration::from_secs(60);
336
337/// Configuration for LLM-based conversation compaction.
338#[derive(Debug, Clone)]
339pub struct LLMCompactorConfig {
340    /// Context utilization ratio (0.0-1.0) that triggers compaction.
341    pub threshold: f64,
342
343    /// Number of recent turns to preserve during compaction.
344    pub keep_recent_turns: usize,
345
346    /// System prompt for summarization. Uses DEFAULT_SUMMARY_SYSTEM_PROMPT if None.
347    pub summary_system_prompt: Option<String>,
348
349    /// Maximum tokens for summary response. Uses DEFAULT_MAX_SUMMARY_TOKENS if None.
350    pub max_summary_tokens: Option<i64>,
351
352    /// Timeout for summarization call. Uses DEFAULT_SUMMARY_TIMEOUT if None.
353    pub summary_timeout: Option<Duration>,
354}
355
356impl LLMCompactorConfig {
357    /// Creates a new LLM compactor config with default optional values.
358    pub fn new(threshold: f64, keep_recent_turns: usize) -> Self {
359        Self {
360            threshold,
361            keep_recent_turns,
362            summary_system_prompt: None,
363            max_summary_tokens: None,
364            summary_timeout: None,
365        }
366    }
367
368    /// Validates the configuration.
369    pub fn validate(&self) -> Result<(), CompactorConfigError> {
370        if self.threshold <= 0.0 || self.threshold >= 1.0 {
371            return Err(CompactorConfigError::InvalidThreshold(self.threshold));
372        }
373        Ok(())
374    }
375
376    /// Returns the system prompt to use (config value or default).
377    pub fn system_prompt(&self) -> &str {
378        self.summary_system_prompt
379            .as_deref()
380            .unwrap_or(DEFAULT_SUMMARY_SYSTEM_PROMPT)
381    }
382
383    /// Returns the max tokens to use (config value or default).
384    pub fn max_tokens(&self) -> i64 {
385        self.max_summary_tokens.unwrap_or(DEFAULT_MAX_SUMMARY_TOKENS)
386    }
387
388    /// Returns the timeout to use (config value or default).
389    pub fn timeout(&self) -> Duration {
390        self.summary_timeout.unwrap_or(DEFAULT_SUMMARY_TIMEOUT)
391    }
392}
393
394impl Default for LLMCompactorConfig {
395    fn default() -> Self {
396        Self::new(0.75, 5)
397    }
398}
399
400/// LLM-based compactor that summarizes older conversation using an LLM.
401/// It replaces older messages with a single summary message while preserving recent turns.
402pub struct LLMCompactor {
403    /// LLMClient client for making summarization LLM calls.
404    client: LLMClient,
405
406    /// Configuration for compaction behavior.
407    config: LLMCompactorConfig,
408}
409
410impl LLMCompactor {
411    /// Creates a new LLM compactor.
412    ///
413    /// # Arguments
414    /// * `client` - LLMClient client for making LLM calls
415    /// * `config` - Configuration for compaction behavior
416    ///
417    /// # Returns
418    /// Error if configuration is invalid.
419    pub fn new(client: LLMClient, config: LLMCompactorConfig) -> Result<Self, CompactorConfigError> {
420        config.validate()?;
421
422        tracing::info!(
423            threshold = config.threshold,
424            keep_recent_turns = config.keep_recent_turns,
425            max_summary_tokens = config.max_tokens(),
426            "LLM compactor created"
427        );
428
429        Ok(Self { client, config })
430    }
431
432    /// Extract unique turn IDs in order of first appearance.
433    fn unique_turn_ids(&self, conversation: &[Message]) -> Vec<TurnId> {
434        let mut seen = HashSet::new();
435        let mut ids = Vec::new();
436
437        for msg in conversation {
438            let turn_id = msg.turn_id();
439            if seen.insert(turn_id.clone()) {
440                ids.push(turn_id.clone());
441            }
442        }
443
444        ids
445    }
446
447    /// Format messages for LLM summarization.
448    fn format_messages_for_summary(&self, messages: &[Message]) -> String {
449        let mut builder = String::new();
450
451        for msg in messages {
452            // Add role label
453            if msg.is_user() {
454                builder.push_str("User: ");
455            } else {
456                builder.push_str("Assistant: ");
457            }
458
459            // Format content blocks
460            for block in msg.content() {
461                match block {
462                    ContentBlock::Text(text) => {
463                        builder.push_str(&text.text);
464                    }
465                    ContentBlock::ToolUse(tool_use) => {
466                        builder.push_str(&format!(
467                            "[Called tool: {} with input: {:?}]",
468                            tool_use.name, tool_use.input
469                        ));
470                    }
471                    ContentBlock::ToolResult(tool_result) => {
472                        let content = truncate_content(&tool_result.content, 1000);
473                        if tool_result.is_error {
474                            builder.push_str(&format!("[Tool error: {}]", content));
475                        } else {
476                            builder.push_str(&format!("[Tool result: {}]", content));
477                        }
478                    }
479                }
480            }
481            builder.push_str("\n\n");
482        }
483
484        builder
485    }
486
487    /// Create a summary message from LLM output.
488    fn create_summary_message(&self, summary: &str, session_id: &str) -> Message {
489        // Use turn number 0 to ensure it comes before all other turns
490        let turn_id = TurnId::new_user_turn(0);
491
492        // Use std::time for timestamps
493        let now = std::time::SystemTime::now()
494            .duration_since(std::time::UNIX_EPOCH)
495            .unwrap_or_default();
496
497        Message::User(UserMessage {
498            id: format!("summary-{}", now.as_nanos()),
499            session_id: session_id.to_string(),
500            turn_id,
501            created_at: now.as_secs() as i64,
502            content: vec![ContentBlock::Text(TextBlock {
503                text: format!("[Previous conversation summary]:\n\n{}", summary),
504            })],
505        })
506    }
507
508    /// Get session ID from conversation (uses first message's session_id).
509    fn get_session_id(&self, conversation: &[Message]) -> String {
510        conversation
511            .first()
512            .map(|m| m.session_id().to_string())
513            .unwrap_or_default()
514    }
515}
516
517impl Compactor for LLMCompactor {
518    fn should_compact(&self, context_used: i64, context_limit: i32) -> bool {
519        if context_limit == 0 {
520            return false;
521        }
522
523        let utilization = context_used as f64 / context_limit as f64;
524        let should_compact = utilization > self.config.threshold;
525
526        if should_compact {
527            tracing::info!(
528                utilization = utilization,
529                threshold = self.config.threshold,
530                context_used,
531                context_limit,
532                "LLM compaction triggered - context utilization exceeded threshold"
533            );
534        }
535
536        should_compact
537    }
538
539    fn compact(
540        &self,
541        _conversation: &mut Vec<Message>,
542        _compact_summaries: &HashMap<String, String>,
543    ) -> CompactionResult {
544        // LLMCompactor requires async - this should not be called
545        tracing::warn!("LLMCompactor::compact() called - use compact_async() instead");
546        CompactionResult::default()
547    }
548
549    fn is_async(&self) -> bool {
550        true
551    }
552}
553
554impl AsyncCompactor for LLMCompactor {
555    fn compact_async<'a>(
556        &'a self,
557        conversation: Vec<Message>,
558        _compact_summaries: &'a HashMap<String, String>,
559    ) -> Pin<Box<dyn Future<Output = Result<(Vec<Message>, CompactionResult), CompactionError>> + Send + 'a>>
560    {
561        Box::pin(async move {
562            if conversation.is_empty() {
563                return Ok((conversation, CompactionResult::default()));
564            }
565
566            // Find unique turn IDs in order
567            let turn_ids = self.unique_turn_ids(&conversation);
568
569            // If fewer turns than keep_recent_turns, nothing to compact
570            if turn_ids.len() <= self.config.keep_recent_turns {
571                tracing::debug!(
572                    total_turns = turn_ids.len(),
573                    keep_recent = self.config.keep_recent_turns,
574                    "Skipping LLM compaction - not enough turns"
575                );
576                return Ok((conversation, CompactionResult::default()));
577            }
578
579            // Separate messages into old (to summarize) and recent (to keep)
580            let start_idx = turn_ids.len() - self.config.keep_recent_turns;
581            let turns_to_keep: HashSet<_> = turn_ids[start_idx..].iter().cloned().collect();
582
583            let mut old_messages = Vec::new();
584            let mut recent_messages = Vec::new();
585
586            for msg in conversation {
587                if turns_to_keep.contains(msg.turn_id()) {
588                    recent_messages.push(msg);
589                } else {
590                    old_messages.push(msg);
591                }
592            }
593
594            if old_messages.is_empty() {
595                return Ok((recent_messages, CompactionResult::default()));
596            }
597
598            let session_id = self.get_session_id(&old_messages);
599
600            // Format old messages for summarization
601            let formatted_conversation = self.format_messages_for_summary(&old_messages);
602
603            tracing::info!(
604                total_turns = turn_ids.len(),
605                turns_to_summarize = start_idx,
606                turns_to_keep = self.config.keep_recent_turns,
607                messages_to_summarize = old_messages.len(),
608                formatted_length = formatted_conversation.len(),
609                "Starting LLM conversation compaction"
610            );
611
612            // Call LLM for summarization with timeout
613            let options = MessageOptions {
614                max_tokens: Some(self.config.max_tokens() as u32),
615                ..Default::default()
616            };
617
618            // Create messages for summarization
619            let llm_messages = vec![
620                LLMMessage::system(self.config.system_prompt()),
621                LLMMessage::user(formatted_conversation),
622            ];
623
624            // Make LLM call with timeout
625            let result = tokio::time::timeout(
626                self.config.timeout(),
627                self.client.send_message(&llm_messages, &options),
628            )
629            .await;
630
631            let response = match result {
632                Ok(Ok(msg)) => msg,
633                Ok(Err(e)) => {
634                    tracing::error!(error = %e, "LLM compaction failed");
635                    return Err(CompactionError::LLMError(e.to_string()));
636                }
637                Err(_) => {
638                    tracing::error!("LLM compaction timed out");
639                    return Err(CompactionError::Timeout);
640                }
641            };
642
643            // Extract text from response
644            let summary_text = response
645                .content
646                .iter()
647                .filter_map(|c| {
648                    if let crate::client::models::Content::Text(t) = c {
649                        Some(t.as_str())
650                    } else {
651                        None
652                    }
653                })
654                .collect::<Vec<_>>()
655                .join("");
656
657            // Create summary message
658            let summary_message = self.create_summary_message(&summary_text, &session_id);
659
660            // Build new conversation: summary + recent messages
661            let mut new_conversation = Vec::with_capacity(1 + recent_messages.len());
662            new_conversation.push(summary_message);
663            new_conversation.extend(recent_messages);
664
665            let result = CompactionResult {
666                tool_results_summarized: 0,
667                tool_results_redacted: 0,
668                turns_compacted: start_idx,
669            };
670
671            tracing::info!(
672                original_messages = old_messages.len() + result.turns_compacted,
673                new_messages = new_conversation.len(),
674                summary_length = summary_text.len(),
675                turns_compacted = result.turns_compacted,
676                "LLM compaction completed"
677            );
678
679            Ok((new_conversation, result))
680        })
681    }
682}
683
684/// Truncate content to a maximum length, adding ellipsis if needed.
685fn truncate_content(content: &str, max_len: usize) -> String {
686    if content.len() <= max_len {
687        content.to_string()
688    } else {
689        format!("{}...", &content[..max_len.saturating_sub(3)])
690    }
691}
692
693#[cfg(test)]
694mod tests {
695    use super::*;
696    use crate::controller::types::{UserMessage, AssistantMessage};
697
698    fn make_user_message(turn_id: TurnId) -> Message {
699        Message::User(UserMessage {
700            id: format!("msg_{}", turn_id),
701            session_id: "test_session".to_string(),
702            turn_id,
703            created_at: 0,
704            content: vec![ContentBlock::Text(crate::controller::types::TextBlock {
705                text: "test".to_string(),
706            })],
707        })
708    }
709
710    fn make_assistant_message(turn_id: TurnId) -> Message {
711        Message::Assistant(AssistantMessage {
712            id: format!("msg_{}", turn_id),
713            session_id: "test_session".to_string(),
714            turn_id,
715            parent_id: String::new(),
716            created_at: 0,
717            completed_at: None,
718            model_id: "test_model".to_string(),
719            provider_id: "test_provider".to_string(),
720            input_tokens: 0,
721            output_tokens: 0,
722            cache_read_tokens: 0,
723            cache_write_tokens: 0,
724            finish_reason: None,
725            error: None,
726            content: vec![ContentBlock::Text(crate::controller::types::TextBlock {
727                text: "test".to_string(),
728            })],
729        })
730    }
731
732    fn make_tool_result_message(tool_use_id: &str, content: &str, turn_id: TurnId) -> Message {
733        Message::User(UserMessage {
734            id: format!("msg_{}", turn_id),
735            session_id: "test_session".to_string(),
736            turn_id,
737            created_at: 0,
738            content: vec![ContentBlock::ToolResult(crate::controller::types::ToolResultBlock {
739                tool_use_id: tool_use_id.to_string(),
740                content: content.to_string(),
741                is_error: false,
742                compact_summary: None,
743            })],
744        })
745    }
746
747    #[test]
748    fn test_threshold_compactor_creation() {
749        // Valid threshold
750        let compactor = ThresholdCompactor::new(0.75, 3, ToolCompaction::Redact);
751        assert!(compactor.is_ok());
752
753        // Invalid threshold (too low)
754        let compactor = ThresholdCompactor::new(0.0, 3, ToolCompaction::Redact);
755        assert!(compactor.is_err());
756
757        // Invalid threshold (too high)
758        let compactor = ThresholdCompactor::new(1.0, 3, ToolCompaction::Redact);
759        assert!(compactor.is_err());
760    }
761
762    #[test]
763    fn test_should_compact() {
764        let compactor = ThresholdCompactor::new(0.75, 3, ToolCompaction::Redact).unwrap();
765
766        // Below threshold - don't compact
767        assert!(!compactor.should_compact(7000, 10000));
768
769        // Above threshold - compact
770        assert!(compactor.should_compact(8000, 10000));
771
772        // Zero context limit - don't compact
773        assert!(!compactor.should_compact(8000, 0));
774    }
775
776    #[test]
777    fn test_compact_not_enough_turns() {
778        let compactor = ThresholdCompactor::new(0.75, 3, ToolCompaction::Redact).unwrap();
779
780        let mut conversation = vec![
781            make_user_message(TurnId::new_user_turn(1)),
782            make_assistant_message(TurnId::new_assistant_turn(1)),
783        ];
784
785        let summaries = std::collections::HashMap::new();
786        let result = compactor.compact(&mut conversation, &summaries);
787
788        // Only 2 turns, but we keep 3, so nothing compacted
789        assert_eq!(result.turns_compacted, 0);
790    }
791
792    #[test]
793    fn test_compact_redacts_old_tool_results() {
794        // keep_recent_turns=2 keeps u2 and a2, compacts u1 and a1
795        let compactor = ThresholdCompactor::new(0.75, 2, ToolCompaction::Redact).unwrap();
796
797        let mut conversation = vec![
798            // Turn 1 - old, should be compacted
799            make_tool_result_message("tool_1", "old result", TurnId::new_user_turn(1)),
800            make_assistant_message(TurnId::new_assistant_turn(1)),
801            // Turn 2 - recent, should be kept
802            make_tool_result_message("tool_2", "new result", TurnId::new_user_turn(2)),
803            make_assistant_message(TurnId::new_assistant_turn(2)),
804        ];
805
806        let summaries = std::collections::HashMap::new();
807        let result = compactor.compact(&mut conversation, &summaries);
808
809        assert_eq!(result.tool_results_redacted, 1);
810        assert_eq!(result.turns_compacted, 2); // u1 and a1 compacted
811
812        // Check that old tool result was redacted
813        if let ContentBlock::ToolResult(tr) = &conversation[0].content()[0] {
814            assert!(tr.content.contains("redacted"));
815        } else {
816            panic!("Expected ToolResult");
817        }
818
819        // Check that new tool result was kept
820        if let ContentBlock::ToolResult(tr) = &conversation[2].content()[0] {
821            assert_eq!(tr.content, "new result");
822        } else {
823            panic!("Expected ToolResult");
824        }
825    }
826
827    #[test]
828    fn test_compact_summarizes_with_summary() {
829        // keep_recent_turns=2 keeps u2 and a2, compacts u1 and a1
830        let compactor = ThresholdCompactor::new(0.75, 2, ToolCompaction::Summarize).unwrap();
831
832        let mut conversation = vec![
833            make_tool_result_message("tool_1", "very long result", TurnId::new_user_turn(1)),
834            make_assistant_message(TurnId::new_assistant_turn(1)),
835            make_user_message(TurnId::new_user_turn(2)),
836            make_assistant_message(TurnId::new_assistant_turn(2)),
837        ];
838
839        let mut summaries = std::collections::HashMap::new();
840        summaries.insert("tool_1".to_string(), "[summary]".to_string());
841
842        let result = compactor.compact(&mut conversation, &summaries);
843
844        assert_eq!(result.tool_results_summarized, 1);
845
846        // Check that tool result was summarized
847        if let ContentBlock::ToolResult(tr) = &conversation[0].content()[0] {
848            assert_eq!(tr.content, "[summary]");
849        } else {
850            panic!("Expected ToolResult");
851        }
852    }
853
854    // ============================================================================
855    // LLMCompactorConfig Tests
856    // ============================================================================
857
858    #[test]
859    fn test_llm_compactor_config_creation() {
860        let config = LLMCompactorConfig::new(0.75, 5);
861        assert_eq!(config.threshold, 0.75);
862        assert_eq!(config.keep_recent_turns, 5);
863        assert!(config.summary_system_prompt.is_none());
864        assert!(config.max_summary_tokens.is_none());
865        assert!(config.summary_timeout.is_none());
866    }
867
868    #[test]
869    fn test_llm_compactor_config_validation() {
870        // Valid config
871        let config = LLMCompactorConfig::new(0.75, 5);
872        assert!(config.validate().is_ok());
873
874        // Invalid threshold (too low)
875        let config = LLMCompactorConfig::new(0.0, 5);
876        assert!(config.validate().is_err());
877
878        // Invalid threshold (too high)
879        let config = LLMCompactorConfig::new(1.0, 5);
880        assert!(config.validate().is_err());
881
882        // Edge case: threshold just above 0
883        let config = LLMCompactorConfig::new(0.01, 5);
884        assert!(config.validate().is_ok());
885
886        // Edge case: threshold just below 1
887        let config = LLMCompactorConfig::new(0.99, 5);
888        assert!(config.validate().is_ok());
889    }
890
891    #[test]
892    fn test_llm_compactor_config_defaults() {
893        let config = LLMCompactorConfig::default();
894        assert_eq!(config.threshold, 0.75);
895        assert_eq!(config.keep_recent_turns, 5);
896
897        // Check that getter methods return defaults
898        assert_eq!(config.system_prompt(), DEFAULT_SUMMARY_SYSTEM_PROMPT);
899        assert_eq!(config.max_tokens(), DEFAULT_MAX_SUMMARY_TOKENS);
900        assert_eq!(config.timeout(), DEFAULT_SUMMARY_TIMEOUT);
901    }
902
903    #[test]
904    fn test_llm_compactor_config_custom_values() {
905        let config = LLMCompactorConfig {
906            threshold: 0.8,
907            keep_recent_turns: 3,
908            summary_system_prompt: Some("Custom prompt".to_string()),
909            max_summary_tokens: Some(4096),
910            summary_timeout: Some(Duration::from_secs(120)),
911        };
912
913        assert_eq!(config.system_prompt(), "Custom prompt");
914        assert_eq!(config.max_tokens(), 4096);
915        assert_eq!(config.timeout(), Duration::from_secs(120));
916    }
917
918    #[test]
919    fn test_truncate_content() {
920        // Short content - no truncation
921        assert_eq!(truncate_content("hello", 10), "hello");
922
923        // Exact length - no truncation
924        assert_eq!(truncate_content("hello", 5), "hello");
925
926        // Long content - truncated with ellipsis
927        assert_eq!(truncate_content("hello world", 8), "hello...");
928
929        // Very short max - edge case
930        assert_eq!(truncate_content("hello", 3), "...");
931    }
932}