// 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());
}
}