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 {
89 let char_count = text.chars().count();
90 let cjk_count = text
92 .chars()
93 .filter(|c| {
94 let cp = *c as u32;
95 (0x4E00..=0x9FFF).contains(&cp)
97 || (0x3400..=0x4DBF).contains(&cp)
98 || (0xF900..=0xFAFF).contains(&cp)
99 || (0xFF00..=0xFFEF).contains(&cp)
100 })
101 .count();
102
103 let non_cjk = char_count.saturating_sub(cjk_count);
104 let cjk_tokens = cjk_count / 2;
105 let non_cjk_tokens = non_cjk / 4;
106
107 cjk_tokens + non_cjk_tokens
108}
109
110pub fn estimate_messages_tokens(messages: &[ChatCompletionRequestMessage]) -> usize {
112 let mut total = 0;
113 for msg in messages {
114 total += 4;
116 total += estimate_message_content_tokens(msg);
117 }
118 total
119}
120
121fn estimate_message_content_tokens(msg: &ChatCompletionRequestMessage) -> usize {
123 match serde_json::to_string(msg) {
127 Ok(json) => estimate_tokens(&json),
128 Err(_) => 0,
129 }
130}
131
132pub struct ContextManager {
138 pub max_tokens: usize,
140 pub reserve_ratio: f32,
142 pub max_output_lines: usize,
144 pub max_output_bytes: usize,
146 pub compression_token_threshold: usize,
148 pub compression_enabled: bool,
150}
151
152impl ContextManager {
153 pub fn new(context_window: Option<u64>, config: Option<&ContextConfig>) -> Self {
154 let max_tokens = context_window.unwrap_or(65536) as usize;
155
156 let (max_output_lines, max_output_bytes, reserve_ratio, compression_token_threshold, compression_enabled) = match config {
157 Some(c) => (
158 c.max_output_lines.unwrap_or(500),
159 c.max_output_bytes.unwrap_or(51200),
160 c.reserve_ratio.unwrap_or(0.2),
161 c.compression_token_threshold.unwrap_or(5000),
162 c.compression_enabled.unwrap_or(true),
163 ),
164 None => (500, 51200, 0.2, 5000, true),
165 };
166
167 Self {
168 max_tokens,
169 reserve_ratio,
170 max_output_lines,
171 max_output_bytes,
172 compression_token_threshold,
173 compression_enabled,
174 }
175 }
176
177 pub fn available_tokens(&self) -> usize {
179 (self.max_tokens as f32 * (1.0 - self.reserve_ratio)) as usize
180 }
181
182 pub fn truncate_tool_output(&self, content: &str) -> String {
184 truncate_output(content, self.max_output_lines, self.max_output_bytes)
185 }
186
187 pub fn maybe_truncate(
193 &self,
194 messages: &mut Vec<ChatCompletionRequestMessage>,
195 ) -> TruncationResult {
196 let estimated = estimate_messages_tokens(messages);
197 let available = self.available_tokens();
198
199 if estimated <= available {
200 return TruncationResult {
201 rounds_removed: 0,
202 messages_removed: 0,
203 removed_messages: Vec::new(),
204 insert_position: 0,
205 needs_compression: false,
206 };
207 }
208
209 tracing::info!(
210 "Context truncation needed: estimated {} tokens, available {} tokens",
211 estimated,
212 available
213 );
214
215 let mut rounds_removed = 0;
217 let mut messages_removed = 0;
218
219 let mut round_starts: Vec<usize> = Vec::new();
222 for (i, msg) in messages.iter().enumerate() {
223 if is_user_message(msg) && i > 0 {
224 round_starts.push(i);
226 } else if is_user_message(msg) && i == 0 {
227 round_starts.push(i);
228 }
229 }
230
231 let mut removed_messages: Vec<ChatCompletionRequestMessage> = Vec::new();
233
234 while !round_starts.is_empty() && estimate_messages_tokens(messages) > available {
237 let start_idx = round_starts[0];
239 let end_idx = if round_starts.len() > 1 {
240 round_starts[1]
241 } else {
242 messages.len()
243 };
244
245 let remaining_user_msgs = messages[end_idx..]
248 .iter()
249 .filter(|m| is_user_message(m))
250 .count();
251 if remaining_user_msgs == 0 {
252 break;
253 }
254
255 if self.compression_enabled {
257 removed_messages.extend(messages[start_idx..end_idx].to_vec());
258 }
259
260 let count = end_idx - start_idx;
261 messages.drain(start_idx..end_idx);
262
263 round_starts.remove(0);
265 for idx in round_starts.iter_mut() {
266 *idx = idx.saturating_sub(count);
267 }
268
269 rounds_removed += 1;
270 messages_removed += count;
271 }
272
273 if rounds_removed > 0 {
274 let removed_tokens = estimate_messages_tokens(&removed_messages);
276 let needs_compression =
277 self.compression_enabled && removed_tokens >= self.compression_token_threshold;
278
279 let notice = if needs_compression {
281 "[Generating conversation summary...]"
282 } else {
283 &format!("[Omitted {} rounds, {} messages]", rounds_removed, messages_removed)
284 };
285
286 let system_msg_count = messages
287 .iter()
288 .take_while(|m| is_system_message(m))
289 .count();
290
291 let notice_msg = ChatCompletionRequestMessage::User(
292 async_openai::types::chat::ChatCompletionRequestUserMessage {
293 content: notice.into(),
294 name: Some("system_notice".to_string()),
295 }
296 .into(),
297 );
298
299 messages.insert(system_msg_count, notice_msg);
300
301 tracing::info!(
302 "Removed {} rounds ({} messages), needs_compression={}, removed_tokens={}",
303 rounds_removed,
304 messages_removed,
305 needs_compression,
306 removed_tokens
307 );
308
309 return TruncationResult {
310 rounds_removed,
311 messages_removed,
312 removed_messages,
313 insert_position: system_msg_count,
314 needs_compression,
315 };
316 }
317
318 TruncationResult {
319 rounds_removed: 0,
320 messages_removed: 0,
321 removed_messages: Vec::new(),
322 insert_position: 0,
323 needs_compression: false,
324 }
325 }
326}
327
328fn is_user_message(msg: &ChatCompletionRequestMessage) -> bool {
329 matches!(msg, ChatCompletionRequestMessage::User(_))
330}
331
332fn is_system_message(msg: &ChatCompletionRequestMessage) -> bool {
333 matches!(msg, ChatCompletionRequestMessage::System(_))
334}
335
336#[cfg(test)]
341mod tests {
342 use super::*;
343 use async_openai::types::chat::{
344 ChatCompletionRequestUserMessage,
345 };
346
347 fn make_user_message(content: &str) -> ChatCompletionRequestMessage {
348 ChatCompletionRequestMessage::User(
349 ChatCompletionRequestUserMessage {
350 content: content.into(),
351 name: None,
352 }
353 .into(),
354 )
355 }
356
357 fn make_system_message(content: &str) -> ChatCompletionRequestMessage {
358 ChatCompletionRequestMessage::System(
359 async_openai::types::chat::ChatCompletionRequestSystemMessage {
360 content: content.into(),
361 name: None,
362 }
363 .into(),
364 )
365 }
366
367 #[test]
368 fn test_truncation_result_no_compression() {
369 let mut messages = vec![
371 make_system_message("You are a helpful assistant"),
372 make_user_message("Hello"),
373 ];
374
375 let config = ContextConfig {
376 max_output_lines: Some(500),
377 max_output_bytes: Some(51200),
378 reserve_ratio: Some(0.2),
379 compression_token_threshold: Some(5000),
380 compression_enabled: Some(true),
381 };
382
383 let manager = ContextManager::new(Some(65536), Some(&config));
384 let result = manager.maybe_truncate(&mut messages);
385
386 assert_eq!(result.rounds_removed, 0);
387 assert!(!result.needs_compression);
388 }
389
390 #[test]
391 fn test_truncation_result_with_compression() {
392 let mut messages = vec![
394 make_system_message("You are a helpful assistant"),
395 ];
396
397 for i in 0..20 {
400 let content = format!("User message {}: {}", i, "x".repeat(2000));
401 messages.push(make_user_message(&content));
402 }
403
404 let config = ContextConfig {
405 max_output_lines: Some(500),
406 max_output_bytes: Some(51200),
407 reserve_ratio: Some(0.2),
408 compression_token_threshold: Some(1000), compression_enabled: Some(true),
410 };
411
412 let manager = ContextManager::new(Some(5000), Some(&config));
414 let result = manager.maybe_truncate(&mut messages);
415
416 assert!(result.rounds_removed > 0);
417 assert!(result.needs_compression);
418 assert!(!result.removed_messages.is_empty());
419 }
420
421 #[test]
422 fn test_compression_disabled() {
423 let mut messages = vec![
424 make_system_message("You are a helpful assistant"),
425 ];
426
427 for i in 0..20 {
429 let content = format!("User message {}: {}", i, "x".repeat(2000));
430 messages.push(make_user_message(&content));
431 }
432
433 let config = ContextConfig {
434 max_output_lines: Some(500),
435 max_output_bytes: Some(51200),
436 reserve_ratio: Some(0.2),
437 compression_token_threshold: Some(1000),
438 compression_enabled: Some(false), };
440
441 let manager = ContextManager::new(Some(5000), Some(&config));
442 let result = manager.maybe_truncate(&mut messages);
443
444 assert!(result.rounds_removed > 0);
445 assert!(!result.needs_compression); }
447
448 #[test]
449 fn test_estimate_tokens() {
450 let text = "Hello world";
451 let tokens = estimate_tokens(text);
452 assert!(tokens > 0);
453 assert!(tokens < 10); let chinese = "你好世界";
456 let tokens_cn = estimate_tokens(chinese);
457 assert!(tokens_cn > 0);
458 assert!(tokens_cn < 5); }
460
461 #[test]
462 fn test_truncate_output() {
463 let content = "line1\nline2\nline3\nline4\nline5";
464 let truncated = truncate_output(content, 3, 100);
465 assert!(truncated.contains("line1"));
466 assert!(truncated.contains("line2"));
467 assert!(truncated.contains("line3"));
468 assert!(!truncated.contains("line4"));
469 assert!(truncated.contains("Output truncated"));
470 }
471}