1use crate::hooks::{AgentHooks, DefaultHooks, RequestDecision, ResponseDecision};
4use crate::llm::{
5 ChatOutcome, ChatRequest, Content, ContentBlock, LlmProvider, Message, Role, StopReason,
6};
7use crate::types::TokenUsage;
8use anyhow::{Context, Result};
9use async_trait::async_trait;
10use std::fmt::Write;
11use std::sync::Arc;
12
13use super::config::CompactionConfig;
14use super::estimator::TokenEstimator;
15
16const SUMMARY_PREFIX: &str = "[Previous conversation summary]\n\n";
17const COMPACTION_SYSTEM_PROMPT: &str = "You are a precise summarizer. Your task is to create concise but complete summaries of conversations, preserving all technical details needed to continue the work.";
18const COMPACTION_SUMMARY_PROMPT_PREFIX: &str = "Summarize this conversation concisely, preserving:\n- Key decisions and conclusions reached\n- Important file paths, code changes, and technical details\n- Current task context and what has been accomplished\n- Any pending items, errors encountered, or next steps\n\nBe specific about technical details (file names, function names, error messages) as these\nare critical for continuing the work.\n\nConversation:\n";
19const COMPACTION_SUMMARY_PROMPT_SUFFIX: &str =
20 "Provide a concise summary (aim for 500-1000 words):";
21const COMPACT_EMPTY_SUMMARY: &str = "No additional context was available to summarize; the previous messages were already compacted.";
22const SUMMARY_ACKNOWLEDGMENT: &str =
23 "I understand the context from the summary. Let me continue from where we left off.";
24const MAX_TOOL_RESULT_CHARS: usize = 500;
25const TRUNCATED_SUMMARY_MARKER: &str =
26 "\n\n[summary truncated: exceeded the configured summary_max_tokens budget]";
27
28#[async_trait]
32pub trait ContextCompactor: Send + Sync {
33 async fn compact(&self, messages: &[Message]) -> Result<String>;
38
39 fn estimate_tokens(&self, messages: &[Message]) -> usize;
41
42 fn needs_compaction(&self, messages: &[Message]) -> bool;
44
45 async fn compact_history(&self, messages: Vec<Message>) -> Result<CompactionResult>;
50
51 async fn compact_history_with_usage(
63 &self,
64 messages: Vec<Message>,
65 ) -> Result<CompactionResult, FailedCompaction> {
66 self.compact_history(messages)
67 .await
68 .map_err(|error| FailedCompaction {
69 error,
70 llm_usage: TokenUsage::default(),
71 })
72 }
73}
74
75#[derive(Debug, Clone)]
77pub struct CompactionResult {
78 pub messages: Vec<Message>,
80 pub original_count: usize,
82 pub new_count: usize,
84 pub original_tokens: usize,
86 pub new_tokens: usize,
88 pub llm_usage: TokenUsage,
95}
96
97#[derive(Debug)]
105pub struct FailedCompaction {
106 pub error: anyhow::Error,
108 pub llm_usage: TokenUsage,
111}
112
113pub struct LlmContextCompactor<P: LlmProvider + ?Sized, H: AgentHooks = DefaultHooks> {
136 provider: Arc<P>,
137 config: CompactionConfig,
138 hooks: Option<Arc<H>>,
143 system_prompt: String,
144 summary_prompt_prefix: String,
145 summary_prompt_suffix: String,
146}
147
148impl<P: LlmProvider + ?Sized> LlmContextCompactor<P> {
149 #[must_use]
151 pub fn new(provider: Arc<P>, config: CompactionConfig) -> Self {
152 Self {
153 provider,
154 config,
155 hooks: None,
156 system_prompt: COMPACTION_SYSTEM_PROMPT.to_string(),
157 summary_prompt_prefix: COMPACTION_SUMMARY_PROMPT_PREFIX.to_string(),
158 summary_prompt_suffix: COMPACTION_SUMMARY_PROMPT_SUFFIX.to_string(),
159 }
160 }
161
162 #[must_use]
164 pub fn with_defaults(provider: Arc<P>) -> Self {
165 Self::new(provider, CompactionConfig::default())
166 }
167}
168
169impl<P: LlmProvider + ?Sized, H: AgentHooks> LlmContextCompactor<P, H> {
170 #[must_use]
182 pub fn with_guardrail_hooks<H2: AgentHooks>(
183 self,
184 hooks: Arc<H2>,
185 ) -> LlmContextCompactor<P, H2> {
186 LlmContextCompactor {
187 provider: self.provider,
188 config: self.config,
189 hooks: Some(hooks),
190 system_prompt: self.system_prompt,
191 summary_prompt_prefix: self.summary_prompt_prefix,
192 summary_prompt_suffix: self.summary_prompt_suffix,
193 }
194 }
195
196 #[must_use]
198 pub const fn config(&self) -> &CompactionConfig {
199 &self.config
200 }
201
202 #[must_use]
204 pub fn with_prompts(
205 mut self,
206 system_prompt: impl Into<String>,
207 summary_prompt_prefix: impl Into<String>,
208 summary_prompt_suffix: impl Into<String>,
209 ) -> Self {
210 self.system_prompt = system_prompt.into();
211 self.summary_prompt_prefix = summary_prompt_prefix.into();
212 self.summary_prompt_suffix = summary_prompt_suffix.into();
213 self
214 }
215
216 fn extract_summary_text(content: &Content) -> Option<String> {
226 match content {
227 Content::Text(text) => text.strip_prefix(SUMMARY_PREFIX).map(str::to_string),
228 Content::Blocks(blocks) => blocks.iter().find_map(|block| match block {
229 ContentBlock::Text { text } => {
230 text.strip_prefix(SUMMARY_PREFIX).map(str::to_string)
231 }
232 _ => None,
233 }),
234 }
235 }
236
237 fn has_tool_use(content: &Content) -> bool {
239 matches!(
240 content,
241 Content::Blocks(blocks)
242 if blocks
243 .iter()
244 .any(|block| matches!(block, ContentBlock::ToolUse { .. }))
245 )
246 }
247
248 fn has_tool_result(content: &Content) -> bool {
250 matches!(
251 content,
252 Content::Blocks(blocks)
253 if blocks
254 .iter()
255 .any(|block| matches!(block, ContentBlock::ToolResult { .. }))
256 )
257 }
258
259 fn has_opaque_reasoning(messages: &[Message]) -> bool {
268 messages.iter().any(|message| {
269 matches!(
270 &message.content,
271 Content::Blocks(blocks)
272 if blocks
273 .iter()
274 .any(|block| matches!(block, ContentBlock::OpaqueReasoning { .. }))
275 )
276 })
277 }
278
279 fn split_point_preserves_tool_pairs(messages: &[Message], mut split_point: usize) -> usize {
292 while split_point > 0 && split_point < messages.len() {
293 let prev = &messages[split_point - 1];
294 let next = &messages[split_point];
295
296 let crosses_tool_pair = prev.role == Role::Assistant
297 && Self::has_tool_use(&prev.content)
298 && next.role == Role::User
299 && Self::has_tool_result(&next.content);
300
301 if crosses_tool_pair {
302 split_point -= 1;
303 continue;
304 }
305
306 break;
307 }
308
309 split_point
310 }
311
312 fn split_point_preserves_tool_pairs_with_cap(
351 messages: &[Message],
352 split_point: usize,
353 max_tokens: usize,
354 ) -> usize {
355 let cap_limit = Self::retain_tail_with_token_cap(messages, split_point, max_tokens);
356 let pair_safe = Self::split_point_preserves_tool_pairs(messages, cap_limit);
357 Self::split_point_skips_leading_orphan(messages, pair_safe)
358 }
359
360 fn split_point_skips_leading_orphan(messages: &[Message], mut split_point: usize) -> usize {
377 while split_point < messages.len() {
378 if Self::leading_message_has_orphan_tool_result(&messages[split_point..]) {
379 split_point = split_point.saturating_add(1);
380 continue;
381 }
382 break;
383 }
384 split_point
385 }
386
387 fn leading_message_has_orphan_tool_result(to_keep: &[Message]) -> bool {
395 let Some(first) = to_keep.first() else {
396 return false;
397 };
398 let Content::Blocks(blocks) = &first.content else {
399 return false;
400 };
401
402 let mut needed: Vec<&str> = Vec::new();
406 for block in blocks {
407 if let ContentBlock::ToolResult { tool_use_id, .. } = block {
408 needed.push(tool_use_id.as_str());
409 }
410 }
411 if needed.is_empty() {
412 return false;
413 }
414
415 let known_ids: std::collections::HashSet<&str> = to_keep
417 .iter()
418 .flat_map(|message| match &message.content {
419 Content::Blocks(blocks) => blocks
420 .iter()
421 .filter_map(|block| match block {
422 ContentBlock::ToolUse { id, .. } => Some(id.as_str()),
423 _ => None,
424 })
425 .collect::<Vec<_>>(),
426 Content::Text(_) => Vec::new(),
427 })
428 .collect();
429
430 needed.iter().any(|id| !known_ids.contains(id))
431 }
432
433 fn retain_tail_with_token_cap(messages: &[Message], start: usize, max_tokens: usize) -> usize {
435 if start >= messages.len() {
436 return messages.len();
437 }
438
439 if max_tokens == 0 {
440 return messages.len();
441 }
442
443 let mut used = 0usize;
444 let mut retained_start = messages.len();
445
446 for idx in (start..messages.len()).rev() {
447 let message_tokens = TokenEstimator::estimate_message(&messages[idx]);
448 if used + message_tokens > max_tokens {
449 break;
450 }
451
452 retained_start = idx;
453 used += message_tokens;
454 }
455
456 retained_start
457 }
458
459 fn format_messages_for_summary<'a>(messages: impl IntoIterator<Item = &'a Message>) -> String {
464 let mut output = String::new();
465
466 for message in messages {
467 let role = match message.role {
468 Role::User => "User",
469 Role::Assistant => "Assistant",
470 };
471
472 let _ = write!(output, "{role}: ");
473
474 match &message.content {
475 Content::Text(text) => {
476 let _ = writeln!(output, "{text}");
477 }
478 Content::Blocks(blocks) => {
479 for block in blocks {
480 match block {
481 ContentBlock::Text { text } => {
482 let _ = writeln!(output, "{text}");
483 }
484 ContentBlock::Thinking { thinking, .. } => {
485 let _ = writeln!(output, "[Thinking: {thinking}]");
487 }
488 ContentBlock::RedactedThinking { .. } => {
489 let _ = writeln!(output, "[Redacted thinking]");
490 }
491 ContentBlock::OpaqueReasoning { .. } => {
492 let _ = writeln!(output, "[Opaque reasoning state omitted]");
497 }
498 ContentBlock::ToolUse { name, input, .. } => {
499 let _ = writeln!(
500 output,
501 "[Called tool: {name} with input: {}]",
502 serde_json::to_string(input).unwrap_or_default()
503 );
504 }
505 ContentBlock::ToolResult {
506 content, is_error, ..
507 } => {
508 let status = if is_error.unwrap_or(false) {
509 "error"
510 } else {
511 "success"
512 };
513 let truncated = if content.chars().count() > MAX_TOOL_RESULT_CHARS {
515 let prefix: String =
516 content.chars().take(MAX_TOOL_RESULT_CHARS).collect();
517 format!("{prefix}... (truncated)")
518 } else {
519 content.clone()
520 };
521 let _ = writeln!(output, "[Tool result ({status}): {truncated}]");
522 }
523 ContentBlock::Image { source } => {
524 let _ = writeln!(output, "[Image: {}]", source.media_type);
525 }
526 ContentBlock::Document { source } => {
527 let _ = writeln!(output, "[Document: {}]", source.media_type);
528 }
529 _ => {
532 let _ = writeln!(output, "[Unrecognized content block]");
533 }
534 }
535 }
536 }
537 }
538 output.push('\n');
539 }
540
541 output
542 }
543
544 fn build_summary_prompt(&self, prior_summaries: &[String], messages_text: &str) -> String {
551 let base = format!(
552 "{}{}{}",
553 self.summary_prompt_prefix, messages_text, self.summary_prompt_suffix
554 );
555
556 if prior_summaries.is_empty() {
557 return base;
558 }
559
560 let prior = prior_summaries.join("\n\n");
561 format!(
562 "Previous summary of earlier conversation. Preserve every fact below \
563 in your new summary so no earlier context is lost:\n{prior}\n\n{base}"
564 )
565 }
566
567 async fn run_summarization(
574 &self,
575 prompt: String,
576 max_tokens: usize,
577 ) -> Result<SummarizationCall, SummarizationFailure> {
578 let mut request = ChatRequest {
579 system: self.system_prompt.clone(),
580 messages: vec![Message::user(prompt)],
581 tools: None,
582 max_tokens: u32::try_from(max_tokens).unwrap_or(u32::MAX),
583 max_tokens_explicit: true,
584 session_id: None,
585 cached_content: None,
586 thinking: None,
587 tool_choice: None,
588 response_format: None,
589 cache: None,
590 };
591
592 if let Some(hooks) = &self.hooks {
596 match hooks.pre_llm_request(&request).await {
597 RequestDecision::Modify(modified) => request = *modified,
598 RequestDecision::Block(reason) => {
599 return Err(SummarizationFailure {
600 error: anyhow::anyhow!(
601 "Summarization request blocked by guardrail: {reason}"
602 ),
603 usage: TokenUsage::default(),
604 });
605 }
606 _ => {}
609 }
610 }
611
612 let outcome = self
613 .provider
614 .chat(request)
615 .await
616 .context("Failed to call LLM for summarization")
617 .map_err(|error| SummarizationFailure {
618 error,
619 usage: TokenUsage::default(),
620 })?;
621
622 match outcome {
623 ChatOutcome::Success(response) => {
624 let usage = TokenUsage {
628 input_tokens: response.usage.input_tokens,
629 output_tokens: response.usage.output_tokens,
630 cached_input_tokens: response.usage.cached_input_tokens,
631 cache_creation_input_tokens: response.usage.cache_creation_input_tokens,
632 };
633 if let Some(hooks) = &self.hooks {
640 match hooks.on_llm_response(&response).await {
641 ResponseDecision::Block(reason) => {
642 return Err(SummarizationFailure {
643 error: anyhow::anyhow!(
644 "Summarization response blocked by guardrail: {reason}"
645 ),
646 usage,
647 });
648 }
649 ResponseDecision::RetryWithFeedback(reason) => {
650 return Err(SummarizationFailure {
651 error: anyhow::anyhow!(
652 "Summarization response rejected by guardrail \
653 (RetryWithFeedback is not retried during compaction): {reason}"
654 ),
655 usage,
656 });
657 }
658 _ => {}
661 }
662 }
663 let truncated = response.stop_reason == Some(StopReason::MaxTokens);
664 let Some(text) = response.first_text().map(String::from) else {
665 return Err(SummarizationFailure {
666 error: anyhow::anyhow!("No text in summarization response"),
667 usage,
668 });
669 };
670 Ok(SummarizationCall {
671 text,
672 truncated,
673 usage,
674 })
675 }
676 ChatOutcome::RateLimited(_) => Err(SummarizationFailure {
677 error: anyhow::anyhow!("Rate limited during summarization"),
678 usage: TokenUsage::default(),
679 }),
680 ChatOutcome::InvalidRequest(msg) => Err(SummarizationFailure {
681 error: anyhow::anyhow!("Invalid request during summarization: {msg}"),
682 usage: TokenUsage::default(),
683 }),
684 ChatOutcome::ServerError(msg) => Err(SummarizationFailure {
685 error: anyhow::anyhow!("Server error during summarization: {msg}"),
686 usage: TokenUsage::default(),
687 }),
688 _ => Err(SummarizationFailure {
691 error: anyhow::anyhow!("Unrecognized provider outcome during summarization"),
692 usage: TokenUsage::default(),
693 }),
694 }
695 }
696
697 async fn summarize_with_usage(
705 &self,
706 messages: &[Message],
707 ) -> Result<(String, TokenUsage), SummarizationFailure> {
708 let mut prior_summaries: Vec<String> = Vec::new();
713 let mut fresh: Vec<&Message> = Vec::new();
714 for message in messages {
715 if let Some(text) = Self::extract_summary_text(&message.content) {
716 if !text.is_empty() {
717 prior_summaries.push(text);
718 }
719 } else {
720 fresh.push(message);
721 }
722 }
723
724 if fresh.is_empty() {
727 if prior_summaries.is_empty() {
728 return Ok((COMPACT_EMPTY_SUMMARY.to_string(), TokenUsage::default()));
729 }
730 return Ok((prior_summaries.join("\n\n"), TokenUsage::default()));
731 }
732
733 let messages_text = Self::format_messages_for_summary(fresh.iter().copied());
734 let prompt = self.build_summary_prompt(&prior_summaries, &messages_text);
735
736 let budget = self.config.summary_max_tokens;
737 let first = self.run_summarization(prompt.clone(), budget).await?;
738 let mut summary = first.text;
739 let mut total_usage = first.usage;
740
741 if first.truncated {
742 log::warn!(
743 "compaction summary hit the max_tokens budget ({budget}); \
744 retrying with a larger budget to avoid silent context loss"
745 );
746 let retry = match self
747 .run_summarization(prompt, budget.saturating_mul(2))
748 .await
749 {
750 Ok(retry) => retry,
751 Err(mut failure) => {
752 failure.usage.add(&total_usage);
755 return Err(failure);
756 }
757 };
758 total_usage.add(&retry.usage);
759 summary = retry.text;
760 if retry.truncated {
761 log::warn!(
762 "compaction summary still truncated after retry; appending a \
763 truncation marker so downstream context loss is visible"
764 );
765 summary.push_str(TRUNCATED_SUMMARY_MARKER);
766 }
767 }
768
769 Ok((summary, total_usage))
770 }
771}
772
773struct SummarizationCall {
775 text: String,
776 truncated: bool,
777 usage: TokenUsage,
778}
779
780struct SummarizationFailure {
784 error: anyhow::Error,
785 usage: TokenUsage,
786}
787
788impl<P: LlmProvider + ?Sized, H: AgentHooks> LlmContextCompactor<P, H> {
789 async fn compact_history_inner(
792 &self,
793 mut messages: Vec<Message>,
794 ) -> Result<CompactionResult, FailedCompaction> {
795 let original_count = messages.len();
796 let original_tokens = self.estimate_tokens(&messages);
797
798 if Self::has_opaque_reasoning(&messages) {
803 log::debug!(
804 "skipping generic context compaction for history with opaque provider reasoning state"
805 );
806 return Ok(CompactionResult {
807 messages,
808 original_count,
809 new_count: original_count,
810 original_tokens,
811 new_tokens: original_tokens,
812 llm_usage: TokenUsage::default(),
813 });
814 }
815
816 if messages.len() <= self.config.retain_recent {
818 return Ok(CompactionResult {
819 messages,
820 original_count,
821 new_count: original_count,
822 original_tokens,
823 new_tokens: original_tokens,
824 llm_usage: TokenUsage::default(),
825 });
826 }
827
828 let mut split_point = messages.len().saturating_sub(self.config.retain_recent);
830 split_point = Self::split_point_preserves_tool_pairs_with_cap(
831 &messages,
832 split_point,
833 self.config.max_retained_tail_tokens,
834 );
835
836 let to_keep = messages.split_off(split_point);
839 let to_summarize = messages;
840
841 let (summary, llm_usage) =
843 self.summarize_with_usage(&to_summarize)
844 .await
845 .map_err(|failure| FailedCompaction {
846 error: failure.error,
847 llm_usage: failure.usage,
848 })?;
849
850 let mut new_messages = Vec::with_capacity(2 + to_keep.len());
852
853 new_messages.push(Message::user(format!("{SUMMARY_PREFIX}{summary}")));
855
856 if !to_keep.is_empty() {
861 new_messages.push(Message::assistant(SUMMARY_ACKNOWLEDGMENT));
862 }
863
864 new_messages.extend(to_keep);
872
873 let new_count = new_messages.len();
874 let new_tokens = self.estimate_tokens(&new_messages);
875
876 Ok(CompactionResult {
877 messages: new_messages,
878 original_count,
879 new_count,
880 original_tokens,
881 new_tokens,
882 llm_usage,
883 })
884 }
885}
886
887#[async_trait]
888impl<P: LlmProvider + ?Sized, H: AgentHooks> ContextCompactor for LlmContextCompactor<P, H> {
889 async fn compact(&self, messages: &[Message]) -> Result<String> {
890 let (summary, _usage) = self
891 .summarize_with_usage(messages)
892 .await
893 .map_err(|failure| failure.error)?;
894 Ok(summary)
895 }
896
897 fn estimate_tokens(&self, messages: &[Message]) -> usize {
898 TokenEstimator::estimate_history(messages)
899 }
900
901 fn needs_compaction(&self, messages: &[Message]) -> bool {
902 if !self.config.auto_compact {
903 return false;
904 }
905
906 if Self::has_opaque_reasoning(messages) {
911 return false;
912 }
913
914 if messages.len() < self.config.min_messages_for_compaction {
915 return false;
916 }
917
918 let estimated_tokens = self.estimate_tokens(messages);
919 estimated_tokens > self.config.threshold_tokens
920 }
921
922 async fn compact_history(&self, messages: Vec<Message>) -> Result<CompactionResult> {
923 self.compact_history_inner(messages)
924 .await
925 .map_err(|failure| failure.error)
926 }
927
928 async fn compact_history_with_usage(
929 &self,
930 messages: Vec<Message>,
931 ) -> Result<CompactionResult, FailedCompaction> {
932 self.compact_history_inner(messages).await
933 }
934}
935
936#[cfg(test)]
937mod tests {
938 use super::*;
939 use crate::llm::{ChatResponse, StopReason, Usage};
940 use anyhow::bail;
941 use std::sync::Mutex;
942
943 struct MockProvider {
944 summary_response: String,
945 requests: Arc<Mutex<Vec<String>>>,
946 echo_input: bool,
949 stop_reason: StopReason,
951 }
952
953 impl MockProvider {
954 fn build(
955 summary: &str,
956 requests: Arc<Mutex<Vec<String>>>,
957 echo_input: bool,
958 stop_reason: StopReason,
959 ) -> Self {
960 Self {
961 summary_response: summary.to_string(),
962 requests,
963 echo_input,
964 stop_reason,
965 }
966 }
967
968 fn new(summary: &str) -> Self {
969 Self::build(
970 summary,
971 Arc::new(Mutex::new(Vec::new())),
972 false,
973 StopReason::EndTurn,
974 )
975 }
976
977 fn new_with_request_log(summary: &str, requests: Arc<Mutex<Vec<String>>>) -> Self {
978 Self::build(summary, requests, false, StopReason::EndTurn)
979 }
980
981 fn new_echo(requests: Arc<Mutex<Vec<String>>>) -> Self {
983 Self::build("", requests, true, StopReason::EndTurn)
984 }
985
986 fn new_truncating(summary: &str, requests: Arc<Mutex<Vec<String>>>) -> Self {
988 Self::build(summary, requests, false, StopReason::MaxTokens)
989 }
990
991 fn user_prompt_of(request: &ChatRequest) -> String {
992 request
993 .messages
994 .iter()
995 .find_map(|message| match &message.content {
996 Content::Text(text) => Some(text.clone()),
997 Content::Blocks(blocks) => {
998 let text = blocks
999 .iter()
1000 .filter_map(|block| {
1001 if let ContentBlock::Text { text } = block {
1002 Some(text.as_str())
1003 } else {
1004 None
1005 }
1006 })
1007 .collect::<Vec<_>>()
1008 .join("\n");
1009 if text.is_empty() { None } else { Some(text) }
1010 }
1011 })
1012 .unwrap_or_default()
1013 }
1014 }
1015
1016 #[async_trait]
1017 impl LlmProvider for MockProvider {
1018 async fn chat(&self, request: ChatRequest) -> Result<ChatOutcome> {
1019 let user_prompt = Self::user_prompt_of(&request);
1020 if let Ok(mut entries) = self.requests.lock() {
1021 entries.push(user_prompt.clone());
1022 }
1023 let text = if self.echo_input {
1024 user_prompt
1025 } else {
1026 self.summary_response.clone()
1027 };
1028 Ok(ChatOutcome::Success(ChatResponse {
1029 id: "test".to_string(),
1030 content: vec![ContentBlock::Text { text }],
1031 model: "mock".to_string(),
1032 stop_reason: Some(self.stop_reason),
1033 usage: Usage {
1034 input_tokens: 100,
1035 output_tokens: 50,
1036 cached_input_tokens: 0,
1037 cache_creation_input_tokens: 0,
1038 },
1039 }))
1040 }
1041
1042 fn model(&self) -> &'static str {
1043 "mock-model"
1044 }
1045
1046 fn provider(&self) -> &'static str {
1047 "mock"
1048 }
1049 }
1050
1051 #[test]
1052 fn test_needs_compaction_below_threshold() {
1053 let provider = Arc::new(MockProvider::new("summary"));
1054 let config = CompactionConfig::default()
1055 .with_threshold_tokens(10_000)
1056 .with_min_messages(5);
1057 let compactor = LlmContextCompactor::new(provider, config);
1058
1059 let messages = vec![
1061 Message::user("Hello"),
1062 Message::assistant("Hi"),
1063 Message::user("How are you?"),
1064 ];
1065
1066 assert!(!compactor.needs_compaction(&messages));
1067 }
1068
1069 #[test]
1070 fn test_needs_compaction_above_threshold() {
1071 let provider = Arc::new(MockProvider::new("summary"));
1072 let config = CompactionConfig::default()
1073 .with_threshold_tokens(50) .with_min_messages(3);
1075 let compactor = LlmContextCompactor::new(provider, config);
1076
1077 let messages = vec![
1079 Message::user("Hello, this is a longer message to test compaction"),
1080 Message::assistant(
1081 "Hi there! This is also a longer response to help trigger compaction",
1082 ),
1083 Message::user("Great, let's continue with even more text here"),
1084 Message::assistant("Absolutely, adding more content to ensure we exceed the threshold"),
1085 ];
1086
1087 assert!(compactor.needs_compaction(&messages));
1088 }
1089
1090 #[test]
1091 fn test_needs_compaction_auto_disabled() {
1092 let provider = Arc::new(MockProvider::new("summary"));
1093 let config = CompactionConfig::default()
1094 .with_threshold_tokens(10) .with_min_messages(1)
1096 .with_auto_compact(false);
1097 let compactor = LlmContextCompactor::new(provider, config);
1098
1099 let messages = vec![
1100 Message::user("Hello, this is a longer message"),
1101 Message::assistant("Response here"),
1102 ];
1103
1104 assert!(!compactor.needs_compaction(&messages));
1105 }
1106
1107 #[test]
1108 fn summary_prompt_redacts_opaque_reasoning_payload() {
1109 let secret = "opaque-secret-that-must-not-enter-the-summary-prompt";
1110 let message = Message::assistant_with_content(vec![ContentBlock::OpaqueReasoning {
1111 provider: "test-provider".to_owned(),
1112 data: serde_json::json!({"encrypted_content": secret}),
1113 }]);
1114
1115 let rendered = LlmContextCompactor::<MockProvider>::format_messages_for_summary([&message]);
1116 assert!(rendered.contains("[Opaque reasoning state omitted]"));
1117 assert!(!rendered.contains(secret));
1118 }
1119
1120 #[tokio::test]
1121 async fn compact_history_preserves_opaque_reasoning_in_retained_tail() -> Result<()> {
1122 let provider = Arc::new(MockProvider::new("older context"));
1123 let config = CompactionConfig::default()
1124 .with_retain_recent(2)
1125 .with_min_messages(3);
1126 let compactor = LlmContextCompactor::new(provider, config);
1127 let opaque_data = serde_json::json!({
1128 "id": "reasoning_1",
1129 "encrypted_content": "ciphertext"
1130 });
1131 let messages = vec![
1132 Message::user("old question"),
1133 Message::assistant("old answer"),
1134 Message::user("current question"),
1135 Message::assistant_with_content(vec![ContentBlock::OpaqueReasoning {
1136 provider: "test-provider".to_owned(),
1137 data: opaque_data.clone(),
1138 }]),
1139 ];
1140
1141 let result = compactor.compact_history(messages).await?;
1142 let retained = result
1143 .messages
1144 .last()
1145 .context("compacted history should retain the newest assistant message")?;
1146 let Content::Blocks(blocks) = &retained.content else {
1147 bail!("retained assistant message should contain blocks");
1148 };
1149 assert!(matches!(
1150 blocks.first(),
1151 Some(ContentBlock::OpaqueReasoning { provider, data })
1152 if provider == "test-provider" && data == &opaque_data
1153 ));
1154 Ok(())
1155 }
1156
1157 #[tokio::test]
1158 async fn compact_history_bypasses_opaque_reasoning_before_the_split() -> Result<()> {
1159 let requests = Arc::new(Mutex::new(Vec::new()));
1160 let provider = Arc::new(MockProvider::new_with_request_log(
1161 "summary that must not be requested",
1162 Arc::clone(&requests),
1163 ));
1164 let config = CompactionConfig::default()
1165 .with_retain_recent(1)
1166 .with_min_messages(1)
1167 .with_threshold_tokens(1);
1168 let compactor = LlmContextCompactor::new(provider, config);
1169 let opaque_data = serde_json::json!({
1170 "type": "reasoning",
1171 "id": "rs_1",
1172 "encrypted_content": "ciphertext"
1173 });
1174 let messages = vec![
1175 Message::user("older user context"),
1176 Message::assistant_with_content(vec![
1179 ContentBlock::OpaqueReasoning {
1180 provider: "openai-responses".to_owned(),
1181 data: opaque_data,
1182 },
1183 ContentBlock::Text {
1184 text: "older assistant response".to_owned(),
1185 },
1186 ]),
1187 Message::user("newer user context"),
1188 Message::assistant("newer assistant response"),
1189 ];
1190 let serialized_before = serde_json::to_value(&messages)?;
1191
1192 assert!(
1193 !compactor.needs_compaction(&messages),
1194 "generic compaction must not be scheduled for opaque replay state"
1195 );
1196
1197 let result = compactor.compact_history(messages).await?;
1198
1199 assert_eq!(serde_json::to_value(&result.messages)?, serialized_before);
1200 assert_eq!(result.new_count, result.original_count);
1201 assert_eq!(result.new_tokens, result.original_tokens);
1202 let recorded = requests
1203 .lock()
1204 .map_err(|_| anyhow::anyhow!("request log poisoned"))?;
1205 assert!(
1206 recorded.is_empty(),
1207 "opaque replay state must not be sent to the generic summarizer"
1208 );
1209 drop(recorded);
1210 Ok(())
1211 }
1212
1213 #[tokio::test]
1214 async fn test_compact_history() -> Result<()> {
1215 let provider = Arc::new(MockProvider::new(
1216 "User asked about Rust programming. Assistant explained ownership, borrowing, and lifetimes.",
1217 ));
1218 let config = CompactionConfig::default()
1219 .with_retain_recent(2)
1220 .with_min_messages(3);
1221 let compactor = LlmContextCompactor::new(provider, config);
1222
1223 let messages = vec![
1225 Message::user(
1226 "What is Rust? I've heard it's a systems programming language but I don't know much about it. Can you explain the key features and why people are excited about it?",
1227 ),
1228 Message::assistant(
1229 "Rust is a systems programming language focused on safety, speed, and concurrency. It achieves memory safety without garbage collection through its ownership system. The key features include zero-cost abstractions, guaranteed memory safety, threads without data races, and minimal runtime.",
1230 ),
1231 Message::user(
1232 "Tell me about ownership in detail. How does it work and what are the rules? I want to understand this core concept thoroughly.",
1233 ),
1234 Message::assistant(
1235 "Ownership is Rust's central feature with three rules: each value has one owner, only one owner at a time, and the value is dropped when owner goes out of scope. This system prevents memory leaks, double frees, and dangling pointers at compile time.",
1236 ),
1237 Message::user("What about borrowing?"), Message::assistant("Borrowing allows references to data without taking ownership."), ];
1240
1241 let result = compactor.compact_history(messages).await?;
1242
1243 assert_eq!(result.new_count, 4);
1245 assert_eq!(result.original_count, 6);
1246
1247 assert!(
1249 result.new_tokens < result.original_tokens,
1250 "Expected fewer tokens after compaction: new={} < original={}",
1251 result.new_tokens,
1252 result.original_tokens
1253 );
1254
1255 if let Content::Text(text) = &result.messages[0].content {
1257 assert!(text.contains("Previous conversation summary"));
1258 }
1259
1260 Ok(())
1261 }
1262
1263 #[tokio::test]
1264 async fn test_compact_history_too_few_messages() -> Result<()> {
1265 let provider = Arc::new(MockProvider::new("summary"));
1266 let config = CompactionConfig::default().with_retain_recent(5);
1267 let compactor = LlmContextCompactor::new(provider, config);
1268
1269 let messages = vec![
1271 Message::user("Hello"),
1272 Message::assistant("Hi"),
1273 Message::user("Bye"),
1274 ];
1275
1276 let result = compactor.compact_history(messages.clone()).await?;
1277
1278 assert_eq!(result.new_count, 3);
1280 assert_eq!(result.messages.len(), 3);
1281
1282 Ok(())
1283 }
1284
1285 #[test]
1286 fn test_format_messages_for_summary() {
1287 let messages = vec![Message::user("Hello"), Message::assistant("Hi there!")];
1288
1289 let formatted = LlmContextCompactor::<MockProvider>::format_messages_for_summary(&messages);
1290
1291 assert!(formatted.contains("User: Hello"));
1292 assert!(formatted.contains("Assistant: Hi there!"));
1293 }
1294
1295 #[test]
1296 fn test_format_messages_for_summary_truncates_tool_results_unicode_safely() {
1297 let long_unicode = "é".repeat(600);
1298
1299 let messages = vec![Message {
1300 role: Role::Assistant,
1301 content: Content::Blocks(vec![ContentBlock::ToolResult {
1302 tool_use_id: "tool-1".to_string(),
1303 content: long_unicode,
1304 is_error: Some(false),
1305 }]),
1306 }];
1307
1308 let formatted = LlmContextCompactor::<MockProvider>::format_messages_for_summary(&messages);
1309
1310 assert!(formatted.contains("... (truncated)"));
1311 }
1312
1313 #[tokio::test]
1314 async fn test_compact_carries_prior_summary_into_request() -> Result<()> {
1315 let requests = Arc::new(Mutex::new(Vec::new()));
1320 let provider = Arc::new(MockProvider::new_with_request_log(
1321 "Fresh summary",
1322 requests.clone(),
1323 ));
1324 let config = CompactionConfig::default().with_min_messages(1);
1325 let compactor = LlmContextCompactor::new(provider, config);
1326
1327 let messages = vec![
1328 Message::user(format!("{SUMMARY_PREFIX}already compacted context")),
1329 Message::assistant("Continue with the next task using this context."),
1330 ];
1331
1332 let summary = compactor.compact(&messages).await?;
1333
1334 let recorded = requests
1335 .lock()
1336 .map_err(|_| anyhow::anyhow!("request log poisoned"))?;
1337 assert_eq!(recorded.len(), 1);
1338 assert_eq!(summary, "Fresh summary");
1341 assert!(recorded[0].contains("Continue with the next task using this context."));
1342 assert!(
1343 recorded[0].contains("already compacted context"),
1344 "prior summary must be carried into the summarization input"
1345 );
1346 drop(recorded);
1347
1348 Ok(())
1349 }
1350
1351 #[tokio::test]
1352 async fn test_compact_history_carries_prior_summary_in_candidate_payload() -> Result<()> {
1353 let requests = Arc::new(Mutex::new(Vec::new()));
1354 let provider = Arc::new(MockProvider::new_with_request_log(
1355 "Fresh history summary",
1356 requests.clone(),
1357 ));
1358 let config = CompactionConfig::default()
1359 .with_retain_recent(2)
1360 .with_min_messages(1);
1361 let compactor = LlmContextCompactor::new(provider, config);
1362
1363 let messages = vec![
1364 Message::user(format!("{SUMMARY_PREFIX}already compacted context")),
1365 Message::assistant("Current turn content from the latest exchange."),
1366 Message::assistant("Recent message that should stay."),
1367 Message::user("Newest note that should stay."),
1368 ];
1369
1370 let result = compactor.compact_history(messages).await?;
1371
1372 let recorded = requests
1373 .lock()
1374 .map_err(|_| anyhow::anyhow!("request log poisoned"))?;
1375 assert_eq!(recorded.len(), 1);
1376 assert!(recorded[0].contains("Current turn content from the latest exchange."));
1377 assert!(
1380 recorded[0].contains("already compacted context"),
1381 "prior summary content must reach the summarizer"
1382 );
1383 drop(recorded);
1384 assert_eq!(result.new_count, 4);
1385
1386 Ok(())
1387 }
1388
1389 #[tokio::test]
1390 async fn test_compact_history_carries_summaries_forward_when_window_has_only_summaries()
1391 -> Result<()> {
1392 let requests = Arc::new(Mutex::new(Vec::new()));
1393 let provider = Arc::new(MockProvider::new_with_request_log(
1394 "This summary should not be used",
1395 requests.clone(),
1396 ));
1397 let config = CompactionConfig::default()
1398 .with_retain_recent(2)
1399 .with_min_messages(1);
1400 let compactor = LlmContextCompactor::new(provider, config);
1401
1402 let messages = vec![
1403 Message::user(format!("{SUMMARY_PREFIX}first prior compacted section")),
1404 Message::assistant(format!("{SUMMARY_PREFIX}second prior compacted section")),
1405 Message::user(format!("{SUMMARY_PREFIX}third prior compacted section")),
1406 Message::assistant("final short note"),
1407 ];
1408
1409 let result = compactor.compact_history(messages).await?;
1410
1411 let recorded = requests
1415 .lock()
1416 .map_err(|_| anyhow::anyhow!("request log poisoned"))?;
1417 assert!(recorded.is_empty());
1418 drop(recorded);
1419 assert_eq!(result.new_count, 4);
1420 assert_eq!(result.messages.len(), 4);
1421
1422 if let Content::Text(text) = &result.messages[0].content {
1423 assert!(
1424 text.contains("first prior compacted section"),
1425 "first prior summary lost"
1426 );
1427 assert!(
1428 text.contains("second prior compacted section"),
1429 "second prior summary lost"
1430 );
1431 assert!(!text.contains(COMPACT_EMPTY_SUMMARY));
1432 } else {
1433 panic!("Expected summary text in first message");
1434 }
1435
1436 Ok(())
1437 }
1438
1439 #[tokio::test]
1440 async fn test_compact_history_preserves_tool_use_tool_result_pairs() -> Result<()> {
1441 let provider = Arc::new(MockProvider::new("Summary of earlier conversation."));
1442 let config = CompactionConfig::default()
1443 .with_retain_recent(2)
1444 .with_min_messages(3);
1445 let compactor = LlmContextCompactor::new(provider, config);
1446
1447 let messages = vec![
1451 Message::user("What files are in the project?"),
1453 Message::assistant("Let me check that for you."),
1455 Message {
1457 role: Role::Assistant,
1458 content: Content::Blocks(vec![ContentBlock::ToolUse {
1459 id: "tool_1".to_string(),
1460 name: "list_files".to_string(),
1461 input: serde_json::json!({}),
1462 thought_signature: None,
1463 }]),
1464 },
1465 Message {
1467 role: Role::User,
1468 content: Content::Blocks(vec![ContentBlock::ToolResult {
1469 tool_use_id: "tool_1".to_string(),
1470 content: "file1.rs\nfile2.rs".to_string(),
1471 is_error: None,
1472 }]),
1473 },
1474 Message::assistant("The project contains file1.rs and file2.rs."),
1476 ];
1477
1478 let result = compactor.compact_history(messages).await?;
1479
1480 assert_eq!(result.new_count, 5);
1484
1485 let kept_assistant = &result.messages[2];
1488 if let Content::Blocks(blocks) = &kept_assistant.content {
1489 assert!(
1490 blocks
1491 .iter()
1492 .any(|b| matches!(b, ContentBlock::ToolUse { .. })),
1493 "Expected assistant tool_use in kept messages"
1494 );
1495 } else {
1496 panic!("Expected Blocks content for assistant tool_use message");
1497 }
1498
1499 let kept_user = &result.messages[3];
1501 if let Content::Blocks(blocks) = &kept_user.content {
1502 assert!(
1503 blocks
1504 .iter()
1505 .any(|b| matches!(b, ContentBlock::ToolResult { .. })),
1506 "Expected user tool_result in kept messages"
1507 );
1508 } else {
1509 panic!("Expected Blocks content for user tool_result message");
1510 }
1511
1512 Ok(())
1513 }
1514
1515 #[tokio::test]
1516 async fn test_compact_history_split_skips_leading_orphan_after_summary_ack() -> Result<()> {
1517 let provider = Arc::new(MockProvider::new("Re-summary."));
1545 let config = CompactionConfig::default()
1546 .with_retain_recent(3)
1547 .with_min_messages(1);
1548 let compactor = LlmContextCompactor::new(provider, config);
1549
1550 let messages = vec![
1551 Message::user(format!("{SUMMARY_PREFIX}Old summary about toolu_X.")),
1552 Message::assistant(SUMMARY_ACKNOWLEDGMENT),
1553 Message {
1554 role: Role::User,
1555 content: Content::Blocks(vec![ContentBlock::ToolResult {
1556 tool_use_id: "toolu_X".to_string(),
1557 content: "result for X".to_string(),
1558 is_error: None,
1559 }]),
1560 },
1561 Message::assistant("Result interpreted."),
1562 Message::user("Now what?"),
1563 ];
1564
1565 let result = compactor.compact_history(messages).await?;
1566
1567 let mut seen_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
1568 for msg in &result.messages {
1569 if let Content::Blocks(blocks) = &msg.content {
1570 for block in blocks {
1571 match block {
1572 ContentBlock::ToolResult { tool_use_id, .. } => {
1573 assert!(
1574 seen_ids.contains(tool_use_id),
1575 "orphan tool_use_id {tool_use_id} survived split selection",
1576 );
1577 }
1578 ContentBlock::ToolUse { id, .. } => {
1579 seen_ids.insert(id.clone());
1580 }
1581 _ => {}
1582 }
1583 }
1584 }
1585 }
1586
1587 Ok(())
1588 }
1589
1590 #[tokio::test]
1591 async fn test_compact_history_keeps_tool_pair_when_immediate_prev_is_text_only() -> Result<()> {
1592 let provider = Arc::new(MockProvider::new("Boundary summary."));
1599 let config = CompactionConfig::default()
1600 .with_retain_recent(2)
1601 .with_min_messages(1);
1602 let compactor = LlmContextCompactor::new(provider, config);
1603
1604 let messages = vec![
1617 Message::user("first turn"),
1618 Message::assistant("text only"),
1619 Message {
1620 role: Role::User,
1621 content: Content::Blocks(vec![ContentBlock::ToolResult {
1622 tool_use_id: "toolu_Y".to_string(),
1623 content: "ancient result".to_string(),
1624 is_error: None,
1625 }]),
1626 },
1627 Message::assistant("then a reply"),
1628 Message::user("ok thanks"),
1629 ];
1630
1631 let result = compactor.compact_history(messages).await?;
1632
1633 let has_tool_result = result.messages.iter().any(|m| {
1637 matches!(
1638 &m.content,
1639 Content::Blocks(blocks)
1640 if blocks.iter().any(|b| matches!(b, ContentBlock::ToolResult { .. }))
1641 )
1642 });
1643 assert!(
1644 !has_tool_result,
1645 "orphan tool_result should have been pushed into to_summarize, not retained",
1646 );
1647
1648 Ok(())
1649 }
1650
1651 #[tokio::test]
1652 async fn test_compact_history_retained_tail_is_token_capped() -> Result<()> {
1653 let provider = Arc::new(MockProvider::new(
1654 "Project summary with a long context and technical context.",
1655 ));
1656 let config = CompactionConfig::default()
1657 .with_retain_recent(8)
1658 .with_min_messages(1)
1659 .with_threshold_tokens(1);
1660 let compactor = LlmContextCompactor::new(provider, config);
1661
1662 let mut messages = Vec::new();
1663
1664 messages.extend((0..6).map(|index| Message::user(format!("pre-compaction noise {index}"))));
1666
1667 messages.extend(
1669 (0..8).map(|index| Message::assistant(format!("kept-{index}: {}", "x".repeat(12_000)))),
1670 );
1671
1672 let result = compactor.compact_history(messages).await?;
1673
1674 let retained_tail = &result.messages[2..];
1676 assert!(retained_tail.len() < 8);
1677
1678 let mut latest_index = -1i32;
1679 let mut all_retained = true;
1680 for message in retained_tail {
1681 if let Content::Text(text) = &message.content {
1682 if let Some(number) = text.split(':').next().and_then(|prefix| {
1683 prefix
1684 .strip_prefix("kept-")
1685 .and_then(|rest| rest.parse::<i32>().ok())
1686 }) {
1687 if number >= 0 {
1688 latest_index = latest_index.max(number);
1689 }
1690 } else {
1691 all_retained = false;
1692 }
1693 } else {
1694 all_retained = false;
1695 }
1696 }
1697
1698 assert!(all_retained);
1699 assert_eq!(latest_index, 7);
1700 assert!(
1701 TokenEstimator::estimate_history(retained_tail)
1702 <= compactor.config().max_retained_tail_tokens
1703 );
1704 assert!(compactor.needs_compaction(&result.messages));
1705
1706 Ok(())
1707 }
1708
1709 #[tokio::test]
1710 async fn test_compact_history_skips_summary_ack_when_retained_tail_is_empty() -> Result<()> {
1711 let provider = Arc::new(MockProvider::new("Summary for oversized user turn."));
1712 let config = CompactionConfig::default()
1713 .with_retain_recent(1)
1714 .with_min_messages(1)
1715 .with_threshold_tokens(1);
1716 let compactor = LlmContextCompactor::new(provider, config);
1717
1718 let messages = vec![
1719 Message::assistant("Earlier assistant context."),
1720 Message::user(format!("oversized-user-turn: {}", "x".repeat(200_000))),
1721 ];
1722
1723 let result = compactor.compact_history(messages).await?;
1724
1725 assert_eq!(result.new_count, 1);
1726 assert_eq!(result.messages.len(), 1);
1727
1728 let only_message = &result.messages[0];
1729 assert_eq!(only_message.role, Role::User);
1730
1731 if let Content::Text(text) = &only_message.content {
1732 assert!(text.contains("Previous conversation summary"));
1733 assert!(!text.contains(SUMMARY_ACKNOWLEDGMENT));
1734 } else {
1735 panic!("Expected summary text when retained tail is empty");
1736 }
1737
1738 Ok(())
1739 }
1740
1741 fn message_contains(message: &Message, needle: &str) -> bool {
1742 match &message.content {
1743 Content::Text(text) => text.contains(needle),
1744 Content::Blocks(blocks) => blocks.iter().any(|block| match block {
1745 ContentBlock::Text { text } => text.contains(needle),
1746 _ => false,
1747 }),
1748 }
1749 }
1750
1751 #[tokio::test]
1752 async fn test_epoch_one_facts_survive_two_compactions() -> Result<()> {
1753 const EPOCH1_FACT: &str = "EPOCH1_FACT: the API key lives in config/secrets.toml";
1759
1760 let requests = Arc::new(Mutex::new(Vec::new()));
1761 let provider = Arc::new(MockProvider::new_echo(requests.clone()));
1762 let config = CompactionConfig::default()
1763 .with_retain_recent(2)
1764 .with_min_messages(1);
1765 let compactor = LlmContextCompactor::new(provider, config);
1766
1767 let epoch1 = vec![
1768 Message::user(EPOCH1_FACT),
1769 Message::assistant("Understood, noted the secrets path."),
1770 Message::user("Now add error handling to main.rs."),
1771 Message::assistant("Added error handling to main.rs."),
1772 Message::user("latest user message one"),
1773 Message::assistant("latest assistant message two"),
1774 ];
1775
1776 let first = compactor.compact_history(epoch1).await?;
1777 assert!(
1778 first
1779 .messages
1780 .iter()
1781 .any(|m| message_contains(m, "EPOCH1_FACT")),
1782 "epoch-1 fact must be captured in the first summary"
1783 );
1784
1785 let mut epoch2 = first.messages;
1787 epoch2.push(Message::user("Another later turn."));
1788 epoch2.push(Message::assistant("Reply to the later turn."));
1789 epoch2.push(Message::user("Final turn a."));
1790 epoch2.push(Message::assistant("Final turn b."));
1791
1792 let second = compactor.compact_history(epoch2).await?;
1793
1794 assert!(
1795 second
1796 .messages
1797 .iter()
1798 .any(|m| message_contains(m, "EPOCH1_FACT")),
1799 "epoch-1 fact must survive the second compaction"
1800 );
1801
1802 let recorded = requests
1805 .lock()
1806 .map_err(|_| anyhow::anyhow!("request log poisoned"))?;
1807 assert!(
1808 recorded.iter().any(|req| req.contains("EPOCH1_FACT")),
1809 "prior summary carrying the epoch-1 fact must reach the summarizer"
1810 );
1811 drop(recorded);
1812
1813 Ok(())
1814 }
1815
1816 #[tokio::test]
1817 async fn test_compact_history_long_tool_chain_respects_token_cap() -> Result<()> {
1818 let provider = Arc::new(MockProvider::new("Summary of the early tool chain."));
1824 let cap = 20_000;
1825 let config = CompactionConfig::default()
1828 .with_retain_recent(18)
1829 .with_min_messages(1)
1830 .with_threshold_tokens(1)
1831 .with_max_retained_tail_tokens(cap);
1832 let compactor = LlmContextCompactor::new(provider, config);
1833
1834 let mut messages = Vec::new();
1837 for i in 0..10 {
1838 messages.push(Message {
1839 role: Role::Assistant,
1840 content: Content::Blocks(vec![ContentBlock::ToolUse {
1841 id: format!("tool_{i}"),
1842 name: "run".to_string(),
1843 input: serde_json::json!({ "arg": "y".repeat(12_000) }),
1844 thought_signature: None,
1845 }]),
1846 });
1847 messages.push(Message {
1848 role: Role::User,
1849 content: Content::Blocks(vec![ContentBlock::ToolResult {
1850 tool_use_id: format!("tool_{i}"),
1851 content: format!("result-{i}: {}", "z".repeat(12_000)),
1852 is_error: None,
1853 }]),
1854 });
1855 }
1856
1857 let full_tokens = TokenEstimator::estimate_history(&messages);
1858 assert!(
1859 full_tokens > cap * 2,
1860 "test setup: full chain must far exceed the cap"
1861 );
1862
1863 let result = compactor.compact_history(messages).await?;
1864
1865 let retained_tail = &result.messages[2..];
1868
1869 let tail_tokens = TokenEstimator::estimate_history(retained_tail);
1870 assert!(
1873 tail_tokens <= cap + 8_000,
1874 "retained tail {tail_tokens} should be bounded by the cap {cap}, not the whole chain"
1875 );
1876 assert!(
1877 retained_tail.len() < 20,
1878 "compaction must have summarized part of the chain"
1879 );
1880
1881 Ok(())
1882 }
1883
1884 #[tokio::test]
1885 async fn test_compact_warns_and_marks_truncated_summary() -> Result<()> {
1886 let requests = Arc::new(Mutex::new(Vec::new()));
1891 let provider = Arc::new(MockProvider::new_truncating(
1892 "partial summary cut off mid-",
1893 requests.clone(),
1894 ));
1895 let config = CompactionConfig::default().with_min_messages(1);
1896 let compactor = LlmContextCompactor::new(provider, config);
1897
1898 let messages = vec![
1899 Message::user("Some content that needs summarizing."),
1900 Message::assistant("More content to summarize here."),
1901 ];
1902
1903 let summary = compactor.compact(&messages).await?;
1904
1905 assert!(
1906 summary.contains("[summary truncated"),
1907 "a persistently truncated summary must carry a truncation marker"
1908 );
1909
1910 let recorded = requests
1912 .lock()
1913 .map_err(|_| anyhow::anyhow!("request log poisoned"))?;
1914 assert_eq!(recorded.len(), 2, "truncation should trigger one retry");
1915 drop(recorded);
1916
1917 Ok(())
1918 }
1919
1920 fn summarizable_messages() -> Vec<Message> {
1923 vec![
1924 Message::user("First question with enough words to summarize meaningfully."),
1925 Message::assistant("First answer, also carrying plenty of prose to compact."),
1926 Message::user("Second question continuing the earlier conversation topic."),
1927 Message::assistant("Second answer expanding on the topic at some length."),
1928 Message::user("Third question?"),
1929 Message::assistant("Third answer."),
1930 ]
1931 }
1932
1933 struct BlockRequestHooks;
1934
1935 #[async_trait]
1936 impl crate::hooks::AgentHooks for BlockRequestHooks {
1937 async fn pre_llm_request(&self, _request: &ChatRequest) -> RequestDecision {
1938 RequestDecision::Block("summaries are not allowed".to_string())
1939 }
1940 }
1941
1942 struct ModifyRequestHooks;
1943
1944 #[async_trait]
1945 impl crate::hooks::AgentHooks for ModifyRequestHooks {
1946 async fn pre_llm_request(&self, request: &ChatRequest) -> RequestDecision {
1947 let mut modified = request.clone();
1948 modified.messages = vec![Message::user("MODIFIED_SUMMARY_PROMPT")];
1949 RequestDecision::Modify(Box::new(modified))
1950 }
1951 }
1952
1953 struct BlockResponseHooks;
1954
1955 #[async_trait]
1956 impl crate::hooks::AgentHooks for BlockResponseHooks {
1957 async fn on_llm_response(&self, _response: &ChatResponse) -> ResponseDecision {
1958 ResponseDecision::Block("summary leaks a secret".to_string())
1959 }
1960 }
1961
1962 struct RetryResponseHooks;
1963
1964 #[async_trait]
1965 impl crate::hooks::AgentHooks for RetryResponseHooks {
1966 async fn on_llm_response(&self, _response: &ChatResponse) -> ResponseDecision {
1967 ResponseDecision::RetryWithFeedback("try harder".to_string())
1968 }
1969 }
1970
1971 #[tokio::test]
1972 async fn blocking_request_hook_aborts_compaction_before_the_llm_call() -> Result<()> {
1973 let requests = Arc::new(Mutex::new(Vec::new()));
1974 let provider = Arc::new(MockProvider::new_with_request_log(
1975 "summary",
1976 requests.clone(),
1977 ));
1978 let config = CompactionConfig::default().with_retain_recent(2);
1979 let compactor = LlmContextCompactor::new(provider, config)
1980 .with_guardrail_hooks(Arc::new(BlockRequestHooks));
1981
1982 let error = match compactor.compact_history(summarizable_messages()).await {
1983 Ok(result) => anyhow::bail!("blocked compaction must not succeed: {result:?}"),
1984 Err(error) => error,
1985 };
1986 assert!(
1987 error.to_string().contains("blocked by guardrail"),
1988 "unexpected error: {error}"
1989 );
1990
1991 let recorded = requests
1992 .lock()
1993 .map_err(|_| anyhow::anyhow!("request log poisoned"))?;
1994 assert!(
1995 recorded.is_empty(),
1996 "a blocked request must never reach the provider"
1997 );
1998 drop(recorded);
1999 Ok(())
2000 }
2001
2002 #[tokio::test]
2003 async fn modify_request_hook_reaches_the_provider() -> Result<()> {
2004 let requests = Arc::new(Mutex::new(Vec::new()));
2005 let provider = Arc::new(MockProvider::new_with_request_log(
2006 "summary",
2007 requests.clone(),
2008 ));
2009 let config = CompactionConfig::default().with_retain_recent(2);
2010 let compactor = LlmContextCompactor::new(provider, config)
2011 .with_guardrail_hooks(Arc::new(ModifyRequestHooks));
2012
2013 let result = compactor.compact_history(summarizable_messages()).await?;
2014 assert!(result.new_count < result.original_count);
2015
2016 let recorded = requests
2017 .lock()
2018 .map_err(|_| anyhow::anyhow!("request log poisoned"))?;
2019 assert_eq!(
2020 recorded.as_slice(),
2021 ["MODIFIED_SUMMARY_PROMPT"],
2022 "the provider must receive the hook-modified request"
2023 );
2024 drop(recorded);
2025 Ok(())
2026 }
2027
2028 #[tokio::test]
2029 async fn blocked_response_aborts_compaction() -> Result<()> {
2030 let provider = Arc::new(MockProvider::new("a summary that leaks the secret"));
2031 let config = CompactionConfig::default().with_retain_recent(2);
2032 let compactor = LlmContextCompactor::new(provider, config)
2033 .with_guardrail_hooks(Arc::new(BlockResponseHooks));
2034
2035 let error = match compactor.compact_history(summarizable_messages()).await {
2036 Ok(result) => anyhow::bail!("blocked summary must not be returned: {result:?}"),
2037 Err(error) => error,
2038 };
2039 assert!(
2040 error.to_string().contains("blocked by guardrail"),
2041 "unexpected error: {error}"
2042 );
2043 Ok(())
2044 }
2045
2046 #[tokio::test]
2047 async fn retry_with_feedback_response_aborts_compaction_without_retrying() -> Result<()> {
2048 let requests = Arc::new(Mutex::new(Vec::new()));
2049 let provider = Arc::new(MockProvider::new_with_request_log(
2050 "summary",
2051 requests.clone(),
2052 ));
2053 let config = CompactionConfig::default().with_retain_recent(2);
2054 let compactor = LlmContextCompactor::new(provider, config)
2055 .with_guardrail_hooks(Arc::new(RetryResponseHooks));
2056
2057 let error = match compactor.compact_history(summarizable_messages()).await {
2058 Ok(result) => anyhow::bail!("rejected summary must not be returned: {result:?}"),
2059 Err(error) => error,
2060 };
2061 assert!(
2062 error.to_string().contains("not retried during compaction"),
2063 "unexpected error: {error}"
2064 );
2065
2066 let recorded = requests
2068 .lock()
2069 .map_err(|_| anyhow::anyhow!("request log poisoned"))?;
2070 assert_eq!(recorded.len(), 1, "RetryWithFeedback must not retry");
2071 drop(recorded);
2072 Ok(())
2073 }
2074
2075 #[tokio::test]
2076 async fn compact_history_reports_summarization_usage() -> Result<()> {
2077 let provider = Arc::new(MockProvider::new("summary"));
2078 let config = CompactionConfig::default().with_retain_recent(2);
2079 let compactor = LlmContextCompactor::new(provider, config);
2080
2081 let result = compactor.compact_history(summarizable_messages()).await?;
2082 assert_eq!(result.llm_usage.input_tokens, 100);
2084 assert_eq!(result.llm_usage.output_tokens, 50);
2085 Ok(())
2086 }
2087}