// Copyright (c) Microsoft. All rights reserved. using System.Text; using Microsoft.Agents.AI; using Microsoft.Agents.AI.DurableTask; using Microsoft.Agents.AI.Hosting.AzureFunctions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Mvc; using Microsoft.Azure.Functions.Worker; using Microsoft.DurableTask.Client; using Microsoft.Extensions.Logging; namespace ReliableStreaming; /// /// HTTP trigger functions for reliable streaming of durable agent responses. /// /// /// This class exposes two endpoints: /// /// /// Create /// Starts an agent run and streams responses. The response format depends on the /// Accept header: text/plain returns raw text (ideal for terminals), while /// text/event-stream or any other value returns Server-Sent Events (SSE). /// /// /// Stream /// Resumes a stream from a cursor position, enabling reliable message delivery /// /// /// public sealed class FunctionTriggers { private readonly RedisStreamResponseHandler _streamHandler; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// The Redis stream handler for reading/writing agent responses. /// The logger instance. public FunctionTriggers(RedisStreamResponseHandler streamHandler, ILogger logger) { this._streamHandler = streamHandler; this._logger = logger; } /// /// Creates a new agent session, starts an agent run with the provided prompt, /// and streams the response back to the client. /// /// /// /// The response format depends on the Accept header: /// /// text/plain: Returns raw text output, ideal for terminal display with curl /// text/event-stream or other: Returns Server-Sent Events (SSE) with cursor support /// /// /// /// The response includes an x-conversation-id header containing the conversation ID. /// For SSE responses, clients can use this conversation ID to resume the stream if disconnected /// by calling the endpoint with the conversation ID and the last received cursor. /// /// /// Each SSE event contains the following fields: /// /// id: The Redis stream entry ID (use as cursor for resumption) /// event: Either "message" for content or "done" for stream completion /// data: The text content of the response chunk /// /// /// /// The HTTP request containing the prompt in the body. /// The Durable Task client for signaling agents. /// The function invocation context. /// Cancellation token. /// A streaming response in the format specified by the Accept header. [Function(nameof(CreateAsync))] public async Task CreateAsync( [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "agent/create")] HttpRequest request, [DurableClient] DurableTaskClient durableClient, FunctionContext context, CancellationToken cancellationToken) { // Read the prompt from the request body string prompt = await new StreamReader(request.Body).ReadToEndAsync(cancellationToken); if (string.IsNullOrWhiteSpace(prompt)) { return new BadRequestObjectResult("Request body must contain a prompt."); } AIAgent agentProxy = durableClient.AsDurableAgentProxy(context, "TravelPlanner"); // Create a new agent thread AgentThread thread = await agentProxy.GetNewThreadAsync(cancellationToken); string agentSessionId = thread.GetService().ToString(); this._logger.LogInformation("Creating new agent session: {AgentSessionId}", agentSessionId); // Run the agent in the background (fire-and-forget) DurableAgentRunOptions options = new() { IsFireAndForget = true }; await agentProxy.RunAsync(prompt, thread, options, cancellationToken); this._logger.LogInformation("Agent run started for session: {AgentSessionId}", agentSessionId); // Check Accept header to determine response format // text/plain = raw text output (ideal for terminals) // text/event-stream or other = SSE format (supports resumption) string? acceptHeader = request.Headers.Accept.FirstOrDefault(); bool useSseFormat = acceptHeader?.Contains("text/plain", StringComparison.OrdinalIgnoreCase) != true; return await this.StreamToClientAsync( conversationId: agentSessionId, cursor: null, useSseFormat, request.HttpContext, cancellationToken); } /// /// Resumes streaming from a specific cursor position for an existing session. /// /// /// /// Use this endpoint to resume a stream after disconnection. Pass the conversation ID /// (from the x-conversation-id response header) and the last received cursor /// (Redis stream entry ID) to continue from where you left off. /// /// /// If no cursor is provided, streaming starts from the beginning of the stream. /// This allows clients to replay the entire response if needed. /// /// /// The response format depends on the Accept header: /// /// text/plain: Returns raw text output, ideal for terminal display with curl /// text/event-stream or other: Returns Server-Sent Events (SSE) with cursor support /// /// /// /// The HTTP request. Use the cursor query parameter to specify the cursor position. /// The conversation ID to stream from. /// Cancellation token. /// A streaming response in the format specified by the Accept header. [Function(nameof(StreamAsync))] public async Task StreamAsync( [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "agent/stream/{conversationId}")] HttpRequest request, string conversationId, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(conversationId)) { return new BadRequestObjectResult("Conversation ID is required."); } // Get the cursor from query string (optional) string? cursor = request.Query["cursor"].FirstOrDefault(); this._logger.LogInformation( "Resuming stream for conversation {ConversationId} from cursor: {Cursor}", conversationId, cursor ?? "(beginning)"); // Check Accept header to determine response format // text/plain = raw text output (ideal for terminals) // text/event-stream or other = SSE format (supports cursor-based resumption) string? acceptHeader = request.Headers.Accept.FirstOrDefault(); bool useSseFormat = acceptHeader?.Contains("text/plain", StringComparison.OrdinalIgnoreCase) != true; return await this.StreamToClientAsync(conversationId, cursor, useSseFormat, request.HttpContext, cancellationToken); } /// /// Streams chunks from the Redis stream to the HTTP response. /// /// The conversation ID to stream from. /// Optional cursor to resume from. If null, streams from the beginning. /// True to use SSE format, false for plain text. /// The HTTP context for writing the response. /// Cancellation token. /// An empty result after streaming completes. private async Task StreamToClientAsync( string conversationId, string? cursor, bool useSseFormat, HttpContext httpContext, CancellationToken cancellationToken) { // Set response headers based on format httpContext.Response.Headers.ContentType = useSseFormat ? "text/event-stream" : "text/plain; charset=utf-8"; httpContext.Response.Headers.CacheControl = "no-cache"; httpContext.Response.Headers.Connection = "keep-alive"; httpContext.Response.Headers["x-conversation-id"] = conversationId; // Disable response buffering if supported httpContext.Features.Get()?.DisableBuffering(); try { await foreach (StreamChunk chunk in this._streamHandler.ReadStreamAsync( conversationId, cursor, cancellationToken)) { if (chunk.Error != null) { this._logger.LogWarning("Stream error for conversation {ConversationId}: {Error}", conversationId, chunk.Error); await WriteErrorAsync(httpContext.Response, chunk.Error, useSseFormat, cancellationToken); break; } if (chunk.IsDone) { await WriteEndOfStreamAsync(httpContext.Response, chunk.EntryId, useSseFormat, cancellationToken); break; } if (chunk.Text != null) { await WriteChunkAsync(httpContext.Response, chunk, useSseFormat, cancellationToken); } } } catch (OperationCanceledException) { this._logger.LogInformation("Client disconnected from stream {ConversationId}", conversationId); } return new EmptyResult(); } /// /// Writes a text chunk to the response. /// private static async Task WriteChunkAsync( HttpResponse response, StreamChunk chunk, bool useSseFormat, CancellationToken cancellationToken) { if (useSseFormat) { await WriteSSEEventAsync(response, "message", chunk.Text!, chunk.EntryId); } else { await response.WriteAsync(chunk.Text!, cancellationToken); } await response.Body.FlushAsync(cancellationToken); } /// /// Writes an end-of-stream marker to the response. /// private static async Task WriteEndOfStreamAsync( HttpResponse response, string entryId, bool useSseFormat, CancellationToken cancellationToken) { if (useSseFormat) { await WriteSSEEventAsync(response, "done", "[DONE]", entryId); } else { await response.WriteAsync("\n", cancellationToken); } await response.Body.FlushAsync(cancellationToken); } /// /// Writes an error message to the response. /// private static async Task WriteErrorAsync( HttpResponse response, string error, bool useSseFormat, CancellationToken cancellationToken) { if (useSseFormat) { await WriteSSEEventAsync(response, "error", error, null); } else { await response.WriteAsync($"\n[Error: {error}]\n", cancellationToken); } await response.Body.FlushAsync(cancellationToken); } /// /// Writes a Server-Sent Event to the response stream. /// private static async Task WriteSSEEventAsync( HttpResponse response, string eventType, string data, string? id) { StringBuilder sb = new(); // Include the ID if provided (used as cursor for resumption) if (!string.IsNullOrEmpty(id)) { sb.AppendLine($"id: {id}"); } sb.AppendLine($"event: {eventType}"); sb.AppendLine($"data: {data}"); sb.AppendLine(); // Empty line marks end of event await response.WriteAsync(sb.ToString()); } }