Skip to content

Commit e895a77

Browse files
committed
Nest handoff history by default
1 parent a30c32e commit e895a77

File tree

7 files changed

+230
-9
lines changed

7 files changed

+230
-9
lines changed

docs/running_agents.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,14 @@ The `run_config` parameter lets you configure some global settings for the agent
5151
- [`model_settings`][agents.run.RunConfig.model_settings]: Overrides agent-specific settings. For example, you can set a global `temperature` or `top_p`.
5252
- [`input_guardrails`][agents.run.RunConfig.input_guardrails], [`output_guardrails`][agents.run.RunConfig.output_guardrails]: A list of input or output guardrails to include on all runs.
5353
- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]: A global input filter to apply to all handoffs, if the handoff doesn't already have one. The input filter allows you to edit the inputs that are sent to the new agent. See the documentation in [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] for more details.
54+
- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]: When `True` (the default) the runner wraps the prior transcript in a developer-role summary message and keeps the latest user turn separate before invoking the next agent. Set this to `False` or provide a custom handoff filter if you prefer to pass through the raw transcript. You can also call [`nest_handoff_history`](agents.extensions.handoff_filters.nest_handoff_history) from your own filters to reuse the default behavior.
5455
- [`tracing_disabled`][agents.run.RunConfig.tracing_disabled]: Allows you to disable [tracing](tracing.md) for the entire run.
5556
- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]: Configures whether traces will include potentially sensitive data, such as LLM and tool call inputs/outputs.
5657
- [`workflow_name`][agents.run.RunConfig.workflow_name], [`trace_id`][agents.run.RunConfig.trace_id], [`group_id`][agents.run.RunConfig.group_id]: Sets the tracing workflow name, trace ID and trace group ID for the run. We recommend at least setting `workflow_name`. The group ID is an optional field that lets you link traces across multiple runs.
5758
- [`trace_metadata`][agents.run.RunConfig.trace_metadata]: Metadata to include on all traces.
5859

60+
By default, the SDK now nests prior turns inside a developer summary message whenever an agent hands off to another agent. This reduces repeated assistant messages and keeps the most recent user turn explicit for the receiving agent. If you'd like to return to the legacy behavior, pass `RunConfig(nest_handoff_history=False)` or supply a `handoff_input_filter` that forwards the conversation exactly as you need.
61+
5962
## Conversations/chat threads
6063

6164
Calling any of the run methods can result in one or more agents running (and hence one or more LLM calls), but it represents a single logical turn in a chat conversation. For example:

