diff --git a/src/agents/handoffs/history.py b/src/agents/handoffs/history.py index efea013523..ad0359a0a9 100644 --- a/src/agents/handoffs/history.py +++ b/src/agents/handoffs/history.py @@ -78,6 +78,15 @@ 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] = [] @@ -85,10 +94,14 @@ def nest_handoff_history( 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) 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: @@ -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: diff --git a/tests/test_handoff_history_duplication.py b/tests/test_handoff_history_duplication.py index 2a487dee38..f3d787b0eb 100644 --- a/tests/test_handoff_history_duplication.py +++ b/tests/test_handoff_history_duplication.py @@ -6,6 +6,7 @@ """ import json +import re from typing import Any, cast import pytest @@ -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. @@ -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 + 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("") + len("") + end = summary.index("") + # 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}"