Skip to main content

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}