Skip to content

Breaking: keep the caller's run context when resuming a RunState in OpenAI Agents - #240

Open
campoy wants to merge 17 commits into
mainfrom
fix/openai-agents-typescript-resume-replaces-run-context
Open

campoy wants to merge 17 commits into
mainfrom
fix/openai-agents-typescript-resume-replaces-run-context

Conversation

@campoy

@campoy campoy commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

What was changed

When TemporalOpenAIRunner.run resumes a RunState, the resumed run now keeps the state's own context object, as the upstream Runner does. Tools that run after an approval resume write the caller's object (or the one injected with RunState.fromStringWithContext), so later reads and the next turn see their writes.

  • For a RunState input, the runner still rebuilds the state from its string so the state points at the converted agent graph. It now uses RunState.fromStringWithContext(converted, serialized, input._context) instead of RunState.fromString. With the default merge strategy, the approvals in the string are merged into that same context, so approved calls still run.

  • Function-tool approvals are stored per agent object. So a resume doesn't create new converted agent objects every time; it converts into the objects that already own the context's approvals:

    • Each resume still converts the agent graph fresh (Agent.clone, SandboxAgent.clone, Handoff.clone).
    • A converted agent from an earlier conversion of the same source agent is reused when its handoffs, once converted, lead to exactly the same objects as before. The fresh clone's own properties and prototype then replace the reused object's. The check repeats over the graph, because an agent that isn't reused can disqualify the agents that hand off to it.
    • Every field of a reused object is therefore exactly what a fresh conversion gives. Shared references such as tools and modelSettings stay shared with the source agent, SandboxAgent manifests are rebuilt through their serialization protocol, and handoffs, model Activity options, model override and sandbox config reflect the current inputs. The object identity stays the same.
    • A reused object never points at a different agent than before, so a state saved earlier still reaches every agent it reached. A later toString() of that state, for example after it rejects a call, keeps the decisions those agents own.
    • The runner serializes the state before it converts, from the graph the state was saved on. The restore merges those approvals under the converted objects.
    • The conversion builds and checks every agent clone before it changes any existing object. It then gives each reused object its new fields before it clones the handoffs, and connects each handoff target before cloning the handoff that leads to it, as before this change. So a custom Handoff.clone sees the target as a fresh conversion gives it. If a Handoff.clone throws, the reused objects get their earlier fields back. A conversion that throws (for example, on a raw function tool in a handoff target) leaves the earlier graph as it was. If the restore throws after the conversion, the graph keeps its latest objects, because the restore may already have merged the approvals under them, and each reused object gets its earlier fields back. So a state saved on that graph still serializes the agent names it ran with. For example, a resume fails because an agent was renamed, and the name is then put back: the state still saves and resumes under the original name. A retry then converts into those objects again rather than adding another copy.
    • Only agents whose clone is exactly Agent.clone or SandboxAgent.clone are reused; those return plain Agent and SandboxAgent objects. An agent whose class overrides clone gets its fresh clone on every resume, as before this change, even if its clone returns a plain Agent, because that clone can hold state (private fields, or entries keyed by the clone) that can't be moved onto another object. Agents that hand off to such an agent, directly or through other agents, then get new objects too.
    • The graph used is the one the state runs on or, for a state rehydrated onto the original agent, the one the latest resume on the same context used for that agent.
    • A resume never converts into a graph that a run is still using, including a streamed run until its result completes. It converts a new graph instead.
    • A resume also doesn't reuse a graph if restoring the state into it would lose one of the state's decisions or rejection messages. The restore merges the state's decisions into the ones the graph's agents already own. In that merge, an approval for good wins over a rejection, and a rejection message the agent already owns wins over the state's. So a state that was always approved, resumed, and then always rejected would run the tool again. And a state whose rejection message changed after a failed resume would send the model the earlier message. The runner reads the decisions the graph's agents own keyed by the same agent identities the restore uses (name, or name#n for agents that share a name, as agents-core assigns them from the converted root). So each of the state's records is compared with exactly the agent the restore merges it into. For each tool that both have a record for, the runner merges the two records with agents-core's own merge. It then checks every call either record names, and any decision for good: wherever the state decides the call, the merged record must resolve it to the same decision and rejection message. That includes a call that only the agent's record gave a message, which a later rejection for good in the state also covers. Each record's call IDs are indexed once, so the comparison takes time proportional to the number of IDs, even for long saved approval histories. The runner checks the graph again after converting into it. If the check then fails, it gives the reused objects their earlier fields back and converts a new graph. If a check fails, the resume converts a new graph, which owns only the state's decisions, and later resumes of that state reuse it.

    The merge then adds no new owners while the graph's structure stays the same, and nothing is removed from the context. Without this, every resume added a full copy of the approvals under a new graph: 100 approval resumes kept 101 owners and 5,150 call IDs. Earlier copies can't be dropped, because saved states that still point at those agents read them. Examples: a retry of the same state after a tool error, or another state rehydrated onto the same context and agents.

  • There is no version gate: every RunState resume takes the new path.

API change: none. No option or signature changes.

Breaking change (replay): two things Workflow code can observe change after a RunState resume:

  • Resumed tools now write the caller's context object.
  • Resumes on the same context reuse the same converted agent objects, where earlier versions created new ones on every resume. The exceptions are agents whose class overrides clone, the agents that hand off to them, agents whose handoff targets changed since the last conversion, and resumes of a state whose decisions or rejection messages the earlier graph's own would override. Callbacks and hooks that receive the agent (for example instructions, isEnabled, agent hooks) and result.lastAgent see the same object across resumes.

Workflow code whose commands depend on either one can take other commands than before. That includes code that never reads the context, such as code that collects or compares agent objects. A run that resumed a RunState on an earlier version and then took such a step fails replay on this version with [TMPRL1100]. Open runs fail their Workflow Tasks, and finished runs fail in the Replayer. Only runs that never resume a RunState are certainly unaffected. Rolling back has the same problem in reverse: a run that resumed on this version is not expected to replay on an earlier version if its commands depend on the context or agent identity.

The commit message states the breaking change.

Upgrade: let runs that resumed a RunState finish on the earlier version before you upgrade, or keep them there with Worker Versioning.

Why?

See #239. The runner rebuilt a resumed state with RunState.fromString, which builds a new RunContext from the context's JSON. The resumed run used that copy, so an approved tool's write never reached the caller's object, and the next turn started with that object didn't see it. Context members JSON can't carry, such as functions or Maps, were also missing in the resumed run. Nothing failed or warned. Passing { context: ctx } again with the state didn't help, because the inner Runner ignores context for a RunState input.

Checklist

  1. Closes [Bug] openai-agents (TypeScript): after an approval resume, tools write a copy of the run context, not the caller's object #239

  2. How was this tested:

    • Integration test Approval resume keeps the caller context object in src/__tests__/test-openai-agents.ts. Its Workflow (approvalResumeContextWorkflow) uses tools that need approval and write the run context:
      • It goes through three approve-and-resume rounds with distinct call IDs. The first round also tries a resume with an agent the state doesn't match, which must throw. It then reads the caller's object and counts the agents that own function approvals in the final context.
      • Three runs pause on one shared RunContext: A from the runner, B rehydrated onto A's context with the original agent, and C rehydrated onto A's context and A's converted agent (lastAgent). All three are approved, then resumed in order. Each tool must run with no new interruption.
      • An approved tool throws once (errorFunction: null), and the same state is resumed again. It must run without a new approval.
      • State D's resume waits in an async tool isEnabled check, then throws. Meanwhile state E, on the same context, resumes, interrupts on its next call and has that call approved. E must then finish without a new interruption.
      • Three approval rounds, each saving the state as a string and rehydrating it onto the original agent (which has a handoff) and the caller's context, keep 2 approval owners and 6 call IDs.
      • A handoff's target and input filter change between an interruption and its resume: the resume must reach the new target through the new filter. The handoff is then disabled before another resume, and calling it must fail.
      • An agent's tools array and modelSettings are replaced with equal copies before a resume. The approved tool then pushes a new tool onto agent.tools and sets agent.modelSettings.temperature to 0.9. The resumed run must call the new tool, and an instructions callback must see temperature 0.9 on the converted agent at the next turns.
      • A resume fails because a handoff target has a raw function tool. After the tool is removed, the original state is saved as a string and rehydrated into a new context. The resume must run the approved call without a new interruption.
      • An Agent subclass whose clone passes on a private #version field changes it between an interruption and the resume. The instructions callback and lastAgent must see the new value.
      • A plain root hands off to a target whose class overrides clone, and the run pauses on the target's call. The resume gives the target a new object. The approved call must run without a new interruption, and the original state saved afterwards must still have one approval owner.
      • A handoff target is replaced by another Agent with the same name before the resume. The approved call must run without a new interruption.
      • The handoff to the paused target is removed, so the restore throws after the conversion. After the handoff is put back, the original state is saved and rehydrated, and its resume must run the approved call without a new interruption.
      • An Agent subclass whose clone returns super.clone() and records a version for the clone in a WeakMap changes that version between an interruption and the resume. The instructions callback and lastAgent must see the new value.
      • A handoff target whose class overrides clone, and one replaced by a same-name Agent, each pause on a call that throws once after approval. After the failed resume, the call is rejected with alwaysReject through the original state, and the retry of that state must not run it. A third target, also replaced by a same-name Agent, goes through the same steps with the call approved with alwaysApprove. So does a saved state rehydrated onto the original agent (RunState.fromString), which has no handoff. Neither may run the call again.
      • A saved state is rehydrated onto the original agent, approved, and its restore fails three times in an async tool isEnabled check before it succeeds. The context must keep 2 approval owners (the source agent and one converted agent) and 2 call IDs throughout.
      • A call is rejected with the message 'before', and a callModelInputFilter makes the next model call fail once after the resume. The call is then rejected again through the same state with 'after', and the state is retried. The model must receive 'after', and the tool must not run. This is run for a state rehydrated onto the original agent (RunState.fromString) with a per-call rejection, the same with alwaysReject, and the run's own state with a per-call rejection.
      • Calls A and B pause together. A is approved and B rejected with 'before', and the next model call fails once after the resume. A is then rejected with alwaysReject and 'after', which also covers B, and the state is retried. The model must receive 'after' for both calls with no new interruption. This is run for a state rehydrated onto the original agent (RunState.fromString) and for the run's own state.
      • A root and its handoff target are both named Same. The root's call is approved with alwaysApprove, and the target's next three calls are each rejected and resumed. The context must keep 2 approval owners when the run's own state is resumed, and 4 (two source and two converted agents) when each round rehydrates the state onto the original agent and the run's context.
      • A saved state gets 50,000 earlier approved call IDs and is rehydrated onto the original agent. Its approved tool throws once after the resume, and the retry of that state compares the long history with the one the reused graph owns. The retry must finish, run the call without a new interruption, and leave 100,002 approved call IDs (the source agent and one converted agent).
      • An agent named Original pauses and its call is approved. It is renamed Changed, so the resume's restore fails, and then renamed back. The state saved afterwards must name Original as its current agent. Its copy rehydrated with RunState.fromString and a retry of the state itself must each run the approved call without a new interruption.
      • A Handoff subclass's clone captures the target's instructions in its input filter. The target's instructions change from 'before' to 'after' between the root's interruption and the resume. The filter and the target must both see 'after'. Then the root is renamed and the handoff's clone throws, so the resume fails in the conversion. After the name is put back, the state saved afterwards must name HandoffCloneRoot, and its rehydrated copy must run the approved call without a new interruption.
      • toolCallResponse and multiToolCallResponse in the test fakes take optional call IDs for this.
    • With this change it passes: { callerAfter: 'S-after', resumedUsesCallerObject: true, writes: ['S-1', 'S-2', 'S-after'], approvalRounds: 3, failedResumeThrew: true, approvalOwners: 1, approvedCallIds: 3, sharedContextWrites: ['A', 'B', 'C'], sharedContextInterruptions: 0, retryThrew: true, retryAttempts: 2, retryWrites: ['R'], retryInterruptions: 0, concurrentFailedResumeThrew: true, concurrentWrites: ['E1', 'E2'], concurrentInterruptions: 0, rehydratedWrites: ['R-1', 'R-2', 'R-3'], rehydratedApprovalOwners: 2, rehydratedApprovedCallIds: 6, handoffTarget: 'SecondTarget', handoffFilters: ['second'], disabledHandoffThrew: true, sharedFieldsWrites: ['X'], sharedFieldsTemperatures: [0.1, 0.9, 0.9], failedConversionThrew: true, failedConversionWrites: ['F'], failedConversionInterruptions: 0, versions: ['before', 'after'], lastAgentVersion: 'after', customChildWrites: ['C-1'], customChildInterruptions: 0, customChildSavedApprovalOwners: 1, replacedChildWrites: ['C-2'], replacedChildInterruptions: 0, failedRestoreThrew: true, failedRestoreWrites: ['D-1'], failedRestoreInterruptions: 0, registeredVersions: ['before', 'after'], lastAgentRegisteredVersion: 'after', rejectedResumesThrew: [true, true, true, true], rejectedExecutions: ['J-1', 'J-2', 'J-3', 'J-4'], rejectedInterruptions: 0, failedRestores: 3, failedRestoreApprovalOwners: 2, failedRestoreApprovedCallIds: 2, recoveredWrites: ['G'], recoveredApprovalOwners: 2, recoveredApprovedCallIds: 2, rejectionMessages: ['VetoPersisted before', 'VetoPersisted after', 'VetoPersistedAlways before', 'VetoPersistedAlways after', 'VetoDirect before', 'VetoDirect after'], revisedRejectionWrites: [], revisedRejectionInterruptions: 0, batchMessages: ['call_batch_persisted_a: ok', 'call_batch_persisted_b: BatchPersisted before', 'call_batch_persisted_a: BatchPersisted after', 'call_batch_persisted_b: BatchPersisted after', 'call_batch_direct_a: ok', 'call_batch_direct_b: BatchDirect before', 'call_batch_direct_a: BatchDirect after', 'call_batch_direct_b: BatchDirect after'], batchInterruptions: 0, sameNameWrites: ['N-0', 'M-0'], sameNameApprovalOwners: [2, 4], longHistoryThrew: true, longHistoryWrites: ['H'], longHistoryInterruptions: 0, longHistoryApprovedCallIds: 100002, renamedResumeThrew: true, renamedSavedAgent: 'Original', renamedRehydratedWrites: ['O'], renamedRetryWrites: ['O'], renamedInterruptions: 0, handoffCloneInstructions: ['after'], handoffCloneTargetInstructions: 'after', handoffCloneThrew: true, handoffCloneSavedAgent: 'HandoffCloneRoot', handoffCloneWrites: ['P-2'], handoffCloneInterruptions: 0 }.
    • Integration test a SandboxAgent manifest or runAs change before the resume reaches the sandbox in src/__tests__/test-openai-sandbox.ts (Workflow sandboxHandoffConfigResumeWorkflow): a root agent pauses for approval before its first handoff to a SandboxAgent. Before the resume, one of three things changes: an entry in target.defaultManifest.environment is replaced in place, an entry holding an environment reference that keeps its value in a private field (PrivateEnvValue, exposed only through serialize()) is replaced with another, or target.runAs.name changes. The sandbox must be created with VERSION='after' or with user after. It passes with this change.
    • Controls:
      • The previous revision's convert-agent.ts cloned the handoffs before it gave the reused targets their new fields. Run with this revision's test: handoffCloneInstructions: ['before'], while the target ran with 'after'.
      • This revision's convert-agent.ts without giving the earlier fields back when a Handoff.clone throws: the Workflow failed with Agent HandoffCloneRenamed not found when it rehydrated the saved state, and the test failed with Workflow execution timed out.
      • In the revision before that, runner.ts and convert-agent.ts left a reused object with the fields of a resume whose restore then failed. Run with its successor's test, the Workflow failed with Agent Changed not found when it rehydrated the state saved after the name was put back, and the test failed with Workflow execution timed out.
      • The revision before that looked up each call ID with a linear search of both records' lists. Run with its successor's test, the long-history retry failed its Workflow Task repeatedly with Script execution timed out after 5000ms, and the test failed with Workflow execution timed out.
      • The revision before that, which matched a state's records to every converted agent whose name was a prefix of the record's identity, and checked only the call IDs the state's record names, run with its successor's test: 'call_batch_persisted_b: BatchPersisted before' on the retry (the direct batch case already passed), and sameNameApprovalOwners: [8, 9].
      • The revision before that, which checked only for an approval for good meeting a rejection for good, run with its successor's test: rejectionMessages: ['VetoPersisted before', 'VetoPersisted before', 'VetoPersistedAlways before', 'VetoPersistedAlways before', 'VetoDirect before', 'VetoDirect after']. Only the rehydrated states lost the revised message.
      • The revision before that, whose runner.ts reused a graph even when one of its agents always approved a tool the state always rejected, run with its successor's test: rejectedExecutions: ['J-1', 'J-2', 'J-3', 'J-3', 'J-4', 'J-4'].
      • An earlier revision, whose runner.ts and convert-agent.ts rewired reused objects to new handoff targets and undid the conversion's agent map when the restore threw, run with that revision's test: rejectedExecutions: ['J-1', 'J-1', 'J-2', 'J-2'], failedRestoreApprovalOwners: 4, failedRestoreApprovedCallIds: 4, recoveredApprovalOwners: 5, recoveredApprovedCallIds: 5.
      • That revision without the check that a reused object keeps its handoff targets: the rehydrated state of the removed-handoff case interrupts again, and the Workflow then fails at the next case's approval.
      • The revision before that serialized the state after the conversion: the custom-clone target's resume interrupted again. Without the source agent's clone check: registeredVersions: ['before', 'before'], lastAgentRegisteredVersion: 'before'.
      • An earlier revision changed each existing agent as soon as it converted it, and copied fields onto agents of any class. Run on their own (throwaway variants of the Workflow), its cases gave:
        • Failed conversion: the rehydrated state has 0 approval owners and interrupts again (writes: [], interruptions: 1). With this change: writes: ['F'], interruptions: 0.
        • Private field: versions: ['before', 'before'], lastAgentVersion: 'before'. With this change: ['before', 'after'] and 'after'.
      • An earlier revision, in runner.ts and convert-agent.ts, reused a graph when a snapshot of the source fields compared equal. It failed that revision's new cases:
        • Tool extra not found in agent SharedFieldsAgent.
        • A throwaway variant with the new tool present from the start sees temperatures [0.1, 0.1, 0.1].
        • The sandbox is created with VERSION='before' after the private reference is replaced.
      • The revision before that, which compared SandboxAgent manifests and users by reference, failed the manifest and runAs cases (VERSION='before', users ['before']).
      • An earlier revision reused only the graph the state itself ran on, and kept Handoff objects by reference in its snapshot. It failed the rehydrated and handoff cases: rehydratedApprovalOwners: 4, rehydratedApprovedCallIds: 9, handoffTarget: 'FirstTarget', handoffFilters: ['first', 'second'] and disabledHandoffThrew: false.
      • A revision that dropped the approvals of the state's previous converted graph and restored a snapshot on failure failed the shared-context, retry and concurrent cases (sharedContextInterruptions: 1, retryAttempts: 1, retryInterruptions: 1, concurrentInterruptions: 1).
      • An earlier revision, which cleared every approval owner, failed the A/B case with sharedContextWrites: ['A'], sharedContextInterruptions: 1.
      • The first revision, without any cleanup or reuse, failed with approvalOwners: 4, approvedCallIds: 9.
      • An earlier, single-round version of the test failed on main with { resumedUsesCallerObject: false }.
    • Locally, in Docker (Node 24, pnpm 10.27.0, Linux arm64), on this revision:
      • make sync, make lint (8 existing warnings, none in files this PR touches) and make build pass.
      • make test: all 209 tests pass, including the sandbox test. The focused Approval resume keeps the caller context object test also passed on its own.
      • Prettier 3 with the SDK settings reports the changed files clean.
      • node ../_shared/smoke.mjs, scripts/ci/check_npm_package.py and scripts/ci/check_conventions.py passed on earlier revisions and were not rerun.
      • The 100-round probes were run on an earlier revision, with 1 owner and 100 call IDs directly, and 2 owners and 200 call IDs with rehydration. They were not rerun on this revision; the committed 3-round assertions cover the owner counts.
    • [Bug] openai-agents (TypeScript): after an approval resume, tools write a copy of the run context, not the caller's object #239's reproduction exits 1 on npm 1.24.0 and on main at c664a34. It exited 0 with an earlier version of this change, where both resume forms (the same RunState, and a state rebuilt with an injected context) keep the object as the upstream Runner does. That earlier version had a version gate and created a new graph on every resume. The reproduction was not rerun since.
    • Replay check, run on an earlier gated version with a separate harness, and not rerun since:
      • Setup: the Workers had no Workflow cache, and the model Activity ran on its own queue so waiting runs survive Worker changes. The Workflow sleeps only when the write reached the object it reads. The histories came from main for both resume forms: finished, held in the model call after the resume, and waiting for an approval Signal.
      • The change without its gate fails main's 2 finished histories with [TMPRL1100] Nondeterminism error: Timer machine does not handle this event: HistoryEvent(id: 17, WorkflowExecutionCompleted): the Workflow now sleeps where the old run completed.
      • main's 3 unfinished histories (2 held in the model call after the resume, 1 waiting for approval) replay up to where they stopped. How they continue without the gate was not run.
      • A control that renames the model Activity in a history fails with [TMPRL1100] Nondeterminism error: Activity type of scheduled event 'invokeRenamedActivity' does not match activity type of activity command 'invokeModelActivity'.
      • A review run of a Workflow that never reads the context, but schedules a timer depending on how many agent objects its instructions callback saw, failed cross-version replay in both directions with [TMPRL1100]. That's why the breaking-change note above covers agent identity. It was not run with this revision.

    CI covers the other Node versions and platforms. Not run against a real model provider.

  3. Any docs updates needed?
    No. The README already says the runner accepts a RunState "matching the upstream runner". Release notes should say two things: a resumed run now keeps the state's context object and reuses its converted agent objects, and runs that resumed on an earlier version may not replay on this one (see Upgrade above).

Not covered:

  • Streaming resumes (stream: true with a RunState) take the same code path but were not run with several approval rounds or with a partly replaced graph.
  • A context holding functions, Maps or class instances was not run; with the state's own context those members are kept, as with the upstream Runner.
  • Converting into an existing graph updates the fields of agent objects that other paused states on the same context may point at. Those states then see the current agent configuration, which differs from earlier versions, where each resume had its own graph. They keep reaching the same agents.
  • Only a failed Handoff.clone or restore gives the reused objects their earlier fields back. A resume whose restore succeeds and whose run then fails leaves them with the configuration that run used.
  • A resume while another run is using the graph converts a new graph, and the earlier graph's approvals stay on the context, so each such overlap keeps one more copy. A streamed run whose result is never consumed keeps its graph in use.
  • An agent whose class overrides clone gets a new converted object on every resume, as in earlier versions, and so do the agents that hand off to it, directly or through other agents. Their approvals gain one copy per resume, and their callbacks see a new object each time. This is a deliberate trade-off: dropping the older copies would lose approvals that older, already-resumed states need when saved with toString(), and pointing the older objects at the new clone would hide it from those states.
  • A change to an agent's handoff targets gives that agent, and the agents that hand off to it, new objects on the next resume. Their approvals gain one copy for each such change.
  • Each time the graph a resume would reuse would override one of the state's decisions or rejection messages, the resume converts a new graph, so the approvals gain one copy for each such change.
  • If the check only fails after the conversion (when the conversion changes which of several same-name agents gets which identity), the reused graph's objects get their earlier fields back before the resume converts a new graph. That path was not run.
  • The same check applies when the conflicting decision on the reused agent came from another state on the same context, for example an alwaysApprove from one state and a per-call rejection from another. That cross-state case follows from the check but was not run.
  • A state rehydrated onto the original agent converts one new graph on its first resume, and reuses it after that. The run's own first graph isn't recorded for its context.
  • Histories recorded with this change, and their replay on an earlier version, were not checked.
  • Worker heap at high resume counts was not measured; the test counts the retained approval owners and call IDs instead.

Related: the other open drafts for this plugin (#209, #210, #215, #216, #217, #218, #236) add tests at the end of src/__tests__/test-openai-agents.ts and src/__tests__/workflows/openai-agents.ts, and #236 also edits src/workflow/runner.ts. Whichever merges later may need a small rebase there.

Framework internals:

  • The runner reads input._context and input._currentAgent, the underscore-named RunState fields that are public in its type, and we accept them under the ~0.14.3 pin.
  • To read the decisions the agents of a graph it would reuse already own, the runner serializes a scratch RunState that starts at the converted root, on the state's context (public constructor and toJSON). agents-core 0.14.3 keys those decisions by the agent identities it builds from that root (name, or name#n for agents that share a name). Its restore resolves the state's identities against the same ones, since it builds them from the same converted root.
  • To predict what the restore would make of those decisions, the runner merges each pair of records with the internal RunContext._mergeFunctionApprovals on a scratch RunContext and reads the result back with the internal RunContext._toJSONForRunState, so it uses agents-core's own merge. It then resolves decisions and rejection messages for a single record as agents-core 0.14.3 does (approval for good, rejection for good, per-call approval, per-call rejection; the call's message, else the sticky message of a rejection for good).
  • The test uses the internal RunContext._toJSONForRunState to count approval owners.
  • Converting into an existing agent object copies the own properties of a fresh clone, and a failed restore puts the earlier ones back. That relies on agents-core 0.14.3's Agent.clone and SandboxAgent.clone returning exactly those classes, which keep their state in own properties (no # private fields). Agents with any other clone aren't reused.
  • Serializing before the conversion relies on agents-core 0.14.3's RunState.toJSON collecting approval owners from the agents reachable from the state's starting agent through handoffs and agent tools.

@campoy campoy changed the title openai-agents: keep the caller's run context when resuming a RunState Breaking: keep the caller's run context when resuming a RunState in OpenAI Agents Oct 29, 2026
campoy added 17 commits November 1, 2026 19:13
TemporalOpenAIRunner rebuilt a resumed RunState with RunState.fromString,
which gives the resumed run a JSON copy of its context. Tools that ran
after an approval resume wrote that copy, so the caller's context object
(or one injected with RunState.fromStringWithContext) and the next turn
missed their writes. The upstream Runner uses the state's own context.

Rebuild the state with RunState.fromStringWithContext and the state's own
RunContext. Runs that resumed before this change keep the JSON copy on
replay, behind patched('openai-agents.keep-resume-context').
Fixes add no version gates for now, because the 1.x plugin releases may
break backward compatibility. Remove the
patched('openai-agents.keep-resume-context') check, so every RunState
resume keeps the state's own context. Remove the replay test, its
History and the test Workflow's sleep, which only tested the gate.

Breaking: a run that resumed a RunState on an earlier version and then
took a step that depends on the context object a resumed tool writes
fails replay with [TMPRL1100]. Let such runs finish before upgrading, or
use Worker Versioning.
@campoy
campoy force-pushed the fix/openai-agents-typescript-resume-replaces-run-context branch from bf9d0dd to c3b3229 Compare November 2, 2026 04:08
@campoy
campoy marked this pull request as ready for review November 2, 2026 04:21
@campoy
campoy requested a review from a team as a code owner November 2, 2026 04:21

@DABH DABH left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review at head c3b3229 (17 commits on 88745dc; it merges cleanly with main at 61fe33a). On a worktree at the head with pnpm 10.27.0, make lint passes (8 pre-existing warnings, none in the changed files) and make test passes 253 of 253. I checked the change with probes against the installed @openai/agents-core 0.14.3, temporary Workflows on a local dev server at the head and at main, histories recorded on main and replayed with Worker.runReplayHistory on the head (and the reverse), and trial merges with #270 and #210. Every finding below was reproduced. Refuted and dropped: false negatives in losesStateDecisions (50,000 fuzzed record pairs, checked against agents-core's own merge and resolution, gave no disagreement in either direction, and alias keys don't apply at run time); growth when the same agent object is passed (100 rounds keep 1 owner and 100 call IDs on the run's own state, and 2 and 200 when each round rehydrates, as the body says); cost (the two checks add about 4 ms for a 4 MB state and about 70 ms at 50,000 call IDs); new raw-error paths outside normalizeAgentsRunError; activeRuns accounting under Promise.all resumes, a resume inside a tool, cancellation and streamed resumes (each releases exactly once, and no graph is changed while a run uses it); hazards of replacing own properties in place; and regressions in non-resume conversions (14 graph shapes convert to the same graphs as main; only the order of clone side effects and which of two errors wins changed). Not verified: streamed resumes over several approval rounds, heap bytes (owner and call-ID counts only), and how an open main run continues on a live upgraded Worker (truncated histories replay up to where they stop).

Severity: P1 = wrong result or stuck run in realistic use; P2 = correctness or robustness in an edge case; P3 = docs, tests, cleanup.

Replay. The disclosed break reproduces at this head with the body's exact message. When an approved tool writes the context and the Workflow then sleeps, main's history fails with [TMPRL1100] Nondeterminism error: Timer machine does not handle this event: HistoryEvent(id: 17, WorkflowExecutionCompleted) for both resume forms, and the rollback direction fails too. The agent-identity case fails as described. The README continue-as-new recipe and a plain same-run approve/resume loop replay in both directions. What the body doesn't describe is the random-stream shift in the first inline comment.

Where the problems cluster: the context fix itself is the fromStringWithContext call at runner.ts:510, and it works: resumed tools write the caller's object. The problems are in the reuse machinery around it, which is nearly all of the 417 added source lines. Its decision check draws from the user's random stream. It bounds approval growth only when the same source agent object is passed on every resume. And it ties the plugin to agents-core internals while #270 is removing the conversion step it works around. The docs haven't caught up either: README line 526 now says the opposite of what happens, and the upgrade guidance exists only in the PR body.

Requesting changes for the random-stream draw: it breaks replay for Workflows that the breaking-change note implies are unaffected, the fix is one verified line, and it has to land before the first release, because changing the draw count later is itself a replay break.

Design, and #270. The restore change is what #239 asks for. The rest exists so the kept context doesn't collect a copy of the state's function approvals under each resume's new agent objects, whose call IDs grow with the square of the resumes on one context (101 owners and 5,150 call IDs after 100, by the body's numbers). That growth is heap only: toJSON serializes only the owners reachable from the state's starting agent, and isToolApproved reads only the calling agent's records. A fresh graph per resume owns exactly the state's decisions, so it can't lose one. The lost-decision problem losesStateDecisions solves exists only because reuse merges a state into a graph that already owns older records. To avoid the growth, the PR reimplements agents-core's approval merge and resolution order on internal APIs (_mergeFunctionApprovals, _toJSONForRunState), relies on Agent and SandboxAgent keeping all their state in own properties, and adds agent identity across resumes as a second replay-visible change. It still grows for agents built per resume (second inline comment). #270, under review, deletes convertAgent and resumes non-sandbox states as they are, which keeps the caller's context with none of this. I'd settle the order with #270 first. If this one lands first, consider the one-line fix with the context assertions, plus a README note that each resume on a context keeps a copy of the state's approvals until the Workflow continues as new. The README's own approval recipe already continues as new after each pause.

Related PRs. #270 conflicts with this head in six files. It deletes convert-agent.ts, which this PR rewrites, and replaces the RunState branch of runner.ts with prepareRunState, which resumes non-sandbox states as they are and so already keeps their context. Its sandbox branch still rebuilds with RunState.fromString(agent, state.toString()), which drops the caller's context, so #239 stays open there. Whichever lands second has to be reworked, not just rebased. After #270, this PR reduces to that sandbox branch plus the context tests. After this PR, #270 has to delete the reuse code and the owner-count assertions, and removing losesStateDecisions would shift the random stream again unless the first inline comment is fixed here. #210 conflicts only in the import block of src/__tests__/workflows/openai-agents.ts, where the merged list is the union of both. Neither PR depends on this one. But #210 adds a README sentence about toolMetaResolver after a resume ('then sees the context rebuilt from JSON: plain objects, without class methods', line 542 at its head), and this PR makes it false, as it does line 526 here. Whichever lands second should fix both.

Cross-language. The Python runner passes the RunState to the upstream runner as is, with no JSON round trip (_openai_runner.py:405-409 on main). This PR brings TypeScript in line on the context.

Findings on lines outside the diff

  • typescript/openai-agents/README.md:526: P3: 'A callable filter also runs when a run resumes from a RunState ... and then sees the context rebuilt from JSON: plain objects, without class methods' is now false for a resume of result.state. The restore runs on input._context (runner.ts:510), so the filter sees the caller's object. A probe filter that records runContext.context.session during the resume sees plain object {"id":"7"} on main and the caller's Session instance on this head. The existing test a tool filter that throws while resuming from a RunState fails the run passes on both only because it checks the error type: the cause is runContext.context.session.isAdmin is not a function on main and session expired on the head. Suggested: 'and then sees the state's own context: the object the run started with, or the one RunState.fromString rebuilt from JSON'. The test could also assert the session expired message.
  • typescript/openai-agents/README.md:105: P3: the README doesn't mention the behaviour change or the replay break. Release notes are built from commit subjects only (scripts/release/release_tool.py:456, --format=%h%x1f%s), so the PR body's Breaking change and Upgrade sections reach no user; only the title does. This README already documents the plugin's other replay breaks (lines 528 and 530). Add two or three sentences under 'Run state and approvals': a resumed run now uses the state's own context, as upstream does, so tools write the object the run started with and callbacks see it; a run that resumed a RunState on an earlier version may fail replay with TMPRL1100; let such runs finish before upgrading, or use Worker Versioning.

PR body. 'make test: all 209 tests pass' doesn't match this revision, which runs 253 locally and in all four CI cells; 209 comes from an older revision. The breaking-change section and 'Framework internals' don't mention the random-stream draws (the scratch state is described only as 'public constructor and toJSON'). 'A streamed run whose result is never consumed keeps its graph in use' didn't hold in a probe: after a resume with stream: true that was never iterated, completed had resolved within a 3 s sleep and the next resume reused the graph. 'If a check fails, the resume converts a new graph ... and later resumes of that state reuse it' doesn't hold on the post-conversion path (second inline comment). The 100-round counts the body says were not rerun do reproduce at this head.

if (stateApprovals.length === 0) return false;
// Serializing a state that starts at `root` keys the decisions the graph's agents own by the same agent
// identities (`name`, or `name#n` for agents that share a name) the restore resolves the state's against.
const owned: FunctionApprovals[] = new RunState(context, '', root, null).toJSON().context.functionApprovals ?? [];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P2: this scratch RunState draws from the Workflow's main random stream, so each resume moves later uuid4() and Math.random() values forward (two draws for a plain runner.run(agent, result.state), up to three), and main-recorded runs that never read the context or look at an agent object fail replay on this head. The agents-core 0.14.3 constructor calls randomUUID() (runState.js:686), which in the Workflow bundle is the plugin's crypto.randomUUID = () => uuid4() (load-polyfills.ts:53-54). This function runs once per candidate in reusableConversion and again after the conversion (484-487). Workflows recorded on main 61fe33a and replayed on this head: approve, runner.run(agent, result.state), then executeChild(child, { workflowId: `zz-child-${uuid4()}` }) fails with [TMPRL1100] Nondeterminism error: Child workflow id of scheduled event 'zz-child-5309f986-...' does not match child workflow id of command 'zz-child-a4521868-...'. So does a child started with no workflowId, because the SDK then calls uuid4() (@temporalio/workflow lib/workflow.js:341). That includes a child started inside the approved tool, with nothing in the Workflow after the resume. An Activity ID built from uuid4() fails with Activity id of scheduled event ... does not match. A 3-round same-run loop followed by a child fails the same way, and every case also fails in the reverse direction. A history that reports uuid4() values shows that the head's first value after the resume equals main's third. Controls: drawing before the resume, runs with no RunState, and the README continue-as-new recipe (no candidate graph, so no scratch state) replay in both directions. With only this expression moved off the main stream, every failing history above replays. The breaking-change note says two things change and that commands differ for 'Workflow code whose commands depend on either one'. A Workflow whose approved tool starts a child Workflow depends on neither and still breaks. The draw count is now tied to the reuse heuristic, so any later change to it (or #270 deleting it) breaks these Workflows again. That is why this has to be fixed before the first release rather than after. Fix, verified to make main's histories above replay and to leave this PR's test result unchanged: const owned = getRandomStream('package-openai-agents-resume').with(() => new RunState(context, '', root, null).toJSON().context.functionApprovals ?? []);. The plugin already keeps its tracing entropy off the user's stream with getRandomStream (trace-interceptor.ts:55). Then add a replay test that records a resume followed by a default-ID child Workflow, so the draw count stays pinned.

];
return candidates.find(
(conversion) =>
conversion?.source === agent &&

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P2 (non-blocking): reuse needs the caller to pass the same source agent object on every resume, so a Workflow that builds its agent per turn gets a new graph and a full copy of the approvals on every resume, growing quadratically in the caller's context, where main added none. conversion?.source === agent here and the resumeConversions key (104) compare object identity. Probe: 20 approval rounds on one context, once passing the same Agent and once passing a fresh makeAgent() (same name and config) to each runner.run(agent, result.state). With the same object the head keeps 1 owner and 20 approved call IDs; with rebuilt agents it keeps 21 owners and 230 call IDs. Main keeps 1 and 1 in both cases, because each resume ran on a JSON copy. The growth is Workflow heap only, since toString() serializes only reachable owners. But it is the case the reuse machinery exists to prevent, and building agents in a per-turn helper (for per-turn instructions or tools) is common. The 'Not covered' list names a custom clone, handoff changes, overlapping runs and conflicting decisions, but not this. Separately, when the post-conversion check fails (484-490), the next resume of that state picks this candidate again, fails again and converts yet another graph. In a same-name probe that flips the identity order, owners went 2, 4, 6 over a resume and one retry, so 'later resumes of that state reuse it' doesn't hold on that path; the run's result matches main. Fix: at least say in the README that reuse needs the same agent object, and list this case and its growth. Better, fall back to the state's own conversion when the new source graph converts to the same identities, and try the next candidate before converting a fresh graph when the second check fails.

/**
* For each `RunContext`, the graph its latest resume used for each source agent. A state rehydrated onto
* the source agent doesn't point at a converted agent, so this is how its resume finds the graph that
* already owns the context's approvals. It's keyed by context so Workflows that share module-level agents

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P3 (comment): module state isn't shared between Workflow executions under reuseV8Context, so 'Workflows that share module-level agents' can't happen. The reusable VM routes __webpack_module_cache__ to the activator's own moduleCache (@temporalio/worker lib/workflow/reusable-vm.js:137-150). A module-level counter in a probe Workflow returned 1 for each of two executions on one Worker. Keying by context is still right, because separate conversations in one Workflow must not share a graph. Only the stated reason needs rewording.

});

const result = await worker.runUntil(
executeWorkflow(approvalResumeContextWorkflow, { workflowExecutionTimeout: '30 seconds' })

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P3 (tests): this one Workflow runs about 40 sub-cases against one FIFO of 89 fake responses with a 30 s execution timeout. On main it stops at the third sub-case and never reaches t.deepEqual, a regression in one sub-case shows up as a misleading error in a later one, and the time budget is tight on CI. With this PR's test files copied onto main, the Workflow fails with Agent workflow failed: Failed to run function tools: Error: flaky failed after 11 of its 89 responses. With an early return after sub-case 2, main gives callerAfter: undefined, resumedUsesCallerObject: false, writes: [] and sharedContextWrites: []. Those are the fields that prove the #239 fix; the other fields match main or can't be reached there, so they guard the new reuse code. With only the retry sub-case guarded, main then fails with Tool set_after not found in agent SharedFieldsAgent., because the shared FIFO is out of step, not because that sub-case regressed. A plain Error thrown in the Workflow body retries the task until the timeout, and the test then reports only Workflow execution timed out; the body's own controls show this several times. In CI at this head the test took 10.9 s (ubuntu, Node 24) to 19.8 s (macOS). One Workflow Task retry costs over 10 s (backoff plus a ~3 s replay of ~480 events), so a single hiccup on macOS overruns 30 s. The 50,000-ID activation itself is fine: 150 to 320 ms against the 5 s isolate limit. Fix: split it into a few tests, each with its own Workflow and FakeModelProvider (the context assertions, shared contexts, retries and rejections, graph reuse), return per-sub-case results so a failure names its sub-case, and raise the execution timeout. The sandbox test also passes on main, which is fine for a regression guard.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] openai-agents (TypeScript): after an approval resume, tools write a copy of the run context, not the caller's object

2 participants