diff --git a/python/packages/core/agent_framework/orchestrations/__init__.py b/python/packages/core/agent_framework/orchestrations/__init__.py index 6e220bac93..47c37c96e2 100644 --- a/python/packages/core/agent_framework/orchestrations/__init__.py +++ b/python/packages/core/agent_framework/orchestrations/__init__.py @@ -17,6 +17,8 @@ "HandoffBuilder", "HandoffConfiguration", "HandoffSentEvent", + "create_handoff_tools", + "get_handoff_tool_name", # Base orchestrator "BaseGroupChatOrchestrator", "GroupChatRequestMessage", diff --git a/python/packages/core/agent_framework/orchestrations/__init__.pyi b/python/packages/core/agent_framework/orchestrations/__init__.pyi index cf26847972..724ddbc59b 100644 --- a/python/packages/core/agent_framework/orchestrations/__init__.pyi +++ b/python/packages/core/agent_framework/orchestrations/__init__.pyi @@ -21,6 +21,8 @@ from agent_framework_orchestrations import ( HandoffBuilder, HandoffConfiguration, HandoffSentEvent, + create_handoff_tools, + get_handoff_tool_name, MagenticAgentExecutor, MagenticBuilder, MagenticContext, @@ -74,4 +76,6 @@ __all__ = [ "SequentialBuilder", "StandardMagenticManager", "__version__", + "create_handoff_tools", + "get_handoff_tool_name", ] diff --git a/python/packages/lab/lightning/assets/train_math_agent.png b/python/packages/lab/lightning/assets/train_math_agent.png index 61c684c205..b6b5599929 100644 Binary files a/python/packages/lab/lightning/assets/train_math_agent.png and b/python/packages/lab/lightning/assets/train_math_agent.png differ diff --git a/python/packages/lab/lightning/assets/train_tau2_agent.png b/python/packages/lab/lightning/assets/train_tau2_agent.png index c8596f99a7..97a624bd50 100644 Binary files a/python/packages/lab/lightning/assets/train_tau2_agent.png and b/python/packages/lab/lightning/assets/train_tau2_agent.png differ diff --git a/python/packages/orchestrations/agent_framework_orchestrations/__init__.py b/python/packages/orchestrations/agent_framework_orchestrations/__init__.py index d1acb7af53..1a07997344 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/__init__.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/__init__.py @@ -39,6 +39,8 @@ HandoffBuilder, HandoffConfiguration, HandoffSentEvent, + create_handoff_tools, + get_handoff_tool_name, ) from ._magentic import ( MAGENTIC_MANAGER_NAME, @@ -107,4 +109,6 @@ "__version__", "clean_conversation_for_handoff", "create_completion_message", + "create_handoff_tools", + "get_handoff_tool_name", ] diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py b/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py index e574528395..7699319fcd 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py @@ -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 @@ -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 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" @@ -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: diff --git a/python/packages/orchestrations/tests/test_handoff.py b/python/packages/orchestrations/tests/test_handoff.py index 38ff6ea49a..a1b2b4f257 100644 --- a/python/packages/orchestrations/tests/test_handoff.py +++ b/python/packages/orchestrations/tests/test_handoff.py @@ -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]): @@ -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 diff --git a/python/samples/getting_started/workflows/README.md b/python/samples/getting_started/workflows/README.md index ce4aee4172..49c2226099 100644 --- a/python/samples/getting_started/workflows/README.md +++ b/python/samples/getting_started/workflows/README.md @@ -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 diff --git a/python/samples/getting_started/workflows/agents/handoff_with_azure_ai_agents.py b/python/samples/getting_started/workflows/agents/handoff_with_azure_ai_agents.py new file mode 100644 index 0000000000..ccab9bcff6 --- /dev/null +++ b/python/samples/getting_started/workflows/agents/handoff_with_azure_ai_agents.py @@ -0,0 +1,244 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from typing import Annotated, cast + +from agent_framework import ( + AgentResponse, + Message, + WorkflowEvent, + WorkflowRunState, + tool, +) +from agent_framework.azure import AzureAIProjectAgentProvider +from agent_framework.orchestrations import ( + HandoffAgentUserRequest, + HandoffBuilder, + create_handoff_tools, +) +from azure.identity.aio import AzureCliCredential + +"""Sample: Handoff Workflow with Azure AI Agent Service using Pre-Registered Tools. + +Azure AI Agent Service requires tools to be registered at agent creation time, not +dynamically at request time. This sample demonstrates how to use create_handoff_tools() +to pre-create handoff tools and pass them to provider.create_agent(), enabling the +handoff workflow pattern with Azure AI agents. + +Prerequisites: + - Azure AI Agent Service configured with required environment variables + (AZURE_AI_PROJECT_ENDPOINT, AZURE_AI_MODEL_DEPLOYMENT_NAME) + - `az login` (Azure CLI authentication) + +Key Concepts: + - create_handoff_tools(): Creates handoff tools upfront for agent creation + - Pre-registration pattern: Tools passed to provider.create_agent(tools=...) + - Duplicate handling: HandoffBuilder gracefully skips pre-registered tools + instead of raising ValueError +""" + + +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# See: +# samples/getting_started/tools/function_tool_with_approval.py +# samples/getting_started/tools/function_tool_with_approval_and_threads.py. +@tool(approval_mode="never_require") +def process_refund(order_number: Annotated[str, "Order number to process refund for"]) -> str: + """Simulated function to process a refund for a given order number.""" + return f"Refund processed successfully for order {order_number}." + + +@tool(approval_mode="never_require") +def check_order_status(order_number: Annotated[str, "Order number to check status for"]) -> str: + """Simulated function to check the status of a given order number.""" + return f"Order {order_number} is currently being processed and will ship in 2 business days." + + +def _handle_events(events: list[WorkflowEvent]) -> list[WorkflowEvent[HandoffAgentUserRequest]]: + """Process workflow events and extract any pending user input requests. + + Args: + events: List of WorkflowEvent to process + + Returns: + List of WorkflowEvent[HandoffAgentUserRequest] representing pending user input requests + """ + requests: list[WorkflowEvent[HandoffAgentUserRequest]] = [] + + for event in events: + if event.type == "handoff_sent": + print(f"\n[Handoff from {event.data.source} to {event.data.target} initiated.]") + elif event.type == "status" and event.state in { + WorkflowRunState.IDLE, + WorkflowRunState.IDLE_WITH_PENDING_REQUESTS, + }: + print(f"\n[Workflow Status] {event.state}") + elif event.type == "output": + data = event.data + if isinstance(data, AgentResponse): + for message in data.messages: + if not message.text: + continue + speaker = message.author_name or message.role + print(f"- {speaker}: {message.text}") + elif isinstance(data, list): + conversation = cast(list[Message], data) + print("\n=== Final Conversation Snapshot ===") + for message in conversation: + speaker = message.author_name or message.role + print(f"- {speaker}: {message.text or [content.type for content in message.contents]}") + print("===================================") + elif event.type == "request_info" and isinstance(event.data, HandoffAgentUserRequest): + _print_handoff_agent_user_request(event.data.agent_response) + requests.append(cast(WorkflowEvent[HandoffAgentUserRequest], event)) + + return requests + + +def _print_handoff_agent_user_request(response: AgentResponse) -> None: + """Display the agent's response messages when requesting user input.""" + if not response.messages: + raise RuntimeError("Cannot print agent responses: response has no messages.") + + print("\n[Agent is requesting your input...]") + for message in response.messages: + if not message.text: + continue + speaker = message.author_name or message.role + print(f"- {speaker}: {message.text}") + + +async def main() -> None: + """Main entry point for the Azure AI handoff workflow demo. + + This function demonstrates: + 1. Using create_handoff_tools() to pre-create handoff tools for Azure AI Agent Service + 2. Creating agents with pre-registered handoff tools via provider.create_agent() + 3. Building a handoff workflow where HandoffBuilder skips pre-registered tools + 4. Running the workflow with scripted user responses + """ + async with ( + AzureCliCredential() as credential, + AzureAIProjectAgentProvider(credential=credential) as provider, + ): + # ============================================================ + # KEY PATTERN: Pre-create handoff tools BEFORE creating agents + # ============================================================ + # Azure AI Agent Service requires tools at agent creation time. + # create_handoff_tools() generates the same tools that HandoffBuilder + # would auto-register, but upfront so they can be passed to + # provider.create_agent(). + # NOTE: Azure AI Agent Service requires agent names to use only + # alphanumeric characters and hyphens (no underscores). + specialist_ids = ["refund-agent", "order-agent"] + triage_handoff_tools = create_handoff_tools( + specialist_ids, + descriptions={ + "refund-agent": "Transfer to refund specialist for processing refunds.", + "order-agent": "Transfer to order specialist for shipping and order inquiries.", + }, + ) + + # Create triage agent with BOTH handoff tools and no domain tools. + # The handoff tools are pre-registered at creation time. + triage = await provider.create_agent( + instructions=( + "You are frontline support triage. Route customer issues to the appropriate specialist agents " + "based on the problem described." + ), + name="triage-agent", + tools=triage_handoff_tools, + ) + + # Create specialist agents with their domain-specific tools. + # Specialists don't need handoff tools pre-registered because they + # don't route to other agents in this example. + refund = await provider.create_agent( + instructions="You process refund requests.", + name="refund-agent", + tools=[process_refund], + ) + + order = await provider.create_agent( + instructions="You handle order and shipping inquiries.", + name="order-agent", + tools=[check_order_status], + ) + + # Build the handoff workflow. + # HandoffBuilder will detect that triage already has handoff tools + # pre-registered and skip them instead of raising ValueError. + workflow = ( + HandoffBuilder( + name="azure_ai_customer_support", + participants=[triage, refund, order], + termination_condition=lambda conversation: ( + len(conversation) > 0 and "welcome" in conversation[-1].text.lower() + ), + ) + .with_start_agent(triage) + .build() + ) + + # Scripted user responses for reproducible demo. + # In a real application, replace with actual user input collection. + scripted_responses = [ + "My order 1234 arrived damaged and I'd like a refund.", + "Thanks for resolving this.", + ] + + # Start the workflow with the initial user message + print("[Starting Azure AI handoff workflow with pre-registered tools...]\n") + initial_message = "Hello, I need assistance with my recent purchase." + print(f"- User: {initial_message}") + workflow_result = workflow.run(initial_message, stream=True) + pending_requests = _handle_events([event async for event in workflow_result]) + + # Process the request/response cycle + while pending_requests: + if not scripted_responses: + responses = {req.request_id: HandoffAgentUserRequest.terminate() for req in pending_requests} + else: + user_response = scripted_responses.pop(0) + print(f"\n- User: {user_response}") + responses = { + req.request_id: HandoffAgentUserRequest.create_response(user_response) for req in pending_requests + } + + events = await workflow.run(responses=responses) + pending_requests = _handle_events(events) + + """ + Sample Output: + + [Starting Azure AI handoff workflow with pre-registered tools...] + + - User: Hello, I need assistance with my recent purchase. + - triage-agent: Could you please provide more details about the issue? + + [Workflow Status] IDLE_WITH_PENDING_REQUESTS + + - User: My order 1234 arrived damaged and I'd like a refund. + + [Handoff from triage-agent to refund-agent initiated.] + - refund-agent: Refund processed successfully for order 1234. + + [Workflow Status] IDLE_WITH_PENDING_REQUESTS + + - User: Thanks for resolving this. + + === Final Conversation Snapshot === + - user: Hello, I need assistance with my recent purchase. + - triage-agent: Could you please provide more details about the issue? + - user: My order 1234 arrived damaged and I'd like a refund. + - refund-agent: Refund processed successfully for order 1234. + - user: Thanks for resolving this. + - triage-agent: You're welcome! Have a great day! + =================================== + + [Workflow Status] IDLE + """ # noqa: E501 + + +if __name__ == "__main__": + asyncio.run(main())