mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
@@ -23,7 +23,6 @@ This folder contains examples demonstrating different ways to create and use age
|
||||
| [`openai_responses_client_image_analysis.py`](openai_responses_client_image_analysis.py) | Demonstrates how to use vision capabilities with agents to analyze images. |
|
||||
| [`openai_responses_client_image_generation.py`](openai_responses_client_image_generation.py) | Demonstrates how to use image generation capabilities with OpenAI agents to create images based on text descriptions. Requires PIL (Pillow) for image display. |
|
||||
| [`openai_responses_client_reasoning.py`](openai_responses_client_reasoning.py) | Demonstrates how to use reasoning capabilities with OpenAI agents, showing how the agent can provide detailed reasoning for its responses. |
|
||||
| [`openai_responses_client_streaming_image_generation.py`](openai_responses_client_streaming_image_generation.py) | Demonstrates streaming image generation with partial images for real-time image creation feedback and improved user experience. |
|
||||
| [`openai_responses_client_with_code_interpreter.py`](openai_responses_client_with_code_interpreter.py) | Shows how to use the HostedCodeInterpreterTool with OpenAI agents to write and execute Python code. Includes helper methods for accessing code interpreter data from response chunks. |
|
||||
| [`openai_responses_client_with_explicit_settings.py`](openai_responses_client_with_explicit_settings.py) | Shows how to initialize an agent with a specific responses client, configuring settings explicitly including API key and model ID. |
|
||||
| [`openai_responses_client_with_file_search.py`](openai_responses_client_with_file_search.py) | Demonstrates how to use file search capabilities with OpenAI agents, allowing the agent to search through uploaded files to answer questions. |
|
||||
|
||||
-96
@@ -1,96 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
|
||||
import anyio
|
||||
from agent_framework import DataContent
|
||||
from agent_framework.openai import OpenAIResponsesClient
|
||||
|
||||
"""OpenAI Responses Client Streaming Image Generation Example
|
||||
|
||||
Demonstrates streaming partial image generation using OpenAI's image generation tool.
|
||||
Shows progressive image rendering with partial images for improved user experience.
|
||||
|
||||
Note: The number of partial images received depends on generation speed:
|
||||
- High quality/complex images: More partials (generation takes longer)
|
||||
- Low quality/simple images: Fewer partials (generation completes quickly)
|
||||
- You may receive fewer partial images than requested if generation is fast
|
||||
|
||||
Important: The final partial image IS the complete, full-quality image. Each partial
|
||||
represents a progressive refinement, with the last one being the finished result.
|
||||
"""
|
||||
|
||||
|
||||
async def save_image_from_data_uri(data_uri: str, filename: str) -> None:
|
||||
"""Save an image from a data URI to a file."""
|
||||
try:
|
||||
if data_uri.startswith("data:image/"):
|
||||
# Extract base64 data
|
||||
base64_data = data_uri.split(",", 1)[1]
|
||||
image_bytes = base64.b64decode(base64_data)
|
||||
|
||||
# Save to file
|
||||
await anyio.Path(filename).write_bytes(image_bytes)
|
||||
print(f" Saved: {filename} ({len(image_bytes) / 1024:.1f} KB)")
|
||||
except Exception as e:
|
||||
print(f" Error saving {filename}: {e}")
|
||||
|
||||
|
||||
async def main():
|
||||
"""Demonstrate streaming image generation with partial images."""
|
||||
print("=== OpenAI Streaming Image Generation Example ===\n")
|
||||
|
||||
# Create agent with streaming image generation enabled
|
||||
agent = OpenAIResponsesClient().create_agent(
|
||||
instructions="You are a helpful agent that can generate images.",
|
||||
tools=[
|
||||
{
|
||||
"type": "image_generation",
|
||||
"size": "1024x1024",
|
||||
"quality": "high",
|
||||
"partial_images": 3,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
query = "Draw a beautiful sunset over a calm ocean with sailboats"
|
||||
print(f" User: {query}")
|
||||
print()
|
||||
|
||||
# Track partial images
|
||||
image_count = 0
|
||||
|
||||
# Create output directory
|
||||
output_dir = anyio.Path("generated_images")
|
||||
await output_dir.mkdir(exist_ok=True)
|
||||
|
||||
print(" Streaming response:")
|
||||
async for update in agent.run_stream(query):
|
||||
for content in update.contents:
|
||||
# Handle partial images
|
||||
# The final partial image IS the complete, full-quality image. Each partial
|
||||
# represents a progressive refinement, with the last one being the finished result.
|
||||
if isinstance(content, DataContent) and content.additional_properties.get("is_partial_image"):
|
||||
print(f" Image {image_count} received")
|
||||
|
||||
# Extract file extension from media_type (e.g., "image/png" -> "png")
|
||||
extension = "png" # Default fallback
|
||||
if content.media_type and "/" in content.media_type:
|
||||
extension = content.media_type.split("/")[-1]
|
||||
|
||||
# Save images with correct extension
|
||||
filename = output_dir / f"image{image_count}.{extension}"
|
||||
await save_image_from_data_uri(content.uri, str(filename))
|
||||
|
||||
image_count += 1
|
||||
|
||||
# Summary
|
||||
print("\n Summary:")
|
||||
print(f" Images received: {image_count}")
|
||||
print(" Output directory: generated_images")
|
||||
print("\n Streaming image generation completed!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1,19 +0,0 @@
|
||||
# Auto-generated Dockerfiles from DevUI deployment
|
||||
*/Dockerfile
|
||||
|
||||
# Python cache
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
|
||||
# Environment files (may contain secrets)
|
||||
.env
|
||||
*.env
|
||||
|
||||
# IDE files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
@@ -2,22 +2,22 @@
|
||||
|
||||
"""Spam Detection Workflow Sample for DevUI.
|
||||
|
||||
The following sample demonstrates a comprehensive 4-step workflow with multiple executors
|
||||
that process, detect spam, and handle email messages. This workflow illustrates
|
||||
complex branching logic with human-in-the-loop approval and realistic processing delays.
|
||||
The following sample demonstrates a comprehensive 5-step workflow with multiple executors
|
||||
that process, analyze, detect spam, and handle email messages. This workflow illustrates
|
||||
complex branching logic and realistic processing delays to demonstrate the workflow framework.
|
||||
|
||||
Workflow Steps:
|
||||
1. Email Preprocessor - Cleans and prepares the email
|
||||
2. Spam Detector - Analyzes content and determines if the message is spam (with human approval)
|
||||
3a. Spam Handler - Processes spam messages (quarantine, log, remove)
|
||||
3b. Message Responder - Handles legitimate messages (validate, respond)
|
||||
4. Final Processor - Completes the workflow with logging and cleanup
|
||||
2. Content Analyzer - Analyzes email content and structure
|
||||
3. Spam Detector - Determines if the message is spam
|
||||
4a. Spam Handler - Processes spam messages (quarantine, log, remove)
|
||||
4b. Message Responder - Handles legitimate messages (validate, respond)
|
||||
5. Final Processor - Completes the workflow with logging and cleanup
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, Annotated
|
||||
|
||||
from agent_framework import (
|
||||
Case,
|
||||
@@ -26,18 +26,10 @@ from agent_framework import (
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
handler,
|
||||
response_handler,
|
||||
)
|
||||
from pydantic import BaseModel, Field
|
||||
from typing_extensions import Never
|
||||
|
||||
# Define response model with clear user guidance
|
||||
class SpamDecision(BaseModel):
|
||||
"""User's decision on whether the email is spam."""
|
||||
decision: Literal["spam", "not spam"] = Field(
|
||||
description="Enter 'spam' to mark as spam, or 'not spam' to mark as legitimate"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmailContent:
|
||||
@@ -49,17 +41,25 @@ class EmailContent:
|
||||
has_suspicious_patterns: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContentAnalysis:
|
||||
"""A data class to hold content analysis results."""
|
||||
|
||||
email_content: EmailContent
|
||||
sentiment_score: float
|
||||
contains_links: bool
|
||||
has_attachments: bool
|
||||
risk_indicators: list[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpamDetectorResponse:
|
||||
"""A data class to hold the spam detection results."""
|
||||
|
||||
email_content: EmailContent
|
||||
analysis: ContentAnalysis
|
||||
is_spam: bool = False
|
||||
confidence_score: float = 0.0
|
||||
spam_reasons: list[str] | None = None
|
||||
human_reviewed: bool = False
|
||||
human_decision: str | None = None
|
||||
ai_original_classification: bool = False
|
||||
|
||||
def __post_init__(self):
|
||||
"""Initialize spam_reasons list if None."""
|
||||
@@ -67,16 +67,6 @@ class SpamDetectorResponse:
|
||||
self.spam_reasons = []
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpamApprovalRequest:
|
||||
"""Human-in-the-loop approval request for spam classification."""
|
||||
|
||||
email_message: str = ""
|
||||
detected_as_spam: bool = False
|
||||
confidence: float = 0.0
|
||||
reasons: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessingResult:
|
||||
"""A data class to hold the final processing result."""
|
||||
@@ -88,9 +78,6 @@ class ProcessingResult:
|
||||
is_spam: bool
|
||||
confidence_score: float
|
||||
spam_reasons: list[str]
|
||||
was_human_reviewed: bool = False
|
||||
human_override: str | None = None
|
||||
ai_original_decision: bool = False
|
||||
|
||||
|
||||
class EmailRequest(BaseModel):
|
||||
@@ -128,27 +115,18 @@ class EmailPreprocessor(Executor):
|
||||
await ctx.send_message(result)
|
||||
|
||||
|
||||
|
||||
|
||||
class SpamDetector(Executor):
|
||||
"""Step 2: An executor that analyzes content and determines if a message is spam."""
|
||||
|
||||
def __init__(self, spam_keywords: list[str], id: str):
|
||||
"""Initialize the executor with spam keywords."""
|
||||
super().__init__(id=id)
|
||||
self._spam_keywords = spam_keywords
|
||||
class ContentAnalyzer(Executor):
|
||||
"""Step 2: An executor that analyzes email content and structure."""
|
||||
|
||||
@handler
|
||||
async def handle_email_content(self, email_content: EmailContent, ctx: WorkflowContext[SpamApprovalRequest]) -> None:
|
||||
"""Analyze email content and determine if the message is spam, then request human approval."""
|
||||
await asyncio.sleep(2.0) # Simulate analysis and detection time
|
||||
async def handle_email_content(self, email_content: EmailContent, ctx: WorkflowContext[ContentAnalysis]) -> None:
|
||||
"""Analyze the email content for various indicators."""
|
||||
await asyncio.sleep(2.0) # Simulate analysis time
|
||||
|
||||
email_text = email_content.cleaned_message
|
||||
|
||||
# Analyze content for risk indicators
|
||||
contains_links = "http" in email_text or "www" in email_text
|
||||
has_attachments = "attachment" in email_text
|
||||
# Simulate content analysis
|
||||
sentiment_score = 0.5 if email_content.has_suspicious_patterns else 0.8
|
||||
contains_links = "http" in email_content.cleaned_message or "www" in email_content.cleaned_message
|
||||
has_attachments = "attachment" in email_content.cleaned_message
|
||||
|
||||
# Build risk indicators
|
||||
risk_indicators: list[str] = []
|
||||
@@ -161,7 +139,32 @@ class SpamDetector(Executor):
|
||||
if email_content.word_count < 10:
|
||||
risk_indicators.append("too_short")
|
||||
|
||||
analysis = ContentAnalysis(
|
||||
email_content=email_content,
|
||||
sentiment_score=sentiment_score,
|
||||
contains_links=contains_links,
|
||||
has_attachments=has_attachments,
|
||||
risk_indicators=risk_indicators,
|
||||
)
|
||||
|
||||
await ctx.send_message(analysis)
|
||||
|
||||
|
||||
class SpamDetector(Executor):
|
||||
"""Step 3: An executor that determines if a message is spam based on analysis."""
|
||||
|
||||
def __init__(self, spam_keywords: list[str], id: str):
|
||||
"""Initialize the executor with spam keywords."""
|
||||
super().__init__(id=id)
|
||||
self._spam_keywords = spam_keywords
|
||||
|
||||
@handler
|
||||
async def handle_analysis(self, analysis: ContentAnalysis, ctx: WorkflowContext[SpamDetectorResponse]) -> None:
|
||||
"""Determine if the message is spam based on content analysis."""
|
||||
await asyncio.sleep(1.8) # Simulate detection time
|
||||
|
||||
# Check for spam keywords
|
||||
email_text = analysis.email_content.cleaned_message
|
||||
keyword_matches = [kw for kw in self._spam_keywords if kw in email_text]
|
||||
|
||||
# Calculate spam probability
|
||||
@@ -172,100 +175,29 @@ class SpamDetector(Executor):
|
||||
spam_score += 0.4
|
||||
spam_reasons.append(f"spam_keywords: {keyword_matches}")
|
||||
|
||||
if email_content.has_suspicious_patterns:
|
||||
if analysis.email_content.has_suspicious_patterns:
|
||||
spam_score += 0.3
|
||||
spam_reasons.append("suspicious_patterns")
|
||||
|
||||
if len(risk_indicators) >= 3:
|
||||
if len(analysis.risk_indicators) >= 3:
|
||||
spam_score += 0.2
|
||||
spam_reasons.append("high_risk_indicators")
|
||||
|
||||
if sentiment_score < 0.4:
|
||||
if analysis.sentiment_score < 0.4:
|
||||
spam_score += 0.1
|
||||
spam_reasons.append("negative_sentiment")
|
||||
|
||||
is_spam = spam_score >= 0.5
|
||||
|
||||
# Store detection result in executor state for later use
|
||||
# Store minimal data needed (not complex objects that don't serialize well)
|
||||
await ctx.set_executor_state({
|
||||
"original_message": email_content.original_message,
|
||||
"cleaned_message": email_content.cleaned_message,
|
||||
"word_count": email_content.word_count,
|
||||
"has_suspicious_patterns": email_content.has_suspicious_patterns,
|
||||
"is_spam": is_spam,
|
||||
"ai_original_classification": is_spam, # Store original AI decision
|
||||
"confidence_score": spam_score,
|
||||
"spam_reasons": spam_reasons
|
||||
})
|
||||
|
||||
# Request human approval before proceeding using new API
|
||||
approval_request = SpamApprovalRequest(
|
||||
email_message=email_text[:200], # First 200 chars
|
||||
detected_as_spam=is_spam,
|
||||
confidence=spam_score,
|
||||
reasons=", ".join(spam_reasons) if spam_reasons else "no specific reasons"
|
||||
)
|
||||
|
||||
await ctx.request_info(
|
||||
request_data=approval_request,
|
||||
response_type=SpamDecision,
|
||||
)
|
||||
|
||||
@response_handler
|
||||
async def handle_human_response(
|
||||
self,
|
||||
original_request: SpamApprovalRequest,
|
||||
response: SpamDecision,
|
||||
ctx: WorkflowContext[SpamDetectorResponse]
|
||||
) -> None:
|
||||
"""Process human approval response and continue workflow."""
|
||||
print(f"[SpamDetector] handle_human_response called with response: {response}")
|
||||
|
||||
# Get stored detection result
|
||||
state = await ctx.get_executor_state() or {}
|
||||
print(f"[SpamDetector] Retrieved state: {state}")
|
||||
ai_original = state.get("ai_original_classification", False)
|
||||
confidence_score = state.get("confidence_score", 0.0)
|
||||
spam_reasons = state.get("spam_reasons", [])
|
||||
|
||||
# Parse human decision from the response model
|
||||
human_decision = response.decision.strip().lower()
|
||||
|
||||
# Determine final classification based on human input
|
||||
if human_decision in ["not spam"]:
|
||||
is_spam = False
|
||||
elif human_decision in ["spam"]:
|
||||
is_spam = True
|
||||
else:
|
||||
# Default to AI decision if unclear
|
||||
is_spam = ai_original
|
||||
|
||||
# Reconstruct EmailContent from stored primitives
|
||||
email_content = EmailContent(
|
||||
original_message=state.get("original_message", ""),
|
||||
cleaned_message=state.get("cleaned_message", ""),
|
||||
word_count=state.get("word_count", 0),
|
||||
has_suspicious_patterns=state.get("has_suspicious_patterns", False)
|
||||
)
|
||||
|
||||
result = SpamDetectorResponse(
|
||||
email_content=email_content,
|
||||
is_spam=is_spam,
|
||||
confidence_score=confidence_score,
|
||||
spam_reasons=spam_reasons,
|
||||
human_reviewed=True,
|
||||
human_decision=response.decision,
|
||||
ai_original_classification=ai_original
|
||||
analysis=analysis, is_spam=is_spam, confidence_score=spam_score, spam_reasons=spam_reasons
|
||||
)
|
||||
|
||||
print(f"[SpamDetector] Sending SpamDetectorResponse: is_spam={is_spam}, confidence={confidence_score}, human_reviewed=True")
|
||||
await ctx.send_message(result)
|
||||
print(f"[SpamDetector] Message sent successfully")
|
||||
|
||||
|
||||
class SpamHandler(Executor):
|
||||
"""Step 3a: An executor that handles spam messages with quarantine and logging."""
|
||||
"""Step 4a: An executor that handles spam messages with quarantine and logging."""
|
||||
|
||||
@handler
|
||||
async def handle_spam_detection(
|
||||
@@ -280,23 +212,20 @@ class SpamHandler(Executor):
|
||||
await asyncio.sleep(2.2) # Simulate spam handling time
|
||||
|
||||
result = ProcessingResult(
|
||||
original_message=spam_result.email_content.original_message,
|
||||
original_message=spam_result.analysis.email_content.original_message,
|
||||
action_taken="quarantined_and_logged",
|
||||
processing_time=2.2,
|
||||
status="spam_handled",
|
||||
is_spam=spam_result.is_spam,
|
||||
confidence_score=spam_result.confidence_score,
|
||||
spam_reasons=spam_result.spam_reasons or [],
|
||||
was_human_reviewed=spam_result.human_reviewed,
|
||||
human_override=spam_result.human_decision,
|
||||
ai_original_decision=spam_result.ai_original_classification,
|
||||
)
|
||||
|
||||
await ctx.send_message(result)
|
||||
|
||||
|
||||
class LegitimateMessageHandler(Executor):
|
||||
"""Step 3b: An executor that handles legitimate (non-spam) messages."""
|
||||
class MessageResponder(Executor):
|
||||
"""Step 4b: An executor that responds to legitimate messages."""
|
||||
|
||||
@handler
|
||||
async def handle_spam_detection(
|
||||
@@ -311,23 +240,20 @@ class LegitimateMessageHandler(Executor):
|
||||
await asyncio.sleep(2.5) # Simulate response time
|
||||
|
||||
result = ProcessingResult(
|
||||
original_message=spam_result.email_content.original_message,
|
||||
action_taken="delivered_to_inbox",
|
||||
original_message=spam_result.analysis.email_content.original_message,
|
||||
action_taken="responded_and_filed",
|
||||
processing_time=2.5,
|
||||
status="message_processed",
|
||||
is_spam=spam_result.is_spam,
|
||||
confidence_score=spam_result.confidence_score,
|
||||
spam_reasons=spam_result.spam_reasons or [],
|
||||
was_human_reviewed=spam_result.human_reviewed,
|
||||
human_override=spam_result.human_decision,
|
||||
ai_original_decision=spam_result.ai_original_classification,
|
||||
)
|
||||
|
||||
await ctx.send_message(result)
|
||||
|
||||
|
||||
class FinalProcessor(Executor):
|
||||
"""Step 4: An executor that completes the workflow with final logging and cleanup."""
|
||||
"""Step 5: An executor that completes the workflow with final logging and cleanup."""
|
||||
|
||||
@handler
|
||||
async def handle_processing_result(
|
||||
@@ -340,98 +266,50 @@ class FinalProcessor(Executor):
|
||||
|
||||
total_time = result.processing_time + 1.5
|
||||
|
||||
# Build classification status with human review info
|
||||
# Include classification details in completion message
|
||||
classification = "SPAM" if result.is_spam else "LEGITIMATE"
|
||||
reasons = ", ".join(result.spam_reasons) if result.spam_reasons else "none"
|
||||
|
||||
# Add human review context
|
||||
review_status = ""
|
||||
if result.was_human_reviewed:
|
||||
if result.ai_original_decision != result.is_spam:
|
||||
review_status = " (human-overridden)"
|
||||
else:
|
||||
review_status = " (human-verified)"
|
||||
|
||||
# Build appropriate message based on classification
|
||||
if result.is_spam:
|
||||
# For spam messages
|
||||
spam_indicators = ", ".join(result.spam_reasons) if result.spam_reasons else "none detected"
|
||||
|
||||
if result.was_human_reviewed:
|
||||
ai_status = "SPAM" if result.ai_original_decision else "LEGITIMATE"
|
||||
human_decision = result.human_override if result.human_override else "unknown"
|
||||
|
||||
completion_message = (
|
||||
f"Email classified as {classification}{review_status}.\n"
|
||||
f"AI detected: {ai_status} (confidence: {result.confidence_score:.2f})\n"
|
||||
f"Human reviewer: {human_decision}\n"
|
||||
f"Spam indicators: {spam_indicators}\n"
|
||||
f"Action: Message quarantined for review\n"
|
||||
f"Processing time: {total_time:.1f}s"
|
||||
)
|
||||
else:
|
||||
completion_message = (
|
||||
f"Email classified as {classification} (confidence: {result.confidence_score:.2f}).\n"
|
||||
f"Spam indicators: {spam_indicators}\n"
|
||||
f"Action: Message quarantined for review\n"
|
||||
f"Processing time: {total_time:.1f}s"
|
||||
)
|
||||
else:
|
||||
# For legitimate messages
|
||||
if result.was_human_reviewed:
|
||||
ai_status = "SPAM" if result.ai_original_decision else "LEGITIMATE"
|
||||
human_decision = result.human_override if result.human_override else "unknown"
|
||||
|
||||
completion_message = (
|
||||
f"Email classified as {classification}{review_status}.\n"
|
||||
f"AI detected: {ai_status} (confidence: {result.confidence_score:.2f})\n"
|
||||
f"Human reviewer: {human_decision}\n"
|
||||
f"Action: Delivered to inbox\n"
|
||||
f"Processing time: {total_time:.1f}s"
|
||||
)
|
||||
else:
|
||||
completion_message = (
|
||||
f"Email classified as {classification} (confidence: {result.confidence_score:.2f}).\n"
|
||||
f"Action: Delivered to inbox\n"
|
||||
f"Processing time: {total_time:.1f}s"
|
||||
)
|
||||
completion_message = (
|
||||
f"Email classified as {classification} (confidence: {result.confidence_score:.2f}). "
|
||||
f"Reasons: {reasons}. "
|
||||
f"Action: {result.action_taken}, "
|
||||
f"Status: {result.status}, "
|
||||
f"Total time: {total_time:.1f}s"
|
||||
)
|
||||
|
||||
await ctx.yield_output(completion_message)
|
||||
|
||||
|
||||
# DevUI will provide checkpoint storage automatically via the new workflow API
|
||||
# No need to create checkpoint storage here anymore!
|
||||
|
||||
# Create the workflow instance that DevUI can discover
|
||||
spam_keywords = ["spam", "advertisement", "offer", "click here", "winner", "congratulations", "urgent"]
|
||||
|
||||
# Create all the executors for the 4-step workflow
|
||||
# Create all the executors for the 5-step workflow
|
||||
email_preprocessor = EmailPreprocessor(id="email_preprocessor")
|
||||
content_analyzer = ContentAnalyzer(id="content_analyzer")
|
||||
spam_detector = SpamDetector(spam_keywords, id="spam_detector")
|
||||
spam_handler = SpamHandler(id="spam_handler")
|
||||
legitimate_message_handler = LegitimateMessageHandler(id="legitimate_message_handler")
|
||||
message_responder = MessageResponder(id="message_responder")
|
||||
final_processor = FinalProcessor(id="final_processor")
|
||||
|
||||
# Build the comprehensive 4-step workflow with branching logic and HIL support
|
||||
# Note: No .with_checkpointing() call - DevUI will pass checkpoint_storage at runtime
|
||||
# Build the comprehensive 5-step workflow with branching logic
|
||||
workflow = (
|
||||
WorkflowBuilder(
|
||||
name="Email Spam Detector",
|
||||
description="4-step email classification workflow with human-in-the-loop spam approval",
|
||||
description="5-step email classification workflow with spam/legitimate routing",
|
||||
)
|
||||
.set_start_executor(email_preprocessor)
|
||||
.add_edge(email_preprocessor, spam_detector)
|
||||
# HIL handled within spam_detector via @response_handler
|
||||
# Continue with branching logic after human approval
|
||||
# Only route SpamDetectorResponse messages (not SpamApprovalRequest)
|
||||
.add_edge(email_preprocessor, content_analyzer)
|
||||
.add_edge(content_analyzer, spam_detector)
|
||||
.add_switch_case_edge_group(
|
||||
spam_detector,
|
||||
[
|
||||
Case(condition=lambda x: isinstance(x, SpamDetectorResponse) and x.is_spam, target=spam_handler),
|
||||
Default(target=legitimate_message_handler), # Default handles non-spam and non-SpamDetectorResponse messages
|
||||
Case(condition=lambda x: x.is_spam, target=spam_handler),
|
||||
Default(target=message_responder),
|
||||
],
|
||||
)
|
||||
.add_edge(spam_handler, final_processor)
|
||||
.add_edge(legitimate_message_handler, final_processor)
|
||||
.add_edge(message_responder, final_processor)
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
"""Sample weather agent for Agent Framework Debug UI."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Annotated
|
||||
@@ -15,20 +14,8 @@ from agent_framework import (
|
||||
Role,
|
||||
chat_middleware,
|
||||
function_middleware,
|
||||
ai_function
|
||||
)
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from agent_framework_devui import register_cleanup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def cleanup_resources():
|
||||
"""Cleanup function that runs when DevUI shuts down."""
|
||||
logger.info("=" * 60)
|
||||
logger.info(" Cleaning up resources...")
|
||||
logger.info(" (In production, this would close credentials, sessions, etc.)")
|
||||
logger.info("=" * 60)
|
||||
|
||||
|
||||
@chat_middleware
|
||||
@@ -106,14 +93,6 @@ def get_forecast(
|
||||
|
||||
return f"Weather forecast for {location}:\n" + "\n".join(forecast)
|
||||
|
||||
@ai_function(approval_mode="always_require")
|
||||
def send_email(
|
||||
recipient: Annotated[str, "The email address of the recipient."],
|
||||
subject: Annotated[str, "The subject of the email."],
|
||||
body: Annotated[str, "The body content of the email."],
|
||||
) -> str:
|
||||
"""Simulate sending an email."""
|
||||
return f"Email sent to {recipient} with subject '{subject}'."
|
||||
|
||||
# Agent instance following Agent Framework conventions
|
||||
agent = ChatAgent(
|
||||
@@ -127,13 +106,10 @@ agent = ChatAgent(
|
||||
chat_client=AzureOpenAIChatClient(
|
||||
api_key=os.environ.get("AZURE_OPENAI_API_KEY", ""),
|
||||
),
|
||||
tools=[get_weather, get_forecast, send_email],
|
||||
tools=[get_weather, get_forecast],
|
||||
middleware=[security_filter_middleware, atlantis_location_filter_middleware],
|
||||
)
|
||||
|
||||
# Register cleanup hook - demonstrates resource cleanup on shutdown
|
||||
register_cleanup(agent, cleanup_resources)
|
||||
|
||||
|
||||
def main():
|
||||
"""Launch the Azure weather agent in DevUI."""
|
||||
|
||||
@@ -191,11 +191,11 @@ dependencies
|
||||
Besides the Application Insights native UI, you can also use Grafana to visualize the telemetry data in Application Insights. There are two tailored dashboards for you to get started quickly:
|
||||
|
||||
#### Agent Overview dashboard
|
||||
Open dashboard in Azure portal: <https://aka.ms/amg/dash/af-agent>
|
||||
Grafana Dashboard Gallery link: <https://aka.ms/amg/dash/af-agent>
|
||||

|
||||
|
||||
#### Workflow Overview dashboard
|
||||
Open dashboard in Azure portal: <https://aka.ms/amg/dash/af-workflow>
|
||||
Grafana Dashboard Gallery link: <https://aka.ms/amg/dash/af-workflow>
|
||||

|
||||
|
||||
## Aspire Dashboard
|
||||
|
||||
@@ -78,7 +78,6 @@ Once comfortable with these, explore the rest of the samples below.
|
||||
|---|---|---|
|
||||
| Human-In-The-Loop (Guessing Game) | [human-in-the-loop/guessing_game_with_human_input.py](./human-in-the-loop/guessing_game_with_human_input.py) | Interactive request/response prompts with a human |
|
||||
| Azure Agents Tool Feedback Loop | [agents/azure_chat_agents_tool_calls_with_feedback.py](./agents/azure_chat_agents_tool_calls_with_feedback.py) | Two-agent workflow that streams tool calls and pauses for human guidance between passes |
|
||||
| Agents with Approval Requests in Workflows | [human-in-the-loop/agents_with_approval_requests.py](./human-in-the-loop/agents_with_approval_requests.py) | Agents that create approval requests during workflow execution and wait for human approval to proceed |
|
||||
|
||||
### observability
|
||||
|
||||
@@ -97,7 +96,6 @@ Once comfortable with these, explore the rest of the samples below.
|
||||
| Group Chat with Simple Function Selector | [orchestration/group_chat_simple_selector.py](./orchestration/group_chat_simple_selector.py) | Group chat with a simple function selector for next speaker |
|
||||
| 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 |
|
||||
| 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()` |
|
||||
| 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 |
|
||||
|
||||
-340
@@ -1,340 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated, Never
|
||||
|
||||
from agent_framework import (
|
||||
AgentExecutorResponse,
|
||||
ChatMessage,
|
||||
Executor,
|
||||
FunctionApprovalRequestContent,
|
||||
FunctionApprovalResponseContent,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
ai_function,
|
||||
executor,
|
||||
handler,
|
||||
)
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
|
||||
"""
|
||||
Sample: Agents in a workflow with AI functions requiring approval
|
||||
|
||||
This sample creates a workflow that automatically replies to incoming emails.
|
||||
If historical email data is needed, it uses an AI function to read the data,
|
||||
which requires human approval before execution.
|
||||
|
||||
This sample works as follows:
|
||||
1. An incoming email is received by the workflow.
|
||||
2. The EmailPreprocessor executor preprocesses the email, adding special notes if the sender is important.
|
||||
3. The preprocessed email is sent to the Email Writer agent, which generates a response.
|
||||
4. If the agent needs to read historical email data, it calls the read_historical_email_data AI function,
|
||||
which triggers an approval request.
|
||||
5. The sample automatically approves the request for demonstration purposes.
|
||||
6. Once approved, the AI function executes and returns the historical email data to the agent.
|
||||
7. The agent uses the historical data to compose a comprehensive email response.
|
||||
8. The response is sent to the conclude_workflow_executor, which yields the final response.
|
||||
|
||||
Purpose:
|
||||
Show how to integrate AI functions with approval requests into a workflow.
|
||||
|
||||
Demonstrate:
|
||||
- Creating AI functions that require approval before execution.
|
||||
- Building a workflow that includes an agent and executors.
|
||||
- Handling approval requests during workflow execution.
|
||||
|
||||
Prerequisites:
|
||||
- Azure AI Agent Service configured, along with the required environment variables.
|
||||
- Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample.
|
||||
- Basic familiarity with WorkflowBuilder, edges, events, RequestInfoEvent, and streaming runs.
|
||||
"""
|
||||
|
||||
|
||||
@ai_function
|
||||
def get_current_date() -> str:
|
||||
"""Get the current date in YYYY-MM-DD format."""
|
||||
# For demonstration purposes, we return a fixed date.
|
||||
return "2025-11-07"
|
||||
|
||||
|
||||
@ai_function
|
||||
def get_team_members_email_addresses() -> list[dict[str, str]]:
|
||||
"""Get the email addresses of team members."""
|
||||
# In a real implementation, this might query a database or directory service.
|
||||
return [
|
||||
{
|
||||
"name": "Alice",
|
||||
"email": "alice@contoso.com",
|
||||
"position": "Software Engineer",
|
||||
"manager": "John Doe",
|
||||
},
|
||||
{
|
||||
"name": "Bob",
|
||||
"email": "bob@contoso.com",
|
||||
"position": "Product Manager",
|
||||
"manager": "John Doe",
|
||||
},
|
||||
{
|
||||
"name": "Charlie",
|
||||
"email": "charlie@contoso.com",
|
||||
"position": "Senior Software Engineer",
|
||||
"manager": "John Doe",
|
||||
},
|
||||
{
|
||||
"name": "Mike",
|
||||
"email": "mike@contoso.com",
|
||||
"position": "Principal Software Engineer Manager",
|
||||
"manager": "VP of Engineering",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@ai_function
|
||||
def get_my_information() -> dict[str, str]:
|
||||
"""Get my personal information."""
|
||||
return {
|
||||
"name": "John Doe",
|
||||
"email": "john@contoso.com",
|
||||
"position": "Software Engineer Manager",
|
||||
"manager": "Mike",
|
||||
}
|
||||
|
||||
|
||||
@ai_function(approval_mode="always_require")
|
||||
async def read_historical_email_data(
|
||||
email_address: Annotated[str, "The email address to read historical data from"],
|
||||
start_date: Annotated[str, "The start date in YYYY-MM-DD format"],
|
||||
end_date: Annotated[str, "The end date in YYYY-MM-DD format"],
|
||||
) -> list[dict[str, str]]:
|
||||
"""Read historical email data for a given email address and date range."""
|
||||
historical_data = {
|
||||
"alice@contoso.com": [
|
||||
{
|
||||
"from": "alice@contoso.com",
|
||||
"to": "john@contoso.com",
|
||||
"date": "2025-11-05",
|
||||
"subject": "Bug Bash Results",
|
||||
"body": "We just completed the bug bash and found a few issues that need immediate attention.",
|
||||
},
|
||||
{
|
||||
"from": "alice@contoso.com",
|
||||
"to": "john@contoso.com",
|
||||
"date": "2025-11-03",
|
||||
"subject": "Code Freeze",
|
||||
"body": "We are entering code freeze starting tomorrow.",
|
||||
},
|
||||
],
|
||||
"bob@contoso.com": [
|
||||
{
|
||||
"from": "bob@contoso.com",
|
||||
"to": "john@contoso.com",
|
||||
"date": "2025-11-04",
|
||||
"subject": "Team Outing",
|
||||
"body": "Don't forget about the team outing this Friday!",
|
||||
},
|
||||
{
|
||||
"from": "bob@contoso.com",
|
||||
"to": "john@contoso.com",
|
||||
"date": "2025-11-02",
|
||||
"subject": "Requirements Update",
|
||||
"body": "The requirements for the new feature have been updated. Please review them.",
|
||||
},
|
||||
],
|
||||
"charlie@contoso.com": [
|
||||
{
|
||||
"from": "charlie@contoso.com",
|
||||
"to": "john@contoso.com",
|
||||
"date": "2025-11-05",
|
||||
"subject": "Project Update",
|
||||
"body": "The bug bash went well. A few critical bugs but should be fixed by the end of the week.",
|
||||
},
|
||||
{
|
||||
"from": "charlie@contoso.com",
|
||||
"to": "john@contoso.com",
|
||||
"date": "2025-11-06",
|
||||
"subject": "Code Review",
|
||||
"body": "Please review my latest code changes.",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
emails = historical_data.get(email_address, [])
|
||||
return [email for email in emails if start_date <= email["date"] <= end_date]
|
||||
|
||||
|
||||
@ai_function(approval_mode="always_require")
|
||||
async def send_email(
|
||||
to: Annotated[str, "The recipient email address"],
|
||||
subject: Annotated[str, "The email subject"],
|
||||
body: Annotated[str, "The email body"],
|
||||
) -> str:
|
||||
"""Send an email."""
|
||||
await asyncio.sleep(1) # Simulate sending email
|
||||
return "Email successfully sent."
|
||||
|
||||
|
||||
@dataclass
|
||||
class Email:
|
||||
sender: str
|
||||
subject: str
|
||||
body: str
|
||||
|
||||
|
||||
class EmailPreprocessor(Executor):
|
||||
def __init__(self, special_email_addresses: set[str]) -> None:
|
||||
super().__init__(id="email_preprocessor")
|
||||
self.special_email_addresses = special_email_addresses
|
||||
|
||||
@handler
|
||||
async def preprocess(self, email: Email, ctx: WorkflowContext[str]) -> None:
|
||||
"""Preprocess the incoming email."""
|
||||
message = str(email)
|
||||
if email.sender in self.special_email_addresses:
|
||||
note = (
|
||||
"Pay special attention to this sender. This email is very important. "
|
||||
"Gather relevant information from all previous emails within my team before responding."
|
||||
)
|
||||
message = f"{note}\n\n{message}"
|
||||
|
||||
await ctx.send_message(message)
|
||||
|
||||
|
||||
@executor(id="conclude_workflow_executor")
|
||||
async def conclude_workflow(
|
||||
email_response: AgentExecutorResponse,
|
||||
ctx: WorkflowContext[Never, str],
|
||||
) -> None:
|
||||
"""Conclude the workflow by yielding the final email response."""
|
||||
await ctx.yield_output(email_response.agent_run_response.text)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
# Create the agent and executors
|
||||
chat_client = OpenAIChatClient()
|
||||
email_writer = chat_client.create_agent(
|
||||
name="Email Writer",
|
||||
instructions=("You are an excellent email assistant. You respond to incoming emails."),
|
||||
# tools with `approval_mode="always_require"` will trigger approval requests
|
||||
tools=[
|
||||
read_historical_email_data,
|
||||
send_email,
|
||||
get_current_date,
|
||||
get_team_members_email_addresses,
|
||||
get_my_information,
|
||||
],
|
||||
)
|
||||
email_preprocessor = EmailPreprocessor(special_email_addresses={"mike@contoso.com"})
|
||||
|
||||
# Build the workflow
|
||||
workflow = (
|
||||
WorkflowBuilder()
|
||||
.set_start_executor(email_preprocessor)
|
||||
.add_edge(email_preprocessor, email_writer)
|
||||
.add_edge(email_writer, conclude_workflow)
|
||||
.build()
|
||||
)
|
||||
|
||||
# Simulate an incoming email
|
||||
incoming_email = Email(
|
||||
sender="mike@contoso.com",
|
||||
subject="Important: Project Update",
|
||||
body="Please provide your team's status update on the project since last week.",
|
||||
)
|
||||
|
||||
responses: dict[str, FunctionApprovalResponseContent] = {}
|
||||
output: list[ChatMessage] | None = None
|
||||
while True:
|
||||
if responses:
|
||||
events = await workflow.send_responses(responses)
|
||||
responses.clear()
|
||||
else:
|
||||
events = await workflow.run(incoming_email)
|
||||
|
||||
request_info_events = events.get_request_info_events()
|
||||
for request_info_event in request_info_events:
|
||||
# We should only expect FunctionApprovalRequestContent in this sample
|
||||
if not isinstance(request_info_event.data, FunctionApprovalRequestContent):
|
||||
raise ValueError(f"Unexpected request info content type: {type(request_info_event.data)}")
|
||||
|
||||
# Pretty print the function call details
|
||||
arguments = json.dumps(request_info_event.data.function_call.parse_arguments(), indent=2)
|
||||
print(
|
||||
f"Received approval request for function: {request_info_event.data.function_call.name} "
|
||||
f"with args:\n{arguments}"
|
||||
)
|
||||
|
||||
# For demo purposes, we automatically approve the request
|
||||
# The expected response type of the request is `FunctionApprovalResponseContent`,
|
||||
# which can be created via `create_response` method on the request content
|
||||
print("Performing automatic approval for demo purposes...")
|
||||
responses[request_info_event.request_id] = request_info_event.data.create_response(approved=True)
|
||||
|
||||
# Once we get an output event, we can conclude the workflow
|
||||
# Outputs can only be produced by the conclude_workflow_executor in this sample
|
||||
if outputs := events.get_outputs():
|
||||
# We expect only one output from the conclude_workflow_executor
|
||||
output = outputs[0]
|
||||
break
|
||||
|
||||
if not output:
|
||||
raise RuntimeError("Workflow did not produce any output event.")
|
||||
|
||||
print("Final email response conversation:")
|
||||
print(output)
|
||||
|
||||
"""
|
||||
Sample Output:
|
||||
Received approval request for function: read_historical_email_data with args:
|
||||
{
|
||||
"email_address": "alice@contoso.com",
|
||||
"start_date": "2025-10-31",
|
||||
"end_date": "2025-11-07"
|
||||
}
|
||||
Performing automatic approval for demo purposes...
|
||||
Received approval request for function: read_historical_email_data with args:
|
||||
{
|
||||
"email_address": "bob@contoso.com",
|
||||
"start_date": "2025-10-31",
|
||||
"end_date": "2025-11-07"
|
||||
}
|
||||
Performing automatic approval for demo purposes...
|
||||
Received approval request for function: read_historical_email_data with args:
|
||||
{
|
||||
"email_address": "charlie@contoso.com",
|
||||
"start_date": "2025-10-31",
|
||||
"end_date": "2025-11-07"
|
||||
}
|
||||
Performing automatic approval for demo purposes...
|
||||
Received approval request for function: send_email with args:
|
||||
{
|
||||
"to": "mike@contoso.com",
|
||||
"subject": "Team's Status Update on the Project",
|
||||
"body": "
|
||||
Hi Mike,
|
||||
|
||||
Here's the status update from our team:
|
||||
- **Bug Bash and Code Freeze:**
|
||||
- We recently completed a bug bash, during which several issues were identified. Alice and Charlie are working on fixing these critical bugs, and we anticipate resolving them by the end of this week.
|
||||
- We have entered a code freeze as of November 4, 2025.
|
||||
|
||||
- **Requirements Update:**
|
||||
- Bob has updated the requirements for a new feature, and all team members are reviewing these changes to ensure alignment.
|
||||
|
||||
- **Ongoing Reviews:**
|
||||
- Charlie has submitted his latest code changes for review to ensure they meet our quality standards.
|
||||
|
||||
Please let me know if you need more detailed information or have any questions.
|
||||
|
||||
Best regards,
|
||||
John"
|
||||
}
|
||||
Performing automatic approval for demo purposes...
|
||||
Final email response conversation:
|
||||
I've sent the status update to Mike with the relevant information from the team. Let me know if there's anything else you need
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1,294 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterable
|
||||
from typing import cast
|
||||
|
||||
from agent_framework import (
|
||||
ChatAgent,
|
||||
HandoffBuilder,
|
||||
HandoffUserInputRequest,
|
||||
RequestInfoEvent,
|
||||
WorkflowEvent,
|
||||
WorkflowOutputEvent,
|
||||
)
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
"""Sample: Handoff workflow with return-to-previous routing enabled.
|
||||
|
||||
This interactive sample demonstrates the return-to-previous feature where user inputs
|
||||
route directly back to the specialist currently handling their request, rather than
|
||||
always going through the coordinator for re-evaluation.
|
||||
|
||||
Routing Pattern (with return-to-previous enabled):
|
||||
User -> Coordinator -> Technical Support -> User -> Technical Support -> ...
|
||||
|
||||
Routing Pattern (default, without return-to-previous):
|
||||
User -> Coordinator -> Technical Support -> User -> Coordinator -> Technical Support -> ...
|
||||
|
||||
This is useful when a specialist needs multiple turns with the user to gather
|
||||
information or resolve an issue, avoiding unnecessary coordinator involvement.
|
||||
|
||||
Specialist-to-Specialist Handoff:
|
||||
When a user's request changes to a topic outside the current specialist's domain,
|
||||
the specialist can hand off DIRECTLY to another specialist without going back through
|
||||
the coordinator:
|
||||
|
||||
User -> Coordinator -> Technical Support -> User -> Technical Support (billing question)
|
||||
-> Billing -> User -> Billing ...
|
||||
|
||||
Example Interaction:
|
||||
1. User reports a technical issue
|
||||
2. Coordinator routes to technical support specialist
|
||||
3. Technical support asks clarifying questions
|
||||
4. User provides details (routes directly back to technical support)
|
||||
5. Technical support continues troubleshooting with full context
|
||||
6. Issue resolved, user asks about billing
|
||||
7. Technical support hands off DIRECTLY to billing specialist
|
||||
8. Billing specialist helps with payment
|
||||
9. User continues with billing (routes directly to billing)
|
||||
|
||||
Prerequisites:
|
||||
- `az login` (Azure CLI authentication)
|
||||
- Environment variables configured for AzureOpenAIChatClient (AZURE_OPENAI_ENDPOINT, etc.)
|
||||
|
||||
Usage:
|
||||
Run the script and interact with the support workflow by typing your requests.
|
||||
Type 'exit' or 'quit' to end the conversation.
|
||||
|
||||
Key Concepts:
|
||||
- Return-to-previous: Direct routing to current agent handling the conversation
|
||||
- Current agent tracking: Framework remembers which agent is actively helping the user
|
||||
- Context preservation: Specialist maintains full conversation context
|
||||
- Domain switching: Specialists can hand back to coordinator when topic changes
|
||||
"""
|
||||
|
||||
|
||||
def create_agents(chat_client: AzureOpenAIChatClient) -> tuple[ChatAgent, ChatAgent, ChatAgent, ChatAgent]:
|
||||
"""Create and configure the coordinator and specialist agents.
|
||||
|
||||
Returns:
|
||||
Tuple of (coordinator, technical_support, account_specialist, billing_agent)
|
||||
"""
|
||||
coordinator = chat_client.create_agent(
|
||||
instructions=(
|
||||
"You are a customer support coordinator. Analyze the user's request and route to "
|
||||
"the appropriate specialist:\n"
|
||||
"- technical_support for technical issues, troubleshooting, repairs, hardware/software problems\n"
|
||||
"- account_specialist for account changes, profile updates, settings, login issues\n"
|
||||
"- billing_agent for payments, invoices, refunds, charges, billing questions\n"
|
||||
"\n"
|
||||
"When you receive a request, immediately call the matching handoff tool without explaining. "
|
||||
"Read the most recent user message to determine the correct specialist."
|
||||
),
|
||||
name="coordinator",
|
||||
)
|
||||
|
||||
technical_support = chat_client.create_agent(
|
||||
instructions=(
|
||||
"You provide technical support. Help users troubleshoot technical issues, "
|
||||
"arrange repairs, and answer technical questions. "
|
||||
"Gather information through conversation. "
|
||||
"If the user asks about billing, payments, invoices, or refunds, hand off to billing_agent. "
|
||||
"If the user asks about account settings or profile changes, hand off to account_specialist."
|
||||
),
|
||||
name="technical_support",
|
||||
)
|
||||
|
||||
account_specialist = chat_client.create_agent(
|
||||
instructions=(
|
||||
"You handle account management. Help with profile updates, account settings, "
|
||||
"and preferences. Gather information through conversation. "
|
||||
"If the user asks about technical issues or troubleshooting, hand off to technical_support. "
|
||||
"If the user asks about billing, payments, invoices, or refunds, hand off to billing_agent."
|
||||
),
|
||||
name="account_specialist",
|
||||
)
|
||||
|
||||
billing_agent = chat_client.create_agent(
|
||||
instructions=(
|
||||
"You handle billing only. Process payments, explain invoices, handle refunds. "
|
||||
"If the user asks about technical issues or troubleshooting, hand off to technical_support. "
|
||||
"If the user asks about account settings or profile changes, hand off to account_specialist."
|
||||
),
|
||||
name="billing_agent",
|
||||
)
|
||||
|
||||
return coordinator, technical_support, account_specialist, billing_agent
|
||||
|
||||
|
||||
def handle_events(events: list[WorkflowEvent]) -> list[RequestInfoEvent]:
|
||||
"""Process events and return pending input requests."""
|
||||
pending_requests: list[RequestInfoEvent] = []
|
||||
for event in events:
|
||||
if isinstance(event, RequestInfoEvent):
|
||||
pending_requests.append(event)
|
||||
request_data = cast(HandoffUserInputRequest, event.data)
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"AWAITING INPUT FROM: {request_data.awaiting_agent_id.upper()}")
|
||||
print(f"{'=' * 60}")
|
||||
for msg in request_data.conversation[-3:]:
|
||||
author = msg.author_name or msg.role.value
|
||||
prefix = ">>> " if author == request_data.awaiting_agent_id else " "
|
||||
print(f"{prefix}[{author}]: {msg.text}")
|
||||
elif isinstance(event, WorkflowOutputEvent):
|
||||
print(f"\n{'=' * 60}")
|
||||
print("[WORKFLOW COMPLETE]")
|
||||
print(f"{'=' * 60}")
|
||||
return pending_requests
|
||||
|
||||
|
||||
async def _drain(stream: AsyncIterable[WorkflowEvent]) -> list[WorkflowEvent]:
|
||||
"""Drain an async iterable into a list."""
|
||||
events: list[WorkflowEvent] = []
|
||||
async for event in stream:
|
||||
events.append(event)
|
||||
return events
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Demonstrate return-to-previous routing in a handoff workflow."""
|
||||
chat_client = AzureOpenAIChatClient(credential=AzureCliCredential())
|
||||
coordinator, technical, account, billing = create_agents(chat_client)
|
||||
|
||||
print("Handoff Workflow with Return-to-Previous Routing")
|
||||
print("=" * 60)
|
||||
print("\nThis interactive demo shows how user inputs route directly")
|
||||
print("to the specialist handling your request, avoiding unnecessary")
|
||||
print("coordinator re-evaluation on each turn.")
|
||||
print("\nSpecialists can hand off directly to other specialists when")
|
||||
print("your request changes topics (e.g., from technical to billing).")
|
||||
print("\nType 'exit' or 'quit' to end the conversation.\n")
|
||||
|
||||
# Configure handoffs with return-to-previous enabled
|
||||
# Specialists can hand off directly to other specialists when topic changes
|
||||
workflow = (
|
||||
HandoffBuilder(
|
||||
name="return_to_previous_demo",
|
||||
participants=[coordinator, technical, account, billing],
|
||||
)
|
||||
.set_coordinator(coordinator)
|
||||
.add_handoff(coordinator, [technical, account, billing]) # Coordinator routes to all specialists
|
||||
.add_handoff(technical, [billing, account]) # Technical can route to billing or account
|
||||
.add_handoff(account, [technical, billing]) # Account can route to technical or billing
|
||||
.add_handoff(billing, [technical, account]) # Billing can route to technical or account
|
||||
.enable_return_to_previous(True) # Enable the `return to previous handoff` feature
|
||||
.with_termination_condition(lambda conv: sum(1 for msg in conv if msg.role.value == "user") >= 10)
|
||||
.build()
|
||||
)
|
||||
|
||||
# Get initial user request
|
||||
initial_request = input("You: ").strip() # noqa: ASYNC250
|
||||
if not initial_request or initial_request.lower() in ["exit", "quit"]:
|
||||
print("Goodbye!")
|
||||
return
|
||||
|
||||
# Start workflow with initial message
|
||||
events = await _drain(workflow.run_stream(initial_request))
|
||||
pending_requests = handle_events(events)
|
||||
|
||||
# Interactive loop: keep prompting for user input
|
||||
while pending_requests:
|
||||
user_input = input("\nYou: ").strip() # noqa: ASYNC250
|
||||
|
||||
if not user_input or user_input.lower() in ["exit", "quit"]:
|
||||
print("\nEnding conversation. Goodbye!")
|
||||
break
|
||||
|
||||
responses = {req.request_id: user_input for req in pending_requests}
|
||||
events = await _drain(workflow.send_responses_streaming(responses))
|
||||
pending_requests = handle_events(events)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Conversation ended.")
|
||||
|
||||
"""
|
||||
Sample Output:
|
||||
|
||||
Handoff Workflow with Return-to-Previous Routing
|
||||
============================================================
|
||||
|
||||
This interactive demo shows how user inputs route directly
|
||||
to the specialist handling your request, avoiding unnecessary
|
||||
coordinator re-evaluation on each turn.
|
||||
|
||||
Specialists can hand off directly to other specialists when
|
||||
your request changes topics (e.g., from technical to billing).
|
||||
|
||||
Type 'exit' or 'quit' to end the conversation.
|
||||
|
||||
You: I need help with my bill, I was charged twice by mistake.
|
||||
|
||||
============================================================
|
||||
AWAITING INPUT FROM: BILLING_AGENT
|
||||
============================================================
|
||||
[user]: I need help with my bill, I was charged twice by mistake.
|
||||
[coordinator]: You will be connected to a billing agent who can assist you with the double charge on your bill.
|
||||
>>> [billing_agent]: I'm here to help with billing concerns! I'm sorry you were charged twice. Could you
|
||||
please provide the invoice number or your account email so I can look into this and begin processing a refund?
|
||||
|
||||
You: Invoice 1234
|
||||
|
||||
============================================================
|
||||
AWAITING INPUT FROM: BILLING_AGENT
|
||||
============================================================
|
||||
>>> [billing_agent]: I'm here to help with billing concerns! I'm sorry you were charged twice.
|
||||
Could you please provide the invoice number or your account email so I can look into this and begin
|
||||
processing a refund?
|
||||
[user]: Invoice 1234
|
||||
>>> [billing_agent]: Thank you for providing the invoice number (1234). I will review the details and work
|
||||
on processing a refund for the duplicate charge.
|
||||
|
||||
Can you confirm which payment method you used for this bill (e.g., credit card, PayPal)?
|
||||
This helps ensure your refund is processed to the correct account.
|
||||
|
||||
You: I used my credit card, which is on autopay.
|
||||
|
||||
============================================================
|
||||
AWAITING INPUT FROM: BILLING_AGENT
|
||||
============================================================
|
||||
>>> [billing_agent]: Thank you for providing the invoice number (1234). I will review the details and work on
|
||||
processing a refund for the duplicate charge.
|
||||
|
||||
Can you confirm which payment method you used for this bill (e.g., credit card, PayPal)? This helps ensure
|
||||
your refund is processed to the correct account.
|
||||
[user]: I used my credit card, which is on autopay.
|
||||
>>> [billing_agent]: Thank you for confirming your payment method. I will look into invoice 1234 and
|
||||
process a refund for the duplicate charge to your credit card.
|
||||
|
||||
You will receive a notification once the refund is completed. If you have any further questions about your billing
|
||||
or need an update, please let me know!
|
||||
|
||||
You: Actually I also can't turn on my modem. It reset and now won't turn on.
|
||||
|
||||
============================================================
|
||||
AWAITING INPUT FROM: TECHNICAL_SUPPORT
|
||||
============================================================
|
||||
[user]: Actually I also can't turn on my modem. It reset and now won't turn on.
|
||||
[billing_agent]: I'm connecting you with technical support for assistance with your modem not turning on after
|
||||
the reset. They'll be able to help troubleshoot and resolve this issue.
|
||||
|
||||
At the same time, technical support will also handle your refund request for the duplicate charge on invoice 1234
|
||||
to your credit card on autopay.
|
||||
|
||||
You will receive updates from the appropriate teams shortly.
|
||||
>>> [technical_support]: Thanks for letting me know about your modem issue! To help you further, could you tell me:
|
||||
|
||||
1. Is there any light showing on the modem at all, or is it completely off?
|
||||
2. Have you tried unplugging the modem from power and plugging it back in?
|
||||
3. Do you hear or feel anything (like a slight hum or vibration) when the modem is plugged in?
|
||||
|
||||
Let me know, and I'll guide you through troubleshooting or arrange a repair if needed.
|
||||
|
||||
You: exit
|
||||
|
||||
Ending conversation. Goodbye!
|
||||
|
||||
============================================================
|
||||
Conversation ended.
|
||||
"""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user