Skip to content

feat(engine): report real per-turn token usage in the envelope and wrapper events - #153

Merged
David Koleczek (DavidKoleczek) merged 4 commits into
mainfrom
feat/envelope-usage-accounting
Aug 26, 2026
Merged

feat(engine): report real per-turn token usage in the envelope and wrapper events#153
David Koleczek (DavidKoleczek) merged 4 commits into
mainfrom
feat/envelope-usage-accounting

Conversation

@DavidKoleczek

@DavidKoleczek David Koleczek (DavidKoleczek) commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

amplifier-agent run --output json 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 populated them, so the defaults always won.

Token data was reachable only by consuming usage display events off the ndjson stderr stream. That required --display ndjson and 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 metadata fields:

cacheReadTokens   int
cacheWriteTokens  int
costUsd           str | null

tokensIn is the charged input total: inputTokens + cacheWriteTokens.

Per amplifier-core's docs/contracts/PROVIDER_CONTRACT.md, a provider's input_tokens is the gross total, fresh plus cache_read combined, and cache_write_tokens is the one bucket billed on top of the gross. cacheReadTokens is 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.

costUsd is 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:

success              reports real spend
in-turn error        reports what the turn spent BEFORE it failed
                     (previously hardcoded 0/0)
pre-boot / argv      still 0/0, 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, stderr_tail. ErrorEvent gains the first four. The parser previously shape-validated the envelope and then discarded everything but reply.
  • New Usage type. Deliberate parity exception: cost_usd is a Decimal in Python but stays a string in TypeScript, since a JS number is IEEE-754 and would lose precision. Documented on both sides.
  • 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:
positive int  ->  last N bytes
None / null   ->  the entire stderr buffer
0             ->  disabled

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_BYTES was 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_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 on purpose: they exist to prove the engine refuses a foreign protocol version.

Coverage

A new usage e2e 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 UsageAccumulator is 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:

  1. tokensIn double-counted cache reads. charged_input summed inputTokens + cacheReadTokens + cacheWriteTokens on 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.

  2. The e2e oracle carried the identical assumption, so tokensIn was 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 main cleanly, picking up #154, #155, and #156. One interaction required a follow-up commit:

#155 wires up bridge_child_cost in spawn.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 reads sessionCostTotal, which is the only figure the bridge moves. The rationale is now restated on the two grounds that survive: sessionCostTotal is session-scoped rather than turn-scoped, and the bridge only fires on successful delegation, so a failed sub-agent's spend never reaches session.cost while 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

make verify             ALL GATES PASSED
  check                 ruff + format + pyright
  verify-codegen        schemas byte-identical to the generator
  verify-parity         10/10 fixtures, Python and TS runners agree
  verify-wrapper        21 files, 157 tests

e2e, post-merge         33 passed  (usage, raw_capture, run, modes)
e2e, pre-merge          82 passed, 4 skipped, 0 failed, fresh DTU

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.0 will refuse a 0.4.0 engine, and vice versa. Merging is safe on its own, since both install paths resolve to the latest tagged release rather than tracking main.

Package versions are intentionally not bumped here; that belongs to the release process.

Reviewer notes

Two things not closed, neither of them regressions:

  1. 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.py reads its fixtures from a directory under src/, so the files in wrappers/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.

  2. 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

…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>
@DavidKoleczek
David Koleczek (DavidKoleczek) merged commit ccb34c0 into main Aug 26, 2026
4 checks passed
@DavidKoleczek
David Koleczek (DavidKoleczek) deleted the feat/envelope-usage-accounting branch August 26, 2026 21:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant