Repository navigation
Conversation
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.
bf9d0dd to
c3b3229
Compare
DABH
left a comment
There was a problem hiding this comment.
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 aRunState... and then sees the context rebuilt from JSON: plain objects, without class methods' is now false for a resume ofresult.state. The restore runs oninput._context(runner.ts:510), so the filter sees the caller's object. A probe filter that recordsrunContext.context.sessionduring the resume seesplain object {"id":"7"}on main and the caller'sSessioninstance on this head. The existing testa tool filter that throws while resuming from a RunState fails the runpasses on both only because it checks the error type: the cause isrunContext.context.session.isAdmin is not a functionon main andsession expiredon the head. Suggested: 'and then sees the state's own context: the object the run started with, or the oneRunState.fromStringrebuilt from JSON'. The test could also assert thesession expiredmessage.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 aRunStateon an earlier version may fail replay withTMPRL1100; 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 ?? []; |
There was a problem hiding this comment.
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 && |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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' }) |
There was a problem hiding this comment.
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.
What was changed
When
TemporalOpenAIRunner.runresumes aRunState, the resumed run now keeps the state's own context object, as the upstreamRunnerdoes. Tools that run after an approval resume write the caller's object (or the one injected withRunState.fromStringWithContext), so later reads and the next turn see their writes.For a
RunStateinput, the runner still rebuilds the state from its string so the state points at the converted agent graph. It now usesRunState.fromStringWithContext(converted, serialized, input._context)instead ofRunState.fromString. With the defaultmergestrategy, 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:
Agent.clone,SandboxAgent.clone,Handoff.clone).toolsandmodelSettingsstay shared with the source agent,SandboxAgentmanifests 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.toString()of that state, for example after it rejects a call, keeps the decisions those agents own.Handoff.clonesees the target as a fresh conversion gives it. If aHandoff.clonethrows, 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.cloneis exactlyAgent.cloneorSandboxAgent.cloneare reused; those return plainAgentandSandboxAgentobjects. An agent whose class overridesclonegets its fresh clone on every resume, as before this change, even if itsclonereturns a plainAgent, 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.name, orname#nfor 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
RunStateresume takes the new path.API change: none. No option or signature changes.
Breaking change (replay): two things Workflow code can observe change after a
RunStateresume: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 exampleinstructions,isEnabled, agent hooks) andresult.lastAgentsee 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
RunStateon 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 aRunStateare 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
RunStatefinish 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 newRunContextfrom 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 orMaps, were also missing in the resumed run. Nothing failed or warned. Passing{ context: ctx }again with the state didn't help, because the innerRunnerignorescontextfor aRunStateinput.Checklist
Closes [Bug] openai-agents (TypeScript): after an approval resume, tools write a copy of the run context, not the caller's object #239
How was this tested:
Approval resume keeps the caller context objectinsrc/__tests__/test-openai-agents.ts. Its Workflow (approvalResumeContextWorkflow) uses tools that need approval and write the run context: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.errorFunction: null), and the same state is resumed again. It must run without a new approval.isEnabledcheck, 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.toolsarray andmodelSettingsare replaced with equal copies before a resume. The approved tool then pushes a new tool ontoagent.toolsand setsagent.modelSettings.temperatureto 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.Agentsubclass whoseclonepasses on a private#versionfield changes it between an interruption and the resume. The instructions callback andlastAgentmust see the new value.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.Agentwith the same name before the resume. The approved call must run without a new interruption.Agentsubclass whoseclonereturnssuper.clone()and records a version for the clone in aWeakMapchanges that version between an interruption and the resume. The instructions callback andlastAgentmust see the new value.clone, and one replaced by a same-nameAgent, each pause on a call that throws once after approval. After the failed resume, the call is rejected withalwaysRejectthrough the original state, and the retry of that state must not run it. A third target, also replaced by a same-nameAgent, goes through the same steps with the call approved withalwaysApprove. So does a saved state rehydrated onto the original agent (RunState.fromString), which has no handoff. Neither may run the call again.isEnabledcheck before it succeeds. The context must keep 2 approval owners (the source agent and one converted agent) and 2 call IDs throughout.callModelInputFiltermakes 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 withalwaysReject, and the run's own state with a per-call rejection.alwaysRejectand '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.Same. The root's call is approved withalwaysApprove, 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.Originalpauses and its call is approved. It is renamedChanged, so the resume's restore fails, and then renamed back. The state saved afterwards must nameOriginalas its current agent. Its copy rehydrated withRunState.fromStringand a retry of the state itself must each run the approved call without a new interruption.Handoffsubclass'sclonecaptures 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'sclonethrows, so the resume fails in the conversion. After the name is put back, the state saved afterwards must nameHandoffCloneRoot, and its rehydrated copy must run the approved call without a new interruption.toolCallResponseandmultiToolCallResponsein the test fakes take optional call IDs for this.{ 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 }.a SandboxAgent manifest or runAs change before the resume reaches the sandboxinsrc/__tests__/test-openai-sandbox.ts(WorkflowsandboxHandoffConfigResumeWorkflow): a root agent pauses for approval before its first handoff to aSandboxAgent. Before the resume, one of three things changes: an entry intarget.defaultManifest.environmentis replaced in place, an entry holding an environment reference that keeps its value in a private field (PrivateEnvValue, exposed only throughserialize()) is replaced with another, ortarget.runAs.namechanges. The sandbox must be created withVERSION='after'or with userafter. It passes with this change.convert-agent.tscloned 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'.convert-agent.tswithout giving the earlier fields back when aHandoff.clonethrows: the Workflow failed withAgent HandoffCloneRenamed not foundwhen it rehydrated the saved state, and the test failed withWorkflow execution timed out.runner.tsandconvert-agent.tsleft a reused object with the fields of a resume whose restore then failed. Run with its successor's test, the Workflow failed withAgent Changed not foundwhen it rehydrated the state saved after the name was put back, and the test failed withWorkflow execution timed out.Script execution timed out after 5000ms, and the test failed withWorkflow execution timed out.'call_batch_persisted_b: BatchPersisted before'on the retry (the direct batch case already passed), andsameNameApprovalOwners: [8, 9].rejectionMessages: ['VetoPersisted before', 'VetoPersisted before', 'VetoPersistedAlways before', 'VetoPersistedAlways before', 'VetoDirect before', 'VetoDirect after']. Only the rehydrated states lost the revised message.runner.tsreused 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'].runner.tsandconvert-agent.tsrewired 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.clonecheck:registeredVersions: ['before', 'before'], lastAgentRegisteredVersion: 'before'.writes: [], interruptions: 1). With this change:writes: ['F'], interruptions: 0.versions: ['before', 'before'], lastAgentVersion: 'before'. With this change:['before', 'after']and'after'.runner.tsandconvert-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.[0.1, 0.1, 0.1].VERSION='before'after the private reference is replaced.SandboxAgentmanifests and users by reference, failed the manifest andrunAscases (VERSION='before', users['before']).Handoffobjects by reference in its snapshot. It failed the rehydrated and handoff cases:rehydratedApprovalOwners: 4, rehydratedApprovedCallIds: 9,handoffTarget: 'FirstTarget',handoffFilters: ['first', 'second']anddisabledHandoffThrew: false.sharedContextInterruptions: 1,retryAttempts: 1, retryInterruptions: 1,concurrentInterruptions: 1).sharedContextWrites: ['A'], sharedContextInterruptions: 1.approvalOwners: 4, approvedCallIds: 9.mainwith{ resumedUsesCallerObject: false }.make sync,make lint(8 existing warnings, none in files this PR touches) andmake buildpass.make test: all 209 tests pass, including the sandbox test. The focusedApproval resume keeps the caller context objecttest also passed on its own.node ../_shared/smoke.mjs,scripts/ci/check_npm_package.pyandscripts/ci/check_conventions.pypassed on earlier revisions and were not rerun.mainatc664a34. It exited 0 with an earlier version of this change, where both resume forms (the sameRunState, and a state rebuilt with an injected context) keep the object as the upstreamRunnerdoes. That earlier version had a version gate and created a new graph on every resume. The reproduction was not rerun since.mainfor both resume forms: finished, held in the model call after the resume, and waiting for an approval Signal.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.[TMPRL1100] Nondeterminism error: Activity type of scheduled event 'invokeRenamedActivity' does not match activity type of activity command 'invokeModelActivity'.[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.
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:
stream: truewith aRunState) take the same code path but were not run with several approval rounds or with a partly replaced graph.Maps or class instances was not run; with the state's own context those members are kept, as with the upstreamRunner.Handoff.cloneor 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.clonegets 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 withtoString(), and pointing the older objects at the new clone would hide it from those states.alwaysApprovefrom one state and a per-call rejection from another. That cross-state case follows from the check but was not run.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.tsandsrc/__tests__/workflows/openai-agents.ts, and #236 also editssrc/workflow/runner.ts. Whichever merges later may need a small rebase there.Framework internals:
input._contextandinput._currentAgent, the underscore-namedRunStatefields that are public in its type, and we accept them under the~0.14.3pin.RunStatethat starts at the converted root, on the state's context (public constructor andtoJSON). agents-core 0.14.3 keys those decisions by the agent identities it builds from that root (name, orname#nfor 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.RunContext._mergeFunctionApprovalson a scratchRunContextand reads the result back with the internalRunContext._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).RunContext._toJSONForRunStateto count approval owners.Agent.cloneandSandboxAgent.clonereturning exactly those classes, which keep their state in own properties (no#private fields). Agents with any otherclonearen't reused.RunState.toJSONcollecting approval owners from the agents reachable from the state's starting agent through handoffs and agent tools.