diff --git a/agent-framework/integrations/ag-ui/backend-tool-rendering.md b/agent-framework/integrations/ag-ui/backend-tool-rendering.md index 933fd48f..56ad8f6b 100644 --- a/agent-framework/integrations/ag-ui/backend-tool-rendering.md +++ b/agent-framework/integrations/ag-ui/backend-tool-rendering.md @@ -43,18 +43,18 @@ Here's a complete server implementation demonstrating how to register tools with using System.ComponentModel; using System.Text.Json.Serialization; -using Azure.AI.Projects; +using Azure.AI.OpenAI; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; using Microsoft.Extensions.AI; using Microsoft.Extensions.Options; +using OpenAI.Chat; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); -builder.Services.AddHttpClient().AddLogging(); builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.TypeInfoResolverChain.Add(SampleJsonSerializerContext.Default)); -builder.Services.AddAGUI(); +builder.Services.AddAGUIServer(); WebApplication app = builder.Build(); @@ -63,33 +63,6 @@ string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"] string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); -// Define request/response types for the tool -internal sealed class RestaurantSearchRequest -{ - public string Location { get; set; } = string.Empty; - public string Cuisine { get; set; } = "any"; -} - -internal sealed class RestaurantSearchResponse -{ - public string Location { get; set; } = string.Empty; - public string Cuisine { get; set; } = string.Empty; - public RestaurantInfo[] Results { get; set; } = []; -} - -internal sealed class RestaurantInfo -{ - public string Name { get; set; } = string.Empty; - public string Cuisine { get; set; } = string.Empty; - public double Rating { get; set; } - public string Address { get; set; } = string.Empty; -} - -// JSON serialization context for source generation -[JsonSerializable(typeof(RestaurantSearchRequest))] -[JsonSerializable(typeof(RestaurantSearchResponse))] -internal sealed partial class SampleJsonSerializerContext : JsonSerializerContext; - // Define the function tool [Description("Search for restaurants in a location.")] static RestaurantSearchResponse SearchRestaurants( @@ -137,23 +110,51 @@ AITool[] tools = [ AIFunctionFactory.Create( SearchRestaurants, + name: "search_restaurants", serializerOptions: jsonOptions.SerializerOptions) ]; // Create the AI agent with tools -AIAgent agent = new AIProjectClient( +AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), new DefaultAzureCredential()) + .GetChatClient(deploymentName) .AsAIAgent( - model: deploymentName, name: "AGUIAssistant", instructions: "You are a helpful assistant with access to restaurant information.", tools: tools); // Map the AG-UI agent endpoint -app.MapAGUI("/", agent); +app.MapAGUIServer("/", agent); await app.RunAsync(); + +// Define request/response types for the tool +internal sealed class RestaurantSearchRequest +{ + public string Location { get; set; } = string.Empty; + public string Cuisine { get; set; } = "any"; +} + +internal sealed class RestaurantSearchResponse +{ + public string Location { get; set; } = string.Empty; + public string Cuisine { get; set; } = string.Empty; + public RestaurantInfo[] Results { get; set; } = []; +} + +internal sealed class RestaurantInfo +{ + public string Name { get; set; } = string.Empty; + public string Cuisine { get; set; } = string.Empty; + public double Rating { get; set; } + public string Address { get; set; } = string.Empty; +} + +// JSON serialization context for source generation +[JsonSerializable(typeof(RestaurantSearchRequest))] +[JsonSerializable(typeof(RestaurantSearchResponse))] +internal sealed partial class SampleJsonSerializerContext : JsonSerializerContext; ``` > [!WARNING] @@ -251,9 +252,9 @@ When the agent calls backend tools, you'll see: ``` User (:q or quit to exit): What's the weather like in Amsterdam? -[Run Started - Thread: thread_abc123, Run: run_xyz789] +[Run Started - Run: run_xyz789] -[Function Call - Name: SearchRestaurants] +[Function Call - Name: search_restaurants] Parameter: Location = Amsterdam Parameter: Cuisine = any @@ -262,7 +263,7 @@ User (:q or quit to exit): What's the weather like in Amsterdam? The weather in Amsterdam is sunny with a temperature of 22°C. Here are some great restaurants in the area: The Golden Fork (Italian, 4.5 stars)... -[Run Finished - Thread: thread_abc123] +[Run Finished] ``` ### Key Concepts @@ -739,4 +740,4 @@ a := foundryprovider.NewAgent(endpoint, token, foundryprovider.ModelDeployment(m > [!TIP] > See the [AG-UI backend tools sample](https://github.com/microsoft/agent-framework-go/blob/main/examples/02-agents/agui/step02_backend_tools/server/main.go) for a complete runnable example. -::: zone-end \ No newline at end of file +::: zone-end diff --git a/agent-framework/integrations/ag-ui/frontend-tools.md b/agent-framework/integrations/ag-ui/frontend-tools.md index 6058efae..1ab4425f 100644 --- a/agent-framework/integrations/ag-ui/frontend-tools.md +++ b/agent-framework/integrations/ag-ui/frontend-tools.md @@ -20,7 +20,7 @@ This tutorial shows you how to add frontend function tools to your AG-UI clients Before you begin, ensure you have completed the [Getting Started](getting-started.md) tutorial and have: - .NET 8.0 or later -- `Microsoft.Agents.AI.AGUI` package installed +- `AGUI.Client` package installed (the AG-UI C# SDK client) - `Microsoft.Agents.AI` package installed - Basic understanding of AG-UI client setup @@ -70,61 +70,16 @@ The rest of your client code remains the same as shown in the Getting Started tu When you register tools with `AsAIAgent()`, the `AGUIChatClient` automatically: 1. Captures the tool definitions (names, descriptions, parameter schemas) -3. Sends the tools with each request to the server agent which maps them to `ChatAgentRunOptions.ChatOptions.Tools` +2. Sends the tools with each request to the server agent, which maps them to `ChatClientAgentRunOptions.ChatOptions.Tools` The server receives the client tool declarations and the AI model can decide when to call them. -### Inspecting and Modifying Tools with Middleware - -You can use agent middleware to inspect or modify the agent run, including accessing the tools: - -```csharp -// Create agent with middleware that inspects tools -AIAgent inspectableAgent = baseAgent - .AsBuilder() - .Use(runFunc: null, runStreamingFunc: InspectToolsMiddleware) - .Build(); - -static async IAsyncEnumerable InspectToolsMiddleware( - IEnumerable messages, - AgentSession? session, - AgentRunOptions? options, - AIAgent innerAgent, - CancellationToken cancellationToken) -{ - // Access the tools from ChatClientAgentRunOptions - if (options is ChatClientAgentRunOptions chatOptions) - { - IList? tools = chatOptions.ChatOptions?.Tools; - if (tools != null) - { - Console.WriteLine($"Tools available for this run: {tools.Count}"); - foreach (AITool tool in tools) - { - if (tool is AIFunction function) - { - Console.WriteLine($" - {function.Metadata.Name}: {function.Metadata.Description}"); - } - } - } - } - - await foreach (AgentResponseUpdate update in innerAgent.RunStreamingAsync(messages, session, options, cancellationToken)) - { - yield return update; - } -} -``` - -This middleware pattern allows you to: -- Validate tool definitions before execution - ### Key Concepts The following are new concepts for frontend tools: - **Client-side registration**: Tools are registered on the client using `AIFunctionFactory.Create()` and passed to `AsAIAgent()` -- **Automatic capture**: Tools are automatically captured and sent via `ChatAgentRunOptions.ChatOptions.Tools` +- **Automatic capture**: Tools are automatically captured and sent via `ChatClientAgentRunOptions.ChatOptions.Tools` ## How Frontend Tools Work diff --git a/agent-framework/integrations/ag-ui/getting-started.md b/agent-framework/integrations/ag-ui/getting-started.md index e4e25af8..946a501f 100644 --- a/agent-framework/integrations/ag-ui/getting-started.md +++ b/agent-framework/integrations/ag-ui/getting-started.md @@ -54,13 +54,13 @@ Install the necessary packages for the server: ```bash dotnet add package Microsoft.Agents.AI.Hosting.AGUI.AspNetCore --prerelease -dotnet add package Azure.AI.Projects --prerelease +dotnet add package Microsoft.Agents.AI.OpenAI --prerelease +dotnet add package Azure.AI.OpenAI dotnet add package Azure.Identity -dotnet add package Microsoft.Agents.AI.Foundry --prerelease ``` > [!NOTE] -> The `Microsoft.Agents.AI.Foundry` package is required for the `AsAIAgent()` extension method that creates an Agent Framework agent from an `AIProjectClient`. +> The `Microsoft.Agents.AI.OpenAI` package provides the `AsAIAgent()` extension method that turns an Azure OpenAI (or OpenAI) chat client into an Agent Framework agent. ### Server Code @@ -69,33 +69,33 @@ Create a file named `Program.cs`: ```csharp // Copyright (c) Microsoft. All rights reserved. -using Azure.AI.Projects; +using Azure.AI.OpenAI; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; +using OpenAI.Chat; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); -builder.Services.AddHttpClient().AddLogging(); -builder.Services.AddAGUI(); - -WebApplication app = builder.Build(); +builder.Services.AddAGUIServer(); string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"] ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); -// Create the AI agent -AIAgent agent = new AIProjectClient( +// Build an agent directly from the Azure OpenAI chat client. +AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), new DefaultAzureCredential()) + .GetChatClient(deploymentName) .AsAIAgent( - model: deploymentName, name: "AGUIAssistant", instructions: "You are a helpful assistant."); -// Map the AG-UI agent endpoint -app.MapAGUI("/", agent); +WebApplication app = builder.Build(); + +// Map the agent to an AG-UI endpoint (HTTP POST + SSE streaming). +app.MapAGUIServer("/", agent); await app.RunAsync(); ``` @@ -105,12 +105,12 @@ await app.RunAsync(); ### Key Concepts -- **`AddAGUI`**: Registers AG-UI services with the dependency injection container -- **`MapAGUI`**: Extension method that registers the AG-UI endpoint with automatic request/response handling and SSE streaming -- **`AsAIAgent`**: Creates an Agent Framework agent from an `AIProjectClient` with a specified model and instructions +- **`AddAGUIServer`**: Registers AG-UI server services with the dependency injection container +- **`MapAGUIServer`**: Extension method that maps an agent to an AG-UI endpoint with automatic request/response handling and SSE streaming +- **`AsAIAgent`**: Creates an Agent Framework agent directly from the Azure OpenAI chat client with a name and instructions - **ASP.NET Core Integration**: Uses ASP.NET Core's native async support for streaming responses - **Instructions**: The agent is created with default instructions, which can be overridden by client messages -- **Configuration**: `AIProjectClient` with `DefaultAzureCredential` provides secure authentication +- **Configuration**: `AzureOpenAIClient` with `DefaultAzureCredential` provides secure authentication ### Configure and Run the Server @@ -144,12 +144,13 @@ The AG-UI client connects to the remote server and displays streaming responses. Install the AG-UI client library: ```bash -dotnet add package Microsoft.Agents.AI.AGUI --prerelease +dotnet add package AGUI.Client --prerelease dotnet add package Microsoft.Agents.AI --prerelease ``` > [!NOTE] -> The `Microsoft.Agents.AI` package provides the `AsAIAgent()` extension method. +> The `Microsoft.Agents.AI` package provides the `AsAIAgent()` extension method. The AG-UI client +> type (`AGUIChatClient`) ships in the AG-UI C# SDK package `AGUI.Client`. ### Client Code @@ -159,7 +160,7 @@ Create a file named `Program.cs`: // Copyright (c) Microsoft. All rights reserved. using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AGUI; +using AGUI.Client; using Microsoft.Extensions.AI; string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; @@ -172,7 +173,7 @@ using HttpClient httpClient = new() Timeout = TimeSpan.FromSeconds(60) }; -AGUIChatClient chatClient = new(httpClient, serverUrl); +AGUIChatClient chatClient = new(new AGUIChatClientOptions(httpClient, serverUrl)); AIAgent agent = chatClient.AsAIAgent( name: "agui-client", @@ -207,7 +208,6 @@ try // Stream the response bool isFirstUpdate = true; - string? threadId = null; await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, session)) { @@ -216,9 +216,8 @@ try // First update indicates run started if (isFirstUpdate) { - threadId = chatUpdate.ConversationId; Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine($"\n[Run Started - Thread: {chatUpdate.ConversationId}, Run: {chatUpdate.ResponseId}]"); + Console.WriteLine($"\n[Run Started - Run: {chatUpdate.ResponseId}]"); Console.ResetColor(); isFirstUpdate = false; } @@ -242,7 +241,7 @@ try } Console.ForegroundColor = ConsoleColor.Green; - Console.WriteLine($"\n[Run Finished - Thread: {threadId}]"); + Console.WriteLine("\n[Run Finished]"); Console.ResetColor(); } } @@ -258,7 +257,7 @@ catch (Exception ex) - **AGUIChatClient**: Client class that connects to AG-UI servers and implements `IChatClient` - **AsAIAgent**: Extension method on `AGUIChatClient` to create an agent from the client - **RunStreamingAsync**: Streams responses as `AgentResponseUpdate` objects -- **AsChatResponseUpdate**: Extension method to access chat-specific properties like `ConversationId` and `ResponseId` +- **AsChatResponseUpdate**: Extension method to access chat-specific properties like `ResponseId` - **Session Management**: The `AgentSession` maintains conversation context across requests - **Content Types**: Responses include `TextContent` for messages and `ErrorContent` for errors @@ -286,38 +285,67 @@ With both the server and client running, you can now test the complete system. $ dotnet run Connecting to AG-UI server at: http://localhost:8888 + User (:q or quit to exit): What is 2 + 2? -[Run Started - Thread: thread_abc123, Run: run_xyz789] +[Run Started - Run: run_OXhljyhFd6LNjtYlQGHaYknF] 2 + 2 equals 4. -[Run Finished - Thread: thread_abc123] +[Run Finished] User (:q or quit to exit): Tell me a fun fact about space -[Run Started - Thread: thread_abc123, Run: run_def456] -Here's a fun fact: A day on Venus is longer than its year! Venus takes -about 243 Earth days to rotate once on its axis, but only about 225 Earth -days to orbit the Sun. -[Run Finished - Thread: thread_abc123] +[Run Started - Run: run_9fTgYc51ITc5xsGetz1zTKnh] +A fun fact about space is that there are more stars in the observable +universe than grains of sand on all the beaches on Earth. +[Run Finished] User (:q or quit to exit): :q ``` -### Color-Coded Output +## Testing with curl (Optional) -The client displays different content types with distinct colors: +You can exercise the server directly with curl before running the client. The AG-UI endpoint accepts a +`RunAgentInput` JSON body. The only required field is `messages`. If you omit `threadId`, the server +generates one for you: -- **Yellow**: Run started notifications -- **Cyan**: Agent text responses (streamed in real-time) -- **Green**: Run completion notifications -- **Red**: Error messages +```bash +curl -N http://localhost:8888/ \ + -H "Content-Type: application/json" \ + -H "Accept: text/event-stream" \ + -d '{ + "messages": [ + {"role": "user", "content": "What is 2 + 2?"} + ] + }' +``` + +You should see Server-Sent Events streaming back: + +``` +data: {"type":"RUN_STARTED","threadId":"b60869bc...","runId":""} + +data: {"type":"TEXT_MESSAGE_START","messageId":"...","role":"assistant","name":"AGUIAssistant"} + +data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"...","delta":"Two"} + +data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"...","delta":" plus"} + +... + +data: {"type":"TEXT_MESSAGE_END","messageId":"..."} + +data: {"type":"RUN_FINISHED","threadId":"b60869bc...","runId":""} +``` + +Use the route you mapped with `MapAGUIServer` (here `/`) and the port your server listens on +(`dotnet run --urls http://localhost:8888`). ## How It Works ### Server-Side Flow 1. Client sends HTTP POST request with messages -2. ASP.NET Core endpoint receives the request via `MapAGUI` +2. ASP.NET Core endpoint receives the request via `MapAGUIServer` 3. Agent processes the messages using Agent Framework 4. Responses are converted to AG-UI events 5. Events are streamed back as Server-Sent Events (SSE) diff --git a/agent-framework/integrations/ag-ui/human-in-the-loop.md b/agent-framework/integrations/ag-ui/human-in-the-loop.md index 565bcb25..5c134d9e 100644 --- a/agent-framework/integrations/ag-ui/human-in-the-loop.md +++ b/agent-framework/integrations/ag-ui/human-in-the-loop.md @@ -19,11 +19,10 @@ This tutorial demonstrates how to implement human-in-the-loop approval workflows The C# AG-UI approval pattern works as follows: -1. **Server**: Wraps functions with `ApprovalRequiredAIFunction` to mark them as requiring approval -2. **Middleware**: Intercepts `FunctionApprovalRequestContent` from the agent and converts it to a client tool call -3. **Client**: Receives the tool call, displays approval UI, and sends the approval response as a tool result -4. **Middleware**: Unwraps the approval response and converts it to `FunctionApprovalResponseContent` -5. **Agent**: Continues execution with the user's approval decision +1. **Server**: Wraps a function with `ApprovalRequiredAIFunction` to mark it as requiring approval, and maps the agent with `MapAGUIServer`. +2. **Interrupt**: When the model calls the tool, the run ends with an interrupt instead of executing it. The AG-UI client surfaces this as a `ToolApprovalRequestContent`. +3. **Client**: Presents the request to the user, then creates a decision with `CreateResponse(approved)` and sends it back. +4. **Resume**: `AGUIChatClient` transports the decision over the AG-UI resume mechanism. The agent continues and runs (or skips) the tool. ## Prerequisites @@ -35,599 +34,136 @@ The C# AG-UI approval pattern works as follows: ## Server Implementation -### Define Approval-Required Tool - -Create a function and wrap it with `ApprovalRequiredAIFunction`: +To require human approval before a tool runs, wrap the tool's `AIFunction` in `ApprovalRequiredAIFunction` and map the agent with `MapAGUIServer`. The AG-UI hosting layer raises an approval interrupt automatically when the model calls the tool. ```csharp using System.ComponentModel; -using Microsoft.Extensions.AI; - -[Description("Send an email to a recipient.")] -static string SendEmail( - [Description("The email address to send to")] string to, - [Description("The subject line")] string subject, - [Description("The email body")] string body) -{ - return $"Email sent to {to} with subject '{subject}'"; -} - -// Create approval-required tool -#pragma warning disable MEAI001 // Type is for evaluation purposes only -AITool[] tools = [new ApprovalRequiredAIFunction(AIFunctionFactory.Create(SendEmail))]; -#pragma warning restore MEAI001 -``` - -### Create Approval Models - -Define models for the approval request and response: - -```csharp -using System.Text.Json.Serialization; - -public sealed class ApprovalRequest -{ - [JsonPropertyName("approval_id")] - public required string ApprovalId { get; init; } - - [JsonPropertyName("function_name")] - public required string FunctionName { get; init; } - - [JsonPropertyName("function_arguments")] - public JsonElement? FunctionArguments { get; init; } - - [JsonPropertyName("message")] - public string? Message { get; init; } -} - -public sealed class ApprovalResponse -{ - [JsonPropertyName("approval_id")] - public required string ApprovalId { get; init; } - - [JsonPropertyName("approved")] - public required bool Approved { get; init; } -} - -[JsonSerializable(typeof(ApprovalRequest))] -[JsonSerializable(typeof(ApprovalResponse))] -[JsonSerializable(typeof(Dictionary))] -internal partial class ApprovalJsonContext : JsonSerializerContext -{ -} -``` - -### Implement Approval Middleware - -Create middleware that translates between Microsoft.Extensions.AI approval types and AG-UI protocol: - -> [!IMPORTANT] -> After converting approval responses, both the `request_approval` tool call and its result must be removed from the message history. Otherwise, Azure OpenAI will return an error: "tool_calls must be followed by tool messages responding to each 'tool_call_id'". - -```csharp -using System.Runtime.CompilerServices; -using System.Text.Json; +using Azure.AI.OpenAI; +using Azure.Identity; using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; using Microsoft.Extensions.AI; -using Microsoft.Extensions.Options; - -// Get JsonSerializerOptions from the configured HTTP JSON options -var jsonOptions = app.Services.GetRequiredService>().Value; - -var agent = baseAgent - .AsBuilder() - .Use(runFunc: null, runStreamingFunc: (messages, session, options, innerAgent, cancellationToken) => - HandleApprovalRequestsMiddleware( - messages, - session, - options, - innerAgent, - jsonOptions.SerializerOptions, - cancellationToken)) - .Build(); - -static async IAsyncEnumerable HandleApprovalRequestsMiddleware( - IEnumerable messages, - AgentSession? session, - AgentRunOptions? options, - AIAgent innerAgent, - JsonSerializerOptions jsonSerializerOptions, - [EnumeratorCancellation] CancellationToken cancellationToken) -{ - // Process messages: Convert approval responses back to agent format - var modifiedMessages = ConvertApprovalResponsesToFunctionApprovals(messages, jsonSerializerOptions); - - // Invoke inner agent - await foreach (var update in innerAgent.RunStreamingAsync( - modifiedMessages, session, options, cancellationToken)) - { - // Process updates: Convert approval requests to client tool calls - await foreach (var processedUpdate in ConvertFunctionApprovalsToToolCalls(update, jsonSerializerOptions)) - { - yield return processedUpdate; - } - } - - // Local function: Convert approval responses from client back to FunctionApprovalResponseContent - static IEnumerable ConvertApprovalResponsesToFunctionApprovals( - IEnumerable messages, - JsonSerializerOptions jsonSerializerOptions) - { - // Look for "request_approval" tool calls and their matching results - Dictionary approvalToolCalls = []; - FunctionResultContent? approvalResult = null; - - foreach (var message in messages) - { - foreach (var content in message.Contents) - { - if (content is FunctionCallContent { Name: "request_approval" } toolCall) - { - approvalToolCalls[toolCall.CallId] = toolCall; - } - else if (content is FunctionResultContent result && approvalToolCalls.ContainsKey(result.CallId)) - { - approvalResult = result; - } - } - } +using OpenAI.Chat; - // If no approval response found, return messages unchanged - if (approvalResult == null) - { - return messages; - } +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +builder.Services.AddAGUIServer(); - // Deserialize the approval response - if ((approvalResult.Result as JsonElement?)?.Deserialize(jsonSerializerOptions.GetTypeInfo(typeof(ApprovalResponse))) is not ApprovalResponse response) - { - return messages; - } - - // Extract the original function call details from the approval request - var originalToolCall = approvalToolCalls[approvalResult.CallId]; - - if (originalToolCall.Arguments?.TryGetValue("request", out JsonElement request) != true || - request.Deserialize(jsonSerializerOptions.GetTypeInfo(typeof(ApprovalRequest))) is not ApprovalRequest approvalRequest) - { - return messages; - } - - // Deserialize the function arguments from JsonElement - var functionArguments = approvalRequest.FunctionArguments is { } args - ? (Dictionary?)args.Deserialize( - jsonSerializerOptions.GetTypeInfo(typeof(Dictionary))) - : null; - - var originalFunctionCall = new FunctionCallContent( - callId: response.ApprovalId, - name: approvalRequest.FunctionName, - arguments: functionArguments); - - var functionApprovalResponse = new FunctionApprovalResponseContent( - response.ApprovalId, - response.Approved, - originalFunctionCall); - - // Replace/remove the approval-related messages - List newMessages = []; - foreach (var message in messages) - { - bool hasApprovalResult = false; - bool hasApprovalRequest = false; - - foreach (var content in message.Contents) - { - if (content is FunctionResultContent { CallId: var callId } && callId == approvalResult.CallId) - { - hasApprovalResult = true; - break; - } - if (content is FunctionCallContent { Name: "request_approval", CallId: var reqCallId } && reqCallId == approvalResult.CallId) - { - hasApprovalRequest = true; - break; - } - } - - if (hasApprovalResult) - { - // Replace tool result with approval response - newMessages.Add(new ChatMessage(ChatRole.User, [functionApprovalResponse])); - } - else if (hasApprovalRequest) - { - // Skip the request_approval tool call message - continue; - } - else - { - newMessages.Add(message); - } - } +WebApplication app = builder.Build(); - return newMessages; - } +string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"] + ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] + ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); - // Local function: Convert FunctionApprovalRequestContent to client tool calls - static async IAsyncEnumerable ConvertFunctionApprovalsToToolCalls( - AgentResponseUpdate update, - JsonSerializerOptions jsonSerializerOptions) - { - // Check if this update contains a FunctionApprovalRequestContent - FunctionApprovalRequestContent? approvalRequestContent = null; - foreach (var content in update.Contents) - { - if (content is FunctionApprovalRequestContent request) - { - approvalRequestContent = request; - break; - } - } +// A tool that must be approved before it runs. +[Description("Send an email to a recipient.")] +static string SendEmail( + [Description("The email address to send to.")] string to, + [Description("The subject line.")] string subject, + [Description("The email body.")] string body) + => $"Email sent to {to} with subject '{subject}'."; - // If no approval request, yield the update unchanged - if (approvalRequestContent == null) - { - yield return update; - yield break; - } +AITool sendEmail = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(SendEmail, name: "send_email")); - // Convert the approval request to a "client tool call" - var functionCall = approvalRequestContent.FunctionCall; - var approvalId = approvalRequestContent.Id; +AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) + .GetChatClient(deploymentName) + .AsAIAgent( + name: "AGUIAssistant", + instructions: "You are a helpful assistant. Use the send_email tool when asked to send email.", + tools: [sendEmail]); - // Serialize the function arguments as JsonElement - var argsElement = functionCall.Arguments?.Count > 0 - ? JsonSerializer.SerializeToElement(functionCall.Arguments, jsonSerializerOptions.GetTypeInfo(typeof(IDictionary))) - : (JsonElement?)null; +app.MapAGUIServer("/", agent); - var approvalData = new ApprovalRequest - { - ApprovalId = approvalId, - FunctionName = functionCall.Name, - FunctionArguments = argsElement, - Message = $"Approve execution of '{functionCall.Name}'?" - }; - - var approvalJson = JsonSerializer.Serialize(approvalData, jsonSerializerOptions.GetTypeInfo(typeof(ApprovalRequest))); - - // Yield a tool call update that represents the approval request - yield return new AgentResponseUpdate(ChatRole.Assistant, [ - new FunctionCallContent( - callId: approvalId, - name: "request_approval", - arguments: new Dictionary { ["request"] = approvalJson }) - ]); - } -} +await app.RunAsync(); ``` -## Client Implementation +> [!WARNING] +> `DefaultAzureCredential` is convenient for development but requires careful consideration in production. In production, consider using a specific credential (e.g., `ManagedIdentityCredential`) to avoid latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -### Implement Client-Side Middleware +When the model calls `SendEmail`, the run ends with an **interrupt** outcome instead of executing the +tool. The AG-UI client surfaces this as a `ToolApprovalRequestContent` that carries the tool call and a +response schema of `{ "approved": boolean }`. The tool runs only after the client sends an approval. -The client requires **bidirectional middleware** that handles both: -1. **Inbound**: Converting `request_approval` tool calls to `FunctionApprovalRequestContent` -2. **Outbound**: Converting `FunctionApprovalResponseContent` back to tool results +## Client Implementation -> [!IMPORTANT] -> Use `AdditionalProperties` on `AIContent` objects to track the correlation between approval requests and responses, avoiding external state dictionaries. +A client handles the interrupt by reading the `ToolApprovalRequestContent`, creating a decision with +`CreateResponse(approved)`, and sending it back on the next turn. You don't hand-encode the AG-UI +resume message. `AGUIChatClient` converts the decision into the AG-UI resume for you, and reusing the +same `AgentSession` resumes the run. ```csharp -using System.Runtime.CompilerServices; -using System.Text.Json; +using AGUI.Client; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AGUI; using Microsoft.Extensions.AI; -// Get JsonSerializerOptions from the client -var jsonSerializerOptions = JsonSerializerOptions.Default; - -#pragma warning disable MEAI001 // Type is for evaluation purposes only -// Wrap the agent with approval middleware -var wrappedAgent = agent - .AsBuilder() - .Use(runFunc: null, runStreamingFunc: (messages, session, options, innerAgent, cancellationToken) => - HandleApprovalRequestsClientMiddleware( - messages, - session, - options, - innerAgent, - jsonSerializerOptions, - cancellationToken)) - .Build(); - -static async IAsyncEnumerable HandleApprovalRequestsClientMiddleware( - IEnumerable messages, - AgentSession? session, - AgentRunOptions? options, - AIAgent innerAgent, - JsonSerializerOptions jsonSerializerOptions, - [EnumeratorCancellation] CancellationToken cancellationToken) -{ - // Process messages: Convert approval responses back to tool results - var processedMessages = ConvertApprovalResponsesToToolResults(messages, jsonSerializerOptions); - - // Invoke inner agent - await foreach (var update in innerAgent.RunStreamingAsync(processedMessages, session, options, cancellationToken)) - { - // Process updates: Convert tool calls to approval requests - await foreach (var processedUpdate in ConvertToolCallsToApprovalRequests(update, jsonSerializerOptions)) - { - yield return processedUpdate; - } - } - - // Local function: Convert FunctionApprovalResponseContent back to tool results - static IEnumerable ConvertApprovalResponsesToToolResults( - IEnumerable messages, - JsonSerializerOptions jsonSerializerOptions) - { - List processedMessages = []; +string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; +using HttpClient httpClient = new() { BaseAddress = new Uri(serverUrl) }; - foreach (var message in messages) - { - List convertedContents = []; - bool hasApprovalResponse = false; +AGUIChatClient chatClient = new(new AGUIChatClientOptions(httpClient, "/")); +AIAgent agent = chatClient.AsAIAgent(); +AgentSession session = await agent.CreateSessionAsync(); - foreach (var content in message.Contents) - { - if (content is FunctionApprovalResponseContent approvalResponse) - { - hasApprovalResponse = true; - - // Get the original request_approval CallId from AdditionalProperties - if (approvalResponse.AdditionalProperties?.TryGetValue("request_approval_call_id", out string? requestApprovalCallId) == true) - { - var response = new ApprovalResponse - { - ApprovalId = approvalResponse.Id, - Approved = approvalResponse.Approved - }; - - var responseJson = JsonSerializer.SerializeToElement(response, jsonSerializerOptions.GetTypeInfo(typeof(ApprovalResponse))); - - var toolResult = new FunctionResultContent( - callId: requestApprovalCallId, - result: responseJson); - - convertedContents.Add(toolResult); - } - } - else - { - convertedContents.Add(content); - } - } +List messages = [new(ChatRole.User, "Email alice@example.com to say the report is ready.")]; - if (hasApprovalResponse && convertedContents.Count > 0) - { - processedMessages.Add(new ChatMessage(ChatRole.Tool, convertedContents)); - } - else - { - processedMessages.Add(message); - } - } - - return processedMessages; - } - - // Local function: Convert request_approval tool calls to FunctionApprovalRequestContent - static async IAsyncEnumerable ConvertToolCallsToApprovalRequests( - AgentResponseUpdate update, - JsonSerializerOptions jsonSerializerOptions) +// First turn: run until the agent requests approval. +ToolApprovalRequestContent? approvalRequest = null; +await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, session)) +{ + foreach (AIContent content in update.Contents) { - FunctionCallContent? approvalToolCall = null; - foreach (var content in update.Contents) - { - if (content is FunctionCallContent { Name: "request_approval" } toolCall) - { - approvalToolCall = toolCall; - break; - } - } - - if (approvalToolCall == null) + if (content is ToolApprovalRequestContent request) { - yield return update; - yield break; + approvalRequest = request; + var call = request.ToolCall as FunctionCallContent; + Console.WriteLine($"Approval requested for '{call?.Name}'."); } - - if (approvalToolCall.Arguments?.TryGetValue("request", out JsonElement request) != true || - request.Deserialize(jsonSerializerOptions.GetTypeInfo(typeof(ApprovalRequest))) is not ApprovalRequest approvalRequest) + else if (content is TextContent text) { - yield return update; - yield break; + Console.Write(text.Text); } - - var functionArguments = approvalRequest.FunctionArguments is { } args - ? (Dictionary?)args.Deserialize( - jsonSerializerOptions.GetTypeInfo(typeof(Dictionary))) - : null; - - var originalFunctionCall = new FunctionCallContent( - callId: approvalRequest.ApprovalId, - name: approvalRequest.FunctionName, - arguments: functionArguments); - - // Yield the original tool call first (for message history) - yield return new AgentResponseUpdate(ChatRole.Assistant, [approvalToolCall]); - - // Create approval request with CallId stored in AdditionalProperties - var approvalRequestContent = new FunctionApprovalRequestContent( - approvalRequest.ApprovalId, - originalFunctionCall); - - // Store the request_approval CallId in AdditionalProperties for later retrieval - approvalRequestContent.AdditionalProperties ??= new Dictionary(); - approvalRequestContent.AdditionalProperties["request_approval_call_id"] = approvalToolCall.CallId; - - yield return new AgentResponseUpdate(ChatRole.Assistant, [approvalRequestContent]); } } -#pragma warning restore MEAI001 -``` - -### Handle Approval Requests and Send Responses - -The consuming code processes approval requests and automatically continues until no more approvals are needed: -### Handle Approval Requests and Send Responses - -The consuming code processes approval requests. When receiving a `FunctionApprovalRequestContent`, store the request_approval CallId in the response's AdditionalProperties: -```csharp -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AGUI; -using Microsoft.Extensions.AI; - -#pragma warning disable MEAI001 // Type is for evaluation purposes only -List approvalResponses = []; -List approvalToolCalls = []; - -do +// Second turn: send the decision. The agent resumes and runs (or skips) the tool. +if (approvalRequest is not null) { - approvalResponses.Clear(); - approvalToolCalls.Clear(); - - await foreach (AgentResponseUpdate update in wrappedAgent.RunStreamingAsync( - messages, session, cancellationToken: cancellationToken)) + ToolApprovalResponseContent decision = approvalRequest.CreateResponse(approved: true); + List resume = [new(ChatRole.User, [decision])]; + + // Reusing the same session resumes the run. No thread/run id plumbing is needed. + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(resume, session)) { foreach (AIContent content in update.Contents) { - if (content is FunctionApprovalRequestContent approvalRequest) - { - DisplayApprovalRequest(approvalRequest); - - // Get user approval - Console.Write($"\nApprove '{approvalRequest.FunctionCall.Name}'? (yes/no): "); - string? userInput = Console.ReadLine(); - bool approved = userInput?.ToUpperInvariant() is "YES" or "Y"; - - // Create approval response and preserve the request_approval CallId - var approvalResponse = approvalRequest.CreateResponse(approved); - - // Copy AdditionalProperties to preserve the request_approval_call_id - if (approvalRequest.AdditionalProperties != null) - { - approvalResponse.AdditionalProperties ??= new Dictionary(); - foreach (var kvp in approvalRequest.AdditionalProperties) - { - approvalResponse.AdditionalProperties[kvp.Key] = kvp.Value; - } - } - - approvalResponses.Add(approvalResponse); - } - else if (content is FunctionCallContent { Name: "request_approval" } requestApprovalCall) - { - // Track the original request_approval tool call - approvalToolCalls.Add(requestApprovalCall); - } - else if (content is TextContent textContent) + if (content is TextContent text) { - Console.Write(textContent.Text); + Console.Write(text.Text); } } } - - // Add both messages in correct order - if (approvalResponses.Count > 0 && approvalToolCalls.Count > 0) - { - messages.Add(new ChatMessage(ChatRole.Assistant, approvalToolCalls.ToArray())); - messages.Add(new ChatMessage(ChatRole.User, approvalResponses.ToArray())); - } } -while (approvalResponses.Count > 0); -#pragma warning restore MEAI001 - -static void DisplayApprovalRequest(FunctionApprovalRequestContent approvalRequest) -{ - Console.WriteLine(); - Console.WriteLine("============================================================"); - Console.WriteLine("APPROVAL REQUIRED"); - Console.WriteLine("============================================================"); - Console.WriteLine($"Function: {approvalRequest.FunctionCall.Name}"); - - if (approvalRequest.FunctionCall.Arguments != null) - { - Console.WriteLine("Arguments:"); - foreach (var arg in approvalRequest.FunctionCall.Arguments) - { - Console.WriteLine($" {arg.Key} = {arg.Value}"); - } - } - - Console.WriteLine("============================================================"); -} -``` - -## Example Interaction - -``` -User (:q or quit to exit): Send an email to user@example.com about the meeting - -[Run Started - Thread: thread_abc123, Run: run_xyz789] - -============================================================ -APPROVAL REQUIRED -============================================================ - -Function: SendEmail -Arguments: {"to":"user@example.com","subject":"Meeting","body":"..."} -Message: Approve execution of 'SendEmail'? - -============================================================ - -[Waiting for approval to execute SendEmail...] -[Run Finished - Thread: thread_abc123] - -Approve this action? (yes/no): yes - -[Sending approval response: APPROVED] - -[Run Resumed - Thread: thread_abc123] -Email sent to user@example.com with subject 'Meeting' -[Run Finished] ``` -## Key Concepts +To reject instead, call `approvalRequest.CreateResponse(approved: false)`; the agent continues without running the tool. -### Client Tool Pattern +## Approval modes -The C# implementation uses a "client tool call" pattern: +Marking a tool for approval, and deciding *when* a call needs it, is a general Agent Framework +capability, not something AG-UI defines. It works the same for an agent exposed over AG-UI: -- **Approval Request** → Tool call named `"request_approval"` with approval details -- **Approval Response** → Tool result containing the user's decision -- **Middleware** → Translates between Microsoft.Extensions.AI types and AG-UI protocol +- **Always require / never require** approval by wrapping (or not wrapping) the function in + `ApprovalRequiredAIFunction`. +- **Selective approval**: wrap only the sensitive tools so the rest run unattended. +- **Conditional approval**: auto-approve some calls to an approval-required tool based on their + **arguments** using `AIAgentBuilder.UseToolApproval` with `AutoApprovalRules`. -This allows the standard `ApprovalRequiredAIFunction` pattern to work across the HTTP+SSE boundary while maintaining consistency with the agent framework's approval model. +For the APIs, examples, and guidance, see [Using function tools with human-in-the-loop approvals](../../agents/tools/tool-approval.md). -### Bidirectional Middleware Pattern - -Both server and client middleware follow a consistent three-step pattern: - -1. **Process Messages**: Transform incoming messages (approval responses → FunctionApprovalResponseContent or tool results) -2. **Invoke Inner Agent**: Call the inner agent with processed messages -3. **Process Updates**: Transform outgoing updates (FunctionApprovalRequestContent → tool calls or vice versa) - -### State Tracking with AdditionalProperties - -Instead of external dictionaries, the implementation uses `AdditionalProperties` on `AIContent` objects to track metadata: - -- **Client**: Stores `request_approval_call_id` in `FunctionApprovalRequestContent.AdditionalProperties` -- **Response Preservation**: Copies `AdditionalProperties` from request to response to maintain the correlation -- **Conversion**: Uses the stored CallId to create properly correlated `FunctionResultContent` - -This keeps all correlation data within the content objects themselves, avoiding the need for external state management. - -### Server-Side Message Cleanup - -The server middleware must remove approval protocol messages after processing: - -- **Problem**: Azure OpenAI requires all tool calls to have matching tool results -- **Solution**: After converting approval responses, remove both the `request_approval` tool call and its result message -- **Reason**: Prevents "tool_calls must be followed by tool messages" errors +What AG-UI adds is the transport. A call that needs a human ends the run with a `RUN_FINISHED` +**interrupt** that carries the tool call and a `{ "approved": boolean }` response schema, which the +AG-UI client approves and resumes. The [Server](#server-implementation) and [Client](#client-implementation) +implementations above show this end to end. Calls that run directly, or that a conditional rule +auto-approves, stream their `TOOL_CALL_RESULT` normally and never interrupt. ## Next steps @@ -1154,4 +690,4 @@ a := foundryprovider.NewAgent(endpoint, token, foundryprovider.ModelDeployment(m > [!TIP] > See the [AG-UI human-in-the-loop sample](https://github.com/microsoft/agent-framework-go/blob/main/examples/02-agents/agui/step04_human_in_loop/server/main.go) for a complete runnable example. -::: zone-end \ No newline at end of file +::: zone-end diff --git a/agent-framework/integrations/ag-ui/index.md b/agent-framework/integrations/ag-ui/index.md index 0b7e569e..85d9ef0a 100644 --- a/agent-framework/integrations/ag-ui/index.md +++ b/agent-framework/integrations/ag-ui/index.md @@ -81,27 +81,27 @@ The AG-UI integration uses ASP.NET Core and follows a clean middleware-based arc └────────┬────────┘ │ HTTP POST + SSE ▼ -┌─────────────────────────┐ -│ ASP.NET Core │ -│ MapAGUI("/", agent) │ -└────────┬────────────────┘ +┌────────────────────────────┐ +│ ASP.NET Core │ +│ MapAGUIServer("/", agent) │ +└────────┬───────────────────┘ │ ▼ -┌─────────────────────────┐ -│ AIAgent │ -│ (with Middleware) │ -└────────┬────────────────┘ +┌────────────────────────────┐ +│ AIAgent │ +│ (with Middleware) │ +└────────┬───────────────────┘ │ ▼ -┌─────────────────────────┐ -│ IChatClient │ -│ (Azure OpenAI, etc.) │ -└─────────────────────────┘ +┌────────────────────────────┐ +│ IChatClient │ +│ (Azure OpenAI, etc.) │ +└────────────────────────────┘ ``` ### Key Components -- **ASP.NET Core Endpoint**: `MapAGUI` extension method handles HTTP requests and SSE streaming +- **ASP.NET Core Endpoint**: `MapAGUIServer` extension method handles HTTP requests and SSE streaming - **AIAgent**: Agent Framework agent created from `IChatClient` or custom implementation - **Middleware Pipeline**: Optional middleware for approvals, state management, and custom logic - **Protocol Adapter**: Converts between Agent Framework types and AG-UI protocol events diff --git a/agent-framework/integrations/ag-ui/state-management.md b/agent-framework/integrations/ag-ui/state-management.md index e5d6db97..02f5c343 100644 --- a/agent-framework/integrations/ag-ui/state-management.md +++ b/agent-framework/integrations/ag-ui/state-management.md @@ -45,6 +45,8 @@ State management is valuable for: ## Creating State-Aware Agents in C# +State management in the .NET AG-UI integration is **declarative**: your agent exposes ordinary tools that return your state objects, and you tell the hosting layer which tool results become AG-UI state events by configuring an `AGUIStreamOptions`. You don't write a custom agent or emit protocol content by hand. + ### Define Your State Model First, define classes for your state structure: @@ -54,284 +56,497 @@ using System.Text.Json.Serialization; namespace RecipeAssistant; -// State response wrapper +// State response wrapper returned by the tool. Its shape is what the client renders as state. internal sealed class RecipeResponse { [JsonPropertyName("recipe")] - public RecipeState Recipe { get; set; } = new(); + public Recipe Recipe { get; set; } = new(); } -// Recipe state model -internal sealed class RecipeState +// Recipe state model. +internal sealed class Recipe { [JsonPropertyName("title")] public string Title { get; set; } = string.Empty; - [JsonPropertyName("cuisine")] - public string Cuisine { get; set; } = string.Empty; + [JsonPropertyName("skill_level")] + public string SkillLevel { get; set; } = string.Empty; + + [JsonPropertyName("cooking_time")] + public string CookingTime { get; set; } = string.Empty; + + [JsonPropertyName("special_preferences")] + public List SpecialPreferences { get; set; } = []; [JsonPropertyName("ingredients")] - public List Ingredients { get; set; } = []; + public List Ingredients { get; set; } = []; - [JsonPropertyName("steps")] - public List Steps { get; set; } = []; + [JsonPropertyName("instructions")] + public List Instructions { get; set; } = []; +} - [JsonPropertyName("prep_time_minutes")] - public int PrepTimeMinutes { get; set; } +// A single ingredient. +internal sealed class Ingredient +{ + [JsonPropertyName("icon")] + public string Icon { get; set; } = string.Empty; - [JsonPropertyName("cook_time_minutes")] - public int CookTimeMinutes { get; set; } + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; - [JsonPropertyName("skill_level")] - public string SkillLevel { get; set; } = string.Empty; + [JsonPropertyName("amount")] + public string Amount { get; set; } = string.Empty; } -// JSON serialization context +// JSON serialization context for the tool payloads. [JsonSerializable(typeof(RecipeResponse))] -[JsonSerializable(typeof(RecipeState))] -[JsonSerializable(typeof(System.Text.Json.JsonElement))] +[JsonSerializable(typeof(Recipe))] +[JsonSerializable(typeof(Ingredient))] internal sealed partial class RecipeSerializerContext : JsonSerializerContext; ``` -### Implement State Management Middleware +### Emit a State Snapshot from a Tool -Create middleware that handles state management by detecting when the client sends state and coordinating the agent's responses: +Expose a tool that returns the complete state. The agent calls it whenever the recipe should change. The hosting layer turns the tool result into a `STATE_SNAPSHOT` event when you map it: + +```csharp +using System.ComponentModel; +using Microsoft.Extensions.AI; + +[Description("Generate or update the shared recipe and display it to the user.")] +static RecipeResponse GenerateRecipe( + [Description("The complete recipe to display.")] Recipe recipe) => new() { Recipe = recipe }; + +AITool generateRecipe = AIFunctionFactory.Create( + GenerateRecipe, + name: "generate_recipe", + description: "Generate or update the shared recipe and display it to the user.", + RecipeSerializerContext.Default.Options); +``` + +### Create the Agent + +Build the agent directly from your chat client with . Put the system prompt and tools on : ```csharp -using System.Runtime.CompilerServices; -using System.Text.Json; using Microsoft.Agents.AI; +using Azure.AI.OpenAI; +using Azure.Identity; using Microsoft.Extensions.AI; +using OpenAI.Chat; -internal sealed class SharedStateAgent : DelegatingAIAgent -{ - private readonly JsonSerializerOptions _jsonSerializerOptions; +const string SharedStateSystemPrompt = + """ + You are a helpful recipe assistant that maintains a shared recipe state with the user. + + IMPORTANT: + - When the user asks you to create, change, or improve a recipe, call the `generate_recipe` + tool with a COMPLETE recipe: a title, skill_level, cooking_time, special_preferences, the + full list of ingredients (each with an icon, name and amount) and the step-by-step + instructions. + - Always include every ingredient the recipe needs, keeping any the user already added. + - When the user only asks a question about the recipe, answer in plain text and do NOT call the tool. + """; + +string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") + ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") + ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); + +AIAgent recipeAgent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) + .GetChatClient(deploymentName) + .AsAIAgent(new ChatClientAgentOptions + { + Name = "RecipeAgent", + Description = "An agent that maintains a shared recipe state with the user.", + ChatOptions = new ChatOptions + { + Instructions = SharedStateSystemPrompt, + Tools = [generateRecipe], + }, + }); +``` + +> [!WARNING] +> `DefaultAzureCredential` is convenient for development but requires careful consideration in production. In production, consider using a specific credential (e.g., `ManagedIdentityCredential`) to avoid latency issues, unintended credential probing, and potential security risks from fallback mechanisms. + +### Map the Tool Result to a State Event + +Create an `AGUIStreamOptions`, register the tool name as a state snapshot, and attach it to the endpoint metadata. `MapAGUIServer` reads the stream options from the endpoint (or from `IOptions` in DI) and emits the state events for you: + +```csharp +using AGUI.Server; +using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +builder.Services.ConfigureHttpJsonOptions(options => + options.SerializerOptions.TypeInfoResolverChain.Add(RecipeSerializerContext.Default)); +builder.Services.AddAGUIServer(); + +// A `generate_recipe` result becomes a STATE_SNAPSHOT event. +AGUIStreamOptions streamOptions = new AGUIStreamOptions() + .MapResultAsStateSnapshot("generate_recipe"); + +WebApplication app = builder.Build(); + +// Attach the stream options to the endpoint. MapAGUIServer emits the state events for you. +app.MapAGUIServer("/", recipeAgent).WithMetadata(streamOptions); + +await app.RunAsync(); +``` + +That's the whole server. There is no custom `DelegatingAIAgent` and no protocol content to build: the tool returns your state object, `MapResultAsStateSnapshot` turns each result into a `STATE_SNAPSHOT`, and the framework streams it to the client. + +### Reading Client State + +The recipe lives on the client. When the client sends a turn, it includes its current state on the AG-UI `RunAgentInput`. Recover it from the request's with `TryGetRunAgentInput` and read `RunAgentInput.State` (a `JsonElement`): + +```csharp +using System.Text.Json; +using AGUI.Abstractions; +using AGUI.Server; +using Microsoft.Extensions.AI; - public SharedStateAgent(AIAgent innerAgent, JsonSerializerOptions jsonSerializerOptions) - : base(innerAgent) +static bool TryGetClientState(ChatOptions chatOptions, out JsonElement state) +{ + if (chatOptions.TryGetRunAgentInput(out RunAgentInput? input) && + input.State is { ValueKind: not JsonValueKind.Undefined } clientState) { - this._jsonSerializerOptions = jsonSerializerOptions; + state = clientState; + return true; } - protected override Task RunCoreAsync( - IEnumerable messages, - AgentSession? session = null, - AgentRunOptions? options = null, - CancellationToken cancellationToken = default) + state = default; + return false; +} +``` + +`TryGetRunAgentInput` reads the input that the hosting layer stashed on `ChatOptions.AdditionalProperties`. You never touch that dictionary directly. Give the model the current recipe by prepending it as a system message before the agent runs (for example, from a lightweight that only injects context and delegates the run), so edits build on the existing state instead of starting from scratch. + +### Key Concepts + +- **Tools return state**: A tool returns your state object; you never construct AG-UI events yourself. +- **Declarative mapping**: `AGUIStreamOptions.MapResultAsStateSnapshot(toolName)` / `MapResultAsStateDelta(toolName)` map a tool result to a `STATE_SNAPSHOT` / `STATE_DELTA` event. +- **Endpoint wiring**: Attach the stream options with `.WithMetadata(streamOptions)` on `MapAGUIServer`, or register `IOptions` in DI. +- **Reading state**: `ChatOptions.TryGetRunAgentInput(out var input)` recovers the `RunAgentInput`; `input.State` is the client's current state as a `JsonElement`. + +## State Deltas with Agentic Generative UI + +`MapResultAsStateSnapshot` replaces the entire state on each turn. For incremental changes, map a tool result to a `STATE_DELTA` with `MapResultAsStateDelta`, returning a [JSON Patch](https://datatracker.ietf.org/doc/html/rfc6902) document. + +A common scenario is *agentic generative UI*: the agent builds a plan, then updates the status of individual steps as it works. `create_plan` sends the full plan as a snapshot; `update_plan_step` sends only the changed fields as a delta. + +Define the plan model and status enum: + +```csharp +using System.Text.Json.Serialization; + +internal sealed class Plan +{ + [JsonPropertyName("steps")] + public List Steps { get; set; } = []; +} + +internal sealed class Step +{ + [JsonPropertyName("description")] + public required string Description { get; set; } + + [JsonPropertyName("status")] + public StepStatus Status { get; set; } = StepStatus.Pending; +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum StepStatus +{ + Pending, + Completed +} + +internal sealed class JsonPatchOperation +{ + [JsonPropertyName("op")] + public required string Op { get; set; } + + [JsonPropertyName("path")] + public required string Path { get; set; } + + [JsonPropertyName("value")] + public object? Value { get; set; } +} +``` + +The `create_plan` tool returns the full plan; `update_plan_step` returns a list of JSON Patch operations: + +```csharp +using System.ComponentModel; + +[Description("Create a plan with multiple steps.")] +public static Plan CreatePlan( + [Description("List of step descriptions to create the plan.")] List steps) +{ + return new Plan { - return this.RunStreamingAsync(messages, session, options, cancellationToken) - .ToAgentResponseAsync(cancellationToken); - } + Steps = [.. steps.Select(s => new Step { Description = s, Status = StepStatus.Pending })] + }; +} + +[Description("Update a step in the plan with new description or status.")] +public static List UpdatePlanStep( + [Description("The index of the step to update.")] int index, + [Description("The new status for the step.")] StepStatus status) +{ + // Status must be lowercase to match AG-UI frontend expectations. + string statusValue = status == StepStatus.Pending ? "pending" : "completed"; + + return + [ + new JsonPatchOperation { Op = "replace", Path = $"/steps/{index}/status", Value = statusValue } + ]; +} +``` + +Register both tools on the agent (with `AllowMultipleToolCalls = false` so the model updates one step at a time), and map each tool result to the matching state event: `create_plan` to a snapshot, `update_plan_step` to a delta. - protected override async IAsyncEnumerable RunCoreStreamingAsync( - IEnumerable messages, - AgentSession? session = null, - AgentRunOptions? options = null, - [EnumeratorCancellation] CancellationToken cancellationToken = default) +```csharp +using AGUI.Server; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +AITool createPlan = AIFunctionFactory.Create( + CreatePlan, name: "create_plan", description: "Create a plan with multiple steps."); +AITool updatePlanStep = AIFunctionFactory.Create( + UpdatePlanStep, name: "update_plan_step", description: "Update a step in the plan with new description or status."); + +AIAgent planAgent = chatClient.AsAIAgent(new ChatClientAgentOptions +{ + Name = "AgenticUIAgent", + ChatOptions = new ChatOptions { - // Check if the client sent state in the request - if (options is not ChatClientAgentRunOptions { ChatOptions.AdditionalProperties: { } properties } chatRunOptions || - !properties.TryGetValue("ag_ui_state", out object? stateObj) || - stateObj is not JsonElement state || - state.ValueKind != JsonValueKind.Object) - { - // No state management requested, pass through to inner agent - await foreach (var update in this.InnerAgent.RunStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false)) - { - yield return update; - } - yield break; - } + Instructions = "Use `create_plan` to set the initial steps, then call `update_plan_step` until every step is completed. Do not describe the plan in text.", + Tools = [createPlan, updatePlanStep], + AllowMultipleToolCalls = false, + }, +}); - // Check if state has properties (not empty {}) - bool hasProperties = false; - foreach (JsonProperty _ in state.EnumerateObject()) - { - hasProperties = true; - break; - } +AGUIStreamOptions planStreamOptions = new AGUIStreamOptions() + .MapResultAsStateSnapshot("create_plan") // full plan -> STATE_SNAPSHOT + .MapResultAsStateDelta("update_plan_step"); // JSON Patch -> STATE_DELTA - if (!hasProperties) - { - // Empty state - treat as no state - await foreach (var update in this.InnerAgent.RunStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false)) - { - yield return update; - } - yield break; - } +app.MapAGUIServer("/agentic_generative_ui", planAgent).WithMetadata(planStreamOptions); +``` - // First run: Generate structured state update - var firstRunOptions = new ChatClientAgentRunOptions - { - ChatOptions = chatRunOptions.ChatOptions.Clone(), - AllowBackgroundResponses = chatRunOptions.AllowBackgroundResponses, - ContinuationToken = chatRunOptions.ContinuationToken, - ChatClientFactory = chatRunOptions.ChatClientFactory, - }; - - // Configure JSON schema response format for structured state output - firstRunOptions.ChatOptions.ResponseFormat = ChatResponseFormat.ForJsonSchema( - schemaName: "RecipeResponse", - schemaDescription: "A response containing a recipe with title, skill level, cooking time, preferences, ingredients, and instructions"); - - // Add current state to the conversation - state is already a JsonElement - ChatMessage stateUpdateMessage = new( - ChatRole.System, - [ - new TextContent("Here is the current state in JSON format:"), - new TextContent(JsonSerializer.Serialize(state, this._jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)))), - new TextContent("The new state is:") - ]); - - var firstRunMessages = messages.Append(stateUpdateMessage); - - // Collect all updates from first run - var allUpdates = new List(); - await foreach (var update in this.InnerAgent.RunStreamingAsync(firstRunMessages, session, firstRunOptions, cancellationToken).ConfigureAwait(false)) - { - allUpdates.Add(update); +> [!NOTE] +> `STATE_SNAPSHOT` replaces the entire state; `STATE_DELTA` applies a JSON Patch to the existing state. Send a snapshot when you set up or reset state, and deltas for incremental changes. - // Yield all non-text updates (tool calls, etc.) - bool hasNonTextContent = update.Contents.Any(c => c is not TextContent); - if (hasNonTextContent) - { - yield return update; - } - } +For a related pattern that streams a tool's arguments into state as the model generates them, continue with Predictive State Updates below. - var response = allUpdates.ToAgentResponse(); +## Predictive State Updates - // Try to deserialize the structured state response - JsonElement stateSnapshot; - try - { - stateSnapshot = JsonSerializer.Deserialize(response.Text, this._jsonSerializerOptions); - } - catch (JsonException) - { - yield break; - } +Predictive state updates let the UI react to a tool call *while its arguments are still being generated*, instead of waiting for the tool to finish. As the model streams the arguments for a tool, the server converts those partial arguments into state snapshots and sends them to the client. The client renders each snapshot immediately, giving the user an optimistic, live preview. For example, a document editor shows the text appearing in real time, then asks the user to confirm the change once the model is done. - // Serialize and emit as STATE_SNAPSHOT via DataContent - byte[] stateBytes = JsonSerializer.SerializeToUtf8Bytes( - stateSnapshot, - this._jsonSerializerOptions.GetTypeInfo(typeof(JsonElement))); - yield return new AgentResponseUpdate - { - Contents = [new DataContent(stateBytes, "application/json")] - }; +> [!NOTE] +> This scenario maps the endpoint manually and uses the built-in `TypedResults.ServerSentEvents(...)`, which requires **.NET 10.0 or later**. - // Second run: Generate user-friendly summary - var secondRunMessages = messages.Concat(response.Messages).Append( - new ChatMessage( - ChatRole.System, - [new TextContent("Please provide a concise summary of the state changes in at most two sentences.")])); +### How It Works - await foreach (var update in this.InnerAgent.RunStreamingAsync(secondRunMessages, session, options, cancellationToken).ConfigureAwait(false)) - { - yield return update; - } - } +Unlike the [shared-state](#emit-a-state-snapshot-from-a-tool) scenario, where a tool *runs* and its result becomes a snapshot, the predictive scenario intercepts the tool **call** before it executes and streams the argument into state: + +1. The agent declares a `write_document_local` tool. The model calls it with the full document text as its `document` argument. +2. The tool is **not** executed server-side. Instead, an `AGUIStreamOptions` `MapCall` mapping intercepts the call. +3. The mapping emits a series of `STATE_SNAPSHOT` events, each carrying a progressively longer prefix of the document, so the client sees the text stream in. +4. It then completes the tool call with a `TOOL_CALL_RESULT` event and injects a client-side `confirm_changes` tool call so the client can prompt the user to approve. +5. The client renders each snapshot and shows the confirm/reject prompt. + +Because the mapping produces the tool's result itself, the document tool is declared but never invoked. The chat client is built **without** function invocation. + +### Define the State Model + +The state model describes the shape the client renders. Use `JsonPropertyName` so the property names match what the client expects: + +```csharp +using System.Text.Json; +using System.Text.Json.Serialization; + +internal sealed class DocumentState +{ + [JsonPropertyName("document")] + public string Document { get; set; } = string.Empty; } + +[JsonSerializable(typeof(DocumentState))] +[JsonSerializable(typeof(JsonElement))] +internal sealed partial class DocumentSerializerContext : JsonSerializerContext; ``` -### Configure the Agent with State Management +### Declare the Document Tool + +The tool signature is what the model fills in. It is declared so the model calls it, but its result is produced by the stream mapping, not by executing the method body: ```csharp -using Microsoft.Agents.AI; -using Azure.AI.Projects; -using Azure.Identity; +using System.ComponentModel; +using Microsoft.Extensions.AI; + +[Description("Write a document in markdown format.")] +static string WriteDocument( + [Description("The document content to write.")] string document) => "Document written successfully"; -AIAgent CreateRecipeAgent(JsonSerializerOptions jsonSerializerOptions) +AITool writeDocument = AIFunctionFactory.Create( + WriteDocument, + name: "write_document_local", + description: "Write a document. Use markdown formatting to format the document."); +``` + +### Configure the Predictive Stream Mapping + +Register a `MapCall` mapping for the tool. When the model calls `write_document_local`, the mapping reads the streamed `document` argument, emits progressive `StateSnapshotEvent` snapshots, completes the tool call, and injects a `confirm_changes` client-side tool call: + +```csharp +using System.Text.Json; +using AGUI.Abstractions; +using AGUI.Server; +using Microsoft.Extensions.AI; + +static AGUIStreamOptions CreatePredictiveStreamOptions(JsonSerializerOptions jsonSerializerOptions) { - string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); - string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") - ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); - - // Create base agent - AIAgent baseAgent = new AIProjectClient( - new Uri(endpoint), - new DefaultAzureCredential()) - .AsAIAgent( - model: deploymentName, - name: "RecipeAgent", - instructions: """ - You are a helpful recipe assistant. When users ask you to create or suggest a recipe, - respond with a complete RecipeResponse JSON object that includes: - - recipe.title: The recipe name - - recipe.cuisine: Type of cuisine (e.g., Italian, Mexican, Japanese) - - recipe.ingredients: Array of ingredient strings with quantities - - recipe.steps: Array of cooking instruction strings - - recipe.prep_time_minutes: Preparation time in minutes - - recipe.cook_time_minutes: Cooking time in minutes - - recipe.skill_level: One of "beginner", "intermediate", or "advanced" - - Always include all fields in the response. Be creative and helpful. - """); - - // Wrap with state management middleware - return new SharedStateAgent(baseAgent, jsonSerializerOptions); + string? lastEmittedDocument = null; + + return new AGUIStreamOptions().MapCall("write_document_local", fcc => + { + string? document = fcc.Arguments?.TryGetValue("document", out var value) == true + ? value?.ToString() + : null; + + if (document is null || document == lastEmittedDocument) + { + return []; + } + + var events = new List(); + + // Only stream the newly added portion if the document grew. + int startIndex = lastEmittedDocument is not null && + document.StartsWith(lastEmittedDocument, StringComparison.Ordinal) + ? lastEmittedDocument.Length + : 0; + + const int chunkSize = 10; + for (int i = startIndex; i < document.Length; i += chunkSize) + { + int length = Math.Min(chunkSize, document.Length - i); + var snapshot = new DocumentState { Document = document[..(i + length)] }; + JsonElement snapshotJson = JsonSerializer.SerializeToElement(snapshot, jsonSerializerOptions); + + events.Add(new StateSnapshotEvent { Snapshot = snapshotJson }); + } + + // Complete the write_document_local call (its document is now reflected in state) so the + // only tool call the client sees pending is confirm_changes. + events.Add(new ToolCallResultEvent + { + MessageId = Guid.NewGuid().ToString("N"), + ToolCallId = fcc.CallId, + Content = "Document written.", + Role = "tool", + }); + + // Inject a client-side confirm_changes tool call so the approval modal renders. + string confirmCallId = Guid.NewGuid().ToString("N"); + string confirmMessageId = Guid.NewGuid().ToString("N"); + events.Add(new ToolCallStartEvent { ToolCallId = confirmCallId, ToolCallName = "confirm_changes", ParentMessageId = confirmMessageId }); + events.Add(new ToolCallArgsEvent { ToolCallId = confirmCallId, Delta = "{}" }); + events.Add(new ToolCallEndEvent { ToolCallId = confirmCallId }); + + lastEmittedDocument = document; + return events; + }); } ``` -> [!WARNING] -> `DefaultAzureCredential` is convenient for development but requires careful consideration in production. In production, consider using a specific credential (e.g., `ManagedIdentityCredential`) to avoid latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +> [!NOTE] +> Each snapshot contains the full document up to that point, so the client always renders a consistent view even if it misses an intermediate update. + +### Map the Endpoint -### Map the Agent Endpoint +Because the mapping produces the tool result itself, build the chat client **without** function invocation and stream through the AG-UI pipeline directly: adapt the incoming `RunAgentInput` with `ToChatRequestContext`, call `GetStreamingResponseAsync`, and convert the updates with `AsAGUIEventStreamAsync`. ```csharp -using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; +using AGUI.Abstractions; +using AGUI.Server; +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Options; +using JsonOptions = Microsoft.AspNetCore.Http.Json.JsonOptions; + +const string PredictiveSystemPrompt = + """ + You are a document editor assistant. When asked to write or edit content: + - Use the `write_document_local` tool with the full document text in Markdown format. + - You MUST write the full document, even when changing only a few words. + - When making edits, keep them minimal. Do not change every word. + After writing the document, briefly summarize the changes you made in at most two sentences. + """; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); -builder.Services.AddHttpClient().AddLogging(); builder.Services.ConfigureHttpJsonOptions(options => - options.SerializerOptions.TypeInfoResolverChain.Add(RecipeSerializerContext.Default)); -builder.Services.AddAGUI(); + options.SerializerOptions.TypeInfoResolverChain.Add(DocumentSerializerContext.Default)); +builder.Services.AddAGUIServer(); + +string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"] + ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] + ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); + +// No UseFunctionInvocation: the call is intercepted by the stream mapping, not executed. +IChatClient chatClient = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) + .GetChatClient(deploymentName) + .AsIChatClient(); WebApplication app = builder.Build(); -var jsonOptions = app.Services.GetRequiredService>().Value; -AIAgent recipeAgent = CreateRecipeAgent(jsonOptions.SerializerOptions); -app.MapAGUI("/", recipeAgent); +JsonSerializerOptions jsonSerializerOptions = app.Services + .GetRequiredService>() + .Value.SerializerOptions; -await app.RunAsync(); -``` +app.MapPost("/", ( + [FromBody] RunAgentInput input, + HttpContext httpContext, + CancellationToken cancellationToken) => +{ + AGUIStreamOptions streamOptions = CreatePredictiveStreamOptions(jsonSerializerOptions); -### Key Concepts + ChatRequestContext ctx = input.ToChatRequestContext(jsonSerializerOptions, streamOptions); + ctx.Messages.Insert(0, new ChatMessage(ChatRole.System, PredictiveSystemPrompt)); + (ctx.ChatOptions.Tools ??= []).Add(writeDocument); -- **State Detection**: Middleware checks for `ag_ui_state` in `ChatOptions.AdditionalProperties` to detect when the client is requesting state management -- **Two-Phase Response**: First generates structured state (JSON schema), then generates a user-friendly summary -- **Structured State Models**: Define C# classes for your state structure with JSON property names -- **JSON Schema Response Format**: Use `ChatResponseFormat.ForJsonSchema()` to ensure structured output -- **STATE_SNAPSHOT Events**: Emitted as `DataContent` with `application/json` media type, which the AG-UI framework automatically converts to STATE_SNAPSHOT events -- **State Context**: Current state is injected as a system message to provide context to the agent + var updates = chatClient.GetStreamingResponseAsync(ctx.Messages, ctx.ChatOptions, cancellationToken); + IAsyncEnumerable events = updates.AsAGUIEventStreamAsync(ctx, cancellationToken); -### How It Works + return TypedResults.ServerSentEvents(events); +}); -1. Client sends request with state in `ChatOptions.AdditionalProperties["ag_ui_state"]` -2. Middleware detects state and performs first run with JSON schema response format -3. Middleware adds current state as context in a system message -4. Agent generates structured state update matching your state model -5. Middleware serializes state and emits as `DataContent` (becomes STATE_SNAPSHOT event) -6. Middleware performs second run to generate user-friendly summary -7. Client receives both the state snapshot and the natural language summary +await app.RunAsync(); +``` -> [!TIP] -> The two-phase approach separates state management from user communication. The first phase ensures structured, reliable state updates while the second phase provides natural language feedback to the user. +> [!WARNING] +> `DefaultAzureCredential` is convenient for development but requires careful consideration in production. In production, consider using a specific credential (e.g., `ManagedIdentityCredential`) to avoid latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -### Client Implementation (C#) +> [!NOTE] +> `confirm_changes` is a *client-side* tool. The stream mapping requests it, and the client renders the approval prompt. See [Human-in-the-Loop](human-in-the-loop.md) for the client-side tool pattern. -> [!IMPORTANT] -> The C# client implementation is not included in this tutorial. The server-side state management is complete, but clients need to: -> 1. Initialize state with an empty object (not null): `RecipeState? currentState = new RecipeState();` -> 2. Send state as `DataContent` in a `ChatRole.System` message -> 3. Receive state snapshots as `DataContent` with `mediaType = "application/json"` -> -> The AG-UI hosting layer automatically extracts state from `DataContent` and places it in `ChatOptions.AdditionalProperties["ag_ui_state"]` as a `JsonElement`. +### Predictive Key Concepts + +- **`AGUIStreamOptions.MapCall`**: Intercepts a tool *call* (before execution) and returns the AG-UI events to emit for it. +- **`FunctionCallContent.Arguments`**: The streamed tool arguments. Read `Arguments["document"]` to get the text as the model produces it. +- **`StateSnapshotEvent`**: Each snapshot holds the full document prefix so far, producing the optimistic streaming effect. +- **`ToChatRequestContext` / `AsAGUIEventStreamAsync`**: The AG-UI streaming pipeline that adapts a `RunAgentInput` to a chat request and converts the response updates back into AG-UI events. +- **`confirm_changes`**: A client-side tool call injected after the document is written, so the user can approve the result. -For a complete client implementation example, see the Python client pattern below which demonstrates the full bidirectional state flow. +### Rendering on the Client + +A UI toolkit such as [CopilotKit](https://copilotkit.ai/) subscribes to the state snapshots and re-renders the document on each one, then shows the confirm or reject prompt when the `confirm_changes` tool call arrives. You can see this scenario running in the [AG-UI Dojo](https://dojo.ag-ui.com/microsoft-agent-framework-dotnet). ::: zone-end @@ -987,4 +1202,4 @@ a := foundryprovider.NewAgent(endpoint, token, foundryprovider.ModelDeployment(m > [!TIP] > See the [AG-UI state management sample](https://github.com/microsoft/agent-framework-go/blob/main/examples/02-agents/agui/step05_state_management/server/main.go) for a complete runnable example. -::: zone-end \ No newline at end of file +::: zone-end diff --git a/agent-framework/integrations/ag-ui/workflows.md b/agent-framework/integrations/ag-ui/workflows.md index 468e6b02..28712644 100644 --- a/agent-framework/integrations/ag-ui/workflows.md +++ b/agent-framework/integrations/ag-ui/workflows.md @@ -13,8 +13,61 @@ ms.service: agent-framework ::: zone pivot="programming-language-csharp" +[Workflows](../../workflows/index.md) orchestrate multiple agents in a defined execution graph. In .NET +you expose a workflow over AG-UI exactly the way you expose any agent: convert it to an `AIAgent` with +`AsAIAgent()` and map it with `MapAGUIServer`. There is no workflow-specific server API to learn. + +```csharp +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; +using Microsoft.Agents.AI.Workflows; +using OpenAI.Chat; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +builder.Services.AddAGUIServer(); + +string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"] + ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] + ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); + +ChatClient chatClient = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) + .GetChatClient(deploymentName); + +AIAgent researcher = chatClient.AsAIAgent( + name: "researcher", instructions: "Research the user's topic and write a short, factual brief."); +AIAgent reporter = chatClient.AsAIAgent( + name: "reporter", instructions: "Summarize the researcher's brief into a single clear paragraph."); + +// A workflow-as-agent is just an AIAgent. Map it like any other agent. +AIAgent workflowAgent = AgentWorkflowBuilder.BuildSequential(researcher, reporter).AsAIAgent(); + +WebApplication app = builder.Build(); +app.MapAGUIServer("/", workflowAgent); + +await app.RunAsync(); +``` + +> [!WARNING] +> `DefaultAzureCredential` is convenient for development but requires careful consideration in production. In production, consider using a specific credential (e.g., `ManagedIdentityCredential`) to avoid latency issues, unintended credential probing, and potential security risks from fallback mechanisms. + +The client side is unchanged. Connect with `AGUIChatClient` as in [Getting Started](getting-started.md). +Each agent's output streams as normal AG-UI text and tool-call events, and the `AuthorName` on each update +identifies which agent in the workflow produced it. + > [!NOTE] -> Workflow support for the .NET AG-UI integration is coming soon. +> The .NET integration streams a workflow's **agent output** (text and tool calls) over AG-UI, but not the +> workflow-specific AG-UI events shown in the Python version of this article: step tracking +> (`STEP_STARTED` / `STEP_FINISHED`), activity snapshots, and workflow-level interrupts. Those events are +> still evolving for .NET (tracked by [agent-framework#2494](https://github.com/microsoft/agent-framework/issues/2494)). + +## Next steps + +- [State Management](state-management.md) +- [Human-in-the-Loop](human-in-the-loop.md) +- [Workflows user guide](../../workflows/index.md) ::: zone-end