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 match serde_json::to_string(msg) {
191 Ok(json) => estimate_tokens(&json),
192 Err(_) => 0,
193 }
194}
195
196pub struct ContextManager {
202 pub max_tokens: usize,
204 pub reserve_ratio: f32,
206 pub truncation_ratio: f32,
208 pub min_keep_rounds: usize,
210 pub token_safety_margin: f32,
212 pub max_output_lines: usize,
214 pub max_output_bytes: usize,
216 pub compression_token_threshold: usize,
218 pub compression_enabled: bool,
220 pub max_tool_calls_per_turn: usize,
222 pub progressive_compression: bool,
224 pub rounds_per_summary: usize,
226 pub max_summary_segments: usize,
228 pub merge_count: usize,
230 pub max_merges_per_segment: usize,
232}
233
234impl ContextManager {
235 pub fn new(context_window: Option<u64>, config: Option<&ContextConfig>) -> Self {
236 let max_tokens = context_window.unwrap_or(65536) as usize;
237
238 let (
239 max_output_lines,
240 max_output_bytes,
241 reserve_ratio,
242 truncation_ratio,
243 min_keep_rounds,
244 token_safety_margin,
245 compression_token_threshold,
246 compression_enabled,
247 max_tool_calls_per_turn,
248 progressive_compression,
249 rounds_per_summary,
250 max_summary_segments,
251 merge_count,
252 max_merges_per_segment,
253 ) = match config {
254 Some(c) => (
255 c.max_output_lines.unwrap_or(500),
256 c.max_output_bytes.unwrap_or(51200),
257 c.reserve_ratio.unwrap_or(0.2),
258 c.truncation_ratio.unwrap_or(0.7),
259 c.min_keep_rounds.unwrap_or(3),
260 c.token_safety_margin.unwrap_or(1.3),
261 c.compression_token_threshold.unwrap_or(5000),
262 c.compression_enabled.unwrap_or(true),
263 c.max_tool_calls_per_turn.unwrap_or(30),
264 c.progressive_compression.unwrap_or(true),
265 c.rounds_per_summary.unwrap_or(3),
266 c.max_summary_segments.unwrap_or(5),
267 c.merge_count.unwrap_or(2),
268 c.max_merges_per_segment.unwrap_or(2),
269 ),
270 None => (500, 51200, 0.2, 0.7, 3, 1.3, 5000, true, 30, true, 3, 5, 2, 2),
271 };
272
273 Self {
274 max_tokens,
275 reserve_ratio,
276 truncation_ratio,
277 min_keep_rounds,
278 token_safety_margin,
279 max_output_lines,
280 max_output_bytes,
281 compression_token_threshold,
282 compression_enabled,
283 max_tool_calls_per_turn,
284 progressive_compression,
285 rounds_per_summary,
286 max_summary_segments,
287 merge_count,
288 max_merges_per_segment,
289 }
290 }
291
292 pub fn truncation_threshold(&self) -> usize {
296 (self.max_tokens as f32 * self.truncation_ratio) as usize
297 }
298
299 pub fn available_tokens(&self) -> usize {
303 (self.max_tokens as f32 * (1.0 - self.reserve_ratio)) as usize
304 }
305
306 pub fn truncate_tool_output(&self, content: &str) -> String {
308 truncate_output(content, self.max_output_lines, self.max_output_bytes)
309 }
310
311 pub fn maybe_truncate(
323 &self,
324 messages: &mut Vec<ChatCompletionRequestMessage>,
325 ) -> TruncationResult {
326 let estimated = estimate_messages_tokens_with_margin(messages, self.token_safety_margin);
327 let threshold = self.truncation_threshold();
328
329 tracing::debug!("maybe_truncate: initial message count = {}, estimated tokens = {}, threshold = {}",
330 messages.len(), estimated, threshold);
331
332 if estimated <= threshold {
333 tracing::debug!("maybe_truncate: No truncation needed ({} <= {})", estimated, threshold);
334 return TruncationResult {
335 rounds_removed: 0,
336 messages_removed: 0,
337 removed_messages: Vec::new(),
338 insert_position: 0,
339 needs_compression: false,
340 action: TruncationAction::TruncateOnly,
341 };
342 }
343
344 tracing::info!(
345 "=== Context truncation triggered ==="
346 );
347 tracing::info!(
348 "Estimated: {} tokens (with {:.1}x margin), Threshold: {} tokens, Max: {} tokens",
349 estimated,
350 self.token_safety_margin,
351 threshold,
352 self.max_tokens
353 );
354 tracing::info!("Compression enabled: {}, Progressive: {}",
355 self.compression_enabled, self.progressive_compression);
356
357 if !self.progressive_compression {
359 tracing::info!("Progressive compression disabled, using legacy single-shot truncation");
360 return self.legacy_truncate(messages, threshold);
361 }
362
363 if let Some(result) = self.try_new_segment(messages) {
365 tracing::info!("Progressive action: NewSegment ({} rounds)", result.rounds_removed);
366 return result;
367 }
368
369 if let Some(result) = self.try_merge_segments(messages) {
370 tracing::info!("Progressive action: MergeSegments");
371 return result;
372 }
373
374 if let Some(result) = self.try_discard_oldest_segment(messages) {
375 tracing::info!("Progressive action: Discard oldest segment");
376 return result;
377 }
378
379 tracing::warn!("All progressive actions exhausted, falling back to legacy truncation");
381 self.legacy_truncate(messages, threshold)
382 }
383
384 fn try_new_segment(
389 &self,
390 messages: &mut Vec<ChatCompletionRequestMessage>,
391 ) -> Option<TruncationResult> {
392 if !self.compression_enabled {
393 return None;
394 }
395
396 let round_starts: Vec<usize> = messages
399 .iter()
400 .enumerate()
401 .filter(|(_, m)| is_user_message(m) && !is_summary_segment(m) && !is_discard_notice(m))
402 .map(|(i, _)| i)
403 .collect();
404
405 if round_starts.len() <= self.min_keep_rounds + self.rounds_per_summary {
406 tracing::debug!("Not enough full rounds to compress (have {}, need min_keep + rounds_per_summary = {})",
407 round_starts.len(), self.min_keep_rounds + self.rounds_per_summary);
408 return None;
409 }
410
411 let take_rounds = self.rounds_per_summary.min(round_starts.len() - self.min_keep_rounds);
413 if take_rounds == 0 {
414 return None;
415 }
416
417 let start_idx = round_starts[0];
418 let end_idx = if take_rounds < round_starts.len() {
419 round_starts[take_rounds]
420 } else {
421 messages.len()
422 };
423
424 let removed_messages: Vec<ChatCompletionRequestMessage> =
425 messages[start_idx..end_idx].to_vec();
426 let messages_removed = removed_messages.len();
427 let removed_tokens = estimate_messages_tokens(&removed_messages);
428
429 if removed_tokens < self.compression_token_threshold {
431 tracing::debug!("Removed tokens ({}) below compression threshold ({})",
432 removed_tokens, self.compression_token_threshold);
433 return None;
434 }
435
436 messages.drain(start_idx..end_idx);
438
439 let system_msg_count = messages.iter().take_while(|m| is_system_message(m)).count();
442 let has_discard = find_discard_notice_pos(messages).is_some();
443 let insert_pos = system_msg_count + if has_discard { 1 } else { 0 };
444
445 let placeholder = make_summary_placeholder(take_rounds);
446 messages.insert(insert_pos, placeholder);
447
448 tracing::info!(
449 "New summary segment: removed {} rounds ({} messages, ~{} tokens), insert at {}",
450 take_rounds, messages_removed, removed_tokens, insert_pos
451 );
452
453 Some(TruncationResult {
454 rounds_removed: take_rounds,
455 messages_removed,
456 removed_messages,
457 insert_position: insert_pos,
458 needs_compression: true,
459 action: TruncationAction::NewSegment,
460 })
461 }
462
463 fn try_merge_segments(
468 &self,
469 messages: &mut Vec<ChatCompletionRequestMessage>,
470 ) -> Option<TruncationResult> {
471 if !self.compression_enabled {
472 return None;
473 }
474
475 let segments = find_summary_segments(messages);
476 if segments.len() <= self.max_summary_segments {
477 tracing::debug!("Segment count ({}) within limit ({})", segments.len(), self.max_summary_segments);
478 return None;
479 }
480
481 let oldest = segments.first()?;
483 if oldest.merge_level >= self.max_merges_per_segment {
484 tracing::debug!("Oldest segment at merge level {} >= max {}, will discard instead",
485 oldest.merge_level, self.max_merges_per_segment);
486 return None;
487 }
488
489 let take_count = self.merge_count.min(segments.len());
491 if take_count < 2 {
492 return None;
493 }
494
495 let start_pos = segments[0].index;
496 let end_pos = segments[take_count - 1].index + 1;
497
498 let summaries: Vec<String> = segments[..take_count]
499 .iter()
500 .map(|s| s.content.clone())
501 .collect();
502
503 let max_level = segments[..take_count]
504 .iter()
505 .map(|s| s.merge_level)
506 .max()
507 .unwrap_or(0);
508 let new_level = max_level + 1;
509
510 messages.drain(start_pos..end_pos);
512
513 let placeholder = make_merge_placeholder(new_level, take_count);
515 messages.insert(start_pos, placeholder);
516
517 tracing::info!(
518 "Merging {} summary segments into one (level {}), start pos {}",
519 take_count, new_level, start_pos
520 );
521
522 Some(TruncationResult {
523 rounds_removed: 0,
524 messages_removed: take_count,
525 removed_messages: Vec::new(),
526 insert_position: start_pos,
527 needs_compression: true,
528 action: TruncationAction::MergeSegments {
529 summaries,
530 start_position: start_pos,
531 count: take_count,
532 },
533 })
534 }
535
536 fn try_discard_oldest_segment(
541 &self,
542 messages: &mut Vec<ChatCompletionRequestMessage>,
543 ) -> Option<TruncationResult> {
544 let segments = find_summary_segments(messages);
545 if segments.len() <= self.max_summary_segments {
546 return None;
547 }
548
549 let oldest = segments.first()?;
550 if oldest.merge_level < self.max_merges_per_segment {
551 return None;
553 }
554
555 let pos = oldest.index;
557 messages.remove(pos);
558
559 tracing::info!("Discarded oldest summary segment at position {}", pos);
560
561 let system_msg_count = messages.iter().take_while(|m| is_system_message(m)).count();
563 if find_discard_notice_pos(messages).is_none() {
564 let notice = make_discard_notice();
565 messages.insert(system_msg_count, notice);
566 tracing::debug!("Added discard notice at position {}", system_msg_count);
567 }
568
569 Some(TruncationResult {
570 rounds_removed: 0,
571 messages_removed: 1,
572 removed_messages: Vec::new(),
573 insert_position: pos,
574 needs_compression: false,
575 action: TruncationAction::TruncateOnly,
576 })
577 }
578
579 fn legacy_truncate(
584 &self,
585 messages: &mut Vec<ChatCompletionRequestMessage>,
586 threshold: usize,
587 ) -> TruncationResult {
588 let mut round_starts: Vec<usize> = Vec::new();
590 for (i, msg) in messages.iter().enumerate() {
591 if is_user_message(msg) {
592 round_starts.push(i);
593 }
594 }
595 tracing::debug!("Found {} user message round boundaries", round_starts.len());
596
597 if round_starts.is_empty() {
598 tracing::debug!("No user messages found, no truncation performed");
599 return TruncationResult {
600 rounds_removed: 0,
601 messages_removed: 0,
602 removed_messages: Vec::new(),
603 insert_position: 0,
604 needs_compression: false,
605 action: TruncationAction::TruncateOnly,
606 };
607 }
608
609 let total_rounds = round_starts.len();
610 let must_keep = self.min_keep_rounds.min(total_rounds);
611 tracing::debug!("Total rounds: {}, Must keep at least: {} rounds", total_rounds, must_keep);
612
613 let mut removed_messages: Vec<ChatCompletionRequestMessage> = Vec::new();
614 let mut rounds_removed = 0;
615 let mut messages_removed = 0;
616
617 while round_starts.len() > must_keep
618 && estimate_messages_tokens_with_margin(messages, self.token_safety_margin) > threshold
619 {
620 let start_idx = round_starts[0];
621 let end_idx = if round_starts.len() > 1 {
622 round_starts[1]
623 } else {
624 messages.len()
625 };
626
627 if self.compression_enabled {
628 removed_messages.extend(messages[start_idx..end_idx].to_vec());
629 }
630
631 let count = end_idx - start_idx;
632 messages.drain(start_idx..end_idx);
633
634 round_starts.remove(0);
635 for idx in round_starts.iter_mut() {
636 *idx = idx.saturating_sub(count);
637 }
638
639 rounds_removed += 1;
640 messages_removed += count;
641 }
642
643 if rounds_removed == 0 {
644 tracing::debug!("No rounds removed after checks");
645 return TruncationResult {
646 rounds_removed: 0,
647 messages_removed: 0,
648 removed_messages: Vec::new(),
649 insert_position: 0,
650 needs_compression: false,
651 action: TruncationAction::TruncateOnly,
652 };
653 }
654
655 let removed_tokens = estimate_messages_tokens(&removed_messages);
656 let needs_compression =
657 self.compression_enabled && removed_tokens >= self.compression_token_threshold;
658
659 let system_msg_count = messages
660 .iter()
661 .take_while(|m| is_system_message(m))
662 .count();
663
664 let notice = if needs_compression {
665 format!(
666 "[Context compressed: {} earlier rounds ({} messages, ~{} tokens) have been summarized. {} most recent rounds preserved.]",
667 rounds_removed, messages_removed, removed_tokens, round_starts.len()
668 )
669 } else {
670 format!(
671 "[Context truncated: {} earlier rounds ({} messages) removed to stay within token limit. {} most recent rounds preserved.]",
672 rounds_removed, messages_removed, round_starts.len()
673 )
674 };
675
676 let notice_msg = ChatCompletionRequestMessage::User(
677 async_openai::types::chat::ChatCompletionRequestUserMessage {
678 content: notice.into(),
679 name: Some("system_notice".to_string()),
680 }
681 .into(),
682 );
683
684 messages.insert(system_msg_count, notice_msg);
685
686 tracing::info!(
687 "Legacy truncation: removed {} rounds ({} messages), kept {} rounds",
688 rounds_removed, messages_removed, round_starts.len()
689 );
690
691 TruncationResult {
692 rounds_removed,
693 messages_removed,
694 removed_messages,
695 insert_position: system_msg_count,
696 needs_compression,
697 action: if needs_compression {
698 TruncationAction::NewSegment
701 } else {
702 TruncationAction::TruncateOnly
703 },
704 }
705 }
706}
707
708fn is_user_message(msg: &ChatCompletionRequestMessage) -> bool {
709 matches!(msg, ChatCompletionRequestMessage::User(_))
710}
711
712fn is_system_message(msg: &ChatCompletionRequestMessage) -> bool {
713 matches!(msg, ChatCompletionRequestMessage::System(_))
714}
715
716const SUMMARY_SEGMENT_PREFIX: &str = "summary_segment";
721const DISCARD_NOTICE_NAME: &str = "discard_notice";
722const LEGACY_NOTICE_NAME: &str = "system_notice";
723
724fn user_message_name(msg: &ChatCompletionRequestMessage) -> Option<&str> {
726 match msg {
727 ChatCompletionRequestMessage::User(u) => u.name.as_deref(),
728 _ => None,
729 }
730}
731
732fn user_message_text(msg: &ChatCompletionRequestMessage) -> String {
734 match msg {
735 ChatCompletionRequestMessage::User(u) => match &u.content {
736 ChatCompletionRequestUserMessageContent::Text(t) => t.clone(),
737 ChatCompletionRequestUserMessageContent::Array(parts) => parts
738 .iter()
739 .filter_map(|p| match p {
740 async_openai::types::chat::ChatCompletionRequestUserMessageContentPart::Text(t) => Some(t.text.as_str()),
741 _ => None,
742 })
743 .collect::<Vec<_>>()
744 .join(" "),
745 },
746 _ => String::new(),
747 }
748}
749
750pub fn is_summary_segment(msg: &ChatCompletionRequestMessage) -> bool {
752 match user_message_name(msg) {
753 Some(name) => {
754 name.starts_with(SUMMARY_SEGMENT_PREFIX) || name == LEGACY_NOTICE_NAME
755 }
756 None => false,
757 }
758}
759
760pub fn get_merge_level(msg: &ChatCompletionRequestMessage) -> usize {
763 match user_message_name(msg) {
764 Some(name) => {
765 if name == LEGACY_NOTICE_NAME {
766 0
767 } else if let Some(suffix) = name.strip_prefix(SUMMARY_SEGMENT_PREFIX) {
768 if suffix.is_empty() {
769 0
770 } else if let Some(num_str) = suffix.strip_prefix("_m") {
771 num_str.parse::<usize>().unwrap_or(0)
772 } else {
773 0
774 }
775 } else {
776 0
777 }
778 }
779 None => 0,
780 }
781}
782
783fn is_discard_notice(msg: &ChatCompletionRequestMessage) -> bool {
785 matches!(user_message_name(msg), Some(name) if name == DISCARD_NOTICE_NAME)
786}
787
788fn summary_segment_name(merge_level: usize) -> String {
790 if merge_level == 0 {
791 SUMMARY_SEGMENT_PREFIX.to_string()
792 } else {
793 format!("{}_m{}", SUMMARY_SEGMENT_PREFIX, merge_level)
794 }
795}
796
797fn make_summary_placeholder(rounds_removed: usize) -> ChatCompletionRequestMessage {
799 let text = format!(
800 "[Compressing {} earlier conversation rounds into a summary...]",
801 rounds_removed
802 );
803 ChatCompletionRequestMessage::User(
804 ChatCompletionRequestUserMessage {
805 content: text.into(),
806 name: Some(summary_segment_name(0)),
807 }
808 .into(),
809 )
810}
811
812fn make_merge_placeholder(merge_level: usize, count: usize) -> ChatCompletionRequestMessage {
814 let text = format!(
815 "[Merging {} earlier summary segments...]",
816 count
817 );
818 ChatCompletionRequestMessage::User(
819 ChatCompletionRequestUserMessage {
820 content: text.into(),
821 name: Some(summary_segment_name(merge_level)),
822 }
823 .into(),
824 )
825}
826
827fn make_discard_notice() -> ChatCompletionRequestMessage {
829 let text = "[Note: Earlier conversation history beyond the earliest summary has been discarded to save context space.]";
830 ChatCompletionRequestMessage::User(
831 ChatCompletionRequestUserMessage {
832 content: text.into(),
833 name: Some(DISCARD_NOTICE_NAME.to_string()),
834 }
835 .into(),
836 )
837}
838
839#[derive(Debug, Clone)]
841struct SummarySegmentInfo {
842 index: usize,
843 merge_level: usize,
844 content: String,
845}
846
847fn find_summary_segments(messages: &[ChatCompletionRequestMessage]) -> Vec<SummarySegmentInfo> {
849 let mut segments = Vec::new();
850 for (i, msg) in messages.iter().enumerate() {
851 if is_summary_segment(msg) {
852 segments.push(SummarySegmentInfo {
853 index: i,
854 merge_level: get_merge_level(msg),
855 content: user_message_text(msg),
856 });
857 }
858 }
859 segments
860}
861
862fn find_discard_notice_pos(messages: &[ChatCompletionRequestMessage]) -> Option<usize> {
864 messages.iter().position(is_discard_notice)
865}
866
867pub fn format_removed_messages_as_transcript(
875 messages: &[ChatCompletionRequestMessage],
876) -> String {
877 let mut transcript = String::new();
878
879 for msg in messages {
880 match msg {
881 ChatCompletionRequestMessage::User(user_msg) => {
882 let text = match &user_msg.content {
883 async_openai::types::chat::ChatCompletionRequestUserMessageContent::Text(t) => {
884 t.clone()
885 }
886 async_openai::types::chat::ChatCompletionRequestUserMessageContent::Array(parts) => {
887 parts.iter()
888 .filter_map(|p| match p {
889 async_openai::types::chat::ChatCompletionRequestUserMessageContentPart::Text(t) => Some(t.text.as_str()),
890 _ => None,
891 })
892 .collect::<Vec<_>>()
893 .join(" ")
894 }
895 };
896 let truncated = truncate_str(&text, 200);
897 transcript.push_str(&format!("User: {}\n", truncated));
898 }
899 ChatCompletionRequestMessage::Assistant(assistant_msg) => {
900 let content_str = assistant_msg.content.as_ref().map(|c| {
901 match serde_json::to_string(c) {
902 Ok(json) => json.trim_matches('"').to_string(),
903 Err(_) => format!("{:?}", c),
904 }
905 }).unwrap_or_default();
906 let truncated = truncate_str(&content_str, 300);
907 transcript.push_str(&format!("Assistant: {}\n", truncated));
908 if let Some(tool_calls) = &assistant_msg.tool_calls {
909 for tc in tool_calls {
910 if let async_openai::types::chat::ChatCompletionMessageToolCalls::Function(f) = tc {
911 transcript.push_str(&format!(
912 " [Tool: {}({})]\n",
913 f.function.name,
914 truncate_str(&f.function.arguments, 100)
915 ));
916 }
917 }
918 }
919 }
920 ChatCompletionRequestMessage::Tool(tool_msg) => {
921 let content_str = match serde_json::to_string(&tool_msg.content) {
922 Ok(json) => json.trim_matches('"').to_string(),
923 Err(_) => format!("{:?}", tool_msg.content),
924 };
925 let truncated = truncate_str(&content_str, 150);
926 transcript.push_str(&format!(" [Result: {}]\n", truncated));
927 }
928 _ => {}
929 }
930 }
931
932 if transcript.is_empty() {
933 transcript.push_str("(no conversation content)");
934 }
935
936 transcript
937}
938
939fn truncate_str(s: &str, max_chars: usize) -> String {
942 if s.len() <= max_chars {
943 s.to_string()
944 } else {
945 let mut end = max_chars;
946 while end > 0 && !s.is_char_boundary(end) {
947 end -= 1;
948 }
949 format!("{}...", &s[..end])
950 }
951}
952
953#[cfg(test)]
958mod tests {
959 use super::*;
960 use async_openai::types::chat::ChatCompletionRequestUserMessage;
961
962 fn make_user_message(content: &str) -> ChatCompletionRequestMessage {
963 ChatCompletionRequestMessage::User(
964 ChatCompletionRequestUserMessage {
965 content: content.into(),
966 name: None,
967 }
968 .into(),
969 )
970 }
971
972 fn make_system_message(content: &str) -> ChatCompletionRequestMessage {
973 ChatCompletionRequestMessage::System(
974 async_openai::types::chat::ChatCompletionRequestSystemMessage {
975 content: content.into(),
976 name: None,
977 }
978 .into(),
979 )
980 }
981
982 fn make_test_config() -> ContextConfig {
983 ContextConfig {
984 max_output_lines: Some(500),
985 max_output_bytes: Some(51200),
986 reserve_ratio: Some(0.2),
987 truncation_ratio: Some(0.7),
988 min_keep_rounds: Some(3),
989 token_safety_margin: Some(1.3),
990 compression_token_threshold: Some(5000),
991 compression_enabled: Some(true),
992 max_tool_calls_per_turn: Some(30),
993 progressive_compression: Some(true),
994 rounds_per_summary: Some(3),
995 max_summary_segments: Some(5),
996 merge_count: Some(2),
997 max_merges_per_segment: Some(2),
998 }
999 }
1000
1001 fn make_user_message_named(content: &str, name: &str) -> ChatCompletionRequestMessage {
1002 ChatCompletionRequestMessage::User(
1003 ChatCompletionRequestUserMessage {
1004 content: content.into(),
1005 name: Some(name.to_string()),
1006 }
1007 .into(),
1008 )
1009 }
1010
1011 fn make_summary_segment(content: &str, merge_level: usize) -> ChatCompletionRequestMessage {
1012 let name = if merge_level == 0 {
1013 "summary_segment".to_string()
1014 } else {
1015 format!("summary_segment_m{}", merge_level)
1016 };
1017 make_user_message_named(content, &name)
1018 }
1019
1020 fn make_legacy_notice(content: &str) -> ChatCompletionRequestMessage {
1021 make_user_message_named(content, "system_notice")
1022 }
1023
1024 #[test]
1036 fn test_estimate_tokens_english() {
1037 let text = "Hello world, this is a test of the token estimation system.";
1038 let tokens = estimate_tokens(text);
1039 assert!(tokens >= 10, "Expected at least 10 tokens, got {}", tokens);
1040 assert!(tokens <= 30, "Expected at most 30 tokens, got {}", tokens);
1041 }
1042
1043 #[test]
1044 fn test_estimate_tokens_chinese() {
1045 let chinese = "你好世界,这是一个测试。";
1046 let tokens = estimate_tokens(chinese);
1047 assert!(tokens >= 5, "Expected at least 5 tokens, got {}", tokens);
1048 assert!(tokens <= 15, "Expected at most 15 tokens, got {}", tokens);
1049 }
1050
1051 #[test]
1052 fn test_estimate_tokens_code() {
1053 let code = "fn main() {\n println!(\"Hello\");\n}";
1054 let tokens = estimate_tokens(code);
1055 assert!(tokens >= 10, "Expected at least 10 tokens, got {}", tokens);
1056 assert!(tokens <= 40, "Expected at most 40 tokens, got {}", tokens);
1057 }
1058
1059 #[test]
1060 fn test_estimate_tokens_empty() {
1061 assert_eq!(estimate_tokens(""), 0);
1062 }
1063
1064 #[test]
1065 fn test_estimate_tokens_mixed() {
1066 let mixed = "Hello 你好 world 世界!fn test() {}";
1067 let tokens = estimate_tokens(mixed);
1068 assert!(tokens > 0);
1069 assert!(tokens <= 40, "Expected at most 40 tokens, got {}", tokens);
1070 }
1071
1072 #[test]
1073 fn test_estimate_messages_tokens_with_margin() {
1074 let messages = vec![
1075 make_system_message("You are a helpful assistant"),
1076 make_user_message("Hello world"),
1077 ];
1078 let raw = estimate_messages_tokens(&messages);
1079 let with_margin = estimate_messages_tokens_with_margin(&messages, 1.3);
1080 assert!(with_margin > raw);
1081 let expected = (raw as f32 * 1.3).ceil() as usize;
1083 assert_eq!(with_margin, expected);
1084 }
1085
1086 #[test]
1091 fn test_truncation_threshold() {
1092 let config = make_test_config();
1093 let manager = ContextManager::new(Some(65536), Some(&config));
1094 assert_eq!(manager.truncation_threshold(), 45875);
1096 }
1097
1098 #[test]
1099 fn test_truncation_result_no_truncation() {
1100 let mut messages = vec![
1101 make_system_message("You are a helpful assistant"),
1102 make_user_message("Hello"),
1103 ];
1104
1105 let config = make_test_config();
1106 let manager = ContextManager::new(Some(65536), Some(&config));
1107 let result = manager.maybe_truncate(&mut messages);
1108
1109 assert_eq!(result.rounds_removed, 0);
1110 assert!(!result.needs_compression);
1111 }
1112
1113 #[test]
1114 fn test_truncation_respects_min_keep_rounds() {
1115 let mut messages = vec![
1116 make_system_message("You are a helpful assistant"),
1117 ];
1118
1119 for i in 0..10 {
1121 let content = format!("User message {}: {}", i, "x".repeat(2000));
1122 messages.push(make_user_message(&content));
1123 }
1124
1125 let mut config = make_test_config();
1126 config.min_keep_rounds = Some(3); let manager = ContextManager::new(Some(5000), Some(&config));
1130 let result = manager.maybe_truncate(&mut messages);
1131
1132 assert!(
1134 result.rounds_removed > 0,
1135 "Should have removed some rounds"
1136 );
1137 let user_count = messages
1139 .iter()
1140 .filter(|m| matches!(m, ChatCompletionRequestMessage::User(_)))
1141 .count();
1142 assert!(
1143 user_count >= 4,
1144 "Should have at least 3 user rounds + notice, got {}",
1145 user_count
1146 );
1147 }
1148
1149 #[test]
1150 fn test_truncation_early_trigger() {
1151 let mut messages = vec![
1152 make_system_message("You are a helpful assistant"),
1153 ];
1154
1155 for i in 0..8 {
1157 let content = format!("User message {}: {}", i, "x".repeat(2000));
1158 messages.push(make_user_message(&content));
1159 }
1160
1161 let mut config = make_test_config();
1162 config.truncation_ratio = Some(0.7);
1163 config.min_keep_rounds = Some(2);
1164 config.token_safety_margin = Some(1.3);
1165
1166 let manager = ContextManager::new(Some(65536), Some(&config));
1169 let result = manager.maybe_truncate(&mut messages);
1170 assert_eq!(
1171 result.rounds_removed, 0,
1172 "Should not truncate small messages in large window"
1173 );
1174
1175 let manager2 = ContextManager::new(Some(8000), Some(&config));
1177 let mut messages2 = messages.clone();
1178 let result2 = manager2.maybe_truncate(&mut messages2);
1179 assert!(
1180 result2.rounds_removed > 0,
1181 "Should truncate when exceeding small window"
1182 );
1183 }
1184
1185 #[test]
1186 fn test_token_safety_margin_effect() {
1187 let mut messages = vec![
1188 make_system_message("You are a helpful assistant"),
1189 ];
1190
1191 for i in 0..10 {
1192 let content = format!("User message {}: {}", i, "x".repeat(500));
1193 messages.push(make_user_message(&content));
1194 }
1195
1196 let mut config_low = make_test_config();
1198 config_low.token_safety_margin = Some(1.0);
1199 config_low.truncation_ratio = Some(0.7);
1200 config_low.min_keep_rounds = Some(1);
1201
1202 let mut msgs_low = messages.clone();
1203 let manager_low = ContextManager::new(Some(8000), Some(&config_low));
1204 let result_low = manager_low.maybe_truncate(&mut msgs_low);
1205
1206 let mut config_high = make_test_config();
1208 config_high.token_safety_margin = Some(2.0);
1209 config_high.truncation_ratio = Some(0.7);
1210 config_high.min_keep_rounds = Some(1);
1211
1212 let mut msgs_high = messages.clone();
1213 let manager_high = ContextManager::new(Some(8000), Some(&config_high));
1214 let result_high = manager_high.maybe_truncate(&mut msgs_high);
1215
1216 assert!(
1218 result_high.rounds_removed >= result_low.rounds_removed,
1219 "Higher safety margin should trigger at least as much truncation: high={}, low={}",
1220 result_high.rounds_removed,
1221 result_low.rounds_removed
1222 );
1223 }
1224
1225 #[test]
1226 fn test_compression_flag_in_old_tests() {
1227 let mut messages = vec![
1228 make_system_message("You are a helpful assistant"),
1229 ];
1230
1231 for i in 0..20 {
1233 let content = format!("User message {}: {}", i, "x".repeat(2000));
1234 messages.push(make_user_message(&content));
1235 }
1236
1237 let mut config = make_test_config();
1238 config.compression_enabled = Some(false);
1239 config.compression_token_threshold = Some(1000);
1240 config.min_keep_rounds = Some(1);
1241
1242 let manager = ContextManager::new(Some(5000), Some(&config));
1243 let result = manager.maybe_truncate(&mut messages);
1244
1245 assert!(result.rounds_removed > 0);
1246 assert!(
1247 !result.needs_compression,
1248 "Should be false when compression disabled"
1249 );
1250 }
1251
1252 #[test]
1253 fn test_truncate_output() {
1254 let content = "line1\nline2\nline3\nline4\nline5";
1255 let truncated = truncate_output(content, 3, 100);
1256 assert!(truncated.contains("line1"));
1257 assert!(truncated.contains("line2"));
1258 assert!(truncated.contains("line3"));
1259 assert!(!truncated.contains("line4"));
1260 assert!(truncated.contains("Output truncated"));
1261 }
1262
1263 #[test]
1268 fn test_truncate_str_no_truncation() {
1269 let result = truncate_str("hello", 10);
1270 assert_eq!(result, "hello");
1271 }
1272
1273 #[test]
1274 fn test_truncate_str_with_truncation() {
1275 let result = truncate_str("hello world this is long", 10);
1276 assert_eq!(result, "hello worl...");
1277 }
1278
1279 #[test]
1280 fn test_format_transcript_user_and_assistant() {
1281 let messages = vec![
1282 make_user_message("Fix the bug in auth.rs"),
1283 make_system_message("System message should be skipped"),
1284 ];
1285
1286 let transcript = format_removed_messages_as_transcript(&messages);
1287 assert!(transcript.contains("User: Fix the bug in auth.rs"));
1288 assert!(!transcript.contains("System message"), "System messages should be skipped");
1289 }
1290
1291 #[test]
1292 fn test_format_transcript_empty() {
1293 let messages: Vec<ChatCompletionRequestMessage> = vec![];
1294 let transcript = format_removed_messages_as_transcript(&messages);
1295 assert!(transcript.contains("no conversation content"));
1296 }
1297
1298 #[test]
1299 fn test_format_transcript_truncates_long_messages() {
1300 let long_text = "x".repeat(500);
1301 let messages = vec![
1302 make_user_message(&long_text),
1303 ];
1304
1305 let transcript = format_removed_messages_as_transcript(&messages);
1306 assert!(transcript.contains("..."));
1307 assert!(transcript.len() < long_text.len() + 50);
1309 }
1310
1311 #[test]
1316 fn test_is_summary_segment_recognizes_all_levels() {
1317 let m0 = make_summary_segment("summary 0", 0);
1318 let m1 = make_summary_segment("summary 1", 1);
1319 let m2 = make_summary_segment("summary 2", 2);
1320 let legacy = make_legacy_notice("old notice");
1321 let normal = make_user_message("hello");
1322
1323 assert!(is_summary_segment(&m0));
1324 assert!(is_summary_segment(&m1));
1325 assert!(is_summary_segment(&m2));
1326 assert!(is_summary_segment(&legacy));
1327 assert!(!is_summary_segment(&normal));
1328 }
1329
1330 #[test]
1331 fn test_get_merge_level() {
1332 assert_eq!(get_merge_level(&make_summary_segment("a", 0)), 0);
1333 assert_eq!(get_merge_level(&make_summary_segment("b", 1)), 1);
1334 assert_eq!(get_merge_level(&make_summary_segment("c", 2)), 2);
1335 assert_eq!(get_merge_level(&make_legacy_notice("d")), 0);
1336 assert_eq!(get_merge_level(&make_user_message("e")), 0);
1337 }
1338
1339 #[test]
1340 fn test_progressive_no_truncation_needed() {
1341 let mut messages = vec![
1342 make_system_message("sys"),
1343 make_user_message("hi"),
1344 ];
1345 let config = make_test_config();
1346 let manager = ContextManager::new(Some(65536), Some(&config));
1347 let result = manager.maybe_truncate(&mut messages);
1348
1349 assert_eq!(result.rounds_removed, 0);
1350 assert!(!result.needs_compression);
1351 assert_eq!(result.action, TruncationAction::TruncateOnly);
1352 }
1353
1354 #[test]
1355 fn test_progressive_new_segment() {
1356 let mut messages = vec![make_system_message("sys")];
1357 for i in 0..10 {
1359 let content = format!("Round {}: {}", i, "x".repeat(2000));
1360 messages.push(make_user_message(&content));
1361 }
1362
1363 let mut config = make_test_config();
1364 config.min_keep_rounds = Some(3);
1365 config.rounds_per_summary = Some(3);
1366 config.compression_token_threshold = Some(100); let manager = ContextManager::new(Some(8000), Some(&config));
1369 let result = manager.maybe_truncate(&mut messages);
1370
1371 assert_eq!(result.action, TruncationAction::NewSegment);
1372 assert!(result.needs_compression);
1373 assert_eq!(result.rounds_removed, 3);
1374 assert!(result.removed_messages.len() > 0);
1375
1376 let has_seg = messages.iter().any(|m| is_summary_segment(m));
1378 assert!(has_seg, "Should have a summary segment placeholder");
1379 }
1380
1381 #[test]
1382 fn test_progressive_disabled_falls_back_to_legacy() {
1383 let mut messages = vec![make_system_message("sys")];
1384 for i in 0..20 {
1386 let content = format!("Round {}: {}", i, "x".repeat(2000));
1387 messages.push(make_user_message(&content));
1388 }
1389
1390 let mut config = make_test_config();
1391 config.progressive_compression = Some(false);
1392 config.min_keep_rounds = Some(3);
1393 config.compression_token_threshold = Some(100);
1394
1395 let manager = ContextManager::new(Some(8000), Some(&config));
1396 let result = manager.maybe_truncate(&mut messages);
1397
1398 assert!(result.rounds_removed > 3,
1401 "Legacy should remove more than rounds_per_summary (3) rounds, removed {}",
1402 result.rounds_removed);
1403 assert!(matches!(result.action, TruncationAction::NewSegment));
1405 }
1406
1407 #[test]
1408 fn test_progressive_merge_segments() {
1409 let mut messages = vec![make_system_message("sys")];
1412 for i in 0..6 {
1414 let content = format!("Summary {}: {}", i, "x".repeat(500));
1415 messages.push(make_summary_segment(&content, 0));
1416 }
1417 for i in 0..2 {
1419 let content = format!("User {}: {}", i, "x".repeat(300));
1420 messages.push(make_user_message(&content));
1421 }
1422
1423 let mut config = make_test_config();
1424 config.max_summary_segments = Some(5);
1425 config.merge_count = Some(2);
1426 config.min_keep_rounds = Some(3);
1427 config.rounds_per_summary = Some(3);
1428 config.compression_token_threshold = Some(10);
1429
1430 let manager = ContextManager::new(Some(2000), Some(&config));
1432
1433 let estimated = estimate_messages_tokens_with_margin(&messages, 1.3);
1435 assert!(estimated > manager.truncation_threshold(),
1436 "Test setup error: should be over threshold, est={}, threshold={}",
1437 estimated, manager.truncation_threshold());
1438
1439 let result = manager.maybe_truncate(&mut messages);
1440
1441 match &result.action {
1444 TruncationAction::MergeSegments { summaries, start_position, count } => {
1445 assert_eq!(*count, 2, "Should merge 2 segments");
1446 assert_eq!(summaries.len(), 2);
1447 assert!(*start_position >= 1, "Start after system message");
1448 }
1449 other => {
1450 panic!("Expected MergeSegments, got {:?}", other);
1451 }
1452 }
1453
1454 let seg_count = messages.iter().filter(|m| is_summary_segment(m)).count();
1456 assert_eq!(seg_count, 5, "Should have 5 segments after merge");
1457 }
1458
1459 #[test]
1460 fn test_progressive_discard_after_merge_limit() {
1461 let mut messages = vec![make_system_message("sys")];
1463 for i in 0..6 {
1464 messages.push(make_summary_segment(&format!("Old summary {}", i), 2));
1465 }
1466 for i in 0..5 {
1467 let content = format!("User {}: {}", i, "x".repeat(2000));
1468 messages.push(make_user_message(&content));
1469 }
1470
1471 let mut config = make_test_config();
1472 config.max_summary_segments = Some(5);
1473 config.max_merges_per_segment = Some(2);
1474 config.merge_count = Some(2);
1475
1476 let manager = ContextManager::new(Some(8000), Some(&config));
1477
1478 let mut did_discard = false;
1480 for _ in 0..5 {
1481 let result = manager.maybe_truncate(&mut messages);
1482 if result.rounds_removed == 0 && result.messages_removed > 0 && !result.needs_compression {
1483 if find_discard_notice_pos(&messages).is_some() {
1485 did_discard = true;
1486 break;
1487 }
1488 }
1489 }
1490
1491 let seg_count = messages.iter().filter(|m| is_summary_segment(m)).count();
1493 assert!(
1494 seg_count <= 6,
1495 "Segment count should decrease or stay same, got {}",
1496 seg_count
1497 );
1498
1499 let _ = did_discard;
1501 }
1502
1503 #[test]
1504 fn test_legacy_notice_recognized_as_summary_segment() {
1505 let mut messages = vec![
1506 make_system_message("sys"),
1507 make_legacy_notice("[Old compressed context notice]"),
1508 ];
1509 for i in 0..8 {
1511 let content = format!("User {}: {}", i, "x".repeat(2000));
1512 messages.push(make_user_message(&content));
1513 }
1514
1515 let mut config = make_test_config();
1516 config.min_keep_rounds = Some(3);
1517 config.max_summary_segments = Some(5);
1518 config.compression_token_threshold = Some(100);
1519
1520 let manager = ContextManager::new(Some(8000), Some(&config));
1521 let result = manager.maybe_truncate(&mut messages);
1522
1523 assert!(result.messages_removed > 0 || result.rounds_removed > 0);
1525 }
1526
1527 #[test]
1528 fn test_discard_notice_inserted_once() {
1529 let mut messages = vec![
1530 make_system_message("sys"),
1531 make_summary_segment("old seg 1", 2),
1532 make_summary_segment("old seg 2", 2),
1533 make_summary_segment("old seg 3", 2),
1534 make_summary_segment("old seg 4", 2),
1535 make_summary_segment("old seg 5", 2),
1536 make_summary_segment("old seg 6", 2),
1537 ];
1538 for i in 0..4 {
1539 let content = format!("User {}: {}", i, "x".repeat(2000));
1540 messages.push(make_user_message(&content));
1541 }
1542
1543 let mut config = make_test_config();
1544 config.max_summary_segments = Some(5);
1545 config.max_merges_per_segment = Some(2);
1546
1547 let manager = ContextManager::new(Some(6000), Some(&config));
1548
1549 for _ in 0..3 {
1551 let _ = manager.maybe_truncate(&mut messages);
1552 }
1553
1554 let discard_count = messages.iter().filter(|m| is_discard_notice(m)).count();
1556 assert!(
1557 discard_count <= 1,
1558 "Should have at most 1 discard notice, found {}",
1559 discard_count
1560 );
1561 }
1562
1563 #[test]
1564 fn test_multiple_progressive_rounds_gradual() {
1565 let mut messages = vec![make_system_message("sys")];
1567 for i in 0..20 {
1568 let content = format!("Round {} user message: {}", i, "x".repeat(1500));
1569 messages.push(make_user_message(&content));
1570 }
1571
1572 let mut config = make_test_config();
1573 config.min_keep_rounds = Some(3);
1574 config.rounds_per_summary = Some(3);
1575 config.max_summary_segments = Some(4);
1576 config.compression_token_threshold = Some(100);
1577
1578 let manager = ContextManager::new(Some(10000), Some(&config));
1579
1580 let mut seg_count_before = 0;
1581 let mut did_new_segment = false;
1582 let mut did_merge = false;
1583
1584 for round in 0..10 {
1585 let result = manager.maybe_truncate(&mut messages);
1586
1587 let seg_count = messages.iter().filter(|m| is_summary_segment(m)).count();
1588
1589 match &result.action {
1590 TruncationAction::NewSegment => {
1591 did_new_segment = true;
1592 assert_eq!(result.rounds_removed, 3);
1593 assert!(seg_count > seg_count_before || seg_count_before == 0);
1594 }
1595 TruncationAction::MergeSegments { .. } => {
1596 did_merge = true;
1597 assert!(seg_count <= seg_count_before);
1598 }
1599 TruncationAction::TruncateOnly => {
1600 }
1602 }
1603
1604 seg_count_before = seg_count;
1605
1606 let estimated = estimate_messages_tokens_with_margin(&messages, 1.3);
1607 if estimated <= manager.truncation_threshold() {
1608 break;
1609 }
1610
1611 tracing::debug!("Round {}: segments={}, estimated={}", round, seg_count, estimated);
1612 }
1613
1614 assert!(did_new_segment, "Should have created at least one new summary segment");
1616 let _ = did_merge;
1617 }
1618}