Skip to content

fix(handoffs): filter duplicate items from model input when nest_handoff_history is enabled (#2171) - #1

Merged
AnkanMisra merged 1 commit into
mainfrom
fix/nest-handoff-history-duplication-2171
Jan 2, 2026
Merged

fix(handoffs): filter duplicate items from model input when nest_handoff_history is enabled (#2171)#1
AnkanMisra merged 1 commit into
mainfrom
fix/nest-handoff-history-duplication-2171

Conversation

@AnkanMisra

@AnkanMisra AnkanMisra commented Jan 2, 2026

Copy link
Copy Markdown
Owner

Summary by CodeRabbit

Release Notes

  • New Features

    • Enhanced session history to preserve complete conversation context across handoffs.
    • Improved handoff workflows with better duplicate filtering while maintaining full history.
  • Bug Fixes

    • Resolved duplication issues in streaming responses during handoff scenarios.
  • Tests

    • Added comprehensive test coverage for handoff deduplication workflows.

✏️ Tip: You can customize this high-level summary in your review settings.

Greptile Summary

This PR fixes issue openai#2171 where function_call and function_call_output items were being duplicated in the model input during agent handoffs when nest_handoff_history is enabled.

Key changes:

  • Introduces input_items field in HandoffInputData to separate filtered model input from complete session history
  • Adds session_step_items field in SingleStepResult to preserve unfiltered items for session saving and observability
  • Implements filtering logic in nest_handoff_history() that converts function calls to summary text while filtering them from direct model input
  • Updates streaming logic to use unfiltered items for events while sending filtered items to the model
  • Includes comprehensive test suite (test_handoff_history_duplication.py) covering various scenarios
  • Updates existing tests to reflect the new behavior (reduced input counts, filtered handoff outputs)

The fix ensures agents receive concise summarized context during handoffs rather than raw duplicate tool calls, while maintaining full observability through session history.

Confidence Score: 5/5

  • This PR is safe to merge with high confidence
  • The implementation is well-designed with clear separation of concerns between model input and session history. The fix includes comprehensive test coverage with 6 dedicated unit tests plus updates to existing integration tests. The changes are backwards compatible (input_items and session_step_items default to None), and the logic correctly handles both filtered and unfiltered code paths.
  • No files require special attention

Important Files Changed

Filename Overview
src/agents/handoffs/history.py Core fix: adds filtering logic to prevent duplicate function_call/function_call_output items in model input while preserving them in session history
src/agents/handoffs/init.py Adds input_items field to HandoffInputData to separate model input from session history
src/agents/_run_impl.py Implements session_step_items field and logic to use filtered input_items for model while preserving unfiltered items for session
src/agents/run.py Updates streaming logic to use unfiltered session_step_items for observability events
tests/test_handoff_history_duplication.py Comprehensive test suite verifying duplication fix works correctly across various scenarios

Sequence Diagram

sequenceDiagram
    participant User
    participant Agent1
    participant Model
    participant HandoffLogic
    participant Agent2

    User->>Agent1: Initial request
    Agent1->>Model: Process input
    Model->>Agent1: Call tool (function_call)
    Agent1->>Agent1: Execute tool
    Agent1->>Agent1: Generate function_call_output
    Agent1->>Model: Tool result
    Model->>Agent1: Handoff to Agent2 (function_call)
    
    Note over Agent1,HandoffLogic: Handoff occurs
    Agent1->>HandoffLogic: Create HandoffInputData<br/>(pre_handoff_items + new_items)
    
    alt nest_handoff_history enabled
        HandoffLogic->>HandoffLogic: nest_handoff_history()
        Note over HandoffLogic: Filter function_call & function_call_output<br/>from pre_handoff_items and new_items
        HandoffLogic->>HandoffLogic: Create summary with filtered items as text
        HandoffLogic->>HandoffLogic: Set input_items (filtered for model)
        HandoffLogic->>HandoffLogic: Keep new_items unchanged (for session)
    end
    
    HandoffLogic->>Agent2: Pass filtered input_items to model
    Note over Agent2: Receives summary + messages only<br/>(no duplicate function_call/output)
    
    HandoffLogic->>HandoffLogic: Save full new_items to session
    Note over HandoffLogic: Session history preserves all items<br/>for observability
    
    Agent2->>Model: Continue with filtered input
    Model->>Agent2: Generate response
    Agent2->>User: Final output
Loading

@coderabbitai

coderabbitai Bot commented Jan 2, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

The changes introduce session history tracking that separates full event items from filtered items sent to the model. New session_step_items and input_items fields preserve complete history while preventing duplicate tool outputs from reaching the model via filtering rules applied during handoff history processing.

Changes

Cohort / File(s) Summary
Core Session & Handoff Data
src/agents/_run_impl.py, src/agents/handoffs/__init__.py
Added session_step_items field to SingleStepResult and input_items field to HandoffInputData to track unfiltered items separately from model input items; SingleStepResult.generated_items now prefers session_step_items when set
Handoff History Filtering Logic
src/agents/handoffs/history.py
Introduced _SUMMARY_ONLY_INPUT_TYPES set and filtering helpers (_should_forward_pre_item, _should_forward_new_item) to separate summarized vs forwarded items; rewrote nest_handoff_history to filter tool outputs from model input while preserving them in session history
Streaming & Deduplication
src/agents/run.py
Updated _get_single_step_result_from_streamed_response to use session_step_items as source of truth for deduplication when available, falling back to new_step_items
Test Expectations
tests/test_agent_runner.py, tests/test_agent_runner_streamed.py
Reduced expected session item counts (e.g., 7→5, 10→8) across multiple test cases to reflect summarization of pre-handoff tool items and simplified input lists
New Duplication Tests
tests/test_handoff_history_duplication.py
New test module validating that tool outputs are filtered from model input but preserved in history, confirming no raw function_call_output items are duplicated in model input
Event Name Fix
tests/test_soft_cancel.py
Updated event trigger from "handoff_occured" to "handoff_requested" in soft cancel handoff path

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 In burrows deep where histories hide,
We separate what flows inside,
Keep every note, yet show but few—
No ducts for tools, just what is true!
Session trails now run pristine,
Where only needed items gleam.

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 67.86% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main fix: filtering duplicate items from model input when nest_handoff_history is enabled, which aligns with the primary changes across the codebase.
✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@AnkanMisra
AnkanMisra merged commit effc877 into main Jan 2, 2026
1 of 2 checks passed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/agents/_run_impl.py (1)

245-270: Filtered handoff items are reintroduced into the next model call via generated_items

The wiring of HandoffInputData.input_items and session_step_items in execute_handoffs correctly separates concerns in isolation:

  • For custom filters with input_items set, you correctly:
    • Keep filtered.new_items as the full set for history (session_step_items),
    • Use filtered.input_items as new_step_items (filtered for the next model).
  • For nested history, you:
    • Collapse history into nested.input_history,
    • Keep full nested.new_items in session_step_items,
    • Use nested.input_items (filtered) as new_step_items when available.

However, the implementation has a critical flaw: because SingleStepResult.generated_items prefers session_step_items when set, and AgentRunner feeds generated_items directly into the next turn's model input, the filtering is bypassed:

  • In AgentRunner.run (line 704) and _start_streaming (line 1216), you set:

    generated_items = turn_result.generated_items
  • Then in _run_single_turn (line 1623–1624) and the streaming flow (line 1406–1407), you construct the next model input as:

    input.extend([generated_item.to_input_item() for generated_item in generated_items])

When session_step_items is populated (i.e., whenever filtering or nesting is active), generated_items contains the unfiltered items (including ToolCallOutputItem / HandoffOutputItem), not the filtered new_step_items you carefully computed. As a result, the next agent still sees raw function_call_output / HandoffOutputItem in its prompt, even though input_items or nest_handoff_history were meant to prevent exactly that.

The tests in test_handoff_history_duplication.py verify the nest_handoff_history function's output in isolation but do not exercise the full AgentRunner loop, so this reintroduction of unfiltered items is not caught.

Suggested fix: Separate the concepts explicitly. Use session_step_items strictly for session persistence and final result representation, while deriving the next-turn input from the filtered path:

  • In the run loop, maintain two accumulators:
    • history_items = turn_result.generated_items (full history, uses session_step_items),
    • input_items_for_next_turn = turn_result.pre_step_items + turn_result.new_step_items (filtered).
  • Pass input_items_for_next_turn to _run_single_turn and _run_single_turn_streamed for model input construction, while keeping history_items for the final RunResult.new_items and streaming observability.

A similar split is needed in _start_streaming where streamed_result.new_items is currently set to turn_result.generated_items.

🧹 Nitpick comments (3)
src/agents/handoffs/history.py (1)

29-35: Handoff history now cleanly separates summary-only vs forwarded items

The new _SUMMARY_ONLY_INPUT_TYPES plus _should_forward_pre_item / _should_forward_new_item give nest_handoff_history a clear policy:

  • Pre‑handoff assistant messages and any function_call / function_call_output items are summarized into input_history and removed from pre_handoff_items.
  • New items are always summarized but only forwarded via input_items when they either:
    • Have a role (user/assistant/system/developer), or
    • Are non‑function tools (e.g., shell, MCP outputs).

new_items remains untouched for session history, while input_items provides a deduplicated view for the next agent. This matches the tests in tests/test_handoff_history_duplication.py and the stated goal of avoiding duplicate function tool calls/outputs in model input while retaining observability via the summary.

Also applies to: 68-105, 252-268

tests/test_handoff_history_duplication.py (1)

1-276: New handoff history duplication tests accurately cover nest_handoff_history behavior

This suite does a good job of pinning down the intended semantics:

  • Tool function_call / function_call_output entries in pre_handoff_items are removed from the forwarded sets and only appear inside the summary.
  • HandoffOutputItem is preserved in new_items for session history but filtered out of input_items so it doesn’t hit the next model call twice.
  • Message items with a role are preserved in both new_items and input_items.
  • The summary text is asserted to contain the filtered tool activity, keeping observability.
  • The final scenario bounds the effective model input to “summary + messages” and explicitly checks that no ToolCallOutputItem / HandoffOutputItem instances appear in what’s treated as model input.

These tests nicely lock in the new HandoffInputData.input_items and nest_handoff_history contracts; adding a couple of Runner-level end-to-end tests in the future (driving an actual handoff with nest_handoff_history=True) would further ensure the full pipeline respects these guarantees.

tests/test_agent_runner_streamed.py (1)

234-234: Add detailed breakdown to the assertion comment.

The expectation of 8 items appears correct, but the comment lacks detail about the composition. Consider adding a breakdown similar to lines 178-180 for clarity:

assert len(result.to_input_list()) == 8, (
    "should have 8 items: summary (contains 2 original inputs + pre-handoff tool call/result), "
    "message, handoff call, handoff output, preamble message, tool call, tool result, final output"
)
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c4ed605 and 824faeb.

📒 Files selected for processing (8)
  • src/agents/_run_impl.py
  • src/agents/handoffs/__init__.py
  • src/agents/handoffs/history.py
  • src/agents/run.py
  • tests/test_agent_runner.py
  • tests/test_agent_runner_streamed.py
  • tests/test_handoff_history_duplication.py
  • tests/test_soft_cancel.py
🧰 Additional context used
🧬 Code graph analysis (4)
tests/test_agent_runner_streamed.py (1)
src/agents/result.py (1)
  • to_input_list (125-130)
tests/test_handoff_history_duplication.py (3)
src/agents/handoffs/__init__.py (1)
  • HandoffInputData (42-82)
src/agents/handoffs/history.py (1)
  • nest_handoff_history (68-105)
src/agents/items.py (1)
  • ToolCallOutputItem (257-287)
tests/test_agent_runner.py (1)
src/agents/result.py (1)
  • to_input_list (125-130)
src/agents/handoffs/history.py (1)
src/agents/handoffs/__init__.py (1)
  • clone (72-82)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Greptile Review
🔇 Additional comments (7)
tests/test_soft_cancel.py (1)

423-429: Use of "handoff_requested" event name matches streaming semantics

Switching the check to event.name == "handoff_requested" aligns this test with RunImpl.stream_step_items_to_queue, which emits "handoff_requested" for HandoffCallItem. This better reflects “handoff has been invoked” for soft-cancel timing.

src/agents/handoffs/__init__.py (1)

41-71: New input_items field is well-scoped and backwards compatible

The input_items: tuple[RunItem, ...] | None = None addition cleanly distinguishes “items for next agent input” from new_items used for history, and the default keeps existing filters working. The docstring clearly describes the contract and how it’s meant to be used by handoff filters and history nesting.

src/agents/run.py (1)

1703-1755: Streaming now prefers session_step_items for observability (LGTM)

Using

streaming_items = (
    single_step_result.session_step_items
    if single_step_result.session_step_items is not None
    else single_step_result.new_step_items
)

and then removing items already present in new_items_processed_response before enqueueing gives the streaming path a consistent, de‑duplicated view of all per‑turn items, while still respecting the new session/history separation introduced in _run_impl.

This change is internally consistent with the new session_step_items semantics; the remaining question of how those semantics impact next‑turn model input is covered in the _run_impl.py comment.

tests/test_agent_runner.py (1)

110-140: Updated to_input_list() expectations match new summary-based history

The revised len(result.to_input_list()) assertions and messages in:

  • test_tool_call_runs (5 items: original input, first message, tool call, tool result, final message),
  • test_handoffs (5 items: summary message + message + handoff call + handoff output + final message),
  • test_structured_output (8 inputs including the nested summary),

accurately reflect the new behavior where prior turns are collapsed into a summary and only key recent items are carried forward explicitly.

Also applies to: 173-178, 227-230

tests/test_agent_runner_streamed.py (3)

737-738: The original review identifies an apparent inconsistency that does not exist. The code is actually consistent and the comment on line 737 clearly explains the intended behavior:

  • Line 717–720 shows that to_input_list() contains 7 items, including "handoff output" (as stated in the comment)
  • Line 737–738 correctly sets "handoff_output": 0 in the stream events
  • The comment on line 737 explains: "handoff_output is summarized in conversation history, not duplicated as raw item"

This means the handoff_output information is included in to_input_list() as part of the conversation summary (item #1), but it does not appear as a separate run_item_stream_event item. This is the intended deduplication behavior and is clearly documented. No clarification or revision needed.

Likely an incorrect or invalid review comment.


717-720: Verify that the to_input_list() count of 7 items is correct.

The test assertion expects 7 items, described as: conversation summary (contains pre-handoff tool calls), message, handoff, handoff output, tool call, tool call result, final output. Based on the code structure, to_input_list() returns original_items (2 input messages) concatenated with new_items generated during the run. Pre-handoff tool calls and outputs are summarized per the handoff history logic (lines 29-34 in src/agents/handoffs/history.py), but the exact composition of new_items in the final result requires runtime verification to confirm the count is accurate.

Run the test to verify:

python -m pytest tests/test_agent_runner_streamed.py::test_streaming_events -xvs

178-181: The assertion count of 5 items is correct. The session items are: summary message (containing original input and pre-handoff tool call/result from turn 1), message from turn 2, handoff call, handoff output, and done message from agent_1.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant