mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Improve the workflow getting started samples (#570)
* Wip: samples * wip - samples * Updates to workflow getting started samples * Checkpointing enhancements * Cleanup * PR feedback * Updates * Sample updates * Updates * Revamp samples, improve doc strings and code comments * Cleanup unused comment * Formatting cleanup * wip * Further work on samples. Allow agent to be specified as edge. * Cleanup * Typing cleanup * Sample updates --------- Co-authored-by: Chris <66376200+crickman@users.noreply.github.com> Co-authored-by: Eric Zhu <ekzhu@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
cd0587c5f6
commit
518fd447fd
@@ -0,0 +1,233 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import ChatMessage, Role # Core chat primitives used to build requests
|
||||
from agent_framework.azure import AzureChatClient # Thin client wrapper for Azure OpenAI chat models
|
||||
from agent_framework.workflow import (
|
||||
AgentExecutor, # Wraps an LLM agent that can be invoked inside a workflow
|
||||
AgentExecutorRequest, # Input message bundle for an AgentExecutor
|
||||
AgentExecutorResponse, # Output from an AgentExecutor
|
||||
WorkflowBuilder, # Fluent builder for wiring executors and edges
|
||||
WorkflowCompletedEvent, # Event we emit at the end to signal completion
|
||||
WorkflowContext, # Per-run context and event bus
|
||||
executor, # Decorator to declare a Python function as a workflow executor
|
||||
)
|
||||
from azure.identity import AzureCliCredential # Uses your az CLI login for credentials
|
||||
from pydantic import BaseModel # Structured outputs for safer parsing
|
||||
|
||||
"""
|
||||
Sample: Conditional routing with structured outputs
|
||||
|
||||
What this sample is:
|
||||
- A minimal decision workflow that classifies an inbound email as spam or not spam, then routes to the
|
||||
appropriate handler.
|
||||
|
||||
Purpose:
|
||||
- Show how to attach boolean edge conditions that inspect an AgentExecutorResponse.
|
||||
- Demonstrate using Pydantic models as response_format so the agent returns JSON we can validate and parse.
|
||||
- Illustrate how to transform one agent's structured result into a new AgentExecutorRequest for a downstream agent.
|
||||
|
||||
Prerequisites:
|
||||
- You understand the basics of WorkflowBuilder, executors, and events in this framework.
|
||||
- You know the concept of edge conditions and how they gate routes using a predicate function.
|
||||
- Azure OpenAI access is configured for AzureChatClient. You should be logged in with Azure CLI (AzureCliCredential)
|
||||
and have the Azure OpenAI environment variables set as documented in the getting started chat client README.
|
||||
- The sample email resource file exists at workflow/resources/email.txt.
|
||||
|
||||
High level flow:
|
||||
1) spam_detection_agent reads an email and returns DetectionResult.
|
||||
2) If not spam, we transform the detection output into a user message for email_assistant_agent, then finish by
|
||||
sending the drafted reply.
|
||||
3) If spam, we short circuit to a spam handler that emits a completion event.
|
||||
|
||||
Output:
|
||||
- The final WorkflowCompletedEvent is printed to stdout, either with a drafted reply or a spam notice.
|
||||
|
||||
Notes:
|
||||
- Conditions read the agent response text and validate it into DetectionResult for robust routing.
|
||||
- Executors are small and single purpose to keep control flow easy to follow.
|
||||
"""
|
||||
|
||||
|
||||
class DetectionResult(BaseModel):
|
||||
"""Represents the result of spam detection."""
|
||||
|
||||
# is_spam drives the routing decision taken by edge conditions
|
||||
is_spam: bool
|
||||
# Human readable rationale from the detector
|
||||
reason: str
|
||||
# The agent must include the original email so downstream agents can operate without reloading content
|
||||
email_content: str
|
||||
|
||||
|
||||
class EmailResponse(BaseModel):
|
||||
"""Represents the response from the email assistant."""
|
||||
|
||||
# The drafted reply that a user could copy or send
|
||||
response: str
|
||||
|
||||
|
||||
def get_condition(expected_result: bool):
|
||||
"""Create a condition callable that routes based on DetectionResult.is_spam."""
|
||||
|
||||
# The returned function will be used as an edge predicate.
|
||||
# It receives whatever the upstream executor produced.
|
||||
def condition(message: Any) -> bool:
|
||||
# Defensive guard. If a non AgentExecutorResponse appears, let the edge pass to avoid dead ends.
|
||||
if not isinstance(message, AgentExecutorResponse):
|
||||
return True
|
||||
|
||||
try:
|
||||
# Prefer parsing a structured DetectionResult from the agent JSON text.
|
||||
# Using model_validate_json ensures type safety and raises if the shape is wrong.
|
||||
detection = DetectionResult.model_validate_json(message.agent_run_response.text)
|
||||
# Route only when the spam flag matches the expected path.
|
||||
return detection.is_spam == expected_result
|
||||
except Exception:
|
||||
# Fail closed on parse errors so we do not accidentally route to the wrong path.
|
||||
# Returning False prevents this edge from activating.
|
||||
return False
|
||||
|
||||
return condition
|
||||
|
||||
|
||||
@executor(id="send_email")
|
||||
async def handle_email_response(response: AgentExecutorResponse, ctx: WorkflowContext[None]) -> None:
|
||||
# Downstream of the email assistant. Parse a validated EmailResponse and emit a completion event.
|
||||
email_response = EmailResponse.model_validate_json(response.agent_run_response.text)
|
||||
await ctx.add_event(WorkflowCompletedEvent(f"Email sent:\n{email_response.response}"))
|
||||
|
||||
|
||||
@executor(id="handle_spam")
|
||||
async def handle_spam_classifier_response(response: AgentExecutorResponse, ctx: WorkflowContext[None]) -> None:
|
||||
# Spam path. Confirm the DetectionResult and finish with the reason. Guard against accidental non spam input.
|
||||
detection = DetectionResult.model_validate_json(response.agent_run_response.text)
|
||||
if detection.is_spam:
|
||||
await ctx.add_event(WorkflowCompletedEvent(f"Email marked as spam: {detection.reason}"))
|
||||
else:
|
||||
# This indicates the routing predicate and executor contract are out of sync.
|
||||
raise RuntimeError("This executor should only handle spam messages.")
|
||||
|
||||
|
||||
@executor(id="to_email_assistant_request")
|
||||
async def to_email_assistant_request(
|
||||
response: AgentExecutorResponse, ctx: WorkflowContext[AgentExecutorRequest]
|
||||
) -> None:
|
||||
"""Transform detection result into an AgentExecutorRequest for the email assistant.
|
||||
|
||||
Extracts DetectionResult.email_content and forwards it as a user message.
|
||||
"""
|
||||
# Bridge executor. Converts a structured DetectionResult into a ChatMessage and forwards it as a new request.
|
||||
detection = DetectionResult.model_validate_json(response.agent_run_response.text)
|
||||
user_msg = ChatMessage(Role.USER, text=detection.email_content)
|
||||
await ctx.send_message(AgentExecutorRequest(messages=[user_msg], should_respond=True))
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
# Create agents
|
||||
# AzureCliCredential uses your current az login. This avoids embedding secrets in code.
|
||||
chat_client = AzureChatClient(credential=AzureCliCredential())
|
||||
|
||||
# Agent 1. Classifies spam and returns a DetectionResult object.
|
||||
# response_format enforces that the LLM returns parsable JSON for the Pydantic model.
|
||||
spam_detection_agent = AgentExecutor(
|
||||
chat_client.create_agent(
|
||||
instructions=(
|
||||
"You are a spam detection assistant that identifies spam emails. "
|
||||
"Always return JSON with fields is_spam (bool), reason (string), and email_content (string). "
|
||||
"Include the original email content in email_content."
|
||||
),
|
||||
response_format=DetectionResult,
|
||||
),
|
||||
id="spam_detection_agent",
|
||||
)
|
||||
|
||||
# Agent 2. Drafts a professional reply. Also uses structured JSON output for reliability.
|
||||
email_assistant_agent = AgentExecutor(
|
||||
chat_client.create_agent(
|
||||
instructions=(
|
||||
"You are an email assistant that helps users draft professional responses to emails. "
|
||||
"Your input may be a JSON object that includes 'email_content'; base your reply on that content. "
|
||||
"Return JSON with a single field 'response' containing the drafted reply."
|
||||
),
|
||||
response_format=EmailResponse,
|
||||
),
|
||||
id="email_assistant_agent",
|
||||
)
|
||||
|
||||
# Build the workflow graph.
|
||||
# Start at the spam detector.
|
||||
# If not spam, hop to a transformer that creates a new AgentExecutorRequest,
|
||||
# then call the email assistant, then finalize.
|
||||
# If spam, go directly to the spam handler and finalize.
|
||||
workflow = (
|
||||
WorkflowBuilder()
|
||||
.set_start_executor(spam_detection_agent)
|
||||
# Not spam path: transform response -> request for assistant -> assistant -> send email
|
||||
.add_edge(spam_detection_agent, to_email_assistant_request, condition=get_condition(False))
|
||||
.add_edge(to_email_assistant_request, email_assistant_agent)
|
||||
.add_edge(email_assistant_agent, handle_email_response)
|
||||
# Spam path: send to spam handler
|
||||
.add_edge(spam_detection_agent, handle_spam_classifier_response, condition=get_condition(True))
|
||||
.build()
|
||||
)
|
||||
|
||||
# Read Email content from the sample resource file.
|
||||
# This keeps the sample deterministic since the model sees the same email every run.
|
||||
email_path = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.realpath(__file__))), "workflow", "resources", "email.txt"
|
||||
)
|
||||
|
||||
with open(email_path) as email_file: # noqa: ASYNC230
|
||||
email = email_file.read()
|
||||
|
||||
# Execute the workflow. Since the start is an AgentExecutor, pass an AgentExecutorRequest.
|
||||
# run_stream yields events as they occur. We watch for the terminal WorkflowCompletedEvent and print it.
|
||||
request = AgentExecutorRequest(messages=[ChatMessage(Role.USER, text=email)], should_respond=True)
|
||||
async for event in workflow.run_stream(request):
|
||||
if isinstance(event, WorkflowCompletedEvent):
|
||||
print(f"{event}")
|
||||
|
||||
"""
|
||||
Sample Output:
|
||||
|
||||
Processing email:
|
||||
Subject: Team Meeting Follow-up - Action Items
|
||||
|
||||
Hi Sarah,
|
||||
|
||||
I wanted to follow up on our team meeting this morning and share the action items we discussed:
|
||||
|
||||
1. Update the project timeline by Friday
|
||||
2. Schedule client presentation for next week
|
||||
3. Review the budget allocation for Q4
|
||||
|
||||
Please let me know if you have any questions or if I missed anything from our discussion.
|
||||
|
||||
Best regards,
|
||||
Alex Johnson
|
||||
Project Manager
|
||||
Tech Solutions Inc.
|
||||
alex.johnson@techsolutions.com
|
||||
(555) 123-4567
|
||||
----------------------------------------
|
||||
|
||||
WorkflowCompletedEvent(data=Email sent:
|
||||
Hi Alex,
|
||||
|
||||
Thank you for the follow-up and for summarizing the action items from this morning's meeting. The points you listed accurately reflect our discussion, and I don't have any additional items to add at this time.
|
||||
|
||||
I will update the project timeline by Friday, begin scheduling the client presentation for next week, and start reviewing the Q4 budget allocation. If any questions or issues arise, I'll reach out.
|
||||
|
||||
Thank you again for outlining the next steps.
|
||||
|
||||
Best regards,
|
||||
Sarah)
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Step 06b — Multi-Selection Edge Group sample."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
from uuid import uuid4
|
||||
|
||||
from agent_framework import ChatMessage, Role
|
||||
from agent_framework.azure import AzureChatClient
|
||||
from agent_framework.workflow import (
|
||||
AgentExecutor,
|
||||
AgentExecutorRequest,
|
||||
AgentExecutorResponse,
|
||||
WorkflowBuilder,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowContext,
|
||||
WorkflowEvent,
|
||||
executor,
|
||||
)
|
||||
from azure.identity import AzureCliCredential
|
||||
from pydantic import BaseModel
|
||||
|
||||
"""
|
||||
Sample: Multi-Selection Edge Group for email triage and response.
|
||||
|
||||
The workflow stores an email,
|
||||
classifies it as NotSpam, Spam, or Uncertain, and then routes to one or more branches.
|
||||
Non-spam emails are drafted into replies, long ones are also summarized, spam is blocked, and uncertain cases are
|
||||
flagged. Each path ends with simulated database persistence.
|
||||
|
||||
Purpose:
|
||||
Demonstrate how to use a multi-selection edge group to fan out from one executor to multiple possible targets.
|
||||
Show how to:
|
||||
- Implement a selection function that chooses one or more downstream branches based on analysis.
|
||||
- Share state across branches so different executors can read the same email content.
|
||||
- Validate agent outputs with Pydantic models for robust structured data exchange.
|
||||
- Merge results from multiple branches (e.g., a summary) back into a typed state.
|
||||
- Apply conditional persistence logic (short vs long emails).
|
||||
|
||||
Prerequisites:
|
||||
- Familiarity with WorkflowBuilder, executors, edges, and events.
|
||||
- Understanding of multi-selection edge groups and how their selection function maps to target ids.
|
||||
- Experience with shared state in workflows for persisting and reusing objects.
|
||||
"""
|
||||
|
||||
|
||||
EMAIL_STATE_PREFIX = "email:"
|
||||
CURRENT_EMAIL_ID_KEY = "current_email_id"
|
||||
LONG_EMAIL_THRESHOLD = 100
|
||||
|
||||
|
||||
class AnalysisResultAgent(BaseModel):
|
||||
spam_decision: Literal["NotSpam", "Spam", "Uncertain"]
|
||||
reason: str
|
||||
|
||||
|
||||
class EmailResponse(BaseModel):
|
||||
response: str
|
||||
|
||||
|
||||
class EmailSummaryModel(BaseModel):
|
||||
summary: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Email:
|
||||
email_id: str
|
||||
email_content: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnalysisResult:
|
||||
spam_decision: str
|
||||
reason: str
|
||||
email_length: int
|
||||
email_summary: str
|
||||
email_id: str
|
||||
|
||||
|
||||
class DatabaseEvent(WorkflowEvent): ...
|
||||
|
||||
|
||||
@executor(id="store_email")
|
||||
async def store_email(email_text: str, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
|
||||
new_email = Email(email_id=str(uuid4()), email_content=email_text)
|
||||
await ctx.set_shared_state(f"{EMAIL_STATE_PREFIX}{new_email.email_id}", new_email)
|
||||
await ctx.set_shared_state(CURRENT_EMAIL_ID_KEY, new_email.email_id)
|
||||
|
||||
await ctx.send_message(
|
||||
AgentExecutorRequest(messages=[ChatMessage(Role.USER, text=new_email.email_content)], should_respond=True)
|
||||
)
|
||||
|
||||
|
||||
@executor(id="to_analysis_result")
|
||||
async def to_analysis_result(response: AgentExecutorResponse, ctx: WorkflowContext[AnalysisResult]) -> None:
|
||||
parsed = AnalysisResultAgent.model_validate_json(response.agent_run_response.text)
|
||||
email_id: str = await ctx.get_shared_state(CURRENT_EMAIL_ID_KEY)
|
||||
email: Email = await ctx.get_shared_state(f"{EMAIL_STATE_PREFIX}{email_id}")
|
||||
await ctx.send_message(
|
||||
AnalysisResult(
|
||||
spam_decision=parsed.spam_decision,
|
||||
reason=parsed.reason,
|
||||
email_length=len(email.email_content),
|
||||
email_summary="",
|
||||
email_id=email_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@executor(id="submit_to_email_assistant")
|
||||
async def submit_to_email_assistant(analysis: AnalysisResult, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
|
||||
if analysis.spam_decision != "NotSpam":
|
||||
raise RuntimeError("This executor should only handle NotSpam messages.")
|
||||
|
||||
email: Email = await ctx.get_shared_state(f"{EMAIL_STATE_PREFIX}{analysis.email_id}")
|
||||
await ctx.send_message(
|
||||
AgentExecutorRequest(messages=[ChatMessage(Role.USER, text=email.email_content)], should_respond=True)
|
||||
)
|
||||
|
||||
|
||||
@executor(id="finalize_and_send")
|
||||
async def finalize_and_send(response: AgentExecutorResponse, ctx: WorkflowContext[None]) -> None:
|
||||
parsed = EmailResponse.model_validate_json(response.agent_run_response.text)
|
||||
await ctx.add_event(WorkflowCompletedEvent(f"Email sent: {parsed.response}"))
|
||||
|
||||
|
||||
@executor(id="summarize_email")
|
||||
async def summarize_email(analysis: AnalysisResult, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
|
||||
# Only called for long NotSpam emails by selection_func
|
||||
email: Email = await ctx.get_shared_state(f"{EMAIL_STATE_PREFIX}{analysis.email_id}")
|
||||
await ctx.send_message(
|
||||
AgentExecutorRequest(messages=[ChatMessage(Role.USER, text=email.email_content)], should_respond=True)
|
||||
)
|
||||
|
||||
|
||||
@executor(id="merge_summary")
|
||||
async def merge_summary(response: AgentExecutorResponse, ctx: WorkflowContext[AnalysisResult]) -> None:
|
||||
summary = EmailSummaryModel.model_validate_json(response.agent_run_response.text)
|
||||
email_id: str = await ctx.get_shared_state(CURRENT_EMAIL_ID_KEY)
|
||||
email: Email = await ctx.get_shared_state(f"{EMAIL_STATE_PREFIX}{email_id}")
|
||||
# Build an AnalysisResult mirroring to_analysis_result but with summary
|
||||
await ctx.send_message(
|
||||
AnalysisResult(
|
||||
spam_decision="NotSpam",
|
||||
reason="",
|
||||
email_length=len(email.email_content),
|
||||
email_summary=summary.summary,
|
||||
email_id=email_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@executor(id="handle_spam")
|
||||
async def handle_spam(analysis: AnalysisResult, ctx: WorkflowContext[None]) -> None:
|
||||
if analysis.spam_decision == "Spam":
|
||||
await ctx.add_event(WorkflowCompletedEvent(f"Email marked as spam: {analysis.reason}"))
|
||||
else:
|
||||
raise RuntimeError("This executor should only handle Spam messages.")
|
||||
|
||||
|
||||
@executor(id="handle_uncertain")
|
||||
async def handle_uncertain(analysis: AnalysisResult, ctx: WorkflowContext[None]) -> None:
|
||||
if analysis.spam_decision == "Uncertain":
|
||||
email: Email | None = await ctx.get_shared_state(f"{EMAIL_STATE_PREFIX}{analysis.email_id}")
|
||||
await ctx.add_event(
|
||||
WorkflowCompletedEvent(
|
||||
f"Email marked as uncertain: {analysis.reason}. Email content: {getattr(email, 'email_content', '')}"
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise RuntimeError("This executor should only handle Uncertain messages.")
|
||||
|
||||
|
||||
@executor(id="database_access")
|
||||
async def database_access(analysis: AnalysisResult, ctx: WorkflowContext[None]) -> None:
|
||||
# Simulate DB writes for email and analysis (and summary if present)
|
||||
await asyncio.sleep(0.05)
|
||||
await ctx.add_event(DatabaseEvent(f"Email {analysis.email_id} saved to database."))
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
# Agents
|
||||
chat_client = AzureChatClient(credential=AzureCliCredential())
|
||||
|
||||
email_analysis_agent = AgentExecutor(
|
||||
chat_client.create_agent(
|
||||
instructions=(
|
||||
"You are a spam detection assistant that identifies spam emails. "
|
||||
"Always return JSON with fields 'spam_decision' (one of NotSpam, Spam, Uncertain) "
|
||||
"and 'reason' (string)."
|
||||
),
|
||||
response_format=AnalysisResultAgent,
|
||||
),
|
||||
id="email_analysis_agent",
|
||||
)
|
||||
|
||||
email_assistant_agent = AgentExecutor(
|
||||
chat_client.create_agent(
|
||||
instructions=(
|
||||
"You are an email assistant that helps users draft responses to emails with professionalism."
|
||||
),
|
||||
response_format=EmailResponse,
|
||||
),
|
||||
id="email_assistant_agent",
|
||||
)
|
||||
|
||||
email_summary_agent = AgentExecutor(
|
||||
chat_client.create_agent(
|
||||
instructions=("You are an assistant that helps users summarize emails."),
|
||||
response_format=EmailSummaryModel,
|
||||
),
|
||||
id="email_summary_agent",
|
||||
)
|
||||
|
||||
# Build the workflow
|
||||
def select_targets(analysis: AnalysisResult, target_ids: list[str]) -> list[str]:
|
||||
# Order: [handle_spam, submit_to_email_assistant, summarize_email, handle_uncertain]
|
||||
handle_spam_id, submit_to_email_assistant_id, summarize_email_id, handle_uncertain_id = target_ids
|
||||
if analysis.spam_decision == "Spam":
|
||||
return [handle_spam_id]
|
||||
if analysis.spam_decision == "NotSpam":
|
||||
targets = [submit_to_email_assistant_id]
|
||||
if analysis.email_length > LONG_EMAIL_THRESHOLD:
|
||||
targets.append(summarize_email_id)
|
||||
return targets
|
||||
return [handle_uncertain_id]
|
||||
|
||||
workflow = (
|
||||
WorkflowBuilder()
|
||||
.set_start_executor(store_email)
|
||||
.add_edge(store_email, email_analysis_agent)
|
||||
.add_edge(email_analysis_agent, to_analysis_result)
|
||||
.add_multi_selection_edge_group(
|
||||
to_analysis_result,
|
||||
[handle_spam, submit_to_email_assistant, summarize_email, handle_uncertain],
|
||||
selection_func=select_targets,
|
||||
)
|
||||
.add_edge(submit_to_email_assistant, email_assistant_agent)
|
||||
.add_edge(email_assistant_agent, finalize_and_send)
|
||||
.add_edge(summarize_email, email_summary_agent)
|
||||
.add_edge(email_summary_agent, merge_summary)
|
||||
# Save to DB if short (no summary path)
|
||||
.add_edge(to_analysis_result, database_access, condition=lambda r: r.email_length <= LONG_EMAIL_THRESHOLD)
|
||||
# Save to DB with summary when long
|
||||
.add_edge(merge_summary, database_access)
|
||||
.build()
|
||||
)
|
||||
|
||||
# Read an email sample
|
||||
resources_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "resources", "email.txt")
|
||||
if os.path.exists(resources_path):
|
||||
with open(resources_path, encoding="utf-8") as f: # noqa: ASYNC230
|
||||
email = f.read()
|
||||
else:
|
||||
email = "Hello team, here are the updates for this week..."
|
||||
|
||||
async for event in workflow.run_stream(email):
|
||||
if isinstance(event, (WorkflowCompletedEvent, DatabaseEvent)):
|
||||
print(f"{event}")
|
||||
|
||||
"""
|
||||
Sample Output:
|
||||
|
||||
WorkflowCompletedEvent(data=Email sent: Hi Alex,
|
||||
|
||||
Thank you for summarizing the action items from this morning's meeting.
|
||||
I have noted the three tasks and will begin working on them right away.
|
||||
I'll aim to have the updated project timeline ready by Friday and will
|
||||
coordinate with the team to schedule the client presentation for next week.
|
||||
I'll also review the Q4 budget allocation and share my feedback soon.
|
||||
|
||||
If anything else comes up, please let me know.
|
||||
|
||||
Best regards,
|
||||
Sarah)
|
||||
DatabaseEvent(data=Email 32021432-2d4e-4c54-b04c-f81b4120340c saved to database.)
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,221 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
from uuid import uuid4
|
||||
|
||||
from agent_framework import ChatMessage, Role # Core chat primitives used to form LLM requests
|
||||
from agent_framework.azure import AzureChatClient # Thin client for Azure OpenAI chat models
|
||||
from agent_framework.workflow import (
|
||||
AgentExecutor, # Wraps an agent so it can run inside a workflow
|
||||
AgentExecutorRequest, # Message bundle sent to an AgentExecutor
|
||||
AgentExecutorResponse, # Result returned by an AgentExecutor
|
||||
Case, # Case entry for a switch-case edge group
|
||||
Default, # Default branch when no cases match
|
||||
WorkflowBuilder, # Fluent builder for assembling the graph
|
||||
WorkflowCompletedEvent, # Terminal event for successful completion
|
||||
WorkflowContext, # Per-run context and event bus
|
||||
executor, # Decorator to turn a function into a workflow executor
|
||||
)
|
||||
from azure.identity import AzureCliCredential # Uses your az CLI login for credentials
|
||||
from pydantic import BaseModel # Structured outputs with validation
|
||||
|
||||
"""
|
||||
Sample: Switch-Case Edge Group with an explicit Uncertain branch.
|
||||
|
||||
The workflow stores a single email in shared state, asks a spam detection agent for a three way decision,
|
||||
then routes with a switch-case group: NotSpam to the drafting assistant, Spam to a spam handler, and
|
||||
Default to an Uncertain handler.
|
||||
|
||||
Purpose:
|
||||
Demonstrate deterministic one of N routing with switch-case edges. Show how to:
|
||||
- Persist input once in shared state, then pass around a small typed pointer that carries the email id.
|
||||
- Validate agent JSON with Pydantic models for robust parsing.
|
||||
- Keep executor responsibilities narrow. Transform model output to a typed DetectionResult, then route based
|
||||
on that type.
|
||||
|
||||
Prerequisites:
|
||||
- Familiarity with WorkflowBuilder, executors, edges, and events.
|
||||
- Understanding of switch-case edge groups and how Case and Default are evaluated in order.
|
||||
- Working Azure OpenAI configuration for AzureChatClient, with Azure CLI login and required environment variables.
|
||||
- Access to workflow/resources/ambiguous_email.txt, or accept the inline fallback string.
|
||||
"""
|
||||
|
||||
|
||||
EMAIL_STATE_PREFIX = "email:"
|
||||
CURRENT_EMAIL_ID_KEY = "current_email_id"
|
||||
|
||||
|
||||
class DetectionResultAgent(BaseModel):
|
||||
"""Structured output returned by the spam detection agent."""
|
||||
|
||||
# The agent classifies the email and provides a rationale.
|
||||
spam_decision: Literal["NotSpam", "Spam", "Uncertain"]
|
||||
reason: str
|
||||
|
||||
|
||||
class EmailResponse(BaseModel):
|
||||
"""Structured output returned by the email assistant agent."""
|
||||
|
||||
# The drafted professional reply.
|
||||
response: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class DetectionResult:
|
||||
# Internal typed payload used for routing and downstream handling.
|
||||
spam_decision: str
|
||||
reason: str
|
||||
email_id: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Email:
|
||||
# In memory record of the email content stored in shared state.
|
||||
email_id: str
|
||||
email_content: str
|
||||
|
||||
|
||||
def get_case(expected_decision: str):
|
||||
"""Factory that returns a predicate matching a specific spam_decision value."""
|
||||
|
||||
def condition(message: Any) -> bool:
|
||||
# Only match when the upstream payload is a DetectionResult with the expected decision.
|
||||
return isinstance(message, DetectionResult) and message.spam_decision == expected_decision
|
||||
|
||||
return condition
|
||||
|
||||
|
||||
@executor(id="store_email")
|
||||
async def store_email(email_text: str, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
|
||||
# Persist the raw email once. Store under a unique key and set the current pointer for convenience.
|
||||
new_email = Email(email_id=str(uuid4()), email_content=email_text)
|
||||
await ctx.set_shared_state(f"{EMAIL_STATE_PREFIX}{new_email.email_id}", new_email)
|
||||
await ctx.set_shared_state(CURRENT_EMAIL_ID_KEY, new_email.email_id)
|
||||
|
||||
# Kick off the detector by forwarding the email as a user message to the spam_detection_agent.
|
||||
await ctx.send_message(
|
||||
AgentExecutorRequest(messages=[ChatMessage(Role.USER, text=new_email.email_content)], should_respond=True)
|
||||
)
|
||||
|
||||
|
||||
@executor(id="to_detection_result")
|
||||
async def to_detection_result(response: AgentExecutorResponse, ctx: WorkflowContext[DetectionResult]) -> None:
|
||||
# Parse the detector JSON into a typed model. Attach the current email id for downstream lookups.
|
||||
parsed = DetectionResultAgent.model_validate_json(response.agent_run_response.text)
|
||||
email_id: str = await ctx.get_shared_state(CURRENT_EMAIL_ID_KEY)
|
||||
await ctx.send_message(DetectionResult(spam_decision=parsed.spam_decision, reason=parsed.reason, email_id=email_id))
|
||||
|
||||
|
||||
@executor(id="submit_to_email_assistant")
|
||||
async def submit_to_email_assistant(detection: DetectionResult, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
|
||||
# Only proceed for the NotSpam branch. Guard against accidental misrouting.
|
||||
if detection.spam_decision != "NotSpam":
|
||||
raise RuntimeError("This executor should only handle NotSpam messages.")
|
||||
|
||||
# Load the original content from shared state using the id carried in DetectionResult.
|
||||
email: Email = await ctx.get_shared_state(f"{EMAIL_STATE_PREFIX}{detection.email_id}")
|
||||
await ctx.send_message(
|
||||
AgentExecutorRequest(messages=[ChatMessage(Role.USER, text=email.email_content)], should_respond=True)
|
||||
)
|
||||
|
||||
|
||||
@executor(id="finalize_and_send")
|
||||
async def finalize_and_send(response: AgentExecutorResponse, ctx: WorkflowContext[None]) -> None:
|
||||
# Terminal step for the drafting branch. Emit a completion event with the reply.
|
||||
parsed = EmailResponse.model_validate_json(response.agent_run_response.text)
|
||||
await ctx.add_event(WorkflowCompletedEvent(f"Email sent: {parsed.response}"))
|
||||
|
||||
|
||||
@executor(id="handle_spam")
|
||||
async def handle_spam(detection: DetectionResult, ctx: WorkflowContext[None]) -> None:
|
||||
# Spam path terminal. Include the detector's rationale.
|
||||
if detection.spam_decision == "Spam":
|
||||
await ctx.add_event(WorkflowCompletedEvent(f"Email marked as spam: {detection.reason}"))
|
||||
else:
|
||||
raise RuntimeError("This executor should only handle Spam messages.")
|
||||
|
||||
|
||||
@executor(id="handle_uncertain")
|
||||
async def handle_uncertain(detection: DetectionResult, ctx: WorkflowContext[None]) -> None:
|
||||
# Uncertain path terminal. Surface the original content to aid human review.
|
||||
if detection.spam_decision == "Uncertain":
|
||||
email: Email | None = await ctx.get_shared_state(f"{EMAIL_STATE_PREFIX}{detection.email_id}")
|
||||
await ctx.add_event(
|
||||
WorkflowCompletedEvent(
|
||||
f"Email marked as uncertain: {detection.reason}. Email content: {getattr(email, 'email_content', '')}"
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise RuntimeError("This executor should only handle Uncertain messages.")
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main function to run the workflow."""
|
||||
chat_client = AzureChatClient(credential=AzureCliCredential())
|
||||
|
||||
# Agents. response_format enforces that the LLM returns JSON that Pydantic can validate.
|
||||
spam_detection_agent = AgentExecutor(
|
||||
chat_client.create_agent(
|
||||
instructions=(
|
||||
"You are a spam detection assistant that identifies spam emails. "
|
||||
"Be less confident in your assessments. "
|
||||
"Always return JSON with fields 'spam_decision' (one of NotSpam, Spam, Uncertain) "
|
||||
"and 'reason' (string)."
|
||||
),
|
||||
response_format=DetectionResultAgent,
|
||||
),
|
||||
id="spam_detection_agent",
|
||||
)
|
||||
|
||||
email_assistant_agent = AgentExecutor(
|
||||
chat_client.create_agent(
|
||||
instructions=(
|
||||
"You are an email assistant that helps users draft responses to emails with professionalism."
|
||||
),
|
||||
response_format=EmailResponse,
|
||||
),
|
||||
id="email_assistant_agent",
|
||||
)
|
||||
|
||||
# Build workflow: store -> detection agent -> to_detection_result -> switch (NotSpam or Spam or Default).
|
||||
# The switch-case group evaluates cases in order, then falls back to Default when none match.
|
||||
workflow = (
|
||||
WorkflowBuilder()
|
||||
.set_start_executor(store_email)
|
||||
.add_edge(store_email, spam_detection_agent)
|
||||
.add_edge(spam_detection_agent, to_detection_result)
|
||||
.add_switch_case_edge_group(
|
||||
to_detection_result,
|
||||
[
|
||||
Case(condition=get_case("NotSpam"), target=submit_to_email_assistant),
|
||||
Case(condition=get_case("Spam"), target=handle_spam),
|
||||
Default(target=handle_uncertain),
|
||||
],
|
||||
)
|
||||
.add_edge(submit_to_email_assistant, email_assistant_agent)
|
||||
.add_edge(email_assistant_agent, finalize_and_send)
|
||||
.build()
|
||||
)
|
||||
|
||||
# Read ambiguous email if available. Otherwise use a simple inline sample.
|
||||
resources_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "resources", "ambiguous_email.txt")
|
||||
if os.path.exists(resources_path):
|
||||
with open(resources_path, encoding="utf-8") as f: # noqa: ASYNC230
|
||||
email = f.read()
|
||||
else:
|
||||
email = (
|
||||
"Hey there, I noticed you might be interested in our latest offer—no pressure, but it expires soon. "
|
||||
"Let me know if you'd like more details."
|
||||
)
|
||||
|
||||
# Run and print the terminal event for whichever branch completes.
|
||||
async for event in workflow.run_stream(email):
|
||||
if isinstance(event, WorkflowCompletedEvent):
|
||||
print(f"{event}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user