Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
"HandoffBuilder",
"HandoffConfiguration",
"HandoffSentEvent",
"create_handoff_tools",
"get_handoff_tool_name",
# Base orchestrator
"BaseGroupChatOrchestrator",
"GroupChatRequestMessage",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ from agent_framework_orchestrations import (
HandoffBuilder,
HandoffConfiguration,
HandoffSentEvent,
create_handoff_tools,
get_handoff_tool_name,
MagenticAgentExecutor,
MagenticBuilder,
MagenticContext,
Expand Down Expand Up @@ -74,4 +76,6 @@ __all__ = [
"SequentialBuilder",
"StandardMagenticManager",
"__version__",
"create_handoff_tools",
"get_handoff_tool_name",
]
Binary file modified python/packages/lab/lightning/assets/train_math_agent.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified python/packages/lab/lightning/assets/train_tau2_agent.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
HandoffBuilder,
HandoffConfiguration,
HandoffSentEvent,
create_handoff_tools,
get_handoff_tool_name,
)
from ._magentic import (
MAGENTIC_MANAGER_NAME,
Expand Down Expand Up @@ -107,4 +109,6 @@
"__version__",
"clean_conversation_for_handoff",
"create_completion_message",
"create_handoff_tools",
"get_handoff_tool_name",
]
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
import inspect
import logging
import sys
from collections.abc import Awaitable, Callable, Sequence
from collections.abc import Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass
from typing import Any, cast

Expand Down Expand Up @@ -116,6 +116,46 @@ def get_handoff_tool_name(target_id: str) -> str:
return f"handoff_to_{target_id}"


def create_handoff_tools(
target_agent_ids: Sequence[str],
descriptions: Mapping[str, str] | None = None,
) -> list[FunctionTool[Any, Any]]:
"""Create handoff tools for pre-registration with agents at creation time.

Use this function when working with services like Azure AI Agent Service
that require tools to be registered at agent creation time rather than
at request time. The returned tools can be passed directly to the agent
constructor.

Example::

# Create handoff tools for Azure AI Agent Service
handoff_tools = create_handoff_tools(["specialist", "escalation"])
agent = Agent(client=client, name="triage", default_options={"tools": handoff_tools})

Args:
target_agent_ids: Sequence of target agent IDs to create handoff tools for.
descriptions: Optional mapping from agent ID to a custom description for
the handoff tool. If not provided or if a given agent ID is not in the
mapping, the default description ``"Handoff to the <id> agent."`` is used.

Returns:
A list of :class:`FunctionTool` instances, one per target agent ID.
"""
tools: list[FunctionTool[Any, Any]] = []
for target_id in target_agent_ids:
tool_name = get_handoff_tool_name(target_id)
doc = (descriptions or {}).get(target_id) or f"Handoff to the {target_id} agent."

@tool(name=tool_name, description=doc, approval_mode="never_require")
def _handoff_tool(context: str | None = None, _tid: str = target_id) -> str:
"""Return a deterministic acknowledgement that encodes the target alias."""
return f"Handoff to {_tid}"

tools.append(_handoff_tool)
return tools


HANDOFF_FUNCTION_RESULT_KEY = "handoff_to"


