Skip to content

Commit 6fea778

Browse files
committed
Add support for returning control in Handoffs
1 parent d88bf14 commit 6fea778

File tree

4 files changed

+137
-15
lines changed

4 files changed

+137
-15
lines changed

src/agents/_run_impl.py

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,11 @@ class NextStepHandoff:
176176
new_agent: Agent[Any]
177177

178178

179+
@dataclass
180+
class NextStepHandoffReturnControl:
181+
previous_agent: Agent[Any]
182+
183+
179184
@dataclass
180185
class NextStepFinalOutput:
181186
output: Any
@@ -201,7 +206,9 @@ class SingleStepResult:
201206
new_step_items: list[RunItem]
202207
"""Items generated during this current step."""
203208

204-
next_step: NextStepHandoff | NextStepFinalOutput | NextStepRunAgain
209+
next_step: (
210+
NextStepHandoff | NextStepFinalOutput | NextStepRunAgain | NextStepHandoffReturnControl
211+
)
205212
"""The next step to take."""
206213

207214
@property
@@ -238,6 +245,7 @@ async def execute_tools_and_side_effects(
238245
hooks: RunHooks[TContext],
239246
context_wrapper: RunContextWrapper[TContext],
240247
run_config: RunConfig,
248+
previous_agents: list[Agent],
241249
) -> SingleStepResult:
242250
# Make a copy of the generated items
243251
pre_step_items = list(pre_step_items)
@@ -286,6 +294,7 @@ async def execute_tools_and_side_effects(
286294
hooks=hooks,
287295
context_wrapper=context_wrapper,
288296
run_config=run_config,
297+
previous_agents=previous_agents,
289298
)
290299

291300
# Next, we'll check if the tool use should result in a final output
@@ -316,6 +325,7 @@ async def execute_tools_and_side_effects(
316325
final_output=check_tool_use.final_output,
317326
hooks=hooks,
318327
context_wrapper=context_wrapper,
328+
previous_agents=previous_agents,
319329
)
320330

