fix(desktop): keep the context meter live through a long agentic turn - #316
Merged
Merged
Conversation
A chat started by `hermes desktop spawn` streams its tokens fine but its context meter sits frozen for the whole run. Measured 2026-08-02 on session 20260802_062726_a24427: 22 API calls, 40 tool calls, 95,377 prompt tokens against a 262,144 window, and the gauge never moved. The cause is not that spawned sessions are wired differently — they use the same stores and the same handlers a typed chat does. It is that context usage only ever crossed the wire at TURN boundaries: `session.info` immediately before `message.start` (carrying the previous turn's occupancy) and `message.complete` at the end. Nothing in between. A chat someone types turns over every few sentences, so the gauge looks live; a `--delegated` spawn is by construction ONE long agentic turn, so it has no boundary to report at until it finishes. The renderer comments that speak of "periodic session.info heartbeats" describe something the backend does not actually do. So `_on_tool_complete` now also pushes a `token.usage` frame. Each completed tool follows an API response, which is exactly when the compressor's occupancy moved, making it the natural sampling point — one small frame per round trip, next to nothing beside the tool.start/tool.complete/message.delta already on that wire. It sits OUTSIDE the tool-progress gate on purpose: the meter has to track a long turn even for a client that streams no tool cards. The desktop has handled `token.usage` since long before this; nothing had ever emitted it. Two details that bite if you only wire up half of it: - The handler reads a FLAT payload (`context_tokens` / `context_length` / `context_pct`), which is NOT what `_get_usage` calls those fields. The emitter translates; a rename on either side silently stops the meter again. - `usageFromTokenUsagePayload` dropped `compressions`, so a compaction landing mid-turn would look like context going backwards, and the monotonic guard in `mergeUsageSnapshot` would pin the gauge high until the turn ended — exactly the window this frame exists to cover. It is carried through now. Nothing is emitted while the window is still unknown: a fresh compressor reports `last_prompt_tokens` 0 and `_get_usage` deliberately omits the gauge rather than fabricate 0%, so a partial frame can never blank a live meter. Verified against the real app, not just in unit tests. The e2e mock inference server can now drive a short tool-calling loop, so a single agent turn spans several round trips, and `MOCK_TOOL_CALL_DELAY_MS` paces it so a test can sample state DURING that turn instead of watching it land between two polls. With the fix a spawned turn reports four times while it runs (16.3k -> 16.4k -> 16.5k -> 16.6k, first at t=2.4s); with the emit reverted it reports exactly once, at t=5.0s, when the turn ends. The typed case moves the same way — the bug was never spawn-specific, only spawn-shaped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This was referenced Aug 2, 2026
OmarB97
added a commit
that referenced
this pull request
Aug 2, 2026
…ount (#332) The status-bar meter still lags through a long agentic turn. Observed 2026-08-02: it sat at 55.8k while the server was serving that same session at 65.7k+ across in-turn tool rounds. #316 made a completed TOOL push a `token.usage` frame, which is what got the gauge moving mid-turn at all. But a tool completion is the wrong signal for the quantity being published. It reports the request that *produced* that tool call, before its result was appended, so the reading is a full round trip behind whatever is in flight — and a round that ends without calling a tool (a reasoning-only round, a `length` continuation, the final round of every turn) says nothing at all. When several of those land in a row the gauge does not drift, it sits still, which is what "pinned" looked like. That last step is an argument from the code, not a reproduction: the harness cannot currently drive a turn that keeps going without calling a tool, so the field symptom itself was not reproduced here. What is reproduced, and asserted, is that occupancy now reaches the wire on the response rather than on the tool — see the verification note at the end for exactly where the line is drawn. Usage now goes out from `record_canonical_usage`, the one place that sees the provider's own prompt count land on the compressor. That is one frame per usage-bearing API response, on the real number rather than an estimate, and because both the chat loop and the codex app-server runtime account through it, both are covered by the same three lines. #316's tool-completion sample stays as a cheap second one — with de-duplication it costs nothing, and it still covers a runtime that accounts outside that recorder. The agent loop had offered a hook for exactly this since the live context bar was added, and nothing ever assigned it, so the branch was dead. It was also offered at the wrong moment — *before* the request, carrying `estimate_messages_tokens_rough`. That estimate assumes ~4 chars/token while dense tool-call/JSON transcripts tokenize nearer ~3.4, so it reads materially low against the server: the over-reservation note in `model_metadata` measured est 58,039 against server >=65,797 on deepseek-v4-flash-w2, a gap the same shape and size as the one reported here. Publishing it would have put a number on the meter that under-reads the window by ~13%. The estimate still does its real job (sizing compression decisions); it just no longer pretends to be a measurement, and the helper is renamed to `_raise_preflight_context_estimate` to stop the name promising otherwise. Three details that matter more than the wiring: - **The hook is a post-construction attribute, never an AIAgent kwarg.** `delegate_tool` forwards ~30 constructor kwargs from parent to child, so a kwarg is precisely how a subagent would inherit this and start reporting its own much smaller window on the parent's gauge. Auxiliary calls — goal judge, title, compaction — never reach the recorder at all; they account through `record_aux_usage`, which touches neither the compressor nor the session counters. Both isolations are asserted, the goal judge through the real auxiliary path with its recorded row checked, so the test cannot pass by the call simply never happening. - **Frames are de-duplicated and floored at 1/s.** Every tool in a parallel batch reads the same occupancy, so an unchanged gauge is dropped outright and costs nothing. A moved gauge waits out the floor; dropping one is safe because occupancy only climbs within a turn, so a later frame supersedes it and `message.complete` still carries the turn's final number. A compaction is exempt — it is the one legitimate fall, and it is the drop the client's monotonic guard is watching `compressions` to authorize. - **The usage frame now precedes the tool card.** `tool.complete` is the one emit in that callback not wrapped in `try/except`; a tool result that fails to serialize raises through it and the tool executor swallows that, which silently took the usage frame down with it. Verified on the real stack, not just in unit tests. The e2e mock answered `stream_options: {include_usage: true}` with nothing at all, so every streamed response looked usage-less and the whole recorder path went unexercised; it now reports a real prompt count that grows each round, the way a provider does. A spawned turn then tracks the server's own number live — 15k -> 18k -> 21k -> 24k, +3k per round trip — and readings are asserted never to dip, which is what a subagent's or an aux call's window reaching the gauge would look like. The typed case moves the same way. Be clear about what that run does and does not prove: against this mock, `main` renders the identical series, because every mock round calls a tool, so tool completion and provider response coincide. The e2e establishes that the meter tracks the real count live and that nothing regressed. The behaviour that actually changes — a round reporting with no tool completion anywhere in it — is pinned at the seam instead, by a test that drives the real `record_canonical_usage` and asserts the frame reaches the wire without a single tool involved. A mock turn cannot reach that state today: it would need a round that continues without calling a tool (a `length` continuation or a reasoning-only retry), which is worth adding to the harness separately rather than smuggling into this fix. Co-authored-by: Omar Baradei <omar@kostudios.io> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.
The symptom
A chat started by
hermes desktop spawnstreams its tokens into the transcriptperfectly, but the status-bar context meter sits frozen for the entire run,
while a chat the user types updates as they go.
Observed 2026-08-02 on a long-running spawned agentic session. From the
operator's own
state.db, session20260802_062726_a24427:--delegatedcontract (so: definitely a spawn)context_lengthlast_prompt_tokensThe window really filled to 95K and the gauge never said so.
Why it happens
It is not that spawned sessions are wired differently. They go through the
same
session.create, the same stores, and the same event handlers a typedchat does — I checked that first, and it is a dead end.
The real cause is that context usage only ever crossed the wire at turn
boundaries:
session.infoimmediately beforemessage.start(carrying theprevious turn's occupancy) and
message.completeat the end. There isnothing in between — no heartbeat, despite renderer comments in
use-session-state-cache.tsthat talk about "periodicsession.infoheartbeats".
_get_usageis simply never called anywhere else.A chat someone types turns over every few sentences, so the gauge looks live.
A
--delegatedspawn is by construction one long agentic turn, so it hasno boundary to report at until it is finished. Hence "spawned sessions are
broken, typed ones are fine" — the bug is not spawn-specific, it is
spawn-shaped.
The giveaway: the desktop has had a full
token.usagehandler for a long time(
gateway-event.ts, with its own tests). Nothing on the Python side had everemitted that event. This wires up the missing half.
The change
_on_tool_completenow also pushes atoken.usageframe. Each completed toolfollows an API response, which is exactly when the compressor's occupancy
moved, so it is the natural sampling point — one small frame per round trip,
next to nothing beside the
tool.start/tool.complete/message.deltatraffic already on that wire.
Deliberate details worth a reviewer's eye:
even for a client that streams no tool cards, so it cannot piggyback on the
tool.completepayload.context_tokens/context_length/context_pct) which is not what_get_usagecallsthose fields. The emitter translates. A rename on either side silently stops
the meter again — hence an explicit test on the key names.
usageFromTokenUsagePayloaddroppedcompressions, so a compaction landingmid-turn would read as context going backwards and the monotonic guard in
mergeUsageSnapshotwould pin the gauge high until the turn ended — exactlythe window this frame exists to cover. Carried through now (3 lines).
reports
last_prompt_tokens0 and_get_usagedeliberately omits the gaugerather than fabricate 0%, so a partial frame can never blank a live meter.
Evidence
Verified against the real app, not only in unit tests. The e2e mock inference
server can now drive a short tool-calling loop, so one agent turn spans several
round trips, and
MOCK_TOOL_CALL_DELAY_MSpaces it so a test can sample stateduring that turn rather than watch it land between two polls.
With the fix — a spawned turn reports four times while it is still running:
With only the
_emit_token_usagecall reverted — exactly one reading, atthe moment the turn ends:
The typed case behaves identically in both runs, which is the point: this was
never a spawn-only defect.
Checks
tests/tui_gateway/— 422 passed. The 4 failures (test_goal_command,test_projects_rpc,test_subagent_child_mirror,test_turn_outcomes) arebyte-identical with my change stashed, i.e. pre-existing on this machine.
apps/desktopnpm run test:ui— 1925 passed, 1 skipped.npm run typecheck(app + electron + e2e) clean;eslintclean on the filestouched (the 9 repo-wide
perfectionist/sort-*errors are all in files thisPR does not touch).
e2e/context-meter-spawn.spec.ts+e2e/chat.spec.ts— 4 passed.use-session-actions.test.tsx"does not mutate thepersisted composer selection", stays green; nothing here goes near
desktopSessionCreateParamsor any composer atom.Two things found on the way, deliberately NOT in this PR
spawn's
-mwill not move the user's saved selection is broken in twoplaces —
applyRuntimeInfo(use-session-actions/utils.ts) callssetCurrentModel(info.model)with the spawn's override, andsyncRuntimeMetadataToView(use-session-state-cache.ts) rewrites it onevery
session.infofor the life of the session. The test that shouldcatch this passes vacuously because its
session.createstub omitsinfoentirely; add
info: { model: … }to it and it goes red today. That is alarger, riskier change and deserves its own PR — filed separately.
test on 2026-08-01 recorded
context_length = 1000000fordeepseek-v4-flash-0731-ds4(real window 262,144) becausecontext_length_cache.yamlhad not learned it yet, so resolution fellthrough to the generic prefix table in
model_metadata.py. Notspawn-specific and not what this PR fixes, but it is why the meter format in
the original report read
/1M.🤖 Generated with Claude Code