Skip to main content

agent_base/engine/
context.rs

1use crate::types::{ChatMessage, SessionId};
2
3/// Find the first non-ephemeral System message (the system prompt).
4pub 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
14/// Estimate total tokens across a message list using `ContextWindowManager::message_tokens`.
15pub fn estimate_messages_tokens(messages: &[ChatMessage]) -> usize {
16    messages
17        .iter()
18        .map(ContextWindowManager::message_tokens)
19        .sum()
20}
21
22// ── Context Window Manager ──────────────────────────────────────────────────
23
24#[derive(Clone, Debug)]
25pub struct ContextWindowManager {
26    pub max_tokens: usize,
27    /// Always keep first N messages (typically system prompt)
28    pub keep_first_n: usize,
29    /// Always keep last N messages
30    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    /// OpenAI Vision API fixed token overhead per image
45    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    /// Simple token estimation: ~4 chars/token for Latin, ~1.5 for CJK
65    /// Mixed text uses a compromise of 3 chars/token
66    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: ~1.5 chars/token, Latin: ~4 chars/token
74        (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    /// Trim message list to keep total tokens under `max_tokens`。
135    ///
136    /// Trimming strategy:
137    /// - Always keep the first `keep_first_n` messages (typically system prompt)
138    /// - Always keep the last `keep_last_n` messages (recent conversation)
139    /// - Remove oldest messages from the middle until within budget
140    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        // Trimmable range: [keep_first, messages.len() - keep_last)
156        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}'   // CJK Unified Ideographs
179        | '\u{3400}'..='\u{4DBF}' // CJK Unified Ideographs Extension A
180        | '\u{3000}'..='\u{303F}' // CJK Symbols and Punctuation
181        | '\u{FF00}'..='\u{FFEF}' // Halfwidth and Fullwidth Forms
182        | '\u{3040}'..='\u{309F}' // Hiragana
183        | '\u{30A0}'..='\u{30FF}' // Katakana
184        | '\u{AC00}'..='\u{D7AF}' // Hangul Syllables
185    )
186}
187
188// ── Inline Context Compaction ──────────────────────────────────────────────
189
190/// Trait for inline context compaction within the react loop.
191///
192/// Implemented by agent-works's `ContextCompactor`. agent-base defines
193/// the trait to avoid circular dependencies (agent-works depends on agent-base).
194///
195/// The react loop calls [`compact`](Self::compact) after tool execution when
196/// the estimated token count exceeds a configurable threshold. This prevents
197/// context window overflow without requiring the LLM call to fail first.
198#[async_trait::async_trait]
199pub trait ContextCompaction: Send + Sync {
200    /// Compact a message history.
201    ///
202    /// Takes the current messages and returns `Some(compacted_messages)` if
203    /// compaction was performed. Returns `None` if compaction was skipped
204    /// (below threshold, too few messages, or disabled).
205    ///
206    /// The react loop handles reading/writing the session — the compactor
207    /// only transforms the message list.
208    async fn compact(
209        &self,
210        session_id: &SessionId,
211        messages: &[ChatMessage],
212    ) -> Option<Vec<ChatMessage>>;
213
214    /// Estimate current token count for the session.
215    ///
216    /// Returns `None` if the implementation cannot estimate (falls back to
217    /// `ContextWindowManager::estimate_tokens` on the react loop side).
218    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        // ~28 chars / 4 ≈ 7
236        assert!(tokens > 0 && tokens <= 15);
237    }
238
239    #[test]
240    fn test_estimate_tokens_cjk() {
241        // 4 CJK chars / 1.5 -> ceil(2.667) = 3
242        assert_eq!(ContextWindowManager::estimate_tokens("你好世界"), 3);
243    }
244
245    #[test]
246    fn test_estimate_tokens_mixed() {
247        // 2 CJK + 5 latin: 2/1.5 + 5/4 = 1.333 + 1.25 = 2.583 -> 3
248        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        // with media_type
268        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        // without media_type
280        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            // tokens should be non-negative (usize) and reasonable
362            assert!(tokens <= text.len() + 1); // at most 1 token per byte + ceil
363        }
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            // Pad to same char length
380            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            // CJK ~1.5 chars/token, Latin ~4 chars/token → CJK uses more tokens
386            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            // System message should always be preserved
407            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            // Only test when we actually exceed budget
427            if total_before > max_tokens {
428                mgr.trim(&mut msgs);
429                let total_after: usize = msgs.iter().map(ContextWindowManager::message_tokens).sum();
430                // After trimming, should be within budget (or couldn't trim more)
431                assert!(total_after <= total_before);
432            }
433        }
434    }
435}