Skip to content

Commit bf2625c

Browse files
seratchAnkanMisra
andauthored
fix: #2171 dedupe nested handoff inputs (#2323)
Co-authored-by: Ankan Misra <[email protected]>
1 parent 4330b2c commit bf2625c

File tree

9 files changed

+568
-51
lines changed

9 files changed

+568
-51
lines changed

src/agents/_run_impl.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,8 @@ class SingleStepResult:
245245
"""Items generated before the current step."""
246246

247247
new_step_items: list[RunItem]
248-
"""Items generated during this current step."""
248+
"""Items generated during this current step. May be filtered during handoffs to avoid
249+
duplication in model input."""
249250

250251
next_step: NextStepHandoff | NextStepFinalOutput | NextStepRunAgain
251252
"""The next step to take."""
@@ -256,11 +257,18 @@ class SingleStepResult:
256257
tool_output_guardrail_results: list[ToolOutputGuardrailResult]
257258
"""Tool output guardrail results from this step."""
258259

260+
session_step_items: list[RunItem] | None = None
261+
"""Full unfiltered items for session history. When set, these are used instead of
262+
new_step_items for session saving and generated_items property."""
263+
259264
@property
260265
def generated_items(self) -> list[RunItem]:
261266
"""Items generated during the agent run (i.e. everything generated after
262-
`original_input`)."""
263-
return self.pre_step_items + self.new_step_items
267+
`original_input`). Uses session_step_items when available for full observability."""
268+
items = (
269+
self.session_step_items if self.session_step_items is not None else self.new_step_items
270+
)
271+
return self.pre_step_items + items
264272

265273

266274
def get_model_tracing_impl(
@@ -1290,6 +1298,12 @@ async def execute_handoffs(
12901298
)
12911299
pre_step_items = list(filtered.pre_handoff_items)
12921300
new_step_items = list(filtered.new_items)
1301+
# For custom input filters, use input_items if available, otherwise new_items
1302+
if filtered.input_items is not None:
1303+
session_step_items = list(filtered.new_items)
1304+
new_step_items = list(filtered.input_items)
1305+
else:
1306+
session_step_items = None
12931307
elif should_nest_history and handoff_input_data is not None:
12941308
nested = nest_handoff_history(
12951309
handoff_input_data,
@@ -1301,7 +1315,16 @@ async def execute_handoffs(
13011315
else list(nested.input_history)
13021316
)
13031317
pre_step_items = list(nested.pre_handoff_items)
1304-
new_step_items = list(nested.new_items)
1318+
# Keep full new_items for session history.
1319+
session_step_items = list(nested.new_items)
1320+
# Use input_items (filtered) for model input if available.
1321+
if nested.input_items is not None:
1322+
new_step_items = list(nested.input_items)
1323+
else:
1324+
new_step_items = session_step_items
1325+
else:
1326+
# No filtering or nesting - session_step_items not needed
1327+
session_step_items = None
13051328

13061329
return SingleStepResult(
13071330
original_input=original_input,
@@ -1311,6 +1334,7 @@ async def execute_handoffs(
13111334
next_step=NextStepHandoff(new_agent),
13121335
tool_input_guardrail_results=[],
13131336
tool_output_guardrail_results=[],
1337+
session_step_items=session_step_items,
13141338
)
13151339

13161340
@classmethod

src/agents/handoffs/__init__.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,13 @@ class HandoffInputData:
6262
later on, it is optional for backwards compatibility.
6363
"""
6464

65+
input_items: tuple[RunItem, ...] | None = None
66+
"""
67+
Items to include in the next agent's input. When set, these items are used instead of
68+
new_items for building the input to the next agent. This allows filtering duplicates
69+
from agent input while preserving all items in new_items for session history.
70+
"""
71+
6572
def clone(self, **kwargs: Any) -> HandoffInputData:
6673
"""
6774
Make a copy of the handoff input data, with the given arguments changed. For example, you
@@ -117,10 +124,11 @@ class Handoff(Generic[TContext, TAgent]):
117124
filter inputs (for example, to remove older inputs or remove tools from existing inputs). The
118125
function receives the entire conversation history so far, including the input item that
119126
triggered the handoff and a tool call output item representing the handoff tool's output. You
120-
are free to modify the input history or new items as you see fit. The next agent that runs will
121-
receive ``handoff_input_data.all_items``. IMPORTANT: in streaming mode, we will not stream
122-
anything as a result of this function. The items generated before will already have been
123-
streamed.
127+
are free to modify the input history or new items as you see fit. The next agent receives the
128+
input history plus ``input_items`` when provided, otherwise it receives ``new_items``. Use
129+
``input_items`` to filter model input while keeping ``new_items`` intact for session history.
130+
IMPORTANT: in streaming mode, we will not stream anything as a result of this function. The
131+
items generated before will already have been streamed.
124132
"""
125133

