Skip to content

Commit 6ab83d4

Browse files
authored
fix: #2211 Move nested handoffs to opt-in feature (#2272)
1 parent 0660582 commit 6ab83d4

File tree

4 files changed

+145
-9
lines changed

4 files changed

+145
-9
lines changed

src/agents/run.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -210,10 +210,10 @@ class RunConfig:
210210
agent. See the documentation in `Handoff.input_filter` for more details.
211211
"""
212212

213-
nest_handoff_history: bool = True
214-
"""Wrap prior run history in a single assistant message before handing off when no custom
215-
input filter is set. Set to False to preserve the raw transcript behavior from previous
216-
releases.
213+
nest_handoff_history: bool = False
214+
"""Opt-in beta: wrap prior run history in a single assistant message before handing off when no
215+
custom input filter is set. This is disabled by default while we stabilize nested handoffs; set
216+
to True to enable the collapsed transcript behavior.
217217
"""
218218

219219
handoff_history_mapper: HandoffHistoryMapper | None = None

tests/test_agent_runner.py

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,7 @@ async def test_structured_output():
288288
get_text_input_item("user_message"),
289289
get_text_input_item("another_message"),
290290
],
291+
run_config=RunConfig(nest_handoff_history=True),
291292
)
292293

293294
assert result.final_output == Foo(bar="baz")
@@ -345,7 +346,36 @@ async def test_handoff_filters():
345346

346347

347348
@pytest.mark.asyncio
348-
async def test_default_handoff_history_nested_and_filters_respected():
349+
async def test_handoff_history_not_nested_by_default():
350+
triage_model = FakeModel()
351+
delegate_model = FakeModel()
352+
353+
delegate = Agent(name="delegate", model=delegate_model)
354+
triage = Agent(name="triage", model=triage_model, handoffs=[delegate])
355+
356+
triage_model.add_multiple_turn_outputs(
357+
[[get_text_message("triage summary"), get_handoff_tool_call(delegate)]]
358+
)
359+
delegate_model.add_multiple_turn_outputs([[get_text_message("resolution")]])
360+
361+
result = await Runner.run(triage, input="user_message")
362+
363+
assert result.final_output == "resolution"
364+
assert delegate_model.first_turn_args is not None
365+
delegate_input = delegate_model.first_turn_args["input"]
366+
assert isinstance(delegate_input, list)
367+
delegate_messages = [item for item in delegate_input if isinstance(item, dict)]
368+
assert delegate_messages
369+
assert _as_message(delegate_messages[0])["role"] == "user"
370+
assert not any(
371+
"<CONVERSATION HISTORY>" in str(item.get("content", ""))
372+
for item in delegate_messages
373+
if isinstance(item.get("content"), str)
374+
)
375+
376+
377+
@pytest.mark.asyncio
378+
async def test_handoff_history_nested_and_filters_respected_when_enabled():
349379
model = FakeModel()
350380
agent_1 = Agent(
351381
name="delegate",
@@ -364,7 +394,9 @@ async def test_default_handoff_history_nested_and_filters_respected():
364394
]
365395
)
366396

367-
result = await Runner.run(agent_2, input="user_message")
397+
result = await Runner.run(
398+
agent_2, input="user_message", run_config=RunConfig(nest_handoff_history=True)
399+
)
368400

369401
assert isinstance(result.input, list)
370402
assert len(result.input) == 1
@@ -395,14 +427,16 @@ def passthrough_filter(data: HandoffInputData) -> HandoffInputData:
395427
]
396428
)
397429

398-
filtered_result = await Runner.run(triage_with_filter, input="user_message")
430+
filtered_result = await Runner.run(
431+
triage_with_filter, input="user_message", run_config=RunConfig(nest_handoff_history=True)
432+
)
399433

400434
assert isinstance(filtered_result.input, str)
401435
assert filtered_result.input == "user_message"
402436

403437

404438
@pytest.mark.asyncio
405-
async def test_default_handoff_history_accumulates_across_multiple_handoffs():
439+
async def test_handoff_history_accumulates_across_multiple_handoffs_when_enabled():
406440
triage_model = FakeModel()
407441
delegate_model = FakeModel()
408442
closer_model = FakeModel()
@@ -419,7 +453,9 @@ async def test_default_handoff_history_accumulates_across_multiple_handoffs():
419453
)
420454
closer_model.add_multiple_turn_outputs([[get_text_message("resolution")]])
421455

422-
result = await Runner.run(triage, input="user_question")
456+
result = await Runner.run(
457+
triage, input="user_question", run_config=RunConfig(nest_handoff_history=True)
458+
)
423459

424460
assert result.final_output == "resolution"
425461
assert closer_model.first_turn_args is not None

