Skip to content

Commit ca7f098

Browse files
tconley1428claude
andauthored
Fix handoff closure bug and add multiple handoffs test (#1310)
Fixed a Python closure bug in _convert_agent where handoff functions were incorrectly captured by reference instead of by value. This caused all handoff calls to route to the last agent processed in the loop instead of the intended agent. - Fix: Use default parameter to capture handoff function by value - Add: Test case validating correct handoff routing with multiple agents - Test: Verifies transfer_to_planner correctly routes to Planner agent 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Claude <noreply@anthropic.com>
1 parent a114671 commit ca7f098

2 files changed

Lines changed: 109 additions & 3 deletions

File tree

temporalio/contrib/openai_agents/_openai_runner.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import dataclasses
2-
from typing import Any
2+
from collections.abc import Awaitable
3+
from typing import Any, Callable
34

45
from agents import (
56
Agent,
@@ -51,8 +52,15 @@ def _convert_agent(
5152
elif isinstance(handoff, Handoff):
5253
original_invoke = handoff.on_invoke_handoff
5354

54-
async def on_invoke(context: RunContextWrapper[Any], args: str) -> Agent:
55-
handoff_agent = await original_invoke(context, args)
55+
# Use default parameter to capture original_invoke by value, not reference
56+
async def on_invoke(
57+
context: RunContextWrapper[Any],
58+
args: str,
59+
invoke_func: Callable[
60+
[RunContextWrapper[Any], str], Awaitable[Any]
61+
] = original_invoke,
62+
) -> Agent:
63+
handoff_agent = await invoke_func(context, args)
5664
return _convert_agent(model_params, handoff_agent, seen)
5765

5866
new_handoffs.append(

tests/contrib/openai_agents/test_openai.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2592,3 +2592,101 @@ async def test_split_workers(client: Client):
25922592
execution_timeout=timedelta(seconds=120),
25932593
)
25942594
assert result == "test"
2595+
2596+
2597+
def multiple_handoffs_mock_model():
2598+
return TestModel.returning_responses(
2599+
[
2600+
ResponseBuilders.tool_call("{}", "transfer_to_planner"),
2601+
ResponseBuilders.output_message(
2602+
"I'll analyze the requirements and create a plan."
2603+
),
2604+
]
2605+
)
2606+
2607+
2608+
@workflow.defn
2609+
class MultipleHandoffsWorkflow:
2610+
@workflow.run
2611+
async def run(self, task: str) -> str:
2612+
planner = Agent[None](
2613+
name="Planner",
2614+
instructions="You analyze requirements and create detailed plans.",
2615+
handoff_description="An agent that creates detailed plans and strategies",
2616+
)
2617+
2618+
writer = Agent[None](
2619+
name="Writer",
2620+
instructions="You write documents and reports based on provided information.",
2621+
handoff_description="An agent that writes professional documents and reports",
2622+
)
2623+
2624+
specialists = [planner, writer]
2625+
handoffs_list: list[Agent[Any] | Handoff[None, Any]] = [
2626+
handoff(agent=a) for a in specialists
2627+
]
2628+
2629+
triage = Agent[None](
2630+
name="Triage",
2631+
instructions="Hand off to Planner when requested.",
2632+
handoffs=handoffs_list,
2633+
)
2634+
2635+
result = await Runner.run(starting_agent=triage, input=task)
2636+
return result.final_output
2637+
2638+
2639+
async def test_multiple_handoffs_workflow(client: Client):
2640+
model = multiple_handoffs_mock_model()
2641+
async with AgentEnvironment(
2642+
model=model,
2643+
model_params=ModelActivityParameters(
2644+
start_to_close_timeout=timedelta(seconds=30),
2645+
),
2646+
) as env:
2647+
client = env.applied_on_client(client)
2648+
2649+
async with new_worker(
2650+
client,
2651+
MultipleHandoffsWorkflow,
2652+
) as worker:
2653+
workflow_handle = await client.start_workflow(
2654+
MultipleHandoffsWorkflow.run,
2655+
"Create a project plan for building a web application",
2656+
id=f"multiple-handoffs-workflow-{uuid.uuid4()}",
2657+
task_queue=worker.task_queue,
2658+
execution_timeout=timedelta(seconds=30),
2659+
)
2660+
result = await workflow_handle.result()
2661+
2662+
assert result == "I'll analyze the requirements and create a plan."
2663+
2664+
# Verify the correct handoff occurred
2665+
events = []
2666+
async for e in workflow_handle.fetch_history_events():
2667+
if e.HasField("activity_task_completed_event_attributes"):
2668+
events.append(e)
2669+
2670+
# Should have 2 activity completions:
2671+
# 1. Triage agent makes handoff call to planner
2672+
# 2. Planner agent responds
2673+
assert len(events) == 2
2674+
2675+
# Verify handoff to planner was requested
2676+
first_event_data = (
2677+
events[0]
2678+
.activity_task_completed_event_attributes.result.payloads[0]
2679+
.data.decode()
2680+
)
2681+
assert "transfer_to_planner" in first_event_data
2682+
2683+
# Verify that the planner agent was actually invoked (this would fail before the fix)
2684+
planner_response_data = (
2685+
events[1]
2686+
.activity_task_completed_event_attributes.result.payloads[0]
2687+
.data.decode()
2688+
)
2689+
assert (
2690+
"I'll analyze the requirements and create a plan."
2691+
in planner_response_data
2692+
)

0 commit comments

Comments
 (0)