Skip to content

Reader: preserve user-turn block sizes to unlock per-tool-call cost attribution #2

@willwashburn

Description

@willwashburn

Problem

packages/reader/src/claude.ts:184-198 (captureSubagentFromToolResult) reads user turns only to index subagent markers, then discards the content. To attribute spend back to individual tool calls we need the size of each tool_result that entered context between assistant turn N and N+1.

Why this unlocks everything else

Anthropic's API reports usage at the message level — never per tool_use. But per-tool-call cost is recoverable as a delta from the session JSONL:

context(N+1) input-side tokens
  = output(N) tokens
  + sum(tool_results added between N and N+1)
  + any user free-text

Every assistant turn already has exact usage.{input, output, cacheRead, cacheCreate5m, cacheCreate1h} (captured at claude.ts:200-211). The missing piece is the size of each block the user turn contributed. Without it we cannot allocate the delta across the tool calls that caused it.

No other usage tracker does this (confirmed against TokenTracker, ccusage) — they all stop at message-level usage. This is burn's unique opening.

Plan

  • Replace captureSubagentFromToolResult with a captureUserTurn that records per-user-turn block info: { toolUseId?, kind: 'tool_result' | 'text', approxTokens, byteLen, isError? }.
  • Token estimate via a cheap heuristic first (bytes/4 works for ASCII-heavy content); upgrade to @dqbd/tiktoken cl100k if accuracy matters. Measure before adding a runtime dep.
  • For object content, JSON.stringify before measuring.
  • Extend TurnRecord with an optional sibling record or a priorUserTurn field on each assistant turn — pick whichever keeps the append-only JSONL ledger clean. Leaning toward a separate UserTurnRecord so assistant turns stay immutable once written.
  • Preserve order. The reader already maintains turn order via order: string[]; user turns need to slot between assistant turns by parentUuid chain.

Acceptance

  • Reader emits ordered per-user-turn block-size info alongside TurnRecord[].
  • On a real session, delta reconciles: context(N+1) - cacheRead(N+1) within ±5% of output(N) + sum(new user-turn block sizes). Add a reconciliation assert as a test helper.
  • Test coverage in packages/reader/src/claude.test.ts with at least one multi-turn session that exercises tool_result of varying sizes (tiny Bash output, large Read result, error results).
  • No change to how existing TurnRecord consumers read data — additive only.

Depends on

Nothing. This is the prerequisite for #2.

Metadata

Metadata

Assignees

No one assigned

    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