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
59 changes: 56 additions & 3 deletions src/agents/result.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,12 +122,65 @@ def final_output_as(self, cls: type[T], raise_if_incorrect_type: bool = False) -

return cast(T, self.final_output)

def to_input_list(self) -> list[TResponseInputItem]:
"""Creates a new input list, merging the original input with all the new items generated."""
def to_input_list(
self, *, include_nested_summary: bool = True
) -> list[TResponseInputItem]:
"""Creates a new input list, merging the original input with all the new items generated.

Args:
include_nested_summary: If True (default), includes handoff summary messages that
contain nested conversation history. Set to False to exclude these summary
messages and return only the raw items, which is useful when you need a flat
history without the nested format that can cause parsing issues.

Returns:
A list of input items suitable for passing to subsequent agent runs.
"""
original_items: list[TResponseInputItem] = ItemHelpers.input_to_new_input_list(self.input)
new_items = [item.to_input_item() for item in self.new_items]

return original_items + new_items
if include_nested_summary:
return original_items + new_items

# Filter nested summaries from BOTH original_items and new_items
filtered_original = [
item for item in original_items
if not self._is_nested_history_summary(item)
]
filtered_new = [
item for item in new_items
if not self._is_nested_history_summary(item)
]

return filtered_original + filtered_new

def to_input_list_raw(self) -> list[TResponseInputItem]:
"""Creates a new input list without nested handoff summary messages.

This is a convenience method equivalent to calling `to_input_list(include_nested_summary=False)`.
Use this when you need a flat conversation history that can be reliably parsed by the API
without the nested `<CONVERSATION HISTORY>` format.

Returns:
A list of input items with handoff summaries excluded.
"""
return self.to_input_list(include_nested_summary=False)

@staticmethod
def _is_nested_history_summary(item: TResponseInputItem) -> bool:
"""Check if an input item is a nested handoff history summary message.

These are assistant messages containing the `<CONVERSATION HISTORY>` markers
generated by the nest_handoff_history function.
"""
if not isinstance(item, dict):
return False
if item.get("role") != "assistant":
return False
content = item.get("content")
if not isinstance(content, str):
return False
return "<CONVERSATION HISTORY>" in content and "</CONVERSATION HISTORY>" in content
Comment on lines +180 to +183
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 Respect custom history wrapper markers

_is_nested_history_summary hardcodes the default <CONVERSATION HISTORY> markers, but the handoff API lets callers change these via set_conversation_history_wrappers (see src/agents/handoffs/history.py). If a user customizes the wrappers, to_input_list(include_nested_summary=False)/to_input_list_raw() will no longer detect and filter the nested summary, so the duplicated nested history remains and the parsing issue this change targets persists. Consider reading the current wrappers (e.g., via get_conversation_history_wrappers) when checking the content.

Useful? React with 👍 / 👎.


@property
def last_response_id(self) -> str | None:
Expand Down
67 changes: 67 additions & 0 deletions tests/test_handoff_history_dedup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""Tests for to_input_list with handoff history deduplication (Issue #2258)."""

from __future__ import annotations

from agents.result import RunResultBase


class TestNestedHistorySummaryDetection:
"""Tests for _is_nested_history_summary static method."""

def test_detects_summary_with_markers(self) -> None:
"""Verify detection of nested history summary messages."""
summary_item = {
"role": "assistant",
"content": "<CONVERSATION HISTORY>\ntest\n</CONVERSATION HISTORY>",
}
assert RunResultBase._is_nested_history_summary(summary_item) is True

def test_ignores_regular_assistant_message(self) -> None:
"""Regular assistant messages should not be detected as summaries."""
regular_item = {
"role": "assistant",
"content": "Hello, how can I help?",
}
assert RunResultBase._is_nested_history_summary(regular_item) is False

def test_ignores_user_message(self) -> None:
"""User messages should not be detected as summaries."""
user_item = {"role": "user", "content": "Test"}
assert RunResultBase._is_nested_history_summary(user_item) is False

def test_ignores_function_output(self) -> None:
"""Function outputs should not be detected as summaries."""
function_item = {
"type": "function_call_output",
"call_id": "123",
"output": "result",
}
assert RunResultBase._is_nested_history_summary(function_item) is False

def test_requires_both_markers(self) -> None:
"""Both start and end markers are required for detection."""
partial_start = {
"role": "assistant",
"content": "<CONVERSATION HISTORY> only start marker",
}
assert RunResultBase._is_nested_history_summary(partial_start) is False

partial_end = {
"role": "assistant",
"content": "only end marker </CONVERSATION HISTORY>",
}
assert RunResultBase._is_nested_history_summary(partial_end) is False

def test_handles_non_string_content(self) -> None:
"""Non-string content should return False."""
structured_content = {
"role": "assistant",
"content": [{"type": "text", "text": "structured content"}],
}
assert RunResultBase._is_nested_history_summary(structured_content) is False

def test_handles_non_dict_input(self) -> None:
"""Non-dict inputs should return False."""
assert RunResultBase._is_nested_history_summary("not a dict") is False # type: ignore
assert RunResultBase._is_nested_history_summary(None) is False # type: ignore
assert RunResultBase._is_nested_history_summary(123) is False # type: ignore