supercode/tokens.rs
1//! The shared token estimator (SPEC.md C9). The repo has no tokenizer — only
2//! provider-reported `Usage` (`provider.rs:153-164`) and the turn/output
3//! counters `agent.rs` already tracks. Every other UX figure (the banner,
4//! `/status`, `/tokens`, `show-reductions`, notices) derives from one
5//! documented heuristic here, and is always printed with a `~` prefix so it
6//! reads as an estimate, never a measurement. Acceptance criteria never
7//! assert against these numbers directly — ACs assert exact byte counts
8//! (`SessionInfo::full_bytes`/`view_bytes`, C9/D15) and only *derive* a
9//! token/dollar figure from them for the test log.
10//!
11//! Provider-reported figures (`Usage.completion_tokens`, B7's optional
12//! `cached_tokens`, `agent.total_output_tokens()`) are real counts and are
13//! never routed through this module — they print un-tilded.
14
15use crate::message::ChatMessage;
16use crate::provider::ToolSchema;
17use crate::reduce::format_commas;
18
19/// Deterministic token estimate: `ceil(utf8_bytes / 4)`. Documented
20/// heuristic — all UX figures derived from it are printed with a `~`
21/// prefix (see [`fmt_approx_tokens`]). Never used in acceptance criteria
22/// (ACs assert exact byte counts).
23pub fn estimate_tokens(s: &str) -> u64 {
24 let bytes = s.len() as u64;
25 bytes.div_ceil(4)
26}
27
28/// Estimate for a serialized message list: the wire form (`serde_json`)
29/// used to build the provider request, not any in-memory/debug
30/// representation.
31pub fn estimate_view_tokens(msgs: &[ChatMessage]) -> u64 {
32 let mut total = 0u64;
33 for m in msgs {
34 total += estimate_message_tokens(m);
35 }
36 total
37}
38
39/// PARITY-18 D2 — per-message estimate used by [`estimate_view_tokens`].
40/// `ChatMessage`'s hand-written `Serialize` impl never actually fails in
41/// practice (every field is a plain string or an already-validated
42/// `serde_json::Value`), but `serde_json::to_string` still returns a
43/// `Result` — and the previous `.unwrap_or_default()` here silently turned
44/// any failure into `""`, i.e. **0 tokens counted for a message the
45/// provider request still carries**, which is exactly the kind of gap a
46/// context guard must never have. Falls back to the `Debug` rendering's
47/// byte length instead: `Debug` includes every field verbatim (plus
48/// `metadata`, which the wire form omits), so it is never a SMALLER
49/// estimate than the wire form would have produced — conservative, never
50/// zero, on the one path that can't normally be exercised.
51fn estimate_message_tokens(m: &ChatMessage) -> u64 {
52 match serde_json::to_string(m) {
53 Ok(wire) => estimate_tokens(&wire),
54 Err(_) => estimate_tokens(&format!("{m:?}")),
55 }
56}
57
58/// PARITY-18 D2 — conservative safety margin folded into the context-guard
59/// boundary only (never into the plain `~`-prefixed UX estimates
60/// themselves, which stay the documented `ceil(bytes/4)` heuristic
61/// unmodified). `ceil(utf8_bytes/4)` under-counts real tokenizer output on
62/// CJK text, base64/binary-ish blobs, and dense code — informally by 25%+
63/// against common tokenizers for those corpora, since multi-byte UTF-8
64/// sequences and non-whitespace-delimited runs pack more real tokens per
65/// byte than the heuristic assumes. 25% is a round number comfortably above
66/// that observed skew: applying it can only make the guard MORE
67/// conservative (refuse sooner), never let an over-context request through
68/// that a real tokenizer would also have refused.
69const GUARD_MARGIN_NUM: u64 = 5;
70const GUARD_MARGIN_DEN: u64 = 4;
71
72/// Apply the [`GUARD_MARGIN_NUM`]/[`GUARD_MARGIN_DEN`] context-guard safety
73/// margin to a raw token estimate. Rounds up (`div_ceil`), never down —
74/// the margin only ever pushes the boundary check to be more cautious.
75pub fn with_guard_margin(tokens: u64) -> u64 {
76 tokens
77 .saturating_mul(GUARD_MARGIN_NUM)
78 .div_ceil(GUARD_MARGIN_DEN)
79}
80
81/// PARITY-18 — headroom reserved for the model's own completion, folded
82/// into the context-guard boundary alongside [`with_guard_margin`]. Neither
83/// [`estimate_view_tokens`] nor [`estimate_request_tokens`] counts anything
84/// for the reply the model is about to generate — this is a flat token
85/// budget carved out of the model's context window for it, since the
86/// completion shares the same window as the request on every provider this
87/// crate targets.
88///
89/// PARITY-18 v3 NOTE-AND-DECIDE (NF6, owner-recorded, not fixed here): this
90/// is a FLAT reserve — it does not read `Config::max_tokens`
91/// (`crates/core/src/config.rs`, user-settable via `--max-tokens`, wired
92/// into the actual provider request at `provider.rs`'s
93/// `ChatRequest::max_tokens`). A user who passes `--max-tokens` greater than
94/// 16,384 can still pass this guard (`projected + 16_384 <= context_limit`)
95/// and then draw a provider-side `input_tokens + max_tokens > context_window`
96/// rejection the guard never anticipated — i.e. the guard's margin can be
97/// smaller than what the user actually asked the provider to reserve for the
98/// completion. Making the reserve `max(CONTEXT_RESPONSE_RESERVE_TOKENS,
99/// config.max_tokens)` would close this, but `context_guard` doesn't
100/// currently receive `Config` at all (only `messages`/`tools`/
101/// `context_limit`) — threading it through is a small but real signature
102/// change touching every call site (`resume_cmd`, `Agent::run_loop`, and
103/// this pass's new `reduce_to_fit` `fits` closures) that's out of scope for
104/// this pass's reducer/guard boundary fix. Recorded for the owner.
105pub const CONTEXT_RESPONSE_RESERVE_TOKENS: u64 = 16_384;
106
107/// PARITY-18 D1 — the full wire-request token estimate: every message in
108/// `messages` (callers pass what [`crate::Agent::build_request_messages`]
109/// actually returns, which already carries the system prompt at index 0)
110/// PLUS the serialized `tools` schema array, which is a real part of the
111/// request body ([`crate::provider::build_request_body`]) but — before
112/// PARITY-18's re-fix — was never counted by the preflight guard at all.
113/// A session whose messages alone fit comfortably could still carry a fat
114/// builtin/MCP tool-schema array that blows the real wire request; this is
115/// the fix.
116pub fn estimate_request_tokens(messages: &[ChatMessage], tools: &[ToolSchema]) -> u64 {
117 let tools_wire = serde_json::to_string(tools).unwrap_or_default();
118 estimate_view_tokens(messages).saturating_add(estimate_tokens(&tools_wire))
119}
120
121/// PARITY-18 D1/D2/D4 — the single context-guard decision, shared by every
122/// call site that must decide whether a request is safe to send: the CLI's
123/// `resume_cmd` preflight check AND `Agent::run_loop`'s per-send check
124/// (D4 — the guard is a session invariant, not a one-shot preflight, so
125/// turn 2+ and `/expand all` are covered too). Because both call through
126/// this one function, a request can never pass one gate and fail the
127/// other — there is only one formula.
128///
129/// `fits` is true iff the [`with_guard_margin`]-adjusted
130/// [`estimate_request_tokens`] estimate, plus the
131/// [`CONTEXT_RESPONSE_RESERVE_TOKENS`] completion reserve, is still within
132/// `context_limit` — i.e. gates on the reduce TARGET
133/// (`context_limit - CONTEXT_RESPONSE_RESERVE_TOKENS`), not the raw limit,
134/// closing the "blind band between reduce target and pass/fail boundary"
135/// gap. Returns the margin-adjusted projected total either way so callers
136/// can report it (dev/02) regardless of verdict.
137pub fn context_guard(
138 messages: &[ChatMessage],
139 tools: &[ToolSchema],
140 context_limit: u64,
141) -> (bool, u64) {
142 let raw = estimate_request_tokens(messages, tools);
143 let projected = with_guard_margin(raw);
144 let fits = projected.saturating_add(CONTEXT_RESPONSE_RESERVE_TOKENS) <= context_limit;
145 (fits, projected)
146}
147
148/// Schema-token estimate for the B6 "tools" banner line: the estimate over
149/// the serialized `full` [`ToolSchema`] list minus the estimate over the
150/// serialized `advertised` list — i.e. the token cost of what's currently
151/// deferred (hidden behind `tool_search`) rather than eagerly advertised.
152/// Saturates to `0` rather than underflow if `advertised` somehow estimates
153/// larger than `full` (e.g. formatting differences), since "negative
154/// deferred tokens" has no meaning for the banner.
155pub fn estimate_deferred_schema_tokens(full: &[ToolSchema], advertised: &[ToolSchema]) -> u64 {
156 let full_tokens = estimate_tokens(&serde_json::to_string(full).unwrap_or_default());
157 let advertised_tokens = estimate_tokens(&serde_json::to_string(advertised).unwrap_or_default());
158 full_tokens.saturating_sub(advertised_tokens)
159}
160
161/// Render an estimated token count in the shared UX style: `~21,904 tok`
162/// (tilde prefix + comma-grouped thousands, matching the stub-line comma
163/// style in `reduce.rs`). Every figure that flows through
164/// [`estimate_tokens`]/[`estimate_view_tokens`]/[`estimate_deferred_schema_tokens`]
165/// should be rendered through this helper so the `~` discipline (D11) is
166/// applied uniformly rather than ad hoc at each call site.
167pub fn fmt_approx_tokens(n: u64) -> String {
168 format!("~{} tok", format_commas(n as usize))
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174
175 #[test]
176 fn empty_string_is_zero_tokens() {
177 assert_eq!(estimate_tokens(""), 0);
178 }
179
180 #[test]
181 fn four_byte_ascii_is_one_token() {
182 assert_eq!(estimate_tokens("abcd"), 1);
183 }
184
185 #[test]
186 fn ceil_behavior_rounds_up() {
187 // 5 bytes / 4 = 1.25 -> ceil to 2.
188 assert_eq!(estimate_tokens("abcde"), 2);
189 }
190
191 #[test]
192 fn deterministic_same_input_same_output() {
193 let s = "the quick brown fox jumps over the lazy dog";
194 assert_eq!(estimate_tokens(s), estimate_tokens(s));
195 }
196
197 #[test]
198 fn monotone_under_concatenation() {
199 // Property-style: a few dozen generated strings, checking
200 // est(a+b) >= est(a) for each.
201 let words = [
202 "a",
203 "ab",
204 "abc",
205 "hello",
206 "world",
207 "",
208 "x",
209 "supercode",
210 "token",
211 "estimate",
212 "!",
213 " ",
214 "\n",
215 "quick brown fox",
216 "z",
217 "1234567890",
218 "the",
219 "lazy",
220 "dog",
221 "jumps",
222 "over",
223 "sidecar",
224 "reduction",
225 "archive",
226 "delete",
227 "session",
228 "store",
229 "family",
230 "meta",
231 "json",
232 ];
233 for a in &words {
234 for b in &words {
235 let combined = format!("{a}{b}");
236 assert!(
237 estimate_tokens(&combined) >= estimate_tokens(a),
238 "est({a:?}+{b:?}) = {} should be >= est({a:?}) = {}",
239 estimate_tokens(&combined),
240 estimate_tokens(a)
241 );
242 }
243 }
244 }
245
246 #[test]
247 fn estimate_view_tokens_matches_serde_json_wire_form() {
248 let msgs = vec![
249 ChatMessage::user("hello there, this is a test message"),
250 ChatMessage::assistant("sure, here's a longer reply with more bytes in it"),
251 ];
252 let expected: u64 = msgs
253 .iter()
254 .map(|m| estimate_tokens(&serde_json::to_string(m).unwrap()))
255 .sum();
256 assert_eq!(estimate_view_tokens(&msgs), expected);
257 }
258
259 #[test]
260 fn estimate_view_tokens_empty_slice_is_zero() {
261 assert_eq!(estimate_view_tokens(&[]), 0);
262 }
263
264 fn fat_schema(name: &str, filler_len: usize) -> ToolSchema {
265 ToolSchema {
266 name: name.to_string(),
267 description: "x".repeat(filler_len),
268 parameters: serde_json::json!({"type": "object", "properties": {}}),
269 }
270 }
271
272 #[test]
273 fn deferred_schema_tokens_full_equals_advertised_is_zero() {
274 let full = vec![fat_schema("shell", 500), fat_schema("read_file", 200)];
275 let advertised = full.clone();
276 assert_eq!(estimate_deferred_schema_tokens(&full, &advertised), 0);
277 }
278
279 #[test]
280 fn deferred_schema_tokens_measures_the_difference() {
281 let builtin = fat_schema("shell", 50);
282 let mcp_fat = fat_schema("mcp__github__search_issues", 4000);
283 let full = vec![builtin.clone(), mcp_fat];
284 let advertised = vec![builtin];
285 let deferred = estimate_deferred_schema_tokens(&full, &advertised);
286 let expected = estimate_tokens(&serde_json::to_string(&full).unwrap()).saturating_sub(
287 estimate_tokens(&serde_json::to_string(&advertised).unwrap()),
288 );
289 assert_eq!(deferred, expected);
290 assert!(deferred > 0, "a fat deferred MCP schema should cost tokens");
291 }
292
293 #[test]
294 fn deferred_schema_tokens_advertised_larger_than_full_saturates_to_zero() {
295 // Pathological input (advertised isn't actually a subset of full) —
296 // must not panic or underflow.
297 let full = vec![fat_schema("a", 1)];
298 let advertised = vec![fat_schema("a", 1000)];
299 assert_eq!(estimate_deferred_schema_tokens(&full, &advertised), 0);
300 }
301
302 #[test]
303 fn fmt_approx_tokens_style() {
304 assert_eq!(fmt_approx_tokens(0), "~0 tok");
305 assert_eq!(fmt_approx_tokens(21904), "~21,904 tok");
306 assert_eq!(fmt_approx_tokens(1000000), "~1,000,000 tok");
307 }
308
309 // ---- PARITY-18 D2: guard margin ----
310
311 #[test]
312 fn guard_margin_adds_25_percent_and_rounds_up() {
313 assert_eq!(with_guard_margin(0), 0);
314 assert_eq!(with_guard_margin(4), 5); // 4 * 1.25 = 5.0 exact
315 assert_eq!(with_guard_margin(100), 125);
316 // 101 * 5 / 4 = 505/4 = 126.25 -> ceil to 127.
317 assert_eq!(with_guard_margin(101), 127);
318 }
319
320 #[test]
321 fn guard_margin_never_decreases() {
322 for n in [0u64, 1, 3, 4, 17, 1_000, 1_048_576] {
323 assert!(
324 with_guard_margin(n) >= n,
325 "margin must never make the estimate smaller: {n} -> {}",
326 with_guard_margin(n)
327 );
328 }
329 }
330
331 #[test]
332 fn guard_margin_saturates_instead_of_overflowing() {
333 // The important property: no panic/wraparound on the largest
334 // possible input — `saturating_mul` must clamp to `u64::MAX`
335 // rather than wrap around to something small (which would defeat
336 // the whole point of a "conservative" margin).
337 let result = with_guard_margin(u64::MAX);
338 assert_eq!(result, u64::MAX.div_ceil(4));
339 }
340
341 // ---- PARITY-18 D2: unserializable-message fallback never counts zero ----
342
343 #[test]
344 fn message_with_content_never_estimates_to_zero_tokens() {
345 let m = ChatMessage::user("hello world, this has real content in it");
346 assert!(estimate_message_tokens(&m) > 0);
347 }
348
349 #[test]
350 fn debug_fallback_is_conservative_not_smaller_than_wire_form() {
351 // We can't force `serde_json::to_string` to fail on a real
352 // `ChatMessage` (its Serialize impl is infallible in practice), so
353 // this test instead pins the CONTRACT the fallback must uphold:
354 // the Debug rendering of a message is never a smaller byte count
355 // than its wire JSON form, which is what makes it safe to use as
356 // the "serialization failed" fallback (D2: conservative, not zero).
357 let m = ChatMessage::user("x".repeat(500));
358 let wire = serde_json::to_string(&m).unwrap();
359 let debug = format!("{m:?}");
360 assert!(
361 debug.len() >= wire.len(),
362 "Debug fallback ({} bytes) must be >= wire form ({} bytes) to stay conservative",
363 debug.len(),
364 wire.len()
365 );
366 }
367
368 // ---- PARITY-18 D1: full request-token accounting ----
369
370 fn schema(name: &str, desc_len: usize) -> ToolSchema {
371 ToolSchema::new(
372 name,
373 "x".repeat(desc_len),
374 serde_json::json!({"type":"object"}),
375 )
376 }
377
378 #[test]
379 fn estimate_request_tokens_includes_tool_schemas() {
380 let messages = vec![ChatMessage::user("hi")];
381 let no_tools = estimate_request_tokens(&messages, &[]);
382 let with_tools = estimate_request_tokens(&messages, &[schema("shell", 2000)]);
383 assert!(
384 with_tools > no_tools,
385 "a fat tool-schema array must increase the request-token estimate"
386 );
387 }
388
389 #[test]
390 fn estimate_request_tokens_matches_messages_plus_schema_sum() {
391 let messages = vec![
392 ChatMessage::system("system prompt"),
393 ChatMessage::user("user turn"),
394 ];
395 let tools = vec![schema("read_file", 100), schema("edit", 100)];
396 let expected = estimate_view_tokens(&messages)
397 + estimate_tokens(&serde_json::to_string(&tools).unwrap());
398 assert_eq!(estimate_request_tokens(&messages, &tools), expected);
399 }
400
401 // ---- PARITY-18 D1/D4: the shared context_guard decision ----
402
403 #[test]
404 fn context_guard_passes_a_small_request() {
405 let messages = vec![ChatMessage::user("hi")];
406 let (fits, projected) = context_guard(&messages, &[], 200_000);
407 assert!(fits, "a tiny request must fit a 200k-token limit");
408 assert!(projected < 200_000);
409 }
410
411 #[test]
412 fn context_guard_refuses_when_reserve_alone_exceeds_limit() {
413 // A limit smaller than the completion reserve can never be
414 // satisfied, no matter how small the request is.
415 let messages = vec![ChatMessage::user("hi")];
416 let (fits, _) = context_guard(&messages, &[], CONTEXT_RESPONSE_RESERVE_TOKENS - 1);
417 assert!(!fits);
418 }
419
420 #[test]
421 fn context_guard_messages_only_fit_but_overhead_pushes_over() {
422 // D1's exact defect shape: a messages-only estimate comfortably
423 // under `context_limit`, but once the (margin-adjusted) tool-schema
424 // overhead and completion reserve are added, the true projected
425 // request no longer fits. The OLD guard (`view_tokens >
426 // context_limit`) would have let this through.
427 let context_limit = 10_000u64;
428 // ~9,000 raw content tokens (36,000 bytes) — well under
429 // `context_limit` on a messages-only basis.
430 let big_text = "x".repeat(36_000);
431 let messages = vec![ChatMessage::user(big_text)];
432 let messages_only = estimate_view_tokens(&messages);
433 assert!(
434 messages_only < context_limit,
435 "fixture must fit messages-only for this test to prove anything: {messages_only} vs {context_limit}"
436 );
437 // A modest tool-schema array on top.
438 let tools = vec![
439 schema("shell", 200),
440 schema("read_file", 200),
441 schema("edit", 200),
442 ];
443 let (fits, projected) = context_guard(&messages, &tools, context_limit);
444 assert!(
445 !fits,
446 "messages alone fit but margin + schema overhead + reserve should push this over: projected={projected} limit={context_limit}"
447 );
448 }
449
450 #[test]
451 fn context_guard_is_the_single_formula_both_call_sites_share() {
452 // Sanity-pin the exact relationship documented on `context_guard`:
453 // fits iff with_guard_margin(estimate_request_tokens(..)) + reserve
454 // <= limit. If this ever drifts from the implementation, every
455 // caller (CLI preflight + Agent::run_loop per-turn guard) drifts
456 // silently apart with it.
457 let messages = vec![
458 ChatMessage::user("hello"),
459 ChatMessage::assistant("hi there"),
460 ];
461 let tools = vec![schema("shell", 50)];
462 let limit = 1_000u64;
463 let raw = estimate_request_tokens(&messages, &tools);
464 let expected_projected = with_guard_margin(raw);
465 let expected_fits =
466 expected_projected.saturating_add(CONTEXT_RESPONSE_RESERVE_TOKENS) <= limit;
467 let (fits, projected) = context_guard(&messages, &tools, limit);
468 assert_eq!(projected, expected_projected);
469 assert_eq!(fits, expected_fits);
470 }
471}