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
+
+
+
+
+
+
+
+
+
+
+
👋 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",
+ }