chore: import upstream snapshot with attribution
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) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:25 +08:00
commit db620d33df
5151 changed files with 925932 additions and 0 deletions
@@ -0,0 +1,73 @@
# Single Agent
This sample demonstrates how to create a worker-client setup that hosts a single AI agent and provides interactive conversation via the Durable Task Scheduler.
## Key Concepts Demonstrated
- Using the Microsoft Agent Framework to define a simple AI agent with a name and instructions.
- Registering durable agents with the worker and interacting with them via a client.
- Conversation management (via sessions) for isolated interactions.
- Worker-client architecture for distributed agent execution.
## Environment Setup
See the [README.md](../README.md) file in the parent directory for more information on how to configure the environment, including how to install and run common sample dependencies.
## Running the Sample
With the environment setup, you can run the sample using the combined approach or separate worker and client processes:
**Option 1: Combined (Recommended for Testing)**
```bash
cd samples/04-hosting/durabletask/01_single_agent
python sample.py
```
**Option 2: Separate Processes**
Start the worker in one terminal:
```bash
python worker.py
```
In a new terminal, run the client:
```bash
python client.py
```
The client will interact with the Joker agent:
```
Starting Durable Task Agent Client...
Using taskhub: default
Using endpoint: http://localhost:8080
Getting reference to Joker agent...
Created conversation session: a1b2c3d4-e5f6-7890-abcd-ef1234567890
User: Tell me a short joke about cloud computing.
Joker: Why did the cloud break up with the server?
Because it found someone more "uplifting"!
User: Now tell me one about Python programming.
Joker: Why do Python programmers prefer dark mode?
Because light attracts bugs!
```
## Viewing Agent State
You can view the state of the agent in the Durable Task Scheduler dashboard:
1. Open your browser and navigate to `http://localhost:8082`
2. In the dashboard, you can view:
- The state of the Joker agent entity (dafx-Joker)
- Conversation history and current state
- How the durable agents extension manages conversation context
@@ -0,0 +1,123 @@
# Copyright (c) Microsoft. All rights reserved.
"""Client application for interacting with a Durable Task hosted agent.
This client connects to the Durable Task Scheduler and sends requests to
registered agents, demonstrating how to interact with agents from external processes.
Prerequisites:
- The worker must be running with the agent registered
- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL
- Sign in with Azure CLI for AzureCliCredential authentication
- Durable Task Scheduler must be running
"""
import asyncio
import logging
import os
from agent_framework.azure import DurableAIAgentClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from durabletask.azuremanaged.client import DurableTaskSchedulerClient
# Load environment variables from .env file
load_dotenv()
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def get_client(
taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None
) -> DurableAIAgentClient:
"""Create a configured DurableAIAgentClient.
Args:
taskhub: Task hub name (defaults to TASKHUB env var or "default")
endpoint: Scheduler endpoint (defaults to ENDPOINT env var or "http://localhost:8080")
log_handler: Optional logging handler for client logging
Returns:
Configured DurableAIAgentClient instance
"""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
logger.debug(f"Using taskhub: {taskhub_name}")
logger.debug(f"Using endpoint: {endpoint_url}")
credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential()
dts_client = DurableTaskSchedulerClient(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
log_handler=log_handler,
)
return DurableAIAgentClient(dts_client)
def run_client(agent_client: DurableAIAgentClient) -> None:
"""Run client interactions with the Joker agent.
Args:
agent_client: The DurableAIAgentClient instance
"""
# Get a reference to the Joker agent
logger.debug("Getting reference to Joker agent...")
joker = agent_client.get_agent("Joker")
# Create a new session for the conversation
session = joker.create_session()
logger.debug(f"Session ID: {session.session_id}")
logger.info("Start chatting with the Joker agent! (Type 'exit' to quit)")
# Interactive conversation loop
while True:
# Get user input
try:
user_message = input("You: ").strip()
except (EOFError, KeyboardInterrupt):
logger.info("\nExiting...")
break
# Check for exit command
if user_message.lower() == "exit":
logger.info("Goodbye!")
break
# Skip empty messages
if not user_message:
continue
# Send message to agent and get response
try:
response = joker.run(user_message, session=session)
logger.info(f"Joker: {response.text} \n")
except Exception as e:
logger.error(f"Error getting response: {e}")
logger.info("Conversation completed.")
async def main() -> None:
"""Main entry point for the client application."""
logger.debug("Starting Durable Task Agent Client...")
# Create client using helper function
agent_client = get_client()
try:
run_client(agent_client)
except Exception as e:
logger.exception(f"Error during agent interaction: {e}")
finally:
logger.debug("Client shutting down")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,14 @@
# Agent Framework packages
# To use the deployed version, uncomment the lines below and comment out the local installation lines
# agent-framework-foundry
# agent-framework-durabletask
# Local installation (for development and testing)
# Each package must be listed explicitly because pip doesn't resolve uv workspace sources.
# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source.
-e ../../../../packages/core # Core framework - base dependency for all packages
-e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples
-e ../../../../packages/durabletask # Durable Task support - the main package for this sample
# Azure authentication
azure-identity
@@ -0,0 +1,51 @@
# Copyright (c) Microsoft. All rights reserved.
"""Single Agent Sample - Durable Task Integration (Combined Worker + Client)
This sample demonstrates running both the worker and client in a single process.
The worker is started first to register the agent, then client operations are
performed against the running worker.
Prerequisites:
- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL
- Sign in with Azure CLI for AzureCliCredential authentication
- Durable Task Scheduler must be running (e.g., using Docker)
To run this sample:
python sample.py
"""
import logging
# Import helper functions from worker and client modules
from client import get_client, run_client # pyrefly: ignore[missing-import]
from dotenv import load_dotenv
from worker import get_worker, setup_worker # pyrefly: ignore[missing-import]
# Configure logging (must be after imports to override their basicConfig)
logging.basicConfig(level=logging.INFO, force=True)
logger = logging.getLogger(__name__)
def main():
"""Main entry point - runs both worker and client in single process."""
logger.debug("Starting Durable Task Agent Sample (Combined Worker + Client)...")
silent_handler = logging.NullHandler()
# Create and start the worker using helper function and context manager
dts_worker = get_worker(log_handler=silent_handler)
with dts_worker:
# Register agents using helper function
setup_worker(dts_worker)
# Start the worker
dts_worker.start()
logger.debug("Worker started and listening for requests...")
# Create the client using helper function
agent_client = get_client(log_handler=silent_handler)
try:
# Run client interactions using helper function
run_client(agent_client)
except Exception as e:
logger.exception(f"Error during agent interaction: {e}")
logger.debug("Sample completed. Worker shutting down...")
if __name__ == "__main__":
load_dotenv()
main()
@@ -0,0 +1,132 @@
# Copyright (c) Microsoft. All rights reserved.
"""Worker process for hosting a single Azure OpenAI-powered agent using Durable Task.
This worker registers agents as durable entities and continuously listens for requests.
The worker should run as a background service, processing incoming agent requests.
Prerequisites:
- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL
- Sign in with Azure CLI for AzureCliCredential authentication
- Start a Durable Task Scheduler (e.g., using Docker)
"""
import asyncio
import logging
import os
from agent_framework import Agent
from agent_framework.azure import DurableAIAgentWorker
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential
from dotenv import load_dotenv
from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker
# Load environment variables from .env file
load_dotenv()
# Configure logging
logging.basicConfig(level=logging.WARNING)
logger = logging.getLogger(__name__)
def create_joker_agent() -> Agent:
"""Create the Joker agent using Azure OpenAI.
Returns:
Agent: The configured Joker agent
"""
return Agent(
client=FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AsyncAzureCliCredential(),
),
name="Joker",
instructions="You are good at telling jokes.",
)
def get_worker(
taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None
) -> DurableTaskSchedulerWorker:
"""Create a configured DurableTaskSchedulerWorker.
Args:
taskhub: Task hub name (defaults to TASKHUB env var or "default")
endpoint: Scheduler endpoint (defaults to ENDPOINT env var or "http://localhost:8080")
log_handler: Optional logging handler for worker logging
Returns:
Configured DurableTaskSchedulerWorker instance
"""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
logger.debug(f"Using taskhub: {taskhub_name}")
logger.debug(f"Using endpoint: {endpoint_url}")
credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential()
return DurableTaskSchedulerWorker(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
log_handler=log_handler,
)
def setup_worker(worker: DurableTaskSchedulerWorker) -> DurableAIAgentWorker:
"""Set up the worker with agents registered.
Args:
worker: The DurableTaskSchedulerWorker instance
Returns:
DurableAIAgentWorker with agents registered
"""
# Wrap it with the agent worker
agent_worker = DurableAIAgentWorker(worker)
# Create and register the Joker agent
logger.debug("Creating and registering Joker agent...")
joker_agent = create_joker_agent()
agent_worker.add_agent(joker_agent)
logger.debug(f"✓ Registered agent: {joker_agent.name}")
logger.debug(f" Entity name: dafx-{joker_agent.name}")
return agent_worker
async def main():
"""Main entry point for the worker process."""
logger.debug("Starting Durable Task Agent Worker...")
# Create a worker using the helper function
worker = get_worker()
# Setup worker with agents
setup_worker(worker)
logger.info("Worker is ready and listening for requests...")
logger.info("Press Ctrl+C to stop.")
logger.info("")
try:
# Start the worker (this blocks until stopped)
worker.start()
# Keep the worker running
while True:
await asyncio.sleep(1)
except KeyboardInterrupt:
logger.debug("Worker shutdown initiated")
logger.debug("Worker stopped")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,80 @@
# Multi-Agent
This sample demonstrates how to host multiple AI agents with different tools in a single worker-client setup using the Durable Task Scheduler.
## Key Concepts Demonstrated
- Hosting multiple agents (WeatherAgent and MathAgent) in a single worker process.
- Each agent with its own specialized tools and instructions.
- Interacting with different agents using separate conversation sessions.
- Worker-client architecture for multi-agent systems.
## Environment Setup
See the [README.md](../README.md) file in the parent directory for more information on how to configure the environment, including how to install and run common sample dependencies.
## Running the Sample
With the environment setup, you can run the sample using the combined approach or separate worker and client processes:
**Option 1: Combined (Recommended for Testing)**
```bash
cd samples/04-hosting/durabletask/02_multi_agent
python sample.py
```
**Option 2: Separate Processes**
Start the worker in one terminal:
```bash
python worker.py
```
In a new terminal, run the client:
```bash
python client.py
```
The client will interact with both agents:
```
Starting Durable Task Multi-Agent Client...
Using taskhub: default
Using endpoint: http://localhost:8080
================================================================================
Testing WeatherAgent
================================================================================
Created weather conversation session: <guid>
User: What is the weather in Seattle?
🔧 [TOOL CALLED] get_weather(location=Seattle)
✓ [TOOL RESULT] {'location': 'Seattle', 'temperature': 72, 'conditions': 'Sunny', 'humidity': 45}
WeatherAgent: The current weather in Seattle is sunny with a temperature of 72°F and 45% humidity.
================================================================================
Testing MathAgent
================================================================================
Created math conversation session: <guid>
User: Calculate a 20% tip on a $50 bill
🔧 [TOOL CALLED] calculate_tip(bill_amount=50.0, tip_percentage=20.0)
✓ [TOOL RESULT] {'bill_amount': 50.0, 'tip_percentage': 20.0, 'tip_amount': 10.0, 'total': 60.0}
MathAgent: For a $50 bill with a 20% tip, the tip amount is $10.00 and the total is $60.00.
```
## Viewing Agent State
You can view the state of both agents in the Durable Task Scheduler dashboard:
1. Open your browser and navigate to `http://localhost:8082`
2. In the dashboard, you can view:
- The state of both WeatherAgent and MathAgent entities (dafx-WeatherAgent, dafx-MathAgent)
- Each agent's conversation state across multiple interactions
@@ -0,0 +1,120 @@
# Copyright (c) Microsoft. All rights reserved.
"""Client application for interacting with multiple hosted agents.
This client connects to the Durable Task Scheduler and interacts with two different
agents (WeatherAgent and MathAgent), demonstrating how to work with multiple agents
each with their own specialized capabilities and tools.
Prerequisites:
- The worker must be running with both agents registered
- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_MODEL when running the worker
- Sign in with Azure CLI for AzureCliCredential authentication
- Durable Task Scheduler must be running
"""
import asyncio
import logging
import os
from agent_framework.azure import DurableAIAgentClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from durabletask.azuremanaged.client import DurableTaskSchedulerClient
# Load environment variables from .env file
load_dotenv()
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def get_client(
taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None
) -> DurableAIAgentClient:
"""Create a configured DurableAIAgentClient.
Args:
taskhub: Task hub name (defaults to TASKHUB env var or "default")
endpoint: Scheduler endpoint (defaults to ENDPOINT env var or "http://localhost:8080")
log_handler: Optional logging handler for client logging
Returns:
Configured DurableAIAgentClient instance
"""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
logger.debug(f"Using taskhub: {taskhub_name}")
logger.debug(f"Using endpoint: {endpoint_url}")
credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential()
dts_client = DurableTaskSchedulerClient(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
log_handler=log_handler,
)
return DurableAIAgentClient(dts_client)
def run_client(agent_client: DurableAIAgentClient) -> None:
"""Run client interactions with both WeatherAgent and MathAgent.
Args:
agent_client: The DurableAIAgentClient instance
"""
logger.debug("Testing WeatherAgent")
# Get reference to WeatherAgent
weather_agent = agent_client.get_agent("WeatherAgent")
weather_session = weather_agent.create_session()
logger.debug(f"Created weather conversation session: {weather_session.session_id}")
# Test WeatherAgent
weather_message = "What is the weather in Seattle?"
logger.info(f"User: {weather_message}")
weather_response = weather_agent.run(weather_message, session=weather_session)
logger.info(f"WeatherAgent: {weather_response.text} \n")
logger.debug("Testing MathAgent")
# Get reference to MathAgent
math_agent = agent_client.get_agent("MathAgent")
math_session = math_agent.create_session()
logger.debug(f"Created math conversation session: {math_session.session_id}")
# Test MathAgent
math_message = "Calculate a 20% tip on a $50 bill"
logger.info(f"User: {math_message}")
math_response = math_agent.run(math_message, session=math_session)
logger.info(f"MathAgent: {math_response.text} \n")
logger.debug("Both agents completed successfully!")
async def main() -> None:
"""Main entry point for the client application."""
logger.debug("Starting Durable Task Multi-Agent Client...")
# Create client using helper function
agent_client = get_client()
try:
run_client(agent_client)
except Exception as e:
logger.exception(f"Error during agent interaction: {e}")
finally:
logger.debug("Client shutting down")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,14 @@
# Agent Framework packages
# To use the deployed version, uncomment the lines below and comment out the local installation lines
# agent-framework-foundry
# agent-framework-durabletask
# Local installation (for development and testing)
# Each package must be listed explicitly because pip doesn't resolve uv workspace sources.
# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source.
-e ../../../../packages/core # Core framework - base dependency for all packages
-e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples
-e ../../../../packages/durabletask # Durable Task support - the main package for this sample
# Azure authentication
azure-identity
@@ -0,0 +1,51 @@
# Copyright (c) Microsoft. All rights reserved.
"""Multi-Agent Sample - Durable Task Integration (Combined Worker + Client)
This sample demonstrates running both the worker and client in a single process
for multiple agents with different tools. The worker registers two agents
(WeatherAgent and MathAgent), each with their own specialized capabilities.
Prerequisites:
- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_MODEL
- Sign in with Azure CLI for AzureCliCredential authentication
- Durable Task Scheduler must be running (e.g., using Docker)
To run this sample:
python sample.py
"""
import logging
# Import helper functions from worker and client modules
from client import get_client, run_client # pyrefly: ignore[missing-import]
from dotenv import load_dotenv
from worker import get_worker, setup_worker # pyrefly: ignore[missing-import]
# Configure logging
logging.basicConfig(level=logging.INFO, force=True)
logger = logging.getLogger(__name__)
def main():
"""Main entry point - runs both worker and client in single process."""
logger.debug("Starting Durable Task Multi-Agent Sample (Combined Worker + Client)...")
silent_handler = logging.NullHandler()
# Create and start the worker using helper function and context manager
dts_worker = get_worker(log_handler=silent_handler)
with dts_worker:
# Register agents using helper function
setup_worker(dts_worker)
# Start the worker
dts_worker.start()
logger.debug("Worker started and listening for requests...")
# Create the client using helper function
agent_client = get_client(log_handler=silent_handler)
try:
# Run client interactions using helper function
run_client(agent_client)
except Exception as e:
logger.exception(f"Error during agent interaction: {e}")
logger.debug("Sample completed. Worker shutting down...")
if __name__ == "__main__":
load_dotenv()
main()
@@ -0,0 +1,184 @@
# Copyright (c) Microsoft. All rights reserved.
"""Worker process for hosting multiple Azure OpenAI agents with different tools using Durable Task.
This worker registers two agents - a weather assistant and a math assistant - each
with their own specialized tools. This demonstrates how to host multiple agents
with different capabilities in a single worker process.
Prerequisites:
- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_MODEL
- Sign in with Azure CLI for AzureCliCredential authentication
- Start a Durable Task Scheduler (e.g., using Docker)
"""
import asyncio
import logging
import os
from typing import Any
from agent_framework import Agent, tool
from agent_framework.azure import DurableAIAgentWorker
from agent_framework.openai import OpenAIChatCompletionClient
from azure.identity import AzureCliCredential
from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential
from dotenv import load_dotenv
from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker
# Load environment variables from .env file
load_dotenv()
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Agent names
WEATHER_AGENT_NAME = "WeatherAgent"
MATH_AGENT_NAME = "MathAgent"
@tool
def get_weather(location: str) -> dict[str, Any]:
"""Get current weather for a location."""
logger.info(f"🔧 [TOOL CALLED] get_weather(location={location})")
result = {
"location": location,
"temperature": 72,
"conditions": "Sunny",
"humidity": 45,
}
logger.info(f"✓ [TOOL RESULT] {result}")
return result
@tool
def calculate_tip(bill_amount: float, tip_percentage: float = 15.0) -> dict[str, Any]:
"""Calculate tip amount and total bill."""
logger.info(f"🔧 [TOOL CALLED] calculate_tip(bill_amount={bill_amount}, tip_percentage={tip_percentage})")
tip = bill_amount * (tip_percentage / 100)
total = bill_amount + tip
result = {
"bill_amount": bill_amount,
"tip_percentage": tip_percentage,
"tip_amount": round(tip, 2),
"total": round(total, 2),
}
logger.info(f"✓ [TOOL RESULT] {result}")
return result
def create_weather_agent():
"""Create the Weather agent using Azure OpenAI.
Returns:
Agent: The configured Weather agent with weather tool
"""
return Agent(
client=OpenAIChatCompletionClient(
credential=AsyncAzureCliCredential(),
),
name=WEATHER_AGENT_NAME,
instructions="You are a helpful weather assistant. Provide current weather information.",
tools=[get_weather],
)
def create_math_agent():
"""Create the Math agent using Azure OpenAI.
Returns:
Agent: The configured Math agent with calculation tools
"""
return Agent(
client=OpenAIChatCompletionClient(
credential=AsyncAzureCliCredential(),
),
name=MATH_AGENT_NAME,
instructions="You are a helpful math assistant. Help users with calculations like tip calculations.",
tools=[calculate_tip],
)
def get_worker(
taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None
) -> DurableTaskSchedulerWorker:
"""Create a configured DurableTaskSchedulerWorker.
Args:
taskhub: Task hub name (defaults to TASKHUB env var or "default")
endpoint: Scheduler endpoint (defaults to ENDPOINT env var or "http://localhost:8080")
log_handler: Optional logging handler for worker logging
Returns:
Configured DurableTaskSchedulerWorker instance
"""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
logger.debug(f"Using taskhub: {taskhub_name}")
logger.debug(f"Using endpoint: {endpoint_url}")
credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential()
return DurableTaskSchedulerWorker(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
log_handler=log_handler,
)
def setup_worker(worker: DurableTaskSchedulerWorker) -> DurableAIAgentWorker:
"""Set up the worker with multiple agents registered.
Args:
worker: The DurableTaskSchedulerWorker instance
Returns:
DurableAIAgentWorker with agents registered
"""
# Wrap it with the agent worker
agent_worker = DurableAIAgentWorker(worker)
# Create and register both agents
logger.debug("Creating and registering agents...")
weather_agent = create_weather_agent()
math_agent = create_math_agent()
agent_worker.add_agent(weather_agent)
agent_worker.add_agent(math_agent)
logger.debug(f"✓ Registered agents: {weather_agent.name}, {math_agent.name}")
return agent_worker
async def main():
"""Main entry point for the worker process."""
logger.debug("Starting Durable Task Multi-Agent Worker...")
# Create a worker using the helper function
worker = get_worker()
# Setup worker with agents
setup_worker(worker)
logger.info("Worker is ready and listening for requests...")
logger.info("Press Ctrl+C to stop. \n")
try:
# Start the worker (this blocks until stopped)
worker.start()
# Keep the worker running
while True:
await asyncio.sleep(1)
except KeyboardInterrupt:
logger.debug("Worker shutdown initiated")
logger.info("Worker stopped")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,150 @@
# Single Agent with Reliable Streaming
This sample demonstrates how to use Redis Streams with agent response callbacks to enable reliable, resumable streaming for durable agents. Streaming responses are persisted to Redis, allowing clients to disconnect and reconnect without losing messages.
## Key Concepts Demonstrated
- Using `AgentResponseCallbackProtocol` to capture streaming agent responses.
- Persisting streaming chunks to Redis Streams for reliable delivery.
- Non-blocking agent execution with `options={"wait_for_response": False}` (fire-and-forget mode).
- Cursor-based resumption for disconnected clients.
- Decoupling agent execution from response streaming.
## Prerequisites
In addition to the common setup in the parent [README.md](../README.md), this sample requires Redis:
```bash
docker run -d --name redis -p 6379:6379 redis:latest
```
## Environment Setup
See the [README.md](../README.md) file in the parent directory for more information on how to configure the environment, including how to install and run common sample dependencies.
Additional environment variables for this sample:
```bash
# Optional: Redis Configuration
REDIS_CONNECTION_STRING=redis://localhost:6379
REDIS_STREAM_TTL_MINUTES=10
```
## Running the Sample
With the environment setup, you can run the sample using the combined approach or separate worker and client processes:
**Option 1: Combined (Recommended for Testing)**
```bash
cd samples/04-hosting/durabletask/03_single_agent_streaming
python sample.py
```
**Option 2: Separate Processes**
Start the worker in one terminal:
```bash
python worker.py
```
In a new terminal, run the client:
```bash
python client.py
```
The client will send a travel planning request to the TravelPlanner agent and stream the response from Redis in real-time:
```
================================================================================
TravelPlanner Agent - Redis Streaming Demo
================================================================================
You: Plan a 3-day trip to Tokyo with emphasis on culture and food
TravelPlanner (streaming from Redis):
--------------------------------------------------------------------------------
# Your Amazing 3-Day Tokyo Adventure! 🗾
Let me create the perfect cultural and culinary journey through Tokyo...
## Day 1: Traditional Tokyo & First Impressions
...
(continues streaming)
...
✓ Response complete!
```
## How It Works
### Redis Streaming Callback
The `RedisStreamCallback` class implements `AgentResponseCallbackProtocol` to capture streaming updates and persist them to Redis:
```python
class RedisStreamCallback(AgentResponseCallbackProtocol):
async def on_streaming_response_update(self, update, context):
# Write chunk to Redis Stream
async with await get_stream_handler() as handler:
await handler.write_chunk(thread_id, update.text, sequence)
async def on_agent_response(self, response, context):
# Write end-of-stream marker
async with await get_stream_handler() as handler:
await handler.write_completion(thread_id, sequence)
```
### Worker Registration
The worker registers the agent with the Redis streaming callback:
```python
redis_callback = RedisStreamCallback()
agent_worker = DurableAIAgentWorker(worker, callback=redis_callback)
agent_worker.add_agent(create_travel_agent())
```
### Client Streaming
The client uses fire-and-forget mode to start the agent and streams from Redis:
```python
# Start agent run with wait_for_response=False for non-blocking execution
travel_planner.run(user_message, thread=thread, options={"wait_for_response": False})
# Stream response from Redis while the agent is processing
async with await get_stream_handler() as stream_handler:
async for chunk in stream_handler.read_stream(thread_id):
if chunk.text:
print(chunk.text, end="", flush=True)
elif chunk.is_done:
break
```
**Fire-and-Forget Mode**: Use `options={"wait_for_response": False}` to enable non-blocking execution. The `run()` method signals the agent and returns immediately, allowing the client to stream from Redis without blocking.
### Cursor-Based Resumption
Clients can resume streaming from any point after disconnection:
```python
cursor = "1734649123456-0" # Entry ID from previous stream
async with await get_stream_handler() as stream_handler:
async for chunk in stream_handler.read_stream(thread_id, cursor=cursor):
# Process chunk
```
## Viewing Agent State
You can view the state of the TravelPlanner agent in the Durable Task Scheduler dashboard:
1. Open your browser and navigate to `http://localhost:8082`
2. In the dashboard, you can view:
- The state of the TravelPlanner agent entity (dafx-TravelPlanner)
- Conversation history and current state
- How the durable agents extension manages conversation context with streaming
@@ -0,0 +1,186 @@
# Copyright (c) Microsoft. All rights reserved.
"""Client application for interacting with the TravelPlanner agent and streaming from Redis.
This client demonstrates:
1. Sending a travel planning request to the durable agent
2. Streaming the response from Redis in real-time
3. Handling reconnection and cursor-based resumption
Prerequisites:
- The worker must be running with the TravelPlanner agent registered
- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL
- Redis must be running
- Durable Task Scheduler must be running
"""
import asyncio
import logging
import os
from datetime import timedelta
import redis.asyncio as aioredis
from agent_framework.azure import DurableAIAgentClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from durabletask.azuremanaged.client import DurableTaskSchedulerClient
from redis_stream_response_handler import RedisStreamResponseHandler # pyrefly: ignore[missing-import]
# Load environment variables from .env file
load_dotenv()
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Configuration
REDIS_CONNECTION_STRING = os.environ.get("REDIS_CONNECTION_STRING", "redis://localhost:6379")
REDIS_STREAM_TTL_MINUTES = int(os.environ.get("REDIS_STREAM_TTL_MINUTES", "10"))
async def get_stream_handler() -> RedisStreamResponseHandler:
"""Create a new Redis stream handler for each request.
This avoids event loop conflicts by creating a fresh Redis client
in the current event loop context.
"""
# Create a new Redis client in the current event loop
redis_client = aioredis.from_url( # type: ignore[reportUnknownMemberType]
REDIS_CONNECTION_STRING,
encoding="utf-8",
decode_responses=False,
)
return RedisStreamResponseHandler(
redis_client=redis_client,
stream_ttl=timedelta(minutes=REDIS_STREAM_TTL_MINUTES),
)
def get_client(
taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None
) -> DurableAIAgentClient:
"""Create a configured DurableAIAgentClient.
Args:
taskhub: Task hub name (defaults to TASKHUB env var or "default")
endpoint: Scheduler endpoint (defaults to ENDPOINT env var or "http://localhost:8080")
log_handler: Optional log handler for client logging
Returns:
Configured DurableAIAgentClient instance
"""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
logger.debug(f"Using taskhub: {taskhub_name}")
logger.debug(f"Using endpoint: {endpoint_url}")
credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential()
dts_client = DurableTaskSchedulerClient(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
log_handler=log_handler,
)
return DurableAIAgentClient(dts_client)
async def stream_from_redis(thread_id: str, cursor: str | None = None) -> None:
"""Stream agent responses from Redis.
Args:
thread_id: The conversation/thread ID to stream from
cursor: Optional cursor to resume from. If None, starts from beginning.
"""
stream_key = f"agent-stream:{thread_id}"
logger.info(f"Streaming response from Redis (thread: {thread_id[:8]}...)")
logger.debug(f"To manually check Redis, run: redis-cli XLEN {stream_key}")
if cursor:
logger.info(f"Resuming from cursor: {cursor}")
async with await get_stream_handler() as stream_handler:
logger.info("Stream handler created, starting to read...")
try:
chunk_count = 0
async for chunk in stream_handler.read_stream(thread_id, cursor):
chunk_count += 1
logger.debug(
f"Received chunk #{chunk_count}: error={chunk.error}, is_done={chunk.is_done}, text_len={len(chunk.text) if chunk.text else 0}"
)
if chunk.error:
logger.error(f"Stream error: {chunk.error}")
break
if chunk.is_done:
print("\n✓ Response complete!", flush=True)
logger.info(f"Stream completed after {chunk_count} chunks")
break
if chunk.text:
# Print directly to console with flush for immediate display
print(chunk.text, end="", flush=True)
if chunk_count == 0:
logger.warning("No chunks received from Redis stream!")
logger.warning(f"Check Redis manually: redis-cli XLEN {stream_key}")
logger.warning(f"View stream contents: redis-cli XREAD STREAMS {stream_key} 0")
except Exception as ex:
logger.error(f"Error reading from Redis: {ex}", exc_info=True)
def run_client(agent_client: DurableAIAgentClient) -> None:
"""Run client interactions with the TravelPlanner agent.
Args:
agent_client: The DurableAIAgentClient instance
"""
# Get a reference to the TravelPlanner agent
logger.debug("Getting reference to TravelPlanner agent...")
travel_planner = agent_client.get_agent("TravelPlanner")
# Create a new session for the conversation
session = travel_planner.create_session()
if not session.session_id:
logger.error("Failed to create a new session with session ID!")
return
key = session.session_id
logger.info(f"Session ID: {key}")
# Get user input
print("\nEnter your travel planning request:")
user_message = input("> ").strip()
if not user_message:
logger.warning("No input provided. Using default message.")
user_message = "Plan a 3-day trip to Tokyo with emphasis on culture and food"
logger.info(f"\nYou: {user_message}\n")
logger.info("TravelPlanner (streaming from Redis):")
logger.info("-" * 80)
# Start the agent run with wait_for_response=False for non-blocking execution
# This signals the agent to start processing without waiting for completion
# The agent will execute in the background and write chunks to Redis
travel_planner.run(user_message, session=session, options={"wait_for_response": False})
# Stream the response from Redis
# This demonstrates that the client can stream from Redis while
# the agent is still processing (or after it completes)
asyncio.run(stream_from_redis(str(key)))
logger.info("\nDemo completed!")
if __name__ == "__main__":
# Create the client
client = get_client()
# Run the demo
run_client(client)
@@ -0,0 +1,203 @@
# Copyright (c) Microsoft. All rights reserved.
"""Redis-based streaming response handler for durable agents.
This module provides reliable, resumable streaming of agent responses using Redis Streams
as a message broker. It enables clients to disconnect and reconnect without losing messages.
"""
import asyncio
import time
from collections.abc import AsyncIterator
from dataclasses import dataclass
from datetime import timedelta
import redis.asyncio as aioredis
@dataclass
class StreamChunk:
"""Represents a chunk of streamed data from Redis.
Attributes:
entry_id: The Redis stream entry ID (used as cursor for resumption).
text: The text content of the chunk, if any.
is_done: Whether this is the final chunk in the stream.
error: Error message if an error occurred, otherwise None.
"""
entry_id: str
text: str | None = None
is_done: bool = False
error: str | None = None
class RedisStreamResponseHandler:
"""Handles agent responses by persisting them to Redis Streams.
This handler writes agent response updates to Redis Streams, enabling reliable,
resumable streaming delivery to clients. Clients can disconnect and reconnect
at any point using cursor-based pagination.
Attributes:
MAX_EMPTY_READS: Maximum number of empty reads before timing out.
POLL_INTERVAL_MS: Interval in milliseconds between polling attempts.
"""
MAX_EMPTY_READS = 300
POLL_INTERVAL_MS = 1000
def __init__(self, redis_client: aioredis.Redis, stream_ttl: timedelta):
"""Initialize the Redis stream response handler.
Args:
redis_client: The async Redis client instance.
stream_ttl: Time-to-live for stream entries in Redis.
"""
self._redis = redis_client
self._stream_ttl = stream_ttl
async def __aenter__(self):
"""Enter async context manager."""
return self
async def __aexit__(
self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: object
) -> None:
"""Exit async context manager and close Redis connection."""
await self._redis.aclose()
async def write_chunk(
self,
conversation_id: str,
text: str,
sequence: int,
) -> None:
"""Write a single text chunk to the Redis Stream.
Args:
conversation_id: The conversation ID for this agent run.
text: The text content to write.
sequence: The sequence number for ordering.
"""
stream_key = self._get_stream_key(conversation_id)
await self._redis.xadd(
stream_key,
{
"text": text,
"sequence": str(sequence),
"timestamp": str(int(time.time() * 1000)),
},
)
await self._redis.expire(stream_key, self._stream_ttl)
async def write_completion(
self,
conversation_id: str,
sequence: int,
) -> None:
"""Write an end-of-stream marker to the Redis Stream.
Args:
conversation_id: The conversation ID for this agent run.
sequence: The final sequence number.
"""
stream_key = self._get_stream_key(conversation_id)
await self._redis.xadd(
stream_key,
{
"text": "",
"sequence": str(sequence),
"timestamp": str(int(time.time() * 1000)),
"done": "true",
},
)
await self._redis.expire(stream_key, self._stream_ttl)
async def read_stream(
self,
conversation_id: str,
cursor: str | None = None,
) -> AsyncIterator[StreamChunk]:
"""Read entries from a Redis Stream with cursor-based pagination.
This method polls the Redis Stream for new entries, yielding chunks as they
become available. Clients can resume from any point using the entry_id from
a previous chunk.
Args:
conversation_id: The conversation ID to read from.
cursor: Optional cursor to resume from. If None, starts from beginning.
Yields:
StreamChunk instances containing text content or status markers.
"""
stream_key = self._get_stream_key(conversation_id)
start_id = cursor if cursor else "0-0"
empty_read_count = 0
has_seen_data = False
while True:
try:
# Read up to 100 entries from the stream
entries = await self._redis.xread(
{stream_key: start_id},
count=100,
block=None,
)
if not entries:
# No entries found
if not has_seen_data:
empty_read_count += 1
if empty_read_count >= self.MAX_EMPTY_READS:
timeout_seconds = self.MAX_EMPTY_READS * self.POLL_INTERVAL_MS / 1000
yield StreamChunk(
entry_id=start_id,
error=f"Stream not found or timed out after {timeout_seconds} seconds",
)
return
# Wait before polling again
await asyncio.sleep(self.POLL_INTERVAL_MS / 1000)
continue
has_seen_data = True
# Process entries from the stream
for _stream_name, stream_entries in entries:
for entry_id, entry_data in stream_entries:
start_id = entry_id.decode() if isinstance(entry_id, bytes) else entry_id
# Decode entry data
text = entry_data.get(b"text", b"").decode() if b"text" in entry_data else None
done = entry_data.get(b"done", b"").decode() if b"done" in entry_data else None
error = entry_data.get(b"error", b"").decode() if b"error" in entry_data else None
if error:
yield StreamChunk(entry_id=start_id, error=error)
return
if done == "true":
yield StreamChunk(entry_id=start_id, is_done=True)
return
if text:
yield StreamChunk(entry_id=start_id, text=text)
except Exception as ex:
yield StreamChunk(entry_id=start_id, error=str(ex))
return
@staticmethod
def _get_stream_key(conversation_id: str) -> str:
"""Generate the Redis key for a conversation's stream.
Args:
conversation_id: The conversation ID.
Returns:
The Redis stream key.
"""
return f"agent-stream:{conversation_id}"
@@ -0,0 +1,17 @@
# Agent Framework packages
# To use the deployed version, uncomment the lines below and comment out the local installation lines
# agent-framework-foundry
# agent-framework-durabletask
# Local installation (for development and testing)
# Each package must be listed explicitly because pip doesn't resolve uv workspace sources.
# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source.
-e ../../../../packages/core # Core framework - base dependency for all packages
-e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples
-e ../../../../packages/durabletask # Durable Task support - the main package for this sample
# Azure authentication
azure-identity
# Redis client with asyncio support (used by redis_stream_response_handler.py)
redis[asyncio]
@@ -0,0 +1,53 @@
# Copyright (c) Microsoft. All rights reserved.
"""Single Agent Streaming Sample - Durable Task Integration (Combined Worker + Client)
This sample demonstrates running both the worker and client in a single process
with reliable Redis-based streaming for agent responses.
The worker is started first to register the TravelPlanner agent with Redis streaming
callback, then client operations are performed against the running worker.
Prerequisites:
- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL
- Sign in with Azure CLI for AzureCliCredential authentication
- Durable Task Scheduler must be running (e.g., using Docker)
- Redis must be running (e.g., docker run -d --name redis -p 6379:6379 redis:latest)
To run this sample:
python sample.py
"""
import logging
# Import helper functions from worker and client modules
from client import get_client, run_client # pyrefly: ignore[missing-import]
from dotenv import load_dotenv
from worker import get_worker, setup_worker # pyrefly: ignore[missing-import]
# Configure logging (must be after imports to override their basicConfig)
logging.basicConfig(level=logging.INFO, force=True)
logger = logging.getLogger(__name__)
def main():
"""Main entry point - runs both worker and client in single process."""
logger.debug("Starting Durable Task Agent Sample with Redis Streaming...")
silent_handler = logging.NullHandler()
# Create and start the worker using helper function and context manager
dts_worker = get_worker(log_handler=silent_handler)
with dts_worker:
# Register agents and callbacks using helper function
setup_worker(dts_worker)
# Start the worker
dts_worker.start()
logger.debug("Worker started and listening for requests...")
# Create the client using helper function
agent_client = get_client(log_handler=silent_handler)
try:
# Run client interactions using helper function
run_client(agent_client)
except Exception as e:
logger.exception(f"Error during agent interaction: {e}")
logger.debug("Sample completed. Worker shutting down...")
if __name__ == "__main__":
load_dotenv()
main()
@@ -0,0 +1,168 @@
# Copyright (c) Microsoft. All rights reserved.
"""Mock travel tools for demonstration purposes.
In a real application, these would call actual weather and events APIs.
"""
from typing import Annotated
from agent_framework import tool
@tool
def get_weather_forecast(
destination: Annotated[str, "The destination city or location"],
date: Annotated[str, 'The date for the forecast (e.g., "2025-01-15" or "next Monday")'],
) -> str:
"""Get the weather forecast for a destination on a specific date.
Use this to provide weather-aware recommendations in the itinerary.
Args:
destination: The destination city or location.
date: The date for the forecast.
Returns:
A weather forecast summary.
"""
# Mock weather data based on destination for realistic responses
weather_by_region = {
"Tokyo": ("Partly cloudy with a chance of light rain", 58, 45),
"Paris": ("Overcast with occasional drizzle", 52, 41),
"New York": ("Clear and cold", 42, 28),
"London": ("Foggy morning, clearing in afternoon", 48, 38),
"Sydney": ("Sunny and warm", 82, 68),
"Rome": ("Sunny with light breeze", 62, 48),
"Barcelona": ("Partly sunny", 59, 47),
"Amsterdam": ("Cloudy with light rain", 46, 38),
"Dubai": ("Sunny and hot", 85, 72),
"Singapore": ("Tropical thunderstorms in afternoon", 88, 77),
"Bangkok": ("Hot and humid, afternoon showers", 91, 78),
"Los Angeles": ("Sunny and pleasant", 72, 55),
"San Francisco": ("Morning fog, afternoon sun", 62, 52),
"Seattle": ("Rainy with breaks", 48, 40),
"Miami": ("Warm and sunny", 78, 65),
"Honolulu": ("Tropical paradise weather", 82, 72),
}
# Find a matching destination or use a default
forecast = ("Partly cloudy", 65, 50)
for city, weather in weather_by_region.items():
if city.lower() in destination.lower():
forecast = weather
break
condition, high_f, low_f = forecast
high_c = (high_f - 32) * 5 // 9
low_c = (low_f - 32) * 5 // 9
recommendation = _get_weather_recommendation(condition)
return f"""Weather forecast for {destination} on {date}:
Conditions: {condition}
High: {high_f}°F ({high_c}°C)
Low: {low_f}°F ({low_c}°C)
Recommendation: {recommendation}"""
@tool
def get_local_events(
destination: Annotated[str, "The destination city or location"],
date: Annotated[str, 'The date to search for events (e.g., "2025-01-15" or "next week")'],
) -> str:
"""Get local events and activities happening at a destination around a specific date.
Use this to suggest timely activities and experiences.
Args:
destination: The destination city or location.
date: The date to search for events.
Returns:
A list of local events and activities.
"""
# Mock events data based on destination
events_by_city = {
"Tokyo": [
"🎭 Kabuki Theater Performance at Kabukiza Theatre - Traditional Japanese drama",
"🌸 Winter Illuminations at Yoyogi Park - Spectacular light displays",
"🍜 Ramen Festival at Tokyo Station - Sample ramen from across Japan",
"🎮 Gaming Expo at Tokyo Big Sight - Latest video games and technology",
],
"Paris": [
"🎨 Impressionist Exhibition at Musée d'Orsay - Extended evening hours",
"🍷 Wine Tasting Tour in Le Marais - Local sommelier guided",
"🎵 Jazz Night at Le Caveau de la Huchette - Historic jazz club",
"🥐 French Pastry Workshop - Learn from master pâtissiers",
],
"New York": [
"🎭 Broadway Show: Hamilton - Limited engagement performances",
"🏀 Knicks vs Lakers at Madison Square Garden",
"🎨 Modern Art Exhibit at MoMA - New installations",
"🍕 Pizza Walking Tour of Brooklyn - Artisan pizzerias",
],
"London": [
"👑 Royal Collection Exhibition at Buckingham Palace",
"🎭 West End Musical: The Phantom of the Opera",
"🍺 Craft Beer Festival at Brick Lane",
"🎪 Winter Wonderland at Hyde Park - Rides and markets",
],
"Sydney": [
"🏄 Pro Surfing Competition at Bondi Beach",
"🎵 Opera at Sydney Opera House - La Bohème",
"🦘 Wildlife Night Safari at Taronga Zoo",
"🍽️ Harbor Dinner Cruise with fireworks",
],
"Rome": [
"🏛️ After-Hours Vatican Tour - Skip the crowds",
"🍝 Pasta Making Class in Trastevere",
"🎵 Classical Concert at Borghese Gallery",
"🍷 Wine Tasting in Roman Cellars",
],
}
# Find events for the destination or use generic events
events = [
"🎭 Local theater performance",
"🍽️ Food and wine festival",
"🎨 Art gallery opening",
"🎵 Live music at local venues",
]
for city, city_events in events_by_city.items():
if city.lower() in destination.lower():
events = city_events
break
event_list = "\n".join(events)
return f"""Local events in {destination} around {date}:
{event_list}
💡 Tip: Book popular events in advance as they may sell out quickly!"""
def _get_weather_recommendation(condition: str) -> str:
"""Get a recommendation based on weather conditions.
Args:
condition: The weather condition description.
Returns:
A recommendation string.
"""
condition_lower = condition.lower()
if "rain" in condition_lower or "drizzle" in condition_lower:
return "Bring an umbrella and waterproof jacket. Consider indoor activities for backup."
if "fog" in condition_lower:
return "Morning visibility may be limited. Plan outdoor sightseeing for afternoon."
if "cold" in condition_lower:
return "Layer up with warm clothing. Hot drinks and cozy cafés recommended."
if "hot" in condition_lower or "warm" in condition_lower:
return "Stay hydrated and use sunscreen. Plan strenuous activities for cooler morning hours."
if "thunder" in condition_lower or "storm" in condition_lower:
return "Keep an eye on weather updates. Have indoor alternatives ready."
return "Pleasant conditions expected. Great day for outdoor exploration!"
@@ -0,0 +1,263 @@
# Copyright (c) Microsoft. All rights reserved.
"""Worker process for hosting a TravelPlanner agent with reliable Redis streaming.
This worker registers the TravelPlanner agent with the Durable Task Scheduler
and uses RedisStreamCallback to persist streaming responses to Redis for reliable delivery.
Prerequisites:
- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL
- Sign in with Azure CLI for AzureCliCredential authentication
- Start a Durable Task Scheduler (e.g., using Docker)
- Start Redis (e.g., docker run -d --name redis -p 6379:6379 redis:latest)
"""
import asyncio
import logging
import os
from datetime import timedelta
import redis.asyncio as aioredis
from agent_framework import Agent, AgentResponseUpdate
from agent_framework.azure import (
AgentCallbackContext,
AgentResponseCallbackProtocol,
DurableAIAgentWorker,
)
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential
from dotenv import load_dotenv
from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker
from redis_stream_response_handler import RedisStreamResponseHandler # pyrefly: ignore[missing-import]
from tools import get_local_events, get_weather_forecast # pyrefly: ignore[missing-import]
# Load environment variables from .env file
load_dotenv()
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Configuration
REDIS_CONNECTION_STRING = os.environ.get("REDIS_CONNECTION_STRING", "redis://localhost:6379")
REDIS_STREAM_TTL_MINUTES = int(os.environ.get("REDIS_STREAM_TTL_MINUTES", "10"))
async def get_stream_handler() -> RedisStreamResponseHandler:
"""Create a new Redis stream handler for each request.
This avoids event loop conflicts by creating a fresh Redis client
in the current event loop context.
"""
# Create a new Redis client in the current event loop
redis_client = aioredis.from_url( # type: ignore[reportUnknownMemberType]
REDIS_CONNECTION_STRING,
encoding="utf-8",
decode_responses=False,
)
return RedisStreamResponseHandler(
redis_client=redis_client,
stream_ttl=timedelta(minutes=REDIS_STREAM_TTL_MINUTES),
)
class RedisStreamCallback(AgentResponseCallbackProtocol):
"""Callback that writes streaming updates to Redis Streams for reliable delivery.
This enables clients to disconnect and reconnect without losing messages.
"""
def __init__(self) -> None:
self._sequence_numbers: dict[str, int] = {} # Track sequence per thread
async def on_streaming_response_update(
self,
update: AgentResponseUpdate,
context: AgentCallbackContext,
) -> None:
"""Write streaming update to Redis Stream.
Args:
update: The streaming response update chunk.
context: The callback context with thread_id, agent_name, etc.
"""
thread_id = context.thread_id
if not thread_id:
logger.warning("No thread_id available for streaming update")
return
if not update.text:
return
text = update.text
# Get or initialize sequence number for this thread
if thread_id not in self._sequence_numbers:
self._sequence_numbers[thread_id] = 0
sequence = self._sequence_numbers[thread_id]
try:
# Use context manager to ensure Redis client is properly closed
async with await get_stream_handler() as stream_handler:
# Write chunk to Redis Stream using public API
await stream_handler.write_chunk(thread_id, text, sequence)
self._sequence_numbers[thread_id] += 1
logger.debug(
"[%s][%s] Wrote chunk to Redis: seq=%d, text=%s",
context.agent_name,
thread_id[:8],
sequence,
text,
)
except Exception as ex:
logger.error(f"Error writing to Redis stream: {ex}", exc_info=True)
async def on_agent_response(self, response: object, context: AgentCallbackContext) -> None:
"""Write end-of-stream marker when agent completes.
Args:
response: The final agent response.
context: The callback context.
"""
thread_id = context.thread_id
if not thread_id:
return
sequence = self._sequence_numbers.get(thread_id, 0)
try:
# Use context manager to ensure Redis client is properly closed
async with await get_stream_handler() as stream_handler:
# Write end-of-stream marker using public API
await stream_handler.write_completion(thread_id, sequence)
logger.info(
"[%s][%s] Agent completed, wrote end-of-stream marker",
context.agent_name,
thread_id[:8],
)
# Clean up sequence tracker
self._sequence_numbers.pop(thread_id, None)
except Exception as ex:
logger.error(f"Error writing end-of-stream marker: {ex}", exc_info=True)
def create_travel_agent() -> "Agent":
"""Create the TravelPlanner agent using Azure OpenAI.
Returns:
Agent: The configured TravelPlanner agent with travel planning tools.
"""
_client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AsyncAzureCliCredential(),
)
return Agent(
client=_client,
name="TravelPlanner",
instructions="""You are an expert travel planner who creates detailed, personalized travel itineraries.
When asked to plan a trip, you should:
1. Create a comprehensive day-by-day itinerary
2. Include specific recommendations for activities, restaurants, and attractions
3. Provide practical tips for each destination
4. Consider weather and local events when making recommendations
5. Include estimated times and logistics between activities
Always use the available tools to get current weather forecasts and local events
for the destination to make your recommendations more relevant and timely.
Format your response with clear headings for each day and include emoji icons
to make the itinerary easy to scan and visually appealing.""",
tools=[get_weather_forecast, get_local_events],
)
def get_worker(
taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None
) -> DurableTaskSchedulerWorker:
"""Create a configured DurableTaskSchedulerWorker.
Args:
taskhub: Task hub name (defaults to TASKHUB env var or "default")
endpoint: Scheduler endpoint (defaults to ENDPOINT env var or "http://localhost:8080")
log_handler: Optional log handler for worker logging
Returns:
Configured DurableTaskSchedulerWorker instance
"""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
logger.debug(f"Using taskhub: {taskhub_name}")
logger.debug(f"Using endpoint: {endpoint_url}")
credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential()
return DurableTaskSchedulerWorker(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
log_handler=log_handler,
)
def setup_worker(worker: DurableTaskSchedulerWorker) -> DurableAIAgentWorker:
"""Set up the worker with the TravelPlanner agent and Redis streaming callback.
Args:
worker: The DurableTaskSchedulerWorker instance
Returns:
DurableAIAgentWorker with agent and callback registered
"""
# Create the Redis streaming callback
redis_callback = RedisStreamCallback()
# Wrap it with the agent worker
agent_worker = DurableAIAgentWorker(worker, callback=redis_callback)
# Create and register the TravelPlanner agent
logger.debug("Creating and registering TravelPlanner agent...")
travel_agent = create_travel_agent()
agent_worker.add_agent(travel_agent)
logger.debug(f"✓ Registered agent: {travel_agent.name}")
return agent_worker
async def main():
"""Main entry point for the worker process."""
logger.debug("Starting Durable Task Agent Worker with Redis Streaming...")
# Create a worker using the helper function
worker = get_worker()
# Setup worker with agent and callback
setup_worker(worker)
# Start the worker
logger.debug("Worker started and listening for requests...")
worker.start()
try:
# Keep the worker running
while True:
await asyncio.sleep(1)
except KeyboardInterrupt:
logger.debug("Worker shutting down...")
finally:
worker.stop()
logger.debug("Worker stopped")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,68 @@
# Single Agent Orchestration Chaining
This sample demonstrates how to chain multiple invocations of the same agent using a durable orchestration while preserving conversation state between runs.
## Key Concepts Demonstrated
- Using durable orchestrations to coordinate sequential agent invocations.
- Chaining agent calls where the output of one run becomes input to the next.
- Maintaining conversation context across sequential runs using a shared session.
- Using `DurableAIAgentOrchestrationContext` to access agents within orchestrations.
## Environment Setup
See the [README.md](../README.md) file in the parent directory for more information on how to configure the environment, including how to install and run common sample dependencies.
## Running the Sample
With the environment setup, you can run the sample using the combined approach or separate worker and client processes:
**Option 1: Combined (Recommended for Testing)**
```bash
cd samples/04-hosting/durabletask/04_single_agent_orchestration_chaining
python sample.py
```
**Option 2: Separate Processes**
Start the worker in one terminal:
```bash
python worker.py
```
In a new terminal, run the client:
```bash
python client.py
```
The orchestration will execute the writer agent twice sequentially:
```
[Orchestration] Starting single agent chaining...
[Orchestration] Created session: abc-123
[Orchestration] First agent run: Generating initial sentence...
[Orchestration] Initial response: Every small step forward is progress toward mastery.
[Orchestration] Second agent run: Refining the sentence...
[Orchestration] Refined response: Each small step forward brings you closer to mastery and growth.
[Orchestration] Chaining complete
================================================================================
Orchestration Result
================================================================================
Each small step forward brings you closer to mastery and growth.
```
## Viewing Orchestration State
You can view the state of the orchestration in the Durable Task Scheduler dashboard:
1. Open your browser and navigate to `http://localhost:8082`
2. In the dashboard, you can view:
- The sequential execution of both agent runs
- The conversation session shared between runs
- Input and output at each step
- Overall orchestration state and history
@@ -0,0 +1,114 @@
# Copyright (c) Microsoft. All rights reserved.
"""Client application for starting a single agent chaining orchestration.
This client connects to the Durable Task Scheduler and starts an orchestration
that runs a writer agent twice sequentially on the same thread, demonstrating
how conversation context is maintained across multiple agent invocations.
Prerequisites:
- The worker must be running with the writer agent and orchestration registered
- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL
- Sign in with Azure CLI for AzureCliCredential authentication
- Durable Task Scheduler must be running
"""
import asyncio
import json
import logging
import os
from azure.identity import AzureCliCredential
from durabletask.azuremanaged.client import DurableTaskSchedulerClient
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def get_client(
taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None
) -> DurableTaskSchedulerClient:
"""Create a configured DurableTaskSchedulerClient.
Args:
taskhub: Task hub name (defaults to TASKHUB env var or "default")
endpoint: Scheduler endpoint (defaults to ENDPOINT env var or "http://localhost:8080")
log_handler: Optional logging handler for client logging
Returns:
Configured DurableTaskSchedulerClient instance
"""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
logger.debug(f"Using taskhub: {taskhub_name}")
logger.debug(f"Using endpoint: {endpoint_url}")
credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential()
return DurableTaskSchedulerClient(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
log_handler=log_handler,
)
def run_client(client: DurableTaskSchedulerClient) -> None:
"""Run client to start and monitor the orchestration.
Args:
client: The DurableTaskSchedulerClient instance
"""
logger.debug("Starting single agent chaining orchestration...")
# Start the orchestration
instance_id = client.schedule_new_orchestration( # type: ignore
orchestrator="single_agent_chaining_orchestration",
input="",
)
logger.info(f"Orchestration started with instance ID: {instance_id}")
logger.debug("Waiting for orchestration to complete...")
# Retrieve the final state
metadata = client.wait_for_orchestration_completion(instance_id=instance_id, timeout=300)
if metadata and metadata.runtime_status.name == "COMPLETED":
result = metadata.serialized_output
logger.debug("Orchestration completed successfully!")
# Parse and display the result
if result:
final_text = json.loads(result)
logger.info("Final refined sentence: %s \n", final_text)
elif metadata:
logger.error(f"Orchestration ended with status: {metadata.runtime_status.name}")
if metadata.serialized_output:
logger.error(f"Output: {metadata.serialized_output}")
else:
logger.error("Orchestration did not complete within the timeout period")
async def main() -> None:
"""Main entry point for the client application."""
logger.debug("Starting Durable Task Single Agent Chaining Orchestration Client...")
# Create client using helper function
client = get_client()
try:
run_client(client)
except Exception as e:
logger.exception(f"Error during orchestration: {e}")
finally:
logger.debug("")
logger.debug("Client shutting down")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,14 @@
# Agent Framework packages
# To use the deployed version, uncomment the lines below and comment out the local installation lines
# agent-framework-foundry
# agent-framework-durabletask
# Local installation (for development and testing)
# Each package must be listed explicitly because pip doesn't resolve uv workspace sources.
# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source.
-e ../../../../packages/core # Core framework - base dependency for all packages
-e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples
-e ../../../../packages/durabletask # Durable Task support - the main package for this sample
# Azure authentication
azure-identity
@@ -0,0 +1,62 @@
# Copyright (c) Microsoft. All rights reserved.
"""Single Agent Orchestration Chaining Sample - Durable Task Integration
This sample demonstrates chaining two invocations of the same agent inside a Durable Task
orchestration while preserving the conversation state between runs. The orchestration
runs the writer agent sequentially on a shared thread to refine text iteratively.
Components used:
- FoundryChatClient to construct the writer agent
- DurableTaskSchedulerWorker and DurableAIAgentWorker for agent hosting
- DurableTaskSchedulerClient and orchestration for sequential agent invocations
- Thread management to maintain conversation context across invocations
Prerequisites:
- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL
- Sign in with Azure CLI for AzureCliCredential authentication
- Durable Task Scheduler must be running (e.g., using Docker emulator)
To run this sample:
python sample.py
"""
import logging
# Import helper functions from worker and client modules
from client import get_client, run_client # pyrefly: ignore[missing-import]
from dotenv import load_dotenv
from worker import get_worker, setup_worker # pyrefly: ignore[missing-import]
# Configure logging
logging.basicConfig(level=logging.INFO, force=True)
logger = logging.getLogger(__name__)
def main():
"""Main entry point - runs both worker and client in single process."""
logger.debug("Starting Single Agent Orchestration Chaining Sample...")
silent_handler = logging.NullHandler()
# Create and start the worker using helper function and context manager
dts_worker = get_worker(log_handler=silent_handler)
with dts_worker:
# Register agents and orchestrations using helper function
setup_worker(dts_worker)
# Start the worker
dts_worker.start()
logger.debug("Worker started and listening for requests...")
# Create the client using helper function
client = get_client(log_handler=silent_handler)
logger.debug("CLIENT: Starting orchestration...")
# Run the client in the same process
try:
run_client(client)
except KeyboardInterrupt:
logger.debug("Sample interrupted by user")
except Exception as e:
logger.exception(f"Error during orchestration: {e}")
finally:
logger.debug("Worker stopping...")
logger.debug("")
logger.debug("Sample completed")
if __name__ == "__main__":
load_dotenv()
main()
@@ -0,0 +1,215 @@
# Copyright (c) Microsoft. All rights reserved.
"""Worker process for hosting a single agent with chaining orchestration using Durable Task.
This worker registers a writer agent and an orchestration function that demonstrates
chaining behavior by running the agent twice sequentially on the same thread,
preserving conversation context between invocations.
Prerequisites:
- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL
- Sign in with Azure CLI for AzureCliCredential authentication
- Start a Durable Task Scheduler (e.g., using Docker)
"""
import asyncio
import logging
import os
from collections.abc import Generator
from agent_framework import Agent, AgentResponse
from agent_framework.azure import DurableAIAgentOrchestrationContext, DurableAIAgentWorker
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential
from dotenv import load_dotenv
from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker
from durabletask.task import OrchestrationContext, Task
# Load environment variables from .env file
load_dotenv()
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Agent name
WRITER_AGENT_NAME = "WriterAgent"
def create_writer_agent() -> "Agent":
"""Create the Writer agent using Azure OpenAI.
This agent refines short pieces of text, enhancing initial sentences
and polishing improved versions further.
Returns:
Agent: The configured Writer agent
"""
instructions = (
"You refine short pieces of text. When given an initial sentence you enhance it;\n"
"when given an improved sentence you polish it further."
)
_client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AsyncAzureCliCredential(),
)
return Agent(
client=_client,
name=WRITER_AGENT_NAME,
instructions=instructions,
)
def get_orchestration():
"""Get the orchestration function for this sample.
Returns:
The orchestration function to register with the worker
"""
return single_agent_chaining_orchestration
def single_agent_chaining_orchestration(
context: OrchestrationContext, _: str
) -> Generator[Task[AgentResponse], AgentResponse, str]:
"""Orchestration that runs the writer agent twice on the same thread.
This demonstrates chaining behavior where the output of the first agent run
becomes part of the input for the second run, all while maintaining the
conversation context through a shared thread.
Args:
context: The orchestration context
_: Input parameter (unused)
Yields:
Task[AgentRunResponse]: Tasks that resolve to AgentRunResponse
Returns:
str: The final refined text from the second agent run
"""
logger.debug("[Orchestration] Starting single agent chaining...")
# Wrap the orchestration context to access agents
agent_context = DurableAIAgentOrchestrationContext(context)
# Get the writer agent using the agent context
writer = agent_context.get_agent(WRITER_AGENT_NAME)
# Create a new session for the conversation - this will be shared across both runs
writer_session = writer.create_session()
logger.debug(f"[Orchestration] Created session: {writer_session.session_id}")
prompt = "Write a concise inspirational sentence about learning."
# First run: Generate an initial inspirational sentence
logger.info("[Orchestration] First agent run: Generating initial sentence about: %s", prompt)
initial_response = yield writer.run(
messages=prompt,
session=writer_session,
)
logger.info(f"[Orchestration] Initial response: {initial_response.text}")
# Second run: Refine the initial response on the same thread
improved_prompt = f"Improve this further while keeping it under 25 words: {initial_response.text}"
logger.info("[Orchestration] Second agent run: Refining the sentence: %s", improved_prompt)
refined_response = yield writer.run(
messages=improved_prompt,
session=writer_session,
)
logger.info(f"[Orchestration] Refined response: {refined_response.text}")
logger.debug("[Orchestration] Chaining complete")
return refined_response.text
def get_worker(
taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None
) -> DurableTaskSchedulerWorker:
"""Create a configured DurableTaskSchedulerWorker.
Args:
taskhub: Task hub name (defaults to TASKHUB env var or "default")
endpoint: Scheduler endpoint (defaults to ENDPOINT env var or "http://localhost:8080")
log_handler: Optional logging handler for worker logging
Returns:
Configured DurableTaskSchedulerWorker instance
"""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
logger.debug(f"Using taskhub: {taskhub_name}")
logger.debug(f"Using endpoint: {endpoint_url}")
credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential()
return DurableTaskSchedulerWorker(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
log_handler=log_handler,
)
def setup_worker(worker: DurableTaskSchedulerWorker) -> DurableAIAgentWorker:
"""Set up the worker with agents and orchestrations registered.
Args:
worker: The DurableTaskSchedulerWorker instance
Returns:
DurableAIAgentWorker with agents and orchestrations registered
"""
# Wrap it with the agent worker
agent_worker = DurableAIAgentWorker(worker)
# Create and register the Writer agent
logger.debug("Creating and registering Writer agent...")
writer_agent = create_writer_agent()
agent_worker.add_agent(writer_agent)
logger.debug(f"✓ Registered agent: {writer_agent.name}")
# Register the orchestration function
logger.debug("Registering orchestration function...")
worker.add_orchestrator(single_agent_chaining_orchestration) # type: ignore
logger.debug(f"✓ Registered orchestration: {single_agent_chaining_orchestration.__name__}")
return agent_worker
async def main():
"""Main entry point for the worker process."""
logger.debug("Starting Durable Task Single Agent Chaining Worker with Orchestration...")
# Create a worker using the helper function
worker = get_worker()
# Setup worker with agents and orchestrations
setup_worker(worker)
logger.debug("Worker is ready and listening for requests...")
logger.debug("Press Ctrl+C to stop.")
try:
# Start the worker (this blocks until stopped)
worker.start()
# Keep the worker running
while True:
await asyncio.sleep(1)
except KeyboardInterrupt:
logger.debug("Worker shutdown initiated")
logger.debug("Worker stopped")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,71 @@
# Multi-Agent Orchestration with Concurrency
This sample demonstrates how to host multiple agents and run them concurrently using a durable orchestration, aggregating their responses into a single result.
## Key Concepts Demonstrated
- Running multiple specialized agents in parallel within an orchestration.
- Using `OrchestrationAgentExecutor` to get `DurableAgentTask` objects for concurrent execution.
- Aggregating results from multiple agents using `task.when_all()`.
- Creating separate conversation sessions for independent agent contexts.
## Environment Setup
See the [README.md](../README.md) file in the parent directory for more information on how to configure the environment, including how to install and run common sample dependencies.
## Running the Sample
With the environment setup, you can run the sample using the combined approach or separate worker and client processes:
**Option 1: Combined (Recommended for Testing)**
```bash
cd samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency
python sample.py
```
**Option 2: Separate Processes**
Start the worker in one terminal:
```bash
python worker.py
```
In a new terminal, run the client:
```bash
python client.py
```
The orchestration will execute both agents concurrently:
```
Prompt: What is temperature?
Starting multi-agent concurrent orchestration...
Orchestration started with instance ID: abc123...
⚡ Running PhysicistAgent and ChemistAgent in parallel...
Orchestration status: COMPLETED
Results:
Physicist's response:
Temperature measures the average kinetic energy of particles in a system...
Chemist's response:
Temperature reflects how molecular motion influences reaction rates...
```
## Viewing Orchestration State
You can view the state of the orchestration in the Durable Task Scheduler dashboard:
1. Open your browser and navigate to `http://localhost:8082`
2. In the dashboard, you can view:
- The concurrent execution of both agents (PhysicistAgent and ChemistAgent)
- Separate conversation sessions for each agent
- Parallel task execution and completion timing
- Aggregated results from both agents
@@ -0,0 +1,114 @@
# Copyright (c) Microsoft. All rights reserved.
"""Client application for starting a multi-agent concurrent orchestration.
This client connects to the Durable Task Scheduler and starts an orchestration
that runs two agents (physicist and chemist) concurrently, then retrieves and
displays the aggregated results.
Prerequisites:
- The worker must be running with both agents and orchestration registered
- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL
- Sign in with Azure CLI for AzureCliCredential authentication
- Durable Task Scheduler must be running
"""
import asyncio
import json
import logging
import os
from azure.identity import AzureCliCredential
from durabletask.azuremanaged.client import DurableTaskSchedulerClient
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def get_client(
taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None
) -> DurableTaskSchedulerClient:
"""Create a configured DurableTaskSchedulerClient.
Args:
taskhub: Task hub name (defaults to TASKHUB env var or "default")
endpoint: Scheduler endpoint (defaults to ENDPOINT env var or "http://localhost:8080")
log_handler: Optional logging handler for client logging
Returns:
Configured DurableTaskSchedulerClient instance
"""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
logger.debug(f"Using taskhub: {taskhub_name}")
logger.debug(f"Using endpoint: {endpoint_url}")
credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential()
return DurableTaskSchedulerClient(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
log_handler=log_handler,
)
def run_client(client: DurableTaskSchedulerClient, prompt: str = "What is temperature?") -> None:
"""Run client to start and monitor the orchestration.
Args:
client: The DurableTaskSchedulerClient instance
prompt: The prompt to send to both agents
"""
# Start the orchestration with the prompt as input
instance_id = client.schedule_new_orchestration( # type: ignore
orchestrator="multi_agent_concurrent_orchestration",
input=prompt,
)
logger.info(f"Orchestration started with instance ID: {instance_id}")
logger.debug("Waiting for orchestration to complete...")
# Retrieve the final state
metadata = client.wait_for_orchestration_completion(
instance_id=instance_id,
)
if metadata and metadata.runtime_status.name == "COMPLETED":
result = metadata.serialized_output
logger.debug("Orchestration completed successfully!")
# Parse and display the result
if result:
result_json = json.loads(result) if isinstance(result, str) else result
logger.info("Orchestration Results:\n%s", json.dumps(result_json, indent=2))
elif metadata:
logger.error(f"Orchestration ended with status: {metadata.runtime_status.name}")
if metadata.serialized_output:
logger.error(f"Output: {metadata.serialized_output}")
else:
logger.error("Orchestration did not complete within the timeout period")
async def main() -> None:
"""Main entry point for the client application."""
logger.debug("Starting Durable Task Multi-Agent Orchestration Client...")
# Create client using helper function
client = get_client()
try:
run_client(client)
except Exception as e:
logger.exception(f"Error during orchestration: {e}")
finally:
logger.debug("Client shutting down")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,14 @@
# Agent Framework packages
# To use the deployed version, uncomment the lines below and comment out the local installation lines
# agent-framework-foundry
# agent-framework-durabletask
# Local installation (for development and testing)
# Each package must be listed explicitly because pip doesn't resolve uv workspace sources.
# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source.
-e ../../../../packages/core # Core framework - base dependency for all packages
-e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples
-e ../../../../packages/durabletask # Durable Task support - the main package for this sample
# Azure authentication
azure-identity
@@ -0,0 +1,56 @@
# Copyright (c) Microsoft. All rights reserved.
"""Multi-Agent Orchestration Sample - Durable Task Integration (Combined Worker + Client)
This sample demonstrates running both the worker and client in a single process for
concurrent multi-agent orchestration. The worker registers two domain-specific agents
(physicist and chemist) and an orchestration function that runs them in parallel.
The orchestration uses OrchestrationAgentExecutor to execute agents concurrently
and aggregate their responses.
Prerequisites:
- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL
- Sign in with Azure CLI for AzureCliCredential authentication
- Durable Task Scheduler must be running (e.g., using Docker)
To run this sample:
python sample.py
"""
import logging
# Import helper functions from worker and client modules
from client import get_client, run_client # pyrefly: ignore[missing-import]
from dotenv import load_dotenv
from worker import get_worker, setup_worker # pyrefly: ignore[missing-import]
# Configure logging
logging.basicConfig(level=logging.INFO, force=True)
logger = logging.getLogger(__name__)
def main():
"""Main entry point - runs both worker and client in single process."""
logger.debug("Starting Durable Task Multi-Agent Orchestration Sample (Combined Worker + Client)...")
silent_handler = logging.NullHandler()
# Create and start the worker using helper function and context manager
dts_worker = get_worker(log_handler=silent_handler)
with dts_worker:
# Register agents and orchestrations using helper function
setup_worker(dts_worker)
# Start the worker
dts_worker.start()
logger.debug("Worker started and listening for requests...")
# Create the client using helper function
client = get_client(log_handler=silent_handler)
# Define the prompt
prompt = "What is temperature?"
logger.debug("CLIENT: Starting orchestration...")
try:
# Run the client to start the orchestration
run_client(client, prompt)
except Exception as e:
logger.exception(f"Error during sample execution: {e}")
logger.debug("Sample completed. Worker shutting down...")
if __name__ == "__main__":
load_dotenv()
main()
@@ -0,0 +1,223 @@
# Copyright (c) Microsoft. All rights reserved.
"""Worker process for hosting multiple agents with orchestration using Durable Task.
This worker registers two domain-specific agents (physicist and chemist) and an orchestration
function that runs them concurrently. The orchestration uses OrchestrationAgentExecutor
to execute agents in parallel and aggregate their responses.
Prerequisites:
- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL
- Sign in with Azure CLI for AzureCliCredential authentication
- Start a Durable Task Scheduler (e.g., using Docker)
"""
import asyncio
import logging
import os
from collections.abc import Generator
from typing import Any
from agent_framework import Agent, AgentResponse
from agent_framework.azure import DurableAIAgentOrchestrationContext, DurableAIAgentWorker
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential
from dotenv import load_dotenv
from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker
from durabletask.task import OrchestrationContext, Task, when_all
# Load environment variables from .env file
load_dotenv()
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Agent names
PHYSICIST_AGENT_NAME = "PhysicistAgent"
CHEMIST_AGENT_NAME = "ChemistAgent"
def create_physicist_agent() -> "Agent":
"""Create the Physicist agent using Azure OpenAI.
Returns:
Agent: The configured Physicist agent
"""
_client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AsyncAzureCliCredential(),
)
return Agent(
client=_client,
name=PHYSICIST_AGENT_NAME,
instructions="You are an expert in physics. You answer questions from a physics perspective.",
)
def create_chemist_agent() -> "Agent":
"""Create the Chemist agent using Azure OpenAI.
Returns:
Agent: The configured Chemist agent
"""
_client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AsyncAzureCliCredential(),
)
return Agent(
client=_client,
name=CHEMIST_AGENT_NAME,
instructions="You are an expert in chemistry. You answer questions from a chemistry perspective.",
)
def multi_agent_concurrent_orchestration(
context: OrchestrationContext, prompt: str
) -> Generator[Task[Any], Any, dict[str, str]]:
"""Orchestration that runs both agents in parallel and aggregates results.
Uses DurableAIAgentOrchestrationContext to wrap the orchestration context and
access agents via the OrchestrationAgentExecutor.
Args:
context: The orchestration context
prompt: The prompt to send to both agents
Returns:
dict: Dictionary with 'physicist' and 'chemist' response texts
"""
logger.info(f"[Orchestration] Starting concurrent execution for prompt: {prompt}")
# Wrap the orchestration context to access agents
agent_context = DurableAIAgentOrchestrationContext(context)
# Get agents using the agent context (returns DurableAIAgent proxies)
physicist = agent_context.get_agent(PHYSICIST_AGENT_NAME)
chemist = agent_context.get_agent(CHEMIST_AGENT_NAME)
# Create separate sessions for each agent
physicist_session = physicist.create_session()
chemist_session = chemist.create_session()
logger.debug(
f"[Orchestration] Created sessions - Physicist: {physicist_session.session_id}, Chemist: {chemist_session.session_id}"
)
# Create tasks from agent.run() calls - these return DurableAgentTask instances
physicist_task = physicist.run(messages=str(prompt), session=physicist_session)
chemist_task = chemist.run(messages=str(prompt), session=chemist_session)
logger.debug("[Orchestration] Created agent tasks, executing concurrently...")
# Execute both tasks concurrently using when_all
# The DurableAgentTask instances wrap the underlying entity calls
task_results = yield when_all([physicist_task, chemist_task])
logger.debug("[Orchestration] Both agents completed")
# Extract results from the tasks - DurableAgentTask yields AgentResponse
physicist_result: AgentResponse = task_results[0]
chemist_result: AgentResponse = task_results[1]
result = {
"physicist": physicist_result.text,
"chemist": chemist_result.text,
}
logger.debug("[Orchestration] Aggregated results ready")
return result
def get_worker(
taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None
) -> DurableTaskSchedulerWorker:
"""Create a configured DurableTaskSchedulerWorker.
Args:
taskhub: Task hub name (defaults to TASKHUB env var or "default")
endpoint: Scheduler endpoint (defaults to ENDPOINT env var or "http://localhost:8080")
log_handler: Optional logging handler for worker logging
Returns:
Configured DurableTaskSchedulerWorker instance
"""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
logger.debug(f"Using taskhub: {taskhub_name}")
logger.debug(f"Using endpoint: {endpoint_url}")
credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential()
return DurableTaskSchedulerWorker(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
log_handler=log_handler,
)
def setup_worker(worker: DurableTaskSchedulerWorker) -> DurableAIAgentWorker:
"""Set up the worker with agents and orchestrations registered.
Args:
worker: The DurableTaskSchedulerWorker instance
Returns:
DurableAIAgentWorker with agents and orchestrations registered
"""
# Wrap it with the agent worker
agent_worker = DurableAIAgentWorker(worker)
# Create and register both agents
logger.debug("Creating and registering agents...")
physicist_agent = create_physicist_agent()
chemist_agent = create_chemist_agent()
agent_worker.add_agent(physicist_agent)
agent_worker.add_agent(chemist_agent)
logger.debug(f"✓ Registered agents: {physicist_agent.name}, {chemist_agent.name}")
# Register the orchestration function
logger.debug("Registering orchestration function...")
worker.add_orchestrator(multi_agent_concurrent_orchestration) # type: ignore
logger.debug(f"✓ Registered orchestration: {multi_agent_concurrent_orchestration.__name__}")
return agent_worker
async def main():
"""Main entry point for the worker process."""
logger.debug("Starting Durable Task Multi-Agent Worker with Orchestration...")
# Create a worker using the helper function
worker = get_worker()
# Setup worker with agents and orchestrations
setup_worker(worker)
logger.debug("Worker is ready and listening for requests...")
logger.debug("Press Ctrl+C to stop.")
try:
# Start the worker (this blocks until stopped)
worker.start()
# Keep the worker running
while True:
await asyncio.sleep(1)
except KeyboardInterrupt:
logger.debug("Worker shutdown initiated")
logger.debug("Worker stopped")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,89 @@
# Multi-Agent Orchestration with Conditionals
This sample demonstrates conditional orchestration logic with two agents that analyze incoming emails and route execution based on spam detection results.
## Key Concepts Demonstrated
- Multi-agent orchestration with two specialized agents (SpamDetectionAgent and EmailAssistantAgent).
- Conditional branching with different execution paths based on spam detection results.
- Structured outputs using Pydantic models with `options={"response_format": ...}` for type-safe agent responses.
- Activity functions for side effects (spam handling and email sending).
- Decision-based routing where orchestration logic branches on agent output.
## Environment Setup
See the [README.md](../README.md) file in the parent directory for more information on how to configure the environment, including how to install and run common sample dependencies.
This sample uses Azure OpenAI credentials:
- `AZURE_OPENAI_ENDPOINT`
- `AZURE_OPENAI_MODEL`
## Running the Sample
With the environment setup, you can run the sample using the combined approach or separate worker and client processes:
**Option 1: Combined (Recommended for Testing)**
```bash
cd samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals
python sample.py
```
**Option 2: Separate Processes**
Start the worker in one terminal:
```bash
python worker.py
```
In a new terminal, run the client:
```bash
python client.py
```
The sample runs two test cases:
**Test 1: Legitimate Email**
```
Email ID: email-001
Email Content: Hello! I wanted to reach out about our upcoming project meeting...
🔍 SpamDetectionAgent: Analyzing email...
✓ Not spam - routing to EmailAssistantAgent
📧 EmailAssistantAgent: Drafting response...
✓ Email sent: [Professional response drafted by EmailAssistantAgent]
```
**Test 2: Spam Email**
```
Email ID: email-002
Email Content: URGENT! You've won $1,000,000! Click here now...
🔍 SpamDetectionAgent: Analyzing email...
⚠️ Spam detected: [Reason from SpamDetectionAgent]
✓ Email marked as spam and handled
```
## How It Works
1. **Input Validation**: Orchestration validates email payload using Pydantic models.
2. **Spam Detection**: SpamDetectionAgent analyzes email content.
3. **Conditional Routing**:
- If spam: Calls `handle_spam_email` activity
- If legitimate: Runs EmailAssistantAgent and calls `send_email` activity
4. **Result**: Returns confirmation message from the appropriate activity.
## Viewing Agent State
You can view the state of both agents and orchestration in the Durable Task Scheduler dashboard:
1. Open your browser and navigate to `http://localhost:8082`
2. In the dashboard, you can view:
- Orchestration instance status and history
- SpamDetectionAgent and EmailAssistantAgent entity states
- Activity execution logs
- Decision branch paths taken
@@ -0,0 +1,142 @@
# Copyright (c) Microsoft. All rights reserved.
"""Client application for starting a spam detection orchestration.
This client connects to the Durable Task Scheduler and starts an orchestration
that uses conditional logic to either handle spam emails or draft professional responses.
Prerequisites:
- The worker must be running with both agents, orchestration, and activities registered
- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_MODEL
- Sign in with Azure CLI for AzureCliCredential authentication
- Durable Task Scheduler must be running
"""
import asyncio
import logging
import os
from azure.identity import AzureCliCredential
from durabletask.azuremanaged.client import DurableTaskSchedulerClient
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def get_client(
taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None
) -> DurableTaskSchedulerClient:
"""Create a configured DurableTaskSchedulerClient.
Args:
taskhub: Task hub name (defaults to TASKHUB env var or "default")
endpoint: Scheduler endpoint (defaults to ENDPOINT env var or "http://localhost:8080")
log_handler: Optional logging handler for client logging
Returns:
Configured DurableTaskSchedulerClient instance
"""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
logger.debug(f"Using taskhub: {taskhub_name}")
logger.debug(f"Using endpoint: {endpoint_url}")
credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential()
return DurableTaskSchedulerClient(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
log_handler=log_handler,
)
def run_client(
client: DurableTaskSchedulerClient,
email_id: str = "email-001",
email_content: str = "Hello! I wanted to reach out about our upcoming project meeting.",
) -> None:
"""Run client to start and monitor the spam detection orchestration.
Args:
client: The DurableTaskSchedulerClient instance
email_id: The email ID
email_content: The email content to analyze
"""
payload = {
"email_id": email_id,
"email_content": email_content,
}
logger.debug("Starting spam detection orchestration...")
# Start the orchestration with the email payload
instance_id = client.schedule_new_orchestration( # type: ignore
orchestrator="spam_detection_orchestration",
input=payload,
)
logger.debug(f"Orchestration started with instance ID: {instance_id}")
logger.debug("Waiting for orchestration to complete...")
# Retrieve the final state
metadata = client.wait_for_orchestration_completion(instance_id=instance_id, timeout=300)
if metadata and metadata.runtime_status.name == "COMPLETED":
result = metadata.serialized_output
logger.debug("Orchestration completed successfully!")
# Parse and display the result
if result:
# Remove quotes if present
if result.startswith('"') and result.endswith('"'):
result = result[1:-1]
logger.info(f"Result: {result}")
elif metadata:
logger.error(f"Orchestration ended with status: {metadata.runtime_status.name}")
if metadata.serialized_output:
logger.error(f"Output: {metadata.serialized_output}")
else:
logger.error("Orchestration did not complete within the timeout period")
async def main() -> None:
"""Main entry point for the client application."""
logger.debug("Starting Durable Task Spam Detection Orchestration Client...")
# Create client using helper function
client = get_client()
try:
# Test with a legitimate email
logger.info("TEST 1: Legitimate Email")
run_client(
client,
email_id="email-001",
email_content="Hello! I wanted to reach out about our upcoming project meeting scheduled for next week.",
)
# Test with a spam email
logger.info("TEST 2: Spam Email")
run_client(
client,
email_id="email-002",
email_content="URGENT! You've won $1,000,000! Click here now to claim your prize! Limited time offer! Don't miss out!",
)
except Exception as e:
logger.exception(f"Error during orchestration: {e}")
finally:
logger.debug("")
logger.debug("Client shutting down")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,14 @@
# Agent Framework packages
# To use the deployed version, uncomment the lines below and comment out the local installation lines
# agent-framework-openai
# agent-framework-durabletask
# Local installation (for development and testing)
# Each package must be listed explicitly because pip doesn't resolve uv workspace sources.
# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source.
-e ../../../../packages/core # Core framework - base dependency for all packages
-e ../../../../packages/openai # OpenAI support - dependency for Azure OpenAI chat samples
-e ../../../../packages/durabletask # Durable Task support - the main package for this sample
# Azure authentication
azure-identity
@@ -0,0 +1,78 @@
# Copyright (c) Microsoft. All rights reserved.
"""Multi-Agent Orchestration with Conditionals Sample - Durable Task Integration
This sample demonstrates conditional orchestration logic with two agents:
- SpamDetectionAgent: Analyzes emails for spam content
- EmailAssistantAgent: Drafts professional responses to legitimate emails
The orchestration branches based on spam detection results, calling different
activity functions to handle spam or send legitimate email responses.
Prerequisites:
- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_MODEL
- Sign in with Azure CLI for AzureCliCredential authentication
- Durable Task Scheduler must be running (e.g., using Docker)
To run this sample:
python sample.py
"""
import logging
# Import helper functions from worker and client modules
from client import get_client, run_client # pyrefly: ignore[missing-import]
from dotenv import load_dotenv
from worker import get_worker, setup_worker # pyrefly: ignore[missing-import]
logging.basicConfig(level=logging.INFO, force=True)
logger = logging.getLogger()
def main():
"""Main entry point - runs both worker and client in single process."""
logger.debug("Starting Durable Task Spam Detection Orchestration Sample (Combined Worker + Client)...")
silent_handler = logging.NullHandler()
# Create and start the worker using helper function and context manager
dts_worker = get_worker(log_handler=silent_handler)
with dts_worker:
# Register agents, orchestrations, and activities using helper function
setup_worker(dts_worker)
# Start the worker
dts_worker.start()
logger.debug("Worker started and listening for requests...")
# Create the client using helper function
client = get_client(log_handler=silent_handler)
logger.debug("CLIENT: Starting orchestration tests...")
try:
# Test 1: Legitimate email
# logger.info("TEST 1: Legitimate Email")
run_client(
client,
email_id="email-001",
email_content="Hello! I wanted to reach out about our upcoming project meeting scheduled for next week.",
)
# Test 2: Spam email
logger.info("TEST 2: Spam Email")
run_client(
client,
email_id="email-002",
email_content="URGENT! You've won $1,000,000! Click here now to claim your prize! Limited time offer! Don't miss out!",
)
except Exception as e:
logger.exception(f"Error during sample execution: {e}")
logger.debug("Sample completed. Worker shutting down...")
if __name__ == "__main__":
load_dotenv()
main()
@@ -0,0 +1,313 @@
# Copyright (c) Microsoft. All rights reserved.
"""Worker process for hosting spam detection and email assistant agents with conditional orchestration.
This worker registers two domain-specific agents (spam detector and email assistant) and an
orchestration function that routes execution based on spam detection results. Activity functions
handle side effects (spam handling and email sending).
Prerequisites:
- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_MODEL
- Sign in with Azure CLI for AzureCliCredential authentication
- Start a Durable Task Scheduler (e.g., using Docker)
"""
import asyncio
import logging
import os
from collections.abc import Generator
from typing import Any, cast
from agent_framework import Agent, AgentResponse
from agent_framework.azure import DurableAIAgentOrchestrationContext, DurableAIAgentWorker
from agent_framework.openai import OpenAIChatCompletionClient
from azure.identity import AzureCliCredential
from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential
from azure.identity.aio import get_bearer_token_provider as get_async_bearer_token_provider
from dotenv import load_dotenv
from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker
from durabletask.task import ActivityContext, OrchestrationContext, Task
from pydantic import BaseModel, ValidationError
# Load environment variables from .env file
load_dotenv()
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Agent names
SPAM_AGENT_NAME = "SpamDetectionAgent"
EMAIL_AGENT_NAME = "EmailAssistantAgent"
class SpamDetectionResult(BaseModel):
"""Result from spam detection agent."""
is_spam: bool
reason: str
class EmailResponse(BaseModel):
"""Result from email assistant agent."""
response: str
class EmailPayload(BaseModel):
"""Input payload for the orchestration."""
email_id: str
email_content: str
def create_spam_agent() -> "Agent":
"""Create the Spam Detection agent using Azure OpenAI.
Returns:
Agent: The configured Spam Detection agent
"""
return Agent(
client=OpenAIChatCompletionClient(
model=os.environ["AZURE_OPENAI_MODEL"],
credential=get_async_bearer_token_provider(
AsyncAzureCliCredential(), "https://cognitiveservices.azure.com/.default"
),
),
name=SPAM_AGENT_NAME,
instructions="You are a spam detection assistant that identifies spam emails.",
)
def create_email_agent() -> "Agent":
"""Create the Email Assistant agent using Azure OpenAI.
Returns:
Agent: The configured Email Assistant agent
"""
return Agent(
client=OpenAIChatCompletionClient(
model=os.environ["AZURE_OPENAI_MODEL"],
credential=get_async_bearer_token_provider(
AsyncAzureCliCredential(), "https://cognitiveservices.azure.com/.default"
),
),
name=EMAIL_AGENT_NAME,
instructions="You are an email assistant that helps users draft responses to emails with professionalism.",
)
def handle_spam_email(context: ActivityContext, reason: str) -> str:
"""Activity function to handle spam emails.
Args:
context: The activity context
reason: The reason why the email was marked as spam
Returns:
str: Confirmation message
"""
logger.debug(f"[Activity] Handling spam email: {reason}")
return f"Email marked as spam: {reason}"
def send_email(context: ActivityContext, message: str) -> str:
"""Activity function to send emails.
Args:
context: The activity context
message: The email message to send
Returns:
str: Confirmation message
"""
logger.debug(f"[Activity] Sending email: {message[:50]}...")
return f"Email sent: {message}"
def spam_detection_orchestration(context: OrchestrationContext, payload_raw: Any) -> Generator[Task[Any], Any, str]:
"""Orchestration that detects spam and conditionally drafts email responses.
This orchestration:
1. Validates the input payload
2. Runs the spam detection agent
3. If spam: calls handle_spam_email activity
4. If legitimate: runs email assistant agent and calls send_email activity
Args:
context: The orchestration context
payload_raw: The input payload dictionary
Returns:
str: Result message from activity functions
"""
logger.debug("[Orchestration] Starting spam detection orchestration")
# Validate input
if not isinstance(payload_raw, dict):
raise ValueError("Email data is required")
try:
payload = EmailPayload.model_validate(payload_raw)
except ValidationError as exc:
raise ValueError(f"Invalid email payload: {exc}") from exc
logger.debug(f"[Orchestration] Processing email ID: {payload.email_id}")
# Wrap the orchestration context to access agents
agent_context = DurableAIAgentOrchestrationContext(context)
# Get spam detection agent
spam_agent = agent_context.get_agent(SPAM_AGENT_NAME)
# Run spam detection
spam_prompt = (
"Analyze this email for spam content and return a JSON response with 'is_spam' (boolean) "
"and 'reason' (string) fields:\n"
f"Email ID: {payload.email_id}\n"
f"Content: {payload.email_content}"
)
logger.info("[Orchestration] Running spam detection agent: %s", spam_prompt)
spam_result_task = spam_agent.run(
messages=spam_prompt,
options={"response_format": SpamDetectionResult},
)
spam_result_raw: AgentResponse = yield spam_result_task
spam_result = cast(SpamDetectionResult, spam_result_raw.value)
logger.info("[Orchestration] Spam detection result: is_spam=%s", spam_result.is_spam)
# Branch based on spam detection result
if spam_result.is_spam:
logger.debug("[Orchestration] Email is spam, handling...")
result_task: Task[str] = context.call_activity("handle_spam_email", input=spam_result.reason)
result: str = yield result_task
return result
# Email is legitimate - draft a response
logger.debug("[Orchestration] Email is legitimate, drafting response...")
email_agent = agent_context.get_agent(EMAIL_AGENT_NAME)
email_prompt = (
"Draft a professional response to this email. Return a JSON response with a 'response' field "
"containing the reply:\n\n"
f"Email ID: {payload.email_id}\n"
f"Content: {payload.email_content}"
)
logger.info("[Orchestration] Running email assistant agent: %s", email_prompt)
email_result_task = email_agent.run(
messages=email_prompt,
options={"response_format": EmailResponse},
)
email_result_raw: AgentResponse = yield email_result_task
email_result = cast(EmailResponse, email_result_raw.value)
logger.debug("[Orchestration] Email response drafted, sending...")
result_task: Task[str] = context.call_activity("send_email", input=email_result.response)
result: str = yield result_task
logger.info("Sent Email: %s", result)
return result
def get_worker(
taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None
) -> DurableTaskSchedulerWorker:
"""Create a configured DurableTaskSchedulerWorker.
Args:
taskhub: Task hub name (defaults to TASKHUB env var or "default")
endpoint: Scheduler endpoint (defaults to ENDPOINT env var or "http://localhost:8080")
log_handler: Optional logging handler for worker logging
Returns:
Configured DurableTaskSchedulerWorker instance
"""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
logger.debug(f"Using taskhub: {taskhub_name}")
logger.debug(f"Using endpoint: {endpoint_url}")
credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential()
return DurableTaskSchedulerWorker(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
log_handler=log_handler,
)
def setup_worker(worker: DurableTaskSchedulerWorker) -> DurableAIAgentWorker:
"""Set up the worker with agents, orchestrations, and activities registered.
Args:
worker: The DurableTaskSchedulerWorker instance
Returns:
DurableAIAgentWorker with agents, orchestrations, and activities registered
"""
# Wrap it with the agent worker
agent_worker = DurableAIAgentWorker(worker)
# Create and register both agents
logger.debug("Creating and registering agents...")
spam_agent = create_spam_agent()
email_agent = create_email_agent()
agent_worker.add_agent(spam_agent)
agent_worker.add_agent(email_agent)
logger.debug(f"✓ Registered agents: {spam_agent.name}, {email_agent.name}")
# Register activity functions
logger.debug("Registering activity functions...")
worker.add_activity(handle_spam_email) # type: ignore[arg-type]
worker.add_activity(send_email) # type: ignore[arg-type]
logger.debug("✓ Registered activity: handle_spam_email")
logger.debug("✓ Registered activity: send_email")
# Register the orchestration function
logger.debug("Registering orchestration function...")
worker.add_orchestrator(spam_detection_orchestration) # type: ignore[arg-type]
logger.debug(f"✓ Registered orchestration: {spam_detection_orchestration.__name__}")
return agent_worker
async def main():
"""Main entry point for the worker process."""
logger.debug("Starting Durable Task Spam Detection Worker with Orchestration...")
# Create a worker using the helper function
worker = get_worker()
# Setup worker with agents, orchestrations, and activities
setup_worker(worker)
logger.debug("Worker is ready and listening for requests...")
logger.debug("Press Ctrl+C to stop.")
try:
# Start the worker (this blocks until stopped)
worker.start()
# Keep the worker running
while True:
await asyncio.sleep(1)
except KeyboardInterrupt:
logger.debug("Worker shutdown initiated")
logger.debug("Worker stopped")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,87 @@
# Single-Agent Orchestration with Human-in-the-Loop (HITL)
This sample demonstrates the human-in-the-loop pattern where a WriterAgent generates content and waits for human approval before publishing. The orchestration handles external events, timeouts, and iterative refinement based on feedback.
## Key Concepts Demonstrated
- Human-in-the-loop workflow with orchestration pausing for external approval/rejection events.
- External event handling using `wait_for_external_event()` to receive human input.
- Timeout management with `when_any()` to race between approval event and timeout.
- Iterative refinement where agent regenerates content based on reviewer feedback.
- Structured outputs using Pydantic models with `options={"response_format": ...}` for type-safe agent responses.
- Activity functions for notifications and publishing as separate side effects.
- Long-running orchestrations maintaining state across multiple interactions.
## Environment Setup
See the [README.md](../README.md) file in the parent directory for more information on how to configure the environment, including how to install and run common sample dependencies.
## Running the Sample
With the environment setup, you can run the sample using the combined approach or separate worker and client processes:
**Option 1: Combined (Recommended for Testing)**
```bash
cd samples/04-hosting/durabletask/07_single_agent_orchestration_hitl
python sample.py
```
**Option 2: Separate Processes**
Start the worker in one terminal:
```bash
python worker.py
```
In a new terminal, run the client:
```bash
python client.py
```
The sample runs two test scenarios:
**Test 1: Immediate Approval**
```
Topic: The benefits of cloud computing
[WriterAgent generates content]
[Notification sent: Please review the content]
[Client sends approval]
✓ Content published successfully
```
**Test 2: Rejection with Feedback, Then Approval**
```
Topic: The future of artificial intelligence
[WriterAgent generates initial content]
[Notification sent: Please review the content]
[Client sends rejection with feedback: "Make it more technical..."]
[WriterAgent regenerates content with feedback]
[Notification sent: Please review the revised content]
[Client sends approval]
✓ Revised content published successfully
```
## How It Works
1. **Initial Generation**: WriterAgent creates content based on the topic.
2. **Review Loop** (up to max_review_attempts):
- Activity notifies user for approval
- Orchestration waits for approval event OR timeout
- **If approved**: Publishes content and returns
- **If rejected**: Incorporates feedback and regenerates
- **If timeout**: Raises TimeoutError
3. **Completion**: Returns published content or error.
## Viewing Agent State
You can view the state of the WriterAgent and orchestration in the Durable Task Scheduler dashboard:
1. Open your browser and navigate to `http://localhost:8082`
2. In the dashboard, you can view:
- Orchestration instance status and pending events
- WriterAgent entity state and conversation sessions
- Activity execution logs
- External event history
@@ -0,0 +1,284 @@
# Copyright (c) Microsoft. All rights reserved.
"""Client application for starting a human-in-the-loop content generation orchestration.
This client connects to the Durable Task Scheduler and demonstrates the HITL pattern
by starting an orchestration, sending approval/rejection events, and monitoring progress.
Prerequisites:
- The worker must be running with the agent, orchestration, and activities registered
- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL
- Sign in with Azure CLI for AzureCliCredential authentication
- Durable Task Scheduler must be running
"""
import asyncio
import json
import logging
import os
import time
from azure.identity import AzureCliCredential
from durabletask.azuremanaged.client import DurableTaskSchedulerClient
from durabletask.client import OrchestrationState
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Constants
HUMAN_APPROVAL_EVENT = "HumanApproval"
def get_client(
taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None
) -> DurableTaskSchedulerClient:
"""Create a configured DurableTaskSchedulerClient.
Args:
taskhub: Task hub name (defaults to TASKHUB env var or "default")
endpoint: Scheduler endpoint (defaults to ENDPOINT env var or "http://localhost:8080")
log_handler: Optional logging handler for client logging
Returns:
Configured DurableTaskSchedulerClient instance
"""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
logger.debug(f"Using taskhub: {taskhub_name}")
logger.debug(f"Using endpoint: {endpoint_url}")
credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential()
return DurableTaskSchedulerClient(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
log_handler=log_handler,
)
def _log_completion_result(
metadata: OrchestrationState | None,
) -> None:
"""Log the orchestration completion result.
Args:
metadata: The orchestration metadata
"""
if metadata and metadata.runtime_status.name == "COMPLETED":
result = metadata.serialized_output
logger.debug("Orchestration completed successfully!")
if result:
try:
result_dict = json.loads(result)
logger.info("Final Result: %s", json.dumps(result_dict, indent=2))
except json.JSONDecodeError:
logger.debug(f"Result: {result}")
elif metadata:
logger.error(f"Orchestration ended with status: {metadata.runtime_status.name}")
if metadata.serialized_output:
logger.error(f"Output: {metadata.serialized_output}")
else:
logger.error("Orchestration did not complete within the timeout period")
def _wait_and_log_completion(client: DurableTaskSchedulerClient, instance_id: str, timeout: int = 60) -> None:
"""Wait for orchestration completion and log the result.
Args:
client: The DurableTaskSchedulerClient instance
instance_id: The orchestration instance ID
timeout: Maximum time to wait for completion in seconds
"""
logger.debug("Waiting for orchestration to complete...")
metadata = client.wait_for_orchestration_completion(instance_id=instance_id, timeout=timeout)
_log_completion_result(metadata)
def send_approval(client: DurableTaskSchedulerClient, instance_id: str, approved: bool, feedback: str = "") -> None:
"""Send approval or rejection event to the orchestration.
Args:
client: The DurableTaskSchedulerClient instance
instance_id: The orchestration instance ID
approved: Whether to approve or reject
feedback: Optional feedback message (used when rejected)
"""
approval_data = {"approved": approved, "feedback": feedback}
logger.debug(f"Sending {'APPROVAL' if approved else 'REJECTION'} to instance {instance_id}")
if feedback:
logger.debug(f"Feedback: {feedback}")
# Raise the external event
client.raise_orchestration_event(instance_id=instance_id, event_name=HUMAN_APPROVAL_EVENT, data=approval_data)
logger.debug("Event sent successfully")
def wait_for_notification(client: DurableTaskSchedulerClient, instance_id: str, timeout_seconds: int = 10) -> bool:
"""Wait for the orchestration to reach a notification point.
Polls the orchestration status until it appears to be waiting for approval.
Args:
client: The DurableTaskSchedulerClient instance
instance_id: The orchestration instance ID
timeout_seconds: Maximum time to wait
Returns:
True if notification detected, False if timeout
"""
logger.debug("Waiting for orchestration to reach notification point...")
start_time = time.time()
while time.time() - start_time < timeout_seconds:
try:
metadata = client.get_orchestration_state(
instance_id=instance_id,
)
if metadata:
# Check if we're waiting for approval by examining custom status
if metadata.serialized_custom_status:
try:
custom_status = json.loads(metadata.serialized_custom_status)
# Handle both string and dict custom status
status_str = custom_status if isinstance(custom_status, str) else str(custom_status)
if status_str.lower().startswith("requesting human feedback"):
logger.debug("Orchestration is requesting human feedback")
return True
except (json.JSONDecodeError, AttributeError):
# If it's not JSON, treat as plain string
if metadata.serialized_custom_status.lower().startswith("requesting human feedback"):
logger.debug("Orchestration is requesting human feedback")
return True
# Check for terminal states
if metadata.runtime_status.name == "COMPLETED":
logger.debug("Orchestration already completed")
return False
if metadata.runtime_status.name == "FAILED":
logger.error("Orchestration failed")
return False
except Exception as e:
logger.debug(f"Status check: {e}")
time.sleep(1)
logger.warning("Timeout waiting for notification")
return False
def run_interactive_client(client: DurableTaskSchedulerClient) -> None:
"""Run an interactive client that prompts for user input and handles approval workflow.
Args:
client: The DurableTaskSchedulerClient instance
"""
# Get user inputs
logger.debug("Content Generation - Human-in-the-Loop")
topic = input("Enter the topic for content generation: ").strip()
if not topic:
topic = "The benefits of cloud computing"
logger.info(f"Using default topic: {topic}")
max_attempts_str = input("Enter max review attempts (default: 3): ").strip()
max_review_attempts = int(max_attempts_str) if max_attempts_str else 3
timeout_hours_str = input("Enter approval timeout in hours (default: 5): ").strip()
timeout_hours = float(timeout_hours_str) if timeout_hours_str else 5.0
approval_timeout_seconds = int(timeout_hours * 3600)
payload = {
"topic": topic,
"max_review_attempts": max_review_attempts,
"approval_timeout_seconds": approval_timeout_seconds,
}
logger.debug(f"Configuration: Topic={topic}, Max attempts={max_review_attempts}, Timeout={timeout_hours}h")
# Start the orchestration
logger.debug("Starting content generation orchestration...")
instance_id = client.schedule_new_orchestration( # type: ignore
orchestrator="content_generation_hitl_orchestration",
input=payload,
)
logger.info(f"Orchestration started with instance ID: {instance_id}")
# Review loop
attempt = 1
while attempt <= max_review_attempts:
logger.info(f"Review Attempt {attempt}/{max_review_attempts}")
# Wait for orchestration to reach notification point
logger.debug("Waiting for content generation...")
if not wait_for_notification(client, instance_id, timeout_seconds=120):
logger.error("Failed to receive notification. Orchestration may have completed or failed.")
break
logger.info("Content is ready for review! Please review the content in the worker logs.")
# Get user decision
while True:
decision = input("Do you approve this content? (yes/no): ").strip().lower()
if decision in ["yes", "y", "no", "n"]:
break
logger.info("Please enter 'yes' or 'no'")
approved = decision in ["yes", "y"]
if approved:
logger.debug("Sending approval...")
send_approval(client, instance_id, approved=True)
logger.info("Approval sent. Waiting for orchestration to complete...")
_wait_and_log_completion(client, instance_id, timeout=60)
break
feedback = input("Enter feedback for improvement: ").strip()
if not feedback:
feedback = "Please revise the content."
logger.debug("Sending rejection with feedback...")
send_approval(client, instance_id, approved=False, feedback=feedback)
logger.info("Rejection sent. Content will be regenerated...")
attempt += 1
if attempt > max_review_attempts:
logger.info(f"Maximum review attempts ({max_review_attempts}) reached.")
_wait_and_log_completion(client, instance_id, timeout=30)
break
# Small pause before next iteration
time.sleep(2)
async def main() -> None:
"""Main entry point for the client application."""
logger.debug("Starting Durable Task HITL Content Generation Client")
# Create client using helper function
client = get_client()
try:
run_interactive_client(client)
except KeyboardInterrupt:
logger.info("Interrupted by user")
except Exception as e:
logger.exception(f"Error during orchestration: {e}")
finally:
logger.debug("Client shutting down")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,14 @@
# Agent Framework packages
# To use the deployed version, uncomment the lines below and comment out the local installation lines
# agent-framework-foundry
# agent-framework-durabletask
# Local installation (for development and testing)
# Each package must be listed explicitly because pip doesn't resolve uv workspace sources.
# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source.
-e ../../../../packages/core # Core framework - base dependency for all packages
-e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples
-e ../../../../packages/durabletask # Durable Task support - the main package for this sample
# Azure authentication
azure-identity
@@ -0,0 +1,53 @@
# Copyright (c) Microsoft. All rights reserved.
"""Human-in-the-Loop Orchestration Sample - Durable Task Integration
This sample demonstrates the HITL pattern with a WriterAgent that generates content
and waits for human approval. The orchestration handles:
- External event waiting (approval/rejection)
- Timeout handling
- Iterative refinement based on feedback
- Activity functions for notifications and publishing
Prerequisites:
- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL
- Sign in with Azure CLI for AzureCliCredential authentication
- Durable Task Scheduler must be running (e.g., using Docker)
To run this sample:
python sample.py
"""
import logging
# Import helper functions from worker and client modules
from client import get_client, run_interactive_client # pyrefly: ignore[missing-import]
from dotenv import load_dotenv
from worker import get_worker, setup_worker # pyrefly: ignore[missing-import]
logging.basicConfig(level=logging.INFO, force=True)
logger = logging.getLogger()
def main():
"""Main entry point - runs both worker and client in single process."""
logger.debug("Starting Durable Task HITL Content Generation Sample (Combined Worker + Client)...")
silent_handler = logging.NullHandler()
# Create and start the worker using helper function and context manager
dts_worker = get_worker(log_handler=silent_handler)
with dts_worker:
# Register agent, orchestration, and activities using helper function
setup_worker(dts_worker)
# Start the worker
dts_worker.start()
logger.debug("Worker started and listening for requests...")
# Create the client using helper function
client = get_client(log_handler=silent_handler)
try:
logger.debug("CLIENT: Starting orchestration tests...")
run_interactive_client(client)
except Exception as e:
logger.exception(f"Error during sample execution: {e}")
logger.debug("Sample completed. Worker shutting down...")
if __name__ == "__main__":
load_dotenv()
main()
@@ -0,0 +1,381 @@
# Copyright (c) Microsoft. All rights reserved.
"""Worker process for hosting a writer agent with human-in-the-loop orchestration.
This worker registers a WriterAgent and an orchestration function that implements
a human-in-the-loop review workflow. The orchestration pauses for external events
(human approval/rejection) with timeout handling, and iterates based on feedback.
Prerequisites:
- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL
- Sign in with Azure CLI for AzureCliCredential authentication
- Start a Durable Task Scheduler (e.g., using Docker)
"""
import asyncio
import logging
import os
from collections.abc import Generator
from datetime import timedelta
from typing import Any, cast
from agent_framework import Agent, AgentResponse
from agent_framework.azure import DurableAIAgentOrchestrationContext, DurableAIAgentWorker
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential
from dotenv import load_dotenv
from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker
from durabletask.task import ActivityContext, OrchestrationContext, Task, when_any # type: ignore
from pydantic import BaseModel, ValidationError
# Load environment variables from .env file
load_dotenv()
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Constants
WRITER_AGENT_NAME = "WriterAgent"
HUMAN_APPROVAL_EVENT = "HumanApproval"
class ContentGenerationInput(BaseModel):
"""Input for content generation orchestration."""
topic: str
max_review_attempts: int = 3
approval_timeout_seconds: float = 300 # 5 minutes for demo (72 hours in production)
class GeneratedContent(BaseModel):
"""Structured output from writer agent."""
title: str
content: str
class HumanApproval(BaseModel):
"""Human approval decision."""
approved: bool
feedback: str = ""
def create_writer_agent() -> "Agent":
"""Create the Writer agent using Azure OpenAI.
Returns:
Agent: The configured Writer agent
"""
instructions = (
"You are a professional content writer who creates high-quality articles on various topics. "
"You write engaging, informative, and well-structured content that follows best practices for readability and accuracy. "
"Return your response as JSON with 'title' and 'content' fields."
"Limit response to 300 words or less."
)
_client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AsyncAzureCliCredential(),
)
return Agent(
client=_client,
name=WRITER_AGENT_NAME,
instructions=instructions,
)
def notify_user_for_approval(context: ActivityContext, content: dict[str, str]) -> str:
"""Activity function to notify user for approval.
Args:
context: The activity context
content: The generated content dictionary
"""
model = GeneratedContent.model_validate(content)
logger.info("NOTIFICATION: Please review the following content for approval:")
logger.info(f"Title: {model.title or '(untitled)'}")
logger.info(f"Content: {model.content}")
logger.info("Use the client to send approval or rejection.")
return "Notification sent to user for approval."
def publish_content(context: ActivityContext, content: dict[str, str]) -> str:
"""Activity function to publish approved content.
Args:
context: The activity context
content: The generated content dictionary
"""
model = GeneratedContent.model_validate(content)
logger.info("PUBLISHING: Content has been published successfully:")
logger.info(f"Title: {model.title or '(untitled)'}")
logger.info(f"Content: {model.content}")
return "Published content successfully."
def content_generation_hitl_orchestration(
context: OrchestrationContext, payload_raw: Any
) -> Generator[Task[Any], Any, dict[str, str]]:
"""Human-in-the-loop orchestration for content generation with approval workflow.
This orchestration:
1. Generates initial content using WriterAgent
2. Loops up to max_review_attempts times:
a. Notifies user for approval
b. Waits for approval event or timeout
c. If approved: publishes and returns
d. If rejected: incorporates feedback and regenerates
e. If timeout: raises TimeoutError
3. Raises RuntimeError if max attempts exhausted
Args:
context: The orchestration context
payload_raw: The input payload
Returns:
dict: Result with published content
Raises:
ValueError: If input is invalid or agent returns no content
TimeoutError: If human approval times out
RuntimeError: If max review attempts exhausted
"""
logger.debug("[Orchestration] Starting HITL content generation orchestration")
# Validate input
if not isinstance(payload_raw, dict):
raise ValueError("Content generation input is required")
try:
payload = ContentGenerationInput.model_validate(payload_raw)
except ValidationError as exc:
raise ValueError(f"Invalid content generation input: {exc}") from exc
logger.debug(f"[Orchestration] Topic: {payload.topic}")
logger.debug(f"[Orchestration] Max attempts: {payload.max_review_attempts}")
logger.debug(f"[Orchestration] Approval timeout: {payload.approval_timeout_seconds}s")
# Wrap the orchestration context to access agents
agent_context = DurableAIAgentOrchestrationContext(context)
# Get the writer agent
writer = agent_context.get_agent(WRITER_AGENT_NAME)
writer_session = writer.create_session()
logger.info(f"SessionID: {writer_session.session_id}")
# Generate initial content
logger.info("[Orchestration] Generating initial content...")
initial_response: AgentResponse = yield writer.run(
messages=f"Write a short article about '{payload.topic}'.",
session=writer_session,
options={"response_format": GeneratedContent},
)
content = cast(GeneratedContent, initial_response.value)
if not isinstance(content, GeneratedContent):
raise ValueError("Agent returned no content after extraction.")
logger.debug(f"[Orchestration] Initial content generated: {content.title}")
# Review loop
attempt = 0
while attempt < payload.max_review_attempts:
attempt += 1
logger.debug(f"[Orchestration] Review iteration #{attempt}/{payload.max_review_attempts}")
context.set_custom_status(
f"Requesting human feedback (Attempt {attempt}, timeout {payload.approval_timeout_seconds}s)"
)
# Notify user for approval
yield context.call_activity("notify_user_for_approval", input=content.model_dump())
logger.debug("[Orchestration] Waiting for human approval or timeout...")
# Wait for approval event or timeout
approval_task: Task[Any] = context.wait_for_external_event(HUMAN_APPROVAL_EVENT) # type: ignore
timeout_task: Task[Any] = context.create_timer( # type: ignore
context.current_utc_datetime + timedelta(seconds=payload.approval_timeout_seconds)
)
# Race between approval and timeout
winner_task = yield when_any([approval_task, timeout_task]) # type: ignore
if winner_task == approval_task:
# Approval received before timeout
logger.debug("[Orchestration] Received human approval event")
context.set_custom_status("Content reviewed by human reviewer.")
# Parse approval
approval_data: Any = approval_task.get_result() # type: ignore
logger.debug(f"[Orchestration] Approval data: {approval_data}")
# Handle different formats of approval_data
if isinstance(approval_data, dict):
approval = HumanApproval.model_validate(approval_data)
elif isinstance(approval_data, str):
# Try to parse as boolean-like string
lower_data = approval_data.lower().strip()
if lower_data in {"true", "yes", "approved", "y", "1"}:
approval = HumanApproval(approved=True, feedback="")
elif lower_data in {"false", "no", "rejected", "n", "0"}:
approval = HumanApproval(approved=False, feedback="")
else:
approval = HumanApproval(approved=False, feedback=approval_data)
else:
approval = HumanApproval(approved=False, feedback=str(approval_data)) # type: ignore
if approval.approved:
# Content approved - publish and return
logger.debug("[Orchestration] Content approved! Publishing...")
context.set_custom_status("Content approved by human reviewer. Publishing...")
publish_task: Task[Any] = context.call_activity("publish_content", input=content.model_dump())
yield publish_task
logger.debug("[Orchestration] Content published successfully")
return {"content": content.content, "title": content.title}
# Content rejected - incorporate feedback and regenerate
logger.debug(f"[Orchestration] Content rejected. Feedback: {approval.feedback}")
# Check if we've exhausted attempts
if attempt >= payload.max_review_attempts:
context.set_custom_status("Max review attempts exhausted.")
# Max attempts exhausted
logger.error(f"[Orchestration] Max attempts ({payload.max_review_attempts}) exhausted")
break
context.set_custom_status("Content rejected by human reviewer. Regenerating...")
rewrite_prompt = (
"The content was rejected by a human reviewer. Please rewrite the article incorporating their feedback.\n\n"
f"Human Feedback: {approval.feedback or 'No specific feedback provided.'}"
)
logger.debug("[Orchestration] Regenerating content with feedback...")
logger.warning(f"Regenerating with SessionID: {writer_session.session_id}")
rewrite_response: AgentResponse = yield writer.run(
messages=rewrite_prompt,
session=writer_session,
options={"response_format": GeneratedContent},
)
rewritten_content = cast(GeneratedContent, rewrite_response.value)
if not isinstance(rewritten_content, GeneratedContent):
raise ValueError("Agent returned no content after rewrite.")
content = rewritten_content
logger.debug(f"[Orchestration] Content regenerated: {content.title}")
else:
# Timeout occurred
logger.error(f"[Orchestration] Approval timeout after {payload.approval_timeout_seconds}s")
raise TimeoutError(f"Human approval timed out after {payload.approval_timeout_seconds} second(s).")
# If we exit the loop without returning, max attempts were exhausted
context.set_custom_status("Max review attempts exhausted.")
raise RuntimeError(f"Content could not be approved after {payload.max_review_attempts} iteration(s).")
def get_worker(
taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None
) -> DurableTaskSchedulerWorker:
"""Create a configured DurableTaskSchedulerWorker.
Args:
taskhub: Task hub name (defaults to TASKHUB env var or "default")
endpoint: Scheduler endpoint (defaults to ENDPOINT env var or "http://localhost:8080")
log_handler: Optional logging handler for worker logging
Returns:
Configured DurableTaskSchedulerWorker instance
"""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
logger.debug(f"Using taskhub: {taskhub_name}")
logger.debug(f"Using endpoint: {endpoint_url}")
credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential()
return DurableTaskSchedulerWorker(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
log_handler=log_handler,
)
def setup_worker(worker: DurableTaskSchedulerWorker) -> DurableAIAgentWorker:
"""Set up the worker with agents, orchestrations, and activities registered.
Args:
worker: The DurableTaskSchedulerWorker instance
Returns:
DurableAIAgentWorker with agents, orchestrations, and activities registered
"""
# Wrap it with the agent worker
agent_worker = DurableAIAgentWorker(worker)
# Create and register the writer agent
logger.debug("Creating and registering Writer agent...")
writer_agent = create_writer_agent()
agent_worker.add_agent(writer_agent)
logger.debug(f"✓ Registered agent: {writer_agent.name}")
# Register activity functions
logger.debug("Registering activity functions...")
worker.add_activity(notify_user_for_approval) # type: ignore
worker.add_activity(publish_content) # type: ignore
logger.debug("✓ Registered activity: notify_user_for_approval")
logger.debug("✓ Registered activity: publish_content")
# Register the orchestration function
logger.debug("Registering orchestration function...")
worker.add_orchestrator(content_generation_hitl_orchestration) # type: ignore
logger.debug(f"✓ Registered orchestration: {content_generation_hitl_orchestration.__name__}")
return agent_worker
async def main():
"""Main entry point for the worker process."""
logger.debug("Starting Durable Task HITL Content Generation Worker...")
# Create a worker using the helper function
worker = get_worker()
# Setup worker with agents, orchestrations, and activities
setup_worker(worker)
logger.debug("Worker is ready and listening for requests...")
logger.debug("Press Ctrl+C to stop.")
try:
# Start the worker (this blocks until stopped)
worker.start()
# Keep the worker running
while True:
await asyncio.sleep(1)
except KeyboardInterrupt:
logger.debug("Worker shutdown initiated")
logger.debug("Worker stopped")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,57 @@
# Workflow on a Standalone Durable Task Worker
This sample demonstrates running an agent-framework `Workflow` as a durable
orchestration on a **standalone Durable Task worker** — no Azure Functions
required. It is the durabletask counterpart to the Azure Functions workflow
samples (`samples/04-hosting/azure_functions/10_workflow_no_shared_state`).
## Key Concepts Demonstrated
- Hosting a MAF `Workflow` outside Azure Functions via
`DurableAIAgentWorker.configure_workflow(workflow)`, which auto-registers:
- a durable **entity** for each agent executor,
- a durable **activity** for each non-agent executor, and
- the **workflow orchestrator** (registered as `WORKFLOW_ORCHESTRATOR_NAME`).
- Conditional routing with `add_switch_case_edge_group` (spam vs. legitimate email).
- Mixing AI agents with non-agent executors in one workflow graph.
- Starting the workflow from a client with
`DurableWorkflowClient.start_workflow(input=...)` and reading its result with
`await_workflow_output(instance_id)`.
## Environment Setup
See the [README.md](../README.md) in the parent directory for environment setup.
This sample uses Azure AI Foundry credentials:
- `FOUNDRY_PROJECT_ENDPOINT`
- `FOUNDRY_MODEL`
It also needs a Durable Task Scheduler. For local development, start the
emulator (defaults to `http://localhost:8080`):
```bash
docker run -d -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest
```
## Running the Sample
Start the worker in one terminal:
```bash
cd samples/04-hosting/durabletask/08_workflow
python worker.py
```
In a second terminal, run the client:
```bash
python client.py
```
The client runs two cases:
- **Legitimate email** → `SpamDetectionAgent``EmailAssistantAgent`
`email_sender``"Email sent: ..."`.
- **Spam email** → `SpamDetectionAgent``spam_handler`
`"Email marked as spam: ..."`.
@@ -0,0 +1,75 @@
# Copyright (c) Microsoft. All rights reserved.
"""Client that starts the standalone workflow orchestration and prints the result.
The worker (``worker.py``) must be running first. The workflow is started via
``DurableWorkflowClient.start_workflow`` - which schedules the ``dafx-{name}``
orchestration that ``DurableAIAgentWorker.configure_workflow`` auto-registers for
the workflow named ``email_triage``.
Prerequisites:
- ``worker.py`` running and connected to the same Durable Task Scheduler.
- A Durable Task Scheduler reachable at ``ENDPOINT`` (default ``http://localhost:8080``).
"""
import asyncio
import logging
import os
from agent_framework.azure import DurableWorkflowClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from durabletask.azuremanaged.client import DurableTaskSchedulerClient
load_dotenv()
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
WORKFLOW_NAME = "email_triage"
def get_client(taskhub: str | None = None, endpoint: str | None = None) -> DurableTaskSchedulerClient:
"""Create a configured DurableTaskSchedulerClient."""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential()
return DurableTaskSchedulerClient(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
)
def run_workflow(client: DurableWorkflowClient, email_content: str) -> None:
"""Start the workflow with an email and wait for the result."""
instance_id = client.start_workflow(input=email_content)
logger.info("Started workflow instance: %s", instance_id)
output = client.await_workflow_output(instance_id)
logger.info("Workflow output: %s", output)
async def main() -> None:
"""Run the workflow against a legitimate email and a spam email."""
client = DurableWorkflowClient(get_client(), workflow_name=WORKFLOW_NAME)
logger.info("TEST 1: Legitimate email")
run_workflow(
client,
"Hi team, just a reminder about our sprint planning meeting tomorrow at 10 AM. "
"Please review the agenda in Jira.",
)
logger.info("TEST 2: Spam email")
run_workflow(
client,
"URGENT! You've won $1,000,000! Click here now to claim your prize! Limited time offer!",
)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,213 @@
# Copyright (c) Microsoft. All rights reserved.
"""Worker that hosts a MAF Workflow as a durable orchestration (no Azure Functions).
This sample shows how to run an agent-framework ``Workflow`` on a standalone
Durable Task worker using ``DurableAIAgentWorker.configure_workflow``. The worker
auto-registers:
- a durable entity for each agent executor,
- a durable activity for each non-agent executor, and
- the workflow orchestrator (named ``WORKFLOW_ORCHESTRATOR_NAME``).
The workflow classifies an email and conditionally routes it: spam is handled by
a non-agent executor, while legitimate email is drafted by a second agent and
"sent" by another non-agent executor.
Prerequisites:
- Set ``FOUNDRY_PROJECT_ENDPOINT`` and ``FOUNDRY_MODEL``.
- Sign in with Azure CLI (``az login``) for ``AzureCliCredential``.
- Start a Durable Task Scheduler (e.g. the DTS emulator on ``localhost:8080``).
Run the worker (this process), then run ``client.py`` in another process.
"""
import asyncio
import logging
import os
from typing import Any
from agent_framework import (
Agent,
AgentExecutorResponse,
Case,
Default,
Executor,
Workflow,
WorkflowBuilder,
WorkflowContext,
handler,
)
from agent_framework.azure import DurableAIAgentWorker
from agent_framework.foundry import FoundryChatClient, FoundryChatOptions
from azure.identity import AzureCliCredential
from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential
from dotenv import load_dotenv
from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker
from pydantic import BaseModel, ValidationError
from typing_extensions import Never
load_dotenv()
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
SPAM_AGENT_NAME = "SpamDetectionAgent"
EMAIL_AGENT_NAME = "EmailAssistantAgent"
WORKFLOW_NAME = "email_triage"
SPAM_DETECTION_INSTRUCTIONS = (
"You are a spam detection assistant that identifies spam emails. "
"Return JSON with fields is_spam (bool) and reason (string)."
)
EMAIL_ASSISTANT_INSTRUCTIONS = (
"You are an email assistant that drafts professional replies to legitimate emails. "
"Return JSON with a single field 'response' containing the drafted reply."
)
class SpamDetectionResult(BaseModel):
"""Structured output from the spam detection agent."""
is_spam: bool
reason: str
class EmailResponse(BaseModel):
"""Structured output from the email assistant agent."""
response: str
class SpamHandlerExecutor(Executor):
"""Non-agent executor that finalizes spam emails."""
@handler
async def handle_spam_result(self, agent_response: AgentExecutorResponse, ctx: WorkflowContext[Never, str]) -> None:
text = agent_response.agent_response.text
try:
result = SpamDetectionResult.model_validate_json(text)
reason = result.reason
except ValidationError:
reason = "Invalid JSON from agent"
await ctx.yield_output(f"Email marked as spam: {reason}")
class EmailSenderExecutor(Executor):
"""Non-agent executor that 'sends' the drafted reply."""
@handler
async def handle_email_response(
self, agent_response: AgentExecutorResponse, ctx: WorkflowContext[Never, str]
) -> None:
text = agent_response.agent_response.text
try:
email = EmailResponse.model_validate_json(text)
reply = email.response
except ValidationError:
reply = "Error generating response."
await ctx.yield_output(f"Email sent: {reply}")
def is_spam_detected(message: Any) -> bool:
"""Routing condition: True when the spam agent flagged the email as spam."""
if not isinstance(message, AgentExecutorResponse):
return False
try:
return SpamDetectionResult.model_validate_json(message.agent_response.text).is_spam
except Exception:
return False
def _create_chat_client() -> FoundryChatClient:
"""Create an Azure AI Foundry chat client using AzureCliCredential."""
return FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AsyncAzureCliCredential(),
)
def create_workflow() -> Workflow:
"""Build the conditional spam-detection workflow."""
chat_client = _create_chat_client()
spam_agent = Agent(
client=chat_client,
name=SPAM_AGENT_NAME,
instructions=SPAM_DETECTION_INSTRUCTIONS,
default_options=FoundryChatOptions[Any](response_format=SpamDetectionResult),
)
email_agent = Agent(
client=chat_client,
name=EMAIL_AGENT_NAME,
instructions=EMAIL_ASSISTANT_INSTRUCTIONS,
default_options=FoundryChatOptions[Any](response_format=EmailResponse),
)
spam_handler = SpamHandlerExecutor(id="spam_handler")
email_sender = EmailSenderExecutor(id="email_sender")
return (
WorkflowBuilder(name=WORKFLOW_NAME, start_executor=spam_agent)
.add_switch_case_edge_group(
spam_agent,
[
Case(condition=is_spam_detected, target=spam_handler),
Default(target=email_agent),
],
)
.add_edge(email_agent, email_sender)
.build()
)
def get_worker(
taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None
) -> DurableTaskSchedulerWorker:
"""Create a configured DurableTaskSchedulerWorker."""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential()
return DurableTaskSchedulerWorker(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
log_handler=log_handler,
)
def setup_worker(worker: DurableTaskSchedulerWorker) -> DurableAIAgentWorker:
"""Register the workflow (agents + activities + orchestrator) on the worker."""
agent_worker = DurableAIAgentWorker(worker)
workflow = create_workflow()
# One call wires up: agent entities, non-agent executor activities, and the
# workflow orchestrator (registered as WORKFLOW_ORCHESTRATOR_NAME).
agent_worker.configure_workflow(workflow)
logger.info("✓ Configured workflow with %d executors", len(workflow.executors))
return agent_worker
async def main() -> None:
"""Start the worker and block until interrupted."""
worker = get_worker()
setup_worker(worker)
logger.info("Worker is ready and listening for work items. Press Ctrl+C to stop.")
try:
worker.start()
while True:
await asyncio.sleep(1)
except KeyboardInterrupt:
logger.info("Worker shutdown initiated")
logger.info("Worker stopped")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,78 @@
# Human-in-the-Loop Workflow on a Standalone Durable Task Worker
This sample demonstrates a Human-in-the-Loop (HITL) agent-framework `Workflow`
running as a durable orchestration on a **standalone Durable Task worker** — no
Azure Functions required. It is the durabletask counterpart to the Azure
Functions sample `samples/04-hosting/azure_functions/12_workflow_hitl`.
## Key Concepts Demonstrated
- Pausing a workflow for human input with MAF's `ctx.request_info()` /
`@response_handler` pattern, hosted on a standalone worker via
`DurableAIAgentWorker.configure_workflow(workflow)`.
- Discovering pending HITL requests from a client with
`DurableWorkflowClient.get_pending_hitl_requests(instance_id)`.
- Resuming the workflow by sending a decision with
`DurableWorkflowClient.send_hitl_response(instance_id, request_id, response)`.
- Reading the final result with `DurableWorkflowClient.await_workflow_output(instance_id)`.
The workflow is a content-moderation pipeline:
```
input_router -> ContentAnalyzerAgent -> content_analyzer_executor
-> human_review_executor (HITL pause) -> publish_executor
```
## How HITL Works Here
The HITL mechanism is host-agnostic — the same shared workflow orchestrator
drives it on both Azure Functions and a standalone worker:
1. `human_review_executor` calls `ctx.request_info(...)`, which pauses the
workflow. The orchestrator records the open request in its **custom status**
and waits for an external event named by the request's `request_id`.
2. The client reads the custom status via `get_pending_hitl_requests` and sends
a response via `send_hitl_response`, which raises that external event.
3. The orchestrator routes the response back to the executor's
`@response_handler`, and the workflow resumes.
`send_hitl_response` sanitizes the payload (neutralizing pickle-marker
injection) before delivery, since the worker deserializes it.
## Environment Setup
See the [README.md](../README.md) in the parent directory for environment setup.
This sample uses Azure AI Foundry credentials:
- `FOUNDRY_PROJECT_ENDPOINT`
- `FOUNDRY_MODEL`
It also needs a Durable Task Scheduler. For local development, start the
emulator (defaults to `http://localhost:8080`):
```bash
docker run -d -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest
```
## Running the Sample
Start the worker in one terminal:
```bash
cd samples/04-hosting/durabletask/09_workflow_hitl
python worker.py
```
In a second terminal, run the client:
```bash
python client.py
```
The client runs two cases:
- **Appropriate content** → analyzed → HITL pause → client **approves**
`"Content '...' has been APPROVED and published."`
- **Spammy content** → analyzed → HITL pause → client **rejects**
`"Content '...' has been REJECTED."`
@@ -0,0 +1,131 @@
# Copyright (c) Microsoft. All rights reserved.
"""Client that drives the standalone HITL workflow to completion.
The worker (``worker.py``) must be running first. This client:
1. Starts the workflow with ``DurableWorkflowClient.start_workflow``.
2. Polls ``get_pending_hitl_requests`` until the workflow pauses for human input.
3. Sends a decision with ``send_hitl_response`` (the request_id correlates the
response back to the paused executor).
4. Reads the final output with ``await_workflow_output``.
It runs two cases: appropriate content (approved) and spammy content (rejected).
Prerequisites:
- ``worker.py`` running and connected to the same Durable Task Scheduler.
- A Durable Task Scheduler reachable at ``ENDPOINT`` (default ``http://localhost:8080``).
"""
import asyncio
import logging
import os
import time
from typing import Any
from agent_framework.azure import DurableWorkflowClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from durabletask.azuremanaged.client import DurableTaskSchedulerClient
load_dotenv()
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
WORKFLOW_NAME = "content_moderation"
def get_client(taskhub: str | None = None, endpoint: str | None = None) -> DurableTaskSchedulerClient:
"""Create a configured DurableTaskSchedulerClient."""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential()
return DurableTaskSchedulerClient(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
)
def _wait_for_hitl_request(
client: DurableWorkflowClient, instance_id: str, timeout_seconds: int = 60
) -> list[dict[str, Any]]:
"""Poll until the workflow has at least one pending HITL request.
Stops early if the workflow reaches a terminal state (e.g. completed or failed)
without pausing, so a misconfiguration or early failure surfaces the real
status instead of a misleading timeout.
"""
terminal_statuses = {"COMPLETED", "FAILED", "TERMINATED"}
deadline = time.time() + timeout_seconds
while time.time() < deadline:
pending = client.get_pending_hitl_requests(instance_id)
if pending:
return pending
status = client.get_runtime_status(instance_id)
if status in terminal_statuses:
raise RuntimeError(
f"Workflow instance {instance_id} reached terminal state '{status}' before pausing for human input."
)
time.sleep(2)
raise TimeoutError(f"Timed out waiting for a HITL request on instance {instance_id}")
def run_case(client: DurableWorkflowClient, submission: dict[str, Any], *, approve: bool) -> None:
"""Run one moderation case: start, respond to the HITL pause, print the result."""
instance_id = client.start_workflow(input=submission)
logger.info("Started workflow instance: %s", instance_id)
pending = _wait_for_hitl_request(client, instance_id)
request = pending[0]
logger.info("Pending HITL request %s from %s", request["request_id"], request["source_executor_id"])
decision = {
"approved": approve,
"reviewer_notes": "Looks good." if approve else "Violates content policy.",
}
client.send_hitl_response(instance_id, request["request_id"], decision)
logger.info("Sent decision: approved=%s", approve)
output = client.await_workflow_output(instance_id)
logger.info("Workflow output: %s", output)
async def main() -> None:
"""Run an approved case and a rejected case."""
client = DurableWorkflowClient(get_client(), workflow_name=WORKFLOW_NAME)
logger.info("CASE 1: Appropriate content (will approve)")
run_case(
client,
{
"content_id": "article-001",
"title": "Introduction to AI in Healthcare",
"body": (
"Artificial intelligence is improving healthcare by enabling faster diagnosis, "
"personalized treatment plans, and better patient outcomes."
),
"author": "Dr. Jane Smith",
},
approve=True,
)
logger.info("CASE 2: Spammy content (will reject)")
run_case(
client,
{
"content_id": "article-002",
"title": "Get Rich Quick",
"body": "Click here NOW to make $10,000 overnight! GUARANTEED! Limited time offer!",
"author": "Definitely Not Spam",
},
approve=False,
)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,344 @@
# Copyright (c) Microsoft. All rights reserved.
"""Worker that hosts a Human-in-the-Loop (HITL) MAF Workflow on a standalone worker.
This sample is the durabletask counterpart to the Azure Functions
``12_workflow_hitl`` sample. It runs an agent-framework ``Workflow`` that pauses
for human approval using MAF's ``ctx.request_info`` / ``@response_handler``
pattern, hosted on a standalone Durable Task worker (no Azure Functions).
``DurableAIAgentWorker.configure_workflow`` auto-registers:
- a durable entity for each agent executor,
- a durable activity for each non-agent executor, and
- the workflow orchestrator (named ``dafx-{workflow.name}``).
When the workflow calls ``ctx.request_info``, the orchestrator pauses and records
the open request in its custom status. An external client discovers the request
(``DurableWorkflowClient.get_pending_hitl_requests``) and resumes the workflow by
sending a response (``DurableWorkflowClient.send_hitl_response``).
The workflow is a content-moderation pipeline:
``input_router`` -> ``ContentAnalyzerAgent`` -> ``content_analyzer_executor``
-> ``human_review_executor`` (HITL pause) -> ``publish_executor``.
Prerequisites:
- Set ``FOUNDRY_PROJECT_ENDPOINT`` and ``FOUNDRY_MODEL``.
- Sign in with Azure CLI (``az login``) for ``AzureCliCredential``.
- Start a Durable Task Scheduler (e.g. the DTS emulator on ``localhost:8080``).
Run the worker (this process), then run ``client.py`` in another process.
"""
import asyncio
import logging
import os
from dataclasses import dataclass
from typing import Any
from agent_framework import (
Agent,
AgentExecutorRequest,
AgentExecutorResponse,
Executor,
Message,
Workflow,
WorkflowBuilder,
WorkflowContext,
handler,
response_handler,
)
from agent_framework.azure import DurableAIAgentWorker
from agent_framework.foundry import FoundryChatClient, FoundryChatOptions
from azure.identity import AzureCliCredential
from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential
from dotenv import load_dotenv
from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker
from pydantic import BaseModel, ValidationError
from typing_extensions import Never
load_dotenv()
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
CONTENT_ANALYZER_AGENT_NAME = "ContentAnalyzerAgent"
WORKFLOW_NAME = "content_moderation"
CONTENT_ANALYZER_INSTRUCTIONS = (
"You are a content moderation assistant that analyzes user-submitted content for policy compliance. "
"Evaluate appropriateness, assign a risk level ('low', 'medium', 'high'), list any concerns, and give a "
"brief recommendation for human reviewers. Return JSON with fields is_appropriate (bool), risk_level (str), "
"concerns (list of str), and recommendation (str)."
)
# ============================================================================
# Data Models
# ============================================================================
class ContentAnalysisResult(BaseModel):
"""Structured output from the content analysis agent."""
is_appropriate: bool
risk_level: str
concerns: list[str]
recommendation: str
@dataclass
class ContentSubmission:
"""Content submitted for moderation."""
content_id: str
title: str
body: str
author: str
@dataclass
class AnalysisWithSubmission:
"""Combines the AI analysis with the original submission for downstream processing."""
submission: ContentSubmission
analysis: ContentAnalysisResult
@dataclass
class HumanApprovalRequest:
"""Request sent to a human reviewer. Surfaced to clients via the orchestration status."""
content_id: str
title: str
body: str
author: str
ai_analysis: ContentAnalysisResult
prompt: str
class HumanApprovalResponse(BaseModel):
"""Response the external client sends back via the HITL response endpoint/method."""
approved: bool
reviewer_notes: str = ""
@dataclass
class ModerationResult:
"""Final result of the moderation workflow."""
content_id: str
status: str
reviewer_notes: str
# ============================================================================
# Executors
# ============================================================================
class InputRouterExecutor(Executor):
"""Parses the incoming submission and routes it to the analysis agent."""
def __init__(self) -> None:
super().__init__(id="input_router")
@handler
async def route_input(self, submission: ContentSubmission, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
ctx.set_state("current_submission", submission)
message = (
f"Please analyze the following content for policy compliance:\n\n"
f"Title: {submission.title}\n"
f"Author: {submission.author}\n"
f"Content:\n{submission.body}"
)
await ctx.send_message(
AgentExecutorRequest(messages=[Message(role="user", contents=[message])], should_respond=True)
)
class ContentAnalyzerExecutor(Executor):
"""Parses the AI agent's response and forwards it with the original submission."""
def __init__(self) -> None:
super().__init__(id="content_analyzer_executor")
@handler
async def handle_analysis(
self, response: AgentExecutorResponse, ctx: WorkflowContext[AnalysisWithSubmission]
) -> None:
try:
analysis = ContentAnalysisResult.model_validate_json(response.agent_response.text)
except ValidationError:
analysis = ContentAnalysisResult(
is_appropriate=False,
risk_level="high",
concerns=["Agent execution failed or yielded invalid JSON."],
recommendation="Manual review required",
)
submission: ContentSubmission = ctx.get_state("current_submission")
await ctx.send_message(AnalysisWithSubmission(submission=submission, analysis=analysis))
class HumanReviewExecutor(Executor):
"""Requests human approval using MAF's request_info / response_handler pattern."""
def __init__(self) -> None:
super().__init__(id="human_review_executor")
@handler
async def request_review(self, data: AnalysisWithSubmission, ctx: WorkflowContext) -> None:
submission = data.submission
analysis = data.analysis
prompt = (
f"Please review the following content for publication:\n\n"
f"Title: {submission.title}\n"
f"Author: {submission.author}\n"
f"Content: {submission.body}\n\n"
f"AI Analysis:\n"
f"- Appropriate: {analysis.is_appropriate}\n"
f"- Risk Level: {analysis.risk_level}\n"
f"- Concerns: {', '.join(analysis.concerns) if analysis.concerns else 'None'}\n"
f"- Recommendation: {analysis.recommendation}\n\n"
f"Please approve or reject this content."
)
approval_request = HumanApprovalRequest(
content_id=submission.content_id,
title=submission.title,
body=submission.body,
author=submission.author,
ai_analysis=analysis,
prompt=prompt,
)
# Pause the workflow and wait for a human response.
await ctx.request_info(request_data=approval_request, response_type=HumanApprovalResponse)
@response_handler
async def handle_approval_response(
self,
original_request: HumanApprovalRequest,
response: HumanApprovalResponse,
ctx: WorkflowContext[ModerationResult],
) -> None:
logger.info(
"Human review received for content %s: approved=%s",
original_request.content_id,
response.approved,
)
await ctx.send_message(
ModerationResult(
content_id=original_request.content_id,
status="approved" if response.approved else "rejected",
reviewer_notes=response.reviewer_notes,
)
)
class PublishExecutor(Executor):
"""Finalizes publication or rejection of the content."""
def __init__(self) -> None:
super().__init__(id="publish_executor")
@handler
async def handle_result(self, result: ModerationResult, ctx: WorkflowContext[Never, str]) -> None:
if result.status == "approved":
message = (
f"Content '{result.content_id}' has been APPROVED and published. "
f"Reviewer notes: {result.reviewer_notes or 'None'}"
)
else:
message = (
f"Content '{result.content_id}' has been REJECTED. Reviewer notes: {result.reviewer_notes or 'None'}"
)
logger.info(message)
await ctx.yield_output(message)
def _create_chat_client() -> FoundryChatClient:
"""Create an Azure AI Foundry chat client using AzureCliCredential."""
return FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AsyncAzureCliCredential(),
)
def create_workflow() -> Workflow:
"""Build the content-moderation workflow with a human-in-the-loop pause."""
chat_client = _create_chat_client()
content_analyzer_agent = Agent(
client=chat_client,
name=CONTENT_ANALYZER_AGENT_NAME,
instructions=CONTENT_ANALYZER_INSTRUCTIONS,
default_options=FoundryChatOptions[Any](response_format=ContentAnalysisResult),
)
input_router = InputRouterExecutor()
content_analyzer_executor = ContentAnalyzerExecutor()
human_review_executor = HumanReviewExecutor()
publish_executor = PublishExecutor()
return (
WorkflowBuilder(name=WORKFLOW_NAME, start_executor=input_router)
.add_edge(input_router, content_analyzer_agent)
.add_edge(content_analyzer_agent, content_analyzer_executor)
.add_edge(content_analyzer_executor, human_review_executor)
.add_edge(human_review_executor, publish_executor)
.build()
)
def get_worker(
taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None
) -> DurableTaskSchedulerWorker:
"""Create a configured DurableTaskSchedulerWorker."""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential()
return DurableTaskSchedulerWorker(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
log_handler=log_handler,
)
def setup_worker(worker: DurableTaskSchedulerWorker) -> DurableAIAgentWorker:
"""Register the workflow (agents + activities + orchestrator) on the worker."""
agent_worker = DurableAIAgentWorker(worker)
workflow = create_workflow()
agent_worker.configure_workflow(workflow)
logger.info("✓ Configured HITL workflow with %d executors", len(workflow.executors))
return agent_worker
async def main() -> None:
"""Start the worker and block until interrupted."""
worker = get_worker()
setup_worker(worker)
logger.info("Worker is ready and listening for work items. Press Ctrl+C to stop.")
try:
worker.start()
while True:
await asyncio.sleep(1)
except KeyboardInterrupt:
logger.info("Worker shutdown initiated")
logger.info("Worker stopped")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,72 @@
# Streaming Workflow Progress on a Standalone Durable Task Worker
This sample demonstrates **streaming a durable workflow's progress** from a
standalone Durable Task worker — no Azure Functions required. It is the
streaming counterpart to [`08_workflow`](../08_workflow/README.md).
## Key Concepts Demonstrated
- The async `DurableWorkflowClient` API:
- `run_workflow(input, wait=False)` — start a workflow without blocking and get
its instance id.
- `stream_workflow(instance_id)` — an async iterator that yields typed
`WorkflowEvent` objects (`executor_invoked` / `executor_completed` / `output`
/ ...) as the workflow progresses, ending when it reaches a terminal state.
Each event's `data` is already reconstructed into its original typed object,
so the client never deserializes anything by hand.
- `await_workflow_output(instance_id)` — read the final reconstructed output.
- **Brokerless streaming.** The orchestrator publishes accumulated events to the
orchestration **custom status** after each superstep (only on live execution,
not replay), and the client streams them by polling. No Redis or other message
broker is required.
- **Per-executor granularity.** Events fire per executor and per yielded output,
not at the token level. Non-agent executors carry their captured event data;
agent executors surface coarse `executor_invoked` / `executor_completed`
lifecycle events. (Token-level streaming through a durable boundary would
require an external broker.)
## Environment Setup
See the [README.md](../README.md) in the parent directory for environment setup.
This sample uses Azure AI Foundry credentials:
- `FOUNDRY_PROJECT_ENDPOINT`
- `FOUNDRY_MODEL`
It also needs a Durable Task Scheduler. For local development, start the
emulator (defaults to `http://localhost:8080`):
```bash
docker run -d -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest
```
## Running the Sample
Start the worker in one terminal:
```bash
cd samples/04-hosting/durabletask/10_workflow_streaming
python worker.py
```
In a second terminal, run the client:
```bash
python client.py
```
The workflow is a linear pipeline — `WriterAgent``ReviewerAgent``publish`
so the client prints the progress events as each executor runs, for example:
```text
Streaming workflow events:
[executor_invoked] WriterAgent
[executor_completed] WriterAgent
[executor_invoked] ReviewerAgent
[executor_completed] ReviewerAgent
[executor_invoked] publish
[executor_completed] publish
[output] from publish: Published: ...
Final output: Published: ...
```
@@ -0,0 +1,74 @@
# Copyright (c) Microsoft. All rights reserved.
"""Client that starts the workflow and streams its progress event-by-event.
The worker (``worker.py``) must be running first. This client demonstrates the
async ``DurableWorkflowClient`` API:
1. ``run_workflow(input, wait=False)`` starts the workflow and returns its
instance id without blocking.
2. ``stream_workflow(instance_id)`` yields typed ``WorkflowEvent`` objects
(``executor_invoked`` / ``executor_completed`` / ``output`` / ...) as the
workflow progresses, by polling the orchestration custom status. This is
brokerless; each event's ``data`` is already reconstructed into its original
typed object, so the client never deserializes anything by hand. Granularity
is per executor / per yielded output, not token-level.
3. ``await_workflow_output(...)`` returns the final reconstructed output.
Prerequisites:
- ``worker.py`` running and connected to the same Durable Task Scheduler.
- A Durable Task Scheduler reachable at ``ENDPOINT`` (default ``http://localhost:8080``).
"""
import asyncio
import logging
import os
from agent_framework.azure import DurableWorkflowClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from durabletask.azuremanaged.client import DurableTaskSchedulerClient
load_dotenv()
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def get_client(taskhub: str | None = None, endpoint: str | None = None) -> DurableTaskSchedulerClient:
"""Create a configured DurableTaskSchedulerClient."""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential()
return DurableTaskSchedulerClient(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
)
async def main() -> None:
"""Start a workflow and stream its typed progress events to the console."""
client = DurableWorkflowClient(get_client())
# Start without waiting so we can stream progress as it happens.
instance_id = await client.run_workflow(input="Write a short note about durable workflows.", wait=False)
logger.info("Started workflow instance: %s", instance_id)
logger.info("Streaming workflow events:")
async for event in client.stream_workflow(instance_id, poll_interval_seconds=1.0):
if event.type == "output":
logger.info(" [output] from %s: %s", event.executor_id, event.data)
else:
logger.info(" [%s] %s", event.type, event.executor_id)
# The stream ends when the workflow reaches a terminal state; read the result.
output = await client.await_workflow_output(instance_id)
logger.info("Final output: %s", output)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,142 @@
# Copyright (c) Microsoft. All rights reserved.
"""Worker that hosts a multi-step MAF Workflow for streaming (no Azure Functions).
This sample is the streaming counterpart to ``08_workflow``. It hosts a simple
linear content pipeline so a client can watch progress event-by-event:
``writer`` (agent) -> ``reviewer`` (agent) -> ``publish`` (non-agent executor)
``DurableAIAgentWorker.configure_workflow`` auto-registers a durable entity for
each agent executor, a durable activity for each non-agent executor, and the
workflow orchestrator. As each executor runs, the orchestrator publishes coarse
workflow events (``executor_invoked`` / ``executor_completed`` / ``output``) to
the orchestration custom status, which the client streams by polling.
Prerequisites:
- Set ``FOUNDRY_PROJECT_ENDPOINT`` and ``FOUNDRY_MODEL``.
- Sign in with Azure CLI (``az login``) for ``AzureCliCredential``.
- Start a Durable Task Scheduler (e.g. the DTS emulator on ``localhost:8080``).
Run the worker (this process), then run ``client.py`` in another process.
"""
import asyncio
import logging
import os
from agent_framework import (
Agent,
AgentExecutorResponse,
Executor,
Workflow,
WorkflowBuilder,
WorkflowContext,
handler,
)
from agent_framework.azure import DurableAIAgentWorker
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential
from dotenv import load_dotenv
from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker
from typing_extensions import Never
load_dotenv()
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
WRITER_AGENT_NAME = "WriterAgent"
REVIEWER_AGENT_NAME = "ReviewerAgent"
WRITER_INSTRUCTIONS = (
"You are a concise technical writer. Write a short, single-paragraph draft on the requested topic."
)
REVIEWER_INSTRUCTIONS = (
"You are an editor. Improve the draft you receive for clarity and tone, "
"and return only the improved single-paragraph version."
)
class PublishExecutor(Executor):
"""Non-agent executor that 'publishes' the reviewed draft as the final output."""
@handler
async def publish(self, agent_response: AgentExecutorResponse, ctx: WorkflowContext[Never, str]) -> None:
reviewed_text = agent_response.agent_response.text
await ctx.yield_output(f"Published:\n{reviewed_text}")
def _create_chat_client() -> FoundryChatClient:
"""Create an Azure AI Foundry chat client using AzureCliCredential."""
return FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AsyncAzureCliCredential(),
)
def create_workflow() -> Workflow:
"""Build the linear writer -> reviewer -> publish pipeline."""
chat_client = _create_chat_client()
writer_agent = Agent(client=chat_client, name=WRITER_AGENT_NAME, instructions=WRITER_INSTRUCTIONS)
reviewer_agent = Agent(client=chat_client, name=REVIEWER_AGENT_NAME, instructions=REVIEWER_INSTRUCTIONS)
publish = PublishExecutor(id="publish")
return (
WorkflowBuilder(start_executor=writer_agent)
.add_edge(writer_agent, reviewer_agent)
.add_edge(reviewer_agent, publish)
.build()
)
def get_worker(
taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None
) -> DurableTaskSchedulerWorker:
"""Create a configured DurableTaskSchedulerWorker."""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential()
return DurableTaskSchedulerWorker(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
log_handler=log_handler,
)
def setup_worker(worker: DurableTaskSchedulerWorker) -> DurableAIAgentWorker:
"""Register the workflow (agents + activities + orchestrator) on the worker."""
agent_worker = DurableAIAgentWorker(worker)
workflow = create_workflow()
agent_worker.configure_workflow(workflow)
logger.info("✓ Configured streaming workflow with %d executors", len(workflow.executors))
return agent_worker
async def main() -> None:
"""Start the worker and block until interrupted."""
worker = get_worker()
setup_worker(worker)
logger.info("Worker is ready and listening for work items. Press Ctrl+C to stop.")
try:
worker.start()
while True: # noqa: ASYNC110
await asyncio.sleep(1)
except KeyboardInterrupt:
logger.info("Worker shutdown initiated")
logger.info("Worker stopped")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,69 @@
# Composed Workflow (Sub-Workflow) on a Standalone Durable Task Worker
This sample demonstrates **workflow composition** on a standalone Durable Task
worker: an inner agent-framework `Workflow` is embedded as a node inside an outer
`Workflow` using `WorkflowExecutor`. On the durable host, the inner workflow runs
as its own durable **child orchestration**.
## Key Concepts Demonstrated
- Embedding one `Workflow` inside another with
`WorkflowExecutor(inner_workflow, id=...)`.
- A single `DurableAIAgentWorker.configure_workflow(outer_workflow)` call walks the
composition and auto-registers a durable orchestration for **each** workflow:
- `dafx-review_pipeline` — the outer workflow.
- `dafx-sentiment_analysis` — the inner workflow, run as a durable **child
orchestration** when the outer workflow reaches the `WorkflowExecutor` node.
- Per-workflow scoping: each workflow's agent executors become durable entities and
its non-agent executors become durable activities, named per workflow so the same
executor id in two workflows never collides.
- Output forwarding: the inner workflow yields a string and, because
`allow_direct_output` is left at its default (`False`), that output is forwarded to
the outer workflow as a message delivered to the `reporter` executor.
## Composition Layout
```text
review_pipeline (outer)
intake (executor)
-> sentiment_sub = WorkflowExecutor(sentiment_analysis)
sentiment_agent (agent) -> sentiment_formatter (executor)
-> reporter (executor)
```
## Environment Setup
See the [README.md](../README.md) in the parent directory for environment setup.
This sample uses Azure AI Foundry credentials:
- `FOUNDRY_PROJECT_ENDPOINT`
- `FOUNDRY_MODEL`
It also needs a Durable Task Scheduler. For local development, start the
emulator (defaults to `http://localhost:8080`):
```bash
docker run -d -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest
```
## Running the Sample
Start the worker in one terminal:
```bash
cd samples/04-hosting/durabletask/11_subworkflow
python worker.py
```
In a second terminal, run the client:
```bash
python client.py
```
The client targets only the outer workflow (`review_pipeline`); the sub-workflow
runs automatically as a child orchestration. Each review flows:
`intake``sentiment_sub` (child orchestration: `sentiment_agent`
`sentiment_formatter`) → `reporter``"Review analysis complete -> sentiment: ..."`.
@@ -0,0 +1,78 @@
# Copyright (c) Microsoft. All rights reserved.
"""Client that starts the composed workflow orchestration and prints the result.
The worker (``worker.py``) must be running first. Only the *outer* workflow is
started by the client; its embedded sub-workflow runs automatically as a durable
child orchestration when the outer workflow reaches the ``WorkflowExecutor`` node.
The workflow is started via ``DurableWorkflowClient.start_workflow`` - which
schedules the ``dafx-review_pipeline`` orchestration that
``DurableAIAgentWorker.configure_workflow`` auto-registers for the outer workflow.
Prerequisites:
- ``worker.py`` running and connected to the same Durable Task Scheduler.
- A Durable Task Scheduler reachable at ``ENDPOINT`` (default ``http://localhost:8080``).
"""
import asyncio
import logging
import os
from agent_framework.azure import DurableWorkflowClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from durabletask.azuremanaged.client import DurableTaskSchedulerClient
load_dotenv()
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# The client targets the outer workflow; the sub-workflow runs as a child orchestration.
WORKFLOW_NAME = "review_pipeline"
def get_client(taskhub: str | None = None, endpoint: str | None = None) -> DurableTaskSchedulerClient:
"""Create a configured DurableTaskSchedulerClient."""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential()
return DurableTaskSchedulerClient(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
)
def run_workflow(client: DurableWorkflowClient, review: str) -> None:
"""Start the outer workflow with a review and wait for the result."""
instance_id = client.start_workflow(input=review)
logger.info("Started workflow instance: %s", instance_id)
output = client.await_workflow_output(instance_id)
logger.info("Workflow output: %s", output)
async def main() -> None:
"""Run the composed workflow against a couple of product reviews."""
client = DurableWorkflowClient(get_client(), workflow_name=WORKFLOW_NAME)
logger.info("TEST 1: Positive review")
run_workflow(
client,
"Absolutely love this espresso machine - it heats up fast and the coffee is consistently great.",
)
logger.info("TEST 2: Negative review")
run_workflow(
client,
"Disappointed. The device stopped working after two weeks and support never replied.",
)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,211 @@
# Copyright (c) Microsoft. All rights reserved.
"""Worker that hosts a MAF Workflow composed of a nested sub-workflow.
This sample shows workflow *composition* on the Durable Task host. A
``WorkflowExecutor`` embeds an inner workflow as a node inside an outer workflow.
``DurableAIAgentWorker.configure_workflow`` walks the composition and
auto-registers a durable orchestration for *each* workflow:
- ``dafx-sentiment_analysis`` - the inner workflow, run as a durable **child
orchestration** whenever the outer workflow reaches the ``WorkflowExecutor`` node.
- ``dafx-review_pipeline`` - the outer workflow.
Each workflow's agent executors become durable entities and its non-agent
executors become durable activities, scoped per workflow so the same executor id
in two workflows never collides.
Composition layout::
review_pipeline (outer)
intake (executor)
-> sentiment_sub = WorkflowExecutor(sentiment_analysis)
sentiment_agent (agent) -> sentiment_formatter (executor)
-> reporter (executor)
The inner workflow yields a string; because ``allow_direct_output`` is left at its
default (``False``), that output is forwarded to the outer workflow as a message
delivered to ``reporter``, which produces the final result.
Prerequisites:
- Set ``FOUNDRY_PROJECT_ENDPOINT`` and ``FOUNDRY_MODEL``.
- Sign in with Azure CLI (``az login``) for ``AzureCliCredential``.
- Start a Durable Task Scheduler (e.g. the DTS emulator on ``localhost:8080``).
Run the worker (this process), then run ``client.py`` in another process.
"""
import asyncio
import logging
import os
from typing import Any
from agent_framework import (
Agent,
AgentExecutorResponse,
Executor,
Workflow,
WorkflowBuilder,
WorkflowContext,
WorkflowExecutor,
handler,
)
from agent_framework.azure import DurableAIAgentWorker
from agent_framework.foundry import FoundryChatClient, FoundryChatOptions
from azure.identity import AzureCliCredential
from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential
from dotenv import load_dotenv
from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker
from pydantic import BaseModel, ValidationError
from typing_extensions import Never
load_dotenv()
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
SENTIMENT_AGENT_NAME = "SentimentAgent"
INNER_WORKFLOW_NAME = "sentiment_analysis"
OUTER_WORKFLOW_NAME = "review_pipeline"
SENTIMENT_INSTRUCTIONS = (
"You classify the sentiment of a customer product review. "
"Return JSON with fields sentiment (one of 'positive', 'neutral', 'negative') "
"and confidence (a number between 0 and 1)."
)
class SentimentResult(BaseModel):
"""Structured output from the sentiment agent."""
sentiment: str
confidence: float
class SentimentFormatterExecutor(Executor):
"""Inner-workflow executor that turns the agent's JSON into a summary line."""
@handler
async def format_sentiment(self, agent_response: AgentExecutorResponse, ctx: WorkflowContext[Never, str]) -> None:
text = agent_response.agent_response.text
try:
result = SentimentResult.model_validate_json(text)
summary = f"{result.sentiment} (confidence {result.confidence:.0%})"
except ValidationError:
summary = "unknown (could not parse sentiment)"
await ctx.yield_output(summary)
class IntakeExecutor(Executor):
"""Outer-workflow entry point that normalizes the review before analysis."""
@handler
async def intake(self, review: str, ctx: WorkflowContext[str]) -> None:
normalized = review.strip()
logger.info("Intake received review (%d chars)", len(normalized))
await ctx.send_message(normalized)
class ReporterExecutor(Executor):
"""Outer-workflow executor that consumes the sub-workflow's forwarded output."""
@handler
async def report(self, sentiment_summary: str, ctx: WorkflowContext[Never, str]) -> None:
await ctx.yield_output(f"Review analysis complete -> sentiment: {sentiment_summary}")
def _create_chat_client() -> FoundryChatClient:
"""Create an Azure AI Foundry chat client using AzureCliCredential."""
return FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AsyncAzureCliCredential(),
)
def create_inner_workflow(chat_client: FoundryChatClient) -> Workflow:
"""Build the inner ``sentiment_analysis`` workflow (agent -> formatter)."""
sentiment_agent = Agent(
client=chat_client,
name=SENTIMENT_AGENT_NAME,
instructions=SENTIMENT_INSTRUCTIONS,
default_options=FoundryChatOptions[Any](response_format=SentimentResult),
)
sentiment_formatter = SentimentFormatterExecutor(id="sentiment_formatter")
return (
WorkflowBuilder(name=INNER_WORKFLOW_NAME, start_executor=sentiment_agent)
.add_edge(sentiment_agent, sentiment_formatter)
.build()
)
def create_workflow() -> Workflow:
"""Build the outer ``review_pipeline`` workflow that embeds the inner workflow."""
chat_client = _create_chat_client()
inner_workflow = create_inner_workflow(chat_client)
intake = IntakeExecutor(id="intake")
# WorkflowExecutor embeds the inner workflow as a single node in the outer
# workflow. On the durable host this node runs as a child orchestration.
sentiment_sub = WorkflowExecutor(inner_workflow, id="sentiment_sub")
reporter = ReporterExecutor(id="reporter")
return (
WorkflowBuilder(name=OUTER_WORKFLOW_NAME, start_executor=intake)
.add_edge(intake, sentiment_sub)
.add_edge(sentiment_sub, reporter)
.build()
)
def get_worker(
taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None
) -> DurableTaskSchedulerWorker:
"""Create a configured DurableTaskSchedulerWorker."""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential()
return DurableTaskSchedulerWorker(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
log_handler=log_handler,
)
def setup_worker(worker: DurableTaskSchedulerWorker) -> DurableAIAgentWorker:
"""Register the outer workflow and its nested sub-workflow on the worker."""
agent_worker = DurableAIAgentWorker(worker)
workflow = create_workflow()
# A single call walks the composition: it registers the outer workflow plus
# every nested sub-workflow (here, sentiment_analysis) as its own durable
# orchestration, deduped by workflow name.
agent_worker.configure_workflow(workflow)
logger.info("✓ Configured workflow '%s' with embedded sub-workflow '%s'", OUTER_WORKFLOW_NAME, INNER_WORKFLOW_NAME)
return agent_worker
async def main() -> None:
"""Start the worker and block until interrupted."""
worker = get_worker()
setup_worker(worker)
logger.info("Worker is ready and listening for work items. Press Ctrl+C to stop.")
try:
worker.start()
while True:
await asyncio.sleep(1)
except KeyboardInterrupt:
logger.info("Worker shutdown initiated")
logger.info("Worker stopped")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,63 @@
# Human-in-the-Loop in a Sub-Workflow (Durable Task Worker)
This sample combines **workflow composition** (`11_subworkflow`) with
**human-in-the-loop** (`09_workflow_hitl`): the HITL `request_info` pause lives
**inside an inner workflow** that an outer workflow embeds via `WorkflowExecutor`.
On the durable host the inner workflow runs as its own **child orchestration**, so
its pending request is recorded on the *child* instance. The parent records the
child instance id in its custom status, which lets the client discover the nested
request behind a **single top-level addressing surface**.
## Key Concepts Demonstrated
- A HITL pause (`ctx.request_info` / `@response_handler`) inside a sub-workflow.
- `DurableAIAgentWorker.configure_workflow(outer_workflow)` registers a durable
orchestration for each workflow:
- `dafx-moderation_pipeline` — the outer workflow.
- `dafx-human_review` — the inner (HITL) workflow, run as a child orchestration.
- **Qualified request ids:** the nested request surfaces to the client with a
qualified id (`review_sub~0~{requestId}`). The client posts the response against the
*top-level* instance id, and the host routes it to the owning child orchestration —
so the caller never has to discover child instance ids.
## Composition Layout
```text
moderation_pipeline (outer)
intake (executor)
-> review_sub = WorkflowExecutor(human_review)
review_gate (executor: request_info -> response_handler)
-> publish (executor)
```
## Environment Setup
See the [README.md](../README.md) in the parent directory for environment setup.
This sample uses **no AI agents**, so no model credentials are required. It only
needs a Durable Task Scheduler. For local development, start the emulator (defaults
to `http://localhost:8080`):
```bash
docker run -d -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest
```
## Running the Sample
Start the worker in one terminal:
```bash
cd samples/04-hosting/durabletask/12_subworkflow_hitl
python worker.py
```
In a second terminal, run the client:
```bash
python client.py
```
Each case flows: `intake``review_sub` (child orchestration pauses at
`review_gate`) → client responds to the qualified request → `review_gate` resumes →
inner decision forwarded to `publish` → final output.
@@ -0,0 +1,134 @@
# Copyright (c) Microsoft. All rights reserved.
"""Client that drives the composed HITL workflow, responding to a nested sub-workflow request.
The worker (``worker.py``) must be running first. This client:
1. Starts the *outer* workflow with ``DurableWorkflowClient.start_workflow``.
2. Polls ``get_pending_hitl_requests`` until a request appears. Because the HITL pause
happens inside a sub-workflow, the request surfaces with a **qualified** request id
(``review_sub~0~{requestId}``).
3. Sends the decision with ``send_hitl_response`` against the *top-level* instance id and
the qualified request id; the host routes it to the owning child orchestration.
4. Reads the final output with ``await_workflow_output``.
It runs two cases: approved content and rejected content.
Prerequisites:
- ``worker.py`` running and connected to the same Durable Task Scheduler.
- A Durable Task Scheduler reachable at ``ENDPOINT`` (default ``http://localhost:8080``).
"""
import asyncio
import logging
import os
import time
from typing import Any
from agent_framework.azure import DurableWorkflowClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from durabletask.azuremanaged.client import DurableTaskSchedulerClient
load_dotenv()
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# The client targets the outer workflow; the HITL pause lives in the sub-workflow.
WORKFLOW_NAME = "moderation_pipeline"
def get_client(taskhub: str | None = None, endpoint: str | None = None) -> DurableTaskSchedulerClient:
"""Create a configured DurableTaskSchedulerClient."""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential()
return DurableTaskSchedulerClient(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
)
def _wait_for_hitl_request(
client: DurableWorkflowClient, instance_id: str, timeout_seconds: int = 60
) -> list[dict[str, Any]]:
"""Poll until the workflow (or one of its sub-workflows) has a pending HITL request.
Stops early if the workflow reaches a terminal state without pausing, so a
misconfiguration surfaces the real status instead of a misleading timeout.
"""
terminal_statuses = {"COMPLETED", "FAILED", "TERMINATED"}
deadline = time.time() + timeout_seconds
while time.time() < deadline:
pending = client.get_pending_hitl_requests(instance_id)
if pending:
return pending
status = client.get_runtime_status(instance_id)
if status in terminal_statuses:
raise RuntimeError(
f"Workflow instance {instance_id} reached terminal state '{status}' before pausing for human input."
)
time.sleep(2)
raise TimeoutError(f"Timed out waiting for a HITL request on instance {instance_id}")
def run_case(client: DurableWorkflowClient, submission: dict[str, Any], *, approve: bool) -> None:
"""Run one moderation case: start, respond to the nested HITL pause, print the result."""
instance_id = client.start_workflow(input=submission)
logger.info("Started workflow instance: %s", instance_id)
pending = _wait_for_hitl_request(client, instance_id)
request = pending[0]
# The request id is qualified (e.g. "review_sub~0~<uuid>") because the pause lives
# in a sub-workflow. We pass it back verbatim against the top-level instance id;
# the host resolves it to the owning child orchestration.
logger.info("Pending HITL request %s from %s", request["request_id"], request["source_executor_id"])
decision = {
"approved": approve,
"reviewer_notes": "Looks good." if approve else "Violates content policy.",
}
client.send_hitl_response(instance_id, request["request_id"], decision)
logger.info("Sent decision: approved=%s", approve)
output = client.await_workflow_output(instance_id)
logger.info("Workflow output: %s", output)
async def main() -> None:
"""Run an approved case and a rejected case."""
client = DurableWorkflowClient(get_client(), workflow_name=WORKFLOW_NAME)
logger.info("CASE 1: Appropriate content (will approve)")
run_case(
client,
{
"content_id": "article-001",
"title": "Introduction to AI in Healthcare",
"body": (
"Artificial intelligence is improving healthcare by enabling faster diagnosis, "
"personalized treatment plans, and better patient outcomes."
),
},
approve=True,
)
logger.info("CASE 2: Spammy content (will reject)")
run_case(
client,
{
"content_id": "article-002",
"title": "Get Rich Quick",
"body": "Click here NOW to make $10,000 overnight! GUARANTEED! Limited time offer!",
},
approve=False,
)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,274 @@
# Copyright (c) Microsoft. All rights reserved.
"""Worker hosting a composed workflow whose Human-in-the-Loop pause lives in a sub-workflow.
This sample combines composition (``11_subworkflow``) with human-in-the-loop
(``09_workflow_hitl``): the HITL ``request_info`` happens **inside an inner
workflow** that an outer workflow embeds via ``WorkflowExecutor``. On the durable
host the inner workflow runs as its own child orchestration, so its pending request
is recorded on the *child* instance. The parent records the child instance id in its
custom status, which lets the client discover the nested request behind a single
top-level addressing surface.
``DurableAIAgentWorker.configure_workflow`` walks the composition and registers a
durable orchestration for each workflow:
- ``dafx-moderation_pipeline`` - the outer workflow.
- ``dafx-human_review`` - the inner workflow (run as a child orchestration), which
contains the HITL pause.
Composition layout::
moderation_pipeline (outer)
intake (executor)
-> review_sub = WorkflowExecutor(human_review)
review_gate (executor: request_info -> response_handler)
-> publish (executor)
The client sees the inner pending request with a **qualified** request id
(``review_sub~0~{requestId}``) and posts the response back to the *top-level*
instance; the host routes it to the owning child orchestration automatically.
Prerequisites:
- Start a Durable Task Scheduler (e.g. the DTS emulator on ``localhost:8080``).
(This sample uses no AI agents, so no model credentials are required.)
Run the worker (this process), then run ``client.py`` in another process.
"""
import asyncio
import logging
import os
from dataclasses import dataclass
from agent_framework import (
Executor,
Workflow,
WorkflowBuilder,
WorkflowContext,
WorkflowExecutor,
handler,
response_handler,
)
from agent_framework.azure import DurableAIAgentWorker
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker
from pydantic import BaseModel
from typing_extensions import Never
load_dotenv()
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
INNER_WORKFLOW_NAME = "human_review"
OUTER_WORKFLOW_NAME = "moderation_pipeline"
# ============================================================================
# Data Models
# ============================================================================
@dataclass
class ContentSubmission:
"""Content submitted for moderation (outer workflow input)."""
content_id: str
title: str
body: str
@dataclass
class HumanApprovalRequest:
"""Request surfaced to the human reviewer (carried in the orchestration status)."""
content_id: str
title: str
body: str
prompt: str
class HumanApprovalResponse(BaseModel):
"""Response the external client sends back via the HITL response endpoint/method."""
approved: bool
reviewer_notes: str = ""
@dataclass
class ModerationDecision:
"""The inner workflow's output: the human's decision for a submission."""
content_id: str
approved: bool
reviewer_notes: str
# ============================================================================
# Inner workflow (contains the HITL pause)
# ============================================================================
class ReviewGateExecutor(Executor):
"""Inner-workflow executor that pauses for human approval via request_info."""
def __init__(self) -> None:
super().__init__(id="review_gate")
@handler
async def request_review(self, submission: ContentSubmission, ctx: WorkflowContext) -> None:
prompt = (
f"Please review the following content for publication:\n\n"
f"Title: {submission.title}\n"
f"Content: {submission.body}\n\n"
f"Approve or reject this content."
)
approval_request = HumanApprovalRequest(
content_id=submission.content_id,
title=submission.title,
body=submission.body,
prompt=prompt,
)
# Pause the (inner) workflow and wait for a human response. On the durable
# host this pauses the child orchestration running this inner workflow.
await ctx.request_info(request_data=approval_request, response_type=HumanApprovalResponse)
@response_handler
async def handle_approval_response(
self,
original_request: HumanApprovalRequest,
response: HumanApprovalResponse,
ctx: WorkflowContext[Never, ModerationDecision],
) -> None:
logger.info(
"Human review received for content %s: approved=%s",
original_request.content_id,
response.approved,
)
# Yield the decision as the inner workflow's output; the WorkflowExecutor
# forwards it to the outer workflow as a message to the next node.
await ctx.yield_output(
ModerationDecision(
content_id=original_request.content_id,
approved=response.approved,
reviewer_notes=response.reviewer_notes,
)
)
def create_inner_workflow() -> Workflow:
"""Build the inner ``human_review`` workflow (a single HITL gate)."""
review_gate = ReviewGateExecutor()
return WorkflowBuilder(name=INNER_WORKFLOW_NAME, start_executor=review_gate).build()
# ============================================================================
# Outer workflow (embeds the inner workflow)
# ============================================================================
class IntakeExecutor(Executor):
"""Outer-workflow entry point that normalizes the submission before review."""
def __init__(self) -> None:
super().__init__(id="intake")
@handler
async def intake(self, submission: ContentSubmission, ctx: WorkflowContext[ContentSubmission]) -> None:
logger.info("Intake received submission %s", submission.content_id)
await ctx.send_message(submission)
class PublishExecutor(Executor):
"""Outer-workflow executor that consumes the inner workflow's forwarded decision."""
def __init__(self) -> None:
super().__init__(id="publish")
@handler
async def handle_decision(self, decision: ModerationDecision, ctx: WorkflowContext[Never, str]) -> None:
if decision.approved:
message = (
f"Content '{decision.content_id}' APPROVED and published. "
f"Reviewer notes: {decision.reviewer_notes or 'None'}"
)
else:
message = f"Content '{decision.content_id}' REJECTED. Reviewer notes: {decision.reviewer_notes or 'None'}"
logger.info(message)
await ctx.yield_output(message)
def create_workflow() -> Workflow:
"""Build the outer ``moderation_pipeline`` workflow embedding the HITL sub-workflow."""
inner_workflow = create_inner_workflow()
intake = IntakeExecutor()
# WorkflowExecutor embeds the inner (HITL) workflow as a single node. On the
# durable host this node runs as a child orchestration, and the inner pause
# surfaces to the client as a qualified request id (``review_sub~0~{requestId}``).
review_sub = WorkflowExecutor(inner_workflow, id="review_sub")
publish = PublishExecutor()
return (
WorkflowBuilder(name=OUTER_WORKFLOW_NAME, start_executor=intake)
.add_edge(intake, review_sub)
.add_edge(review_sub, publish)
.build()
)
def get_worker(
taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None
) -> DurableTaskSchedulerWorker:
"""Create a configured DurableTaskSchedulerWorker."""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential()
return DurableTaskSchedulerWorker(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
log_handler=log_handler,
)
def setup_worker(worker: DurableTaskSchedulerWorker) -> DurableAIAgentWorker:
"""Register the outer workflow and its nested HITL sub-workflow on the worker."""
agent_worker = DurableAIAgentWorker(worker)
workflow = create_workflow()
# A single call registers the outer workflow plus the nested human_review
# sub-workflow (each as its own durable orchestration).
agent_worker.configure_workflow(workflow)
logger.info(
"✓ Configured workflow '%s' with embedded HITL sub-workflow '%s'",
OUTER_WORKFLOW_NAME,
INNER_WORKFLOW_NAME,
)
return agent_worker
async def main() -> None:
"""Start the worker and block until interrupted."""
worker = get_worker()
setup_worker(worker)
logger.info("Worker is ready and listening for work items. Press Ctrl+C to stop.")
try:
worker.start()
while True:
await asyncio.sleep(1)
except KeyboardInterrupt:
logger.info("Worker shutdown initiated")
logger.info("Worker stopped")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,171 @@
# Durable Task Samples
This directory contains samples for durable agent hosting using the Durable Task Scheduler. These samples demonstrate the worker-client architecture pattern, enabling distributed agent execution with persistent conversation state.
## Quick Prerequisites Checklist
Install and verify these tools before [Running the Samples](#running-the-samples):
- **[Docker](https://docs.docker.com/get-docker/)** run the Durable Task Scheduler emulator locally
- **[uv](https://docs.astral.sh/uv/)** manage Python dependencies (optional but recommended)
- **[Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli)** authenticate with `az login` for `AzureCliCredential`
**Windows (PowerShell):**
```powershell
winget install Docker.DockerDesktop
irm https://astral.sh/uv/install.ps1 | iex
winget install Microsoft.AzureCLI
```
**macOS / Linux:**
```bash
# Docker: https://docs.docker.com/get-docker/
curl -LsSf https://astral.sh/uv/install.sh | sh
# Azure CLI: https://learn.microsoft.com/cli/azure/install-azure-cli
```
**Verify:**
```bash
docker --version
uv --version
az account show
```
## Sample Catalog
### Basic Patterns
- **[01_single_agent](01_single_agent/)**: Host a single conversational agent and interact with it via a client. Demonstrates basic worker-client architecture and agent state management.
- **[02_multi_agent](02_multi_agent/)**: Host multiple domain-specific agents (physicist and chemist) and route requests to the appropriate agent based on the question topic.
- **[03_single_agent_streaming](03_single_agent_streaming/)**: Enable reliable, resumable streaming using Redis Streams with agent response callbacks. Demonstrates non-blocking agent execution and cursor-based resumption for disconnected clients.
### Orchestration Patterns
- **[04_single_agent_orchestration_chaining](04_single_agent_orchestration_chaining/)**: Chain multiple invocations of the same agent using durable orchestration, preserving conversation context across sequential runs.
- **[05_multi_agent_orchestration_concurrency](05_multi_agent_orchestration_concurrency/)**: Run multiple agents concurrently within an orchestration, aggregating their responses in parallel.
- **[06_multi_agent_orchestration_conditionals](06_multi_agent_orchestration_conditionals/)**: Implement conditional branching in orchestrations with spam detection and email assistant agents. Demonstrates structured outputs with Pydantic models and activity functions for side effects.
- **[07_single_agent_orchestration_hitl](07_single_agent_orchestration_hitl/)**: Human-in-the-loop pattern with external event handling, timeouts, and iterative refinement based on human feedback. Shows long-running workflows with external interactions.
### Workflow Hosting Patterns
- **[08_workflow](08_workflow/)**: Host a MAF `Workflow` as a durable orchestration on a standalone worker via `DurableAIAgentWorker.configure_workflow`. Demonstrates conditional routing and mixing AI agents with non-agent executors.
- **[09_workflow_hitl](09_workflow_hitl/)**: A workflow that pauses for human approval using `ctx.request_info` / `@response_handler`, with the client discovering and answering the pending request.
- **[10_workflow_streaming](10_workflow_streaming/)**: Stream a hosted workflow's events as typed `WorkflowEvent` objects by polling the orchestration's custom status.
- **[11_subworkflow](11_subworkflow/)**: Compose workflows by embedding an inner `Workflow` as a node via `WorkflowExecutor`. On the durable host the inner workflow runs as its own child orchestration, and a single `configure_workflow` call registers both.
- **[12_subworkflow_hitl](12_subworkflow_hitl/)**: A human-in-the-loop pause that lives **inside a sub-workflow**. The nested request surfaces to the client with a qualified request id (`{executor}~{ordinal}~{requestId}`) behind a single top-level addressing surface.
## Running the Samples
These samples are designed to be run locally in a cloned repository.
### Prerequisites
The following prerequisites are required to run the samples:
- [Python 3.9 or later](https://www.python.org/downloads/)
- [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli) installed and authenticated (`az login`) or an API key for the Azure OpenAI service
- [Azure OpenAI Service](https://learn.microsoft.com/azure/ai-services/openai/how-to/create-resource) with a deployed model (gpt-4o-mini or better is recommended)
- [Durable Task Scheduler](https://learn.microsoft.com/azure/azure-functions/durable/durable-task-scheduler/develop-with-durable-task-scheduler) (local emulator or Azure-hosted)
- [Docker](https://docs.docker.com/get-docker/) installed if running the Durable Task Scheduler emulator locally
### Configuring RBAC Permissions for Azure OpenAI
These samples are configured to use the Azure OpenAI service with RBAC permissions to access the model. You'll need to configure the RBAC permissions for the Azure OpenAI service to allow the Python app to access the model.
Below is an example of how to configure the RBAC permissions for the Azure OpenAI service to allow the current user to access the model.
Bash (Linux/macOS/WSL):
```bash
az role assignment create \
--assignee "yourname@contoso.com" \
--role "Cognitive Services OpenAI User" \
--scope /subscriptions/<your-subscription-id>/resourceGroups/<your-resource-group-name>/providers/Microsoft.CognitiveServices/accounts/<your-openai-resource-name>
```
PowerShell:
```powershell
az role assignment create `
--assignee "yourname@contoso.com" `
--role "Cognitive Services OpenAI User" `
--scope /subscriptions/<your-subscription-id>/resourceGroups/<your-resource-group-name>/providers/Microsoft.CognitiveServices/accounts/<your-openai-resource-name>
```
More information on how to configure RBAC permissions for Azure OpenAI can be found in the [Azure OpenAI documentation](https://learn.microsoft.com/azure/ai-services/openai/how-to/create-resource?pivots=cli).
### Start Durable Task Scheduler
Most samples use the Durable Task Scheduler (DTS) to support hosted agents and durable orchestrations. DTS also allows you to view the status of orchestrations and their inputs and outputs from a web UI.
To run the Durable Task Scheduler locally, you can use the following `docker` command:
```bash
docker run -d --name dts-emulator -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest
```
The DTS dashboard will be available at `http://localhost:8082`.
### Environment Configuration
Each sample reads configuration from environment variables. You'll need to set the following environment variables:
Bash (Linux/macOS/WSL):
```bash
export FOUNDRY_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/api/projects/your-project"
export FOUNDRY_MODEL="your-deployment-name"
```
PowerShell:
```powershell
$env:FOUNDRY_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/api/projects/your-project"
$env:FOUNDRY_MODEL="your-deployment-name"
```
### Installing Dependencies
Navigate to the sample directory and install dependencies. For example:
```bash
cd samples/04-hosting/durabletask/01_single_agent
pip install -r requirements.txt
```
If you're using `uv` for package management:
```bash
uv pip install -r requirements.txt
```
### Running the Samples
Each sample follows a worker-client architecture. Most samples provide separate `worker.py` and `client.py` files, though some include a combined `sample.py` for convenience.
**Running with separate worker and client:**
In one terminal, start the worker:
```bash
python worker.py
```
In another terminal, run the client:
```bash
python client.py
```
**Running with combined sample:**
```bash
python sample.py
```
### Viewing the Sample Output
The sample output is displayed directly in the terminal where you ran the Python script. Agent responses are printed to stdout with log formatting for better readability.
You can also see the state of agents and orchestrations in the Durable Task Scheduler dashboard at `http://localhost:8082`.