src/agents/_run_impl.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
ToolOutputGuardrailTripwireTriggered,
5252
UserError,
5353
)
54+
from .extensions.handoff_filters import nest_handoff_history
5455
from .guardrail import InputGuardrail, InputGuardrailResult, OutputGuardrail, OutputGuardrailResult
5556
from .handoffs import Handoff, HandoffInputData
5657
from .items import (
@@ -998,8 +999,8 @@ async def execute_handoffs(
998999
input_filter = handoff.input_filter or (
9991000
run_config.handoff_input_filter if run_config else None
10001001
)
1001-
if input_filter:
1002-
logger.debug("Filtering inputs for handoff")
1002+
handoff_input_data: HandoffInputData | None = None
1003+
if input_filter or run_config.nest_handoff_history:
10031004
handoff_input_data = HandoffInputData(
10041005
input_history=tuple(original_input)
10051006
if isinstance(original_input, list)
@@ -1008,6 +1009,9 @@ async def execute_handoffs(
10081009
new_items=tuple(new_step_items),
10091010
run_context=context_wrapper,
10101011
)
1012+
1013+
if input_filter and handoff_input_data is not None:
1014+
logger.debug("Filtering inputs for handoff")
10111015
if not callable(input_filter):
10121016
_error_tracing.attach_error_to_span(
10131017
span_handoff,
@@ -1037,6 +1041,15 @@ async def execute_handoffs(
10371041
)
10381042
pre_step_items = list(filtered.pre_handoff_items)
10391043
new_step_items = list(filtered.new_items)
1044+
elif run_config.nest_handoff_history and handoff_input_data is not None:
1045+
nested = nest_handoff_history(handoff_input_data)
1046+
original_input = (
1047+
nested.input_history
1048+
if isinstance(nested.input_history, str)
1049+
else list(nested.input_history)
1050+
)
1051+
pre_step_items = list(nested.pre_handoff_items)
1052+
new_step_items = list(nested.new_items)
10401053

10411054
return SingleStepResult(
10421055
original_input=original_input,

src/agents/extensions/handoff_filters.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
from __future__ import annotations
22

3+
import json
4+
from copy import deepcopy
5+
from typing import Any
6+
37
from ..handoffs import HandoffInputData
48
from ..items import (
59
HandoffCallItem,
610
HandoffOutputItem,
11+
ItemHelpers,
712
ReasoningItem,
813
RunItem,
914
ToolCallItem,
@@ -34,6 +39,102 @@ def remove_all_tools(handoff_input_data: HandoffInputData) -> HandoffInputData:
3439
)
3540

3641

42+
def nest_handoff_history(handoff_input_data: HandoffInputData) -> HandoffInputData:
43+
"""Summarizes the previous transcript into a developer message for the next agent."""
44+
45+
normalized_history = _normalize_input_history(handoff_input_data.input_history)
46+
pre_items_as_inputs = [
47+
_run_item_to_plain_input(item) for item in handoff_input_data.pre_handoff_items
48+
]
49+
new_items_as_inputs = [_run_item_to_plain_input(item) for item in handoff_input_data.new_items]
50+
transcript = normalized_history + pre_items_as_inputs + new_items_as_inputs
51+
52+
developer_message = _build_developer_message(transcript)
53+
latest_user = _find_latest_user_turn(transcript)
54+
history_items: list[TResponseInputItem] = [developer_message]
55+
if latest_user is not None:
56+
history_items.append(latest_user)
57+
58+
filtered_pre_items = tuple(
59+
item
60+
for item in handoff_input_data.pre_handoff_items
61+
if _get_run_item_role(item) != "assistant"
62+
)
63+
64+
return handoff_input_data.clone(
65+
input_history=tuple(history_items),
66+
pre_handoff_items=filtered_pre_items,
67+
)
68+
69+
70+
def _normalize_input_history(
71+
input_history: str | tuple[TResponseInputItem, ...],
72+
) -> list[TResponseInputItem]:
73+
if isinstance(input_history, str):
74+
return ItemHelpers.input_to_new_input_list(input_history)
75+
return [deepcopy(item) for item in input_history]
76+
77+
78+
def _run_item_to_plain_input(run_item: RunItem) -> TResponseInputItem:
79+
return deepcopy(run_item.to_input_item())
80+
81+
82+
def _build_developer_message(transcript: list[TResponseInputItem]) -> TResponseInputItem:
83+
if transcript:
84+
summary_lines = [
85+
f"{idx + 1}. {_format_transcript_item(item)}" for idx, item in enumerate(transcript)
86+
]
87+
else:
88+
summary_lines = ["(no previous turns recorded)"]
89+
90+
content = "Previous conversation before this handoff:\n" + "\n".join(summary_lines)
91+
return {"role": "developer", "content": content}
92+
93+
94+
def _format_transcript_item(item: TResponseInputItem) -> str:
95+
role = item.get("role")
96+
if isinstance(role, str):
97+
prefix = role
98+
name = item.get("name")
99+
if isinstance(name, str) and name:
100+
prefix = f"{prefix} ({name})"
101+
content_str = _stringify_content(item.get("content"))
102+
return f"{prefix}: {content_str}" if content_str else prefix
103+
104+
item_type = item.get("type", "item")
105+
rest = {k: v for k, v in item.items() if k != "type"}
106+
try:
107+
serialized = json.dumps(rest, ensure_ascii=False, default=str)
108+
except TypeError:
109+
serialized = str(rest)
110+
return f"{item_type}: {serialized}" if serialized else str(item_type)
111+
112+
113+
def _stringify_content(content: Any) -> str:
114+
if content is None:
115+
return ""
116+
if isinstance(content, str):
117+
return content
118+
try:
119+
return json.dumps(content, ensure_ascii=False, default=str)
120+
except TypeError:
121+
return str(content)
122+
123+
124+
def _find_latest_user_turn(
125+
transcript: list[TResponseInputItem],
126+
) -> TResponseInputItem | None:
127+
for item in reversed(transcript):
128+
if item.get("role") == "user":
129+
return deepcopy(item)
130+
return None
131+
132+
133+
def _get_run_item_role(run_item: RunItem) -> str | None:
134+
role_candidate = run_item.to_input_item().get("role")
135+
return role_candidate if isinstance(role_candidate, str) else None
136+
137+
37138
def _remove_tools_from_items(items: tuple[RunItem, ...]) -> tuple[RunItem, ...]:
38139
filtered_items = []
39140
for item in items:

src/agents/run.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,11 @@ class RunConfig:
196196
agent. See the documentation in `Handoff.input_filter` for more details.
197197
"""
198198

199+
nest_handoff_history: bool = True
200+
"""Wrap prior run history in a developer message before handing off when no custom input
201+
filter is set. Set to False to preserve the raw transcript behavior from previous releases.
202+
"""
203+
199204
input_guardrails: list[InputGuardrail[Any]] | None = None
200205
"""A list of input guardrails to run on the initial run input."""
201206

tests/test_agent_runner.py

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,9 +164,9 @@ async def test_handoffs():
164164

165165
assert result.final_output == "done"
166166
assert len(result.raw_responses) == 3, "should have three model responses"
167-
assert len(result.to_input_list()) == 7, (
168-
"should have 7 inputs: orig input, tool call, tool result, message, handoff, handoff"
169-
"result, and done message"
167+
assert len(result.to_input_list()) == 8, (
168+
"should have 8 inputs: dev summary, latest user input, tool call, tool result, message, "
169+
"handoff, handoff result, and done message"
170170
)
171171
assert result.last_agent == agent_1, "should have handed off to agent_1"
172172

@@ -270,6 +270,60 @@ async def test_handoff_filters():
270270
)
271271

272272

273+
@pytest.mark.asyncio
274+
async def test_default_handoff_history_nested_and_filters_respected():
275+
model = FakeModel()
276+
agent_1 = Agent(
277+
name="delegate",
278+
model=model,
279+
)
280+
agent_2 = Agent(
281+
name="triage",
282+
model=model,
283+
handoffs=[agent_1],
284+
)
285+
286+
model.add_multiple_turn_outputs(
287+
[
288+
[get_text_message("triage summary"), get_handoff_tool_call(agent_1)],
289+
[get_text_message("resolution")],
290+
]
291+
)
292+
293+
result = await Runner.run(agent_2, input="user_message")
294+
295+
assert isinstance(result.input, list)
296+
assert result.input[0]["role"] == "developer"
297+
assert "Previous conversation" in result.input[0]["content"]
298+
assert "triage summary" in result.input[0]["content"]
299+
assert result.input[1]["role"] == "user"
300+
assert result.input[1]["content"] == "user_message"
301+
302+
passthrough_model = FakeModel()
303+
delegate = Agent(name="delegate", model=passthrough_model)
304+
305+
def passthrough_filter(data: HandoffInputData) -> HandoffInputData:
306+
return data
307+
308+
triage_with_filter = Agent(
309+
name="triage",
310+
model=passthrough_model,
311+
handoffs=[handoff(delegate, input_filter=passthrough_filter)],
312+
)
313+
314+
passthrough_model.add_multiple_turn_outputs(
315+
[
316+
[get_text_message("triage summary"), get_handoff_tool_call(delegate)],
317+
[get_text_message("resolution")],
318+
]
319+
)
320+
321+
filtered_result = await Runner.run(triage_with_filter, input="user_message")
322+
323+
assert isinstance(filtered_result.input, str)
324+
assert filtered_result.input == "user_message"
325+
326+
273327
@pytest.mark.asyncio
274328
async def test_async_input_filter_supported():
275329
# DO NOT rename this without updating pyproject.toml

tests/test_agent_runner_streamed.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -175,9 +175,9 @@ async def test_handoffs():
175175

176176
assert result.final_output == "done"
177177
assert len(result.raw_responses) == 3, "should have three model responses"
178-
assert len(result.to_input_list()) == 7, (
179-
"should have 7 inputs: orig input, tool call, tool result, message, handoff, handoff"
180-
"result, and done message"
178+
assert len(result.to_input_list()) == 8, (
179+
"should have 8 inputs: dev summary, latest user input, tool call, tool result, message, "
180+
"handoff, handoff result, and done message"
181181
)
182182
assert result.last_agent == agent_1, "should have handed off to agent_1"
183183

tests/test_extension_filters.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from openai.types.responses.response_reasoning_item import ResponseReasoningItem
33

44
from agents import Agent, HandoffInputData, RunContextWrapper
5-
from agents.extensions.handoff_filters import remove_all_tools
5+
from agents.extensions.handoff_filters import nest_handoff_history, remove_all_tools
66
from agents.items import (
77
HandoffOutputItem,
88
MessageOutputItem,
@@ -25,6 +25,13 @@ def _get_message_input_item(content: str) -> TResponseInputItem:
2525
}
2626

2727

28+
def _get_user_input_item(content: str) -> TResponseInputItem:
29+
return {
30+
"role": "user",
31+
"content": content,
32+
}
33+
34+
2835
def _get_reasoning_input_item() -> TResponseInputItem:
2936
return {"id": "rid", "summary": [], "type": "reasoning"}
3037

@@ -219,3 +226,41 @@ def test_removes_handoffs_from_history():
219226
assert len(filtered_data.input_history) == 1
220227
assert len(filtered_data.pre_handoff_items) == 1
221228
assert len(filtered_data.new_items) == 1
229+
230+
231+
def test_nest_handoff_history_wraps_transcript() -> None:
232+
data = HandoffInputData(
233+
input_history=(_get_user_input_item("Hello"),),
234+
pre_handoff_items=(_get_message_output_run_item("Assist reply"),),
235+
new_items=(
236+
_get_message_output_run_item("Handoff request"),
237+
_get_handoff_output_run_item("transfer"),
238+
),
239+
run_context=RunContextWrapper(context=()),
240+
)
241+
242+
nested = nest_handoff_history(data)
243+
244+
assert isinstance(nested.input_history, tuple)
245+
assert nested.input_history[0]["role"] == "developer"
246+
assert "Assist reply" in nested.input_history[0]["content"]
247+
assert nested.input_history[1]["role"] == "user"
248+
assert nested.input_history[1]["content"] == "Hello"
249+
assert len(nested.pre_handoff_items) == 0
250+
assert nested.new_items == data.new_items
251+
252+
253+
def test_nest_handoff_history_handles_missing_user() -> None:
254+
data = HandoffInputData(
255+
input_history=(),
256+
pre_handoff_items=(_get_reasoning_output_run_item(),),
257+
new_items=(),
258+
run_context=RunContextWrapper(context=()),
259+
)
260+
261+
nested = nest_handoff_history(data)
262+
263+
assert isinstance(nested.input_history, tuple)
264+
assert len(nested.input_history) == 1
265+
assert nested.input_history[0]["role"] == "developer"
266+
assert "reasoning" in nested.input_history[0]["content"].lower()

0 commit comments

Comments
 (0)