diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index 187924e895..780db80b2f 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -333,6 +333,14 @@ async def save_result_to_session( serialized_to_save_counts[serialized] -= 1 saved_run_items_count += 1 + # Drop items the Conversations API cannot accept (counted above so the + # persisted-item counter advances correctly on the next retry/call). + if is_openai_conversation_session: + items_to_save = [ + item for item in items_to_save + if not _is_unpersistable_for_openai_conversation(item) + ] + if len(items_to_save) == 0: if run_state: run_state._current_turn_persisted_item_count = already_persisted + saved_run_items_count @@ -571,6 +579,19 @@ def _sanitize_openai_conversation_item(item: TResponseInputItem) -> TResponseInp return item +def _is_unpersistable_for_openai_conversation(item: TResponseInputItem) -> bool: + """Return True for items the OpenAI Conversations API cannot accept. + + The Conversations API rejects reasoning items with an empty summary list. + Callers should drop these items before calling ``session.add_items`` while + still counting them in the persisted-item counter so the retry logic + advances past them correctly. + """ + if not isinstance(item, dict): + return False + return item.get("type") == "reasoning" and item.get("summary") == [] + + def _sanitize_openai_conversation_history_items_for_model_input( items: Sequence[TResponseInputItem], history_indexes: set[int], diff --git a/tests/test_agent_runner.py b/tests/test_agent_runner.py index 284927a984..f1eaaffb76 100644 --- a/tests/test_agent_runner.py +++ b/tests/test_agent_runner.py @@ -2796,6 +2796,105 @@ async def test_save_result_to_session_omits_reasoning_ids_when_policy_is_omit() assert "id" not in saved_reasoning +@pytest.mark.asyncio +async def test_save_result_to_session_drops_empty_reasoning_from_openai_conversations() -> None: + """OpenAIConversationsSession must not receive reasoning items with summary=[].""" + + class DummyOpenAIConversationsSession(OpenAIConversationsSession): + def __init__(self) -> None: + self.saved_items: list[TResponseInputItem] = [] + + async def _get_session_id(self) -> str: + return "conv_test" + + async def add_items(self, items: list[TResponseInputItem]) -> None: + self.saved_items.extend(items) + + async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]: + return [] + + async def pop_item(self) -> TResponseInputItem | None: + return None + + async def clear_session(self) -> None: + return None + + session = DummyOpenAIConversationsSession() + agent = Agent(name="agent", model=FakeModel()) + run_state: RunState[Any] = RunState( + context=RunContextWrapper(context={}), + original_input="input", + starting_agent=agent, + max_turns=1, + ) + + empty_reasoning = ReasoningItem( + agent=agent, + raw_item=ResponseReasoningItem(type="reasoning", id="rs_empty", summary=[]), + ) + + saved_count = await save_result_to_session( + session, + [], + cast(list[RunItem], [empty_reasoning]), + run_state, + ) + + # The item is counted (so the retry counter advances) but not sent to add_items. + assert saved_count == 1 + assert run_state._current_turn_persisted_item_count == 1 + assert len(session.saved_items) == 0 + + +@pytest.mark.asyncio +async def test_save_result_to_session_keeps_nonempty_reasoning_in_openai_conversations() -> None: + """Non-empty reasoning items must still be persisted to OpenAIConversationsSession.""" + + class DummyOpenAIConversationsSession(OpenAIConversationsSession): + def __init__(self) -> None: + self.saved_items: list[TResponseInputItem] = [] + + async def _get_session_id(self) -> str: + return "conv_test" + + async def add_items(self, items: list[TResponseInputItem]) -> None: + self.saved_items.extend(items) + + async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]: + return [] + + async def pop_item(self) -> TResponseInputItem | None: + return None + + async def clear_session(self) -> None: + return None + + session = DummyOpenAIConversationsSession() + agent = Agent(name="agent", model=FakeModel()) + + nonempty_reasoning = ReasoningItem( + agent=agent, + raw_item=ResponseReasoningItem( + type="reasoning", + id="rs_full", + summary=[Summary(type="summary_text", text="I thought about it")], + ), + ) + + saved_count = await save_result_to_session( + session, + [], + cast(list[RunItem], [nonempty_reasoning]), + None, + ) + + assert saved_count == 1 + assert len(session.saved_items) == 1 + saved = cast(dict[str, Any], session.saved_items[0]) + assert saved.get("type") == "reasoning" + assert saved.get("summary") != [] + + @pytest.mark.asyncio async def test_save_result_to_session_keeps_tool_call_payload_api_safe() -> None: session = SimpleListSession()