Skip to content

Commit a3e5bf7

Browse files
committed
fix #2151 shield server-managed handoffs from unsupported history rewrites
1 parent 8db2ed2 commit a3e5bf7

5 files changed

Lines changed: 176 additions & 5 deletions

File tree

src/agents/handoffs/__init__.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,11 +134,17 @@ class Handoff(Generic[TContext, TAgent]):
134134
input history plus ``input_items`` when provided, otherwise it receives ``new_items``. Use
135135
``input_items`` to filter model input while keeping ``new_items`` intact for session history.
136136
IMPORTANT: in streaming mode, we will not stream anything as a result of this function. The
137-
items generated before will already have been streamed.
137+
items generated before will already have been streamed. Server-managed conversations
138+
(`conversation_id`, `previous_response_id`, or `auto_previous_response_id`) do not support
139+
handoff input filters.
138140
"""
139141

140142
nest_handoff_history: bool | None = None
141-
"""Override the run-level ``nest_handoff_history`` behavior for this handoff only."""
143+
"""Override the run-level ``nest_handoff_history`` behavior for this handoff only.
144+
145+
Server-managed conversations (`conversation_id`, `previous_response_id`, or
146+
`auto_previous_response_id`) automatically disable nested handoff history with a warning.
147+
"""
142148

143149
strict_json_schema: bool = True
144150
"""Whether the input JSON schema is in strict mode. We strongly recommend setting this to True

src/agents/run_config.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,13 +100,17 @@ class RunConfig:
100100
handoff_input_filter: HandoffInputFilter | None = None
101101
"""A global input filter to apply to all handoffs. If `Handoff.input_filter` is set, then that
102102
will take precedence. The input filter allows you to edit the inputs that are sent to the new
103-
agent. See the documentation in `Handoff.input_filter` for more details.
103+
agent. See the documentation in `Handoff.input_filter` for more details. Server-managed
104+
conversations (`conversation_id`, `previous_response_id`, or `auto_previous_response_id`)
105+
do not support handoff input filters.
104106
"""
105107

106108
nest_handoff_history: bool = False
107109
"""Opt-in beta: wrap prior run history in a single assistant message before handing off when no
108110
custom input filter is set. This is disabled by default while we stabilize nested handoffs; set
109-
to True to enable the collapsed transcript behavior.
111+
to True to enable the collapsed transcript behavior. Server-managed conversations
112+
(`conversation_id`, `previous_response_id`, or `auto_previous_response_id`) automatically
113+
disable this behavior with a warning.
110114
"""
111115

112116
handoff_history_mapper: HandoffHistoryMapper | None = None

src/agents/run_internal/run_loop.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,6 +614,7 @@ async def _save_stream_items_without_count(
614614
hooks=hooks,
615615
context_wrapper=context_wrapper,
616616
run_config=run_config,
617+
server_manages_conversation=server_conversation_tracker is not None,
617618
run_state=run_state,
618619
)
619620

@@ -1432,6 +1433,7 @@ async def rewind_model_request() -> None:
14321433
context_wrapper=context_wrapper,
14331434
run_config=run_config,
14341435
tool_use_tracker=tool_use_tracker,
1436+
server_manages_conversation=server_conversation_tracker is not None,
14351437
event_queue=streamed_result._event_queue,
14361438
)
14371439

@@ -1558,6 +1560,7 @@ async def run_single_turn(
15581560
context_wrapper=context_wrapper,
15591561
run_config=run_config,
15601562
tool_use_tracker=tool_use_tracker,
1563+
server_manages_conversation=server_conversation_tracker is not None,
15611564
)
15621565

15631566

src/agents/run_internal/turn_resolution.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
from ..agent_output import AgentOutputSchemaBase
4444
from ..agent_tool_state import get_agent_tool_state_scope, peek_agent_tool_run_result
4545
from ..exceptions import ModelBehaviorError, UserError
46-
from ..handoffs import Handoff, HandoffInputData, nest_handoff_history
46+
from ..handoffs import Handoff, HandoffInputData, HandoffInputFilter, nest_handoff_history
4747
from ..items import (
4848
CompactionItem,
4949
HandoffCallItem,
@@ -282,6 +282,38 @@ async def execute_final_output(
282282
)
283283

284284

285+
def _resolve_server_managed_handoff_behavior(
286+
*,
287+
handoff: Handoff[Any, Agent[Any]],
288+
from_agent: Agent[Any],
289+
to_agent: Agent[Any],
290+
run_config: RunConfig,
291+
server_manages_conversation: bool,
292+
input_filter: HandoffInputFilter | None,
293+
should_nest_history: bool,
294+
) -> tuple[HandoffInputFilter | None, bool]:
295+
if not server_manages_conversation:
296+
return input_filter, should_nest_history
297+
298+
if input_filter is not None:
299+
raise UserError(
300+
"Server-managed conversations do not support handoff input filters. "
301+
"Remove Handoff.input_filter or RunConfig.handoff_input_filter, "
302+
"or disable conversation_id, previous_response_id, and auto_previous_response_id."
303+
)
304+
305+
if not should_nest_history:
306+
return input_filter, should_nest_history
307+
308+
logger.warning(
309+
"Server-managed conversations do not support nest_handoff_history for handoff "
310+
"%s -> %s. Disabling nested handoff history and continuing with delta-only input.",
311+
from_agent.name,
312+
to_agent.name,
313+
)
314+
return input_filter, False
315+
316+
285317
async def execute_handoffs(
286318
*,
287319
agent: Agent[TContext],
@@ -293,6 +325,7 @@ async def execute_handoffs(
293325
hooks: RunHooks[TContext],
294326
context_wrapper: RunContextWrapper[TContext],
295327
run_config: RunConfig,
328+
server_manages_conversation: bool = False,
296329
nest_handoff_history_fn: Callable[..., HandoffInputData] | None = None,
297330
) -> SingleStepResult:
298331
"""Execute a handoff and prepare the next turn for the new agent."""
@@ -372,6 +405,15 @@ def nest_history(data: HandoffInputData, mapper: Any | None = None) -> HandoffIn
372405
if handoff_nest_setting is not None
373406
else run_config.nest_handoff_history
374407
)
408+
input_filter, should_nest_history = _resolve_server_managed_handoff_behavior(
409+
handoff=handoff,
410+
from_agent=agent,
411+
to_agent=new_agent,
412+
run_config=run_config,
413+
server_manages_conversation=server_manages_conversation,
414+
input_filter=input_filter,
415+
should_nest_history=should_nest_history,
416+
)
375417
handoff_input_data: HandoffInputData | None = None
376418
session_step_items: list[RunItem] | None = None
377419
if input_filter or should_nest_history:
@@ -507,6 +549,7 @@ async def execute_tools_and_side_effects(
507549
hooks: RunHooks[TContext],
508550
context_wrapper: RunContextWrapper[TContext],
509551
run_config: RunConfig,
552+
server_manages_conversation: bool = False,
510553
) -> SingleStepResult:
511554
"""Run one turn of the loop, coordinating tools, approvals, guardrails, and handoffs."""
512555

@@ -596,6 +639,7 @@ async def execute_tools_and_side_effects(
596639
hooks=hooks,
597640
context_wrapper=context_wrapper,
598641
run_config=run_config,
642+
server_manages_conversation=server_manages_conversation,
599643
)
600644

601645
tool_final_output = await _maybe_finalize_from_tool_results(
@@ -672,6 +716,7 @@ async def resolve_interrupted_turn(
672716
hooks: RunHooks[TContext],
673717
context_wrapper: RunContextWrapper[TContext],
674718
run_config: RunConfig,
719+
server_manages_conversation: bool = False,
675720
run_state: RunState | None = None,
676721
nest_handoff_history_fn: Callable[..., HandoffInputData] | None = None,
677722
) -> SingleStepResult:
@@ -1241,6 +1286,7 @@ def _add_unmatched_pending(approval: ToolApprovalItem) -> None:
12411286
hooks=hooks,
12421287
context_wrapper=context_wrapper,
12431288
run_config=run_config,
1289+
server_manages_conversation=server_manages_conversation,
12441290
nest_handoff_history_fn=nest_history,
12451291
)
12461292

@@ -1695,6 +1741,7 @@ async def get_single_step_result_from_response(
16951741
context_wrapper: RunContextWrapper[TContext],
16961742
run_config: RunConfig,
16971743
tool_use_tracker,
1744+
server_manages_conversation: bool = False,
16981745
event_queue: asyncio.Queue[StreamEvent | QueueCompleteSentinel] | None = None,
16991746
) -> SingleStepResult:
17001747
processed_response = process_model_response(
@@ -1725,4 +1772,5 @@ async def get_single_step_result_from_response(
17251772
hooks=hooks,
17261773
context_wrapper=context_wrapper,
17271774
run_config=run_config,
1775+
server_manages_conversation=server_manages_conversation,
17281776
)

tests/test_agent_runner.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,21 @@ async def run_execute_approved_tools(
142142
return generated_items
143143

144144

145+
async def _run_agent_with_optional_streaming(
146+
agent: Agent[Any],
147+
*,
148+
input: str | list[TResponseInputItem],
149+
streamed: bool,
150+
**kwargs: Any,
151+
):
152+
if streamed:
153+
result = Runner.run_streamed(agent, input=input, **kwargs)
154+
async for _ in result.stream_events():
155+
pass
156+
return result
157+
return await Runner.run(agent, input=input, **kwargs)
158+
159+
145160
def test_set_default_agent_runner_roundtrip():
146161
runner = AgentRunner()
147162
set_default_agent_runner(runner)
@@ -1301,6 +1316,101 @@ async def test_opt_in_handoff_history_accumulates_across_multiple_handoffs():
13011316
assert "user_question" in summary_content
13021317

13031318

1319+
@pytest.mark.asyncio
1320+
@pytest.mark.parametrize("streamed", [False, True], ids=["non_streamed", "streamed"])
1321+
@pytest.mark.parametrize("nest_source", ["run_config", "handoff"], ids=["run_config", "handoff"])
1322+
async def test_server_managed_handoff_history_auto_disables_with_warning(
1323+
streamed: bool,
1324+
nest_source: str,
1325+
caplog: pytest.LogCaptureFixture,
1326+
) -> None:
1327+
triage_model = FakeModel()
1328+
delegate_model = FakeModel()
1329+
delegate = Agent(name="delegate", model=delegate_model)
1330+
1331+
run_config = RunConfig()
1332+
triage_handoffs: list[Agent[Any] | Handoff[Any, Any]]
1333+
if nest_source == "handoff":
1334+
triage_handoffs = [handoff(delegate, nest_handoff_history=True)]
1335+
else:
1336+
triage_handoffs = [delegate]
1337+
run_config = RunConfig(nest_handoff_history=True)
1338+
1339+
triage = Agent(name="triage", model=triage_model, handoffs=triage_handoffs)
1340+
triage_model.add_multiple_turn_outputs(
1341+
[[get_text_message("triage summary"), get_handoff_tool_call(delegate)]]
1342+
)
1343+
delegate_model.add_multiple_turn_outputs([[get_text_message("done")]])
1344+
1345+
with caplog.at_level("WARNING", logger="openai.agents"):
1346+
result = await _run_agent_with_optional_streaming(
1347+
triage,
1348+
input="user_message",
1349+
streamed=streamed,
1350+
run_config=run_config,
1351+
auto_previous_response_id=True,
1352+
)
1353+
1354+
assert result.final_output == "done"
1355+
assert "do not support nest_handoff_history" in caplog.text
1356+
assert delegate_model.first_turn_args is not None
1357+
delegate_input = delegate_model.first_turn_args["input"]
1358+
assert isinstance(delegate_input, list)
1359+
assert len(delegate_input) == 1
1360+
handoff_output = delegate_input[0]
1361+
assert handoff_output.get("type") == "function_call_output"
1362+
assert "delegate" in str(handoff_output.get("output"))
1363+
assert not any(
1364+
isinstance(item, dict)
1365+
and item.get("role") == "assistant"
1366+
and "<CONVERSATION HISTORY>" in str(item.get("content"))
1367+
for item in delegate_input
1368+
)
1369+
1370+
1371+
@pytest.mark.asyncio
1372+
@pytest.mark.parametrize("streamed", [False, True], ids=["non_streamed", "streamed"])
1373+
@pytest.mark.parametrize("filter_source", ["run_config", "handoff"], ids=["run_config", "handoff"])
1374+
async def test_server_managed_handoff_input_filters_still_raise(
1375+
streamed: bool,
1376+
filter_source: str,
1377+
) -> None:
1378+
triage_model = FakeModel()
1379+
delegate_model = FakeModel()
1380+
delegate = Agent(name="delegate", model=delegate_model)
1381+
1382+
def passthrough_filter(data: HandoffInputData) -> HandoffInputData:
1383+
return data
1384+
1385+
run_config = RunConfig()
1386+
triage_handoffs: list[Agent[Any] | Handoff[Any, Any]]
1387+
if filter_source == "handoff":
1388+
triage_handoffs = [handoff(delegate, input_filter=passthrough_filter)]
1389+
else:
1390+
triage_handoffs = [delegate]
1391+
run_config = RunConfig(handoff_input_filter=passthrough_filter)
1392+
1393+
triage = Agent(name="triage", model=triage_model, handoffs=triage_handoffs)
1394+
triage_model.add_multiple_turn_outputs(
1395+
[[get_text_message("triage summary"), get_handoff_tool_call(delegate)]]
1396+
)
1397+
delegate_model.add_multiple_turn_outputs([[get_text_message("done")]])
1398+
1399+
with pytest.raises(
1400+
UserError,
1401+
match="Server-managed conversations do not support handoff input filters",
1402+
):
1403+
await _run_agent_with_optional_streaming(
1404+
triage,
1405+
input="user_message",
1406+
streamed=streamed,
1407+
run_config=run_config,
1408+
auto_previous_response_id=True,
1409+
)
1410+
1411+
assert delegate_model.first_turn_args is None
1412+
1413+
13041414
@pytest.mark.asyncio
13051415
async def test_async_input_filter_supported():
13061416
# DO NOT rename this without updating pyproject.toml

0 commit comments

Comments
 (0)