Skip to content

fix(openai-agents): hand off to the agent a custom Handoff returns - #238

Closed
campoy wants to merge 2 commits into
mainfrom
fix/openai-agents-typescript-custom-handoff-target-replaced
Closed

campoy wants to merge 2 commits into
mainfrom
fix/openai-agents-typescript-custom-handoff-target-replaced

Conversation

@campoy

@campoy campoy commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

What was changed

TemporalOpenAIRunner now hands off to the agent a custom Handoff's callback returns, as the upstream Runner does.

  • convertAgent clones each Handoff with a new onInvokeHandoff. It runs the original callback and converts the agent it returns, through the same seen-map, so a returned agent that is also reachable through handoffs gets the same converted clone.
  • When the callback returns the Handoff's own agent, which is always the case for handoff(agent) and plain Agent handoffs, the result is the converted agent field, as before.
  • An agent first reached through the callback gets the run's sandbox config, like the rest of the graph. The runner's step that gives runConfig.sandbox to agentAsTool tools moved into convertAgent, which now takes runConfig.sandbox, so agents converted at handoff time get it too. Before converting such an agent, the callback path also runs the runner's missing-sandbox check, now shared as requireSandboxRunConfig, so a callback that returns a SandboxAgent without runConfig.sandbox fails with SandboxConfigurationError.
  • New integration tests in src/__tests__/test-openai-agents.ts: a custom Handoff that picks vip or standard from the run context, and a replay of a History recorded on main before this change (src/__tests__/histories/custom-handoff-before-fix.json). New sandbox tests in src/__tests__/test-openai-sandbox.ts for agents found through the callback.

API change: none. convertAgent is not exported, and no option or signature changes.

Replay and rollback: a run that already handed off to the agent field, when the callback returned another agent, would replay into a different agent, with other tools and other commands. So the new path is behind patched('openai-agents.handoff-returned-agent'), and the gate is only asked when the callback returns another agent. Runs recorded before this change keep the old target on replay. Runs that never hit that case record no marker and can roll back to the previous release. Runs that took the new path record the marker, so an earlier plugin version can't replay them ([TMPRL1100] on the marker). To roll back, keep this version and decline the patch with the Worker's patchActivationCallback (experimental in SDK 1.24), or use Worker Versioning.

Why?

See #237. convertAgent replaced each Handoff with h.clone({ agent: convertedAgent }). Given only agent, the framework's Handoff.clone wraps the callback as await this.onInvokeHandoff(context, args); return agent;, so the agent the callback returned was dropped and the run always went to the agent field: the wrong agent answered, with its own tools, and nothing warned.

