diff --git a/docs/running_agents.md b/docs/running_agents.md index fb3e9aa47..68a5d0ac9 100644 --- a/docs/running_agents.md +++ b/docs/running_agents.md @@ -189,6 +189,7 @@ async def main(): # California ``` +For the first turn where no real `previous_response_id` exists, you can set `previous_response_id="bootstrap"` to force-enable response chaining for internal function calls inside the first turn. ## Long running agents & human-in-the-loop diff --git a/src/agents/run.py b/src/agents/run.py index fce7b4840..6a7529c87 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -129,7 +129,11 @@ class CallModelData(Generic[TContext]): @dataclass class _ServerConversationTracker: """Tracks server-side conversation state for either conversation_id or - previous_response_id modes.""" + previous_response_id modes. + + Special value 'bootstrap' for previous_response_id enables chaining for internal + function calls in the first turn, even when there's no actual previous response ID yet. + """ conversation_id: str | None = None previous_response_id: str | None = None @@ -1360,6 +1364,7 @@ async def _run_single_turn_streamed( previous_response_id = ( server_conversation_tracker.previous_response_id if server_conversation_tracker + and server_conversation_tracker.previous_response_id != "bootstrap" else None ) conversation_id = ( @@ -1798,6 +1803,7 @@ async def _get_new_response( previous_response_id = ( server_conversation_tracker.previous_response_id if server_conversation_tracker + and server_conversation_tracker.previous_response_id != "bootstrap" else None ) conversation_id = ( diff --git a/tests/test_agent_runner.py b/tests/test_agent_runner.py index 2deda31bd..7379dd798 100644 --- a/tests/test_agent_runner.py +++ b/tests/test_agent_runner.py @@ -1224,6 +1224,119 @@ async def test_default_send_all_items_streamed(): assert function_result.get("call_id") is not None +@pytest.mark.asyncio +async def test_bootstrap_mode_multi_turn(): + """Test that bootstrap mode (previous_response_id='bootstrap') enables + chaining from the first internal turn.""" + model = FakeModel() + agent = Agent( + name="test", + model=model, + tools=[get_function_tool("test_func", "tool_result")], + ) + + model.add_multiple_turn_outputs( + [ + # First turn: a message and tool call + [get_text_message("a_message"), get_function_tool_call("test_func", '{"arg": "foo"}')], + # Second turn: final text message + [get_text_message("done")], + ] + ) + + result = await Runner.run(agent, input="user_message", previous_response_id="bootstrap") + assert result.final_output == "done" + + # Check the first call + assert model.first_turn_args is not None + first_input = model.first_turn_args["input"] + + # First call should include the original user input + assert isinstance(first_input, list) + assert len(first_input) == 1 # Should contain the user message + + # The input should be the user message + user_message = first_input[0] + assert user_message.get("role") == "user" + assert user_message.get("content") == "user_message" + + # In bootstrap mode, first call should NOT have previous_response_id + assert model.first_turn_args.get("previous_response_id") is None + + # Check the input from the second turn (after function execution) + last_input = model.last_turn_args["input"] + + # In bootstrap mode, the second turn should only contain the tool output + assert isinstance(last_input, list) + assert len(last_input) == 1 # Only the function result + + # The single item should be a tool result + tool_result_item = last_input[0] + assert tool_result_item.get("type") == "function_call_output" + assert tool_result_item.get("call_id") is not None + + # In bootstrap mode, second call should have previous_response_id set to the first response + assert model.last_turn_args.get("previous_response_id") == "resp-789" + + +@pytest.mark.asyncio +async def test_bootstrap_mode_multi_turn_streamed(): + """Test that bootstrap mode (previous_response_id='bootstrap') enables + chaining from the first internal turn (streamed mode).""" + model = FakeModel() + agent = Agent( + name="test", + model=model, + tools=[get_function_tool("test_func", "tool_result")], + ) + + model.add_multiple_turn_outputs( + [ + # First turn: a message and tool call + [get_text_message("a_message"), get_function_tool_call("test_func", '{"arg": "foo"}')], + # Second turn: final text message + [get_text_message("done")], + ] + ) + + result = Runner.run_streamed(agent, input="user_message", previous_response_id="bootstrap") + async for _ in result.stream_events(): + pass + + assert result.final_output == "done" + + # Check the first call + assert model.first_turn_args is not None + first_input = model.first_turn_args["input"] + + # First call should include the original user input + assert isinstance(first_input, list) + assert len(first_input) == 1 # Should contain the user message + + # The input should be the user message + user_message = first_input[0] + assert user_message.get("role") == "user" + assert user_message.get("content") == "user_message" + + # In bootstrap mode, first call should NOT have previous_response_id + assert model.first_turn_args.get("previous_response_id") is None + + # Check the input from the second turn (after function execution) + last_input = model.last_turn_args["input"] + + # In bootstrap mode, the second turn should only contain the tool output + assert isinstance(last_input, list) + assert len(last_input) == 1 # Only the function result + + # The single item should be a tool result + tool_result_item = last_input[0] + assert tool_result_item.get("type") == "function_call_output" + assert tool_result_item.get("call_id") is not None + + # In bootstrap mode, second call should have previous_response_id set to the first response + assert model.last_turn_args.get("previous_response_id") == "resp-789" + + @pytest.mark.asyncio async def test_dynamic_tool_addition_run() -> None: """Test that tools can be added to an agent during a run."""