Skip to main content

agent_base/engine/
context.rs

1use crate::types::{ChatMessage, SessionId};
2
3#[derive(Clone, Debug)]
4pub struct ContextWindowManager {
5    pub max_tokens: usize,
6    /// Always keep first N messages (typically system prompt)
7    pub keep_first_n: usize,
8    /// Always keep last N messages
9    pub keep_last_n: usize,
10}
11
12impl Default for ContextWindowManager {
13    fn default() -> Self {
14        Self {
15            max_tokens: 128_000,
16            keep_first_n: 1,
17            keep_last_n: 20,
18        }
19    }
20}
21
22impl ContextWindowManager {
23    /// OpenAI Vision API fixed token overhead per image
24    const IMAGE_OVERHEAD_TOKENS: usize = 85;
25
26    pub fn new(max_tokens: usize) -> Self {
27        Self {
28            max_tokens,
29            ..Default::default()
30        }
31    }
32
33    pub fn with_keep_first_n(mut self, n: usize) -> Self {
34        self.keep_first_n = n;
35        self
36    }
37
38    pub fn with_keep_last_n(mut self, n: usize) -> Self {
39        self.keep_last_n = n;
40        self
41    }
42
43    /// Simple token estimation: ~4 chars/token for Latin, ~1.5 for CJK
44    /// Mixed text uses a compromise of 3 chars/token
45    pub fn estimate_tokens(text: &str) -> usize {
46        if text.is_empty() {
47            return 0;
48        }
49        let chars = text.chars().count();
50        let cjk_count = text.chars().filter(|c| is_cjk(*c)).count();
51        let latin_count = chars - cjk_count;
52        // CJK: ~1.5 chars/token, Latin: ~4 chars/token
53        (cjk_count as f64 / 1.5 + latin_count as f64 / 4.0).ceil() as usize
54    }
55
56    pub(crate) fn message_tokens(msg: &ChatMessage) -> usize {
57        match msg {
58            ChatMessage::System { content, .. } => Self::estimate_tokens(content),
59            ChatMessage::User {
60                content, images, ..
61            } => {
62                let mut tokens = Self::estimate_tokens(content);
63                for img in images {
64                    match img {
65                        crate::types::ImageAttachment::Url { url, detail: _ } => {
66                            tokens += Self::estimate_tokens(url);
67                        }
68                        crate::types::ImageAttachment::Base64 {
69                            data,
70                            media_type,
71                            detail: _,
72                        } => {
73                            tokens += data.len() / 4;
74                            if let Some(mt) = media_type {
75                                tokens += Self::estimate_tokens(mt);
76                            }
77                        }
78                    }
79                    tokens += Self::IMAGE_OVERHEAD_TOKENS;
80                }
81                tokens
82            }
83            ChatMessage::Assistant {
84                content,
85                reasoning_content,
86                tool_calls,
87                thinking_signature: _,
88            } => {
89                let mut tokens = content.as_deref().map(Self::estimate_tokens).unwrap_or(0);
90                if let Some(rc) = reasoning_content {
91                    tokens += Self::estimate_tokens(rc);
92                }
93                if let Some(tc) = tool_calls {
94                    for t in tc {
95                        tokens += Self::estimate_tokens(&t.name);
96                        tokens += Self::estimate_tokens(&t.arguments);
97                        tokens += Self::estimate_tokens(&t.id);
98                    }
99                }
100                tokens
101            }
102            ChatMessage::Tool {
103                tool_call_id,
104                content,
105                ..
106            } => Self::estimate_tokens(tool_call_id) + Self::estimate_tokens(content),
107            ChatMessage::Custom { role, data } => {
108                Self::estimate_tokens(role) + Self::estimate_tokens(&data.to_string())
109            }
110        }
111    }
112
113    /// Trim message list to keep total tokens under `max_tokens`。
114    ///
115    /// Trimming strategy:
116    /// - Always keep the first `keep_first_n` messages (typically system prompt)
117    /// - Always keep the last `keep_last_n` messages (recent conversation)
118    /// - Remove oldest messages from the middle until within budget
119    pub fn trim(&self, messages: &mut Vec<ChatMessage>) {
120        if messages.is_empty() || self.max_tokens == 0 {
121            return;
122        }
123
124        let total_tokens: usize = messages.iter().map(Self::message_tokens).sum();
125        if total_tokens <= self.max_tokens {
126            return;
127        }
128
129        let keep_first = self.keep_first_n.min(messages.len());
130        let keep_last = self
131            .keep_last_n
132            .min(messages.len().saturating_sub(keep_first));
133
134        // Trimmable range: [keep_first, messages.len() - keep_last)
135        let trim_start = keep_first;
136        let trim_end = messages.len().saturating_sub(keep_last);
137        if trim_start >= trim_end {
138            return;
139        }
140
141        let mut current_tokens: usize = total_tokens;
142        let remove_idx = trim_start;
143        let mut trim_end = trim_end;
144
145        while current_tokens > self.max_tokens && remove_idx < trim_end {
146            let removed = Self::message_tokens(&messages[remove_idx]);
147            messages.remove(remove_idx);
148            current_tokens = current_tokens.saturating_sub(removed);
149            trim_end = messages.len().saturating_sub(keep_last);
150        }
151    }
152}
153
154fn is_cjk(c: char) -> bool {
155    matches!(
156        c,
157        '\u{4E00}'..='\u{9FFF}'   // CJK Unified Ideographs
158        | '\u{3400}'..='\u{4DBF}' // CJK Unified Ideographs Extension A
159        | '\u{3000}'..='\u{303F}' // CJK Symbols and Punctuation
160        | '\u{FF00}'..='\u{FFEF}' // Halfwidth and Fullwidth Forms
161        | '\u{3040}'..='\u{309F}' // Hiragana
162        | '\u{30A0}'..='\u{30FF}' // Katakana
163        | '\u{AC00}'..='\u{D7AF}' // Hangul Syllables
164    )
165}
166
167// ── Inline Context Compaction ──────────────────────────────────────────────
168
169/// Trait for inline context compaction within the react loop.
170///
171/// Implemented by agent-works's `ContextCompactor`. agent-base defines
172/// the trait to avoid circular dependencies (agent-works depends on agent-base).
173///
174/// The react loop calls [`compact`](Self::compact) after tool execution when
175/// the estimated token count exceeds a configurable threshold. This prevents
176/// context window overflow without requiring the LLM call to fail first.
177#[async_trait::async_trait]
178pub trait ContextCompaction: Send + Sync {
179    /// Compact a message history.
180    ///
181    /// Takes the current messages and returns `Some(compacted_messages)` if
182    /// compaction was performed. Returns `None` if compaction was skipped
183    /// (below threshold, too few messages, or disabled).
184    ///
185    /// The react loop handles reading/writing the session — the compactor
186    /// only transforms the message list.
187    async fn compact(
188        &self,
189        session_id: &SessionId,
190        messages: &[ChatMessage],
191    ) -> Option<Vec<ChatMessage>>;
192
193    /// Estimate current token count for the session.
194    ///
195    /// Returns `None` if the implementation cannot estimate (falls back to
196    /// `ContextWindowManager::estimate_tokens` on the react loop side).
197    fn token_count_hint(&self, session_id: &SessionId) -> Option<usize>;
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use crate::types::{ImageAttachment, ToolCallMessage};
204
205    #[test]
206    fn test_estimate_tokens_empty() {
207        assert_eq!(ContextWindowManager::estimate_tokens(""), 0);
208    }
209
210    #[test]
211    fn test_estimate_tokens_english() {
212        let text = "Hello world this is a test";
213        let tokens = ContextWindowManager::estimate_tokens(text);
214        // ~28 chars / 4 ≈ 7
215        assert!(tokens > 0 && tokens <= 15);
216    }
217
218    #[test]
219    fn test_estimate_tokens_cjk() {
220        // 4 CJK chars / 1.5 -> ceil(2.667) = 3
221        assert_eq!(ContextWindowManager::estimate_tokens("你好世界"), 3);
222    }
223
224    #[test]
225    fn test_estimate_tokens_mixed() {
226        // 2 CJK + 5 latin: 2/1.5 + 5/4 = 1.333 + 1.25 = 2.583 -> 3
227        assert_eq!(ContextWindowManager::estimate_tokens("你好hello"), 3);
228    }
229
230    #[test]
231    fn test_message_tokens_user_with_url_image() {
232        let msg = ChatMessage::user_with_images(
233            "pic",
234            vec![ImageAttachment::Url {
235                url: "http://x/a.png".into(),
236                detail: None,
237            }],
238        );
239        let base = ContextWindowManager::message_tokens(&ChatMessage::user("pic"));
240        let t = ContextWindowManager::message_tokens(&msg);
241        assert!(t > base);
242    }
243
244    #[test]
245    fn test_message_tokens_user_with_base64_image() {
246        // with media_type
247        let msg = ChatMessage::user_with_images(
248            "pic",
249            vec![ImageAttachment::Base64 {
250                data: "abcd".into(),
251                media_type: Some("image/png".into()),
252                detail: None,
253            }],
254        );
255        let base = ContextWindowManager::message_tokens(&ChatMessage::user("pic"));
256        assert!(ContextWindowManager::message_tokens(&msg) > base);
257
258        // without media_type
259        let msg = ChatMessage::user_with_images(
260            "pic",
261            vec![ImageAttachment::Base64 {
262                data: "abcd".into(),
263                media_type: None,
264                detail: None,
265            }],
266        );
267        assert!(ContextWindowManager::message_tokens(&msg) > base);
268    }
269
270    #[test]
271    fn test_message_tokens_assistant_reasoning_and_tool_calls() {
272        let msg = ChatMessage::Assistant {
273            content: Some("ans".into()),
274            reasoning_content: Some("thinking".into()),
275            tool_calls: Some(vec![ToolCallMessage {
276                id: "tc1".into(),
277                name: "echo".into(),
278                arguments: "{}".into(),
279            }]),
280            thinking_signature: None,
281        };
282        let t = ContextWindowManager::message_tokens(&msg);
283        assert!(t > 0);
284    }
285
286    #[test]
287    fn test_message_tokens_tool_and_custom() {
288        let tool = ChatMessage::tool("tc1", "done");
289        assert!(ContextWindowManager::message_tokens(&tool) > 0);
290
291        let custom = ChatMessage::Custom {
292            role: "artifact".into(),
293            data: serde_json::json!({"x": 1}),
294        };
295        assert!(ContextWindowManager::message_tokens(&custom) > 0);
296    }
297
298    #[test]
299    fn test_trim_no_trim_needed() {
300        let mgr = ContextWindowManager::new(1000);
301        let mut msgs = vec![
302            ChatMessage::system("You are a helpful assistant."),
303            ChatMessage::user("Hello"),
304            ChatMessage::assistant("Hi there!"),
305        ];
306        let original_len = msgs.len();
307        mgr.trim(&mut msgs);
308        assert_eq!(msgs.len(), original_len);
309    }
310
311    #[test]
312    fn test_trim_keeps_first_and_last() {
313        let mgr = ContextWindowManager::new(8)
314            .with_keep_first_n(1)
315            .with_keep_last_n(2);
316        let mut msgs = vec![
317            ChatMessage::system("system"),
318            ChatMessage::user("message number one"),
319            ChatMessage::assistant("message number two"),
320            ChatMessage::user("message number three"),
321            ChatMessage::assistant("message number four"),
322            ChatMessage::user("message number five"),
323            ChatMessage::assistant("message number six"),
324        ];
325        mgr.trim(&mut msgs);
326        assert_eq!(msgs.len(), 3);
327        assert!(matches!(msgs[0], ChatMessage::System { .. }));
328    }
329}
330
331#[cfg(test)]
332mod proptest_tests {
333    use super::*;
334    use proptest::prelude::*;
335
336    proptest! {
337        #[test]
338        fn estimate_tokens_never_panics(text in ".*") {
339            let tokens = ContextWindowManager::estimate_tokens(&text);
340            // tokens should be non-negative (usize) and reasonable
341            assert!(tokens <= text.len() + 1); // at most 1 token per byte + ceil
342        }
343
344        #[test]
345        fn estimate_tokens_empty_is_zero(text in "[a-z\u{4e00}-\u{9fff}]{0,100}") {
346            if text.is_empty() {
347                assert_eq!(ContextWindowManager::estimate_tokens(&text), 0);
348            } else {
349                assert!(ContextWindowManager::estimate_tokens(&text) > 0);
350            }
351        }
352
353        #[test]
354        fn estimate_tokens_cjk_higher_than_latin_same_len(
355            cjk_text in "[\u{4e00}-\u{9fff}]{1,50}",
356            latin_text in "[a-z]{1,50}",
357        ) {
358            // Pad to same char length
359            let max_len = cjk_text.chars().count().max(latin_text.chars().count());
360            let cjk_padded: String = cjk_text.chars().cycle().take(max_len).collect();
361            let latin_padded: String = latin_text.chars().cycle().take(max_len).collect();
362            let cjk_tokens = ContextWindowManager::estimate_tokens(&cjk_padded);
363            let latin_tokens = ContextWindowManager::estimate_tokens(&latin_padded);
364            // CJK ~1.5 chars/token, Latin ~4 chars/token → CJK uses more tokens
365            assert!(cjk_tokens >= latin_tokens,
366                "CJK ({}) should use >= tokens than Latin ({}) for {} chars",
367                cjk_tokens, latin_tokens, max_len);
368        }
369
370        #[test]
371        fn trim_preserves_system_prefix(
372            num_messages in 2usize..15,
373            max_tokens in 5usize..50,
374        ) {
375            let mgr = ContextWindowManager {
376                max_tokens,
377                keep_first_n: 1,
378                keep_last_n: 0,
379            };
380            let mut msgs = vec![ChatMessage::system("system prompt")];
381            for i in 0..num_messages {
382                msgs.push(ChatMessage::user(format!("message {}", i)));
383            }
384            mgr.trim(&mut msgs);
385            // System message should always be preserved
386            assert!(!msgs.is_empty());
387            assert!(matches!(msgs[0], ChatMessage::System { .. }));
388        }
389
390        #[test]
391        fn trim_result_within_budget(
392            num_messages in 3usize..15,
393            max_tokens in 10usize..100,
394        ) {
395            let mgr = ContextWindowManager {
396                max_tokens,
397                keep_first_n: 1,
398                keep_last_n: 1,
399            };
400            let mut msgs = vec![ChatMessage::system("sys")];
401            for i in 0..num_messages {
402                msgs.push(ChatMessage::user(format!("msg {}", i)));
403            }
404            let total_before: usize = msgs.iter().map(ContextWindowManager::message_tokens).sum();
405            // Only test when we actually exceed budget
406            if total_before > max_tokens {
407                mgr.trim(&mut msgs);
408                let total_after: usize = msgs.iter().map(ContextWindowManager::message_tokens).sum();
409                // After trimming, should be within budget (or couldn't trim more)
410                assert!(total_after <= total_before);
411            }
412        }
413    }
414}