diff --git a/python/samples/05-end-to-end/enterprise-chat-agent/.gitignore b/python/samples/05-end-to-end/enterprise-chat-agent/.gitignore new file mode 100644 index 0000000000..02fc31821c --- /dev/null +++ b/python/samples/05-end-to-end/enterprise-chat-agent/.gitignore @@ -0,0 +1,61 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +.venv/ +venv/ +ENV/ +env/ + +# Azure Functions +local.settings.json +.python_packages/ +.func/ + +# Azure Developer CLI +.azure/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.nox/ + +# Logs +*.log +logs/ + +# Environment variables +.env +.env.local diff --git a/python/samples/05-end-to-end/enterprise-chat-agent/README.md b/python/samples/05-end-to-end/enterprise-chat-agent/README.md new file mode 100644 index 0000000000..9ebc8bb249 --- /dev/null +++ b/python/samples/05-end-to-end/enterprise-chat-agent/README.md @@ -0,0 +1,334 @@ +# Enterprise Chat Agent + +A production-ready sample demonstrating how to build a scalable Chat API using Microsoft Agent Framework with Azure Functions and Cosmos DB. + +## Overview + +This sample showcases: + +- **Azure Functions HTTP Triggers** - Serverless REST API endpoints +- **Runtime Tool Selection** - Agent autonomously decides which tools to invoke based on user intent +- **Cosmos DB Persistence** - Two containers: `threads` (partition key `/thread_id`) and `messages` (`/session_id`) +- **Production Patterns** - Error handling, observability, and security best practices +- **One-command deployment** - `azd up` deploys all infrastructure + +## Architecture + +```text +Client → Azure Functions (HTTP Triggers) → ChatAgent → Azure OpenAI + ↓ + [Tools] + ┌─────────┼──────────┐ + ↓ ↓ ↓ + Weather Calculator Knowledge Base + ↓ + Microsoft Docs ← → Azure Cosmos DB + (MCP Integration) +``` + +## Prerequisites + +- Python 3.11+ +- [Azure Developer CLI (azd)](https://learn.microsoft.com/azure/developer/azure-developer-cli/install-azd) +- [Azure Functions Core Tools v4](https://learn.microsoft.com/azure/azure-functions/functions-run-local) +- Azure subscription with: + - Azure OpenAI resource (GPT-4o recommended) + +## Quick Start + +### Option 1: Deploy to Azure (Recommended) + +Deploy the complete infrastructure with a single command: + +```bash +cd python/samples/05-end-to-end/enterprise-chat-agent + +# Login to Azure +azd auth login + +# Deploy infrastructure and application +azd up +``` + +This deploys: +- **Azure Function App** (Flex Consumption) - Serverless hosting +- **Azure Cosmos DB** (Serverless) - Conversation persistence +- **Azure Storage** - Function App state +- **Application Insights** - Monitoring and observability + +#### Configuration + +Before running `azd up`, you'll be prompted for: + +| Parameter | Description | +|-----------|-------------| +| `AZURE_ENV_NAME` | Environment name (e.g., `dev`, `prod`) | +| `AZURE_LOCATION` | Azure region (e.g., `eastus2`) | +| `AZURE_OPENAI_ENDPOINT` | Your Azure OpenAI endpoint URL | +| `AZURE_OPENAI_MODEL` | Model deployment name (default: `gpt-4o`) | + +#### Other azd Commands + +```bash +# Provision infrastructure only (no deployment) +azd provision + +# Deploy application code only +azd deploy + +# View deployed resources +azd show + +# Delete all resources +azd down +``` + +### Option 2: Run Locally + +```bash +cd python/samples/05-end-to-end/enterprise-chat-agent +pip install -r requirements.txt +``` + +Copy `local.settings.json.example` to `local.settings.json` and update: + +```json +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "AzureWebJobsFeatureFlags": "EnableWorkerIndexing", + "FUNCTIONS_WORKER_RUNTIME": "python", + "AZURE_OPENAI_ENDPOINT": "https://your-resource.openai.azure.com/", + "AZURE_OPENAI_DEPLOYMENT_NAME": "gpt-4o", + "AZURE_OPENAI_API_VERSION": "2024-10-21", + "AZURE_COSMOS_ENDPOINT": "https://your-cosmos-account.documents.azure.com:443/", + "AZURE_COSMOS_DATABASE_NAME": "chat_db", + "AZURE_COSMOS_CONTAINER_NAME": "messages", + "AZURE_COSMOS_THREADS_CONTAINER_NAME": "threads", + "APPLICATIONINSIGHTS_CONNECTION_STRING": "", + "ENABLE_INSTRUMENTATION": "true", + "ENABLE_SENSITIVE_DATA": "false", + "ENABLE_DEBUG_ENDPOINTS": "false" + } +} +``` + +Run locally: + +```bash +func start +``` + +### Test the API + +After running `func start`, you can test the API in two ways: + +#### Option A: API-Only Testing (demo.http) + +Use the included `demo.http` file with VS Code's REST Client extension or any HTTP client: + +```bash +# Create a thread +curl -X POST http://localhost:7071/api/threads + +# Send a message +curl -X POST http://localhost:7071/api/threads/{thread_id}/messages \ + -H "Content-Type: application/json" \ + -d '{"content": "What is the weather in Seattle and what is 15% tip on $85?"}' +``` + +#### Option B: Interactive UI Testing (demo-ui.html) + +For a quick visual way to interact with the API, open `demo-ui.html` in your browser. This provides a simple chat interface to test thread creation, messaging, and agent responses. + +> ⚠️ **Development Only**: The `demo-ui.html` file is intended for local development and testing purposes only. It is not designed for production use. + +## API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/api/threads` | Create a new conversation thread | +| `GET` | `/api/threads` | List all threads (with optional filters) | +| `GET` | `/api/threads/{thread_id}` | Get thread metadata | +| `DELETE` | `/api/threads/{thread_id}` | Delete a thread | +| `POST` | `/api/threads/{thread_id}/messages` | Send a message and get response | +| `GET` | `/api/threads/{thread_id}/messages` | Get conversation history | + +### Query Parameters for List Threads + +| Parameter | Type | Description | +|-----------|------|-------------| +| `user_id` | string | Filter threads by user ID | +| `status` | string | Filter by status: `active`, `archived`, `deleted` | +| `limit` | int | Max threads to return (default 50, max 100) | +| `offset` | int | Skip N threads for pagination | + +**Examples:** +```bash +# List all threads +GET /api/threads + +# List threads for a specific user +GET /api/threads?user_id=user_1234 + +# List active threads with pagination +GET /api/threads?status=active&limit=20&offset=0 +``` + +## Tool Selection Demo + +The agent is configured with multiple tools and **decides at runtime** which to use: + +```text +User: "What's the weather in Tokyo?" +→ Agent calls: get_weather("Tokyo") + +User: "What's the weather in Paris and what's 18% tip on €75?" +→ Agent calls: get_weather("Paris") AND calculate("75 * 0.18") + +User: "How do I configure partition keys in Azure Cosmos DB?" +→ Agent calls: microsoft_docs_search("Cosmos DB partition keys") via MCP +→ Returns: Official Microsoft documentation with best practices + +User: "Show me Python code for Azure OpenAI chat completion" +→ Agent calls: microsoft_code_sample_search("Azure OpenAI chat") via MCP +→ Returns: Official code examples from Microsoft Learn + +User: "What's your return policy?" +→ Agent calls: search_knowledge_base("return policy") + +User: "Tell me a joke" +→ Agent responds directly (no tools needed) +``` + +### Available Tools + +| Tool | Description | Source | +|------|-------------|--------| +| `microsoft_docs_search` | Search official Microsoft/Azure docs | MCP (remote) | +| `microsoft_code_sample_search` | Find code examples from Microsoft Learn | MCP (remote) | +| `search_knowledge_base` | Internal company knowledge | Local | +| `get_weather` | Current weather data | Local | +| `calculate` | Safe math evaluation (exponent ≤ 100) | Local | + +> **Note:** MCP tools (`microsoft_docs_search`, `microsoft_code_sample_search`) are provided by the +> Microsoft Learn MCP server at `https://learn.microsoft.com/api/mcp` and discovered at runtime +> via `MCPStreamableHTTPTool`. No local tool file is needed. + +## Streaming Responses + +### Current Approach + +This sample uses **buffered responses** - the agent processes the entire message and returns the complete response at once. This works well with Azure Functions and is simpler to implement. + +### Streaming Support in Agent Framework + +The Agent Framework supports streaming via `ResponseStream`: + +```python +from agent_framework import Agent, AgentSession + +# Enable streaming +response_stream = await agent.run( + prompt="Hello, world!", + session=session, + stream=True # Returns ResponseStream instead of Response +) + +# Iterate over chunks as they arrive +async for chunk in response_stream: + print(chunk.content, end="", flush=True) +``` + +### Why This Sample Doesn't Use Streaming + +**Azure Functions buffers HTTP responses** - even with Server-Sent Events (SSE) or chunked transfer encoding, Azure Functions collects the entire response before sending it to the client. This means true streaming isn't achievable without additional infrastructure. + +### Streaming Alternatives + +If you need true streaming for a production chat experience, consider these options: + +| Option | Description | Pros | Cons | +|--------|-------------|------|------| +| **FastAPI/Starlette** | Deploy as a container with native async streaming | True SSE streaming, simple to implement | Need container hosting (App Service, ACA) | +| **Azure Container Apps** | Host a streaming-capable web framework | Native streaming, auto-scaling | More infrastructure to manage | +| **Azure Web PubSub** | Real-time messaging service | True real-time, scalable | Additional service cost, more complexity | +| **Azure SignalR** | Managed SignalR service | WebSocket support, .NET integration | Adds dependency | + +#### FastAPI Streaming Example + +```python +from fastapi import FastAPI +from fastapi.responses import StreamingResponse +from agent_framework import Agent, AgentSession + +app = FastAPI() + +@app.post("/api/threads/{thread_id}/messages/stream") +async def send_message_stream(thread_id: str, request: MessageRequest): + async def generate(): + session = AgentSession(session_id=thread_id) + response_stream = await agent.run( + prompt=request.content, + session=session, + stream=True + ) + async for chunk in response_stream: + yield f"data: {json.dumps({'content': chunk.content})}\n\n" + yield "data: [DONE]\n\n" + + return StreamingResponse(generate(), media_type="text/event-stream") +``` + +### Recommendation + +- **For demos/prototypes**: Use buffered responses (this sample) with a typing indicator in the UI +- **For production chat UIs**: Consider FastAPI on Azure Container Apps or Web PubSub for true streaming + +## Project Structure + +```text +enterprise-chat-agent/ +├── azure.yaml # Azure Developer CLI configuration +├── DESIGN.md # Detailed design specification +├── README.md # This file +├── requirements.txt # Python dependencies +├── local.settings.json.example +├── host.json # Azure Functions host config +├── function_app.py # Azure Functions entry point +├── demo.http # API test requests +├── demo-ui.html # Browser-based demo UI +├── services/ +│ ├── agent_service.py # ChatAgent + CosmosHistoryProvider +│ ├── cosmos_store.py # Thread metadata storage +│ └── observability.py # OpenTelemetry instrumentation +├── routes/ +│ ├── threads.py # Thread CRUD endpoints +│ ├── messages.py # Message endpoint +│ └── health.py # Health check +├── tools/ +│ ├── weather.py # Weather tool (local) +│ ├── calculator.py # Calculator tool (local) +│ └── knowledge_base.py # Knowledge base search tool (local) +└── infra/ # Infrastructure as Code (Bicep) + ├── main.bicep # Main deployment template + └── core/ # Modular Bicep components +``` + +## Design Documentation + +See [DESIGN.md](./docs/DESIGN.md) for: + +- Architecture diagrams and message processing flow +- Cosmos DB data model and partition strategy +- Observability span hierarchy (framework vs custom) +- Tool selection and MCP integration details +- Security considerations + +## Related Resources + +- [GitHub Issue #2436](https://github.com/microsoft/agent-framework/issues/2436) +- [Microsoft Agent Framework Documentation](https://learn.microsoft.com/agent-framework/) +- [Azure Functions Python Developer Guide](https://learn.microsoft.com/azure/azure-functions/functions-reference-python) + diff --git a/python/samples/05-end-to-end/enterprise-chat-agent/azure.yaml b/python/samples/05-end-to-end/enterprise-chat-agent/azure.yaml new file mode 100644 index 0000000000..64ac0027b6 --- /dev/null +++ b/python/samples/05-end-to-end/enterprise-chat-agent/azure.yaml @@ -0,0 +1,12 @@ +# Azure Developer CLI (azd) configuration +# Run `azd up` to provision infrastructure and deploy the application + +name: enterprise-chat-agent +metadata: + template: enterprise-chat-agent@0.0.1 + +services: + api: + project: . + language: python + host: function diff --git a/python/samples/05-end-to-end/enterprise-chat-agent/demo-ui.html b/python/samples/05-end-to-end/enterprise-chat-agent/demo-ui.html new file mode 100644 index 0000000000..05fbaf2d7e --- /dev/null +++ b/python/samples/05-end-to-end/enterprise-chat-agent/demo-ui.html @@ -0,0 +1,443 @@ + + + + + + Enterprise Chat Agent Demo + + + + + +
+
+
Select or create a chat
+
+
+

