Skip to content

Commit e1a9346

Browse files
committed
Python: feat: Add create_handoff_tools() for Azure AI Agent Service compatibility (#3713)
Add create_handoff_tools() public function to allow pre-registration of handoff tools at agent creation time, needed for Azure AI Agent Service which does not support adding tools at request time. Also modify _apply_auto_tools() to skip duplicate tools instead of raising ValueError, enabling the pre-registration pattern.
1 parent 38f22ef commit e1a9346

5 files changed

Lines changed: 160 additions & 6 deletions

File tree

python/packages/core/agent_framework/orchestrations/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
"HandoffBuilder",
1818
"HandoffConfiguration",
1919
"HandoffSentEvent",
20+
"create_handoff_tools",
21+
"get_handoff_tool_name",
2022
# Base orchestrator
2123
"BaseGroupChatOrchestrator",
2224
"GroupChatRequestMessage",

python/packages/core/agent_framework/orchestrations/__init__.pyi

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ from agent_framework_orchestrations import (
2121
HandoffBuilder,
2222
HandoffConfiguration,
2323
HandoffSentEvent,
24+
create_handoff_tools,
25+
get_handoff_tool_name,
2426
MagenticAgentExecutor,
2527
MagenticBuilder,
2628
MagenticContext,
@@ -74,4 +76,6 @@ __all__ = [
7476
"SequentialBuilder",
7577
"StandardMagenticManager",
7678
"__version__",
79+
"create_handoff_tools",
80+
"get_handoff_tool_name",
7781
]

python/packages/orchestrations/agent_framework_orchestrations/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@
3939
HandoffBuilder,
4040
HandoffConfiguration,
4141
HandoffSentEvent,
42+
create_handoff_tools,
43+
get_handoff_tool_name,
4244
)
4345
from ._magentic import (
4446
MAGENTIC_MANAGER_NAME,
@@ -107,4 +109,6 @@
107109
"__version__",
108110
"clean_conversation_for_handoff",
109111
"create_completion_message",
112+
"create_handoff_tools",
113+
"get_handoff_tool_name",
110114
]

python/packages/orchestrations/agent_framework_orchestrations/_handoff.py

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
import inspect
3333
import logging
3434
import sys
35-
from collections.abc import Awaitable, Callable, Sequence
35+
from collections.abc import Awaitable, Callable, Mapping, Sequence
3636
from dataclasses import dataclass
3737
from typing import Any, cast
3838

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

118118

119+
def create_handoff_tools(
120+
target_agent_ids: Sequence[str],
121+
descriptions: Mapping[str, str] | None = None,
122+
) -> list[FunctionTool[Any, Any]]:
123+
"""Create handoff tools for pre-registration with agents at creation time.
124+
125+
Use this function when working with services like Azure AI Agent Service
126+
that require tools to be registered at agent creation time rather than
127+
at request time. The returned tools can be passed directly to the agent
128+
constructor.
129+
130+
Example::
131+
132+
# Create handoff tools for Azure AI Agent Service
133+
handoff_tools = create_handoff_tools(["specialist", "escalation"])
134+
agent = Agent(client=client, name="triage", default_options={"tools": handoff_tools})
135+
136+
Args:
137+
target_agent_ids: Sequence of target agent IDs to create handoff tools for.
138+
descriptions: Optional mapping from agent ID to a custom description for
139+
the handoff tool. If not provided or if a given agent ID is not in the
140+
mapping, the default description ``"Handoff to the <id> agent."`` is used.
141+
142+
Returns:
143+
A list of :class:`FunctionTool` instances, one per target agent ID.
144+
"""
145+
tools: list[FunctionTool[Any, Any]] = []
146+
for target_id in target_agent_ids:
147+
tool_name = get_handoff_tool_name(target_id)
148+
doc = (descriptions or {}).get(target_id) or f"Handoff to the {target_id} agent."
149+
150+
@tool(name=tool_name, description=doc, approval_mode="never_require")
151+
def _handoff_tool(context: str | None = None, _tid: str = target_id) -> str:
152+
"""Return a deterministic acknowledgement that encodes the target alias."""
153+
return f"Handoff to {_tid}"
154+
155+
tools.append(_handoff_tool)
156+
return tools
157+
158+
119159
HANDOFF_FUNCTION_RESULT_KEY = "handoff_to"
120160

121161

@@ -329,11 +369,12 @@ def _apply_auto_tools(self, agent: Agent, targets: Sequence[HandoffConfiguration
329369
for target in targets:
330370
handoff_tool = self._create_handoff_tool(target.target_id, target.description)
331371
if handoff_tool.name in existing_names:
332-
raise ValueError(
333-
f"Agent '{resolve_agent_id(agent)}' already has a tool named '{handoff_tool.name}'. "
334-
f"Handoff tool name '{handoff_tool.name}' conflicts with existing tool."
335-
"Please rename the existing tool or modify the target agent ID to avoid conflicts."
372+
logger.debug(
373+
"Agent '%s' already has a tool named '%s'; skipping auto-registration.",
374+
resolve_agent_id(agent),
375+
handoff_tool.name,
336376
)
377+
continue
337378
new_tools.append(handoff_tool)
338379

339380
if new_tools:

python/packages/orchestrations/tests/test_handoff.py

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from agent_framework._clients import BaseChatClient
2121
from agent_framework._middleware import ChatMiddlewareLayer
2222
from agent_framework._tools import FunctionInvocationLayer
23-
from agent_framework.orchestrations import HandoffAgentUserRequest, HandoffBuilder
23+
from agent_framework.orchestrations import HandoffAgentUserRequest, HandoffBuilder, create_handoff_tools, get_handoff_tool_name
2424

2525

2626
class MockChatClient(ChatMiddlewareLayer[Any], FunctionInvocationLayer[Any], BaseChatClient[Any]):
@@ -364,3 +364,106 @@ def test_handoff_builder_accepts_all_instances_in_add_handoff():
364364
assert "triage" in workflow.executors
365365
assert "specialist_a" in workflow.executors
366366
assert "specialist_b" in workflow.executors
367+
368+
369+
def test_create_handoff_tools_creates_correct_tools():
370+
"""Test that create_handoff_tools creates tools with correct names and default descriptions."""
371+
target_ids = ["specialist", "escalation"]
372+
tools = create_handoff_tools(target_ids)
373+
374+
assert len(tools) == 2
375+
assert tools[0].name == "handoff_to_specialist"
376+
assert tools[1].name == "handoff_to_escalation"
377+
assert tools[0].description == "Handoff to the specialist agent."
378+
assert tools[1].description == "Handoff to the escalation agent."
379+
380+
381+
def test_create_handoff_tools_with_custom_descriptions():
382+
"""Test that create_handoff_tools uses custom descriptions when provided."""
383+
target_ids = ["billing", "tech"]
384+
descriptions = {
385+
"billing": "Transfer the customer to the billing department.",
386+
"tech": "Transfer the customer to technical support.",
387+
}
388+
tools = create_handoff_tools(target_ids, descriptions=descriptions)
389+
390+
assert len(tools) == 2
391+
assert tools[0].name == "handoff_to_billing"
392+
assert tools[1].name == "handoff_to_tech"
393+
assert tools[0].description == "Transfer the customer to the billing department."
394+
assert tools[1].description == "Transfer the customer to technical support."
395+
396+
397+
def test_create_handoff_tools_empty():
398+
"""Test that create_handoff_tools returns empty list for empty input."""
399+
tools = create_handoff_tools([])
400+
assert tools == []
401+
402+
403+
async def test_pre_registered_tools_no_conflict():
404+
"""Test that pre-registered handoff tools do not raise ValueError when building a workflow.
405+
406+
This verifies the Azure AI Agent Service compatibility fix: when a user creates
407+
handoff tools upfront and attaches them to the agent, _apply_auto_tools should
408+
skip the duplicates instead of raising.
409+
"""
410+
pre_registered_tools = create_handoff_tools(["specialist"])
411+
mock_client = MockChatClient(name="triage")
412+
agent = Agent(
413+
client=mock_client,
414+
name="triage",
415+
id="triage",
416+
default_options={"tools": pre_registered_tools}, # type: ignore
417+
)
418+
419+
specialist = MockHandoffAgent(name="specialist")
420+
# This should NOT raise ValueError for duplicate tool names
421+
workflow = (
422+
HandoffBuilder(participants=[agent, specialist])
423+
.with_start_agent(agent)
424+
.add_handoff(agent, [specialist])
425+
.build()
426+
)
427+
428+
assert "triage" in workflow.executors
429+
assert "specialist" in workflow.executors
430+
431+
432+
def test_get_handoff_tool_name():
433+
"""Test that get_handoff_tool_name returns the expected format."""
434+
assert get_handoff_tool_name("specialist") == "handoff_to_specialist"
435+
assert get_handoff_tool_name("billing_agent") == "handoff_to_billing_agent"
436+
437+
438+
async def test_mesh_topology_with_pre_registered_tools():
439+
"""Test that pre-registered tools work in a mesh topology where each agent can route to all others."""
440+
# Create agents with pre-registered handoff tools (Azure AI Agent Service pattern)
441+
mock_client_a = MockChatClient(name="agent_a", handoff_to="agent_b")
442+
tools_a = create_handoff_tools(["agent_b", "agent_c"])
443+
agent_a = Agent(
444+
client=mock_client_a,
445+
name="agent_a",
446+
id="agent_a",
447+
default_options={"tools": tools_a}, # type: ignore
448+
)
449+
450+
agent_b = MockHandoffAgent(name="agent_b")
451+
agent_c = MockHandoffAgent(name="agent_c")
452+
453+
workflow = (
454+
HandoffBuilder(participants=[agent_a, agent_b, agent_c])
455+
.with_start_agent(agent_a)
456+
.build()
457+
)
458+
459+
assert "agent_a" in workflow.executors
460+
assert "agent_b" in workflow.executors
461+
assert "agent_c" in workflow.executors
462+
463+
# Run the workflow to verify it works end-to-end
464+
events = await _drain(workflow.run("Test message", stream=True))
465+
# agent_a should hand off to agent_b, which has no further handoff
466+
# so it should request user input
467+
requests = [ev for ev in events if ev.type == "request_info"]
468+
assert requests
469+

0 commit comments

Comments
 (0)