Skip to content

fix: restore run usage when deserializing RunState - #1451

Merged
seratch merged 1 commit into
openai:mainfrom
winklemad:fix/restore-runstate-usage
Jul 10, 2026
Merged

seratch merged 1 commit into
openai:mainfrom
winklemad:fix/restore-runstate-usage

Conversation

@winklemad

@winklemad winklemad commented Jul 9, 2026 •

Copy link
Copy Markdown
Contributor

What

RunContext.toJSON() serializes the aggregated run usage (usage: this.usage), and it ends up in the serialized run state. But on deserialization, buildRunStateFromJson constructs a fresh RunContext:

const context =
  contextOverride ??
  new RunContext<TContext>(stateJson.context.context as TContext);

…and never restores the usage. The RunContext constructor sets this.usage = new Usage() (all zeros), so RunState.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 and toolInput are restored right below — usage was just missing.

Fix

Restore context.usage from the serialized state on the no-override path only. A caller-supplied RunContext is authoritative and owns its usage accounting, so it is left untouched:

if (!contextOverride) {
  context.usage = new Usage(stateJson.context.usage);
}

A supplied context's zero counters do not establish whether its Usage is newly owned or shared with another run — e.g. a nested Agent.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 (including requestUsageEntries), so nothing is lost on the default path.

Tests

packages/agents-core/test/runState.test.ts:

  • default deserialization — fromString round-trips a state with usage and asserts requests/tokens and per-request entries are preserved;
  • override with existing usage is not double-counted — fromStringWithContext with a context that already has usage leaves it untouched;
  • fresh override is left untouched — a fresh RunContext does not inherit the serialized usage;
  • shared reference preserved — a forked context that shares its Usage with 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-core suite pass.

@changeset-bot

changeset-bot Bot commented Jul 9, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 15d4c43

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 5 packages
Name Type
@openai/agents-core Patch
@openai/agents-extensions Patch
@openai/agents-openai Patch
@openai/agents-realtime Patch
@openai/agents Patch

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread packages/agents-core/src/runState.ts Outdated
Comment on lines +1530 to +1531
} else if (contextStrategy === 'merge') {
context.usage.add(new Usage(stateJson.context.usage));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

@winklemad
winklemad force-pushed the fix/restore-runstate-usage branch from b264e48 to 03c1055 Compare July 9, 2026 09:28
@winklemad
winklemad force-pushed the fix/restore-runstate-usage branch from 03c1055 to ae4648f Compare July 9, 2026 09:32

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +1531 to +1533
if (!contextOverride) {
context.usage = new Usage(stateJson.context.usage);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@winklemad
winklemad force-pushed the fix/restore-runstate-usage branch from ae4648f to 38ac3de Compare July 9, 2026 09:43

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread packages/agents-core/src/runState.ts Outdated
if (!contextOverride) {
context.usage = new Usage(stateJson.context.usage);
} else if (contextStrategy === 'merge' && !overrideHasUsage) {
context.usage = new Usage(stateJson.context.usage);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@winklemad
winklemad force-pushed the fix/restore-runstate-usage branch from 38ac3de to 3689c65 Compare July 9, 2026 10:04

@seratch seratch left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@winklemad
winklemad force-pushed the fix/restore-runstate-usage branch from 3689c65 to 15d4c43 Compare July 10, 2026 22:36
@github-actions github-actions Bot added documentation Improvements or additions to documentation project labels Jul 10, 2026
@winklemad

Copy link
Copy Markdown
Contributor Author

Done in the latest push — narrowed usage restoration to the no-override path and removed the requests === 0 && totalTokens === 0 heuristic entirely. A supplied RunContext is now always left untouched:

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 Usage reference to sever.

Tests updated to match:

  • kept the default-path deserialization regression (fromString restores the aggregate + per-request entries);
  • kept the "override with existing usage isn't double-counted" test;
  • flipped the fresh-override test to assert the override is left untouched (it no longer inherits the serialized usage);
  • the forked/shared-context test now also asserts the Usage object identity is preserved (restored.usage === outer.usage), so later usage still reaches the outer run.

Full agents-core suite, eslint and tsc pass. Thanks for the clear steer.

@seratch

seratch commented Jul 10, 2026

Copy link
Copy Markdown
Member

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.

@seratch seratch added this to the 0.13.x milestone Jul 10, 2026
@winklemad

Copy link
Copy Markdown
Contributor Author

Updated the description to match the final no-override-only approach — thanks for the review. Just waiting on CI / test (22) now.

@seratch
seratch merged commit 240b6eb into openai:main Jul 10, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation package:agents-core project

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants