Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion src/agents/run_internal/items.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
32 changes: 29 additions & 3 deletions tests/test_run_internal_items.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")],
),
)

Expand All @@ -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")],
),
)

Expand All @@ -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(
Expand Down
Loading