1use async_openai::types::chat::{
4 ChatCompletionRequestMessage, ChatCompletionRequestUserMessage,
5 ChatCompletionRequestUserMessageContent,
6};
7use robit_ai::config::ContextConfig;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum TruncationAction {
16 NewSegment,
19 MergeSegments {
24 summaries: Vec<String>,
25 start_position: usize,
26 count: usize,
27 },
28 TruncateOnly,
31}
32
33#[derive(Debug)]
35pub struct TruncationResult {
36 pub rounds_removed: usize,
38 pub messages_removed: usize,
40 pub removed_messages: Vec<ChatCompletionRequestMessage>,
42 pub insert_position: usize,
44 pub needs_compression: bool,
46 pub action: TruncationAction,
48}
49
50pub fn truncate_output(content: &str, max_lines: usize, max_bytes: usize) -> String {
56 let lines: Vec<&str> = content.lines().collect();
57 let total_lines = lines.len();
58 let total_bytes = content.len();
59
60 let line_truncated = total_lines > max_lines;
62 let byte_truncated = total_bytes > max_bytes;
63
64 if !line_truncated && !byte_truncated {
65 return content.to_string();
66 }
67
68 let mut output = String::new();
69 let mut byte_count = 0;
70 let mut displayed_lines = 0;
71
72 for (i, line) in lines.iter().enumerate() {
73 if i >= max_lines {
74 break;
75 }
76 let line_with_newline = if i < total_lines - 1 {
77 format!("{}\n", line)
78 } else {
79 line.to_string()
80 };
81
82 if byte_count + line_with_newline.len() > max_bytes {
83 break;
84 }
85
86 output.push_str(&line_with_newline);
87 byte_count += line_with_newline.len();
88 displayed_lines += 1;
89 }
90
91 if line_truncated {
92 output.push_str(&format!(
93 "\n... (Output truncated, {} lines total, showing first {}. Use offset/limit to read more)",
94 total_lines, displayed_lines
95 ));
96 } else if byte_truncated {
97 output.push_str(&format!(
98 "\n... (Output truncated, {} bytes total, showing first {} bytes)",
99 total_bytes, byte_count
100 ));
101 }
102
103 output
104}
105
106pub fn estimate_tokens(text: &str) -> usize {
121 if text.is_empty() {
122 return 0;
123 }
124
125 let mut ascii_alnum = 0usize;
126 let mut cjk = 0usize;
127 let mut whitespace = 0usize;
128 let mut other = 0usize; for ch in text.chars() {
131 if ch.is_whitespace() {
132 whitespace += 1;
133 } else if ch.is_ascii_alphanumeric() {
134 ascii_alnum += 1;
135 } else {
136 let cp = ch as u32;
137 if (0x4E00..=0x9FFF).contains(&cp)
140 || (0x3400..=0x4DBF).contains(&cp)
141 || (0xF900..=0xFAFF).contains(&cp)
142 || (0xFF00..=0xFFEF).contains(&cp)
143 || (0x3000..=0x303F).contains(&cp)
144 || (0x3040..=0x309F).contains(&cp)
145 || (0x30A0..=0x30FF).contains(&cp)
146 || (0xAC00..=0xD7AF).contains(&cp)
147 {
148 cjk += 1;
149 } else {
150 other += 1;
151 }
152 }
153 }
154
155 let ascii_tokens = (ascii_alnum as f64 / 3.5).ceil() as usize;
161 let cjk_tokens = (cjk as f64 / 1.5).ceil() as usize;
162 let whitespace_tokens = (whitespace as f64 / 10.0).ceil() as usize;
163 let other_tokens = other; ascii_tokens + cjk_tokens + whitespace_tokens + other_tokens
166}
167
168pub fn estimate_messages_tokens(messages: &[ChatCompletionRequestMessage]) -> usize {
170 let mut total = 0;
171 for msg in messages {
172 total += 4;
174 total += estimate_message_content_tokens(msg);
175 }
176 total
177}
178
179pub fn estimate_messages_tokens_with_margin(
181 messages: &[ChatCompletionRequestMessage],
182 safety_margin: f32,
183) -> usize {
184 let raw = estimate_messages_tokens(messages);
185 (raw as f32 * safety_margin).ceil() as usize
186}
187
188fn estimate_message_content_tokens(msg: &ChatCompletionRequestMessage) -> usize {
190 use async_openai::types::chat::ChatCompletionRequestUserMessageContentPart;
191
192 if let ChatCompletionRequestMessage::User(user_msg) = msg {
198 if let ChatCompletionRequestUserMessageContent::Array(parts) = &user_msg.content {
199 let mut total = 0;
200 for part in parts {
201 match part {
202 ChatCompletionRequestUserMessageContentPart::Text(t) => {
203 total += estimate_tokens(&t.text);
204 }
205 ChatCompletionRequestUserMessageContentPart::ImageUrl(_) => {
206 total += 2000;
210 }
211 _ => {
212 }
214 }
215 }
216 return total;
217 }
218 }
219
220 match serde_json::to_string(msg) {
222 Ok(json) => estimate_tokens(&json),
223 Err(_) => 0,
224 }
225}
226
227pub struct ContextManager {
233 pub max_tokens: usize,
235 pub reserve_ratio: f32,
237 pub truncation_ratio: f32,
239 pub min_keep_rounds: usize,
241 pub token_safety_margin: f32,
243 pub max_output_lines: usize,
245 pub max_output_bytes: usize,
247 pub compression_token_threshold: usize,
249 pub compression_enabled: bool,
251 pub max_tool_calls_per_turn: usize,
253 pub progressive_compression: bool,
255 pub rounds_per_summary: usize,
257 pub max_summary_segments: usize,
259 pub merge_count: usize,
261 pub max_merges_per_segment: usize,
263}
264
265impl ContextManager {
266 pub fn new(context_window: Option<u64>, config: Option<&ContextConfig>) -> Self {
267 let max_tokens = context_window.unwrap_or(65536) as usize;
268
269 let (
270 max_output_lines,
271 max_output_bytes,
272 reserve_ratio,
273 truncation_ratio,
274 min_keep_rounds,
275 token_safety_margin,
276 compression_token_threshold,
277 compression_enabled,
278 max_tool_calls_per_turn,
279 progressive_compression,
280 rounds_per_summary,
281 max_summary_segments,
282 merge_count,
283 max_merges_per_segment,
284 ) = match config {
285 Some(c) => (
286 c.max_output_lines.unwrap_or(500),
287 c.max_output_bytes.unwrap_or(51200),
288 c.reserve_ratio.unwrap_or(0.2),
289 c.truncation_ratio.unwrap_or(0.7),
290 c.min_keep_rounds.unwrap_or(3),
291 c.token_safety_margin.unwrap_or(1.3),
292 c.compression_token_threshold.unwrap_or(5000),
293 c.compression_enabled.unwrap_or(true),
294 c.max_tool_calls_per_turn.unwrap_or(30),
295 c.progressive_compression.unwrap_or(true),
296 c.rounds_per_summary.unwrap_or(3),
297 c.max_summary_segments.unwrap_or(5),
298 c.merge_count.unwrap_or(2),
299 c.max_merges_per_segment.unwrap_or(2),
300 ),
301 None => (500, 51200, 0.2, 0.7, 3, 1.3, 5000, true, 30, true, 3, 5, 2, 2),
302 };
303
304 Self {
305 max_tokens,
306 reserve_ratio,
307 truncation_ratio,
308 min_keep_rounds,
309 token_safety_margin,
310 max_output_lines,
311 max_output_bytes,
312 compression_token_threshold,
313 compression_enabled,
314 max_tool_calls_per_turn,
315 progressive_compression,
316 rounds_per_summary,
317 max_summary_segments,
318 merge_count,
319 max_merges_per_segment,
320 }
321 }
322
323 pub fn truncation_threshold(&self) -> usize {
327 (self.max_tokens as f32 * self.truncation_ratio) as usize
328 }
329
330 pub fn available_tokens(&self) -> usize {
334 (self.max_tokens as f32 * (1.0 - self.reserve_ratio)) as usize
335 }
336
337 pub fn truncate_tool_output(&self, content: &str) -> String {
339 truncate_output(content, self.max_output_lines, self.max_output_bytes)
340 }
341
342 pub fn maybe_truncate(
354 &self,
355 messages: &mut Vec<ChatCompletionRequestMessage>,
356 ) -> TruncationResult {
357 let estimated = estimate_messages_tokens_with_margin(messages, self.token_safety_margin);
358 let threshold = self.truncation_threshold();
359
360 tracing::debug!("maybe_truncate: initial message count = {}, estimated tokens = {}, threshold = {}",
361 messages.len(), estimated, threshold);
362
363 if estimated <= threshold {
364 tracing::debug!("maybe_truncate: No truncation needed ({} <= {})", estimated, threshold);
365 return TruncationResult {
366 rounds_removed: 0,
367 messages_removed: 0,
368 removed_messages: Vec::new(),
369 insert_position: 0,
370 needs_compression: false,
371 action: TruncationAction::TruncateOnly,
372 };
373 }
374
375 tracing::info!(
376 "=== Context truncation triggered ==="
377 );
378 tracing::info!(
379 "Estimated: {} tokens (with {:.1}x margin), Threshold: {} tokens, Max: {} tokens",
380 estimated,
381 self.token_safety_margin,
382 threshold,
383 self.max_tokens
384 );
385 tracing::info!("Compression enabled: {}, Progressive: {}",
386 self.compression_enabled, self.progressive_compression);
387
388 if !self.progressive_compression {
390 tracing::info!("Progressive compression disabled, using legacy single-shot truncation");
391 return self.legacy_truncate(messages, threshold);
392 }
393
394 if let Some(result) = self.try_new_segment(messages) {
396 tracing::info!("Progressive action: NewSegment ({} rounds)", result.rounds_removed);
397 return result;
398 }
399
400 if let Some(result) = self.try_merge_segments(messages) {
401 tracing::info!("Progressive action: MergeSegments");
402 return result;
403 }
404
405 if let Some(result) = self.try_discard_oldest_segment(messages) {
406 tracing::info!("Progressive action: Discard oldest segment");
407 return result;
408 }
409
410 tracing::warn!("All progressive actions exhausted, falling back to legacy truncation");
412 self.legacy_truncate(messages, threshold)
413 }
414
415 fn try_new_segment(
420 &self,
421 messages: &mut Vec<ChatCompletionRequestMessage>,
422 ) -> Option<TruncationResult> {
423 if !self.compression_enabled {
424 return None;
425 }
426
427 let round_starts: Vec<usize> = messages
430 .iter()
431 .enumerate()
432 .filter(|(_, m)| is_user_message(m) && !is_summary_segment(m) && !is_discard_notice(m))
433 .map(|(i, _)| i)
434 .collect();
435
436 if round_starts.len() <= self.min_keep_rounds + self.rounds_per_summary {
437 tracing::debug!("Not enough full rounds to compress (have {}, need min_keep + rounds_per_summary = {})",
438 round_starts.len(), self.min_keep_rounds + self.rounds_per_summary);
439 return None;
440 }
441
442 let take_rounds = self.rounds_per_summary.min(round_starts.len() - self.min_keep_rounds);
444 if take_rounds == 0 {
445 return None;
446 }
447
448 let start_idx = round_starts[0];
449 let end_idx = if take_rounds < round_starts.len() {
450 round_starts[take_rounds]
451 } else {
452 messages.len()
453 };
454
455 let removed_messages: Vec<ChatCompletionRequestMessage> =
456 messages[start_idx..end_idx].to_vec();
457 let messages_removed = removed_messages.len();
458 let removed_tokens = estimate_messages_tokens(&removed_messages);
459
460 if removed_tokens < self.compression_token_threshold {
462 tracing::debug!("Removed tokens ({}) below compression threshold ({})",
463 removed_tokens, self.compression_token_threshold);
464 return None;
465 }
466
467 messages.drain(start_idx..end_idx);
469
470 let system_msg_count = messages.iter().take_while(|m| is_system_message(m)).count();
473 let has_discard = find_discard_notice_pos(messages).is_some();
474 let insert_pos = system_msg_count + if has_discard { 1 } else { 0 };
475
476 let placeholder = make_summary_placeholder(take_rounds);
477 messages.insert(insert_pos, placeholder);
478
479 tracing::info!(
480 "New summary segment: removed {} rounds ({} messages, ~{} tokens), insert at {}",
481 take_rounds, messages_removed, removed_tokens, insert_pos
482 );
483
484 Some(TruncationResult {
485 rounds_removed: take_rounds,
486 messages_removed,
487 removed_messages,
488 insert_position: insert_pos,
489 needs_compression: true,
490 action: TruncationAction::NewSegment,
491 })
492 }
493
494 fn try_merge_segments(
499 &self,
500 messages: &mut Vec<ChatCompletionRequestMessage>,
501 ) -> Option<TruncationResult> {
502 if !self.compression_enabled {
503 return None;
504 }
505
506 let segments = find_summary_segments(messages);
507 if segments.len() <= self.max_summary_segments {
508 tracing::debug!("Segment count ({}) within limit ({})", segments.len(), self.max_summary_segments);
509 return None;
510 }
511
512 let oldest = segments.first()?;
514 if oldest.merge_level >= self.max_merges_per_segment {
515 tracing::debug!("Oldest segment at merge level {} >= max {}, will discard instead",
516 oldest.merge_level, self.max_merges_per_segment);
517 return None;
518 }
519
520 let take_count = self.merge_count.min(segments.len());
522 if take_count < 2 {
523 return None;
524 }
525
526 let start_pos = segments[0].index;
527 let end_pos = segments[take_count - 1].index + 1;
528
529 let summaries: Vec<String> = segments[..take_count]
530 .iter()
531 .map(|s| s.content.clone())
532 .collect();
533
534 let max_level = segments[..take_count]
535 .iter()
536 .map(|s| s.merge_level)
537 .max()
538 .unwrap_or(0);
539 let new_level = max_level + 1;
540
541 messages.drain(start_pos..end_pos);
543
544 let placeholder = make_merge_placeholder(new_level, take_count);
546 messages.insert(start_pos, placeholder);
547
548 tracing::info!(
549 "Merging {} summary segments into one (level {}), start pos {}",
550 take_count, new_level, start_pos
551 );
552
553 Some(TruncationResult {
554 rounds_removed: 0,
555 messages_removed: take_count,
556 removed_messages: Vec::new(),
557 insert_position: start_pos,
558 needs_compression: true,
559 action: TruncationAction::MergeSegments {
560 summaries,
561 start_position: start_pos,
562 count: take_count,
563 },
564 })
565 }
566
567 fn try_discard_oldest_segment(
572 &self,
573 messages: &mut Vec<ChatCompletionRequestMessage>,
574 ) -> Option<TruncationResult> {
575 let segments = find_summary_segments(messages);
576 if segments.len() <= self.max_summary_segments {
577 return None;
578 }
579
580 let oldest = segments.first()?;
581 if oldest.merge_level < self.max_merges_per_segment {
582 return None;
584 }
585
586 let pos = oldest.index;
588 messages.remove(pos);
589
590 tracing::info!("Discarded oldest summary segment at position {}", pos);
591
592 let system_msg_count = messages.iter().take_while(|m| is_system_message(m)).count();
594 if find_discard_notice_pos(messages).is_none() {
595 let notice = make_discard_notice();
596 messages.insert(system_msg_count, notice);
597 tracing::debug!("Added discard notice at position {}", system_msg_count);
598 }
599
600 Some(TruncationResult {
601 rounds_removed: 0,
602 messages_removed: 1,
603 removed_messages: Vec::new(),
604 insert_position: pos,
605 needs_compression: false,
606 action: TruncationAction::TruncateOnly,
607 })
608 }
609
610 fn legacy_truncate(
615 &self,
616 messages: &mut Vec<ChatCompletionRequestMessage>,
617 threshold: usize,
618 ) -> TruncationResult {
619 let mut round_starts: Vec<usize> = Vec::new();
621 for (i, msg) in messages.iter().enumerate() {
622 if is_user_message(msg) {
623 round_starts.push(i);
624 }
625 }
626 tracing::debug!("Found {} user message round boundaries", round_starts.len());
627
628 if round_starts.is_empty() {
629 tracing::debug!("No user messages found, no truncation performed");
630 return TruncationResult {
631 rounds_removed: 0,
632 messages_removed: 0,
633 removed_messages: Vec::new(),
634 insert_position: 0,
635 needs_compression: false,
636 action: TruncationAction::TruncateOnly,
637 };
638 }
639
640 let total_rounds = round_starts.len();
641 let must_keep = self.min_keep_rounds.min(total_rounds);
642 tracing::debug!("Total rounds: {}, Must keep at least: {} rounds", total_rounds, must_keep);
643
644 let mut removed_messages: Vec<ChatCompletionRequestMessage> = Vec::new();
645 let mut rounds_removed = 0;
646 let mut messages_removed = 0;
647
648 while round_starts.len() > must_keep
649 && estimate_messages_tokens_with_margin(messages, self.token_safety_margin) > threshold
650 {
651 let start_idx = round_starts[0];
652 let end_idx = if round_starts.len() > 1 {
653 round_starts[1]
654 } else {
655 messages.len()
656 };
657
658 if self.compression_enabled {
659 removed_messages.extend(messages[start_idx..end_idx].to_vec());
660 }
661
662 let count = end_idx - start_idx;
663 messages.drain(start_idx..end_idx);
664
665 round_starts.remove(0);
666 for idx in round_starts.iter_mut() {
667 *idx = idx.saturating_sub(count);
668 }
669
670 rounds_removed += 1;
671 messages_removed += count;
672 }
673
674 if rounds_removed == 0 {
675 tracing::debug!("No rounds removed after checks");
676 return TruncationResult {
677 rounds_removed: 0,
678 messages_removed: 0,
679 removed_messages: Vec::new(),
680 insert_position: 0,
681 needs_compression: false,
682 action: TruncationAction::TruncateOnly,
683 };
684 }
685
686 let removed_tokens = estimate_messages_tokens(&removed_messages);
687 let needs_compression =
688 self.compression_enabled && removed_tokens >= self.compression_token_threshold;
689
690 let system_msg_count = messages
691 .iter()
692 .take_while(|m| is_system_message(m))
693 .count();
694
695 let notice = if needs_compression {
696 format!(
697 "[Context compressed: {} earlier rounds ({} messages, ~{} tokens) have been summarized. {} most recent rounds preserved.]",
698 rounds_removed, messages_removed, removed_tokens, round_starts.len()
699 )
700 } else {
701 format!(
702 "[Context truncated: {} earlier rounds ({} messages) removed to stay within token limit. {} most recent rounds preserved.]",
703 rounds_removed, messages_removed, round_starts.len()
704 )
705 };
706
707 let notice_msg = ChatCompletionRequestMessage::User(
708 async_openai::types::chat::ChatCompletionRequestUserMessage {
709 content: notice.into(),
710 name: Some("system_notice".to_string()),
711 }
712 .into(),
713 );
714
715 messages.insert(system_msg_count, notice_msg);
716
717 tracing::info!(
718 "Legacy truncation: removed {} rounds ({} messages), kept {} rounds",
719 rounds_removed, messages_removed, round_starts.len()
720 );
721
722 TruncationResult {
723 rounds_removed,
724 messages_removed,
725 removed_messages,
726 insert_position: system_msg_count,
727 needs_compression,
728 action: if needs_compression {
729 TruncationAction::NewSegment
732 } else {
733 TruncationAction::TruncateOnly
734 },
735 }
736 }
737}
738
739fn is_user_message(msg: &ChatCompletionRequestMessage) -> bool {
740 matches!(msg, ChatCompletionRequestMessage::User(_))
741}
742
743fn is_system_message(msg: &ChatCompletionRequestMessage) -> bool {
744 matches!(msg, ChatCompletionRequestMessage::System(_))
745}
746
747const SUMMARY_SEGMENT_PREFIX: &str = "summary_segment";
752const DISCARD_NOTICE_NAME: &str = "discard_notice";
753const LEGACY_NOTICE_NAME: &str = "system_notice";
754
755fn user_message_name(msg: &ChatCompletionRequestMessage) -> Option<&str> {
757 match msg {
758 ChatCompletionRequestMessage::User(u) => u.name.as_deref(),
759 _ => None,
760 }
761}
762
763fn user_message_text(msg: &ChatCompletionRequestMessage) -> String {
765 match msg {
766 ChatCompletionRequestMessage::User(u) => match &u.content {
767 ChatCompletionRequestUserMessageContent::Text(t) => t.clone(),
768 ChatCompletionRequestUserMessageContent::Array(parts) => parts
769 .iter()
770 .filter_map(|p| match p {
771 async_openai::types::chat::ChatCompletionRequestUserMessageContentPart::Text(t) => Some(t.text.as_str()),
772 _ => None,
773 })
774 .collect::<Vec<_>>()
775 .join(" "),
776 },
777 _ => String::new(),
778 }
779}
780
781pub fn is_summary_segment(msg: &ChatCompletionRequestMessage) -> bool {
783 match user_message_name(msg) {
784 Some(name) => {
785 name.starts_with(SUMMARY_SEGMENT_PREFIX) || name == LEGACY_NOTICE_NAME
786 }
787 None => false,
788 }
789}
790
791pub fn get_merge_level(msg: &ChatCompletionRequestMessage) -> usize {
794 match user_message_name(msg) {
795 Some(name) => {
796 if name == LEGACY_NOTICE_NAME {
797 0
798 } else if let Some(suffix) = name.strip_prefix(SUMMARY_SEGMENT_PREFIX) {
799 if suffix.is_empty() {
800 0
801 } else if let Some(num_str) = suffix.strip_prefix("_m") {
802 num_str.parse::<usize>().unwrap_or(0)
803 } else {
804 0
805 }
806 } else {
807 0
808 }
809 }
810 None => 0,
811 }
812}
813
814fn is_discard_notice(msg: &ChatCompletionRequestMessage) -> bool {
816 matches!(user_message_name(msg), Some(name) if name == DISCARD_NOTICE_NAME)
817}
818
819fn summary_segment_name(merge_level: usize) -> String {
821 if merge_level == 0 {
822 SUMMARY_SEGMENT_PREFIX.to_string()
823 } else {
824 format!("{}_m{}", SUMMARY_SEGMENT_PREFIX, merge_level)
825 }
826}
827
828fn make_summary_placeholder(rounds_removed: usize) -> ChatCompletionRequestMessage {
830 let text = format!(
831 "[Compressing {} earlier conversation rounds into a summary...]",
832 rounds_removed
833 );
834 ChatCompletionRequestMessage::User(
835 ChatCompletionRequestUserMessage {
836 content: text.into(),
837 name: Some(summary_segment_name(0)),
838 }
839 .into(),
840 )
841}
842
843fn make_merge_placeholder(merge_level: usize, count: usize) -> ChatCompletionRequestMessage {
845 let text = format!(
846 "[Merging {} earlier summary segments...]",
847 count
848 );
849 ChatCompletionRequestMessage::User(
850 ChatCompletionRequestUserMessage {
851 content: text.into(),
852 name: Some(summary_segment_name(merge_level)),
853 }
854 .into(),
855 )
856}
857
858fn make_discard_notice() -> ChatCompletionRequestMessage {
860 let text = "[Note: Earlier conversation history beyond the earliest summary has been discarded to save context space.]";
861 ChatCompletionRequestMessage::User(
862 ChatCompletionRequestUserMessage {
863 content: text.into(),
864 name: Some(DISCARD_NOTICE_NAME.to_string()),
865 }
866 .into(),
867 )
868}
869
870#[derive(Debug, Clone)]
872struct SummarySegmentInfo {
873 index: usize,
874 merge_level: usize,
875 content: String,
876}
877
878fn find_summary_segments(messages: &[ChatCompletionRequestMessage]) -> Vec<SummarySegmentInfo> {
880 let mut segments = Vec::new();
881 for (i, msg) in messages.iter().enumerate() {
882 if is_summary_segment(msg) {
883 segments.push(SummarySegmentInfo {
884 index: i,
885 merge_level: get_merge_level(msg),
886 content: user_message_text(msg),
887 });
888 }
889 }
890 segments
891}
892
893fn find_discard_notice_pos(messages: &[ChatCompletionRequestMessage]) -> Option<usize> {
895 messages.iter().position(is_discard_notice)
896}
897
898pub fn format_removed_messages_as_transcript(
906 messages: &[ChatCompletionRequestMessage],
907) -> String {
908 let mut transcript = String::new();
909
910 for msg in messages {
911 match msg {
912 ChatCompletionRequestMessage::User(user_msg) => {
913 let text = match &user_msg.content {
914 async_openai::types::chat::ChatCompletionRequestUserMessageContent::Text(t) => {
915 t.clone()
916 }
917 async_openai::types::chat::ChatCompletionRequestUserMessageContent::Array(parts) => {
918 parts.iter()
919 .filter_map(|p| match p {
920 async_openai::types::chat::ChatCompletionRequestUserMessageContentPart::Text(t) => Some(t.text.as_str()),
921 _ => None,
922 })
923 .collect::<Vec<_>>()
924 .join(" ")
925 }
926 };
927 let truncated = truncate_str(&text, 200);
928 transcript.push_str(&format!("User: {}\n", truncated));
929 }
930 ChatCompletionRequestMessage::Assistant(assistant_msg) => {
931 let content_str = assistant_msg.content.as_ref().map(|c| {
932 match serde_json::to_string(c) {
933 Ok(json) => json.trim_matches('"').to_string(),
934 Err(_) => format!("{:?}", c),
935 }
936 }).unwrap_or_default();
937 let truncated = truncate_str(&content_str, 300);
938 transcript.push_str(&format!("Assistant: {}\n", truncated));
939 if let Some(tool_calls) = &assistant_msg.tool_calls {
940 for tc in tool_calls {
941 if let async_openai::types::chat::ChatCompletionMessageToolCalls::Function(f) = tc {
942 transcript.push_str(&format!(
943 " [Tool: {}({})]\n",
944 f.function.name,
945 truncate_str(&f.function.arguments, 100)
946 ));
947 }
948 }
949 }
950 }
951 ChatCompletionRequestMessage::Tool(tool_msg) => {
952 let content_str = match serde_json::to_string(&tool_msg.content) {
953 Ok(json) => json.trim_matches('"').to_string(),
954 Err(_) => format!("{:?}", tool_msg.content),
955 };
956 let truncated = truncate_str(&content_str, 150);
957 transcript.push_str(&format!(" [Result: {}]\n", truncated));
958 }
959 _ => {}
960 }
961 }
962
963 if transcript.is_empty() {
964 transcript.push_str("(no conversation content)");
965 }
966
967 transcript
968}
969
970fn truncate_str(s: &str, max_chars: usize) -> String {
973 if s.len() <= max_chars {
974 s.to_string()
975 } else {
976 let mut end = max_chars;
977 while end > 0 && !s.is_char_boundary(end) {
978 end -= 1;
979 }
980 format!("{}...", &s[..end])
981 }
982}
983
984#[cfg(test)]
989mod tests {
990 use super::*;
991 use async_openai::types::chat::ChatCompletionRequestUserMessage;
992
993 fn make_user_message(content: &str) -> ChatCompletionRequestMessage {
994 ChatCompletionRequestMessage::User(
995 ChatCompletionRequestUserMessage {
996 content: content.into(),
997 name: None,
998 }
999 .into(),
1000 )
1001 }
1002
1003 fn make_system_message(content: &str) -> ChatCompletionRequestMessage {
1004 ChatCompletionRequestMessage::System(
1005 async_openai::types::chat::ChatCompletionRequestSystemMessage {
1006 content: content.into(),
1007 name: None,
1008 }
1009 .into(),
1010 )
1011 }
1012
1013 fn make_test_config() -> ContextConfig {
1014 ContextConfig {
1015 max_output_lines: Some(500),
1016 max_output_bytes: Some(51200),
1017 reserve_ratio: Some(0.2),
1018 truncation_ratio: Some(0.7),
1019 min_keep_rounds: Some(3),
1020 token_safety_margin: Some(1.3),
1021 compression_token_threshold: Some(5000),
1022 compression_enabled: Some(true),
1023 max_tool_calls_per_turn: Some(30),
1024 progressive_compression: Some(true),
1025 rounds_per_summary: Some(3),
1026 max_summary_segments: Some(5),
1027 merge_count: Some(2),
1028 max_merges_per_segment: Some(2),
1029 }
1030 }
1031
1032 fn make_user_message_named(content: &str, name: &str) -> ChatCompletionRequestMessage {
1033 ChatCompletionRequestMessage::User(
1034 ChatCompletionRequestUserMessage {
1035 content: content.into(),
1036 name: Some(name.to_string()),
1037 }
1038 .into(),
1039 )
1040 }
1041
1042 fn make_summary_segment(content: &str, merge_level: usize) -> ChatCompletionRequestMessage {
1043 let name = if merge_level == 0 {
1044 "summary_segment".to_string()
1045 } else {
1046 format!("summary_segment_m{}", merge_level)
1047 };
1048 make_user_message_named(content, &name)
1049 }
1050
1051 fn make_legacy_notice(content: &str) -> ChatCompletionRequestMessage {
1052 make_user_message_named(content, "system_notice")
1053 }
1054
1055 #[test]
1067 fn test_estimate_tokens_english() {
1068 let text = "Hello world, this is a test of the token estimation system.";
1069 let tokens = estimate_tokens(text);
1070 assert!(tokens >= 10, "Expected at least 10 tokens, got {}", tokens);
1071 assert!(tokens <= 30, "Expected at most 30 tokens, got {}", tokens);
1072 }
1073
1074 #[test]
1075 fn test_estimate_tokens_chinese() {
1076 let chinese = "你好世界,这是一个测试。";
1077 let tokens = estimate_tokens(chinese);
1078 assert!(tokens >= 5, "Expected at least 5 tokens, got {}", tokens);
1079 assert!(tokens <= 15, "Expected at most 15 tokens, got {}", tokens);
1080 }
1081
1082 #[test]
1083 fn test_estimate_tokens_code() {
1084 let code = "fn main() {\n println!(\"Hello\");\n}";
1085 let tokens = estimate_tokens(code);
1086 assert!(tokens >= 10, "Expected at least 10 tokens, got {}", tokens);
1087 assert!(tokens <= 40, "Expected at most 40 tokens, got {}", tokens);
1088 }
1089
1090 #[test]
1091 fn test_estimate_tokens_empty() {
1092 assert_eq!(estimate_tokens(""), 0);
1093 }
1094
1095 #[test]
1096 fn test_estimate_tokens_mixed() {
1097 let mixed = "Hello 你好 world 世界!fn test() {}";
1098 let tokens = estimate_tokens(mixed);
1099 assert!(tokens > 0);
1100 assert!(tokens <= 40, "Expected at most 40 tokens, got {}", tokens);
1101 }
1102
1103 #[test]
1104 fn test_estimate_messages_tokens_with_margin() {
1105 let messages = vec![
1106 make_system_message("You are a helpful assistant"),
1107 make_user_message("Hello world"),
1108 ];
1109 let raw = estimate_messages_tokens(&messages);
1110 let with_margin = estimate_messages_tokens_with_margin(&messages, 1.3);
1111 assert!(with_margin > raw);
1112 let expected = (raw as f32 * 1.3).ceil() as usize;
1114 assert_eq!(with_margin, expected);
1115 }
1116
1117 #[test]
1122 fn test_truncation_threshold() {
1123 let config = make_test_config();
1124 let manager = ContextManager::new(Some(65536), Some(&config));
1125 assert_eq!(manager.truncation_threshold(), 45875);
1127 }
1128
1129 #[test]
1130 fn test_truncation_result_no_truncation() {
1131 let mut messages = vec![
1132 make_system_message("You are a helpful assistant"),
1133 make_user_message("Hello"),
1134 ];
1135
1136 let config = make_test_config();
1137 let manager = ContextManager::new(Some(65536), Some(&config));
1138 let result = manager.maybe_truncate(&mut messages);
1139
1140 assert_eq!(result.rounds_removed, 0);
1141 assert!(!result.needs_compression);
1142 }
1143
1144 #[test]
1145 fn test_truncation_respects_min_keep_rounds() {
1146 let mut messages = vec![
1147 make_system_message("You are a helpful assistant"),
1148 ];
1149
1150 for i in 0..10 {
1152 let content = format!("User message {}: {}", i, "x".repeat(2000));
1153 messages.push(make_user_message(&content));
1154 }
1155
1156 let mut config = make_test_config();
1157 config.min_keep_rounds = Some(3); let manager = ContextManager::new(Some(5000), Some(&config));
1161 let result = manager.maybe_truncate(&mut messages);
1162
1163 assert!(
1165 result.rounds_removed > 0,
1166 "Should have removed some rounds"
1167 );
1168 let user_count = messages
1170 .iter()
1171 .filter(|m| matches!(m, ChatCompletionRequestMessage::User(_)))
1172 .count();
1173 assert!(
1174 user_count >= 4,
1175 "Should have at least 3 user rounds + notice, got {}",
1176 user_count
1177 );
1178 }
1179
1180 #[test]
1181 fn test_truncation_early_trigger() {
1182 let mut messages = vec![
1183 make_system_message("You are a helpful assistant"),
1184 ];
1185
1186 for i in 0..8 {
1188 let content = format!("User message {}: {}", i, "x".repeat(2000));
1189 messages.push(make_user_message(&content));
1190 }
1191
1192 let mut config = make_test_config();
1193 config.truncation_ratio = Some(0.7);
1194 config.min_keep_rounds = Some(2);
1195 config.token_safety_margin = Some(1.3);
1196
1197 let manager = ContextManager::new(Some(65536), Some(&config));
1200 let result = manager.maybe_truncate(&mut messages);
1201 assert_eq!(
1202 result.rounds_removed, 0,
1203 "Should not truncate small messages in large window"
1204 );
1205
1206 let manager2 = ContextManager::new(Some(8000), Some(&config));
1208 let mut messages2 = messages.clone();
1209 let result2 = manager2.maybe_truncate(&mut messages2);
1210 assert!(
1211 result2.rounds_removed > 0,
1212 "Should truncate when exceeding small window"
1213 );
1214 }
1215
1216 #[test]
1217 fn test_token_safety_margin_effect() {
1218 let mut messages = vec![
1219 make_system_message("You are a helpful assistant"),
1220 ];
1221
1222 for i in 0..10 {
1223 let content = format!("User message {}: {}", i, "x".repeat(500));
1224 messages.push(make_user_message(&content));
1225 }
1226
1227 let mut config_low = make_test_config();
1229 config_low.token_safety_margin = Some(1.0);
1230 config_low.truncation_ratio = Some(0.7);
1231 config_low.min_keep_rounds = Some(1);
1232
1233 let mut msgs_low = messages.clone();
1234 let manager_low = ContextManager::new(Some(8000), Some(&config_low));
1235 let result_low = manager_low.maybe_truncate(&mut msgs_low);
1236
1237 let mut config_high = make_test_config();
1239 config_high.token_safety_margin = Some(2.0);
1240 config_high.truncation_ratio = Some(0.7);
1241 config_high.min_keep_rounds = Some(1);
1242
1243 let mut msgs_high = messages.clone();
1244 let manager_high = ContextManager::new(Some(8000), Some(&config_high));
1245 let result_high = manager_high.maybe_truncate(&mut msgs_high);
1246
1247 assert!(
1249 result_high.rounds_removed >= result_low.rounds_removed,
1250 "Higher safety margin should trigger at least as much truncation: high={}, low={}",
1251 result_high.rounds_removed,
1252 result_low.rounds_removed
1253 );
1254 }
1255
1256 #[test]
1257 fn test_compression_flag_in_old_tests() {
1258 let mut messages = vec![
1259 make_system_message("You are a helpful assistant"),
1260 ];
1261
1262 for i in 0..20 {
1264 let content = format!("User message {}: {}", i, "x".repeat(2000));
1265 messages.push(make_user_message(&content));
1266 }
1267
1268 let mut config = make_test_config();
1269 config.compression_enabled = Some(false);
1270 config.compression_token_threshold = Some(1000);
1271 config.min_keep_rounds = Some(1);
1272
1273 let manager = ContextManager::new(Some(5000), Some(&config));
1274 let result = manager.maybe_truncate(&mut messages);
1275
1276 assert!(result.rounds_removed > 0);
1277 assert!(
1278 !result.needs_compression,
1279 "Should be false when compression disabled"
1280 );
1281 }
1282
1283 #[test]
1284 fn test_truncate_output() {
1285 let content = "line1\nline2\nline3\nline4\nline5";
1286 let truncated = truncate_output(content, 3, 100);
1287 assert!(truncated.contains("line1"));
1288 assert!(truncated.contains("line2"));
1289 assert!(truncated.contains("line3"));
1290 assert!(!truncated.contains("line4"));
1291 assert!(truncated.contains("Output truncated"));
1292 }
1293
1294 #[test]
1299 fn test_truncate_str_no_truncation() {
1300 let result = truncate_str("hello", 10);
1301 assert_eq!(result, "hello");
1302 }
1303
1304 #[test]
1305 fn test_truncate_str_with_truncation() {
1306 let result = truncate_str("hello world this is long", 10);
1307 assert_eq!(result, "hello worl...");
1308 }
1309
1310 #[test]
1311 fn test_format_transcript_user_and_assistant() {
1312 let messages = vec![
1313 make_user_message("Fix the bug in auth.rs"),
1314 make_system_message("System message should be skipped"),
1315 ];
1316
1317 let transcript = format_removed_messages_as_transcript(&messages);
1318 assert!(transcript.contains("User: Fix the bug in auth.rs"));
1319 assert!(!transcript.contains("System message"), "System messages should be skipped");
1320 }
1321
1322 #[test]
1323 fn test_format_transcript_empty() {
1324 let messages: Vec<ChatCompletionRequestMessage> = vec![];
1325 let transcript = format_removed_messages_as_transcript(&messages);
1326 assert!(transcript.contains("no conversation content"));
1327 }
1328
1329 #[test]
1330 fn test_format_transcript_truncates_long_messages() {
1331 let long_text = "x".repeat(500);
1332 let messages = vec![
1333 make_user_message(&long_text),
1334 ];
1335
1336 let transcript = format_removed_messages_as_transcript(&messages);
1337 assert!(transcript.contains("..."));
1338 assert!(transcript.len() < long_text.len() + 50);
1340 }
1341
1342 #[test]
1347 fn test_is_summary_segment_recognizes_all_levels() {
1348 let m0 = make_summary_segment("summary 0", 0);
1349 let m1 = make_summary_segment("summary 1", 1);
1350 let m2 = make_summary_segment("summary 2", 2);
1351 let legacy = make_legacy_notice("old notice");
1352 let normal = make_user_message("hello");
1353
1354 assert!(is_summary_segment(&m0));
1355 assert!(is_summary_segment(&m1));
1356 assert!(is_summary_segment(&m2));
1357 assert!(is_summary_segment(&legacy));
1358 assert!(!is_summary_segment(&normal));
1359 }
1360
1361 #[test]
1362 fn test_get_merge_level() {
1363 assert_eq!(get_merge_level(&make_summary_segment("a", 0)), 0);
1364 assert_eq!(get_merge_level(&make_summary_segment("b", 1)), 1);
1365 assert_eq!(get_merge_level(&make_summary_segment("c", 2)), 2);
1366 assert_eq!(get_merge_level(&make_legacy_notice("d")), 0);
1367 assert_eq!(get_merge_level(&make_user_message("e")), 0);
1368 }
1369
1370 #[test]
1371 fn test_progressive_no_truncation_needed() {
1372 let mut messages = vec![
1373 make_system_message("sys"),
1374 make_user_message("hi"),
1375 ];
1376 let config = make_test_config();
1377 let manager = ContextManager::new(Some(65536), Some(&config));
1378 let result = manager.maybe_truncate(&mut messages);
1379
1380 assert_eq!(result.rounds_removed, 0);
1381 assert!(!result.needs_compression);
1382 assert_eq!(result.action, TruncationAction::TruncateOnly);
1383 }
1384
1385 #[test]
1386 fn test_progressive_new_segment() {
1387 let mut messages = vec![make_system_message("sys")];
1388 for i in 0..10 {
1390 let content = format!("Round {}: {}", i, "x".repeat(2000));
1391 messages.push(make_user_message(&content));
1392 }
1393
1394 let mut config = make_test_config();
1395 config.min_keep_rounds = Some(3);
1396 config.rounds_per_summary = Some(3);
1397 config.compression_token_threshold = Some(100); let manager = ContextManager::new(Some(8000), Some(&config));
1400 let result = manager.maybe_truncate(&mut messages);
1401
1402 assert_eq!(result.action, TruncationAction::NewSegment);
1403 assert!(result.needs_compression);
1404 assert_eq!(result.rounds_removed, 3);
1405 assert!(result.removed_messages.len() > 0);
1406
1407 let has_seg = messages.iter().any(|m| is_summary_segment(m));
1409 assert!(has_seg, "Should have a summary segment placeholder");
1410 }
1411
1412 #[test]
1413 fn test_progressive_disabled_falls_back_to_legacy() {
1414 let mut messages = vec![make_system_message("sys")];
1415 for i in 0..20 {
1417 let content = format!("Round {}: {}", i, "x".repeat(2000));
1418 messages.push(make_user_message(&content));
1419 }
1420
1421 let mut config = make_test_config();
1422 config.progressive_compression = Some(false);
1423 config.min_keep_rounds = Some(3);
1424 config.compression_token_threshold = Some(100);
1425
1426 let manager = ContextManager::new(Some(8000), Some(&config));
1427 let result = manager.maybe_truncate(&mut messages);
1428
1429 assert!(result.rounds_removed > 3,
1432 "Legacy should remove more than rounds_per_summary (3) rounds, removed {}",
1433 result.rounds_removed);
1434 assert!(matches!(result.action, TruncationAction::NewSegment));
1436 }
1437
1438 #[test]
1439 fn test_progressive_merge_segments() {
1440 let mut messages = vec![make_system_message("sys")];
1443 for i in 0..6 {
1445 let content = format!("Summary {}: {}", i, "x".repeat(500));
1446 messages.push(make_summary_segment(&content, 0));
1447 }
1448 for i in 0..2 {
1450 let content = format!("User {}: {}", i, "x".repeat(300));
1451 messages.push(make_user_message(&content));
1452 }
1453
1454 let mut config = make_test_config();
1455 config.max_summary_segments = Some(5);
1456 config.merge_count = Some(2);
1457 config.min_keep_rounds = Some(3);
1458 config.rounds_per_summary = Some(3);
1459 config.compression_token_threshold = Some(10);
1460
1461 let manager = ContextManager::new(Some(2000), Some(&config));
1463
1464 let estimated = estimate_messages_tokens_with_margin(&messages, 1.3);
1466 assert!(estimated > manager.truncation_threshold(),
1467 "Test setup error: should be over threshold, est={}, threshold={}",
1468 estimated, manager.truncation_threshold());
1469
1470 let result = manager.maybe_truncate(&mut messages);
1471
1472 match &result.action {
1475 TruncationAction::MergeSegments { summaries, start_position, count } => {
1476 assert_eq!(*count, 2, "Should merge 2 segments");
1477 assert_eq!(summaries.len(), 2);
1478 assert!(*start_position >= 1, "Start after system message");
1479 }
1480 other => {
1481 panic!("Expected MergeSegments, got {:?}", other);
1482 }
1483 }
1484
1485 let seg_count = messages.iter().filter(|m| is_summary_segment(m)).count();
1487 assert_eq!(seg_count, 5, "Should have 5 segments after merge");
1488 }
1489
1490 #[test]
1491 fn test_progressive_discard_after_merge_limit() {
1492 let mut messages = vec![make_system_message("sys")];
1494 for i in 0..6 {
1495 messages.push(make_summary_segment(&format!("Old summary {}", i), 2));
1496 }
1497 for i in 0..5 {
1498 let content = format!("User {}: {}", i, "x".repeat(2000));
1499 messages.push(make_user_message(&content));
1500 }
1501
1502 let mut config = make_test_config();
1503 config.max_summary_segments = Some(5);
1504 config.max_merges_per_segment = Some(2);
1505 config.merge_count = Some(2);
1506
1507 let manager = ContextManager::new(Some(8000), Some(&config));
1508
1509 let mut did_discard = false;
1511 for _ in 0..5 {
1512 let result = manager.maybe_truncate(&mut messages);
1513 if result.rounds_removed == 0 && result.messages_removed > 0 && !result.needs_compression {
1514 if find_discard_notice_pos(&messages).is_some() {
1516 did_discard = true;
1517 break;
1518 }
1519 }
1520 }
1521
1522 let seg_count = messages.iter().filter(|m| is_summary_segment(m)).count();
1524 assert!(
1525 seg_count <= 6,
1526 "Segment count should decrease or stay same, got {}",
1527 seg_count
1528 );
1529
1530 let _ = did_discard;
1532 }
1533
1534 #[test]
1535 fn test_legacy_notice_recognized_as_summary_segment() {
1536 let mut messages = vec![
1537 make_system_message("sys"),
1538 make_legacy_notice("[Old compressed context notice]"),
1539 ];
1540 for i in 0..8 {
1542 let content = format!("User {}: {}", i, "x".repeat(2000));
1543 messages.push(make_user_message(&content));
1544 }
1545
1546 let mut config = make_test_config();
1547 config.min_keep_rounds = Some(3);
1548 config.max_summary_segments = Some(5);
1549 config.compression_token_threshold = Some(100);
1550
1551 let manager = ContextManager::new(Some(8000), Some(&config));
1552 let result = manager.maybe_truncate(&mut messages);
1553
1554 assert!(result.messages_removed > 0 || result.rounds_removed > 0);
1556 }
1557
1558 #[test]
1559 fn test_discard_notice_inserted_once() {
1560 let mut messages = vec![
1561 make_system_message("sys"),
1562 make_summary_segment("old seg 1", 2),
1563 make_summary_segment("old seg 2", 2),
1564 make_summary_segment("old seg 3", 2),
1565 make_summary_segment("old seg 4", 2),
1566 make_summary_segment("old seg 5", 2),
1567 make_summary_segment("old seg 6", 2),
1568 ];
1569 for i in 0..4 {
1570 let content = format!("User {}: {}", i, "x".repeat(2000));
1571 messages.push(make_user_message(&content));
1572 }
1573
1574 let mut config = make_test_config();
1575 config.max_summary_segments = Some(5);
1576 config.max_merges_per_segment = Some(2);
1577
1578 let manager = ContextManager::new(Some(6000), Some(&config));
1579
1580 for _ in 0..3 {
1582 let _ = manager.maybe_truncate(&mut messages);
1583 }
1584
1585 let discard_count = messages.iter().filter(|m| is_discard_notice(m)).count();
1587 assert!(
1588 discard_count <= 1,
1589 "Should have at most 1 discard notice, found {}",
1590 discard_count
1591 );
1592 }
1593
1594 #[test]
1595 fn test_multiple_progressive_rounds_gradual() {
1596 let mut messages = vec![make_system_message("sys")];
1598 for i in 0..20 {
1599 let content = format!("Round {} user message: {}", i, "x".repeat(1500));
1600 messages.push(make_user_message(&content));
1601 }
1602
1603 let mut config = make_test_config();
1604 config.min_keep_rounds = Some(3);
1605 config.rounds_per_summary = Some(3);
1606 config.max_summary_segments = Some(4);
1607 config.compression_token_threshold = Some(100);
1608
1609 let manager = ContextManager::new(Some(10000), Some(&config));
1610
1611 let mut seg_count_before = 0;
1612 let mut did_new_segment = false;
1613 let mut did_merge = false;
1614
1615 for round in 0..10 {
1616 let result = manager.maybe_truncate(&mut messages);
1617
1618 let seg_count = messages.iter().filter(|m| is_summary_segment(m)).count();
1619
1620 match &result.action {
1621 TruncationAction::NewSegment => {
1622 did_new_segment = true;
1623 assert_eq!(result.rounds_removed, 3);
1624 assert!(seg_count > seg_count_before || seg_count_before == 0);
1625 }
1626 TruncationAction::MergeSegments { .. } => {
1627 did_merge = true;
1628 assert!(seg_count <= seg_count_before);
1629 }
1630 TruncationAction::TruncateOnly => {
1631 }
1633 }
1634
1635 seg_count_before = seg_count;
1636
1637 let estimated = estimate_messages_tokens_with_margin(&messages, 1.3);
1638 if estimated <= manager.truncation_threshold() {
1639 break;
1640 }
1641
1642 tracing::debug!("Round {}: segments={}, estimated={}", round, seg_count, estimated);
1643 }
1644
1645 assert!(did_new_segment, "Should have created at least one new summary segment");
1647 let _ = did_merge;
1648 }
1649}