Expand Down Expand Up @@ -329,11 +369,12 @@ def _apply_auto_tools(self, agent: Agent, targets: Sequence[HandoffConfiguration
for target in targets:
handoff_tool = self._create_handoff_tool(target.target_id, target.description)
if handoff_tool.name in existing_names:
raise ValueError(
f"Agent '{resolve_agent_id(agent)}' already has a tool named '{handoff_tool.name}'. "
f"Handoff tool name '{handoff_tool.name}' conflicts with existing tool."
"Please rename the existing tool or modify the target agent ID to avoid conflicts."
logger.debug(
"Agent '%s' already has a tool named '%s'; skipping auto-registration.",
resolve_agent_id(agent),
handoff_tool.name,
)
continue
new_tools.append(handoff_tool)

if new_tools:
Expand Down
104 changes: 103 additions & 1 deletion python/packages/orchestrations/tests/test_handoff.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from agent_framework._clients import BaseChatClient
from agent_framework._middleware import ChatMiddlewareLayer
from agent_framework._tools import FunctionInvocationLayer
from agent_framework.orchestrations import HandoffAgentUserRequest, HandoffBuilder
from agent_framework.orchestrations import HandoffAgentUserRequest, HandoffBuilder, create_handoff_tools, get_handoff_tool_name


class MockChatClient(ChatMiddlewareLayer[Any], FunctionInvocationLayer[Any], BaseChatClient[Any]):
Expand Down Expand Up @@ -364,3 +364,105 @@ def test_handoff_builder_accepts_all_instances_in_add_handoff():
assert "triage" in workflow.executors
assert "specialist_a" in workflow.executors
assert "specialist_b" in workflow.executors


def test_create_handoff_tools_creates_correct_tools():
"""Test that create_handoff_tools creates tools with correct names and default descriptions."""
target_ids = ["specialist", "escalation"]
tools = create_handoff_tools(target_ids)

assert len(tools) == 2
assert tools[0].name == "handoff_to_specialist"
assert tools[1].name == "handoff_to_escalation"
assert tools[0].description == "Handoff to the specialist agent."
assert tools[1].description == "Handoff to the escalation agent."


def test_create_handoff_tools_with_custom_descriptions():
"""Test that create_handoff_tools uses custom descriptions when provided."""
target_ids = ["billing", "tech"]
descriptions = {
"billing": "Transfer the customer to the billing department.",
"tech": "Transfer the customer to technical support.",
}
tools = create_handoff_tools(target_ids, descriptions=descriptions)

assert len(tools) == 2
assert tools[0].name == "handoff_to_billing"
assert tools[1].name == "handoff_to_tech"
assert tools[0].description == "Transfer the customer to the billing department."
assert tools[1].description == "Transfer the customer to technical support."


def test_create_handoff_tools_empty():
"""Test that create_handoff_tools returns empty list for empty input."""
tools = create_handoff_tools([])
assert tools == []


async def test_pre_registered_tools_no_conflict():
"""Test that pre-registered handoff tools do not raise ValueError when building a workflow.

This verifies the Azure AI Agent Service compatibility fix: when a user creates
handoff tools upfront and attaches them to the agent, _apply_auto_tools should
skip the duplicates instead of raising.
"""
pre_registered_tools = create_handoff_tools(["specialist"])
mock_client = MockChatClient(name="triage")
agent = Agent(
client=mock_client,
name="triage",
id="triage",
default_options={"tools": pre_registered_tools}, # type: ignore
)

specialist = MockHandoffAgent(name="specialist")
# This should NOT raise ValueError for duplicate tool names
workflow = (
HandoffBuilder(participants=[agent, specialist])
.with_start_agent(agent)
.add_handoff(agent, [specialist])
.build()
)

assert "triage" in workflow.executors
assert "specialist" in workflow.executors


def test_get_handoff_tool_name():
"""Test that get_handoff_tool_name returns the expected format."""
assert get_handoff_tool_name("specialist") == "handoff_to_specialist"
assert get_handoff_tool_name("billing_agent") == "handoff_to_billing_agent"


async def test_mesh_topology_with_pre_registered_tools():
"""Test that pre-registered tools work in a mesh topology where each agent can route to all others."""
# Create agents with pre-registered handoff tools (Azure AI Agent Service pattern)
mock_client_a = MockChatClient(name="agent_a", handoff_to="agent_b")
tools_a = create_handoff_tools(["agent_b", "agent_c"])
agent_a = Agent(
client=mock_client_a,
name="agent_a",
id="agent_a",
default_options={"tools": tools_a}, # type: ignore
)

agent_b = MockHandoffAgent(name="agent_b")
agent_c = MockHandoffAgent(name="agent_c")

workflow = (
HandoffBuilder(participants=[agent_a, agent_b, agent_c])
.with_start_agent(agent_a)
.build()
)

assert "agent_a" in workflow.executors
assert "agent_b" in workflow.executors
assert "agent_c" in workflow.executors

# Run the workflow to verify it works end-to-end
events = await _drain(workflow.run("Test message", stream=True))
# agent_a should hand off to agent_b, which has no further handoff
# so it should request user input
requests = [ev for ev in events if ev.type == "request_info"]
assert requests
1 change: 1 addition & 0 deletions python/samples/getting_started/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ Once comfortable with these, explore the rest of the samples below.
| Workflow as Agent with Thread | [agents/workflow_as_agent_with_thread.py](./agents/workflow_as_agent_with_thread.py) | Use AgentThread to maintain conversation history across workflow-as-agent invocations |
| Workflow as Agent kwargs | [agents/workflow_as_agent_kwargs.py](./agents/workflow_as_agent_kwargs.py) | Pass custom context (data, user tokens) via kwargs through workflow.as_agent() to @ai_function tools |
| Handoff Workflow as Agent | [agents/handoff_workflow_as_agent.py](./agents/handoff_workflow_as_agent.py) | Use a HandoffBuilder workflow as an agent with HITL via FunctionCallContent/FunctionResultContent |
| Azure AI Handoff (Pre-Registered Tools) | [agents/handoff_with_azure_ai_agents.py](./agents/handoff_with_azure_ai_agents.py) | Use create_handoff_tools() to pre-register handoff tools at agent creation time for Azure AI Agent Service |

### checkpoint

Expand Down
Loading
Loading