From 2ddabfe993f6532b176bc7f98e1c827ac582f4a0 Mon Sep 17 00:00:00 2001 From: liweiguang Date: Tue, 10 Feb 2026 14:25:18 +0800 Subject: [PATCH 1/4] fix(run): reject incompatible handoff settings with server conversation --- src/agents/run.py | 29 +++++ .../run_internal/agent_runner_helpers.py | 99 ++++++++++++++++ tests/test_agent_runner.py | 106 ++++++++++++++++++ 3 files changed, 234 insertions(+) diff --git a/src/agents/run.py b/src/agents/run.py index f239a1ef9..0fff68418 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -54,6 +54,7 @@ save_turn_items_if_needed, should_cancel_parallel_model_task_on_input_guardrail_trip, update_run_state_for_interruption, + validate_server_conversation_handoff_settings, validate_session_conversation_settings, ) from .run_internal.approvals import approvals_from_step @@ -438,6 +439,13 @@ async def run( previous_response_id=previous_response_id, auto_previous_response_id=auto_previous_response_id, ) + validate_server_conversation_handoff_settings( + run_state._current_agent if run_state._current_agent else starting_agent, + run_config, + conversation_id=conversation_id, + previous_response_id=previous_response_id, + auto_previous_response_id=auto_previous_response_id, + ) starting_input = run_state._original_input original_user_input = copy_input_items(run_state._original_input) prepared_input = normalize_resumed_input(original_user_input) @@ -459,6 +467,13 @@ async def run( previous_response_id=previous_response_id, auto_previous_response_id=auto_previous_response_id, ) + validate_server_conversation_handoff_settings( + starting_agent, + run_config, + conversation_id=conversation_id, + previous_response_id=previous_response_id, + auto_previous_response_id=auto_previous_response_id, + ) server_manages_conversation = ( conversation_id is not None @@ -1422,6 +1437,13 @@ def run_streamed( previous_response_id=previous_response_id, auto_previous_response_id=auto_previous_response_id, ) + validate_server_conversation_handoff_settings( + run_state._current_agent if run_state._current_agent else starting_agent, + run_config, + conversation_id=conversation_id, + previous_response_id=previous_response_id, + auto_previous_response_id=auto_previous_response_id, + ) # When resuming, use the original_input from state. # primeFromState will mark items as sent so prepareInput skips them starting_input = run_state._original_input @@ -1457,6 +1479,13 @@ def run_streamed( previous_response_id=previous_response_id, auto_previous_response_id=auto_previous_response_id, ) + validate_server_conversation_handoff_settings( + starting_agent, + run_config, + conversation_id=conversation_id, + previous_response_id=previous_response_id, + auto_previous_response_id=auto_previous_response_id, + ) context_wrapper = ensure_context_wrapper(context) # input_for_state is the same as input_for_result here input_for_state = input_for_result diff --git a/src/agents/run_internal/agent_runner_helpers.py b/src/agents/run_internal/agent_runner_helpers.py index 3498b4572..02b0886f9 100644 --- a/src/agents/run_internal/agent_runner_helpers.py +++ b/src/agents/run_internal/agent_runner_helpers.py @@ -7,6 +7,7 @@ from ..agent import Agent from ..exceptions import UserError from ..guardrail import InputGuardrailResult +from ..handoffs import Handoff from ..items import ModelResponse, RunItem, ToolApprovalItem, TResponseInputItem from ..memory import Session from ..result import RunResult @@ -45,6 +46,7 @@ "save_turn_items_if_needed", "should_cancel_parallel_model_task_on_input_guardrail_trip", "update_run_state_for_interruption", + "validate_server_conversation_handoff_settings", ] _PARALLEL_INPUT_GUARDRAIL_CANCEL_PATCH_ID = ( @@ -103,6 +105,103 @@ def validate_session_conversation_settings( ) +def _is_remove_all_tools_filter(filter_fn: object | None) -> bool: + if filter_fn is None: + return False + module_name = getattr(filter_fn, "__module__", "") + function_name = getattr(filter_fn, "__name__", "") + return function_name == "remove_all_tools" and module_name.endswith( + "agents.extensions.handoff_filters" + ) + + +def _collect_handoff_edges( + agent: Agent[Any], +) -> list[tuple[Agent[Any], Handoff[Any, Agent[Any]] | None]]: + edges: list[tuple[Agent[Any], Handoff[Any, Agent[Any]] | None]] = [] + visited_agents: set[int] = set() + queue: list[Agent[Any]] = [agent] + + while queue: + current = queue.pop(0) + current_id = id(current) + if current_id in visited_agents: + continue + visited_agents.add(current_id) + + for handoff_or_agent in current.handoffs: + handoff: Handoff[Any, Agent[Any]] | None = None + target: Agent[Any] + if isinstance(handoff_or_agent, Agent): + target = handoff_or_agent + elif isinstance(handoff_or_agent, Handoff): + handoff = handoff_or_agent + target_ref = handoff._agent_ref() if handoff._agent_ref is not None else None + if not isinstance(target_ref, Agent): + continue + target = target_ref + else: + continue + + edges.append((target, handoff)) + queue.append(target) + + return edges + + +def validate_server_conversation_handoff_settings( + agent: Agent[Any], + run_config: RunConfig, + *, + conversation_id: str | None, + previous_response_id: str | None, + auto_previous_response_id: bool, +) -> None: + server_manages_conversation = ( + conversation_id is not None + or previous_response_id is not None + or auto_previous_response_id + ) + if not server_manages_conversation: + return + + has_remove_all_tools = False + has_nested_handoff_history = False + for _, handoff in _collect_handoff_edges(agent): + input_filter = ( + handoff.input_filter + if handoff and handoff.input_filter is not None + else run_config.handoff_input_filter + ) + if _is_remove_all_tools_filter(input_filter): + has_remove_all_tools = True + + handoff_nest_handoff_history = handoff.nest_handoff_history if handoff else None + should_nest_history = ( + handoff_nest_handoff_history + if handoff_nest_handoff_history is not None + else run_config.nest_handoff_history + ) + if input_filter is None and should_nest_history: + has_nested_handoff_history = True + + if not has_remove_all_tools and not has_nested_handoff_history: + return + + conflict_parts: list[str] = [] + if has_nested_handoff_history: + conflict_parts.append("nest_handoff_history") + if has_remove_all_tools: + conflict_parts.append("remove_all_tools") + + raise UserError( + "Server-managed conversation (conversation_id / previous_response_id / " + "auto_previous_response_id) is incompatible with handoff settings: " + + ", ".join(conflict_parts) + + ". Use nest_handoff_history=False and avoid remove_all_tools." + ) + + def resolve_trace_settings( *, run_state: RunState[TContext] | None, diff --git a/tests/test_agent_runner.py b/tests/test_agent_runner.py index 160b35d0e..550bb75ef 100644 --- a/tests/test_agent_runner.py +++ b/tests/test_agent_runner.py @@ -36,6 +36,7 @@ ) from agents.agent import ToolsToFinalOutputResult from agents.computer import Computer +from agents.extensions.handoff_filters import remove_all_tools from agents.items import ( HandoffOutputItem, ModelResponse, @@ -2096,6 +2097,111 @@ async def test_run_streamed_rejects_session_with_resumed_conversation_state(): Runner.run_streamed(agent, state, session=session) +@pytest.mark.asyncio +async def test_run_rejects_server_conversation_with_nested_handoff_history(): + model = FakeModel() + delegate = Agent(name="delegate", model=model) + triage = Agent(name="triage", model=model, handoffs=[delegate]) + + with pytest.raises(UserError, match="Server-managed conversation"): + await Runner.run( + triage, + input="test", + conversation_id="conv-test", + run_config=RunConfig(nest_handoff_history=True), + ) + + +@pytest.mark.asyncio +async def test_run_rejects_server_conversation_with_remove_all_tools_filter(): + model = FakeModel() + delegate = Agent(name="delegate", model=model) + triage = Agent( + name="triage", + model=model, + handoffs=[handoff(delegate, input_filter=remove_all_tools)], + ) + + with pytest.raises(UserError, match="Server-managed conversation"): + await Runner.run( + triage, + input="test", + conversation_id="conv-test", + ) + + +@pytest.mark.asyncio +async def test_run_rejects_server_conversation_with_global_remove_all_tools_filter(): + model = FakeModel() + delegate = Agent(name="delegate", model=model) + triage = Agent(name="triage", model=model, handoffs=[delegate]) + + with pytest.raises(UserError, match="Server-managed conversation"): + await Runner.run( + triage, + input="test", + conversation_id="conv-test", + run_config=RunConfig(handoff_input_filter=remove_all_tools), + ) + + +@pytest.mark.asyncio +async def test_run_streamed_rejects_server_conversation_with_nested_handoff_history(): + model = FakeModel() + delegate = Agent(name="delegate", model=model) + triage = Agent(name="triage", model=model, handoffs=[delegate]) + + with pytest.raises(UserError, match="Server-managed conversation"): + Runner.run_streamed( + triage, + input="test", + conversation_id="conv-test", + run_config=RunConfig(nest_handoff_history=True), + ) + + +@pytest.mark.asyncio +async def test_run_rejects_resumed_server_conversation_with_nested_handoff_history(): + model = FakeModel() + delegate = Agent(name="delegate", model=model) + triage = Agent(name="triage", model=model, handoffs=[delegate]) + context_wrapper = RunContextWrapper(context=None) + state = RunState( + context=context_wrapper, + original_input="hello", + starting_agent=triage, + conversation_id="conv-test", + ) + + with pytest.raises(UserError, match="Server-managed conversation"): + await Runner.run( + triage, + state, + run_config=RunConfig(nest_handoff_history=True), + ) + + +@pytest.mark.asyncio +async def test_run_streamed_rejects_resumed_server_conversation_with_nested_handoff_history(): + model = FakeModel() + delegate = Agent(name="delegate", model=model) + triage = Agent(name="triage", model=model, handoffs=[delegate]) + context_wrapper = RunContextWrapper(context=None) + state = RunState( + context=context_wrapper, + original_input="hello", + starting_agent=triage, + conversation_id="conv-test", + ) + + with pytest.raises(UserError, match="Server-managed conversation"): + Runner.run_streamed( + triage, + state, + run_config=RunConfig(nest_handoff_history=True), + ) + + @pytest.mark.asyncio async def test_multi_turn_previous_response_id_passed_between_runs(): """Test that previous_response_id is passed to the model on subsequent runs.""" From b00b03973c02c4ccc00f46565ba9f828664185c7 Mon Sep 17 00:00:00 2001 From: liweiguang Date: Tue, 10 Feb 2026 14:29:11 +0800 Subject: [PATCH 2/4] style(run): apply formatter for handoff validation helper --- src/agents/run_internal/agent_runner_helpers.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/agents/run_internal/agent_runner_helpers.py b/src/agents/run_internal/agent_runner_helpers.py index 02b0886f9..82a7a71da 100644 --- a/src/agents/run_internal/agent_runner_helpers.py +++ b/src/agents/run_internal/agent_runner_helpers.py @@ -158,9 +158,7 @@ def validate_server_conversation_handoff_settings( auto_previous_response_id: bool, ) -> None: server_manages_conversation = ( - conversation_id is not None - or previous_response_id is not None - or auto_previous_response_id + conversation_id is not None or previous_response_id is not None or auto_previous_response_id ) if not server_manages_conversation: return From 95b4213ad1cfa481402a1e89edbec4f7570f5624 Mon Sep 17 00:00:00 2001 From: liweiguang Date: Tue, 10 Feb 2026 14:41:04 +0800 Subject: [PATCH 3/4] fix(run): skip disabled handoffs in server conversation validation --- .../run_internal/agent_runner_helpers.py | 7 ++++ tests/test_agent_runner.py | 39 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/agents/run_internal/agent_runner_helpers.py b/src/agents/run_internal/agent_runner_helpers.py index 82a7a71da..cdc139a2e 100644 --- a/src/agents/run_internal/agent_runner_helpers.py +++ b/src/agents/run_internal/agent_runner_helpers.py @@ -115,6 +115,11 @@ def _is_remove_all_tools_filter(filter_fn: object | None) -> bool: ) +def _is_disabled_handoff(handoff_obj: Handoff[Any, Agent[Any]]) -> bool: + is_enabled = handoff_obj.is_enabled + return isinstance(is_enabled, bool) and not is_enabled + + def _collect_handoff_edges( agent: Agent[Any], ) -> list[tuple[Agent[Any], Handoff[Any, Agent[Any]] | None]]: @@ -136,6 +141,8 @@ def _collect_handoff_edges( target = handoff_or_agent elif isinstance(handoff_or_agent, Handoff): handoff = handoff_or_agent + if _is_disabled_handoff(handoff): + continue target_ref = handoff._agent_ref() if handoff._agent_ref is not None else None if not isinstance(target_ref, Agent): continue diff --git a/tests/test_agent_runner.py b/tests/test_agent_runner.py index 550bb75ef..bf82703d5 100644 --- a/tests/test_agent_runner.py +++ b/tests/test_agent_runner.py @@ -2145,6 +2145,45 @@ async def test_run_rejects_server_conversation_with_global_remove_all_tools_filt ) +@pytest.mark.asyncio +async def test_run_allows_server_conversation_with_disabled_remove_all_tools_handoff(): + model = FakeModel() + delegate = Agent(name="delegate", model=model) + triage = Agent( + name="triage", + model=model, + handoffs=[handoff(delegate, input_filter=remove_all_tools, is_enabled=False)], + ) + + result = await Runner.run( + triage, + input="test", + conversation_id="conv-test", + ) + + assert result.last_agent == triage + + +@pytest.mark.asyncio +async def test_run_allows_server_conversation_with_disabled_nested_handoff_history(): + model = FakeModel() + delegate = Agent(name="delegate", model=model) + triage = Agent( + name="triage", + model=model, + handoffs=[handoff(delegate, nest_handoff_history=True, is_enabled=False)], + ) + + result = await Runner.run( + triage, + input="test", + conversation_id="conv-test", + run_config=RunConfig(nest_handoff_history=True), + ) + + assert result.last_agent == triage + + @pytest.mark.asyncio async def test_run_streamed_rejects_server_conversation_with_nested_handoff_history(): model = FakeModel() From 482e17a7e3ef8e42fcf10ae7962d5f2b9e3bce3f Mon Sep 17 00:00:00 2001 From: liweiguang Date: Wed, 11 Feb 2026 08:20:40 +0800 Subject: [PATCH 4/4] fix(run): validate unresolved handoff settings --- src/agents/run.py | 45 ++++++++----------- .../run_internal/agent_runner_helpers.py | 2 + tests/test_agent_runner.py | 20 +++++++++ 3 files changed, 41 insertions(+), 26 deletions(-) diff --git a/src/agents/run.py b/src/agents/run.py index 0fff68418..ee0361b4e 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -3,7 +3,7 @@ import asyncio import contextlib import warnings -from typing import Union, cast +from typing import Any, Union, cast from typing_extensions import Unpack @@ -410,6 +410,21 @@ async def run( if run_config is None: run_config = RunConfig() + def validate_conversation_settings(active_agent_for_validation: Agent[Any]) -> None: + validate_session_conversation_settings( + session, + conversation_id=conversation_id, + previous_response_id=previous_response_id, + auto_previous_response_id=auto_previous_response_id, + ) + validate_server_conversation_handoff_settings( + active_agent_for_validation, + run_config, + conversation_id=conversation_id, + previous_response_id=previous_response_id, + auto_previous_response_id=auto_previous_response_id, + ) + is_resumed_state = isinstance(input, RunState) run_state: RunState[TContext] | None = None starting_input = input if not is_resumed_state else None @@ -433,18 +448,8 @@ async def run( previous_response_id=previous_response_id, auto_previous_response_id=auto_previous_response_id, ) - validate_session_conversation_settings( - session, - conversation_id=conversation_id, - previous_response_id=previous_response_id, - auto_previous_response_id=auto_previous_response_id, - ) - validate_server_conversation_handoff_settings( - run_state._current_agent if run_state._current_agent else starting_agent, - run_config, - conversation_id=conversation_id, - previous_response_id=previous_response_id, - auto_previous_response_id=auto_previous_response_id, + validate_conversation_settings( + run_state._current_agent if run_state._current_agent else starting_agent ) starting_input = run_state._original_input original_user_input = copy_input_items(run_state._original_input) @@ -461,19 +466,7 @@ async def run( raw_input = cast(Union[str, list[TResponseInputItem]], input) original_user_input = raw_input - validate_session_conversation_settings( - session, - conversation_id=conversation_id, - previous_response_id=previous_response_id, - auto_previous_response_id=auto_previous_response_id, - ) - validate_server_conversation_handoff_settings( - starting_agent, - run_config, - conversation_id=conversation_id, - previous_response_id=previous_response_id, - auto_previous_response_id=auto_previous_response_id, - ) + validate_conversation_settings(starting_agent) server_manages_conversation = ( conversation_id is not None diff --git a/src/agents/run_internal/agent_runner_helpers.py b/src/agents/run_internal/agent_runner_helpers.py index cdc139a2e..73e05a5ee 100644 --- a/src/agents/run_internal/agent_runner_helpers.py +++ b/src/agents/run_internal/agent_runner_helpers.py @@ -145,6 +145,8 @@ def _collect_handoff_edges( continue target_ref = handoff._agent_ref() if handoff._agent_ref is not None else None if not isinstance(target_ref, Agent): + # Still validate handoff-level settings even when target resolution is deferred. + edges.append((current, handoff)) continue target = target_ref else: diff --git a/tests/test_agent_runner.py b/tests/test_agent_runner.py index bf82703d5..111085378 100644 --- a/tests/test_agent_runner.py +++ b/tests/test_agent_runner.py @@ -2130,6 +2130,26 @@ async def test_run_rejects_server_conversation_with_remove_all_tools_filter(): ) +@pytest.mark.asyncio +async def test_run_rejects_server_conversation_with_unresolved_remove_all_tools_handoff(): + model = FakeModel() + delegate = Agent(name="delegate", model=model) + unresolved_handoff = handoff(delegate, input_filter=remove_all_tools) + unresolved_handoff._agent_ref = None + triage = Agent( + name="triage", + model=model, + handoffs=[unresolved_handoff], + ) + + with pytest.raises(UserError, match="Server-managed conversation"): + await Runner.run( + triage, + input="test", + conversation_id="conv-test", + ) + + @pytest.mark.asyncio async def test_run_rejects_server_conversation_with_global_remove_all_tools_filter(): model = FakeModel()