fix(agents): prevent totalTokens crash when assistant usage is missing#34977
Conversation
Greptile SummaryThis PR adds a defensive normalization pass (
Confidence Score: 4/5
Last reviewed commit: e5a18e5 |
| if ( | ||
| message.usage && | ||
| typeof message.usage === "object" && | ||
| (message.usage as { input?: unknown }).input === normalizedUsage.input && | ||
| (message.usage as { output?: unknown }).output === normalizedUsage.output && | ||
| (message.usage as { cacheRead?: unknown }).cacheRead === normalizedUsage.cacheRead && | ||
| (message.usage as { cacheWrite?: unknown }).cacheWrite === normalizedUsage.cacheWrite && | ||
| (message.usage as { totalTokens?: unknown }).totalTokens === normalizedUsage.totalTokens | ||
| ) { |
There was a problem hiding this comment.
Equality check omits the cost sub-object
The fast-path equality check compares the five numeric fields but never checks whether the cost sub-object is present. AssistantUsageSnapshot requires cost: { input, output, cacheRead, cacheWrite, total } (see usage.ts makeZeroUsageSnapshot). A message that arrives with all five numeric fields already correct but with cost absent (or undefined) will satisfy every condition here and be skipped without normalization — leaving cost absent downstream.
If any consumer later does usage.cost.total (or similar) on such a message it will crash with the same class of TypeError this PR is fixing for totalTokens.
Consider adding a cost presence guard to the skip condition:
| if ( | |
| message.usage && | |
| typeof message.usage === "object" && | |
| (message.usage as { input?: unknown }).input === normalizedUsage.input && | |
| (message.usage as { output?: unknown }).output === normalizedUsage.output && | |
| (message.usage as { cacheRead?: unknown }).cacheRead === normalizedUsage.cacheRead && | |
| (message.usage as { cacheWrite?: unknown }).cacheWrite === normalizedUsage.cacheWrite && | |
| (message.usage as { totalTokens?: unknown }).totalTokens === normalizedUsage.totalTokens | |
| ) { | |
| if ( | |
| message.usage && | |
| typeof message.usage === "object" && | |
| typeof (message.usage as { cost?: unknown }).cost === "object" && | |
| (message.usage as { input?: unknown }).input === normalizedUsage.input && | |
| (message.usage as { output?: unknown }).output === normalizedUsage.output && | |
| (message.usage as { cacheRead?: unknown }).cacheRead === normalizedUsage.cacheRead && | |
| (message.usage as { cacheWrite?: unknown }).cacheWrite === normalizedUsage.cacheWrite && | |
| (message.usage as { totalTokens?: unknown }).totalTokens === normalizedUsage.totalTokens | |
| ) { |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/pi-embedded-runner/google.ts
Line: 236-244
Comment:
**Equality check omits the `cost` sub-object**
The fast-path equality check compares the five numeric fields but never checks whether the `cost` sub-object is present. `AssistantUsageSnapshot` requires `cost: { input, output, cacheRead, cacheWrite, total }` (see `usage.ts` `makeZeroUsageSnapshot`). A message that arrives with all five numeric fields already correct but with `cost` absent (or `undefined`) will satisfy every condition here and be skipped without normalization — leaving `cost` absent downstream.
If any consumer later does `usage.cost.total` (or similar) on such a message it will crash with the same class of `TypeError` this PR is fixing for `totalTokens`.
Consider adding a `cost` presence guard to the skip condition:
```suggestion
if (
message.usage &&
typeof message.usage === "object" &&
typeof (message.usage as { cost?: unknown }).cost === "object" &&
(message.usage as { input?: unknown }).input === normalizedUsage.input &&
(message.usage as { output?: unknown }).output === normalizedUsage.output &&
(message.usage as { cacheRead?: unknown }).cacheRead === normalizedUsage.cacheRead &&
(message.usage as { cacheWrite?: unknown }).cacheWrite === normalizedUsage.cacheWrite &&
(message.usage as { totalTokens?: unknown }).totalTokens === normalizedUsage.totalTokens
) {
```
How can I resolve this? If you propose a fix, please make it concise.| function normalizeAssistantUsageSnapshot(usage: unknown) { | ||
| const normalized = normalizeUsage((usage ?? undefined) as UsageLike | undefined); | ||
| if (!normalized) { | ||
| return makeZeroUsageSnapshot(); | ||
| } | ||
| const input = normalized.input ?? 0; | ||
| const output = normalized.output ?? 0; | ||
| const cacheRead = normalized.cacheRead ?? 0; | ||
| const cacheWrite = normalized.cacheWrite ?? 0; | ||
| const totalTokens = normalized.total ?? input + output + cacheRead + cacheWrite; | ||
| return { | ||
| ...makeZeroUsageSnapshot(), | ||
| input, | ||
| output, | ||
| cacheRead, | ||
| cacheWrite, | ||
| totalTokens, | ||
| }; | ||
| } |
There was a problem hiding this comment.
Normalization silently zeroes the cost sub-object
When a message has a partial-but-non-empty usage (e.g. { output: 3, cache_read_input_tokens: 9 }), the function calls normalizeUsage to recover the numeric fields and then spreads makeZeroUsageSnapshot() as the base. Because makeZeroUsageSnapshot() always sets cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, any real cost information that was already present on the usage object is silently discarded.
For the missing-usage crash-fix path this is harmless (there was no cost to preserve). For the partial-usage normalization path it could mask genuine cost data from providers that do supply cost but omit totalTokens. If preserving cost is important, consider extracting it before building the normalized snapshot:
function normalizeAssistantUsageSnapshot(usage: unknown) {
const normalized = normalizeUsage((usage ?? undefined) as UsageLike | undefined);
if (!normalized) {
return makeZeroUsageSnapshot();
}
const input = normalized.input ?? 0;
const output = normalized.output ?? 0;
const cacheRead = normalized.cacheRead ?? 0;
const cacheWrite = normalized.cacheWrite ?? 0;
const totalTokens = normalized.total ?? input + output + cacheRead + cacheWrite;
const base = makeZeroUsageSnapshot();
// Preserve existing cost if already present and well-formed
const existingCost =
usage && typeof usage === "object" && typeof (usage as { cost?: unknown }).cost === "object"
? (usage as { cost: typeof base.cost }).cost
: base.cost;
return { ...base, cost: existingCost, input, output, cacheRead, cacheWrite, totalTokens };
}Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/pi-embedded-runner/google.ts
Line: 203-221
Comment:
**Normalization silently zeroes the `cost` sub-object**
When a message has a partial-but-non-empty usage (e.g. `{ output: 3, cache_read_input_tokens: 9 }`), the function calls `normalizeUsage` to recover the numeric fields and then spreads `makeZeroUsageSnapshot()` as the base. Because `makeZeroUsageSnapshot()` always sets `cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }`, any real cost information that was already present on the usage object is silently discarded.
For the missing-usage crash-fix path this is harmless (there was no cost to preserve). For the partial-usage normalization path it could mask genuine cost data from providers that do supply cost but omit `totalTokens`. If preserving cost is important, consider extracting it before building the normalized snapshot:
```ts
function normalizeAssistantUsageSnapshot(usage: unknown) {
const normalized = normalizeUsage((usage ?? undefined) as UsageLike | undefined);
if (!normalized) {
return makeZeroUsageSnapshot();
}
const input = normalized.input ?? 0;
const output = normalized.output ?? 0;
const cacheRead = normalized.cacheRead ?? 0;
const cacheWrite = normalized.cacheWrite ?? 0;
const totalTokens = normalized.total ?? input + output + cacheRead + cacheWrite;
const base = makeZeroUsageSnapshot();
// Preserve existing cost if already present and well-formed
const existingCost =
usage && typeof usage === "object" && typeof (usage as { cost?: unknown }).cost === "object"
? (usage as { cost: typeof base.cost }).cost
: base.cost;
return { ...base, cost: existingCost, input, output, cacheRead, cacheWrite, totalTokens };
}
```
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e5a18e5cf8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| ...makeZeroUsageSnapshot(), | ||
| input, | ||
| output, | ||
| cacheRead, | ||
| cacheWrite, | ||
| totalTokens, |
There was a problem hiding this comment.
Preserve usage cost when normalizing assistant snapshots
When a message needs normalization (for example legacy usage with prompt_tokens/completion_tokens, or missing zero-valued cache fields), this rebuilds the snapshot from makeZeroUsageSnapshot() and overwrites any existing usage.cost with zeros. Because sanitizeSessionHistory output is fed back into session state before runs, historical cost data can be silently lost and downstream reports that sum msg.usage.cost will under-report spend for affected sessions.
Useful? React with 👍 / 👎.
|
Addressed the non-spam review feedback in follow-up commit Changes made:
Validation run:
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 75301450e3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (!rawCost || typeof rawCost !== "object") { | ||
| return base; |
There was a problem hiding this comment.
Preserve unknown cost when normalizing assistant usage
When an assistant message has token counts but no usage.cost (common in legacy/provider-limited transcripts), this path returns a zeroed cost object and ensureAssistantUsageSnapshots persists it back into session history. That changes semantics from “cost unknown” to “cost is exactly zero,” so downstream aggregation in src/infra/session-cost-usage.ts (which treats finite cost.total as authoritative) will stop counting these turns as missing-cost entries and under-report spend after compaction. Keep cost undefined when the source usage has no cost block instead of synthesizing zeros.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: eaea29ea90
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (!normalized) { | ||
| return makeZeroUsageSnapshot(); | ||
| } |
There was a problem hiding this comment.
Preserve cost when usage has no token counters
If an assistant message carries a valid usage.cost block but no token fields, normalizeAssistantUsageSnapshot returns makeZeroUsageSnapshot() immediately and never calls normalizeAssistantUsageCost, so non-zero historical cost is overwritten with zeros during sanitization. This regresses billing/report accuracy for legacy/provider-limited transcripts that only persisted cost metadata. Fresh evidence in this commit: the new early return at normalizeAssistantUsageSnapshot bypasses all cost-preservation logic when normalizeUsage(...) is falsy.
Useful? React with 👍 / 👎.
eaea29e to
8522537
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8522537e19
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (!normalized) { | ||
| return makeZeroUsageSnapshot(); | ||
| } |
There was a problem hiding this comment.
Preserve cost-only snapshots during usage normalization
If an assistant message carries only usage.cost (with no token counters), normalizeUsage(...) returns undefined and this branch replaces the message with makeZeroUsageSnapshot(), wiping the original non-zero cost in ensureAssistantUsageSnapshots. Since sanitizeSessionHistory feeds this normalized transcript back into the active session, downstream reporting (for example extractCostBreakdown in src/infra/session-cost-usage.ts) will read cost.total = 0 instead of the recorded value and under-report spend for legacy/provider-limited turns.
Useful? React with 👍 / 👎.
8522537 to
bb100cf
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bb100cf538
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (!normalized) { | ||
| return makeZeroUsageSnapshot(); | ||
| } |
There was a problem hiding this comment.
Preserve cost-only assistant snapshots during normalization
If an assistant message carries usage.cost but no token counters (common in legacy/provider-limited transcripts), normalizeUsage(...) returns undefined and this branch replaces the message with makeZeroUsageSnapshot(), which sets cost.total to 0. ensureAssistantUsageSnapshots then persists that normalized value back into session history, so downstream cost aggregation reads an authoritative zero instead of the recorded spend and under-reports usage costs for those turns.
Useful? React with 👍 / 👎.
bb100cf to
1c14094
Compare
|
Merged via squash. Thanks @sp-hk2ldn! |
openclaw#34977) Merged via squash. Prepared head SHA: 1c14094 Co-authored-by: sp-hk2ldn <[email protected]> Co-authored-by: jalehman <[email protected]> Reviewed-by: @jalehman
openclaw#34977) Merged via squash. Prepared head SHA: 1c14094 Co-authored-by: sp-hk2ldn <[email protected]> Co-authored-by: jalehman <[email protected]> Reviewed-by: @jalehman
openclaw#34977) Merged via squash. Prepared head SHA: 1c14094 Co-authored-by: sp-hk2ldn <[email protected]> Co-authored-by: jalehman <[email protected]> Reviewed-by: @jalehman
openclaw#34977) Merged via squash. Prepared head SHA: 1c14094 Co-authored-by: sp-hk2ldn <[email protected]> Co-authored-by: jalehman <[email protected]> Reviewed-by: @jalehman
openclaw#34977) Merged via squash. Prepared head SHA: 1c14094 Co-authored-by: sp-hk2ldn <[email protected]> Co-authored-by: jalehman <[email protected]> Reviewed-by: @jalehman
openclaw#34977) Merged via squash. Prepared head SHA: 1c14094 Co-authored-by: sp-hk2ldn <[email protected]> Co-authored-by: jalehman <[email protected]> Reviewed-by: @jalehman
Summary
Describe the problem and fix in 2–5 bullets:
openclaw agent --jsoncan crash in compaction/token accounting withTypeError: Cannot read properties of undefined (reading 'totalTokens')when assistant messages have missing/partialusage.Change Type (select all)
Scope (select all touched areas)
Linked Issue/PR
User-visible / Behavior Changes
openclaw agent --jsonno longer crashes when assistant usage is missing in the compaction/token-accounting path; execution continues and JSON output shape remains intact.Security Impact (required)
No)No)No)No)No)Yes, explain risk + mitigation:Repro + Verification
Environment
openclaw agent --json)Steps
pnpm test src/agents/pi-embedded-runner.sanitize-session-history.test.ts src/agents/usage.test.tspnpm test src/commands/agent.test.ts -t "prints JSON payload when requested"pnpm openclaw agent --agent sherbert --message "ping" --jsonExpected
Actual
totalTokensTypeError; run in this environment stopped earlier due local legacy config validation.Evidence
Attach at least one:
Human Verification (required)
What you personally verified (not just CI), and how:
usageis normalized to zero snapshot and does not crash path.totalTokens.Compatibility / Migration
Yes)No)No)Failure Recovery (if this breaks)
e5a18e5cf.src/agents/pi-embedded-runner/google.tssrc/agents/pi-embedded-runner.sanitize-session-history.test.tsRisks and Mitigations
List only real risks for this PR. Add/remove entries as needed. If none, write
None.