126134
nest_handoff_history: bool | None = None

src/agents/handoffs/history.py

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@
2626
_conversation_history_start = _DEFAULT_CONVERSATION_HISTORY_START
2727
_conversation_history_end = _DEFAULT_CONVERSATION_HISTORY_END
2828

29+
# Item types that are summarized in the conversation history.
30+
# They should not be forwarded verbatim to the next agent to avoid duplication.
31+
_SUMMARY_ONLY_INPUT_TYPES = {
32+
"function_call",
33+
"function_call_output",
34+
}
35+
2936

3037
def set_conversation_history_wrappers(
3138
*,
@@ -67,23 +74,34 @@ def nest_handoff_history(
6774

6875
normalized_history = _normalize_input_history(handoff_input_data.input_history)
6976
flattened_history = _flatten_nested_history_messages(normalized_history)
70-
pre_items_as_inputs = [
71-
_run_item_to_plain_input(item) for item in handoff_input_data.pre_handoff_items
72-
]
73-
new_items_as_inputs = [_run_item_to_plain_input(item) for item in handoff_input_data.new_items]
77+
78+
# Convert items to plain inputs for the transcript summary.
79+
pre_items_as_inputs: list[TResponseInputItem] = []
80+
filtered_pre_items: list[RunItem] = []
81+
for run_item in handoff_input_data.pre_handoff_items:
82+
plain_input = _run_item_to_plain_input(run_item)
83+
pre_items_as_inputs.append(plain_input)
84+
if _should_forward_pre_item(plain_input):
85+
filtered_pre_items.append(run_item)
86+
87+
new_items_as_inputs: list[TResponseInputItem] = []
88+
filtered_input_items: list[RunItem] = []
89+
for run_item in handoff_input_data.new_items:
90+
plain_input = _run_item_to_plain_input(run_item)
91+
new_items_as_inputs.append(plain_input)
92+
if _should_forward_new_item(plain_input):
93+
filtered_input_items.append(run_item)
94+
7495
transcript = flattened_history + pre_items_as_inputs + new_items_as_inputs
7596

7697
mapper = history_mapper or default_handoff_history_mapper
7798
history_items = mapper(transcript)
78-
filtered_pre_items = tuple(
79-
item
80-
for item in handoff_input_data.pre_handoff_items
81-
if _get_run_item_role(item) != "assistant"
82-
)
8399

84100
return handoff_input_data.clone(
85101
input_history=tuple(deepcopy(item) for item in history_items),
86-
pre_handoff_items=filtered_pre_items,
102+
pre_handoff_items=tuple(filtered_pre_items),
103+
# new_items stays unchanged for session history.
104+
input_items=tuple(filtered_input_items),
87105
)
88106

89107

@@ -231,6 +249,20 @@ def _split_role_and_name(role_text: str) -> tuple[str, str | None]:
231249
return (role_text or "developer", None)
232250

233251

234-
def _get_run_item_role(run_item: RunItem) -> str | None:
235-
role_candidate = run_item.to_input_item().get("role")
236-
return role_candidate if isinstance(role_candidate, str) else None
252+
def _should_forward_pre_item(input_item: TResponseInputItem) -> bool:
253+
"""Return False when the previous transcript item is represented in the summary."""
254+
role_candidate = input_item.get("role")
255+
if isinstance(role_candidate, str) and role_candidate == "assistant":
256+
return False
257+
type_candidate = input_item.get("type")
258+
return not (isinstance(type_candidate, str) and type_candidate in _SUMMARY_ONLY_INPUT_TYPES)
259+
260+
261+
def _should_forward_new_item(input_item: TResponseInputItem) -> bool:
262+
"""Return False for tool or side-effect items that the summary already covers."""
263+
# Items with a role should always be forwarded.
264+
role_candidate = input_item.get("role")
265+
if isinstance(role_candidate, str) and role_candidate:
266+
return True
267+
type_candidate = input_item.get("type")
268+
return not (isinstance(type_candidate, str) and type_candidate in _SUMMARY_ONLY_INPUT_TYPES)

src/agents/result.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,9 @@ class RunResultStreaming(RunResultBase):
209209
default=None,
210210
)
211211

212+
_model_input_items: list[RunItem] = field(default_factory=list, repr=False)
213+
"""Filtered items used to build model input between streaming turns."""
214+
212215
# Queues that the background run_loop writes to
213216
_event_queue: asyncio.Queue[StreamEvent | QueueCompleteSentinel] = field(
214217
default_factory=asyncio.Queue, repr=False

0 commit comments

Comments
 (0)