Skip to content

Commit 8800212

Browse files
committed
fix #2151 shield server-managed handoffs from unsupported history rewrites
1 parent fb67680 commit 8800212

File tree

5 files changed

+176
-5
lines changed

5 files changed

+176
-5
lines changed

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
@@ -631,6 +631,7 @@ async def _save_stream_items_without_count(
631631
hooks=hooks,
632632
context_wrapper=context_wrapper,
633633
run_config=run_config,
634+
server_manages_conversation=server_conversation_tracker is not None,
634635
run_state=run_state,
635636
)
636637

@@ -1449,6 +1450,7 @@ async def rewind_model_request() -> None:
14491450
context_wrapper=context_wrapper,
14501451
run_config=run_config,
14511452
tool_use_tracker=tool_use_tracker,
1453+
server_manages_conversation=server_conversation_tracker is not None,
14521454
event_queue=streamed_result._event_queue,
14531455
)
14541456

@@ -1575,6 +1577,7 @@ async def run_single_turn(
15751577
context_wrapper=context_wrapper,
15761578
run_config=run_config,
15771579
tool_use_tracker=tool_use_tracker,
1580+
server_manages_conversation=server_conversation_tracker is not None,
15781581
)
15791582

15801583

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
@@ -145,6 +145,21 @@ async def run_execute_approved_tools(
145145
return generated_items
146146

147147

148+
async def _run_agent_with_optional_streaming(
149+
agent: Agent[Any],
150+
*,
151+
input: str | list[TResponseInputItem],
152+
streamed: bool,
153+
**kwargs: Any,
154+
):
155+
if streamed:
156+
result = Runner.run_streamed(agent, input=input, **kwargs)
157+
async for _ in result.stream_events():
158+
pass
159+
return result
160+
return await Runner.run(agent, input=input, **kwargs)
161+
162+
148163
def test_set_default_agent_runner_roundtrip():
149164
runner = AgentRunner()
150165
set_default_agent_runner(runner)
@@ -1343,6 +1358,101 @@ async def test_opt_in_handoff_history_accumulates_across_multiple_handoffs():
13431358
assert "user_question" in summary_content
13441359

13451360

1361+
@pytest.mark.asyncio
1362+
@pytest.mark.parametrize("streamed", [False, True], ids=["non_streamed", "streamed"])
1363+
@pytest.mark.parametrize("nest_source", ["run_config", "handoff"], ids=["run_config", "handoff"])
1364+
async def test_server_managed_handoff_history_auto_disables_with_warning(
1365+
streamed: bool,
1366+
nest_source: str,
1367+
caplog: pytest.LogCaptureFixture,
1368+
) -> None:
1369+
triage_model = FakeModel()
1370+
delegate_model = FakeModel()
1371+
delegate = Agent(name="delegate", model=delegate_model)
1372+
1373+
run_config = RunConfig()
1374+
triage_handoffs: list[Agent[Any] | Handoff[Any, Any]]
1375+
if nest_source == "handoff":
1376+
triage_handoffs = [handoff(delegate, nest_handoff_history=True)]
1377+
else:
1378+
triage_handoffs = [delegate]
1379+
run_config = RunConfig(nest_handoff_history=True)
1380+
1381+
triage = Agent(name="triage", model=triage_model, handoffs=triage_handoffs)
1382+
triage_model.add_multiple_turn_outputs(
1383+
[[get_text_message("triage summary"), get_handoff_tool_call(delegate)]]
1384+
)
1385+
delegate_model.add_multiple_turn_outputs([[get_text_message("done")]])
1386+
1387+
with caplog.at_level("WARNING", logger="openai.agents"):
1388+
result = await _run_agent_with_optional_streaming(
1389+
triage,
1390+
input="user_message",
1391+
streamed=streamed,
1392+
run_config=run_config,
1393+
auto_previous_response_id=True,
1394+
)
1395+
1396+
assert result.final_output == "done"
1397+
assert "do not support nest_handoff_history" in caplog.text
1398+
assert delegate_model.first_turn_args is not None
1399+
delegate_input = delegate_model.first_turn_args["input"]
1400+
assert isinstance(delegate_input, list)
1401+
assert len(delegate_input) == 1
1402+
handoff_output = delegate_input[0]
1403+
assert handoff_output.get("type") == "function_call_output"
1404+
assert "delegate" in str(handoff_output.get("output"))
1405+
assert not any(
1406+
isinstance(item, dict)
1407+
and item.get("role") == "assistant"
1408+
and "<CONVERSATION HISTORY>" in str(item.get("content"))
1409+
for item in delegate_input
1410+
)
1411+
1412+
1413+
@pytest.mark.asyncio
1414+
@pytest.mark.parametrize("streamed", [False, True], ids=["non_streamed", "streamed"])
1415+
@pytest.mark.parametrize("filter_source", ["run_config", "handoff"], ids=["run_config", "handoff"])
1416+
async def test_server_managed_handoff_input_filters_still_raise(
1417+
streamed: bool,
1418+
filter_source: str,
1419+
) -> None:
1420+
triage_model = FakeModel()
1421+
delegate_model = FakeModel()
1422+
delegate = Agent(name="delegate", model=delegate_model)
1423+
1424+
def passthrough_filter(data: HandoffInputData) -> HandoffInputData:
1425+
return data
1426+
1427+
run_config = RunConfig()
1428+
triage_handoffs: list[Agent[Any] | Handoff[Any, Any]]
1429+
if filter_source == "handoff":
1430+
triage_handoffs = [handoff(delegate, input_filter=passthrough_filter)]
1431+
else:
1432+
triage_handoffs = [delegate]
1433+
run_config = RunConfig(handoff_input_filter=passthrough_filter)
1434+
1435+
triage = Agent(name="triage", model=triage_model, handoffs=triage_handoffs)
1436+
triage_model.add_multiple_turn_outputs(
1437+
[[get_text_message("triage summary"), get_handoff_tool_call(delegate)]]
1438+
)
1439+
delegate_model.add_multiple_turn_outputs([[get_text_message("done")]])
1440+
1441+
with pytest.raises(
1442+
UserError,
1443+
match="Server-managed conversations do not support handoff input filters",
1444+
):
1445+
await _run_agent_with_optional_streaming(
1446+
triage,
1447+
input="user_message",
1448+
streamed=streamed,
1449+
run_config=run_config,
1450+
auto_previous_response_id=True,
1451+
)
1452+
1453+
assert delegate_model.first_turn_args is None
1454+
1455+
13461456
@pytest.mark.asyncio
13471457
async def test_async_input_filter_supported():
13481458
# DO NOT rename this without updating pyproject.toml

0 commit comments

Comments
 (0)