use async_openai::types::chat::ChatCompletionRequestMessage;
use robit_ai::config::ContextConfig;
#[derive(Debug)]
pub struct TruncationResult {
pub rounds_removed: usize,
pub messages_removed: usize,
pub removed_messages: Vec<ChatCompletionRequestMessage>,
pub insert_position: usize,
pub needs_compression: bool,
}
pub fn truncate_output(content: &str, max_lines: usize, max_bytes: usize) -> String {
let lines: Vec<&str> = content.lines().collect();
let total_lines = lines.len();
let total_bytes = content.len();
let line_truncated = total_lines > max_lines;
let byte_truncated = total_bytes > max_bytes;
if !line_truncated && !byte_truncated {
return content.to_string();
}
let mut output = String::new();
let mut byte_count = 0;
let mut displayed_lines = 0;
for (i, line) in lines.iter().enumerate() {
if i >= max_lines {
break;
}
let line_with_newline = if i < total_lines - 1 {
format!("{}\n", line)
} else {
line.to_string()
};
if byte_count + line_with_newline.len() > max_bytes {
break;
}
output.push_str(&line_with_newline);
byte_count += line_with_newline.len();
displayed_lines += 1;
}
if line_truncated {
output.push_str(&format!(
"\n... (Output truncated, {} lines total, showing first {}. Use offset/limit to read more)",
total_lines, displayed_lines
));
} else if byte_truncated {
output.push_str(&format!(
"\n... (Output truncated, {} bytes total, showing first {} bytes)",
total_bytes, byte_count
));
}
output
}
pub fn estimate_tokens(text: &str) -> usize {
let char_count = text.chars().count();
let cjk_count = text
.chars()
.filter(|c| {
let cp = *c as u32;
(0x4E00..=0x9FFF).contains(&cp)
|| (0x3400..=0x4DBF).contains(&cp)
|| (0xF900..=0xFAFF).contains(&cp)
|| (0xFF00..=0xFFEF).contains(&cp)
})
.count();
let non_cjk = char_count.saturating_sub(cjk_count);
let cjk_tokens = cjk_count / 2;
let non_cjk_tokens = non_cjk / 4;
cjk_tokens + non_cjk_tokens
}
pub fn estimate_messages_tokens(messages: &[ChatCompletionRequestMessage]) -> usize {
let mut total = 0;
for msg in messages {
total += 4;
total += estimate_message_content_tokens(msg);
}
total
}
fn estimate_message_content_tokens(msg: &ChatCompletionRequestMessage) -> usize {
match serde_json::to_string(msg) {
Ok(json) => estimate_tokens(&json),
Err(_) => 0,
}
}
pub struct ContextManager {
pub max_tokens: usize,
pub reserve_ratio: f32,
pub max_output_lines: usize,
pub max_output_bytes: usize,
pub compression_token_threshold: usize,
pub compression_enabled: bool,
}
impl ContextManager {
pub fn new(context_window: Option<u64>, config: Option<&ContextConfig>) -> Self {
let max_tokens = context_window.unwrap_or(65536) as usize;
let (max_output_lines, max_output_bytes, reserve_ratio, compression_token_threshold, compression_enabled) = match config {
Some(c) => (
c.max_output_lines.unwrap_or(500),
c.max_output_bytes.unwrap_or(51200),
c.reserve_ratio.unwrap_or(0.2),
c.compression_token_threshold.unwrap_or(5000),
c.compression_enabled.unwrap_or(true),
),
None => (500, 51200, 0.2, 5000, true),
};
Self {
max_tokens,
reserve_ratio,
max_output_lines,
max_output_bytes,
compression_token_threshold,
compression_enabled,
}
}
pub fn available_tokens(&self) -> usize {
(self.max_tokens as f32 * (1.0 - self.reserve_ratio)) as usize
}
pub fn truncate_tool_output(&self, content: &str) -> String {
truncate_output(content, self.max_output_lines, self.max_output_bytes)
}
pub fn maybe_truncate(
&self,
messages: &mut Vec<ChatCompletionRequestMessage>,
) -> TruncationResult {
let estimated = estimate_messages_tokens(messages);
let available = self.available_tokens();
if estimated <= available {
return TruncationResult {
rounds_removed: 0,
messages_removed: 0,
removed_messages: Vec::new(),
insert_position: 0,
needs_compression: false,
};
}
tracing::info!(
"Context truncation needed: estimated {} tokens, available {} tokens",
estimated,
available
);
let mut rounds_removed = 0;
let mut messages_removed = 0;
let mut round_starts: Vec<usize> = Vec::new();
for (i, msg) in messages.iter().enumerate() {
if is_user_message(msg) && i > 0 {
round_starts.push(i);
} else if is_user_message(msg) && i == 0 {
round_starts.push(i);
}
}
let mut removed_messages: Vec<ChatCompletionRequestMessage> = Vec::new();
while !round_starts.is_empty() && estimate_messages_tokens(messages) > available {
let start_idx = round_starts[0];
let end_idx = if round_starts.len() > 1 {
round_starts[1]
} else {
messages.len()
};
let remaining_user_msgs = messages[end_idx..]
.iter()
.filter(|m| is_user_message(m))
.count();
if remaining_user_msgs == 0 {
break;
}
if self.compression_enabled {
removed_messages.extend(messages[start_idx..end_idx].to_vec());
}
let count = end_idx - start_idx;
messages.drain(start_idx..end_idx);
round_starts.remove(0);
for idx in round_starts.iter_mut() {
*idx = idx.saturating_sub(count);
}
rounds_removed += 1;
messages_removed += count;
}
if rounds_removed > 0 {
let removed_tokens = estimate_messages_tokens(&removed_messages);
let needs_compression =
self.compression_enabled && removed_tokens >= self.compression_token_threshold;
let notice = if needs_compression {
"[Generating conversation summary...]"
} else {
&format!("[Omitted {} rounds, {} messages]", rounds_removed, messages_removed)
};
let system_msg_count = messages
.iter()
.take_while(|m| is_system_message(m))
.count();
let notice_msg = ChatCompletionRequestMessage::User(
async_openai::types::chat::ChatCompletionRequestUserMessage {
content: notice.into(),
name: Some("system_notice".to_string()),
}
.into(),
);
messages.insert(system_msg_count, notice_msg);
tracing::info!(
"Removed {} rounds ({} messages), needs_compression={}, removed_tokens={}",
rounds_removed,
messages_removed,
needs_compression,
removed_tokens
);
return TruncationResult {
rounds_removed,
messages_removed,
removed_messages,
insert_position: system_msg_count,
needs_compression,
};
}
TruncationResult {
rounds_removed: 0,
messages_removed: 0,
removed_messages: Vec::new(),
insert_position: 0,
needs_compression: false,
}
}
}
fn is_user_message(msg: &ChatCompletionRequestMessage) -> bool {
matches!(msg, ChatCompletionRequestMessage::User(_))
}
fn is_system_message(msg: &ChatCompletionRequestMessage) -> bool {
matches!(msg, ChatCompletionRequestMessage::System(_))
}
#[cfg(test)]
mod tests {
use super::*;
use async_openai::types::chat::{
ChatCompletionRequestUserMessage,
};
fn make_user_message(content: &str) -> ChatCompletionRequestMessage {
ChatCompletionRequestMessage::User(
ChatCompletionRequestUserMessage {
content: content.into(),
name: None,
}
.into(),
)
}
fn make_system_message(content: &str) -> ChatCompletionRequestMessage {
ChatCompletionRequestMessage::System(
async_openai::types::chat::ChatCompletionRequestSystemMessage {
content: content.into(),
name: None,
}
.into(),
)
}
#[test]
fn test_truncation_result_no_compression() {
let mut messages = vec![
make_system_message("You are a helpful assistant"),
make_user_message("Hello"),
];
let config = ContextConfig {
max_output_lines: Some(500),
max_output_bytes: Some(51200),
reserve_ratio: Some(0.2),
compression_token_threshold: Some(5000),
compression_enabled: Some(true),
};
let manager = ContextManager::new(Some(65536), Some(&config));
let result = manager.maybe_truncate(&mut messages);
assert_eq!(result.rounds_removed, 0);
assert!(!result.needs_compression);
}
#[test]
fn test_truncation_result_with_compression() {
let mut messages = vec![
make_system_message("You are a helpful assistant"),
];
for i in 0..20 {
let content = format!("User message {}: {}", i, "x".repeat(2000));
messages.push(make_user_message(&content));
}
let config = ContextConfig {
max_output_lines: Some(500),
max_output_bytes: Some(51200),
reserve_ratio: Some(0.2),
compression_token_threshold: Some(1000), compression_enabled: Some(true),
};
let manager = ContextManager::new(Some(5000), Some(&config));
let result = manager.maybe_truncate(&mut messages);
assert!(result.rounds_removed > 0);
assert!(result.needs_compression);
assert!(!result.removed_messages.is_empty());
}
#[test]
fn test_compression_disabled() {
let mut messages = vec![
make_system_message("You are a helpful assistant"),
];
for i in 0..20 {
let content = format!("User message {}: {}", i, "x".repeat(2000));
messages.push(make_user_message(&content));
}
let config = ContextConfig {
max_output_lines: Some(500),
max_output_bytes: Some(51200),
reserve_ratio: Some(0.2),
compression_token_threshold: Some(1000),
compression_enabled: Some(false), };
let manager = ContextManager::new(Some(5000), Some(&config));
let result = manager.maybe_truncate(&mut messages);
assert!(result.rounds_removed > 0);
assert!(!result.needs_compression); }
#[test]
fn test_estimate_tokens() {
let text = "Hello world";
let tokens = estimate_tokens(text);
assert!(tokens > 0);
assert!(tokens < 10);
let chinese = "你好世界";
let tokens_cn = estimate_tokens(chinese);
assert!(tokens_cn > 0);
assert!(tokens_cn < 5); }
#[test]
fn test_truncate_output() {
let content = "line1\nline2\nline3\nline4\nline5";
let truncated = truncate_output(content, 3, 100);
assert!(truncated.contains("line1"));
assert!(truncated.contains("line2"));
assert!(truncated.contains("line3"));
assert!(!truncated.contains("line4"));
assert!(truncated.contains("Output truncated"));
}
}