Skip to content
Closed
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
65 changes: 65 additions & 0 deletions src/agents/extensions/handoff_filters.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

from collections.abc import Sequence

from ..handoffs import (
HandoffInputData,
default_handoff_history_mapper,
Expand All @@ -24,6 +26,8 @@

__all__ = [
"remove_all_tools",
"remove_reasoning_items",
"strip_reasoning_items",
"nest_handoff_history",
"default_handoff_history_mapper",
]
Expand All @@ -49,6 +53,45 @@ def remove_all_tools(handoff_input_data: HandoffInputData) -> HandoffInputData:
)


def strip_reasoning_items(items: Sequence[TResponseInputItem]) -> list[TResponseInputItem]:
"""Remove reasoning items from plain input history.

When the last reasoning item is stripped, assistant message IDs become orphaned and can trigger
Responses API validation errors on the next turn. In that case, strip those assistant IDs too.
"""

filtered_items: list[TResponseInputItem] = []
removed_reasoning = False

for item in items:
if item.get("type") == "reasoning":
removed_reasoning = True
continue
filtered_items.append(item)

if removed_reasoning:
return _strip_orphaned_assistant_ids(filtered_items)
return filtered_items


def remove_reasoning_items(handoff_input_data: HandoffInputData) -> HandoffInputData:
"""Filters out reasoning items while preserving ordinary messages."""

history = handoff_input_data.input_history
filtered_history = (
tuple(strip_reasoning_items(history)) if isinstance(history, tuple) else history
)
filtered_pre_handoff_items = _remove_reasoning_from_items(handoff_input_data.pre_handoff_items)
filtered_new_items = _remove_reasoning_from_items(handoff_input_data.new_items)
Comment on lines +84 to +85
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 Strip assistant IDs from message run items after reasoning removal

remove_reasoning_items removes ReasoningItem entries from new_items but never sanitizes assistant message IDs in those same run items. In handoffs where a turn emits both reasoning and an assistant message, execute_handoffs uses filtered.new_items for the next model input, and MessageOutputItem.to_input_item() preserves the message id; after reasoning is dropped, that ID can still be orphaned and trigger the Responses API validation error this helper is intended to avoid. The same orphaned-ID cleanup needs to be applied to message run items (or to derived input_items) when reasoning is removed from new_items.

Useful? React with 👍 / 👎.


return HandoffInputData(
input_history=filtered_history,
pre_handoff_items=filtered_pre_handoff_items,
new_items=filtered_new_items,
run_context=handoff_input_data.run_context,
Comment on lines +87 to +91
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 Preserve input_items when constructing filtered handoff data

remove_reasoning_items constructs a fresh HandoffInputData but does not carry over input_items. If this helper is composed with another filter that sets input_items (the field that controls what is sent to the next model while keeping full session history), this function silently resets it to None, causing the runtime to fall back to new_items and undo the caller’s filtering intent. Return via handoff_input_data.clone(...) or explicitly pass through input_items.

Useful? React with 👍 / 👎.

)


def _remove_tools_from_items(items: tuple[RunItem, ...]) -> tuple[RunItem, ...]:
filtered_items = []
for item in items:
Expand All @@ -69,6 +112,15 @@ def _remove_tools_from_items(items: tuple[RunItem, ...]) -> tuple[RunItem, ...]:
return tuple(filtered_items)


def _remove_reasoning_from_items(items: tuple[RunItem, ...]) -> tuple[RunItem, ...]:
filtered_items = []
for item in items:
if isinstance(item, ReasoningItem):
continue
filtered_items.append(item)
return tuple(filtered_items)


def _remove_tool_types_from_input(
items: tuple[TResponseInputItem, ...],
) -> tuple[TResponseInputItem, ...]:
Expand All @@ -95,3 +147,16 @@ def _remove_tool_types_from_input(
continue
filtered_items.append(item)
return tuple(filtered_items)


def _strip_orphaned_assistant_ids(items: list[TResponseInputItem]) -> list[TResponseInputItem]:
cleaned: list[TResponseInputItem] = []
for item in items:
if (
item.get("role") == "assistant"
and item.get("type") == "message"
and "id" in item
):
item = {k: v for k, v in item.items() if k != "id"}
cleaned.append(item)
return cleaned
75 changes: 74 additions & 1 deletion tests/test_extension_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@
reset_conversation_history_wrappers,
set_conversation_history_wrappers,
)
from agents.extensions.handoff_filters import nest_handoff_history, remove_all_tools
from agents.extensions.handoff_filters import (
nest_handoff_history,
remove_all_tools,
remove_reasoning_items,
strip_reasoning_items,
)
from agents.items import (
HandoffOutputItem,
MCPApprovalRequestItem,
Expand Down Expand Up @@ -45,6 +50,17 @@ def _get_message_input_item(content: str) -> TResponseInputItem:
}


def _get_assistant_message_item(content: str, item_id: str | None = None) -> TResponseInputItem:
item: TResponseInputItem = {
"type": "message",
"role": "assistant",
"content": content,
}
if item_id is not None:
item["id"] = item_id
return item


def _get_user_input_item(content: str) -> TResponseInputItem:
return {
"role": "user",
Expand Down Expand Up @@ -1015,3 +1031,60 @@ def test_removes_mixed_mcp_and_function_items() -> None:
assert len(filtered_data.input_history) == 2
assert len(filtered_data.pre_handoff_items) == 1
assert len(filtered_data.new_items) == 1


def test_strip_reasoning_items_removes_reasoning_and_orphaned_assistant_ids() -> None:
items = [
_get_user_input_item("Hello"),
_get_reasoning_input_item(),
_get_assistant_message_item("Hi", item_id="msg_123"),
]

filtered = strip_reasoning_items(items)

assert len(filtered) == 2
assert all(item.get("type") != "reasoning" for item in filtered)
assistant = cast(dict[str, Any], filtered[-1])
assert assistant["role"] == "assistant"
assert "id" not in assistant


def test_strip_reasoning_items_keeps_assistant_ids_when_no_reasoning_removed() -> None:
items = [
_get_user_input_item("Hello"),
_get_assistant_message_item("Hi", item_id="msg_456"),
]

filtered = strip_reasoning_items(items)

assert len(filtered) == 2
assistant = cast(dict[str, Any], filtered[-1])
assert assistant["id"] == "msg_456"


def test_remove_reasoning_items_filters_handoff_data() -> None:
handoff_input_data = handoff_data(
input_history=(
_get_user_input_item("Hello"),
_get_reasoning_input_item(),
_get_assistant_message_item("World", item_id="msg_789"),
),
pre_handoff_items=(
_get_reasoning_output_run_item(),
_get_message_output_run_item("kept"),
),
new_items=(
_get_reasoning_output_run_item(),
_get_message_output_run_item("kept"),
),
)

filtered_data = remove_reasoning_items(handoff_input_data)

assert len(filtered_data.input_history) == 2
assert all(item.get("type") != "reasoning" for item in filtered_data.input_history)
assistant = cast(dict[str, Any], filtered_data.input_history[-1])
assert assistant["role"] == "assistant"
assert "id" not in assistant
assert len(filtered_data.pre_handoff_items) == 1
assert len(filtered_data.new_items) == 1