diff --git a/python/samples/getting_started/azure_functions/09_agent_streaming_signalr/README.md b/python/samples/getting_started/azure_functions/09_agent_streaming_signalr/README.md new file mode 100644 index 0000000000..b264f68ffb --- /dev/null +++ b/python/samples/getting_started/azure_functions/09_agent_streaming_signalr/README.md @@ -0,0 +1,362 @@ +# Agent Response Callbacks with Azure SignalR + +This sample demonstrates how to use Azure SignalR Service with agent response callbacks to enable real-time streaming for durable agents. The agent streams responses directly to connected web clients as the agent generates them. + +## Key Concepts Demonstrated + +- Using `AgentResponseCallbackProtocol` to capture streaming agent responses +- Real-time delivery of streaming chunks via Azure SignalR Service REST API +- Custom SignalR negotiation endpoint for browser client authentication +- Automatic reconnection support using SignalR JavaScript client +- Durable agent execution with streaming callbacks +- Conversation continuity across multiple messages +- **User isolation** - Each user only receives messages for their own conversation via SignalR groups + +## Prerequisites + +1. **Azure SignalR Service** - Create a SignalR Service instance in Azure (Serverless mode) +2. **Azure Functions Core Tools** - For local development +3. **Azure OpenAI** - Configured for agent model + +Update `local.settings.json` with your configuration: + +```json +{ + "Values": { + "FUNCTIONS_WORKER_RUNTIME": "python", + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", + "TASKHUB_NAME": "default", + "AZURE_OPENAI_ENDPOINT": "", + "AZURE_OPENAI_API_KEY": "", + "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "", + "AzureSignalRConnectionString": "Endpoint=https://.service.signalr.net;AccessKey=;Version=1.0;ServiceMode=Serverless;", + "SIGNALR_HUB_NAME": "travel" + } +} +``` + +**Note:** There is no local SignalR emulation. You must use a deployed Azure SignalR Service instance. + +## Running the Sample + +### 1. Start the Azure Functions host + +```bash +func start +``` + +The function app will start on `http://localhost:7071` + +### 2. Open the web interface + +Navigate to `http://localhost:7071/api/index` in your browser. The page will automatically: + +- Connect to Azure SignalR Service via the `/api/agent/negotiate` endpoint +- Display the connection status (Connected/Disconnected) +- Enable the chat interface + +[Demo Video](./az_func_signalr_demo.gif) + +### 3. Send a message to the agent + +Type a travel planning request in the input box, for example: + +```text +Plan a 3-day trip to Singapore +``` + +Click "Send" or press Enter. The agent will: + +- Execute in the background via durable orchestration +- Stream responses in real-time as they're generated + +### 4. Continue the conversation + +The client maintains the `conversationId` (thread_id) across messages, so you can have a multi-turn conversation: + +```text +Include neighbouring countries as well +``` + +The agent will have context from previous messages in the same conversation. + +## API Endpoints + +### POST `/api/agents/TravelPlanner/run` + +Start or continue an agent conversation. + +**Query Parameters:** + +- `thread_id` (required for user isolation) - Conversation ID obtained from `/api/agent/create-thread` + +**Headers:** + +- `Content-Type: text/plain` - Plain text prompt +- `X-Wait-For-Response: false` - Return immediately without waiting for agent response + +**Request Body:** Plain text prompt + +```text +Plan a 3-day trip to Singapore +``` + +**Response (202 Accepted):** + +```json +{ + "message": "Plan a 3-day trip to Singapore", + "thread_id": "a1b2c3d4e5f6789012345678901234ab", + "correlation_id": "f8e7d6c5b4a39281...", + "status": "accepted" +} +``` + +### GET/POST `/api/agent/negotiate` + +SignalR negotiation endpoint for browser clients. + +**Response (200 OK):** + +```json +{ + "url": "https://.service.signalr.net/client/?hub=travel", + "accessToken": "" +} +``` + +### GET `/api/index` + +Serves the web interface (index.html). + +### POST `/api/agent/create-thread` + +Create a new thread_id before starting a conversation. This is required for user isolation - the client must join a SignalR group before the agent starts streaming. + +**Response (200 OK):** + +```json +{ + "thread_id": "a1b2c3d4e5f6789012345678901234ab" +} +``` + +> **Note:** The agent framework auto-generates thread_ids, but we create one upfront so the client can join the SignalR group before sending messages, avoiding a race condition where messages stream before the client is subscribed. + +### POST `/api/agent/join-group` + +Add a SignalR connection to a conversation group for user isolation. + +**Request Body:** + +```json +{ + "group": "", + "connectionId": "" +} +``` + +**Response (200 OK):** + +```json +{ + "status": "joined", + "group": "" +} +``` + +## How It Works + +### 1. SignalR Service Client + +A custom `SignalRServiceClient` class communicates with Azure SignalR Service via REST API: + +```python +class SignalRServiceClient: + def __init__(self, connection_string: str, hub_name: str): + # Parse connection string for endpoint and access key + self._endpoint = ... + self._access_key = ... + self._hub_name = hub_name + + async def send(self, *, target: str, arguments: list, group: str | None = None): + # Generate JWT token for authentication + token = self._generate_token(url) + + # POST message to SignalR REST API + # Broadcasts to all connected clients + async with session.post(url, headers={...}, json={...}): + ... +``` + +### 2. SignalR Callback + +`SignalRCallback` implements `AgentResponseCallbackProtocol` to capture streaming updates: + +```python +class SignalRCallback(AgentResponseCallbackProtocol): + async def on_streaming_response_update(self, update, context): + # Send each chunk to the specific conversation group + await self._client.send( + target="agentMessage", + arguments=[{ + "conversationId": context.thread_id, + "correlationId": context.correlation_id, + "text": update.text + }], + group=context.thread_id # User isolation via groups + ) + + async def on_agent_response(self, response, context): + # Notify completion to the specific group + await self._client.send( + target="agentDone", + arguments=[{ + "conversationId": context.thread_id, + "status": "completed" + }], + group=context.thread_id # User isolation via groups + ) +``` + +The callback is configured as the default callback in the AgentFunctionApp. + +### 3. Negotiate Endpoint + +The `/api/agent/negotiate` endpoint provides SignalR connection info for browser clients: + +```python +@app.route(route="agent/negotiate", methods=["POST", "GET"]) +def negotiate(req): + # Build client URL for the SignalR hub + client_url = f"{base_url}/client/?hub={SIGNALR_HUB_NAME}" + + # Generate JWT token for client authentication + access_token = signalr_client._generate_token(client_url) + + return { + "url": client_url, + "accessToken": access_token + } +``` + +### 4. Browser Client + +The client uses the SignalR JavaScript library with user isolation: + +```javascript +// Get connection info from negotiate endpoint +const { url, accessToken } = await fetch('/api/agent/negotiate').then(r => r.json()); + +// Connect to SignalR +const connection = new signalR.HubConnectionBuilder() + .withUrl(url, { accessTokenFactory: () => accessToken }) + .withAutomaticReconnect() + .build(); + +// Listen for streaming messages +connection.on('agentMessage', (data) => { + // Append text chunk to UI + updateAgentMessage(data.text); +}); + +// Listen for completion +connection.on('agentDone', (data) => { + // Enable input, clear typing indicator + isAgentProcessing = false; +}); + +await connection.start(); + +// Before sending the first message, create thread and join group +async function sendMessage(message) { + if (!conversationId) { + // 1. Get thread_id from server + const { thread_id } = await fetch('/api/agent/create-thread', { method: 'POST' }) + .then(r => r.json()); + conversationId = thread_id; + + // 2. Join the SignalR group for this thread + await fetch('/api/agent/join-group', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + group: conversationId, + connectionId: connection.connectionId + }) + }); + } + + // 3. Now send message - agent will stream to this group + await fetch(`/api/agents/TravelPlanner/run?thread_id=${conversationId}`, { + method: 'POST', + body: message + }); +} +``` + +### 5. Thread Management and User Isolation + +The sample implements user isolation using SignalR groups: + +1. **Thread Creation**: Before the first message, client requests a `thread_id` from `/api/agent/create-thread` +2. **Group Joining**: Client joins a SignalR group named after the `thread_id` via `/api/agent/join-group` +3. **Message Sending**: Client sends message with `thread_id` query parameter +4. **Streaming**: Agent callback sends messages to the `thread_id` group, not broadcast + +This ensures: + +- Each user only sees their own conversation +- No race condition between message sending and group subscription +- Multiple users can use the app simultaneously without interference + +``` +┌─────────┐ ┌──────────┐ ┌─────────────┐ ┌─────────┐ +│ Client │ │ Functions│ │ SignalR │ │ Agent │ +└────┬────┘ └────┬─────┘ └──────┬──────┘ └────┬────┘ + │ │ │ │ + │ create-thread │ │ │ + │───────────────>│ │ │ + │<─ thread_id ───│ │ │ + │ │ │ │ + │ join-group │ add to group │ │ + │───────────────>│──────────────────>│ │ + │<─ joined ──────│ │ │ + │ │ │ │ + │ run (thread_id)│ │ │ + │───────────────>│────────────────────────────────────>│ + │<─ 202 accepted │ │ │ + │ │ │ streaming │ + │ │ │<─────────────────│ + │ agentMessage │<── to group ──────│ │ + │<───────────────│ │ │ + │ agentDone │<── to group ──────│ │ + │<───────────────│ │ │ +``` + +### 6. Agent Execution + +The agent is defined with tools and streaming is automatic: + +```python +def create_travel_agent(): + return AzureOpenAIChatClient(credential=AzureCliCredential()).create_agent( + name="TravelPlanner", + instructions="...", + tools=[get_weather_forecast, get_local_events] + ) + +# Create AgentFunctionApp with SignalR callback +app = AgentFunctionApp( + agents=[create_travel_agent()], + default_callback=signalr_callback, # All agents use this callback + enable_health_check=True +) +``` + +The framework automatically: + +- Creates durable orchestrations for agent runs +- Invokes the callback as responses stream +- Manages conversation state (thread_id) diff --git a/python/samples/getting_started/azure_functions/09_agent_streaming_signalr/az_func_signalr_demo.gif b/python/samples/getting_started/azure_functions/09_agent_streaming_signalr/az_func_signalr_demo.gif new file mode 100644 index 0000000000..c4c95dcdb4 Binary files /dev/null and b/python/samples/getting_started/azure_functions/09_agent_streaming_signalr/az_func_signalr_demo.gif differ diff --git a/python/samples/getting_started/azure_functions/09_agent_streaming_signalr/content/index.html b/python/samples/getting_started/azure_functions/09_agent_streaming_signalr/content/index.html new file mode 100644 index 0000000000..75ee2d7df5 --- /dev/null +++ b/python/samples/getting_started/azure_functions/09_agent_streaming_signalr/content/index.html @@ -0,0 +1,435 @@ + + + + + + + Travel Planner Agent - SignalR Chat + + + + + +
+
+

