Skip to main content

agent_base/engine/
context.rs

1use crate::types::ChatMessage;
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            } => {
88                let mut tokens = content.as_deref().map(Self::estimate_tokens).unwrap_or(0);
89                if let Some(rc) = reasoning_content {
90                    tokens += Self::estimate_tokens(rc);
91                }
92                if let Some(tc) = tool_calls {
93                    for t in tc {
94                        tokens += Self::estimate_tokens(&t.name);
95                        tokens += Self::estimate_tokens(&t.arguments);
96                        tokens += Self::estimate_tokens(&t.id);
97                    }
98                }
99                tokens
100            }
101            ChatMessage::Tool {
102                tool_call_id,
103                content,
104            } => Self::estimate_tokens(tool_call_id) + Self::estimate_tokens(content),
105            ChatMessage::Custom { role, data } => {
106                Self::estimate_tokens(role) + Self::estimate_tokens(&data.to_string())
107            }
108        }
109    }
110
111    /// Trim message list to keep total tokens under `max_tokens`。
112    ///
113    /// Trimming strategy:
114    /// - Always keep the first `keep_first_n` messages (typically system prompt)
115    /// - Always keep the last `keep_last_n` messages (recent conversation)
116    /// - Remove oldest messages from the middle until within budget
117    pub fn trim(&self, messages: &mut Vec<ChatMessage>) {
118        if messages.is_empty() || self.max_tokens == 0 {
119            return;
120        }
121
122        let total_tokens: usize = messages.iter().map(Self::message_tokens).sum();
123        if total_tokens <= self.max_tokens {
124            return;
125        }
126
127        let keep_first = self.keep_first_n.min(messages.len());
128        let keep_last = self
129            .keep_last_n
130            .min(messages.len().saturating_sub(keep_first));
131
132        // Trimmable range: [keep_first, messages.len() - keep_last)
133        let trim_start = keep_first;
134        let trim_end = messages.len().saturating_sub(keep_last);
135        if trim_start >= trim_end {
136            return;
137        }
138
139        let mut current_tokens: usize = total_tokens;
140        let remove_idx = trim_start;
141        let mut trim_end = trim_end;
142
143        while current_tokens > self.max_tokens && remove_idx < trim_end {
144            let removed = Self::message_tokens(&messages[remove_idx]);
145            messages.remove(remove_idx);
146            current_tokens = current_tokens.saturating_sub(removed);
147            trim_end = messages.len().saturating_sub(keep_last);
148        }
149    }
150}
151
152fn is_cjk(c: char) -> bool {
153    matches!(
154        c,
155        '\u{4E00}'..='\u{9FFF}'   // CJK Unified Ideographs
156        | '\u{3400}'..='\u{4DBF}' // CJK Unified Ideographs Extension A
157        | '\u{3000}'..='\u{303F}' // CJK Symbols and Punctuation
158        | '\u{FF00}'..='\u{FFEF}' // Halfwidth and Fullwidth Forms
159        | '\u{3040}'..='\u{309F}' // Hiragana
160        | '\u{30A0}'..='\u{30FF}' // Katakana
161        | '\u{AC00}'..='\u{D7AF}' // Hangul Syllables
162    )
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use crate::types::{ImageAttachment, ToolCallMessage};
169
170    #[test]
171    fn test_estimate_tokens_empty() {
172        assert_eq!(ContextWindowManager::estimate_tokens(""), 0);
173    }
174
175    #[test]
176    fn test_estimate_tokens_english() {
177        let text = "Hello world this is a test";
178        let tokens = ContextWindowManager::estimate_tokens(text);
179        // ~28 chars / 4 ≈ 7
180        assert!(tokens > 0 && tokens <= 15);
181    }
182
183    #[test]
184    fn test_estimate_tokens_cjk() {
185        // 4 CJK chars / 1.5 -> ceil(2.667) = 3
186        assert_eq!(ContextWindowManager::estimate_tokens("你好世界"), 3);
187    }
188
189    #[test]
190    fn test_estimate_tokens_mixed() {
191        // 2 CJK + 5 latin: 2/1.5 + 5/4 = 1.333 + 1.25 = 2.583 -> 3
192        assert_eq!(ContextWindowManager::estimate_tokens("你好hello"), 3);
193    }
194
195    #[test]
196    fn test_message_tokens_user_with_url_image() {
197        let msg = ChatMessage::user_with_images(
198            "pic",
199            vec![ImageAttachment::Url {
200                url: "http://x/a.png".into(),
201                detail: None,
202            }],
203        );
204        let base = ContextWindowManager::message_tokens(&ChatMessage::user("pic"));
205        let t = ContextWindowManager::message_tokens(&msg);
206        assert!(t > base);
207    }
208
209    #[test]
210    fn test_message_tokens_user_with_base64_image() {
211        // with media_type
212        let msg = ChatMessage::user_with_images(
213            "pic",
214            vec![ImageAttachment::Base64 {
215                data: "abcd".into(),
216                media_type: Some("image/png".into()),
217                detail: None,
218            }],
219        );
220        let base = ContextWindowManager::message_tokens(&ChatMessage::user("pic"));
221        assert!(ContextWindowManager::message_tokens(&msg) > base);
222
223        // without media_type
224        let msg = ChatMessage::user_with_images(
225            "pic",
226            vec![ImageAttachment::Base64 {
227                data: "abcd".into(),
228                media_type: None,
229                detail: None,
230            }],
231        );
232        assert!(ContextWindowManager::message_tokens(&msg) > base);
233    }
234
235    #[test]
236    fn test_message_tokens_assistant_reasoning_and_tool_calls() {
237        let msg = ChatMessage::Assistant {
238            content: Some("ans".into()),
239            reasoning_content: Some("thinking".into()),
240            tool_calls: Some(vec![ToolCallMessage {
241                id: "tc1".into(),
242                name: "echo".into(),
243                arguments: "{}".into(),
244            }]),
245        };
246        let t = ContextWindowManager::message_tokens(&msg);
247        assert!(t > 0);
248    }
249
250    #[test]
251    fn test_message_tokens_tool_and_custom() {
252        let tool = ChatMessage::tool("tc1", "done");
253        assert!(ContextWindowManager::message_tokens(&tool) > 0);
254
255        let custom = ChatMessage::Custom {
256            role: "artifact".into(),
257            data: serde_json::json!({"x": 1}),
258        };
259        assert!(ContextWindowManager::message_tokens(&custom) > 0);
260    }
261
262    #[test]
263    fn test_trim_no_trim_needed() {
264        let mgr = ContextWindowManager::new(1000);
265        let mut msgs = vec![
266            ChatMessage::system("You are a helpful assistant."),
267            ChatMessage::user("Hello"),
268            ChatMessage::assistant("Hi there!"),
269        ];
270        let original_len = msgs.len();
271        mgr.trim(&mut msgs);
272        assert_eq!(msgs.len(), original_len);
273    }
274
275    #[test]
276    fn test_trim_keeps_first_and_last() {
277        let mgr = ContextWindowManager::new(8)
278            .with_keep_first_n(1)
279            .with_keep_last_n(2);
280        let mut msgs = vec![
281            ChatMessage::system("system"),
282            ChatMessage::user("message number one"),
283            ChatMessage::assistant("message number two"),
284            ChatMessage::user("message number three"),
285            ChatMessage::assistant("message number four"),
286            ChatMessage::user("message number five"),
287            ChatMessage::assistant("message number six"),
288        ];
289        mgr.trim(&mut msgs);
290        assert_eq!(msgs.len(), 3);
291        assert!(matches!(msgs[0], ChatMessage::System { .. }));
292    }
293}