mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Support an autonomous handoff flow (#2497)
* Support an autonomous handoff flow. * Simplify public API * Address feedback
This commit is contained in:
committed by
GitHub
Unverified
parent
f2ed5b55f6
commit
b78a2b6d2e
@@ -4,10 +4,13 @@
|
||||
|
||||
The handoff pattern models a coordinator agent that optionally routes
|
||||
control to specialist agents before handing the conversation back to the user.
|
||||
The flow is intentionally cyclical:
|
||||
The flow is intentionally cyclical by default:
|
||||
|
||||
user input -> coordinator -> optional specialist -> request user input -> ...
|
||||
|
||||
An autonomous interaction mode can bypass the user input request and continue routing
|
||||
responses back to agents until a handoff occurs or termination criteria are met.
|
||||
|
||||
Key properties:
|
||||
- The entire conversation is maintained and reused on every hop
|
||||
- The coordinator signals a handoff by invoking a tool call that names the specialist
|
||||
@@ -19,7 +22,7 @@ import re
|
||||
import sys
|
||||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from agent_framework import (
|
||||
AgentProtocol,
|
||||
@@ -59,8 +62,8 @@ else:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_HANDOFF_TOOL_PATTERN = re.compile(r"(?:handoff|transfer)[_\s-]*to[_\s-]*(?P<target>[\w-]+)", re.IGNORECASE)
|
||||
_DEFAULT_AUTONOMOUS_TURN_LIMIT = 50
|
||||
|
||||
|
||||
def _create_handoff_tool(alias: str, description: str | None = None) -> AIFunction[Any, Any]:
|
||||
@@ -291,6 +294,8 @@ class _HandoffCoordinator(BaseGroupChatOrchestrator):
|
||||
id: str,
|
||||
handoff_tool_targets: Mapping[str, str] | None = None,
|
||||
return_to_previous: bool = False,
|
||||
interaction_mode: Literal["human_in_loop", "autonomous"] = "human_in_loop",
|
||||
autonomous_turn_limit: int | None = None,
|
||||
) -> None:
|
||||
"""Create a coordinator that manages routing between specialists and the user."""
|
||||
super().__init__(id)
|
||||
@@ -302,6 +307,9 @@ class _HandoffCoordinator(BaseGroupChatOrchestrator):
|
||||
self._handoff_tool_targets = {k.lower(): v for k, v in (handoff_tool_targets or {}).items()}
|
||||
self._return_to_previous = return_to_previous
|
||||
self._current_agent_id: str | None = None # Track the current agent handling conversation
|
||||
self._interaction_mode = interaction_mode
|
||||
self._autonomous_turn_limit = autonomous_turn_limit
|
||||
self._autonomous_turns = 0
|
||||
|
||||
def _get_author_name(self) -> str:
|
||||
"""Get the coordinator name for orchestrator-generated messages."""
|
||||
@@ -340,6 +348,7 @@ class _HandoffCoordinator(BaseGroupChatOrchestrator):
|
||||
if target is not None:
|
||||
# Update current agent when handoff occurs
|
||||
self._current_agent_id = target
|
||||
self._autonomous_turns = 0
|
||||
logger.info(f"Handoff detected: {source} -> {target}. Routing control to specialist '{target}'.")
|
||||
|
||||
# Clean tool-related content before sending to next agent
|
||||
@@ -354,17 +363,38 @@ class _HandoffCoordinator(BaseGroupChatOrchestrator):
|
||||
|
||||
# Update current agent when they respond without handoff
|
||||
self._current_agent_id = source
|
||||
logger.info(
|
||||
f"Agent '{source}' responded without handoff. "
|
||||
f"Requesting user input. Return-to-previous: {self._return_to_previous}"
|
||||
)
|
||||
|
||||
if await self._check_termination():
|
||||
# Clean the output conversation for display
|
||||
cleaned_output = clean_conversation_for_handoff(conversation)
|
||||
await ctx.yield_output(cleaned_output)
|
||||
return
|
||||
|
||||
if self._interaction_mode == "autonomous":
|
||||
self._autonomous_turns += 1
|
||||
if self._autonomous_turn_limit is not None and self._autonomous_turns >= self._autonomous_turn_limit:
|
||||
logger.info(
|
||||
f"Autonomous turn limit reached ({self._autonomous_turn_limit}). "
|
||||
"Yielding conversation and stopping."
|
||||
)
|
||||
cleaned_output = clean_conversation_for_handoff(conversation)
|
||||
await ctx.yield_output(cleaned_output)
|
||||
return
|
||||
|
||||
# In autonomous mode, agents continue iterating until they invoke a handoff tool
|
||||
logger.info(
|
||||
f"Agent '{source}' responded without handoff (turn {self._autonomous_turns}). "
|
||||
"Continuing autonomous execution."
|
||||
)
|
||||
cleaned = clean_conversation_for_handoff(conversation)
|
||||
request = AgentExecutorRequest(messages=cleaned, should_respond=True)
|
||||
await ctx.send_message(request, target_id=source)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"Agent '{source}' responded without handoff. "
|
||||
f"Requesting user input. Return-to-previous: {self._return_to_previous}"
|
||||
)
|
||||
|
||||
# Clean conversation before sending to gateway for user input request
|
||||
# This removes tool messages that shouldn't be shown to users
|
||||
cleaned_for_display = clean_conversation_for_handoff(conversation)
|
||||
@@ -386,6 +416,9 @@ class _HandoffCoordinator(BaseGroupChatOrchestrator):
|
||||
# Update authoritative conversation
|
||||
self._conversation = list(message.full_conversation)
|
||||
|
||||
# Reset autonomous turn counter on new user input
|
||||
self._autonomous_turns = 0
|
||||
|
||||
# Check termination before sending to agent
|
||||
if await self._check_termination():
|
||||
await ctx.yield_output(list(self._conversation))
|
||||
@@ -478,11 +511,12 @@ class _HandoffCoordinator(BaseGroupChatOrchestrator):
|
||||
Returns:
|
||||
Dict containing current agent if return-to-previous is enabled
|
||||
"""
|
||||
metadata: dict[str, Any] = {}
|
||||
if self._return_to_previous:
|
||||
return {
|
||||
"current_agent_id": self._current_agent_id,
|
||||
}
|
||||
return {}
|
||||
metadata["current_agent_id"] = self._current_agent_id
|
||||
if self._interaction_mode == "autonomous":
|
||||
metadata["autonomous_turns"] = self._autonomous_turns
|
||||
return metadata
|
||||
|
||||
@override
|
||||
def _restore_pattern_metadata(self, metadata: dict[str, Any]) -> None:
|
||||
@@ -495,6 +529,8 @@ class _HandoffCoordinator(BaseGroupChatOrchestrator):
|
||||
"""
|
||||
if self._return_to_previous and "current_agent_id" in metadata:
|
||||
self._current_agent_id = metadata["current_agent_id"]
|
||||
if self._interaction_mode == "autonomous" and "autonomous_turns" in metadata:
|
||||
self._autonomous_turns = metadata["autonomous_turns"]
|
||||
|
||||
def _apply_response_metadata(self, conversation: list[ChatMessage], agent_response: AgentRunResponse) -> None:
|
||||
"""Merge top-level response metadata into the latest assistant message."""
|
||||
@@ -604,13 +640,17 @@ class HandoffBuilder:
|
||||
r"""Fluent builder for conversational handoff workflows with coordinator and specialist agents.
|
||||
|
||||
The handoff pattern enables a coordinator agent to route requests to specialist agents.
|
||||
A termination condition determines when the workflow should stop requesting input and complete.
|
||||
Interaction mode controls whether the workflow requests user input after each agent response or
|
||||
completes autonomously once agents finish responding. A termination condition determines when
|
||||
the workflow should stop requesting input and complete.
|
||||
|
||||
Routing Patterns:
|
||||
|
||||
**Single-Tier (Default):** Only the coordinator can hand off to specialists. After any specialist
|
||||
**Single-Tier (Default):** Only the coordinator can hand off to specialists. By default, after any specialist
|
||||
responds, control returns to the user for more input. This creates a cyclical flow:
|
||||
user -> coordinator -> [optional specialist] -> user -> coordinator -> ...
|
||||
Use `with_interaction_mode("autonomous")` to skip requesting additional user input and yield the
|
||||
final conversation when an agent responds without delegating.
|
||||
|
||||
**Multi-Tier (Advanced):** Specialists can hand off to other specialists using `.add_handoff()`.
|
||||
This provides more flexibility for complex workflows but is less controllable than the single-tier
|
||||
@@ -621,13 +661,16 @@ class HandoffBuilder:
|
||||
|
||||
Key Features:
|
||||
- **Automatic handoff detection**: The coordinator invokes a handoff tool whose
|
||||
arguments (for example ``{"handoff_to": "shipping_agent"}``) identify the specialist to receive control.
|
||||
arguments (for example `{"handoff_to": "shipping_agent"}`) identify the specialist to receive control.
|
||||
- **Auto-generated tools**: By default the builder synthesizes `handoff_to_<agent>` tools for the coordinator,
|
||||
so you don't manually define placeholder functions.
|
||||
- **Full conversation history**: The entire conversation (including any
|
||||
`ChatMessage.additional_properties`) is preserved and passed to each agent.
|
||||
- **Termination control**: By default, terminates after 10 user messages. Override with
|
||||
`.with_termination_condition(lambda conv: ...)` for custom logic (e.g., detect "goodbye").
|
||||
- **Interaction modes**: Choose `human_in_loop` (default) to prompt users between agent turns,
|
||||
or `autonomous` to continue routing back to agents without prompting for user input until a
|
||||
handoff occurs or a termination/turn limit is reached (default autonomous turn limit: 50).
|
||||
- **Checkpointing**: Optional persistence for resumable workflows.
|
||||
|
||||
Usage (Single-Tier):
|
||||
@@ -765,7 +808,7 @@ class HandoffBuilder:
|
||||
Participants must have stable names/ids because the workflow maps the
|
||||
handoff tool arguments to these identifiers. Agent names should match
|
||||
the strings emitted by the coordinator's handoff tool (e.g., a tool that
|
||||
outputs ``{\"handoff_to\": \"billing\"}`` requires an agent named ``billing``).
|
||||
outputs `{\"handoff_to\": \"billing\"}` requires an agent named `billing`).
|
||||
"""
|
||||
self._name = name
|
||||
self._description = description
|
||||
@@ -781,6 +824,8 @@ class HandoffBuilder:
|
||||
self._auto_register_handoff_tools: bool = True
|
||||
self._handoff_config: dict[str, list[str]] = {} # Maps agent_id -> [target_agent_ids]
|
||||
self._return_to_previous: bool = False
|
||||
self._interaction_mode: Literal["human_in_loop", "autonomous"] = "human_in_loop"
|
||||
self._autonomous_turn_limit: int | None = _DEFAULT_AUTONOMOUS_TURN_LIMIT
|
||||
|
||||
if participants:
|
||||
self.participants(participants)
|
||||
@@ -871,8 +916,10 @@ class HandoffBuilder:
|
||||
1. Handle the request directly and respond to the user, OR
|
||||
2. Hand off to a specialist agent by including handoff metadata in the response
|
||||
|
||||
After a specialist responds, the workflow automatically returns control to the user,
|
||||
creating a cyclical flow: user -> coordinator -> [specialist] -> user -> ...
|
||||
After a specialist responds, the workflow automatically returns control to the user
|
||||
(default) creating a cyclical flow: user -> coordinator -> [specialist] -> user -> ...
|
||||
Configure `with_interaction_mode("autonomous")` to continue with the responding agent
|
||||
without requesting another user turn until a handoff occurs or a termination/turn limit is met.
|
||||
|
||||
Args:
|
||||
agent: The agent to use as the coordinator. Can be:
|
||||
@@ -899,8 +946,8 @@ class HandoffBuilder:
|
||||
|
||||
Note:
|
||||
The coordinator determines routing by invoking a handoff tool call whose
|
||||
arguments identify the target specialist (for example ``{\"handoff_to\": \"billing\"}``).
|
||||
Decorate the tool with ``approval_mode="always_require"`` to ensure the workflow
|
||||
arguments identify the target specialist (for example `{\"handoff_to\": \"billing\"}`).
|
||||
Decorate the tool with `approval_mode="always_require"` to ensure the workflow
|
||||
intercepts the call before execution and can make the transition.
|
||||
"""
|
||||
if not self._executors:
|
||||
@@ -1236,6 +1283,70 @@ class HandoffBuilder:
|
||||
self._termination_condition = condition
|
||||
return self
|
||||
|
||||
def with_interaction_mode(
|
||||
self,
|
||||
interaction_mode: Literal["human_in_loop", "autonomous"] = "human_in_loop",
|
||||
*,
|
||||
autonomous_turn_limit: int | None = None,
|
||||
) -> "HandoffBuilder":
|
||||
"""Choose whether the workflow requests user input or runs autonomously after agent replies.
|
||||
|
||||
In autonomous mode, agents (including specialists) continue iterating on their task
|
||||
until they explicitly invoke a handoff tool or the turn limit is reached. This allows
|
||||
specialists to perform long-running autonomous tasks (e.g., research, coding, analysis)
|
||||
without prematurely returning control to the coordinator or user.
|
||||
|
||||
Args:
|
||||
interaction_mode: `"human_in_loop"` (default) requests user input after each agent response
|
||||
that does not trigger a handoff. `"autonomous"` lets agents continue
|
||||
working until they invoke a handoff tool or the turn limit is reached.
|
||||
|
||||
Keyword Args:
|
||||
autonomous_turn_limit: Maximum number of agent responses before the workflow yields
|
||||
when in autonomous mode. Only applicable when interaction_mode
|
||||
is `"autonomous"`. Default is 50. Set to `None` to disable
|
||||
the limit (use with caution). Ignored with a warning if provided
|
||||
when interaction_mode is `"human_in_loop"`.
|
||||
|
||||
Returns:
|
||||
Self for chaining.
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
workflow = (
|
||||
HandoffBuilder(participants=[coordinator, research_agent])
|
||||
.set_coordinator(coordinator)
|
||||
.add_handoff(coordinator, research_agent)
|
||||
.add_handoff(research_agent, coordinator)
|
||||
.with_interaction_mode("autonomous", autonomous_turn_limit=20)
|
||||
.build()
|
||||
)
|
||||
|
||||
# Flow: User asks a question
|
||||
# -> Coordinator routes to Research Agent
|
||||
# -> Research Agent iterates (researches, analyzes, refines)
|
||||
# -> Research Agent calls handoff_to_coordinator when done
|
||||
# -> Coordinator provides final response
|
||||
"""
|
||||
if interaction_mode not in ("human_in_loop", "autonomous"):
|
||||
raise ValueError("interaction_mode must be either 'human_in_loop' or 'autonomous'")
|
||||
self._interaction_mode = interaction_mode
|
||||
|
||||
if autonomous_turn_limit is not None:
|
||||
if interaction_mode != "autonomous":
|
||||
logger.warning(
|
||||
f"autonomous_turn_limit={autonomous_turn_limit} was provided but interaction_mode is "
|
||||
f"'{interaction_mode}'; ignoring."
|
||||
)
|
||||
elif autonomous_turn_limit <= 0:
|
||||
raise ValueError("autonomous_turn_limit must be positive when provided")
|
||||
else:
|
||||
self._autonomous_turn_limit = autonomous_turn_limit
|
||||
|
||||
return self
|
||||
|
||||
def enable_return_to_previous(self, enabled: bool = True) -> "HandoffBuilder":
|
||||
"""Enable direct return to the current agent after user input, bypassing the coordinator.
|
||||
|
||||
@@ -1437,6 +1548,8 @@ class HandoffBuilder:
|
||||
id="handoff-coordinator",
|
||||
handoff_tool_targets=handoff_tool_targets,
|
||||
return_to_previous=self._return_to_previous,
|
||||
interaction_mode=self._interaction_mode,
|
||||
autonomous_turn_limit=self._autonomous_turn_limit,
|
||||
)
|
||||
|
||||
wiring = _GroupChatConfig(
|
||||
|
||||
@@ -289,6 +289,140 @@ async def test_tool_call_handoff_detection_with_text_hint():
|
||||
assert len(specialist.calls[0]) >= 2
|
||||
|
||||
|
||||
async def test_autonomous_interaction_mode_yields_output_without_user_request():
|
||||
"""Ensure autonomous interaction mode yields output without requesting user input."""
|
||||
triage = _RecordingAgent(name="triage", handoff_to="specialist")
|
||||
specialist = _RecordingAgent(name="specialist")
|
||||
|
||||
workflow = (
|
||||
HandoffBuilder(participants=[triage, specialist])
|
||||
.set_coordinator("triage")
|
||||
.with_interaction_mode("autonomous", autonomous_turn_limit=1)
|
||||
.build()
|
||||
)
|
||||
|
||||
events = await _drain(workflow.run_stream("Package arrived broken"))
|
||||
assert len(triage.calls) == 1
|
||||
assert len(specialist.calls) == 1
|
||||
requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)]
|
||||
assert not requests, "Autonomous mode should not request additional user input"
|
||||
|
||||
outputs = [ev for ev in events if isinstance(ev, WorkflowOutputEvent)]
|
||||
assert outputs, "Autonomous mode should yield a workflow output"
|
||||
|
||||
final_conversation = outputs[-1].data
|
||||
assert isinstance(final_conversation, list)
|
||||
conversation_list = cast(list[ChatMessage], final_conversation)
|
||||
assert any(
|
||||
msg.role == Role.ASSISTANT and (msg.text or "").startswith("specialist reply") for msg in conversation_list
|
||||
)
|
||||
|
||||
|
||||
async def test_autonomous_continues_without_handoff_until_termination():
|
||||
"""Autonomous mode should keep invoking the same agent when no handoff occurs."""
|
||||
worker = _RecordingAgent(name="worker")
|
||||
|
||||
workflow = (
|
||||
HandoffBuilder(participants=[worker])
|
||||
.set_coordinator(worker)
|
||||
.with_interaction_mode("autonomous", autonomous_turn_limit=3)
|
||||
.with_termination_condition(lambda conv: False)
|
||||
.build()
|
||||
)
|
||||
|
||||
events = await _drain(workflow.run_stream("Start"))
|
||||
outputs = [ev for ev in events if isinstance(ev, WorkflowOutputEvent)]
|
||||
assert outputs, "Autonomous mode should yield output after termination condition"
|
||||
assert len(worker.calls) == 3, "Worker should be invoked multiple times without user input"
|
||||
requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)]
|
||||
assert not requests, "Autonomous mode should not request user input"
|
||||
|
||||
|
||||
async def test_autonomous_turn_limit_stops_loop():
|
||||
"""Autonomous mode should stop when the configured turn limit is reached."""
|
||||
worker = _RecordingAgent(name="worker")
|
||||
|
||||
workflow = (
|
||||
HandoffBuilder(participants=[worker])
|
||||
.set_coordinator(worker)
|
||||
.with_interaction_mode("autonomous", autonomous_turn_limit=2)
|
||||
.with_termination_condition(lambda conv: False)
|
||||
.build()
|
||||
)
|
||||
|
||||
events = await _drain(workflow.run_stream("Start"))
|
||||
outputs = [ev for ev in events if isinstance(ev, WorkflowOutputEvent)]
|
||||
assert outputs, "Turn limit should force a workflow output"
|
||||
assert len(worker.calls) == 2, "Worker should stop after reaching the turn limit"
|
||||
requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)]
|
||||
assert not requests, "Autonomous mode should not request user input"
|
||||
|
||||
|
||||
async def test_autonomous_routes_back_to_coordinator_when_specialist_stops():
|
||||
"""Specialist without handoff should route back to coordinator in autonomous mode."""
|
||||
triage = _RecordingAgent(name="triage", handoff_to="specialist")
|
||||
specialist = _RecordingAgent(name="specialist")
|
||||
|
||||
workflow = (
|
||||
HandoffBuilder(participants=[triage, specialist])
|
||||
.set_coordinator(triage)
|
||||
.add_handoff(triage, specialist)
|
||||
.with_interaction_mode("autonomous", autonomous_turn_limit=3)
|
||||
.with_termination_condition(lambda conv: len(conv) >= 4)
|
||||
.build()
|
||||
)
|
||||
|
||||
events = await _drain(workflow.run_stream("Issue"))
|
||||
outputs = [ev for ev in events if isinstance(ev, WorkflowOutputEvent)]
|
||||
assert outputs, "Workflow should complete without user input"
|
||||
assert len(specialist.calls) >= 1, "Specialist should run without handoff"
|
||||
|
||||
|
||||
async def test_autonomous_mode_with_inline_turn_limit():
|
||||
"""Autonomous mode should respect turn limit passed via with_interaction_mode."""
|
||||
worker = _RecordingAgent(name="worker")
|
||||
|
||||
workflow = (
|
||||
HandoffBuilder(participants=[worker])
|
||||
.set_coordinator(worker)
|
||||
.with_interaction_mode("autonomous", autonomous_turn_limit=2)
|
||||
.with_termination_condition(lambda conv: False)
|
||||
.build()
|
||||
)
|
||||
|
||||
events = await _drain(workflow.run_stream("Start"))
|
||||
outputs = [ev for ev in events if isinstance(ev, WorkflowOutputEvent)]
|
||||
assert outputs, "Turn limit should force a workflow output"
|
||||
assert len(worker.calls) == 2, "Worker should stop after reaching the inline turn limit"
|
||||
|
||||
|
||||
def test_autonomous_turn_limit_ignored_in_human_in_loop_mode(caplog):
|
||||
"""Verify that autonomous_turn_limit logs a warning when mode is human_in_loop."""
|
||||
worker = _RecordingAgent(name="worker")
|
||||
|
||||
# Should not raise, but should log a warning
|
||||
HandoffBuilder(participants=[worker]).set_coordinator(worker).with_interaction_mode(
|
||||
"human_in_loop", autonomous_turn_limit=10
|
||||
)
|
||||
|
||||
assert "autonomous_turn_limit=10 was provided but interaction_mode is 'human_in_loop'; ignoring." in caplog.text
|
||||
|
||||
|
||||
def test_autonomous_turn_limit_must_be_positive():
|
||||
"""Verify that autonomous_turn_limit raises an error when <= 0."""
|
||||
worker = _RecordingAgent(name="worker")
|
||||
|
||||
with pytest.raises(ValueError, match="autonomous_turn_limit must be positive"):
|
||||
HandoffBuilder(participants=[worker]).set_coordinator(worker).with_interaction_mode(
|
||||
"autonomous", autonomous_turn_limit=0
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="autonomous_turn_limit must be positive"):
|
||||
HandoffBuilder(participants=[worker]).set_coordinator(worker).with_interaction_mode(
|
||||
"autonomous", autonomous_turn_limit=-5
|
||||
)
|
||||
|
||||
|
||||
def test_build_fails_without_coordinator():
|
||||
"""Verify that build() raises ValueError when set_coordinator() was not called."""
|
||||
triage = _RecordingAgent(name="triage")
|
||||
|
||||
Reference in New Issue
Block a user