1use async_openai::types::chat::ChatCompletionRequestMessage;
4use robit_ai::config::ContextConfig;
5
6#[derive(Debug)]
12pub struct TruncationResult {
13 pub rounds_removed: usize,
15 pub messages_removed: usize,
17 pub removed_messages: Vec<ChatCompletionRequestMessage>,
19 pub insert_position: usize,
21 pub needs_compression: bool,
23}
24
25pub fn truncate_output(content: &str, max_lines: usize, max_bytes: usize) -> String {
31 let lines: Vec<&str> = content.lines().collect();
32 let total_lines = lines.len();
33 let total_bytes = content.len();
34
35 let line_truncated = total_lines > max_lines;
37 let byte_truncated = total_bytes > max_bytes;
38
39 if !line_truncated && !byte_truncated {
40 return content.to_string();
41 }
42
43 let mut output = String::new();
44 let mut byte_count = 0;
45 let mut displayed_lines = 0;
46
47 for (i, line) in lines.iter().enumerate() {
48 if i >= max_lines {
49 break;
50 }
51 let line_with_newline = if i < total_lines - 1 {
52 format!("{}\n", line)
53 } else {
54 line.to_string()
55 };
56
57 if byte_count + line_with_newline.len() > max_bytes {
58 break;
59 }
60
61 output.push_str(&line_with_newline);
62 byte_count += line_with_newline.len();
63 displayed_lines += 1;
64 }
65
66 if line_truncated {
67 output.push_str(&format!(
68 "\n... (Output truncated, {} lines total, showing first {}. Use offset/limit to read more)",
69 total_lines, displayed_lines
70 ));
71 } else if byte_truncated {
72 output.push_str(&format!(
73 "\n... (Output truncated, {} bytes total, showing first {} bytes)",
74 total_bytes, byte_count
75 ));
76 }
77
78 output
79}
80
81pub fn estimate_tokens(text: &str) -> usize {
96 if text.is_empty() {
97 return 0;
98 }
99
100 let mut ascii_alnum = 0usize;
101 let mut cjk = 0usize;
102 let mut whitespace = 0usize;
103 let mut other = 0usize; for ch in text.chars() {
106 if ch.is_whitespace() {
107 whitespace += 1;
108 } else if ch.is_ascii_alphanumeric() {
109 ascii_alnum += 1;
110 } else {
111 let cp = ch as u32;
112 if (0x4E00..=0x9FFF).contains(&cp)
115 || (0x3400..=0x4DBF).contains(&cp)
116 || (0xF900..=0xFAFF).contains(&cp)
117 || (0xFF00..=0xFFEF).contains(&cp)
118 || (0x3000..=0x303F).contains(&cp)
119 || (0x3040..=0x309F).contains(&cp)
120 || (0x30A0..=0x30FF).contains(&cp)
121 || (0xAC00..=0xD7AF).contains(&cp)
122 {
123 cjk += 1;
124 } else {
125 other += 1;
126 }
127 }
128 }
129
130 let ascii_tokens = (ascii_alnum as f64 / 3.5).ceil() as usize;
136 let cjk_tokens = (cjk as f64 / 1.5).ceil() as usize;
137 let whitespace_tokens = (whitespace as f64 / 10.0).ceil() as usize;
138 let other_tokens = other; ascii_tokens + cjk_tokens + whitespace_tokens + other_tokens
141}
142
143pub fn estimate_messages_tokens(messages: &[ChatCompletionRequestMessage]) -> usize {
145 let mut total = 0;
146 for msg in messages {
147 total += 4;
149 total += estimate_message_content_tokens(msg);
150 }
151 total
152}
153
154pub fn estimate_messages_tokens_with_margin(
156 messages: &[ChatCompletionRequestMessage],
157 safety_margin: f32,
158) -> usize {
159 let raw = estimate_messages_tokens(messages);
160 (raw as f32 * safety_margin).ceil() as usize
161}
162
163fn estimate_message_content_tokens(msg: &ChatCompletionRequestMessage) -> usize {
165 match serde_json::to_string(msg) {
166 Ok(json) => estimate_tokens(&json),
167 Err(_) => 0,
168 }
169}
170
171pub struct ContextManager {
177 pub max_tokens: usize,
179 pub reserve_ratio: f32,
181 pub truncation_ratio: f32,
183 pub min_keep_rounds: usize,
185 pub token_safety_margin: f32,
187 pub max_output_lines: usize,
189 pub max_output_bytes: usize,
191 pub compression_token_threshold: usize,
193 pub compression_enabled: bool,
195 pub max_tool_calls_per_turn: usize,
197}
198
199impl ContextManager {
200 pub fn new(context_window: Option<u64>, config: Option<&ContextConfig>) -> Self {
201 let max_tokens = context_window.unwrap_or(65536) as usize;
202
203 let (
204 max_output_lines,
205 max_output_bytes,
206 reserve_ratio,
207 truncation_ratio,
208 min_keep_rounds,
209 token_safety_margin,
210 compression_token_threshold,
211 compression_enabled,
212 max_tool_calls_per_turn,
213 ) = match config {
214 Some(c) => (
215 c.max_output_lines.unwrap_or(500),
216 c.max_output_bytes.unwrap_or(51200),
217 c.reserve_ratio.unwrap_or(0.2),
218 c.truncation_ratio.unwrap_or(0.7),
219 c.min_keep_rounds.unwrap_or(3),
220 c.token_safety_margin.unwrap_or(1.3),
221 c.compression_token_threshold.unwrap_or(5000),
222 c.compression_enabled.unwrap_or(true),
223 c.max_tool_calls_per_turn.unwrap_or(30),
224 ),
225 None => (500, 51200, 0.2, 0.7, 3, 1.3, 5000, true, 30),
226 };
227
228 Self {
229 max_tokens,
230 reserve_ratio,
231 truncation_ratio,
232 min_keep_rounds,
233 token_safety_margin,
234 max_output_lines,
235 max_output_bytes,
236 compression_token_threshold,
237 compression_enabled,
238 max_tool_calls_per_turn,
239 }
240 }
241
242 pub fn truncation_threshold(&self) -> usize {
246 (self.max_tokens as f32 * self.truncation_ratio) as usize
247 }
248
249 pub fn available_tokens(&self) -> usize {
253 (self.max_tokens as f32 * (1.0 - self.reserve_ratio)) as usize
254 }
255
256 pub fn truncate_tool_output(&self, content: &str) -> String {
258 truncate_output(content, self.max_output_lines, self.max_output_bytes)
259 }
260
261 pub fn maybe_truncate(
270 &self,
271 messages: &mut Vec<ChatCompletionRequestMessage>,
272 ) -> TruncationResult {
273 let estimated = estimate_messages_tokens_with_margin(messages, self.token_safety_margin);
274 let threshold = self.truncation_threshold();
275
276 if estimated <= threshold {
277 return TruncationResult {
278 rounds_removed: 0,
279 messages_removed: 0,
280 removed_messages: Vec::new(),
281 insert_position: 0,
282 needs_compression: false,
283 };
284 }
285
286 tracing::info!(
287 "Context truncation needed: estimated {} tokens (with {:.1}x margin), threshold {} tokens, max {} tokens",
288 estimated,
289 self.token_safety_margin,
290 threshold,
291 self.max_tokens
292 );
293
294 let mut round_starts: Vec<usize> = Vec::new();
296 for (i, msg) in messages.iter().enumerate() {
297 if is_user_message(msg) {
298 round_starts.push(i);
299 }
300 }
301
302 if round_starts.is_empty() {
303 return TruncationResult {
304 rounds_removed: 0,
305 messages_removed: 0,
306 removed_messages: Vec::new(),
307 insert_position: 0,
308 needs_compression: false,
309 };
310 }
311
312 let total_rounds = round_starts.len();
314 let must_keep = self.min_keep_rounds.min(total_rounds);
315
316 let mut removed_messages: Vec<ChatCompletionRequestMessage> = Vec::new();
317 let mut rounds_removed = 0;
318 let mut messages_removed = 0;
319
320 while round_starts.len() > must_keep
324 && estimate_messages_tokens_with_margin(messages, self.token_safety_margin) > threshold
325 {
326 let start_idx = round_starts[0];
327 let end_idx = if round_starts.len() > 1 {
328 round_starts[1]
329 } else {
330 messages.len()
331 };
332
333 if self.compression_enabled {
335 removed_messages.extend(messages[start_idx..end_idx].to_vec());
336 }
337
338 let count = end_idx - start_idx;
339 messages.drain(start_idx..end_idx);
340
341 round_starts.remove(0);
343 for idx in round_starts.iter_mut() {
344 *idx = idx.saturating_sub(count);
345 }
346
347 rounds_removed += 1;
348 messages_removed += count;
349 }
350
351 if rounds_removed == 0 {
352 return TruncationResult {
353 rounds_removed: 0,
354 messages_removed: 0,
355 removed_messages: Vec::new(),
356 insert_position: 0,
357 needs_compression: false,
358 };
359 }
360
361 let removed_tokens = estimate_messages_tokens(&removed_messages);
363 let needs_compression =
364 self.compression_enabled && removed_tokens >= self.compression_token_threshold;
365
366 let system_msg_count = messages
368 .iter()
369 .take_while(|m| is_system_message(m))
370 .count();
371
372 let notice = if needs_compression {
373 format!(
374 "[Context compressed: {} earlier rounds ({} messages, ~{} tokens) have been summarized. {} most recent rounds preserved.]",
375 rounds_removed,
376 messages_removed,
377 removed_tokens,
378 round_starts.len()
379 )
380 } else {
381 format!(
382 "[Context truncated: {} earlier rounds ({} messages) removed to stay within token limit. {} most recent rounds preserved.]",
383 rounds_removed,
384 messages_removed,
385 round_starts.len()
386 )
387 };
388
389 let notice_msg = ChatCompletionRequestMessage::User(
390 async_openai::types::chat::ChatCompletionRequestUserMessage {
391 content: notice.into(),
392 name: Some("system_notice".to_string()),
393 }
394 .into(),
395 );
396
397 messages.insert(system_msg_count, notice_msg);
398
399 tracing::info!(
400 "Context truncation: removed {} rounds ({} messages, ~{} tokens), kept {} rounds, needs_compression={}",
401 rounds_removed,
402 messages_removed,
403 removed_tokens,
404 round_starts.len(),
405 needs_compression
406 );
407
408 TruncationResult {
409 rounds_removed,
410 messages_removed,
411 removed_messages,
412 insert_position: system_msg_count,
413 needs_compression,
414 }
415 }
416}
417
418fn is_user_message(msg: &ChatCompletionRequestMessage) -> bool {
419 matches!(msg, ChatCompletionRequestMessage::User(_))
420}
421
422fn is_system_message(msg: &ChatCompletionRequestMessage) -> bool {
423 matches!(msg, ChatCompletionRequestMessage::System(_))
424}
425
426pub fn format_removed_messages_as_transcript(
434 messages: &[ChatCompletionRequestMessage],
435) -> String {
436 let mut transcript = String::new();
437
438 for msg in messages {
439 match msg {
440 ChatCompletionRequestMessage::User(user_msg) => {
441 let text = match &user_msg.content {
442 async_openai::types::chat::ChatCompletionRequestUserMessageContent::Text(t) => {
443 t.clone()
444 }
445 async_openai::types::chat::ChatCompletionRequestUserMessageContent::Array(parts) => {
446 parts.iter()
447 .filter_map(|p| match p {
448 async_openai::types::chat::ChatCompletionRequestUserMessageContentPart::Text(t) => Some(t.text.as_str()),
449 _ => None,
450 })
451 .collect::<Vec<_>>()
452 .join(" ")
453 }
454 };
455 let truncated = truncate_str(&text, 200);
456 transcript.push_str(&format!("User: {}\n", truncated));
457 }
458 ChatCompletionRequestMessage::Assistant(assistant_msg) => {
459 let content_str = assistant_msg.content.as_ref().map(|c| {
460 match serde_json::to_string(c) {
461 Ok(json) => json.trim_matches('"').to_string(),
462 Err(_) => format!("{:?}", c),
463 }
464 }).unwrap_or_default();
465 let truncated = truncate_str(&content_str, 300);
466 transcript.push_str(&format!("Assistant: {}\n", truncated));
467 if let Some(tool_calls) = &assistant_msg.tool_calls {
468 for tc in tool_calls {
469 if let async_openai::types::chat::ChatCompletionMessageToolCalls::Function(f) = tc {
470 transcript.push_str(&format!(
471 " [Tool: {}({})]\n",
472 f.function.name,
473 truncate_str(&f.function.arguments, 100)
474 ));
475 }
476 }
477 }
478 }
479 ChatCompletionRequestMessage::Tool(tool_msg) => {
480 let content_str = match serde_json::to_string(&tool_msg.content) {
481 Ok(json) => json.trim_matches('"').to_string(),
482 Err(_) => format!("{:?}", tool_msg.content),
483 };
484 let truncated = truncate_str(&content_str, 150);
485 transcript.push_str(&format!(" [Result: {}]\n", truncated));
486 }
487 _ => {}
488 }
489 }
490
491 if transcript.is_empty() {
492 transcript.push_str("(no conversation content)");
493 }
494
495 transcript
496}
497
498fn truncate_str(s: &str, max_chars: usize) -> String {
501 if s.len() <= max_chars {
502 s.to_string()
503 } else {
504 let mut end = max_chars;
505 while end > 0 && !s.is_char_boundary(end) {
506 end -= 1;
507 }
508 format!("{}...", &s[..end])
509 }
510}
511
512#[cfg(test)]
517mod tests {
518 use super::*;
519 use async_openai::types::chat::ChatCompletionRequestUserMessage;
520
521 fn make_user_message(content: &str) -> ChatCompletionRequestMessage {
522 ChatCompletionRequestMessage::User(
523 ChatCompletionRequestUserMessage {
524 content: content.into(),
525 name: None,
526 }
527 .into(),
528 )
529 }
530
531 fn make_system_message(content: &str) -> ChatCompletionRequestMessage {
532 ChatCompletionRequestMessage::System(
533 async_openai::types::chat::ChatCompletionRequestSystemMessage {
534 content: content.into(),
535 name: None,
536 }
537 .into(),
538 )
539 }
540
541 fn make_test_config() -> ContextConfig {
542 ContextConfig {
543 max_output_lines: Some(500),
544 max_output_bytes: Some(51200),
545 reserve_ratio: Some(0.2),
546 truncation_ratio: Some(0.7),
547 min_keep_rounds: Some(3),
548 token_safety_margin: Some(1.3),
549 compression_token_threshold: Some(5000),
550 compression_enabled: Some(true),
551 max_tool_calls_per_turn: Some(30),
552 }
553 }
554
555 #[test]
560 fn test_estimate_tokens_english() {
561 let text = "Hello world, this is a test of the token estimation system.";
562 let tokens = estimate_tokens(text);
563 assert!(tokens >= 10, "Expected at least 10 tokens, got {}", tokens);
564 assert!(tokens <= 30, "Expected at most 30 tokens, got {}", tokens);
565 }
566
567 #[test]
568 fn test_estimate_tokens_chinese() {
569 let chinese = "你好世界,这是一个测试。";
570 let tokens = estimate_tokens(chinese);
571 assert!(tokens >= 5, "Expected at least 5 tokens, got {}", tokens);
572 assert!(tokens <= 15, "Expected at most 15 tokens, got {}", tokens);
573 }
574
575 #[test]
576 fn test_estimate_tokens_code() {
577 let code = "fn main() {\n println!(\"Hello\");\n}";
578 let tokens = estimate_tokens(code);
579 assert!(tokens >= 10, "Expected at least 10 tokens, got {}", tokens);
580 assert!(tokens <= 40, "Expected at most 40 tokens, got {}", tokens);
581 }
582
583 #[test]
584 fn test_estimate_tokens_empty() {
585 assert_eq!(estimate_tokens(""), 0);
586 }
587
588 #[test]
589 fn test_estimate_tokens_mixed() {
590 let mixed = "Hello 你好 world 世界!fn test() {}";
591 let tokens = estimate_tokens(mixed);
592 assert!(tokens > 0);
593 assert!(tokens <= 40, "Expected at most 40 tokens, got {}", tokens);
594 }
595
596 #[test]
597 fn test_estimate_messages_tokens_with_margin() {
598 let messages = vec![
599 make_system_message("You are a helpful assistant"),
600 make_user_message("Hello world"),
601 ];
602 let raw = estimate_messages_tokens(&messages);
603 let with_margin = estimate_messages_tokens_with_margin(&messages, 1.3);
604 assert!(with_margin > raw);
605 let expected = (raw as f32 * 1.3).ceil() as usize;
607 assert_eq!(with_margin, expected);
608 }
609
610 #[test]
615 fn test_truncation_threshold() {
616 let config = make_test_config();
617 let manager = ContextManager::new(Some(65536), Some(&config));
618 assert_eq!(manager.truncation_threshold(), 45875);
620 }
621
622 #[test]
623 fn test_truncation_result_no_truncation() {
624 let mut messages = vec![
625 make_system_message("You are a helpful assistant"),
626 make_user_message("Hello"),
627 ];
628
629 let config = make_test_config();
630 let manager = ContextManager::new(Some(65536), Some(&config));
631 let result = manager.maybe_truncate(&mut messages);
632
633 assert_eq!(result.rounds_removed, 0);
634 assert!(!result.needs_compression);
635 }
636
637 #[test]
638 fn test_truncation_respects_min_keep_rounds() {
639 let mut messages = vec![
640 make_system_message("You are a helpful assistant"),
641 ];
642
643 for i in 0..10 {
645 let content = format!("User message {}: {}", i, "x".repeat(2000));
646 messages.push(make_user_message(&content));
647 }
648
649 let mut config = make_test_config();
650 config.min_keep_rounds = Some(3); let manager = ContextManager::new(Some(5000), Some(&config));
654 let result = manager.maybe_truncate(&mut messages);
655
656 assert!(
658 result.rounds_removed > 0,
659 "Should have removed some rounds"
660 );
661 let user_count = messages
663 .iter()
664 .filter(|m| matches!(m, ChatCompletionRequestMessage::User(_)))
665 .count();
666 assert!(
667 user_count >= 4,
668 "Should have at least 3 user rounds + notice, got {}",
669 user_count
670 );
671 }
672
673 #[test]
674 fn test_truncation_early_trigger() {
675 let mut messages = vec![
676 make_system_message("You are a helpful assistant"),
677 ];
678
679 for i in 0..8 {
681 let content = format!("User message {}: {}", i, "x".repeat(2000));
682 messages.push(make_user_message(&content));
683 }
684
685 let mut config = make_test_config();
686 config.truncation_ratio = Some(0.7);
687 config.min_keep_rounds = Some(2);
688 config.token_safety_margin = Some(1.3);
689
690 let manager = ContextManager::new(Some(65536), Some(&config));
693 let result = manager.maybe_truncate(&mut messages);
694 assert_eq!(
695 result.rounds_removed, 0,
696 "Should not truncate small messages in large window"
697 );
698
699 let manager2 = ContextManager::new(Some(8000), Some(&config));
701 let mut messages2 = messages.clone();
702 let result2 = manager2.maybe_truncate(&mut messages2);
703 assert!(
704 result2.rounds_removed > 0,
705 "Should truncate when exceeding small window"
706 );
707 }
708
709 #[test]
710 fn test_token_safety_margin_effect() {
711 let mut messages = vec![
712 make_system_message("You are a helpful assistant"),
713 ];
714
715 for i in 0..10 {
716 let content = format!("User message {}: {}", i, "x".repeat(500));
717 messages.push(make_user_message(&content));
718 }
719
720 let mut config_low = make_test_config();
722 config_low.token_safety_margin = Some(1.0);
723 config_low.truncation_ratio = Some(0.7);
724 config_low.min_keep_rounds = Some(1);
725
726 let mut msgs_low = messages.clone();
727 let manager_low = ContextManager::new(Some(8000), Some(&config_low));
728 let result_low = manager_low.maybe_truncate(&mut msgs_low);
729
730 let mut config_high = make_test_config();
732 config_high.token_safety_margin = Some(2.0);
733 config_high.truncation_ratio = Some(0.7);
734 config_high.min_keep_rounds = Some(1);
735
736 let mut msgs_high = messages.clone();
737 let manager_high = ContextManager::new(Some(8000), Some(&config_high));
738 let result_high = manager_high.maybe_truncate(&mut msgs_high);
739
740 assert!(
742 result_high.rounds_removed >= result_low.rounds_removed,
743 "Higher safety margin should trigger at least as much truncation: high={}, low={}",
744 result_high.rounds_removed,
745 result_low.rounds_removed
746 );
747 }
748
749 #[test]
750 fn test_compression_flag_in_old_tests() {
751 let mut messages = vec![
752 make_system_message("You are a helpful assistant"),
753 ];
754
755 for i in 0..20 {
757 let content = format!("User message {}: {}", i, "x".repeat(2000));
758 messages.push(make_user_message(&content));
759 }
760
761 let mut config = make_test_config();
762 config.compression_enabled = Some(false);
763 config.compression_token_threshold = Some(1000);
764 config.min_keep_rounds = Some(1);
765
766 let manager = ContextManager::new(Some(5000), Some(&config));
767 let result = manager.maybe_truncate(&mut messages);
768
769 assert!(result.rounds_removed > 0);
770 assert!(
771 !result.needs_compression,
772 "Should be false when compression disabled"
773 );
774 }
775
776 #[test]
777 fn test_truncate_output() {
778 let content = "line1\nline2\nline3\nline4\nline5";
779 let truncated = truncate_output(content, 3, 100);
780 assert!(truncated.contains("line1"));
781 assert!(truncated.contains("line2"));
782 assert!(truncated.contains("line3"));
783 assert!(!truncated.contains("line4"));
784 assert!(truncated.contains("Output truncated"));
785 }
786
787 #[test]
792 fn test_truncate_str_no_truncation() {
793 let result = truncate_str("hello", 10);
794 assert_eq!(result, "hello");
795 }
796
797 #[test]
798 fn test_truncate_str_with_truncation() {
799 let result = truncate_str("hello world this is long", 10);
800 assert_eq!(result, "hello worl...");
801 }
802
803 #[test]
804 fn test_format_transcript_user_and_assistant() {
805 let messages = vec![
806 make_user_message("Fix the bug in auth.rs"),
807 make_system_message("System message should be skipped"),
808 ];
809
810 let transcript = format_removed_messages_as_transcript(&messages);
811 assert!(transcript.contains("User: Fix the bug in auth.rs"));
812 assert!(!transcript.contains("System message"), "System messages should be skipped");
813 }
814
815 #[test]
816 fn test_format_transcript_empty() {
817 let messages: Vec<ChatCompletionRequestMessage> = vec![];
818 let transcript = format_removed_messages_as_transcript(&messages);
819 assert!(transcript.contains("no conversation content"));
820 }
821
822 #[test]
823 fn test_format_transcript_truncates_long_messages() {
824 let long_text = "x".repeat(500);
825 let messages = vec![
826 make_user_message(&long_text),
827 ];
828
829 let transcript = format_removed_messages_as_transcript(&messages);
830 assert!(transcript.contains("..."));
831 assert!(transcript.len() < long_text.len() + 50);
833 }
834}