agent_base/engine/
context.rs1use crate::types::ChatMessage;
2
3#[derive(Clone, Debug)]
4pub struct ContextWindowManager {
5 pub max_tokens: usize,
6 pub keep_first_n: usize,
8 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 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 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_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 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 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}' | '\u{3400}'..='\u{4DBF}' | '\u{3000}'..='\u{303F}' | '\u{FF00}'..='\u{FFEF}' | '\u{3040}'..='\u{309F}' | '\u{30A0}'..='\u{30FF}' | '\u{AC00}'..='\u{D7AF}' )
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 assert!(tokens > 0 && tokens <= 15);
181 }
182
183 #[test]
184 fn test_estimate_tokens_cjk() {
185 assert_eq!(ContextWindowManager::estimate_tokens("你好世界"), 3);
187 }
188
189 #[test]
190 fn test_estimate_tokens_mixed() {
191 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 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 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}