1use crate::types::{ChatMessage, SessionId};
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 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 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 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}' | '\u{3400}'..='\u{4DBF}' | '\u{3000}'..='\u{303F}' | '\u{FF00}'..='\u{FFEF}' | '\u{3040}'..='\u{309F}' | '\u{30A0}'..='\u{30FF}' | '\u{AC00}'..='\u{D7AF}' )
165}
166
167#[async_trait::async_trait]
178pub trait ContextCompaction: Send + Sync {
179 async fn compact(
188 &self,
189 session_id: &SessionId,
190 messages: &[ChatMessage],
191 ) -> Option<Vec<ChatMessage>>;
192
193 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 assert!(tokens > 0 && tokens <= 15);
216 }
217
218 #[test]
219 fn test_estimate_tokens_cjk() {
220 assert_eq!(ContextWindowManager::estimate_tokens("你好世界"), 3);
222 }
223
224 #[test]
225 fn test_estimate_tokens_mixed() {
226 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 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 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 assert!(tokens <= text.len() + 1); }
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 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 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 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 if total_before > max_tokens {
407 mgr.trim(&mut msgs);
408 let total_after: usize = msgs.iter().map(ContextWindowManager::message_tokens).sum();
409 assert!(total_after <= total_before);
411 }
412 }
413 }
414}