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
@@ -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())