Measure the context baseline instead of assuming 12,000 tokens - #1
Open
iamenahs wants to merge 1 commit into
Open
Measure the context baseline instead of assuming 12,000 tokens#1iamenahs wants to merge 1 commit into
iamenahs wants to merge 1 commit into
Conversation
iamenahs
force-pushed
the
fix/measure-context-baseline
branch
4 times, most recently
from
August 25, 2026 04:28
ad42915 to
dc58ecc
Compare
`percent_of_context_window_remaining` divides by `BASELINE_TOKENS`, a constant standing in for the part of a request the conversation cannot influence. The function's own doc describes that quantity as something to capture — "the system prompt and fixed tool instructions" — and Codex can measure a good deal of it. Measure what the `Prompt` can see, when the request is built: the base instructions, the tool payload in the wire form `build_responses_request` will send it in, and the response format as `create_text_param_for_request` builds it. Price it all with `approx_token_count`, the bytes-over-four heuristic already used for history estimation and truncation, so no tokenizer is introduced. That figure is part of the fixed cost, not all of it, and it is estimated rather than counted. Developer instructions, plugin and app descriptions, skill fragments and extension contributions are injected into the conversation before the prompt is built; they reach the model inside `Prompt.input` and cannot be told apart from what the user typed, because `CodexHarnessMetadata` carries no provenance. `approx_token_count` can also land either side of a real tokenizer. So `context_baseline_tokens` floors the estimate at the historical constant: sessions whose measured components exceed 12,000 divide by what was actually sent, and every other session keeps the behaviour it has today. Pair the baseline with the usage it was measured against. It is staged when a request is built and published only when that request's provider usage arrives, so a failed request cannot pair its baseline with the previous request's usage. A request that stages none publishes none: local compaction sends a prompt carrying no tools and no output schema yet still reports provider usage, and denominating that against the previous request's tool payload would subtract a fixed cost it never paid. The same applies after a compaction, when `last_token_usage` comes from the local estimate, which omits the tool schemas. In every withheld case the reading falls back to the constant — what `main` shows today — because dividing a total that excludes the tool surface by a baseline that includes it moves the reading further from the truth than the constant does. Carry the figure on `TokenUsageInfo` and `ThreadTokenUsage`, and move the percentage arithmetic into two free functions in `codex-protocol` so `codex-tui` keeps its own serde types without a second copy of the formula. `estimate_token_count_with_base_instructions` is deliberately untouched: it feeds `recompute_token_usage`, which writes the field `get_total_token_usage` reads and auto-compaction compares against its limit. A regression test pins the estimate as unchanged. Also correct the constant's comment. It claims to include "space to call compact"; auto-compaction reserves its own headroom in `session/context_window.rs` and has never read this value.
iamenahs
force-pushed
the
fix/measure-context-baseline
branch
from
August 25, 2026 05:01
dc58ecc to
aa3c3cc
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Codex's context indicator divides by a constant that stands in for a quantity Codex can partly measure. This PR measures the part it can and uses it as the denominator where that is likely to help, keeping today's constant everywhere else.
percent_of_context_window_remainingproduces the number behind the status-line indicator and the/statuscard. Its denominator is:That value represents the part of a request the conversation cannot influence — base instructions and tool schemas. It varies per session; it is written as a global literal. The indicator is therefore off by whatever the difference happens to be.
This PR estimates part of that quantity when each request is built — the base instructions, the tool payload and the response format — and divides by it whenever it exceeds
12000. It is a heuristic correction, not a complete one, and it is aimed at the case the constant serves worst: a session with a large tool surface. It does not guarantee a fresh session reads 100%, and it is not a proof that every session's reading improves. See Scope for what is measured, what is not, and why the constant is kept as a floor rather than replaced.The problem
The function normalizes both sides of the fraction:
remainingreduces tocontext_window - total_tokens, so the baseline only ever moves the denominator — and the sign of the error follows the size of the session's tool surface.A baseline that is too large. On this repository's default test configuration —
suite::client::token_count_includes_rate_limits_snapshot, 8 tools — the measured base/tool/output subtotal is 7,015 tokens: 5,337 of base instructions and 1,678 of tool schemas, read from that test's serializedTokenCountEvent. Where the true fixed cost is near that, subtracting 12,000 shrinks the denominator and the indicator reports more headroom than the window has.A baseline that is too small. Plugins and connected MCP servers add schemas. Past 12,000 tokens of fixed cost the denominator is too large, the indicator reports less room than exists, and a fresh session never reaches the 100% the function documents:
The constant is also duplicated.
codex-rs/tui/src/token_usage.rsholds its own12000and its own copy of the arithmetic. The two agree today; nothing keeps them agreeing.The value is display-only, which is what bounds the blast radius. On
mainit is defined atprotocol/src/protocol.rs:2241andtui/src/token_usage.rs:9, and every read of it sits inside the correspondingpercent_of_context_window_remaining. Auto-compaction reserves its headroom separately, incodex-rs/core/src/session/context_window.rs, comparingsess.get_total_token_usage()againstmodel_info.auto_compact_token_limit()plusTokenBudgetConfig::fallback_buffer_tokens. Changing the baseline changes what is displayed and nothing else — a property the rest of this PR is built to preserve. The source comment above the constant claims it also covers "space to call compact"; this PR corrects that line.What this changes
An estimate of part of the fixed cost.
Prompt::context_baselineprices the components the conversation cannot influence that are visible in thePrompt, at the moment the request is built:approx_token_count(APPROX_BYTES_PER_TOKEN = 4), the same byte-density heuristic Codex already applies to history estimation and output truncation. No tokenizer is introduced; the result is a coarse figure of the same kind the constant stood in for.build_responses_requestfolds every function and freeform spec into a single namespace object whenmodel_info.use_responses_lite && provider.capabilities().namespace_tools, and sends the specs one after another otherwise;ToolsWireFormat::for_requestmirrors that branch and renders through the same builders. The response format is likewise priced by callingcreate_text_param_for_request, so theTextControlswrapper — format type, schema name,strict— is counted rather than the bare schema. Verbosity travels in that same request field but is deliberately excluded: it is not part of the response format, and folding it in made a request carrying no schema at all report a non-zerooutput_schema_tokens.A denominator that never drops below today's. The estimate can land below the true fixed cost — that is the common case for a session carrying injected context — and dividing by a figure that low understates the percentage by more than the constant does.
context_baseline_tokenstherefore floors the estimate atLEGACY_CONTEXT_BASELINE_TOKENS. Sessions whose measured components alone exceed 12,000 get a denominator derived from what was actually sent; every other session keeps exactly the behaviour it has now.TokenUsageInfo::percent_of_context_window_remainingis the entry point that supplies it;TokenUsage::percent_of_context_window_remainingkeeps the constant for callers holding noTokenUsageInfo. The status line and the/statuscard now go through theTokenUsageInfoform.A baseline paired with the usage it was measured against. The baseline is staged when a request is built and published only when that request's own provider usage arrives, so a request that fails cannot pair its baseline with the previous request's usage. A request that stages none publishes none — local compaction sends a
Promptcarrying no tools and no output schema (compact.rs) yet still reports provider usage, and denominating that against the previous request's tool payload would subtract a fixed cost it never paid. The same holds after a compaction, whenlast_token_usageisestimate_token_count_with_base_instructions, which omits the tool schemas. In every withheld case the reading falls back to the constant, which is whatmainshows today: dividing a total that excludes the tool surface by a baseline that includes it mixes two accountings, and the mismatch moves the reading further from the truth than the constant does.One copy of the arithmetic.
codex-protocolnow exportspercent_of_context_window_remainingandcontext_baseline_tokensas free functions.codex-tuikeeps its own serde-owned token structs — it persists them in its own format — but no longer carries its own constant or its own formula, and a parity test asserts the two agree on baseline and percentage across eight input combinations.Carried over the protocol.
TokenUsageInfo.context_baseline→ThreadTokenUsage.contextBaseline, nullable. It is null whenever no measurement belongs to the request the event reports usage for — before the first request, for requests built without tools, and while the total is a local estimate — which tells a client to denominate against the constant, not to reuse the previous value. Schemas and TypeScript exports regenerated for both the stable and experimental variants.The constant renamed to
LEGACY_CONTEXT_BASELINE_TOKENS, documented as a fallback, with the compaction claim in its comment corrected.Scope
Injected fixed context is not measured, and the reading is still short because of it.
build_initial_context_with_world_stateandbuild_skills_and_pluginsemit developer instructions, plugin and app descriptions, skill fragments and extension contributions as ordinaryResponseItems, recorded throughrecord_conversation_items. They reach the model insidePrompt.input, count toward providertotal_tokens, and are indistinguishable there from what the user typed —CodexHarnessMetadatacarries no provenance to separate them. Sofixed_tokens()is a subtotal, and a tool-and-plugin-heavy fresh session can still read below 100%.Pricing them properly means giving injected items a provenance at the point they are created, carrying it into
Prompt, and deciding whether a skill the user invoked by name counts as fixed at all — a persisted-schema change and a product judgment, not a defect fix. The floor above is what keeps the denominator from dropping below today's in the meantime.Auto-compaction is unchanged, by construction.
estimate_token_count_with_base_instructionsfeedsrecompute_token_usage, which writeslast_token_usage.total_tokens— the fieldget_total_token_usagereads andtoken_limit_reachedcompares against its limit. Adding the tool schemas there would move when Codex compacts, so that estimator is left exactly as it is. It still omits the tool schemas, and the PR leaves aNOTErecording that rather than fixing it here. Three things hold the boundary:token_info()only stamps it onto the returned clone;grepfor the constant and forcontext_baselineinsession/context_window.rsboth come back empty;recording_a_baseline_does_not_move_the_history_estimateasserts the estimate is identical before and after a baseline is recorded.Provider-reported usage is unchanged.
totalandlastare untouched. The baseline is a denominator, never a substitute for what the provider reports consumed, and the protocol docs say so at each hop.No
/contextsurface. No new command, and no per-tool or per-server attribution.ContextBaselineis the minimum a denominator needs, and is named for that job.Testing
Toolchain: Rust 1.95.0, macOS arm64.
New tests:
a_fresh_session_reads_full_only_with_a_measured_baselinea_measured_subtotal_below_the_constant_changes_nothingmaindoesthe_floor_never_lowers_the_baselinethe_percentage_matches_the_protocol_for_the_same_inputscodex-tuiandcodex-protocolagree on baseline and percentage over eight casesa_staged_baseline_is_withheld_until_usage_arrivesan_estimated_usage_figure_is_not_paired_with_a_measured_baselinemainshows — not the 69% a mixed accounting would givea_request_that_stages_no_baseline_does_not_inherit_the_previous_onean_absent_baseline_keeps_the_historical_constantmainproduces todaybaseline_at_or_beyond_the_window_is_zero_remainingcontext_baseline_survives_a_usage_appendnew_or_appendpreserves itprompt_context_baseline_prices_instructions_tools_and_output_schemafixed_tokens()is their sumprompt_context_baseline_prices_tools_in_the_form_they_are_senttools_wire_format_follows_the_request_branch(use_responses_lite, namespace_tools)combinationsprompt_context_baseline_is_near_zero_for_an_empty_promptrecording_a_baseline_does_not_move_the_history_estimateExisting suites:
codex-protocol --lib: 306 passed.codex-app-server-protocol --lib: 291 passed, including the schema-fixture and precomputed-export checks.codex-core --lib: 2,268 passed. Two failures (guardian::tests::guardian_ephemeral_retry_preserves_parallel_trunk_and_fork_history,session::turn::tests::post_sampling_token_estimate_is_disabled_by_always_on_sinks) reproduce identically on unmodifiedmain. NeedsRUST_MIN_STACK=16777216; severalagent::controltests overflow the default stack onmaintoo.codex-tui: 3,806 passed, 0 failed (--test-threads=1).codex-core --test all, filtered to everything touching this change (token_count context_baseline context_window compact_ usage): 131 passed, 1 failed. That one,suite::otel::turn_and_completed_response_spans_record_token_usage, passes alone and passes with the wholesuite::otel::module at any thread count; it fails only when interleaved with unrelated tests, because it asserts against a captured global tracing subscriber. The unfiltered suite is not a useful signal on this host:codex-code-mode-runtimedoes not build here, so ~250 tests fail for reasons unrelated to any change.cargo fmt --allclean.cargo clippy --all-targetsclean acrosscodex-core,codex-protocol,codex-tui,codex-app-server-protocol,codex-app-server,codex-stateandcodex-exec; the one remaining warning is an unused import incore/tests/suite/openai_file_mcp.rs, present onmain.suite::client::token_count_includes_rate_limits_snapshotneeded updating: it asserts the full serializedTokenCountEvent, which now carriescontext_baseline. Rather than pin sizes that any prompt edit would break, the baseline is asserted structurally and removed from the exact comparison.Compatibility
TokenUsageInfo.context_baselineisOption,#[serde(default)], andskip_serializing_if = "Option::is_none". Old rollouts deserialize asNoneand behave exactly as they do today; new rollouts carry the field.ThreadTokenUsage.contextBaselineis nullable rather than optional, matchingmodelContextWindow, so the generated TypeScript stays inside the export lint's rules for non-params types. Clients that ignore it are unaffected.Limitations and follow-ups
approx_token_countisceil(bytes / 4), which can land either side of what a tokenizer produces depending on the content. So this is a heuristic aimed at large-tool-surface sessions, not a guarantee that any individual reading improves. The floor bounds the denominator, not the accuracy.estimate_token_count_with_base_instructionsstill omits the tool schemas, which matters after a compaction, when that estimate stands in for provider usage. It belongs in a separate change for the reason given under Scope.ContextBaseline. #27898 asks for one, and @alainkaiser has prototyped it infeat/context-breakdownwith per-tool and per-MCP-server attribution. That prototype does not change the divisor — it renames the TUI copy of the constant and keeps12_000in both copies — so it composes with this change rather than replacing it. If it lands,ContextBaselineshould be deleted andbaseline_tokens()should read its fixed terms offContextWindowUsage.