mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
[BREAKING] Python: Add factory pattern to handoff orchestration builder (#2844)
* WIP: Factory pattern to handoff * Add factory pattern to concurrent orchestration builder; Next: tests and sample verification * Add tests and improve comments * Fix mypy * Simplify handoff_simple.py * Simplify handoff_autonoumous.py and bug fix * Update readme * Address Copilot comments
This commit is contained in:
committed by
GitHub
Unverified
parent
2bde58f915
commit
8fca71e5ad
@@ -86,7 +86,6 @@ Once comfortable with these, explore the rest of the samples below.
|
||||
| ConcurrentBuilder Request Info | [human-in-the-loop/concurrent_request_info.py](./human-in-the-loop/concurrent_request_info.py) | Review concurrent agent outputs before aggregation using `.with_request_info()` on ConcurrentBuilder |
|
||||
| GroupChatBuilder Request Info | [human-in-the-loop/group_chat_request_info.py](./human-in-the-loop/group_chat_request_info.py) | Steer group discussions with periodic guidance using `.with_request_info()` on GroupChatBuilder |
|
||||
|
||||
|
||||
### tool-approval
|
||||
|
||||
Tool approval samples demonstrate using `@ai_function(approval_mode="always_require")` to gate sensitive tool executions with human approval. These work with the high-level builder APIs.
|
||||
@@ -120,6 +119,7 @@ For additional observability samples in Agent Framework, see the [observability
|
||||
| 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 |
|
||||
| Handoff (Return-to-Previous) | [orchestration/handoff_return_to_previous.py](./orchestration/handoff_return_to_previous.py) | Return-to-previous routing: after user input, routes back to the previous specialist instead of coordinator using `.enable_return_to_previous()` |
|
||||
| Handoff (Autonomous) | [orchestration/handoff_autonomous.py](./orchestration/handoff_autonomous.py) | Autonomous mode: specialists iterate independently until invoking a handoff tool using `.with_interaction_mode("autonomous", autonomous_turn_limit=N)` |
|
||||
| Handoff (Participant Factory) | [orchestration/handoff_participant_factory.py](./orchestration/handoff_participant_factory.py) | Use participant factories for state isolation between workflow instances |
|
||||
| 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 + Human Stall Intervention | [orchestration/magentic_human_replan.py](./orchestration/magentic_human_replan.py) | Human intervenes when workflow stalls with `with_human_input_on_stall()` |
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import AsyncIterable
|
||||
from typing import cast
|
||||
|
||||
from agent_framework import (
|
||||
AgentRunResponseUpdate,
|
||||
AgentRunUpdateEvent,
|
||||
ChatAgent,
|
||||
ChatMessage,
|
||||
HandoffBuilder,
|
||||
HostedWebSearchTool,
|
||||
WorkflowEvent,
|
||||
WorkflowOutputEvent,
|
||||
)
|
||||
@@ -44,31 +45,29 @@ def create_agents(
|
||||
"""Create coordinator and specialists for autonomous iteration."""
|
||||
coordinator = chat_client.create_agent(
|
||||
instructions=(
|
||||
"You are a coordinator. Route user requests to either research_agent or summary_agent. "
|
||||
"Always call exactly one handoff tool with a short routing acknowledgement. "
|
||||
"If unsure, default to research_agent. Never request information yourself. "
|
||||
"After a specialist hands off back to you, provide a concise final summary and stop."
|
||||
"You are a coordinator. You break down a user query into a research task and a summary task. "
|
||||
"Assign the two tasks to the appropriate specialists, one after the other."
|
||||
),
|
||||
name="coordinator",
|
||||
)
|
||||
|
||||
research_agent = chat_client.create_agent(
|
||||
instructions=(
|
||||
"You are a research specialist that explores topics thoroughly. "
|
||||
"You are a research specialist that explores topics thoroughly on the Microsoft Learn Site."
|
||||
"When given a research task, break it down into multiple aspects and explore each one. "
|
||||
"Continue your research across multiple responses - don't try to finish everything in one response. "
|
||||
"After each response, think about what else needs to be explored. "
|
||||
"When you have covered the topic comprehensively (at least 3-4 different aspects), "
|
||||
"call the handoff tool to return to coordinator with your findings. "
|
||||
"Keep each individual response focused on one aspect."
|
||||
"Continue your research across multiple responses - don't try to finish everything in one "
|
||||
"response. After each response, think about what else needs to be explored. When you have "
|
||||
"covered the topic comprehensively (at least 3-4 different aspects), return control to the "
|
||||
"coordinator. Keep each individual response focused on one aspect."
|
||||
),
|
||||
name="research_agent",
|
||||
tools=[HostedWebSearchTool()],
|
||||
)
|
||||
|
||||
summary_agent = chat_client.create_agent(
|
||||
instructions=(
|
||||
"You summarize research findings. Provide a concise, well-organized summary. "
|
||||
"When done, hand off to coordinator."
|
||||
"You summarize research findings. Provide a concise, well-organized summary. When done, return "
|
||||
"control to the coordinator."
|
||||
),
|
||||
name="summary_agent",
|
||||
)
|
||||
@@ -76,25 +75,29 @@ def create_agents(
|
||||
return coordinator, research_agent, summary_agent
|
||||
|
||||
|
||||
async def _drain(stream: AsyncIterable[WorkflowEvent]) -> list[WorkflowEvent]:
|
||||
"""Collect all events from an async stream."""
|
||||
return [event async for event in stream]
|
||||
last_response_id: str | None = None
|
||||
|
||||
|
||||
def _print_conversation(events: list[WorkflowEvent]) -> None:
|
||||
def _display_event(event: WorkflowEvent) -> None:
|
||||
"""Print the final conversation snapshot from workflow output events."""
|
||||
for event in events:
|
||||
if isinstance(event, AgentRunUpdateEvent):
|
||||
print(event.data, flush=True, end="")
|
||||
elif isinstance(event, WorkflowOutputEvent):
|
||||
conversation = cast(list[ChatMessage], event.data)
|
||||
print("\n=== Final Conversation (Autonomous with Iteration) ===")
|
||||
for message in conversation:
|
||||
speaker = message.author_name or message.role.value
|
||||
text_preview = message.text[:200] + "..." if len(message.text) > 200 else message.text
|
||||
print(f"- {speaker}: {text_preview}")
|
||||
print(f"\nTotal messages: {len(conversation)}")
|
||||
print("=====================================================")
|
||||
if isinstance(event, AgentRunUpdateEvent) and event.data:
|
||||
update: AgentRunResponseUpdate = event.data
|
||||
if not update.text:
|
||||
return
|
||||
global last_response_id
|
||||
if update.response_id != last_response_id:
|
||||
last_response_id = update.response_id
|
||||
print(f"\n- {update.author_name}: ", flush=True, end="")
|
||||
print(event.data, flush=True, end="")
|
||||
elif isinstance(event, WorkflowOutputEvent):
|
||||
conversation = cast(list[ChatMessage], event.data)
|
||||
print("\n=== Final Conversation (Autonomous with Iteration) ===")
|
||||
for message in conversation:
|
||||
speaker = message.author_name or message.role.value
|
||||
text_preview = message.text[:200] + "..." if len(message.text) > 200 else message.text
|
||||
print(f"- {speaker}: {text_preview}")
|
||||
print(f"\nTotal messages: {len(conversation)}")
|
||||
print("=====================================================")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
@@ -122,12 +125,10 @@ async def main() -> None:
|
||||
.build()
|
||||
)
|
||||
|
||||
initial_request = "Research the key benefits and challenges of renewable energy adoption."
|
||||
print("Initial request:", initial_request)
|
||||
print("\nExpecting multiple iterations from the research agent...\n")
|
||||
|
||||
events = await _drain(workflow.run_stream(initial_request))
|
||||
_print_conversation(events)
|
||||
request = "Perform a comprehensive research on Microsoft Agent Framework."
|
||||
print("Request:", request)
|
||||
async for event in workflow.run_stream(request):
|
||||
_display_event(event)
|
||||
|
||||
"""
|
||||
Expected behavior:
|
||||
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import AsyncIterable
|
||||
from typing import cast
|
||||
|
||||
from agent_framework import (
|
||||
ChatAgent,
|
||||
ChatMessage,
|
||||
HandoffBuilder,
|
||||
HandoffUserInputRequest,
|
||||
RequestInfoEvent,
|
||||
Role,
|
||||
Workflow,
|
||||
WorkflowEvent,
|
||||
WorkflowOutputEvent,
|
||||
ai_function,
|
||||
)
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
from typing import Annotated
|
||||
|
||||
logging.basicConfig(level=logging.ERROR)
|
||||
|
||||
"""Sample: Autonomous handoff workflow with agent factory.
|
||||
|
||||
This sample demonstrates how to use participant factories in HandoffBuilder to create
|
||||
agents dynamically.
|
||||
|
||||
Using participant factories allows you to set up proper state isolation between workflow
|
||||
instances created by the same builder. This is particularly useful when you need to handle
|
||||
requests or tasks in parallel with stateful participants.
|
||||
|
||||
Routing Pattern:
|
||||
User -> Coordinator -> Specialist (iterates N times) -> Handoff -> Final Output
|
||||
|
||||
Prerequisites:
|
||||
- `az login` (Azure CLI authentication)
|
||||
- Environment variables for AzureOpenAIChatClient (AZURE_OPENAI_ENDPOINT, etc.)
|
||||
|
||||
Key Concepts:
|
||||
- Participant factories: create agents via factory functions for isolation
|
||||
"""
|
||||
|
||||
|
||||
@ai_function
|
||||
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}."
|
||||
|
||||
|
||||
@ai_function
|
||||
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."
|
||||
|
||||
|
||||
@ai_function
|
||||
def process_return(order_number: Annotated[str, "Order number to process return for"]) -> str:
|
||||
"""Simulated function to process a return for a given order number."""
|
||||
return f"Return initiated successfully for order {order_number}. You will receive return instructions via email."
|
||||
|
||||
|
||||
def create_triage_agent() -> ChatAgent:
|
||||
"""Factory function to create a triage agent instance."""
|
||||
return AzureOpenAIChatClient(credential=AzureCliCredential()).create_agent(
|
||||
instructions=(
|
||||
"You are frontline support triage. Route customer issues to the appropriate specialist agents "
|
||||
"based on the problem described."
|
||||
),
|
||||
name="triage_agent",
|
||||
)
|
||||
|
||||
|
||||
def create_refund_agent() -> ChatAgent:
|
||||
"""Factory function to create a refund agent instance."""
|
||||
return AzureOpenAIChatClient(credential=AzureCliCredential()).create_agent(
|
||||
instructions="You process refund requests.",
|
||||
name="refund_agent",
|
||||
# In a real application, an agent can have multiple tools; here we keep it simple
|
||||
tools=[process_refund],
|
||||
)
|
||||
|
||||
|
||||
def create_order_status_agent() -> ChatAgent:
|
||||
"""Factory function to create an order status agent instance."""
|
||||
return AzureOpenAIChatClient(credential=AzureCliCredential()).create_agent(
|
||||
instructions="You handle order and shipping inquiries.",
|
||||
name="order_agent",
|
||||
# In a real application, an agent can have multiple tools; here we keep it simple
|
||||
tools=[check_order_status],
|
||||
)
|
||||
|
||||
|
||||
def create_return_agent() -> ChatAgent:
|
||||
"""Factory function to create a return agent instance."""
|
||||
return AzureOpenAIChatClient(credential=AzureCliCredential()).create_agent(
|
||||
instructions="You manage product return requests.",
|
||||
name="return_agent",
|
||||
# In a real application, an agent can have multiple tools; here we keep it simple
|
||||
tools=[process_return],
|
||||
)
|
||||
|
||||
|
||||
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:
|
||||
# WorkflowOutputEvent: Contains the final conversation when workflow terminates
|
||||
if 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_agent_responses_since_last_user_message(event.data)
|
||||
requests.append(event)
|
||||
|
||||
return requests
|
||||
|
||||
|
||||
def _print_agent_responses_since_last_user_message(request: HandoffUserInputRequest) -> None:
|
||||
"""Display agent responses since the last user message in a handoff request.
|
||||
|
||||
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
|
||||
"""
|
||||
if not request.conversation:
|
||||
raise RuntimeError("HandoffUserInputRequest missing conversation history.")
|
||||
|
||||
# Reverse iterate to collect agent responses since last user message
|
||||
agent_responses: list[ChatMessage] = []
|
||||
for message in request.conversation[::-1]:
|
||||
if message.role == Role.USER:
|
||||
break
|
||||
agent_responses.append(message)
|
||||
|
||||
# Print agent responses in original order
|
||||
agent_responses.reverse()
|
||||
for message in agent_responses:
|
||||
speaker = message.author_name or message.role.value
|
||||
print(f"- {speaker}: {message.text}")
|
||||
|
||||
|
||||
async def _run_Workflow(workflow: Workflow, user_inputs: list[str]) -> None:
|
||||
"""Run the workflow with the given user input and display events."""
|
||||
print(f"- User: {user_inputs[0]}")
|
||||
events = await _drain(workflow.run_stream(user_inputs[0]))
|
||||
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 user_inputs[1:]:
|
||||
# Get the next scripted response
|
||||
user_response = user_inputs.pop(1)
|
||||
print(f"\n- User: {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
|
||||
# We use send_responses_streaming() to get events as they occur, allowing us to
|
||||
# display agent responses in real-time and handle new requests as they arrive
|
||||
events = await _drain(workflow.send_responses_streaming(responses))
|
||||
pending_requests = _handle_events(events)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Run the autonomous handoff workflow with participant factories."""
|
||||
# Build the handoff workflow using participant factories
|
||||
workflow_builder = (
|
||||
HandoffBuilder(
|
||||
name="Autonomous Handoff with Participant Factories",
|
||||
participant_factories={
|
||||
"triage": create_triage_agent,
|
||||
"refund": create_refund_agent,
|
||||
"order_status": create_order_status_agent,
|
||||
"return": create_return_agent,
|
||||
},
|
||||
)
|
||||
.set_coordinator("triage")
|
||||
.with_termination_condition(
|
||||
# Custom termination: Check if the triage agent has provided a closing message.
|
||||
# This looks for the last message being from triage_agent and containing "welcome",
|
||||
# which indicates the conversation has concluded naturally.
|
||||
lambda conversation: len(conversation) > 0
|
||||
and conversation[-1].author_name == "triage_agent"
|
||||
and "welcome" in conversation[-1].text.lower()
|
||||
)
|
||||
)
|
||||
|
||||
# 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
|
||||
user_inputs = [
|
||||
"Hello, I need assistance with my recent purchase.",
|
||||
"My order 1234 arrived damaged and the packaging was destroyed. I'd like to return it.",
|
||||
"Is my return being processed?",
|
||||
"Thanks for resolving this.",
|
||||
]
|
||||
|
||||
workflow_a = workflow_builder.build()
|
||||
print("=== Running workflow_a ===")
|
||||
await _run_Workflow(workflow_a, list(user_inputs))
|
||||
|
||||
workflow_b = workflow_builder.build()
|
||||
print("=== Running workflow_b ===")
|
||||
# Only provide the last two inputs to workflow_b to demonstrate state isolation
|
||||
# The agents in this workflow have no prior context thus should not have knowledge of
|
||||
# order 1234 or previous interactions.
|
||||
await _run_Workflow(workflow_b, user_inputs[2:])
|
||||
"""
|
||||
Expected behavior:
|
||||
- workflow_a and workflow_b maintain separate states for their participants.
|
||||
- Each workflow processes its requests independently without interference.
|
||||
- workflow_a will answer the follow-up request based on its own conversation history,
|
||||
while workflow_b will provide a general answer without prior context.
|
||||
"""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterable
|
||||
from typing import cast
|
||||
from typing import Annotated, cast
|
||||
|
||||
from agent_framework import (
|
||||
ChatAgent,
|
||||
@@ -10,10 +10,12 @@ from agent_framework import (
|
||||
HandoffBuilder,
|
||||
HandoffUserInputRequest,
|
||||
RequestInfoEvent,
|
||||
Role,
|
||||
WorkflowEvent,
|
||||
WorkflowOutputEvent,
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
ai_function,
|
||||
)
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
@@ -22,10 +24,10 @@ from azure.identity import AzureCliCredential
|
||||
|
||||
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.
|
||||
specialist responds, control returns to the user (via the triage agent) for the next input.
|
||||
|
||||
Routing Pattern:
|
||||
User → Triage Agent → Specialist → Back to User → Triage Agent → ...
|
||||
User → Triage Agent → Specialist → Triage Agent → 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
|
||||
@@ -39,12 +41,31 @@ Prerequisites:
|
||||
|
||||
Key Concepts:
|
||||
- Single-tier routing: Only triage agent has handoff capabilities
|
||||
- Auto-registered handoff tools: HandoffBuilder creates tools automatically
|
||||
- Auto-registered handoff tools: HandoffBuilder automatically creates handoff tools
|
||||
for each participant, allowing the coordinator to transfer control to specialists
|
||||
- Termination condition: Controls when the workflow stops requesting user input
|
||||
- Request/response cycle: Workflow requests input, user responds, cycle continues
|
||||
"""
|
||||
|
||||
|
||||
@ai_function
|
||||
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}."
|
||||
|
||||
|
||||
@ai_function
|
||||
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."
|
||||
|
||||
|
||||
@ai_function
|
||||
def process_return(order_number: Annotated[str, "Order number to process return for"]) -> str:
|
||||
"""Simulated function to process a return for a given order number."""
|
||||
return f"Return initiated successfully for order {order_number}. You will receive return instructions via email."
|
||||
|
||||
|
||||
def create_agents(chat_client: AzureOpenAIChatClient) -> tuple[ChatAgent, ChatAgent, ChatAgent, ChatAgent]:
|
||||
"""Create and configure the triage and specialist agents.
|
||||
|
||||
@@ -54,51 +75,46 @@ def create_agents(chat_client: AzureOpenAIChatClient) -> tuple[ChatAgent, ChatAg
|
||||
- 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.
|
||||
After a specialist responds, control returns to the triage agent, which then prompts
|
||||
the user for their next message.
|
||||
|
||||
Returns:
|
||||
Tuple of (triage_agent, refund_agent, order_agent, support_agent)
|
||||
Tuple of (triage_agent, refund_agent, order_agent, return_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(
|
||||
triage_agent = 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`)."
|
||||
"You are frontline support triage. Route customer issues to the appropriate specialist agents "
|
||||
"based on the problem described."
|
||||
),
|
||||
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."
|
||||
),
|
||||
refund_agent = chat_client.create_agent(
|
||||
instructions="You process refund requests.",
|
||||
name="refund_agent",
|
||||
# In a real application, an agent can have multiple tools; here we keep it simple
|
||||
tools=[process_refund],
|
||||
)
|
||||
|
||||
# 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."
|
||||
),
|
||||
order_agent = chat_client.create_agent(
|
||||
instructions="You handle order and shipping inquiries.",
|
||||
name="order_agent",
|
||||
# In a real application, an agent can have multiple tools; here we keep it simple
|
||||
tools=[check_order_status],
|
||||
)
|
||||
|
||||
# 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 specialist: Handles return requests
|
||||
return_agent = chat_client.create_agent(
|
||||
instructions="You manage product return requests.",
|
||||
name="return_agent",
|
||||
# In a real application, an agent can have multiple tools; here we keep it simple
|
||||
tools=[process_return],
|
||||
)
|
||||
|
||||
return triage, refund, order, support
|
||||
return triage_agent, refund_agent, order_agent, return_agent
|
||||
|
||||
|
||||
async def _drain(stream: AsyncIterable[WorkflowEvent]) -> list[WorkflowEvent]:
|
||||
@@ -139,7 +155,7 @@ def _handle_events(events: list[WorkflowEvent]) -> list[RequestInfoEvent]:
|
||||
WorkflowRunState.IDLE,
|
||||
WorkflowRunState.IDLE_WITH_PENDING_REQUESTS,
|
||||
}:
|
||||
print(f"[status] {event.state.name}")
|
||||
print(f"\n[Workflow Status] {event.state.name}")
|
||||
|
||||
# WorkflowOutputEvent: Contains the final conversation when workflow terminates
|
||||
elif isinstance(event, WorkflowOutputEvent):
|
||||
@@ -154,14 +170,14 @@ def _handle_events(events: list[WorkflowEvent]) -> list[RequestInfoEvent]:
|
||||
# RequestInfoEvent: Workflow is requesting user input
|
||||
elif isinstance(event, RequestInfoEvent):
|
||||
if isinstance(event.data, HandoffUserInputRequest):
|
||||
_print_handoff_request(event.data)
|
||||
_print_agent_responses_since_last_user_message(event.data)
|
||||
requests.append(event)
|
||||
|
||||
return requests
|
||||
|
||||
|
||||
def _print_handoff_request(request: HandoffUserInputRequest) -> None:
|
||||
"""Display a user input request prompt with conversation context.
|
||||
def _print_agent_responses_since_last_user_message(request: HandoffUserInputRequest) -> None:
|
||||
"""Display agent responses since the last user message in a handoff request.
|
||||
|
||||
The HandoffUserInputRequest contains the full conversation history so far,
|
||||
allowing the user to see what's been discussed before providing their next input.
|
||||
@@ -169,11 +185,21 @@ def _print_handoff_request(request: HandoffUserInputRequest) -> None:
|
||||
Args:
|
||||
request: The user input request containing conversation and prompt
|
||||
"""
|
||||
print("\n=== User Input Requested ===")
|
||||
for message in request.conversation:
|
||||
if not request.conversation:
|
||||
raise RuntimeError("HandoffUserInputRequest missing conversation history.")
|
||||
|
||||
# Reverse iterate to collect agent responses since last user message
|
||||
agent_responses: list[ChatMessage] = []
|
||||
for message in request.conversation[::-1]:
|
||||
if message.role == Role.USER:
|
||||
break
|
||||
agent_responses.append(message)
|
||||
|
||||
# Print agent responses in original order
|
||||
agent_responses.reverse()
|
||||
for message in agent_responses:
|
||||
speaker = message.author_name or message.role.value
|
||||
print(f"- {speaker}: {message.text}")
|
||||
print("============================")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
@@ -196,20 +222,26 @@ async def main() -> None:
|
||||
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
|
||||
# - participants: All agents that can participate in the workflow
|
||||
# - set_coordinator: The triage agent is designated as the coordinator, which means
|
||||
# it receives all user input first and orchestrates handoffs to specialists
|
||||
# - with_termination_condition: Custom logic to stop the request/response loop.
|
||||
# Without this, the default behavior continues requesting user input until max_turns
|
||||
# is reached. Here we use a custom condition that checks if the conversation has ended
|
||||
# naturally (when triage agent says something like "you're welcome").
|
||||
workflow = (
|
||||
HandoffBuilder(
|
||||
name="customer_support_handoff",
|
||||
participants=[triage, refund, order, support],
|
||||
)
|
||||
.set_coordinator("triage_agent")
|
||||
.set_coordinator(triage)
|
||||
.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
|
||||
# Custom termination: Check if the triage agent has provided a closing message.
|
||||
# This looks for the last message being from triage_agent and containing "welcome",
|
||||
# which indicates the conversation has concluded naturally.
|
||||
lambda conversation: len(conversation) > 0
|
||||
and conversation[-1].author_name == "triage_agent"
|
||||
and "welcome" in conversation[-1].text.lower()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
@@ -219,15 +251,16 @@ async def main() -> None:
|
||||
# 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.",
|
||||
"My order 1234 arrived damaged and the packaging was destroyed. I'd like to return it.",
|
||||
"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."))
|
||||
print("[Starting workflow with initial user message...]\n")
|
||||
initial_message = "Hello, I need assistance with my recent purchase."
|
||||
print(f"- User: {initial_message}")
|
||||
events = await _drain(workflow.run_stream(initial_message))
|
||||
pending_requests = _handle_events(events)
|
||||
|
||||
# Process the request/response cycle
|
||||
@@ -237,13 +270,15 @@ async def main() -> None:
|
||||
while pending_requests and scripted_responses:
|
||||
# Get the next scripted response
|
||||
user_response = scripted_responses.pop(0)
|
||||
print(f"\n[User responding: {user_response}]")
|
||||
print(f"\n- User: {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
|
||||
# We use send_responses_streaming() to get events as they occur, allowing us to
|
||||
# display agent responses in real-time and handle new requests as they arrive
|
||||
events = await _drain(workflow.send_responses_streaming(responses))
|
||||
pending_requests = _handle_events(events)
|
||||
|
||||
@@ -252,84 +287,30 @@ async def main() -> None:
|
||||
|
||||
[Starting workflow with initial user message...]
|
||||
|
||||
=== User Input Requested ===
|
||||
- User: Hello, I need assistance with my recent purchase.
|
||||
- triage_agent: Could you please provide more details about the issue you're experiencing with your recent purchase? This will help me route you to the appropriate specialist.
|
||||
|
||||
[Workflow Status] IDLE_WITH_PENDING_REQUESTS
|
||||
|
||||
- User: My order 1234 arrived damaged and the packaging was destroyed. I'd like to return it.
|
||||
- triage_agent: I've directed your request to our return agent, who will assist you with returning the damaged order. Thank you for your patience!
|
||||
- return_agent: The return for your order 1234 has been successfully initiated. You will receive return instructions via email shortly. If you have any other questions or need further assistance, feel free to ask!
|
||||
|
||||
[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: 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.
|
||||
- triage_agent: Could you please provide more details about the issue you're experiencing with your recent purchase? This will help me route you to the appropriate specialist.
|
||||
- user: My order 1234 arrived damaged and the packaging was destroyed. I'd like to return it.
|
||||
- triage_agent: I've directed your request to our return agent, who will assist you with returning the damaged order. Thank you for your patience!
|
||||
- return_agent: The return for your order 1234 has been successfully initiated. You will receive return instructions via email shortly. If you have any other questions or need further assistance, feel free to ask!
|
||||
- user: Thanks for resolving this.
|
||||
- triage_agent: You're welcome! If you have any more questions or need assistance in the future, feel free to reach out. Have a great day!
|
||||
===================================
|
||||
[status] IDLE
|
||||
|
||||
[Workflow Status] IDLE
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
|
||||
+1
-2
@@ -26,9 +26,8 @@ Prerequisites:
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterable
|
||||
from collections.abc import AsyncIterable, AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from agent_framework import (
|
||||
AgentRunUpdateEvent,
|
||||
|
||||
Reference in New Issue
Block a user