Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/agents/handoffs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,16 @@ class Handoff(Generic[TContext, TAgent]):
context or state.
"""

auto_handoff_back: bool = False
"""Whether the target agent should automatically handoff back to the originating agent when it
finishes its task.

When True, after the child agent completes its work (produces a final output), control
automatically returns to the agent that initiated the handoff. This enables orchestration
patterns where a main orchestrator agent delegates to specialist agents and expects results
back without needing explicit circular handoff configurations.
"""

_agent_ref: weakref.ReferenceType[AgentBase[Any]] | None = field(
default=None, init=False, repr=False
)
Expand Down Expand Up @@ -188,6 +198,7 @@ def handoff(
tool_description_override: str | None = None,
input_filter: Callable[[HandoffInputData], HandoffInputData] | None = None,
nest_handoff_history: bool | None = None,
auto_handoff_back: bool = False,
is_enabled: bool | Callable[[RunContextWrapper[Any], Agent[Any]], MaybeAwaitable[bool]] = True,
) -> Handoff[TContext, Agent[TContext]]: ...

Expand All @@ -202,6 +213,7 @@ def handoff(
tool_name_override: str | None = None,
input_filter: Callable[[HandoffInputData], HandoffInputData] | None = None,
nest_handoff_history: bool | None = None,
auto_handoff_back: bool = False,
is_enabled: bool | Callable[[RunContextWrapper[Any], Agent[Any]], MaybeAwaitable[bool]] = True,
) -> Handoff[TContext, Agent[TContext]]: ...

Expand All @@ -215,6 +227,7 @@ def handoff(
tool_name_override: str | None = None,
input_filter: Callable[[HandoffInputData], HandoffInputData] | None = None,
nest_handoff_history: bool | None = None,
auto_handoff_back: bool = False,
is_enabled: bool | Callable[[RunContextWrapper[Any], Agent[Any]], MaybeAwaitable[bool]] = True,
) -> Handoff[TContext, Agent[TContext]]: ...

Expand All @@ -227,6 +240,7 @@ def handoff(
input_type: type[THandoffInput] | None = None,
input_filter: Callable[[HandoffInputData], HandoffInputData] | None = None,
nest_handoff_history: bool | None = None,
auto_handoff_back: bool = False,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve positional is_enabled binding

This inserts auto_handoff_back before the existing is_enabled positional slot in the runtime implementation, so existing callers that passed is_enabled positionally now bind that value to auto_handoff_back and leave is_enabled at its default True. For example, a previously disabled handoff using positional arguments is silently re-enabled, which breaks the public API positional-compatibility contract; the new optional parameter needs to be appended after is_enabled or handled with a compatibility shim.

Useful? React with 👍 / 👎.

is_enabled: bool
| Callable[[RunContextWrapper[Any], Agent[TContext]], MaybeAwaitable[bool]] = True,
) -> Handoff[TContext, Agent[TContext]]:
Expand All @@ -247,6 +261,9 @@ def handoff(
input_filter: A function that filters the inputs that are passed to the next agent.
nest_handoff_history: Optional override for the RunConfig-level ``nest_handoff_history``
flag. If ``None`` we fall back to the run's configuration.
auto_handoff_back: Whether the target agent should automatically handoff back to the
originating agent when it finishes its task. Useful for orchestration patterns where a
main agent delegates to specialist agents and expects results back.
is_enabled: Whether the handoff is enabled. Can be a bool or a callable that takes the run
context and agent and returns whether the handoff is enabled. Disabled handoffs are
hidden from the LLM at runtime.
Expand Down Expand Up @@ -327,6 +344,7 @@ async def _is_enabled(ctx: RunContextWrapper[Any], agent_base: AgentBase[Any]) -
input_filter=input_filter,
nest_handoff_history=nest_handoff_history,
agent_name=agent.name,
auto_handoff_back=auto_handoff_back,
is_enabled=_is_enabled if callable(is_enabled) else is_enabled,
)
handoff_obj._agent_ref = weakref.ref(agent)
Expand Down
39 changes: 37 additions & 2 deletions src/agents/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -765,6 +765,7 @@ def _finalize_result(result: RunResult) -> RunResult:
raise

try:
handoff_chain: list[Agent[Any]] = []
while True:
resuming_turn = is_resumed_state
all_input_guardrails = (
Expand Down Expand Up @@ -959,6 +960,20 @@ def _finalize_result(result: RunResult) -> RunResult:
)

if isinstance(turn_result.next_step, NextStepFinalOutput):
if handoff_chain:
parent_agent = handoff_chain.pop()
child_output_text = format_final_output_text(
current_agent, turn_result.next_step.output
)
back_msg = create_message_output_item(
current_agent, child_output_text
)
generated_items.append(back_msg)
session_items.append(back_msg)
current_agent = cast(Agent[TContext], parent_agent)
if run_state is not None:
run_state._current_agent = current_agent
continue
output_guardrail_results = await run_output_guardrails(
current_agent.output_guardrails
+ (run_config.output_guardrails or []),
Expand Down Expand Up @@ -1009,8 +1024,11 @@ def _finalize_result(result: RunResult) -> RunResult:
result._original_input = copy_input_items(original_input)
return _finalize_result(result)
elif isinstance(turn_result.next_step, NextStepHandoff):
next_step = turn_result.next_step
if next_step.auto_handoff_back and next_step.originating_agent is not None:
handoff_chain.append(next_step.originating_agent)
Comment on lines +1028 to +1029
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Persist the auto-handoff stack across resume

This stores the originating agent only in a local handoff_chain, but interruption results persist and resume from RunState, which does not include that local stack. If the child agent pauses for approval or another interruption after an auto handoff, resuming the run loses the parent agent, and the child's eventual final output is returned as the run result instead of handing control back.

Useful? React with 👍 / 👎.

current_agent = cast(
Agent[TContext], turn_result.next_step.new_agent
Agent[TContext], next_step.new_agent
)
if run_state is not None:
run_state._current_agent = current_agent
Expand Down Expand Up @@ -1365,6 +1383,20 @@ def _finalize_result(result: RunResult) -> RunResult:

try:
if isinstance(turn_result.next_step, NextStepFinalOutput):
if handoff_chain:
parent_agent = handoff_chain.pop()
child_output_text = format_final_output_text(
current_agent, turn_result.next_step.output
)
back_msg = create_message_output_item(
current_agent, child_output_text
)
generated_items.append(back_msg)
session_items.append(back_msg)
current_agent = cast(Agent[TContext], parent_agent)
if run_state is not None:
run_state._current_agent = current_agent
continue
output_guardrail_results = await run_output_guardrails(
current_agent.output_guardrails
+ (run_config.output_guardrails or []),
Expand Down Expand Up @@ -1470,7 +1502,10 @@ def _finalize_result(result: RunResult) -> RunResult:
)
return _finalize_result(result)
elif isinstance(turn_result.next_step, NextStepHandoff):
current_agent = cast(Agent[TContext], turn_result.next_step.new_agent)
next_step = turn_result.next_step
if next_step.auto_handoff_back and next_step.originating_agent is not None:
handoff_chain.append(next_step.originating_agent)
current_agent = cast(Agent[TContext], next_step.new_agent)
if run_state is not None:
run_state._current_agent = current_agent
# Next agent starts with the nested/filtered input.
Expand Down
56 changes: 54 additions & 2 deletions src/agents/run_internal/run_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,7 @@ async def _save_stream_items_without_count(
raise

try:
handoff_chain: list[Agent[Any]] = []
while True:
all_input_guardrails = (
starting_agent.input_guardrails + (run_config.input_guardrails or [])
Expand Down Expand Up @@ -801,7 +802,10 @@ async def _save_stream_items_without_count(
break

if isinstance(turn_result.next_step, NextStepHandoff):
current_agent = turn_result.next_step.new_agent
next_step = turn_result.next_step
if next_step.auto_handoff_back and next_step.originating_agent is not None:
handoff_chain.append(next_step.originating_agent)
current_agent = next_step.new_agent
if run_state is not None:
run_state._current_agent = current_agent
if current_span:
Expand All @@ -815,6 +819,29 @@ async def _save_stream_items_without_count(
continue

if isinstance(turn_result.next_step, NextStepFinalOutput):
if handoff_chain:
parent_agent = handoff_chain.pop()
child_output_text = format_final_output_text(
current_agent, turn_result.next_step.output
)
back_msg = create_message_output_item(
current_agent, child_output_text
)
turn_session_items.append(back_msg)
streamed_result.new_items.append(back_msg)
current_agent = parent_agent
if run_state is not None:
run_state._current_agent = current_agent
streamed_result._event_queue.put_nowait(
AgentUpdatedStreamEvent(new_agent=current_agent)
)
await _save_resumed_items(
list(turn_session_items),
turn_result.model_response.response_id,
store_setting,
)
run_state._current_step = NextStepRunAgain() # type: ignore[assignment]
continue
await _finalize_streamed_final_output(
streamed_result=streamed_result,
agent=current_agent,
Expand Down Expand Up @@ -1089,12 +1116,15 @@ async def _save_stream_items_without_count(
server_conversation_tracker.track_server_items(turn_result.model_response)

if isinstance(turn_result.next_step, NextStepHandoff):
next_step = turn_result.next_step
if next_step.auto_handoff_back and next_step.originating_agent is not None:
handoff_chain.append(next_step.originating_agent)
await _save_stream_items_without_count(
turn_session_items,
turn_result.model_response.response_id,
store_setting,
)
current_agent = turn_result.next_step.new_agent
current_agent = next_step.new_agent
if run_state is not None:
run_state._current_agent = current_agent
current_span.finish(reset_current=True)
Expand All @@ -1111,6 +1141,28 @@ async def _save_stream_items_without_count(
streamed_result._event_queue.put_nowait(QueueCompleteSentinel())
break
elif isinstance(turn_result.next_step, NextStepFinalOutput):
if handoff_chain:
parent_agent = handoff_chain.pop()
child_output_text = format_final_output_text(
current_agent, turn_result.next_step.output
)
back_msg = create_message_output_item(
current_agent, child_output_text
)
turn_session_items.append(back_msg)
streamed_result.new_items.append(back_msg)
Comment on lines +1152 to +1153
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Feed the back-handoff result to streamed parent turns

In the streaming path, this synthetic child-result message is appended only to new_items/session items, but the next streamed turn is built from streamed_result._model_input_items (set earlier to pre_step_items + new_step_items). When Runner.run_streamed() auto-hands back, the parent agent is invoked without the specialist's final output in its model input, so the advertised orchestration pattern fails for streaming runs.

Useful? React with 👍 / 👎.

current_agent = parent_agent
if run_state is not None:
run_state._current_agent = current_agent
streamed_result._event_queue.put_nowait(
AgentUpdatedStreamEvent(new_agent=current_agent)
)
await _save_stream_items_with_count(
turn_session_items,
turn_result.model_response.response_id,
store_setting,
)
continue
await _finalize_streamed_final_output(
streamed_result=streamed_result,
agent=current_agent,
Expand Down
7 changes: 7 additions & 0 deletions src/agents/run_internal/run_steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,13 @@ def has_interruptions(self) -> bool:
@dataclass
class NextStepHandoff:
new_agent: Agent[Any]
auto_handoff_back: bool = False
"""Whether the new agent should automatically handoff back to the originating agent when it
finishes its task."""

originating_agent: Agent[Any] | None = None
"""The agent that initiated the handoff. When auto_handoff_back is True, control will return
to this agent after the new agent completes its task."""


@dataclass
Expand Down
6 changes: 5 additions & 1 deletion src/agents/run_internal/turn_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -584,7 +584,11 @@ def nest_history(data: HandoffInputData, mapper: Any | None = None) -> HandoffIn
model_response=new_response,
pre_step_items=pre_step_items,
new_step_items=new_step_items,
next_step=NextStepHandoff(new_agent),
next_step=NextStepHandoff(
new_agent,
auto_handoff_back=handoff.auto_handoff_back,
originating_agent=public_agent if handoff.auto_handoff_back else None,
),
tool_input_guardrail_results=list(tool_input_guardrail_results or []),
tool_output_guardrail_results=list(tool_output_guardrail_results or []),
session_step_items=session_step_items,
Expand Down
Loading