🌍 Travel Planner Agent

+
+
+ Connecting... +
+
+
+
+
Welcome! Ask me to plan a trip to any destination.
+
+
+
+
+ + +
+
+
+ + + + + \ No newline at end of file diff --git a/python/samples/getting_started/azure_functions/09_agent_streaming_signalr/function_app.py b/python/samples/getting_started/azure_functions/09_agent_streaming_signalr/function_app.py new file mode 100644 index 0000000000..8d54798b80 --- /dev/null +++ b/python/samples/getting_started/azure_functions/09_agent_streaming_signalr/function_app.py @@ -0,0 +1,364 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Azure Functions sample: stream agent responses to clients via Azure SignalR. + +This sample demonstrates how to: +- Host an agent built with the `agent_framework` in an Azure Functions app +- Use Azure OpenAI (via `AzureOpenAIChatClient`) to power a travel planning agent +- Stream incremental agent responses and tool calls to clients over Azure SignalR Service +- Integrate a lightweight REST client (`SignalRServiceClient`) with the Functions runtime + +Components used: +- Azure Functions (HTTP-triggered function with `AgentFunctionApp`) +- Azure OpenAI Chat model accessed via `AzureOpenAIChatClient` +- Azure SignalR Service accessed via a custom `SignalRServiceClient` +- `AgentResponseCallbackProtocol` implementation (`SignalRCallback`) to forward updates +- Tool functions imported from `tools` (e.g., `get_local_events`, `get_weather_forecast`) + +Prerequisites: +- An Azure subscription with: + - An Azure SignalR Service instance + - An Azure OpenAI resource and chat model deployment +- Azure Functions Core Tools or an Azure Functions host to run this app +- Authentication configured for `AzureCliCredential` (e.g., `az login`) +- Environment variables: + - `AzureSignalRConnectionString`: connection string for the SignalR resource + - `SIGNALR_HUB_NAME`: name of the SignalR hub (defaults to "travel" if not set) + - Any additional Azure OpenAI configuration required by `AzureOpenAIChatClient` +""" + +import base64 +import hashlib +import hmac +import json +import logging +import os +import time +import uuid + +import aiohttp +from agent_framework import AgentRunResponseUpdate +import azure.functions as func +from agent_framework.azure import ( + AgentCallbackContext, + AgentFunctionApp, + AgentResponseCallbackProtocol, + AzureOpenAIChatClient, +) +from azure.identity import AzureCliCredential + +from tools import get_local_events, get_weather_forecast + +# Configuration +SIGNALR_CONNECTION_STRING = os.environ.get("AzureSignalRConnectionString", "") +SIGNALR_HUB_NAME = os.environ.get("SIGNALR_HUB_NAME", "travel") + +# Ensure local console logging is enabled when running the Functions host locally. +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) + + +class SignalRServiceClient: + """Lightweight client for Azure SignalR Service REST API.""" + + def __init__(self, connection_string: str, hub_name: str) -> None: + parts = { + key: value + for key, value in ( + segment.split("=", 1) + for segment in connection_string.split(";") + if segment + ) + } + + self._endpoint = parts.get("Endpoint", "").rstrip("/") + "/" + self._access_key = parts.get("AccessKey") + self._hub_name = hub_name + + if not self._endpoint or not self._access_key: + raise ValueError( + "AzureSignalRConnectionString must include Endpoint and AccessKey." + ) + + @staticmethod + def _encode_segment(data: dict) -> bytes: + return base64.urlsafe_b64encode( + json.dumps(data, separators=(",", ":")).encode("utf-8") + ).rstrip(b"=") + + def _generate_token(self, audience: str, expires_in_seconds: int = 3600) -> str: + header = {"alg": "HS256", "typ": "JWT"} + payload = { + "aud": audience, + "exp": int(time.time()) + expires_in_seconds, + } + + signing_input = b".".join( + [self._encode_segment(header), self._encode_segment(payload)] + ) + # Azure SignalR expects HMAC signed with the raw access key (UTF-8) + signature = hmac.new( + self._access_key.encode("utf-8"), signing_input, hashlib.sha256 # type: ignore + ).digest() + token = b".".join( + [signing_input, base64.urlsafe_b64encode(signature).rstrip(b"=")] + ) + return token.decode("utf-8") + + async def send( # noqa: D401 - simple REST send helper + self, + *, + target: str, + arguments: list, + group: str | None = None, + user_id: str | None = None, + ) -> None: + # Build the API path + url_path = f"/api/v1/hubs/{self._hub_name}" + if group: + url_path += f"/groups/{group}" + elif user_id: + url_path += f"/users/{user_id}" + + # Construct full URL (no /:send suffix - just POST to the path) + base_endpoint = self._endpoint.rstrip("/") + url = f"{base_endpoint}{url_path}" + + # Token audience should match the URL path + token = self._generate_token(url) + + async with aiohttp.ClientSession() as session: + async with session.post( + url, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + json={"target": target, "arguments": arguments}, + ) as response: + if response.status >= 300: + details = await response.text() + raise RuntimeError( + f"SignalR send failed ({response.status}): {details}" + ) + + async def add_connection_to_group(self, group: str, connection_id: str) -> None: + """Add a connection to a group.""" + base_endpoint = self._endpoint.rstrip("/") + url = f"{base_endpoint}/api/v1/hubs/{self._hub_name}/groups/{group}/connections/{connection_id}" + token = self._generate_token(url) + + async with aiohttp.ClientSession() as session: + async with session.put( + url, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + ) as response: + if response.status >= 300: + details = await response.text() + raise RuntimeError( + f"SignalR add to group failed ({response.status}): {details}" + ) + + +class SignalRCallback(AgentResponseCallbackProtocol): + """Callback that pushes streaming updates directly to SignalR clients.""" + + def __init__( + self, + client: SignalRServiceClient, + *, + message_target: str = "agentMessage", + done_target: str = "agentDone", + ) -> None: + self._client = client + self._message_target = message_target + self._done_target = done_target + self._logger = logging.getLogger("durableagent.samples.signalr_streaming") + + async def on_streaming_response_update( + self, + update: AgentRunResponseUpdate, + context: AgentCallbackContext, + ) -> None: + text = update.text + if not text: + return + + payload = { + "conversationId": context.thread_id, + "correlationId": context.correlation_id, + "text": text, + } + + try: + # Send to the specific conversation group for user isolation + await self._client.send( + target=self._message_target, + arguments=[payload], + group=context.thread_id, + ) + except Exception as ex: + if "404" not in str(ex): + self._logger.error("SignalR send failed: %s", ex) + + async def on_agent_response(self, response, context: AgentCallbackContext) -> None: + payload = { + "conversationId": context.thread_id, + "correlationId": context.correlation_id, + "status": "completed", + } + + try: + # Send to the specific conversation group for user isolation + await self._client.send( + target=self._done_target, + arguments=[payload], + group=context.thread_id, + ) + except Exception as ex: + if "404" not in str(ex): + self._logger.error("SignalR send failed: %s", ex) + + +signalr_client = SignalRServiceClient( + connection_string=SIGNALR_CONNECTION_STRING, + hub_name=SIGNALR_HUB_NAME, +) +signalr_callback = SignalRCallback(client=signalr_client) + + +# Create the travel planner agent +def create_travel_agent(): + """Create the TravelPlanner agent with tools.""" + return AzureOpenAIChatClient(credential=AzureCliCredential()).create_agent( + name="TravelPlanner", + instructions="""You are an expert travel planner who creates detailed, personalized travel itineraries. +When asked to plan a trip, you should: +1. Create a comprehensive day-by-day itinerary +2. Include specific recommendations for activities, restaurants, and attractions +3. Provide practical tips for each destination +4. Consider weather and local events when making recommendations +5. Include estimated times and logistics between activities + +Always use the available tools to get current weather forecasts and local events +for the destination to make your recommendations more relevant and timely. + +Format your response with clear headings for each day and include emoji icons +to make the itinerary easy to scan and visually appealing.""", + tools=[get_weather_forecast, get_local_events], + ) + + +# Create AgentFunctionApp with the SignalR callback +app = AgentFunctionApp( + agents=[create_travel_agent()], + enable_health_check=True, + default_callback=signalr_callback, + max_poll_retries=100, # Increase for longer-running agents +) + +def _get_signalr_endpoint_from_connection_string(connection_string: str) -> str: + """Extract the SignalR service endpoint from a connection string.""" + for part in connection_string.split(";"): + if part.startswith("Endpoint="): + # Strip the 'Endpoint=' prefix and any trailing slash for consistency + return part[len("Endpoint=") :].rstrip("/") + raise ValueError("Endpoint not found in Azure SignalR connection string.") + + +@app.function_name("negotiate") +@app.route(route="agent/negotiate", methods=["POST", "GET"]) +def negotiate(req: func.HttpRequest) -> func.HttpResponse: + """Provide SignalR connection info for clients (manual negotiation).""" + try: + # Build client URL for the configured hub + # Endpoint format: https://.service.signalr.net/client/?hub= + base_url = signalr_client._endpoint.rstrip("/") + client_url = f"{base_url}/client/?hub={SIGNALR_HUB_NAME}" + + # Generate token with the CLIENT URL as audience for browser clients + # Azure SignalR Service expects audience to match the client connection URL + access_token = signalr_client._generate_token(client_url) + + # Return negotiation response for SignalR JS client + body = json.dumps({"url": client_url, "accessToken": access_token}) + return func.HttpResponse(body=body, mimetype="application/json") + except Exception as ex: + logging.error("Failed to negotiate SignalR connection: %s", ex) + return func.HttpResponse( + json.dumps({"error": str(ex)}), + status_code=500, + mimetype="application/json", + ) + + +@app.function_name("joinGroup") +@app.route(route="agent/join-group", methods=["POST"]) +async def join_group(req: func.HttpRequest) -> func.HttpResponse: + """Add a SignalR connection to a conversation group for user isolation.""" + try: + body = req.get_json() + group = body.get("group") + connection_id = body.get("connectionId") + + if not group or not connection_id: + return func.HttpResponse( + json.dumps({"error": "group and connectionId are required"}), + status_code=400, + mimetype="application/json", + ) + + await signalr_client.add_connection_to_group(group, connection_id) + return func.HttpResponse( + json.dumps({"status": "joined", "group": group}), + mimetype="application/json", + ) + except Exception as ex: + logging.error("Failed to join group: %s", ex) + return func.HttpResponse( + json.dumps({"error": str(ex)}), + status_code=500, + mimetype="application/json", + ) + + +@app.function_name("createThread") +@app.route(route="agent/create-thread", methods=["POST"]) +def create_thread(req: func.HttpRequest) -> func.HttpResponse: + """Create a new thread_id for a conversation. + + Note: The agent framework auto-generates thread_ids, but we need to create + one upfront so the client can join the SignalR group before sending messages. + """ + thread_id = uuid.uuid4().hex # Match agent framework format (32-char hex) + return func.HttpResponse( + json.dumps({"thread_id": thread_id}), + mimetype="application/json", + ) + + +@app.route(route="index", methods=["GET"]) +def index(req: func.HttpRequest) -> func.HttpResponse: + html_path = os.path.join(os.path.dirname(__file__), "content", "index.html") + try: + with open(html_path) as f: + return func.HttpResponse(f.read(), mimetype="text/html") + except FileNotFoundError: + logging.error("index.html not found at path: %s", html_path) + return func.HttpResponse( + json.dumps({"error": "index.html not found"}), + status_code=404, + mimetype="application/json", + ) + except OSError as ex: + logging.error("Failed to read index.html at path %s: %s", html_path, ex) + return func.HttpResponse( + json.dumps({"error": "Failed to load index page"}), + status_code=500, + mimetype="application/json", + ) diff --git a/python/samples/getting_started/azure_functions/09_agent_streaming_signalr/host.json b/python/samples/getting_started/azure_functions/09_agent_streaming_signalr/host.json new file mode 100644 index 0000000000..7efcaa1400 --- /dev/null +++ b/python/samples/getting_started/azure_functions/09_agent_streaming_signalr/host.json @@ -0,0 +1,20 @@ +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "maxTelemetryItemsPerSecond": 20 + } + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + }, + "extensions": { + "durableTask": { + "hubName": "%TASKHUB_NAME%" + } + } +} diff --git a/python/samples/getting_started/azure_functions/09_agent_streaming_signalr/local.settings.json.template b/python/samples/getting_started/azure_functions/09_agent_streaming_signalr/local.settings.json.template new file mode 100644 index 0000000000..1bbfb253a2 --- /dev/null +++ b/python/samples/getting_started/azure_functions/09_agent_streaming_signalr/local.settings.json.template @@ -0,0 +1,13 @@ +{ + "Values": { + "FUNCTIONS_WORKER_RUNTIME": "python", + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", + "TASKHUB_NAME": "default", + "AZURE_OPENAI_ENDPOINT": "", + "AZURE_OPENAI_API_KEY": "", + "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "", + "AzureSignalRConnectionString": "Endpoint=https://.service.signalr.net;AccessKey=;Version=1.0;ServiceMode=Serverless;", + "SIGNALR_HUB_NAME": "travel" + } +} \ No newline at end of file diff --git a/python/samples/getting_started/azure_functions/09_agent_streaming_signalr/requirements.txt b/python/samples/getting_started/azure_functions/09_agent_streaming_signalr/requirements.txt new file mode 100644 index 0000000000..a9f77b3a62 --- /dev/null +++ b/python/samples/getting_started/azure_functions/09_agent_streaming_signalr/requirements.txt @@ -0,0 +1,8 @@ +# Uncomment to enable Azure Monitor OpenTelemetry +# Ref: aka.ms/functions-azure-monitor-python +# azure-monitor-opentelemetry + +azure-functions +agent-framework-azurefunctions +azure-identity +aiohttp \ No newline at end of file diff --git a/python/samples/getting_started/azure_functions/09_agent_streaming_signalr/tools.py b/python/samples/getting_started/azure_functions/09_agent_streaming_signalr/tools.py new file mode 100644 index 0000000000..ed4c19ec9a --- /dev/null +++ b/python/samples/getting_started/azure_functions/09_agent_streaming_signalr/tools.py @@ -0,0 +1,177 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Mock travel tools for demonstration purposes. + +In a real application, these would call actual weather and events APIs. +""" + +from typing import Annotated + + +def get_weather_forecast( + destination: Annotated[str, "The destination city or location"], + date: Annotated[ + str, 'The date for the forecast (e.g., "2025-01-15" or "next Monday")' + ], +) -> str: + """Get the weather forecast for a destination on a specific date. + + Use this to provide weather-aware recommendations in the itinerary. + + Args: + destination: The destination city or location. + date: The date for the forecast. + + Returns: + A weather forecast summary. + """ + # Mock weather data based on destination for realistic responses + weather_by_region = { + "Tokyo": ("Partly cloudy with a chance of light rain", 58, 45), + "Paris": ("Overcast with occasional drizzle", 52, 41), + "New York": ("Clear and cold", 42, 28), + "London": ("Foggy morning, clearing in afternoon", 48, 38), + "Sydney": ("Sunny and warm", 82, 68), + "Rome": ("Sunny with light breeze", 62, 48), + "Barcelona": ("Partly sunny", 59, 47), + "Amsterdam": ("Cloudy with light rain", 46, 38), + "Dubai": ("Sunny and hot", 85, 72), + "Singapore": ("Tropical thunderstorms in afternoon", 88, 77), + "Bangkok": ("Hot and humid, afternoon showers", 91, 78), + "Los Angeles": ("Sunny and pleasant", 72, 55), + "San Francisco": ("Morning fog, afternoon sun", 62, 52), + "Seattle": ("Rainy with breaks", 48, 40), + "Miami": ("Warm and sunny", 78, 65), + "Honolulu": ("Tropical paradise weather", 82, 72), + } + + # Find a matching destination or use a default + forecast = ("Partly cloudy", 65, 50) + for city, weather in weather_by_region.items(): + if city.lower() in destination.lower(): + forecast = weather + break + + condition, high_f, low_f = forecast + high_c = (high_f - 32) * 5 // 9 + low_c = (low_f - 32) * 5 // 9 + + recommendation = _get_weather_recommendation(condition) + + return f"""Weather forecast for {destination} on {date}: +Conditions: {condition} +High: {high_f}°F ({high_c}°C) +Low: {low_f}°F ({low_c}°C) + +Recommendation: {recommendation}""" + + +def get_local_events( + destination: Annotated[str, "The destination city or location"], + date: Annotated[ + str, 'The date to search for events (e.g., "2025-01-15" or "next week")' + ], +) -> str: + """Get local events and activities happening at a destination around a specific date. + + Use this to suggest timely activities and experiences. + + Args: + destination: The destination city or location. + date: The date to search for events. + + Returns: + A list of local events and activities. + """ + # Mock events data based on destination + events_by_city = { + "Tokyo": [ + "🎭 Kabuki Theater Performance at Kabukiza Theatre - Traditional Japanese drama", + "🌸 Winter Illuminations at Yoyogi Park - Spectacular light displays", + "🍜 Ramen Festival at Tokyo Station - Sample ramen from across Japan", + "🎮 Gaming Expo at Tokyo Big Sight - Latest video games and technology", + ], + "Paris": [ + "🎨 Impressionist Exhibition at Musée d'Orsay - Extended evening hours", + "🍷 Wine Tasting Tour in Le Marais - Local sommelier guided", + "🎵 Jazz Night at Le Caveau de la Huchette - Historic jazz club", + "🥐 French Pastry Workshop - Learn from master pâtissiers", + ], + "New York": [ + "🎭 Broadway Show: Hamilton - Limited engagement performances", + "🏀 Knicks vs Lakers at Madison Square Garden", + "🎨 Modern Art Exhibit at MoMA - New installations", + "🍕 Pizza Walking Tour of Brooklyn - Artisan pizzerias", + ], + "London": [ + "👑 Royal Collection Exhibition at Buckingham Palace", + "🎭 West End Musical: The Phantom of the Opera", + "🍺 Craft Beer Festival at Brick Lane", + "🎪 Winter Wonderland at Hyde Park - Rides and markets", + ], + "Sydney": [ + "🏄 Pro Surfing Competition at Bondi Beach", + "🎵 Opera at Sydney Opera House - La Bohème", + "🦘 Wildlife Night Safari at Taronga Zoo", + "🍽️ Harbor Dinner Cruise with fireworks", + ], + "Rome": [ + "🏛️ After-Hours Vatican Tour - Skip the crowds", + "🍝 Pasta Making Class in Trastevere", + "🎵 Classical Concert at Borghese Gallery", + "🍷 Wine Tasting in Roman Cellars", + ], + "Singapore": [ + "🌆 Marina Bay Light Show - Spectacular nightly display", + "🍜 Hawker Center Food Tour - Authentic local cuisine", + "🌿 Night Safari at Singapore Zoo - World's first nocturnal zoo", + "🏛️ Peranakan Culture Experience at Katong", + ], + } + + # Find events for the destination or use generic events + events = [ + "🎭 Local theater performance", + "🍽️ Food and wine festival", + "🎨 Art gallery opening", + "🎵 Live music at local venues", + ] + + for city, city_events in events_by_city.items(): + if city.lower() in destination.lower(): + events = city_events + break + + event_list = "\n• ".join(events) + return f"""Local events in {destination} around {date}: + +• {event_list} + +💡 Tip: Book popular events in advance as they may sell out quickly!""" + + +def _get_weather_recommendation(condition: str) -> str: + """Get a recommendation based on weather conditions. + + Args: + condition: The weather condition description. + + Returns: + A recommendation string. + """ + condition_lower = condition.lower() + + if "rain" in condition_lower or "drizzle" in condition_lower: + return "Bring an umbrella and waterproof jacket. Consider indoor activities for backup." + elif "fog" in condition_lower: + return ( + "Morning visibility may be limited. Plan outdoor sightseeing for afternoon." + ) + elif "cold" in condition_lower: + return "Layer up with warm clothing. Hot drinks and cozy cafés recommended." + elif "hot" in condition_lower or "warm" in condition_lower: + return "Stay hydrated and use sunscreen. Plan strenuous activities for cooler morning hours." + elif "thunder" in condition_lower or "storm" in condition_lower: + return "Keep an eye on weather updates. Have indoor alternatives ready." + else: + return "Pleasant conditions expected. Great day for outdoor exploration!"