Skip to content

feat(agent): add getUsage() aggregate usage accessor to ModelResult - #97

Merged
LukasParke merged 5 commits into
mainfrom
feat/10-get-usage-accessor
Aug 5, 2026
Merged

feat(agent): add getUsage() aggregate usage accessor to ModelResult#97
LukasParke merged 5 commits into
mainfrom
feat/10-get-usage-accessor

Conversation

@LukasParke

Copy link
Copy Markdown
Contributor

Closes #10

The gap

Reported against getItemsStream() with a multi-round tool-calling run: "I only get the last generation with stop. I don't get the generations with tool_calls."

Two things compound:

  • getResponse() returns only the final round. finalResponse is the last response the loop materialized, so response.usage accounts for the closing stop generation alone. Every intermediate tool_calls generation's tokens are unreachable through it.
  • getItemsStream() carries output items only. It yields message / function_call / reasoning / function_call_output items and never surfaces the response.completed events that hold each round's usage block. So a caller who consumes items and then calls getResponse() gets the final round twice over, and nothing else.

Net effect: no pull-based way to answer "what did this run cost?"

What already existed

  • PostModelCall hook — fires once per completed model response with turnType / turnNumber / usage. Correct per-call granularity, but push-based: you must register a hook before the run.
  • SessionEnd.totalUsage — the aggregate, already computed and already the right shape (SessionUsageTotals). Also push-based, and only emitted at teardown.
  • getFullResponsesStream() — does surface each round's response.completed, so per-round usage is technically reachable, but only by consuming the raw event stream instead of the items stream.
  • allToolExecutionRounds — retains each round's full response.

The aggregate the issue wants was therefore already being computed on every run and simply had no public reader.

What this adds

ModelResult.getUsage(): Promise<SessionUsageTotals> — reuses the existing hook type/schema rather than inventing a shape, so getUsage() and SessionEnd.totalUsage are the same numbers by construction (both read one new private snapshotSessionUsage() helper).

const result = callModel(client, { model, input, tools });

for await (const item of result.getItemsStream()) {
  render(item);
}

const usage = await result.getUsage();
console.log(usage.modelCalls, usage.totalTokens, usage.cost);

Covers the initial request, each tool-round follow-up, the empty-final retry, the allowFinalResponse final turn, and approval-resume requests.

Two supporting fixes

The aggregate was previously only correct when hooks were configured — accumulation ran as a side effect of hook emission:

  • emitPostModelCall now folds usage in before the hooksManager short-circuit; only the emit stays gated.
  • finishHooksSessionForStream now materializes the parked model-call telemetry before its hooksManager guard. This is the only site that folds the no-tools streaming response into the aggregate, so without this a hook-less getItemsStream() / getTextStream() run reported modelCalls: 0.

Neither changes hook-facing behavior (a test asserts getUsage() equals the SessionEnd.totalUsage payload); all 651 existing agent tests pass unchanged.

Judgment calls

  • Awaits completion, like getResponse(). Gates on initStreamGuarded() + executeToolsIfNeeded(), mirroring getDoomLoopVerdict(). So the totals are final whether you await it directly, after getResponse(), or after draining any streaming getter.
  • Never rejects. A failed run still consumed tokens, and cost accounting typically runs in a finally/catch where a second throw would mask the run's original error. A failed/paused run returns totals accrued so far; modelCalls: 0 with zeroed tokens when nothing completed. Await the run itself if you need to observe the failure.
  • cost stays optional, matching the hook payload — a summed 0 from cost-less responses would read as "free" rather than "unknown".
  • No per-round accessor (getRounds()) in this PR. It does not drop out cleanly: both allToolExecutionRounds.push sites store the response that produced the tool calls, so the closing stop round and the final / retry turns are never in that array. An accessor over it would silently under-report exactly the rounds the issue is about. Per-round usage is already reachable via the PostModelCall hook or getFullResponsesStream()'s response.completed; a dedicated accessor would want its own per-call ledger and is better as a follow-up.

Testing

Written red-first — all 8 failed with result.getUsage is not a function before implementation, then green. packages/agent/tests/unit/get-usage.test.ts covers the multi-round sum (asserting the same run's getResponse() still reports only 300 of 450 tokens), the streaming path via a fully-consumed getItemsStream() (both tool-loop and no-tools), the no-hooks path, agreement with SessionEnd.totalUsage, usage-less responses, and a failed run.

Build, typecheck, lint, and the full suite (754 tests) all green. Changeset included (minor — new public API).

🤖 Generated with Claude Code

)

