From ce85435d68008505875b03e865edce8fee15f461 Mon Sep 17 00:00:00 2001 From: giulio-leone Date: Fri, 13 Mar 2026 02:55:43 +0100 Subject: [PATCH 1/4] fix: to_input_list() produces unparsable data after handoff with nesting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When nest_handoff_history=True, self.input is replaced with a nested summary that embeds function_call/function_call_output items as text. However, to_input_list() was still iterating self.new_items (the unfiltered session items), producing orphaned structured function_call items without a parent assistant message — causing the API to reject the input. Fix: to_input_list() now prefers _model_input_items (populated during handoffs with correctly filtered items) over new_items. Falls back to new_items for non-handoff runs where _model_input_items is empty. Closes #2258 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/agents/result.py | 17 +++- tests/test_handoff_history_duplication.py | 106 ++++++++++++++++++++++ 2 files changed, 121 insertions(+), 2 deletions(-) diff --git a/src/agents/result.py b/src/agents/result.py index 36d303c3d5..33ee2ae494 100644 --- a/src/agents/result.py +++ b/src/agents/result.py @@ -209,11 +209,24 @@ def final_output_as(self, cls: type[T], raise_if_incorrect_type: bool = False) - return cast(T, self.final_output) def to_input_list(self) -> list[TResponseInputItem]: - """Creates a new input list, merging the original input with all the new items generated.""" + """Creates a new input list, merging the original input with all the new items generated. + + When ``nest_handoff_history=True`` is active, ``self.input`` is replaced + with a nested summary that already embeds function_call / function_call_output + items as text. Using the full ``new_items`` would duplicate those items as + orphaned structured entries, which the API rejects. In that case we fall + back to ``_model_input_items`` which contains only the filtered items that + are consistent with the (possibly nested) ``self.input``. + """ original_items: list[TResponseInputItem] = ItemHelpers.input_to_new_input_list(self.input) new_items: list[TResponseInputItem] = [] reasoning_item_id_policy = getattr(self, "_reasoning_item_id_policy", None) - for item in self.new_items: + + # Prefer _model_input_items when populated (after handoffs with nesting) + # to avoid orphaned function_call/function_call_output items. + source_items = getattr(self, "_model_input_items", None) or self.new_items + + for item in source_items: converted = run_item_to_input_item(item, reasoning_item_id_policy) if converted is None: continue diff --git a/tests/test_handoff_history_duplication.py b/tests/test_handoff_history_duplication.py index 9afc65e7d8..fa9c02de58 100644 --- a/tests/test_handoff_history_duplication.py +++ b/tests/test_handoff_history_duplication.py @@ -365,3 +365,109 @@ def test_full_handoff_scenario_no_duplication(self): assert len(function_call_outputs) == 0, ( "No function_call_output items should be in model input" ) + + +class TestToInputListAfterHandoff: + """Tests for Issue #2258: to_input_list() produces unparsable data after handoff. + + When nest_handoff_history=True, to_input_list() should use _model_input_items + (filtered) instead of new_items (unfiltered), since self.input has been replaced + with the nested summary and the raw function_call/function_call_output items in + new_items would be orphaned (no parent assistant message to own them). + """ + + def test_to_input_list_uses_model_input_items_when_available(self): + """to_input_list() should prefer _model_input_items over new_items.""" + from agents import RunContextWrapper, RunResult + + agent = _create_mock_agent() + tool_call = _create_tool_call_item(agent) + tool_output = _create_tool_output_item(agent) + message = _create_message_item(agent) + handoff_call = _create_handoff_call_item(agent) + handoff_output = _create_handoff_output_item(agent) + + # Simulate post-handoff state: + # - input is the nested summary (replaces original) + # - new_items has ALL items (session-oriented, unfiltered) + # - _model_input_items has only message items (model-oriented, filtered) + result = RunResult( + input=[{ + "role": "assistant", + "content": ( + "For context, here is the conversation so far:\n" + "\n" + "1. user: What's the weather?\n" + "2. function_call: get_weather\n" + "3. function_call_output: Sunny, 22°C\n" + "" + ), + }], + new_items=[ + tool_call, # would be orphaned in output + tool_output, # would be orphaned in output + message, + handoff_call, # would be orphaned in output + handoff_output, # would be orphaned in output + ], + raw_responses=[], + final_output="Done", + input_guardrail_results=[], + output_guardrail_results=[], + tool_input_guardrail_results=[], + tool_output_guardrail_results=[], + _last_agent=agent, + context_wrapper=RunContextWrapper(context=None), + interruptions=[], + ) + + # Set _model_input_items to the filtered set (only the message) + result._model_input_items = [message] + + input_list = result.to_input_list() + + # Should have: 1 summary item (from input) + 1 message item (from _model_input_items) + assert len(input_list) == 2, ( + f"Expected 2 items (summary + message), got {len(input_list)}: {input_list}" + ) + + # Verify no orphaned function_call items + types_in_output = [ + item.get("type") if isinstance(item, dict) else getattr(item, "type", None) + for item in input_list + ] + assert "function_call" not in types_in_output, ( + "function_call items should not appear as orphaned entries" + ) + assert "function_call_output" not in types_in_output, ( + "function_call_output items should not appear as orphaned entries" + ) + + def test_to_input_list_falls_back_to_new_items_without_handoff(self): + """Without handoff (no _model_input_items), to_input_list() uses new_items.""" + from agents import RunContextWrapper, RunResult + + agent = _create_mock_agent() + message = _create_message_item(agent) + + result = RunResult( + input="Hello", + new_items=[message], + raw_responses=[], + final_output="Done", + input_guardrail_results=[], + output_guardrail_results=[], + tool_input_guardrail_results=[], + tool_output_guardrail_results=[], + _last_agent=agent, + context_wrapper=RunContextWrapper(context=None), + interruptions=[], + ) + + # _model_input_items defaults to empty list, so should fall back to new_items + input_list = result.to_input_list() + + # Should have: 1 item from input ("Hello") + 1 message item from new_items + assert len(input_list) == 2, ( + f"Expected 2 items, got {len(input_list)}: {input_list}" + ) From ddad0f7caf5a904aa6f51b3a43b3d8e0fdb1ceaf Mon Sep 17 00:00:00 2001 From: giulio-leone Date: Fri, 13 Mar 2026 12:32:44 +0100 Subject: [PATCH 2/4] fix: restrict _model_input_items fallback to handoff-nesting case Only prefer _model_input_items over new_items when their content actually differs (evidence that handoff nesting filtered items). In server-managed conversation runs (_conversation_id set), _model_input_items may only hold recent deltas rather than the full transcript, so falling back unconditionally could truncate history. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/agents/result.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/agents/result.py b/src/agents/result.py index 33ee2ae494..13732cb1f8 100644 --- a/src/agents/result.py +++ b/src/agents/result.py @@ -222,9 +222,11 @@ def to_input_list(self) -> list[TResponseInputItem]: new_items: list[TResponseInputItem] = [] reasoning_item_id_policy = getattr(self, "_reasoning_item_id_policy", None) - # Prefer _model_input_items when populated (after handoffs with nesting) - # to avoid orphaned function_call/function_call_output items. - source_items = getattr(self, "_model_input_items", None) or self.new_items + # Only prefer _model_input_items when handoff nesting actually occurred, + # so server-managed conversation runs still use the full new_items transcript. + model_input_items = getattr(self, "_model_input_items", None) + has_nesting = model_input_items and model_input_items != self.new_items + source_items = model_input_items if has_nesting else self.new_items for item in source_items: converted = run_item_to_input_item(item, reasoning_item_id_policy) From 12ea0e2f540c053b5847d7984bcee174c63745f6 Mon Sep 17 00:00:00 2001 From: giulio-leone Date: Fri, 13 Mar 2026 16:27:45 +0100 Subject: [PATCH 3/4] fix: narrow to_input_list fallback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/agents/result.py | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/src/agents/result.py b/src/agents/result.py index 13732cb1f8..8d278ab922 100644 --- a/src/agents/result.py +++ b/src/agents/result.py @@ -211,22 +211,33 @@ def final_output_as(self, cls: type[T], raise_if_incorrect_type: bool = False) - def to_input_list(self) -> list[TResponseInputItem]: """Creates a new input list, merging the original input with all the new items generated. - When ``nest_handoff_history=True`` is active, ``self.input`` is replaced - with a nested summary that already embeds function_call / function_call_output - items as text. Using the full ``new_items`` would duplicate those items as - orphaned structured entries, which the API rejects. In that case we fall - back to ``_model_input_items`` which contains only the filtered items that - are consistent with the (possibly nested) ``self.input``. + Prefer ``_model_input_items`` only when it has already been reduced to safe + post-handoff items. If it still contains structured tool or reasoning items, + it may just be a partial model delta and ``new_items`` remains the complete + transcript. """ original_items: list[TResponseInputItem] = ItemHelpers.input_to_new_input_list(self.input) new_items: list[TResponseInputItem] = [] reasoning_item_id_policy = getattr(self, "_reasoning_item_id_policy", None) - # Only prefer _model_input_items when handoff nesting actually occurred, - # so server-managed conversation runs still use the full new_items transcript. model_input_items = getattr(self, "_model_input_items", None) - has_nesting = model_input_items and model_input_items != self.new_items - source_items = model_input_items if has_nesting else self.new_items + filtered_model_items: list[RunItem] | None = None + if isinstance(model_input_items, list): + candidate_items = list(model_input_items) + contains_structured_items = False + for item in candidate_items: + converted = run_item_to_input_item(item, reasoning_item_id_policy) + if converted is not None and converted.get("type") in { + "function_call", + "function_call_output", + "reasoning", + }: + contains_structured_items = True + break + if candidate_items and not contains_structured_items: + filtered_model_items = candidate_items + + source_items = filtered_model_items if filtered_model_items is not None else self.new_items for item in source_items: converted = run_item_to_input_item(item, reasoning_item_id_policy) From 74046951a3f9c95a2df488b97e3ccb62d2261134 Mon Sep 17 00:00:00 2001 From: giulio-leone Date: Fri, 13 Mar 2026 18:46:57 +0100 Subject: [PATCH 4/4] style: apply ruff formatting to handoff test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/test_handoff_history_duplication.py | 34 +++++++++++------------ 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/tests/test_handoff_history_duplication.py b/tests/test_handoff_history_duplication.py index fa9c02de58..07ea3f024d 100644 --- a/tests/test_handoff_history_duplication.py +++ b/tests/test_handoff_history_duplication.py @@ -392,22 +392,24 @@ def test_to_input_list_uses_model_input_items_when_available(self): # - new_items has ALL items (session-oriented, unfiltered) # - _model_input_items has only message items (model-oriented, filtered) result = RunResult( - input=[{ - "role": "assistant", - "content": ( - "For context, here is the conversation so far:\n" - "\n" - "1. user: What's the weather?\n" - "2. function_call: get_weather\n" - "3. function_call_output: Sunny, 22°C\n" - "" - ), - }], + input=[ + { + "role": "assistant", + "content": ( + "For context, here is the conversation so far:\n" + "\n" + "1. user: What's the weather?\n" + "2. function_call: get_weather\n" + "3. function_call_output: Sunny, 22°C\n" + "" + ), + } + ], new_items=[ - tool_call, # would be orphaned in output - tool_output, # would be orphaned in output + tool_call, # would be orphaned in output + tool_output, # would be orphaned in output message, - handoff_call, # would be orphaned in output + handoff_call, # would be orphaned in output handoff_output, # would be orphaned in output ], raw_responses=[], @@ -468,6 +470,4 @@ def test_to_input_list_falls_back_to_new_items_without_handoff(self): input_list = result.to_input_list() # Should have: 1 item from input ("Hello") + 1 message item from new_items - assert len(input_list) == 2, ( - f"Expected 2 items, got {len(input_list)}: {input_list}" - ) + assert len(input_list) == 2, f"Expected 2 items, got {len(input_list)}: {input_list}"