chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,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)
|
||||
+203
@@ -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())
|
||||
Reference in New Issue
Block a user