Python: Add Handoff orchestration pattern support (#1469)

* Add Handoff orchestration pattern support

* PR feedback

* Use AOAI client in samples

* Adjust to tool

* Handoff to sub-agent via ai function

* PR feedback

* More cleanup

* Improvements

* PR feedback cleanup

* Add handoff migration sample.

* Remove type ignore

* fix markdown link formatting

* Remove readme link for non-existent sample
This commit is contained in:
Evan Mattson
2025-10-22 10:51:51 +09:00
committed by GitHub
Unverified
parent 4554de00ab
commit b66619a544
14 changed files with 2861 additions and 44 deletions
@@ -77,7 +77,7 @@ async def run_sequential_workflow() -> None:
print(f"Starting workflow with input: '{input_text}'")
output_event = None
async for event in workflow.run_stream(input_text):
async for event in workflow.run_stream("Hello world"):
if isinstance(event, WorkflowOutputEvent):
# The WorkflowOutputEvent contains the final result.
output_event = event
@@ -89,6 +89,8 @@ Once comfortable with these, explore the rest of the samples below.
| Concurrent Orchestration (Default Aggregator) | [orchestration/concurrent_agents.py](./orchestration/concurrent_agents.py) | Fan-out to multiple agents; fan-in with default aggregator returning combined ChatMessages |
| Concurrent Orchestration (Custom Aggregator) | [orchestration/concurrent_custom_aggregator.py](./orchestration/concurrent_custom_aggregator.py) | Override aggregator via callback; summarize results with an LLM |
| Concurrent Orchestration (Custom Agent Executors) | [orchestration/concurrent_custom_agent_executors.py](./orchestration/concurrent_custom_agent_executors.py) | Child executors own ChatAgents; concurrent fan-out/fan-in via ConcurrentBuilder |
| Handoff (Simple) | [orchestration/handoff_simple.py](./orchestration/handoff_simple.py) | Single-tier routing: triage agent routes to specialists, control returns to user after each specialist response |
| Handoff (Specialist-to-Specialist) | [orchestration/handoff_specialist_to_specialist.py](./orchestration/handoff_specialist_to_specialist.py) | Multi-tier routing: specialists can hand off to other specialists using `.add_handoff()` fluent API |
| Magentic Workflow (Multi-Agent) | [orchestration/magentic.py](./orchestration/magentic.py) | Orchestrate multiple agents with Magentic manager and streaming |
| Magentic + Human Plan Review | [orchestration/magentic_human_plan_update.py](./orchestration/magentic_human_plan_update.py) | Human reviews/updates the plan before execution |
| Magentic + Checkpoint Resume | [orchestration/magentic_checkpoint.py](./orchestration/magentic_checkpoint.py) | Resume Magentic orchestration from saved checkpoints |
@@ -97,6 +99,11 @@ Once comfortable with these, explore the rest of the samples below.
**Magentic checkpointing tip**: Treat `MagenticBuilder.participants` keys as stable identifiers. When resuming from a checkpoint, the rebuilt workflow must reuse the same participant names; otherwise the checkpoint cannot be applied and the run will fail fast.
**Handoff workflow tip**: Handoff workflows maintain the full conversation history including any
`ChatMessage.additional_properties` emitted by your agents. This ensures routing metadata remains
intact across all agent transitions. For specialist-to-specialist handoffs, use `.add_handoff(source, targets)`
to configure which agents can route to which others with a fluent, type-safe API.
### parallelism
| Sample | File | Concepts |
@@ -0,0 +1,337 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from collections.abc import AsyncIterable
from typing import cast
from agent_framework import (
ChatAgent,
ChatMessage,
HandoffBuilder,
HandoffUserInputRequest,
RequestInfoEvent,
WorkflowEvent,
WorkflowOutputEvent,
WorkflowRunState,
WorkflowStatusEvent,
)
from agent_framework.azure import AzureOpenAIChatClient
from azure.identity import AzureCliCredential
"""Sample: Simple handoff workflow with single-tier triage-to-specialist routing.
This sample demonstrates the basic handoff pattern where only the triage agent can
route to specialists. Specialists cannot hand off to other specialists - after any
specialist responds, control returns to the user for the next input.
Routing Pattern:
User → Triage Agent → Specialist → Back to User → Triage Agent → ...
This is the simplest handoff configuration, suitable for straightforward support
scenarios where a triage agent dispatches to domain specialists, and each specialist
works independently.
For multi-tier specialist-to-specialist handoffs, see handoff_specialist_to_specialist.py.
Prerequisites:
- `az login` (Azure CLI authentication)
- Environment variables configured for AzureOpenAIChatClient (AZURE_OPENAI_ENDPOINT, etc.)
Key Concepts:
- Single-tier routing: Only triage agent has handoff capabilities
- Auto-registered handoff tools: HandoffBuilder creates tools automatically
- Termination condition: Controls when the workflow stops requesting user input
- Request/response cycle: Workflow requests input, user responds, cycle continues
"""
def create_agents(chat_client: AzureOpenAIChatClient) -> tuple[ChatAgent, ChatAgent, ChatAgent, ChatAgent]:
"""Create and configure the triage and specialist agents.
The triage agent is responsible for:
- Receiving all user input first
- Deciding whether to handle the request directly or hand off to a specialist
- Signaling handoff by calling one of the explicit handoff tools exposed to it
Specialist agents are invoked only when the triage agent explicitly hands off to them.
After a specialist responds, control returns to the triage agent.
Returns:
Tuple of (triage_agent, refund_agent, order_agent, support_agent)
"""
# Triage agent: Acts as the frontline dispatcher
# NOTE: The instructions explicitly tell it to call the correct handoff tool when routing.
# The HandoffBuilder intercepts these tool calls and routes to the matching specialist.
triage = chat_client.create_agent(
instructions=(
"You are frontline support triage. Read the latest user message and decide whether "
"to hand off to refund_agent, order_agent, or support_agent. Provide a brief natural-language "
"response for the user. When delegation is required, call the matching handoff tool "
"(`handoff_to_refund_agent`, `handoff_to_order_agent`, or `handoff_to_support_agent`)."
),
name="triage_agent",
)
# Refund specialist: Handles refund requests
refund = chat_client.create_agent(
instructions=(
"You handle refund workflows. Ask for any order identifiers you require and outline the refund steps."
),
name="refund_agent",
)
# Order/shipping specialist: Resolves delivery issues
order = chat_client.create_agent(
instructions=(
"You resolve shipping and fulfillment issues. Clarify the delivery problem and describe the actions "
"you will take to remedy it."
),
name="order_agent",
)
# General support specialist: Fallback for other issues
support = chat_client.create_agent(
instructions=(
"You are a general support agent. Offer empathetic troubleshooting and gather missing details if the "
"issue does not match other specialists."
),
name="support_agent",
)
return triage, refund, order, support
async def _drain(stream: AsyncIterable[WorkflowEvent]) -> list[WorkflowEvent]:
"""Collect all events from an async stream into a list.
This helper drains the workflow's event stream so we can process events
synchronously after each workflow step completes.
Args:
stream: Async iterable of WorkflowEvent
Returns:
List of all events from the stream
"""
return [event async for event in stream]
def _handle_events(events: list[WorkflowEvent]) -> list[RequestInfoEvent]:
"""Process workflow events and extract any pending user input requests.
This function inspects each event type and:
- Prints workflow status changes (IDLE, IDLE_WITH_PENDING_REQUESTS, etc.)
- Displays final conversation snapshots when workflow completes
- Prints user input request prompts
- Collects all RequestInfoEvent instances for response handling
Args:
events: List of WorkflowEvent to process
Returns:
List of RequestInfoEvent representing pending user input requests
"""
requests: list[RequestInfoEvent] = []
for event in events:
# WorkflowStatusEvent: Indicates workflow state changes
if isinstance(event, WorkflowStatusEvent) and event.state in {
WorkflowRunState.IDLE,
WorkflowRunState.IDLE_WITH_PENDING_REQUESTS,
}:
print(f"[status] {event.state.name}")
# WorkflowOutputEvent: Contains the final conversation when workflow terminates
elif isinstance(event, WorkflowOutputEvent):
conversation = cast(list[ChatMessage], event.data)
if isinstance(conversation, list):
print("\n=== Final Conversation Snapshot ===")
for message in conversation:
speaker = message.author_name or message.role.value
print(f"- {speaker}: {message.text}")
print("===================================")
# RequestInfoEvent: Workflow is requesting user input
elif isinstance(event, RequestInfoEvent):
if isinstance(event.data, HandoffUserInputRequest):
_print_handoff_request(event.data)
requests.append(event)
return requests
def _print_handoff_request(request: HandoffUserInputRequest) -> None:
"""Display a user input request prompt with conversation context.
The HandoffUserInputRequest contains the full conversation history so far,
allowing the user to see what's been discussed before providing their next input.
Args:
request: The user input request containing conversation and prompt
"""
print("\n=== User Input Requested ===")
for message in request.conversation:
speaker = message.author_name or message.role.value
print(f"- {speaker}: {message.text}")
print("============================")
async def main() -> None:
"""Main entry point for the handoff workflow demo.
This function demonstrates:
1. Creating triage and specialist agents
2. Building a handoff workflow with custom termination condition
3. Running the workflow with scripted user responses
4. Processing events and handling user input requests
The workflow uses scripted responses instead of interactive input to make
the demo reproducible and testable. In a production application, you would
replace the scripted_responses with actual user input collection.
"""
# Initialize the Azure OpenAI chat client
chat_client = AzureOpenAIChatClient(credential=AzureCliCredential())
# Create all agents: triage + specialists
triage, refund, order, support = create_agents(chat_client)
# Build the handoff workflow
# - participants: All agents that can participate (triage MUST be first or explicitly set as set_coordinator)
# - set_coordinator: The triage agent receives all user input first
# - with_termination_condition: Custom logic to stop the request/response loop
# Default is 10 user messages; here we terminate after 4 to match our scripted demo
workflow = (
HandoffBuilder(
name="customer_support_handoff",
participants=[triage, refund, order, support],
)
.set_coordinator("triage_agent")
.with_termination_condition(
# Terminate after 4 user messages (initial + 3 scripted responses)
# Count only USER role messages to avoid counting agent responses
lambda conv: sum(1 for msg in conv if msg.role.value == "user") >= 4
)
.build()
)
# Scripted user responses for reproducible demo
# In a console application, replace this with:
# user_input = input("Your response: ")
# or integrate with a UI/chat interface
scripted_responses = [
"My order 1234 arrived damaged and the packaging was destroyed.",
"Yes, I'd like a refund if that's possible.",
"Thanks for resolving this.",
]
# Start the workflow with the initial user message
# run_stream() returns an async iterator of WorkflowEvent
print("\n[Starting workflow with initial user message...]")
events = await _drain(workflow.run_stream("Hello, I need assistance with my recent purchase."))
pending_requests = _handle_events(events)
# Process the request/response cycle
# The workflow will continue requesting input until:
# 1. The termination condition is met (4 user messages in this case), OR
# 2. We run out of scripted responses
while pending_requests and scripted_responses:
# Get the next scripted response
user_response = scripted_responses.pop(0)
print(f"\n[User responding: {user_response}]")
# Send response(s) to all pending requests
# In this demo, there's typically one request per cycle, but the API supports multiple
responses = {req.request_id: user_response for req in pending_requests}
# Send responses and get new events
events = await _drain(workflow.send_responses_streaming(responses))
pending_requests = _handle_events(events)
"""
Sample Output:
[Starting workflow with initial user message...]
=== User Input Requested ===
- user: Hello, I need assistance with my recent purchase.
- triage_agent: I'd be happy to help you with your recent purchase. Could you please provide more details about the issue you're experiencing?
============================
[status] IDLE_WITH_PENDING_REQUESTS
[User responding: My order 1234 arrived damaged and the packaging was destroyed.]
=== User Input Requested ===
- user: Hello, I need assistance with my recent purchase.
- triage_agent: I'd be happy to help you with your recent purchase. Could you please provide more details about the issue you're experiencing?
- user: My order 1234 arrived damaged and the packaging was destroyed.
- triage_agent: I'm sorry to hear that your order arrived damaged and the packaging was destroyed. I will connect you with a specialist who can assist you further with this issue.
Tool Call: handoff_to_support_agent (awaiting approval)
- support_agent: I'm so sorry to hear that your order arrived in such poor condition. I'll help you get this sorted out.
To assist you better, could you please let me know:
- Which item(s) from order 1234 arrived damaged?
- Could you describe the damage, or provide photos if possible?
- Would you prefer a replacement or a refund?
Once I have this information, I can help resolve this for you as quickly as possible.
============================
[status] IDLE_WITH_PENDING_REQUESTS
[User responding: Yes, I'd like a refund if that's possible.]
=== User Input Requested ===
- user: Hello, I need assistance with my recent purchase.
- triage_agent: I'd be happy to help you with your recent purchase. Could you please provide more details about the issue you're experiencing?
- user: My order 1234 arrived damaged and the packaging was destroyed.
- triage_agent: I'm sorry to hear that your order arrived damaged and the packaging was destroyed. I will connect you with a specialist who can assist you further with this issue.
Tool Call: handoff_to_support_agent (awaiting approval)
- support_agent: I'm so sorry to hear that your order arrived in such poor condition. I'll help you get this sorted out.
To assist you better, could you please let me know:
- Which item(s) from order 1234 arrived damaged?
- Could you describe the damage, or provide photos if possible?
- Would you prefer a replacement or a refund?
Once I have this information, I can help resolve this for you as quickly as possible.
- user: Yes, I'd like a refund if that's possible.
- triage_agent: Thank you for letting me know you'd prefer a refund. I'll connect you with a specialist who can process your refund request.
Tool Call: handoff_to_refund_agent (awaiting approval)
- refund_agent: Thank you for confirming that you'd like a refund for order 1234.
Here's what will happen next:
...
Tool Call: handoff_to_refund_agent (awaiting approval)
- refund_agent: Thank you for confirming that you'd like a refund for order 1234.
Here's what will happen next:
**1. Verification:**
I will need to verify a few more details to proceed.
- Can you confirm the items in order 1234 that arrived damaged?
- Do you have any photos of the damaged items/packaging? (Photos help speed up the process.)
**2. Refund Request Submission:**
- Once I have the details, I will submit your refund request for review.
**3. Return Instructions (if needed):**
- In some cases, we may provide instructions on how to return the damaged items.
- You will receive a prepaid return label if necessary.
**4. Refund Processing:**
- After your request is approved (and any returns are received if required), your refund will be processed.
- Refunds usually appear on your original payment method within 5-10 business days.
Could you please reply with the specific item(s) damaged and, if possible, attach photos? This will help me get your refund started right away.
- user: Thanks for resolving this.
===================================
[status] IDLE
""" # noqa: E501
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,286 @@
# Copyright (c) Microsoft. All rights reserved.
"""Sample: Multi-tier handoff workflow with specialist-to-specialist routing.
This sample demonstrates advanced handoff routing where specialist agents can hand off
to other specialists, enabling complex multi-tier workflows. Unlike the simple handoff
pattern (see handoff_simple.py), specialists here can delegate to other specialists
without returning control to the user until the specialist chain completes.
Routing Pattern:
User → Triage → Specialist A → Specialist B → Back to User
This pattern is useful for complex support scenarios where different specialists need
to collaborate or escalate to each other before returning to the user. For example:
- Replacement agent needs shipping info → hands off to delivery agent
- Technical support needs billing info → hands off to billing agent
- Level 1 support escalates to Level 2 → hands off to escalation agent
Configuration uses `.add_handoff()` to explicitly define the routing graph.
Prerequisites:
- `az login` (Azure CLI authentication)
- Environment variables configured for AzureOpenAIChatClient
"""
import asyncio
from collections.abc import AsyncIterable
from typing import cast
from agent_framework import (
ChatMessage,
HandoffBuilder,
HandoffUserInputRequest,
RequestInfoEvent,
WorkflowEvent,
WorkflowOutputEvent,
WorkflowRunState,
WorkflowStatusEvent,
)
from agent_framework.azure import AzureOpenAIChatClient
from azure.identity import AzureCliCredential
def create_agents(chat_client: AzureOpenAIChatClient):
"""Create triage and specialist agents with multi-tier handoff capabilities.
Returns:
Tuple of (triage_agent, replacement_agent, delivery_agent, billing_agent)
"""
triage = chat_client.create_agent(
instructions=(
"You are a customer support triage agent. Assess the user's issue and route appropriately:\n"
"- For product replacement issues: call handoff_to_replacement_agent\n"
"- For delivery/shipping inquiries: call handoff_to_delivery_agent\n"
"- For billing/payment issues: call handoff_to_billing_agent\n"
"Be concise and friendly."
),
name="triage_agent",
)
replacement = chat_client.create_agent(
instructions=(
"You handle product replacement requests. Ask for order number and reason for replacement.\n"
"If the user also needs shipping/delivery information, call handoff_to_delivery_agent to "
"get tracking details. Otherwise, process the replacement and confirm with the user.\n"
"Be concise and helpful."
),
name="replacement_agent",
)
delivery = chat_client.create_agent(
instructions=(
"You handle shipping and delivery inquiries. Provide tracking information, estimated "
"delivery dates, and address any delivery concerns.\n"
"If billing issues come up, call handoff_to_billing_agent.\n"
"Be concise and clear."
),
name="delivery_agent",
)
billing = chat_client.create_agent(
instructions=(
"You handle billing and payment questions. Help with refunds, payment methods, "
"and invoice inquiries. Be concise."
),
name="billing_agent",
)
return triage, replacement, delivery, billing
async def _drain(stream: AsyncIterable[WorkflowEvent]) -> list[WorkflowEvent]:
"""Collect all events from an async stream into a list."""
return [event async for event in stream]
def _handle_events(events: list[WorkflowEvent]) -> list[RequestInfoEvent]:
"""Process workflow events and extract pending user input requests."""
requests: list[RequestInfoEvent] = []
for event in events:
if isinstance(event, WorkflowStatusEvent) and event.state in {
WorkflowRunState.IDLE,
WorkflowRunState.IDLE_WITH_PENDING_REQUESTS,
}:
print(f"[status] {event.state.name}")
elif isinstance(event, WorkflowOutputEvent):
conversation = cast(list[ChatMessage], event.data)
if isinstance(conversation, list):
print("\n=== Final Conversation ===")
for message in conversation:
# Filter out messages with no text (tool calls)
if not message.text.strip():
continue
speaker = message.author_name or message.role.value
print(f"- {speaker}: {message.text}")
print("==========================")
elif isinstance(event, RequestInfoEvent):
if isinstance(event.data, HandoffUserInputRequest):
_print_handoff_request(event.data)
requests.append(event)
return requests
def _print_handoff_request(request: HandoffUserInputRequest) -> None:
"""Display a user input request with conversation context."""
print("\n=== User Input Requested ===")
# Filter out messages with no text for cleaner display
messages_with_text = [msg for msg in request.conversation if msg.text.strip()]
print(f"Last {len(messages_with_text)} messages in conversation:")
for message in messages_with_text[-5:]: # Show last 5 for brevity
speaker = message.author_name or message.role.value
text = message.text[:100] + "..." if len(message.text) > 100 else message.text
print(f" {speaker}: {text}")
print("============================")
async def main() -> None:
"""Demonstrate specialist-to-specialist handoffs in a multi-tier support scenario.
This sample shows:
1. Triage agent routes to replacement specialist
2. Replacement specialist hands off to delivery specialist
3. Delivery specialist can hand off to billing if needed
4. All transitions are seamless without returning to user until complete
The workflow configuration explicitly defines which agents can hand off to which others:
- triage_agent → replacement_agent, delivery_agent, billing_agent
- replacement_agent → delivery_agent, billing_agent
- delivery_agent → billing_agent
"""
chat_client = AzureOpenAIChatClient(credential=AzureCliCredential())
triage, replacement, delivery, billing = create_agents(chat_client)
# Configure multi-tier handoffs using fluent add_handoff() API
# This allows specialists to hand off to other specialists
workflow = (
HandoffBuilder(
name="multi_tier_support",
participants=[triage, replacement, delivery, billing],
)
.set_coordinator(triage)
.add_handoff(triage, [replacement, delivery, billing]) # Triage can route to any specialist
.add_handoff(replacement, [delivery, billing]) # Replacement can delegate to delivery or billing
.add_handoff(delivery, billing) # Delivery can escalate to billing
# Termination condition: Stop when more than 4 user messages exist.
# This allows agents to respond to the 4th user message before the 5th triggers termination.
# In this sample: initial message + 3 scripted responses = 4 messages, then 5th message ends workflow.
.with_termination_condition(lambda conv: sum(1 for msg in conv if msg.role.value == "user") > 4)
.build()
)
# Scripted user responses simulating a multi-tier handoff scenario
# Note: The initial run_stream() call sends the first user message,
# then these scripted responses are sent in sequence (total: 4 user messages).
# A 5th response triggers termination after agents respond to the 4th message.
scripted_responses = [
"I need help with order 12345. I want a replacement and need to know when it will arrive.",
"The item arrived damaged. I'd like a replacement shipped to the same address.",
"Great! Can you confirm the shipping cost won't be charged again?",
"Thank you!", # Final response to trigger termination after billing agent answers
]
print("\n" + "=" * 80)
print("SPECIALIST-TO-SPECIALIST HANDOFF DEMONSTRATION")
print("=" * 80)
print("\nScenario: Customer needs replacement + shipping info + billing confirmation")
print("Expected flow: User → Triage → Replacement → Delivery → Billing → User")
print("=" * 80 + "\n")
# Start workflow with initial message
print("[User]: I need help with order 12345. I want a replacement and need to know when it will arrive.\n")
events = await _drain(
workflow.run_stream("I need help with order 12345. I want a replacement and need to know when it will arrive.")
)
pending_requests = _handle_events(events)
# Process scripted responses
response_index = 0
while pending_requests and response_index < len(scripted_responses):
user_response = scripted_responses[response_index]
print(f"\n[User]: {user_response}\n")
responses = {req.request_id: user_response for req in pending_requests}
events = await _drain(workflow.send_responses_streaming(responses))
pending_requests = _handle_events(events)
response_index += 1
"""
Sample Output:
================================================================================
SPECIALIST-TO-SPECIALIST HANDOFF DEMONSTRATION
================================================================================
Scenario: Customer needs replacement + shipping info + billing confirmation
Expected flow: User → Triage → Replacement → Delivery → Billing → User
================================================================================
[User]: I need help with order 12345. I want a replacement and need to know when it will arrive.
=== User Input Requested ===
Last 5 messages in conversation:
user: I need help with order 12345. I want a replacement and need to know when it will arrive.
triage_agent: I'm connecting you to our replacement team to assist with your request, and to our delivery team for...
replacement_agent: To assist with your replacement for order 12345 and provide tracking details for delivery, I've reac...
delivery_agent: I'm handing over your request for a replacement of order 12345, as well as your inquiry about estima...
billing_agent: I handle billing and payment questions. For replacement and delivery details for order 12345, please...
============================
[status] IDLE_WITH_PENDING_REQUESTS
[User]: I need help with order 12345. I want a replacement and need to know when it will arrive.
=== User Input Requested ===
Last 7 messages in conversation:
replacement_agent: To assist with your replacement for order 12345 and provide tracking details for delivery, I've reac...
delivery_agent: I'm handing over your request for a replacement of order 12345, as well as your inquiry about estima...
billing_agent: I handle billing and payment questions. For replacement and delivery details for order 12345, please...
user: I need help with order 12345. I want a replacement and need to know when it will arrive.
triage_agent: I'm connecting you with our replacement team to help with your request, and our delivery team for in...
============================
[status] IDLE_WITH_PENDING_REQUESTS
[User]: The item arrived damaged. I'd like a replacement shipped to the same address.
=== User Input Requested ===
Last 12 messages in conversation:
user: The item arrived damaged. I'd like a replacement shipped to the same address.
triage_agent: Thank you for letting us know your item from order 12345 arrived damaged. Im connecting you to our ...
replacement_agent: Your replacement request for order 12345 (damaged item) will be processed, and shipped to your curre...
delivery_agent: I've forwarded your request for a replacement of the damaged item from order 12345 to our team and a...
billing_agent: Thank you for the information. I've forwarded your request to our support team to arrange a replacem...
============================
[status] IDLE_WITH_PENDING_REQUESTS
[User]: Great! Can you confirm the shipping cost won't be charged again?
=== Final Conversation ===
- user: I need help with order 12345. I want a replacement and need to know when it will arrive.
- triage_agent: I'm connecting you to our replacement team to assist with your request, and to our delivery team for updates on when your replacement will arrive. They'll help you shortly!
- replacement_agent: To assist with your replacement for order 12345 and provide tracking details for delivery, I've reached out to our teams. Can you please provide the reason for your replacement request? This will help to process things faster.
- delivery_agent: I'm handing over your request for a replacement of order 12345, as well as your inquiry about estimated delivery timing, to the appropriate team. They'll assist you further with the replacement and delivery details very soon.
- billing_agent: I handle billing and payment questions. For replacement and delivery details for order 12345, please contact our support team or the order department. Is there a billing or payment issue I can help you with?
- user: I need help with order 12345. I want a replacement and need to know when it will arrive.
- triage_agent: I'm connecting you with our replacement team to help with your request, and our delivery team for information about when your replacement will arrive. They'll assist you shortly!
- user: The item arrived damaged. I'd like a replacement shipped to the same address.
- triage_agent: Thank you for letting us know your item from order 12345 arrived damaged. I'm connecting you to our replacement team to arrange a replacement shipped to your address, and to our delivery team to update you on the arrival date of your replacement. They'll assist you shortly!
- replacement_agent: Your replacement request for order 12345 (damaged item) will be processed, and shipped to your current address. The delivery team will also provide tracking and estimated arrival details for your new item soon.
- delivery_agent: I've forwarded your request for a replacement of the damaged item from order 12345 to our team and asked them to ship it to your current address. You'll also receive tracking information and an estimated delivery date for the replacement soon.
- billing_agent: Thank you for the information. I've forwarded your request to our support team to arrange a replacement for the damaged item from order 12345. Your replacement will be shipped to the same address, and you'll receive delivery updates soon. If you need a refund instead or have any billing questions, please let me know.
- user: Great! Can you confirm the shipping cost won't be charged again?
==========================
[status] IDLE
""" # noqa: E501
if __name__ == "__main__":
asyncio.run(main())
@@ -4,13 +4,40 @@
This gallery helps Semantic Kernel (SK) developers move to the Microsoft Agent Framework (AF) with minimal guesswork. Each script pairs SK code with its AF equivalent so you can compare primitives, tooling, and orchestration patterns side by side while you migrate production workloads.
## Whats Included
- `chat_completion/` SK `ChatCompletionAgent` scenarios and their AF `ChatAgent` counterparts (basic chat, tooling, threading/streaming).
- `azure_ai_agent/` Remote Azure AI agent examples, including hosted code interpreter and explicit thread reuse.
- `openai_assistant/` Assistants API migrations covering basic usage, code interpreter, and custom function tools.
- `openai_responses/` Responses API parity samples with tooling and structured JSON output.
- `copilot_studio/` Copilot Studio agent parity, tools, and streaming examples.
- `orchestrations/` Sequential, Concurrent, and Magentic workflow migrations that mirror SK Team abstractions.
- `processes/` Fan-out/fan-in and nested process examples that contrast SKs Process Framework with AF workflows.
### Chat completion parity
- [01_basic_chat_completion.py](chat_completion/01_basic_chat_completion.py) — Minimal SK `ChatCompletionAgent` and AF `ChatAgent` conversation.
- [02_chat_completion_with_tool.py](chat_completion/02_chat_completion_with_tool.py) — Adds a simple tool/function call in both SDKs.
- [03_chat_completion_thread_and_stream.py](chat_completion/03_chat_completion_thread_and_stream.py) — Demonstrates thread reuse and streaming prompts.
### Azure AI agent parity
- [01_basic_azure_ai_agent.py](azure_ai_agent/01_basic_azure_ai_agent.py) — Create and run an Azure AI agent end to end.
- [02_azure_ai_agent_with_code_interpreter.py](azure_ai_agent/02_azure_ai_agent_with_code_interpreter.py) — Enable hosted code interpreter/tool execution.
- [03_azure_ai_agent_threads_and_followups.py](azure_ai_agent/03_azure_ai_agent_threads_and_followups.py) — Persist threads and follow-ups across invocations.
### OpenAI Assistants API parity
- [01_basic_openai_assistant.py](openai_assistant/01_basic_openai_assistant.py) — Baseline assistant comparison.
- [02_openai_assistant_with_code_interpreter.py](openai_assistant/02_openai_assistant_with_code_interpreter.py) — Code interpreter tool usage.
- [03_openai_assistant_function_tool.py](openai_assistant/03_openai_assistant_function_tool.py) — Custom function tooling.
### OpenAI Responses API parity
- [01_basic_responses_agent.py](openai_responses/01_basic_responses_agent.py) — Basic responses agent migration.
- [02_responses_agent_with_tool.py](openai_responses/02_responses_agent_with_tool.py) — Tool-augmented responses workflows.
- [03_responses_agent_structured_output.py](openai_responses/03_responses_agent_structured_output.py) — Structured JSON output alignment.
### Copilot Studio parity
- [01_basic_copilot_studio_agent.py](copilot_studio/01_basic_copilot_studio_agent.py) — Minimal Copilot Studio agent invocation.
- [02_copilot_studio_streaming.py](copilot_studio/02_copilot_studio_streaming.py) — Streaming responses from Copilot Studio agents.
### Orchestrations
- [sequential.py](orchestrations/sequential.py) — Step-by-step SK Team → AF `SequentialBuilder` migration.
- [concurrent_basic.py](orchestrations/concurrent_basic.py) — Concurrent orchestration parity.
- [handoff.py](orchestrations/handoff.py) — Support triage handoff migration with specialist routing.
- [magentic.py](orchestrations/magentic.py) — Magentic Team orchestration vs. AF builder wiring.
### Processes
- [fan_out_fan_in_process.py](processes/fan_out_fan_in_process.py) — Fan-out/fan-in comparison between SK Process Framework and AF workflows.
- [nested_process.py](processes/nested_process.py) — Nested process orchestration vs. AF sub-workflows.
Each script is fully async and the `main()` routine runs both implementations back to back so you can observe their outputs in a single execution.
@@ -23,14 +50,14 @@ Each script is fully async and the `main()` routine runs both implementations ba
## Running Single-Agent Samples
From the repository root:
```
python samantic-kernel-migration/chat_completion/01_basic_chat_completion.py
python samples/semantic-kernel-migration/chat_completion/01_basic_chat_completion.py
```
Every script accepts no CLI arguments and will first call the SK implementation, followed by the AF version. Adjust the prompt or credentials inside the file as necessary before running.
## Running Orchestration & Workflow Samples
Advanced comparisons are split between `samantic-kernel-migration/orchestrations` (Sequential, Concurrent, Magentic) and `samantic-kernel-migration/processes` (fan-out/fan-in, nested). You can run them directly, or isolate dependencies in a throwaway virtual environment:
Advanced comparisons are split between `samples/semantic-kernel-migration/orchestrations` (Sequential, Concurrent, Group Chat, Handoff, Magentic) and `samples/semantic-kernel-migration/processes` (fan-out/fan-in, nested). You can run them directly, or isolate dependencies in a throwaway virtual environment:
```
cd samantic-kernel-migration
cd samples/semantic-kernel-migration
uv venv --python 3.10 .venv-migration
source .venv-migration/bin/activate
uv pip install semantic-kernel agent-framework
@@ -0,0 +1,297 @@
# Copyright (c) Microsoft. All rights reserved.
"""Side-by-side handoff orchestrations for Semantic Kernel and Agent Framework."""
from __future__ import annotations
import asyncio
import sys
from collections.abc import AsyncIterable, Sequence
from typing import Any, cast
from collections.abc import Iterator
from agent_framework import (
ChatMessage,
HandoffBuilder,
HandoffUserInputRequest,
RequestInfoEvent,
WorkflowEvent,
WorkflowOutputEvent,
)
from agent_framework.azure import AzureOpenAIChatClient
from azure.identity import AzureCliCredential
from semantic_kernel.agents import Agent, ChatCompletionAgent, HandoffOrchestration, OrchestrationHandoffs
from semantic_kernel.agents.runtime import InProcessRuntime
from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion
from semantic_kernel.contents import (
AuthorRole,
ChatMessageContent,
FunctionCallContent,
FunctionResultContent,
StreamingChatMessageContent,
)
from semantic_kernel.functions import KernelArguments, kernel_function
from semantic_kernel.prompt_template import KernelPromptTemplate, PromptTemplateConfig
if sys.version_info >= (3, 12):
from typing import override # pragma: no cover
else:
from typing_extensions import override # pragma: no cover
CUSTOMER_PROMPT = "I need help with order 12345. I want a replacement and need to know when it will arrive."
SCRIPTED_RESPONSES = [
"The item arrived damaged. I'd like a replacement shipped to the same address.",
"Great! Can you confirm the shipping cost won't be charged again?",
"Thanks for confirming!",
]
######################################################################
# Semantic Kernel orchestration path
######################################################################
class OrderStatusPlugin:
@kernel_function
def check_order_status(self, order_id: str) -> str:
return f"Order {order_id} is shipped and will arrive in 2-3 days."
class OrderRefundPlugin:
@kernel_function
def process_refund(self, order_id: str, reason: str) -> str:
return f"Refund for order {order_id} has been processed successfully (reason: {reason})."
class OrderReturnPlugin:
@kernel_function
def process_return(self, order_id: str, reason: str) -> str:
return f"Return for order {order_id} has been processed successfully (reason: {reason})."
def build_semantic_kernel_agents() -> tuple[list[Agent], OrchestrationHandoffs]:
credential = AzureCliCredential()
triage = ChatCompletionAgent(
name="TriageAgent",
description="Customer support triage specialist.",
instructions="Greet the customer, collect intent, and hand off to the right specialist.",
service=AzureChatCompletion(credential=credential),
)
refund = ChatCompletionAgent(
name="RefundAgent",
description="Handles refunds.",
instructions="Process refund requests.",
service=AzureChatCompletion(credential=credential),
plugins=[OrderRefundPlugin()],
)
order_status = ChatCompletionAgent(
name="OrderStatusAgent",
description="Looks up order status.",
instructions="Provide shipping timelines and tracking information.",
service=AzureChatCompletion(credential=credential),
plugins=[OrderStatusPlugin()],
)
order_return = ChatCompletionAgent(
name="OrderReturnAgent",
description="Handles returns.",
instructions="Coordinate order returns.",
service=AzureChatCompletion(credential=credential),
plugins=[OrderReturnPlugin()],
)
handoffs = (
OrchestrationHandoffs()
.add_many(
source_agent=triage.name,
target_agents={
refund.name: "Route refund-related requests here.",
order_status.name: "Route shipping questions here.",
order_return.name: "Route return-related requests here.",
},
)
.add(refund.name, triage.name, "Return to triage for non-refund issues.")
.add(order_status.name, triage.name, "Return to triage for non-status issues.")
.add(order_return.name, triage.name, "Return to triage for non-return issues.")
)
return [triage, refund, order_status, order_return], handoffs
_sk_new_message = True
def _sk_streaming_callback(message: StreamingChatMessageContent, is_final: bool) -> None:
"""Display SK agent messages as they stream."""
global _sk_new_message
if _sk_new_message:
print(f"{message.name}: ", end="", flush=True)
_sk_new_message = False
if message.content:
print(message.content, end="", flush=True)
for item in message.items:
if isinstance(item, FunctionCallContent):
print(f"[tool call: {item.name}({item.arguments})]", end="", flush=True)
if isinstance(item, FunctionResultContent):
print(f"[tool result: {item.result}]", end="", flush=True)
if is_final:
print()
_sk_new_message = True
def _make_sk_human_responder(script: Iterator[str]) -> callable:
def _responder() -> ChatMessageContent:
try:
user_text = next(script)
except StopIteration:
user_text = "Thanks, that's all."
print(f"[User]: {user_text}")
return ChatMessageContent(role=AuthorRole.USER, content=user_text)
return _responder
async def run_semantic_kernel_example(initial_task: str, scripted_responses: Sequence[str]) -> str:
agents, handoffs = build_semantic_kernel_agents()
response_iter = iter(scripted_responses)
orchestration = HandoffOrchestration(
members=agents,
handoffs=handoffs,
streaming_agent_response_callback=_sk_streaming_callback,
human_response_function=_make_sk_human_responder(response_iter),
)
runtime = InProcessRuntime()
runtime.start()
try:
orchestration_result = await orchestration.invoke(task=initial_task, runtime=runtime)
final_message = await orchestration_result.get(timeout=30)
if isinstance(final_message, ChatMessageContent):
return final_message.content or ""
return str(final_message)
finally:
await runtime.stop_when_idle()
######################################################################
# Agent Framework orchestration path
######################################################################
def _create_af_agents(client: AzureOpenAIChatClient):
triage = client.create_agent(
name="triage_agent",
instructions=(
"You are a customer support triage agent. Route requests:\n"
"- handoff_to_refund_agent for refunds\n"
"- handoff_to_order_status_agent for shipping/timeline questions\n"
"- handoff_to_order_return_agent for returns"
),
)
refund = client.create_agent(
name="refund_agent",
instructions=(
"Handle refunds. Ask for order id and reason. If shipping info is needed, hand off to order_status_agent."
),
)
status = client.create_agent(
name="order_status_agent",
instructions=(
"Provide order status, tracking, and timelines. If billing questions appear, hand off to refund_agent."
),
)
returns = client.create_agent(
name="order_return_agent",
instructions=(
"Coordinate returns, confirm addresses, and summarize next steps. Hand off to triage_agent if unsure."
),
)
return triage, refund, status, returns
async def _drain_events(stream: AsyncIterable[WorkflowEvent]) -> list[WorkflowEvent]:
return [event async for event in stream]
def _collect_handoff_requests(events: list[WorkflowEvent]) -> list[RequestInfoEvent]:
requests: list[RequestInfoEvent] = []
for event in events:
if isinstance(event, RequestInfoEvent) and isinstance(event.data, HandoffUserInputRequest):
requests.append(event)
return requests
def _extract_final_conversation(events: list[WorkflowEvent]) -> list[ChatMessage]:
for event in events:
if isinstance(event, WorkflowOutputEvent):
data = cast(list[ChatMessage], event.data)
return data
return []
async def run_agent_framework_example(initial_task: str, scripted_responses: Sequence[str]) -> str:
client = AzureOpenAIChatClient(credential=AzureCliCredential())
triage, refund, status, returns = _create_af_agents(client)
workflow = (
HandoffBuilder(name="sk_af_handoff_migration", participants=[triage, refund, status, returns])
.set_coordinator(triage)
.add_handoff(triage, [refund, status, returns])
.add_handoff(refund, [status, triage])
.add_handoff(status, [refund, triage])
.add_handoff(returns, triage)
.build()
)
events = await _drain_events(workflow.run_stream(initial_task))
pending = _collect_handoff_requests(events)
scripted_iter = iter(scripted_responses)
final_events = events
while pending:
try:
user_reply = next(scripted_iter)
except StopIteration:
user_reply = "Thanks, that's all."
responses = {request.request_id: user_reply for request in pending}
final_events = await _drain_events(workflow.send_responses_streaming(responses))
pending = _collect_handoff_requests(final_events)
conversation = _extract_final_conversation(final_events)
if not conversation:
return ""
# Render final transcript succinctly.
lines = []
for message in conversation:
text = message.text or ""
if not text.strip():
continue
speaker = message.author_name or message.role.value
lines.append(f"{speaker}: {text}")
return "\n".join(lines)
######################################################################
# Console entry point
######################################################################
async def main() -> None:
print("===== Agent Framework Handoff =====")
af_transcript = await run_agent_framework_example(CUSTOMER_PROMPT, SCRIPTED_RESPONSES)
print(af_transcript or "No output produced.")
print()
print("===== Semantic Kernel Handoff =====")
sk_result = await run_semantic_kernel_example(CUSTOMER_PROMPT, SCRIPTED_RESPONSES)
print(sk_result or "No output produced.")
if __name__ == "__main__":
asyncio.run(main())