Skip to content

Commit 61d0400

Browse files
committed
feat: Add create_handoff_tools() for Azure AI Agent Service compatibility
This PR addresses issue #3713 where HandoffBuilder raised ValueError when users pre-created handoff tools for Azure AI Agent Service compatibility. Changes: - Add create_handoff_tools() function to pre-create handoff tools that can be registered at agent creation time (required for Azure AI Agent Service) - Modify _apply_auto_tools() to skip duplicate tools instead of raising ValueError, logging a debug message instead - Export create_handoff_tools and get_handoff_tool_name from the public API - Add comprehensive tests for the new functionality The Azure AI Agent Service requires tools to be registered at agent creation time, not at request time. This change allows users to pre-create handoff tools and include them in the agent's default_options.tools list. Fixes #3713
1 parent 3737ffc commit 61d0400

File tree

3 files changed

+219
-5
lines changed

3 files changed

+219
-5
lines changed

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,13 @@
7373
GroupChatBuilder,
7474
GroupChatState,
7575
)
76-
from ._handoff import HandoffAgentUserRequest, HandoffBuilder, HandoffSentEvent
76+
from ._handoff import (
77+
HandoffAgentUserRequest,
78+
HandoffBuilder,
79+
HandoffSentEvent,
80+
create_handoff_tools,
81+
get_handoff_tool_name,
82+
)
7783
from ._magentic import (
7884
ORCH_MSG_KIND_INSTRUCTION,
7985
ORCH_MSG_KIND_NOTICE,
@@ -215,8 +221,10 @@
215221
"WorkflowValidationError",
216222
"WorkflowViz",
217223
"create_edge_runner",
224+
"create_handoff_tools",
218225
"executor",
219226
"get_checkpoint_summary",
227+
"get_handoff_tool_name",
220228
"handler",
221229
"resolve_agent_id",
222230
"response_handler",

python/packages/core/agent_framework/_workflows/_handoff.py

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,76 @@ def get_handoff_tool_name(target_id: str) -> str:
122122
return f"handoff_to_{target_id}"
123123

124124

125+
def create_handoff_tools(
126+
target_agent_ids: Sequence[str],
127+
descriptions: Mapping[str, str] | None = None,
128+
) -> list[FunctionTool[Any, Any]]:
129+
"""Create handoff tools for pre-registration with agents.
130+
131+
This function is particularly useful when using Azure AI Agent Service or other
132+
providers that require tools to be registered at agent creation time rather than
133+
at request time. By pre-creating handoff tools, you can include them in the agent's
134+
tool list when creating the agent.
135+
136+
The HandoffBuilder will automatically detect pre-existing handoff tools and skip
137+
creating duplicates, ensuring seamless integration.
138+
139+
Args:
140+
target_agent_ids: Sequence of target agent identifiers that can be handed off to.
141+
descriptions: Optional mapping of agent IDs to custom tool descriptions.
142+
If not provided, a default description will be used.
143+
144+
Returns:
145+
List of FunctionTool instances ready for use with ChatAgent.
146+
147+
Example:
148+
.. code-block:: python
149+
150+
from agent_framework import ChatAgent
151+
from agent_framework._workflows._handoff import create_handoff_tools
152+
153+
# Pre-create handoff tools for Azure AI Agent Service compatibility
154+
handoff_tools = create_handoff_tools(
155+
["specialist", "escalation"],
156+
descriptions={
157+
"specialist": "Route to specialist for technical issues",
158+
"escalation": "Escalate to supervisor for complex cases",
159+
},
160+
)
161+
162+
# Include tools when creating the agent
163+
agent = ChatAgent(
164+
chat_client=azure_client,
165+
name="triage",
166+
default_options={"tools": handoff_tools + other_tools},
167+
)
168+
169+
See Also:
170+
- `get_handoff_tool_name`: Get the standardized tool name for a target agent.
171+
- `HandoffBuilder`: Builder for creating handoff workflows.
172+
"""
173+
descriptions = descriptions or {}
174+
tools: list[FunctionTool[Any, Any]] = []
175+
176+
for target_id in target_agent_ids:
177+
tool_name = get_handoff_tool_name(target_id)
178+
doc = descriptions.get(target_id) or f"Handoff to the {target_id} agent."
179+
180+
# Note: approval_mode is set to "never_require" for handoff tools because
181+
# they are framework-internal signals that trigger routing logic, not
182+
# actual function executions. They are automatically intercepted by
183+
# _AutoHandoffMiddleware which short-circuits execution and provides synthetic
184+
# results, so the function body never actually runs in practice.
185+
@tool(name=tool_name, description=doc, approval_mode="never_require")
186+
def _handoff_tool(context: str | None = None, *, _target: str = target_id) -> str:
187+
"""Return a deterministic acknowledgement that encodes the target alias."""
188+
return f"Handoff to {_target}"
189+
190+
tools.append(_handoff_tool)
191+
192+
return tools
193+
194+
125195
HANDOFF_FUNCTION_RESULT_KEY = "handoff_to"
126196

127197

@@ -335,11 +405,15 @@ def _apply_auto_tools(self, agent: ChatAgent, targets: Sequence[HandoffConfigura
335405
for target in targets:
336406
tool = self._create_handoff_tool(target.target_id, target.description)
337407
if tool.name in existing_names:
338-
raise ValueError(
339-
f"Agent '{resolve_agent_id(agent)}' already has a tool named '{tool.name}'. "
340-
f"Handoff tool name '{tool.name}' conflicts with existing tool."
341-
"Please rename the existing tool or modify the target agent ID to avoid conflicts."
408+
# Skip adding duplicate tools - this supports Azure AI Agent Service
409+
# where users pre-create handoff tools using create_handoff_tools()
410+
# and register them at agent creation time.
411+
logger.debug(
412+
"Handoff tool '%s' already exists for agent '%s', skipping duplicate creation.",
413+
tool.name,
414+
resolve_agent_id(agent),
342415
)
416+
continue
343417
new_tools.append(tool)
344418

345419
if new_tools:

python/packages/core/tests/workflow/test_handoff.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -707,3 +707,135 @@ def create_specialist() -> MockHandoffAgent:
707707

708708

709709
# endregion Participant Factory Tests
710+
711+
712+
# region Azure AI Agent Service Compatibility Tests (Issue #3713)
713+
714+
715+
def test_handoff_skips_existing_tools_without_error():
716+
"""Test that pre-existing handoff tools are skipped without raising ValueError.
717+
718+
This test addresses the Azure AI Agent Service compatibility issue where tools
719+
must be registered at agent creation time, not at request time.
720+
Users can pre-create handoff tools using create_handoff_tools() and the builder
721+
should skip duplicates instead of raising ValueError.
722+
723+
See: https://github.com/microsoft/agent-framework/issues/3713
724+
"""
725+
from agent_framework._workflows._handoff import create_handoff_tools, get_handoff_tool_name
726+
727+
# Create handoff tools that will be pre-registered with the agent
728+
pre_created_tools = create_handoff_tools(["specialist", "escalation"])
729+
730+
# Verify tools were created with correct names
731+
tool_names = [t.name for t in pre_created_tools]
732+
assert get_handoff_tool_name("specialist") in tool_names
733+
assert get_handoff_tool_name("escalation") in tool_names
734+
735+
# Create a MockChatClient that has the pre-created tools
736+
from agent_framework import ChatAgent
737+
738+
class PreTooledMockChatClient(MockChatClient):
739+
"""Mock client where agent has pre-registered handoff tools."""
740+
741+
def __init__(self, name: str, handoff_to: str | None = None) -> None:
742+
super().__init__(name, handoff_to=handoff_to)
743+
744+
# Create agent with pre-registered handoff tools in default_options
745+
triage = ChatAgent(
746+
chat_client=PreTooledMockChatClient("triage", handoff_to="specialist"),
747+
name="triage",
748+
id="triage",
749+
default_options={"tools": pre_created_tools},
750+
)
751+
specialist = MockHandoffAgent(name="specialist", handoff_to="escalation")
752+
escalation = MockHandoffAgent(name="escalation")
753+
754+
# This should NOT raise ValueError - the builder should skip existing tools
755+
workflow = (
756+
HandoffBuilder(participants=[triage, specialist, escalation])
757+
.with_start_agent(triage)
758+
.with_termination_condition(lambda conv: len(conv) >= 3)
759+
.build()
760+
)
761+
762+
# Verify the workflow was built successfully
763+
assert "triage" in workflow.executors
764+
assert "specialist" in workflow.executors
765+
assert "escalation" in workflow.executors
766+
767+
768+
def test_create_handoff_tools_returns_correct_tools():
769+
"""Test that create_handoff_tools creates FunctionTool objects with correct names and descriptions."""
770+
from agent_framework._workflows._handoff import create_handoff_tools, get_handoff_tool_name
771+
772+
tools = create_handoff_tools(
773+
["agent_a", "agent_b"],
774+
descriptions={"agent_a": "Route to Agent A for billing", "agent_b": "Route to Agent B for tech support"},
775+
)
776+
777+
assert len(tools) == 2
778+
779+
tool_by_name = {t.name: t for t in tools}
780+
781+
# Check tool names
782+
assert get_handoff_tool_name("agent_a") in tool_by_name
783+
assert get_handoff_tool_name("agent_b") in tool_by_name
784+
785+
# Check descriptions
786+
assert tool_by_name[get_handoff_tool_name("agent_a")].description == "Route to Agent A for billing"
787+
assert tool_by_name[get_handoff_tool_name("agent_b")].description == "Route to Agent B for tech support"
788+
789+
790+
def test_create_handoff_tools_default_descriptions():
791+
"""Test that create_handoff_tools uses default descriptions when not provided."""
792+
from agent_framework._workflows._handoff import create_handoff_tools
793+
794+
tools = create_handoff_tools(["my_agent"])
795+
796+
assert len(tools) == 1
797+
assert tools[0].description == "Handoff to the my_agent agent."
798+
799+
800+
async def test_workflow_with_pre_created_tools_executes_correctly():
801+
"""Integration test: workflow with pre-created handoff tools executes handoffs correctly."""
802+
from agent_framework import ChatAgent
803+
from agent_framework._workflows._handoff import create_handoff_tools
804+
805+
# Pre-create handoff tools for Azure AI Agent Service compatibility
806+
triage_tools = create_handoff_tools(["specialist"])
807+
specialist_tools = create_handoff_tools(["escalation"])
808+
809+
# Create agents with pre-registered tools
810+
triage = ChatAgent(
811+
chat_client=MockChatClient("triage", handoff_to="specialist"),
812+
name="triage",
813+
id="triage",
814+
default_options={"tools": triage_tools},
815+
)
816+
specialist = ChatAgent(
817+
chat_client=MockChatClient("specialist", handoff_to="escalation"),
818+
name="specialist",
819+
id="specialist",
820+
default_options={"tools": specialist_tools},
821+
)
822+
escalation = MockHandoffAgent(name="escalation")
823+
824+
workflow = (
825+
HandoffBuilder(participants=[triage, specialist, escalation])
826+
.with_start_agent(triage)
827+
.with_termination_condition(lambda conv: sum(1 for m in conv if m.role == "user") >= 2)
828+
.build()
829+
)
830+
831+
# Execute the workflow - should complete without errors
832+
events = await _drain(workflow.run_stream("Need help"))
833+
requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)]
834+
835+
# Workflow should reach escalation agent and request user input
836+
assert requests
837+
assert len(requests) == 1
838+
assert requests[0].source_executor_id == escalation.name
839+
840+
841+
# endregion Azure AI Agent Service Compatibility Tests

0 commit comments

Comments
 (0)