From c38c799df8f0d41d453d703cdaa7af63f2db3dbf Mon Sep 17 00:00:00 2001 From: Francesc Campoy Date: Mon, 26 Oct 2026 20:26:38 -0700 Subject: [PATCH 1/2] fix(openai-agents): hand off to the agent a custom Handoff returns 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. --- .../histories/custom-handoff-before-fix.json | 628 ++++++++++++++++++ .../src/__tests__/test-openai-agents.ts | 49 +- .../src/__tests__/workflows/openai-agents.ts | 29 +- .../src/workflow/convert-agent.ts | 19 +- 4 files changed, 721 insertions(+), 4 deletions(-) create mode 100644 typescript/openai-agents/src/__tests__/histories/custom-handoff-before-fix.json diff --git a/typescript/openai-agents/src/__tests__/histories/custom-handoff-before-fix.json b/typescript/openai-agents/src/__tests__/histories/custom-handoff-before-fix.json new file mode 100644 index 00000000..3b8128ca --- /dev/null +++ b/typescript/openai-agents/src/__tests__/histories/custom-handoff-before-fix.json @@ -0,0 +1,628 @@ +{ + "events": [ + { + "eventId": "1", + "eventTime": "2026-10-27T03:26:06.673561418Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_STARTED", + "taskId": "1048587", + "workflowExecutionStartedEventAttributes": { + "workflowType": { + "name": "customHandoffWorkflow" + }, + "taskQueue": { + "name": "custom-handoff-fixture-1793071564962", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "input": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "InZpcCI=" + } + ] + }, + "workflowExecutionTimeout": "30s", + "workflowRunTimeout": "30s", + "workflowTaskTimeout": "10s", + "originalExecutionRunId": "01a17b64-cf51-788e-a94f-dd93bb7e34fb", + "identity": "144@04f4f6f859c5", + "firstExecutionRunId": "01a17b64-cf51-788e-a94f-dd93bb7e34fb", + "attempt": 1, + "workflowExecutionExpirationTime": "2026-10-27T03:26:36.673Z", + "firstWorkflowTaskBackoff": "0s", + "header": {}, + "workflowId": "custom-handoff-fixture-1793071564962" + } + }, + { + "eventId": "2", + "eventTime": "2026-10-27T03:26:06.673868668Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "taskId": "1048588", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "custom-handoff-fixture-1793071564962", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "3", + "eventTime": "2026-10-27T03:26:06.681904376Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "taskId": "1048594", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "2", + "identity": "144@04f4f6f859c5", + "requestId": "af5bfb25-73db-4a0a-8f74-b7e71ee3684e", + "historySizeBytes": "374", + "workerVersion": { + "buildId": "@temporalio/worker@1.24.0+1d59385e28b0929ecbb717f5d79775abe528040dac445b2c10fc93211a276bc4" + } + } + }, + { + "eventId": "4", + "eventTime": "2026-10-27T03:26:06.788844293Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "taskId": "1048599", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "2", + "startedEventId": "3", + "identity": "144@04f4f6f859c5", + "workerVersion": { + "buildId": "@temporalio/worker@1.24.0+1d59385e28b0929ecbb717f5d79775abe528040dac445b2c10fc93211a276bc4" + }, + "sdkMetadata": { + "coreUsedFlags": [ + 3, + 1, + 2 + ], + "sdkName": "temporal-typescript", + "sdkVersion": "1.24.0" + }, + "meteringMetadata": {} + } + }, + { + "eventId": "5", + "eventTime": "2026-10-27T03:26:06.789044335Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", + "taskId": "1048600", + "activityTaskScheduledEventAttributes": { + "activityId": "1", + "activityType": { + "name": "invokeModelActivity" + }, + "taskQueue": { + "name": "custom-handoff-fixture-1793071564962", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "header": { + "fields": { + "__openai_span": { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJ0cmFjZU5hbWUiOiJBZ2VudCB3b3JrZmxvdyIsInNwYW5JZCI6InNwYW5fN2U4OTUyMWE0ZmNjMzgwZDk1YTcxMTA0IiwidHJhY2VJZCI6InRyYWNlXzBlNmU0ZDRlODJiMzgyNGEwNmQ1ODEzZWU0N2YzZDJkIn0=" + } + } + }, + "input": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJtb2RlbE5hbWUiOiJncHQtNG8tbWluaSIsInJlcXVlc3QiOnsic3lzdGVtSW5zdHJ1Y3Rpb25zIjoiIiwiaW5wdXQiOlt7InR5cGUiOiJtZXNzYWdlIiwicm9sZSI6InVzZXIiLCJjb250ZW50IjoiaGVscCJ9XSwibW9kZWxTZXR0aW5ncyI6e30sInRvb2xzIjpbXSwidG9vbHNFeHBsaWNpdGx5UHJvdmlkZWQiOnRydWUsIm91dHB1dFR5cGUiOiJ0ZXh0IiwiaGFuZG9mZnMiOlt7InRvb2xOYW1lIjoidHJhbnNmZXJfdG9fZGVzayIsInRvb2xEZXNjcmlwdGlvbiI6IlJvdXRlIHRvIHRoZSByaWdodCBkZXNrIiwiaW5wdXRKc29uU2NoZW1hIjp7InR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7fSwicmVxdWlyZWQiOltdLCJhZGRpdGlvbmFsUHJvcGVydGllcyI6ZmFsc2V9LCJzdHJpY3RKc29uU2NoZW1hIjp0cnVlfV0sInRyYWNpbmciOnRydWUsIm92ZXJyaWRlUHJvbXB0TW9kZWwiOnRydWV9fQ==" + } + ] + }, + "scheduleToCloseTimeout": "30s", + "scheduleToStartTimeout": "30s", + "startToCloseTimeout": "30s", + "heartbeatTimeout": "0s", + "workflowTaskCompletedEventId": "4", + "retryPolicy": { + "initialInterval": "1s", + "backoffCoefficient": 2, + "maximumInterval": "100s" + }, + "useWorkflowBuildId": true + } + }, + { + "eventId": "6", + "eventTime": "2026-10-27T03:26:06.789338335Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", + "taskId": "1048603", + "activityTaskStartedEventAttributes": { + "scheduledEventId": "5", + "identity": "144@04f4f6f859c5", + "requestId": "e93de853-b01e-415d-a6cb-3ebfa37ee010", + "attempt": 1, + "workerVersion": { + "buildId": "@temporalio/worker@1.24.0+1d59385e28b0929ecbb717f5d79775abe528040dac445b2c10fc93211a276bc4" + } + } + }, + { + "eventId": "7", + "eventTime": "2026-10-27T03:26:06.802194543Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", + "taskId": "1048604", + "activityTaskCompletedEventAttributes": { + "result": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJ1c2FnZSI6eyJyZXF1ZXN0cyI6MSwiaW5wdXRUb2tlbnMiOjEwLCJvdXRwdXRUb2tlbnMiOjIwLCJ0b3RhbFRva2VucyI6MzAsImlucHV0VG9rZW5zRGV0YWlscyI6W10sIm91dHB1dFRva2Vuc0RldGFpbHMiOltdfSwib3V0cHV0IjpbeyJ0eXBlIjoiZnVuY3Rpb25fY2FsbCIsIm5hbWUiOiJ0cmFuc2Zlcl90b19kZXNrIiwiYXJndW1lbnRzIjoie30iLCJjYWxsSWQiOiJjYWxsX2Zha2VfdHJhbnNmZXJfdG9fZGVzayIsInN0YXR1cyI6ImNvbXBsZXRlZCJ9XX0=" + } + ] + }, + "scheduledEventId": "5", + "startedEventId": "6", + "identity": "144@04f4f6f859c5" + } + }, + { + "eventId": "8", + "eventTime": "2026-10-27T03:26:06.802253085Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "taskId": "1048605", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "144@04f4f6f859c5-be9bc8cf0af044e28b063af871363c1e", + "kind": "TASK_QUEUE_KIND_STICKY", + "normalName": "custom-handoff-fixture-1793071564962" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "9", + "eventTime": "2026-10-27T03:26:06.803479210Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "taskId": "1048609", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "8", + "identity": "144@04f4f6f859c5", + "requestId": "58cb9ce4-afbf-49d8-a0b6-38dbfcb6c922", + "historySizeBytes": "2186", + "workerVersion": { + "buildId": "@temporalio/worker@1.24.0+1d59385e28b0929ecbb717f5d79775abe528040dac445b2c10fc93211a276bc4" + } + } + }, + { + "eventId": "10", + "eventTime": "2026-10-27T03:26:06.819121460Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "taskId": "1048614", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "8", + "startedEventId": "9", + "identity": "144@04f4f6f859c5", + "workerVersion": { + "buildId": "@temporalio/worker@1.24.0+1d59385e28b0929ecbb717f5d79775abe528040dac445b2c10fc93211a276bc4" + }, + "sdkMetadata": {}, + "meteringMetadata": {} + } + }, + { + "eventId": "11", + "eventTime": "2026-10-27T03:26:06.819175751Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", + "taskId": "1048615", + "activityTaskScheduledEventAttributes": { + "activityId": "2", + "activityType": { + "name": "invokeModelActivity" + }, + "taskQueue": { + "name": "custom-handoff-fixture-1793071564962", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "header": { + "fields": { + "__openai_span": { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJ0cmFjZU5hbWUiOiJBZ2VudCB3b3JrZmxvdyIsInNwYW5JZCI6InNwYW5fOTYxN2FmNmQxMDIyZmVjZGJkY2Q1NzFmIiwidHJhY2VJZCI6InRyYWNlXzBlNmU0ZDRlODJiMzgyNGEwNmQ1ODEzZWU0N2YzZDJkIn0=" + } + } + }, + "input": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJtb2RlbE5hbWUiOiJncHQtNG8tbWluaSIsInJlcXVlc3QiOnsic3lzdGVtSW5zdHJ1Y3Rpb25zIjoiIiwiaW5wdXQiOlt7InR5cGUiOiJtZXNzYWdlIiwicm9sZSI6InVzZXIiLCJjb250ZW50IjoiaGVscCJ9LHsidHlwZSI6ImZ1bmN0aW9uX2NhbGwiLCJuYW1lIjoidHJhbnNmZXJfdG9fZGVzayIsImFyZ3VtZW50cyI6Int9IiwiY2FsbElkIjoiY2FsbF9mYWtlX3RyYW5zZmVyX3RvX2Rlc2siLCJzdGF0dXMiOiJjb21wbGV0ZWQifSx7InR5cGUiOiJmdW5jdGlvbl9jYWxsX3Jlc3VsdCIsIm5hbWUiOiJ0cmFuc2Zlcl90b19kZXNrIiwiY2FsbElkIjoiY2FsbF9mYWtlX3RyYW5zZmVyX3RvX2Rlc2siLCJzdGF0dXMiOiJjb21wbGV0ZWQiLCJvdXRwdXQiOnsidHlwZSI6InRleHQiLCJ0ZXh0Ijoie1wiYXNzaXN0YW50XCI6XCJzdGFuZGFyZFwifSJ9fV0sIm1vZGVsU2V0dGluZ3MiOnt9LCJ0b29scyI6W3sidHlwZSI6ImZ1bmN0aW9uIiwibmFtZSI6InN0YW5kYXJkRGVzayIsImRlc2NyaXB0aW9uIjoiTG9vayB1cCB0aGUgc3RhbmRhcmREZXNrIGRlc2siLCJwYXJhbWV0ZXJzIjp7InR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7fSwicmVxdWlyZWQiOltdLCJhZGRpdGlvbmFsUHJvcGVydGllcyI6ZmFsc2V9LCJzdHJpY3QiOnRydWV9XSwidG9vbHNFeHBsaWNpdGx5UHJvdmlkZWQiOnRydWUsIm91dHB1dFR5cGUiOiJ0ZXh0IiwiaGFuZG9mZnMiOltdLCJ0cmFjaW5nIjp0cnVlLCJvdmVycmlkZVByb21wdE1vZGVsIjp0cnVlfX0=" + } + ] + }, + "scheduleToCloseTimeout": "30s", + "scheduleToStartTimeout": "30s", + "startToCloseTimeout": "30s", + "heartbeatTimeout": "0s", + "workflowTaskCompletedEventId": "10", + "retryPolicy": { + "initialInterval": "1s", + "backoffCoefficient": 2, + "maximumInterval": "100s" + }, + "useWorkflowBuildId": true + } + }, + { + "eventId": "12", + "eventTime": "2026-10-27T03:26:06.819193043Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", + "taskId": "1048617", + "activityTaskStartedEventAttributes": { + "scheduledEventId": "11", + "identity": "144@04f4f6f859c5", + "requestId": "18928143-c3f5-41b1-947e-73e56c642a56", + "attempt": 1, + "workerVersion": { + "buildId": "@temporalio/worker@1.24.0+1d59385e28b0929ecbb717f5d79775abe528040dac445b2c10fc93211a276bc4" + } + } + }, + { + "eventId": "13", + "eventTime": "2026-10-27T03:26:06.824585085Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", + "taskId": "1048618", + "activityTaskCompletedEventAttributes": { + "result": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJ1c2FnZSI6eyJyZXF1ZXN0cyI6MSwiaW5wdXRUb2tlbnMiOjEwLCJvdXRwdXRUb2tlbnMiOjIwLCJ0b3RhbFRva2VucyI6MzAsImlucHV0VG9rZW5zRGV0YWlscyI6W10sIm91dHB1dFRva2Vuc0RldGFpbHMiOltdfSwib3V0cHV0IjpbeyJ0eXBlIjoiZnVuY3Rpb25fY2FsbCIsIm5hbWUiOiJzdGFuZGFyZERlc2siLCJhcmd1bWVudHMiOiJ7fSIsImNhbGxJZCI6ImNhbGxfZmFrZV9zdGFuZGFyZERlc2siLCJzdGF0dXMiOiJjb21wbGV0ZWQifV19" + } + ] + }, + "scheduledEventId": "11", + "startedEventId": "12", + "identity": "144@04f4f6f859c5" + } + }, + { + "eventId": "14", + "eventTime": "2026-10-27T03:26:06.824598043Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "taskId": "1048619", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "144@04f4f6f859c5-be9bc8cf0af044e28b063af871363c1e", + "kind": "TASK_QUEUE_KIND_STICKY", + "normalName": "custom-handoff-fixture-1793071564962" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "15", + "eventTime": "2026-10-27T03:26:06.825781168Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "taskId": "1048623", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "14", + "identity": "144@04f4f6f859c5", + "requestId": "db01ba40-77bd-4798-9582-fbe7b9d4dfd4", + "historySizeBytes": "4259", + "workerVersion": { + "buildId": "@temporalio/worker@1.24.0+1d59385e28b0929ecbb717f5d79775abe528040dac445b2c10fc93211a276bc4" + } + } + }, + { + "eventId": "16", + "eventTime": "2026-10-27T03:26:06.833963960Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "taskId": "1048628", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "14", + "startedEventId": "15", + "identity": "144@04f4f6f859c5", + "workerVersion": { + "buildId": "@temporalio/worker@1.24.0+1d59385e28b0929ecbb717f5d79775abe528040dac445b2c10fc93211a276bc4" + }, + "sdkMetadata": {}, + "meteringMetadata": {} + } + }, + { + "eventId": "17", + "eventTime": "2026-10-27T03:26:06.833995126Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", + "taskId": "1048629", + "activityTaskScheduledEventAttributes": { + "activityId": "3", + "activityType": { + "name": "standardDesk" + }, + "taskQueue": { + "name": "custom-handoff-fixture-1793071564962", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "header": { + "fields": { + "__openai_span": { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJ0cmFjZU5hbWUiOiJBZ2VudCB3b3JrZmxvdyIsInNwYW5JZCI6InNwYW5fNTgxMTg5YTA3OGQ4ZDg2ZDI0Mzc1NTgwIiwidHJhY2VJZCI6InRyYWNlXzBlNmU0ZDRlODJiMzgyNGEwNmQ1ODEzZWU0N2YzZDJkIn0=" + } + } + }, + "input": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "e30=" + } + ] + }, + "scheduleToCloseTimeout": "30s", + "scheduleToStartTimeout": "30s", + "startToCloseTimeout": "30s", + "heartbeatTimeout": "0s", + "workflowTaskCompletedEventId": "16", + "retryPolicy": { + "initialInterval": "1s", + "backoffCoefficient": 2, + "maximumInterval": "100s" + }, + "useWorkflowBuildId": true + } + }, + { + "eventId": "18", + "eventTime": "2026-10-27T03:26:06.834009960Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", + "taskId": "1048631", + "activityTaskStartedEventAttributes": { + "scheduledEventId": "17", + "identity": "144@04f4f6f859c5", + "requestId": "166a2823-fbc4-4729-b6fc-0a50e4a443d0", + "attempt": 1, + "workerVersion": { + "buildId": "@temporalio/worker@1.24.0+1d59385e28b0929ecbb717f5d79775abe528040dac445b2c10fc93211a276bc4" + } + } + }, + { + "eventId": "19", + "eventTime": "2026-10-27T03:26:06.836230001Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", + "taskId": "1048632", + "activityTaskCompletedEventAttributes": { + "result": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "InN0YW5kYXJkIGRlc2si" + } + ] + }, + "scheduledEventId": "17", + "startedEventId": "18", + "identity": "144@04f4f6f859c5" + } + }, + { + "eventId": "20", + "eventTime": "2026-10-27T03:26:06.836238293Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "taskId": "1048633", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "144@04f4f6f859c5-be9bc8cf0af044e28b063af871363c1e", + "kind": "TASK_QUEUE_KIND_STICKY", + "normalName": "custom-handoff-fixture-1793071564962" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "21", + "eventTime": "2026-10-27T03:26:06.837167460Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "taskId": "1048637", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "20", + "identity": "144@04f4f6f859c5", + "requestId": "958768f3-7e99-4232-a11b-5f863b013409", + "historySizeBytes": "5321", + "workerVersion": { + "buildId": "@temporalio/worker@1.24.0+1d59385e28b0929ecbb717f5d79775abe528040dac445b2c10fc93211a276bc4" + } + } + }, + { + "eventId": "22", + "eventTime": "2026-10-27T03:26:06.843864793Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "taskId": "1048642", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "20", + "startedEventId": "21", + "identity": "144@04f4f6f859c5", + "workerVersion": { + "buildId": "@temporalio/worker@1.24.0+1d59385e28b0929ecbb717f5d79775abe528040dac445b2c10fc93211a276bc4" + }, + "sdkMetadata": {}, + "meteringMetadata": {} + } + }, + { + "eventId": "23", + "eventTime": "2026-10-27T03:26:06.843889335Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", + "taskId": "1048643", + "activityTaskScheduledEventAttributes": { + "activityId": "4", + "activityType": { + "name": "invokeModelActivity" + }, + "taskQueue": { + "name": "custom-handoff-fixture-1793071564962", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "header": { + "fields": { + "__openai_span": { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJ0cmFjZU5hbWUiOiJBZ2VudCB3b3JrZmxvdyIsInNwYW5JZCI6InNwYW5fYjdjMWJjMTBiMjA5OTllNGYwMTNhNTFjIiwidHJhY2VJZCI6InRyYWNlXzBlNmU0ZDRlODJiMzgyNGEwNmQ1ODEzZWU0N2YzZDJkIn0=" + } + } + }, + "input": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJtb2RlbE5hbWUiOiJncHQtNG8tbWluaSIsInJlcXVlc3QiOnsic3lzdGVtSW5zdHJ1Y3Rpb25zIjoiIiwiaW5wdXQiOlt7InR5cGUiOiJtZXNzYWdlIiwicm9sZSI6InVzZXIiLCJjb250ZW50IjoiaGVscCJ9LHsidHlwZSI6ImZ1bmN0aW9uX2NhbGwiLCJuYW1lIjoidHJhbnNmZXJfdG9fZGVzayIsImFyZ3VtZW50cyI6Int9IiwiY2FsbElkIjoiY2FsbF9mYWtlX3RyYW5zZmVyX3RvX2Rlc2siLCJzdGF0dXMiOiJjb21wbGV0ZWQifSx7InR5cGUiOiJmdW5jdGlvbl9jYWxsX3Jlc3VsdCIsIm5hbWUiOiJ0cmFuc2Zlcl90b19kZXNrIiwiY2FsbElkIjoiY2FsbF9mYWtlX3RyYW5zZmVyX3RvX2Rlc2siLCJzdGF0dXMiOiJjb21wbGV0ZWQiLCJvdXRwdXQiOnsidHlwZSI6InRleHQiLCJ0ZXh0Ijoie1wiYXNzaXN0YW50XCI6XCJzdGFuZGFyZFwifSJ9fSx7InR5cGUiOiJmdW5jdGlvbl9jYWxsIiwibmFtZSI6InN0YW5kYXJkRGVzayIsImFyZ3VtZW50cyI6Int9IiwiY2FsbElkIjoiY2FsbF9mYWtlX3N0YW5kYXJkRGVzayIsInN0YXR1cyI6ImNvbXBsZXRlZCJ9LHsidHlwZSI6ImZ1bmN0aW9uX2NhbGxfcmVzdWx0IiwibmFtZSI6InN0YW5kYXJkRGVzayIsImNhbGxJZCI6ImNhbGxfZmFrZV9zdGFuZGFyZERlc2siLCJzdGF0dXMiOiJjb21wbGV0ZWQiLCJvdXRwdXQiOnsidHlwZSI6InRleHQiLCJ0ZXh0Ijoic3RhbmRhcmQgZGVzayJ9fV0sIm1vZGVsU2V0dGluZ3MiOnt9LCJ0b29scyI6W3sidHlwZSI6ImZ1bmN0aW9uIiwibmFtZSI6InN0YW5kYXJkRGVzayIsImRlc2NyaXB0aW9uIjoiTG9vayB1cCB0aGUgc3RhbmRhcmREZXNrIGRlc2siLCJwYXJhbWV0ZXJzIjp7InR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7fSwicmVxdWlyZWQiOltdLCJhZGRpdGlvbmFsUHJvcGVydGllcyI6ZmFsc2V9LCJzdHJpY3QiOnRydWV9XSwidG9vbHNFeHBsaWNpdGx5UHJvdmlkZWQiOnRydWUsIm91dHB1dFR5cGUiOiJ0ZXh0IiwiaGFuZG9mZnMiOltdLCJ0cmFjaW5nIjp0cnVlLCJvdmVycmlkZVByb21wdE1vZGVsIjp0cnVlfX0=" + } + ] + }, + "scheduleToCloseTimeout": "30s", + "scheduleToStartTimeout": "30s", + "startToCloseTimeout": "30s", + "heartbeatTimeout": "0s", + "workflowTaskCompletedEventId": "22", + "retryPolicy": { + "initialInterval": "1s", + "backoffCoefficient": 2, + "maximumInterval": "100s" + }, + "useWorkflowBuildId": true + } + }, + { + "eventId": "24", + "eventTime": "2026-10-27T03:26:06.843902126Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", + "taskId": "1048645", + "activityTaskStartedEventAttributes": { + "scheduledEventId": "23", + "identity": "144@04f4f6f859c5", + "requestId": "8fa8c61a-7c24-4f4e-8e24-2c1a251c341a", + "attempt": 1, + "workerVersion": { + "buildId": "@temporalio/worker@1.24.0+1d59385e28b0929ecbb717f5d79775abe528040dac445b2c10fc93211a276bc4" + } + } + }, + { + "eventId": "25", + "eventTime": "2026-10-27T03:26:06.846124126Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", + "taskId": "1048646", + "activityTaskCompletedEventAttributes": { + "result": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJ1c2FnZSI6eyJyZXF1ZXN0cyI6MSwiaW5wdXRUb2tlbnMiOjEwLCJvdXRwdXRUb2tlbnMiOjE1LCJ0b3RhbFRva2VucyI6MjUsImlucHV0VG9rZW5zRGV0YWlscyI6W10sIm91dHB1dFRva2Vuc0RldGFpbHMiOltdfSwib3V0cHV0IjpbeyJ0eXBlIjoibWVzc2FnZSIsImlkIjoibXNnX2Zha2VfMDAxIiwicm9sZSI6ImFzc2lzdGFudCIsImNvbnRlbnQiOlt7InR5cGUiOiJvdXRwdXRfdGV4dCIsInRleHQiOiJzdGFuZGFyZCBhbnN3ZXIifV0sInN0YXR1cyI6ImNvbXBsZXRlZCJ9XX0=" + } + ] + }, + "scheduledEventId": "23", + "startedEventId": "24", + "identity": "144@04f4f6f859c5" + } + }, + { + "eventId": "26", + "eventTime": "2026-10-27T03:26:06.846134460Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "taskId": "1048647", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "144@04f4f6f859c5-be9bc8cf0af044e28b063af871363c1e", + "kind": "TASK_QUEUE_KIND_STICKY", + "normalName": "custom-handoff-fixture-1793071564962" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "27", + "eventTime": "2026-10-27T03:26:06.847182043Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "taskId": "1048651", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "26", + "identity": "144@04f4f6f859c5", + "requestId": "e9fdf211-9fcd-4123-83b5-8a007fc3d603", + "historySizeBytes": "7690", + "workerVersion": { + "buildId": "@temporalio/worker@1.24.0+1d59385e28b0929ecbb717f5d79775abe528040dac445b2c10fc93211a276bc4" + } + } + }, + { + "eventId": "28", + "eventTime": "2026-10-27T03:26:06.856326126Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "taskId": "1048655", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "26", + "startedEventId": "27", + "identity": "144@04f4f6f859c5", + "workerVersion": { + "buildId": "@temporalio/worker@1.24.0+1d59385e28b0929ecbb717f5d79775abe528040dac445b2c10fc93211a276bc4" + }, + "sdkMetadata": {}, + "meteringMetadata": {} + } + }, + { + "eventId": "29", + "eventTime": "2026-10-27T03:26:06.856485126Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_COMPLETED", + "taskId": "1048656", + "workflowExecutionCompletedEventAttributes": { + "result": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "InN0YW5kYXJkOiBzdGFuZGFyZCBhbnN3ZXIi" + } + ] + }, + "workflowTaskCompletedEventId": "28" + } + } + ] +} diff --git a/typescript/openai-agents/src/__tests__/test-openai-agents.ts b/typescript/openai-agents/src/__tests__/test-openai-agents.ts index cf660dee..c6081ca4 100644 --- a/typescript/openai-agents/src/__tests__/test-openai-agents.ts +++ b/typescript/openai-agents/src/__tests__/test-openai-agents.ts @@ -1,10 +1,13 @@ // Integration tests that need a real Worker. Pure-function tests live in // the sibling `*.test.ts` files. +import { readFileSync } from 'node:fs'; +import path from 'node:path'; import { setTracingDisabled } from '@openai/agents-core'; import type { MCPServer } from '@openai/agents-core'; import { WorkflowClient } from '@temporalio/client'; import { temporal } from '@temporalio/proto'; import { helpers } from '@temporalio/test-helpers'; +import { Worker } from '@temporalio/worker'; import { WorkflowStreamClient } from '@temporalio/workflow-streams/client'; import { OpenAIAgentsPlugin, @@ -13,6 +16,7 @@ import { StatelessMCPServerProvider, } from '..'; import { + customHandoffWorkflow, localActivityAgentWorkflow, mcpFactoryArgWorkflow, summaryOverrideStringWorkflow, @@ -25,7 +29,7 @@ import { streamingNoTopicWorkflow, } from './workflows/openai-agents'; import { makeTestFunction } from './helpers/test-fn'; -import { FakeModelProvider, textResponse, toolCallResponse } from './stubs/openai-agents'; +import { FakeModelProvider, handoffResponse, textResponse, toolCallResponse } from './stubs/openai-agents'; import { StreamingFakeModelProvider, streamingTextEvents } from './stubs/openai-agents-fakes'; import EventType = temporal.api.enums.v1.EventType; @@ -524,3 +528,46 @@ test('Streaming: useLocalActivity fails fast before scheduling an Activity', asy t.is(result, 'StreamingLocalActivityUnsupported'); }); }); + +const deskActivities = { + standardDesk: async () => 'standard desk', + vipDesk: async () => 'vip desk', +}; + +test('A custom Handoff hands off to the agent its callback returns', async (t) => { + const { createWorker, executeWorkflow } = helpers(t); + const modelProvider = new FakeModelProvider([ + handoffResponse('transfer_to_desk'), + textResponse('vip answer'), + handoffResponse('transfer_to_desk'), + textResponse('standard answer'), + ]); + const worker = await createWorker({ + plugins: [new OpenAIAgentsPlugin({ modelProvider })], + activities: deskActivities, + }); + + const results = await worker.runUntil(async () => [ + await executeWorkflow(customHandoffWorkflow, { args: ['vip'], workflowExecutionTimeout: '30 seconds' }), + await executeWorkflow(customHandoffWorkflow, { args: ['standard'], workflowExecutionTimeout: '30 seconds' }), + ]); + + t.deepEqual(results, ['vip: vip answer', 'standard: standard answer']); +}); + +test('A custom Handoff keeps its agent field when replaying a run recorded before the fix', async (t) => { + // Recorded with the plugin before this change: the callback returned vip, but the run went to + // standard, whose model called its standardDesk tool. + const history = JSON.parse( + readFileSync(path.resolve(__dirname, '../../src/__tests__/histories/custom-handoff-before-fix.json'), 'utf8') + ); + await t.notThrowsAsync( + Worker.runReplayHistory( + { + workflowBundle: t.context.workflowBundle, + plugins: [new OpenAIAgentsPlugin({ modelProvider: new FakeModelProvider([]) })], + }, + history + ) + ); +}); diff --git a/typescript/openai-agents/src/__tests__/workflows/openai-agents.ts b/typescript/openai-agents/src/__tests__/workflows/openai-agents.ts index b739c0d1..443634dd 100644 --- a/typescript/openai-agents/src/__tests__/workflows/openai-agents.ts +++ b/typescript/openai-agents/src/__tests__/workflows/openai-agents.ts @@ -1,7 +1,7 @@ -import { Agent } from '@openai/agents-core'; +import { Agent, Handoff } from '@openai/agents-core'; import { ApplicationFailure } from '@temporalio/workflow'; import { WorkflowStream } from '@temporalio/workflow-streams/workflow'; -import { TemporalOpenAIRunner, statelessMcpServer, statefulMcpServer } from '../../workflow'; +import { TemporalOpenAIRunner, activityAsTool, statelessMcpServer, statefulMcpServer } from '../../workflow'; export interface StreamingAgentResult { finalOutput: string; @@ -176,3 +176,28 @@ export async function statefulMcpSlowConnectHeartbeatWorkflow(): Promise await server.cleanup(); } } + +/** + * A custom `Handoff` whose callback picks the desk from the run context, as the + * upstream handoffs guide describes. Each desk has its own Activity tool. + */ +export async function customHandoffWorkflow(tier: string): Promise { + const deskTool = (name: string) => + activityAsTool<(input: Record) => Promise>({ + name, + description: `Look up the ${name} desk`, + parameters: { type: 'object', properties: {}, required: [], additionalProperties: false }, + }); + const standard = new Agent<{ tier: string }>({ + name: 'standard', + model: 'gpt-4o-mini', + tools: [deskTool('standardDesk')], + }); + const vip = new Agent<{ tier: string }>({ name: 'vip', model: 'gpt-4o-mini', tools: [deskTool('vipDesk')] }); + const desk = new Handoff(standard, async (runContext) => (runContext.context.tier === 'vip' ? vip : standard)); + desk.toolName = 'transfer_to_desk'; + desk.toolDescription = 'Route to the right desk'; + const router = new Agent<{ tier: string }>({ name: 'router', model: 'gpt-4o-mini', handoffs: [desk] }); + const result = await new TemporalOpenAIRunner().run(router, 'help', { context: { tier } }); + return `${result.lastAgent?.name}: ${result.finalOutput}`; +} diff --git a/typescript/openai-agents/src/workflow/convert-agent.ts b/typescript/openai-agents/src/workflow/convert-agent.ts index c699db37..714b6226 100644 --- a/typescript/openai-agents/src/workflow/convert-agent.ts +++ b/typescript/openai-agents/src/workflow/convert-agent.ts @@ -1,8 +1,16 @@ import { type Agent, Handoff } from '@openai/agents-core'; import { ApplicationFailure } from '@temporalio/common'; +import { patched } from '@temporalio/workflow'; import type { ModelActivityOptions } from '../common/model-activity-options'; import { ActivityBackedModel } from './activity-backed-model'; +/** + * Runs that handed off to a custom `Handoff`'s `agent` field, when its callback + * returned another agent, keep doing so on replay: that agent's model call and + * tools would not match their history. + */ +const HANDOFF_RETURNED_AGENT_PATCH = 'openai-agents.handoff-returned-agent'; + /** * Recursively converts an agent graph: validate tools, replace each agent's * model with an ActivityBackedModel, and clone handoffs. Uses a seen-map to @@ -63,7 +71,16 @@ export function convertAgent( const convertedHandoffs = (agent.handoffs ?? []).map((h) => { if (h instanceof Handoff) { const convertedHandoffAgent = convertAgent(h.agent, modelParams, seen, modelNameOverride); - return h.clone({ agent: convertedHandoffAgent }); + // The run hands off to the agent the callback returns, which a custom + // Handoff can choose at invocation time, so convert that agent. + return h.clone({ + agent: convertedHandoffAgent, + onInvokeHandoff: async (context, args) => { + const target = await h.onInvokeHandoff(context, args); + if (target === h.agent || !patched(HANDOFF_RETURNED_AGENT_PATCH)) return convertedHandoffAgent; + return convertAgent(target, modelParams, seen, modelNameOverride); + }, + }); } return convertAgent(h, modelParams, seen, modelNameOverride); }); From e1c862059f165370f07941c08c8505e45b2fbb3c Mon Sep 17 00:00:00 2001 From: Francesc Campoy Date: Tue, 27 Oct 2026 18:03:07 -0700 Subject: [PATCH 2/2] fix(openai-agents): give agents found through a handoff callback the 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. --- .../src/__tests__/test-openai-sandbox.ts | 44 +++++++++++++- .../src/__tests__/workflows/openai-sandbox.ts | 40 ++++++++++++- .../src/workflow/convert-agent.ts | 55 +++++++++++++++--- .../openai-agents/src/workflow/runner.ts | 57 ++----------------- 4 files changed, 134 insertions(+), 62 deletions(-) diff --git a/typescript/openai-agents/src/__tests__/test-openai-sandbox.ts b/typescript/openai-agents/src/__tests__/test-openai-sandbox.ts index 4dcab3fd..5435dd76 100644 --- a/typescript/openai-agents/src/__tests__/test-openai-sandbox.ts +++ b/typescript/openai-agents/src/__tests__/test-openai-sandbox.ts @@ -4,9 +4,11 @@ import { helpers } from '@temporalio/test-helpers'; import { OpenAIAgentsPlugin, OpenAIAgentsTraceClientInterceptor, SandboxClientProvider } from '..'; import { installAgentSdkCollector } from './helpers/openai-agents-spans'; import { makeTestFunction } from './helpers/test-fn'; -import { FakeModelProvider, textResponse, toolCallResponse } from './stubs/openai-agents'; +import { FakeModelProvider, handoffResponse, textResponse, toolCallResponse } from './stubs/openai-agents'; import { FakeSandboxClient, FakeSandboxSession } from './stubs/sandbox-fakes'; import { + customHandoffSandboxAgentWithoutConfigWorkflow, + customHandoffSandboxToolWorkflow, sandboxAgentWorkflow, sandboxApprovalResumeWorkflow, sandboxArchiveLimitsWorkflow, @@ -223,3 +225,43 @@ test('addTemporalSpans: sandbox exec emits sandbox:* spans carrying only safe me await agentSdk.teardown(); } }); + +for (const inGraph of [false, true]) { + test(`A custom Handoff target ${inGraph ? 'also in the handoff graph' : 'returned only by the callback'} gets the sandbox config`, async (t) => { + const { createWorker, executeWorkflow } = helpers(t); + const session = new FakeSandboxSession(); + const client = new FakeSandboxClient(session); + const worker = await createWorker({ + plugins: [ + new OpenAIAgentsPlugin({ + modelProvider: new FakeModelProvider([ + handoffResponse('transfer_to_desk'), + toolCallResponse('ask_sandbox', { input: 'go' }), + toolCallResponse('run_command', { cmd: 'echo hello' }), + textResponse('sandbox done'), + textResponse('vip answer'), + ]), + sandboxClientProviders: [new SandboxClientProvider('fake', client)], + }), + ], + }); + + await worker.runUntil(async () => { + t.is(await executeWorkflow(customHandoffSandboxToolWorkflow, { args: [inGraph] }), 'vip: vip answer'); + }); + t.is(session.execCalls[0]?.cmd, 'echo hello'); + }); +} + +test('A custom Handoff returning a SandboxAgent without runConfig.sandbox fails the Workflow', async (t) => { + const { createWorker, executeWorkflow } = helpers(t); + const worker = await createWorker({ + plugins: [new OpenAIAgentsPlugin({ modelProvider: new FakeModelProvider([handoffResponse('transfer_to_desk')]) })], + }); + + await worker.runUntil(async () => { + const err = await t.throwsAsync(executeWorkflow(customHandoffSandboxAgentWithoutConfigWorkflow)); + t.regex(String((err as any)?.cause?.message), /runConfig\.sandbox is not configured/); + t.is((err as any)?.cause?.type, 'SandboxConfigurationError'); + }); +}); diff --git a/typescript/openai-agents/src/__tests__/workflows/openai-sandbox.ts b/typescript/openai-agents/src/__tests__/workflows/openai-sandbox.ts index f259645d..bc9efedd 100644 --- a/typescript/openai-agents/src/__tests__/workflows/openai-sandbox.ts +++ b/typescript/openai-agents/src/__tests__/workflows/openai-sandbox.ts @@ -1,4 +1,4 @@ -import { Agent, type FunctionTool, type RunContext, type Tool } from '@openai/agents-core'; +import { Agent, Handoff, type FunctionTool, type RunContext, type Tool } from '@openai/agents-core'; import { Capability, Manifest, @@ -6,7 +6,7 @@ import { isEnvValueReference, type SandboxSessionLike, } from '@openai/agents-core/sandbox'; -import { TemporalOpenAIRunner, workerEnvValue, temporalSandboxClient } from '../../workflow'; +import { TemporalOpenAIRunner, agentAsTool, workerEnvValue, temporalSandboxClient } from '../../workflow'; class TestSandboxCapability extends Capability { readonly type = 'test_sandbox'; @@ -203,3 +203,39 @@ export async function sandboxValidationWorkflow(): Promise { return 'OK'; } + +/** + * A custom `Handoff` whose callback returns `vip`, which asks a nested `SandboxAgent` through + * `agentAsTool`. With `inGraph`, `vip` is also a handoff of the router, so the runner sees it up front. + */ +export async function customHandoffSandboxToolWorkflow(inGraph: boolean): Promise { + const specialist = new SandboxAgent({ + name: 'sandbox-specialist', + model: 'gpt-4o-mini', + capabilities: [new TestSandboxCapability()], + }); + const vip = new Agent({ + name: 'vip', + model: 'gpt-4o-mini', + tools: [agentAsTool(specialist, { toolName: 'ask_sandbox' })], + }); + const standard = new Agent({ name: 'standard', model: 'gpt-4o-mini' }); + const desk = new Handoff(standard, async () => vip); + desk.toolName = 'transfer_to_desk'; + const router = new Agent({ name: 'router', model: 'gpt-4o-mini', handoffs: inGraph ? [desk, vip] : [desk] }); + const result = await new TemporalOpenAIRunner().run(router, 'help', { + runConfig: { sandbox: { client: temporalSandboxClient('fake') } }, + }); + return `${result.lastAgent?.name}: ${result.finalOutput}`; +} + +/** A custom `Handoff` whose callback returns a `SandboxAgent`, with no `runConfig.sandbox`. */ +export async function customHandoffSandboxAgentWithoutConfigWorkflow(): Promise { + const specialist = new SandboxAgent({ name: 'sandbox-specialist', model: 'gpt-4o-mini' }); + const standard = new Agent({ name: 'standard', model: 'gpt-4o-mini' }); + const desk = new Handoff(standard, async () => specialist); + desk.toolName = 'transfer_to_desk'; + const router = new Agent({ name: 'router', model: 'gpt-4o-mini', handoffs: [desk] }); + await new TemporalOpenAIRunner().run(router, 'help'); + return 'FAIL: should have thrown'; +} diff --git a/typescript/openai-agents/src/workflow/convert-agent.ts b/typescript/openai-agents/src/workflow/convert-agent.ts index 714b6226..34684079 100644 --- a/typescript/openai-agents/src/workflow/convert-agent.ts +++ b/typescript/openai-agents/src/workflow/convert-agent.ts @@ -1,4 +1,5 @@ import { type Agent, Handoff } from '@openai/agents-core'; +import { SandboxAgent, type SandboxRunConfig } from '@openai/agents-core/sandbox'; import { ApplicationFailure } from '@temporalio/common'; import { patched } from '@temporalio/workflow'; import type { ModelActivityOptions } from '../common/model-activity-options'; @@ -11,16 +12,44 @@ import { ActivityBackedModel } from './activity-backed-model'; */ const HANDOFF_RETURNED_AGENT_PATCH = 'openai-agents.handoff-returned-agent'; +const agentAsToolSandboxConfig = Symbol.for('@temporalio/openai-agents/agentAsToolSandboxConfig'); + +/** Whether a `SandboxAgent` is reachable from `agent` through its handoff graph. */ +function hasSandboxAgent(agent: Agent, seen: Set> = new Set()): boolean { + if (agent instanceof SandboxAgent) return true; + if (seen.has(agent)) return false; + seen.add(agent); + for (const handoff of agent.handoffs ?? []) { + const target = handoff instanceof Handoff ? handoff.agent : handoff; + if (hasSandboxAgent(target, seen)) return true; + } + return false; +} + +/** Fails when a `SandboxAgent` is reachable from `agent` and `runConfig.sandbox` is not set. */ +export function requireSandboxRunConfig(agent: Agent, sandbox: SandboxRunConfig | undefined): void { + if (sandbox !== undefined || !hasSandboxAgent(agent)) return; + throw ApplicationFailure.create({ + message: + 'A SandboxAgent was provided but runConfig.sandbox is not configured. ' + + 'Set runConfig.sandbox with a client created via temporalSandboxClient(name) ' + + 'from @temporalio/openai-agents/workflow.', + type: 'SandboxConfigurationError', + nonRetryable: true, + }); +} + /** * Recursively converts an agent graph: validate tools, replace each agent's - * model with an ActivityBackedModel, and clone handoffs. Uses a seen-map to - * handle circular handoff references. + * model with an ActivityBackedModel, pass `sandbox` to its `agentAsTool` tools, + * and clone handoffs. Uses a seen-map to handle circular handoff references. */ export function convertAgent( agent: Agent, modelParams: ModelActivityOptions, seen?: Map, Agent>, - modelNameOverride?: string + modelNameOverride?: string, + sandbox?: SandboxRunConfig ): Agent { seen = seen ?? new Map(); if (seen.has(agent)) return seen.get(agent)!; @@ -67,22 +96,34 @@ export function convertAgent( const converted = agent.clone({ model: activityBackedModel }); seen.set(agent, converted); + if (sandbox !== undefined) { + converted.tools = converted.tools.map((tool) => { + if (tool.type !== 'function') return tool; + const configureSandbox = ( + tool as typeof tool & { [agentAsToolSandboxConfig]?: (sandbox: SandboxRunConfig) => typeof tool } + )[agentAsToolSandboxConfig]; + if (typeof configureSandbox !== 'function') return tool; + return configureSandbox(sandbox); + }); + } const convertedHandoffs = (agent.handoffs ?? []).map((h) => { if (h instanceof Handoff) { - const convertedHandoffAgent = convertAgent(h.agent, modelParams, seen, modelNameOverride); + const convertedHandoffAgent = convertAgent(h.agent, modelParams, seen, modelNameOverride, sandbox); // The run hands off to the agent the callback returns, which a custom - // Handoff can choose at invocation time, so convert that agent. + // Handoff can choose at invocation time, so check and convert that agent + // as the runner does the initial graph. return h.clone({ agent: convertedHandoffAgent, onInvokeHandoff: async (context, args) => { const target = await h.onInvokeHandoff(context, args); if (target === h.agent || !patched(HANDOFF_RETURNED_AGENT_PATCH)) return convertedHandoffAgent; - return convertAgent(target, modelParams, seen, modelNameOverride); + requireSandboxRunConfig(target, sandbox); + return convertAgent(target, modelParams, seen, modelNameOverride, sandbox); }, }); } - return convertAgent(h, modelParams, seen, modelNameOverride); + return convertAgent(h, modelParams, seen, modelNameOverride, sandbox); }); converted.handoffs = convertedHandoffs; diff --git a/typescript/openai-agents/src/workflow/runner.ts b/typescript/openai-agents/src/workflow/runner.ts index 732cfedc..ae826ca2 100644 --- a/typescript/openai-agents/src/workflow/runner.ts +++ b/typescript/openai-agents/src/workflow/runner.ts @@ -1,7 +1,6 @@ import { type Agent, type AgentInputItem, - Handoff, MemorySession, Runner, RunState, @@ -17,7 +16,7 @@ import { type StreamedRunResult, type TracingConfig, } from '@openai/agents-core'; -import { SandboxAgent, type SandboxRunConfig } from '@openai/agents-core/sandbox'; +import type { SandboxRunConfig } from '@openai/agents-core/sandbox'; import { ApplicationFailure } from '@temporalio/common'; import { DEFAULT_MODEL_ACTIVITY_OPTIONS, @@ -27,14 +26,12 @@ import { type SerializableModelActivityOptions, } from '../common/model-activity-options'; import { unwrapTemporalFailure } from '../common/errors'; -import { convertAgent } from './convert-agent'; +import { convertAgent, requireSandboxRunConfig } from './convert-agent'; import { TemporalSandboxClient } from './sandbox-client'; import { ensureTracingProcessorRegistered } from './tracing'; import { flushOpenSpans } from './agent-sink-processor'; import { getCurrentPluginConfig } from './plugin-config-store'; -const agentAsToolSandboxConfig = Symbol.for('@temporalio/openai-agents/agentAsToolSandboxConfig'); - export interface TemporalRunOptions { /** Run context passed to agents and tools */ context?: TContext; @@ -133,34 +130,13 @@ function definedFields(obj: T | undefined): Partial { return result; } -/** Whether a `SandboxAgent` is reachable from `agent` through its handoff graph. */ -function hasSandboxAgent(agent: Agent, seen: Set> = new Set()): boolean { - if (agent instanceof SandboxAgent) return true; - if (seen.has(agent)) return false; - seen.add(agent); - for (const handoff of agent.handoffs ?? []) { - const target = handoff instanceof Handoff ? handoff.agent : handoff; - if (hasSandboxAgent(target, seen)) return true; - } - return false; -} - /** * `runConfig.sandbox.client` must be a `TemporalSandboxClient` so every sandbox * operation is dispatched as an Activity rather than run inline in the Workflow. */ function validateSandboxRunConfig(agent: Agent, sandbox: SandboxRunConfig | undefined): void { - if (!hasSandboxAgent(agent) && sandbox === undefined) return; - if (sandbox === undefined) { - throw ApplicationFailure.create({ - message: - 'A SandboxAgent was provided but runConfig.sandbox is not configured. ' + - 'Set runConfig.sandbox with a client created via temporalSandboxClient(name) ' + - 'from @temporalio/openai-agents/workflow.', - type: 'SandboxConfigurationError', - nonRetryable: true, - }); - } + requireSandboxRunConfig(agent, sandbox); + if (sandbox === undefined) return; if (sandbox.client == null) { throw ApplicationFailure.create({ message: @@ -181,26 +157,6 @@ function validateSandboxRunConfig(agent: Agent, sandbox: SandboxRunCon } } -function propagateAgentToolSandboxConfig( - agent: Agent, - sandbox: SandboxRunConfig, - seen: Set> = new Set() -): void { - if (seen.has(agent)) return; - seen.add(agent); - agent.tools = agent.tools.map((tool) => { - if (tool.type !== 'function') return tool; - const configureSandbox = ( - tool as typeof tool & { [agentAsToolSandboxConfig]?: (sandbox: SandboxRunConfig) => typeof tool } - )[agentAsToolSandboxConfig]; - if (typeof configureSandbox !== 'function') return tool; - return configureSandbox(sandbox); - }); - for (const handoff of agent.handoffs ?? []) { - propagateAgentToolSandboxConfig(handoff instanceof Handoff ? handoff.agent : handoff, sandbox, seen); - } -} - /** * A Temporal-aware agent runner that delegates model calls to Activities. * @@ -271,10 +227,7 @@ export class TemporalOpenAIRunner { const { model: modelOverride, ...runnerConfigOverrides } = options?.runConfig ?? {}; - const converted = convertAgent(agent, this.modelParams, undefined, modelOverride); - if (options?.runConfig?.sandbox !== undefined) { - propagateAgentToolSandboxConfig(converted, options.runConfig.sandbox); - } + const converted = convertAgent(agent, this.modelParams, undefined, modelOverride, options?.runConfig?.sandbox); let preparedInput: string | AgentInputItem[] | RunState; if (input instanceof RunState) {