Skip to content

Commit f3ae68b

Browse files
committed
feat: add previous_response_id="bootstrap" to enable chaining for internal calls on the first turn
1 parent a7c539f commit f3ae68b

File tree

3 files changed

+121
-1
lines changed

3 files changed

+121
-1
lines changed

docs/running_agents.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ async def main():
189189
# California
190190
```
191191

192+
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.
192193

193194
## Long running agents & human-in-the-loop
194195

src/agents/run.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,11 @@ class CallModelData(Generic[TContext]):
129129
@dataclass
130130
class _ServerConversationTracker:
131131
"""Tracks server-side conversation state for either conversation_id or
132-
previous_response_id modes."""
132+
previous_response_id modes.
133+
134+
Special value 'bootstrap' for previous_response_id enables chaining for internal
135+
function calls in the first turn, even when there's no actual previous response ID yet.
136+
"""
133137

134138
conversation_id: str | None = None
135139
previous_response_id: str | None = None
@@ -1360,6 +1364,7 @@ async def _run_single_turn_streamed(
13601364
previous_response_id = (
13611365
server_conversation_tracker.previous_response_id
13621366
if server_conversation_tracker
1367+
and server_conversation_tracker.previous_response_id != "bootstrap"
13631368
else None
13641369
)
13651370
conversation_id = (
@@ -1798,6 +1803,7 @@ async def _get_new_response(
17981803
previous_response_id = (
17991804
server_conversation_tracker.previous_response_id
18001805
if server_conversation_tracker
1806+
and server_conversation_tracker.previous_response_id != "bootstrap"
18011807
else None
18021808
)
18031809
conversation_id = (

tests/test_agent_runner.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1224,6 +1224,119 @@ async def test_default_send_all_items_streamed():
12241224
assert function_result.get("call_id") is not None
12251225

12261226

1227+
@pytest.mark.asyncio
1228+
async def test_bootstrap_mode_multi_turn():
1229+
"""Test that bootstrap mode (previous_response_id='bootstrap') enables
1230+
chaining from the first internal turn."""
1231+
model = FakeModel()
1232+
agent = Agent(
1233+
name="test",
1234+
model=model,
1235+
tools=[get_function_tool("test_func", "tool_result")],
1236+
)
1237+
1238+
model.add_multiple_turn_outputs(
1239+
[
1240+
# First turn: a message and tool call
1241+
[get_text_message("a_message"), get_function_tool_call("test_func", '{"arg": "foo"}')],
1242+
# Second turn: final text message
1243+
[get_text_message("done")],
1244+
]
1245+
)
1246+
1247+
result = await Runner.run(agent, input="user_message", previous_response_id="bootstrap")
1248+
assert result.final_output == "done"
1249+
1250+
# Check the first call
1251+
assert model.first_turn_args is not None
1252+
first_input = model.first_turn_args["input"]
1253+
1254+
# First call should include the original user input
1255+
assert isinstance(first_input, list)
1256+
assert len(first_input) == 1 # Should contain the user message
1257+
1258+
# The input should be the user message
1259+
user_message = first_input[0]
1260+
assert user_message.get("role") == "user"
1261+
assert user_message.get("content") == "user_message"
1262+
1263+
# In bootstrap mode, first call should NOT have previous_response_id
1264+
assert model.first_turn_args.get("previous_response_id") is None
1265+
1266+
# Check the input from the second turn (after function execution)
1267+
last_input = model.last_turn_args["input"]
1268+
1269+
# In bootstrap mode, the second turn should only contain the tool output
1270+
assert isinstance(last_input, list)
1271+
assert len(last_input) == 1 # Only the function result
1272+
1273+
# The single item should be a tool result
1274+
tool_result_item = last_input[0]
1275+
assert tool_result_item.get("type") == "function_call_output"
1276+
assert tool_result_item.get("call_id") is not None
1277+
1278+
# In bootstrap mode, second call should have previous_response_id set to the first response
1279+
assert model.last_turn_args.get("previous_response_id") == "resp-789"
1280+
1281+
1282+
@pytest.mark.asyncio
1283+
async def test_bootstrap_mode_multi_turn_streamed():
1284+
"""Test that bootstrap mode (previous_response_id='bootstrap') enables
1285+
chaining from the first internal turn (streamed mode)."""
1286+
model = FakeModel()
1287+
agent = Agent(
1288+
name="test",
1289+
model=model,
1290+
tools=[get_function_tool("test_func", "tool_result")],
1291+
)
1292+
1293+
model.add_multiple_turn_outputs(
1294+
[
1295+
# First turn: a message and tool call
1296+
[get_text_message("a_message"), get_function_tool_call("test_func", '{"arg": "foo"}')],
1297+
# Second turn: final text message
1298+
[get_text_message("done")],
1299+
]
1300+
)
1301+
1302+
result = Runner.run_streamed(agent, input="user_message", previous_response_id="bootstrap")
1303+
async for _ in result.stream_events():
1304+
pass
1305+
1306+
assert result.final_output == "done"
1307+
1308+
# Check the first call
1309+
assert model.first_turn_args is not None
1310+
first_input = model.first_turn_args["input"]
1311+
1312+
# First call should include the original user input
1313+
assert isinstance(first_input, list)
1314+
assert len(first_input) == 1 # Should contain the user message
1315+
1316+
# The input should be the user message
1317+
user_message = first_input[0]
1318+
assert user_message.get("role") == "user"
1319+
assert user_message.get("content") == "user_message"
1320+
1321+
# In bootstrap mode, first call should NOT have previous_response_id
1322+
assert model.first_turn_args.get("previous_response_id") is None
1323+
1324+
# Check the input from the second turn (after function execution)
1325+
last_input = model.last_turn_args["input"]
1326+
1327+
# In bootstrap mode, the second turn should only contain the tool output
1328+
assert isinstance(last_input, list)
1329+
assert len(last_input) == 1 # Only the function result
1330+
1331+
# The single item should be a tool result
1332+
tool_result_item = last_input[0]
1333+
assert tool_result_item.get("type") == "function_call_output"
1334+
assert tool_result_item.get("call_id") is not None
1335+
1336+
# In bootstrap mode, second call should have previous_response_id set to the first response
1337+
assert model.last_turn_args.get("previous_response_id") == "resp-789"
1338+
1339+
12271340
@pytest.mark.asyncio
12281341
async def test_dynamic_tool_addition_run() -> None:
12291342
"""Test that tools can be added to an agent during a run."""

0 commit comments

Comments
 (0)