1use std::collections::hash_map::DefaultHasher;
14use std::hash::{Hash, Hasher};
15use std::sync::{Arc, Mutex};
16
17use agent_base::{
18 AgentResult, AgentRuntime, ChatMessage, ContextWindowManager, SessionId, StreamClient,
19};
20
21use crate::compression::config::CompressionConfig;
22use crate::compression::events::{CompressionEvent, CompressionTrigger};
23use crate::compression::filter::{SUMMARY_PREFIX, is_summary_message, split_system_prompt};
24use crate::compression::summarizer::summarize;
25
26type CompactionCache = std::collections::HashMap<(u64, u64), (usize, String)>;
33
34pub struct ContextCompactor {
44 client: Arc<dyn StreamClient>,
45 config: CompressionConfig,
46 cache: Arc<Mutex<CompactionCache>>,
49}
50
51#[allow(missing_docs)]
52impl ContextCompactor {
53 pub fn new(client: Arc<dyn StreamClient>, config: CompressionConfig) -> Self {
54 Self {
55 client,
56 config,
57 cache: Arc::new(Mutex::new(std::collections::HashMap::new())),
58 }
59 }
60
61 pub fn clone_handle(&self) -> Self {
66 Self {
67 client: self.client.clone(),
68 config: self.config.clone(),
69 cache: self.cache.clone(),
70 }
71 }
72
73 pub fn config(&self) -> &CompressionConfig {
74 &self.config
75 }
76
77 const MAX_PRESERVED_USER_TOKENS: usize = 20_000;
86
87 pub async fn compact(
106 &self,
107 session_id: u64,
108 messages: &[ChatMessage],
109 trigger: CompressionTrigger,
110 emit_fn: Option<&(dyn Fn(CompressionEvent) + Sync)>,
111 ) -> AgentResult<Option<Vec<ChatMessage>>> {
112 let t0 = std::time::Instant::now();
113 if !self.config.enabled {
114 return Ok(None);
115 }
116
117 let total_tokens: usize = messages.iter().map(estimate_message_tokens).sum();
118 tracing::info!(
119 total_tokens,
120 trigger = self.config.trigger_tokens,
121 "[compact-timing] enter compact()"
122 );
123 if total_tokens <= self.config.trigger_tokens {
124 return Ok(None);
125 }
126
127 let (system_msgs, conversation) = split_system_prompt(messages);
129 tracing::info!(
130 elapsed_ms = t0.elapsed().as_millis() as u64,
131 conv_len = conversation.len(),
132 keep = self.config.keep_recent_messages,
133 "[compact-timing] after split_system_prompt"
134 );
135 if conversation.len() <= self.config.keep_recent_messages + 1 {
136 return Ok(None);
137 }
138
139 let mut recent_start = conversation.len() - self.config.keep_recent_messages;
141 recent_start = safe_cut_index(conversation, 0, recent_start);
142 tracing::info!(
143 elapsed_ms = t0.elapsed().as_millis() as u64,
144 recent_start,
145 "[compact-timing] after split old/recent"
146 );
147 if recent_start == 0 {
148 return Ok(None);
149 }
150
151 let old = &conversation[..recent_start];
154 let recent = &conversation[recent_start..];
155
156 if matches!(old.first(), Some(ChatMessage::Tool { .. })) {
158 tracing::warn!("context compression skipped: old block starts with a tool result");
159 return Ok(None);
160 }
161
162 let old: Vec<&ChatMessage> = old.iter().filter(|m| !is_summary_message(m)).collect();
164 let recent: Vec<&ChatMessage> = recent.iter().filter(|m| !is_summary_message(m)).collect();
165
166 let original_goal = old
168 .iter()
169 .find_map(|m| match m {
170 ChatMessage::User { content, .. } if !content.starts_with(SUMMARY_PREFIX) => {
171 Some(content.as_str())
172 }
173 _ => None,
174 })
175 .unwrap_or("(unknown goal)");
176 let original_goal = truncate_str(original_goal, 400);
177
178 let preserved_user_msgs: Vec<&ChatMessage> = old
181 .iter()
182 .filter(|m| matches!(m, ChatMessage::User { .. }))
183 .copied()
184 .collect();
185 let assistant_tool_msgs: Vec<&ChatMessage> = old
186 .iter()
187 .filter(|m| !matches!(m, ChatMessage::User { .. }))
188 .copied()
189 .collect();
190
191 let transcript = truncate_to_chars(&assistant_tool_msgs, self.config.max_transcript_chars);
193 if transcript.trim().is_empty() && preserved_user_msgs.is_empty() {
194 return Ok(None);
195 }
196
197 let msg_count = messages.len();
199 if let Some(emit) = emit_fn {
200 emit(CompressionEvent::Preparing {
201 session_id,
202 tokens_before: total_tokens,
203 msg_count,
204 trigger: trigger.clone(),
205 });
206 emit(CompressionEvent::Started {
207 session_id,
208 tokens_before: total_tokens,
209 msg_count,
210 trigger: trigger.clone(),
211 });
212 }
213
214 const CACHE_PREFIX_CHARS: usize = 4096;
215 let prefix: String = transcript.chars().take(CACHE_PREFIX_CHARS).collect();
216 let key = (session_id, hash_str(&prefix));
217
218 let cached = self.cache.lock().ok().and_then(|c| c.get(&key).cloned());
219
220 let cache_effective = matches!(cached, Some((cl, _)) if cl == transcript.len() || transcript.len() >= self.config.max_transcript_chars);
224
225 let summary = if transcript.trim().is_empty() {
226 String::new()
228 } else {
229 match cached {
230 Some((_, ref s)) if cache_effective => s.clone(),
231 _ => {
232 if let Some(emit) = emit_fn {
234 emit(CompressionEvent::Progress {
235 session_id,
236 chars: 0,
237 });
238 }
239 tracing::info!(
240 elapsed_ms = t0.elapsed().as_millis() as u64,
241 "[compact-timing] before summarize() LLM call"
242 );
243 let s = if let Some(emit) = emit_fn {
246 let progress_fn = |chars: usize| {
247 emit(CompressionEvent::Progress { session_id, chars });
248 };
249 summarize(
250 self.client.as_ref(),
251 &transcript,
252 &original_goal,
253 self.config.max_summary_chars,
254 Some(&progress_fn),
255 )
256 .await
257 } else {
258 summarize(
259 self.client.as_ref(),
260 &transcript,
261 &original_goal,
262 self.config.max_summary_chars,
263 None,
264 )
265 .await
266 };
267 let s = match s {
268 Ok(s) => {
269 tracing::info!(
270 elapsed_ms = t0.elapsed().as_millis() as u64,
271 "[compact-timing] summarize() returned Ok"
272 );
273 s
274 }
275 Err(e) => {
276 tracing::warn!(
277 session_id,
278 "summarisation failed, falling back to dropping old block: {e}"
279 );
280 String::new()
281 }
282 };
283 if !s.is_empty()
284 && let Ok(mut cache) = self.cache.lock()
285 {
286 cache.insert(key, (transcript.len(), s.clone()));
287 }
288 s
289 }
290 }
291 };
292
293 let mut new_messages: Vec<ChatMessage> = system_msgs.to_vec();
297
298 let truncated_user =
300 truncate_user_messages(&preserved_user_msgs, Self::MAX_PRESERVED_USER_TOKENS);
301
302 let trimmed = summary.trim();
303
304 let old_block_chars: usize = old.iter().map(|m| message_content_len(m)).sum();
310 let replacement_chars: usize = truncated_user
311 .iter()
312 .map(|m| message_content_len(m))
313 .sum::<usize>()
314 + if trimmed.is_empty() {
315 0
316 } else {
317 SUMMARY_PREFIX.len() + 1 + trimmed.len()
318 };
319 if replacement_chars >= old_block_chars {
320 tracing::info!(
321 session_id,
322 old_block_chars,
323 replacement_chars,
324 "[compact-timing] replacement larger than original, skipping compression"
325 );
326 return Ok(None);
327 }
328
329 new_messages.extend(truncated_user.into_iter().cloned());
330
331 if !trimmed.is_empty() {
332 new_messages.push(ChatMessage::user(format!("{SUMMARY_PREFIX}\n{trimmed}")));
333 }
334 new_messages.extend(recent.into_iter().cloned());
335
336 let compressed_tokens: usize = new_messages.iter().map(estimate_message_tokens).sum();
338 tracing::info!(
339 session_id,
340 tokens_before = total_tokens,
341 tokens_after = compressed_tokens,
342 kept_recent = self.config.keep_recent_messages,
343 summary_len = trimmed.len(),
344 preserved_user_count = preserved_user_msgs.len(),
345 old_block_chars,
346 replacement_chars,
347 cache_hit = cache_effective,
348 elapsed_ms = t0.elapsed().as_millis() as u64,
349 "[compact-timing] compression complete"
350 );
351
352 Ok(Some(new_messages))
353 }
354
355 pub fn clear_cache(&self) {
359 if let Ok(mut cache) = self.cache.lock() {
360 cache.clear();
361 }
362 }
363
364 pub async fn compact_session(
375 &self,
376 runtime: &AgentRuntime,
377 session_id: &SessionId,
378 emit_fn: Option<std::sync::Arc<dyn Fn(agent_base::UserEvent) + Send + Sync>>,
379 ) -> AgentResult<bool> {
380 let sid = session_id.id;
381 let trigger = CompressionTrigger::Manual;
382
383 let messages = runtime
385 .with_session_mut(session_id, |session| session.chat_messages().to_vec())
386 .await?;
387 let msg_count_before = messages.len();
388 let tokens_before: usize = messages.iter().map(estimate_message_tokens).sum();
389
390 let filtered: Vec<ChatMessage> = messages
393 .iter()
394 .filter(|m| !is_summary_message(m))
395 .cloned()
396 .collect();
397
398 let compressed = if let Some(ref ef) = emit_fn {
400 let ef = ef.clone();
401 self.compact(
402 sid,
403 &filtered,
404 trigger.clone(),
405 Some(&move |ev: CompressionEvent| {
406 ef(ev.into_user_event());
407 }),
408 )
409 .await?
410 } else {
411 self.compact(sid, &filtered, trigger.clone(), None).await?
412 };
413
414 let compressed = match compressed {
415 Some(msgs) => msgs,
416 None => {
417 return Ok(false);
419 }
420 };
421
422 let tokens_after: usize = compressed.iter().map(estimate_message_tokens).sum();
423 let msg_count_after = compressed.len();
424
425 runtime
428 .with_session_mut(session_id, |session| {
429 let msg_count_now = session.chat_messages().len();
430 if msg_count_now != msg_count_before {
431 return Err(agent_base::AgentError::internal(format!(
432 "session modified concurrently ({} → {} messages), aborting write-back",
433 msg_count_before, msg_count_now
434 )));
435 }
436 session.set_chat_messages(compressed).map_err(|e| {
437 agent_base::AgentError::internal(format!(
438 "set_chat_messages validation failed: {e}"
439 ))
440 })
441 })
442 .await??;
443
444 let reduction_pct = if tokens_before > 0 {
446 ((tokens_before as f64 - tokens_after as f64) / tokens_before as f64 * 100.0).round()
447 as i32
448 } else {
449 0
450 };
451 if let Some(ref f) = emit_fn {
452 f(CompressionEvent::Completed {
453 session_id: sid,
454 tokens_before,
455 tokens_after,
456 reduction_pct,
457 msg_count_before,
458 msg_count_after,
459 trigger,
460 }
461 .into_user_event());
462 }
463
464 Ok(true)
465 }
466}
467
468fn message_content_len(msg: &ChatMessage) -> usize {
475 match msg {
476 ChatMessage::System { content, .. } => content.len(),
477 ChatMessage::User { content, .. } => content.len(),
478 ChatMessage::Assistant {
479 content,
480 reasoning_content,
481 tool_calls,
482 } => {
483 let mut len = content.as_deref().map(|c| c.len()).unwrap_or(0);
484 if let Some(rc) = reasoning_content {
485 len += rc.len();
486 }
487 if let Some(calls) = tool_calls {
488 for c in calls {
489 len += c.id.len() + c.name.len() + c.arguments.len();
490 }
491 }
492 len
493 }
494 ChatMessage::Tool {
495 tool_call_id,
496 content,
497 } => tool_call_id.len() + content.len(),
498 ChatMessage::Custom { role, data } => role.len() + data.to_string().len(),
499 }
500}
501
502pub fn safe_cut_index(messages: &[ChatMessage], start: usize, mut cut: usize) -> usize {
512 while cut > start {
513 let left_is_tool_call = matches!(
514 messages[cut - 1],
515 ChatMessage::Assistant {
516 tool_calls: Some(_),
517 ..
518 }
519 );
520 let right_is_tool = matches!(messages[cut], ChatMessage::Tool { .. });
521 if !left_is_tool_call && !right_is_tool {
522 break;
523 }
524 cut -= 1;
525 }
526 cut
527}
528
529pub(crate) fn estimate_message_tokens(msg: &ChatMessage) -> usize {
534 match msg {
535 ChatMessage::System { content, .. } => ContextWindowManager::estimate_tokens(content),
536 ChatMessage::User {
537 content, images, ..
538 } => ContextWindowManager::estimate_tokens(content) + images.len() * 85,
539 ChatMessage::Assistant {
540 content,
541 reasoning_content,
542 tool_calls,
543 } => {
544 let mut tokens = content
545 .as_deref()
546 .map(ContextWindowManager::estimate_tokens)
547 .unwrap_or(0);
548 if let Some(rc) = reasoning_content {
549 tokens += ContextWindowManager::estimate_tokens(rc);
550 }
551 if let Some(calls) = tool_calls {
552 for c in calls {
553 tokens += ContextWindowManager::estimate_tokens(&c.id);
554 tokens += ContextWindowManager::estimate_tokens(&c.name);
555 tokens += ContextWindowManager::estimate_tokens(&c.arguments);
556 }
557 }
558 tokens
559 }
560 ChatMessage::Tool {
561 tool_call_id,
562 content,
563 } => {
564 ContextWindowManager::estimate_tokens(tool_call_id)
565 + ContextWindowManager::estimate_tokens(content)
566 }
567 ChatMessage::Custom { role, data } => {
568 ContextWindowManager::estimate_tokens(role)
569 + ContextWindowManager::estimate_tokens(&data.to_string())
570 }
571 }
572}
573
574#[allow(dead_code)]
576fn estimate_total_tokens(messages: &[ChatMessage]) -> usize {
577 messages.iter().map(estimate_message_tokens).sum()
578}
579
580fn hash_str(s: &str) -> u64 {
582 let mut h = DefaultHasher::new();
583 s.hash(&mut h);
584 h.finish()
585}
586
587pub fn serialize_block(messages: &[&ChatMessage], max_chars: usize) -> String {
592 let mut parts: Vec<String> = Vec::with_capacity(messages.len());
593 for msg in messages {
594 let line = match msg {
595 ChatMessage::System { content, .. } => {
596 format!("[system] {}", truncate_str(content, 400))
597 }
598 ChatMessage::User { content, .. } => format!("[user] {}", truncate_str(content, 400)),
599 ChatMessage::Assistant {
600 content,
601 tool_calls,
602 ..
603 } => match tool_calls {
604 Some(calls) if !calls.is_empty() => {
605 let calls: Vec<String> = calls
606 .iter()
607 .map(|c| format!("{}({})", c.name, truncate_str(&c.arguments, 150)))
608 .collect();
609 format!("[assistant tool_call] {}", calls.join("; "))
610 }
611 _ => format!(
612 "[assistant] {}",
613 content
614 .as_deref()
615 .map(|c| truncate_str(c, 400))
616 .unwrap_or_default()
617 ),
618 },
619 ChatMessage::Tool {
620 tool_call_id,
621 content,
622 } => format!(
623 "[tool:{}] {}",
624 truncate_str(tool_call_id, 50),
625 truncate_str(content, 300)
626 ),
627 ChatMessage::Custom { role, data } => {
628 format!("[custom:{}] {}", role, truncate_str(&data.to_string(), 400))
629 }
630 };
631 parts.push(line);
632 }
633 let joined = parts.join("\n");
634 truncate_str(&joined, max_chars)
635}
636
637pub fn truncate_str(s: &str, max_chars: usize) -> String {
639 let count = s.chars().count();
640 if count <= max_chars {
641 return s.to_string();
642 }
643 let head: String = s.chars().take(max_chars).collect();
644 format!("{head}…")
645}
646
647fn truncate_to_chars(messages: &[&ChatMessage], max_chars: usize) -> String {
649 serialize_block(messages, max_chars)
650}
651
652fn truncate_user_messages<'a>(
658 messages: &[&'a ChatMessage],
659 max_tokens: usize,
660) -> Vec<&'a ChatMessage> {
661 if max_tokens == 0 {
662 return Vec::new();
663 }
664 let mut result: Vec<&ChatMessage> = Vec::with_capacity(messages.len());
665 let mut remaining = max_tokens;
666 for msg in messages.iter().rev() {
668 let tokens = estimate_message_tokens(msg);
669 if tokens <= remaining {
670 result.push(msg);
671 remaining -= tokens;
672 }
673 }
675 result.reverse();
676 result
677}
678
679#[cfg(test)]
682mod tests {
683 use super::*;
684 use agent_base::{AgentResult, ResponseFormat, ToolCallMessage};
685
686 struct MockClient(&'static str);
690
691 #[async_trait::async_trait]
692 impl StreamClient for MockClient {
693 async fn stream(
694 &self,
695 _messages: &[ChatMessage],
696 _tools: &[serde_json::Value],
697 _reasoning: Option<&agent_base::ReasoningConfig>,
698 _response_format: Option<&ResponseFormat>,
699 ) -> AgentResult<
700 std::pin::Pin<
701 Box<dyn futures_core::Stream<Item = AgentResult<agent_base::StreamChunk>> + Send>,
702 >,
703 > {
704 let response = self.0.to_string();
705 Ok(Box::pin(futures_util::stream::once(async move {
706 Ok(agent_base::StreamChunk::Text(response))
707 })))
708 }
709
710 async fn chat(
711 &self,
712 _messages: &[ChatMessage],
713 _tools: &[serde_json::Value],
714 _reasoning: Option<&agent_base::ReasoningConfig>,
715 _response_format: Option<&ResponseFormat>,
716 ) -> AgentResult<String> {
717 Ok(self.0.to_string())
718 }
719
720 fn capabilities(&self) -> agent_base::LlmCapabilities {
721 agent_base::LlmCapabilities::default()
722 }
723 }
724
725 struct CountingClient {
727 response: &'static str,
728 calls: std::sync::Arc<std::sync::atomic::AtomicUsize>,
729 }
730
731 #[async_trait::async_trait]
732 impl StreamClient for CountingClient {
733 async fn stream(
734 &self,
735 _messages: &[ChatMessage],
736 _tools: &[serde_json::Value],
737 _reasoning: Option<&agent_base::ReasoningConfig>,
738 _response_format: Option<&ResponseFormat>,
739 ) -> AgentResult<
740 std::pin::Pin<
741 Box<dyn futures_core::Stream<Item = AgentResult<agent_base::StreamChunk>> + Send>,
742 >,
743 > {
744 self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
745 let response = self.response.to_string();
746 Ok(Box::pin(futures_util::stream::once(async move {
747 Ok(agent_base::StreamChunk::Text(response))
748 })))
749 }
750
751 async fn chat(
752 &self,
753 _messages: &[ChatMessage],
754 _tools: &[serde_json::Value],
755 _reasoning: Option<&agent_base::ReasoningConfig>,
756 _response_format: Option<&ResponseFormat>,
757 ) -> AgentResult<String> {
758 self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
759 Ok(self.response.to_string())
760 }
761
762 fn capabilities(&self) -> agent_base::LlmCapabilities {
763 agent_base::LlmCapabilities::default()
764 }
765 }
766
767 struct FailingClient;
769
770 #[async_trait::async_trait]
771 impl StreamClient for FailingClient {
772 async fn stream(
773 &self,
774 _messages: &[ChatMessage],
775 _tools: &[serde_json::Value],
776 _reasoning: Option<&agent_base::ReasoningConfig>,
777 _response_format: Option<&ResponseFormat>,
778 ) -> AgentResult<
779 std::pin::Pin<
780 Box<dyn futures_core::Stream<Item = AgentResult<agent_base::StreamChunk>> + Send>,
781 >,
782 > {
783 Err(agent_base::AgentError::llm("summarisation failed"))
784 }
785
786 async fn chat(
787 &self,
788 _messages: &[ChatMessage],
789 _tools: &[serde_json::Value],
790 _reasoning: Option<&agent_base::ReasoningConfig>,
791 _response_format: Option<&ResponseFormat>,
792 ) -> AgentResult<String> {
793 Err(agent_base::AgentError::llm("summarisation failed"))
794 }
795
796 fn capabilities(&self) -> agent_base::LlmCapabilities {
797 agent_base::LlmCapabilities::default()
798 }
799 }
800
801 fn make_messages(count: usize) -> Vec<ChatMessage> {
802 let mut msgs = vec![ChatMessage::system("You are a test agent.")];
803 for i in 0..count {
804 msgs.push(ChatMessage::user(format!("question {i}")));
805 msgs.push(ChatMessage::assistant(format!(
806 "answer {i} with some extra content to make it longer"
807 )));
808 }
809 msgs
810 }
811
812 #[test]
815 fn test_safe_cut_never_splits_tool_pair() {
816 let msgs = vec![
817 ChatMessage::user("hi"),
818 ChatMessage::assistant(""),
819 ChatMessage::assistant_tool_call("call_1", "bash", "{}"),
820 ChatMessage::tool("call_1", "result"),
821 ChatMessage::user("next"),
822 ];
823 let cut = super::safe_cut_index(&msgs, 0, 3);
826 assert!(
827 cut <= 2,
828 "cut should walk back before assistant{{tool_call}}"
829 );
830 assert!(
831 !matches!(
832 &msgs[cut - 1],
833 ChatMessage::Assistant {
834 tool_calls: Some(_),
835 ..
836 }
837 ),
838 "left boundary must not be assistant with pending tool calls"
839 );
840 }
841
842 #[test]
843 fn test_safe_cut_prefers_given_boundary_when_safe() {
844 let msgs = vec![
845 ChatMessage::user("hi"),
846 ChatMessage::assistant("hello"),
847 ChatMessage::user("next"),
848 ChatMessage::assistant("world"),
849 ];
850 assert_eq!(super::safe_cut_index(&msgs, 0, 2), 2);
852 }
853
854 #[test]
855 fn test_safe_cut_walks_back_from_orphan_tool() {
856 let msgs = vec![
857 ChatMessage::user("a"),
858 ChatMessage::assistant("b"),
859 ChatMessage::tool("c1", "r1"),
860 ChatMessage::user("d"),
861 ];
862 assert_eq!(super::safe_cut_index(&msgs, 0, 1), 1);
864 }
865
866 #[test]
869 fn test_estimate_tokens_cjk_and_tool() {
870 let msg = ChatMessage::tool("t1", "你好世界");
871 let tokens = super::estimate_message_tokens(&msg);
872 assert!((2..=10).contains(&tokens));
874 }
875
876 #[test]
877 fn test_estimate_tokens_user_with_images() {
878 let msg = ChatMessage::user_with_images(
879 "describe this",
880 vec![agent_base::ImageAttachment::Url {
881 url: "http://example.com/img.png".into(),
882 detail: Some(agent_base::ImageDetail::Auto),
883 }],
884 );
885 let tokens = super::estimate_message_tokens(&msg);
886 assert!(tokens >= 85);
888 }
889
890 #[test]
893 fn test_serialize_block_preserves_tool_calls() {
894 let msgs = [
895 ChatMessage::assistant(""),
896 ChatMessage::tool("c1", "result"),
897 ];
898 let msg_refs: Vec<&ChatMessage> = msgs.iter().collect();
899 let out = super::serialize_block(&msg_refs, 2000);
900 assert!(out.contains("[tool:c1]"), "got: {out}");
901 assert!(out.contains("result"), "got: {out}");
902 }
903
904 #[test]
905 fn test_serialize_block_truncates_oversized_fields() {
906 let long_arg = "x".repeat(500);
907 let msgs = [ChatMessage::Assistant {
908 content: None,
909 reasoning_content: None,
910 tool_calls: Some(vec![ToolCallMessage {
911 id: "t1".into(),
912 name: "bash".into(),
913 arguments: long_arg,
914 }]),
915 }];
916 let msg_refs: Vec<&ChatMessage> = msgs.iter().collect();
917 let out = super::serialize_block(&msg_refs, 2000);
918 assert!(out.contains("bash("));
920 assert!(out.contains("…"));
921 assert!(out.len() < 400);
922 }
923
924 #[tokio::test]
927 async fn test_compact_noop_when_disabled() {
928 let config = CompressionConfig::default().with_enabled(false);
929 let client = std::sync::Arc::new(MockClient("summary"));
930 let compactor = ContextCompactor::new(client, config);
931 let msgs = make_messages(100);
932 let result = compactor
933 .compact(1, &msgs, CompressionTrigger::Auto, None)
934 .await
935 .unwrap();
936 assert!(result.is_none());
937 }
938
939 #[tokio::test]
940 async fn test_compact_noop_when_below_threshold() {
941 let config = CompressionConfig::default().with_trigger_tokens(999_999);
942 let client = std::sync::Arc::new(MockClient("summary"));
943 let compactor = ContextCompactor::new(client, config);
944 let msgs = make_messages(10);
945 let result = compactor
946 .compact(1, &msgs, CompressionTrigger::Auto, None)
947 .await
948 .unwrap();
949 assert!(result.is_none());
950 }
951
952 #[tokio::test]
953 async fn test_compact_produces_valid_output() {
954 let config = CompressionConfig::default()
955 .with_trigger_tokens(1) .with_keep_recent_messages(4);
957 let client = std::sync::Arc::new(MockClient("test summary text"));
958 let compactor = ContextCompactor::new(client, config);
959 let msgs = make_messages(20);
960 let result = compactor
961 .compact(1, &msgs, CompressionTrigger::Auto, None)
962 .await
963 .unwrap()
964 .unwrap();
965
966 assert!(matches!(&result[0], ChatMessage::System { .. }));
968
969 let has_summary = result.iter().any(|m| match m {
971 ChatMessage::User { content, .. } => content.starts_with(SUMMARY_PREFIX),
972 _ => false,
973 });
974 assert!(has_summary, "expected summary message in output");
975
976 let last = result.last().unwrap();
978 assert!(matches!(
979 last,
980 ChatMessage::User { .. } | ChatMessage::Assistant { .. }
981 ));
982
983 for (i, msg) in result.iter().enumerate() {
985 assert!(
986 !matches!(msg, ChatMessage::Tool { .. }) || i > 0,
987 "orphan Tool at index 0"
988 );
989 }
990 }
991
992 #[tokio::test]
995 async fn test_compact_fallback_on_summarisation_failure() {
996 let config = CompressionConfig::default()
997 .with_trigger_tokens(1)
998 .with_keep_recent_messages(4);
999 let client = std::sync::Arc::new(FailingClient);
1000 let compactor = ContextCompactor::new(client, config);
1001 let msgs = make_messages(20);
1002 let result = compactor
1003 .compact(1, &msgs, CompressionTrigger::Auto, None)
1004 .await
1005 .unwrap()
1006 .unwrap();
1007
1008 assert!(matches!(&result[0], ChatMessage::System { .. }));
1010 let has_summary = result.iter().any(|m| match m {
1011 ChatMessage::User { content, .. } => content.starts_with(SUMMARY_PREFIX),
1012 _ => false,
1013 });
1014 assert!(!has_summary, "should have no summary on failure");
1015 }
1016
1017 #[tokio::test]
1020 async fn test_compact_caches_summary() {
1021 let config = CompressionConfig::default()
1022 .with_trigger_tokens(1)
1023 .with_keep_recent_messages(4);
1024 let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1025 let client = std::sync::Arc::new(CountingClient {
1026 response: "cached summary",
1027 calls: calls.clone(),
1028 });
1029 let compactor = ContextCompactor::new(client, config);
1030 let msgs = make_messages(20);
1031
1032 let _ = compactor
1034 .compact(1, &msgs, CompressionTrigger::Auto, None)
1035 .await
1036 .unwrap();
1037 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
1038
1039 let _ = compactor
1041 .compact(1, &msgs, CompressionTrigger::Auto, None)
1042 .await
1043 .unwrap();
1044 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
1045
1046 let mut grown = msgs.clone();
1048 grown.push(ChatMessage::user("new question"));
1049 grown.push(ChatMessage::assistant("new answer"));
1050 let _ = compactor
1051 .compact(1, &grown, CompressionTrigger::Auto, None)
1052 .await
1053 .unwrap();
1054 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2);
1055 }
1056
1057 #[tokio::test]
1060 async fn test_compact_cache_length_guard_re_summarises_on_growth() {
1061 let config = CompressionConfig::default()
1065 .with_trigger_tokens(1)
1066 .with_keep_recent_messages(4)
1067 .with_max_transcript_chars(60_000); let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1069 let client = std::sync::Arc::new(CountingClient {
1070 response: "summary",
1071 calls: calls.clone(),
1072 });
1073 let compactor = ContextCompactor::new(client, config);
1074
1075 let mut msgs = vec![ChatMessage::system("sys")];
1078 for i in 0..200 {
1079 msgs.push(ChatMessage::user(format!("question {i:04}")));
1080 msgs.push(ChatMessage::assistant(format!("answer {i:04}")));
1081 }
1082
1083 let _ = compactor
1085 .compact(1, &msgs, CompressionTrigger::Auto, None)
1086 .await
1087 .unwrap();
1088 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
1089
1090 let _ = compactor
1092 .compact(1, &msgs, CompressionTrigger::Auto, None)
1093 .await
1094 .unwrap();
1095 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
1096
1097 msgs.push(ChatMessage::user("extra question"));
1100 msgs.push(ChatMessage::assistant("extra answer"));
1101 let _ = compactor
1102 .compact(1, &msgs, CompressionTrigger::Auto, None)
1103 .await
1104 .unwrap();
1105 assert_eq!(
1106 calls.load(std::sync::atomic::Ordering::SeqCst),
1107 2,
1108 "length guard should have triggered re-summarisation"
1109 );
1110 }
1111
1112 #[tokio::test]
1115 async fn test_compact_no_orphan_tool_messages() {
1116 let config = CompressionConfig::default()
1117 .with_trigger_tokens(1)
1118 .with_keep_recent_messages(4);
1119 let client = std::sync::Arc::new(MockClient("summary"));
1120 let compactor = ContextCompactor::new(client, config);
1121
1122 let mut msgs = vec![ChatMessage::system("sys")];
1124 for i in 0..10 {
1125 msgs.push(ChatMessage::user(format!("q{i}")));
1126 msgs.push(ChatMessage::assistant_tool_call(
1127 format!("tc{i}"),
1128 "bash",
1129 format!("{{\"cmd\":\"echo {i} with some extra arguments to make it longer\"}}"),
1130 ));
1131 msgs.push(ChatMessage::tool(
1132 format!("tc{i}"),
1133 format!("result {i} with some extra output content"),
1134 ));
1135 }
1136
1137 let result = compactor
1138 .compact(1, &msgs, CompressionTrigger::Auto, None)
1139 .await
1140 .unwrap()
1141 .unwrap();
1142
1143 for (i, msg) in result.iter().enumerate() {
1146 if let ChatMessage::Tool { tool_call_id, .. } = msg {
1147 assert!(i > 0, "Tool message at index 0 has no preceding assistant");
1148 let prev = &result[i - 1];
1149 match prev {
1150 ChatMessage::Assistant {
1151 tool_calls: Some(calls),
1152 ..
1153 } => {
1154 let ids: Vec<&str> = calls.iter().map(|c| c.id.as_str()).collect();
1155 assert!(
1156 ids.contains(&tool_call_id.as_str()),
1157 "Tool {tool_call_id} not referenced by preceding assistant: {ids:?}"
1158 );
1159 }
1160 other => panic!(
1161 "Tool {tool_call_id} at index {i} not preceded by \
1162 Assistant{{tool_calls}}, got: {other:?}"
1163 ),
1164 }
1165 }
1166 }
1167 }
1168
1169 #[tokio::test]
1172 async fn test_compact_noop_empty_messages() {
1173 let config = CompressionConfig::default().with_trigger_tokens(1);
1174 let client = std::sync::Arc::new(MockClient("summary"));
1175 let compactor = ContextCompactor::new(client, config);
1176 let result = compactor
1177 .compact(1, &[], CompressionTrigger::Auto, None)
1178 .await
1179 .unwrap();
1180 assert!(result.is_none());
1181 }
1182
1183 #[tokio::test]
1184 async fn test_compact_noop_system_only() {
1185 let config = CompressionConfig::default().with_trigger_tokens(1);
1186 let client = std::sync::Arc::new(MockClient("summary"));
1187 let compactor = ContextCompactor::new(client, config);
1188 let msgs = vec![ChatMessage::system("You are helpful.")];
1189 let result = compactor
1190 .compact(1, &msgs, CompressionTrigger::Auto, None)
1191 .await
1192 .unwrap();
1193 assert!(result.is_none());
1194 }
1195
1196 #[tokio::test]
1197 async fn test_compact_noop_conversation_equals_keep() {
1198 let config = CompressionConfig::default()
1200 .with_trigger_tokens(1)
1201 .with_keep_recent_messages(100);
1202 let client = std::sync::Arc::new(MockClient("summary"));
1203 let compactor = ContextCompactor::new(client, config);
1204 let msgs = make_messages(3); let result = compactor
1206 .compact(1, &msgs, CompressionTrigger::Auto, None)
1207 .await
1208 .unwrap();
1209 assert!(result.is_none());
1210 }
1211
1212 #[tokio::test]
1213 async fn test_compact_filters_old_summary_in_old_block() {
1214 let config = CompressionConfig::default()
1215 .with_trigger_tokens(1)
1216 .with_keep_recent_messages(4);
1217 let client = std::sync::Arc::new(MockClient("new summary"));
1218 let compactor = ContextCompactor::new(client, config);
1219
1220 let mut msgs = vec![ChatMessage::system("sys")];
1222 msgs.push(ChatMessage::user(format!(
1224 "{SUMMARY_PREFIX}\nprevious summary text"
1225 )));
1226 for i in 0..10 {
1227 msgs.push(ChatMessage::user(format!("question {i}")));
1228 msgs.push(ChatMessage::assistant(format!(
1229 "answer {i} with some extra content to make the old block large enough"
1230 )));
1231 }
1232
1233 let result = compactor
1234 .compact(1, &msgs, CompressionTrigger::Auto, None)
1235 .await
1236 .unwrap()
1237 .unwrap();
1238
1239 let summary_count = result
1241 .iter()
1242 .filter(|m| match m {
1243 ChatMessage::User { content, .. } => content.starts_with(SUMMARY_PREFIX),
1244 _ => false,
1245 })
1246 .count();
1247 assert_eq!(
1248 summary_count, 1,
1249 "should have exactly 1 summary (the new one), old one must be filtered"
1250 );
1251 }
1252
1253 #[tokio::test]
1254 async fn test_compact_filters_old_summary_in_recent_block() {
1255 let config = CompressionConfig::default()
1256 .with_trigger_tokens(1)
1257 .with_keep_recent_messages(4);
1258 let client = std::sync::Arc::new(MockClient("new summary"));
1259 let compactor = ContextCompactor::new(client, config);
1260
1261 let mut msgs = vec![ChatMessage::system("sys")];
1263 for i in 0..5 {
1264 msgs.push(ChatMessage::user(format!("question {i}")));
1265 msgs.push(ChatMessage::assistant(format!(
1266 "answer {i} with some extra content to make the old block large enough"
1267 )));
1268 }
1269 msgs.push(ChatMessage::user(format!(
1271 "{SUMMARY_PREFIX}\nold summary in recent"
1272 )));
1273 for i in 5..10 {
1274 msgs.push(ChatMessage::user(format!("question {i}")));
1275 msgs.push(ChatMessage::assistant(format!(
1276 "answer {i} with some extra content to make the old block large enough"
1277 )));
1278 }
1279
1280 let result = compactor
1281 .compact(1, &msgs, CompressionTrigger::Auto, None)
1282 .await
1283 .unwrap()
1284 .unwrap();
1285
1286 let summary_count = result
1288 .iter()
1289 .filter(|m| match m {
1290 ChatMessage::User { content, .. } => content.starts_with(SUMMARY_PREFIX),
1291 _ => false,
1292 })
1293 .count();
1294 assert_eq!(
1295 summary_count, 1,
1296 "old summary in recent block must be filtered"
1297 );
1298 }
1299
1300 #[tokio::test]
1301 #[ignore] async fn test_compact_with_real_deepseek_api() {
1303 let api_key = std::env::var("DEEPSEEK_API_KEY").unwrap_or_default();
1307 if api_key.is_empty() {
1308 eprintln!("Skipping test: DEEPSEEK_API_KEY not set");
1309 return;
1310 }
1311
1312 let base_url = std::env::var("DEEPSEEK_BASE_URL")
1313 .unwrap_or_else(|_| "https://api.deepseek.com".to_string());
1314
1315 let client: std::sync::Arc<dyn agent_base::StreamClient> = std::sync::Arc::new(
1317 agent_base::OpenAiClient::new(api_key, "deepseek-v4-flash".to_string(), Some(base_url)),
1318 );
1319
1320 let config = CompressionConfig::default()
1321 .with_trigger_tokens(1) .with_keep_recent_messages(4);
1323
1324 let compactor = ContextCompactor::new(client, config);
1325
1326 let msgs = vec![
1328 ChatMessage::system("You are a versatile AI assistant."),
1329 ChatMessage::user("你好".to_string()),
1330 ChatMessage::assistant("你好!有什么我可以帮你的吗?".to_string()),
1331 ChatMessage::user("来一首古诗".to_string()),
1332 ChatMessage::assistant(
1333 "好的,送你一首王维的《山居秋暝》:\n空山新雨后,天气晚来秋。".to_string(),
1334 ),
1335 ChatMessage::user("再来".to_string()),
1336 ChatMessage::assistant("秋江独酌\n清秋江上月,独酌对孤舟。".to_string()),
1337 ChatMessage::user("再来一首".to_string()),
1338 ChatMessage::assistant("夏日山居\n薰风拂面柳丝柔,蝉噪林深夏日幽。".to_string()),
1339 ChatMessage::user("再来".to_string()),
1340 ];
1341
1342 let result = compactor
1343 .compact(1, &msgs, CompressionTrigger::Auto, None)
1344 .await
1345 .unwrap();
1346
1347 match result {
1348 Some(compressed) => {
1349 eprintln!("=== Compact Result ===");
1350 eprintln!("Messages: {} -> {}", msgs.len(), compressed.len());
1351 for (i, msg) in compressed.iter().enumerate() {
1352 match msg {
1353 ChatMessage::User { content, .. } => {
1354 let preview = if content.len() > 100 {
1355 &content[..100]
1356 } else {
1357 content
1358 };
1359 eprintln!("{}: [user] {}", i, preview);
1360 }
1361 ChatMessage::Assistant { content, .. } => {
1362 let text = content.as_deref().unwrap_or("");
1363 let preview = if text.len() > 100 { &text[..100] } else { text };
1364 eprintln!("{}: [assistant] {}", i, preview);
1365 }
1366 ChatMessage::System { content, .. } => {
1367 let preview = if content.len() > 100 {
1368 &content[..100]
1369 } else {
1370 content
1371 };
1372 eprintln!("{}: [system] {}", i, preview);
1373 }
1374 _ => {}
1375 }
1376 }
1377 eprintln!("======================");
1378
1379 let summary_msg = compressed.iter().find(|m| match m {
1381 ChatMessage::User { content, .. } => content.starts_with(SUMMARY_PREFIX),
1382 _ => false,
1383 });
1384
1385 if let Some(ChatMessage::User { content, .. }) = summary_msg {
1386 let summary_content = content
1387 .strip_prefix(SUMMARY_PREFIX)
1388 .unwrap_or(content)
1389 .trim();
1390 let preview = if summary_content.len() > 200 {
1391 &summary_content[..200]
1392 } else {
1393 summary_content
1394 };
1395 eprintln!("=== Summary Content ===");
1396 eprintln!("{}", preview);
1397 eprintln!("========================");
1398
1399 assert!(
1400 !summary_content.starts_with("An alternate model reviewed"),
1401 "Summary should not start with 'An alternate model reviewed', got: {}",
1402 preview
1403 );
1404 }
1405 }
1406 None => {
1407 eprintln!("Compact returned None (no compression needed)");
1408 }
1409 }
1410 }
1411}