Checklist

  1. Closes [Bug] openai-agents (TypeScript): TemporalOpenAIRunner ignores the agent a custom Handoff's callback returns #237

  2. How was this tested:

    • New test A custom Handoff hands off to the agent its callback returns. With main's convert-agent.ts it fails on its results assertion ('standard: vip answer' instead of 'vip: vip answer'). With this change it passes. The standard run is the control.
    • New test A custom Handoff keeps its agent field when replaying a run recorded before the fix. The History was recorded on main (c664a34) with the test Workflow: the callback returned vip, the run went to standard, and standard's model called its standardDesk Activity. It replays on main and with this change. Without the gate it fails with [TMPRL1100] Nondeterminism error: Fail workflow machine does not handle this event: HistoryEvent(id: 17, ActivityTaskScheduled).
    • New sandbox tests, where a custom Handoff's callback returns vip, whose agentAsTool asks a nested SandboxAgent to run a command through the fake Temporal sandbox client:
      • A custom Handoff target returned only by the callback gets the sandbox config: on this PR's first commit it fails with A SandboxAgent was provided but runConfig.sandbox is not configured, although the run sets runConfig.sandbox. With the second commit it passes and the nested command runs.
      • A custom Handoff target also in the handoff graph gets the sandbox config is the control: vip is also a router handoff. It passes on both commits.
      • A custom Handoff returning a SandboxAgent without runConfig.sandbox fails the Workflow: on the first commit the framework's SandboxAgent execution requires RunConfig.sandbox error ends the run; with the second it fails with SandboxConfigurationError.
    • Locally, in Docker (Node v24.22.0, pnpm 10.27.0, Linux arm64): make lint passes (8 existing warnings, none in files this PR touches), make build packs, and check_npm_package.py, smoke.mjs and check_conventions.py pass. make test TEST_ARGS="--concurrency=2" passes 212 tests (207 on main plus the 5 new ones). With the default concurrency, one unrelated test (read-only handler tracing: repeated query/update-validation ...) timed out while the machine was busy; it passes on its own on main and with this change.
    • [Bug] openai-agents (TypeScript): TemporalOpenAIRunner ignores the agent a custom Handoff's callback returns #237's reproduction exits 1 (bug) on @temporalio/openai-agents 1.24.0 and on main, and 0 with this change.
    • Replay check with real Workers on a local dev server (Temporal CLI 1.9.1, Server 1.32.0), Workflow Workers with no Workflow cache, and runs with a vip (changed) and a standard (unchanged) callback result, finished and held at the desk's last model call:
      • main's histories replay with this change and on main (6 of 6 each). They include two held main runs finished on this change: both kept the standard target, with no failed Workflow Task.
      • This change's histories replay with this change (7 of 7). On main, the 3 that took the new path fail with [TMPRL1100] ... Non-deprecated patch marker encountered, as expected for a gate; the other 4 replay.
      • Rollback: held runs started on this change finished on this change with patchActivationCallback declining new patches. The one that had taken the new path kept vip, with no failed Workflow Task; a new vip run on that build went to standard and recorded no marker.
      • Controls: this change without its gate fails the 2 vip histories from main with [TMPRL1100]; a renamed desk Activity in a history fails with [TMPRL1100].

    CI covers the other Node versions and platforms.

  3. Any docs updates needed?
    No. The README lists handoffs as supported; this makes the custom Handoff form behave as upstream documents it.

Not covered:

  • An agent reachable only through a callback is converted when the handoff runs, not when the run starts. So the checks for raw function tools, a Model object and a SandboxAgent without runConfig.sandbox see it only at handoff time. The sandbox client checks run up front whenever runConfig.sandbox is set, so they already cover it.

Related: the open drafts for agent-hooks-never-fire and agent-tool-run-model-lost also change convertAgent and its call in runner.ts, and other open drafts for this plugin (#209, #210, #215, #216–#218) add tests at the end of src/__tests__/test-openai-agents.ts and src/__tests__/workflows/openai-agents.ts. Whichever merges later may need a small rebase there. With the hook-forwarding change, agents converted at handoff time should get the same hook forwarding.

Open questions:

  • Is a gate asked only when the callback returns another agent the right scope? A gate asked on every run would be simpler to reason about, but would put the marker in every new run.

Draft while this question is open.

A custom Handoff can pick the receiving agent when it is invoked: the
upstream Runner hands off to the agent its callback returns. convertAgent
cloned each Handoff with only a new agent, and Handoff.clone then returns
that agent after running the callback, so TemporalOpenAIRunner always
handed off to the Handoff's agent field and dropped the callback's choice.

The cloned Handoff now converts the agent the callback returns, through the
same seen-map. When the callback returns its own agent nothing changes.
Runs that already handed off to the agent field keep doing so on replay:
the new path is behind patched('openai-agents.handoff-returned-agent'),
which is only asked when the callback returns another agent.
…sandbox config

An agent first reached through a custom Handoff's callback is converted
when the handoff runs. The runner set runConfig.sandbox on agentAsTool
tools only over the initial graph, so such an agent's agentAsTool tools
never got it, and a nested SandboxAgent failed with "runConfig.sandbox
is not configured" although the run had a valid Temporal sandbox
client.

Move that step into convertAgent, which now takes runConfig.sandbox and
passes it to each converted agent's agentAsTool tools, so agents
converted at handoff time get it too. Before converting such an agent,
the callback path also runs the runner's missing-sandbox check, now
shared as requireSandboxRunConfig, so a callback that returns a
SandboxAgent without runConfig.sandbox fails with
SandboxConfigurationError.
@campoy

campoy commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Replaced by #270. It runs agents as written instead of cloning them, so a custom Handoff hands off to the agent its callback returns without the conversion code here. This PR's tests are carried over there and pass, with agentAsTool replaced by agent.asTool().

@campoy campoy closed this Oct 29, 2026
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): TemporalOpenAIRunner ignores the agent a custom Handoff's callback returns

1 participant