Skip to main content

robit_agent/
context.rs

1//! Context management — output truncation and history window management.
2
3use async_openai::types::chat::ChatCompletionRequestMessage;
4use robit_ai::config::ContextConfig;
5
6// ============================================================================
7// Truncation result
8// ============================================================================
9
10/// Result of context truncation, used for async compression.
11#[derive(Debug)]
12pub struct TruncationResult {
13    /// Number of conversation rounds removed.
14    pub rounds_removed: usize,
15    /// Number of individual messages removed.
16    pub messages_removed: usize,
17    /// The removed messages (for generating summary).
18    pub removed_messages: Vec<ChatCompletionRequestMessage>,
19    /// Position where summary should be inserted.
20    pub insert_position: usize,
21    /// Whether compression is needed (token count exceeds threshold).
22    pub needs_compression: bool,
23}
24
25// ============================================================================
26// Tool output truncation (Layer 1)
27// ============================================================================
28
29/// Truncate tool output based on line count and byte limits.
30pub 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    // Check if truncation is needed
36    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
81// ============================================================================
82// Token estimation
83// ============================================================================
84
85/// Estimate token count for a string.
86/// Uses a simple heuristic: ~4 chars per English token, ~2 chars per Chinese token.
87/// For mixed content, we use ~3 chars per token as a rough estimate.
88pub fn estimate_tokens(text: &str) -> usize {
89    let char_count = text.chars().count();
90    // Count CJK characters (rough heuristic)
91    let cjk_count = text
92        .chars()
93        .filter(|c| {
94            let cp = *c as u32;
95            // CJK Unified Ideographs + extensions + fullwidth forms
96            (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
110/// Estimate tokens for a list of messages.
111pub fn estimate_messages_tokens(messages: &[ChatCompletionRequestMessage]) -> usize {
112    let mut total = 0;
113    for msg in messages {
114        // Each message has ~4 tokens of overhead (role, delimiters)
115        total += 4;
116        total += estimate_message_content_tokens(msg);
117    }
118    total
119}
120
121/// Estimate tokens for a single message's content.
122fn estimate_message_content_tokens(msg: &ChatCompletionRequestMessage) -> usize {
123    // We need to extract the text content from the message.
124    // Since ChatCompletionRequestMessage is an enum, we match on variants.
125    // For simplicity, serialize to JSON and estimate from the string.
126    match serde_json::to_string(msg) {
127        Ok(json) => estimate_tokens(&json),
128        Err(_) => 0,
129    }
130}
131
132// ============================================================================
133// Context manager (Layer 2: history truncation)
134// ============================================================================
135
136/// Manages the context window, truncating history when approaching token limits.
137pub struct ContextManager {
138    /// Model's context window size in tokens.
139    pub max_tokens: usize,
140    /// Ratio of context window to reserve for LLM response (default 0.2 = 20%).
141    pub reserve_ratio: f32,
142    /// Max output lines for tool results.
143    pub max_output_lines: usize,
144    /// Max output bytes for tool results.
145    pub max_output_bytes: usize,
146    /// Token threshold for triggering compression.
147    pub compression_token_threshold: usize,
148    /// Whether compression is enabled.
149    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    /// Maximum tokens available for input (total - reserved for response).
178    pub fn available_tokens(&self) -> usize {
179        (self.max_tokens as f32 * (1.0 - self.reserve_ratio)) as usize
180    }
181
182    /// Truncate tool output using the configured limits.
183    pub fn truncate_tool_output(&self, content: &str) -> String {
184        truncate_output(content, self.max_output_lines, self.max_output_bytes)
185    }
186
187    /// Check if history needs truncation and perform it if necessary.
188    /// Returns `TruncationResult` with removed messages for async compression.
189    ///
190    /// Strategy: remove oldest non-system messages by "rounds"
191    /// (user + assistant + tool_calls + tool_results grouped together).
192    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        // Find rounds to remove (skip system messages at the start)
216        let mut rounds_removed = 0;
217        let mut messages_removed = 0;
218
219        // Group messages into rounds: a round starts with a User message
220        // and includes all subsequent messages until the next User message
221        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                // Skip the first user message if there's a system message before it
225                round_starts.push(i);
226            } else if is_user_message(msg) && i == 0 {
227                round_starts.push(i);
228            }
229        }
230
231        // Collect removed messages for potential compression
232        let mut removed_messages: Vec<ChatCompletionRequestMessage> = Vec::new();
233
234        // Remove rounds from the oldest first
235        // Keep at least the system message
236        while !round_starts.is_empty() && estimate_messages_tokens(messages) > available {
237            // Determine the range of the oldest round to remove
238            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            // Don't remove if it would leave us with only system messages
246            // and no user messages
247            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            // Collect messages before removing
256            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            // Update round_starts indices
264            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            // Calculate removed tokens for threshold check
275            let removed_tokens = estimate_messages_tokens(&removed_messages);
276            let needs_compression =
277                self.compression_enabled && removed_tokens >= self.compression_token_threshold;
278
279            // Insert placeholder or wait for summary
280            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// ============================================================================
337// Tests
338// ============================================================================
339
340#[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        // Small messages - no truncation needed
370        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        // Create many large messages to exceed threshold
393        let mut messages = vec![
394            make_system_message("You are a helpful assistant"),
395        ];
396
397        // Add 20 rounds of large messages (each ~2000 chars = ~666 tokens)
398        // Total: ~13,320 tokens, context window: 5000, available: 4000
399        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), // Low threshold for testing
409            compression_enabled: Some(true),
410        };
411
412        // Set small context window to force truncation
413        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        // Add 20 rounds of large messages
428        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), // Disabled
439        };
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); // Should be false even if threshold exceeded
446    }
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); // ~11 chars / 4 = ~2-3 tokens
454
455        let chinese = "你好世界";
456        let tokens_cn = estimate_tokens(chinese);
457        assert!(tokens_cn > 0);
458        assert!(tokens_cn < 5); // ~4 chars / 2 = ~2 tokens
459    }
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}