Skip to content

Commit 17aa135

Browse files
committed
Add handoff history mapper
1 parent 71df74b commit 17aa135

File tree

9 files changed

+118
-77
lines changed

9 files changed

+118
-77
lines changed

docs/handoffs.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ handoff_obj = handoff(
8282

8383
When a handoff occurs, it's as though the new agent takes over the conversation, and gets to see the entire previous conversation history. If you want to change this, you can set an [`input_filter`][agents.handoffs.Handoff.input_filter]. An input filter is a function that receives the existing input via a [`HandoffInputData`][agents.handoffs.HandoffInputData], and must return a new `HandoffInputData`.
8484

85-
By default the runner now wraps the prior transcript inside a developer-role summary message (see [`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]). The summary appears inside a `<CONVERSATION HISTORY>` block that keeps appending new turns when multiple handoffs happen during the same run. That default only applies when neither the handoff nor the run supplies an explicit `input_filter`, so existing code that already customizes the payload (including the examples in this repository) keeps its current behavior without changes.
85+
By default the runner now collapses the prior transcript into a single assistant summary message (see [`RunConfig.nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]). The summary appears inside a `<CONVERSATION HISTORY>` block that keeps appending new turns when multiple handoffs happen during the same run. You can provide your own mapping function via [`RunConfig.handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper] to replace the generated message without writing a full `input_filter`. That default only applies when neither the handoff nor the run supplies an explicit `input_filter`, so existing code that already customizes the payload (including the examples in this repository) keeps its current behavior without changes.
8686

8787
There are some common patterns (for example removing all tool calls from the history), which are implemented for you in [`agents.extensions.handoff_filters`][]
8888

docs/running_agents.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +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, placing the content inside a `<CONVERSATION HISTORY>` block while keeping the latest user turn separate before invoking the next agent. The block automatically appends new turns as subsequent handoffs occur. 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. All [`Runner` methods](agents.run.Runner) automatically create a `RunConfig` when you do not pass one, so the quickstarts and examples pick up this default automatically, and any explicit [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] callbacks continue to override it.
54+
- [`nest_handoff_history`][agents.run.RunConfig.nest_handoff_history]: When `True` (the default) the runner collapses the prior transcript into a single assistant message before invoking the next agent. The helper places the content inside a `<CONVERSATION HISTORY>` block that keeps appending new turns as subsequent handoffs occur. Set this to `False` or provide a custom handoff filter if you prefer to pass through the raw transcript. All [`Runner` methods](agents.run.Runner) automatically create a `RunConfig` when you do not pass one, so the quickstarts and examples pick up this default automatically, and any explicit [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] callbacks continue to override it.
55+
- [`handoff_history_mapper`][agents.run.RunConfig.handoff_history_mapper]: Optional callable that receives the normalized transcript (history + handoff items) whenever `nest_handoff_history` is `True`. It must return the exact list of input items to forward to the next agent, allowing you to replace the built-in summary without writing a full handoff filter.
5556
- [`tracing_disabled`][agents.run.RunConfig.tracing_disabled]: Allows you to disable [tracing](tracing.md) for the entire run.
5657
- [`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.
5758
- [`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.
5859
- [`trace_metadata`][agents.run.RunConfig.trace_metadata]: Metadata to include on all traces.
5960

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+
By default, the SDK now nests prior turns inside a single assistant summary message whenever an agent hands off to another agent. This reduces repeated assistant messages and keeps the full transcript inside a single block that new agents can scan quickly. If you'd like to return to the legacy behavior, pass `RunConfig(nest_handoff_history=False)` or supply a `handoff_input_filter` (or `handoff_history_mapper`) that forwards the conversation exactly as you need.
6162

6263
## Conversations/chat threads
6364

src/agents/_run_impl.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1042,7 +1042,10 @@ async def execute_handoffs(
10421042
pre_step_items = list(filtered.pre_handoff_items)
10431043
new_step_items = list(filtered.new_items)
10441044
elif run_config.nest_handoff_history and handoff_input_data is not None:
1045-
nested = nest_handoff_history(handoff_input_data)
1045+
nested = nest_handoff_history(
1046+
handoff_input_data,
1047+
history_mapper=run_config.handoff_history_mapper,
1048+
)
10461049
original_input = (
10471050
nested.input_history
10481051
if isinstance(nested.input_history, str)

src/agents/extensions/handoff_filters.py

Lines changed: 23 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from copy import deepcopy
55
from typing import Any, cast
66

7-
from ..handoffs import HandoffInputData
7+
from ..handoffs import HandoffHistoryMapper, HandoffInputData
88
from ..items import (
99
HandoffCallItem,
1010
HandoffOutputItem,
@@ -43,8 +43,12 @@ def remove_all_tools(handoff_input_data: HandoffInputData) -> HandoffInputData:
4343
_CONVERSATION_HISTORY_END = "</CONVERSATION HISTORY>"
4444

4545

46-
def nest_handoff_history(handoff_input_data: HandoffInputData) -> HandoffInputData:
47-
"""Summarizes the previous transcript into a developer message for the next agent."""
46+
def nest_handoff_history(
47+
handoff_input_data: HandoffInputData,
48+
*,
49+
history_mapper: HandoffHistoryMapper | None = None,
50+
) -> HandoffInputData:
51+
"""Summarizes the previous transcript for the next agent."""
4852

4953
normalized_history = _normalize_input_history(handoff_input_data.input_history)
5054
flattened_history = _flatten_nested_history_messages(normalized_history)
@@ -54,24 +58,29 @@ def nest_handoff_history(handoff_input_data: HandoffInputData) -> HandoffInputDa
5458
new_items_as_inputs = [_run_item_to_plain_input(item) for item in handoff_input_data.new_items]
5559
transcript = flattened_history + pre_items_as_inputs + new_items_as_inputs
5660

57-
developer_message = _build_developer_message(transcript)
58-
latest_user = _find_latest_user_turn(transcript)
59-
history_items: list[TResponseInputItem] = [developer_message]
60-
if latest_user is not None:
61-
history_items.append(latest_user)
62-
61+
mapper = history_mapper or default_handoff_history_mapper
62+
history_items = mapper(transcript)
6363
filtered_pre_items = tuple(
6464
item
6565
for item in handoff_input_data.pre_handoff_items
6666
if _get_run_item_role(item) != "assistant"
6767
)
6868

6969
return handoff_input_data.clone(
70-
input_history=tuple(history_items),
70+
input_history=tuple(deepcopy(item) for item in history_items),
7171
pre_handoff_items=filtered_pre_items,
7272
)
7373

7474

75+
def default_handoff_history_mapper(
76+
transcript: list[TResponseInputItem],
77+
) -> list[TResponseInputItem]:
78+
"""Returns a single assistant message summarizing the transcript."""
79+
80+
summary_message = _build_summary_message(transcript)
81+
return [summary_message]
82+
83+
7584
def _normalize_input_history(
7685
input_history: str | tuple[TResponseInputItem, ...],
7786
) -> list[TResponseInputItem]:
@@ -84,7 +93,7 @@ def _run_item_to_plain_input(run_item: RunItem) -> TResponseInputItem:
8493
return deepcopy(run_item.to_input_item())
8594

8695

87-
def _build_developer_message(transcript: list[TResponseInputItem]) -> TResponseInputItem:
96+
def _build_summary_message(transcript: list[TResponseInputItem]) -> TResponseInputItem:
8897
transcript_copy = [deepcopy(item) for item in transcript]
8998
if transcript_copy:
9099
summary_lines = [
@@ -96,11 +105,11 @@ def _build_developer_message(transcript: list[TResponseInputItem]) -> TResponseI
96105

97106
content_lines = [_CONVERSATION_HISTORY_START, *summary_lines, _CONVERSATION_HISTORY_END]
98107
content = "\n".join(content_lines)
99-
developer_message: dict[str, Any] = {
100-
"role": "developer",
108+
assistant_message: dict[str, Any] = {
109+
"role": "assistant",
101110
"content": content,
102111
}
103-
return cast(TResponseInputItem, developer_message)
112+
return cast(TResponseInputItem, assistant_message)
104113

105114

106115
def _format_transcript_item(item: TResponseInputItem) -> str:
@@ -133,15 +142,6 @@ def _stringify_content(content: Any) -> str:
133142
return str(content)
134143

135144

136-
def _find_latest_user_turn(
137-
transcript: list[TResponseInputItem],
138-
) -> TResponseInputItem | None:
139-
for item in reversed(transcript):
140-
if item.get("role") == "user":
141-
return deepcopy(item)
142-
return None
143-
144-
145145
def _flatten_nested_history_messages(
146146
items: list[TResponseInputItem],
147147
) -> list[TResponseInputItem]:
@@ -158,8 +158,6 @@ def _flatten_nested_history_messages(
158158
def _extract_nested_history_transcript(
159159
item: TResponseInputItem,
160160
) -> list[TResponseInputItem] | None:
161-
if item.get("role") != "developer":
162-
return None
163161
content = item.get("content")
164162
if not isinstance(content, str):
165163
return None

src/agents/handoffs.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ def clone(self, **kwargs: Any) -> HandoffInputData:
6969
HandoffInputFilter: TypeAlias = Callable[[HandoffInputData], MaybeAwaitable[HandoffInputData]]
7070
"""A function that filters the input data passed to the next agent."""
7171

72+
HandoffHistoryMapper: TypeAlias = Callable[[list[TResponseInputItem]], list[TResponseInputItem]]
73+
"""A function that rewrites the conversation history before the next agent sees it."""
74+
7275

7376
@dataclass
7477
class Handoff(Generic[TContext, TAgent]):

src/agents/run.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
OutputGuardrail,
4545
OutputGuardrailResult,
4646
)
47-
from .handoffs import Handoff, HandoffInputFilter, handoff
47+
from .handoffs import Handoff, HandoffHistoryMapper, HandoffInputFilter, handoff
4848
from .items import (
4949
HandoffCallItem,
5050
ItemHelpers,
@@ -197,8 +197,16 @@ class RunConfig:
197197
"""
198198

199199
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.
200+
"""Wrap prior run history in a single assistant message before handing off when no custom
201+
input filter is set. Set to False to preserve the raw transcript behavior from previous
202+
releases.
203+
"""
204+
205+
handoff_history_mapper: HandoffHistoryMapper | None = None
206+
"""Optional function that receives the normalized transcript (history + handoff items) and
207+
returns the input history that should be passed to the next agent. When left as `None`, the
208+
runner collapses the transcript into a single assistant message. This function only runs when
209+
`nest_handoff_history` is True.
202210
"""
203211

204212
input_guardrails: list[InputGuardrail[Any]] | None = None

tests/test_agent_runner.py

Lines changed: 19 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -172,9 +172,9 @@ async def test_handoffs():
172172

173173
assert result.final_output == "done"
174174
assert len(result.raw_responses) == 3, "should have three model responses"
175-
assert len(result.to_input_list()) == 8, (
176-
"should have 8 inputs: dev summary, latest user input, tool call, tool result, message, "
177-
"handoff, handoff result, and done message"
175+
assert len(result.to_input_list()) == 7, (
176+
"should have 7 inputs: summary message, tool call, tool result, message, handoff, "
177+
"handoff result, and done message"
178178
)
179179
assert result.last_agent == agent_1, "should have handed off to agent_1"
180180

@@ -301,15 +301,14 @@ async def test_default_handoff_history_nested_and_filters_respected():
301301
result = await Runner.run(agent_2, input="user_message")
302302

303303
assert isinstance(result.input, list)
304-
developer = _as_message(result.input[0])
305-
assert developer["role"] == "developer"
306-
developer_content = developer["content"]
307-
assert isinstance(developer_content, str)
308-
assert "<CONVERSATION HISTORY>" in developer_content
309-
assert "triage summary" in developer_content
310-
latest_user = _as_message(result.input[1])
311-
assert latest_user["role"] == "user"
312-
assert latest_user["content"] == "user_message"
304+
assert len(result.input) == 1
305+
summary = _as_message(result.input[0])
306+
assert summary["role"] == "assistant"
307+
summary_content = summary["content"]
308+
assert isinstance(summary_content, str)
309+
assert "<CONVERSATION HISTORY>" in summary_content
310+
assert "triage summary" in summary_content
311+
assert "user_message" in summary_content
313312

314313
passthrough_model = FakeModel()
315314
delegate = Agent(name="delegate", model=passthrough_model)
@@ -360,16 +359,14 @@ async def test_default_handoff_history_accumulates_across_multiple_handoffs():
360359
assert closer_model.first_turn_args is not None
361360
closer_input = closer_model.first_turn_args["input"]
362361
assert isinstance(closer_input, list)
363-
developer = _as_message(closer_input[0])
364-
assert developer["role"] == "developer"
365-
developer_content = developer["content"]
366-
assert isinstance(developer_content, str)
367-
assert developer_content.count("<CONVERSATION HISTORY>") == 1
368-
assert "triage summary" in developer_content
369-
assert "delegate update" in developer_content
370-
latest_user = _as_message(closer_input[1])
371-
assert latest_user["role"] == "user"
372-
assert latest_user["content"] == "user_question"
362+
summary = _as_message(closer_input[0])
363+
assert summary["role"] == "assistant"
364+
summary_content = summary["content"]
365+
assert isinstance(summary_content, str)
366+
assert summary_content.count("<CONVERSATION HISTORY>") == 1
367+
assert "triage summary" in summary_content
368+
assert "delegate update" in summary_content
369+
assert "user_question" in summary_content
373370

374371

375372
@pytest.mark.asyncio

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()) == 8, (
179-
"should have 8 inputs: dev summary, latest user input, tool call, tool result, message, "
180-
"handoff, handoff result, and done message"
178+
assert len(result.to_input_list()) == 7, (
179+
"should have 7 inputs: summary message, tool call, tool result, message, handoff, "
180+
"handoff result, and done message"
181181
)
182182
assert result.last_agent == agent_1, "should have handed off to agent_1"
183183

0 commit comments

Comments
 (0)