Repository navigation
fix: restore run usage when deserializing RunState - #1451
Conversation
🦋 Changeset detectedLatest commit: 15d4c43 The changes in this PR will be included in the next version bump. This PR includes changesets to release 5 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b264e48833
ℹ️ 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".
| } else if (contextStrategy === 'merge') { | ||
| context.usage.add(new Usage(stateJson.context.usage)); |
There was a problem hiding this comment.
Avoid double-counting usage on nested agent resume
When a pending nested Agent.asTool() run is resumed with the default contextStrategy: 'merge', the override context passed here is the live outer run context, whose usage was already restored from the outer serialized state; the nested pending state was serialized from a context that shares that same aggregate usage. Adding the nested snapshot's aggregate usage here therefore counts all pre-interruption requests/tokens a second time, so resumed outer runs report inflated usage after any nested agent-tool interruption.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Good catch — fixed in ae4648f. You're right: nested agent-tool resume calls fromStringWithContext with the live outer context (agent.ts:816, default contextStrategy: 'merge'), and child contexts share the usage aggregate (runContext.ts:148, target.usage = this.usage), so adding the serialized snapshot double-counted the pre-interruption tokens.
I dropped the merge branch entirely — usage is now restored only on the default path (no override). When a context override is provided it is authoritative and keeps its own usage. Updated the test to assert the override context's usage is left untouched (it reproduces the double-count on the previous commit and passes now).
b264e48 to
03c1055
Compare
03c1055 to
ae4648f
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ae4648f38a
ℹ️ 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 (!contextOverride) { | ||
| context.usage = new Usage(stateJson.context.usage); | ||
| } |
There was a problem hiding this comment.
Preserve usage for fresh context overrides
When callers use the documented RunState.fromStringWithContext(..., new RunContext(...)) path to inject a fresh context while resuming a saved top-level HITL run, this branch leaves the override context's empty usage untouched, so the restored RunState reports zero pre-interruption tokens and only counts requests made after resume. The nested-agent double-counting case needs avoiding, but treating every override as already carrying the serialized aggregate makes fresh overrides under-report usage compared with fromString().
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed comprehensively in 38ac3de. Your two rounds are two sides of the same coin: the override context sometimes already carries this run's usage (nested resume shares the aggregate) and sometimes does not (a fresh context injected for a top-level resume). One rule covers the whole space:
- default path (
fromString) → restore the serialized usage - merge override where the override has no usage of its own (fresh context) → restore
- merge override where the override already has usage (the live outer context from a nested agent-tool resume) → leave it untouched, so no double-count
I checked the alternatives: set-always would lose the outer context's newer usage in the nested case, and add-always double-counts — only restore-if-empty-else-leave is correct in every case. Added a third test so all three usage cases are pinned.
ae4648f to
38ac3de
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 38ac3def5a
ℹ️ 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 (!contextOverride) { | ||
| context.usage = new Usage(stateJson.context.usage); | ||
| } else if (contextStrategy === 'merge' && !overrideHasUsage) { | ||
| context.usage = new Usage(stateJson.context.usage); |
There was a problem hiding this comment.
Preserve shared usage when override usage is empty
When a resumed agent tool passes a forked RunContext whose usage is shared with its parent (RunContext._forkWithToolInput / _forkWithoutToolInput), this branch replaces that shared Usage object whenever the aggregate is still zero. In that scenario—for example, a structured agent-tool resume after an HITL interruption where the custom model reported zero usage before the interruption—subsequent nested model usage is added only to the child context and no longer reaches the outer run, so the resumed parent under-reports usage. Mutating the existing Usage instance or skipping zero restores for merge overrides would preserve the sharing contract.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Yeah, that's the real one — replacing the Usage object severs the shared reference. Switched it to add-in-place onto the existing (zero) Usage in 3689c65, so a forked context keeps sharing with its parent and later usage still lands on the outer run. Added a test that forks a context, resumes, then records usage and checks the parent sees it. Thanks for the three rounds on this one.
38ac3de to
3689c65
Compare
seratch
left a comment
There was a problem hiding this comment.
Thanks for the contribution. The underlying RunState.fromString() usage-restoration bug is valid, and the default-path fix is worth merging.
Before we can merge this, please narrow usage restoration to the no-override path. Remove the requests === 0 && totalTokens === 0 heuristic and the expectation that a fresh merge override inherits serialized usage. A supplied RunContext should remain authoritative: zero counters do not establish whether its Usage is newly owned or shared with another run, and nested agent-tool resumes pass a context whose usage accumulator may be shared.
Please keep the default deserialization regression test and tests confirming that override usage and shared references remain untouched. Once that is updated and Codex's PR review says 👍, this should be ready for another review.
RunContext.toJSON serializes the aggregated usage, but buildRunStateFromJson constructs a fresh RunContext and never restores it, so RunState.fromString returns a run whose usage is reset to zero and any resumed run under-reports token usage. Restore it on the default path, and on a merge override only when that context has no usage of its own: a fresh context injected to resume a top-level run should inherit the serialized usage, whereas a nested agent-tool resume passes the live outer context that already shares the aggregate. Add onto the existing Usage in place rather than replacing it, so a forked context that shares its Usage with a parent keeps the shared reference and later usage still reaches the outer run. Adds tests for all four cases.
3689c65 to
15d4c43
Compare
|
Done in the latest push — narrowed usage restoration to the no-override path and removed the if (!contextOverride) {
context.usage = new Usage(stateJson.context.usage);
}That collapses all three Codex rounds into one rule — the override context is authoritative, so there's nothing to double-count, nothing to under-report on our side, and no shared Tests updated to match:
Full |
|
Re-reviewed at 15d4c43. The previous blocker is resolved: usage is now restored only on the no-override path, while every caller-supplied RunContext remains authoritative. The updated tests cover default restoration, existing and fresh overrides, and preservation of the shared Usage object identity. This looks good from the code and ownership-contract perspective. Please update the PR description, which still describes the removed merge-override heuristic, and wait for the latest CI run. Otherwise, LGTM. |
|
Updated the description to match the final no-override-only approach — thanks for the review. Just waiting on |
What
RunContext.toJSON()serializes the aggregated run usage (usage: this.usage), and it ends up in the serialized run state. But on deserialization,buildRunStateFromJsonconstructs a freshRunContext:…and never restores the usage. The
RunContextconstructor setsthis.usage = new Usage()(all zeros), soRunState.fromString(...)returns a run whose usage is reset to zero. Any run resumed from a serialized state then under-reports token usage (billing / observability). Approvals andtoolInputare restored right below — usage was just missing.Fix
Restore
context.usagefrom the serialized state on the no-override path only. A caller-suppliedRunContextis authoritative and owns its usage accounting, so it is left untouched:A supplied context's zero counters do not establish whether its
Usageis newly owned or shared with another run — e.g. a nestedAgent.asTool()resume passes a context whose usage accumulator is shared with the outer run — so restoring into it could either double-count or sever the shared reference. Leaving it untouched is correct in every override case.Usage's constructor faithfully reconstructs the aggregate (includingrequestUsageEntries), so nothing is lost on the default path.Tests
packages/agents-core/test/runState.test.ts:fromStringround-trips a state with usage and asserts requests/tokens and per-request entries are preserved;fromStringWithContextwith a context that already has usage leaves it untouched;RunContextdoes not inherit the serialized usage;Usagewith a parent keeps that object identity (restored.usage === outer.usage), so usage recorded after resume still reaches the outer run.All pass; eslint, tsc, and the full
agents-coresuite pass.