1use crate::types::{ChatMessage, SessionId};
2
3pub fn first_system_prompt(messages: &[ChatMessage]) -> Option<ChatMessage> {
5 messages.iter().find_map(|msg| match msg {
6 ChatMessage::System {
7 content,
8 ephemeral: false,
9 } => Some(ChatMessage::system(content.clone())),
10 _ => None,
11 })
12}
13
14pub fn estimate_messages_tokens(messages: &[ChatMessage]) -> usize {
16 messages
17 .iter()
18 .map(ContextWindowManager::message_tokens)
19 .sum()
20}
21
22#[derive(Clone, Debug)]
25pub struct ContextWindowManager {
26 pub max_tokens: usize,
27 pub keep_first_n: usize,
29 pub keep_last_n: usize,
31}
32
33impl Default for ContextWindowManager {
34 fn default() -> Self {
35 Self {
36 max_tokens: 128_000,
37 keep_first_n: 1,
38 keep_last_n: 20,
39 }
40 }
41}
42
43impl ContextWindowManager {
44 const IMAGE_OVERHEAD_TOKENS: usize = 85;
46
47 pub fn new(max_tokens: usize) -> Self {
48 Self {
49 max_tokens,
50 ..Default::default()
51 }
52 }
53
54 pub fn with_keep_first_n(mut self, n: usize) -> Self {
55 self.keep_first_n = n;
56 self
57 }
58
59 pub fn with_keep_last_n(mut self, n: usize) -> Self {
60 self.keep_last_n = n;
61 self
62 }
63
64 pub fn estimate_tokens(text: &str) -> usize {
67 if text.is_empty() {
68 return 0;
69 }
70 let chars = text.chars().count();
71 let cjk_count = text.chars().filter(|c| is_cjk(*c)).count();
72 let latin_count = chars - cjk_count;
73 (cjk_count as f64 / 1.5 + latin_count as f64 / 4.0).ceil() as usize
75 }
76
77 pub(crate) fn message_tokens(msg: &ChatMessage) -> usize {
78 match msg {
79 ChatMessage::System { content, .. } => Self::estimate_tokens(content),
80 ChatMessage::User {
81 content, images, ..
82 } => {
83 let mut tokens = Self::estimate_tokens(content);
84 for img in images {
85 match img {
86 crate::types::ImageAttachment::Url { url, detail: _ } => {
87 tokens += Self::estimate_tokens(url);
88 }
89 crate::types::ImageAttachment::Base64 {
90 data,
91 media_type,
92 detail: _,
93 } => {
94 tokens += data.len() / 4;
95 if let Some(mt) = media_type {
96 tokens += Self::estimate_tokens(mt);
97 }
98 }
99 }
100 tokens += Self::IMAGE_OVERHEAD_TOKENS;
101 }
102 tokens
103 }
104 ChatMessage::Assistant {
105 content,
106 reasoning_content,
107 tool_calls,
108 thinking_signature: _,
109 } => {
110 let mut tokens = content.as_deref().map(Self::estimate_tokens).unwrap_or(0);
111 if let Some(rc) = reasoning_content {
112 tokens += Self::estimate_tokens(rc);
113 }
114 if let Some(tc) = tool_calls {
115 for t in tc {
116 tokens += Self::estimate_tokens(&t.name);
117 tokens += Self::estimate_tokens(&t.arguments);
118 tokens += Self::estimate_tokens(&t.id);
119 }
120 }
121 tokens
122 }
123 ChatMessage::Tool {
124 tool_call_id,
125 content,
126 ..
127 } => Self::estimate_tokens(tool_call_id) + Self::estimate_tokens(content),
128 ChatMessage::Custom { role, data } => {
129 Self::estimate_tokens(role) + Self::estimate_tokens(&data.to_string())
130 }
131 }
132 }
133
134 pub fn trim(&self, messages: &mut Vec<ChatMessage>) {
141 if messages.is_empty() || self.max_tokens == 0 {
142 return;
143 }
144
145 let total_tokens: usize = messages.iter().map(Self::message_tokens).sum();
146 if total_tokens <= self.max_tokens {
147 return;
148 }
149
150 let keep_first = self.keep_first_n.min(messages.len());
151 let keep_last = self
152 .keep_last_n
153 .min(messages.len().saturating_sub(keep_first));
154
155 let trim_start = keep_first;
157 let trim_end = messages.len().saturating_sub(keep_last);
158 if trim_start >= trim_end {
159 return;
160 }
161
162 let mut current_tokens: usize = total_tokens;
163 let remove_idx = trim_start;
164 let mut trim_end = trim_end;
165
166 while current_tokens > self.max_tokens && remove_idx < trim_end {
167 let removed = Self::message_tokens(&messages[remove_idx]);
168 messages.remove(remove_idx);
169 current_tokens = current_tokens.saturating_sub(removed);
170 trim_end = messages.len().saturating_sub(keep_last);
171 }
172 }
173}
174
175fn is_cjk(c: char) -> bool {
176 matches!(
177 c,
178 '\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}' )
186}
187
188#[async_trait::async_trait]
199pub trait ContextCompaction: Send + Sync {
200 async fn compact(
209 &self,
210 session_id: &SessionId,
211 messages: &[ChatMessage],
212 ) -> Option<Vec<ChatMessage>>;
213
214 fn token_count_hint(&self, session_id: &SessionId) -> Option<usize>;
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224 use crate::types::{ImageAttachment, ToolCallMessage};
225
226 #[test]
227 fn test_estimate_tokens_empty() {
228 assert_eq!(ContextWindowManager::estimate_tokens(""), 0);
229 }
230
231 #[test]
232 fn test_estimate_tokens_english() {
233 let text = "Hello world this is a test";
234 let tokens = ContextWindowManager::estimate_tokens(text);
235 assert!(tokens > 0 && tokens <= 15);
237 }
238
239 #[test]
240 fn test_estimate_tokens_cjk() {
241 assert_eq!(ContextWindowManager::estimate_tokens("你好世界"), 3);
243 }
244
245 #[test]
246 fn test_estimate_tokens_mixed() {
247 assert_eq!(ContextWindowManager::estimate_tokens("你好hello"), 3);
249 }
250
251 #[test]
252 fn test_message_tokens_user_with_url_image() {
253 let msg = ChatMessage::user_with_images(
254 "pic",
255 vec![ImageAttachment::Url {
256 url: "http://x/a.png".into(),
257 detail: None,
258 }],
259 );
260 let base = ContextWindowManager::message_tokens(&ChatMessage::user("pic"));
261 let t = ContextWindowManager::message_tokens(&msg);
262 assert!(t > base);
263 }
264
265 #[test]
266 fn test_message_tokens_user_with_base64_image() {
267 let msg = ChatMessage::user_with_images(
269 "pic",
270 vec![ImageAttachment::Base64 {
271 data: "abcd".into(),
272 media_type: Some("image/png".into()),
273 detail: None,
274 }],
275 );
276 let base = ContextWindowManager::message_tokens(&ChatMessage::user("pic"));
277 assert!(ContextWindowManager::message_tokens(&msg) > base);
278
279 let msg = ChatMessage::user_with_images(
281 "pic",
282 vec![ImageAttachment::Base64 {
283 data: "abcd".into(),
284 media_type: None,
285 detail: None,
286 }],
287 );
288 assert!(ContextWindowManager::message_tokens(&msg) > base);
289 }
290
291 #[test]
292 fn test_message_tokens_assistant_reasoning_and_tool_calls() {
293 let msg = ChatMessage::Assistant {
294 content: Some("ans".into()),
295 reasoning_content: Some("thinking".into()),
296 tool_calls: Some(vec![ToolCallMessage {
297 id: "tc1".into(),
298 name: "echo".into(),
299 arguments: "{}".into(),
300 }]),
301 thinking_signature: None,
302 };
303 let t = ContextWindowManager::message_tokens(&msg);
304 assert!(t > 0);
305 }
306
307 #[test]
308 fn test_message_tokens_tool_and_custom() {
309 let tool = ChatMessage::tool("tc1", "done");
310 assert!(ContextWindowManager::message_tokens(&tool) > 0);
311
312 let custom = ChatMessage::Custom {
313 role: "artifact".into(),
314 data: serde_json::json!({"x": 1}),
315 };
316 assert!(ContextWindowManager::message_tokens(&custom) > 0);
317 }
318
319 #[test]
320 fn test_trim_no_trim_needed() {
321 let mgr = ContextWindowManager::new(1000);
322 let mut msgs = vec![
323 ChatMessage::system("You are a helpful assistant."),
324 ChatMessage::user("Hello"),
325 ChatMessage::assistant("Hi there!"),
326 ];
327 let original_len = msgs.len();
328 mgr.trim(&mut msgs);
329 assert_eq!(msgs.len(), original_len);
330 }
331
332 #[test]
333 fn test_trim_keeps_first_and_last() {
334 let mgr = ContextWindowManager::new(8)
335 .with_keep_first_n(1)
336 .with_keep_last_n(2);
337 let mut msgs = vec![
338 ChatMessage::system("system"),
339 ChatMessage::user("message number one"),
340 ChatMessage::assistant("message number two"),
341 ChatMessage::user("message number three"),
342 ChatMessage::assistant("message number four"),
343 ChatMessage::user("message number five"),
344 ChatMessage::assistant("message number six"),
345 ];
346 mgr.trim(&mut msgs);
347 assert_eq!(msgs.len(), 3);
348 assert!(matches!(msgs[0], ChatMessage::System { .. }));
349 }
350}
351
352#[cfg(test)]
353mod proptest_tests {
354 use super::*;
355 use proptest::prelude::*;
356
357 proptest! {
358 #[test]
359 fn estimate_tokens_never_panics(text in ".*") {
360 let tokens = ContextWindowManager::estimate_tokens(&text);
361 assert!(tokens <= text.len() + 1); }
364
365 #[test]
366 fn estimate_tokens_empty_is_zero(text in "[a-z\u{4e00}-\u{9fff}]{0,100}") {
367 if text.is_empty() {
368 assert_eq!(ContextWindowManager::estimate_tokens(&text), 0);
369 } else {
370 assert!(ContextWindowManager::estimate_tokens(&text) > 0);
371 }
372 }
373
374 #[test]
375 fn estimate_tokens_cjk_higher_than_latin_same_len(
376 cjk_text in "[\u{4e00}-\u{9fff}]{1,50}",
377 latin_text in "[a-z]{1,50}",
378 ) {
379 let max_len = cjk_text.chars().count().max(latin_text.chars().count());
381 let cjk_padded: String = cjk_text.chars().cycle().take(max_len).collect();
382 let latin_padded: String = latin_text.chars().cycle().take(max_len).collect();
383 let cjk_tokens = ContextWindowManager::estimate_tokens(&cjk_padded);
384 let latin_tokens = ContextWindowManager::estimate_tokens(&latin_padded);
385 assert!(cjk_tokens >= latin_tokens,
387 "CJK ({}) should use >= tokens than Latin ({}) for {} chars",
388 cjk_tokens, latin_tokens, max_len);
389 }
390
391 #[test]
392 fn trim_preserves_system_prefix(
393 num_messages in 2usize..15,
394 max_tokens in 5usize..50,
395 ) {
396 let mgr = ContextWindowManager {
397 max_tokens,
398 keep_first_n: 1,
399 keep_last_n: 0,
400 };
401 let mut msgs = vec![ChatMessage::system("system prompt")];
402 for i in 0..num_messages {
403 msgs.push(ChatMessage::user(format!("message {}", i)));
404 }
405 mgr.trim(&mut msgs);
406 assert!(!msgs.is_empty());
408 assert!(matches!(msgs[0], ChatMessage::System { .. }));
409 }
410
411 #[test]
412 fn trim_result_within_budget(
413 num_messages in 3usize..15,
414 max_tokens in 10usize..100,
415 ) {
416 let mgr = ContextWindowManager {
417 max_tokens,
418 keep_first_n: 1,
419 keep_last_n: 1,
420 };
421 let mut msgs = vec![ChatMessage::system("sys")];
422 for i in 0..num_messages {
423 msgs.push(ChatMessage::user(format!("msg {}", i)));
424 }
425 let total_before: usize = msgs.iter().map(ContextWindowManager::message_tokens).sum();
426 if total_before > max_tokens {
428 mgr.trim(&mut msgs);
429 let total_after: usize = msgs.iter().map(ContextWindowManager::message_tokens).sum();
430 assert!(total_after <= total_before);
432 }
433 }
434 }
435}