diff --git a/src/agents/run_internal/items.py b/src/agents/run_internal/items.py index aadba1d361..042cfb4854 100644 --- a/src/agents/run_internal/items.py +++ b/src/agents/run_internal/items.py @@ -67,13 +67,26 @@ def run_item_to_input_item( run_item: RunItem, reasoning_item_id_policy: ReasoningItemIdPolicy | None = None, ) -> TResponseInputItem | None: - """Convert a run item to model input, optionally stripping reasoning IDs.""" + """Convert a run item to model input, optionally stripping reasoning IDs. + + Returns ``None`` for items that should be omitted from model input, including + tool-approval placeholders and reasoning items whose ``summary`` list is empty + (e.g. encrypted-reasoning turns where the model emits no visible summary text). + Persisting an empty reasoning item to the Conversations API causes a 400 error, + so we skip them rather than forwarding a ``{"type": "reasoning", "summary": []}`` + payload that the API would reject. + """ if run_item.type == "tool_approval_item": return None to_input = getattr(run_item, "to_input_item", None) input_item = to_input() if callable(to_input) else cast(TResponseInputItem, run_item.raw_item) if isinstance(input_item, dict) and input_item.get("status") is None: input_item = {k: v for k, v in input_item.items() if k != "status"} + # Skip reasoning items that carry no summary content. The Conversations API + # rejects these with a 400, and they add no useful context for future turns. + if run_item.type == "reasoning_item" and isinstance(input_item, dict): + if not input_item.get("summary"): + return None if ( _should_omit_reasoning_item_ids(reasoning_item_id_policy) and run_item.type == "reasoning_item" diff --git a/tests/test_run_internal_items.py b/tests/test_run_internal_items.py index 019a8ededa..9ec9350935 100644 --- a/tests/test_run_internal_items.py +++ b/tests/test_run_internal_items.py @@ -8,7 +8,7 @@ ResponseToolSearchCall, ResponseToolSearchOutputItem, ) -from openai.types.responses.response_reasoning_item import ResponseReasoningItem +from openai.types.responses.response_reasoning_item import ResponseReasoningItem, Summary from agents import Agent from agents.exceptions import AgentsException @@ -440,7 +440,7 @@ def test_run_item_to_input_item_preserves_reasoning_item_ids_by_default() -> Non raw_item=ResponseReasoningItem( type="reasoning", id="rs_123", - summary=[], + summary=[Summary(type="summary_text", text="thinking")], ), ) @@ -458,7 +458,7 @@ def test_run_item_to_input_item_omits_reasoning_item_ids_when_configured() -> No raw_item=ResponseReasoningItem( type="reasoning", id="rs_456", - summary=[], + summary=[Summary(type="summary_text", text="thinking")], ), ) @@ -469,6 +469,32 @@ def test_run_item_to_input_item_omits_reasoning_item_ids_when_configured() -> No assert "id" not in result +def test_run_item_to_input_item_skips_reasoning_item_with_empty_summary() -> None: + """Regression test for #3268. + + When a reasoning item has an empty summary list (e.g. encrypted-reasoning turns + where the model emits no visible summary text), ``run_item_to_input_item`` must + return ``None`` so the empty item is not forwarded to session persistence. The + Conversations API rejects ``{"type": "reasoning", "summary": []}`` with a 400, + and the payload carries no useful context for subsequent turns anyway. + """ + agent = Agent(name="A") + reasoning = ReasoningItem( + agent=agent, + raw_item=ResponseReasoningItem( + type="reasoning", + id="rs_empty", + summary=[], + ), + ) + + result = run_items.run_item_to_input_item(reasoning) + + assert result is None, ( + f"Expected None for a reasoning item with an empty summary list, got {result!r} instead" + ) + + def test_run_item_to_input_item_preserves_tool_search_items() -> None: agent = Agent(name="A") tool_search_call = ToolSearchCallItem(