From b923ff80233063501f18c896c26d02966f45bd30 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 11 May 2026 17:03:27 +0900 Subject: [PATCH] fix: #3268 fix OpenAI Conversations reasoning persistence --- .../run_internal/session_persistence.py | 28 +++- .../test_session_persistence_sanitize.py | 29 +++- tests/test_agent_runner.py | 148 ++++++++++++++++++ 3 files changed, 199 insertions(+), 6 deletions(-) diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index 79656ce4d2..f483da13a3 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -289,19 +289,22 @@ async def save_result_to_session( ] ) + is_openai_conversation_session = isinstance(session, OpenAIConversationsSession) resolved_reasoning_item_id_policy = ( reasoning_item_id_policy if reasoning_item_id_policy is not None else (run_state._reasoning_item_id_policy if run_state is not None else None) ) + persistence_reasoning_item_id_policy = ( + None if is_openai_conversation_session else resolved_reasoning_item_id_policy + ) new_items_as_input: list[TResponseInputItem] = [] for run_item in new_run_items: - converted = run_item_to_input_item(run_item, resolved_reasoning_item_id_policy) + converted = run_item_to_input_item(run_item, persistence_reasoning_item_id_policy) if converted is None: continue new_items_as_input.append(ensure_input_item_format(converted)) - is_openai_conversation_session = isinstance(session, OpenAIConversationsSession) ignore_ids_for_matching = _ignore_ids_for_matching(session) new_items_for_fingerprint = ( @@ -333,6 +336,11 @@ async def save_result_to_session( serialized_to_save_counts[serialized] -= 1 saved_run_items_count += 1 + 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 @@ -582,12 +590,15 @@ def _sanitize_openai_conversation_item(item: TResponseInputItem) -> TResponseInp """Remove provider-specific fields before fingerprinting or persistence. Some Responses input item types require their server-assigned ``id`` when they are - persisted through the Conversations API. Other item IDs remain stripped so replayed - messages, reasoning items, and function calls do not carry stale provider IDs. + persisted through the Conversations API. Reasoning items also need their server + identity or encrypted content to remain persistable. Other item IDs remain stripped + so replayed messages, function calls, and tool outputs do not carry stale provider IDs. """ if isinstance(item, dict): clean_item = cast(dict[str, Any], strip_internal_input_item_metadata(item)) - if not _openai_conversation_item_requires_id(clean_item): + if clean_item.get("type") != "reasoning" and not _openai_conversation_item_requires_id( + clean_item + ): clean_item.pop("id", None) clean_item.pop("provider_data", None) return cast(TResponseInputItem, clean_item) @@ -599,6 +610,13 @@ def _openai_conversation_item_requires_id(item: dict[str, Any]) -> bool: return item.get("type") in _OPENAI_CONVERSATION_ITEM_TYPES_WITH_REQUIRED_ID +def _is_unpersistable_for_openai_conversation(item: TResponseInputItem) -> bool: + """Return whether the item should be counted but not sent to Conversations.""" + if not isinstance(item, dict) or item.get("type") != "reasoning": + return False + return not item.get("id") and not item.get("encrypted_content") + + def _sanitize_openai_conversation_history_items_for_model_input( items: Sequence[TResponseInputItem], history_indexes: set[int], diff --git a/tests/memory/test_session_persistence_sanitize.py b/tests/memory/test_session_persistence_sanitize.py index a5a894b3c5..bae7d3348d 100644 --- a/tests/memory/test_session_persistence_sanitize.py +++ b/tests/memory/test_session_persistence_sanitize.py @@ -71,7 +71,6 @@ def test_sanitize_preserves_file_search_call_payload_id() -> None: }, {"type": "function_call_output", "id": "out_abc", "call_id": "call_abc", "output": "{}"}, {"type": "computer_call_output", "id": "ccout_abc", "call_id": "call_abc", "output": {}}, - {"type": "reasoning", "id": "rs_abc", "summary": []}, {"type": "tool_search_call", "id": "ts_abc", "status": "completed"}, {"type": "shell_call", "id": "sh_abc", "call_id": "call_abc", "action": {}}, ], @@ -83,6 +82,34 @@ def test_sanitize_strips_optional_or_policy_controlled_ids(item: dict[str, Any]) assert sanitized["type"] == item["type"] +def test_sanitize_preserves_reasoning_id_for_openai_conversations() -> None: + item = { + "type": "reasoning", + "id": "rs_abc", + "summary": [], + "content": [], + "provider_data": {"server": "metadata"}, + } + + sanitized = _sanitize(item) + + assert sanitized["id"] == "rs_abc" + assert "provider_data" not in sanitized + + +def test_sanitize_preserves_reasoning_encrypted_content() -> None: + item = { + "type": "reasoning", + "summary": [], + "content": [], + "encrypted_content": "encrypted", + } + + sanitized = _sanitize(item) + + assert sanitized["encrypted_content"] == "encrypted" + + def test_sanitize_always_strips_provider_data() -> None: item = { "type": "file_search_call", diff --git a/tests/test_agent_runner.py b/tests/test_agent_runner.py index 284927a984..ec42b553b4 100644 --- a/tests/test_agent_runner.py +++ b/tests/test_agent_runner.py @@ -2796,6 +2796,154 @@ 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_openai_conversation_preserves_reasoning_id_when_policy_is_omit() -> ( + None +): + 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, + ) + run_state.set_reasoning_item_id_policy("omit") + + reasoning_item = ReasoningItem( + agent=agent, + raw_item=ResponseReasoningItem( + type="reasoning", + id="rs_openai_conversation", + summary=[Summary(text="thinking", type="summary_text")], + ), + ) + + saved_count = await save_result_to_session( + session, + [], + cast(list[RunItem], [reasoning_item]), + run_state, + ) + + assert saved_count == 1 + assert run_state._current_turn_persisted_item_count == 1 + assert len(session.saved_items) == 1 + saved_reasoning = cast(dict[str, Any], session.saved_items[0]) + assert saved_reasoning.get("type") == "reasoning" + assert saved_reasoning.get("id") == "rs_openai_conversation" + + +@pytest.mark.asyncio +async def test_save_result_to_openai_conversation_drops_unpersistable_reasoning_item() -> None: + 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, + ) + malformed_reasoning = _DummyRunItem( + {"type": "reasoning", "summary": [], "content": []}, + "reasoning_item", + ) + + saved_count = await save_result_to_session( + session, + [], + cast(list[RunItem], [malformed_reasoning]), + run_state, + ) + + assert saved_count == 1 + assert run_state._current_turn_persisted_item_count == 1 + assert session.saved_items == [] + + +@pytest.mark.asyncio +async def test_save_result_to_openai_conversation_keeps_reasoning_encrypted_content() -> None: + 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() + encrypted_reasoning = _DummyRunItem( + { + "type": "reasoning", + "summary": [], + "content": [], + "encrypted_content": "encrypted", + }, + "reasoning_item", + ) + + saved_count = await save_result_to_session( + session, + [], + cast(list[RunItem], [encrypted_reasoning]), + None, + ) + + assert saved_count == 1 + assert len(session.saved_items) == 1 + saved_reasoning = cast(dict[str, Any], session.saved_items[0]) + assert saved_reasoning["encrypted_content"] == "encrypted" + + @pytest.mark.asyncio async def test_save_result_to_session_keeps_tool_call_payload_api_safe() -> None: session = SimpleListSession()