deepstrike_core/context/
token_engine.rs1use std::sync::Arc;
2
3use crate::types::message::{Content, ContentPart, Message};
4
5pub trait TokenCounter: Send + Sync {
8 fn count(&self, text: &str) -> u32;
10
11 fn truncate<'a>(&self, text: &'a str, max_tokens: u32) -> &'a str;
14}
15
16pub 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(); 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#[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 ContentPart::Image { .. } | ContentPart::Audio { .. } => {
78 part.estimate_tokens().unwrap_or(1)
79 }
80 }
81 }
82
83 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 let cjk_count = e.count("你好世界"); let ascii_count = e.count("abcd"); 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 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 assert!(e.count_message(&audio) < 100);
202 }
203}