`getResponse()` resolves to the final round's response, so in a multi-round
tool loop the tokens spent on the intermediate `tool_calls` generations were
unreachable. `getItemsStream()` compounds this: it carries output items only
and never surfaces the `response.completed` events that hold each round's
usage block, so callers streaming items had no way to account for a run's real
token spend without registering a hook.

`await result.getUsage()` returns the same `SessionUsageTotals` shape as the
`SessionEnd` hook's `totalUsage`, summed over every model call the run made —
initial, each tool-round follow-up, the empty-final retry, the
`allowFinalResponse` final turn, and approval-resume requests. It gates on run
completion like `getResponse()` does, so totals are final whether awaited
directly, after `getResponse()`, or after draining any streaming getter.
Unlike `getResponse()` it never rejects: a failed run still consumed tokens,
and cost accounting typically runs in a `finally` where a second throw would
mask the original error.

Two supporting changes make the aggregate correct for hook-less callers, who
previously saw zeros because accumulation only advanced as a side effect of
`PostModelCall` emission:
- `emitPostModelCall` folds usage in before the `hooksManager` short-circuit;
  only the hook emit stays gated.
- `finishHooksSessionForStream` materializes the parked model-call telemetry
  before its `hooksManager` guard — it is the only site that folds the
  no-tools streaming response into the aggregate.

`SessionEnd.totalUsage` and `getUsage()` now both read from one
`snapshotSessionUsage()` helper, so the two cannot drift.

Also documents on `getItemsStream()` that the stream carries no usage or
response-level metadata, pointing at `getUsage()`, the `PostModelCall` hook,
and `getFullResponsesStream()`'s per-round `response.completed`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
perry-the-pr-reviewer[bot]

This comment was marked as resolved.

Review follow-up on #97: the `isResumingFromApproval` guard in `getUsage()`
was the one code path with no direct coverage.

Adds four cases under a `paused at awaiting_approval` block, using an
approval-gated tool plus an in-memory StateAccessor so a pause can be resumed:

- a run parked at `awaiting_approval` reports only the model call that
  completed before the pause (the requested case)
- repeated reads while paused are idempotent and dispatch no request — a
  further scripted response is queued so an accidental dispatch would surface
  as both a call-count bump and inflated totals
- the aggregate is scoped per ModelResult, not per conversation: a resumed run
  reports only its own generation, so a caller summing across resumes adds
  rather than double-counts
- reading usage as the FIRST call on an approval-resumed run does not advance
  the conversation

That last case is what actually pins the guard. Verified by mutation: with the
guard removed the run settles to 'complete' as a side effect of merely asking
for usage, and the test fails ('complete' vs 'in_progress'). Asserting on
totals alone could not catch it — the resume's generation is dispatched during
initStream either way, so the numbers are identical with and without the
guard; only run status distinguishes them.

Also parameterizes the `toolCallResponse` fixture's function-call name, which
previously hardcoded `echo` and silently routed to `awaiting_client_tools`
instead of `awaiting_approval`.

Test-only change; no source modifications.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@LukasParke
LukasParke marked this pull request as ready for review August 3, 2026 20:55
devin-ai-integration[bot]

This comment was marked as resolved.

@cortex-github-agent

Copy link
Copy Markdown
Contributor

cortex review — 48404c4

Security · 💬 Experience (DX · UX · A11y) (3) · ✅ Performance (1)

Experience (DX · UX · A11y)

🟡 minor · packages/agent/src/lib/model-result.ts:4938-4950
getUsage() swallows the run failure with no diagnostic at all. A caller whose only interaction with the run is await result.getUsage() (a plausible pattern for a cost-accounting sidecar, and exercised by the test "awaits run completion when called without awaiting getResponse() first") gets { modelCalls: 0, totalTokens: 0 } on a totally failed run and has no way — no thrown error, no flag, not even a console.warn — to distinguish "the API was down" from "the run was genuinely free". Every other never-throw path in this file logs its swallowed cause (finishHooksSessionForStreamconsole.warn('[SessionEnd] error during stream teardown:', …), sealDoomLoopStop/doom-loop fallbacks). Suggest a console.warn('[getUsage] run failed; reporting partial totals:', error) in the catch so the 2am debugging session has a thread to pull, rather than an empty catch {}.

