feat(engine): report real per-turn token usage in the envelope and wrapper events - #153
Merged
David Koleczek (DavidKoleczek) merged 4 commits intoAug 26, 2026
Merged
Conversation
…apper events
The `--output json` envelope reported `metadata.tokensIn: 0` and
`tokensOut: 0` on every invocation regardless of actual usage. The CLI read
those keys off the engine turn result, but nothing ever populated them, so
the defaults always won. Token data was observable only by consuming the
`usage` display events off the ndjson stderr stream, which required
`--display ndjson` and left every host to re-derive the totals itself.
That aggregation is easy to get wrong: the engine emits one usage event per
LLM call plus a trailing rollup carrying zero tokens, so a last-value-wins
reader silently reports nothing.
The engine now accumulates usage at the display protocol point and reports
it directly. That placement sits upstream of the human-facing renderer, so
the same numbers appear under `--display text`, `--display ndjson`, and
`--quiet`; usage is no longer a side effect of which renderer is attached.
Delegated sub-agent calls flow through the same point and are included.
Envelope metadata gains three fields:
cacheReadTokens int
cacheWriteTokens int
costUsd str | null
`tokensIn` is the charged input total: new input plus cache reads plus cache
writes. This matches the field's original definition, "input tokens
charged", and avoids the undercount that reporting only new input produces
on a cached turn. Hosts wanting the new-input figure derive it by
subtracting the two cache fields. `costUsd` is a decimal string rather than
a float, because a float loses monetary precision as soon as a host sums
turns.
An error envelope raised once a turn is running now reports what the turn
spent before it failed, rather than a hardcoded zero. The pre-boot
argv-validation envelope still reports zero and omits the three new fields:
no turn ran, so there is nothing to report.
Wrapper SDKs, both in parity:
- `ResultEvent` gains `session_id`, `turn_id`, `exit_code`, `usage`, and
`stderr_tail`; `ErrorEvent` gains the first four. The parser previously
shape-validated the envelope and then discarded everything but `reply`.
- A new `Usage` type. `cost_usd` is a `Decimal` in Python but stays a string
in TypeScript, since a JS `number` is IEEE-754 and would lose precision.
This is a deliberate parity exception and is documented as one.
- Synthesized errors that never produced an envelope now carry the session
id the wrapper already knows. `turn_id` stays absent, because the engine
assigns turn ids and no envelope came back. Envelope values remain
authoritative when one exists.
- New `stderr_tail_bytes` / `stderrTailBytes` session option governing both
terminal events: a positive int caps the tail, `None`/`null` returns the
entire buffer, `0` disables it. The default preserves existing behavior.
Fixes the stderr tail measuring characters rather than bytes. Both wrappers
sliced a string, so `STDERR_TAIL_BYTES` was wrong on any non-ASCII stderr.
The tail is now UTF-8 byte-accurate and never splits a codepoint, which
means it may return slightly fewer than the requested bytes when trimming to
a boundary.
PROTOCOL_VERSION moves 0.3.0 to 0.4.0 for the additive envelope fields.
Both wrappers, the conformance fixtures, and the documented pins move with
it. The two fixtures pinning the `2099-12-future-vN` skew sentinel stay
stale deliberately: they exist to prove the engine refuses a foreign
version.
Coverage: a new `usage` e2e suite, the first to drive a wrapper SDK inside
the DTU. Its accuracy case pins the envelope against an independent oracle,
summing the provider's own per-call usage out of the session event log and
requiring exact equality, so a dropped event, a double count, or an omitted
cache bucket fails the build rather than merely looking plausible.
Impact: coordinated cross-component. Engine and both wrapper SDKs must be
released together; a wrapper pinning 0.3.0 will refuse a 0.4.0 engine.
🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)
Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
charged_input summed inputTokens + cacheReadTokens + cacheWriteTokens on the assumption that the three are disjoint buckets. They are not. amplifier-core's docs/contracts/PROVIDER_CONTRACT.md specifies a provider's input_tokens as the "gross total (fresh + cache_read combined)", and providers normalize to that shape: the Anthropic module ADDS cache_read_input_tokens into input_tokens, and the OpenAI module subtracts only cache_write out of the vendor total, leaving cache reads inside. cacheReadTokens is therefore a reported SUBSET of the gross figure, not an addend. Adding it a second time roughly doubles the reported input on a cache-heavy turn -- the normal case in an agent loop. On the turn measured while building this, tokensIn read 24,700 against a true charged input of 12,788. That is the same class of error the change set out to fix, inverted: it replaced a wrong zero with a wrong non-zero, which is worse, because a zero is visibly broken and a plausible number gets persisted downstream. charged_input is now inputTokens + cacheWriteTokens -- cache writes are the one bucket billed on top of the gross total. This matches _compute_total_input in amplifier-module-hooks-streaming-ui, the ecosystem's existing consumer of the same event. Renamed new_input to gross_input so the attribute stops asserting the thing that was wrong. The e2e oracle carried the identical assumption, so tokensIn was being checked against a second implementation of the same wrong formula and passed. Corrected, and its independence from UsageAccumulator is now stated as deliberate: if both sides shared a helper the suite could only prove the accumulator summed events correctly and would be structurally blind to the formula itself. Also documents why costUsd sums per-call cost rather than substituting sessionCostTotal: this engine never calls bridge_child_cost, so delegated sub-agent spend never reaches the parent's session.cost channel, while child sessions do inherit display.emit and emit their per-call cost events here. The display stream is the only path that sees sub-agent cost today. Two doc corrections in the same pass: tokensIn had no prior definition in main to "match", and the protocol version check is exact string equality, so hosts cannot gate on the new fields -- a version mismatch is refused outright. No behavior change outside charged_input. Wrapper edits are comment-only.
…idge landed UsageAccumulator's module docstring justified summing per-call cost partly on the grounds that this engine never calls bridge_child_cost, so delegated sub-agent spend could not reach the parent's session.cost channel. It also said to revisit that reasoning if the bridge was ever wired up. spawn.py now calls bridge_child_cost, so the claim is stale. The conclusion is unchanged and no behavior changes: the accumulator sums per-call cost off the display stream and never reads sessionCostTotal, which is the only figure the bridge moves. Two reasons survive and are now the ones stated. sessionCostTotal is session-scoped rather than turn-scoped, so a resumed session carries prior turns into it. And the bridge runs only after a delegation succeeds, so a failed sub-agent's spend never reaches session.cost while its per-call cost events still arrive on the display stream. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
David Koleczek (DavidKoleczek)
deleted the
feat/envelope-usage-accounting
branch
August 26, 2026 21:13
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
amplifier-agent run --output jsonreportedmetadata.tokensIn: 0andtokensOut: 0on every invocation regardless of actual usage. The CLI read those keys off the engine turn result, but nothing populated them, so the defaults always won.Token data was reachable only by consuming
usagedisplay events off the ndjson stderr stream. That required--display ndjsonand left every host to re-derive the totals itself, which is easy to get wrong: the engine emits one usage event per LLM call plus a trailing rollup carrying zero tokens, so a last-value-wins reader silently reports nothing.The engine now accumulates usage at the display protocol point and reports it directly.
Why that placement
The accumulator sits upstream of the human-facing renderer, so the same numbers appear under
--display text,--display ndjson, and--quiet. Usage is no longer a side effect of which renderer happens to be attached. Delegated sub-agent calls flow through the same point and are included in the total.Envelope
Three new
metadatafields:tokensInis the charged input total:inputTokens + cacheWriteTokens.Per amplifier-core's
docs/contracts/PROVIDER_CONTRACT.md, a provider'sinput_tokensis the gross total, fresh plus cache_read combined, andcache_write_tokensis the one bucket billed on top of the gross.cacheReadTokensis therefore a reported subset of the gross figure, not an addend, and is exposed for visibility rather than for summing. Cache reads are not added a second time.costUsdis a decimal string, not a float, because a float loses monetary precision as soon as a host sums turns.The three envelope paths differ deliberately:
Wrapper SDKs, both in parity
ResultEventgainssession_id,turn_id,exit_code,usage,stderr_tail.ErrorEventgains the first four. The parser previously shape-validated the envelope and then discarded everything butreply.Usagetype. Deliberate parity exception:cost_usdis aDecimalin Python but stays astringin TypeScript, since a JSnumberis IEEE-754 and would lose precision. Documented on both sides.turn_idstays absent, because the engine assigns turn ids and no envelope came back. Envelope values remain authoritative when one exists.stderr_tail_bytes/stderrTailBytessession option, governing both terminal events:The default preserves existing behavior.
Bug fixed along the way
The stderr tail measured characters, not bytes. Both wrappers sliced a string, so
STDERR_TAIL_BYTESwas silently wrong on any non-ASCII stderr. It is now UTF-8 byte-accurate and never splits a codepoint, which means it may return slightly fewer than the requested bytes when trimming to a boundary.Protocol
PROTOCOL_VERSIONmoves0.3.0to0.4.0for the additive envelope fields. Both wrappers, the conformance fixtures, and the documented pins move with it.The two fixtures pinning the
2099-12-future-vNskew sentinel stay stale on purpose: they exist to prove the engine refuses a foreign protocol version.Coverage
A new
usagee2e suite. It is the first suite to drive a wrapper SDK inside the DTU, installing it from the same mirror the engine comes from so local wrapper changes are actually exercised.The accuracy case pins the envelope against the provider's own per-call usage, summed out of the session event log. Its independence from
UsageAccumulatoris deliberate and load-bearing: the two must not share a helper, or the suite could only prove the accumulator summed events correctly and would be structurally blind to the formula itself. That blindness is not hypothetical, see below.Corrections made during review
Two defects were found and fixed on this branch after the initial push, both worth reading before approving:
tokensIndouble-counted cache reads.charged_inputsummedinputTokens + cacheReadTokens + cacheWriteTokenson the assumption the three were disjoint buckets. They are not, per the provider contract quoted above. On a cache-heavy turn, the normal case in an agent loop, this roughly doubled the reported input: one measured turn read 24,700 against a true charged input of 12,788. That is the same class of error this PR set out to fix, inverted, and worse, because a wrong zero looks broken while a plausible wrong number gets persisted downstream.The e2e oracle carried the identical assumption, so
tokensInwas being validated against a second implementation of the same wrong formula and passed. Both sides are corrected, and the oracle's independence is now stated as a deliberate property rather than left implicit.Rebased onto current main
Merged
maincleanly, picking up #154, #155, and #156. One interaction required a follow-up commit:#155 wires up
bridge_child_costinspawn.py.UsageAccumulator's docstring had justified summing per-call cost partly on the grounds that the bridge did not exist, and said to revisit if it ever landed. No behavior changes, since the accumulator never readssessionCostTotal, which is the only figure the bridge moves. The rationale is now restated on the two grounds that survive:sessionCostTotalis session-scoped rather than turn-scoped, and the bridge only fires on successful delegation, so a failed sub-agent's spend never reachessession.costwhile its per-call events still arrive on the display stream.#154 fixes the same cache-read double-count on the OpenAI wire, independently. Same root cause, different surface.
Verification
Also exercised by hand in the DTU, via both the CLI envelope and the Python SDK.
Impact
Coordinated cross-component. Engine and both wrapper SDKs must be released together: a wrapper pinning
0.3.0will refuse a0.4.0engine, and vice versa. Merging is safe on its own, since both install paths resolve to the latest tagged release rather than trackingmain.Package versions are intentionally not bumped here; that belongs to the release process.
Reviewer notes
Two things not closed, neither of them regressions:
No conformance parity fixture was added. The conformance runners replay scripted JSON-RPC frames against a mock transport and never import a wrapper, and
verify-parity.pyreads its fixtures from a directory undersrc/, so the files inwrappers/conformance/fixtures/are not executed by anything. There is no way to express wrapper-level parity in that harness today without extending it. Parity here is evidenced by byte-identical proof output across both languages and new TS parser cases, but it is not gated. Worth a follow-up.No e2e case covers a turn that spends tokens and then fails. No deterministic trigger was found for an in-turn error after a real LLM call. The fields are present on the error path, but the interesting half of that behavior is untested.
🤖 Generated with Amplifier