👋 Welcome!

+

Create a new chat to get started

+
+
+
+ + +
+
+ + + + diff --git a/python/samples/05-end-to-end/enterprise-chat-agent/demo.http b/python/samples/05-end-to-end/enterprise-chat-agent/demo.http new file mode 100644 index 0000000000..7221f8d51b --- /dev/null +++ b/python/samples/05-end-to-end/enterprise-chat-agent/demo.http @@ -0,0 +1,164 @@ +### Enterprise Chat Agent - Demo Requests +### Use with VS Code REST Client extension or similar + +@baseUrl = http://localhost:7071/api + +### ============================================================ +### Health Check +### ============================================================ + +### Check API health +GET {{baseUrl}}/health + +### ============================================================ +### Thread Management +### ============================================================ + +### Create a new thread +# @name createThread +POST {{baseUrl}}/threads +Content-Type: application/json + +{ + "user_id": "user_1234", + "title": "Customer Support Chat", + "metadata": { + "session_type": "support", + "department": "technical" + } +} + +### Get thread ID from response +@threadId = {{createThread.response.body.id}} + +### Get thread details +GET {{baseUrl}}/threads/{{threadId}} + +### List all threads +GET {{baseUrl}}/threads + +### List threads for a specific user +GET {{baseUrl}}/threads?user_id=user_1234 + +### List active threads only +GET {{baseUrl}}/threads?status=active + +### List threads with pagination +GET {{baseUrl}}/threads?limit=10&offset=0 + +### List threads with all filters +GET {{baseUrl}}/threads?user_id=user_1234&status=active&limit=20 + +### ============================================================ +### Tool Selection Examples +### ============================================================ + +### Example 1: Single Tool - Weather +POST {{baseUrl}}/threads/{{threadId}}/messages +Content-Type: application/json + +{ + "content": "What's the weather in New York?" +} + +### Example 2: Single Tool - Calculator +POST {{baseUrl}}/threads/{{threadId}}/messages +Content-Type: application/json + +{ + "content": "Calculate 15% tip on $85" +} + +### Example 3: Multiple Tools - Weather + Calculator +POST {{baseUrl}}/threads/{{threadId}}/messages +Content-Type: application/json + +{ + "content": "What's the weather in Tokyo and what's 20% of 150?" +} + +### Example 4: Knowledge Base Search +POST {{baseUrl}}/threads/{{threadId}}/messages +Content-Type: application/json + +{ + "content": "What's your return policy?" +} + +### Example 5: Microsoft Docs - Azure Service +POST {{baseUrl}}/threads/{{threadId}}/messages +Content-Type: application/json + +{ + "content": "How do I configure partition keys in Azure Cosmos DB?" +} + +### Example 6: Microsoft Docs - Best Practices +POST {{baseUrl}}/threads/{{threadId}}/messages +Content-Type: application/json + +{ + "content": "What are the best practices for Azure Functions error handling?" +} + +### Example 7: Microsoft Code Samples - Python +POST {{baseUrl}}/threads/{{threadId}}/messages +Content-Type: application/json + +{ + "content": "Show me Python code for Azure OpenAI chat completion" +} + +### Example 8: Microsoft Code Samples - General +POST {{baseUrl}}/threads/{{threadId}}/messages +Content-Type: application/json + +{ + "content": "How do I authenticate to Azure using DefaultAzureCredential? Show me code examples." +} + +### Example 9: Complex Query - Multiple Tools +POST {{baseUrl}}/threads/{{threadId}}/messages +Content-Type: application/json + +{ + "content": "What's the weather in Seattle? Also, explain how Azure Functions scales and show me example code for HTTP triggers." +} + +### Example 10: Azure Architecture Question +POST {{baseUrl}}/threads/{{threadId}}/messages +Content-Type: application/json + +{ + "content": "What's the difference between Azure App Service and Azure Container Apps?" +} + +### Example 11: Deployment Question +POST {{baseUrl}}/threads/{{threadId}}/messages +Content-Type: application/json + +{ + "content": "How do I deploy a Python application to Azure using azd?" +} + +### Example 12: No Tools Needed +POST {{baseUrl}}/threads/{{threadId}}/messages +Content-Type: application/json + +{ + "content": "Hello! How are you today?" +} + +### ============================================================ +### Conversation History +### ============================================================ + +### Get all messages in thread +GET {{baseUrl}}/threads/{{threadId}}/messages + +### ============================================================ +### Cleanup +### ============================================================ + +### Delete thread +DELETE {{baseUrl}}/threads/{{threadId}} \ No newline at end of file diff --git a/python/samples/05-end-to-end/enterprise-chat-agent/docs/DESIGN.md b/python/samples/05-end-to-end/enterprise-chat-agent/docs/DESIGN.md new file mode 100644 index 0000000000..161b1e96d8 --- /dev/null +++ b/python/samples/05-end-to-end/enterprise-chat-agent/docs/DESIGN.md @@ -0,0 +1,351 @@ +--- +status: in-progress +contact: @vj-msft +date: 2024-12-06 +updated: 2026-03-25 +--- + +# Enterprise Chat Agent — Design Document + +## Overview + +This document describes the architecture and design decisions for a **production-ready Chat API** built with Microsoft Agent Framework, Azure Functions, and Cosmos DB. + +### Goals + +1. Demonstrate enterprise patterns: state persistence, observability, and thread-based conversations +2. Showcase **agent autonomy** — the agent decides which tools to invoke at runtime based on conversation context +3. Provide a reference architecture for deploying Agent Framework in production +4. Enable one-command deployment with `azd up` + +### References + +- **GitHub Issue**: [#2436 - Production Chat API with Azure Functions, Cosmos DB & Agent Framework](https://github.com/microsoft/agent-framework/issues/2436) +- [Agent Framework Tools (Python)](https://learn.microsoft.com/agent-framework/agents/tools/function-tools) +- [Azure Functions Python Developer Guide](https://learn.microsoft.com/azure/azure-functions/functions-reference-python) + +--- + +## Architecture + +```mermaid +flowchart TB + subgraph Clients["Client Applications"] + Web["Web"] + Mobile["Mobile"] + CLI["CLI / Postman"] + end + + subgraph AzureFunctions["Azure Functions (Flex Consumption)"] + subgraph Endpoints["HTTP Trigger Endpoints"] + POST1["POST /api/threads"] + POST2["POST /api/threads/{id}/messages"] + GET1["GET /api/threads/{id}"] + DELETE1["DELETE /api/threads/{id}"] + end + + subgraph Agent["ChatAgent"] + Weather["get_weather"] + Calc["calculate"] + KB["search_knowledge_base"] + Docs["microsoft_docs_search
(MCP)"] + Code["microsoft_code_sample_search
(MCP)"] + end + + Endpoints --> Agent + end + + subgraph Services["Azure Services"] + OpenAI["Azure OpenAI
(GPT-4o)"] + CosmosDB["Cosmos DB
(threads + messages)"] + AppInsights["Application Insights
(telemetry)"] + end + + subgraph MCP["MCP Server"] + MCPDocs["Microsoft Learn
Docs & Code Samples"] + end + + Clients --> AzureFunctions + Agent --> OpenAI + Agent --> CosmosDB + AzureFunctions --> AppInsights + Docs --> MCPDocs + Code --> MCPDocs +``` + +### Components + +| Component | Technology | Purpose | +|-----------|------------|---------| +| API Layer | Azure Functions (Flex Consumption) | Serverless HTTP endpoints | +| Agent | Microsoft Agent Framework ChatAgent | Conversation orchestration with tools | +| LLM | Azure OpenAI (GPT-4o) | Language model for responses | +| Message Storage | Cosmos DB + CosmosHistoryProvider | Automatic conversation persistence | +| Thread Metadata | Cosmos DB + CosmosConversationStore | Thread lifecycle management | +| External Knowledge | MCP (Microsoft Learn) | Official documentation access | +| Observability | OpenTelemetry + Application Insights | Tracing and monitoring | + +--- + +## Message Processing Flow + +```text +User Request + ↓ +POST /api/threads/{thread_id}/messages + ↓ +1. Validate thread exists (Cosmos DB lookup) + ↓ +2. agent.run(content, session=AgentSession(thread_id)) + ↓ + ┌─────────────────────────────────────────┐ + │ CosmosHistoryProvider (automatic): │ + │ • Load previous messages from Cosmos │ + │ • Add to agent context │ + └─────────────────────────────────────────┘ + ↓ +3. Agent analyzes context and decides which tools to use + ↓ +4. Agent automatically calls tools as needed: + • get_weather("Seattle") + • calculate("85 * 0.15") + • microsoft_docs_search("Azure Functions") via MCP + ↓ + ┌─────────────────────────────────────────┐ + │ CosmosHistoryProvider (automatic): │ + │ • Store user message to Cosmos │ + │ • Store assistant response to Cosmos │ + └─────────────────────────────────────────┘ + ↓ +5. Return response to user +``` + +--- + +## Key Design Decisions + +### 1. Runtime Tool Selection (Agent Autonomy) + +The agent is configured with multiple tools but **decides at runtime** which tool(s) to invoke based on user intent. Tools are registered once; the agent autonomously selects which to use for each request. + +| Tool | Purpose | Source | +|------|---------|--------| +| `get_weather` | Weather information | Local (simulated) | +| `calculate` | Math expressions | Local (safe AST eval) | +| `search_knowledge_base` | FAQ/KB search | Local (simulated) | +| `microsoft_docs_search` | Microsoft Learn search | MCP Server | +| `microsoft_code_sample_search` | Code sample search | MCP Server | + +**Example Interactions:** + +| User Query | Tool(s) Called | +|------------|----------------| +| "What's the weather in Tokyo?" | `get_weather("Tokyo")` | +| "What's the weather in Paris and what's 18% tip on €75?" | `get_weather("Paris")` AND `calculate("75 * 0.18")` | +| "How do I configure partition keys in Azure Cosmos DB?" | `microsoft_docs_search("Cosmos DB partition keys")` via MCP | +| "Tell me a joke" | (No tools — direct response) | + +### 2. Cosmos DB Persistence Strategy + +**Two-Container Approach:** + +| Container | Purpose | Managed By | Partition Key | +|-----------|---------|------------|---------------| +| `threads` | Thread metadata (user_id, title, timestamps) | `CosmosConversationStore` (custom) | `/thread_id` | +| `messages` | Conversation messages | `CosmosHistoryProvider` (framework) | `/session_id` | + +> **Note:** Both containers are auto-created by the Python code at runtime with the correct partition keys. +> The Bicep infrastructure only provisions the Cosmos DB account and database — not the containers. + +**CosmosHistoryProvider** from `agent-framework-azure-cosmos` automatically: +- Loads conversation history before each agent run +- Stores user inputs and agent responses after each run +- Uses `session_id` (which equals `thread_id`) as the partition key +- Supports `source_id` field allowing multiple agents to share a container + +### 3. Azure Functions Hosting + +Using **HTTP Triggers** for a familiar REST API pattern: + +| Aspect | Choice | Rationale | +|--------|--------|-----------| +| Trigger Type | HTTP Triggers | Standard REST API pattern | +| Hosting Plan | Flex Consumption | Serverless scaling, cost-effective | +| Agent Lifecycle | Singleton pattern | Reused across invocations | +| Deployment | `azd up` | One-command infrastructure + code | + +### 4. MCP Integration for Microsoft Docs + +**Model Context Protocol (MCP)** provides access to official Microsoft documentation: +- Official Microsoft Learn documentation +- Azure service documentation +- Code samples and examples +- API references + +The integration uses `MCPStreamableHTTPTool` with per-request connections (serverless-friendly pattern). + +**Implementation** (in `services/agent_service.py`): +```python +MICROSOFT_LEARN_MCP_URL = "https://learn.microsoft.com/api/mcp" + +def get_mcp_tool() -> MCPStreamableHTTPTool: + return MCPStreamableHTTPTool( + name="Microsoft Learn", + url=MICROSOFT_LEARN_MCP_URL, + approval_mode="never_require", + ) + +# Usage in messages.py: +async with get_mcp_tool() as mcp: + response = await agent.run(content, session=session, tools=mcp) +``` + +**Key Points:** +- No local tool file needed — tools are discovered from the remote MCP server +- Tools (`microsoft_docs_search`, `microsoft_code_sample_search`) are injected at runtime +- Async context manager ensures proper connection lifecycle + +**Benefits:** +- ✅ Authoritative information from official sources +- ✅ Always current with latest product updates +- ✅ Reduces hallucination by grounding in actual documentation +- ✅ Real, tested code samples + +--- + +## API Design + +### Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/api/threads` | Create a new conversation thread | +| `GET` | `/api/threads` | List all threads (with optional filters) | +| `GET` | `/api/threads/{thread_id}` | Get thread metadata | +| `DELETE` | `/api/threads/{thread_id}` | Delete a thread and its messages | +| `POST` | `/api/threads/{thread_id}/messages` | Send a message and get response | +| `GET` | `/api/threads/{thread_id}/messages` | Get conversation history | +| `GET` | `/api/health` | Health check | + +### Query Parameters for List Threads + +| Parameter | Type | Description | +|-----------|------|-------------| +| `user_id` | string | Filter threads by user ID | +| `status` | string | Filter by status: `active`, `archived`, `deleted` | +| `limit` | int | Max threads to return (default 50, max 100) | +| `offset` | int | Skip N threads for pagination | + +### Request/Response Behavior + +| Endpoint | Behavior | +|----------|----------| +| **Create Thread** | Accepts optional `user_id`, `title`, `metadata`. Returns created thread with generated `thread_id`. | +| **Send Message** | Accepts `content` string. Agent loads history, processes request (with tools as needed), persists conversation. Returns assistant response with tool calls made. | +| **Delete Thread** | Removes thread metadata and clears all messages from the history provider. | + +--- + +## Observability + +### Design Principles + +1. **Don't duplicate framework instrumentation** — Use the Agent Framework's automatic spans for agent/LLM/tool tracing +2. **Fill the gaps** — Add manual spans only for layers the framework cannot see (HTTP, Cosmos DB, validation) +3. **Use framework APIs** — Leverage `setup_observability()`, `get_tracer()`, and `get_meter()` from `agent_framework` + +### Framework Built-in Instrumentation (Automatic) + +| Span Name Pattern | When Created | Key Attributes | +|---|---|---| +| `invoke_agent {agent_name}` | `agent.run()` | `gen_ai.agent.id`, `gen_ai.agent.name`, `gen_ai.conversation.id` | +| `chat {model_id}` | `chat_client.get_response()` | `gen_ai.request.model`, `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens` | +| `execute_tool {function_name}` | Tool invocations | `gen_ai.tool.name`, `gen_ai.tool.call.id`, `gen_ai.tool.type` | + +### Custom Spans (Manual) + +| Layer | Span Name Pattern | Purpose | +|-------|-------------------|---------| +| HTTP Request | `http.request {method} {path}` | Track request lifecycle | +| Cosmos DB | `cosmos.{operation} {container}` | Track database operations | +| Redis | `redis.{operation} {key_pattern}` | Track caching operations | +| AI Search | `ai_search.{operation} {index}` | Track search operations | +| Validation | `request.validate {operation}` | Track authorization checks | + +### Span Hierarchy + +```text +http.request POST /threads/{thread_id}/messages ← MANUAL (HTTP layer) +├── cosmos.read threads ← MANUAL (Cosmos layer) +├── request.validate verify_thread_ownership ← MANUAL (Validation) +├── invoke_agent ChatAgent ← FRAMEWORK (automatic) +│ ├── chat gpt-4o ← FRAMEWORK (automatic) +│ │ └── (internal LLM call spans) +│ └── execute_tool get_weather ← FRAMEWORK (automatic) +├── cosmos.upsert threads ← MANUAL (Cosmos layer) +└── http.response ← MANUAL (optional) +``` + +### Tool vs Non-Tool Service Calls + +| Scenario | Manual Span Needed? | Why | +|----------|---------------------|-----| +| Service called **as agent tool** | ❌ No | Framework creates `execute_tool` span automatically | +| Service called **outside agent** | ✅ Yes | Framework doesn't see calls outside `agent.run()` | +| Cosmos DB (thread storage) | ✅ Yes | Always called outside agent context | + +### Automatic Metrics + +| Metric Name | Description | +|---|---| +| `gen_ai.client.operation.duration` | Duration of LLM operations | +| `gen_ai.client.token.usage` | Token usage (input/output) | +| `agent_framework.function.invocation.duration` | Tool function execution duration | + +### Viewing Traces + +| Environment | Backend | +|-------------|---------| +| Local Development | Jaeger, Aspire Dashboard, or AI Toolkit Extension | +| Azure Production | Application Insights → Transaction Search or Application Map | + +--- + +## Security Considerations + +| Concern | Mitigation | +|---------|------------| +| **Authentication** | `DefaultAzureCredential` (supports Managed Identity, CLI, etc.) | +| **Thread Isolation** | Cosmos DB partition key on `thread_id` / `session_id` | +| **Secrets Management** | Environment variables (Key Vault recommended for production) | +| **Input Validation** | Request body validation in route handlers | + +--- + +## Project Structure + +```text +enterprise-chat-agent/ +├── function_app.py # Azure Functions entry point +├── requirements.txt # Dependencies +├── host.json # Functions host configuration +├── azure.yaml # azd deployment configuration +├── demo.http # API test file +├── demo-ui.html # Browser-based demo UI +├── services/ +│ ├── agent_service.py # ChatAgent + CosmosHistoryProvider +│ ├── cosmos_store.py # Thread metadata storage +│ └── observability.py # OpenTelemetry instrumentation +├── routes/ +│ ├── threads.py # Thread CRUD endpoints +│ ├── messages.py # Message endpoint +│ └── health.py # Health check +├── tools/ +│ ├── weather.py # Weather tool (local) +│ ├── calculator.py # Calculator tool (local) +│ └── knowledge_base.py # KB search tool (local) +├── docs/ # Additional documentation +└── infra/ + └── main.bicep # Azure infrastructure (Bicep) +``` + diff --git a/python/samples/05-end-to-end/enterprise-chat-agent/function_app.py b/python/samples/05-end-to-end/enterprise-chat-agent/function_app.py new file mode 100644 index 0000000000..b6e7708cdb --- /dev/null +++ b/python/samples/05-end-to-end/enterprise-chat-agent/function_app.py @@ -0,0 +1,28 @@ +# Copyright (c) Microsoft. All rights reserved. + +""" +Enterprise Chat Agent - Azure Functions Application + +This sample demonstrates a production-ready Chat API using Microsoft Agent Framework +with Azure Functions. The agent is configured with multiple tools and autonomously +decides which tools to invoke based on user intent. + +Key Features: +- Azure Functions HTTP triggers for REST API endpoints +- ChatAgent with runtime tool selection +- Cosmos DB for persistent thread and message storage +- OpenTelemetry observability with automatic and custom spans +""" + +import azure.functions as func +from routes import health_bp, messages_bp, threads_bp +from services import init_observability + +# Initialize observability once at startup +init_observability() + +# Create the Function App and register blueprints +app = func.FunctionApp() +app.register_functions(threads_bp) +app.register_functions(messages_bp) +app.register_functions(health_bp) diff --git a/python/samples/05-end-to-end/enterprise-chat-agent/host.json b/python/samples/05-end-to-end/enterprise-chat-agent/host.json new file mode 100644 index 0000000000..cd6a04cc05 --- /dev/null +++ b/python/samples/05-end-to-end/enterprise-chat-agent/host.json @@ -0,0 +1,21 @@ +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + } + }, + "logLevel": { + "default": "Information", + "azure.cosmos": "Warning", + "azure.core.pipeline.policies.http_logging_policy": "Warning" + } + }, + "telemetryMode": "OpenTelemetry", + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} diff --git a/python/samples/05-end-to-end/enterprise-chat-agent/infra/abbreviations.json b/python/samples/05-end-to-end/enterprise-chat-agent/infra/abbreviations.json new file mode 100644 index 0000000000..e078caf0d6 --- /dev/null +++ b/python/samples/05-end-to-end/enterprise-chat-agent/infra/abbreviations.json @@ -0,0 +1,8 @@ +{ + "documentDBDatabaseAccounts": "cosmos-", + "insightsComponents": "appi-", + "operationalInsightsWorkspaces": "log-", + "resourcesResourceGroups": "rg-", + "storageStorageAccounts": "st", + "webSitesFunctions": "func-" +} diff --git a/python/samples/05-end-to-end/enterprise-chat-agent/infra/core/database/cosmos-nosql.bicep b/python/samples/05-end-to-end/enterprise-chat-agent/infra/core/database/cosmos-nosql.bicep new file mode 100644 index 0000000000..25ed8e1684 --- /dev/null +++ b/python/samples/05-end-to-end/enterprise-chat-agent/infra/core/database/cosmos-nosql.bicep @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft. All rights reserved. +// Azure Cosmos DB NoSQL account and database +// Note: Containers are auto-created by CosmosHistoryProvider and CosmosConversationStore + +@description('Name of the Cosmos DB account') +param accountName string + +@description('Location for the Cosmos DB account') +param location string + +@description('Tags to apply to the Cosmos DB account') +param tags object = {} + +@description('Name of the database') +param databaseName string + +@description('Enable free tier (only one per subscription)') +param enableFreeTier bool = false + +@description('Default consistency level') +@allowed(['Eventual', 'ConsistentPrefix', 'Session', 'BoundedStaleness', 'Strong']) +param defaultConsistencyLevel string = 'Session' + +// ============================================================================ +// Cosmos DB Account +// ============================================================================ + +resource cosmosAccount 'Microsoft.DocumentDB/databaseAccounts@2023-11-15' = { + name: accountName + location: location + tags: tags + kind: 'GlobalDocumentDB' + properties: { + databaseAccountOfferType: 'Standard' + enableFreeTier: enableFreeTier + consistencyPolicy: { + defaultConsistencyLevel: defaultConsistencyLevel + } + locations: [ + { + locationName: location + failoverPriority: 0 + isZoneRedundant: false + } + ] + capabilities: [ + { + name: 'EnableServerless' + } + ] + // Security settings + publicNetworkAccess: 'Enabled' + disableLocalAuth: false + } +} + +// ============================================================================ +// Database +// ============================================================================ + +resource database 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases@2023-11-15' = { + parent: cosmosAccount + name: databaseName + properties: { + resource: { + id: databaseName + } + } +} + +// ============================================================================ +// Outputs +// ============================================================================ + +output accountId string = cosmosAccount.id +output accountName string = cosmosAccount.name +output endpoint string = cosmosAccount.properties.documentEndpoint +output databaseName string = database.name diff --git a/python/samples/05-end-to-end/enterprise-chat-agent/infra/core/host/function-app.bicep b/python/samples/05-end-to-end/enterprise-chat-agent/infra/core/host/function-app.bicep new file mode 100644 index 0000000000..5823bc3420 --- /dev/null +++ b/python/samples/05-end-to-end/enterprise-chat-agent/infra/core/host/function-app.bicep @@ -0,0 +1,195 @@ +// Copyright (c) Microsoft. All rights reserved. +// Azure Function App (Flex Consumption Plan) + +@description('Name of the Function App') +param name string + +@description('Location for the Function App') +param location string + +@description('Tags to apply to the Function App') +param tags object = {} + +@description('Name of the storage account') +param storageAccountName string + +@description('Name of the Application Insights resource') +param applicationInsightsName string + +@description('Name of the Cosmos DB account') +param cosmosAccountName string + +@description('Cosmos DB database name') +param cosmosDatabaseName string + +@description('Cosmos DB container name for messages') +param cosmosContainerName string + +@description('Cosmos DB container name for threads') +param cosmosThreadsContainerName string = 'threads' + +@description('Azure OpenAI endpoint URL') +param azureOpenAiEndpoint string = '' + +@description('Azure OpenAI model deployment name') +param azureOpenAiModel string = 'gpt-4o' + +// ============================================================================ +// References to existing resources +// ============================================================================ + +resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' existing = { + name: storageAccountName +} + +resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = { + name: applicationInsightsName +} + +resource cosmosAccount 'Microsoft.DocumentDB/databaseAccounts@2023-11-15' existing = { + name: cosmosAccountName +} + +// ============================================================================ +// App Service Plan (Flex Consumption) +// ============================================================================ + +resource flexPlan 'Microsoft.Web/serverfarms@2023-12-01' = { + name: '${name}-plan' + location: location + tags: tags + sku: { + tier: 'FlexConsumption' + name: 'FC1' + } + kind: 'functionapp' + properties: { + reserved: true // Required for Linux + } +} + +// ============================================================================ +// Function App +// ============================================================================ + +resource functionApp 'Microsoft.Web/sites@2023-12-01' = { + name: name + location: location + tags: union(tags, { 'azd-service-name': 'api' }) + kind: 'functionapp,linux' + identity: { + type: 'SystemAssigned' + } + properties: { + serverFarmId: flexPlan.id + httpsOnly: true + publicNetworkAccess: 'Enabled' + functionAppConfig: { + deployment: { + storage: { + type: 'blobContainer' + value: '${storageAccount.properties.primaryEndpoints.blob}deploymentpackage' + authentication: { + type: 'SystemAssignedIdentity' + } + } + } + scaleAndConcurrency: { + maximumInstanceCount: 100 + instanceMemoryMB: 2048 + } + runtime: { + name: 'python' + version: '3.11' + } + } + siteConfig: { + appSettings: [ + { + name: 'AzureWebJobsStorage__accountName' + value: storageAccount.name + } + { + name: 'FUNCTIONS_EXTENSION_VERSION' + value: '~4' + } + { + name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' + value: applicationInsights.properties.ConnectionString + } + { + name: 'AZURE_OPENAI_ENDPOINT' + value: azureOpenAiEndpoint + } + { + name: 'AZURE_OPENAI_DEPLOYMENT_NAME' + value: azureOpenAiModel + } + { + name: 'AZURE_COSMOS_ENDPOINT' + value: cosmosAccount.properties.documentEndpoint + } + { + name: 'AZURE_COSMOS_DATABASE_NAME' + value: cosmosDatabaseName + } + { + name: 'AZURE_COSMOS_CONTAINER_NAME' + value: cosmosContainerName + } + { + name: 'AZURE_COSMOS_THREADS_CONTAINER_NAME' + value: cosmosThreadsContainerName + } + ] + cors: { + allowedOrigins: [ + 'https://portal.azure.com' + ] + } + } + } +} + +// ============================================================================ +// Role Assignments for Storage (required for MI-based deployment) +// ============================================================================ + +// Storage Blob Data Owner role for Function App managed identity +var storageBlobDataOwnerRoleId = 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b' + +resource storageRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(storageAccount.id, functionApp.id, storageBlobDataOwnerRoleId) + scope: storageAccount + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', storageBlobDataOwnerRoleId) + principalId: functionApp.identity.principalId + principalType: 'ServicePrincipal' + } +} + +// ============================================================================ +// Role Assignments +// ============================================================================ + +// Cosmos DB Data Contributor role for Function App managed identity +var cosmosDataContributorRoleId = '00000000-0000-0000-0000-000000000002' // Cosmos DB Built-in Data Contributor + +resource cosmosRoleAssignment 'Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments@2023-11-15' = { + parent: cosmosAccount + name: guid(cosmosAccount.id, functionApp.id, cosmosDataContributorRoleId) + properties: { + roleDefinitionId: '${cosmosAccount.id}/sqlRoleDefinitions/${cosmosDataContributorRoleId}' + principalId: functionApp.identity.principalId + scope: cosmosAccount.id + } +} + +// ============================================================================ +// Outputs +// ============================================================================ + +output id string = functionApp.id +output name string = functionApp.name +output url string = 'https://${functionApp.properties.defaultHostName}' +output principalId string = functionApp.identity.principalId diff --git a/python/samples/05-end-to-end/enterprise-chat-agent/infra/core/monitor/monitoring.bicep b/python/samples/05-end-to-end/enterprise-chat-agent/infra/core/monitor/monitoring.bicep new file mode 100644 index 0000000000..e39693e8ba --- /dev/null +++ b/python/samples/05-end-to-end/enterprise-chat-agent/infra/core/monitor/monitoring.bicep @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft. All rights reserved. +// Monitoring resources: Log Analytics Workspace and Application Insights + +@description('Location for all resources') +param location string + +@description('Tags to apply to all resources') +param tags object = {} + +@description('Name of the Log Analytics workspace') +param logAnalyticsName string + +@description('Name of the Application Insights resource') +param applicationInsightsName string + +// ============================================================================ +// Log Analytics Workspace +// ============================================================================ + +resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2022-10-01' = { + name: logAnalyticsName + location: location + tags: tags + properties: { + sku: { + name: 'PerGB2018' + } + retentionInDays: 30 + } +} + +// ============================================================================ +// Application Insights +// ============================================================================ + +resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = { + name: applicationInsightsName + location: location + tags: tags + kind: 'web' + properties: { + Application_Type: 'web' + WorkspaceResourceId: logAnalytics.id + } +} + +// ============================================================================ +// Outputs +// ============================================================================ + +output logAnalyticsWorkspaceId string = logAnalytics.id +output logAnalyticsWorkspaceName string = logAnalytics.name +output applicationInsightsId string = applicationInsights.id +output applicationInsightsName string = applicationInsights.name +output applicationInsightsConnectionString string = applicationInsights.properties.ConnectionString diff --git a/python/samples/05-end-to-end/enterprise-chat-agent/infra/core/storage/storage-account.bicep b/python/samples/05-end-to-end/enterprise-chat-agent/infra/core/storage/storage-account.bicep new file mode 100644 index 0000000000..6149c555a6 --- /dev/null +++ b/python/samples/05-end-to-end/enterprise-chat-agent/infra/core/storage/storage-account.bicep @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft. All rights reserved. +// Azure Storage Account for Function App + +@description('Name of the storage account') +param name string + +@description('Location for the storage account') +param location string + +@description('Tags to apply to the storage account') +param tags object = {} + +@description('Storage account SKU') +@allowed(['Standard_LRS', 'Standard_GRS', 'Standard_ZRS']) +param sku string = 'Standard_LRS' + +// ============================================================================ +// Storage Account +// ============================================================================ + +resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' = { + name: name + location: location + tags: tags + sku: { + name: sku + } + kind: 'StorageV2' + properties: { + minimumTlsVersion: 'TLS1_2' + allowBlobPublicAccess: false + supportsHttpsTrafficOnly: true + allowSharedKeyAccess: true // Required for Function App deployment + accessTier: 'Hot' + } +} + +// Blob service for Function App deployment packages +resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2023-01-01' = { + parent: storageAccount + name: 'default' +} + +// Container for deployment packages (required for Flex Consumption) +resource deploymentContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-01-01' = { + parent: blobService + name: 'deploymentpackage' + properties: { + publicAccess: 'None' + } +} + +// ============================================================================ +// Outputs +// ============================================================================ + +output id string = storageAccount.id +output name string = storageAccount.name +output primaryEndpoints object = storageAccount.properties.primaryEndpoints diff --git a/python/samples/05-end-to-end/enterprise-chat-agent/infra/main.bicep b/python/samples/05-end-to-end/enterprise-chat-agent/infra/main.bicep new file mode 100644 index 0000000000..73449968da --- /dev/null +++ b/python/samples/05-end-to-end/enterprise-chat-agent/infra/main.bicep @@ -0,0 +1,143 @@ +// Copyright (c) Microsoft. All rights reserved. +// Enterprise Chat Agent - Infrastructure as Code +// +// This Bicep template deploys: +// - Azure Function App (Flex Consumption) +// - Azure Cosmos DB (NoSQL) +// - Azure OpenAI (optional, can use existing) +// - Supporting resources (Storage, App Insights, Log Analytics) + +targetScope = 'subscription' + +// ============================================================================ +// Parameters +// ============================================================================ + +@minLength(1) +@maxLength(64) +@description('Name of the environment (e.g., dev, staging, prod)') +param environmentName string + +@minLength(1) +@description('Primary location for all resources') +param location string + +@description('Name of the resource group') +param resourceGroupName string = '' + +@description('Azure OpenAI endpoint URL (leave empty to create new)') +param azureOpenAiEndpoint string = '' + +@description('Azure OpenAI model deployment name') +param azureOpenAiModel string = 'gpt-4o' + +@description('Cosmos DB database name') +param cosmosDatabaseName string = 'chat_db' + +@description('Cosmos DB container name for messages') +param cosmosContainerName string = 'messages' + +@description('Cosmos DB container name for threads') +param cosmosThreadsContainerName string = 'threads' + +// ============================================================================ +// Variables +// ============================================================================ + +var abbrs = loadJsonContent('./abbreviations.json') +var tags = { 'azd-env-name': environmentName } +var resourceToken = toLower(uniqueString(subscription().id, environmentName, location)) + +// ============================================================================ +// Resource Group +// ============================================================================ + +resource rg 'Microsoft.Resources/resourceGroups@2022-09-01' = { + name: !empty(resourceGroupName) ? resourceGroupName : '${abbrs.resourcesResourceGroups}${environmentName}' + location: location + tags: tags +} + +// ============================================================================ +// Monitoring (Log Analytics + App Insights) +// ============================================================================ + +module monitoring './core/monitor/monitoring.bicep' = { + name: 'monitoring' + scope: rg + params: { + location: location + tags: tags + logAnalyticsName: '${abbrs.operationalInsightsWorkspaces}${resourceToken}' + applicationInsightsName: '${abbrs.insightsComponents}${resourceToken}' + } +} + +// ============================================================================ +// Storage Account (for Function App) +// ============================================================================ + +module storage './core/storage/storage-account.bicep' = { + name: 'storage' + scope: rg + params: { + name: '${abbrs.storageStorageAccounts}${resourceToken}' + location: location + tags: tags + } +} + +// ============================================================================ +// Cosmos DB +// ============================================================================ + +module cosmos './core/database/cosmos-nosql.bicep' = { + name: 'cosmos' + scope: rg + params: { + accountName: '${abbrs.documentDBDatabaseAccounts}${resourceToken}' + location: location + tags: tags + databaseName: cosmosDatabaseName + } +} + +// ============================================================================ +// Function App +// ============================================================================ + +module functionApp './core/host/function-app.bicep' = { + name: 'functionApp' + scope: rg + params: { + name: '${abbrs.webSitesFunctions}${resourceToken}' + location: location + tags: tags + storageAccountName: storage.outputs.name + applicationInsightsName: monitoring.outputs.applicationInsightsName + cosmosAccountName: cosmos.outputs.accountName + cosmosDatabaseName: cosmosDatabaseName + cosmosContainerName: cosmosContainerName + cosmosThreadsContainerName: cosmosThreadsContainerName + azureOpenAiEndpoint: azureOpenAiEndpoint + azureOpenAiModel: azureOpenAiModel + } +} + +// ============================================================================ +// Outputs +// ============================================================================ + +output AZURE_LOCATION string = location +output AZURE_TENANT_ID string = tenant().tenantId +output AZURE_RESOURCE_GROUP string = rg.name + +output AZURE_FUNCTION_APP_NAME string = functionApp.outputs.name +output AZURE_FUNCTION_APP_URL string = functionApp.outputs.url + +output AZURE_COSMOS_ENDPOINT string = cosmos.outputs.endpoint +output AZURE_COSMOS_DATABASE_NAME string = cosmosDatabaseName +output AZURE_COSMOS_CONTAINER_NAME string = cosmosContainerName +output AZURE_COSMOS_THREADS_CONTAINER_NAME string = cosmosThreadsContainerName + +output APPLICATIONINSIGHTS_CONNECTION_STRING string = monitoring.outputs.applicationInsightsConnectionString diff --git a/python/samples/05-end-to-end/enterprise-chat-agent/infra/main.parameters.json b/python/samples/05-end-to-end/enterprise-chat-agent/infra/main.parameters.json new file mode 100644 index 0000000000..24e79cf2e9 --- /dev/null +++ b/python/samples/05-end-to-end/enterprise-chat-agent/infra/main.parameters.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "environmentName": { + "value": "${AZURE_ENV_NAME}" + }, + "location": { + "value": "${AZURE_LOCATION}" + }, + "azureOpenAiEndpoint": { + "value": "${AZURE_OPENAI_ENDPOINT}" + }, + "azureOpenAiModel": { + "value": "${AZURE_OPENAI_MODEL=gpt-4o}" + } + } +} diff --git a/python/samples/05-end-to-end/enterprise-chat-agent/local.settings.json.example b/python/samples/05-end-to-end/enterprise-chat-agent/local.settings.json.example new file mode 100644 index 0000000000..d96c7d89a2 --- /dev/null +++ b/python/samples/05-end-to-end/enterprise-chat-agent/local.settings.json.example @@ -0,0 +1,23 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "AzureWebJobsFeatureFlags": "EnableWorkerIndexing", + "FUNCTIONS_WORKER_RUNTIME": "python", + "AZURE_OPENAI_ENDPOINT": "https://your-resource.openai.azure.com/", + "AZURE_OPENAI_DEPLOYMENT_NAME": "gpt-4o", + "AZURE_OPENAI_API_VERSION": "2024-10-21", + "AZURE_COSMOS_ENDPOINT": "https://your-cosmos-account.documents.azure.com:443/", + "AZURE_COSMOS_DATABASE_NAME": "chat_db", + "AZURE_COSMOS_CONTAINER_NAME": "messages", + "AZURE_COSMOS_THREADS_CONTAINER_NAME": "threads", + "APPLICATIONINSIGHTS_CONNECTION_STRING": "", + "ENABLE_INSTRUMENTATION": "true", + "ENABLE_SENSITIVE_DATA": "false", + "ENABLE_DEBUG_ENDPOINTS": "false" + }, + "Host": { + "CORS": "*", + "CORSCredentials": false + } +} diff --git a/python/samples/05-end-to-end/enterprise-chat-agent/prompts/system_prompt.txt b/python/samples/05-end-to-end/enterprise-chat-agent/prompts/system_prompt.txt new file mode 100644 index 0000000000..2564ab0a37 --- /dev/null +++ b/python/samples/05-end-to-end/enterprise-chat-agent/prompts/system_prompt.txt @@ -0,0 +1,28 @@ +You are a helpful enterprise chat assistant with access to multiple tools: + +**Microsoft Documentation (via MCP):** +- microsoft_docs_search: Search official Microsoft and Azure documentation +- microsoft_code_sample_search: Find code examples from Microsoft Learn + +**Internal Knowledge:** +- search_knowledge_base: Search internal company policies and FAQs + +**Utility Tools:** +- get_weather: Get current weather information for any location +- calculate: Evaluate mathematical expressions safely + +**When to use each tool:** +- Use microsoft_docs_search for questions about Azure, Microsoft products, cloud architecture +- Use microsoft_code_sample_search when users need code examples or implementation details +- Use search_knowledge_base for company-specific policies, procedures, and FAQs +- Use get_weather for weather-related questions +- use calculate for mathematical computations + +**Best practices:** +1. Determine which tools are needed based on the question +2. Call appropriate tools to gather authoritative information +3. Provide clear responses with citations and sources +4. For Azure/Microsoft questions, check official docs first +5. Be concise but thorough + +Always cite your sources, especially when referencing documentation. diff --git a/python/samples/05-end-to-end/enterprise-chat-agent/requirements.txt b/python/samples/05-end-to-end/enterprise-chat-agent/requirements.txt new file mode 100644 index 0000000000..b9cfdc2338 --- /dev/null +++ b/python/samples/05-end-to-end/enterprise-chat-agent/requirements.txt @@ -0,0 +1,33 @@ +# Azure Functions +azure-functions>=1.21.0 + +# Microsoft Agent Framework (rc5 - March 2026) +# See: https://learn.microsoft.com/agent-framework/support/upgrade/python-2026-significant-changes +agent-framework>=1.0.0rc5,<2.0.0 + +# Azure Cosmos DB History Provider +# Provides CosmosHistoryProvider for automatic conversation history persistence +agent-framework-azure-cosmos>=1.0.0b260311,<2.0.0 + +# Azure SDK +azure-identity>=1.15.0 + +# Azure OpenAI (used directly until Agent Framework packages are published) +openai>=1.0.0 + +# MCP Client (for Microsoft Learn documentation tools) +mcp>=1.0.0 + +# OpenTelemetry Core +opentelemetry-api>=1.25.0 +opentelemetry-sdk>=1.25.0 + +# OpenTelemetry Exporters +opentelemetry-exporter-otlp>=1.25.0 +azure-monitor-opentelemetry-exporter>=1.0.0b41 + +# OpenTelemetry Semantic Conventions (matches agent_framework) +opentelemetry-semantic-conventions-ai>=0.4.13 + +# Utilities +pydantic>=2.0.0 diff --git a/python/samples/05-end-to-end/enterprise-chat-agent/routes/__init__.py b/python/samples/05-end-to-end/enterprise-chat-agent/routes/__init__.py new file mode 100644 index 0000000000..19ce83a5c8 --- /dev/null +++ b/python/samples/05-end-to-end/enterprise-chat-agent/routes/__init__.py @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft. All rights reserved. + +""" +Route blueprints for Enterprise Chat Agent. + +This package contains Azure Functions blueprints organized by resource: +- threads: Thread CRUD operations +- messages: Message send/retrieve operations +- health: Health check endpoint +""" + +from routes.health import bp as health_bp +from routes.messages import bp as messages_bp +from routes.threads import bp as threads_bp +from routes.threads import close_store + +__all__ = ["threads_bp", "messages_bp", "health_bp", "close_store"] diff --git a/python/samples/05-end-to-end/enterprise-chat-agent/routes/health.py b/python/samples/05-end-to-end/enterprise-chat-agent/routes/health.py new file mode 100644 index 0000000000..a11976d76c --- /dev/null +++ b/python/samples/05-end-to-end/enterprise-chat-agent/routes/health.py @@ -0,0 +1,45 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Health check endpoint.""" + +import json +import logging + +import azure.functions as func + +from routes.threads import get_store + +bp = func.Blueprint() + + +@bp.route(route="health", methods=["GET"]) +async def health_check(req: func.HttpRequest) -> func.HttpResponse: + """ + Health check endpoint for monitoring. + + Request: + GET /api/health + + Response: + 200 OK + {"status": "healthy", "version": "1.0.0", "cosmos_connected": true} + """ + cosmos_connected = False + try: + store = get_store() + # Simple connectivity check - initializes connection if needed + _ = store.container + cosmos_connected = True + except Exception as e: + logging.warning(f"Cosmos DB connectivity check failed: {e}") + + return func.HttpResponse( + body=json.dumps( + { + "status": "healthy", + "version": "1.0.0", + "cosmos_connected": cosmos_connected, + } + ), + mimetype="application/json", + ) diff --git a/python/samples/05-end-to-end/enterprise-chat-agent/routes/messages.py b/python/samples/05-end-to-end/enterprise-chat-agent/routes/messages.py new file mode 100644 index 0000000000..0033eb88be --- /dev/null +++ b/python/samples/05-end-to-end/enterprise-chat-agent/routes/messages.py @@ -0,0 +1,216 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Message endpoints for thread conversations.""" + +import json +import logging +import os +from datetime import datetime, timezone + +import azure.functions as func +from agent_framework import AgentSession +from services import ( + cosmos_span, + get_agent, + get_history_provider, + get_mcp_tool, + http_request_span, +) + +from routes.threads import get_store + +# Only log message content when explicitly enabled (PII protection) +ENABLE_SENSITIVE_DATA = os.environ.get("ENABLE_SENSITIVE_DATA", "").lower() == "true" + +bp = func.Blueprint() + + +@bp.route(route="threads/{thread_id}/messages", methods=["POST"]) +async def send_message(req: func.HttpRequest) -> func.HttpResponse: + """ + Send a message to the agent and get a response. + + The agent uses: + - CosmosHistoryProvider for automatic conversation history persistence + - MCPStreamableHTTPTool for Microsoft Learn documentation search + - Local tools for weather, calculator, and knowledge base + + Request: + POST /api/threads/{thread_id}/messages + Body: {"content": "What's the weather in Seattle?"} + + Response: + 200 OK + { + "thread_id": "thread_xxx", + "role": "assistant", + "content": "The weather in Seattle is...", + "tool_calls": [...], + "timestamp": "..." + } + """ + thread_id = req.route_params.get("thread_id") + + async with http_request_span( + "POST", "/threads/{thread_id}/messages", thread_id=thread_id + ) as span: + store = get_store() + + # Check if thread exists + async with cosmos_span("read", "threads", thread_id): + thread_exists = await store.thread_exists(thread_id) + + if not thread_exists: + span.set_attribute("http.status_code", 404) + return func.HttpResponse( + body=json.dumps({"error": "Thread not found"}), + status_code=404, + mimetype="application/json", + ) + + try: + body = req.get_json() + content = body.get("content") + if not content: + span.set_attribute("http.status_code", 400) + return func.HttpResponse( + body=json.dumps({"error": "Missing 'content' in request body"}), + status_code=400, + mimetype="application/json", + ) + except ValueError: + span.set_attribute("http.status_code", 400) + return func.HttpResponse( + body=json.dumps({"error": "Invalid JSON body"}), + status_code=400, + mimetype="application/json", + ) + + # Get agent (configured with CosmosHistoryProvider and local tools) + agent = get_agent() + + # Create session with thread_id so CosmosHistoryProvider uses it + session = AgentSession(session_id=thread_id) + logging.info(f"Running agent with session_id={session.session_id}") + + # Run agent with MCP tools for Microsoft Learn documentation + # The agent combines: + # - Local tools: get_weather, calculate, search_knowledge_base + # - MCP tools: microsoft_docs_search, microsoft_code_sample_search + async with get_mcp_tool() as mcp: + response = await agent.run( + content, + session=session, # Pass session object, not session_id + tools=mcp, # Add MCP tools for this run + ) + + # Extract response content and tool calls + response_content = response.text or "" + tool_calls = [] + + # Parse tool calls from response if any + if hasattr(response, "tool_calls") and response.tool_calls: + for tool_call in response.tool_calls: + tool_calls.append( + { + "tool": getattr(tool_call, "name", str(tool_call)), + "arguments": getattr(tool_call, "arguments", {}), + } + ) + + # Update thread metadata with last message preview + async with cosmos_span("update", "threads", thread_id): + preview = ( + response_content[:100] + "..." + if len(response_content) > 100 + else response_content + ) + await store.update_thread( + thread_id=thread_id, + last_message_preview=preview, + ) + + logging.info( + f"Processed message for thread {thread_id}, " + f"tools used: {[t['tool'] for t in tool_calls]}" + ) + + # Build response + result = { + "thread_id": thread_id, + "role": "assistant", + "content": response_content, + "tool_calls": tool_calls if tool_calls else None, + "timestamp": datetime.now(timezone.utc).isoformat(), + } + + span.set_attribute("http.status_code", 200) + return func.HttpResponse( + body=json.dumps(result), + mimetype="application/json", + ) + + +@bp.route(route="threads/{thread_id}/messages", methods=["GET"]) +async def get_messages(req: func.HttpRequest) -> func.HttpResponse: + """ + Get conversation history for a thread from CosmosHistoryProvider. + + Request: + GET /api/threads/{thread_id}/messages + + Response: + 200 OK + {"messages": [...]} + """ + thread_id = req.route_params.get("thread_id") + + async with http_request_span( + "GET", "/threads/{thread_id}/messages", thread_id=thread_id + ) as span: + store = get_store() + + # Check if thread exists + async with cosmos_span("read", "threads", thread_id): + thread_exists = await store.thread_exists(thread_id) + + if not thread_exists: + span.set_attribute("http.status_code", 404) + return func.HttpResponse( + body=json.dumps({"error": "Thread not found"}), + status_code=404, + mimetype="application/json", + ) + + # Get messages from CosmosHistoryProvider + history_provider = get_history_provider() + async with cosmos_span("query", "messages", thread_id): + messages = await history_provider.get_messages(session_id=thread_id) + + logging.info(f"Retrieved {len(messages)} messages for thread {thread_id}") + + # Convert Message objects to serializable dicts + # Message has .role (str) and .text (property that concatenates all TextContent) + message_list = [] + for idx, msg in enumerate(messages): + role = msg.role.value if hasattr(msg.role, "value") else str(msg.role) + # Use the .text property which concatenates all text contents + content = msg.text if hasattr(msg, "text") else "" + if ENABLE_SENSITIVE_DATA: + logging.info( + f"Message {idx}: role={role}, " + f"content={content[:100] if content else 'empty'}..." + ) + message_list.append( + { + "role": role, + "content": content, + } + ) + + logging.info(f"Returning {len(message_list)} serialized messages") + span.set_attribute("http.status_code", 200) + return func.HttpResponse( + body=json.dumps({"messages": message_list}), + mimetype="application/json", + ) diff --git a/python/samples/05-end-to-end/enterprise-chat-agent/routes/threads.py b/python/samples/05-end-to-end/enterprise-chat-agent/routes/threads.py new file mode 100644 index 0000000000..35d19de891 --- /dev/null +++ b/python/samples/05-end-to-end/enterprise-chat-agent/routes/threads.py @@ -0,0 +1,268 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Thread management endpoints.""" + +import json +import logging +import os +import uuid + +import azure.functions as func +from services import ( + CosmosConversationStore, + cosmos_span, + get_history_provider, + http_request_span, +) + +# Debug endpoints are disabled by default for security +DEBUG_ENDPOINTS_ENABLED = os.environ.get("ENABLE_DEBUG_ENDPOINTS", "").lower() == "true" + +bp = func.Blueprint() + +# Cosmos DB store (lazy singleton) +_store: CosmosConversationStore | None = None + + +def get_store() -> CosmosConversationStore: + """Get or create the Cosmos DB conversation store instance.""" + global _store + if _store is None: + _store = CosmosConversationStore() + logging.info("Initialized Cosmos DB conversation store") + return _store + + +async def close_store() -> None: + """Close the Cosmos DB conversation store and release resources.""" + global _store + if _store is not None: + await _store.close() + _store = None + logging.info("Closed Cosmos DB conversation store") + + +@bp.route(route="threads", methods=["POST"]) +async def create_thread(req: func.HttpRequest) -> func.HttpResponse: + """ + Create a new conversation thread. + + Request: + POST /api/threads + Body: {"user_id": "...", "title": "...", "metadata": {...}} + + Response: + 201 Created + {"id": "thread_xxx", "created_at": "...", ...} + """ + try: + body = req.get_json() if req.get_body() else {} + except ValueError: + body = {} + + thread_id = f"thread_{uuid.uuid4().hex[:12]}" + user_id = body.get("user_id", "anonymous") + title = body.get("title") + metadata = body.get("metadata", {}) + + async with http_request_span("POST", "/threads", user_id=user_id) as span: + store = get_store() + async with cosmos_span("create", "threads", thread_id): + thread = await store.create_thread(thread_id, user_id, title, metadata) + + logging.info(f"Created thread {thread_id}") + + span.set_attribute("http.status_code", 201) + return func.HttpResponse( + body=json.dumps(thread), + status_code=201, + mimetype="application/json", + ) + + +@bp.route(route="threads", methods=["GET"]) +async def list_threads(req: func.HttpRequest) -> func.HttpResponse: + """ + List all conversation threads. + + Query Parameters: + user_id: Filter by user ID (optional) + status: Filter by status - 'active', 'archived', 'deleted' (optional) + limit: Maximum number of threads to return (default 50, max 100) + offset: Number of threads to skip for pagination (default 0) + + Request: + GET /api/threads + GET /api/threads?user_id=user_1234 + GET /api/threads?status=active&limit=20 + + Response: + 200 OK + { + "threads": [...], + "count": 10, + "limit": 50, + "offset": 0 + } + """ + user_id = req.params.get("user_id") + status = req.params.get("status") + + try: + limit = min(int(req.params.get("limit", 50)), 100) + except ValueError: + limit = 50 + + try: + offset = max(int(req.params.get("offset", 0)), 0) + except ValueError: + offset = 0 + + async with http_request_span("GET", "/threads", user_id=user_id) as span: + store = get_store() + async with cosmos_span("query", "threads", "list"): + threads = await store.list_threads( + user_id=user_id, + status=status, + limit=limit, + offset=offset, + ) + + result = { + "threads": threads, + "count": len(threads), + "limit": limit, + "offset": offset, + } + + logging.info( + f"Listed {len(threads)} threads (user_id={user_id}, status={status})" + ) + + span.set_attribute("http.status_code", 200) + return func.HttpResponse( + body=json.dumps(result), + mimetype="application/json", + ) + + +@bp.route(route="threads/{thread_id}", methods=["GET"]) +async def get_thread(req: func.HttpRequest) -> func.HttpResponse: + """ + Get thread metadata. + + Request: + GET /api/threads/{thread_id} + + Response: + 200 OK + {"id": "thread_xxx", "created_at": "...", ...} + """ + thread_id = req.route_params.get("thread_id") + + async with http_request_span( + "GET", "/threads/{thread_id}", thread_id=thread_id + ) as span: + store = get_store() + async with cosmos_span("read", "threads", thread_id): + thread = await store.get_thread(thread_id) + + if thread is None: + span.set_attribute("http.status_code", 404) + return func.HttpResponse( + body=json.dumps({"error": "Thread not found"}), + status_code=404, + mimetype="application/json", + ) + + span.set_attribute("http.status_code", 200) + return func.HttpResponse( + body=json.dumps(thread), + mimetype="application/json", + ) + + +@bp.route(route="threads/{thread_id}", methods=["DELETE"]) +async def delete_thread(req: func.HttpRequest) -> func.HttpResponse: + """ + Delete a thread and its messages. + + Deletes both the thread metadata and all messages stored by + CosmosHistoryProvider for this thread's session. + + Request: + DELETE /api/threads/{thread_id} + + Response: + 204 No Content + """ + thread_id = req.route_params.get("thread_id") + + async with http_request_span( + "DELETE", "/threads/{thread_id}", thread_id=thread_id + ) as span: + store = get_store() + + # Delete thread metadata + async with cosmos_span("delete", "threads", thread_id): + deleted = await store.delete_thread(thread_id) + + if not deleted: + span.set_attribute("http.status_code", 404) + return func.HttpResponse( + body=json.dumps({"error": "Thread not found"}), + status_code=404, + mimetype="application/json", + ) + + # Clear messages from CosmosHistoryProvider + history_provider = get_history_provider() + async with cosmos_span("delete", "messages", thread_id): + await history_provider.clear(session_id=thread_id) + + logging.info(f"Deleted thread {thread_id} and cleared messages") + + span.set_attribute("http.status_code", 204) + return func.HttpResponse(status_code=204) + + +@bp.route(route="debug/sessions", methods=["GET"]) +async def debug_list_sessions(req: func.HttpRequest) -> func.HttpResponse: + """ + Debug endpoint to list all session_ids that have messages in CosmosHistoryProvider. + This helps diagnose mismatches between thread_ids and session_ids. + + SECURITY: Disabled by default. Set ENABLE_DEBUG_ENDPOINTS=true to enable. + + GET /api/debug/sessions + """ + if not DEBUG_ENDPOINTS_ENABLED: + return func.HttpResponse( + body=json.dumps({"error": "Debug endpoints are disabled"}), + status_code=404, + mimetype="application/json", + ) + + history_provider = get_history_provider() + + try: + sessions = await history_provider.list_sessions() + + return func.HttpResponse( + body=json.dumps( + { + "sessions": sessions, + "count": len(sessions), + "source_id": history_provider.source_id, + "note": "Session IDs from messages container. Should match thread_ids.", + } + ), + mimetype="application/json", + ) + except Exception as e: + logging.error(f"Failed to list sessions: {e}") + return func.HttpResponse( + body=json.dumps({"error": str(e)}), + status_code=500, + mimetype="application/json", + ) diff --git a/python/samples/05-end-to-end/enterprise-chat-agent/services/__init__.py b/python/samples/05-end-to-end/enterprise-chat-agent/services/__init__.py new file mode 100644 index 0000000000..039c38c1a1 --- /dev/null +++ b/python/samples/05-end-to-end/enterprise-chat-agent/services/__init__.py @@ -0,0 +1,38 @@ +# Copyright (c) Microsoft. All rights reserved. + +""" +Core modules for Enterprise Chat Agent. + +This package contains foundational components: +- cosmos_store: Azure Cosmos DB storage for thread metadata +- observability: OpenTelemetry instrumentation for tracing +- agent_service: ChatAgent with CosmosHistoryProvider and MCP integration +""" + +from services.agent_service import ( + close_providers, + get_agent, + get_history_provider, + get_mcp_tool, +) +from services.cosmos_store import CosmosConversationStore +from services.observability import ( + EnterpriseAgentAttr, + cosmos_span, + http_request_span, + init_observability, + validation_span, +) + +__all__ = [ + "CosmosConversationStore", + "init_observability", + "http_request_span", + "cosmos_span", + "validation_span", + "EnterpriseAgentAttr", + "get_agent", + "get_history_provider", + "get_mcp_tool", + "close_providers", +] diff --git a/python/samples/05-end-to-end/enterprise-chat-agent/services/agent_service.py b/python/samples/05-end-to-end/enterprise-chat-agent/services/agent_service.py new file mode 100644 index 0000000000..0e2d2b70ec --- /dev/null +++ b/python/samples/05-end-to-end/enterprise-chat-agent/services/agent_service.py @@ -0,0 +1,186 @@ +# Copyright (c) Microsoft. All rights reserved. + +""" +Chat Agent Service + +Provides an Agent instance configured with CosmosHistoryProvider for +automatic conversation history persistence, local tools for weather, +calculation, and knowledge base search, plus MCP integration for +Microsoft Learn documentation search. +""" + +import logging +import os +from pathlib import Path + +from agent_framework import Agent, MCPStreamableHTTPTool +from agent_framework.azure import AzureOpenAIChatClient +from agent_framework_azure_cosmos import CosmosHistoryProvider +from azure.identity import DefaultAzureCredential +from tools import ( + calculate, + get_weather, + search_knowledge_base, +) + +_history_provider: CosmosHistoryProvider | None = None +_agent: Agent | None = None +_credential: DefaultAzureCredential | None = None + +# Prompts directory +_PROMPTS_DIR = Path(__file__).parent.parent / "prompts" + + +def _load_prompt(name: str) -> str: + """Load a prompt from the prompts directory.""" + prompt_path = _PROMPTS_DIR / f"{name}.txt" + return prompt_path.read_text(encoding="utf-8") + + +# Microsoft Learn MCP server URL +MICROSOFT_LEARN_MCP_URL = "https://learn.microsoft.com/api/mcp" + + +def get_history_provider() -> CosmosHistoryProvider: + """ + Get or create the singleton CosmosHistoryProvider instance. + + The provider automatically: + - Loads conversation history before each agent run + - Stores user inputs and agent responses + - Uses session_id as the Cosmos DB partition key + + Returns: + Configured CosmosHistoryProvider instance. + """ + global _history_provider, _credential + + if _history_provider is None: + endpoint = os.environ.get("AZURE_COSMOS_ENDPOINT") + database_name = os.environ.get("AZURE_COSMOS_DATABASE_NAME", "chat_db") + container_name = os.environ.get("AZURE_COSMOS_CONTAINER_NAME", "messages") + + if not endpoint: + raise ValueError("AZURE_COSMOS_ENDPOINT environment variable is required") + + if _credential is None: + _credential = DefaultAzureCredential() + + _history_provider = CosmosHistoryProvider( + source_id="enterprise_chat_agent", + endpoint=endpoint, + database_name=database_name, + container_name=container_name, + credential=_credential, + load_messages=True, # Load history before each run + store_inputs=True, # Store user messages + store_outputs=True, # Store assistant responses + ) + + logging.info( + f"Initialized CosmosHistoryProvider with database={database_name}, " + f"container={container_name}" + ) + + return _history_provider + + +def get_agent() -> Agent: + """ + Get or create the singleton Agent instance. + + The agent is configured with: + - Azure OpenAI chat client + - CosmosHistoryProvider for automatic conversation persistence + - Weather, calculator, and knowledge base tools + - System instructions for enterprise chat support + + Returns: + Configured Agent instance. + """ + global _agent + + if _agent is None: + # Get Azure OpenAI configuration from environment + endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT") + deployment_name = os.environ.get("AZURE_OPENAI_DEPLOYMENT_NAME", "gpt-4o") + api_version = os.environ.get("AZURE_OPENAI_API_VERSION", "2024-10-21") + + if not endpoint: + raise ValueError("AZURE_OPENAI_ENDPOINT environment variable is required") + + # Create Azure OpenAI chat client with credential + global _credential + if _credential is None: + _credential = DefaultAzureCredential() + + chat_client = AzureOpenAIChatClient( + endpoint=endpoint, + deployment_name=deployment_name, + api_version=api_version, + credential=_credential, + ) + + # Get the history provider + history_provider = get_history_provider() + + # Load system instructions from prompts folder + instructions = _load_prompt("system_prompt") + + # Create Agent with local tools and history provider + # MCP tools are added at runtime via run() method + _agent = Agent( + client=chat_client, + instructions=instructions, + tools=[ + get_weather, + calculate, + search_knowledge_base, + ], + context_providers=[history_provider], # Auto-persist history + name="EnterpriseAssistant", + ) + + logging.info( + f"Initialized Agent with deployment {deployment_name}, CosmosHistoryProvider, " + "and local tools: get_weather, calculate, search_knowledge_base" + ) + + return _agent + + +def get_mcp_tool() -> MCPStreamableHTTPTool: + """ + Create an MCPStreamableHTTPTool for Microsoft Learn documentation. + + This connects to the Microsoft Learn MCP server which provides: + - microsoft_docs_search: Search Microsoft documentation + - microsoft_code_sample_search: Search code samples + + The tool should be used as an async context manager: + async with get_mcp_tool() as mcp: + response = await agent.run(content, session_id=thread_id, tools=mcp) + + Returns: + Configured MCPStreamableHTTPTool instance. + """ + return MCPStreamableHTTPTool( + name="Microsoft Learn", + url=MICROSOFT_LEARN_MCP_URL, + description="Search Microsoft and Azure documentation and code samples", + approval_mode="never_require", # Auto-approve tool calls for docs search + ) + + +async def close_providers() -> None: + """Close the history provider and conversation store, and release resources.""" + global _history_provider + if _history_provider is not None: + await _history_provider.close() + _history_provider = None + logging.info("Closed CosmosHistoryProvider") + + # Close the conversation store (imported here to avoid circular imports) + from routes.threads import close_store + + await close_store() diff --git a/python/samples/05-end-to-end/enterprise-chat-agent/services/cosmos_store.py b/python/samples/05-end-to-end/enterprise-chat-agent/services/cosmos_store.py new file mode 100644 index 0000000000..ba49280be2 --- /dev/null +++ b/python/samples/05-end-to-end/enterprise-chat-agent/services/cosmos_store.py @@ -0,0 +1,301 @@ +# Copyright (c) Microsoft. All rights reserved. +""" +Cosmos DB Storage for Thread Metadata + +This module provides persistent storage for conversation thread metadata +using Azure Cosmos DB (async SDK). Message storage is handled separately by the +CosmosHistoryProvider from agent-framework-azure-cosmos package. + +Document Types: +- Thread: {"type": "thread", "id": "thread_xxx", "thread_id": "thread_xxx", ...} + +Note: Conversation messages are managed by CosmosHistoryProvider which uses +session_id (thread_id) as the partition key for efficient message retrieval. +""" + +import logging +import os +from datetime import datetime, timezone +from typing import Any + +from azure.cosmos import PartitionKey +from azure.cosmos.aio import CosmosClient +from azure.cosmos.exceptions import CosmosResourceNotFoundError +from azure.identity.aio import DefaultAzureCredential + + +class CosmosConversationStore: + """ + Manages conversation thread metadata in Azure Cosmos DB (async). + + Thread metadata includes: user_id, title, status, created_at, updated_at. + Message persistence is handled by CosmosHistoryProvider (context provider). + """ + + def __init__( + self, + endpoint: str | None = None, + database_name: str | None = None, + container_name: str | None = None, + credential: Any | None = None, + ): + """ + Initialize the Cosmos DB conversation store. + + Args: + endpoint: Cosmos DB endpoint URL. Defaults to AZURE_COSMOS_ENDPOINT env var. + database_name: Database name. Defaults to AZURE_COSMOS_DATABASE_NAME env var. + container_name: Container name for threads. Defaults to AZURE_COSMOS_THREADS_CONTAINER_NAME. + credential: Azure credential. Defaults to DefaultAzureCredential. + """ + self.endpoint = endpoint or os.environ.get("AZURE_COSMOS_ENDPOINT") + self.database_name = database_name or os.environ.get( + "AZURE_COSMOS_DATABASE_NAME", "chat_db" + ) + self.container_name = container_name or os.environ.get( + "AZURE_COSMOS_THREADS_CONTAINER_NAME", "threads" + ) + + if not self.endpoint: + raise ValueError( + "Cosmos DB endpoint is required. " + "Set AZURE_COSMOS_ENDPOINT environment variable." + ) + + self._credential = credential + self._client: CosmosClient | None = None + self._container = None + self._initialized = False + + async def _ensure_initialized(self): + """Lazy async initialization of Cosmos DB container client with auto-create.""" + if self._initialized: + return + + if self._credential is None: + self._credential = DefaultAzureCredential() + + self._client = CosmosClient(self.endpoint, credential=self._credential) + + # Create database if it doesn't exist + database = await self._client.create_database_if_not_exists( + id=self.database_name + ) + + # Create container with thread_id as partition key + self._container = await database.create_container_if_not_exists( + id=self.container_name, + partition_key=PartitionKey(path="/thread_id"), + ) + + self._initialized = True + logging.info( + f"Initialized async Cosmos container: {self.database_name}/{self.container_name}" + ) + + # ------------------------------------------------------------------------- + # Thread Operations + # ------------------------------------------------------------------------- + + async def create_thread( + self, + thread_id: str, + user_id: str, + title: str | None = None, + metadata: dict | None = None, + ) -> dict: + """ + Create a new conversation thread. + + Args: + thread_id: Unique thread identifier. + user_id: Owner's user ID. + title: Optional thread title. + metadata: Optional custom metadata. + + Returns: + The created thread document. + """ + await self._ensure_initialized() + + now = datetime.now(timezone.utc).isoformat() + thread = { + "id": thread_id, + "thread_id": thread_id, # Partition key + "type": "thread", + "user_id": user_id, + "title": title, + "status": "active", + "message_count": 0, + "created_at": now, + "updated_at": now, + "last_message_preview": None, + "metadata": metadata or {}, + } + + await self._container.create_item(body=thread) + logging.info(f"Created thread {thread_id} for user {user_id} in Cosmos DB") + return thread + + async def get_thread(self, thread_id: str) -> dict | None: + """ + Get a thread by ID. + + Args: + thread_id: Thread identifier. + + Returns: + Thread document or None if not found. + """ + await self._ensure_initialized() + + try: + return await self._container.read_item( + item=thread_id, + partition_key=thread_id, + ) + except CosmosResourceNotFoundError: + return None + + async def delete_thread(self, thread_id: str) -> bool: + """ + Delete a thread metadata document. + + Note: Messages are stored separately by CosmosHistoryProvider and + can be cleared using history_provider.clear(session_id=thread_id). + + Args: + thread_id: Thread identifier. + + Returns: + True if deleted, False if not found. + """ + await self._ensure_initialized() + + try: + await self._container.delete_item(item=thread_id, partition_key=thread_id) + logging.info(f"Deleted thread {thread_id} from Cosmos DB") + return True + except CosmosResourceNotFoundError: + return False + + async def update_thread( + self, + thread_id: str, + title: str | None = None, + status: str | None = None, + message_count: int | None = None, + last_message_preview: str | None = None, + ) -> dict | None: + """ + Update thread metadata. + + Args: + thread_id: Thread identifier. + title: New title (optional). + status: New status - 'active', 'archived', or 'deleted' (optional). + message_count: New message count (optional). + last_message_preview: Preview of last message (optional). + + Returns: + Updated thread document or None if not found. + """ + thread = await self.get_thread(thread_id) + if thread is None: + return None + + # Update fields + if title is not None: + thread["title"] = title + if status is not None: + thread["status"] = status + if message_count is not None: + thread["message_count"] = message_count + if last_message_preview is not None: + thread["last_message_preview"] = last_message_preview + + thread["updated_at"] = datetime.now(timezone.utc).isoformat() + + updated = await self._container.replace_item(item=thread_id, body=thread) + logging.info(f"Updated thread {thread_id}") + return updated + + async def thread_exists(self, thread_id: str) -> bool: + """ + Check if a thread exists. + + Args: + thread_id: Thread identifier. + + Returns: + True if thread exists, False otherwise. + """ + thread = await self.get_thread(thread_id) + return thread is not None + + async def list_threads( + self, + user_id: str | None = None, + status: str | None = None, + limit: int = 50, + offset: int = 0, + ) -> list[dict]: + """ + List all threads with optional filters. + + Args: + user_id: Filter by user ID (optional). + status: Filter by status - 'active', 'archived', 'deleted' (optional). + limit: Maximum number of threads to return (default 50). + offset: Number of threads to skip for pagination. + + Returns: + List of thread documents sorted by updated_at descending. + """ + await self._ensure_initialized() + + # Build query with optional filters + conditions = ["c.type = 'thread'"] + parameters = [] + + if user_id: + conditions.append("c.user_id = @user_id") + parameters.append({"name": "@user_id", "value": user_id}) + + if status: + conditions.append("c.status = @status") + parameters.append({"name": "@status", "value": status}) + + # Build WHERE clause from fixed condition set (not user input) + where_clause = " AND ".join(conditions) + query = ( + f"SELECT * FROM c WHERE {where_clause} " # nosec B608 + "ORDER BY c.updated_at DESC OFFSET @offset LIMIT @limit" + ) + parameters.extend( + [ + {"name": "@offset", "value": offset}, + {"name": "@limit", "value": limit}, + ] + ) + + items = [] + async for item in self._container.query_items( + query=query, + parameters=parameters, + ): + items.append(item) + + logging.info( + f"Listed {len(items)} threads (user_id={user_id}, status={status})" + ) + return items + + async def close(self) -> None: + """Close the Cosmos DB client and release resources.""" + if self._client is not None: + await self._client.close() + self._client = None + self._container = None + self._initialized = False + logging.info("Closed async Cosmos DB client") diff --git a/python/samples/05-end-to-end/enterprise-chat-agent/services/observability.py b/python/samples/05-end-to-end/enterprise-chat-agent/services/observability.py new file mode 100644 index 0000000000..bec7baf37c --- /dev/null +++ b/python/samples/05-end-to-end/enterprise-chat-agent/services/observability.py @@ -0,0 +1,184 @@ +# Copyright (c) Microsoft. All rights reserved. + +""" +Observability module for Enterprise Chat Agent. + +Provides complementary spans for layers the Agent Framework doesn't instrument: +- HTTP request lifecycle +- Cosmos DB operations +- Request validation + +Uses the framework's configure_otel_providers() and get_tracer() APIs. +""" + +import logging +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +# Import framework's observability - use framework APIs, don't recreate them +from agent_framework.observability import configure_otel_providers, get_tracer +from opentelemetry.trace import Span, SpanKind, Status, StatusCode + +logger = logging.getLogger(__name__) + + +class EnterpriseAgentAttr: + """Custom semantic attributes for enterprise chat agent.""" + + # Thread/User context + THREAD_ID = "enterprise_agent.thread.id" + USER_ID = "enterprise_agent.user.id" + + # Cosmos DB attributes (following OpenTelemetry DB conventions) + COSMOS_CONTAINER = "db.cosmosdb.container" + COSMOS_OPERATION = "db.operation" + COSMOS_PARTITION_KEY = "db.cosmosdb.partition_key" + + +def init_observability() -> None: + """Initialize observability using the Agent Framework's setup. + + Call once at Azure Functions app startup. + + The framework handles: + - TracerProvider configuration + - MeterProvider configuration + - LoggerProvider configuration + - OTLP and Azure Monitor exporters + + Environment variables used: + - ENABLE_INSTRUMENTATION: Enable telemetry (default: false) + - ENABLE_SENSITIVE_DATA: Log message contents (default: false) + - ENABLE_CONSOLE_EXPORTERS: Enable console output (default: false) + - OTEL_EXPORTER_OTLP_ENDPOINT: OTLP collector endpoint + - APPLICATIONINSIGHTS_CONNECTION_STRING: Azure Monitor connection + - OTEL_SERVICE_NAME: Service name (default: agent_framework) + """ + try: + # Reduce verbose logging from httpx/httpcore (HTTP transport details) + # These generate 100+ traces per request with DEBUG logging + logging.getLogger("httpx").setLevel(logging.WARNING) + logging.getLogger("httpcore").setLevel(logging.WARNING) + + configure_otel_providers() + logger.info("Observability initialized successfully") + except Exception as e: + logger.warning(f"Failed to initialize observability: {e}") + + +@asynccontextmanager +async def http_request_span( + method: str, + path: str, + thread_id: str | None = None, + user_id: str | None = None, +) -> AsyncIterator[Span]: + """Create a top-level HTTP request span. + + Wraps the entire request lifecycle. Child spans (Cosmos, agent invocation) + will be nested under this span. + + The span is yielded so callers can set http.status_code before exiting. + + Args: + method: HTTP method (GET, POST, DELETE, etc.) + path: Route pattern (e.g., "/threads/{thread_id}/messages") + thread_id: Thread identifier for correlation + user_id: User identifier for correlation + + Yields: + The active span for setting additional attributes like status code. + """ + tracer = get_tracer("enterprise_chat_agent") + attributes = { + "http.method": method, + "http.route": path, + } + if thread_id: + attributes[EnterpriseAgentAttr.THREAD_ID] = thread_id + if user_id: + attributes[EnterpriseAgentAttr.USER_ID] = user_id + + with tracer.start_as_current_span( + f"http.request {method} {path}", + kind=SpanKind.SERVER, + attributes=attributes, + ) as span: + try: + yield span + # Check if status_code was set; determine success based on it + status_code = ( + span.attributes.get("http.status_code") + if hasattr(span, "attributes") + else None + ) + if status_code and status_code >= 400: + span.set_status(Status(StatusCode.ERROR)) + else: + span.set_status(Status(StatusCode.OK)) + except Exception as e: + span.set_status(Status(StatusCode.ERROR, str(e))) + span.record_exception(e) + raise + + +@asynccontextmanager +async def cosmos_span( + operation: str, + container: str, + partition_key: str | None = None, +) -> AsyncIterator[None]: + """Create a Cosmos DB operation span. + + Tracks database operations with OpenTelemetry database semantic conventions. + + Args: + operation: Database operation (read, query, upsert, delete, create) + container: Cosmos DB container name + partition_key: Partition key value for the operation + """ + tracer = get_tracer("enterprise_chat_agent") + attributes = { + "db.system": "cosmosdb", + EnterpriseAgentAttr.COSMOS_OPERATION: operation, + EnterpriseAgentAttr.COSMOS_CONTAINER: container, + } + if partition_key: + attributes[EnterpriseAgentAttr.COSMOS_PARTITION_KEY] = partition_key + + with tracer.start_as_current_span( + f"cosmos.{operation} {container}", + kind=SpanKind.CLIENT, + attributes=attributes, + ) as span: + try: + yield + span.set_status(Status(StatusCode.OK)) + except Exception as e: + span.set_status(Status(StatusCode.ERROR, str(e))) + span.record_exception(e) + raise + + +@asynccontextmanager +async def validation_span(operation: str) -> AsyncIterator[None]: + """Create a request validation span. + + Tracks authorization and validation checks. + + Args: + operation: Validation operation name (e.g., "verify_thread_ownership") + """ + tracer = get_tracer("enterprise_chat_agent") + + with tracer.start_as_current_span( + f"request.validate {operation}", + kind=SpanKind.INTERNAL, + ) as span: + try: + yield + span.set_status(Status(StatusCode.OK)) + except Exception as e: + span.set_status(Status(StatusCode.ERROR, str(e))) + span.record_exception(e) + raise diff --git a/python/samples/05-end-to-end/enterprise-chat-agent/tools/__init__.py b/python/samples/05-end-to-end/enterprise-chat-agent/tools/__init__.py new file mode 100644 index 0000000000..b5ad75dd80 --- /dev/null +++ b/python/samples/05-end-to-end/enterprise-chat-agent/tools/__init__.py @@ -0,0 +1,27 @@ +""" +Enterprise Chat Agent - Function Tools + +This module contains the local tools that the ChatAgent can invoke at runtime. +The agent autonomously decides which tools to use based on the user's message. + +Local Tools: +- get_weather: Get weather information for a location +- calculate: Evaluate mathematical expressions +- search_knowledge_base: Search internal company knowledge base + +MCP Tools (via Microsoft Learn MCP Server): +- microsoft_docs_search: Search Microsoft documentation +- microsoft_code_sample_search: Search code samples + +MCP tools are connected at runtime via MCPStreamableHTTPTool in agent_service.py +""" + +from tools.calculator import calculate +from tools.knowledge_base import search_knowledge_base +from tools.weather import get_weather + +__all__ = [ + "get_weather", + "calculate", + "search_knowledge_base", +] diff --git a/python/samples/05-end-to-end/enterprise-chat-agent/tools/calculator.py b/python/samples/05-end-to-end/enterprise-chat-agent/tools/calculator.py new file mode 100644 index 0000000000..79d9a8a44f --- /dev/null +++ b/python/samples/05-end-to-end/enterprise-chat-agent/tools/calculator.py @@ -0,0 +1,105 @@ +""" +Calculator Tool + +Provides safe mathematical expression evaluation with DoS protections. +""" + +import ast +import operator + +from agent_framework import tool + +# Safety limits to prevent DoS attacks +MAX_EXPRESSION_LENGTH = 200 # Maximum characters in expression +MAX_EXPONENT = 100 # Maximum allowed exponent value +MAX_RESULT_MAGNITUDE = 1e308 # Maximum result magnitude (near float max) + +# Safe operators for expression evaluation +SAFE_OPERATORS = { + ast.Add: operator.add, + ast.Sub: operator.sub, + ast.Mult: operator.mul, + ast.Div: operator.truediv, + ast.Pow: operator.pow, + ast.USub: operator.neg, + ast.UAdd: operator.pos, +} + + +def _safe_eval(node: ast.AST) -> int | float: + """ + Safely evaluate an AST node containing only numeric operations. + """ + if isinstance(node, ast.Constant): + if isinstance(node.value, (int, float)): + return node.value + raise ValueError(f"Unsupported constant type: {type(node.value)}") + + if isinstance(node, ast.BinOp): + left = _safe_eval(node.left) + right = _safe_eval(node.right) + op_type = type(node.op) + if op_type not in SAFE_OPERATORS: + raise ValueError(f"Unsupported operator: {op_type.__name__}") + + # Enforce exponent limit to prevent DoS (e.g., 2 ** 1000000000) + if op_type is ast.Pow and abs(right) > MAX_EXPONENT: + raise ValueError( + f"Exponent {right} exceeds maximum allowed ({MAX_EXPONENT})" + ) + + result = SAFE_OPERATORS[op_type](left, right) + + # Check result magnitude + if abs(result) > MAX_RESULT_MAGNITUDE: + raise ValueError("Result exceeds maximum allowed magnitude") + + return result + + if isinstance(node, ast.UnaryOp): + operand = _safe_eval(node.operand) + op_type = type(node.op) + if op_type in SAFE_OPERATORS: + return SAFE_OPERATORS[op_type](operand) + raise ValueError(f"Unsupported unary operator: {op_type.__name__}") + + if isinstance(node, ast.Expression): + return _safe_eval(node.body) + + raise ValueError(f"Unsupported AST node type: {type(node).__name__}") + + +@tool +def calculate(expression: str) -> float: + """ + Evaluate a mathematical expression safely. + + Supports: +, -, *, /, ** (power with exponent <= 100), parentheses + + Args: + expression: A mathematical expression string (e.g., "85 * 0.15") + Maximum length: 200 characters. + + Returns: + The result of the calculation. + + Raises: + ValueError: If the expression contains unsupported operations, + exceeds length limits, or has exponents > 100. + """ + # Length limit to prevent parsing DoS + if len(expression) > MAX_EXPRESSION_LENGTH: + raise ValueError( + f"Expression exceeds maximum length ({MAX_EXPRESSION_LENGTH} chars)" + ) + + try: + # Parse the expression into an AST + tree = ast.parse(expression, mode="eval") + + # Safely evaluate the AST + result = _safe_eval(tree) + + return float(result) + except (SyntaxError, ValueError) as e: + raise ValueError(f"Invalid expression '{expression}': {e}") from e diff --git a/python/samples/05-end-to-end/enterprise-chat-agent/tools/knowledge_base.py b/python/samples/05-end-to-end/enterprise-chat-agent/tools/knowledge_base.py new file mode 100644 index 0000000000..8b0fb26bae --- /dev/null +++ b/python/samples/05-end-to-end/enterprise-chat-agent/tools/knowledge_base.py @@ -0,0 +1,104 @@ +""" +Knowledge Base Search Tool + +Provides search functionality over a knowledge base. +In a production scenario, this would integrate with Azure AI Search, +Cosmos DB, or another search service. +""" + +from agent_framework import tool + +# Simulated knowledge base entries +KNOWLEDGE_BASE = [ + { + "id": "kb_001", + "title": "Order Status FAQ", + "content": ( + "To check your order status, log into your account and visit the " + "'My Orders' section. Track your package using the tracking number sent to your email." + ), + "category": "orders", + }, + { + "id": "kb_002", + "title": "Return Policy", + "content": ( + "Items can be returned within 30 days of purchase. " + "Items must be unused and in original packaging. Refunds processed in 5-7 business days." + ), + "category": "returns", + }, + { + "id": "kb_003", + "title": "Shipping Information", + "content": ( + "Standard shipping takes 5-7 business days. " + "Express shipping (2-3 days) available for an additional fee. Free shipping on orders over $50." + ), + "category": "shipping", + }, + { + "id": "kb_004", + "title": "Payment Methods", + "content": ( + "We accept Visa, Mastercard, American Express, PayPal, and Apple Pay. " + "All transactions are securely processed." + ), + "category": "payments", + }, + { + "id": "kb_005", + "title": "Account Management", + "content": ( + "To update your account information, go to Settings > Profile. " + "You can change your email, password, and notification preferences there." + ), + "category": "account", + }, +] + + +@tool +def search_knowledge_base( + query: str, + category: str | None = None, + max_results: int = 3, +) -> list[dict]: + """ + Search the knowledge base for relevant information. + + Args: + query: The search query. + category: Optional category to filter results (e.g., "orders", "returns"). + max_results: Maximum number of results to return. + + Returns: + A list of matching knowledge base entries. + """ + query_lower = query.lower() + results = [] + + for entry in KNOWLEDGE_BASE: + # Filter by category if specified + if category and entry["category"] != category.lower(): + continue + + # Simple keyword matching (replace with vector search in production) + if ( + query_lower in entry["title"].lower() + or query_lower in entry["content"].lower() + or any(word in entry["content"].lower() for word in query_lower.split()) + ): + results.append( + { + "id": entry["id"], + "title": entry["title"], + "content": entry["content"], + "category": entry["category"], + "relevance_score": 0.85, # Simulated score + } + ) + + # Sort by relevance (simulated) and limit results + results.sort(key=lambda x: x["relevance_score"], reverse=True) + return results[:max_results] diff --git a/python/samples/05-end-to-end/enterprise-chat-agent/tools/weather.py b/python/samples/05-end-to-end/enterprise-chat-agent/tools/weather.py new file mode 100644 index 0000000000..81257ba859 --- /dev/null +++ b/python/samples/05-end-to-end/enterprise-chat-agent/tools/weather.py @@ -0,0 +1,34 @@ +""" +Weather Tool + +Provides weather information for a given location. +In a production scenario, this would integrate with a weather API. +""" + +import random + +from agent_framework import tool + + +@tool +def get_weather(location: str) -> dict: + """ + Get current weather for a location. + + Args: + location: The city or location to get weather for. + + Returns: + A dictionary containing temperature and weather condition. + """ + # Simulated weather data (replace with actual API call in production) + conditions = ["sunny", "cloudy", "light rain", "partly cloudy", "overcast"] + + # Random is used for mock demo data, not for security purposes + return { + "location": location, + "temp": random.randint(32, 85), # nosec B311 + "condition": random.choice(conditions), # nosec B311 + "humidity": random.randint(30, 90), # nosec B311 + "unit": "fahrenheit", + }