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
28 changes: 27 additions & 1 deletion src/agents/handoffs/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,17 +78,30 @@ def nest_handoff_history(
normalized_history = _normalize_input_history(handoff_input_data.input_history)
flattened_history = _flatten_nested_history_messages(normalized_history)

# A previous handoff may have forwarded a message verbatim alongside a
# summary that already records it. Such a message reappears here as a
# pre-handoff item, so track what the summarized history already covers and
# skip re-adding it to the transcript. Otherwise it would be listed twice
# inside the summary, with the duplication compounding on every handoff.
summarized_keys = {
key for item in flattened_history if (key := _summary_dedupe_key(item)) is not None
}

# Convert items to plain inputs for the transcript summary.
pre_items_as_inputs: list[TResponseInputItem] = []
filtered_pre_items: list[RunItem] = []
for run_item in handoff_input_data.pre_handoff_items:
if isinstance(run_item, ToolApprovalItem):
continue
plain_input = _run_item_to_plain_input(run_item)
pre_items_as_inputs.append(plain_input)
if not _is_already_summarized(plain_input, summarized_keys):
pre_items_as_inputs.append(plain_input)
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 Preserve repeated pre-handoff turns in summaries

When a nested handoff agent has an earlier non-handoff turn before it delegates, pre_handoff_items contains that real turn output as well as forwarded copies. If that output serializes identically to any item in flattened_history (for example A says same, then B says same before using a tool and later hands off), this check suppresses B's message from pre_items_as_inputs; because assistant pre-items are also not forwarded raw, the downstream agent never sees B's turn in either the summary or model input. The dedupe needs to distinguish forwarded duplicates from legitimate pre-handoff outputs.

Useful? React with 👍 / 👎.

if _should_forward_pre_item(plain_input):
filtered_pre_items.append(run_item)

# ``new_items`` are produced by the current agent turn, so they are never
# forwarded copies of something the summary already records. They are kept
# verbatim, even when they happen to serialize identically to an earlier item.
new_items_as_inputs: list[TResponseInputItem] = []
filtered_input_items: list[RunItem] = []
for run_item in handoff_input_data.new_items:
Expand Down Expand Up @@ -223,6 +236,19 @@ def _flatten_nested_history_messages(
return flattened


def _summary_dedupe_key(item: TResponseInputItem) -> str | None:
payload = {k: v for k, v in item.items() if k != "provider_data"}
try:
return json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str)
except (TypeError, ValueError):
return None


def _is_already_summarized(item: TResponseInputItem, summarized_keys: set[str]) -> bool:
key = _summary_dedupe_key(item)
return key is not None and key in summarized_keys


def _extract_nested_history_transcript(
item: TResponseInputItem,
) -> list[TResponseInputItem] | None:
Expand Down
74 changes: 74 additions & 0 deletions tests/test_handoff_history_duplication.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"""

import json
import re
from typing import Any, cast

import pytest
Expand Down Expand Up @@ -248,6 +249,31 @@ def test_message_items_are_preserved_in_new_items(self):
assert len(nested.input_items) == 1, "MessageOutputItem should be preserved in input_items"
assert isinstance(nested.input_items[0], MessageOutputItem)

def test_new_items_are_kept_when_identical_to_summarized_history(self):
"""Verify a current-turn item is never dropped as "already summarized".

``new_items`` are produced by the current agent turn, so they are never
forwarded copies of a message the summary already records. An item that
happens to serialize identically to one already in the history must still
be recorded in the transcript, otherwise the current turn silently loses
an output.
"""
agent = _create_mock_agent()
message_item = _create_message_item(agent)

handoff_data = HandoffInputData(
input_history=(message_item.to_input_item(),),
pre_handoff_items=(),
new_items=(message_item,),
)

nested = nest_handoff_history(handoff_data)

summary = str(cast(dict[str, Any], nested.input_history[0]).get("content", ""))
assert summary.count("Hello!") == 2, (
f"current-turn message must not be dropped as a duplicate, got: {summary}"
)

def test_reasoning_items_are_filtered_from_input_items(self):
"""Verify ReasoningItem in new_items is filtered from input_items.

Expand Down Expand Up @@ -524,3 +550,51 @@ def keep_messages_only(data: HandoffInputData) -> HandoffInputData:
assert len(normalized_input) == 3
assert "function_call" not in normalized_types
assert "function_call_output" not in normalized_types


@pytest.mark.asyncio
async def test_nested_handoff_chain_does_not_duplicate_summary_records() -> None:
"""A chain of handoffs must not duplicate records inside the summary block.

Each agent's pre-handoff message is both summarized and forwarded verbatim
to the next agent. When the next agent hands off again, the verbatim copy is
re-summarized alongside the previous summary that already contains it, so the
same message ends up listed twice inside a single <CONVERSATION HISTORY>
block, and the duplication compounds on every subsequent handoff.
"""
names = ["A", "B", "C", "D"]
models = [FakeModel() for _ in names]
agents = [Agent(name=name, model=model) for name, model in zip(names, models, strict=True)]
for i in range(len(agents) - 1):
agents[i].handoffs = [agents[i + 1]]

for i in range(len(agents) - 1):
models[i].add_multiple_turn_outputs(
[[get_text_message(f"{names[i]}: msg"), get_handoff_tool_call(agents[i + 1])]]
)
models[-1].add_multiple_turn_outputs([[get_text_message("D: final")]])

await Runner.run(
agents[0],
input="user_question",
run_config=RunConfig(nest_handoff_history=True),
)

final_input = models[-1].last_turn_args["input"]
summary = str(cast(dict[str, Any], final_input[0]).get("content", ""))
start = summary.index("<CONVERSATION HISTORY>") + len("<CONVERSATION HISTORY>")
end = summary.index("</CONVERSATION HISTORY>")
# Drop the ``N.`` ordering prefix so records are compared by content only.
records = [
re.sub(r"^\d+\.\s*", "", line.strip())
for line in summary[start:end].splitlines()
if line.strip()
]

# No two records inside the summary block should be identical.
assert len(records) == len(set(records)), f"summary has duplicate records: {records}"

# Each intermediate agent's message must appear exactly once in the summary.
for name in names[:-1]:
count = summary.count(f"{name}: msg")
assert count == 1, f"{name}: msg should appear once in the summary, got {count}"