Skip to content
Open
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
31 changes: 31 additions & 0 deletions src/agents/run_internal/items.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
6 changes: 6 additions & 0 deletions src/agents/run_internal/session_persistence.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Comment on lines +97 to +98
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor explicit preserve policy for session history

When a caller uses a local session with RunConfig(reasoning_item_id_policy="preserve"), this unconditional strip runs before the config is consulted, so persisted reasoning items are sent without their IDs even though the public policy says "preserve" keeps IDs. This makes local session behavior inconsistent with generated same-run history and prevents callers who explicitly need ID preservation from opting out; pass the resolved policy into session preparation or only apply this mitigation when preservation was not requested.

Useful? React with 👍 / 👎.


new_input_list = [
ensure_input_item_format(item) for item in ItemHelpers.input_to_new_input_list(input)
]
Expand Down
46 changes: 46 additions & 0 deletions tests/test_session_reasoning_items.py
Original file line number Diff line number Diff line change
@@ -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 == []