From 2cecee706098a93569546cfc17f1f5914b5414e7 Mon Sep 17 00:00:00 2001 From: Oxygen <1391083091@qq.com> Date: Fri, 5 Jun 2026 09:17:34 +0800 Subject: [PATCH] fix: strip stale reasoning item IDs from local session history When reasoning items are stored in a local session (SQLiteSession, etc.) and later replayed as model input, their server-assigned IDs point to content that no longer exists on the server. This causes 404 errors: 'Item with id rs_xxx not found'. Add strip_stale_reasoning_item_ids() which removes the 'id' field from reasoning items. Wire it into prepare_input_with_session() for all session types EXCEPT OpenAIConversationsSession (where the server manages item identity). The existing ReasoningItemIdPolicy='omit' already allows users to opt out of reasoning IDs entirely. This fix handles the common default case where IDs are preserved but become stale in local storage. Fixes #2020 --- src/agents/run_internal/items.py | 31 +++++++++++++ .../run_internal/session_persistence.py | 6 +++ tests/test_session_reasoning_items.py | 46 +++++++++++++++++++ 3 files changed, 83 insertions(+) create mode 100644 tests/test_session_reasoning_items.py diff --git a/src/agents/run_internal/items.py b/src/agents/run_internal/items.py index aadba1d361..b33c8d72a0 100644 --- a/src/agents/run_internal/items.py +++ b/src/agents/run_internal/items.py @@ -305,6 +305,37 @@ def strip_internal_input_item_metadata(item: TResponseInputItem) -> TResponseInp return cast(TResponseInputItem, cleaned) +def strip_stale_reasoning_item_ids( + items: list[TResponseInputItem], +) -> list[TResponseInputItem]: + """Strip the ``id`` field from reasoning items in a list of input items. + + Reasoning item IDs reference server-side content that only exists during the + original API interaction. When reasoning items are stored in a local session + (SQLite, file-based, etc.) and later replayed, the server no longer recognises + those IDs and returns a 404. Removing the ID lets the server treat the item as a + fresh, untracked reasoning annotation. + + This is intentionally NOT applied for ``OpenAIConversationsSession``, where the + server itself manages item identity. + + Args: + items: A list of input items, potentially containing reasoning items. + + Returns: + The same list with reasoning item IDs removed where present. + """ + result: list[TResponseInputItem] = [] + for item in items: + if isinstance(item, dict) and item.get("type") == "reasoning" and "id" in item: + sanitized = dict(item) + sanitized.pop("id") + result.append(cast(TResponseInputItem, sanitized)) + else: + result.append(item) + return result + + def _should_omit_reasoning_item_ids(reasoning_item_id_policy: ReasoningItemIdPolicy | None) -> bool: return reasoning_item_id_policy == "omit" diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index f483da13a3..e9e6b42572 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -34,6 +34,7 @@ normalize_input_items_for_api, run_item_to_input_item, strip_internal_input_item_metadata, + strip_stale_reasoning_item_ids, ) from .oai_conversation import OpenAIServerConversationTracker from .run_steps import SingleStepResult @@ -91,6 +92,11 @@ async def prepare_input_with_session( strip_internal_input_item_metadata(ensure_input_item_format(item)) for item in history ] + # When items come from a local session (not server-managed Conversations), + # strip reasoning item IDs to prevent stale-server-ID 404s. + if not is_openai_conversation_session: + converted_history = strip_stale_reasoning_item_ids(converted_history) + new_input_list = [ ensure_input_item_format(item) for item in ItemHelpers.input_to_new_input_list(input) ] diff --git a/tests/test_session_reasoning_items.py b/tests/test_session_reasoning_items.py new file mode 100644 index 0000000000..94200d5081 --- /dev/null +++ b/tests/test_session_reasoning_items.py @@ -0,0 +1,46 @@ +"""Tests for stripping stale reasoning item IDs from local session history.""" +from __future__ import annotations + +from agents.run_internal.items import strip_stale_reasoning_item_ids + + +class TestStripStaleReasoningItemIds: + def test_strips_id_from_reasoning_item(self) -> None: + items: list[dict[str, object]] = [ + {"type": "reasoning", "id": "rs_deadbeef", "summary": []}, + ] + result = strip_stale_reasoning_item_ids(items) # type: ignore[arg-type] + assert result[0].get("id") is None # type: ignore[union-attr] + + def test_preserves_non_reasoning_item_ids(self) -> None: + items: list[dict[str, object]] = [ + {"type": "message", "id": "msg_123", "role": "user", "content": "hi"}, + {"type": "function_call", "id": "fc_456", "call_id": "c1", "name": "f", "arguments": "{}"}, + ] + result = strip_stale_reasoning_item_ids(items) # type: ignore[arg-type] + assert result[0].get("id") == "msg_123" # type: ignore[union-attr] + assert result[1].get("id") == "fc_456" # type: ignore[union-attr] + + def test_reasoning_without_id_passes_through(self) -> None: + items: list[dict[str, object]] = [ + {"type": "reasoning", "summary": []}, + ] + result = strip_stale_reasoning_item_ids(items) # type: ignore[arg-type] + assert "id" not in result[0] # type: ignore[arg-type] + + def test_mixed_items_strip_only_reasoning(self) -> None: + items: list[dict[str, object]] = [ + {"type": "reasoning", "id": "rs_1", "summary": []}, + {"type": "message", "id": "msg_1", "role": "assistant", "content": "ok"}, + {"type": "reasoning", "id": "rs_2", "summary": []}, + {"type": "function_call_output", "call_id": "c1", "output": "result"}, + ] + result = strip_stale_reasoning_item_ids(items) # type: ignore[arg-type] + assert result[0].get("id") is None # type: ignore[union-attr] + assert result[1].get("id") == "msg_1" # type: ignore[union-attr] + assert result[2].get("id") is None # type: ignore[union-attr] + assert result[3].get("call_id") == "c1" # type: ignore[union-attr] + + def test_empty_list(self) -> None: + result = strip_stale_reasoning_item_ids([]) + assert result == []