feat(agent): context size anchors on provider-reported usage — estimation shrinks to the last turn

Every provider response carries usage.prompt_tokens — exact ground truth
for the full request (system prompt + tool schemas + history). Context-size
checks now anchor on the last main-loop response's usage and estimate only
the messages appended since, instead of re-estimating the whole history
with chars/4 heuristics and flat 1500-token image costs. The estimate error
window shrinks from the entire conversation to one turn and self-corrects
at every response.

- agent/model_metadata.py: capture_usage_anchor() / anchored_context_tokens()
  with a structural base-message identity check that fails closed on any
  transcript rewrite.
- agent/conversation_loop.py: anchor captured at the single main-loop usage
  site (MoA uses pre-fold aggregator usage; advisor/aux calls never anchor);
  pre-API pressure check prefers the anchor.
- agent/turn_context.py: preflight compression estimate prefers the anchor.
- agent/context_breakdown.py: /context display prefers the anchor.
- Invalidation: compaction rewrite (conversation_compression), codex native
  compaction (codex_runtime), session reset/switch (run_agent), plus the
  fail-closed structural check for splices/micro-compaction.
- Usage-less responses keep the previous anchor; no anchor -> pure
  estimation fallback (first request of a session).
This commit is contained in:
Teknium
2026-08-28 06:59:12 -07:00
parent c5b44e0756
commit d3a1c46510
10 changed files with 389 additions and 4 deletions
+6
View File
@@ -2953,6 +2953,12 @@ def init_agent(
# Copilot x-initiator flag: first API call of a user turn sends "user" (#3040).
agent._is_user_initiated_turn = False
# Usage-anchored context accounting (agent/model_metadata.py): the last
# main-loop provider response's exact usage + transcript snapshot. None
# until the first response with usage; invalidated on compaction and
# session switches so stale anchors can never suppress compression.
agent._usage_anchor = None
# Cumulative token usage for the session
agent.session_prompt_tokens = 0
agent.session_completion_tokens = 0
+4
View File
@@ -307,6 +307,10 @@ def _record_codex_app_server_compaction(
compressor.last_completion_tokens = 0
compressor.awaiting_real_usage_after_compression = True
# Native compaction rewrote the provider-side context; the usage anchor's
# transcript snapshot no longer matches what will be sent. Invalidate it.
agent._usage_anchor = None
agent._last_compaction_in_place = False
try:
if getattr(agent, "event_callback", None):
+13 -1
View File
@@ -129,8 +129,20 @@ def compute_session_context_breakdown(
comp = getattr(agent, "context_compressor", None)
context_max = int(getattr(comp, "context_length", 0) or 0) if comp else 0
# Prefer the usage-anchored figure: provider-exact prompt+completion of
# the last response plus a delta estimate of anything appended since —
# fresher than the raw last_prompt_tokens (which lags messages appended
# after the response) and far more accurate than the heuristic total.
from agent.model_metadata import anchored_context_tokens
anchored_used = anchored_context_tokens(
messages or [], getattr(agent, "_usage_anchor", None)
)
measured_used = int(getattr(comp, "last_prompt_tokens", 0) or 0) if comp else 0
context_used = measured_used if measured_used > 0 else estimated_total
if anchored_used is not None:
context_used = anchored_used
else:
context_used = measured_used if measured_used > 0 else estimated_total
context_percent = (
max(0, min(100, round(context_used / context_max * 100)))
if context_max
+6
View File
@@ -4626,6 +4626,12 @@ def compress_context(
agent.context_compressor.last_prompt_tokens = -1
agent.context_compressor.last_completion_tokens = 0
agent.context_compressor.awaiting_real_usage_after_compression = True
# Compaction rewrote the transcript, so the usage anchor's base
# message-list snapshot no longer describes what will be sent —
# invalidate it. Context checks fall back to full estimation until
# the next response with usage re-anchors (its structural id/index
# check would also fail closed, but explicit is safer).
agent._usage_anchor = None
# Arm the effectiveness verdict only after a completed rewrite crosses
# the full compaction boundary. Exceptions, aborts, and no-op attempts
# leave this false, so unrelated later usage cannot be charged to an
+33
View File
@@ -72,6 +72,8 @@ _STALE_MARKER_RE = re.compile(r"^\[[A-Za-z_][A-Za-z0-9_.-]*\]$")
from agent.model_metadata import (
MINIMUM_CONTEXT_LENGTH,
_estimate_tools_tokens_rough,
anchored_context_tokens,
capture_usage_anchor,
estimate_messages_tokens_rough,
estimate_request_tokens_rough,
get_context_length_from_provider_error,
@@ -2593,6 +2595,18 @@ def run_conversation(
request_pressure_tokens = approx_tokens + (
_estimate_tools_tokens_rough(agent.tools) if agent.tools else 0
)
# Usage-anchored override: when the last provider response's exact
# usage is still valid for the durable transcript, replace the
# whole-history heuristic with anchor + delta-estimate. The anchor's
# prompt_tokens already includes system prompt AND tool schemas as
# the provider counted them, so no tools add-on is needed. Falls
# back to the rough figures above when the anchor is stale/missing
# (first request, post-compaction, usage-less providers).
_anchored_pressure = anchored_context_tokens(
messages, getattr(agent, "_usage_anchor", None)
)
if _anchored_pressure is not None:
request_pressure_tokens = _anchored_pressure
total_chars = approx_tokens * 4
# Stash this request's rough estimate so update_from_response() can
# pair it with the provider's real prompt count — the (rough, real)
@@ -4185,6 +4199,25 @@ def run_conversation(
)
)
agent.context_compressor.update_from_response(usage_dict)
# Usage-anchored context accounting: snapshot this
# response's exact provider-reported usage against the
# durable transcript. Later context-size checks anchor on
# this and estimate only the messages appended since,
# instead of re-estimating the whole history with
# heuristics. Main-loop responses ONLY — MoA advisor and
# auxiliary calls never reach this site, so they cannot
# pollute the anchor. A usage-less response leaves the
# previous anchor in place (still valid for its base).
# MoA note: use the pre-fold aggregator usage — the folded
# canonical figure adds advisor fan-out tokens that were
# never part of THIS conversation's prompt.
_new_anchor = capture_usage_anchor(
aggregator_usage.prompt_tokens,
aggregator_usage.output_tokens,
messages,
)
if _new_anchor is not None:
agent._usage_anchor = _new_anchor
_compression_threshold = int(
getattr(agent.context_compressor, "threshold_tokens", 0)
or 0
+85
View File
@@ -3764,6 +3764,91 @@ def estimate_request_tokens_rough(
return total
# --- Usage-anchored context accounting ------------------------------------
#
# Provider responses carry ``usage.prompt_tokens`` — EXACT ground truth for
# everything sent on that request (system prompt + tool schemas + full
# history). Re-estimating the whole conversation with chars/4 heuristics on
# every context-size check compounds error over the entire transcript (flat
# 1500-token images, CJK density, provider replay blobs). Anchoring on the
# last real usage shrinks the estimation window to the messages appended
# since that response; the error self-corrects at every new response.
#
# The anchor is a plain dict so callers can store it anywhere:
# prompt_tokens / completion_tokens — provider-reported usage at capture.
# base_count — len(messages) at capture time (the assistant reply for the
# captured response is NOT yet appended at the capture site; when it
# appears at index base_count its cost is covered by completion_tokens,
# so the delta walk skips it).
# base_last_id / base_last_role — identity fingerprint of the last message
# at capture time. Compaction, splices, and history rewrites shift or
# replace that element, failing the check and falling back to full
# estimation. Belt-and-braces on top of explicit invalidation.
def capture_usage_anchor(
prompt_tokens: Any,
completion_tokens: Any,
messages: List[Dict[str, Any]],
) -> Optional[Dict[str, Any]]:
"""Build a usage anchor from provider-reported usage, or None."""
try:
pt = int(prompt_tokens or 0)
ct = int(completion_tokens or 0)
except (TypeError, ValueError):
return None
if pt <= 0 or not isinstance(messages, list):
# No usable usage (some OpenAI-compatible endpoints omit it) — the
# caller keeps whatever anchor it had, or stays on pure estimation.
return None
base_count = len(messages)
last = messages[-1] if base_count else None
return {
"prompt_tokens": pt,
"completion_tokens": max(0, ct),
"base_count": base_count,
"base_last_id": id(last) if last is not None else None,
"base_last_role": last.get("role") if isinstance(last, dict) else None,
}
def anchored_context_tokens(
messages: List[Dict[str, Any]],
anchor: Optional[Dict[str, Any]],
) -> Optional[int]:
"""Context size anchored on the last provider-reported usage.
Returns ``prompt_tokens + completion_tokens`` of the anchored response
plus a rough estimate of ONLY the messages appended since — or ``None``
when the anchor is missing or stale (caller falls back to full
estimation). The assistant reply produced by the anchored response
(first appended message after the base) is skipped: its cost is already
counted exactly by ``completion_tokens``.
"""
if not isinstance(anchor, dict) or not isinstance(messages, list):
return None
base_count = anchor.get("base_count") or 0
if base_count <= 0 or len(messages) < base_count:
return None
base_msg = messages[base_count - 1]
if id(base_msg) != anchor.get("base_last_id"):
return None
base_role = base_msg.get("role") if isinstance(base_msg, dict) else None
if base_role != anchor.get("base_last_role"):
return None
total = int(anchor["prompt_tokens"]) + int(anchor.get("completion_tokens") or 0)
delta = messages[base_count:]
if delta:
first = delta[0]
if isinstance(first, dict) and first.get("role") == "assistant":
# The anchored response's own reply — already counted exactly by
# completion_tokens above.
delta = delta[1:]
if delta:
total += estimate_messages_tokens_rough(delta)
return total
# NOTE: tool schemas can be large. Avoid repeated `str(tools)` conversions,
# which are CPU-heavy and can stall GUI event loops under GIL pressure.
#
+12
View File
@@ -44,6 +44,7 @@ from agent.memory_manager import build_memory_context_block
from agent.memory_provider import is_trivial_prompt
from agent.message_metadata import append_message, stamp_message_timestamp
from agent.model_metadata import (
anchored_context_tokens,
estimate_messages_tokens_rough,
estimate_request_tokens_rough,
)
@@ -62,7 +63,18 @@ def _preflight_request_tokens(
count the checkpoint-pruned wire payload rather than the full durable
transcript. Auxiliary compression still uses the generic estimator
(``native_compaction_eligible=False``).
Usage-anchored fast path: when a provider-reported usage anchor is
valid for ``messages`` (see ``anchored_context_tokens``), it already
covers system prompt + tool schemas + full history EXACTLY as the
provider counted them, with estimation confined to the messages
appended since that response. Prefer it over every heuristic.
"""
anchored = anchored_context_tokens(
messages, getattr(agent, "_usage_anchor", None)
)
if anchored is not None:
return anchored
tools = getattr(agent, "tools", None) or None
try:
from agent.codex_responses_adapter import (
+5
View File
@@ -798,6 +798,11 @@ class AIAgent:
self.session_estimated_cost_usd = 0.0
self.session_cost_status = "unknown"
self.session_cost_source = "none"
# Session boundary: the usage anchor describes the OLD session's
# transcript — a fresh/branched/resumed session must fall back to
# full estimation until its first provider response re-anchors.
self._usage_anchor = None
# Turn counter (added after reset_session_state was first written — #2635)
self._user_turn_count = 0
+212
View File
@@ -0,0 +1,212 @@
"""Usage-anchored context accounting (agent/model_metadata.py).
Context-size checks anchor on the provider-reported ``usage.prompt_tokens``
of the last main-loop response and estimate ONLY the messages appended
since. These tests cover:
* anchor + delta arithmetic (exact base, small estimated delta);
* the image-heavy divergence the anchor eliminates (flat 1500/image
heuristic vs provider truth);
* fallback to full estimation when no anchor exists (first request,
usage-less providers);
* invalidation when compaction rewrites the transcript (structural
id/index check fails closed) and on explicit reset sites;
* the preflight consumer (_preflight_request_tokens) preferring the
anchor, plus a sabotage check proving the anchored path (not the
heuristic) produces the number.
"""
from types import SimpleNamespace
import pytest
from agent.model_metadata import (
anchored_context_tokens,
capture_usage_anchor,
estimate_messages_tokens_rough,
)
from agent.turn_context import _preflight_request_tokens
def _msg(role, content):
return {"role": role, "content": content}
def _image_msg():
# ~40KB of fake base64 — the rough estimator charges a flat 1500
# tokens per image part regardless of true provider accounting.
return {
"role": "user",
"content": [
{"type": "text", "text": "look at this"},
{
"type": "image_url",
"image_url": {"url": "data:image/png;base64," + "A" * 40000},
},
],
}
def _history_with_images(n_images=10):
msgs = [_msg("user", "start")]
for i in range(n_images):
msgs.append(_msg("assistant", f"taking screenshot {i}"))
msgs.append(_image_msg())
msgs.append(_msg("assistant", "done looking"))
return msgs
class TestAnchorArithmetic:
def test_anchor_plus_small_delta(self):
messages = _history_with_images(10)
anchor = capture_usage_anchor(50_000, 250, messages)
assert anchor is not None
assert anchor["prompt_tokens"] == 50_000
assert anchor["base_count"] == len(messages)
# Main loop appends the response's own assistant reply, then a tool
# result / user follow-up.
messages.append(_msg("assistant", "the anchored reply itself"))
messages.append(_msg("user", "short follow-up"))
anchored = anchored_context_tokens(messages, anchor)
assert anchored is not None
# Exact base + completion; the assistant reply at base_count is
# covered by completion_tokens, so only the follow-up is estimated.
delta_est = estimate_messages_tokens_rough([messages[-1]])
assert anchored == 50_000 + 250 + delta_est
assert delta_est < 50 # the estimated window is one small message
def test_image_heavy_divergence_eliminated(self):
messages = _history_with_images(10)
# Provider ground truth: say the real prompt was 12,000 tokens
# (providers often charge far less than 1500/image, or the images
# were downscaled). The heuristic charges 10 * 1500 + text.
anchor = capture_usage_anchor(12_000, 100, messages)
messages.append(_msg("assistant", "reply"))
messages.append(_msg("user", "ok"))
rough = estimate_messages_tokens_rough(messages)
anchored = anchored_context_tokens(messages, anchor)
assert rough >= 15_000 # flat 1500 x 10 images dominates
assert anchored is not None
assert anchored < 12_200
# The whole-history heuristic diverges by thousands of tokens;
# the anchored figure is provider truth + a tiny delta.
assert rough - anchored > 2_800
def test_no_usage_returns_none(self):
messages = [_msg("user", "hi")]
assert capture_usage_anchor(0, 0, messages) is None
assert capture_usage_anchor(None, None, messages) is None
assert capture_usage_anchor("garbage", 1, messages) is None
def test_missing_anchor_falls_back(self):
messages = _history_with_images(2)
assert anchored_context_tokens(messages, None) is None
class TestAnchorInvalidation:
def test_compaction_rewrite_fails_closed(self):
messages = _history_with_images(4)
anchor = capture_usage_anchor(30_000, 50, messages)
# Compaction: transcript rebuilt as a new, shorter list.
compacted = [
_msg("user", "summary handoff"),
_msg("assistant", "[compressed summary]"),
]
assert anchored_context_tokens(compacted, anchor) is None
def test_middle_splice_shifts_base_and_fails_closed(self):
messages = _history_with_images(4)
anchor = capture_usage_anchor(30_000, 50, messages)
# Micro-compact style splice: middle window replaced by one marker.
spliced = messages[:1] + [_msg("assistant", "[marker]")] + messages[5:]
assert anchored_context_tokens(spliced, anchor) is None
def test_same_length_different_objects_fails_closed(self):
messages = _history_with_images(4)
anchor = capture_usage_anchor(30_000, 50, messages)
rebuilt = [dict(m) for m in messages] # fresh dicts, same values
assert anchored_context_tokens(rebuilt, anchor) is None
def test_explicit_invalidation_sites(self):
"""The compaction + session-reset sites null agent._usage_anchor."""
import inspect
import agent.conversation_compression as cc
import agent.codex_runtime as cr
import run_agent
assert "agent._usage_anchor = None" in inspect.getsource(cc)
assert "agent._usage_anchor = None" in inspect.getsource(cr)
assert "self._usage_anchor = None" in inspect.getsource(
run_agent.AIAgent.reset_session_state
)
class TestPreflightConsumer:
def _agent(self, anchor):
return SimpleNamespace(
_usage_anchor=anchor,
tools=None,
api_mode="",
provider="openai",
)
def test_preflight_prefers_anchor(self):
messages = _history_with_images(10)
anchor = capture_usage_anchor(50_000, 250, messages)
messages.append(_msg("assistant", "reply"))
messages.append(_msg("user", "ok"))
agent = self._agent(anchor)
got = _preflight_request_tokens(agent, messages, "SYSTEM PROMPT " * 500)
expected = anchored_context_tokens(messages, anchor)
assert got == expected
# The anchored figure ignores the (already-counted) system prompt
# text passed in — provider usage includes the real one.
assert 50_000 < got < 50_500
def test_preflight_falls_back_without_anchor(self):
messages = _history_with_images(3)
agent = self._agent(None)
got = _preflight_request_tokens(agent, messages, "sys")
# Pure heuristic: flat image cost dominates.
assert got >= 4_500
def test_sabotage_disabling_anchor_changes_result(self):
"""Prove the anchored path produced the number: with the anchor
removed (the sabotage), the same inputs yield the heuristic figure,
which diverges by thousands of tokens on an image-heavy history."""
messages = _history_with_images(10)
anchor = capture_usage_anchor(12_000, 100, messages)
messages.append(_msg("assistant", "reply"))
messages.append(_msg("user", "ok"))
anchored_result = _preflight_request_tokens(
self._agent(anchor), messages, ""
)
sabotaged_result = _preflight_request_tokens(
self._agent(None), messages, ""
)
assert sabotaged_result - anchored_result > 2_800
class TestCompressionTriggerUsesAnchor:
def test_threshold_decision_flips_with_anchor(self):
"""An image-heavy history the heuristic pushes over a 15K threshold
stays under it when the provider reports the real 12K prompt."""
messages = _history_with_images(10)
anchor = capture_usage_anchor(12_000, 100, messages)
messages.append(_msg("assistant", "reply"))
threshold = 15_000
heuristic = estimate_messages_tokens_rough(messages)
anchored = anchored_context_tokens(messages, anchor)
assert heuristic >= threshold # old behavior: spurious compression
assert anchored is not None and anchored < threshold
if __name__ == "__main__":
raise SystemExit(pytest.main([__file__, "-v"]))
@@ -71,9 +71,9 @@ def _tool_definition():
@pytest.mark.parametrize(
("prompt_tokens", "expected_compactions", "provider_recovery"),
[(50, 2, False), (150, 1, False), (50, 2, True)],
[(50, 1, False), (150, 1, False), (50, 2, True)],
ids=[
"pressure-cleared-rearms",
"pressure-cleared-anchored-no-recompaction",
"pressure-still-high-stays-capped",
"pressure-cleared-rearms-after-provider-recovery",
],
@@ -83,7 +83,17 @@ def test_pre_api_compression_budget_rearms_only_after_pressure_clears(
expected_compactions: int,
provider_recovery: bool,
):
"""Only provider-confirmed headroom starts a new pressure episode."""
"""Only provider-confirmed headroom starts a new pressure episode.
Usage-anchored accounting update: once the provider reports
``prompt_tokens=50`` for the full transcript, later pre-API checks anchor
on that real reading plus a delta estimate of the few appended messages —
the scripted whole-history rough estimate (200) no longer drives the
decision, so the pressure-cleared case performs exactly ONE compaction
(the pre-anchor one). The budget-rearm mechanics remain covered by the
provider-recovery variant, whose first response carries no usage (no
anchor) and therefore still compacts on the rough estimate.
"""
with (
patch("run_agent.get_tool_definitions", return_value=[_tool_definition()]),
patch("run_agent.check_toolset_requirements", return_value={}),