🟡 minor · packages/agent/src/lib/model-result.ts:4920-4936
JSDoc promises totals are "final" while the resume path deliberately does not drive the loop. The doc says "Gates on run completion the same way getResponse() does, so totals are final whether you await it directly…", but the implementation skips executeToolsIfNeeded() when isResumingFromApproval (model-result.ts:4949-4952), unlike getResponse(), which always awaits it. The PR's own test ("does not advance an approval-resumed run when read before the loop") shows the resumed run still in_progress after getUsage() resolves — so on an approval resume the returned totals can be mid-run and the caller cannot tell partial from final. Either scope the sentence (e.g. "…except on an approval-resumed run, where reading usage never advances the loop; await getResponse()/getText() first for final totals") or mirror getDoomLoopVerdict()'s narrower wording.

nit · packages/agent/README.md:71-73
The corrected "FINAL round only" caveat landed in the README but not in getResponse()'s JSDoc. The README now says // Await the full response with usage data (the FINAL round only), yet getResponse()'s own doc comment still reads "Get the complete response object including usage information … Returns the full OpenResponsesResult with usage data (inputTokens, outputTokens, cachedTokens, etc.)" — which is the exact misconception issue #10 reported, and the IDE hover is where most developers will read it. Adding one line pointing at getUsage() there (as was done for getItemsStream()) closes the loop for editor users.

Performance

🟡 minor · packages/agent/src/lib/model-result.ts:1963-1975
Hook-less streaming runs now pay an extra full pass over the retained event buffer at teardown. Moving the telemetry materialization above the if (!this.hooksManager) return; guard means every no-tools streaming run (the common, hook-free case) now calls consumeStreamForCompletion(this.reusableStream) in finishHooksSessionForStream, which creates a fresh ReusableReadableStream consumer and walks the whole buffered event array again looking for response.completed (packages/agent/src/lib/stream-transformers.ts:520-546). Each replayed event costs one async next() microtask hop (packages/agent/src/lib/reusable-stream.ts:60-120), so a token-level stream with tens of thousands of delta events adds a second O(events) pass — single-digit-to-tens of ms — inside the finally of getTextStream()/getItemsStream(), i.e. before the caller's iteration settles. Previously this was zero work without hooks. Cheap fix: capture the completion response (or just its usage block) once during the single existing pass — e.g. latch the last response.completed/response.incomplete event on ReusableReadableStream while pumping — instead of replaying the buffer to find it.

Automatic first-pass review · updated in place on every push

cortex-github-agent[bot]

This comment was marked as resolved.

…failure; avoid teardown buffer replay

Review feedback on #97:

- getUsage() on an approval-resumed run whose resume dispatch returned a
  real event stream now consumes the buffered stream (a passive read) so
  the resume generation's tokens are counted — previously only the
  non-streaming resume branch was covered. The tool loop is still never
  advanced by a usage read. (Devin)
- The never-rejects catch in getUsage() now console.warns the swallowed
  cause, matching every other never-throw path in the file. (cortex)
- getUsage() JSDoc no longer claims totals are unconditionally final:
  documents the approval-resume carve-out. getResponse() JSDoc gains the
  final-round-only note that previously existed only in the README. (cortex)
- Hook-less streaming teardown no longer replays the whole retained event
  buffer through an async consumer to find the completion event:
  extractCompletionFromBuffer() scans the buffer backwards
  synchronously. (cortex)
devin-ai-integration[bot]

This comment was marked as resolved.

…he terminal event

Devin's follow-up on #97: item/text consumers stop at the terminal event
(streamTerminationEvents), but ReusableReadableStream.isComplete only
flips when the pump reads the source close — which real network streams
deliver AFTER response.completed. In that window teardown found
isComplete=false and silently dropped the parked PostModelCall (a
pre-existing hole for hook users, and since #97 also a getUsage() gap on
the no-tools streaming path).

finishHooksSessionForStream now falls back to a non-throwing buffered
terminal-event scan (tryExtractCompletionFromBuffer) when the stream is
not yet marked complete, emitting the parked telemetry when the terminal
event is already buffered and staying silent otherwise (errored
mid-flight streams have no materialized response).

Regression test models the late close with a source that never closes:
red without the fix (0 PostModelCall emits), green with it.
devin-ai-integration[bot]

This comment was marked as resolved.

…ge() changeset

Per .agents/skills/public-api-examples: every public-API changeset must
carry a fenced consumer example so the generated CHANGELOG.md is
self-documenting. Also scopes the 'totals are final' sentence with the
approval-resume carve-out to match the JSDoc.
@LukasParke
LukasParke merged commit a629cf1 into main Aug 5, 2026
6 checks passed
@LukasParke
LukasParke deleted the feat/10-get-usage-accessor branch August 5, 2026 15:15
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.

Usage & metadata not available with getItemsStream()

1 participant