527 lines
19 KiB
Python
527 lines
19 KiB
Python
"""Tests for handoff history duplication fix (Issue #2171).
|
|
|
|
These tests verify that when nest_handoff_history is enabled,
|
|
function_call and function_call_output items are NOT duplicated
|
|
in the input sent to the next agent.
|
|
"""
|
|
|
|
import json
|
|
from typing import Any, cast
|
|
|
|
import pytest
|
|
from openai.types.responses import (
|
|
ResponseFunctionToolCall,
|
|
ResponseOutputMessage,
|
|
ResponseOutputText,
|
|
)
|
|
from openai.types.responses.response_reasoning_item import ResponseReasoningItem, Summary
|
|
|
|
from agents import Agent, RunConfig, Runner, function_tool, handoff
|
|
from agents.handoffs import HandoffInputData, nest_handoff_history
|
|
from agents.items import (
|
|
HandoffCallItem,
|
|
HandoffOutputItem,
|
|
MessageOutputItem,
|
|
ReasoningItem,
|
|
ToolApprovalItem,
|
|
ToolCallItem,
|
|
ToolCallOutputItem,
|
|
)
|
|
|
|
from .fake_model import FakeModel
|
|
from .test_responses import get_function_tool_call, get_handoff_tool_call, get_text_message
|
|
|
|
|
|
def _create_mock_agent() -> Agent:
|
|
"""Create a mock agent for testing."""
|
|
return Agent(name="test_agent")
|
|
|
|
|
|
def _create_tool_call_item(agent: Agent) -> ToolCallItem:
|
|
"""Create a mock ToolCallItem."""
|
|
raw_item = ResponseFunctionToolCall(
|
|
id="call_tool_123",
|
|
call_id="call_tool_123",
|
|
name="get_weather",
|
|
arguments='{"city": "London"}',
|
|
type="function_call",
|
|
)
|
|
return ToolCallItem(agent=agent, raw_item=raw_item, type="tool_call_item")
|
|
|
|
|
|
def _create_tool_output_item(agent: Agent) -> ToolCallOutputItem:
|
|
"""Create a mock ToolCallOutputItem."""
|
|
raw_item = {
|
|
"type": "function_call_output",
|
|
"call_id": "call_tool_123",
|
|
"output": "Sunny, 22°C",
|
|
}
|
|
return ToolCallOutputItem(
|
|
agent=agent,
|
|
raw_item=raw_item,
|
|
output="Sunny, 22°C",
|
|
type="tool_call_output_item",
|
|
)
|
|
|
|
|
|
def _create_handoff_call_item(agent: Agent) -> HandoffCallItem:
|
|
"""Create a mock HandoffCallItem."""
|
|
raw_item = ResponseFunctionToolCall(
|
|
id="call_handoff_456",
|
|
call_id="call_handoff_456",
|
|
name="transfer_to_agent_b",
|
|
arguments="{}",
|
|
type="function_call",
|
|
)
|
|
return HandoffCallItem(agent=agent, raw_item=raw_item, type="handoff_call_item")
|
|
|
|
|
|
def _create_handoff_output_item(agent: Agent[Any]) -> HandoffOutputItem:
|
|
"""Create a mock HandoffOutputItem."""
|
|
raw_item: dict[str, str] = {
|
|
"type": "function_call_output",
|
|
"call_id": "call_handoff_456",
|
|
"output": '{"assistant": "agent_b"}',
|
|
}
|
|
return HandoffOutputItem(
|
|
agent=agent,
|
|
raw_item=cast(Any, raw_item),
|
|
source_agent=agent,
|
|
target_agent=agent,
|
|
type="handoff_output_item",
|
|
)
|
|
|
|
|
|
def _create_message_item(agent: Agent) -> MessageOutputItem:
|
|
"""Create a mock MessageOutputItem."""
|
|
raw_item = ResponseOutputMessage(
|
|
id="msg_123",
|
|
content=[ResponseOutputText(text="Hello!", type="output_text", annotations=[])],
|
|
role="assistant",
|
|
status="completed",
|
|
type="message",
|
|
)
|
|
return MessageOutputItem(agent=agent, raw_item=raw_item, type="message_output_item")
|
|
|
|
|
|
def _create_reasoning_item(agent: Agent) -> ReasoningItem:
|
|
"""Create a mock ReasoningItem."""
|
|
raw_item = ResponseReasoningItem(
|
|
id="reasoning_123",
|
|
type="reasoning",
|
|
summary=[Summary(text="Thinking about handoff", type="summary_text")],
|
|
)
|
|
return ReasoningItem(agent=agent, raw_item=raw_item, type="reasoning_item")
|
|
|
|
|
|
def _create_tool_approval_item(agent: Agent) -> ToolApprovalItem:
|
|
"""Create a mock ToolApprovalItem."""
|
|
raw_item = {
|
|
"type": "function_call",
|
|
"call_id": "call_tool_approve",
|
|
"name": "needs_approval",
|
|
"arguments": "{}",
|
|
}
|
|
return ToolApprovalItem(agent=agent, raw_item=raw_item)
|
|
|
|
|
|
class TestHandoffHistoryDuplicationFix:
|
|
"""Tests for Issue #2171: nest_handoff_history duplication fix."""
|
|
|
|
def test_pre_handoff_tool_items_are_filtered(self):
|
|
"""Verify ToolCallItem and ToolCallOutputItem in pre_handoff_items are filtered.
|
|
|
|
These items should NOT appear in the filtered output because they are
|
|
already included in the summary message.
|
|
"""
|
|
agent = _create_mock_agent()
|
|
|
|
handoff_data = HandoffInputData(
|
|
input_history=({"role": "user", "content": "Hello"},),
|
|
pre_handoff_items=(
|
|
_create_tool_call_item(agent),
|
|
_create_tool_output_item(agent),
|
|
),
|
|
new_items=(),
|
|
)
|
|
|
|
nested = nest_handoff_history(handoff_data)
|
|
|
|
# pre_handoff_items should be empty (tool items filtered)
|
|
assert len(nested.pre_handoff_items) == 0, (
|
|
"ToolCallItem and ToolCallOutputItem should be filtered from pre_handoff_items"
|
|
)
|
|
|
|
# Summary should contain the conversation
|
|
assert len(nested.input_history) == 1
|
|
first_item = nested.input_history[0]
|
|
assert isinstance(first_item, dict)
|
|
assert "<CONVERSATION HISTORY>" in str(first_item.get("content", ""))
|
|
|
|
def test_tool_approval_items_are_skipped(self):
|
|
"""Verify ToolApprovalItem does not break handoff history mapping."""
|
|
agent = _create_mock_agent()
|
|
|
|
handoff_data = HandoffInputData(
|
|
input_history=({"role": "user", "content": "Hello"},),
|
|
pre_handoff_items=(_create_tool_approval_item(agent),),
|
|
new_items=(),
|
|
)
|
|
|
|
nested = nest_handoff_history(handoff_data)
|
|
|
|
assert isinstance(nested.input_history, tuple)
|
|
assert len(nested.pre_handoff_items) == 0
|
|
assert nested.input_items == ()
|
|
|
|
def test_pre_handoff_reasoning_items_are_filtered(self):
|
|
"""Verify ReasoningItem in pre_handoff_items is filtered.
|
|
|
|
Reasoning is represented in the summary transcript and should not be
|
|
forwarded as a raw item.
|
|
"""
|
|
agent = _create_mock_agent()
|
|
|
|
handoff_data = HandoffInputData(
|
|
input_history=({"role": "user", "content": "Hello"},),
|
|
pre_handoff_items=(_create_reasoning_item(agent),),
|
|
new_items=(),
|
|
)
|
|
|
|
nested = nest_handoff_history(handoff_data)
|
|
|
|
assert len(nested.pre_handoff_items) == 0
|
|
first_item = nested.input_history[0]
|
|
assert isinstance(first_item, dict)
|
|
summary = str(first_item.get("content", ""))
|
|
assert "reasoning" in summary
|
|
|
|
def test_new_items_handoff_output_is_filtered_for_input(self):
|
|
"""Verify HandoffOutputItem in new_items is filtered from input_items.
|
|
|
|
The HandoffOutputItem is a function_call_output which would be duplicated.
|
|
It should be filtered from input_items but preserved in new_items.
|
|
"""
|
|
agent = _create_mock_agent()
|
|
|
|
handoff_data = HandoffInputData(
|
|
input_history=({"role": "user", "content": "Hello"},),
|
|
pre_handoff_items=(),
|
|
new_items=(
|
|
_create_handoff_call_item(agent),
|
|
_create_handoff_output_item(agent),
|
|
),
|
|
)
|
|
|
|
nested = nest_handoff_history(handoff_data)
|
|
|
|
# new_items should still have both items (for session history)
|
|
assert len(nested.new_items) == 2, "new_items should preserve all items for session history"
|
|
|
|
# input_items should be populated and filtered
|
|
assert nested.input_items is not None, "input_items should be populated"
|
|
|
|
# input_items should NOT contain HandoffOutputItem (it's function_call_output)
|
|
has_handoff_output = any(isinstance(item, HandoffOutputItem) for item in nested.input_items)
|
|
assert not has_handoff_output, "HandoffOutputItem should be filtered from input_items"
|
|
|
|
def test_message_items_are_preserved_in_new_items(self):
|
|
"""Verify MessageOutputItem in new_items is preserved.
|
|
|
|
Message items have a 'role' and should NOT be filtered from input_items.
|
|
Note: pre_handoff_items are converted to summary text regardless of type.
|
|
"""
|
|
agent = _create_mock_agent()
|
|
|
|
handoff_data = HandoffInputData(
|
|
input_history=({"role": "user", "content": "Hello"},),
|
|
pre_handoff_items=(), # pre_handoff items go into summary
|
|
new_items=(_create_message_item(agent),),
|
|
)
|
|
|
|
nested = nest_handoff_history(handoff_data)
|
|
|
|
# Message items should be preserved in new_items
|
|
assert len(nested.new_items) == 1, "MessageOutputItem should be preserved in new_items"
|
|
# And in input_items (since it has a role)
|
|
assert nested.input_items is not None
|
|
assert len(nested.input_items) == 1, "MessageOutputItem should be preserved in input_items"
|
|
assert isinstance(nested.input_items[0], MessageOutputItem)
|
|
|
|
def test_reasoning_items_are_filtered_from_input_items(self):
|
|
"""Verify ReasoningItem in new_items is filtered from input_items.
|
|
|
|
Reasoning is summarized in the conversation transcript and should not be
|
|
forwarded verbatim in nested handoff model input.
|
|
"""
|
|
agent = _create_mock_agent()
|
|
|
|
handoff_data = HandoffInputData(
|
|
input_history=({"role": "user", "content": "Hello"},),
|
|
pre_handoff_items=(),
|
|
new_items=(
|
|
_create_reasoning_item(agent),
|
|
_create_handoff_call_item(agent),
|
|
_create_handoff_output_item(agent),
|
|
),
|
|
)
|
|
|
|
nested = nest_handoff_history(handoff_data)
|
|
|
|
assert nested.input_items is not None
|
|
has_reasoning = any(isinstance(item, ReasoningItem) for item in nested.input_items)
|
|
assert not has_reasoning, "ReasoningItem should be filtered from input_items"
|
|
|
|
first_item = nested.input_history[0]
|
|
assert isinstance(first_item, dict)
|
|
summary = str(first_item.get("content", ""))
|
|
assert "reasoning" in summary
|
|
|
|
def test_summary_contains_filtered_items_as_text(self):
|
|
"""Verify the summary message contains the filtered tool items as text.
|
|
|
|
This ensures observability - the items are not lost, just converted to text.
|
|
"""
|
|
agent = _create_mock_agent()
|
|
|
|
handoff_data = HandoffInputData(
|
|
input_history=({"role": "user", "content": "Hello"},),
|
|
pre_handoff_items=(
|
|
_create_tool_call_item(agent),
|
|
_create_tool_output_item(agent),
|
|
),
|
|
new_items=(),
|
|
)
|
|
|
|
nested = nest_handoff_history(handoff_data)
|
|
|
|
first_item = nested.input_history[0]
|
|
assert isinstance(first_item, dict)
|
|
summary = str(first_item.get("content", ""))
|
|
|
|
# Summary should contain function_call reference
|
|
assert "function_call" in summary or "get_weather" in summary, (
|
|
"Summary should contain the tool call that was filtered"
|
|
)
|
|
|
|
def test_input_items_field_exists_after_nesting(self):
|
|
"""Verify the input_items field is populated after nest_handoff_history.
|
|
|
|
This is the key field that separates model input from session history.
|
|
"""
|
|
agent = _create_mock_agent()
|
|
|
|
handoff_data = HandoffInputData(
|
|
input_history=({"role": "user", "content": "Hello"},),
|
|
pre_handoff_items=(),
|
|
new_items=(_create_handoff_call_item(agent),),
|
|
)
|
|
|
|
nested = nest_handoff_history(handoff_data)
|
|
|
|
assert nested.input_items is not None, (
|
|
"input_items should be populated after nest_handoff_history"
|
|
)
|
|
|
|
def test_full_handoff_scenario_no_duplication(self):
|
|
"""Full end-to-end test of the handoff scenario from Issue #2171.
|
|
|
|
Simulates: User -> Agent does tool call -> Agent hands off to next agent
|
|
Verifies: Next agent receives summary only, no duplicate raw items.
|
|
"""
|
|
agent = _create_mock_agent()
|
|
|
|
# Full scenario: tool call in pre_handoff, handoff in new_items
|
|
handoff_data = HandoffInputData(
|
|
input_history=({"role": "user", "content": "What's the weather?"},),
|
|
pre_handoff_items=(
|
|
_create_tool_call_item(agent), # function_call
|
|
_create_tool_output_item(agent), # function_call_output
|
|
),
|
|
new_items=(
|
|
_create_message_item(agent), # assistant message
|
|
_create_handoff_call_item(agent), # function_call (handoff)
|
|
_create_handoff_output_item(agent), # function_call_output (handoff)
|
|
),
|
|
)
|
|
|
|
nested = nest_handoff_history(handoff_data)
|
|
|
|
# Count what would be sent to the model
|
|
total_model_items = (
|
|
len(nested.input_history) # Summary
|
|
+ len(nested.pre_handoff_items) # Filtered pre-handoff
|
|
+ len(nested.input_items or []) # Filtered new items
|
|
)
|
|
|
|
# Before fix: would have 6+ items (summary + raw tool items)
|
|
# After fix: should have ~2 items (summary + message)
|
|
assert total_model_items <= 3, (
|
|
f"Model should receive at most 3 items (summary + messages), got {total_model_items}"
|
|
)
|
|
|
|
# Verify no raw function_call_output items in model input
|
|
all_input_items = list(nested.pre_handoff_items) + list(nested.input_items or [])
|
|
function_call_outputs = [
|
|
item
|
|
for item in all_input_items
|
|
if isinstance(item, ToolCallOutputItem | HandoffOutputItem)
|
|
]
|
|
assert len(function_call_outputs) == 0, (
|
|
"No function_call_output items should be in model input"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_to_input_list_normalized_uses_filtered_continuation_after_nested_handoff() -> None:
|
|
triage_model = FakeModel()
|
|
delegate_model = FakeModel()
|
|
|
|
delegate = Agent(name="delegate", model=delegate_model)
|
|
triage = Agent(name="triage", model=triage_model, handoffs=[delegate])
|
|
|
|
triage_model.add_multiple_turn_outputs(
|
|
[[get_text_message("triage summary"), get_handoff_tool_call(delegate)]]
|
|
)
|
|
delegate_model.add_multiple_turn_outputs(
|
|
[
|
|
[get_text_message("resolution")],
|
|
[get_text_message("followup answer")],
|
|
]
|
|
)
|
|
|
|
result = await Runner.run(
|
|
triage,
|
|
input="user_question",
|
|
run_config=RunConfig(nest_handoff_history=True),
|
|
)
|
|
|
|
preserve_all_input = result.to_input_list()
|
|
normalized_input = result.to_input_list(mode="normalized")
|
|
preserve_all_types = [
|
|
item.get("type", "message") for item in preserve_all_input if isinstance(item, dict)
|
|
]
|
|
normalized_types = [
|
|
item.get("type", "message") for item in normalized_input if isinstance(item, dict)
|
|
]
|
|
|
|
assert len(preserve_all_input) == 5
|
|
assert "function_call" in preserve_all_types
|
|
assert "function_call_output" in preserve_all_types
|
|
assert len(normalized_input) == 3
|
|
assert "function_call" not in normalized_types
|
|
assert "function_call_output" not in normalized_types
|
|
|
|
follow_up_input = normalized_input + [{"role": "user", "content": "follow up?"}]
|
|
follow_up_result = await Runner.run(delegate, input=follow_up_input)
|
|
|
|
assert follow_up_result.final_output == "followup answer"
|
|
assert delegate_model.last_turn_args["input"] == follow_up_input
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_to_input_list_normalized_keeps_delegate_tool_items_after_nested_handoff() -> None:
|
|
async def lookup_weather(city: str) -> str:
|
|
return f"weather:{city}"
|
|
|
|
triage_model = FakeModel()
|
|
delegate_model = FakeModel()
|
|
|
|
delegate = Agent(
|
|
name="delegate",
|
|
model=delegate_model,
|
|
tools=[function_tool(lookup_weather, name_override="lookup_weather")],
|
|
)
|
|
triage = Agent(name="triage", model=triage_model, handoffs=[delegate])
|
|
|
|
triage_model.add_multiple_turn_outputs(
|
|
[[get_text_message("triage summary"), get_handoff_tool_call(delegate)]]
|
|
)
|
|
delegate_model.add_multiple_turn_outputs(
|
|
[
|
|
[
|
|
get_text_message("delegate preamble"),
|
|
get_function_tool_call("lookup_weather", json.dumps({"city": "Tokyo"})),
|
|
],
|
|
[get_text_message("resolution")],
|
|
]
|
|
)
|
|
|
|
result = await Runner.run(
|
|
triage,
|
|
input="user_question",
|
|
run_config=RunConfig(nest_handoff_history=True),
|
|
)
|
|
|
|
preserve_all_input = result.to_input_list()
|
|
normalized_input = result.to_input_list(mode="normalized")
|
|
preserve_all_function_calls = [
|
|
cast(dict[str, Any], item)
|
|
for item in preserve_all_input
|
|
if isinstance(item, dict) and item.get("type") == "function_call"
|
|
]
|
|
preserve_all_function_outputs = [
|
|
cast(dict[str, Any], item)
|
|
for item in preserve_all_input
|
|
if isinstance(item, dict) and item.get("type") == "function_call_output"
|
|
]
|
|
function_calls = [
|
|
cast(dict[str, Any], item)
|
|
for item in normalized_input
|
|
if isinstance(item, dict) and item.get("type") == "function_call"
|
|
]
|
|
function_outputs = [
|
|
cast(dict[str, Any], item)
|
|
for item in normalized_input
|
|
if isinstance(item, dict) and item.get("type") == "function_call_output"
|
|
]
|
|
|
|
assert len(preserve_all_function_calls) == 2
|
|
assert len(preserve_all_function_outputs) == 2
|
|
assert len(function_calls) == 1
|
|
assert function_calls[0]["name"] == "lookup_weather"
|
|
assert len(function_outputs) == 1
|
|
assert function_outputs[0]["output"] == "weather:Tokyo"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_to_input_list_normalized_uses_custom_filter_input_items() -> None:
|
|
def keep_messages_only(data: HandoffInputData) -> HandoffInputData:
|
|
return data.clone(
|
|
input_items=tuple(
|
|
item for item in data.new_items if isinstance(item, MessageOutputItem)
|
|
)
|
|
)
|
|
|
|
triage_model = FakeModel()
|
|
delegate_model = FakeModel()
|
|
|
|
delegate = Agent(name="delegate", model=delegate_model)
|
|
triage = Agent(
|
|
name="triage",
|
|
model=triage_model,
|
|
handoffs=[handoff(delegate, input_filter=keep_messages_only)],
|
|
)
|
|
|
|
triage_model.add_multiple_turn_outputs(
|
|
[[get_text_message("triage summary"), get_handoff_tool_call(delegate)]]
|
|
)
|
|
delegate_model.add_multiple_turn_outputs([[get_text_message("resolution")]])
|
|
|
|
result = await Runner.run(triage, input="user_question")
|
|
preserve_all_input = result.to_input_list()
|
|
normalized_input = result.to_input_list(mode="normalized")
|
|
preserve_all_types = [
|
|
item.get("type", "message") for item in preserve_all_input if isinstance(item, dict)
|
|
]
|
|
normalized_types = [
|
|
item.get("type", "message") for item in normalized_input if isinstance(item, dict)
|
|
]
|
|
|
|
assert len(preserve_all_input) == 5
|
|
assert "function_call" in preserve_all_types
|
|
assert "function_call_output" in preserve_all_types
|
|
assert len(normalized_input) == 3
|
|
assert "function_call" not in normalized_types
|
|
assert "function_call_output" not in normalized_types
|