chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
# Shared configuration for samples/02-agents/devui
|
||||
# Used by in_memory_mode.py, main.py, and as a fallback for discovered samples.
|
||||
# Run `az login` before starting Azure-backed samples.
|
||||
|
||||
# Microsoft Foundry samples
|
||||
FOUNDRY_PROJECT_ENDPOINT=https://your-project.services.ai.azure.com
|
||||
FOUNDRY_MODEL=gpt-4o
|
||||
|
||||
# Azure OpenAI workflow sample
|
||||
AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com
|
||||
AZURE_OPENAI_CHAT_MODEL=gpt-4o
|
||||
# Optional fallback env name also supported by workflow_with_agents/workflow.py:
|
||||
AZURE_OPENAI_MODEL=gpt-4o
|
||||
# Optional if you need to override the default API version:
|
||||
AZURE_OPENAI_API_VERSION=2024-10-21
|
||||
@@ -0,0 +1,19 @@
|
||||
# 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
|
||||
*~
|
||||
@@ -0,0 +1,208 @@
|
||||
# DevUI Samples
|
||||
|
||||
This folder contains sample agents and workflows designed to work with the Agent Framework DevUI - a lightweight web interface for running and testing agents interactively.
|
||||
|
||||
## What is DevUI?
|
||||
|
||||
DevUI is a sample application that provides:
|
||||
|
||||
- A web interface for testing agents and workflows
|
||||
- OpenAI-compatible API endpoints
|
||||
- Directory-based entity discovery
|
||||
- In-memory entity registration
|
||||
- Sample entity gallery
|
||||
|
||||
> **Note**: DevUI is a sample app for development and testing. For production use, build your own custom interface using the Agent Framework SDK.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Option 1: In-Memory Mode (Programmatic Registration)
|
||||
|
||||
Run a single sample directly. This demonstrates how to register agents and workflows in code without using DevUI's directory discovery.
|
||||
|
||||
This sample uses Azure AI Foundry. Before running it:
|
||||
|
||||
1. Copy `.env.example` in this folder to `.env`, or export the same values in your shell
|
||||
2. Set `FOUNDRY_PROJECT_ENDPOINT` and `FOUNDRY_MODEL`
|
||||
3. Run `az login`
|
||||
|
||||
Then start the sample:
|
||||
|
||||
```bash
|
||||
cd python/samples/02-agents/devui
|
||||
python in_memory_mode.py
|
||||
```
|
||||
|
||||
This opens your browser at http://localhost:8090 with two Foundry-backed agents and a simple text transformation workflow.
|
||||
|
||||
### Option 2: Directory Discovery with Shared Root `.env`
|
||||
|
||||
Run the folder-level launcher to load `samples/02-agents/devui/.env` and then start DevUI with directory discovery for this folder:
|
||||
|
||||
```bash
|
||||
cd python/samples/02-agents/devui
|
||||
python main.py
|
||||
```
|
||||
|
||||
This starts the server at http://localhost:8080 with all discoverable agents and workflows available. The root `.env` acts as shared fallback configuration for discovered samples.
|
||||
|
||||
### Option 3: Directory Discovery with the `devui` CLI
|
||||
|
||||
If you prefer the CLI directly, you can still launch DevUI from this folder:
|
||||
|
||||
```bash
|
||||
cd python/samples/02-agents/devui
|
||||
devui .
|
||||
```
|
||||
|
||||
DevUI discovery checks for a sample-specific `.env` first and then falls back to `.env` in `samples/02-agents/devui/`.
|
||||
|
||||
## Sample Structure
|
||||
|
||||
DevUI discovers samples from Python packages that export either `agent` or `workflow`.
|
||||
|
||||
Typical agent layout:
|
||||
|
||||
```
|
||||
agent_name/
|
||||
├── __init__.py # Must export: agent = ...
|
||||
├── agent.py # Agent implementation
|
||||
└── .env.example # Optional example environment variables
|
||||
```
|
||||
|
||||
Typical workflow layout:
|
||||
|
||||
```
|
||||
workflow_name/
|
||||
├── __init__.py # Must export: workflow = ...
|
||||
├── workflow.py # Workflow implementation
|
||||
├── workflow.yaml # Optional declarative definition
|
||||
└── .env.example # Optional example environment variables
|
||||
```
|
||||
|
||||
## Available Samples
|
||||
|
||||
### Agents
|
||||
|
||||
| Sample | What it demonstrates | Required keys / auth |
|
||||
| ------ | -------------------- | -------------------- |
|
||||
| [**agent_weather/**](agent_weather/) | A richer Foundry-backed weather agent that shows chat middleware, function middleware, tool calling, and an approval-required tool alongside auto-approved tools. | `FOUNDRY_PROJECT_ENDPOINT`, `FOUNDRY_MODEL`, plus Azure CLI auth via `az login` |
|
||||
| [**agent_foundry/**](agent_foundry/) | A minimal Foundry-backed weather agent with current weather and forecast tools. Use this when you want the smallest possible directory-discovered agent sample. | `FOUNDRY_PROJECT_ENDPOINT`, `FOUNDRY_MODEL`, plus Azure CLI auth via `az login` |
|
||||
|
||||
### Workflows
|
||||
|
||||
| Sample | What it demonstrates | Required keys / auth |
|
||||
| ------ | -------------------- | -------------------- |
|
||||
| [**workflow_declarative/**](workflow_declarative/) | A YAML-defined workflow loaded through `WorkflowFactory`, with nested age-based branching and no model client code. | None |
|
||||
| [**workflow_with_agents/**](workflow_with_agents/) | A content review workflow that uses agents as executors and routes based on structured review output (`Writer -> Reviewer -> Editor/Publisher -> Summarizer`). | `AZURE_OPENAI_ENDPOINT`, plus `AZURE_OPENAI_CHAT_MODEL` or `AZURE_OPENAI_MODEL`; Azure CLI auth via `az login`; `AZURE_OPENAI_API_VERSION` is optional |
|
||||
| [**workflow_spam/**](workflow_spam/) | A multi-step spam detection workflow with human-in-the-loop approval, branching for spam vs. legitimate messages, and a final reporting step. | None |
|
||||
| [**workflow_fanout/**](workflow_fanout/) | A larger fan-out/fan-in data processing workflow with parallel validation, multiple transformations, QA, aggregation, and demo failure toggles. | None |
|
||||
|
||||
### Standalone Examples
|
||||
|
||||
| Sample | What it demonstrates | Required keys / auth |
|
||||
| ------ | -------------------- | -------------------- |
|
||||
| [**in_memory_mode.py**](in_memory_mode.py) | Registers multiple entities directly in Python: two Foundry-backed agents plus a simple workflow, all served from one file without directory discovery. | `FOUNDRY_PROJECT_ENDPOINT`, `FOUNDRY_MODEL`, plus Azure CLI auth via `az login` |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
For samples that require external services:
|
||||
|
||||
1. Copy `.env.example` to `.env`
|
||||
2. Fill in the required values
|
||||
3. Run `az login` for samples that use Azure CLI authentication
|
||||
|
||||
Directory discovery checks `.env` files in this order:
|
||||
|
||||
1. The entity directory itself, for example `agent_weather/.env`
|
||||
2. The root DevUI samples folder, `samples/02-agents/devui/.env`
|
||||
|
||||
That means the root `.env.example` can hold shared defaults for multiple samples, while a sample-specific `.env` can override those values when needed.
|
||||
|
||||
`in_memory_mode.py` and `main.py` both load `.env` from `samples/02-agents/devui/`, so the root `.env.example` in this folder is the right starting point for both commands.
|
||||
|
||||
Alternatively, set environment variables globally:
|
||||
|
||||
```bash
|
||||
# Foundry-backed samples
|
||||
export FOUNDRY_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com"
|
||||
export FOUNDRY_MODEL="gpt-4o"
|
||||
|
||||
# Azure OpenAI workflow_with_agents sample
|
||||
export AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com"
|
||||
export AZURE_OPENAI_CHAT_MODEL="gpt-4o"
|
||||
export AZURE_OPENAI_MODEL="gpt-4o"
|
||||
|
||||
az login
|
||||
```
|
||||
|
||||
## Using DevUI with Your Own Agents
|
||||
|
||||
To make your agent discoverable by DevUI:
|
||||
|
||||
1. Create a folder for your agent
|
||||
2. Add an `__init__.py` that exports `agent` or `workflow`
|
||||
3. (Optional) Add a `.env` file for environment variables
|
||||
|
||||
Example:
|
||||
|
||||
```python
|
||||
# my_agent/__init__.py
|
||||
from agent_framework import Agent
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
|
||||
agent = Agent(
|
||||
name="MyAgent",
|
||||
description="My custom agent",
|
||||
client=OpenAIChatClient(),
|
||||
# ... your configuration
|
||||
)
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
devui /path/to/my/agents/folder
|
||||
```
|
||||
|
||||
## API Usage
|
||||
|
||||
DevUI exposes OpenAI-compatible endpoints:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/v1/responses \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "agent-framework",
|
||||
"input": "What is the weather in Seattle?",
|
||||
"extra_body": {"entity_id": "agent_directory_weather-agent_<uuid>"}
|
||||
}'
|
||||
```
|
||||
|
||||
List available entities:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/v1/entities
|
||||
```
|
||||
|
||||
## Learn More
|
||||
|
||||
- [DevUI Documentation](../../../packages/devui/README.md)
|
||||
- [Agent Framework Documentation](https://docs.microsoft.com/agent-framework)
|
||||
- [Sample Guidelines](../../SAMPLE_GUIDELINES.md)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Missing credentials or settings**: Check your `.env` files, confirm the required variables for the sample you are running, and make sure `az login` has completed for Azure-authenticated samples.
|
||||
|
||||
**Import errors**: Make sure you've installed the devui package:
|
||||
|
||||
```bash
|
||||
pip install agent-framework-devui --pre
|
||||
```
|
||||
|
||||
**Port conflicts**: DevUI uses ports 8080 (directory mode) and 8090 (in-memory mode) by default. Close other services or specify a different port:
|
||||
|
||||
```bash
|
||||
devui --port 8888
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
# Azure AI Foundry Configuration
|
||||
# Make sure to run 'az login' before starting devui
|
||||
|
||||
FOUNDRY_PROJECT_ENDPOINT=https://your-project.services.ai.azure.com
|
||||
FOUNDRY_MODEL=gpt-4o
|
||||
@@ -0,0 +1,7 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Weather agent sample for DevUI testing."""
|
||||
|
||||
from .agent import agent # ty: ignore[unresolved-import] # pyrefly: ignore
|
||||
|
||||
__all__ = ["agent"]
|
||||
@@ -0,0 +1,88 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
"""Foundry-based weather agent for Agent Framework Debug UI.
|
||||
|
||||
This agent uses Azure AI Foundry with Azure CLI authentication.
|
||||
Make sure to run 'az login' before starting devui.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Annotated
|
||||
|
||||
from agent_framework import Agent, tool
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.identity.aio import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
from pydantic import Field
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
|
||||
# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production;
|
||||
# see samples/02-agents/tools/function_tool_with_approval.py
|
||||
# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py.
|
||||
@tool(approval_mode="never_require")
|
||||
def get_weather(
|
||||
location: Annotated[str, Field(description="The location to get the weather for.")],
|
||||
) -> str:
|
||||
"""Get the weather for a given location."""
|
||||
conditions = ["sunny", "cloudy", "rainy", "stormy"]
|
||||
temperature = 22
|
||||
return f"The weather in {location} is {conditions[0]} with a high of {temperature}°C."
|
||||
|
||||
|
||||
@tool(approval_mode="never_require")
|
||||
def get_forecast(
|
||||
location: Annotated[str, Field(description="The location to get the forecast for.")],
|
||||
days: Annotated[int, Field(description="Number of days for forecast")] = 3,
|
||||
) -> str:
|
||||
"""Get weather forecast for multiple days."""
|
||||
conditions = ["sunny", "cloudy", "rainy", "stormy"]
|
||||
forecast: list[str] = []
|
||||
|
||||
for day in range(1, days + 1):
|
||||
condition = conditions[day % len(conditions)]
|
||||
temp = 18 + day
|
||||
forecast.append(f"Day {day}: {condition}, {temp}°C")
|
||||
|
||||
return f"Weather forecast for {location}:\n" + "\n".join(forecast)
|
||||
|
||||
|
||||
# Agent instance following Agent Framework conventions
|
||||
agent = Agent(
|
||||
name="FoundryWeatherAgent",
|
||||
client=FoundryChatClient(
|
||||
project_endpoint=os.environ.get("FOUNDRY_PROJECT_ENDPOINT"),
|
||||
model=os.environ.get("FOUNDRY_MODEL"),
|
||||
credential=AzureCliCredential(),
|
||||
),
|
||||
instructions="""
|
||||
You are a weather assistant using Azure AI Foundry models. You can provide
|
||||
current weather information and forecasts for any location. Always be helpful
|
||||
and provide detailed weather information when asked.
|
||||
""",
|
||||
tools=[get_weather, get_forecast],
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
"""Launch the Foundry weather agent in DevUI."""
|
||||
import logging
|
||||
|
||||
from agent_framework.devui import serve
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger.info("Starting Foundry Weather Agent")
|
||||
logger.info("Available at: http://localhost:8090")
|
||||
logger.info("Entity ID: agent_FoundryWeatherAgent")
|
||||
logger.info("Note: Make sure 'az login' has been run for authentication")
|
||||
|
||||
# Launch server with the agent
|
||||
serve(entities=[agent], port=8090, auto_open=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,5 @@
|
||||
# Azure AI Foundry Configuration
|
||||
# Make sure to run 'az login' before starting devui
|
||||
|
||||
FOUNDRY_PROJECT_ENDPOINT=https://your-project.services.ai.azure.com
|
||||
FOUNDRY_MODEL=gpt-4o
|
||||
@@ -0,0 +1,7 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Weather agent sample for DevUI testing."""
|
||||
|
||||
from .agent import agent # ty: ignore[unresolved-import] # pyrefly: ignore
|
||||
|
||||
__all__ = ["agent"]
|
||||
@@ -0,0 +1,188 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
"""Sample weather agent for Agent Framework Debug UI."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import AsyncIterable, Awaitable, Callable
|
||||
from typing import Annotated
|
||||
|
||||
from agent_framework import (
|
||||
Agent,
|
||||
ChatContext,
|
||||
ChatResponse,
|
||||
ChatResponseUpdate,
|
||||
Content,
|
||||
FunctionInvocationContext,
|
||||
Message,
|
||||
MiddlewareTermination,
|
||||
ResponseStream,
|
||||
chat_middleware,
|
||||
function_middleware,
|
||||
tool,
|
||||
)
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from agent_framework_devui import register_cleanup
|
||||
from azure.identity.aio import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
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
|
||||
async def security_filter_middleware(
|
||||
context: ChatContext,
|
||||
call_next: Callable[[], Awaitable[None]],
|
||||
) -> None:
|
||||
"""Chat middleware that blocks requests containing sensitive information."""
|
||||
blocked_terms = ["password", "secret", "api_key", "token"]
|
||||
|
||||
# Check only the last message (most recent user input)
|
||||
last_message = context.messages[-1] if context.messages else None
|
||||
if last_message and last_message.role == "user" and last_message.text:
|
||||
message_lower = last_message.text.lower()
|
||||
for term in blocked_terms:
|
||||
if term in message_lower:
|
||||
error_message = (
|
||||
"I cannot process requests containing sensitive information. "
|
||||
"Please rephrase your question without including passwords, secrets, "
|
||||
"or other sensitive data."
|
||||
)
|
||||
|
||||
if context.stream:
|
||||
# Streaming mode: wrap in ResponseStream
|
||||
async def blocked_stream(msg: str = error_message) -> AsyncIterable[ChatResponseUpdate]:
|
||||
yield ChatResponseUpdate(
|
||||
contents=[Content.from_text(text=msg)],
|
||||
role="assistant",
|
||||
)
|
||||
|
||||
response = ChatResponse(messages=[Message(role="assistant", contents=[error_message])])
|
||||
context.result = ResponseStream(blocked_stream(), finalizer=lambda _, r=response: r)
|
||||
else:
|
||||
# Non-streaming mode: return complete response
|
||||
context.result = ChatResponse(
|
||||
messages=[
|
||||
Message(
|
||||
role="assistant",
|
||||
contents=[error_message],
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
raise MiddlewareTermination(result=context.result)
|
||||
|
||||
await call_next()
|
||||
|
||||
|
||||
@function_middleware
|
||||
async def atlantis_location_filter_middleware(
|
||||
context: FunctionInvocationContext,
|
||||
call_next: Callable[[], Awaitable[None]],
|
||||
) -> None:
|
||||
"""Function middleware that blocks weather requests for Atlantis."""
|
||||
# Check if location parameter is "atlantis"
|
||||
location = getattr(context.arguments, "location", None)
|
||||
if location and location.lower() == "atlantis":
|
||||
context.result = (
|
||||
"Blocked! Hold up right there!! Tell the user that "
|
||||
"'Atlantis is a special place, we must never ask about the weather there!!'"
|
||||
)
|
||||
raise MiddlewareTermination(result=context.result)
|
||||
|
||||
await call_next()
|
||||
|
||||
|
||||
# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production;
|
||||
# see samples/02-agents/tools/function_tool_with_approval.py
|
||||
# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py.
|
||||
@tool(approval_mode="never_require")
|
||||
def get_weather(
|
||||
location: Annotated[str, "The location to get the weather for."],
|
||||
) -> str:
|
||||
"""Get the weather for a given location."""
|
||||
conditions = ["sunny", "cloudy", "rainy", "stormy"]
|
||||
temperature = 53
|
||||
return f"The weather in {location} is {conditions[0]} with a high of {temperature}°C."
|
||||
|
||||
|
||||
@tool(approval_mode="never_require")
|
||||
def get_forecast(
|
||||
location: Annotated[str, "The location to get the forecast for."],
|
||||
days: Annotated[int, "Number of days for forecast"] = 3,
|
||||
) -> str:
|
||||
"""Get weather forecast for multiple days."""
|
||||
conditions = ["sunny", "cloudy", "rainy", "stormy"]
|
||||
forecast: list[str] = []
|
||||
|
||||
for day in range(1, days + 1):
|
||||
condition = conditions[0]
|
||||
temp = 53
|
||||
forecast.append(f"Day {day}: {condition}, {temp}°C")
|
||||
|
||||
return f"Weather forecast for {location}:\n" + "\n".join(forecast)
|
||||
|
||||
|
||||
@tool(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 = Agent(
|
||||
name="WeatherAgent",
|
||||
description="A helpful agent that provides weather information and forecasts",
|
||||
instructions="""
|
||||
You are a weather assistant. You can provide current weather information
|
||||
and forecasts for any location. Always be helpful and provide detailed
|
||||
weather information when asked.
|
||||
""",
|
||||
client=FoundryChatClient(
|
||||
project_endpoint=os.environ.get("FOUNDRY_PROJECT_ENDPOINT"),
|
||||
model=os.environ.get("FOUNDRY_MODEL"),
|
||||
credential=AzureCliCredential(),
|
||||
),
|
||||
tools=[get_weather, get_forecast, send_email],
|
||||
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 Weather Agent in DevUI."""
|
||||
import logging
|
||||
|
||||
from agent_framework.devui import serve
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger.info("Starting Weather Agent")
|
||||
logger.info("Available at: http://localhost:8090")
|
||||
logger.info("Entity ID: agent_WeatherAgent")
|
||||
|
||||
# Launch server with the agent
|
||||
serve(entities=[agent], port=8090, auto_open=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,135 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Example of using Agent Framework DevUI with in-memory entity registration.
|
||||
|
||||
This demonstrates the simplest way to serve agents and workflows as OpenAI-compatible API endpoints.
|
||||
Includes both agents and a basic workflow to showcase different entity types.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Annotated
|
||||
|
||||
from agent_framework import (
|
||||
Agent,
|
||||
Executor,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
handler,
|
||||
tool,
|
||||
)
|
||||
from agent_framework.devui import serve
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.identity.aio import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
from typing_extensions import Never
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
|
||||
# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production;
|
||||
# see samples/02-agents/tools/function_tool_with_approval.py
|
||||
# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py.
|
||||
@tool(approval_mode="never_require")
|
||||
def get_weather(
|
||||
location: Annotated[str, "The location to get the weather for."],
|
||||
) -> str:
|
||||
"""Get the weather for a given location."""
|
||||
conditions = ["sunny", "cloudy", "rainy", "stormy"]
|
||||
temperature = 53
|
||||
return f"The weather in {location} is {conditions[0]} with a high of {temperature}°C."
|
||||
|
||||
|
||||
@tool(approval_mode="never_require")
|
||||
def get_time(
|
||||
timezone: Annotated[str, "The timezone to get time for."] = "UTC",
|
||||
) -> str:
|
||||
"""Get current time for a timezone."""
|
||||
from datetime import datetime
|
||||
|
||||
# Simplified for example
|
||||
return f"Current time in {timezone}: {datetime.now().strftime('%H:%M:%S')}"
|
||||
|
||||
|
||||
# Basic workflow executors
|
||||
class UpperCase(Executor):
|
||||
"""Convert text to uppercase."""
|
||||
|
||||
@handler
|
||||
async def to_upper(self, text: str, ctx: WorkflowContext[str]) -> None:
|
||||
"""Convert input to uppercase and forward to next executor."""
|
||||
result = text.upper()
|
||||
await ctx.send_message(result)
|
||||
|
||||
|
||||
class AddExclamation(Executor):
|
||||
"""Add exclamation mark to text."""
|
||||
|
||||
@handler
|
||||
async def add_exclamation(self, text: str, ctx: WorkflowContext[Never, str]) -> None:
|
||||
"""Add exclamation and yield as workflow output."""
|
||||
result = f"{text}!"
|
||||
await ctx.yield_output(result)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function demonstrating in-memory entity registration."""
|
||||
# Setup logging
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Create Azure OpenAI chat client
|
||||
client = FoundryChatClient(
|
||||
model=os.environ["FOUNDRY_MODEL"],
|
||||
project_endpoint=os.environ.get("FOUNDRY_PROJECT_ENDPOINT"),
|
||||
credential=AzureCliCredential(),
|
||||
)
|
||||
|
||||
# Create agents
|
||||
weather_assistant = Agent(
|
||||
name="weather-assistant",
|
||||
description="Provides weather information and time",
|
||||
instructions=(
|
||||
"You are a helpful weather and time assistant. Use the available tools to "
|
||||
"provide accurate weather information and current time for any location."
|
||||
),
|
||||
client=client,
|
||||
tools=[get_weather, get_time],
|
||||
)
|
||||
|
||||
simple_agent = Agent(
|
||||
name="general-assistant",
|
||||
description="A simple conversational agent",
|
||||
instructions="You are a helpful assistant.",
|
||||
client=client,
|
||||
)
|
||||
|
||||
# Create a basic workflow: Input -> UpperCase -> AddExclamation -> Output
|
||||
upper_executor = UpperCase(id="upper_case")
|
||||
exclaim_executor = AddExclamation(id="add_exclamation")
|
||||
|
||||
basic_workflow = (
|
||||
WorkflowBuilder(
|
||||
name="Text Transformer",
|
||||
description="Simple 2-step workflow that converts text to uppercase and adds exclamation",
|
||||
start_executor=upper_executor,
|
||||
)
|
||||
.add_edge(upper_executor, exclaim_executor)
|
||||
.build()
|
||||
)
|
||||
|
||||
# Collect entities for serving
|
||||
entities = [weather_assistant, simple_agent, basic_workflow]
|
||||
|
||||
logger.info("Starting DevUI on http://localhost:8090")
|
||||
logger.info("Entities available:")
|
||||
logger.info(" - Agents: weather-assistant, general-assistant")
|
||||
logger.info(" - Workflow: basic text transformer (uppercase + exclamation)")
|
||||
|
||||
# Launch server with auto-generated entity IDs
|
||||
serve(entities=entities, port=8090, auto_open=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,32 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Launch DevUI with folder discovery for the samples in this directory.
|
||||
|
||||
This sample demonstrates:
|
||||
- Loading a shared root `.env` file for the DevUI samples folder
|
||||
- Starting DevUI in directory discovery mode for this folder
|
||||
- Using root-level settings as fallbacks for discovered samples
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from agent_framework.devui import serve
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Load the root .env file and launch DevUI with folder discovery."""
|
||||
samples_dir = Path(__file__).resolve().parent
|
||||
|
||||
# 1. Load shared defaults for the samples in this folder.
|
||||
load_dotenv(samples_dir / ".env")
|
||||
|
||||
# 2. Start DevUI and discover entities from this directory.
|
||||
serve(entities_dir=str(samples_dir), auto_open=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
# Sample output:
|
||||
# Starting Agent Framework DevUI on 127.0.0.1:8080
|
||||
@@ -0,0 +1,3 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Declarative workflow sample for DevUI."""
|
||||
@@ -0,0 +1,25 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""
|
||||
Run the declarative workflow sample with DevUI.
|
||||
|
||||
Demonstrates conditional branching based on age input using YAML-defined workflow.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from agent_framework.declarative import WorkflowFactory
|
||||
from agent_framework.devui import serve
|
||||
|
||||
factory = WorkflowFactory()
|
||||
workflow_path = Path(__file__).parent / "workflow.yaml"
|
||||
workflow = factory.create_workflow_from_yaml_path(workflow_path)
|
||||
|
||||
|
||||
def main():
|
||||
"""Run the declarative workflow with DevUI."""
|
||||
serve(entities=[workflow], auto_open=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,64 @@
|
||||
name: conditional-workflow
|
||||
description: Demonstrates conditional branching based on user input
|
||||
|
||||
inputs:
|
||||
age:
|
||||
type: integer
|
||||
description: The user's age in years
|
||||
|
||||
actions:
|
||||
- kind: SetValue
|
||||
id: get_age
|
||||
displayName: Get user age
|
||||
path: turn.age
|
||||
value: =inputs.age
|
||||
|
||||
- kind: If
|
||||
id: check_age
|
||||
displayName: Check age category
|
||||
condition: =turn.age < 13
|
||||
then:
|
||||
- kind: SetValue
|
||||
path: turn.category
|
||||
value: child
|
||||
- kind: SendActivity
|
||||
activity:
|
||||
text: "Welcome, young one! Here are some fun activities for kids."
|
||||
else:
|
||||
- kind: If
|
||||
condition: =turn.age < 20
|
||||
then:
|
||||
- kind: SetValue
|
||||
path: turn.category
|
||||
value: teenager
|
||||
- kind: SendActivity
|
||||
activity:
|
||||
text: "Hey there! Check out these cool things for teens."
|
||||
else:
|
||||
- kind: If
|
||||
condition: =turn.age < 65
|
||||
then:
|
||||
- kind: SetValue
|
||||
path: turn.category
|
||||
value: adult
|
||||
- kind: SendActivity
|
||||
activity:
|
||||
text: "Welcome! Here are our professional services."
|
||||
else:
|
||||
- kind: SetValue
|
||||
path: turn.category
|
||||
value: senior
|
||||
- kind: SendActivity
|
||||
activity:
|
||||
text: "Welcome! Enjoy our senior member benefits."
|
||||
|
||||
- kind: SendActivity
|
||||
id: summary
|
||||
displayName: Send category summary
|
||||
activity:
|
||||
text: '=Concat("You have been categorized as: ", turn.category)'
|
||||
|
||||
- kind: SetValue
|
||||
id: set_output
|
||||
path: workflow.outputs.category
|
||||
value: =turn.category
|
||||
@@ -0,0 +1,3 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Fanout workflow example."""
|
||||
@@ -0,0 +1,703 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Complex Fan-In/Fan-Out Data Processing Workflow.
|
||||
|
||||
This workflow demonstrates a sophisticated data processing pipeline with multiple stages:
|
||||
1. Data Ingestion - Simulates loading data from multiple sources
|
||||
2. Data Validation - Multiple validators run in parallel to check data quality
|
||||
3. Data Transformation - Fan-out to different transformation processors
|
||||
4. Quality Assurance - Multiple QA checks run in parallel
|
||||
5. Data Aggregation - Fan-in to combine processed results
|
||||
6. Final Processing - Generate reports and complete workflow
|
||||
|
||||
The workflow includes realistic delays to simulate actual processing time and
|
||||
shows complex fan-in/fan-out patterns with conditional processing.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Literal
|
||||
|
||||
from agent_framework import (
|
||||
Executor,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
handler,
|
||||
)
|
||||
from pydantic import BaseModel, Field
|
||||
from typing_extensions import Never
|
||||
|
||||
|
||||
class DataType(Enum):
|
||||
"""Types of data being processed."""
|
||||
|
||||
CUSTOMER = "customer"
|
||||
TRANSACTION = "transaction"
|
||||
PRODUCT = "product"
|
||||
ANALYTICS = "analytics"
|
||||
|
||||
|
||||
class ValidationResult(Enum):
|
||||
"""Results of data validation."""
|
||||
|
||||
VALID = "valid"
|
||||
WARNING = "warning"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
class ProcessingRequest(BaseModel):
|
||||
"""Complex input structure for data processing workflow."""
|
||||
|
||||
# Basic information
|
||||
data_source: Literal["database", "api", "file_upload", "streaming"] = Field(
|
||||
description="The source of the data to be processed", default="database"
|
||||
)
|
||||
|
||||
data_type: Literal["customer", "transaction", "product", "analytics"] = Field(
|
||||
description="Type of data being processed", default="customer"
|
||||
)
|
||||
|
||||
processing_priority: Literal["low", "normal", "high", "critical"] = Field(
|
||||
description="Processing priority level", default="normal"
|
||||
)
|
||||
|
||||
# Processing configuration
|
||||
batch_size: int = Field(description="Number of records to process in each batch", default=500, ge=100, le=10000)
|
||||
|
||||
quality_threshold: float = Field(
|
||||
description="Minimum quality score required (0.0-1.0)", default=0.8, ge=0.0, le=1.0
|
||||
)
|
||||
|
||||
# Validation settings
|
||||
enable_schema_validation: bool = Field(description="Enable schema validation checks", default=True)
|
||||
|
||||
enable_security_validation: bool = Field(description="Enable security validation checks", default=True)
|
||||
|
||||
enable_quality_validation: bool = Field(description="Enable data quality validation checks", default=True)
|
||||
|
||||
# Transformation options
|
||||
transformations: list[Literal["normalize", "enrich", "aggregate"]] = Field(
|
||||
description="List of transformations to apply", default=["normalize", "enrich"]
|
||||
)
|
||||
|
||||
# Optional description
|
||||
description: str | None = Field(description="Optional description of the processing request", default=None)
|
||||
|
||||
# Test failure scenarios
|
||||
force_validation_failure: bool = Field(
|
||||
description="Force validation failure for testing (demo purposes)", default=False
|
||||
)
|
||||
|
||||
force_transformation_failure: bool = Field(
|
||||
description="Force transformation failure for testing (demo purposes)", default=False
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DataBatch:
|
||||
"""Represents a batch of data being processed."""
|
||||
|
||||
batch_id: str
|
||||
data_type: DataType
|
||||
size: int
|
||||
content: str
|
||||
source: str = "unknown"
|
||||
timestamp: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationReport:
|
||||
"""Report from data validation."""
|
||||
|
||||
batch_id: str
|
||||
validator_id: str
|
||||
result: ValidationResult
|
||||
issues_found: int
|
||||
processing_time: float
|
||||
details: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class TransformationResult:
|
||||
"""Result from data transformation."""
|
||||
|
||||
batch_id: str
|
||||
transformer_id: str
|
||||
original_size: int
|
||||
processed_size: int
|
||||
transformation_type: str
|
||||
processing_time: float
|
||||
success: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class QualityAssessment:
|
||||
"""Quality assessment result."""
|
||||
|
||||
batch_id: str
|
||||
assessor_id: str
|
||||
quality_score: float
|
||||
recommendations: list[str]
|
||||
processing_time: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessingSummary:
|
||||
"""Summary of all processing stages."""
|
||||
|
||||
batch_id: str
|
||||
total_processing_time: float
|
||||
validation_reports: list[ValidationReport]
|
||||
transformation_results: list[TransformationResult]
|
||||
quality_assessments: list[QualityAssessment]
|
||||
final_status: str
|
||||
|
||||
|
||||
# Data Ingestion Stage
|
||||
class DataIngestion(Executor):
|
||||
"""Simulates ingesting data from multiple sources with delays."""
|
||||
|
||||
@handler
|
||||
async def ingest_data(self, request: ProcessingRequest, ctx: WorkflowContext[DataBatch]) -> None:
|
||||
"""Simulate data ingestion with realistic delays based on input configuration."""
|
||||
# Simulate network delay based on data source
|
||||
delay_map = {"database": 1.5, "api": 3.0, "file_upload": 4.0, "streaming": 1.0}
|
||||
delay = delay_map.get(request.data_source, 3.0)
|
||||
await asyncio.sleep(delay) # Fixed delay for demo
|
||||
|
||||
# Simulate data size based on priority and configuration
|
||||
base_size = request.batch_size
|
||||
if request.processing_priority == "critical":
|
||||
size_multiplier = 1.7 # Critical priority gets the largest batches
|
||||
elif request.processing_priority == "high":
|
||||
size_multiplier = 1.3 # High priority gets larger batches
|
||||
elif request.processing_priority == "low":
|
||||
size_multiplier = 0.6 # Low priority gets smaller batches
|
||||
else: # normal
|
||||
size_multiplier = 1.0 # Normal priority uses base size
|
||||
|
||||
actual_size = int(base_size * size_multiplier)
|
||||
|
||||
batch = DataBatch(
|
||||
batch_id=f"batch_{5555}", # Fixed batch ID for demo
|
||||
data_type=DataType(request.data_type),
|
||||
size=actual_size,
|
||||
content=f"Processing {request.data_type} data from {request.data_source}",
|
||||
source=request.data_source,
|
||||
timestamp=asyncio.get_event_loop().time(),
|
||||
)
|
||||
|
||||
# Store both batch data and original request in workflow state
|
||||
ctx.set_state(f"batch_{batch.batch_id}", batch)
|
||||
ctx.set_state(f"request_{batch.batch_id}", request)
|
||||
|
||||
await ctx.send_message(batch)
|
||||
|
||||
|
||||
# Validation Stage (Fan-out)
|
||||
class SchemaValidator(Executor):
|
||||
"""Validates data schema and structure."""
|
||||
|
||||
@handler
|
||||
async def validate_schema(self, batch: DataBatch, ctx: WorkflowContext[ValidationReport]) -> None:
|
||||
"""Perform schema validation with processing delay."""
|
||||
# Check if schema validation is enabled
|
||||
request = ctx.get_state(f"request_{batch.batch_id}")
|
||||
if not request or not request.enable_schema_validation:
|
||||
return
|
||||
|
||||
# Simulate schema validation processing
|
||||
processing_time = 2.0 # Fixed processing time
|
||||
await asyncio.sleep(processing_time)
|
||||
|
||||
# Simulate validation results - consider force failure flag
|
||||
issues = 4 if request.force_validation_failure else 2 # Fixed issue counts
|
||||
|
||||
result = (
|
||||
ValidationResult.VALID
|
||||
if issues <= 1
|
||||
else (ValidationResult.WARNING if issues <= 2 else ValidationResult.ERROR)
|
||||
)
|
||||
|
||||
report = ValidationReport(
|
||||
batch_id=batch.batch_id,
|
||||
validator_id=self.id,
|
||||
result=result,
|
||||
issues_found=issues,
|
||||
processing_time=processing_time,
|
||||
details=f"Schema validation found {issues} issues in {batch.data_type.value} data from {batch.source}",
|
||||
)
|
||||
|
||||
await ctx.send_message(report)
|
||||
|
||||
|
||||
class DataQualityValidator(Executor):
|
||||
"""Validates data quality and completeness."""
|
||||
|
||||
@handler
|
||||
async def validate_quality(self, batch: DataBatch, ctx: WorkflowContext[ValidationReport]) -> None:
|
||||
"""Perform data quality validation."""
|
||||
# Check if quality validation is enabled
|
||||
request = ctx.get_state(f"request_{batch.batch_id}")
|
||||
if not request or not request.enable_quality_validation:
|
||||
return
|
||||
|
||||
processing_time = 2.5 # Fixed processing time
|
||||
await asyncio.sleep(processing_time)
|
||||
|
||||
# Quality checks are stricter for higher priority data
|
||||
issues = (
|
||||
2 # Fixed issue count for high priority
|
||||
if request.processing_priority in ["critical", "high"]
|
||||
else 3 # Fixed issue count for normal priority
|
||||
)
|
||||
|
||||
if request.force_validation_failure:
|
||||
issues = max(issues, 4) # Ensure failure
|
||||
|
||||
result = (
|
||||
ValidationResult.VALID
|
||||
if issues <= 1
|
||||
else (ValidationResult.WARNING if issues <= 3 else ValidationResult.ERROR)
|
||||
)
|
||||
|
||||
report = ValidationReport(
|
||||
batch_id=batch.batch_id,
|
||||
validator_id=self.id,
|
||||
result=result,
|
||||
issues_found=issues,
|
||||
processing_time=processing_time,
|
||||
details=f"Quality check found {issues} data quality issues (priority: {request.processing_priority})",
|
||||
)
|
||||
|
||||
await ctx.send_message(report)
|
||||
|
||||
|
||||
class SecurityValidator(Executor):
|
||||
"""Validates data for security and compliance issues."""
|
||||
|
||||
@handler
|
||||
async def validate_security(self, batch: DataBatch, ctx: WorkflowContext[ValidationReport]) -> None:
|
||||
"""Perform security validation."""
|
||||
# Check if security validation is enabled
|
||||
request = ctx.get_state(f"request_{batch.batch_id}")
|
||||
if not request or not request.enable_security_validation:
|
||||
return
|
||||
|
||||
processing_time = 3.0 # Fixed processing time
|
||||
await asyncio.sleep(processing_time)
|
||||
|
||||
# Security is more stringent for customer/transaction data
|
||||
issues = 1 if batch.data_type in [DataType.CUSTOMER, DataType.TRANSACTION] else 2
|
||||
|
||||
if request.force_validation_failure:
|
||||
issues = max(issues, 1) # Force at least one security issue
|
||||
|
||||
# Security errors are more serious - less tolerance
|
||||
result = ValidationResult.VALID if issues == 0 else ValidationResult.ERROR
|
||||
|
||||
report = ValidationReport(
|
||||
batch_id=batch.batch_id,
|
||||
validator_id=self.id,
|
||||
result=result,
|
||||
issues_found=issues,
|
||||
processing_time=processing_time,
|
||||
details=f"Security scan found {issues} security issues in {batch.data_type.value} data",
|
||||
)
|
||||
|
||||
await ctx.send_message(report)
|
||||
|
||||
|
||||
# Validation Aggregator (Fan-in)
|
||||
class ValidationAggregator(Executor):
|
||||
"""Aggregates validation results and decides on next steps."""
|
||||
|
||||
@handler
|
||||
async def aggregate_validations(
|
||||
self, reports: list[ValidationReport], ctx: WorkflowContext[DataBatch, str]
|
||||
) -> None:
|
||||
"""Aggregate all validation reports and make processing decision."""
|
||||
if not reports:
|
||||
return
|
||||
|
||||
batch_id = reports[0].batch_id
|
||||
request = ctx.get_state(f"request_{batch_id}")
|
||||
|
||||
await asyncio.sleep(1) # Aggregation processing time
|
||||
|
||||
total_issues = sum(report.issues_found for report in reports)
|
||||
has_errors = any(report.result == ValidationResult.ERROR for report in reports)
|
||||
|
||||
# Calculate quality score (0.0 to 1.0)
|
||||
max_possible_issues = len(reports) * 5 # Assume max 5 issues per validator
|
||||
quality_score = max(0.0, 1.0 - (total_issues / max_possible_issues))
|
||||
|
||||
# Decision logic: fail if errors OR quality below threshold
|
||||
should_fail = has_errors or (quality_score < request.quality_threshold)
|
||||
|
||||
if should_fail:
|
||||
failure_reason: list[str] = []
|
||||
if has_errors:
|
||||
failure_reason.append("validation errors detected")
|
||||
if quality_score < request.quality_threshold:
|
||||
failure_reason.append(
|
||||
f"quality score {quality_score:.2f} below threshold {request.quality_threshold:.2f}"
|
||||
)
|
||||
|
||||
reason = " and ".join(failure_reason)
|
||||
await ctx.yield_output(
|
||||
f"Batch {batch_id} failed validation: {reason}. "
|
||||
f"Total issues: {total_issues}, Quality score: {quality_score:.2f}"
|
||||
)
|
||||
return
|
||||
|
||||
# Retrieve original batch from workflow state
|
||||
batch_data = ctx.get_state(f"batch_{batch_id}")
|
||||
if batch_data:
|
||||
await ctx.send_message(batch_data)
|
||||
else:
|
||||
# Fallback: create a simplified batch
|
||||
batch = DataBatch(
|
||||
batch_id=batch_id,
|
||||
data_type=DataType.ANALYTICS,
|
||||
size=500,
|
||||
content="Validated data ready for transformation",
|
||||
)
|
||||
await ctx.send_message(batch)
|
||||
|
||||
|
||||
# Transformation Stage (Fan-out)
|
||||
class DataNormalizer(Executor):
|
||||
"""Normalizes and cleans data."""
|
||||
|
||||
@handler
|
||||
async def normalize_data(self, batch: DataBatch, ctx: WorkflowContext[TransformationResult]) -> None:
|
||||
"""Perform data normalization."""
|
||||
request = ctx.get_state(f"request_{batch.batch_id}")
|
||||
|
||||
# Check if normalization is enabled
|
||||
if not request or "normalize" not in request.transformations:
|
||||
# Send a "skipped" result
|
||||
result = TransformationResult(
|
||||
batch_id=batch.batch_id,
|
||||
transformer_id=self.id,
|
||||
original_size=batch.size,
|
||||
processed_size=batch.size,
|
||||
transformation_type="normalization",
|
||||
processing_time=0.1,
|
||||
success=True, # Consider skipped as successful
|
||||
)
|
||||
await ctx.send_message(result)
|
||||
return
|
||||
|
||||
processing_time = 4.0 # Fixed processing time
|
||||
await asyncio.sleep(processing_time)
|
||||
|
||||
# Simulate data size change during normalization
|
||||
processed_size = int(batch.size * 1.0) # No size change for demo
|
||||
|
||||
# Consider force failure flag
|
||||
success = not request.force_transformation_failure # 75% success rate simplified to always success
|
||||
|
||||
result = TransformationResult(
|
||||
batch_id=batch.batch_id,
|
||||
transformer_id=self.id,
|
||||
original_size=batch.size,
|
||||
processed_size=processed_size,
|
||||
transformation_type="normalization",
|
||||
processing_time=processing_time,
|
||||
success=success,
|
||||
)
|
||||
|
||||
await ctx.send_message(result)
|
||||
|
||||
|
||||
class DataEnrichment(Executor):
|
||||
"""Enriches data with additional information."""
|
||||
|
||||
@handler
|
||||
async def enrich_data(self, batch: DataBatch, ctx: WorkflowContext[TransformationResult]) -> None:
|
||||
"""Perform data enrichment."""
|
||||
request = ctx.get_state(f"request_{batch.batch_id}")
|
||||
|
||||
# Check if enrichment is enabled
|
||||
if not request or "enrich" not in request.transformations:
|
||||
# Send a "skipped" result
|
||||
result = TransformationResult(
|
||||
batch_id=batch.batch_id,
|
||||
transformer_id=self.id,
|
||||
original_size=batch.size,
|
||||
processed_size=batch.size,
|
||||
transformation_type="enrichment",
|
||||
processing_time=0.1,
|
||||
success=True, # Consider skipped as successful
|
||||
)
|
||||
await ctx.send_message(result)
|
||||
return
|
||||
|
||||
processing_time = 5.0 # Fixed processing time
|
||||
await asyncio.sleep(processing_time)
|
||||
|
||||
processed_size = int(batch.size * 1.3) # Enrichment increases data
|
||||
|
||||
# Consider force failure flag
|
||||
success = not request.force_transformation_failure # 67% success rate simplified to always success
|
||||
|
||||
result = TransformationResult(
|
||||
batch_id=batch.batch_id,
|
||||
transformer_id=self.id,
|
||||
original_size=batch.size,
|
||||
processed_size=processed_size,
|
||||
transformation_type="enrichment",
|
||||
processing_time=processing_time,
|
||||
success=success,
|
||||
)
|
||||
|
||||
await ctx.send_message(result)
|
||||
|
||||
|
||||
class DataAggregator(Executor):
|
||||
"""Aggregates and summarizes data."""
|
||||
|
||||
@handler
|
||||
async def aggregate_data(self, batch: DataBatch, ctx: WorkflowContext[TransformationResult]) -> None:
|
||||
"""Perform data aggregation."""
|
||||
request = ctx.get_state(f"request_{batch.batch_id}")
|
||||
|
||||
# Check if aggregation is enabled
|
||||
if not request or "aggregate" not in request.transformations:
|
||||
# Send a "skipped" result
|
||||
result = TransformationResult(
|
||||
batch_id=batch.batch_id,
|
||||
transformer_id=self.id,
|
||||
original_size=batch.size,
|
||||
processed_size=batch.size,
|
||||
transformation_type="aggregation",
|
||||
processing_time=0.1,
|
||||
success=True, # Consider skipped as successful
|
||||
)
|
||||
await ctx.send_message(result)
|
||||
return
|
||||
|
||||
processing_time = 2.5 # Fixed processing time
|
||||
await asyncio.sleep(processing_time)
|
||||
|
||||
processed_size = int(batch.size * 0.5) # Aggregation reduces data
|
||||
|
||||
# Consider force failure flag
|
||||
success = not request.force_transformation_failure # 80% success rate simplified to always success
|
||||
|
||||
result = TransformationResult(
|
||||
batch_id=batch.batch_id,
|
||||
transformer_id=self.id,
|
||||
original_size=batch.size,
|
||||
processed_size=processed_size,
|
||||
transformation_type="aggregation",
|
||||
processing_time=processing_time,
|
||||
success=success,
|
||||
)
|
||||
|
||||
await ctx.send_message(result)
|
||||
|
||||
|
||||
# Quality Assurance Stage (Fan-out)
|
||||
class PerformanceAssessor(Executor):
|
||||
"""Assesses performance characteristics of processed data."""
|
||||
|
||||
@handler
|
||||
async def assess_performance(
|
||||
self, results: list[TransformationResult], ctx: WorkflowContext[QualityAssessment]
|
||||
) -> None:
|
||||
"""Assess performance of transformations."""
|
||||
if not results:
|
||||
return
|
||||
|
||||
batch_id = results[0].batch_id
|
||||
|
||||
processing_time = 2.0 # Fixed processing time
|
||||
await asyncio.sleep(processing_time)
|
||||
|
||||
avg_processing_time = sum(r.processing_time for r in results) / len(results)
|
||||
success_rate = sum(1 for r in results if r.success) / len(results)
|
||||
|
||||
quality_score = (success_rate * 0.7 + (1 - min(avg_processing_time / 10, 1)) * 0.3) * 100
|
||||
|
||||
recommendations: list[str] = []
|
||||
if success_rate < 0.8:
|
||||
recommendations.append("Consider improving transformation reliability")
|
||||
if avg_processing_time > 5:
|
||||
recommendations.append("Optimize processing performance")
|
||||
if quality_score < 70:
|
||||
recommendations.append("Review overall data pipeline efficiency")
|
||||
|
||||
assessment = QualityAssessment(
|
||||
batch_id=batch_id,
|
||||
assessor_id=self.id,
|
||||
quality_score=quality_score,
|
||||
recommendations=recommendations,
|
||||
processing_time=processing_time,
|
||||
)
|
||||
|
||||
await ctx.send_message(assessment)
|
||||
|
||||
|
||||
class AccuracyAssessor(Executor):
|
||||
"""Assesses accuracy and correctness of processed data."""
|
||||
|
||||
@handler
|
||||
async def assess_accuracy(
|
||||
self, results: list[TransformationResult], ctx: WorkflowContext[QualityAssessment]
|
||||
) -> None:
|
||||
"""Assess accuracy of transformations."""
|
||||
if not results:
|
||||
return
|
||||
|
||||
batch_id = results[0].batch_id
|
||||
|
||||
processing_time = 3.0 # Fixed processing time
|
||||
await asyncio.sleep(processing_time)
|
||||
|
||||
# Simulate accuracy analysis
|
||||
accuracy_score = 85.0 # Fixed accuracy score
|
||||
|
||||
recommendations: list[str] = []
|
||||
if accuracy_score < 85:
|
||||
recommendations.append("Review data transformation algorithms")
|
||||
if accuracy_score < 80:
|
||||
recommendations.append("Implement additional validation steps")
|
||||
|
||||
assessment = QualityAssessment(
|
||||
batch_id=batch_id,
|
||||
assessor_id=self.id,
|
||||
quality_score=accuracy_score,
|
||||
recommendations=recommendations,
|
||||
processing_time=processing_time,
|
||||
)
|
||||
|
||||
await ctx.send_message(assessment)
|
||||
|
||||
|
||||
# Final Processing and Completion
|
||||
class FinalProcessor(Executor):
|
||||
"""Final processing stage that combines all results."""
|
||||
|
||||
@handler
|
||||
async def process_final_results(
|
||||
self, assessments: list[QualityAssessment], ctx: WorkflowContext[Never, str]
|
||||
) -> None:
|
||||
"""Generate final processing summary and complete workflow."""
|
||||
if not assessments:
|
||||
await ctx.yield_output("No quality assessments received")
|
||||
return
|
||||
|
||||
batch_id = assessments[0].batch_id
|
||||
|
||||
# Simulate final processing delay
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Calculate overall metrics
|
||||
avg_quality_score = sum(a.quality_score for a in assessments) / len(assessments)
|
||||
total_recommendations = sum(len(a.recommendations) for a in assessments)
|
||||
total_processing_time = sum(a.processing_time for a in assessments)
|
||||
|
||||
# Determine final status
|
||||
if avg_quality_score >= 85:
|
||||
final_status = "EXCELLENT"
|
||||
elif avg_quality_score >= 75:
|
||||
final_status = "GOOD"
|
||||
elif avg_quality_score >= 65:
|
||||
final_status = "ACCEPTABLE"
|
||||
else:
|
||||
final_status = "NEEDS_IMPROVEMENT"
|
||||
|
||||
completion_message = (
|
||||
f"Batch {batch_id} processing completed!\n"
|
||||
f"📊 Overall Quality Score: {avg_quality_score:.1f}%\n"
|
||||
f"⏱️ Total Processing Time: {total_processing_time:.1f}s\n"
|
||||
f"💡 Total Recommendations: {total_recommendations}\n"
|
||||
f"🎖️ Final Status: {final_status}"
|
||||
)
|
||||
|
||||
await ctx.yield_output(completion_message)
|
||||
|
||||
|
||||
# Workflow Builder Helper
|
||||
class WorkflowSetupHelper:
|
||||
"""Helper class to set up the complex workflow with state management."""
|
||||
|
||||
@staticmethod
|
||||
async def store_batch_data(batch: DataBatch, ctx: WorkflowContext) -> None:
|
||||
"""Store batch data in workflow state for later retrieval."""
|
||||
ctx.set_state(f"batch_{batch.batch_id}", batch)
|
||||
|
||||
|
||||
# Create the workflow instance
|
||||
def create_complex_workflow():
|
||||
"""Create the complex fan-in/fan-out workflow."""
|
||||
# Create all executors
|
||||
data_ingestion = DataIngestion(id="data_ingestion")
|
||||
|
||||
# Validation stage (fan-out)
|
||||
schema_validator = SchemaValidator(id="schema_validator")
|
||||
quality_validator = DataQualityValidator(id="quality_validator")
|
||||
security_validator = SecurityValidator(id="security_validator")
|
||||
validation_aggregator = ValidationAggregator(id="validation_aggregator")
|
||||
|
||||
# Transformation stage (fan-out)
|
||||
data_normalizer = DataNormalizer(id="data_normalizer")
|
||||
data_enrichment = DataEnrichment(id="data_enrichment")
|
||||
data_aggregator_exec = DataAggregator(id="data_aggregator")
|
||||
|
||||
# Quality assurance stage (fan-out)
|
||||
performance_assessor = PerformanceAssessor(id="performance_assessor")
|
||||
accuracy_assessor = AccuracyAssessor(id="accuracy_assessor")
|
||||
|
||||
# Final processing
|
||||
final_processor = FinalProcessor(id="final_processor")
|
||||
|
||||
# Build the workflow with complex fan-in/fan-out patterns
|
||||
return (
|
||||
WorkflowBuilder(
|
||||
name="Data Processing Pipeline",
|
||||
description="Complex workflow with parallel validation, transformation, and quality assurance stages",
|
||||
start_executor=data_ingestion,
|
||||
)
|
||||
# Fan-out to validation stage
|
||||
.add_fan_out_edges(data_ingestion, [schema_validator, quality_validator, security_validator])
|
||||
# Fan-in from validation to aggregator
|
||||
.add_fan_in_edges([schema_validator, quality_validator, security_validator], validation_aggregator)
|
||||
# Fan-out to transformation stage
|
||||
.add_fan_out_edges(validation_aggregator, [data_normalizer, data_enrichment, data_aggregator_exec])
|
||||
# Fan-in to quality assurance stage (both assessors receive all transformation results)
|
||||
.add_fan_in_edges([data_normalizer, data_enrichment, data_aggregator_exec], performance_assessor)
|
||||
.add_fan_in_edges([data_normalizer, data_enrichment, data_aggregator_exec], accuracy_assessor)
|
||||
# Fan-in to final processor
|
||||
.add_fan_in_edges([performance_assessor, accuracy_assessor], final_processor)
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
# Export the workflow for DevUI discovery
|
||||
workflow = create_complex_workflow()
|
||||
|
||||
|
||||
def main():
|
||||
"""Launch the fanout workflow in DevUI."""
|
||||
from agent_framework.devui import serve
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger.info("Starting Complex Fan-In/Fan-Out Data Processing Workflow")
|
||||
logger.info("Available at: http://localhost:8090")
|
||||
logger.info("Entity ID: workflow_complex_workflow")
|
||||
|
||||
# Launch server with the workflow
|
||||
serve(entities=[workflow], port=8090, auto_open=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,7 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Spam detection workflow sample for DevUI testing."""
|
||||
|
||||
from .workflow import workflow # ty: ignore[unresolved-import] # pyrefly: ignore
|
||||
|
||||
__all__ = ["workflow"]
|
||||
@@ -0,0 +1,440 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""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.
|
||||
|
||||
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
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from agent_framework import (
|
||||
Case,
|
||||
Default,
|
||||
Executor,
|
||||
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:
|
||||
"""A data class to hold the processed email content."""
|
||||
|
||||
original_message: str
|
||||
cleaned_message: str
|
||||
word_count: int
|
||||
has_suspicious_patterns: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpamDetectorResponse:
|
||||
"""A data class to hold the spam detection results."""
|
||||
|
||||
email_content: EmailContent
|
||||
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."""
|
||||
if self.spam_reasons is None:
|
||||
self.spam_reasons = []
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpamApprovalRequest:
|
||||
"""Human-in-the-loop approval request for spam classification."""
|
||||
|
||||
email_message: str
|
||||
detected_as_spam: bool
|
||||
confidence: float
|
||||
reasons: list[str]
|
||||
full_email_content: EmailContent
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessingResult:
|
||||
"""A data class to hold the final processing result."""
|
||||
|
||||
original_message: str
|
||||
action_taken: str
|
||||
processing_time: float
|
||||
status: str
|
||||
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):
|
||||
"""Request model for email processing."""
|
||||
|
||||
email: str = Field(
|
||||
description="The email message to be processed.",
|
||||
default="Hi there, are you interested in our new urgent offer today? Click here!",
|
||||
)
|
||||
|
||||
|
||||
class EmailPreprocessor(Executor):
|
||||
"""Step 1: An executor that preprocesses and cleans email content."""
|
||||
|
||||
@handler
|
||||
async def handle_email(self, email: EmailRequest, ctx: WorkflowContext[EmailContent]) -> None:
|
||||
"""Clean and preprocess the email message."""
|
||||
await asyncio.sleep(1.5) # Simulate preprocessing time
|
||||
|
||||
# Simulate email cleaning
|
||||
cleaned = email.email.strip().lower()
|
||||
word_count = len(email.email.split())
|
||||
|
||||
# Check for suspicious patterns
|
||||
suspicious_patterns = ["urgent", "limited time", "act now", "free money"]
|
||||
has_suspicious = any(pattern in cleaned for pattern in suspicious_patterns)
|
||||
|
||||
result = EmailContent(
|
||||
original_message=email.email,
|
||||
cleaned_message=cleaned,
|
||||
word_count=word_count,
|
||||
has_suspicious_patterns=has_suspicious,
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@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
|
||||
|
||||
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
|
||||
sentiment_score = 0.5 if email_content.has_suspicious_patterns else 0.8
|
||||
|
||||
# Build risk indicators
|
||||
risk_indicators: list[str] = []
|
||||
if email_content.has_suspicious_patterns:
|
||||
risk_indicators.append("suspicious_language")
|
||||
if contains_links:
|
||||
risk_indicators.append("contains_links")
|
||||
if has_attachments:
|
||||
risk_indicators.append("has_attachments")
|
||||
if email_content.word_count < 10:
|
||||
risk_indicators.append("too_short")
|
||||
|
||||
# Check for spam keywords
|
||||
keyword_matches = [kw for kw in self._spam_keywords if kw in email_text]
|
||||
|
||||
# Calculate spam probability
|
||||
spam_score = 0.0
|
||||
spam_reasons: list[str] = []
|
||||
|
||||
if keyword_matches:
|
||||
spam_score += 0.4
|
||||
spam_reasons.append(f"spam_keywords: {keyword_matches}")
|
||||
|
||||
if email_content.has_suspicious_patterns:
|
||||
spam_score += 0.3
|
||||
spam_reasons.append("suspicious_patterns")
|
||||
|
||||
if len(risk_indicators) >= 3:
|
||||
spam_score += 0.2
|
||||
spam_reasons.append("high_risk_indicators")
|
||||
|
||||
if sentiment_score < 0.4:
|
||||
spam_score += 0.1
|
||||
spam_reasons.append("negative_sentiment")
|
||||
|
||||
is_spam = spam_score >= 0.5
|
||||
|
||||
# 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=spam_reasons,
|
||||
full_email_content=email_content,
|
||||
)
|
||||
|
||||
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
|
||||
ai_original = original_request.detected_as_spam
|
||||
confidence_score = original_request.confidence
|
||||
spam_reasons = original_request.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
|
||||
|
||||
result = SpamDetectorResponse(
|
||||
email_content=original_request.full_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,
|
||||
)
|
||||
|
||||
print(
|
||||
f"[SpamDetector] Sending SpamDetectorResponse: is_spam={is_spam}, confidence={confidence_score}, human_reviewed=True"
|
||||
)
|
||||
await ctx.send_message(result)
|
||||
print("[SpamDetector] Message sent successfully")
|
||||
|
||||
|
||||
class SpamHandler(Executor):
|
||||
"""Step 3a: An executor that handles spam messages with quarantine and logging."""
|
||||
|
||||
@handler
|
||||
async def handle_spam_detection(
|
||||
self,
|
||||
spam_result: SpamDetectorResponse,
|
||||
ctx: WorkflowContext[ProcessingResult],
|
||||
) -> None:
|
||||
"""Handle spam messages by quarantining and logging."""
|
||||
if not spam_result.is_spam:
|
||||
raise RuntimeError("Message is not spam, cannot process with spam handler.")
|
||||
|
||||
await asyncio.sleep(2.2) # Simulate spam handling time
|
||||
|
||||
result = ProcessingResult(
|
||||
original_message=spam_result.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."""
|
||||
|
||||
@handler
|
||||
async def handle_spam_detection(
|
||||
self,
|
||||
spam_result: SpamDetectorResponse,
|
||||
ctx: WorkflowContext[ProcessingResult],
|
||||
) -> None:
|
||||
"""Respond to legitimate messages."""
|
||||
if spam_result.is_spam:
|
||||
raise RuntimeError("Message is spam, cannot respond with message responder.")
|
||||
|
||||
await asyncio.sleep(2.5) # Simulate response time
|
||||
|
||||
result = ProcessingResult(
|
||||
original_message=spam_result.email_content.original_message,
|
||||
action_taken="delivered_to_inbox",
|
||||
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."""
|
||||
|
||||
@handler
|
||||
async def handle_processing_result(
|
||||
self,
|
||||
result: ProcessingResult,
|
||||
ctx: WorkflowContext[Never, str],
|
||||
) -> None:
|
||||
"""Complete the workflow with final processing and logging."""
|
||||
await asyncio.sleep(1.5) # Simulate final processing time
|
||||
|
||||
total_time = result.processing_time + 1.5
|
||||
|
||||
# Build classification status with human review info
|
||||
classification = "SPAM" if result.is_spam else "LEGITIMATE"
|
||||
|
||||
# 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"
|
||||
)
|
||||
|
||||
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
|
||||
email_preprocessor = EmailPreprocessor(id="email_preprocessor")
|
||||
spam_detector = SpamDetector(spam_keywords, id="spam_detector")
|
||||
spam_handler = SpamHandler(id="spam_handler")
|
||||
legitimate_message_handler = LegitimateMessageHandler(id="legitimate_message_handler")
|
||||
final_processor = FinalProcessor(id="final_processor")
|
||||
|
||||
# Build the comprehensive 4-step workflow with branching logic and HIL support
|
||||
# Note: No checkpoint_storage in constructor - DevUI will pass checkpoint_storage at runtime
|
||||
workflow = (
|
||||
WorkflowBuilder(
|
||||
name="Email Spam Detector",
|
||||
description="4-step email classification workflow with human-in-the-loop spam approval",
|
||||
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_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
|
||||
],
|
||||
)
|
||||
.add_edge(spam_handler, final_processor)
|
||||
.add_edge(legitimate_message_handler, final_processor)
|
||||
.build()
|
||||
)
|
||||
|
||||
# Note: Workflow metadata is determined by executors and graph structure
|
||||
|
||||
|
||||
def main():
|
||||
"""Launch the spam detection workflow in DevUI."""
|
||||
from agent_framework.devui import serve
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger.info("Starting Spam Detection Workflow")
|
||||
logger.info("Available at: http://localhost:8090")
|
||||
logger.info("Entity ID: workflow_spam_detection")
|
||||
|
||||
# Launch server with the workflow
|
||||
serve(entities=[workflow], port=8090, auto_open=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,9 @@
|
||||
# Azure OpenAI configuration for the Responses-based workflow sample
|
||||
# This sample uses Azure CLI auth, so run `az login` before starting DevUI.
|
||||
|
||||
AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com
|
||||
AZURE_OPENAI_CHAT_MODEL=gpt-4o
|
||||
# Optional fallback env name also supported by the client:
|
||||
# AZURE_OPENAI_MODEL=gpt-4o
|
||||
# Optional if you need to override the default API version:
|
||||
AZURE_OPENAI_API_VERSION=2024-10-21
|
||||
@@ -0,0 +1,7 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Sequential Agents Workflow - Writer → Reviewer."""
|
||||
|
||||
from .workflow import workflow # ty: ignore[unresolved-import] # pyrefly: ignore
|
||||
|
||||
__all__ = ["workflow"]
|
||||
@@ -0,0 +1,185 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Agent Workflow - Content Review with Quality Routing.
|
||||
|
||||
This sample demonstrates:
|
||||
- Using agents directly as executors
|
||||
- Conditional routing based on structured outputs
|
||||
- Quality-based workflow paths with convergence
|
||||
|
||||
Use case: Content creation with automated review.
|
||||
Writer creates content, Reviewer evaluates quality:
|
||||
- High quality (score >= 80): → Publisher → Summarizer
|
||||
- Low quality (score < 80): → Editor → Publisher → Summarizer
|
||||
Both paths converge at Summarizer for final report.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import Agent, AgentExecutorResponse, WorkflowBuilder
|
||||
from agent_framework.openai import OpenAIChatClient, OpenAIChatOptions
|
||||
from azure.identity import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
from pydantic import BaseModel
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
|
||||
# Define structured output for review results
|
||||
class ReviewResult(BaseModel):
|
||||
"""Review evaluation with scores and feedback."""
|
||||
|
||||
score: int # Overall quality score (0-100)
|
||||
feedback: str # Concise, actionable feedback
|
||||
clarity: int # Clarity score (0-100)
|
||||
completeness: int # Completeness score (0-100)
|
||||
accuracy: int # Accuracy score (0-100)
|
||||
structure: int # Structure score (0-100)
|
||||
|
||||
|
||||
# Condition function: route to editor if score < 80
|
||||
def needs_editing(message: Any) -> bool:
|
||||
"""Check if content needs editing based on review score."""
|
||||
if not isinstance(message, AgentExecutorResponse):
|
||||
return False
|
||||
try:
|
||||
review = ReviewResult.model_validate_json(message.agent_response.text)
|
||||
return review.score < 80
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# Condition function: content is approved (score >= 80)
|
||||
def is_approved(message: Any) -> bool:
|
||||
"""Check if content is approved (high quality)."""
|
||||
if not isinstance(message, AgentExecutorResponse):
|
||||
return True
|
||||
try:
|
||||
review = ReviewResult.model_validate_json(message.agent_response.text)
|
||||
return review.score >= 80
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
# Create Azure OpenAI Responses chat client
|
||||
client = OpenAIChatClient(
|
||||
model=os.environ.get("AZURE_OPENAI_CHAT_MODEL") or os.environ.get("AZURE_OPENAI_MODEL"),
|
||||
azure_endpoint=os.environ.get("AZURE_OPENAI_ENDPOINT"),
|
||||
api_version=os.environ.get("AZURE_OPENAI_API_VERSION"),
|
||||
credential=AzureCliCredential(),
|
||||
)
|
||||
|
||||
# Create Writer agent - generates content
|
||||
writer = Agent(
|
||||
client=client,
|
||||
name="Writer",
|
||||
instructions=(
|
||||
"You are an excellent content writer. "
|
||||
"Create clear, engaging content based on the user's request. "
|
||||
"Focus on clarity, accuracy, and proper structure."
|
||||
),
|
||||
)
|
||||
|
||||
# Create Reviewer agent - evaluates and provides structured feedback
|
||||
reviewer = Agent(
|
||||
client=client,
|
||||
name="Reviewer",
|
||||
instructions=(
|
||||
"You are an expert content reviewer. "
|
||||
"Evaluate the writer's content based on:\n"
|
||||
"1. Clarity - Is it easy to understand?\n"
|
||||
"2. Completeness - Does it fully address the topic?\n"
|
||||
"3. Accuracy - Is the information correct?\n"
|
||||
"4. Structure - Is it well-organized?\n\n"
|
||||
"Return a JSON object with:\n"
|
||||
"- score: overall quality (0-100)\n"
|
||||
"- feedback: concise, actionable feedback\n"
|
||||
"- clarity, completeness, accuracy, structure: individual scores (0-100)"
|
||||
),
|
||||
default_options=OpenAIChatOptions[Any](response_format=ReviewResult),
|
||||
)
|
||||
|
||||
# Create Editor agent - improves content based on feedback
|
||||
editor = Agent(
|
||||
client=client,
|
||||
name="Editor",
|
||||
instructions=(
|
||||
"You are a skilled editor. "
|
||||
"You will receive content along with review feedback. "
|
||||
"Improve the content by addressing all the issues mentioned in the feedback. "
|
||||
"Maintain the original intent while enhancing clarity, completeness, accuracy, and structure."
|
||||
),
|
||||
)
|
||||
|
||||
# Create Publisher agent - formats content for publication
|
||||
publisher = Agent(
|
||||
client=client,
|
||||
name="Publisher",
|
||||
instructions=(
|
||||
"You are a publishing agent. "
|
||||
"You receive either approved content or edited content. "
|
||||
"Format it for publication with proper headings and structure."
|
||||
),
|
||||
)
|
||||
|
||||
# Create Summarizer agent - creates final publication report
|
||||
summarizer = Agent(
|
||||
client=client,
|
||||
name="Summarizer",
|
||||
instructions=(
|
||||
"You are a summarizer agent. "
|
||||
"Create a final publication report that includes:\n"
|
||||
"1. A brief summary of the published content\n"
|
||||
"2. The workflow path taken (direct approval or edited)\n"
|
||||
"3. Key highlights and takeaways\n"
|
||||
"Keep it concise and professional."
|
||||
),
|
||||
)
|
||||
|
||||
# Build workflow with branching and convergence:
|
||||
# Writer → Reviewer → [branches]:
|
||||
# - If score >= 80: → Publisher → Summarizer (direct approval path)
|
||||
# - If score < 80: → Editor → Publisher → Summarizer (improvement path)
|
||||
# Both paths converge at Summarizer for final report
|
||||
workflow = (
|
||||
WorkflowBuilder(
|
||||
name="Content Review Workflow",
|
||||
description="Multi-agent content creation workflow with quality-based routing (Writer → Reviewer → Editor/Publisher)",
|
||||
start_executor=writer,
|
||||
)
|
||||
.add_edge(writer, reviewer)
|
||||
# Branch 1: High quality (>= 80) goes directly to publisher
|
||||
.add_edge(reviewer, publisher, condition=is_approved)
|
||||
# Branch 2: Low quality (< 80) goes to editor first, then publisher
|
||||
.add_edge(reviewer, editor, condition=needs_editing)
|
||||
.add_edge(editor, publisher)
|
||||
# Both paths converge: Publisher → Summarizer
|
||||
.add_edge(publisher, summarizer)
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
"""Launch the branching workflow in DevUI."""
|
||||
import logging
|
||||
|
||||
from agent_framework.devui import serve
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger.info("Starting Agent Workflow (Content Review with Quality Routing)")
|
||||
logger.info("Available at: http://localhost:8093")
|
||||
logger.info("\nThis workflow demonstrates:")
|
||||
logger.info("- Conditional routing based on structured outputs")
|
||||
logger.info("- Path 1 (score >= 80): Reviewer → Publisher → Summarizer")
|
||||
logger.info("- Path 2 (score < 80): Reviewer → Editor → Publisher → Summarizer")
|
||||
logger.info("- Both paths converge at Summarizer for final report")
|
||||
|
||||
serve(entities=[workflow], port=8093, auto_open=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user