321331
# Now we can check if the model also produced a final output
@@ -340,6 +350,7 @@ async def execute_tools_and_side_effects(
340350
final_output=final_output,
341351
hooks=hooks,
342352
context_wrapper=context_wrapper,
353+
previous_agents=previous_agents,
343354
)
344355
elif (
345356
not output_schema or output_schema.is_plain_text()
@@ -353,6 +364,7 @@ async def execute_tools_and_side_effects(
353364
final_output=potential_final_output_text or "",
354365
hooks=hooks,
355366
context_wrapper=context_wrapper,
367+
previous_agents=previous_agents,
356368
)
357369
else:
358370
# If there's no final output, we can just run again
@@ -663,6 +675,7 @@ async def execute_handoffs(
663675
hooks: RunHooks[TContext],
664676
context_wrapper: RunContextWrapper[TContext],
665677
run_config: RunConfig,
678+
previous_agents: list[Agent[TContext]],
666679
) -> SingleStepResult:
667680
# If there is more than one handoff, add tool responses that reject those handoffs
668681
multiple_handoffs = len(run_handoffs) > 1
@@ -684,6 +697,8 @@ async def execute_handoffs(
684697
actual_handoff = run_handoffs[0]
685698
with handoff_span(from_agent=agent.name) as span_handoff:
686699
handoff = actual_handoff.handoff
700+
if handoff.should_return_control:
701+
previous_agents.append(agent)
687702
new_agent: Agent[Any] = await handoff.on_invoke_handoff(
688703
context_wrapper, actual_handoff.tool_call.arguments
689704
)
@@ -825,16 +840,21 @@ async def execute_final_output(
825840
final_output: Any,
826841
hooks: RunHooks[TContext],
827842
context_wrapper: RunContextWrapper[TContext],
843+
previous_agents: list[Agent[TContext]],
828844
) -> SingleStepResult:
845+
is_returning_control = len(previous_agents) > 0
829846
# Run the on_end hooks
830-
await cls.run_final_output_hooks(agent, hooks, context_wrapper, final_output)
831-
847+
await cls.run_final_output_hooks(
848+
agent, hooks, context_wrapper, final_output, is_returning_control
849+
)
832850
return SingleStepResult(
833851
original_input=original_input,
834852
model_response=new_response,
835853
pre_step_items=pre_step_items,
836854
new_step_items=new_step_items,
837-
next_step=NextStepFinalOutput(final_output),
855+
next_step=NextStepHandoffReturnControl(previous_agents.pop())
856+
if is_returning_control
857+
else NextStepFinalOutput(final_output),
838858
)
839859

840860
@classmethod
@@ -844,13 +864,19 @@ async def run_final_output_hooks(
844864
hooks: RunHooks[TContext],
845865
context_wrapper: RunContextWrapper[TContext],
846866
final_output: Any,
867+
is_returning_control: bool,
847868
):
848-
await asyncio.gather(
849-
hooks.on_agent_end(context_wrapper, agent, final_output),
850-
agent.hooks.on_end(context_wrapper, agent, final_output)
851-
if agent.hooks
852-
else _coro.noop_coroutine(),
853-
)
869+
# If the agent is not returning control, run the hooks
870+
if not is_returning_control:
871+
await asyncio.gather(
872+
hooks.on_agent_end(context_wrapper, agent, final_output),
873+
agent.hooks.on_end(context_wrapper, agent, final_output)
874+
if agent.hooks
875+
else _coro.noop_coroutine(),
876+
)
877+
# If the agent is returning control, only run the current agent's hooks
878+
elif agent.hooks:
879+
await agent.hooks.on_end(context_wrapper, agent, final_output)
854880

855881
@classmethod
856882
async def run_single_input_guardrail(

src/agents/handoffs.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,12 @@ class Handoff(Generic[TContext]):
105105
agent and returns whether the handoff is enabled. You can use this to dynamically enable/disable
106106
a handoff based on your context/state."""
107107

108+
should_return_control: bool = False
109+
"""Whether the Agent that receives control during a handoff should return control to the
110+
original (previous) Agent upon completion of its work. If False, after the Agent that received
111+
the handoff completes its work, the interaction will end.
112+
"""
113+
108114
def get_transfer_message(self, agent: Agent[Any]) -> str:
109115
return json.dumps({"assistant": agent.name})
110116

@@ -128,6 +134,7 @@ def handoff(
128134
tool_description_override: str | None = None,
129135
input_filter: Callable[[HandoffInputData], HandoffInputData] | None = None,
130136
is_enabled: bool | Callable[[RunContextWrapper[Any], Agent[Any]], MaybeAwaitable[bool]] = True,
137+
should_return_control: bool = False,
131138
) -> Handoff[TContext]: ...
132139

133140

@@ -141,6 +148,7 @@ def handoff(
141148
tool_name_override: str | None = None,
142149
input_filter: Callable[[HandoffInputData], HandoffInputData] | None = None,
143150
is_enabled: bool | Callable[[RunContextWrapper[Any], Agent[Any]], MaybeAwaitable[bool]] = True,
151+
should_return_control: bool = False,
144152
) -> Handoff[TContext]: ...
145153

146154

@@ -153,6 +161,7 @@ def handoff(
153161
tool_name_override: str | None = None,
154162
input_filter: Callable[[HandoffInputData], HandoffInputData] | None = None,
155163
is_enabled: bool | Callable[[RunContextWrapper[Any], Agent[Any]], MaybeAwaitable[bool]] = True,
164+
should_return_control: bool = False,
156165
) -> Handoff[TContext]: ...
157166

158167

@@ -164,6 +173,7 @@ def handoff(
164173
input_type: type[THandoffInput] | None = None,
165174
input_filter: Callable[[HandoffInputData], HandoffInputData] | None = None,
166175
is_enabled: bool | Callable[[RunContextWrapper[Any], Agent[Any]], MaybeAwaitable[bool]] = True,
176+
should_return_control: bool = False,
167177
) -> Handoff[TContext]:
168178
"""Create a handoff from an agent.
169179
@@ -181,7 +191,7 @@ def handoff(
181191
hidden from the LLM at runtime.
182192
"""
183193
assert (on_handoff and input_type) or not (on_handoff and input_type), (
184-
"You must provide either both on_handoff and input_type, or neither"
194+
"You must provide either both on_input and input_type, or neither"
185195
)
186196
type_adapter: TypeAdapter[Any] | None
187197
if input_type is not None:
@@ -247,4 +257,5 @@ async def _invoke_handoff(
247257
input_filter=input_filter,
248258
agent_name=agent.name,
249259
is_enabled=is_enabled,
260+
should_return_control=should_return_control,
250261
)

0 commit comments

Comments
 (0)