Summary
toNormalizedUsage() in src/agents/pi-embedded-runner/run.ts (line ~192) intentionally returns only the last API call's input, cacheRead, and cacheWrite tokens instead of the accumulated values from the UsageAccumulator. While output tokens are correctly accumulated, input/cache tokens are taken from lastInput/lastCacheRead/lastCacheWrite. This causes session transcripts and cost tracking to miss ~80% of actual billed tokens in tool-heavy workflows.
This was introduced to fix the context-size display issue (#13698), but it inadvertently broke cost/accounting accuracy.
Impact
On a real deployment (572 sessions, ~7,900 assistant messages, ~7,300 tool calls over March 2026):
| Metric |
Anthropic Console (actual) |
Transcript (logged) |
Captured |
| Input tokens |
3,106,387,899 |
413,926,649 |
13% |
| Output tokens |
12,523,367 |
1,858,613 |
15% |
| Cost |
$2,148.66 |
$430.25 |
20% |
Verified independently by Claude Code and OpenAI Codex analyzing the source code — both confirmed no alternative persistence path exists for the full accumulated usage.
Root Cause
In run.ts:
const toNormalizedUsage = (usage: UsageAccumulator) => {
const lastPromptTokens = usage.lastInput + usage.lastCacheRead + usage.lastCacheWrite;
return {
input: usage.lastInput || undefined, // ❌ last call only
output: usage.output || undefined, // ✅ accumulated
cacheRead: usage.lastCacheRead || undefined, // ❌ last call only
cacheWrite: usage.lastCacheWrite || undefined, // ❌ last call only
total: lastPromptTokens + usage.output || undefined,
};
};
The UsageAccumulator correctly accumulates all values via mergeUsageIntoAccumulator() (including input, cacheRead, cacheWrite), but toNormalizedUsage() discards the accumulated prompt-side fields. For a turn with N tool-call round-trips, this loses (N-1)/N of input and cache tokens.
Downstream consumers all read this lossy object:
- Session store (
session-store.ts:47) → inputTokens, cacheRead, cacheWrite, estimatedCostUsd
- Transcript JSONL entries →
usage block per assistant message
- Gateway transcript fallback (
session-utils.fs.ts)
- Diagnostics emitter (
agent-runner.ts:585-619)
- Cost calculation (
session-cost-usage.ts)
No alternative persistence path captures the full accumulated totals.
Steps to Reproduce
- Configure OpenClaw with Anthropic provider (prompt caching enabled by default)
- Start a session with tool-using agent (e.g.,
exec tool)
- Send a message that triggers multiple tool calls in one turn:
Run these 3 commands: echo "hello", echo "world", echo "done"
- Check the transcript JSONL for the assistant message's
usage.cacheRead
- Compare with Anthropic Console usage for the same time window
Expected: usage.cacheRead reflects sum of all API calls in the turn (≈ context_size × number_of_calls)
Actual: usage.cacheRead reflects only the last API call
Suggested Fix
The types already have the separation needed (EmbeddedPiAgentMeta.lastCallUsage exists for context-size display). The fix is:
- Make
toNormalizedUsage() return accumulated values for all fields (restore usage.input, usage.cacheRead, usage.cacheWrite from the accumulator)
- Continue using
lastCallUsage / promptTokens for context-size display (already wired correctly via deriveSessionTotalTokens)
- Cost/accounting consumers already use
agentMeta.usage — they will automatically get correct values
This separates the two conflated concepts:
- Context-size display: needs last call's prompt tokens →
lastCallUsage / promptTokens
- Cost tracking: needs accumulated billed tokens →
agentMeta.usage
Related Issues
Environment
- OpenClaw 2026.3.23-2
- Anthropic provider with prompt caching
- Heavy tool-use workflows (avg ~1 tool call per assistant message)
Summary
toNormalizedUsage()insrc/agents/pi-embedded-runner/run.ts(line ~192) intentionally returns only the last API call'sinput,cacheRead, andcacheWritetokens instead of the accumulated values from theUsageAccumulator. Whileoutputtokens are correctly accumulated, input/cache tokens are taken fromlastInput/lastCacheRead/lastCacheWrite. This causes session transcripts and cost tracking to miss ~80% of actual billed tokens in tool-heavy workflows.This was introduced to fix the context-size display issue (#13698), but it inadvertently broke cost/accounting accuracy.
Impact
On a real deployment (572 sessions, ~7,900 assistant messages, ~7,300 tool calls over March 2026):
Verified independently by Claude Code and OpenAI Codex analyzing the source code — both confirmed no alternative persistence path exists for the full accumulated usage.
Root Cause
In
run.ts:The
UsageAccumulatorcorrectly accumulates all values viamergeUsageIntoAccumulator()(includinginput,cacheRead,cacheWrite), buttoNormalizedUsage()discards the accumulated prompt-side fields. For a turn with N tool-call round-trips, this loses (N-1)/N of input and cache tokens.Downstream consumers all read this lossy object:
session-store.ts:47) →inputTokens,cacheRead,cacheWrite,estimatedCostUsdusageblock per assistant messagesession-utils.fs.ts)agent-runner.ts:585-619)session-cost-usage.ts)No alternative persistence path captures the full accumulated totals.
Steps to Reproduce
exectool)usage.cacheReadExpected:
usage.cacheReadreflects sum of all API calls in the turn (≈context_size × number_of_calls)Actual:
usage.cacheReadreflects only the last API callSuggested Fix
The types already have the separation needed (
EmbeddedPiAgentMeta.lastCallUsageexists for context-size display). The fix is:toNormalizedUsage()return accumulated values for all fields (restoreusage.input,usage.cacheRead,usage.cacheWritefrom the accumulator)lastCallUsage/promptTokensfor context-size display (already wired correctly viaderiveSessionTotalTokens)agentMeta.usage— they will automatically get correct valuesThis separates the two conflated concepts:
lastCallUsage/promptTokensagentMeta.usageRelated Issues
lastCacheRead(the fix that caused this regression)Environment