Python: Tighten HandoffBuilder to require Agent instead of SupportsAgentRun (#4301) (#4302)

HandoffBuilder.participants() accepted SupportsAgentRun by API contract,
but build() failed at runtime because _prepare_agent_with_handoffs()
requires Agent instances for cloning, tool injection, and middleware.

Fix: Update all public type hints, docstrings, and validation in
HandoffBuilder and HandoffAgentExecutor to require Agent explicitly.
The isinstance check is now performed early in participants() with a
clear error message explaining why Agent is required.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Evan Mattson
2026-02-27 05:52:06 +09:00
committed by GitHub
Unverified
parent a033721ac2
commit 97b24990d9
2 changed files with 59 additions and 32 deletions
@@ -197,7 +197,7 @@ class HandoffAgentExecutor(AgentExecutor):
def __init__(
self,
agent: SupportsAgentRun,
agent: Agent,
handoffs: Sequence[HandoffConfiguration],
*,
agent_session: AgentSession | None = None,
@@ -210,7 +210,7 @@ class HandoffAgentExecutor(AgentExecutor):
"""Initialize the HandoffAgentExecutor.
Args:
agent: The agent to execute
agent: The ``Agent`` instance to execute
handoffs: Sequence of handoff configurations defining target agents
agent_session: Optional AgentSession that manages the agent's execution context
is_start_agent: Whether this agent is the starting agent in the handoff workflow.
@@ -240,20 +240,18 @@ class HandoffAgentExecutor(AgentExecutor):
def _prepare_agent_with_handoffs(
self,
agent: SupportsAgentRun,
agent: Agent,
handoffs: Sequence[HandoffConfiguration],
) -> SupportsAgentRun:
) -> Agent:
"""Prepare an agent by adding handoff tools for the specified target agents.
Args:
agent: The agent to prepare
agent: The ``Agent`` instance to prepare
handoffs: Sequence of handoff configurations defining target agents
Returns:
A new AgentExecutor instance with handoff tools added
A cloned ``Agent`` instance with handoff tools added
"""
if not isinstance(agent, Agent):
raise TypeError("Handoff can only be applied to Agent. Please ensure the agent is a Agent instance.")
# Clone the agent to avoid mutating the original
cloned_agent = self._clone_chat_agent(agent) # type: ignore
@@ -701,13 +699,15 @@ class HandoffBuilder:
approach to multi-agent collaboration. Handoffs can be configured using `.add_handoff`. If
none are specified, all agents can hand off to all others by default (making a mesh topology).
Participants must be agents. Support for custom executors is not available in handoff workflows.
Participants must be ``Agent`` instances. ``SupportsAgentRun`` protocol implementors that
are not ``Agent`` subclasses are not supported because handoff workflows require cloning,
tool injection, and middleware — capabilities only available on ``Agent``.
Outputs:
The final conversation history as a list of Message once the group chat completes.
Note:
1. Agents in handoff workflows must be Agent instances and support local tool calls.
1. Agents in handoff workflows must be ``Agent`` instances and support local tool calls.
2. Handoff doesn't support intermediate outputs from agents. All outputs are returned as
they become available. This is because agents in handoff workflows are not considered
sub-agents of a central orchestrator, thus all outputs are directly emitted.
@@ -717,7 +717,7 @@ class HandoffBuilder:
self,
*,
name: str | None = None,
participants: Sequence[SupportsAgentRun] | None = None,
participants: Sequence[Agent] | None = None,
description: str | None = None,
checkpoint_storage: CheckpointStorage | None = None,
termination_condition: TerminationCondition | None = None,
@@ -734,7 +734,7 @@ class HandoffBuilder:
Args:
name: Optional workflow identifier used in logging and debugging.
If not provided, a default name will be generated.
participants: Optional list of agents that will participate in the handoff workflow.
participants: Optional list of ``Agent`` instances that will participate in the handoff workflow.
You can also call `.participants([...])` later. Each participant must have a
unique identifier (`.name` is preferred if set, otherwise `.id` is used).
description: Optional human-readable description explaining the workflow's
@@ -747,7 +747,7 @@ class HandoffBuilder:
self._description = description
# Participant related members
self._participants: dict[str, SupportsAgentRun] = {}
self._participants: dict[str, Agent] = {}
self._start_id: str | None = None
if participants:
@@ -768,11 +768,11 @@ class HandoffBuilder:
# Termination related members
self._termination_condition: Callable[[list[Message]], bool | Awaitable[bool]] | None = termination_condition
def participants(self, participants: Sequence[SupportsAgentRun]) -> "HandoffBuilder":
def participants(self, participants: Sequence[Agent]) -> "HandoffBuilder":
"""Register the agents that will participate in the handoff workflow.
Args:
participants: Sequence of SupportsAgentRun instances. Each must have a unique identifier.
participants: Sequence of ``Agent`` instances. Each must have a unique identifier.
(`.name` is preferred if set, otherwise `.id` is used).
Returns:
@@ -781,7 +781,7 @@ class HandoffBuilder:
Raises:
ValueError: If participants is empty, contains duplicates, or `.participants()`
has already been called.
TypeError: If participants are not SupportsAgentRun instances.
TypeError: If participants are not ``Agent`` instances.
Example:
@@ -804,14 +804,15 @@ class HandoffBuilder:
if not participants:
raise ValueError("participants cannot be empty")
named: dict[str, SupportsAgentRun] = {}
named: dict[str, Agent] = {}
for participant in participants:
if isinstance(participant, SupportsAgentRun):
resolved_id = self._resolve_to_id(participant)
else:
if not isinstance(participant, Agent):
raise TypeError(
f"Participants must be SupportsAgentRun or Executor instances. Got {type(participant).__name__}."
f"Participants must be Agent instances. Got {type(participant).__name__}. "
"Handoff workflows require Agent because they rely on cloning, tool injection, "
"and middleware capabilities."
)
resolved_id = self._resolve_to_id(participant)
if resolved_id in named:
raise ValueError(f"Duplicate participant name '{resolved_id}' detected")
@@ -823,8 +824,8 @@ class HandoffBuilder:
def add_handoff(
self,
source: SupportsAgentRun,
targets: Sequence[SupportsAgentRun],
source: Agent,
targets: Sequence[Agent],
*,
description: str | None = None,
) -> "HandoffBuilder":
@@ -905,7 +906,7 @@ class HandoffBuilder:
return self
def with_start_agent(self, agent: SupportsAgentRun) -> "HandoffBuilder":
def with_start_agent(self, agent: Agent) -> "HandoffBuilder":
"""Set the agent that will initiate the handoff workflow.
If not specified, the first registered participant will be used as the starting agent.
@@ -929,7 +930,7 @@ class HandoffBuilder:
def with_autonomous_mode(
self,
*,
agents: Sequence[SupportsAgentRun] | Sequence[str] | None = None,
agents: Sequence[Agent] | Sequence[str] | None = None,
prompts: dict[str, str] | None = None,
turn_limits: dict[str, int] | None = None,
) -> "HandoffBuilder":
@@ -943,7 +944,7 @@ class HandoffBuilder:
Args:
agents: Optional list of agents to enable autonomous mode for. Can be:
- Factory names (str): If using participant factories
- SupportsAgentRun instances: The actual agent objects
- SupportsAgentRun / Agent instances: The actual agent objects
- If not provided, all agents will operate in autonomous mode.
prompts: Optional mapping of agent identifiers/factory names to custom prompts to use when continuing
in autonomous mode. If not provided, a default prompt will be used.
@@ -1092,22 +1093,22 @@ class HandoffBuilder:
# region Internal Helper Methods
def _resolve_agents(self) -> dict[str, SupportsAgentRun]:
def _resolve_agents(self) -> dict[str, Agent]:
"""Resolve participant instances into agent instances.
Returns:
Map of executor IDs to `SupportsAgentRun` instances
Map of executor IDs to ``Agent`` instances
"""
if not self._participants:
raise ValueError("No participants provided. Call .participants() first.")
return self._participants
def _resolve_handoffs(self, agents: dict[str, SupportsAgentRun]) -> dict[str, list[HandoffConfiguration]]:
def _resolve_handoffs(self, agents: dict[str, Agent]) -> dict[str, list[HandoffConfiguration]]:
"""Resolve handoff configurations to executor IDs.
Args:
agents: Map of agent IDs to `SupportsAgentRun` instances
agents: Map of agent IDs to ``Agent`` instances
Returns:
Map of executor IDs to list of HandoffConfiguration instances
@@ -1154,13 +1155,13 @@ class HandoffBuilder:
def _resolve_executors(
self,
agents: dict[str, SupportsAgentRun],
agents: dict[str, Agent],
handoffs: dict[str, list[HandoffConfiguration]],
) -> dict[str, HandoffAgentExecutor]:
"""Resolve agents into HandoffAgentExecutors.
Args:
agents: Map of agent IDs to `SupportsAgentRun` instances
agents: Map of agent IDs to ``Agent`` instances
handoffs: Map of executor IDs to list of HandoffConfiguration instances
Returns:
@@ -1091,3 +1091,29 @@ async def test_auto_handoff_middleware_calls_next_for_non_handoff_tool() -> None
call_next.assert_awaited_once()
assert context.result is None
def test_handoff_builder_rejects_non_agent_supports_agent_run():
"""Verify that participants() rejects SupportsAgentRun implementations that are not Agent instances."""
from agent_framework import AgentResponse, AgentSession, SupportsAgentRun
class FakeAgentRun:
def __init__(self, id, name):
self.id = id
self.name = name
self.description = "d"
async def run(self, messages=None, *, stream=False, session=None, **kwargs):
return AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("ok")])])
def create_session(self, **kwargs):
return AgentSession()
def get_session(self, *, service_session_id, **kwargs):
return AgentSession(service_session_id=service_session_id)
fake = FakeAgentRun("a", "A")
assert isinstance(fake, SupportsAgentRun)
with pytest.raises(TypeError, match="Participants must be Agent instances"):
HandoffBuilder().participants([fake])