Skip to main content

agent_sdk/context/
estimator.rs

1//! Token estimation for context size calculation.
2
3use crate::llm::{Content, ContentBlock, Message};
4
5/// Estimates token count for messages.
6///
7/// Uses a simple heuristic of ~4 characters per token, which provides
8/// a reasonable approximation for most English text and code.
9///
10/// For more accurate counting, consider using a tokenizer library
11/// specific to your model (e.g., tiktoken for `OpenAI` models).
12pub struct TokenEstimator;
13
14impl TokenEstimator {
15    /// Characters per token estimate.
16    /// This is a conservative estimate; actual ratio varies by content.
17    const CHARS_PER_TOKEN: usize = 4;
18
19    /// Overhead tokens per message (role, formatting).
20    const MESSAGE_OVERHEAD: usize = 4;
21
22    /// Overhead for tool use blocks (id, name, formatting).
23    const TOOL_USE_OVERHEAD: usize = 20;
24
25    /// Overhead for tool result blocks (id, formatting).
26    const TOOL_RESULT_OVERHEAD: usize = 10;
27
28    /// Minimum token estimate for redacted thinking blocks.
29    ///
30    /// Even small redacted thinking blocks carry significant API token cost
31    /// because they contain encrypted reasoning that the model must process.
32    const REDACTED_THINKING_MIN_TOKENS: usize = 512;
33
34    /// Minimum estimate for an opaque provider reasoning item.
35    ///
36    /// The SDK never interprets the item, but it is still replayed into model
37    /// context and therefore needs a conservative non-zero estimate.
38    const OPAQUE_REASONING_MIN_TOKENS: usize = 512;
39
40    /// Estimate tokens for a text string.
41    #[must_use]
42    pub const fn estimate_text(text: &str) -> usize {
43        // Simple estimation: ~4 chars per token
44        text.len().div_ceil(Self::CHARS_PER_TOKEN)
45    }
46
47    /// Estimate tokens for a single message.
48    #[must_use]
49    pub fn estimate_message(message: &Message) -> usize {
50        let content_tokens = match &message.content {
51            Content::Text(text) => Self::estimate_text(text),
52            Content::Blocks(blocks) => blocks.iter().map(Self::estimate_block).sum(),
53        };
54
55        content_tokens + Self::MESSAGE_OVERHEAD
56    }
57
58    /// Estimate tokens for a content block.
59    #[must_use]
60    pub fn estimate_block(block: &ContentBlock) -> usize {
61        match block {
62            ContentBlock::Text { text } => Self::estimate_text(text),
63            ContentBlock::Thinking { thinking, .. } => Self::estimate_text(thinking),
64            ContentBlock::RedactedThinking { data } => {
65                // The data field is a base64-encoded encrypted blob whose size
66                // correlates with the original thinking content.  Base64 encodes
67                // 3 bytes into 4 chars, so `data.len() * 3 / 4` approximates
68                // the raw byte count.  Using the same chars-per-token heuristic
69                // on the raw bytes gives a reasonable lower bound.
70                //
71                // A floor of REDACTED_THINKING_MIN_TOKENS prevents tiny blocks
72                // from being under-counted — the API charges substantial token
73                // overhead for every redacted thinking block regardless of size.
74                let raw_bytes = data.len() * 3 / 4;
75                let estimated = raw_bytes.div_ceil(Self::CHARS_PER_TOKEN);
76                estimated.max(Self::REDACTED_THINKING_MIN_TOKENS)
77            }
78            ContentBlock::OpaqueReasoning { provider, data } => {
79                // Account for the wire-sized JSON without allocating a
80                // serialized copy or exposing any payload value to logs.
81                let payload_tokens = Self::estimate_json_len(data)
82                    .div_ceil(Self::CHARS_PER_TOKEN)
83                    .max(Self::OPAQUE_REASONING_MIN_TOKENS);
84                Self::estimate_text(provider) + payload_tokens
85            }
86            ContentBlock::ToolUse { name, input, .. } => {
87                // Estimate the serialized JSON length without actually
88                // serializing: `needs_compaction` runs before every LLM call,
89                // so allocating a String per tool-use block on every round-trip
90                // is O(n^2) over a session. The recursive estimator also avoids
91                // the silent 0-byte underestimate that `to_string(..)
92                // .unwrap_or_default()` produced on a serialization failure.
93                let input_len = Self::estimate_json_len(input);
94                Self::estimate_text(name)
95                    + input_len.div_ceil(Self::CHARS_PER_TOKEN)
96                    + Self::TOOL_USE_OVERHEAD
97            }
98            ContentBlock::ToolResult { content, .. } => {
99                Self::estimate_text(content) + Self::TOOL_RESULT_OVERHEAD
100            }
101            ContentBlock::Image { source } | ContentBlock::Document { source } => {
102                // Rough estimate: base64 data is ~4/3 of original, 1 token per 4 chars
103                source.data.len() / 4 + Self::MESSAGE_OVERHEAD
104            }
105            // `ContentBlock` is `#[non_exhaustive]`; charge an unknown future
106            // block kind the per-message overhead as a conservative floor.
107            _ => Self::MESSAGE_OVERHEAD,
108        }
109    }
110
111    /// Estimate total tokens for a message history.
112    #[must_use]
113    pub fn estimate_history(messages: &[Message]) -> usize {
114        messages.iter().map(Self::estimate_message).sum()
115    }
116
117    /// Approximate the serialized-JSON byte length of a value without
118    /// allocating a serialized `String`.
119    ///
120    /// Mirrors `serde_json::to_string`'s output length closely enough for token
121    /// estimation: it sums key/string lengths, structural punctuation, and a
122    /// digit count for numbers. It is intentionally slightly conservative
123    /// (over-counts a trailing separator per element) since over-estimating
124    /// context size is safer than under-estimating it.
125    fn estimate_json_len(value: &serde_json::Value) -> usize {
126        match value {
127            serde_json::Value::Null => 4, // "null"
128            serde_json::Value::Bool(b) => {
129                if *b {
130                    4 // "true"
131                } else {
132                    5 // "false"
133                }
134            }
135            serde_json::Value::Number(n) => n.as_u64().map_or_else(
136                || {
137                    n.as_i64().map_or(
138                        // Floating point or arbitrary-precision: a fixed
139                        // estimate is fine for a token heuristic.
140                        8,
141                        |i| Self::decimal_digits(i.unsigned_abs()) + usize::from(i < 0),
142                    )
143                },
144                Self::decimal_digits,
145            ),
146            // String value plus surrounding quotes.
147            serde_json::Value::String(s) => s.len() + 2,
148            serde_json::Value::Array(items) => {
149                // Brackets plus a separator allowance per element.
150                2 + items
151                    .iter()
152                    .map(|item| Self::estimate_json_len(item) + 1)
153                    .sum::<usize>()
154            }
155            serde_json::Value::Object(entries) => {
156                // Braces plus key (quoted) + ':' + value + ',' per entry.
157                2 + entries
158                    .iter()
159                    .map(|(key, val)| key.len() + 2 + 1 + Self::estimate_json_len(val) + 1)
160                    .sum::<usize>()
161            }
162        }
163    }
164
165    /// Count the decimal digits in a `u64` without allocating.
166    const fn decimal_digits(mut n: u64) -> usize {
167        let mut digits = 1;
168        while n >= 10 {
169            n /= 10;
170            digits += 1;
171        }
172        digits
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use crate::llm::Role;
180    use serde_json::json;
181
182    #[test]
183    fn test_estimate_text() {
184        // Empty text
185        assert_eq!(TokenEstimator::estimate_text(""), 0);
186
187        // Short text (less than 4 chars)
188        assert_eq!(TokenEstimator::estimate_text("hi"), 1);
189
190        // Exactly 4 chars
191        assert_eq!(TokenEstimator::estimate_text("test"), 1);
192
193        // 5 chars should be 2 tokens
194        assert_eq!(TokenEstimator::estimate_text("hello"), 2);
195
196        // Longer text
197        assert_eq!(TokenEstimator::estimate_text("hello world!"), 3); // 12 chars / 4 = 3
198    }
199
200    #[test]
201    fn test_estimate_text_message() {
202        let message = Message {
203            role: Role::User,
204            content: Content::Text("Hello, how are you?".to_string()), // 19 chars = 5 tokens
205        };
206
207        let estimate = TokenEstimator::estimate_message(&message);
208        // 5 content tokens + 4 overhead = 9
209        assert_eq!(estimate, 9);
210    }
211
212    #[test]
213    fn test_estimate_blocks_message() {
214        let message = Message {
215            role: Role::Assistant,
216            content: Content::Blocks(vec![
217                ContentBlock::Text {
218                    text: "Let me help.".to_string(), // 12 chars = 3 tokens
219                },
220                ContentBlock::ToolUse {
221                    id: "tool_123".to_string(),
222                    name: "read".to_string(),            // 4 chars = 1 token
223                    input: json!({"path": "/test.txt"}), // ~20 chars = 5 tokens
224                    thought_signature: None,
225                },
226            ]),
227        };
228
229        let estimate = TokenEstimator::estimate_message(&message);
230        // Text: 3 tokens
231        // ToolUse: 1 (name) + 5 (input) + 20 (overhead) = 26 tokens
232        // Message overhead: 4
233        // Total: 3 + 26 + 4 = 33
234        assert!(estimate > 25); // Verify it accounts for tool use
235    }
236
237    #[test]
238    fn test_estimate_tool_result() {
239        let message = Message {
240            role: Role::User,
241            content: Content::Blocks(vec![ContentBlock::ToolResult {
242                tool_use_id: "tool_123".to_string(),
243                content: "File contents here...".to_string(), // 21 chars = 6 tokens
244                is_error: None,
245            }]),
246        };
247
248        let estimate = TokenEstimator::estimate_message(&message);
249        // 6 content + 10 overhead + 4 message overhead = 20
250        assert_eq!(estimate, 20);
251    }
252
253    #[test]
254    fn test_estimate_history() {
255        let messages = vec![
256            Message::user("Hello"),          // 5 chars = 2 tokens + 4 overhead = 6
257            Message::assistant("Hi there!"), // 9 chars = 3 tokens + 4 overhead = 7
258            Message::user("How are you?"),   // 12 chars = 3 tokens + 4 overhead = 7
259        ];
260
261        let estimate = TokenEstimator::estimate_history(&messages);
262        assert_eq!(estimate, 20);
263    }
264
265    #[test]
266    fn test_empty_history() {
267        let messages: Vec<Message> = vec![];
268        assert_eq!(TokenEstimator::estimate_history(&messages), 0);
269    }
270
271    #[test]
272    fn test_estimate_redacted_thinking_uses_data_length() {
273        // Simulate a realistic redacted thinking blob (~8KB base64 data).
274        // 8192 base64 chars → ~6144 raw bytes → 6144/4 = 1536 estimated tokens.
275        let data = "A".repeat(8192);
276        let block = ContentBlock::RedactedThinking { data };
277
278        let estimate = TokenEstimator::estimate_block(&block);
279        assert_eq!(estimate, 1536);
280    }
281
282    #[test]
283    fn test_estimate_redacted_thinking_respects_minimum() {
284        // Tiny data blob: 100 base64 chars → ~75 raw bytes → 75/4 = 19 tokens.
285        // Should be clamped to the minimum (512).
286        let data = "A".repeat(100);
287        let block = ContentBlock::RedactedThinking { data };
288
289        let estimate = TokenEstimator::estimate_block(&block);
290        assert_eq!(estimate, TokenEstimator::REDACTED_THINKING_MIN_TOKENS);
291    }
292
293    #[test]
294    fn test_estimate_redacted_thinking_empty_data() {
295        // Empty data should return the minimum floor.
296        let block = ContentBlock::RedactedThinking {
297            data: String::new(),
298        };
299
300        let estimate = TokenEstimator::estimate_block(&block);
301        assert_eq!(estimate, TokenEstimator::REDACTED_THINKING_MIN_TOKENS);
302    }
303
304    #[test]
305    fn estimate_opaque_reasoning_uses_payload_size_with_a_floor() {
306        let small = ContentBlock::OpaqueReasoning {
307            provider: "test-provider".to_owned(),
308            data: json!({"encrypted_content": "x"}),
309        };
310        let large = ContentBlock::OpaqueReasoning {
311            provider: "test-provider".to_owned(),
312            data: json!({"encrypted_content": "x".repeat(8_192)}),
313        };
314
315        let small_estimate = TokenEstimator::estimate_block(&small);
316        let large_estimate = TokenEstimator::estimate_block(&large);
317        assert!(small_estimate >= TokenEstimator::OPAQUE_REASONING_MIN_TOKENS);
318        assert!(large_estimate > small_estimate);
319    }
320
321    #[test]
322    fn test_estimate_json_len_tracks_serialized_size() {
323        // The no-allocation estimator should track the real serialized length
324        // closely (within the per-element separator slack it intentionally adds).
325        for value in [
326            json!({"path": "/test.txt"}),
327            json!({"a": 1, "b": [1, 2, 3], "c": {"nested": true}}),
328            json!([null, false, "string", 12_345]),
329            json!("plain string"),
330            json!(9_876_543),
331        ] {
332            let estimated = TokenEstimator::estimate_json_len(&value);
333            let actual = serde_json::to_string(&value).map_or(0, |s| s.len());
334            assert!(
335                estimated >= actual,
336                "estimate {estimated} should be >= actual {actual} for {value}"
337            );
338            assert!(
339                estimated <= actual * 2 + 8,
340                "estimate {estimated} wildly exceeds actual {actual} for {value}"
341            );
342        }
343    }
344
345    #[test]
346    fn test_tool_use_estimate_is_nonzero_for_nonempty_input() {
347        // Regression: the old `to_string(..).unwrap_or_default()` path could
348        // silently produce a 0-length input estimate. The recursive estimator
349        // always accounts for the input.
350        let block = ContentBlock::ToolUse {
351            id: "tool_1".to_string(),
352            name: "bash".to_string(),
353            input: json!({"command": "echo hello world"}),
354            thought_signature: None,
355        };
356
357        let estimate = TokenEstimator::estimate_block(&block);
358        // name (1) + overhead (20) is 21; the input must add more on top.
359        assert!(
360            estimate > 21,
361            "input length must contribute to the estimate"
362        );
363    }
364
365    #[test]
366    fn test_redacted_thinking_accumulates_in_history() {
367        // 5 redacted thinking blocks at ~2000 tokens each should produce a
368        // meaningful total that triggers compaction.
369        let blocks: Vec<ContentBlock> = (0..5)
370            .map(|_| ContentBlock::RedactedThinking {
371                data: "B".repeat(10_000), // 10k base64 → 7500 raw → 1875 tokens
372            })
373            .collect();
374        let message = Message {
375            role: Role::Assistant,
376            content: Content::Blocks(blocks),
377        };
378
379        let estimate = TokenEstimator::estimate_message(&message);
380        // 5 × 1875 + 4 message overhead = 9379
381        assert_eq!(estimate, 9379);
382    }
383}