tests/test_agent_runner_streamed.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,7 @@ async def test_structured_output():
289289
get_text_input_item("user_message"),
290290
get_text_input_item("another_message"),
291291
],
292+
run_config=RunConfig(nest_handoff_history=True),
292293
)
293294
async for _ in result.stream_events():
294295
pass
@@ -771,6 +772,7 @@ async def test_streaming_events():
771772
get_text_input_item("user_message"),
772773
get_text_input_item("another_message"),
773774
],
775+
run_config=RunConfig(nest_handoff_history=True),
774776
)
775777
async for event in result.stream_events():
776778
event_counts[event.type] = event_counts.get(event.type, 0) + 1

tests/test_run_step_processing.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,102 @@ async def test_handoffs_parsed_correctly():
211211
assert handoff_agent == agent_1
212212

213213

214+
@pytest.mark.asyncio
215+
async def test_history_nesting_disabled_by_default(monkeypatch: pytest.MonkeyPatch):
216+
source_agent = Agent(name="source")
217+
target_agent = Agent(name="target")
218+
default_handoff = handoff(target_agent)
219+
tool_call = cast(ResponseFunctionToolCall, get_handoff_tool_call(target_agent))
220+
run_handoffs = [ToolRunHandoff(handoff=default_handoff, tool_call=tool_call)]
221+
run_config = RunConfig()
222+
context_wrapper = RunContextWrapper(context=None)
223+
hooks = RunHooks()
224+
original_input = [get_text_input_item("hello")]
225+
pre_step_items: list[RunItem] = []
226+
new_step_items: list[RunItem] = []
227+
new_response = ModelResponse(output=[tool_call], usage=Usage(), response_id=None)
228+
229+
def fail_if_called(
230+
_handoff_input_data: HandoffInputData,
231+
*,
232+
history_mapper: Any,
233+
) -> HandoffInputData:
234+
_ = history_mapper
235+
raise AssertionError("nest_handoff_history should be opt-in.")
236+
237+
monkeypatch.setattr("agents._run_impl.nest_handoff_history", fail_if_called)
238+
239+
result = await RunImpl.execute_handoffs(
240+
agent=source_agent,
241+
original_input=list(original_input),
242+
pre_step_items=pre_step_items,
243+
new_step_items=new_step_items,
244+
new_response=new_response,
245+
run_handoffs=run_handoffs,
246+
hooks=hooks,
247+
context_wrapper=context_wrapper,
248+
run_config=run_config,
249+
)
250+
251+
assert result.original_input == original_input
252+
253+
254+
@pytest.mark.asyncio
255+
async def test_run_level_history_nesting_can_be_enabled(monkeypatch: pytest.MonkeyPatch):
256+
source_agent = Agent(name="source")
257+
target_agent = Agent(name="target")
258+
default_handoff = handoff(target_agent)
259+
tool_call = cast(ResponseFunctionToolCall, get_handoff_tool_call(target_agent))
260+
run_handoffs = [ToolRunHandoff(handoff=default_handoff, tool_call=tool_call)]
261+
run_config = RunConfig(nest_handoff_history=True)
262+
context_wrapper = RunContextWrapper(context=None)
263+
hooks = RunHooks()
264+
original_input = [get_text_input_item("hello")]
265+
pre_step_items: list[RunItem] = []
266+
new_step_items: list[RunItem] = []
267+
new_response = ModelResponse(output=[tool_call], usage=Usage(), response_id=None)
268+
269+
calls: list[HandoffInputData] = []
270+
271+
def fake_nest(
272+
handoff_input_data: HandoffInputData,
273+
*,
274+
history_mapper: Any,
275+
) -> HandoffInputData:
276+
_ = history_mapper
277+
calls.append(handoff_input_data)
278+
return handoff_input_data.clone(
279+
input_history=(
280+
{
281+
"role": "assistant",
282+
"content": "nested",
283+
},
284+
)
285+
)
286+
287+
monkeypatch.setattr("agents._run_impl.nest_handoff_history", fake_nest)
288+
289+
result = await RunImpl.execute_handoffs(
290+
agent=source_agent,
291+
original_input=list(original_input),
292+
pre_step_items=pre_step_items,
293+
new_step_items=new_step_items,
294+
new_response=new_response,
295+
run_handoffs=run_handoffs,
296+
hooks=hooks,
297+
context_wrapper=context_wrapper,
298+
run_config=run_config,
299+
)
300+
301+
assert calls
302+
assert result.original_input == [
303+
{
304+
"role": "assistant",
305+
"content": "nested",
306+
}
307+
]
308+
309+
214310
@pytest.mark.asyncio
215311
async def test_handoff_can_disable_run_level_history_nesting(monkeypatch: pytest.MonkeyPatch):
216312
source_agent = Agent(name="source")
@@ -233,6 +329,7 @@ def fake_nest(
233329
*,
234330
history_mapper: Any,
235331
) -> HandoffInputData:
332+
_ = history_mapper
236333
calls.append(handoff_input_data)
237334
return handoff_input_data
238335

@@ -274,6 +371,7 @@ def fake_nest(
274371
*,
275372
history_mapper: Any,
276373
) -> HandoffInputData:
374+
_ = history_mapper
277375
return handoff_input_data.clone(
278376
input_history=(
279377
{

0 commit comments

Comments
 (0)