Skip to content

Multi-step tool calls overwrite token counts instead of accumulating (output, reasoning, cache.write lost) #42324

Closed

Description

@gold-beyond

Bug

In multi-step tool-call assistant messages, processor.ts overwrites assistantMessage.tokens on each step-finish event instead of accumulating additive fields. Only the last step's output, reasoning, and cache.write survive.

Root cause

packages/opencode/src/session/processor.ts step-finish handler:

ctx.assistantMessage.cost += usage.cost     // accumulates
ctx.assistantMessage.tokens = usage.tokens  // overwrites!

Impact

For an assistant message with N tool-call steps, the message-level tokens only reflect the last step:

  • Context metrics (session context meter) under-report token usage for multi-step messages
  • compaction.isOverflow checks use lastFinished.tokens (prompt.ts), so underestimating tokens delays auto-compaction and makes the next request more likely to hit the provider context limit

Notes

Expected behavior

Output, reasoning, and cache.write tokens should accumulate across steps (like cost already does). Input and cache.read should keep the final step's values, since the final step's request already includes all prior steps.

Activity

  1. github-actions commented on Aug 13, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

    • ACP PromptResponse.usage reports only the turn's final assistant message #41660: Describes the same root-cause bug — processor.ts assigns (overwrites) assistantMessage.tokens on each step-finish instead of accumulating, so only the last step's tokens survive for multi-step tool-call turns. That issue approaches it from the ACP PromptResponse.usage angle; this one focuses on the session context meter and compaction under-reporting, but both trace to the same cost += vs tokens = asymmetry.
  2. Zaczero commented on Aug 18, 2026

    @Zaczero

    I went looking for this in V2 at 39be1595993de57a5a667c86e21ff788a80ebbbe. It didn't survive the
    port, because the step topology changed rather than the assignment being fixed.

    V2 issues one request per step

    llm.ts doesn't loop steps inside a call. A call returns an outcome, and continuation re-enters with
    an incremented step:

    next = result.needsContinuation ? { step: result.step + 1 } : undefined

    The event publisher is constructed inside that per-call function, at
    llm.ts:284,
    and that is its only call site in the tree. So each step gets its own request, its own publisher, its
    own settlement, and its own assistant message. There is no object that several steps' usage could be
    folded into.

    And the settlement is guarded anyway

    case "step-finish":
    yield* flush()
    if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish"))
    stepSettlement = { finish: event.reason.normalized, tokens: SessionUsage.tokens(event.usage) }
    if (event.reason.normalized === "content-filter") {

    Still an assignment, but a second step-finish on one publisher is a hard Effect.die. Even if the
    topology above changed, this would crash rather than silently under-report.

    The helper this issue asks for already exists

    usage.ts:44-55
    is the accumulating fold, and it's used in the two places where one logical call really does stream
    several step-finish events — SessionCompaction.execute and SessionTitle both do
    usage = usage ? SessionUsage.add(usage, step) : step.

    Worth noting it sums every field including input and cache.read, which is the opposite of what
    this issue proposes for those two. That's correct for compaction and title, where each step is a
    separate prompt, and would be wrong for a multi-step agentic turn where the final request already
    contains the earlier ones. So if the V2 topology ever does change, SessionUsage.add isn't the right
    fold to reach for as-is.

    Corroboration from data

    105,379 assistant messages against 4,309 user ones — a 24:1 ratio consistent with a new assistant
    message per round-trip. Individual messages carry two to seven tool parts (14,349 with one, 1,473 with
    two, 1,129 with three), which is parallel calls inside a single step, not sequential steps.

  3. gold-beyond commented on Sep 13, 2026

    @gold-beyond
    Author

    Thanks for the thorough dig. None of this can happen in V2: each turn gets its own publisher, a second step-finish dies, and SessionUsage.add already handles the spots that do fold steps.

    This issue targets the V1 processor though (packages/opencode/src/session/processor.ts), which is still what ships. The overwrite is still there on current dev:

    ctx.assistantMessage.cost += usage.cost
    ctx.assistantMessage.tokens = usage.tokens

    In V1 the prompt loop reuses one assistant message across steps, so cost += and tokens = drift apart once a turn has multiple step-finishes.

    Also agreed SessionUsage.add isn't the right fold here as-is, it sums input and cache.read too. #42326 only accumulates output/reasoning/cache.write and keeps the final step's input/cache.read instead.

    Can retitle with a (v1) prefix if that makes triage easier.

  4. neriousy commented on Oct 19, 2026

    @neriousy
    Member

    This is resolved in v2 by the new execution model: each model request has its own step, publisher, and assistant message, so later steps do not overwrite earlier steps’ token counts. This does not change the legacy v1 processor discussed above. To get v2, run npm i -g @opencode/cli@latest. If you still see this on v2, please open a new issue with the details.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions