Skip to main content

deepstrike_core/context/
token_engine.rs

1use std::sync::Arc;
2
3use crate::types::message::{Content, ContentPart, Message};
4
5/// Token counting and truncation interface. Implementations must be
6/// deterministic and must never panic on any valid UTF-8 input.
7pub trait TokenCounter: Send + Sync {
8    /// Count tokens in a UTF-8 string.
9    fn count(&self, text: &str) -> u32;
10
11    /// Return the longest prefix of `text` that fits within `max_tokens`.
12    /// The returned slice is always a valid UTF-8 prefix of `text`.
13    fn truncate<'a>(&self, text: &'a str, max_tokens: u32) -> &'a str;
14}
15
16/// Char-count approximation: 4 chars ≈ 1 token.
17/// Used when no real tokeniser is available. More accurate than byte-count
18/// for CJK text (3 bytes/char but ~0.5 tokens/char).
19pub struct CharApproxCounter;
20
21impl TokenCounter for CharApproxCounter {
22    fn count(&self, text: &str) -> u32 {
23        (text.chars().count() as u32 / 4).max(1)
24    }
25
26    fn truncate<'a>(&self, text: &'a str, max_tokens: u32) -> &'a str {
27        let max_chars = (max_tokens as usize).saturating_mul(4);
28        let mut byte_end = text.len(); // default: keep all
29        let mut seen = 0usize;
30        for (byte_idx, _) in text.char_indices() {
31            if seen >= max_chars {
32                byte_end = byte_idx;
33                break;
34            }
35            seen += 1;
36        }
37        &text[..byte_end]
38    }
39}
40
41/// Cheaply cloneable token engine shared across the context subsystem.
42/// All token counting and truncation goes through this single object —
43/// pressure, compression, and render use the same backend.
44#[derive(Clone)]
45pub struct ContextTokenEngine(Arc<dyn TokenCounter>);
46
47impl ContextTokenEngine {
48    pub fn char_approx() -> Self {
49        Self(Arc::new(CharApproxCounter))
50    }
51
52    pub fn count(&self, text: &str) -> u32 {
53        self.0.count(text)
54    }
55
56    pub fn truncate<'a>(&self, text: &'a str, max_tokens: u32) -> &'a str {
57        self.0.truncate(text, max_tokens)
58    }
59
60    pub fn token_budget_to_bytes(&self, tokens: u32) -> usize {
61        (tokens as usize).saturating_mul(4)
62    }
63
64    pub fn count_message(&self, msg: &Message) -> u32 {
65        match &msg.content {
66            Content::Text(t) => self.count(t),
67            Content::Parts(parts) => parts.iter().map(|p| self.count_part(p)).sum(),
68        }
69    }
70
71    fn count_part(&self, part: &ContentPart) -> u32 {
72        match part {
73            ContentPart::Text { text } => self.count(text),
74            ContentPart::ToolResult { output, .. } => self.count(output.as_str()),
75            // Image/Audio: modality heuristic from ContentPart::estimate_tokens — never
76            // treat base64/url payloads as UTF-8 text (that blind-spots compression ρ).
77            ContentPart::Image { .. } | ContentPart::Audio { .. } => {
78                part.estimate_tokens().unwrap_or(1)
79            }
80        }
81    }
82
83    /// Truncate a text message to `max_tokens`. Returns the message unchanged
84    /// if it fits. Parts messages are never truncated — mangling structured
85    /// content produces worse outcomes than a minor token overrun.
86    pub fn truncate_message(&self, msg: &Message, max_tokens: u32) -> Message {
87        match &msg.content {
88            Content::Text(t) => {
89                let kept = self.0.truncate(t, max_tokens);
90                if kept.len() < t.len() {
91                    let mut m = msg.clone();
92                    m.content = Content::Text(format!("{}… [truncated]", kept));
93                    m.token_count = Some(max_tokens);
94                    m
95                } else {
96                    msg.clone()
97                }
98            }
99            Content::Parts(_) => msg.clone(),
100        }
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use crate::types::message::{ContentPart, Message};
108
109    fn engine() -> ContextTokenEngine {
110        ContextTokenEngine::char_approx()
111    }
112
113    #[test]
114    fn count_nonzero_for_nonempty_text() {
115        assert!(engine().count("hello") > 0);
116    }
117
118    #[test]
119    fn count_is_char_based_not_byte_based() {
120        let e = engine();
121        // "你好" = 6 bytes, 2 chars → count = max(2/4, 1) = 1
122        // "hello" = 5 bytes, 5 chars → count = max(5/4, 1) = 1
123        // The key property: count doesn't grow 3× for CJK vs ASCII
124        let cjk_count = e.count("你好世界"); // 4 chars
125        let ascii_count = e.count("abcd"); // 4 chars (same char count)
126        assert_eq!(cjk_count, ascii_count);
127    }
128
129    #[test]
130    fn truncate_stays_within_budget() {
131        let e = engine();
132        let text = "a".repeat(1000);
133        let kept = e.0.truncate(&text, 10);
134        assert!(e.count(kept) <= 10);
135    }
136
137    #[test]
138    fn truncate_cjk_valid_utf8() {
139        let e = engine();
140        let text = "你好世界".repeat(100);
141        let kept = e.0.truncate(&text, 5);
142        assert!(std::str::from_utf8(kept.as_bytes()).is_ok());
143    }
144
145    #[test]
146    fn truncate_count_le_budget() {
147        let e = engine();
148        for max in [1u32, 5, 20, 100] {
149            let kept =
150                e.0.truncate("The quick brown fox jumps over the lazy dog.", max);
151            assert!(
152                e.count(kept) <= max,
153                "max={max} kept_count={}",
154                e.count(kept)
155            );
156        }
157    }
158
159    #[test]
160    fn truncate_message_appends_suffix_on_cut() {
161        let e = engine();
162        let msg = Message::user("a".repeat(200));
163        let truncated = e.truncate_message(&msg, 5);
164        let text = truncated.content.as_text().unwrap();
165        assert!(text.ends_with("… [truncated]"), "got: {text}");
166    }
167
168    #[test]
169    fn truncate_message_unchanged_when_fits() {
170        let e = engine();
171        let msg = Message::user("hi");
172        let out = e.truncate_message(&msg, 1000);
173        assert_eq!(out.content.as_text().unwrap(), "hi");
174    }
175
176    #[test]
177    fn count_image_uses_detail_heuristic_not_one() {
178        let e = engine();
179        let low = Message::user_multimodal(vec![ContentPart::image_base64_with_detail(
180            "abc", "image/png", "low",
181        )]);
182        let auto = Message::user_multimodal(vec![ContentPart::image_base64("abc", "image/png")]);
183        let high = Message::user_multimodal(vec![ContentPart::image_base64_with_detail(
184            "abc", "image/png", "high",
185        )]);
186        assert_eq!(e.count_message(&low), 85);
187        assert_eq!(e.count_message(&auto), 255);
188        assert_eq!(e.count_message(&high), 680);
189    }
190
191    #[test]
192    fn count_audio_uses_decoded_byte_heuristic_not_base64_text() {
193        let e = engine();
194        // 6400 base64 chars → ~4800 decoded bytes → 4800/1600 = 3 tokens
195        let audio = Message::user_multimodal(vec![ContentPart::audio(
196            "A".repeat(6400),
197            "audio/wav",
198        )]);
199        assert_eq!(e.count_message(&audio), 3);
200        // Must not explode to thousands the way counting base64 as text would.
201        assert!(e.count_message(&audio) < 100);
202    }
203}