mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into feature-azure-functions
This commit is contained in:
@@ -230,9 +230,14 @@ This directory contains samples demonstrating the capabilities of Microsoft Agen
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| [`getting_started/tools/ai_tool_with_approval.py`](./getting_started/tools/ai_tool_with_approval.py) | Demonstration of a tool with approvals |
|
||||
| [`getting_started/tools/ai_tool_with_approval_and_threads.py`](./getting_started/tools/ai_tool_with_approval_and_threads.py) | Tool Approvals with Threads |
|
||||
| [`getting_started/tools/failing_tools.py`](./getting_started/tools/failing_tools.py) | Tool exceptions handled by returning the error for the agent to recover from |
|
||||
| [`getting_started/tools/ai_function_declaration_only.py`](./getting_started/tools/ai_function_declaration_only.py) | Function declarations without implementations for testing agent reasoning |
|
||||
| [`getting_started/tools/ai_function_from_dict_with_dependency_injection.py`](./getting_started/tools/ai_function_from_dict_with_dependency_injection.py) | Creating AI functions from dictionary definitions using dependency injection |
|
||||
| [`getting_started/tools/ai_function_recover_from_failures.py`](./getting_started/tools/ai_function_recover_from_failures.py) | Graceful error handling when tools raise exceptions |
|
||||
| [`getting_started/tools/ai_function_with_approval.py`](./getting_started/tools/ai_function_with_approval.py) | User approval workflows for function calls without threads |
|
||||
| [`getting_started/tools/ai_function_with_approval_and_threads.py`](./getting_started/tools/ai_function_with_approval_and_threads.py) | Tool approval workflows using threads for conversation history management |
|
||||
| [`getting_started/tools/ai_function_with_max_exceptions.py`](./getting_started/tools/ai_function_with_max_exceptions.py) | Limiting tool failure exceptions using max_invocation_exceptions |
|
||||
| [`getting_started/tools/ai_function_with_max_invocations.py`](./getting_started/tools/ai_function_with_max_invocations.py) | Limiting total tool invocations using max_invocations |
|
||||
| [`getting_started/tools/ai_functions_in_class.py`](./getting_started/tools/ai_functions_in_class.py) | Using ai_function decorator with class methods for stateful tools |
|
||||
|
||||
## Workflows
|
||||
|
||||
@@ -288,6 +293,7 @@ This directory contains samples demonstrating the capabilities of Microsoft Agen
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| [`getting_started/workflows/human-in-the-loop/guessing_game_with_human_input.py`](./getting_started/workflows/human-in-the-loop/guessing_game_with_human_input.py) | Sample: Human in the loop guessing game |
|
||||
| [`getting_started/workflows/human-in-the-loop/agents_with_approval_requests.py`](./getting_started/workflows/human-in-the-loop/agents_with_approval_requests.py) | Sample: Agents with Approval Requests in Workflows |
|
||||
|
||||
### Observability
|
||||
|
||||
@@ -306,6 +312,7 @@ This directory contains samples demonstrating the capabilities of Microsoft Agen
|
||||
| [`getting_started/workflows/orchestration/group_chat_simple_selector.py`](./getting_started/workflows/orchestration/group_chat_simple_selector.py) | Sample: Group Chat Orchestration with function-based speaker selector |
|
||||
| [`getting_started/workflows/orchestration/handoff_simple.py`](./getting_started/workflows/orchestration/handoff_simple.py) | Sample: Handoff Orchestration with simple agent handoff pattern |
|
||||
| [`getting_started/workflows/orchestration/handoff_specialist_to_specialist.py`](./getting_started/workflows/orchestration/handoff_specialist_to_specialist.py) | Sample: Handoff Orchestration with specialist-to-specialist routing |
|
||||
| [`getting_started/workflows/orchestration/handoff_return_to_previous`](./getting_started/workflows/orchestration/handoff_return_to_previous.py) | Return-to-previous routing: after user input, routes back to the previous specialist instead of coordinator using `.enable_return_to_previous()` |
|
||||
| [`getting_started/workflows/orchestration/magentic.py`](./getting_started/workflows/orchestration/magentic.py) | Sample: Magentic Orchestration (agentic task planning with multi-agent execution) |
|
||||
| [`getting_started/workflows/orchestration/magentic_checkpoint.py`](./getting_started/workflows/orchestration/magentic_checkpoint.py) | Sample: Magentic Orchestration with Checkpointing |
|
||||
| [`getting_started/workflows/orchestration/magentic_human_plan_update.py`](./getting_started/workflows/orchestration/magentic_human_plan_update.py) | Sample: Magentic Orchestration with Human Plan Review |
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
uploads/
|
||||
@@ -0,0 +1,268 @@
|
||||
# ChatKit Integration Sample with Weather Agent and Image Analysis
|
||||
|
||||
This sample demonstrates how to integrate Microsoft Agent Framework with OpenAI ChatKit. It provides a complete implementation of a weather assistant with interactive widget visualization, image analysis, and file upload support.
|
||||
|
||||
**Features:**
|
||||
|
||||
- Weather information with interactive widgets
|
||||
- Image analysis using vision models
|
||||
- Current time queries
|
||||
- File upload with attachment storage
|
||||
- Chat interface with streaming responses
|
||||
- City selector widget with one-click weather
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Frontend["React Frontend (ChatKit UI)"]
|
||||
UI[ChatKit Components]
|
||||
Upload[File Upload]
|
||||
end
|
||||
|
||||
subgraph Backend["FastAPI Server"]
|
||||
FastAPI[FastAPI Endpoints]
|
||||
|
||||
subgraph ChatKit["WeatherChatKitServer"]
|
||||
Respond[respond method]
|
||||
Action[action method]
|
||||
end
|
||||
|
||||
subgraph Stores["Data & Storage Layer"]
|
||||
SQLite[SQLiteStore<br/>Store Protocol]
|
||||
AttStore[FileBasedAttachmentStore<br/>AttachmentStore Protocol]
|
||||
DB[(SQLite DB<br/>chatkit_demo.db)]
|
||||
Files[/uploads directory/]
|
||||
end
|
||||
|
||||
subgraph Integration["Agent Framework Integration"]
|
||||
Converter[ThreadItemConverter]
|
||||
Streamer[stream_agent_response]
|
||||
Agent[ChatAgent]
|
||||
end
|
||||
|
||||
Widgets[Widget Rendering<br/>render_weather_widget<br/>render_city_selector_widget]
|
||||
end
|
||||
|
||||
subgraph Azure["Azure AI"]
|
||||
Foundry[GPT-5<br/>with Vision]
|
||||
end
|
||||
|
||||
UI -->|HTTP POST /chatkit| FastAPI
|
||||
Upload -->|HTTP POST /upload/id| FastAPI
|
||||
|
||||
FastAPI --> ChatKit
|
||||
|
||||
ChatKit -->|save/load threads| SQLite
|
||||
ChatKit -->|save/load attachments| AttStore
|
||||
ChatKit -->|convert messages| Converter
|
||||
|
||||
SQLite -.->|persist| DB
|
||||
AttStore -.->|save files| Files
|
||||
AttStore -.->|save metadata| SQLite
|
||||
|
||||
Converter -->|ChatMessage array| Agent
|
||||
Agent -->|AgentRunResponseUpdate| Streamer
|
||||
Streamer -->|ThreadStreamEvent| ChatKit
|
||||
|
||||
ChatKit --> Widgets
|
||||
Widgets -->|WidgetItem| ChatKit
|
||||
|
||||
Agent <-->|Chat Completions API| Foundry
|
||||
|
||||
ChatKit -->|ThreadStreamEvent| FastAPI
|
||||
FastAPI -->|SSE Stream| UI
|
||||
|
||||
style ChatKit fill:#e1f5ff
|
||||
style Stores fill:#fff4e1
|
||||
style Integration fill:#f0e1ff
|
||||
style Azure fill:#e1ffe1
|
||||
```
|
||||
|
||||
### Server Implementation
|
||||
|
||||
The sample implements a ChatKit server using the `ChatKitServer` base class from the `chatkit` package:
|
||||
|
||||
**Core Components:**
|
||||
|
||||
- **`WeatherChatKitServer`**: Custom ChatKit server implementation that:
|
||||
|
||||
- Extends `ChatKitServer[dict[str, Any]]`
|
||||
- Uses Agent Framework's `ChatAgent` with Azure OpenAI
|
||||
- Converts ChatKit messages to Agent Framework format using `ThreadItemConverter`
|
||||
- Streams responses back to ChatKit using `stream_agent_response`
|
||||
- Creates and streams interactive widgets after agent responses
|
||||
|
||||
- **`SQLiteStore`**: Data persistence layer that:
|
||||
|
||||
- Implements the `Store[dict[str, Any]]` protocol from ChatKit
|
||||
- Persists threads, messages, and attachment metadata in SQLite
|
||||
- Provides thread management and item history
|
||||
- Stores attachment metadata for the upload lifecycle
|
||||
|
||||
- **`FileBasedAttachmentStore`**: File storage implementation that:
|
||||
- Implements the `AttachmentStore[dict[str, Any]]` protocol from ChatKit
|
||||
- Stores uploaded files on the local filesystem (in `./uploads` directory)
|
||||
- Generates upload URLs for two-phase file upload
|
||||
- Saves attachment metadata to the data store for upload tracking
|
||||
- Provides preview URLs for images
|
||||
|
||||
**Key Integration Points:**
|
||||
|
||||
```python
|
||||
# Converting ChatKit messages to Agent Framework
|
||||
converter = ThreadItemConverter(
|
||||
attachment_data_fetcher=self._fetch_attachment_data
|
||||
)
|
||||
agent_messages = await converter.to_agent_input(user_message_item)
|
||||
|
||||
# Running agent and streaming back to ChatKit
|
||||
async for event in stream_agent_response(
|
||||
self.weather_agent.run_stream(agent_messages),
|
||||
thread_id=thread.id,
|
||||
):
|
||||
yield event
|
||||
|
||||
# Streaming widgets
|
||||
widget = render_weather_widget(weather_data)
|
||||
async for event in stream_widget(thread_id=thread.id, widget=widget):
|
||||
yield event
|
||||
```
|
||||
|
||||
## Installation and Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.10+
|
||||
- Node.js 18.18+ and npm 9+
|
||||
- Azure OpenAI service configured
|
||||
- Azure CLI for authentication (`az login`)
|
||||
|
||||
### Backend Setup
|
||||
|
||||
1. **Install Python packages:**
|
||||
|
||||
```bash
|
||||
cd python/samples/demos/chatkit-integration
|
||||
pip install agent-framework-chatkit fastapi uvicorn azure-identity
|
||||
```
|
||||
|
||||
2. **Configure Azure OpenAI:**
|
||||
|
||||
```bash
|
||||
export AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/"
|
||||
export AZURE_OPENAI_API_VERSION="2024-06-01"
|
||||
export AZURE_OPENAI_CHAT_DEPLOYMENT_NAME="gpt-4o"
|
||||
```
|
||||
|
||||
3. **Authenticate with Azure:**
|
||||
|
||||
```bash
|
||||
az login
|
||||
```
|
||||
|
||||
### Frontend Setup
|
||||
|
||||
Install the Node.js dependencies:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
## How to Run
|
||||
|
||||
### Start the Backend Server
|
||||
|
||||
From the `chatkit-integration` directory:
|
||||
|
||||
```bash
|
||||
python app.py
|
||||
```
|
||||
|
||||
Or with auto-reload for development:
|
||||
|
||||
```bash
|
||||
uvicorn app:app --host 127.0.0.1 --port 8001 --reload
|
||||
```
|
||||
|
||||
The backend will start on `http://localhost:8001`
|
||||
|
||||
### Start the Frontend Development Server
|
||||
|
||||
In a new terminal, from the `frontend` directory:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The frontend will start on `http://localhost:5171`
|
||||
|
||||
### Access the Application
|
||||
|
||||
Open your browser and navigate to:
|
||||
|
||||
```
|
||||
http://localhost:5171
|
||||
```
|
||||
|
||||
You can now:
|
||||
|
||||
- Ask about weather in any location (weather widgets display automatically)
|
||||
- Upload images for analysis using the attachment button
|
||||
- Get the current time
|
||||
- Ask to see available cities and click city buttons for instant weather
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
chatkit-integration/
|
||||
├── app.py # FastAPI backend with ChatKitServer implementation
|
||||
├── store.py # SQLiteStore implementation
|
||||
├── attachment_store.py # FileBasedAttachmentStore implementation
|
||||
├── weather_widget.py # Widget rendering functions
|
||||
├── chatkit_demo.db # SQLite database (auto-created)
|
||||
├── uploads/ # Uploaded files directory (auto-created)
|
||||
└── frontend/
|
||||
├── package.json
|
||||
├── vite.config.ts
|
||||
├── index.html
|
||||
└── src/
|
||||
├── main.tsx
|
||||
└── App.tsx # ChatKit UI integration
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
You can customize the application by editing constants at the top of `app.py`:
|
||||
|
||||
```python
|
||||
# Server configuration
|
||||
SERVER_HOST = "127.0.0.1" # Bind to localhost only for security (local dev)
|
||||
SERVER_PORT = 8001
|
||||
SERVER_BASE_URL = f"http://localhost:{SERVER_PORT}"
|
||||
|
||||
# Database configuration
|
||||
DATABASE_PATH = "chatkit_demo.db"
|
||||
|
||||
# File storage configuration
|
||||
UPLOADS_DIRECTORY = "./uploads"
|
||||
|
||||
# User context
|
||||
DEFAULT_USER_ID = "demo_user"
|
||||
```
|
||||
|
||||
### Sample Conversations
|
||||
|
||||
Try these example queries:
|
||||
|
||||
- "What's the weather like in Tokyo?"
|
||||
- "Show me available cities" (displays interactive city selector)
|
||||
- "What's the current time?"
|
||||
- Upload an image and ask "What do you see in this image?"
|
||||
|
||||
## Learn More
|
||||
|
||||
- [Agent Framework Documentation](https://aka.ms/agent-framework)
|
||||
- [ChatKit Documentation](https://platform.openai.com/docs/guides/chatkit)
|
||||
- [Azure OpenAI Documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/)
|
||||
@@ -0,0 +1 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
@@ -0,0 +1,538 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""
|
||||
ChatKit Integration Sample with Weather Agent and Image Analysis
|
||||
|
||||
This sample demonstrates how to integrate Microsoft Agent Framework with OpenAI ChatKit
|
||||
using a weather tool with widget visualization, image analysis, and Azure OpenAI. It shows
|
||||
a complete ChatKit server implementation using Agent Framework agents with proper FastAPI
|
||||
setup, interactive weather widgets, and vision capabilities for analyzing uploaded images.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from datetime import datetime, timezone
|
||||
from random import randint
|
||||
from typing import Annotated, Any
|
||||
|
||||
import uvicorn
|
||||
from azure.identity import AzureCliCredential
|
||||
from fastapi import FastAPI, File, Request, UploadFile
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, JSONResponse, Response, StreamingResponse
|
||||
from pydantic import Field
|
||||
|
||||
# ============================================================================
|
||||
# Configuration Constants
|
||||
# ============================================================================
|
||||
|
||||
# Server configuration
|
||||
SERVER_HOST = "127.0.0.1" # Bind to localhost only for security (local dev)
|
||||
SERVER_PORT = 8001
|
||||
SERVER_BASE_URL = f"http://localhost:{SERVER_PORT}"
|
||||
|
||||
# Database configuration
|
||||
DATABASE_PATH = "chatkit_demo.db"
|
||||
|
||||
# File storage configuration
|
||||
UPLOADS_DIRECTORY = "./uploads"
|
||||
|
||||
# User context
|
||||
DEFAULT_USER_ID = "demo_user"
|
||||
|
||||
# Logging configuration
|
||||
LOG_LEVEL = logging.INFO
|
||||
LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
LOG_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
# ============================================================================
|
||||
# Logging Setup
|
||||
# ============================================================================
|
||||
|
||||
logging.basicConfig(
|
||||
level=LOG_LEVEL,
|
||||
format=LOG_FORMAT,
|
||||
datefmt=LOG_DATE_FORMAT,
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Agent Framework imports
|
||||
from agent_framework import AgentRunResponseUpdate, ChatAgent, ChatMessage, FunctionResultContent, Role
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
|
||||
# Agent Framework ChatKit integration
|
||||
from agent_framework_chatkit import ThreadItemConverter, stream_agent_response
|
||||
|
||||
# Local imports
|
||||
from attachment_store import FileBasedAttachmentStore
|
||||
|
||||
# ChatKit imports
|
||||
from chatkit.actions import Action
|
||||
from chatkit.server import ChatKitServer
|
||||
from chatkit.store import StoreItemType, default_generate_id
|
||||
from chatkit.types import (
|
||||
ThreadItemDoneEvent,
|
||||
ThreadMetadata,
|
||||
ThreadStreamEvent,
|
||||
UserMessageItem,
|
||||
WidgetItem,
|
||||
)
|
||||
from chatkit.widgets import WidgetRoot
|
||||
from store import SQLiteStore
|
||||
from weather_widget import (
|
||||
WeatherData,
|
||||
city_selector_copy_text,
|
||||
render_city_selector_widget,
|
||||
render_weather_widget,
|
||||
weather_widget_copy_text,
|
||||
)
|
||||
|
||||
|
||||
class WeatherResponse(str):
|
||||
"""A string response that also carries WeatherData for widget creation."""
|
||||
|
||||
def __new__(cls, text: str, weather_data: WeatherData):
|
||||
instance = super().__new__(cls, text)
|
||||
instance.weather_data = weather_data # type: ignore
|
||||
return instance
|
||||
|
||||
|
||||
async def stream_widget(
|
||||
thread_id: str,
|
||||
widget: WidgetRoot,
|
||||
copy_text: str | None = None,
|
||||
generate_id: Callable[[StoreItemType], str] = default_generate_id,
|
||||
) -> AsyncIterator[ThreadStreamEvent]:
|
||||
"""Stream a ChatKit widget as a ThreadStreamEvent.
|
||||
|
||||
This helper function creates a ChatKit widget item and yields it as a
|
||||
ThreadItemDoneEvent that can be consumed by the ChatKit UI.
|
||||
|
||||
Args:
|
||||
thread_id: The ChatKit thread ID for the conversation.
|
||||
widget: The ChatKit widget to display.
|
||||
copy_text: Optional text representation of the widget for copy/paste.
|
||||
generate_id: Optional function to generate IDs for ChatKit items.
|
||||
|
||||
Yields:
|
||||
ThreadStreamEvent: ChatKit event containing the widget.
|
||||
"""
|
||||
item_id = generate_id("message")
|
||||
|
||||
widget_item = WidgetItem(
|
||||
id=item_id,
|
||||
thread_id=thread_id,
|
||||
created_at=datetime.now(),
|
||||
widget=widget,
|
||||
copy_text=copy_text,
|
||||
)
|
||||
|
||||
yield ThreadItemDoneEvent(type="thread.item.done", item=widget_item)
|
||||
|
||||
|
||||
def get_weather(
|
||||
location: Annotated[str, Field(description="The location to get the weather for.")],
|
||||
) -> str:
|
||||
"""Get the weather for a given location.
|
||||
|
||||
Returns a string description with embedded WeatherData for widget creation.
|
||||
"""
|
||||
logger.info(f"Fetching weather for location: {location}")
|
||||
|
||||
conditions = ["sunny", "cloudy", "rainy", "stormy", "snowy", "foggy"]
|
||||
temperature = randint(-5, 35)
|
||||
condition = conditions[randint(0, len(conditions) - 1)]
|
||||
|
||||
# Add some realistic details
|
||||
humidity = randint(30, 90)
|
||||
wind_speed = randint(5, 25)
|
||||
|
||||
weather_data = WeatherData(
|
||||
location=location,
|
||||
condition=condition,
|
||||
temperature=temperature,
|
||||
humidity=humidity,
|
||||
wind_speed=wind_speed,
|
||||
)
|
||||
|
||||
logger.debug(f"Weather data generated: {condition}, {temperature}°C, {humidity}% humidity, {wind_speed} km/h wind")
|
||||
|
||||
# Return a WeatherResponse that is both a string (for the LLM) and carries structured data
|
||||
text = (
|
||||
f"Weather in {location}:\n"
|
||||
f"• Condition: {condition.title()}\n"
|
||||
f"• Temperature: {temperature}°C\n"
|
||||
f"• Humidity: {humidity}%\n"
|
||||
f"• Wind: {wind_speed} km/h"
|
||||
)
|
||||
return WeatherResponse(text, weather_data)
|
||||
|
||||
|
||||
def get_time() -> str:
|
||||
"""Get the current UTC time."""
|
||||
current_time = datetime.now(timezone.utc)
|
||||
logger.info("Getting current UTC time")
|
||||
return f"Current UTC time: {current_time.strftime('%Y-%m-%d %H:%M:%S')} UTC"
|
||||
|
||||
|
||||
def show_city_selector() -> str:
|
||||
"""Show an interactive city selector widget to the user.
|
||||
|
||||
This function triggers the display of a widget that allows users
|
||||
to select from popular cities to get weather information.
|
||||
|
||||
Returns a special marker string that will be detected to show the widget.
|
||||
"""
|
||||
logger.info("Activating city selector widget")
|
||||
return "__SHOW_CITY_SELECTOR__"
|
||||
|
||||
|
||||
class WeatherChatKitServer(ChatKitServer[dict[str, Any]]):
|
||||
"""ChatKit server implementation using Agent Framework.
|
||||
|
||||
This server integrates Agent Framework agents with ChatKit's server protocol,
|
||||
providing weather information with interactive widgets and time queries through Azure OpenAI.
|
||||
"""
|
||||
|
||||
def __init__(self, data_store: SQLiteStore, attachment_store: FileBasedAttachmentStore):
|
||||
super().__init__(data_store, attachment_store)
|
||||
|
||||
logger.info("Initializing WeatherChatKitServer")
|
||||
|
||||
# Create Agent Framework agent with Azure OpenAI
|
||||
# For authentication, run `az login` command in terminal
|
||||
try:
|
||||
self.weather_agent = ChatAgent(
|
||||
chat_client=AzureOpenAIChatClient(credential=AzureCliCredential()),
|
||||
instructions=(
|
||||
"You are a helpful weather assistant with image analysis capabilities. "
|
||||
"You can provide weather information for any location, tell the current time, "
|
||||
"and analyze images that users upload. Be friendly and informative in your responses.\n\n"
|
||||
"If a user asks to see a list of cities or wants to choose from available cities, "
|
||||
"use the show_city_selector tool to display an interactive city selector.\n\n"
|
||||
"When users upload images, you will automatically receive them and can analyze their content. "
|
||||
"Describe what you see in detail and be helpful in answering questions about the images."
|
||||
),
|
||||
tools=[get_weather, get_time, show_city_selector],
|
||||
)
|
||||
logger.info("Weather agent initialized successfully with Azure OpenAI")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize weather agent: {e}")
|
||||
raise
|
||||
|
||||
# Create ThreadItemConverter with attachment data fetcher
|
||||
self.converter = ThreadItemConverter(
|
||||
attachment_data_fetcher=self._fetch_attachment_data,
|
||||
)
|
||||
|
||||
logger.info("WeatherChatKitServer initialized")
|
||||
|
||||
async def _fetch_attachment_data(self, attachment_id: str) -> bytes:
|
||||
"""Fetch attachment binary data for the converter.
|
||||
|
||||
Args:
|
||||
attachment_id: The ID of the attachment to fetch.
|
||||
|
||||
Returns:
|
||||
The binary data of the attachment.
|
||||
"""
|
||||
return await attachment_store.read_attachment_bytes(attachment_id)
|
||||
|
||||
async def respond(
|
||||
self,
|
||||
thread: ThreadMetadata,
|
||||
input_user_message: UserMessageItem | None,
|
||||
context: dict[str, Any],
|
||||
) -> AsyncIterator[ThreadStreamEvent]:
|
||||
"""Handle incoming user messages and generate responses.
|
||||
|
||||
This method converts ChatKit messages to Agent Framework format using ThreadItemConverter,
|
||||
runs the agent, converts the response back to ChatKit events using stream_agent_response,
|
||||
and creates interactive weather widgets when weather data is queried.
|
||||
"""
|
||||
from agent_framework import FunctionResultContent
|
||||
|
||||
if input_user_message is None:
|
||||
logger.debug("Received None user message, skipping")
|
||||
return
|
||||
|
||||
logger.info(f"Processing message for thread: {thread.id}")
|
||||
|
||||
try:
|
||||
# Track weather data and city selector flag for this request
|
||||
weather_data: WeatherData | None = None
|
||||
show_city_selector = False
|
||||
|
||||
# Convert ChatKit user message to Agent Framework ChatMessage using ThreadItemConverter
|
||||
agent_messages = await self.converter.to_agent_input(input_user_message)
|
||||
|
||||
if not agent_messages:
|
||||
logger.warning("No messages after conversion")
|
||||
return
|
||||
|
||||
logger.info(f"Running agent with {len(agent_messages)} message(s)")
|
||||
|
||||
# Run the Agent Framework agent with streaming
|
||||
agent_stream = self.weather_agent.run_stream(agent_messages)
|
||||
|
||||
# Create an intercepting stream that extracts function results while passing through updates
|
||||
async def intercept_stream() -> AsyncIterator[AgentRunResponseUpdate]:
|
||||
nonlocal weather_data, show_city_selector
|
||||
async for update in agent_stream:
|
||||
# Check for function results in the update
|
||||
if update.contents:
|
||||
for content in update.contents:
|
||||
if isinstance(content, FunctionResultContent):
|
||||
result = content.result
|
||||
|
||||
# Check if it's a WeatherResponse (string subclass with weather_data attribute)
|
||||
if isinstance(result, str) and hasattr(result, "weather_data"):
|
||||
extracted_data = getattr(result, "weather_data", None)
|
||||
if isinstance(extracted_data, WeatherData):
|
||||
weather_data = extracted_data
|
||||
logger.info(f"Weather data extracted: {weather_data.location}")
|
||||
# Check if it's the city selector marker
|
||||
elif isinstance(result, str) and result == "__SHOW_CITY_SELECTOR__":
|
||||
show_city_selector = True
|
||||
logger.info("City selector flag detected")
|
||||
yield update
|
||||
|
||||
# Stream updates as ChatKit events with interception
|
||||
async for event in stream_agent_response(
|
||||
intercept_stream(),
|
||||
thread_id=thread.id,
|
||||
):
|
||||
yield event
|
||||
|
||||
# If weather data was collected during the tool call, create a widget
|
||||
if weather_data is not None and isinstance(weather_data, WeatherData):
|
||||
logger.info(f"Creating weather widget for location: {weather_data.location}")
|
||||
# Create weather widget
|
||||
widget = render_weather_widget(weather_data)
|
||||
copy_text = weather_widget_copy_text(weather_data)
|
||||
|
||||
# Stream the widget
|
||||
async for widget_event in stream_widget(thread_id=thread.id, widget=widget, copy_text=copy_text):
|
||||
yield widget_event
|
||||
logger.debug("Weather widget streamed successfully")
|
||||
|
||||
# If city selector should be shown, create and stream that widget
|
||||
if show_city_selector:
|
||||
logger.info("Creating city selector widget")
|
||||
# Create city selector widget
|
||||
selector_widget = render_city_selector_widget()
|
||||
selector_copy_text = city_selector_copy_text()
|
||||
|
||||
# Stream the widget
|
||||
async for widget_event in stream_widget(
|
||||
thread_id=thread.id, widget=selector_widget, copy_text=selector_copy_text
|
||||
):
|
||||
yield widget_event
|
||||
logger.debug("City selector widget streamed successfully")
|
||||
|
||||
logger.info(f"Completed processing message for thread: {thread.id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing message for thread {thread.id}: {e}", exc_info=True)
|
||||
|
||||
async def action(
|
||||
self,
|
||||
thread: ThreadMetadata,
|
||||
action: Action[str, Any],
|
||||
sender: WidgetItem | None,
|
||||
context: dict[str, Any],
|
||||
) -> AsyncIterator[ThreadStreamEvent]:
|
||||
"""Handle widget actions from the frontend.
|
||||
|
||||
This method processes actions triggered by interactive widgets,
|
||||
such as city selection from the city selector widget.
|
||||
"""
|
||||
|
||||
logger.info(f"Received action: {action.type} for thread: {thread.id}")
|
||||
|
||||
if action.type == "city_selected":
|
||||
# Extract city information from the action payload
|
||||
city_label = action.payload.get("city_label", "Unknown")
|
||||
|
||||
logger.info(f"City selected: {city_label}")
|
||||
logger.debug(f"Action payload: {action.payload}")
|
||||
|
||||
# Track weather data for this request
|
||||
weather_data: WeatherData | None = None
|
||||
|
||||
# Create an agent message asking about the weather
|
||||
agent_messages = [ChatMessage(role=Role.USER, text=f"What's the weather in {city_label}?")]
|
||||
|
||||
logger.debug(f"Processing weather query: {agent_messages[0].text}")
|
||||
|
||||
# Run the Agent Framework agent with streaming
|
||||
agent_stream = self.weather_agent.run_stream(agent_messages)
|
||||
|
||||
# Create an intercepting stream that extracts function results while passing through updates
|
||||
async def intercept_stream() -> AsyncIterator[AgentRunResponseUpdate]:
|
||||
nonlocal weather_data
|
||||
async for update in agent_stream:
|
||||
# Check for function results in the update
|
||||
if update.contents:
|
||||
for content in update.contents:
|
||||
if isinstance(content, FunctionResultContent):
|
||||
result = content.result
|
||||
|
||||
# Check if it's a WeatherResponse (string subclass with weather_data attribute)
|
||||
if isinstance(result, str) and hasattr(result, "weather_data"):
|
||||
extracted_data = getattr(result, "weather_data", None)
|
||||
if isinstance(extracted_data, WeatherData):
|
||||
weather_data = extracted_data
|
||||
logger.info(f"Weather data extracted: {weather_data.location}")
|
||||
yield update
|
||||
|
||||
# Stream updates as ChatKit events with interception
|
||||
async for event in stream_agent_response(
|
||||
intercept_stream(),
|
||||
thread_id=thread.id,
|
||||
):
|
||||
yield event
|
||||
|
||||
# If weather data was collected during the tool call, create a widget
|
||||
if weather_data is not None and isinstance(weather_data, WeatherData):
|
||||
logger.info(f"Creating weather widget for: {weather_data.location}")
|
||||
# Create weather widget
|
||||
widget = render_weather_widget(weather_data)
|
||||
copy_text = weather_widget_copy_text(weather_data)
|
||||
|
||||
# Stream the widget
|
||||
async for widget_event in stream_widget(thread_id=thread.id, widget=widget, copy_text=copy_text):
|
||||
yield widget_event
|
||||
logger.debug("Weather widget created successfully from action")
|
||||
else:
|
||||
logger.warning("No weather data available to create widget after action")
|
||||
|
||||
|
||||
# FastAPI application setup
|
||||
app = FastAPI(
|
||||
title="ChatKit Weather & Vision Agent",
|
||||
description="Weather and image analysis assistant powered by Agent Framework and Azure OpenAI",
|
||||
version="1.0.0",
|
||||
)
|
||||
|
||||
# Add CORS middleware to allow frontend connections
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # In production, specify exact origins
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Initialize data store and ChatKit server
|
||||
logger.info("Initializing application components")
|
||||
data_store = SQLiteStore(db_path=DATABASE_PATH)
|
||||
attachment_store = FileBasedAttachmentStore(
|
||||
uploads_dir=UPLOADS_DIRECTORY,
|
||||
base_url=SERVER_BASE_URL,
|
||||
data_store=data_store,
|
||||
)
|
||||
chatkit_server = WeatherChatKitServer(data_store, attachment_store)
|
||||
logger.info("Application initialization complete")
|
||||
|
||||
|
||||
@app.post("/chatkit")
|
||||
async def chatkit_endpoint(request: Request):
|
||||
"""Main ChatKit endpoint that handles all ChatKit requests.
|
||||
|
||||
This endpoint follows the ChatKit server protocol and handles both
|
||||
streaming and non-streaming responses.
|
||||
"""
|
||||
logger.debug(f"Received ChatKit request from {request.client}")
|
||||
request_body = await request.body()
|
||||
|
||||
# Create context following the working examples pattern
|
||||
context = {"request": request}
|
||||
|
||||
try:
|
||||
# Process the request using ChatKit server
|
||||
result = await chatkit_server.process(request_body, context)
|
||||
|
||||
# Return appropriate response type
|
||||
if hasattr(result, "__aiter__"): # StreamingResult
|
||||
logger.debug("Returning streaming response")
|
||||
return StreamingResponse(result, media_type="text/event-stream") # type: ignore[arg-type]
|
||||
# NonStreamingResult
|
||||
logger.debug("Returning non-streaming response")
|
||||
return Response(content=result.json, media_type="application/json") # type: ignore[union-attr]
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing ChatKit request: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
@app.post("/upload/{attachment_id}")
|
||||
async def upload_file(attachment_id: str, file: UploadFile = File(...)):
|
||||
"""Handle file upload for two-phase upload.
|
||||
|
||||
The client POSTs the file bytes here after creating the attachment
|
||||
via the ChatKit attachments.create endpoint.
|
||||
"""
|
||||
logger.info(f"Receiving file upload for attachment: {attachment_id}")
|
||||
|
||||
try:
|
||||
# Read file contents
|
||||
contents = await file.read()
|
||||
|
||||
# Save to disk
|
||||
file_path = attachment_store.get_file_path(attachment_id)
|
||||
file_path.write_bytes(contents)
|
||||
|
||||
logger.info(f"Saved {len(contents)} bytes to {file_path}")
|
||||
|
||||
# Load the attachment metadata from the data store
|
||||
attachment = await data_store.load_attachment(attachment_id, {"user_id": DEFAULT_USER_ID})
|
||||
|
||||
# Clear the upload_url since upload is complete
|
||||
attachment.upload_url = None
|
||||
|
||||
# Save the updated attachment back to the store
|
||||
await data_store.save_attachment(attachment, {"user_id": DEFAULT_USER_ID})
|
||||
|
||||
# Return the attachment metadata as JSON
|
||||
return JSONResponse(content=attachment.model_dump(mode="json"))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error uploading file for attachment {attachment_id}: {e}", exc_info=True)
|
||||
return JSONResponse(status_code=500, content={"error": f"Failed to upload file: {str(e)}"})
|
||||
|
||||
|
||||
@app.get("/preview/{attachment_id}")
|
||||
async def preview_image(attachment_id: str):
|
||||
"""Serve image preview/thumbnail.
|
||||
|
||||
For simplicity, this serves the full image. In production, you should
|
||||
generate and cache thumbnails.
|
||||
"""
|
||||
logger.debug(f"Serving preview for attachment: {attachment_id}")
|
||||
|
||||
try:
|
||||
file_path = attachment_store.get_file_path(attachment_id)
|
||||
|
||||
if not file_path.exists():
|
||||
return JSONResponse(status_code=404, content={"error": "File not found"})
|
||||
|
||||
# Determine media type from file extension or attachment metadata
|
||||
# For simplicity, we'll try to load from the store
|
||||
try:
|
||||
attachment = await data_store.load_attachment(attachment_id, {"user_id": DEFAULT_USER_ID})
|
||||
media_type = attachment.mime_type
|
||||
except Exception:
|
||||
# Default to binary if we can't determine
|
||||
media_type = "application/octet-stream"
|
||||
|
||||
return FileResponse(file_path, media_type=media_type)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error serving preview for attachment {attachment_id}: {e}", exc_info=True)
|
||||
return JSONResponse(status_code=500, content={"error": str(e)})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Run the server
|
||||
logger.info(f"Starting ChatKit Weather Agent server on {SERVER_HOST}:{SERVER_PORT}")
|
||||
uvicorn.run(app, host=SERVER_HOST, port=SERVER_PORT, log_level="info")
|
||||
@@ -0,0 +1,121 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""File-based AttachmentStore implementation for ChatKit.
|
||||
|
||||
This module provides a simple AttachmentStore implementation that stores
|
||||
uploaded files on the local filesystem. In production, you should use
|
||||
cloud storage like S3, Azure Blob Storage, or Google Cloud Storage.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
from chatkit.store import AttachmentStore
|
||||
from chatkit.types import Attachment, AttachmentCreateParams, FileAttachment, ImageAttachment
|
||||
from pydantic import AnyUrl
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from store import SQLiteStore
|
||||
|
||||
|
||||
class FileBasedAttachmentStore(AttachmentStore[dict[str, Any]]):
|
||||
"""File-based AttachmentStore that stores files on local disk.
|
||||
|
||||
This implementation stores uploaded files in a local directory and provides
|
||||
upload URLs that point to the FastAPI upload endpoint. It supports both
|
||||
image and file attachments.
|
||||
|
||||
Features:
|
||||
- Stores files in a local uploads directory
|
||||
- Generates upload URLs for two-phase upload
|
||||
- Generates preview URLs for images
|
||||
- Proper cleanup on deletion
|
||||
|
||||
Note: This is for demonstration purposes. In production, use cloud storage
|
||||
with signed URLs for better security and scalability.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
uploads_dir: str = "./uploads",
|
||||
base_url: str = "http://localhost:8001",
|
||||
data_store: "SQLiteStore | None" = None,
|
||||
):
|
||||
"""Initialize the file-based attachment store.
|
||||
|
||||
Args:
|
||||
uploads_dir: Directory where uploaded files will be stored
|
||||
base_url: Base URL for generating upload and preview URLs
|
||||
data_store: Optional data store to persist attachment metadata
|
||||
"""
|
||||
self.uploads_dir = Path(uploads_dir)
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.data_store = data_store
|
||||
|
||||
# Create uploads directory if it doesn't exist
|
||||
self.uploads_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def get_file_path(self, attachment_id: str) -> Path:
|
||||
"""Get the filesystem path for an attachment."""
|
||||
return self.uploads_dir / attachment_id
|
||||
|
||||
async def delete_attachment(self, attachment_id: str, context: dict[str, Any]) -> None:
|
||||
"""Delete an attachment and its file from disk."""
|
||||
file_path = self.get_file_path(attachment_id)
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
|
||||
async def create_attachment(
|
||||
self, input: AttachmentCreateParams, context: dict[str, Any]
|
||||
) -> Attachment:
|
||||
"""Create an attachment with upload URL for two-phase upload.
|
||||
|
||||
This creates the attachment metadata and returns upload URLs that
|
||||
the client will use to POST the actual file bytes.
|
||||
"""
|
||||
# Generate unique ID for this attachment
|
||||
attachment_id = self.generate_attachment_id(input.mime_type, context)
|
||||
|
||||
# Generate upload URL that points to our FastAPI upload endpoint
|
||||
upload_url = f"{self.base_url}/upload/{attachment_id}"
|
||||
|
||||
# Create appropriate attachment type based on MIME type
|
||||
if input.mime_type.startswith("image/"):
|
||||
# For images, also provide a preview URL
|
||||
preview_url = f"{self.base_url}/preview/{attachment_id}"
|
||||
|
||||
attachment = ImageAttachment(
|
||||
id=attachment_id,
|
||||
type="image",
|
||||
mime_type=input.mime_type,
|
||||
name=input.name,
|
||||
upload_url=AnyUrl(upload_url),
|
||||
preview_url=AnyUrl(preview_url),
|
||||
)
|
||||
else:
|
||||
# For files, just provide upload URL
|
||||
attachment = FileAttachment(
|
||||
id=attachment_id,
|
||||
type="file",
|
||||
mime_type=input.mime_type,
|
||||
name=input.name,
|
||||
upload_url=AnyUrl(upload_url),
|
||||
)
|
||||
|
||||
# Save attachment metadata to data store so it's available during upload
|
||||
if self.data_store is not None:
|
||||
await self.data_store.save_attachment(attachment, context)
|
||||
|
||||
return attachment
|
||||
|
||||
async def read_attachment_bytes(self, attachment_id: str) -> bytes:
|
||||
"""Read the raw bytes of an uploaded attachment.
|
||||
|
||||
This is used by the ThreadItemConverter to create base64-encoded
|
||||
content for sending to the Agent Framework.
|
||||
"""
|
||||
file_path = self.get_file_path(attachment_id)
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(f"Attachment {attachment_id} not found on disk")
|
||||
|
||||
return file_path.read_bytes()
|
||||
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ChatKit + Agent Framework Demo</title>
|
||||
<script src="https://cdn.platform.openai.com/deployments/chatkit/chatkit.js"></script>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 1rem;
|
||||
background: #f5f5f5;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
#root {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>ChatKit + Agent Framework Demo</h1>
|
||||
<p>Simple weather assistant powered by Agent Framework and ChatKit</p>
|
||||
</header>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "chatkit-agent-framework-demo",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.18",
|
||||
"npm": ">=9"
|
||||
},
|
||||
"dependencies": {
|
||||
"@openai/chatkit-react": "^0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"typescript": "^5.4.0",
|
||||
"vite": "^7.1.12"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ChatKit, useChatKit } from "@openai/chatkit-react";
|
||||
|
||||
const CHATKIT_API_URL = "/chatkit";
|
||||
const CHATKIT_API_DOMAIN_KEY =
|
||||
import.meta.env.VITE_CHATKIT_API_DOMAIN_KEY ?? "domain_pk_localhost_dev";
|
||||
|
||||
export default function App() {
|
||||
const chatkit = useChatKit({
|
||||
api: {
|
||||
url: CHATKIT_API_URL,
|
||||
domainKey: CHATKIT_API_DOMAIN_KEY,
|
||||
uploadStrategy: { type: "two_phase" },
|
||||
},
|
||||
startScreen: {
|
||||
greeting: "Hello! I'm your weather and image analysis assistant. Ask me about the weather in any location or upload images for me to analyze.",
|
||||
prompts: [
|
||||
{ label: "Weather in New York", prompt: "What's the weather in New York?" },
|
||||
{ label: "Select City to Get Weather", prompt: "Show me the city selector for weather" },
|
||||
{ label: "Current Time", prompt: "What time is it?" },
|
||||
{ label: "Analyze an Image", prompt: "I'll upload an image for you to analyze" },
|
||||
],
|
||||
},
|
||||
composer: {
|
||||
placeholder: "Ask about weather or upload an image...",
|
||||
attachments: {
|
||||
enabled: true,
|
||||
accept: { "image/*": [".png", ".jpg", ".jpeg", ".gif", ".webp"] },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return <ChatKit control={chatkit.control} style={{ height: "100%" }} />;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
|
||||
const container = document.getElementById("root");
|
||||
|
||||
if (!container) {
|
||||
throw new Error("Root element with id 'root' not found");
|
||||
}
|
||||
|
||||
createRoot(container).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
|
||||
const backendTarget = process.env.BACKEND_URL ?? "http://127.0.0.1:8001";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: 5171,
|
||||
proxy: {
|
||||
"/chatkit": {
|
||||
target: backendTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
// For production deployments, you need to add your public domains to this list
|
||||
allowedHosts: [
|
||||
// You can remove these examples added just to demonstrate how to configure the allowlist
|
||||
".ngrok.io",
|
||||
".trycloudflare.com",
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,361 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""SQLite-based store implementation for ChatKit data persistence.
|
||||
|
||||
This module provides a complete Store implementation using SQLite for data persistence.
|
||||
It includes proper thread safety, user isolation, and follows the ChatKit Store protocol.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from chatkit.store import Store, NotFoundError
|
||||
from chatkit.types import (
|
||||
Attachment,
|
||||
Page,
|
||||
ThreadItem,
|
||||
ThreadMetadata,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ThreadData(BaseModel):
|
||||
"""Model for serializing thread data to SQLite."""
|
||||
thread: ThreadMetadata
|
||||
|
||||
|
||||
class ItemData(BaseModel):
|
||||
"""Model for serializing thread item data to SQLite."""
|
||||
item: ThreadItem
|
||||
|
||||
|
||||
class AttachmentData(BaseModel):
|
||||
"""Model for serializing attachment data to SQLite."""
|
||||
attachment: Attachment
|
||||
|
||||
|
||||
class SQLiteStore(Store[dict[str, Any]]):
|
||||
"""SQLite-based store implementation for ChatKit data.
|
||||
|
||||
This implementation follows the pattern from the ChatKit Python tests
|
||||
and provides persistent storage for threads, messages, and attachments.
|
||||
|
||||
Features:
|
||||
- Thread-safe SQLite connections with WAL mode
|
||||
- User isolation for multi-tenant support
|
||||
- Proper error handling and transaction management
|
||||
- Complete Store protocol implementation
|
||||
|
||||
Note: This is for demonstration purposes. In production, you should
|
||||
implement proper error handling, connection pooling, and migration strategies.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: str | None = None):
|
||||
self.db_path = db_path or "chatkit_demo.db" # Use file-based DB for demo
|
||||
self._create_tables()
|
||||
|
||||
def _create_connection(self):
|
||||
# Enable thread safety and WAL mode for better concurrent access
|
||||
conn = sqlite3.connect(self.db_path, check_same_thread=False)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
return conn
|
||||
|
||||
def _create_tables(self):
|
||||
with self._create_connection() as conn:
|
||||
# Create threads table
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS threads (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
)"""
|
||||
)
|
||||
|
||||
# Create items table
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS items (
|
||||
id TEXT PRIMARY KEY,
|
||||
thread_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
)"""
|
||||
)
|
||||
|
||||
# Create attachments table
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS attachments (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
)"""
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def generate_thread_id(self, context: dict[str, Any]) -> str:
|
||||
return f"thr_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
def generate_item_id(
|
||||
self,
|
||||
item_type: str,
|
||||
thread: ThreadMetadata,
|
||||
context: dict[str, Any],
|
||||
) -> str:
|
||||
prefix_map = {
|
||||
"message": "msg",
|
||||
"tool_call": "tc",
|
||||
"task": "tsk",
|
||||
"workflow": "wf",
|
||||
"attachment": "atc",
|
||||
}
|
||||
prefix = prefix_map.get(item_type, "itm")
|
||||
return f"{prefix}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
async def load_thread(self, thread_id: str, context: dict[str, Any]) -> ThreadMetadata:
|
||||
user_id = context.get("user_id", "demo_user")
|
||||
|
||||
with self._create_connection() as conn:
|
||||
cursor = conn.execute(
|
||||
"SELECT data FROM threads WHERE id = ? AND user_id = ?",
|
||||
(thread_id, user_id),
|
||||
).fetchone()
|
||||
|
||||
if cursor is None:
|
||||
raise NotFoundError(f"Thread {thread_id} not found")
|
||||
|
||||
thread_data = ThreadData.model_validate_json(cursor[0])
|
||||
return thread_data.thread
|
||||
|
||||
async def save_thread(self, thread: ThreadMetadata, context: dict[str, Any]) -> None:
|
||||
user_id = context.get("user_id", "demo_user")
|
||||
|
||||
with self._create_connection() as conn:
|
||||
thread_data = ThreadData(thread=thread)
|
||||
|
||||
# Replace existing thread data
|
||||
conn.execute(
|
||||
"DELETE FROM threads WHERE id = ? AND user_id = ?",
|
||||
(thread.id, user_id),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO threads (id, user_id, created_at, data) VALUES (?, ?, ?, ?)",
|
||||
(
|
||||
thread.id,
|
||||
user_id,
|
||||
thread.created_at.isoformat(),
|
||||
thread_data.model_dump_json(),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
async def load_thread_items(
|
||||
self,
|
||||
thread_id: str,
|
||||
after: str | None,
|
||||
limit: int,
|
||||
order: str,
|
||||
context: dict[str, Any],
|
||||
) -> Page[ThreadItem]:
|
||||
user_id = context.get("user_id", "demo_user")
|
||||
|
||||
with self._create_connection() as conn:
|
||||
created_after: str | None = None
|
||||
if after:
|
||||
after_cursor = conn.execute(
|
||||
"SELECT created_at FROM items WHERE id = ? AND user_id = ?",
|
||||
(after, user_id),
|
||||
).fetchone()
|
||||
if after_cursor is None:
|
||||
raise NotFoundError(f"Item {after} not found")
|
||||
created_after = after_cursor[0]
|
||||
|
||||
query = """
|
||||
SELECT data FROM items
|
||||
WHERE thread_id = ? AND user_id = ?
|
||||
"""
|
||||
params: list[Any] = [thread_id, user_id]
|
||||
|
||||
if created_after:
|
||||
query += " AND created_at > ?" if order == "asc" else " AND created_at < ?"
|
||||
params.append(created_after)
|
||||
|
||||
query += f" ORDER BY created_at {order} LIMIT ?"
|
||||
params.append(limit + 1)
|
||||
|
||||
items_cursor = conn.execute(query, params).fetchall()
|
||||
items = [
|
||||
ItemData.model_validate_json(row[0]).item for row in items_cursor
|
||||
]
|
||||
|
||||
has_more = len(items) > limit
|
||||
if has_more:
|
||||
items = items[:limit]
|
||||
|
||||
return Page[ThreadItem](
|
||||
data=items,
|
||||
has_more=has_more,
|
||||
after=items[-1].id if items else None
|
||||
)
|
||||
|
||||
async def save_attachment(self, attachment: Attachment, context: dict[str, Any]) -> None:
|
||||
user_id = context.get("user_id", "demo_user")
|
||||
|
||||
with self._create_connection() as conn:
|
||||
attachment_data = AttachmentData(attachment=attachment)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO attachments (id, user_id, data) VALUES (?, ?, ?)",
|
||||
(
|
||||
attachment.id,
|
||||
user_id,
|
||||
attachment_data.model_dump_json(),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
async def load_attachment(self, attachment_id: str, context: dict[str, Any]) -> Attachment:
|
||||
user_id = context.get("user_id", "demo_user")
|
||||
|
||||
with self._create_connection() as conn:
|
||||
cursor = conn.execute(
|
||||
"SELECT data FROM attachments WHERE id = ? AND user_id = ?",
|
||||
(attachment_id, user_id),
|
||||
).fetchone()
|
||||
|
||||
if cursor is None:
|
||||
raise NotFoundError(f"Attachment {attachment_id} not found")
|
||||
|
||||
attachment_data = AttachmentData.model_validate_json(cursor[0])
|
||||
return attachment_data.attachment
|
||||
|
||||
async def delete_attachment(self, attachment_id: str, context: dict[str, Any]) -> None:
|
||||
user_id = context.get("user_id", "demo_user")
|
||||
|
||||
with self._create_connection() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM attachments WHERE id = ? AND user_id = ?",
|
||||
(attachment_id, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
async def load_threads(
|
||||
self,
|
||||
limit: int,
|
||||
after: str | None,
|
||||
order: str,
|
||||
context: dict[str, Any],
|
||||
) -> Page[ThreadMetadata]:
|
||||
user_id = context.get("user_id", "demo_user")
|
||||
|
||||
with self._create_connection() as conn:
|
||||
created_after: str | None = None
|
||||
if after:
|
||||
after_cursor = conn.execute(
|
||||
"SELECT created_at FROM threads WHERE id = ? AND user_id = ?",
|
||||
(after, user_id),
|
||||
).fetchone()
|
||||
if after_cursor is None:
|
||||
raise NotFoundError(f"Thread {after} not found")
|
||||
created_after = after_cursor[0]
|
||||
|
||||
query = "SELECT data FROM threads WHERE user_id = ?"
|
||||
params: list[Any] = [user_id]
|
||||
|
||||
if created_after:
|
||||
query += " AND created_at > ?" if order == "asc" else " AND created_at < ?"
|
||||
params.append(created_after)
|
||||
|
||||
query += f" ORDER BY created_at {order} LIMIT ?"
|
||||
params.append(limit + 1)
|
||||
|
||||
threads_cursor = conn.execute(query, params).fetchall()
|
||||
threads = [
|
||||
ThreadData.model_validate_json(row[0]).thread for row in threads_cursor
|
||||
]
|
||||
|
||||
has_more = len(threads) > limit
|
||||
if has_more:
|
||||
threads = threads[:limit]
|
||||
|
||||
return Page[ThreadMetadata](
|
||||
data=threads,
|
||||
has_more=has_more,
|
||||
after=threads[-1].id if threads else None
|
||||
)
|
||||
|
||||
async def add_thread_item(
|
||||
self, thread_id: str, item: ThreadItem, context: dict[str, Any]
|
||||
) -> None:
|
||||
user_id = context.get("user_id", "demo_user")
|
||||
|
||||
with self._create_connection() as conn:
|
||||
item_data = ItemData(item=item)
|
||||
conn.execute(
|
||||
"INSERT INTO items (id, thread_id, user_id, created_at, data) VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
item.id,
|
||||
thread_id,
|
||||
user_id,
|
||||
item.created_at.isoformat(),
|
||||
item_data.model_dump_json(),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
async def save_item(self, thread_id: str, item: ThreadItem, context: dict[str, Any]) -> None:
|
||||
user_id = context.get("user_id", "demo_user")
|
||||
|
||||
with self._create_connection() as conn:
|
||||
item_data = ItemData(item=item)
|
||||
conn.execute(
|
||||
"UPDATE items SET data = ? WHERE id = ? AND thread_id = ? AND user_id = ?",
|
||||
(
|
||||
item_data.model_dump_json(),
|
||||
item.id,
|
||||
thread_id,
|
||||
user_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
async def load_item(self, thread_id: str, item_id: str, context: dict[str, Any]) -> ThreadItem:
|
||||
user_id = context.get("user_id", "demo_user")
|
||||
|
||||
with self._create_connection() as conn:
|
||||
cursor = conn.execute(
|
||||
"SELECT data FROM items WHERE id = ? AND thread_id = ? AND user_id = ?",
|
||||
(item_id, thread_id, user_id),
|
||||
).fetchone()
|
||||
|
||||
if cursor is None:
|
||||
raise NotFoundError(f"Item {item_id} not found in thread {thread_id}")
|
||||
|
||||
item_data = ItemData.model_validate_json(cursor[0])
|
||||
return item_data.item
|
||||
|
||||
async def delete_thread(self, thread_id: str, context: dict[str, Any]) -> None:
|
||||
user_id = context.get("user_id", "demo_user")
|
||||
|
||||
with self._create_connection() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM threads WHERE id = ? AND user_id = ?",
|
||||
(thread_id, user_id),
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM items WHERE thread_id = ? AND user_id = ?",
|
||||
(thread_id, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
async def delete_thread_item(
|
||||
self, thread_id: str, item_id: str, context: dict[str, Any]
|
||||
) -> None:
|
||||
user_id = context.get("user_id", "demo_user")
|
||||
|
||||
with self._create_connection() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM items WHERE id = ? AND thread_id = ? AND user_id = ?",
|
||||
(item_id, thread_id, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
@@ -0,0 +1,437 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Weather widget rendering for ChatKit integration sample."""
|
||||
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
|
||||
from chatkit.actions import ActionConfig
|
||||
from chatkit.widgets import Box, Button, Card, Col, Image, Row, Text, Title, WidgetRoot
|
||||
|
||||
WEATHER_ICON_COLOR = "#1D4ED8"
|
||||
WEATHER_ICON_ACCENT = "#DBEAFE"
|
||||
|
||||
# Popular cities for the selector
|
||||
POPULAR_CITIES = [
|
||||
{"value": "seattle", "label": "Seattle, WA", "description": "Pacific Northwest"},
|
||||
{"value": "new_york", "label": "New York, NY", "description": "East Coast"},
|
||||
{"value": "san_francisco", "label": "San Francisco, CA", "description": "Bay Area"},
|
||||
{"value": "chicago", "label": "Chicago, IL", "description": "Midwest"},
|
||||
{"value": "miami", "label": "Miami, FL", "description": "Southeast"},
|
||||
{"value": "austin", "label": "Austin, TX", "description": "Southwest"},
|
||||
{"value": "boston", "label": "Boston, MA", "description": "New England"},
|
||||
{"value": "denver", "label": "Denver, CO", "description": "Mountain West"},
|
||||
{"value": "portland", "label": "Portland, OR", "description": "Pacific Northwest"},
|
||||
{"value": "atlanta", "label": "Atlanta, GA", "description": "Southeast"},
|
||||
]
|
||||
|
||||
# Mapping from city values to display names for weather queries
|
||||
CITY_VALUE_TO_NAME = {city["value"]: city["label"] for city in POPULAR_CITIES}
|
||||
|
||||
|
||||
|
||||
def _sun_svg() -> str:
|
||||
"""Generate SVG for sunny weather icon."""
|
||||
color = WEATHER_ICON_COLOR
|
||||
accent = WEATHER_ICON_ACCENT
|
||||
return (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">'
|
||||
f'<circle cx="32" cy="32" r="13" fill="{accent}" stroke="{color}" stroke-width="3"/>'
|
||||
f'<g stroke="{color}" stroke-width="3" stroke-linecap="round">'
|
||||
'<line x1="32" y1="8" x2="32" y2="16"/>'
|
||||
'<line x1="32" y1="48" x2="32" y2="56"/>'
|
||||
'<line x1="8" y1="32" x2="16" y2="32"/>'
|
||||
'<line x1="48" y1="32" x2="56" y2="32"/>'
|
||||
'<line x1="14.93" y1="14.93" x2="20.55" y2="20.55"/>'
|
||||
'<line x1="43.45" y1="43.45" x2="49.07" y2="49.07"/>'
|
||||
'<line x1="14.93" y1="49.07" x2="20.55" y2="43.45"/>'
|
||||
'<line x1="43.45" y1="20.55" x2="49.07" y2="14.93"/>'
|
||||
"</g>"
|
||||
"</svg>"
|
||||
)
|
||||
|
||||
|
||||
def _cloud_svg() -> str:
|
||||
"""Generate SVG for cloudy weather icon."""
|
||||
color = WEATHER_ICON_COLOR
|
||||
accent = WEATHER_ICON_ACCENT
|
||||
return (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">'
|
||||
f'<path d="M22 46H44C50.075 46 55 41.075 55 35S50.075 24 44 24H42.7C41.2 16.2 34.7 10 26.5 10 18 10 11.6 16.1 11 24.3 6.5 25.6 3 29.8 3 35s4.925 11 11 11h8Z" '
|
||||
f'fill="{accent}" stroke="{color}" stroke-width="3" stroke-linejoin="round"/>'
|
||||
"</svg>"
|
||||
)
|
||||
|
||||
|
||||
def _rain_svg() -> str:
|
||||
"""Generate SVG for rainy weather icon."""
|
||||
color = WEATHER_ICON_COLOR
|
||||
accent = WEATHER_ICON_ACCENT
|
||||
return (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">'
|
||||
f'<path d="M22 40H44C50.075 40 55 35.075 55 29S50.075 18 44 18H42.7C41.2 10.2 34.7 4 26.5 4 18 4 11.6 10.1 11 18.3 6.5 19.6 3 23.8 3 29s4.925 11 11 11h8Z" '
|
||||
f'fill="{accent}" stroke="{color}" stroke-width="3" stroke-linejoin="round"/>'
|
||||
f'<g stroke="{color}" stroke-width="3" stroke-linecap="round">'
|
||||
'<line x1="20" y1="48" x2="24" y2="56"/>'
|
||||
'<line x1="30" y1="50" x2="34" y2="58"/>'
|
||||
'<line x1="40" y1="48" x2="44" y2="56"/>'
|
||||
"</g>"
|
||||
"</svg>"
|
||||
)
|
||||
|
||||
|
||||
def _storm_svg() -> str:
|
||||
"""Generate SVG for stormy weather icon."""
|
||||
color = WEATHER_ICON_COLOR
|
||||
accent = WEATHER_ICON_ACCENT
|
||||
return (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">'
|
||||
f'<path d="M22 40H44C50.075 40 55 35.075 55 29S50.075 18 44 18H42.7C41.2 10.2 34.7 4 26.5 4 18 4 11.6 10.1 11 18.3 6.5 19.6 3 23.8 3 29s4.925 11 11 11h8Z" '
|
||||
f'fill="{accent}" stroke="{color}" stroke-width="3" stroke-linejoin="round"/>'
|
||||
f'<path d="M34 46L28 56H34L30 64L42 50H36L40 46Z" '
|
||||
f'fill="{color}" stroke="{color}" stroke-width="2" stroke-linejoin="round"/>'
|
||||
"</svg>"
|
||||
)
|
||||
|
||||
|
||||
def _snow_svg() -> str:
|
||||
"""Generate SVG for snowy weather icon."""
|
||||
color = WEATHER_ICON_COLOR
|
||||
accent = WEATHER_ICON_ACCENT
|
||||
return (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">'
|
||||
f'<path d="M22 40H44C50.075 40 55 35.075 55 29S50.075 18 44 18H42.7C41.2 10.2 34.7 4 26.5 4 18 4 11.6 10.1 11 18.3 6.5 19.6 3 23.8 3 29s4.925 11 11 11h8Z" '
|
||||
f'fill="{accent}" stroke="{color}" stroke-width="3" stroke-linejoin="round"/>'
|
||||
f'<g stroke="{color}" stroke-width="2" stroke-linecap="round">'
|
||||
'<line x1="20" y1="48" x2="20" y2="56"/>'
|
||||
'<line x1="17" y1="51" x2="23" y2="53"/>'
|
||||
'<line x1="17" y1="53" x2="23" y2="51"/>'
|
||||
'<line x1="36" y1="48" x2="36" y2="56"/>'
|
||||
'<line x1="33" y1="51" x2="39" y2="53"/>'
|
||||
'<line x1="33" y1="53" x2="39" y2="51"/>'
|
||||
"</g>"
|
||||
"</svg>"
|
||||
)
|
||||
|
||||
|
||||
def _fog_svg() -> str:
|
||||
"""Generate SVG for foggy weather icon."""
|
||||
color = WEATHER_ICON_COLOR
|
||||
accent = WEATHER_ICON_ACCENT
|
||||
return (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">'
|
||||
f'<path d="M22 40H44C50.075 40 55 35.075 55 29S50.075 18 44 18H42.7C41.2 10.2 34.7 4 26.5 4 18 4 11.6 10.1 11 18.3 6.5 19.6 3 23.8 3 29s4.925 11 11 11h8Z" '
|
||||
f'fill="{accent}" stroke="{color}" stroke-width="3" stroke-linejoin="round"/>'
|
||||
f'<g stroke="{color}" stroke-width="3" stroke-linecap="round">'
|
||||
'<line x1="18" y1="50" x2="42" y2="50"/>'
|
||||
'<line x1="24" y1="56" x2="48" y2="56"/>'
|
||||
"</g>"
|
||||
"</svg>"
|
||||
)
|
||||
|
||||
|
||||
def _encode_svg(svg: str) -> str:
|
||||
"""Encode SVG as base64 data URI."""
|
||||
encoded = base64.b64encode(svg.encode("utf-8")).decode("ascii")
|
||||
return f"data:image/svg+xml;base64,{encoded}"
|
||||
|
||||
|
||||
# Weather condition to icon mapping
|
||||
WEATHER_ICONS = {
|
||||
"sunny": _encode_svg(_sun_svg()),
|
||||
"cloudy": _encode_svg(_cloud_svg()),
|
||||
"rainy": _encode_svg(_rain_svg()),
|
||||
"stormy": _encode_svg(_storm_svg()),
|
||||
"snowy": _encode_svg(_snow_svg()),
|
||||
"foggy": _encode_svg(_fog_svg()),
|
||||
}
|
||||
|
||||
DEFAULT_WEATHER_ICON = _encode_svg(_cloud_svg())
|
||||
|
||||
|
||||
@dataclass
|
||||
class WeatherData:
|
||||
"""Weather data container."""
|
||||
|
||||
location: str
|
||||
condition: str
|
||||
temperature: int
|
||||
humidity: int
|
||||
wind_speed: int
|
||||
|
||||
|
||||
def render_weather_widget(data: WeatherData) -> WidgetRoot:
|
||||
"""Render a weather widget from weather data.
|
||||
|
||||
Args:
|
||||
data: WeatherData containing weather information
|
||||
|
||||
Returns:
|
||||
A ChatKit WidgetRoot (Card) displaying the weather information
|
||||
"""
|
||||
# Get weather icon
|
||||
weather_icon_src = WEATHER_ICONS.get(data.condition.lower(), DEFAULT_WEATHER_ICON)
|
||||
|
||||
# Build the widget
|
||||
header = Box(
|
||||
padding=5,
|
||||
background="surface-tertiary",
|
||||
children=[
|
||||
Row(
|
||||
justify="between",
|
||||
align="center",
|
||||
children=[
|
||||
Col(
|
||||
align="start",
|
||||
gap=1,
|
||||
children=[
|
||||
Text(
|
||||
value=data.location,
|
||||
size="lg",
|
||||
weight="semibold",
|
||||
),
|
||||
Text(
|
||||
value="Current conditions",
|
||||
color="tertiary",
|
||||
size="xs",
|
||||
),
|
||||
],
|
||||
),
|
||||
Box(
|
||||
padding=3,
|
||||
radius="full",
|
||||
background="blue-100",
|
||||
children=[
|
||||
Image(
|
||||
src=weather_icon_src,
|
||||
alt=data.condition,
|
||||
size=28,
|
||||
fit="contain",
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
align="start",
|
||||
gap=4,
|
||||
children=[
|
||||
Title(
|
||||
value=f"{data.temperature}°C",
|
||||
size="lg",
|
||||
weight="semibold",
|
||||
),
|
||||
Col(
|
||||
align="start",
|
||||
gap=1,
|
||||
children=[
|
||||
Text(
|
||||
value=data.condition.title(),
|
||||
color="secondary",
|
||||
size="sm",
|
||||
weight="medium",
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
# Details section
|
||||
details = Box(
|
||||
padding=5,
|
||||
gap=4,
|
||||
children=[
|
||||
Text(value="Weather details", weight="semibold", size="sm"),
|
||||
Row(
|
||||
gap=3,
|
||||
wrap="wrap",
|
||||
children=[
|
||||
_detail_chip("Humidity", f"{data.humidity}%"),
|
||||
_detail_chip("Wind", f"{data.wind_speed} km/h"),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
return Card(
|
||||
key="weather",
|
||||
padding=0,
|
||||
children=[header, details],
|
||||
)
|
||||
|
||||
|
||||
def _detail_chip(label: str, value: str) -> Box:
|
||||
"""Create a detail chip widget component."""
|
||||
return Box(
|
||||
padding=3,
|
||||
radius="xl",
|
||||
background="surface-tertiary",
|
||||
width=150,
|
||||
minWidth=150,
|
||||
maxWidth=150,
|
||||
minHeight=80,
|
||||
maxHeight=80,
|
||||
flex="0 0 auto",
|
||||
children=[
|
||||
Col(
|
||||
align="stretch",
|
||||
gap=2,
|
||||
children=[
|
||||
Text(value=label, size="xs", weight="medium", color="tertiary"),
|
||||
Row(
|
||||
justify="center",
|
||||
margin={"top": 2},
|
||||
children=[Text(value=value, weight="semibold", size="lg")],
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def weather_widget_copy_text(data: WeatherData) -> str:
|
||||
"""Generate plain text representation of weather data.
|
||||
|
||||
Args:
|
||||
data: WeatherData containing weather information
|
||||
|
||||
Returns:
|
||||
Plain text description for copy/paste functionality
|
||||
"""
|
||||
return (
|
||||
f"Weather in {data.location}:\n"
|
||||
f"• Condition: {data.condition.title()}\n"
|
||||
f"• Temperature: {data.temperature}°C\n"
|
||||
f"• Humidity: {data.humidity}%\n"
|
||||
f"• Wind: {data.wind_speed} km/h"
|
||||
)
|
||||
|
||||
|
||||
def render_city_selector_widget() -> WidgetRoot:
|
||||
"""Render an interactive city selector widget.
|
||||
|
||||
This widget displays popular cities as a visual selection interface.
|
||||
Users can click or ask about any city to get weather information.
|
||||
|
||||
Returns:
|
||||
A ChatKit WidgetRoot (Card) with city selection display
|
||||
"""
|
||||
# Create location icon SVG
|
||||
location_icon = _encode_svg(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">'
|
||||
f'<path d="M32 8c-8.837 0-16 7.163-16 16 0 12 16 32 16 32s16-20 16-32c0-8.837-7.163-16-16-16z" '
|
||||
f'fill="{WEATHER_ICON_ACCENT}" stroke="{WEATHER_ICON_COLOR}" stroke-width="3" stroke-linejoin="round"/>'
|
||||
f'<circle cx="32" cy="24" r="6" fill="{WEATHER_ICON_COLOR}"/>'
|
||||
"</svg>"
|
||||
)
|
||||
|
||||
# Header section
|
||||
header = Box(
|
||||
padding=5,
|
||||
background="surface-tertiary",
|
||||
children=[
|
||||
Row(
|
||||
gap=3,
|
||||
align="center",
|
||||
children=[
|
||||
Box(
|
||||
padding=3,
|
||||
radius="full",
|
||||
background="blue-100",
|
||||
children=[
|
||||
Image(
|
||||
src=location_icon,
|
||||
alt="Location",
|
||||
size=28,
|
||||
fit="contain",
|
||||
)
|
||||
],
|
||||
),
|
||||
Col(
|
||||
align="start",
|
||||
gap=1,
|
||||
children=[
|
||||
Title(
|
||||
value="Popular Cities",
|
||||
size="md",
|
||||
weight="semibold",
|
||||
),
|
||||
Text(
|
||||
value="Select a city or ask about any location",
|
||||
color="tertiary",
|
||||
size="xs",
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
# Create city chips in a grid layout
|
||||
city_chips: list[Button] = []
|
||||
for city in POPULAR_CITIES:
|
||||
# Create a button that sends an action to query weather for the selected city
|
||||
chip = Button(
|
||||
label=city["label"],
|
||||
variant="outline",
|
||||
size="md",
|
||||
onClickAction=ActionConfig(
|
||||
type="city_selected",
|
||||
payload={"city_value": city["value"], "city_label": city["label"]},
|
||||
handler="server", # Handle on server-side
|
||||
),
|
||||
)
|
||||
city_chips.append(chip)
|
||||
|
||||
# Arrange in rows of 3
|
||||
city_rows: list[Row] = []
|
||||
for i in range(0, len(city_chips), 3):
|
||||
row_chips: list[Button] = city_chips[i : i + 3]
|
||||
city_rows.append(
|
||||
Row(
|
||||
gap=3,
|
||||
wrap="wrap",
|
||||
justify="start",
|
||||
children=list(row_chips), # Convert to generic list
|
||||
)
|
||||
)
|
||||
|
||||
# Cities display section
|
||||
cities_section = Box(
|
||||
padding=5,
|
||||
gap=3,
|
||||
children=[
|
||||
*city_rows,
|
||||
Box(
|
||||
padding=3,
|
||||
radius="md",
|
||||
background="blue-50",
|
||||
children=[
|
||||
Text(
|
||||
value="💡 Click any city to get its weather, or ask about any other location!",
|
||||
size="xs",
|
||||
color="secondary",
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
return Card(
|
||||
key="city_selector",
|
||||
padding=0,
|
||||
children=[header, cities_section],
|
||||
)
|
||||
|
||||
|
||||
def city_selector_copy_text() -> str:
|
||||
"""Generate plain text representation of city selector.
|
||||
|
||||
Returns:
|
||||
Plain text description for copy/paste functionality
|
||||
"""
|
||||
cities_list = "\n".join([f"• {city['label']}" for city in POPULAR_CITIES])
|
||||
return f"Popular cities (click to get weather):\n{cities_list}\n\nYou can also ask about weather in any other location!"
|
||||
@@ -38,10 +38,8 @@ Before running the examples, you need to set up your environment variables. You
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME="your-model-deployment-name"
|
||||
```
|
||||
|
||||
3. For samples using Bing Grounding search (like `azure_ai_with_bing_grounding.py` and `azure_ai_with_multiple_tools.py`), you'll also need either:
|
||||
3. For samples using Bing Grounding search (like `azure_ai_with_bing_grounding.py` and `azure_ai_with_multiple_tools.py`), you'll also need:
|
||||
```
|
||||
BING_CONNECTION_NAME="bing-grounding-connection"
|
||||
# OR
|
||||
BING_CONNECTION_ID="your-bing-connection-id"
|
||||
```
|
||||
|
||||
@@ -49,7 +47,7 @@ Before running the examples, you need to set up your environment variables. You
|
||||
- Go to [Azure AI Foundry portal](https://ai.azure.com)
|
||||
- Navigate to your project's "Connected resources" section
|
||||
- Add a new connection for "Grounding with Bing Search"
|
||||
- Copy either the connection name or ID
|
||||
- Copy the ID
|
||||
|
||||
### Option 2: Using environment variables directly
|
||||
|
||||
@@ -58,9 +56,7 @@ Set the environment variables in your shell:
|
||||
```bash
|
||||
export AZURE_AI_PROJECT_ENDPOINT="your-project-endpoint"
|
||||
export AZURE_AI_MODEL_DEPLOYMENT_NAME="your-model-deployment-name"
|
||||
export BING_CONNECTION_NAME="your-bing-connection-name" # Optional, only needed for web search samples
|
||||
# OR
|
||||
export BING_CONNECTION_ID="your-bing-connection-id" # Alternative to BING_CONNECTION_NAME
|
||||
export BING_CONNECTION_ID="your-bing-connection-id"
|
||||
```
|
||||
|
||||
### Required Variables
|
||||
@@ -70,4 +66,4 @@ export BING_CONNECTION_ID="your-bing-connection-id" # Alternative to BING_CONNE
|
||||
|
||||
### Optional Variables
|
||||
|
||||
- `BING_CONNECTION_NAME` or `BING_CONNECTION_ID`: Your Bing connection name or ID (required for `azure_ai_with_bing_grounding.py` and `azure_ai_with_multiple_tools.py`)
|
||||
- `BING_CONNECTION_ID`: Your Bing connection ID (required for `azure_ai_with_bing_grounding.py` and `azure_ai_with_multiple_tools.py`)
|
||||
|
||||
@@ -5,6 +5,7 @@ import os
|
||||
|
||||
from agent_framework import ChatAgent, CitationAnnotation
|
||||
from agent_framework.azure import AzureAIAgentClient
|
||||
from azure.ai.agents.aio import AgentsClient
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.ai.projects.models import ConnectionType
|
||||
from azure.identity.aio import AzureCliCredential
|
||||
@@ -38,16 +39,17 @@ async def main() -> None:
|
||||
# Create the client and manually create an agent with Azure AI Search tool
|
||||
async with (
|
||||
AzureCliCredential() as credential,
|
||||
AIProjectClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as client,
|
||||
AIProjectClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as project_client,
|
||||
AgentsClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as agents_client,
|
||||
):
|
||||
ai_search_conn_id = ""
|
||||
async for connection in client.connections.list():
|
||||
async for connection in project_client.connections.list():
|
||||
if connection.type == ConnectionType.AZURE_AI_SEARCH:
|
||||
ai_search_conn_id = connection.id
|
||||
break
|
||||
|
||||
# 1. Create Azure AI agent with the search tool
|
||||
azure_ai_agent = await client.agents.create_agent(
|
||||
azure_ai_agent = await project_client.agents.create_agent(
|
||||
model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
|
||||
name="HotelSearchAgent",
|
||||
instructions=(
|
||||
@@ -69,7 +71,7 @@ async def main() -> None:
|
||||
)
|
||||
|
||||
# 2. Create chat client with the existing agent
|
||||
chat_client = AzureAIAgentClient(project_client=client, agent_id=azure_ai_agent.id)
|
||||
chat_client = AzureAIAgentClient(agents_client=agents_client, agent_id=azure_ai_agent.id)
|
||||
|
||||
try:
|
||||
async with ChatAgent(
|
||||
@@ -112,7 +114,7 @@ async def main() -> None:
|
||||
|
||||
finally:
|
||||
# Clean up the agent manually
|
||||
await client.agents.delete_agent(azure_ai_agent.id)
|
||||
await project_client.agents.delete_agent(azure_ai_agent.id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -12,8 +12,7 @@ uses Bing Grounding search to find real-time information from the web.
|
||||
|
||||
Prerequisites:
|
||||
1. A connected Grounding with Bing Search resource in your Azure AI project
|
||||
2. Set either BING_CONNECTION_NAME or BING_CONNECTION_ID environment variable
|
||||
Example: BING_CONNECTION_NAME="bing-grounding-connection"
|
||||
2. Set BING_CONNECTION_ID environment variable
|
||||
Example: BING_CONNECTION_ID="your-bing-connection-id"
|
||||
|
||||
To set up Bing Grounding:
|
||||
@@ -27,7 +26,7 @@ To set up Bing Grounding:
|
||||
async def main() -> None:
|
||||
"""Main function demonstrating Azure AI agent with Bing Grounding search."""
|
||||
# 1. Create Bing Grounding search tool using HostedWebSearchTool
|
||||
# The connection_name or ID will be automatically picked up from environment variable
|
||||
# The connection ID will be automatically picked up from environment variable
|
||||
bing_search_tool = HostedWebSearchTool(
|
||||
name="Bing Grounding Search",
|
||||
description="Search the web for current information using Bing",
|
||||
|
||||
@@ -5,6 +5,7 @@ import os
|
||||
|
||||
from agent_framework import ChatAgent
|
||||
from agent_framework.azure import AzureAIAgentClient
|
||||
from azure.ai.agents.aio import AgentsClient
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.identity.aio import AzureCliCredential
|
||||
|
||||
@@ -22,16 +23,17 @@ async def main() -> None:
|
||||
# Create the client
|
||||
async with (
|
||||
AzureCliCredential() as credential,
|
||||
AIProjectClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as client,
|
||||
AIProjectClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as project_client,
|
||||
AgentsClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as agents_client,
|
||||
):
|
||||
azure_ai_agent = await client.agents.create_agent(
|
||||
azure_ai_agent = await project_client.agents.create_agent(
|
||||
model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
|
||||
# Create remote agent with default instructions
|
||||
# These instructions will persist on created agent for every run.
|
||||
instructions="End each response with [END].",
|
||||
)
|
||||
|
||||
chat_client = AzureAIAgentClient(project_client=client, agent_id=azure_ai_agent.id)
|
||||
chat_client = AzureAIAgentClient(agents_client=agents_client, agent_id=azure_ai_agent.id)
|
||||
|
||||
try:
|
||||
async with ChatAgent(
|
||||
@@ -50,7 +52,7 @@ async def main() -> None:
|
||||
print(f"Agent: {result}\n")
|
||||
finally:
|
||||
# Clean up the agent manually
|
||||
await client.agents.delete_agent(azure_ai_agent.id)
|
||||
await project_client.agents.delete_agent(azure_ai_agent.id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Annotated
|
||||
|
||||
from agent_framework import ChatAgent
|
||||
from agent_framework.azure import AzureAIAgentClient
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.ai.agents.aio import AgentsClient
|
||||
from azure.identity.aio import AzureCliCredential
|
||||
from pydantic import Field
|
||||
|
||||
@@ -33,16 +33,16 @@ async def main() -> None:
|
||||
# Create the client
|
||||
async with (
|
||||
AzureCliCredential() as credential,
|
||||
AIProjectClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as client,
|
||||
AgentsClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as agents_client,
|
||||
):
|
||||
# Create an thread that will persist
|
||||
created_thread = await client.agents.threads.create()
|
||||
created_thread = await agents_client.threads.create()
|
||||
|
||||
try:
|
||||
async with ChatAgent(
|
||||
# passing in the client is optional here, so if you take the agent_id from the portal
|
||||
# you can use it directly without the two lines above.
|
||||
chat_client=AzureAIAgentClient(project_client=client),
|
||||
chat_client=AzureAIAgentClient(agents_client=agents_client),
|
||||
instructions="You are a helpful weather agent.",
|
||||
tools=get_weather,
|
||||
) as agent:
|
||||
@@ -52,7 +52,7 @@ async def main() -> None:
|
||||
print(f"Result: {result}\n")
|
||||
finally:
|
||||
# Clean up the thread manually
|
||||
await client.agents.threads.delete(created_thread.id)
|
||||
await agents_client.threads.delete(created_thread.id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -41,6 +41,7 @@ async def main() -> None:
|
||||
model_deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
|
||||
async_credential=credential,
|
||||
agent_name="WeatherAgent",
|
||||
should_cleanup_agent=True, # Set to False if you want to disable automatic agent cleanup
|
||||
),
|
||||
instructions="You are a helpful weather agent.",
|
||||
tools=get_weather,
|
||||
|
||||
@@ -44,8 +44,6 @@ async def main() -> None:
|
||||
AzureCliCredential() as credential,
|
||||
AzureAIAgentClient(async_credential=credential) as chat_client,
|
||||
):
|
||||
# enable azure-ai observability
|
||||
await chat_client.setup_azure_ai_observability()
|
||||
agent = chat_client.create_agent(
|
||||
name="DocsAgent",
|
||||
instructions="You are a helpful assistant that can help with microsoft documentation questions.",
|
||||
|
||||
@@ -69,8 +69,6 @@ async def main() -> None:
|
||||
AzureCliCredential() as credential,
|
||||
AzureAIAgentClient(async_credential=credential) as chat_client,
|
||||
):
|
||||
# enable azure-ai observability
|
||||
await chat_client.setup_azure_ai_observability()
|
||||
agent = chat_client.create_agent(
|
||||
name="DocsAgent",
|
||||
instructions="You are a helpful assistant that can help with microsoft documentation questions.",
|
||||
|
||||
@@ -23,6 +23,7 @@ This folder contains examples demonstrating different ways to create and use age
|
||||
| [`openai_responses_client_image_analysis.py`](openai_responses_client_image_analysis.py) | Demonstrates how to use vision capabilities with agents to analyze images. |
|
||||
| [`openai_responses_client_image_generation.py`](openai_responses_client_image_generation.py) | Demonstrates how to use image generation capabilities with OpenAI agents to create images based on text descriptions. Requires PIL (Pillow) for image display. |
|
||||
| [`openai_responses_client_reasoning.py`](openai_responses_client_reasoning.py) | Demonstrates how to use reasoning capabilities with OpenAI agents, showing how the agent can provide detailed reasoning for its responses. |
|
||||
| [`openai_responses_client_streaming_image_generation.py`](openai_responses_client_streaming_image_generation.py) | Demonstrates streaming image generation with partial images for real-time image creation feedback and improved user experience. |
|
||||
| [`openai_responses_client_with_code_interpreter.py`](openai_responses_client_with_code_interpreter.py) | Shows how to use the HostedCodeInterpreterTool with OpenAI agents to write and execute Python code. Includes helper methods for accessing code interpreter data from response chunks. |
|
||||
| [`openai_responses_client_with_explicit_settings.py`](openai_responses_client_with_explicit_settings.py) | Shows how to initialize an agent with a specific responses client, configuring settings explicitly including API key and model ID. |
|
||||
| [`openai_responses_client_with_file_search.py`](openai_responses_client_with_file_search.py) | Demonstrates how to use file search capabilities with OpenAI agents, allowing the agent to search through uploaded files to answer questions. |
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
|
||||
import anyio
|
||||
from agent_framework import DataContent
|
||||
from agent_framework.openai import OpenAIResponsesClient
|
||||
|
||||
"""OpenAI Responses Client Streaming Image Generation Example
|
||||
|
||||
Demonstrates streaming partial image generation using OpenAI's image generation tool.
|
||||
Shows progressive image rendering with partial images for improved user experience.
|
||||
|
||||
Note: The number of partial images received depends on generation speed:
|
||||
- High quality/complex images: More partials (generation takes longer)
|
||||
- Low quality/simple images: Fewer partials (generation completes quickly)
|
||||
- You may receive fewer partial images than requested if generation is fast
|
||||
|
||||
Important: The final partial image IS the complete, full-quality image. Each partial
|
||||
represents a progressive refinement, with the last one being the finished result.
|
||||
"""
|
||||
|
||||
|
||||
async def save_image_from_data_uri(data_uri: str, filename: str) -> None:
|
||||
"""Save an image from a data URI to a file."""
|
||||
try:
|
||||
if data_uri.startswith("data:image/"):
|
||||
# Extract base64 data
|
||||
base64_data = data_uri.split(",", 1)[1]
|
||||
image_bytes = base64.b64decode(base64_data)
|
||||
|
||||
# Save to file
|
||||
await anyio.Path(filename).write_bytes(image_bytes)
|
||||
print(f" Saved: {filename} ({len(image_bytes) / 1024:.1f} KB)")
|
||||
except Exception as e:
|
||||
print(f" Error saving {filename}: {e}")
|
||||
|
||||
|
||||
async def main():
|
||||
"""Demonstrate streaming image generation with partial images."""
|
||||
print("=== OpenAI Streaming Image Generation Example ===\n")
|
||||
|
||||
# Create agent with streaming image generation enabled
|
||||
agent = OpenAIResponsesClient().create_agent(
|
||||
instructions="You are a helpful agent that can generate images.",
|
||||
tools=[
|
||||
{
|
||||
"type": "image_generation",
|
||||
"size": "1024x1024",
|
||||
"quality": "high",
|
||||
"partial_images": 3,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
query = "Draw a beautiful sunset over a calm ocean with sailboats"
|
||||
print(f" User: {query}")
|
||||
print()
|
||||
|
||||
# Track partial images
|
||||
image_count = 0
|
||||
|
||||
# Create output directory
|
||||
output_dir = anyio.Path("generated_images")
|
||||
await output_dir.mkdir(exist_ok=True)
|
||||
|
||||
print(" Streaming response:")
|
||||
async for update in agent.run_stream(query):
|
||||
for content in update.contents:
|
||||
# Handle partial images
|
||||
# The final partial image IS the complete, full-quality image. Each partial
|
||||
# represents a progressive refinement, with the last one being the finished result.
|
||||
if isinstance(content, DataContent) and content.additional_properties.get("is_partial_image"):
|
||||
print(f" Image {image_count} received")
|
||||
|
||||
# Extract file extension from media_type (e.g., "image/png" -> "png")
|
||||
extension = "png" # Default fallback
|
||||
if content.media_type and "/" in content.media_type:
|
||||
extension = content.media_type.split("/")[-1]
|
||||
|
||||
# Save images with correct extension
|
||||
filename = output_dir / f"image{image_count}.{extension}"
|
||||
await save_image_from_data_uri(content.uri, str(filename))
|
||||
|
||||
image_count += 1
|
||||
|
||||
# Summary
|
||||
print("\n Summary:")
|
||||
print(f" Images received: {image_count}")
|
||||
print(" Output directory: generated_images")
|
||||
print("\n Streaming image generation completed!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,19 @@
|
||||
# Auto-generated Dockerfiles from DevUI deployment
|
||||
*/Dockerfile
|
||||
|
||||
# Python cache
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
|
||||
# Environment files (may contain secrets)
|
||||
.env
|
||||
*.env
|
||||
|
||||
# IDE files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
@@ -2,22 +2,22 @@
|
||||
|
||||
"""Spam Detection Workflow Sample for DevUI.
|
||||
|
||||
The following sample demonstrates a comprehensive 5-step workflow with multiple executors
|
||||
that process, analyze, detect spam, and handle email messages. This workflow illustrates
|
||||
complex branching logic and realistic processing delays to demonstrate the workflow framework.
|
||||
The following sample demonstrates a comprehensive 4-step workflow with multiple executors
|
||||
that process, detect spam, and handle email messages. This workflow illustrates
|
||||
complex branching logic with human-in-the-loop approval and realistic processing delays.
|
||||
|
||||
Workflow Steps:
|
||||
1. Email Preprocessor - Cleans and prepares the email
|
||||
2. Content Analyzer - Analyzes email content and structure
|
||||
3. Spam Detector - Determines if the message is spam
|
||||
4a. Spam Handler - Processes spam messages (quarantine, log, remove)
|
||||
4b. Message Responder - Handles legitimate messages (validate, respond)
|
||||
5. Final Processor - Completes the workflow with logging and cleanup
|
||||
2. Spam Detector - Analyzes content and determines if the message is spam (with human approval)
|
||||
3a. Spam Handler - Processes spam messages (quarantine, log, remove)
|
||||
3b. Message Responder - Handles legitimate messages (validate, respond)
|
||||
4. Final Processor - Completes the workflow with logging and cleanup
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, Annotated
|
||||
|
||||
from agent_framework import (
|
||||
Case,
|
||||
@@ -26,10 +26,18 @@ from agent_framework import (
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
handler,
|
||||
response_handler,
|
||||
)
|
||||
from pydantic import BaseModel, Field
|
||||
from typing_extensions import Never
|
||||
|
||||
# Define response model with clear user guidance
|
||||
class SpamDecision(BaseModel):
|
||||
"""User's decision on whether the email is spam."""
|
||||
decision: Literal["spam", "not spam"] = Field(
|
||||
description="Enter 'spam' to mark as spam, or 'not spam' to mark as legitimate"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmailContent:
|
||||
@@ -41,25 +49,17 @@ class EmailContent:
|
||||
has_suspicious_patterns: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContentAnalysis:
|
||||
"""A data class to hold content analysis results."""
|
||||
|
||||
email_content: EmailContent
|
||||
sentiment_score: float
|
||||
contains_links: bool
|
||||
has_attachments: bool
|
||||
risk_indicators: list[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpamDetectorResponse:
|
||||
"""A data class to hold the spam detection results."""
|
||||
|
||||
analysis: ContentAnalysis
|
||||
email_content: EmailContent
|
||||
is_spam: bool = False
|
||||
confidence_score: float = 0.0
|
||||
spam_reasons: list[str] | None = None
|
||||
human_reviewed: bool = False
|
||||
human_decision: str | None = None
|
||||
ai_original_classification: bool = False
|
||||
|
||||
def __post_init__(self):
|
||||
"""Initialize spam_reasons list if None."""
|
||||
@@ -67,6 +67,16 @@ class SpamDetectorResponse:
|
||||
self.spam_reasons = []
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpamApprovalRequest:
|
||||
"""Human-in-the-loop approval request for spam classification."""
|
||||
|
||||
email_message: str = ""
|
||||
detected_as_spam: bool = False
|
||||
confidence: float = 0.0
|
||||
reasons: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessingResult:
|
||||
"""A data class to hold the final processing result."""
|
||||
@@ -78,6 +88,9 @@ class ProcessingResult:
|
||||
is_spam: bool
|
||||
confidence_score: float
|
||||
spam_reasons: list[str]
|
||||
was_human_reviewed: bool = False
|
||||
human_override: str | None = None
|
||||
ai_original_decision: bool = False
|
||||
|
||||
|
||||
class EmailRequest(BaseModel):
|
||||
@@ -115,18 +128,27 @@ class EmailPreprocessor(Executor):
|
||||
await ctx.send_message(result)
|
||||
|
||||
|
||||
class ContentAnalyzer(Executor):
|
||||
"""Step 2: An executor that analyzes email content and structure."""
|
||||
|
||||
|
||||
class SpamDetector(Executor):
|
||||
"""Step 2: An executor that analyzes content and determines if a message is spam."""
|
||||
|
||||
def __init__(self, spam_keywords: list[str], id: str):
|
||||
"""Initialize the executor with spam keywords."""
|
||||
super().__init__(id=id)
|
||||
self._spam_keywords = spam_keywords
|
||||
|
||||
@handler
|
||||
async def handle_email_content(self, email_content: EmailContent, ctx: WorkflowContext[ContentAnalysis]) -> None:
|
||||
"""Analyze the email content for various indicators."""
|
||||
await asyncio.sleep(2.0) # Simulate analysis time
|
||||
async def handle_email_content(self, email_content: EmailContent, ctx: WorkflowContext[SpamApprovalRequest]) -> None:
|
||||
"""Analyze email content and determine if the message is spam, then request human approval."""
|
||||
await asyncio.sleep(2.0) # Simulate analysis and detection time
|
||||
|
||||
# Simulate content analysis
|
||||
email_text = email_content.cleaned_message
|
||||
|
||||
# Analyze content for risk indicators
|
||||
contains_links = "http" in email_text or "www" in email_text
|
||||
has_attachments = "attachment" in email_text
|
||||
sentiment_score = 0.5 if email_content.has_suspicious_patterns else 0.8
|
||||
contains_links = "http" in email_content.cleaned_message or "www" in email_content.cleaned_message
|
||||
has_attachments = "attachment" in email_content.cleaned_message
|
||||
|
||||
# Build risk indicators
|
||||
risk_indicators: list[str] = []
|
||||
@@ -139,32 +161,7 @@ class ContentAnalyzer(Executor):
|
||||
if email_content.word_count < 10:
|
||||
risk_indicators.append("too_short")
|
||||
|
||||
analysis = ContentAnalysis(
|
||||
email_content=email_content,
|
||||
sentiment_score=sentiment_score,
|
||||
contains_links=contains_links,
|
||||
has_attachments=has_attachments,
|
||||
risk_indicators=risk_indicators,
|
||||
)
|
||||
|
||||
await ctx.send_message(analysis)
|
||||
|
||||
|
||||
class SpamDetector(Executor):
|
||||
"""Step 3: An executor that determines if a message is spam based on analysis."""
|
||||
|
||||
def __init__(self, spam_keywords: list[str], id: str):
|
||||
"""Initialize the executor with spam keywords."""
|
||||
super().__init__(id=id)
|
||||
self._spam_keywords = spam_keywords
|
||||
|
||||
@handler
|
||||
async def handle_analysis(self, analysis: ContentAnalysis, ctx: WorkflowContext[SpamDetectorResponse]) -> None:
|
||||
"""Determine if the message is spam based on content analysis."""
|
||||
await asyncio.sleep(1.8) # Simulate detection time
|
||||
|
||||
# Check for spam keywords
|
||||
email_text = analysis.email_content.cleaned_message
|
||||
keyword_matches = [kw for kw in self._spam_keywords if kw in email_text]
|
||||
|
||||
# Calculate spam probability
|
||||
@@ -175,29 +172,100 @@ class SpamDetector(Executor):
|
||||
spam_score += 0.4
|
||||
spam_reasons.append(f"spam_keywords: {keyword_matches}")
|
||||
|
||||
if analysis.email_content.has_suspicious_patterns:
|
||||
if email_content.has_suspicious_patterns:
|
||||
spam_score += 0.3
|
||||
spam_reasons.append("suspicious_patterns")
|
||||
|
||||
if len(analysis.risk_indicators) >= 3:
|
||||
if len(risk_indicators) >= 3:
|
||||
spam_score += 0.2
|
||||
spam_reasons.append("high_risk_indicators")
|
||||
|
||||
if analysis.sentiment_score < 0.4:
|
||||
if sentiment_score < 0.4:
|
||||
spam_score += 0.1
|
||||
spam_reasons.append("negative_sentiment")
|
||||
|
||||
is_spam = spam_score >= 0.5
|
||||
|
||||
result = SpamDetectorResponse(
|
||||
analysis=analysis, is_spam=is_spam, confidence_score=spam_score, spam_reasons=spam_reasons
|
||||
# Store detection result in executor state for later use
|
||||
# Store minimal data needed (not complex objects that don't serialize well)
|
||||
await ctx.set_executor_state({
|
||||
"original_message": email_content.original_message,
|
||||
"cleaned_message": email_content.cleaned_message,
|
||||
"word_count": email_content.word_count,
|
||||
"has_suspicious_patterns": email_content.has_suspicious_patterns,
|
||||
"is_spam": is_spam,
|
||||
"ai_original_classification": is_spam, # Store original AI decision
|
||||
"confidence_score": spam_score,
|
||||
"spam_reasons": spam_reasons
|
||||
})
|
||||
|
||||
# Request human approval before proceeding using new API
|
||||
approval_request = SpamApprovalRequest(
|
||||
email_message=email_text[:200], # First 200 chars
|
||||
detected_as_spam=is_spam,
|
||||
confidence=spam_score,
|
||||
reasons=", ".join(spam_reasons) if spam_reasons else "no specific reasons"
|
||||
)
|
||||
|
||||
await ctx.request_info(
|
||||
request_data=approval_request,
|
||||
response_type=SpamDecision,
|
||||
)
|
||||
|
||||
@response_handler
|
||||
async def handle_human_response(
|
||||
self,
|
||||
original_request: SpamApprovalRequest,
|
||||
response: SpamDecision,
|
||||
ctx: WorkflowContext[SpamDetectorResponse]
|
||||
) -> None:
|
||||
"""Process human approval response and continue workflow."""
|
||||
print(f"[SpamDetector] handle_human_response called with response: {response}")
|
||||
|
||||
# Get stored detection result
|
||||
state = await ctx.get_executor_state() or {}
|
||||
print(f"[SpamDetector] Retrieved state: {state}")
|
||||
ai_original = state.get("ai_original_classification", False)
|
||||
confidence_score = state.get("confidence_score", 0.0)
|
||||
spam_reasons = state.get("spam_reasons", [])
|
||||
|
||||
# Parse human decision from the response model
|
||||
human_decision = response.decision.strip().lower()
|
||||
|
||||
# Determine final classification based on human input
|
||||
if human_decision in ["not spam"]:
|
||||
is_spam = False
|
||||
elif human_decision in ["spam"]:
|
||||
is_spam = True
|
||||
else:
|
||||
# Default to AI decision if unclear
|
||||
is_spam = ai_original
|
||||
|
||||
# Reconstruct EmailContent from stored primitives
|
||||
email_content = EmailContent(
|
||||
original_message=state.get("original_message", ""),
|
||||
cleaned_message=state.get("cleaned_message", ""),
|
||||
word_count=state.get("word_count", 0),
|
||||
has_suspicious_patterns=state.get("has_suspicious_patterns", False)
|
||||
)
|
||||
|
||||
result = SpamDetectorResponse(
|
||||
email_content=email_content,
|
||||
is_spam=is_spam,
|
||||
confidence_score=confidence_score,
|
||||
spam_reasons=spam_reasons,
|
||||
human_reviewed=True,
|
||||
human_decision=response.decision,
|
||||
ai_original_classification=ai_original
|
||||
)
|
||||
|
||||
print(f"[SpamDetector] Sending SpamDetectorResponse: is_spam={is_spam}, confidence={confidence_score}, human_reviewed=True")
|
||||
await ctx.send_message(result)
|
||||
print(f"[SpamDetector] Message sent successfully")
|
||||
|
||||
|
||||
class SpamHandler(Executor):
|
||||
"""Step 4a: An executor that handles spam messages with quarantine and logging."""
|
||||
"""Step 3a: An executor that handles spam messages with quarantine and logging."""
|
||||
|
||||
@handler
|
||||
async def handle_spam_detection(
|
||||
@@ -212,20 +280,23 @@ class SpamHandler(Executor):
|
||||
await asyncio.sleep(2.2) # Simulate spam handling time
|
||||
|
||||
result = ProcessingResult(
|
||||
original_message=spam_result.analysis.email_content.original_message,
|
||||
original_message=spam_result.email_content.original_message,
|
||||
action_taken="quarantined_and_logged",
|
||||
processing_time=2.2,
|
||||
status="spam_handled",
|
||||
is_spam=spam_result.is_spam,
|
||||
confidence_score=spam_result.confidence_score,
|
||||
spam_reasons=spam_result.spam_reasons or [],
|
||||
was_human_reviewed=spam_result.human_reviewed,
|
||||
human_override=spam_result.human_decision,
|
||||
ai_original_decision=spam_result.ai_original_classification,
|
||||
)
|
||||
|
||||
await ctx.send_message(result)
|
||||
|
||||
|
||||
class MessageResponder(Executor):
|
||||
"""Step 4b: An executor that responds to legitimate messages."""
|
||||
class LegitimateMessageHandler(Executor):
|
||||
"""Step 3b: An executor that handles legitimate (non-spam) messages."""
|
||||
|
||||
@handler
|
||||
async def handle_spam_detection(
|
||||
@@ -240,20 +311,23 @@ class MessageResponder(Executor):
|
||||
await asyncio.sleep(2.5) # Simulate response time
|
||||
|
||||
result = ProcessingResult(
|
||||
original_message=spam_result.analysis.email_content.original_message,
|
||||
action_taken="responded_and_filed",
|
||||
original_message=spam_result.email_content.original_message,
|
||||
action_taken="delivered_to_inbox",
|
||||
processing_time=2.5,
|
||||
status="message_processed",
|
||||
is_spam=spam_result.is_spam,
|
||||
confidence_score=spam_result.confidence_score,
|
||||
spam_reasons=spam_result.spam_reasons or [],
|
||||
was_human_reviewed=spam_result.human_reviewed,
|
||||
human_override=spam_result.human_decision,
|
||||
ai_original_decision=spam_result.ai_original_classification,
|
||||
)
|
||||
|
||||
await ctx.send_message(result)
|
||||
|
||||
|
||||
class FinalProcessor(Executor):
|
||||
"""Step 5: An executor that completes the workflow with final logging and cleanup."""
|
||||
"""Step 4: An executor that completes the workflow with final logging and cleanup."""
|
||||
|
||||
@handler
|
||||
async def handle_processing_result(
|
||||
@@ -266,50 +340,98 @@ class FinalProcessor(Executor):
|
||||
|
||||
total_time = result.processing_time + 1.5
|
||||
|
||||
# Include classification details in completion message
|
||||
# Build classification status with human review info
|
||||
classification = "SPAM" if result.is_spam else "LEGITIMATE"
|
||||
reasons = ", ".join(result.spam_reasons) if result.spam_reasons else "none"
|
||||
|
||||
completion_message = (
|
||||
f"Email classified as {classification} (confidence: {result.confidence_score:.2f}). "
|
||||
f"Reasons: {reasons}. "
|
||||
f"Action: {result.action_taken}, "
|
||||
f"Status: {result.status}, "
|
||||
f"Total time: {total_time:.1f}s"
|
||||
)
|
||||
# Add human review context
|
||||
review_status = ""
|
||||
if result.was_human_reviewed:
|
||||
if result.ai_original_decision != result.is_spam:
|
||||
review_status = " (human-overridden)"
|
||||
else:
|
||||
review_status = " (human-verified)"
|
||||
|
||||
# Build appropriate message based on classification
|
||||
if result.is_spam:
|
||||
# For spam messages
|
||||
spam_indicators = ", ".join(result.spam_reasons) if result.spam_reasons else "none detected"
|
||||
|
||||
if result.was_human_reviewed:
|
||||
ai_status = "SPAM" if result.ai_original_decision else "LEGITIMATE"
|
||||
human_decision = result.human_override if result.human_override else "unknown"
|
||||
|
||||
completion_message = (
|
||||
f"Email classified as {classification}{review_status}.\n"
|
||||
f"AI detected: {ai_status} (confidence: {result.confidence_score:.2f})\n"
|
||||
f"Human reviewer: {human_decision}\n"
|
||||
f"Spam indicators: {spam_indicators}\n"
|
||||
f"Action: Message quarantined for review\n"
|
||||
f"Processing time: {total_time:.1f}s"
|
||||
)
|
||||
else:
|
||||
completion_message = (
|
||||
f"Email classified as {classification} (confidence: {result.confidence_score:.2f}).\n"
|
||||
f"Spam indicators: {spam_indicators}\n"
|
||||
f"Action: Message quarantined for review\n"
|
||||
f"Processing time: {total_time:.1f}s"
|
||||
)
|
||||
else:
|
||||
# For legitimate messages
|
||||
if result.was_human_reviewed:
|
||||
ai_status = "SPAM" if result.ai_original_decision else "LEGITIMATE"
|
||||
human_decision = result.human_override if result.human_override else "unknown"
|
||||
|
||||
completion_message = (
|
||||
f"Email classified as {classification}{review_status}.\n"
|
||||
f"AI detected: {ai_status} (confidence: {result.confidence_score:.2f})\n"
|
||||
f"Human reviewer: {human_decision}\n"
|
||||
f"Action: Delivered to inbox\n"
|
||||
f"Processing time: {total_time:.1f}s"
|
||||
)
|
||||
else:
|
||||
completion_message = (
|
||||
f"Email classified as {classification} (confidence: {result.confidence_score:.2f}).\n"
|
||||
f"Action: Delivered to inbox\n"
|
||||
f"Processing time: {total_time:.1f}s"
|
||||
)
|
||||
|
||||
await ctx.yield_output(completion_message)
|
||||
|
||||
|
||||
# DevUI will provide checkpoint storage automatically via the new workflow API
|
||||
# No need to create checkpoint storage here anymore!
|
||||
|
||||
# Create the workflow instance that DevUI can discover
|
||||
spam_keywords = ["spam", "advertisement", "offer", "click here", "winner", "congratulations", "urgent"]
|
||||
|
||||
# Create all the executors for the 5-step workflow
|
||||
# Create all the executors for the 4-step workflow
|
||||
email_preprocessor = EmailPreprocessor(id="email_preprocessor")
|
||||
content_analyzer = ContentAnalyzer(id="content_analyzer")
|
||||
spam_detector = SpamDetector(spam_keywords, id="spam_detector")
|
||||
spam_handler = SpamHandler(id="spam_handler")
|
||||
message_responder = MessageResponder(id="message_responder")
|
||||
legitimate_message_handler = LegitimateMessageHandler(id="legitimate_message_handler")
|
||||
final_processor = FinalProcessor(id="final_processor")
|
||||
|
||||
# Build the comprehensive 5-step workflow with branching logic
|
||||
# Build the comprehensive 4-step workflow with branching logic and HIL support
|
||||
# Note: No .with_checkpointing() call - DevUI will pass checkpoint_storage at runtime
|
||||
workflow = (
|
||||
WorkflowBuilder(
|
||||
name="Email Spam Detector",
|
||||
description="5-step email classification workflow with spam/legitimate routing",
|
||||
description="4-step email classification workflow with human-in-the-loop spam approval",
|
||||
)
|
||||
.set_start_executor(email_preprocessor)
|
||||
.add_edge(email_preprocessor, content_analyzer)
|
||||
.add_edge(content_analyzer, spam_detector)
|
||||
.add_edge(email_preprocessor, spam_detector)
|
||||
# HIL handled within spam_detector via @response_handler
|
||||
# Continue with branching logic after human approval
|
||||
# Only route SpamDetectorResponse messages (not SpamApprovalRequest)
|
||||
.add_switch_case_edge_group(
|
||||
spam_detector,
|
||||
[
|
||||
Case(condition=lambda x: x.is_spam, target=spam_handler),
|
||||
Default(target=message_responder),
|
||||
Case(condition=lambda x: isinstance(x, SpamDetectorResponse) and x.is_spam, target=spam_handler),
|
||||
Default(target=legitimate_message_handler), # Default handles non-spam and non-SpamDetectorResponse messages
|
||||
],
|
||||
)
|
||||
.add_edge(spam_handler, final_processor)
|
||||
.add_edge(message_responder, final_processor)
|
||||
.add_edge(legitimate_message_handler, final_processor)
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
"""Sample weather agent for Agent Framework Debug UI."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Annotated
|
||||
@@ -14,8 +15,20 @@ from agent_framework import (
|
||||
Role,
|
||||
chat_middleware,
|
||||
function_middleware,
|
||||
ai_function
|
||||
)
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from agent_framework_devui import register_cleanup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def cleanup_resources():
|
||||
"""Cleanup function that runs when DevUI shuts down."""
|
||||
logger.info("=" * 60)
|
||||
logger.info(" Cleaning up resources...")
|
||||
logger.info(" (In production, this would close credentials, sessions, etc.)")
|
||||
logger.info("=" * 60)
|
||||
|
||||
|
||||
@chat_middleware
|
||||
@@ -93,6 +106,14 @@ def get_forecast(
|
||||
|
||||
return f"Weather forecast for {location}:\n" + "\n".join(forecast)
|
||||
|
||||
@ai_function(approval_mode="always_require")
|
||||
def send_email(
|
||||
recipient: Annotated[str, "The email address of the recipient."],
|
||||
subject: Annotated[str, "The subject of the email."],
|
||||
body: Annotated[str, "The body content of the email."],
|
||||
) -> str:
|
||||
"""Simulate sending an email."""
|
||||
return f"Email sent to {recipient} with subject '{subject}'."
|
||||
|
||||
# Agent instance following Agent Framework conventions
|
||||
agent = ChatAgent(
|
||||
@@ -106,10 +127,13 @@ agent = ChatAgent(
|
||||
chat_client=AzureOpenAIChatClient(
|
||||
api_key=os.environ.get("AZURE_OPENAI_API_KEY", ""),
|
||||
),
|
||||
tools=[get_weather, get_forecast],
|
||||
tools=[get_weather, get_forecast, send_email],
|
||||
middleware=[security_filter_middleware, atlantis_location_filter_middleware],
|
||||
)
|
||||
|
||||
# Register cleanup hook - demonstrates resource cleanup on shutdown
|
||||
register_cleanup(agent, cleanup_resources)
|
||||
|
||||
|
||||
def main():
|
||||
"""Launch the Azure weather agent in DevUI."""
|
||||
|
||||
@@ -191,11 +191,11 @@ dependencies
|
||||
Besides the Application Insights native UI, you can also use Grafana to visualize the telemetry data in Application Insights. There are two tailored dashboards for you to get started quickly:
|
||||
|
||||
#### Agent Overview dashboard
|
||||
Grafana Dashboard Gallery link: <https://aka.ms/amg/dash/af-agent>
|
||||
Open dashboard in Azure portal: <https://aka.ms/amg/dash/af-agent>
|
||||

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

|
||||
|
||||
## Aspire Dashboard
|
||||
|
||||
@@ -9,7 +9,9 @@ import dotenv
|
||||
from agent_framework import ChatAgent
|
||||
from agent_framework.azure import AzureAIAgentClient
|
||||
from agent_framework.observability import get_tracer
|
||||
from azure.ai.agents.aio import AgentsClient
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.core.exceptions import ResourceNotFoundError
|
||||
from azure.identity.aio import AzureCliCredential
|
||||
from opentelemetry.trace import SpanKind
|
||||
from opentelemetry.trace.span import format_trace_id
|
||||
@@ -38,16 +40,36 @@ async def get_weather(
|
||||
return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C."
|
||||
|
||||
|
||||
async def setup_azure_ai_observability(
|
||||
project_client: AIProjectClient, enable_sensitive_data: bool | None = None
|
||||
) -> None:
|
||||
"""Use this method to setup tracing in your Azure AI Project.
|
||||
|
||||
This will take the connection string from the AIProjectClient.
|
||||
It will override any connection string that is set in the environment variables.
|
||||
It will disable any OTLP endpoint that might have been set.
|
||||
"""
|
||||
try:
|
||||
conn_string = await project_client.telemetry.get_application_insights_connection_string()
|
||||
except ResourceNotFoundError:
|
||||
print("No Application Insights connection string found for the Azure AI Project.")
|
||||
return
|
||||
from agent_framework.observability import setup_observability
|
||||
|
||||
setup_observability(applicationinsights_connection_string=conn_string, enable_sensitive_data=enable_sensitive_data)
|
||||
|
||||
|
||||
async def main():
|
||||
async with (
|
||||
AzureCliCredential() as credential,
|
||||
AIProjectClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as project,
|
||||
AzureAIAgentClient(project_client=project) as client,
|
||||
AIProjectClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as project_client,
|
||||
AgentsClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as agents_client,
|
||||
AzureAIAgentClient(agents_client=agents_client) as client,
|
||||
):
|
||||
# This will enable tracing and configure the application to send telemetry data to the
|
||||
# Application Insights instance attached to the Azure AI project.
|
||||
# This will override any existing configuration.
|
||||
await client.setup_azure_ai_observability()
|
||||
await setup_azure_ai_observability(project_client)
|
||||
|
||||
questions = ["What's the weather in Amsterdam?", "and in Paris, and which is better?", "Why is the sky blue?"]
|
||||
|
||||
|
||||
+25
-3
@@ -9,7 +9,9 @@ import dotenv
|
||||
from agent_framework import HostedCodeInterpreterTool
|
||||
from agent_framework.azure import AzureAIAgentClient
|
||||
from agent_framework.observability import get_tracer
|
||||
from azure.ai.agents.aio import AgentsClient
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.core.exceptions import ResourceNotFoundError
|
||||
from azure.identity.aio import AzureCliCredential
|
||||
from opentelemetry.trace import SpanKind
|
||||
from opentelemetry.trace.span import format_trace_id
|
||||
@@ -42,6 +44,25 @@ async def get_weather(
|
||||
return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C."
|
||||
|
||||
|
||||
async def setup_azure_ai_observability(
|
||||
project_client: AIProjectClient, enable_sensitive_data: bool | None = None
|
||||
) -> None:
|
||||
"""Use this method to setup tracing in your Azure AI Project.
|
||||
|
||||
This will take the connection string from the AIProjectClient instance.
|
||||
It will override any connection string that is set in the environment variables.
|
||||
It will disable any OTLP endpoint that might have been set.
|
||||
"""
|
||||
try:
|
||||
conn_string = await project_client.telemetry.get_application_insights_connection_string()
|
||||
except ResourceNotFoundError:
|
||||
print("No Application Insights connection string found for the Azure AI Project.")
|
||||
return
|
||||
from agent_framework.observability import setup_observability
|
||||
|
||||
setup_observability(applicationinsights_connection_string=conn_string, enable_sensitive_data=enable_sensitive_data)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Run an AI service.
|
||||
|
||||
@@ -62,13 +83,14 @@ async def main() -> None:
|
||||
]
|
||||
async with (
|
||||
AzureCliCredential() as credential,
|
||||
AIProjectClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as project,
|
||||
AzureAIAgentClient(project_client=project) as client,
|
||||
AIProjectClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as project_client,
|
||||
AgentsClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as agents_client,
|
||||
AzureAIAgentClient(agents_client=agents_client) as client,
|
||||
):
|
||||
# This will enable tracing and configure the application to send telemetry data to the
|
||||
# Application Insights instance attached to the Azure AI project.
|
||||
# This will override any existing configuration.
|
||||
await client.setup_azure_ai_observability()
|
||||
await setup_azure_ai_observability(project_client)
|
||||
|
||||
with get_tracer().start_as_current_span(
|
||||
name="Foundry Telemetry from Agent Framework", kind=SpanKind.CLIENT
|
||||
|
||||
@@ -2,9 +2,14 @@
|
||||
|
||||
This getting-started sample shows how to attach Microsoft Purview policy evaluation to an Agent Framework `ChatAgent` using the **middleware** approach.
|
||||
|
||||
**What this sample demonstrates:**
|
||||
1. Configure an Azure OpenAI chat client
|
||||
2. Add Purview policy enforcement middleware (`PurviewPolicyMiddleware`)
|
||||
3. Run a short conversation and observe prompt / response blocking behavior
|
||||
3. Add Purview policy enforcement at the chat client level (`PurviewChatPolicyMiddleware`)
|
||||
4. Implement a custom cache provider for advanced caching scenarios
|
||||
5. Run conversations and observe prompt / response blocking behavior
|
||||
|
||||
**Note:** Caching is **automatic** and enabled by default with sensible defaults (30-minute TTL, 200MB max size).
|
||||
|
||||
---
|
||||
## 1. Setup
|
||||
@@ -20,8 +25,6 @@ This getting-started sample shows how to attach Microsoft Purview policy evaluat
|
||||
| `PURVIEW_CERT_PATH` | Yes (when cert auth on) | Path to your .pfx certificate |
|
||||
| `PURVIEW_CERT_PASSWORD` | Optional | Password for encrypted certs |
|
||||
|
||||
*A demo default exists in code for illustration only—always set your own value.
|
||||
|
||||
### 2. Auth Modes Supported
|
||||
|
||||
#### A. Interactive Browser Authentication (default)
|
||||
@@ -42,7 +45,7 @@ $env:PURVIEW_CERT_PATH = "C:\path\to\cert.pfx"
|
||||
$env:PURVIEW_CERT_PASSWORD = "optional-password"
|
||||
```
|
||||
|
||||
Certificate steps (summary): create / register app, generate certificate, upload public key, export .pfx with private key, grant required Graph / Purview permissions.
|
||||
Certificate steps (summary): create / register entra app, generate certificate, upload public key, export .pfx with private key, grant required Graph / Purview permissions.
|
||||
|
||||
---
|
||||
|
||||
@@ -61,18 +64,39 @@ If interactive auth is used, a browser window will appear the first time.
|
||||
|
||||
## 4. How It Works
|
||||
|
||||
The sample demonstrates three different scenarios:
|
||||
|
||||
### A. Agent Middleware (`run_with_agent_middleware`)
|
||||
1. Builds an Azure OpenAI chat client (using the environment endpoint / deployment)
|
||||
2. Chooses credential mode (certificate vs interactive)
|
||||
3. Creates `PurviewPolicyMiddleware` with `PurviewSettings`
|
||||
4. Injects middleware into the agent at construction
|
||||
5. Sends two user messages sequentially
|
||||
6. Prints results (or policy block messages)
|
||||
7. Uses default caching automatically
|
||||
|
||||
### B. Chat Client Middleware (`run_with_chat_middleware`)
|
||||
1. Creates a chat client with `PurviewChatPolicyMiddleware` attached directly
|
||||
2. Policy evaluation happens at the chat client level rather than agent level
|
||||
3. Demonstrates an alternative integration point for Purview policies
|
||||
4. Uses default caching automatically
|
||||
|
||||
### C. Custom Cache Provider (`run_with_custom_cache_provider`)
|
||||
1. Implements the `CacheProvider` protocol with a custom class (`SimpleDictCacheProvider`)
|
||||
2. Shows how to add custom logging and metrics to cache operations
|
||||
3. The custom provider must implement three async methods:
|
||||
- `async def get(self, key: str) -> Any | None`
|
||||
- `async def set(self, key: str, value: Any, ttl_seconds: int | None = None) -> None`
|
||||
- `async def remove(self, key: str) -> None`
|
||||
|
||||
**Policy Behavior:**
|
||||
Prompt blocks set a system-level message: `Prompt blocked by policy` and terminate the run early. Response blocks rewrite the output to `Response blocked by policy`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Code Snippet (Middleware Injection)
|
||||
## 5. Code Snippets
|
||||
|
||||
### Agent Middleware Injection
|
||||
|
||||
```python
|
||||
agent = ChatAgent(
|
||||
@@ -80,9 +104,41 @@ agent = ChatAgent(
|
||||
instructions="You are good at telling jokes.",
|
||||
name="Joker",
|
||||
middleware=[
|
||||
PurviewPolicyMiddleware(credential, PurviewSettings(app_name="Sample App", default_user_id="<guid>"))
|
||||
PurviewPolicyMiddleware(credential, PurviewSettings(app_name="Sample App"))
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
### Custom Cache Provider Implementation
|
||||
|
||||
This is only needed if you want to integrate with external caching systems.
|
||||
|
||||
```python
|
||||
class SimpleDictCacheProvider:
|
||||
"""Custom cache provider that implements the CacheProvider protocol."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._cache: dict[str, Any] = {}
|
||||
|
||||
async def get(self, key: str) -> Any | None:
|
||||
"""Get a value from the cache."""
|
||||
return self._cache.get(key)
|
||||
|
||||
async def set(self, key: str, value: Any, ttl_seconds: int | None = None) -> None:
|
||||
"""Set a value in the cache."""
|
||||
self._cache[key] = value
|
||||
|
||||
async def remove(self, key: str) -> None:
|
||||
"""Remove a value from the cache."""
|
||||
self._cache.pop(key, None)
|
||||
|
||||
# Use the custom cache provider
|
||||
custom_cache = SimpleDictCacheProvider()
|
||||
middleware = PurviewPolicyMiddleware(
|
||||
credential,
|
||||
PurviewSettings(app_name="Sample App"),
|
||||
cache_provider=custom_cache,
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -5,7 +5,10 @@ Shows:
|
||||
1. Creating a basic chat agent
|
||||
2. Adding Purview policy evaluation via AGENT middleware (agent-level)
|
||||
3. Adding Purview policy evaluation via CHAT middleware (chat-client level)
|
||||
4. Running a threaded conversation and printing results
|
||||
4. Implementing a custom cache provider for advanced caching scenarios
|
||||
5. Running threaded conversations and printing results
|
||||
|
||||
Note: Caching is automatic and enabled by default.
|
||||
|
||||
Environment variables:
|
||||
- AZURE_OPENAI_ENDPOINT (required)
|
||||
@@ -31,7 +34,6 @@ from azure.identity import (
|
||||
InteractiveBrowserCredential,
|
||||
)
|
||||
|
||||
# Purview integration pieces
|
||||
from agent_framework.microsoft import (
|
||||
PurviewPolicyMiddleware,
|
||||
PurviewChatPolicyMiddleware,
|
||||
@@ -42,6 +44,59 @@ JOKER_NAME = "Joker"
|
||||
JOKER_INSTRUCTIONS = "You are good at telling jokes. Keep responses concise."
|
||||
|
||||
|
||||
# Custom Cache Provider Implementation
|
||||
class SimpleDictCacheProvider:
|
||||
"""A simple custom cache provider that stores everything in a dictionary.
|
||||
|
||||
This example demonstrates how to implement the CacheProvider protocol.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the simple dictionary cache."""
|
||||
self._cache: dict[str, Any] = {}
|
||||
self._access_count: dict[str, int] = {}
|
||||
|
||||
async def get(self, key: str) -> Any | None:
|
||||
"""Get a value from the cache.
|
||||
|
||||
Args:
|
||||
key: The cache key.
|
||||
|
||||
Returns:
|
||||
The cached value or None if not found.
|
||||
"""
|
||||
value = self._cache.get(key)
|
||||
if value is not None:
|
||||
self._access_count[key] = self._access_count.get(key, 0) + 1
|
||||
print(f"[CustomCache] Cache HIT for key: {key[:50]}... (accessed {self._access_count[key]} times)")
|
||||
else:
|
||||
print(f"[CustomCache] Cache MISS for key: {key[:50]}...")
|
||||
return value
|
||||
|
||||
async def set(self, key: str, value: Any, ttl_seconds: int | None = None) -> None:
|
||||
"""Set a value in the cache.
|
||||
|
||||
Args:
|
||||
key: The cache key.
|
||||
value: The value to cache.
|
||||
ttl_seconds: Time to live in seconds (ignored in this simple implementation).
|
||||
"""
|
||||
self._cache[key] = value
|
||||
print(f"[CustomCache] Cached value for key: {key[:50]}... (TTL: {ttl_seconds}s)")
|
||||
|
||||
async def remove(self, key: str) -> None:
|
||||
"""Remove a value from the cache.
|
||||
|
||||
Args:
|
||||
key: The cache key.
|
||||
"""
|
||||
if key in self._cache:
|
||||
del self._cache[key]
|
||||
self._access_count.pop(key, None)
|
||||
print(f"[CustomCache] Removed key: {key[:50]}...")
|
||||
|
||||
|
||||
|
||||
def _get_env(name: str, *, required: bool = True, default: str | None = None) -> str:
|
||||
val = os.environ.get(name, default)
|
||||
if required and not val:
|
||||
@@ -161,9 +216,91 @@ async def run_with_chat_middleware() -> None:
|
||||
)
|
||||
print("Second response (chat middleware):\n", second)
|
||||
|
||||
async def run_with_custom_cache_provider() -> None:
|
||||
"""Demonstrate implementing and using a custom cache provider."""
|
||||
endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT")
|
||||
if not endpoint:
|
||||
print("Skipping custom cache provider run: AZURE_OPENAI_ENDPOINT not set")
|
||||
return
|
||||
|
||||
deployment = os.environ.get("AZURE_OPENAI_DEPLOYMENT_NAME", "gpt-4o-mini")
|
||||
user_id = os.environ.get("PURVIEW_DEFAULT_USER_ID")
|
||||
chat_client = AzureOpenAIChatClient(deployment_name=deployment, endpoint=endpoint, credential=AzureCliCredential())
|
||||
|
||||
custom_cache = SimpleDictCacheProvider()
|
||||
|
||||
purview_agent_middleware = PurviewPolicyMiddleware(
|
||||
build_credential(),
|
||||
PurviewSettings(
|
||||
app_name="Agent Framework Sample App (Custom Provider)",
|
||||
),
|
||||
cache_provider=custom_cache,
|
||||
)
|
||||
|
||||
agent = ChatAgent(
|
||||
chat_client=chat_client,
|
||||
instructions=JOKER_INSTRUCTIONS,
|
||||
name=JOKER_NAME,
|
||||
middleware=purview_agent_middleware,
|
||||
)
|
||||
|
||||
print("-- Custom Cache Provider Path --")
|
||||
print("Using SimpleDictCacheProvider")
|
||||
|
||||
first: AgentRunResponse = await agent.run(
|
||||
ChatMessage(role=Role.USER, text="Tell me a joke about a programmer.", additional_properties={"user_id": user_id})
|
||||
)
|
||||
print("First response (custom provider):\n", first)
|
||||
|
||||
second: AgentRunResponse = await agent.run(
|
||||
ChatMessage(role=Role.USER, text="That's hilarious! One more?", additional_properties={"user_id": user_id})
|
||||
)
|
||||
print("Second response (custom provider):\n", second)
|
||||
|
||||
"""Demonstrate using the default built-in cache."""
|
||||
endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT")
|
||||
if not endpoint:
|
||||
print("Skipping default cache run: AZURE_OPENAI_ENDPOINT not set")
|
||||
return
|
||||
|
||||
deployment = os.environ.get("AZURE_OPENAI_DEPLOYMENT_NAME", "gpt-4o-mini")
|
||||
user_id = os.environ.get("PURVIEW_DEFAULT_USER_ID")
|
||||
chat_client = AzureOpenAIChatClient(deployment_name=deployment, endpoint=endpoint, credential=AzureCliCredential())
|
||||
|
||||
# No cache_provider specified - uses default InMemoryCacheProvider
|
||||
purview_agent_middleware = PurviewPolicyMiddleware(
|
||||
build_credential(),
|
||||
PurviewSettings(
|
||||
app_name="Agent Framework Sample App (Default Cache)",
|
||||
cache_ttl_seconds=3600,
|
||||
max_cache_size_bytes=100 * 1024 * 1024, # 100MB
|
||||
),
|
||||
)
|
||||
|
||||
agent = ChatAgent(
|
||||
chat_client=chat_client,
|
||||
instructions=JOKER_INSTRUCTIONS,
|
||||
name=JOKER_NAME,
|
||||
middleware=purview_agent_middleware,
|
||||
)
|
||||
|
||||
print("-- Default Cache Path --")
|
||||
print("Using default InMemoryCacheProvider with settings-based configuration")
|
||||
|
||||
first: AgentRunResponse = await agent.run(
|
||||
ChatMessage(role=Role.USER, text="Tell me a joke about AI.", additional_properties={"user_id": user_id})
|
||||
)
|
||||
print("First response (default cache):\n", first)
|
||||
|
||||
second: AgentRunResponse = await agent.run(
|
||||
ChatMessage(role=Role.USER, text="Nice! Another AI joke please.", additional_properties={"user_id": user_id})
|
||||
)
|
||||
print("Second response (default cache):\n", second)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
print("== Purview Agent Sample (Agent & Chat Middleware) ==")
|
||||
print("== Purview Agent Sample (Middleware with Automatic Caching) ==")
|
||||
|
||||
try:
|
||||
await run_with_agent_middleware()
|
||||
except Exception as ex: # pragma: no cover - demo resilience
|
||||
@@ -174,6 +311,11 @@ async def main() -> None:
|
||||
except Exception as ex: # pragma: no cover - demo resilience
|
||||
print(f"Chat middleware path failed: {ex}")
|
||||
|
||||
try:
|
||||
await run_with_custom_cache_provider()
|
||||
except Exception as ex: # pragma: no cover - demo resilience
|
||||
print(f"Custom cache provider path failed: {ex}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
@@ -7,5 +7,14 @@ This folder contains examples demonstrating different ways to manage conversatio
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| [`custom_chat_message_store_thread.py`](custom_chat_message_store_thread.py) | Demonstrates how to implement a custom `ChatMessageStore` for persisting conversation history. Shows how to create a custom store with serialization/deserialization capabilities and integrate it with agents for thread management across multiple sessions. |
|
||||
| [`suspend_resume_thread.py`](suspend_resume_thread.py) | Shows how to suspend and resume conversation threads, allowing you to save the state of a conversation and continue it later. This is useful for long-running conversations or when you need to persist conversation state across application restarts. |
|
||||
| [`redis_chat_message_store_thread.py`](redis_chat_message_store_thread.py) | Comprehensive examples of using the Redis-backed `RedisChatMessageStore` for persistent conversation storage. Covers basic usage, user session management, conversation persistence across app restarts, thread serialization, and automatic message trimming. Requires Redis server and demonstrates production-ready patterns for scalable chat applications. |
|
||||
| [`suspend_resume_thread.py`](suspend_resume_thread.py) | Shows how to suspend and resume conversation threads, comparing service-managed threads (Azure AI) with in-memory threads (OpenAI). Demonstrates saving conversation state and continuing it later, useful for long-running conversations or persisting state across application restarts. |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Make sure to set the following environment variables before running the examples:
|
||||
|
||||
- `OPENAI_API_KEY`: Your OpenAI API key (required for all samples)
|
||||
- `OPENAI_CHAT_MODEL_ID`: The OpenAI model to use (e.g., `gpt-4o`, `gpt-4o-mini`, `gpt-3.5-turbo`) (required for all samples)
|
||||
- `AZURE_AI_PROJECT_ENDPOINT`: Azure AI Project endpoint URL (required for service-managed thread examples)
|
||||
- `AZURE_AI_MODEL_DEPLOYMENT_NAME`: The name of your model deployment (required for service-managed thread examples)
|
||||
|
||||
@@ -8,6 +8,14 @@ from agent_framework import ChatMessage, ChatMessageStoreProtocol
|
||||
from agent_framework._threads import ChatMessageStoreState
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
|
||||
"""
|
||||
Custom Chat Message Store Thread Example
|
||||
|
||||
This sample demonstrates how to implement and use a custom chat message store
|
||||
for thread management, allowing you to persist conversation history in your
|
||||
preferred storage solution (database, file system, etc.).
|
||||
"""
|
||||
|
||||
|
||||
class CustomChatMessageStore(ChatMessageStoreProtocol):
|
||||
"""Implementation of custom chat message store.
|
||||
@@ -24,13 +32,22 @@ class CustomChatMessageStore(ChatMessageStoreProtocol):
|
||||
async def list_messages(self) -> list[ChatMessage]:
|
||||
return self._messages
|
||||
|
||||
async def deserialize_state(self, serialized_store_state: Any, **kwargs: Any) -> None:
|
||||
@classmethod
|
||||
async def deserialize(cls, serialized_store_state: Any, **kwargs: Any) -> "CustomChatMessageStore":
|
||||
"""Create a new instance from serialized state."""
|
||||
store = cls()
|
||||
await store.update_from_state(serialized_store_state, **kwargs)
|
||||
return store
|
||||
|
||||
async def update_from_state(self, serialized_store_state: Any, **kwargs: Any) -> None:
|
||||
"""Update this instance from serialized state."""
|
||||
if serialized_store_state:
|
||||
state = ChatMessageStoreState.from_dict(serialized_store_state, **kwargs)
|
||||
if state.messages:
|
||||
self._messages.extend(state.messages)
|
||||
|
||||
async def serialize_state(self, **kwargs: Any) -> Any:
|
||||
async def serialize(self, **kwargs: Any) -> Any:
|
||||
"""Serialize this store's state."""
|
||||
state = ChatMessageStoreState(messages=self._messages)
|
||||
return state.to_dict(**kwargs)
|
||||
|
||||
@@ -42,8 +59,8 @@ async def main() -> None:
|
||||
# OpenAI Chat Client is used as an example here,
|
||||
# other chat clients can be used as well.
|
||||
agent = OpenAIChatClient().create_agent(
|
||||
name="Joker",
|
||||
instructions="You are good at telling jokes.",
|
||||
name="CustomBot",
|
||||
instructions="You are a helpful assistant that remembers our conversation.",
|
||||
# Use custom chat message store.
|
||||
# If not provided, the default in-memory store will be used.
|
||||
chat_message_store_factory=CustomChatMessageStore,
|
||||
@@ -53,7 +70,7 @@ async def main() -> None:
|
||||
thread = agent.get_new_thread()
|
||||
|
||||
# Respond to user input.
|
||||
query = "Tell me a joke about a pirate."
|
||||
query = "Hello! My name is Alice and I love pizza."
|
||||
print(f"User: {query}")
|
||||
print(f"Agent: {await agent.run(query, thread=thread)}\n")
|
||||
|
||||
@@ -67,7 +84,7 @@ async def main() -> None:
|
||||
resumed_thread = await agent.deserialize_thread(serialized_thread)
|
||||
|
||||
# Respond to user input.
|
||||
query = "Now tell the same joke in the voice of a pirate, and add some emojis to the joke."
|
||||
query = "What do you remember about me?"
|
||||
print(f"User: {query}")
|
||||
print(f"Agent: {await agent.run(query, thread=resumed_thread)}\n")
|
||||
|
||||
|
||||
@@ -8,6 +8,14 @@ from agent_framework import AgentThread
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from agent_framework.redis import RedisChatMessageStore
|
||||
|
||||
"""
|
||||
Redis Chat Message Store Thread Example
|
||||
|
||||
This sample demonstrates how to use Redis as a chat message store for thread
|
||||
management, enabling persistent conversation history storage across sessions
|
||||
with Redis as the backend data store.
|
||||
"""
|
||||
|
||||
|
||||
async def example_manual_memory_store() -> None:
|
||||
"""Basic example of using Redis chat message store."""
|
||||
|
||||
@@ -2,38 +2,51 @@
|
||||
|
||||
import asyncio
|
||||
|
||||
from agent_framework.azure import AzureAIAgentClient
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from azure.identity.aio import AzureCliCredential
|
||||
|
||||
"""
|
||||
Thread Suspend and Resume Example
|
||||
|
||||
This sample demonstrates how to suspend and resume conversation threads, comparing
|
||||
service-managed threads (Azure AI) with in-memory threads (OpenAI) for persistent
|
||||
conversation state across sessions.
|
||||
"""
|
||||
|
||||
|
||||
async def suspend_resume_service_managed_thread() -> None:
|
||||
"""Demonstrates how to suspend and resume a service-managed thread."""
|
||||
print("=== Suspend-Resume Service-Managed Thread ===")
|
||||
|
||||
# OpenAI Chat Client is used as an example here,
|
||||
# other chat clients can be used as well.
|
||||
agent = OpenAIChatClient().create_agent(name="Joker", instructions="You are good at telling jokes.")
|
||||
# AzureAIAgentClient supports service-managed threads.
|
||||
async with (
|
||||
AzureCliCredential() as credential,
|
||||
AzureAIAgentClient(async_credential=credential).create_agent(
|
||||
name="MemoryBot", instructions="You are a helpful assistant that remembers our conversation."
|
||||
) as agent,
|
||||
):
|
||||
# Start a new thread for the agent conversation.
|
||||
thread = agent.get_new_thread()
|
||||
|
||||
# Start a new thread for the agent conversation.
|
||||
thread = agent.get_new_thread()
|
||||
# Respond to user input.
|
||||
query = "Hello! My name is Alice and I love pizza."
|
||||
print(f"User: {query}")
|
||||
print(f"Agent: {await agent.run(query, thread=thread)}\n")
|
||||
|
||||
# Respond to user input.
|
||||
query = "Tell me a joke about a pirate."
|
||||
print(f"User: {query}")
|
||||
print(f"Agent: {await agent.run(query, thread=thread)}\n")
|
||||
# Serialize the thread state, so it can be stored for later use.
|
||||
serialized_thread = await thread.serialize()
|
||||
|
||||
# Serialize the thread state, so it can be stored for later use.
|
||||
serialized_thread = await thread.serialize()
|
||||
# The thread can now be saved to a database, file, or any other storage mechanism and loaded again later.
|
||||
print(f"Serialized thread: {serialized_thread}\n")
|
||||
|
||||
# The thread can now be saved to a database, file, or any other storage mechanism and loaded again later.
|
||||
print(f"Serialized thread: {serialized_thread}\n")
|
||||
# Deserialize the thread state after loading from storage.
|
||||
resumed_thread = await agent.deserialize_thread(serialized_thread)
|
||||
|
||||
# Deserialize the thread state after loading from storage.
|
||||
resumed_thread = await agent.deserialize_thread(serialized_thread)
|
||||
|
||||
# Respond to user input.
|
||||
query = "Now tell the same joke in the voice of a pirate, and add some emojis to the joke."
|
||||
print(f"User: {query}")
|
||||
print(f"Agent: {await agent.run(query, thread=resumed_thread)}\n")
|
||||
# Respond to user input.
|
||||
query = "What do you remember about me?"
|
||||
print(f"User: {query}")
|
||||
print(f"Agent: {await agent.run(query, thread=resumed_thread)}\n")
|
||||
|
||||
|
||||
async def suspend_resume_in_memory_thread() -> None:
|
||||
@@ -42,13 +55,15 @@ async def suspend_resume_in_memory_thread() -> None:
|
||||
|
||||
# OpenAI Chat Client is used as an example here,
|
||||
# other chat clients can be used as well.
|
||||
agent = OpenAIChatClient().create_agent(name="Joker", instructions="You are good at telling jokes.")
|
||||
agent = OpenAIChatClient().create_agent(
|
||||
name="MemoryBot", instructions="You are a helpful assistant that remembers our conversation."
|
||||
)
|
||||
|
||||
# Start a new thread for the agent conversation.
|
||||
thread = agent.get_new_thread()
|
||||
|
||||
# Respond to user input.
|
||||
query = "Tell me a joke about a pirate."
|
||||
query = "Hello! My name is Alice and I love pizza."
|
||||
print(f"User: {query}")
|
||||
print(f"Agent: {await agent.run(query, thread=thread)}\n")
|
||||
|
||||
@@ -62,7 +77,7 @@ async def suspend_resume_in_memory_thread() -> None:
|
||||
resumed_thread = await agent.deserialize_thread(serialized_thread)
|
||||
|
||||
# Respond to user input.
|
||||
query = "Now tell the same joke in the voice of a pirate, and add some emojis to the joke."
|
||||
query = "What do you remember about me?"
|
||||
print(f"User: {query}")
|
||||
print(f"Agent: {await agent.run(query, thread=resumed_thread)}\n")
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# Tools Examples
|
||||
|
||||
This folder contains examples demonstrating how to use AI functions (tools) with the Agent Framework. AI functions allow agents to interact with external systems, perform computations, and execute custom logic.
|
||||
|
||||
## Examples
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| [`ai_function_declaration_only.py`](ai_function_declaration_only.py) | Demonstrates how to create function declarations without implementations. Useful for testing agent reasoning about tool usage or when tools are defined elsewhere. Shows how agents request tool calls even when the tool won't be executed. |
|
||||
| [`ai_function_from_dict_with_dependency_injection.py`](ai_function_from_dict_with_dependency_injection.py) | Shows how to create AI functions from dictionary definitions using dependency injection. The function implementation is injected at runtime during deserialization, enabling dynamic tool creation and configuration. Note: This serialization/deserialization feature is in active development. |
|
||||
| [`ai_function_recover_from_failures.py`](ai_function_recover_from_failures.py) | Demonstrates graceful error handling when tools raise exceptions. Shows how agents receive error information and can recover from failures, deciding whether to retry or respond differently based on the exception. |
|
||||
| [`ai_function_with_approval.py`](ai_function_with_approval.py) | Shows how to implement user approval workflows for function calls without using threads. Demonstrates both streaming and non-streaming approval patterns where users can approve or reject function executions before they run. |
|
||||
| [`ai_function_with_approval_and_threads.py`](ai_function_with_approval_and_threads.py) | Demonstrates tool approval workflows using threads for automatic conversation history management. Shows how threads simplify approval workflows by automatically storing and retrieving conversation context. Includes both approval and rejection examples. |
|
||||
| [`ai_function_with_max_exceptions.py`](ai_function_with_max_exceptions.py) | Shows how to limit the number of times a tool can fail with exceptions using `max_invocation_exceptions`. Useful for preventing expensive tools from being called repeatedly when they keep failing. |
|
||||
| [`ai_function_with_max_invocations.py`](ai_function_with_max_invocations.py) | Demonstrates limiting the total number of times a tool can be invoked using `max_invocations`. Useful for rate-limiting expensive operations or ensuring tools are only called a specific number of times per conversation. |
|
||||
| [`ai_functions_in_class.py`](ai_functions_in_class.py) | Shows how to use `ai_function` decorator with class methods to create stateful tools. Demonstrates how class state can control tool behavior dynamically, allowing you to adjust tool functionality at runtime by modifying class properties. |
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### AI Function Features
|
||||
|
||||
- **Function Declarations**: Define tool schemas without implementations for testing or external tools
|
||||
- **Dependency Injection**: Create tools from configurations with runtime-injected implementations
|
||||
- **Error Handling**: Gracefully handle and recover from tool execution failures
|
||||
- **Approval Workflows**: Require user approval before executing sensitive or important operations
|
||||
- **Invocation Limits**: Control how many times tools can be called or fail
|
||||
- **Stateful Tools**: Use class methods as tools to maintain state and dynamically control behavior
|
||||
|
||||
### Common Patterns
|
||||
|
||||
#### Basic Tool Definition
|
||||
|
||||
```python
|
||||
from agent_framework import ai_function
|
||||
from typing import Annotated
|
||||
|
||||
@ai_function
|
||||
def my_tool(param: Annotated[str, "Description"]) -> str:
|
||||
"""Tool description for the AI."""
|
||||
return f"Result: {param}"
|
||||
```
|
||||
|
||||
#### Tool with Approval
|
||||
|
||||
```python
|
||||
@ai_function(approval_mode="always_require")
|
||||
def sensitive_operation(data: Annotated[str, "Data to process"]) -> str:
|
||||
"""This requires user approval before execution."""
|
||||
return f"Processed: {data}"
|
||||
```
|
||||
|
||||
#### Tool with Invocation Limits
|
||||
|
||||
```python
|
||||
@ai_function(max_invocations=3)
|
||||
def limited_tool() -> str:
|
||||
"""Can only be called 3 times total."""
|
||||
return "Result"
|
||||
|
||||
@ai_function(max_invocation_exceptions=2)
|
||||
def fragile_tool() -> str:
|
||||
"""Can only fail 2 times before being disabled."""
|
||||
return "Result"
|
||||
```
|
||||
|
||||
#### Stateful Tools with Classes
|
||||
|
||||
```python
|
||||
class MyTools:
|
||||
def __init__(self, mode: str = "normal"):
|
||||
self.mode = mode
|
||||
|
||||
def process(self, data: Annotated[str, "Data to process"]) -> str:
|
||||
"""Process data based on current mode."""
|
||||
if self.mode == "safe":
|
||||
return f"Safely processed: {data}"
|
||||
return f"Processed: {data}"
|
||||
|
||||
# Create instance and use methods as tools
|
||||
tools = MyTools(mode="safe")
|
||||
agent = client.create_agent(tools=tools.process)
|
||||
|
||||
# Change behavior dynamically
|
||||
tools.mode = "normal"
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
When tools raise exceptions:
|
||||
1. The exception is captured and sent to the agent as a function result
|
||||
2. The agent receives the error message and can reason about what went wrong
|
||||
3. The agent can retry with different parameters, use alternative tools, or explain the issue to the user
|
||||
4. With invocation limits, tools can be disabled after repeated failures
|
||||
|
||||
### Approval Workflows
|
||||
|
||||
Two approaches for handling approvals:
|
||||
|
||||
1. **Without Threads**: Manually manage conversation context, including the query, approval request, and response in each iteration
|
||||
2. **With Threads**: Thread automatically manages conversation history, simplifying the approval workflow
|
||||
|
||||
## Usage Tips
|
||||
|
||||
- Use **declaration-only** functions when you want to test agent reasoning without execution
|
||||
- Use **dependency injection** for dynamic tool configuration and plugin architectures
|
||||
- Implement **approval workflows** for operations that modify data, spend money, or require human oversight
|
||||
- Set **invocation limits** to prevent runaway costs or infinite loops with expensive tools
|
||||
- Handle **exceptions gracefully** to create robust agents that can recover from failures
|
||||
- Use **class-based tools** when you need to maintain state or dynamically adjust tool behavior at runtime
|
||||
|
||||
## Running the Examples
|
||||
|
||||
Each example is a standalone Python script that can be run directly:
|
||||
|
||||
```bash
|
||||
uv run python ai_function_with_approval.py
|
||||
```
|
||||
|
||||
Make sure you have the necessary environment variables configured (like `OPENAI_API_KEY` or Azure credentials) before running the examples.
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from agent_framework import AIFunction
|
||||
from agent_framework.openai import OpenAIResponsesClient
|
||||
|
||||
"""
|
||||
Example of how to create a function that only consists of a declaration without an implementation.
|
||||
This is useful when you want the agent to use tools that are defined elsewhere or when you want
|
||||
to test the agent's ability to reason about tool usage without executing them.
|
||||
|
||||
The only difference is that you provide an AIFunction without a function.
|
||||
If you need a input_model, you can still provide that as well.
|
||||
"""
|
||||
|
||||
|
||||
async def main():
|
||||
function_declaration = AIFunction[None, None](
|
||||
name="get_current_time",
|
||||
description="Get the current time in ISO 8601 format.",
|
||||
)
|
||||
|
||||
agent = OpenAIResponsesClient().create_agent(
|
||||
name="DeclarationOnlyToolAgent",
|
||||
instructions="You are a helpful agent that uses tools.",
|
||||
tools=function_declaration,
|
||||
)
|
||||
query = "What is the current time?"
|
||||
print(f"User: {query}")
|
||||
result = await agent.run(query)
|
||||
print(f"Result: {result.to_json(indent=2)}\n")
|
||||
|
||||
|
||||
"""
|
||||
Expected result:
|
||||
User: What is the current time?
|
||||
Result: {
|
||||
"type": "agent_run_response",
|
||||
"messages": [
|
||||
{
|
||||
"type": "chat_message",
|
||||
"role": {
|
||||
"type": "role",
|
||||
"value": "assistant"
|
||||
},
|
||||
"contents": [
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": "call_0flN9rfGLK8LhORy4uMDiRSC",
|
||||
"name": "get_current_time",
|
||||
"arguments": "{}",
|
||||
"fc_id": "fc_0fd5f269955c589f016904c46584348195b84a8736e61248de"
|
||||
}
|
||||
],
|
||||
"author_name": "DeclarationOnlyToolAgent",
|
||||
"additional_properties": {}
|
||||
}
|
||||
],
|
||||
"response_id": "resp_0fd5f269955c589f016904c462d5cc819599d28384ba067edc",
|
||||
"created_at": "2025-10-31T15:14:58.000000Z",
|
||||
"usage_details": {
|
||||
"type": "usage_details",
|
||||
"input_token_count": 63,
|
||||
"output_token_count": 145,
|
||||
"total_token_count": 208,
|
||||
"openai.reasoning_tokens": 128
|
||||
},
|
||||
"additional_properties": {}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import asyncio
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,188 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
from typing import Annotated
|
||||
|
||||
from agent_framework import FunctionCallContent, FunctionResultContent, ai_function
|
||||
from agent_framework.openai import OpenAIResponsesClient
|
||||
|
||||
"""
|
||||
Some tools are very expensive to run, so you may want to limit the number of times
|
||||
it tries to call them and fails. This sample shows a tool that can only raise exceptions a
|
||||
limited number of times.
|
||||
"""
|
||||
|
||||
|
||||
# we trick the AI into calling this function with 0 as denominator to trigger the exception
|
||||
@ai_function(max_invocation_exceptions=1)
|
||||
def safe_divide(
|
||||
a: Annotated[int, "Numerator"],
|
||||
b: Annotated[int, "Denominator"],
|
||||
) -> str:
|
||||
"""Divide two numbers can be used with 0 as denominator."""
|
||||
try:
|
||||
result = a / b # Will raise ZeroDivisionError
|
||||
except ZeroDivisionError as exc:
|
||||
print(f" Tool failed with error: {exc}")
|
||||
raise
|
||||
|
||||
return f"{a} / {b} = {result}"
|
||||
|
||||
|
||||
async def main():
|
||||
# tools = Tools()
|
||||
agent = OpenAIResponsesClient().create_agent(
|
||||
name="ToolAgent",
|
||||
instructions="Use the provided tools.",
|
||||
tools=[safe_divide],
|
||||
)
|
||||
thread = agent.get_new_thread()
|
||||
print("=" * 60)
|
||||
print("Step 1: Call divide(10, 0) - tool raises exception")
|
||||
response = await agent.run("Divide 10 by 0", thread=thread)
|
||||
print(f"Response: {response.text}")
|
||||
print("=" * 60)
|
||||
print("Step 2: Call divide(100, 0) - will refuse to execute due to max_invocation_exceptions")
|
||||
response = await agent.run("Divide 100 by 0", thread=thread)
|
||||
print(f"Response: {response.text}")
|
||||
print("=" * 60)
|
||||
print(f"Number of tool calls attempted: {safe_divide.invocation_count}")
|
||||
print(f"Number of tool calls failed: {safe_divide.invocation_exception_count}")
|
||||
print("Replay the conversation:")
|
||||
assert thread.message_store
|
||||
assert thread.message_store.list_messages
|
||||
for idx, msg in enumerate(await thread.message_store.list_messages()):
|
||||
if msg.text:
|
||||
print(f"{idx + 1} {msg.author_name or msg.role}: {msg.text} ")
|
||||
for content in msg.contents:
|
||||
if isinstance(content, FunctionCallContent):
|
||||
print(
|
||||
f"{idx + 1} {msg.author_name}: calling function: {content.name} with arguments: {content.arguments}"
|
||||
)
|
||||
if isinstance(content, FunctionResultContent):
|
||||
print(f"{idx + 1} {msg.role}: {content.result if content.result else content.exception}")
|
||||
|
||||
|
||||
"""
|
||||
Expected Output:
|
||||
============================================================
|
||||
Step 1: Call divide(10, 0) - tool raises exception
|
||||
Tool failed with error: division by zero
|
||||
[2025-10-31 15:39:53 - /Users/edvan/Work/agent-framework/python/packages/core/agent_framework/_tools.py:718 - ERROR]
|
||||
Function failed. Error: division by zero
|
||||
Response: Division by zero is undefined in standard arithmetic. There is no finite value for 10 ÷ 0.
|
||||
|
||||
If you want alternatives:
|
||||
- A valid example: 10 ÷ 2 = 5.
|
||||
- To handle safely in code, you can check the denominator first (e.g., in Python: if b == 0:
|
||||
handle error else: compute a/b).
|
||||
- If you’re curious about limits: as x → 0+, 10/x → +∞; as x → 0−, 10/x → −∞; there is no finite limit.
|
||||
|
||||
Would you like me to show a safe division snippet in a specific language, or compute something else?
|
||||
============================================================
|
||||
Step 2: Call divide(100, 0) - will refuse to execute due to max_invocations
|
||||
[2025-10-31 15:40:09 - /Users/edvan/Work/agent-framework/python/packages/core/agent_framework/_tools.py:718 - ERROR]
|
||||
Function failed. Error: Function 'safe_divide' has reached its maximum exception limit, you tried to use this
|
||||
tool too many times and it kept failing.
|
||||
Response: Division by zero is undefined in standard arithmetic, so 100 ÷ 0 has no finite value.
|
||||
|
||||
If you’re coding and want safe handling, here are quick patterns in a few languages:
|
||||
|
||||
- Python
|
||||
def safe_divide(a, b):
|
||||
if b == 0:
|
||||
return None # or raise an exception
|
||||
return a / b
|
||||
|
||||
safe_divide(100, 0) # -> None
|
||||
|
||||
- JavaScript
|
||||
function safeDivide(a, b) {
|
||||
if (b === 0) return undefined; // or throw
|
||||
return a / b;
|
||||
}
|
||||
|
||||
safeDivide(100, 0) // -> undefined
|
||||
|
||||
- Java
|
||||
public static Double safeDivide(double a, double b) {
|
||||
if (b == 0.0) throw new ArithmeticException("Divide by zero");
|
||||
return a / b;
|
||||
}
|
||||
|
||||
safeDivide(100, 0) // -> exception
|
||||
|
||||
- C/C++
|
||||
double safeDivide(double a, double b) {
|
||||
if (b == 0.0) return std::numeric_limits<double>::infinity(); // or handle error
|
||||
return a / b;
|
||||
}
|
||||
|
||||
Note: In many languages, dividing by zero with floating-point numbers yields Infinity (or -Infinity) or NaN,
|
||||
but integer division typically raises an error.
|
||||
|
||||
Would you like a snippet in a specific language or to see a math explanation (limits) for what happens as the
|
||||
divisor approaches zero?
|
||||
============================================================
|
||||
Number of tool calls attempted: 1
|
||||
Number of tool calls failed: 1
|
||||
Replay the conversation:
|
||||
1 user: Divide 10 by 0
|
||||
2 ToolAgent: calling function: safe_divide with arguments: {"a":10,"b":0}
|
||||
3 tool: division by zero
|
||||
4 ToolAgent: Division by zero is undefined in standard arithmetic. There is no finite value for 10 ÷ 0.
|
||||
|
||||
If you want alternatives:
|
||||
- A valid example: 10 ÷ 2 = 5.
|
||||
- To handle safely in code, you can check the denominator first (e.g., in Python: if b == 0:
|
||||
handle error else: compute a/b).
|
||||
- If you’re curious about limits: as x → 0+, 10/x → +∞; as x → 0−, 10/x → −∞; there is no finite limit.
|
||||
|
||||
Would you like me to show a safe division snippet in a specific language, or compute something else?
|
||||
5 user: Divide 100 by 0
|
||||
6 ToolAgent: calling function: safe_divide with arguments: {"a":100,"b":0}
|
||||
7 tool: Function 'safe_divide' has reached its maximum exception limit, you tried to use this tool too many times
|
||||
and it kept failing.
|
||||
8 ToolAgent: Division by zero is undefined in standard arithmetic, so 100 ÷ 0 has no finite value.
|
||||
|
||||
If you’re coding and want safe handling, here are quick patterns in a few languages:
|
||||
|
||||
- Python
|
||||
def safe_divide(a, b):
|
||||
if b == 0:
|
||||
return None # or raise an exception
|
||||
return a / b
|
||||
|
||||
safe_divide(100, 0) # -> None
|
||||
|
||||
- JavaScript
|
||||
function safeDivide(a, b) {
|
||||
if (b === 0) return undefined; // or throw
|
||||
return a / b;
|
||||
}
|
||||
|
||||
safeDivide(100, 0) // -> undefined
|
||||
|
||||
- Java
|
||||
public static Double safeDivide(double a, double b) {
|
||||
if (b == 0.0) throw new ArithmeticException("Divide by zero");
|
||||
return a / b;
|
||||
}
|
||||
|
||||
safeDivide(100, 0) // -> exception
|
||||
|
||||
- C/C++
|
||||
double safeDivide(double a, double b) {
|
||||
if (b == 0.0) return std::numeric_limits<double>::infinity(); // or handle error
|
||||
return a / b;
|
||||
}
|
||||
|
||||
Note: In many languages, dividing by zero with floating-point numbers yields Infinity (or -Infinity) or NaN,
|
||||
but integer division typically raises an error.
|
||||
|
||||
Would you like a snippet in a specific language or to see a math explanation (limits) for what happens as the
|
||||
divisor approaches zero?
|
||||
"""
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,89 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
from typing import Annotated
|
||||
|
||||
from agent_framework import FunctionCallContent, FunctionResultContent, ai_function
|
||||
from agent_framework.openai import OpenAIResponsesClient
|
||||
|
||||
"""
|
||||
For tools you can specify if there is a maximum number of invocations allowed.
|
||||
This sample shows a tool that can only be invoked once.
|
||||
"""
|
||||
|
||||
|
||||
@ai_function(max_invocations=1)
|
||||
def unicorn_function(times: Annotated[int, "The number of unicorns to return."]) -> str:
|
||||
"""This function returns precious unicorns!"""
|
||||
return f"{'🦄' * times}✨"
|
||||
|
||||
|
||||
async def main():
|
||||
# tools = Tools()
|
||||
agent = OpenAIResponsesClient().create_agent(
|
||||
name="ToolAgent",
|
||||
instructions="Use the provided tools.",
|
||||
tools=[unicorn_function],
|
||||
)
|
||||
thread = agent.get_new_thread()
|
||||
print("=" * 60)
|
||||
print("Step 1: Call unicorn_function")
|
||||
response = await agent.run("Call 5 unicorns!", thread=thread)
|
||||
print(f"Response: {response.text}")
|
||||
print("=" * 60)
|
||||
print("Step 2: Call unicorn_function again - will refuse to execute due to max_invocations")
|
||||
response = await agent.run("Call 10 unicorns and use the function to do it.", thread=thread)
|
||||
print(f"Response: {response.text}")
|
||||
print("=" * 60)
|
||||
print(f"Number of tool calls attempted: {unicorn_function.invocation_count}")
|
||||
print(f"Number of tool calls failed: {unicorn_function.invocation_exception_count}")
|
||||
print("Replay the conversation:")
|
||||
assert thread.message_store
|
||||
assert thread.message_store.list_messages
|
||||
for idx, msg in enumerate(await thread.message_store.list_messages()):
|
||||
if msg.text:
|
||||
print(f"{idx + 1} {msg.author_name or msg.role}: {msg.text} ")
|
||||
for content in msg.contents:
|
||||
if isinstance(content, FunctionCallContent):
|
||||
print(
|
||||
f"{idx + 1} {msg.author_name}: calling function: {content.name} with arguments: {content.arguments}"
|
||||
)
|
||||
if isinstance(content, FunctionResultContent):
|
||||
print(f"{idx + 1} {msg.role}: {content.result if content.result else content.exception}")
|
||||
|
||||
|
||||
"""
|
||||
Expected Output:
|
||||
============================================================
|
||||
Step 1: Call unicorn_function
|
||||
Response: Five unicorns summoned: 🦄🦄🦄🦄🦄✨
|
||||
============================================================
|
||||
Step 2: Call unicorn_function again - will refuse to execute due to max_invocations
|
||||
[2025-10-31 15:54:40 - /Users/edvan/Work/agent-framework/python/packages/core/agent_framework/_tools.py:718 - ERROR]
|
||||
Function failed. Error: Function 'unicorn_function' has reached its maximum invocation limit,
|
||||
you can no longer use this tool.
|
||||
Response: The unicorn function has reached its maximum invocation limit. I can’t call it again right now.
|
||||
|
||||
Here are 10 unicorns manually: 🦄 🦄 🦄 🦄 🦄 🦄 🦄 🦄 🦄 🦄
|
||||
|
||||
Would you like me to try again later, or generate something else?
|
||||
============================================================
|
||||
Number of tool calls attempted: 1
|
||||
Number of tool calls failed: 0
|
||||
Replay the conversation:
|
||||
1 user: Call 5 unicorns!
|
||||
2 ToolAgent: calling function: unicorn_function with arguments: {"times":5}
|
||||
3 tool: 🦄🦄🦄🦄🦄✨
|
||||
4 ToolAgent: Five unicorns summoned: 🦄🦄🦄🦄🦄✨
|
||||
5 user: Call 10 unicorns and use the function to do it.
|
||||
6 ToolAgent: calling function: unicorn_function with arguments: {"times":10}
|
||||
7 tool: Function 'unicorn_function' has reached its maximum invocation limit, you can no longer use this tool.
|
||||
8 ToolAgent: The unicorn function has reached its maximum invocation limit. I can’t call it again right now.
|
||||
|
||||
Here are 10 unicorns manually: 🦄 🦄 🦄 🦄 🦄 🦄 🦄 🦄 🦄 🦄
|
||||
|
||||
Would you like me to try again later, or generate something else?
|
||||
"""
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,100 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
from typing import Annotated
|
||||
|
||||
from agent_framework import ai_function
|
||||
from agent_framework.openai import OpenAIResponsesClient
|
||||
|
||||
"""
|
||||
This sample demonstrates using ai_function within a class,
|
||||
showing how to manage state within the class that affects tool behavior.
|
||||
|
||||
And how to use ai_function-decorated methods as tools in an agent in order to adjust the behavior of a tool.
|
||||
"""
|
||||
|
||||
|
||||
class MyFunctionClass:
|
||||
def __init__(self, safe: bool = False) -> None:
|
||||
"""Simple class with two ai_functions: divide and add.
|
||||
|
||||
The safe parameter controls whether divide raises on division by zero or returns `infinity` for divide by zero.
|
||||
"""
|
||||
self.safe = safe
|
||||
|
||||
def divide(
|
||||
self,
|
||||
a: Annotated[int, "Numerator"],
|
||||
b: Annotated[int, "Denominator"],
|
||||
) -> str:
|
||||
"""Divide two numbers, safe to use also with 0 as denominator."""
|
||||
result = "∞" if b == 0 and self.safe else a / b
|
||||
return f"{a} / {b} = {result}"
|
||||
|
||||
def add(
|
||||
self,
|
||||
x: Annotated[int, "First number"],
|
||||
y: Annotated[int, "Second number"],
|
||||
) -> str:
|
||||
return f"{x} + {y} = {x + y}"
|
||||
|
||||
|
||||
async def main():
|
||||
# Creating my function class with safe division enabled
|
||||
tools = MyFunctionClass(safe=True)
|
||||
# Applying the ai_function decorator to one of the methods of the class
|
||||
add_function = ai_function(description="Add two numbers.")(tools.add)
|
||||
|
||||
agent = OpenAIResponsesClient().create_agent(
|
||||
name="ToolAgent",
|
||||
instructions="Use the provided tools.",
|
||||
)
|
||||
print("=" * 60)
|
||||
print("Step 1: Call divide(10, 0) - tool returns infinity")
|
||||
query = "Divide 10 by 0"
|
||||
response = await agent.run(
|
||||
query,
|
||||
tools=[add_function, tools.divide],
|
||||
)
|
||||
print(f"Response: {response.text}")
|
||||
print("=" * 60)
|
||||
print("Step 2: Call set safe to False and call again")
|
||||
# Disabling safe mode to allow exceptions
|
||||
tools.safe = False
|
||||
response = await agent.run(query, tools=[add_function, tools.divide])
|
||||
print(f"Response: {response.text}")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
"""
|
||||
Expected Output:
|
||||
============================================================
|
||||
Step 1: Call divide(10, 0) - tool returns infinity
|
||||
Response: Division by zero is undefined in standard arithmetic. There is no real number that equals 10 divided by 0.
|
||||
|
||||
- If you look at limits: as x → 0+ (denominator approaches 0 from the positive side), 10/x → +∞; as x → 0−, 10/x → −∞.
|
||||
- Some calculators may display "infinity" or give an error, but that's not a real number.
|
||||
|
||||
If you want a numeric surrogate, you can use a small nonzero denominator, e.g., 10/0.001 = 10000. Would you like to
|
||||
see more on limits or handle it with a tiny epsilon?
|
||||
============================================================
|
||||
Step 2: Call set safe to False and call again
|
||||
[2025-10-31 16:17:44 - /Users/edvan/Work/agent-framework/python/packages/core/agent_framework/_tools.py:718 - ERROR]
|
||||
Function failed. Error: division by zero
|
||||
Response: Division by zero is undefined in standard arithmetic. There is no number y such that 0 × y = 10.
|
||||
|
||||
If you’re looking at limits:
|
||||
- as x → 0+, 10/x → +∞
|
||||
- as x → 0−, 10/x → −∞
|
||||
So the limit does not exist.
|
||||
|
||||
In programming, dividing by zero usually raises an error or results in special values (e.g., NaN or ∞) depending
|
||||
on the language.
|
||||
|
||||
If you want, tell me what you’d like to do instead (e.g., compute 10 divided by 2, or handle division by zero safely
|
||||
in code), and I can help with examples.
|
||||
============================================================
|
||||
"""
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,58 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
from typing import Annotated
|
||||
|
||||
from agent_framework.openai import OpenAIResponsesClient
|
||||
|
||||
"""
|
||||
This sample demonstrates how to configure function invocation settings
|
||||
for an client and use a simple ai_function as a tool in an agent.
|
||||
|
||||
This behavior is the same for all chat client types.
|
||||
"""
|
||||
|
||||
|
||||
def add(
|
||||
x: Annotated[int, "First number"],
|
||||
y: Annotated[int, "Second number"],
|
||||
) -> str:
|
||||
return f"{x} + {y} = {x + y}"
|
||||
|
||||
|
||||
async def main():
|
||||
client = OpenAIResponsesClient()
|
||||
if client.function_invocation_configuration is not None:
|
||||
client.function_invocation_configuration.include_detailed_errors = True
|
||||
client.function_invocation_configuration.max_iterations = 40
|
||||
print(f"Function invocation configured as: \n{client.function_invocation_configuration.to_json(indent=2)}")
|
||||
|
||||
agent = client.create_agent(name="ToolAgent", instructions="Use the provided tools.", tools=add)
|
||||
|
||||
print("=" * 60)
|
||||
print("Call add(239847293, 29834)")
|
||||
query = "Add 239847293 and 29834"
|
||||
response = await agent.run(query)
|
||||
print(f"Response: {response.text}")
|
||||
|
||||
|
||||
"""
|
||||
Expected Output:
|
||||
============================================================
|
||||
Function invocation configured as:
|
||||
{
|
||||
"type": "function_invocation_configuration",
|
||||
"enabled": true,
|
||||
"max_iterations": 40,
|
||||
"max_consecutive_errors_per_request": 3,
|
||||
"terminate_on_unknown_calls": false,
|
||||
"additional_tools": [],
|
||||
"include_detailed_errors": true
|
||||
}
|
||||
============================================================
|
||||
Call add(239847293, 29834)
|
||||
Response: 239,877,127
|
||||
"""
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -78,6 +78,7 @@ Once comfortable with these, explore the rest of the samples below.
|
||||
|---|---|---|
|
||||
| Human-In-The-Loop (Guessing Game) | [human-in-the-loop/guessing_game_with_human_input.py](./human-in-the-loop/guessing_game_with_human_input.py) | Interactive request/response prompts with a human |
|
||||
| Azure Agents Tool Feedback Loop | [agents/azure_chat_agents_tool_calls_with_feedback.py](./agents/azure_chat_agents_tool_calls_with_feedback.py) | Two-agent workflow that streams tool calls and pauses for human guidance between passes |
|
||||
| Agents with Approval Requests in Workflows | [human-in-the-loop/agents_with_approval_requests.py](./human-in-the-loop/agents_with_approval_requests.py) | Agents that create approval requests during workflow execution and wait for human approval to proceed |
|
||||
|
||||
### observability
|
||||
|
||||
@@ -96,6 +97,7 @@ Once comfortable with these, explore the rest of the samples below.
|
||||
| Group Chat with Simple Function Selector | [orchestration/group_chat_simple_selector.py](./orchestration/group_chat_simple_selector.py) | Group chat with a simple function selector for next speaker |
|
||||
| Handoff (Simple) | [orchestration/handoff_simple.py](./orchestration/handoff_simple.py) | Single-tier routing: triage agent routes to specialists, control returns to user after each specialist response |
|
||||
| Handoff (Specialist-to-Specialist) | [orchestration/handoff_specialist_to_specialist.py](./orchestration/handoff_specialist_to_specialist.py) | Multi-tier routing: specialists can hand off to other specialists using `.add_handoff()` fluent API |
|
||||
| Handoff (Return-to-Previous) | [orchestration/handoff_return_to_previous.py](./orchestration/handoff_return_to_previous.py) | Return-to-previous routing: after user input, routes back to the previous specialist instead of coordinator using `.enable_return_to_previous()` |
|
||||
| Magentic Workflow (Multi-Agent) | [orchestration/magentic.py](./orchestration/magentic.py) | Orchestrate multiple agents with Magentic manager and streaming |
|
||||
| Magentic + Human Plan Review | [orchestration/magentic_human_plan_update.py](./orchestration/magentic_human_plan_update.py) | Human reviews/updates the plan before execution |
|
||||
| Magentic + Checkpoint Resume | [orchestration/magentic_checkpoint.py](./orchestration/magentic_checkpoint.py) | Resume Magentic orchestration from saved checkpoints |
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
from typing import Never
|
||||
|
||||
from agent_framework import (
|
||||
AgentExecutorResponse,
|
||||
Executor,
|
||||
HostedCodeInterpreterTool,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
handler,
|
||||
)
|
||||
from agent_framework.azure import AzureAIAgentClient
|
||||
from azure.identity.aio import AzureCliCredential
|
||||
|
||||
"""
|
||||
This sample demonstrates how to create a workflow that combines an AI agent executor
|
||||
with a custom executor.
|
||||
|
||||
The workflow consists of two stages:
|
||||
1. An AI agent with code interpreter capabilities that generates and executes Python code
|
||||
2. An evaluator executor that reviews the agent's output and provides a final assessment
|
||||
|
||||
Key concepts demonstrated:
|
||||
- Creating an AI agent with tool capabilities (HostedCodeInterpreterTool)
|
||||
- Building workflows using WorkflowBuilder with an agent and a custom executor
|
||||
- Using the @handler decorator in the executor to process AgentExecutorResponse from the agent
|
||||
- Connecting workflow executors with edges to create a processing pipeline
|
||||
- Yielding final outputs from terminal executors
|
||||
- Non-streaming workflow execution and result collection
|
||||
|
||||
Prerequisites:
|
||||
- Azure AI services configured with required environment variables
|
||||
- Azure CLI authentication (run 'az login' before executing)
|
||||
- Basic understanding of async Python and workflow concepts
|
||||
"""
|
||||
|
||||
|
||||
class Evaluator(Executor):
|
||||
"""Custom executor that evaluates the output from an AI agent.
|
||||
|
||||
This executor demonstrates how to:
|
||||
- Create a custom workflow executor that processes agent responses
|
||||
- Use the @handler decorator to define the processing logic
|
||||
- Access agent execution details including response text and usage metrics
|
||||
- Yield final results to complete the workflow execution
|
||||
|
||||
The evaluator checks if the agent successfully generated the Fibonacci sequence
|
||||
and provides feedback on correctness along with resource consumption details.
|
||||
"""
|
||||
|
||||
@handler
|
||||
async def handle(self, message: AgentExecutorResponse, ctx: WorkflowContext[Never, str]) -> None:
|
||||
"""Evaluate the agent's response and complete the workflow with a final assessment.
|
||||
|
||||
This handler:
|
||||
1. Receives the AgentExecutorResponse containing the agent's complete interaction
|
||||
2. Checks if the expected Fibonacci sequence appears in the response text
|
||||
3. Extracts usage details (token consumption, execution time, etc.)
|
||||
4. Yields a final evaluation string to complete the workflow
|
||||
|
||||
Args:
|
||||
message: The response from the Azure AI agent containing text and metadata
|
||||
ctx: Workflow context for yielding the final output string
|
||||
"""
|
||||
target_text = "1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89"
|
||||
correctness = target_text in message.agent_run_response.text
|
||||
consumption = message.agent_run_response.usage_details
|
||||
await ctx.yield_output(f"Correctness: {correctness}, Consumption: {consumption}")
|
||||
|
||||
|
||||
async def main():
|
||||
async with (
|
||||
AzureCliCredential() as credential,
|
||||
AzureAIAgentClient(async_credential=credential) as chat_client,
|
||||
):
|
||||
# Create an agent with code interpretation capabilities
|
||||
agent = chat_client.create_agent(
|
||||
name="CodingAgent",
|
||||
instructions=("You are a helpful assistant that can write and execute Python code to solve problems."),
|
||||
tools=HostedCodeInterpreterTool(),
|
||||
)
|
||||
|
||||
# Build a workflow: Agent generates code -> Evaluator assesses results
|
||||
# The agent will be wrapped in a special agent executor which produces AgentExecutorResponse
|
||||
workflow = WorkflowBuilder().set_start_executor(agent).add_edge(agent, Evaluator(id="evaluator")).build()
|
||||
|
||||
# Execute the workflow with a specific coding task
|
||||
results = await workflow.run(
|
||||
"Generate the fibonacci numbers to 100 using python code, show the code and execute it."
|
||||
)
|
||||
|
||||
# Extract and display the final evaluation
|
||||
outputs = results.get_outputs()
|
||||
if isinstance(outputs, list) and len(outputs) == 1:
|
||||
print("Workflow results:", outputs[0])
|
||||
else:
|
||||
raise ValueError("Unexpected workflow outputs:", outputs)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+340
@@ -0,0 +1,340 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated, Never
|
||||
|
||||
from agent_framework import (
|
||||
AgentExecutorResponse,
|
||||
ChatMessage,
|
||||
Executor,
|
||||
FunctionApprovalRequestContent,
|
||||
FunctionApprovalResponseContent,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
ai_function,
|
||||
executor,
|
||||
handler,
|
||||
)
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
|
||||
"""
|
||||
Sample: Agents in a workflow with AI functions requiring approval
|
||||
|
||||
This sample creates a workflow that automatically replies to incoming emails.
|
||||
If historical email data is needed, it uses an AI function to read the data,
|
||||
which requires human approval before execution.
|
||||
|
||||
This sample works as follows:
|
||||
1. An incoming email is received by the workflow.
|
||||
2. The EmailPreprocessor executor preprocesses the email, adding special notes if the sender is important.
|
||||
3. The preprocessed email is sent to the Email Writer agent, which generates a response.
|
||||
4. If the agent needs to read historical email data, it calls the read_historical_email_data AI function,
|
||||
which triggers an approval request.
|
||||
5. The sample automatically approves the request for demonstration purposes.
|
||||
6. Once approved, the AI function executes and returns the historical email data to the agent.
|
||||
7. The agent uses the historical data to compose a comprehensive email response.
|
||||
8. The response is sent to the conclude_workflow_executor, which yields the final response.
|
||||
|
||||
Purpose:
|
||||
Show how to integrate AI functions with approval requests into a workflow.
|
||||
|
||||
Demonstrate:
|
||||
- Creating AI functions that require approval before execution.
|
||||
- Building a workflow that includes an agent and executors.
|
||||
- Handling approval requests during workflow execution.
|
||||
|
||||
Prerequisites:
|
||||
- Azure AI Agent Service configured, along with the required environment variables.
|
||||
- Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample.
|
||||
- Basic familiarity with WorkflowBuilder, edges, events, RequestInfoEvent, and streaming runs.
|
||||
"""
|
||||
|
||||
|
||||
@ai_function
|
||||
def get_current_date() -> str:
|
||||
"""Get the current date in YYYY-MM-DD format."""
|
||||
# For demonstration purposes, we return a fixed date.
|
||||
return "2025-11-07"
|
||||
|
||||
|
||||
@ai_function
|
||||
def get_team_members_email_addresses() -> list[dict[str, str]]:
|
||||
"""Get the email addresses of team members."""
|
||||
# In a real implementation, this might query a database or directory service.
|
||||
return [
|
||||
{
|
||||
"name": "Alice",
|
||||
"email": "alice@contoso.com",
|
||||
"position": "Software Engineer",
|
||||
"manager": "John Doe",
|
||||
},
|
||||
{
|
||||
"name": "Bob",
|
||||
"email": "bob@contoso.com",
|
||||
"position": "Product Manager",
|
||||
"manager": "John Doe",
|
||||
},
|
||||
{
|
||||
"name": "Charlie",
|
||||
"email": "charlie@contoso.com",
|
||||
"position": "Senior Software Engineer",
|
||||
"manager": "John Doe",
|
||||
},
|
||||
{
|
||||
"name": "Mike",
|
||||
"email": "mike@contoso.com",
|
||||
"position": "Principal Software Engineer Manager",
|
||||
"manager": "VP of Engineering",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@ai_function
|
||||
def get_my_information() -> dict[str, str]:
|
||||
"""Get my personal information."""
|
||||
return {
|
||||
"name": "John Doe",
|
||||
"email": "john@contoso.com",
|
||||
"position": "Software Engineer Manager",
|
||||
"manager": "Mike",
|
||||
}
|
||||
|
||||
|
||||
@ai_function(approval_mode="always_require")
|
||||
async def read_historical_email_data(
|
||||
email_address: Annotated[str, "The email address to read historical data from"],
|
||||
start_date: Annotated[str, "The start date in YYYY-MM-DD format"],
|
||||
end_date: Annotated[str, "The end date in YYYY-MM-DD format"],
|
||||
) -> list[dict[str, str]]:
|
||||
"""Read historical email data for a given email address and date range."""
|
||||
historical_data = {
|
||||
"alice@contoso.com": [
|
||||
{
|
||||
"from": "alice@contoso.com",
|
||||
"to": "john@contoso.com",
|
||||
"date": "2025-11-05",
|
||||
"subject": "Bug Bash Results",
|
||||
"body": "We just completed the bug bash and found a few issues that need immediate attention.",
|
||||
},
|
||||
{
|
||||
"from": "alice@contoso.com",
|
||||
"to": "john@contoso.com",
|
||||
"date": "2025-11-03",
|
||||
"subject": "Code Freeze",
|
||||
"body": "We are entering code freeze starting tomorrow.",
|
||||
},
|
||||
],
|
||||
"bob@contoso.com": [
|
||||
{
|
||||
"from": "bob@contoso.com",
|
||||
"to": "john@contoso.com",
|
||||
"date": "2025-11-04",
|
||||
"subject": "Team Outing",
|
||||
"body": "Don't forget about the team outing this Friday!",
|
||||
},
|
||||
{
|
||||
"from": "bob@contoso.com",
|
||||
"to": "john@contoso.com",
|
||||
"date": "2025-11-02",
|
||||
"subject": "Requirements Update",
|
||||
"body": "The requirements for the new feature have been updated. Please review them.",
|
||||
},
|
||||
],
|
||||
"charlie@contoso.com": [
|
||||
{
|
||||
"from": "charlie@contoso.com",
|
||||
"to": "john@contoso.com",
|
||||
"date": "2025-11-05",
|
||||
"subject": "Project Update",
|
||||
"body": "The bug bash went well. A few critical bugs but should be fixed by the end of the week.",
|
||||
},
|
||||
{
|
||||
"from": "charlie@contoso.com",
|
||||
"to": "john@contoso.com",
|
||||
"date": "2025-11-06",
|
||||
"subject": "Code Review",
|
||||
"body": "Please review my latest code changes.",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
emails = historical_data.get(email_address, [])
|
||||
return [email for email in emails if start_date <= email["date"] <= end_date]
|
||||
|
||||
|
||||
@ai_function(approval_mode="always_require")
|
||||
async def send_email(
|
||||
to: Annotated[str, "The recipient email address"],
|
||||
subject: Annotated[str, "The email subject"],
|
||||
body: Annotated[str, "The email body"],
|
||||
) -> str:
|
||||
"""Send an email."""
|
||||
await asyncio.sleep(1) # Simulate sending email
|
||||
return "Email successfully sent."
|
||||
|
||||
|
||||
@dataclass
|
||||
class Email:
|
||||
sender: str
|
||||
subject: str
|
||||
body: str
|
||||
|
||||
|
||||
class EmailPreprocessor(Executor):
|
||||
def __init__(self, special_email_addresses: set[str]) -> None:
|
||||
super().__init__(id="email_preprocessor")
|
||||
self.special_email_addresses = special_email_addresses
|
||||
|
||||
@handler
|
||||
async def preprocess(self, email: Email, ctx: WorkflowContext[str]) -> None:
|
||||
"""Preprocess the incoming email."""
|
||||
message = str(email)
|
||||
if email.sender in self.special_email_addresses:
|
||||
note = (
|
||||
"Pay special attention to this sender. This email is very important. "
|
||||
"Gather relevant information from all previous emails within my team before responding."
|
||||
)
|
||||
message = f"{note}\n\n{message}"
|
||||
|
||||
await ctx.send_message(message)
|
||||
|
||||
|
||||
@executor(id="conclude_workflow_executor")
|
||||
async def conclude_workflow(
|
||||
email_response: AgentExecutorResponse,
|
||||
ctx: WorkflowContext[Never, str],
|
||||
) -> None:
|
||||
"""Conclude the workflow by yielding the final email response."""
|
||||
await ctx.yield_output(email_response.agent_run_response.text)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
# Create the agent and executors
|
||||
chat_client = OpenAIChatClient()
|
||||
email_writer = chat_client.create_agent(
|
||||
name="Email Writer",
|
||||
instructions=("You are an excellent email assistant. You respond to incoming emails."),
|
||||
# tools with `approval_mode="always_require"` will trigger approval requests
|
||||
tools=[
|
||||
read_historical_email_data,
|
||||
send_email,
|
||||
get_current_date,
|
||||
get_team_members_email_addresses,
|
||||
get_my_information,
|
||||
],
|
||||
)
|
||||
email_preprocessor = EmailPreprocessor(special_email_addresses={"mike@contoso.com"})
|
||||
|
||||
# Build the workflow
|
||||
workflow = (
|
||||
WorkflowBuilder()
|
||||
.set_start_executor(email_preprocessor)
|
||||
.add_edge(email_preprocessor, email_writer)
|
||||
.add_edge(email_writer, conclude_workflow)
|
||||
.build()
|
||||
)
|
||||
|
||||
# Simulate an incoming email
|
||||
incoming_email = Email(
|
||||
sender="mike@contoso.com",
|
||||
subject="Important: Project Update",
|
||||
body="Please provide your team's status update on the project since last week.",
|
||||
)
|
||||
|
||||
responses: dict[str, FunctionApprovalResponseContent] = {}
|
||||
output: list[ChatMessage] | None = None
|
||||
while True:
|
||||
if responses:
|
||||
events = await workflow.send_responses(responses)
|
||||
responses.clear()
|
||||
else:
|
||||
events = await workflow.run(incoming_email)
|
||||
|
||||
request_info_events = events.get_request_info_events()
|
||||
for request_info_event in request_info_events:
|
||||
# We should only expect FunctionApprovalRequestContent in this sample
|
||||
if not isinstance(request_info_event.data, FunctionApprovalRequestContent):
|
||||
raise ValueError(f"Unexpected request info content type: {type(request_info_event.data)}")
|
||||
|
||||
# Pretty print the function call details
|
||||
arguments = json.dumps(request_info_event.data.function_call.parse_arguments(), indent=2)
|
||||
print(
|
||||
f"Received approval request for function: {request_info_event.data.function_call.name} "
|
||||
f"with args:\n{arguments}"
|
||||
)
|
||||
|
||||
# For demo purposes, we automatically approve the request
|
||||
# The expected response type of the request is `FunctionApprovalResponseContent`,
|
||||
# which can be created via `create_response` method on the request content
|
||||
print("Performing automatic approval for demo purposes...")
|
||||
responses[request_info_event.request_id] = request_info_event.data.create_response(approved=True)
|
||||
|
||||
# Once we get an output event, we can conclude the workflow
|
||||
# Outputs can only be produced by the conclude_workflow_executor in this sample
|
||||
if outputs := events.get_outputs():
|
||||
# We expect only one output from the conclude_workflow_executor
|
||||
output = outputs[0]
|
||||
break
|
||||
|
||||
if not output:
|
||||
raise RuntimeError("Workflow did not produce any output event.")
|
||||
|
||||
print("Final email response conversation:")
|
||||
print(output)
|
||||
|
||||
"""
|
||||
Sample Output:
|
||||
Received approval request for function: read_historical_email_data with args:
|
||||
{
|
||||
"email_address": "alice@contoso.com",
|
||||
"start_date": "2025-10-31",
|
||||
"end_date": "2025-11-07"
|
||||
}
|
||||
Performing automatic approval for demo purposes...
|
||||
Received approval request for function: read_historical_email_data with args:
|
||||
{
|
||||
"email_address": "bob@contoso.com",
|
||||
"start_date": "2025-10-31",
|
||||
"end_date": "2025-11-07"
|
||||
}
|
||||
Performing automatic approval for demo purposes...
|
||||
Received approval request for function: read_historical_email_data with args:
|
||||
{
|
||||
"email_address": "charlie@contoso.com",
|
||||
"start_date": "2025-10-31",
|
||||
"end_date": "2025-11-07"
|
||||
}
|
||||
Performing automatic approval for demo purposes...
|
||||
Received approval request for function: send_email with args:
|
||||
{
|
||||
"to": "mike@contoso.com",
|
||||
"subject": "Team's Status Update on the Project",
|
||||
"body": "
|
||||
Hi Mike,
|
||||
|
||||
Here's the status update from our team:
|
||||
- **Bug Bash and Code Freeze:**
|
||||
- We recently completed a bug bash, during which several issues were identified. Alice and Charlie are working on fixing these critical bugs, and we anticipate resolving them by the end of this week.
|
||||
- We have entered a code freeze as of November 4, 2025.
|
||||
|
||||
- **Requirements Update:**
|
||||
- Bob has updated the requirements for a new feature, and all team members are reviewing these changes to ensure alignment.
|
||||
|
||||
- **Ongoing Reviews:**
|
||||
- Charlie has submitted his latest code changes for review to ensure they meet our quality standards.
|
||||
|
||||
Please let me know if you need more detailed information or have any questions.
|
||||
|
||||
Best regards,
|
||||
John"
|
||||
}
|
||||
Performing automatic approval for demo purposes...
|
||||
Final email response conversation:
|
||||
I've sent the status update to Mike with the relevant information from the team. Let me know if there's anything else you need
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+4
-5
@@ -4,7 +4,6 @@ import asyncio
|
||||
from dataclasses import dataclass
|
||||
|
||||
from agent_framework import (
|
||||
AgentExecutor, # Executor that runs the agent
|
||||
AgentExecutorRequest, # Message bundle sent to an AgentExecutor
|
||||
AgentExecutorResponse, # Result returned by an AgentExecutor
|
||||
ChatMessage, # Chat message structure
|
||||
@@ -148,6 +147,7 @@ async def main() -> None:
|
||||
# response_format enforces that the model produces JSON compatible with GuessOutput.
|
||||
chat_client = AzureOpenAIChatClient(credential=AzureCliCredential())
|
||||
agent = chat_client.create_agent(
|
||||
name="GuessingAgent",
|
||||
instructions=(
|
||||
"You guess a number between 1 and 10. "
|
||||
"If the user says 'higher' or 'lower', adjust your next guess. "
|
||||
@@ -158,16 +158,15 @@ async def main() -> None:
|
||||
response_format=GuessOutput,
|
||||
)
|
||||
|
||||
# Build a simple loop: TurnManager <-> AgentExecutor.
|
||||
# TurnManager coordinates and gathers human replies while AgentExecutor runs the model.
|
||||
turn_manager = TurnManager(id="turn_manager")
|
||||
agent_exec = AgentExecutor(agent=agent, id="agent")
|
||||
|
||||
# Build a simple loop: TurnManager <-> AgentExecutor.
|
||||
workflow = (
|
||||
WorkflowBuilder()
|
||||
.set_start_executor(turn_manager)
|
||||
.add_edge(turn_manager, agent_exec) # Ask agent to make/adjust a guess
|
||||
.add_edge(agent_exec, turn_manager) # Agent's response comes back to coordinator
|
||||
.add_edge(turn_manager, agent) # Ask agent to make/adjust a guess
|
||||
.add_edge(agent, turn_manager) # Agent's response comes back to coordinator
|
||||
).build()
|
||||
|
||||
# Human in the loop run: alternate between invoking the workflow and supplying collected responses.
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterable
|
||||
from typing import cast
|
||||
|
||||
from agent_framework import (
|
||||
ChatAgent,
|
||||
HandoffBuilder,
|
||||
HandoffUserInputRequest,
|
||||
RequestInfoEvent,
|
||||
WorkflowEvent,
|
||||
WorkflowOutputEvent,
|
||||
)
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
"""Sample: Handoff workflow with return-to-previous routing enabled.
|
||||
|
||||
This interactive sample demonstrates the return-to-previous feature where user inputs
|
||||
route directly back to the specialist currently handling their request, rather than
|
||||
always going through the coordinator for re-evaluation.
|
||||
|
||||
Routing Pattern (with return-to-previous enabled):
|
||||
User -> Coordinator -> Technical Support -> User -> Technical Support -> ...
|
||||
|
||||
Routing Pattern (default, without return-to-previous):
|
||||
User -> Coordinator -> Technical Support -> User -> Coordinator -> Technical Support -> ...
|
||||
|
||||
This is useful when a specialist needs multiple turns with the user to gather
|
||||
information or resolve an issue, avoiding unnecessary coordinator involvement.
|
||||
|
||||
Specialist-to-Specialist Handoff:
|
||||
When a user's request changes to a topic outside the current specialist's domain,
|
||||
the specialist can hand off DIRECTLY to another specialist without going back through
|
||||
the coordinator:
|
||||
|
||||
User -> Coordinator -> Technical Support -> User -> Technical Support (billing question)
|
||||
-> Billing -> User -> Billing ...
|
||||
|
||||
Example Interaction:
|
||||
1. User reports a technical issue
|
||||
2. Coordinator routes to technical support specialist
|
||||
3. Technical support asks clarifying questions
|
||||
4. User provides details (routes directly back to technical support)
|
||||
5. Technical support continues troubleshooting with full context
|
||||
6. Issue resolved, user asks about billing
|
||||
7. Technical support hands off DIRECTLY to billing specialist
|
||||
8. Billing specialist helps with payment
|
||||
9. User continues with billing (routes directly to billing)
|
||||
|
||||
Prerequisites:
|
||||
- `az login` (Azure CLI authentication)
|
||||
- Environment variables configured for AzureOpenAIChatClient (AZURE_OPENAI_ENDPOINT, etc.)
|
||||
|
||||
Usage:
|
||||
Run the script and interact with the support workflow by typing your requests.
|
||||
Type 'exit' or 'quit' to end the conversation.
|
||||
|
||||
Key Concepts:
|
||||
- Return-to-previous: Direct routing to current agent handling the conversation
|
||||
- Current agent tracking: Framework remembers which agent is actively helping the user
|
||||
- Context preservation: Specialist maintains full conversation context
|
||||
- Domain switching: Specialists can hand back to coordinator when topic changes
|
||||
"""
|
||||
|
||||
|
||||
def create_agents(chat_client: AzureOpenAIChatClient) -> tuple[ChatAgent, ChatAgent, ChatAgent, ChatAgent]:
|
||||
"""Create and configure the coordinator and specialist agents.
|
||||
|
||||
Returns:
|
||||
Tuple of (coordinator, technical_support, account_specialist, billing_agent)
|
||||
"""
|
||||
coordinator = chat_client.create_agent(
|
||||
instructions=(
|
||||
"You are a customer support coordinator. Analyze the user's request and route to "
|
||||
"the appropriate specialist:\n"
|
||||
"- technical_support for technical issues, troubleshooting, repairs, hardware/software problems\n"
|
||||
"- account_specialist for account changes, profile updates, settings, login issues\n"
|
||||
"- billing_agent for payments, invoices, refunds, charges, billing questions\n"
|
||||
"\n"
|
||||
"When you receive a request, immediately call the matching handoff tool without explaining. "
|
||||
"Read the most recent user message to determine the correct specialist."
|
||||
),
|
||||
name="coordinator",
|
||||
)
|
||||
|
||||
technical_support = chat_client.create_agent(
|
||||
instructions=(
|
||||
"You provide technical support. Help users troubleshoot technical issues, "
|
||||
"arrange repairs, and answer technical questions. "
|
||||
"Gather information through conversation. "
|
||||
"If the user asks about billing, payments, invoices, or refunds, hand off to billing_agent. "
|
||||
"If the user asks about account settings or profile changes, hand off to account_specialist."
|
||||
),
|
||||
name="technical_support",
|
||||
)
|
||||
|
||||
account_specialist = chat_client.create_agent(
|
||||
instructions=(
|
||||
"You handle account management. Help with profile updates, account settings, "
|
||||
"and preferences. Gather information through conversation. "
|
||||
"If the user asks about technical issues or troubleshooting, hand off to technical_support. "
|
||||
"If the user asks about billing, payments, invoices, or refunds, hand off to billing_agent."
|
||||
),
|
||||
name="account_specialist",
|
||||
)
|
||||
|
||||
billing_agent = chat_client.create_agent(
|
||||
instructions=(
|
||||
"You handle billing only. Process payments, explain invoices, handle refunds. "
|
||||
"If the user asks about technical issues or troubleshooting, hand off to technical_support. "
|
||||
"If the user asks about account settings or profile changes, hand off to account_specialist."
|
||||
),
|
||||
name="billing_agent",
|
||||
)
|
||||
|
||||
return coordinator, technical_support, account_specialist, billing_agent
|
||||
|
||||
|
||||
def handle_events(events: list[WorkflowEvent]) -> list[RequestInfoEvent]:
|
||||
"""Process events and return pending input requests."""
|
||||
pending_requests: list[RequestInfoEvent] = []
|
||||
for event in events:
|
||||
if isinstance(event, RequestInfoEvent):
|
||||
pending_requests.append(event)
|
||||
request_data = cast(HandoffUserInputRequest, event.data)
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"AWAITING INPUT FROM: {request_data.awaiting_agent_id.upper()}")
|
||||
print(f"{'=' * 60}")
|
||||
for msg in request_data.conversation[-3:]:
|
||||
author = msg.author_name or msg.role.value
|
||||
prefix = ">>> " if author == request_data.awaiting_agent_id else " "
|
||||
print(f"{prefix}[{author}]: {msg.text}")
|
||||
elif isinstance(event, WorkflowOutputEvent):
|
||||
print(f"\n{'=' * 60}")
|
||||
print("[WORKFLOW COMPLETE]")
|
||||
print(f"{'=' * 60}")
|
||||
return pending_requests
|
||||
|
||||
|
||||
async def _drain(stream: AsyncIterable[WorkflowEvent]) -> list[WorkflowEvent]:
|
||||
"""Drain an async iterable into a list."""
|
||||
events: list[WorkflowEvent] = []
|
||||
async for event in stream:
|
||||
events.append(event)
|
||||
return events
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Demonstrate return-to-previous routing in a handoff workflow."""
|
||||
chat_client = AzureOpenAIChatClient(credential=AzureCliCredential())
|
||||
coordinator, technical, account, billing = create_agents(chat_client)
|
||||
|
||||
print("Handoff Workflow with Return-to-Previous Routing")
|
||||
print("=" * 60)
|
||||
print("\nThis interactive demo shows how user inputs route directly")
|
||||
print("to the specialist handling your request, avoiding unnecessary")
|
||||
print("coordinator re-evaluation on each turn.")
|
||||
print("\nSpecialists can hand off directly to other specialists when")
|
||||
print("your request changes topics (e.g., from technical to billing).")
|
||||
print("\nType 'exit' or 'quit' to end the conversation.\n")
|
||||
|
||||
# Configure handoffs with return-to-previous enabled
|
||||
# Specialists can hand off directly to other specialists when topic changes
|
||||
workflow = (
|
||||
HandoffBuilder(
|
||||
name="return_to_previous_demo",
|
||||
participants=[coordinator, technical, account, billing],
|
||||
)
|
||||
.set_coordinator(coordinator)
|
||||
.add_handoff(coordinator, [technical, account, billing]) # Coordinator routes to all specialists
|
||||
.add_handoff(technical, [billing, account]) # Technical can route to billing or account
|
||||
.add_handoff(account, [technical, billing]) # Account can route to technical or billing
|
||||
.add_handoff(billing, [technical, account]) # Billing can route to technical or account
|
||||
.enable_return_to_previous(True) # Enable the `return to previous handoff` feature
|
||||
.with_termination_condition(lambda conv: sum(1 for msg in conv if msg.role.value == "user") >= 10)
|
||||
.build()
|
||||
)
|
||||
|
||||
# Get initial user request
|
||||
initial_request = input("You: ").strip() # noqa: ASYNC250
|
||||
if not initial_request or initial_request.lower() in ["exit", "quit"]:
|
||||
print("Goodbye!")
|
||||
return
|
||||
|
||||
# Start workflow with initial message
|
||||
events = await _drain(workflow.run_stream(initial_request))
|
||||
pending_requests = handle_events(events)
|
||||
|
||||
# Interactive loop: keep prompting for user input
|
||||
while pending_requests:
|
||||
user_input = input("\nYou: ").strip() # noqa: ASYNC250
|
||||
|
||||
if not user_input or user_input.lower() in ["exit", "quit"]:
|
||||
print("\nEnding conversation. Goodbye!")
|
||||
break
|
||||
|
||||
responses = {req.request_id: user_input for req in pending_requests}
|
||||
events = await _drain(workflow.send_responses_streaming(responses))
|
||||
pending_requests = handle_events(events)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Conversation ended.")
|
||||
|
||||
"""
|
||||
Sample Output:
|
||||
|
||||
Handoff Workflow with Return-to-Previous Routing
|
||||
============================================================
|
||||
|
||||
This interactive demo shows how user inputs route directly
|
||||
to the specialist handling your request, avoiding unnecessary
|
||||
coordinator re-evaluation on each turn.
|
||||
|
||||
Specialists can hand off directly to other specialists when
|
||||
your request changes topics (e.g., from technical to billing).
|
||||
|
||||
Type 'exit' or 'quit' to end the conversation.
|
||||
|
||||
You: I need help with my bill, I was charged twice by mistake.
|
||||
|
||||
============================================================
|
||||
AWAITING INPUT FROM: BILLING_AGENT
|
||||
============================================================
|
||||
[user]: I need help with my bill, I was charged twice by mistake.
|
||||
[coordinator]: You will be connected to a billing agent who can assist you with the double charge on your bill.
|
||||
>>> [billing_agent]: I'm here to help with billing concerns! I'm sorry you were charged twice. Could you
|
||||
please provide the invoice number or your account email so I can look into this and begin processing a refund?
|
||||
|
||||
You: Invoice 1234
|
||||
|
||||
============================================================
|
||||
AWAITING INPUT FROM: BILLING_AGENT
|
||||
============================================================
|
||||
>>> [billing_agent]: I'm here to help with billing concerns! I'm sorry you were charged twice.
|
||||
Could you please provide the invoice number or your account email so I can look into this and begin
|
||||
processing a refund?
|
||||
[user]: Invoice 1234
|
||||
>>> [billing_agent]: Thank you for providing the invoice number (1234). I will review the details and work
|
||||
on processing a refund for the duplicate charge.
|
||||
|
||||
Can you confirm which payment method you used for this bill (e.g., credit card, PayPal)?
|
||||
This helps ensure your refund is processed to the correct account.
|
||||
|
||||
You: I used my credit card, which is on autopay.
|
||||
|
||||
============================================================
|
||||
AWAITING INPUT FROM: BILLING_AGENT
|
||||
============================================================
|
||||
>>> [billing_agent]: Thank you for providing the invoice number (1234). I will review the details and work on
|
||||
processing a refund for the duplicate charge.
|
||||
|
||||
Can you confirm which payment method you used for this bill (e.g., credit card, PayPal)? This helps ensure
|
||||
your refund is processed to the correct account.
|
||||
[user]: I used my credit card, which is on autopay.
|
||||
>>> [billing_agent]: Thank you for confirming your payment method. I will look into invoice 1234 and
|
||||
process a refund for the duplicate charge to your credit card.
|
||||
|
||||
You will receive a notification once the refund is completed. If you have any further questions about your billing
|
||||
or need an update, please let me know!
|
||||
|
||||
You: Actually I also can't turn on my modem. It reset and now won't turn on.
|
||||
|
||||
============================================================
|
||||
AWAITING INPUT FROM: TECHNICAL_SUPPORT
|
||||
============================================================
|
||||
[user]: Actually I also can't turn on my modem. It reset and now won't turn on.
|
||||
[billing_agent]: I'm connecting you with technical support for assistance with your modem not turning on after
|
||||
the reset. They'll be able to help troubleshoot and resolve this issue.
|
||||
|
||||
At the same time, technical support will also handle your refund request for the duplicate charge on invoice 1234
|
||||
to your credit card on autopay.
|
||||
|
||||
You will receive updates from the appropriate teams shortly.
|
||||
>>> [technical_support]: Thanks for letting me know about your modem issue! To help you further, could you tell me:
|
||||
|
||||
1. Is there any light showing on the modem at all, or is it completely off?
|
||||
2. Have you tried unplugging the modem from power and plugging it back in?
|
||||
3. Do you hear or feel anything (like a slight hum or vibration) when the modem is plugged in?
|
||||
|
||||
Let me know, and I'll guide you through troubleshooting or arrange a repair if needed.
|
||||
|
||||
You: exit
|
||||
|
||||
Ending conversation. Goodbye!
|
||||
|
||||
============================================================
|
||||
Conversation ended.
|
||||
"""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user