From 158ea7fa4391fa6badafca4ea8072a9a174bb021 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 22 Jul 2026 18:10:31 -0700 Subject: [PATCH 01/35] Update AG-UI C# docs for MAF v1.14 package/API changes - Microsoft.Agents.AI.AGUI package removed; client now in AGUI.Client (AG-UI C# SDK) - AddAGUI() -> AddAGUIServer(); MapAGUI() -> MapAGUIServer() - AGUIChatClient now options-based: new AGUIChatClient(new AGUIChatClientOptions(httpClient, url)) - Update using directives and key-concept descriptions accordingly Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eeb2168d-2ecc-4f8d-9830-c287072eb7e1 --- .../ag-ui/backend-tool-rendering.md | 4 ++-- .../integrations/ag-ui/frontend-tools.md | 2 +- .../integrations/ag-ui/getting-started.md | 20 ++++++++++--------- .../integrations/ag-ui/human-in-the-loop.md | 4 ++-- agent-framework/integrations/ag-ui/index.md | 4 ++-- .../integrations/ag-ui/state-management.md | 4 ++-- 6 files changed, 20 insertions(+), 18 deletions(-) diff --git a/agent-framework/integrations/ag-ui/backend-tool-rendering.md b/agent-framework/integrations/ag-ui/backend-tool-rendering.md index 933fd48f..e2374a1e 100644 --- a/agent-framework/integrations/ag-ui/backend-tool-rendering.md +++ b/agent-framework/integrations/ag-ui/backend-tool-rendering.md @@ -54,7 +54,7 @@ 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(); @@ -151,7 +151,7 @@ AIAgent agent = new AIProjectClient( tools: tools); // Map the AG-UI agent endpoint -app.MapAGUI("/", agent); +app.MapAGUIServer("/", agent); await app.RunAsync(); ``` diff --git a/agent-framework/integrations/ag-ui/frontend-tools.md b/agent-framework/integrations/ag-ui/frontend-tools.md index 6058efae..088a9934 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 diff --git a/agent-framework/integrations/ag-ui/getting-started.md b/agent-framework/integrations/ag-ui/getting-started.md index e4e25af8..42853d19 100644 --- a/agent-framework/integrations/ag-ui/getting-started.md +++ b/agent-framework/integrations/ag-ui/getting-started.md @@ -76,7 +76,7 @@ using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); builder.Services.AddHttpClient().AddLogging(); -builder.Services.AddAGUI(); +builder.Services.AddAGUIServer(); WebApplication app = builder.Build(); @@ -95,7 +95,7 @@ AIAgent agent = new AIProjectClient( instructions: "You are a helpful assistant."); // Map the AG-UI agent endpoint -app.MapAGUI("/", agent); +app.MapAGUIServer("/", agent); await app.RunAsync(); ``` @@ -105,8 +105,8 @@ 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 +- **`AddAGUIServer`**: Registers AG-UI services with the dependency injection container +- **`MapAGUIServer`**: 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 - **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 @@ -144,12 +144,14 @@ 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`) now ships in the AG-UI C# SDK package `AGUI.Client` (previously it was part +> of the removed `Microsoft.Agents.AI.AGUI` package). ### Client Code @@ -159,7 +161,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 +174,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", @@ -317,7 +319,7 @@ The client displays different content types with distinct colors: ### 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..5baa5b5c 100644 --- a/agent-framework/integrations/ag-ui/human-in-the-loop.md +++ b/agent-framework/integrations/ag-ui/human-in-the-loop.md @@ -317,7 +317,7 @@ The client requires **bidirectional middleware** that handles both: using System.Runtime.CompilerServices; using System.Text.Json; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AGUI; +using AGUI.Client; using Microsoft.Extensions.AI; // Get JsonSerializerOptions from the client @@ -478,7 +478,7 @@ The consuming code processes approval requests. When receiving a `FunctionApprov ```csharp using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AGUI; +using AGUI.Client; using Microsoft.Extensions.AI; #pragma warning disable MEAI001 // Type is for evaluation purposes only diff --git a/agent-framework/integrations/ag-ui/index.md b/agent-framework/integrations/ag-ui/index.md index 0b7e569e..8e613cbc 100644 --- a/agent-framework/integrations/ag-ui/index.md +++ b/agent-framework/integrations/ag-ui/index.md @@ -83,7 +83,7 @@ The AG-UI integration uses ASP.NET Core and follows a clean middleware-based arc ▼ ┌─────────────────────────┐ │ ASP.NET Core │ -│ MapAGUI("/", agent) │ +│ MapAGUIServer("/", agent) │ └────────┬────────────────┘ │ ▼ @@ -101,7 +101,7 @@ The AG-UI integration uses ASP.NET Core and follows a clean middleware-based arc ### 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..8f8aec9e 100644 --- a/agent-framework/integrations/ag-ui/state-management.md +++ b/agent-framework/integrations/ag-ui/state-management.md @@ -288,13 +288,13 @@ WebApplicationBuilder builder = WebApplication.CreateBuilder(args); builder.Services.AddHttpClient().AddLogging(); builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.TypeInfoResolverChain.Add(RecipeSerializerContext.Default)); -builder.Services.AddAGUI(); +builder.Services.AddAGUIServer(); WebApplication app = builder.Build(); var jsonOptions = app.Services.GetRequiredService>().Value; AIAgent recipeAgent = CreateRecipeAgent(jsonOptions.SerializerOptions); -app.MapAGUI("/", recipeAgent); +app.MapAGUIServer("/", recipeAgent); await app.RunAsync(); ``` From 44152c3ba8240b0af55de75ebf14fc5ee37dd389 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 22 Jul 2026 20:51:44 -0700 Subject: [PATCH 02/35] Fix ASCII architecture diagram alignment after MapAGUIServer rename Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eeb2168d-2ecc-4f8d-9830-c287072eb7e1 --- agent-framework/integrations/ag-ui/index.md | 22 ++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/agent-framework/integrations/ag-ui/index.md b/agent-framework/integrations/ag-ui/index.md index 8e613cbc..85d9ef0a 100644 --- a/agent-framework/integrations/ag-ui/index.md +++ b/agent-framework/integrations/ag-ui/index.md @@ -81,22 +81,22 @@ The AG-UI integration uses ASP.NET Core and follows a clean middleware-based arc └────────┬────────┘ │ HTTP POST + SSE ▼ -┌─────────────────────────┐ -│ ASP.NET Core │ +┌────────────────────────────┐ +│ ASP.NET Core │ │ MapAGUIServer("/", agent) │ -└────────┬────────────────┘ +└────────┬───────────────────┘ │ ▼ -┌─────────────────────────┐ -│ AIAgent │ -│ (with Middleware) │ -└────────┬────────────────┘ +┌────────────────────────────┐ +│ AIAgent │ +│ (with Middleware) │ +└────────┬───────────────────┘ │ ▼ -┌─────────────────────────┐ -│ IChatClient │ -│ (Azure OpenAI, etc.) │ -└─────────────────────────┘ +┌────────────────────────────┐ +│ IChatClient │ +│ (Azure OpenAI, etc.) │ +└────────────────────────────┘ ``` ### Key Components From 6cc3dbb44d266a7c3e4eb65d5291a7fd00ceedc7 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 22 Jul 2026 21:14:37 -0700 Subject: [PATCH 03/35] Fill missing C# AG-UI scenario coverage to match Python docs - state-management (C#): correct client-state input to chatOptions.TryGetRunAgentInput()/ RunAgentInput.State (was a stale ag_ui_state key) and STATE_SNAPSHOT emission to ChatResponseUpdate.RawRepresentation = StateSnapshotEvent (was DataContent, which the released AGUI.Server drops); add a working C# client example and a new Predictive State Updates section. - workflows (C#): replace 'coming soon' with the enabled pattern (AgentWorkflowBuilder...AsAIAgent() + MapAGUIServer), honestly scoping which workflow events stream today. - mcp-apps (C#): replace 'coming soon' with MCP Apps compatibility guidance (no .NET-side changes). - testing-with-dojo (C#): replace 'coming soon' with a full Dojo testing guide. - getting-started (C#): add Common Patterns and Troubleshooting. - backend-tool-rendering (C#): add Tool Best Practices and Tool Organization. - frontend-tools (C#): add Best Practices and Troubleshooting. All changes are within programming-language-csharp pivot zones; Python/Go zones untouched. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eeb2168d-2ecc-4f8d-9830-c287072eb7e1 --- .../ag-ui/backend-tool-rendering.md | 177 ++++++++++++++ .../integrations/ag-ui/frontend-tools.md | 132 ++++++++++ .../integrations/ag-ui/getting-started.md | 109 +++++++++ .../integrations/ag-ui/mcp-apps.md | 96 +++++++- .../integrations/ag-ui/state-management.md | 227 ++++++++++++++---- .../integrations/ag-ui/testing-with-dojo.md | 193 ++++++++++++++- .../integrations/ag-ui/workflows.md | 74 +++++- 7 files changed, 962 insertions(+), 46 deletions(-) diff --git a/agent-framework/integrations/ag-ui/backend-tool-rendering.md b/agent-framework/integrations/ag-ui/backend-tool-rendering.md index e2374a1e..47aa2c1b 100644 --- a/agent-framework/integrations/ag-ui/backend-tool-rendering.md +++ b/agent-framework/integrations/ag-ui/backend-tool-rendering.md @@ -270,6 +270,183 @@ great restaurants in the area: The Golden Fork (Italian, 4.5 stars)... - **`FunctionCallContent`**: Represents a tool being called with its `Name` and `Arguments` (parameter key-value pairs) - **`FunctionResultContent`**: Contains the tool's `Result` or `Exception`, identified by `CallId` +## Tool Implementation Best Practices + +### Error Handling + +Handle errors gracefully in your tools. Return failures as data so the model can explain the issue to the user instead of failing the entire run: + +```csharp +internal sealed class WeatherResult +{ + public bool Success { get; set; } + public string Location { get; set; } = string.Empty; + public string? Forecast { get; set; } + public string? Error { get; set; } +} + +[Description("Get the current weather for a location.")] +static WeatherResult GetWeather( + [Description("The city, region, or address for the weather lookup.")] string location) +{ + try + { + return new WeatherResult + { + Success = true, + Location = location, + Forecast = "Sunny, 22°C" + }; + } + catch (Exception ex) + { + return new WeatherResult + { + Success = false, + Location = location, + Error = $"Unable to retrieve weather for {location}: {ex.Message}" + }; + } +} + +AITool weatherTool = AIFunctionFactory.Create( + GetWeather, + serializerOptions: jsonOptions.SerializerOptions); +``` + +### Rich Return Types + +Return structured objects when appropriate. Typed return values are serialized with the JSON options you pass to `AIFunctionFactory.Create()`: + +```csharp +internal sealed class SentimentResult +{ + public string Text { get; set; } = string.Empty; + public string Sentiment { get; set; } = string.Empty; + public double Confidence { get; set; } + public SentimentScores Scores { get; set; } = new(); +} + +internal sealed class SentimentScores +{ + public double Positive { get; set; } + public double Neutral { get; set; } + public double Negative { get; set; } +} + +[JsonSerializable(typeof(SentimentResult))] +internal sealed partial class SentimentJsonSerializerContext : JsonSerializerContext; + +builder.Services.ConfigureHttpJsonOptions(options => + options.SerializerOptions.TypeInfoResolverChain.Add(SentimentJsonSerializerContext.Default)); + +[Description("Analyze the sentiment of text.")] +static SentimentResult AnalyzeSentiment( + [Description("The text to analyze.")] string text) +{ + return new SentimentResult + { + Text = text, + Sentiment = "positive", + Confidence = 0.87, + Scores = new SentimentScores + { + Positive = 0.87, + Neutral = 0.10, + Negative = 0.03 + } + }; +} + +AITool sentimentTool = AIFunctionFactory.Create( + AnalyzeSentiment, + serializerOptions: jsonOptions.SerializerOptions); +``` + +### Descriptive Documentation + +Use `[Description]` on tool methods and parameters so the model understands when to call the tool and how to provide arguments: + +```csharp +internal sealed class FlightBookingResult +{ + public string ConfirmationNumber { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; +} + +[Description("Book a flight for specified passengers from origin to destination. Use this when the user wants to reserve airline tickets.")] +static FlightBookingResult BookFlight( + [Description("Departure city and airport code, e.g., 'New York, JFK'.")] string origin, + [Description("Arrival city and airport code, e.g., 'London, LHR'.")] string destination, + [Description("Departure date in YYYY-MM-DD format.")] string date, + [Description("Number of passengers.")] int passengers = 1) +{ + return new FlightBookingResult + { + ConfirmationNumber = "ABC123", + Status = $"Booked {passengers} passenger(s) from {origin} to {destination} on {date}." + }; +} + +AITool bookingTool = AIFunctionFactory.Create( + BookFlight, + serializerOptions: jsonOptions.SerializerOptions); +``` + +## Tool Organization + +For related tools, group methods in a static class and register each method with `AIFunctionFactory.Create()`: + +```csharp +internal static class WeatherTools +{ + [Description("Get current weather for a location.")] + public static WeatherReport GetCurrentWeather( + [Description("The city, region, or address.")] string location) + { + return new WeatherReport + { + Location = location, + Conditions = "Sunny", + TemperatureCelsius = 22 + }; + } + + [Description("Get a multi-day weather forecast for a location.")] + public static ForecastReport GetForecast( + [Description("The city, region, or address.")] string location, + [Description("Number of days to forecast.")] int days = 3) + { + return new ForecastReport + { + Location = location, + Days = days, + Summary = "Sunny with light clouds." + }; + } +} + +internal sealed class WeatherReport +{ + public string Location { get; set; } = string.Empty; + public string Conditions { get; set; } = string.Empty; + public int TemperatureCelsius { get; set; } +} + +internal sealed class ForecastReport +{ + public string Location { get; set; } = string.Empty; + public int Days { get; set; } + public string Summary { get; set; } = string.Empty; +} + +AITool[] tools = +[ + AIFunctionFactory.Create(WeatherTools.GetCurrentWeather, serializerOptions: jsonOptions.SerializerOptions), + AIFunctionFactory.Create(WeatherTools.GetForecast, serializerOptions: jsonOptions.SerializerOptions) +]; +``` + ## Next Steps Now that you can add function tools, you can: diff --git a/agent-framework/integrations/ag-ui/frontend-tools.md b/agent-framework/integrations/ag-ui/frontend-tools.md index 088a9934..00906969 100644 --- a/agent-framework/integrations/ag-ui/frontend-tools.md +++ b/agent-framework/integrations/ag-ui/frontend-tools.md @@ -176,6 +176,138 @@ The server doesn't need special configuration to support frontend tools. Use the - Waits for results from the client - Incorporates results into the agent's decision-making +## Best Practices + +### Security + +Frontend tools can access local resources, so validate and limit what they can do. Treat all model-generated tool-call arguments as untrusted input: + +```csharp +[Description("Read an allowed user preference.")] +static string ReadUserPreference( + [Description("The preference name to read.")] string preferenceName) +{ + string[] allowedPreferences = ["theme", "locale", "timezone"]; + + if (!allowedPreferences.Contains(preferenceName, StringComparer.OrdinalIgnoreCase)) + { + return $"Error: Preference '{preferenceName}' is not allowed."; + } + + return preferenceName.ToLowerInvariant() switch + { + "theme" => "dark", + "locale" => "en-US", + "timezone" => "Pacific Standard Time", + _ => "Error: Preference not found." + }; +} + +AITool preferenceTool = AIFunctionFactory.Create( + ReadUserPreference, + name: "read_user_preference", + description: "Read a permitted user preference from the client."); +``` + +### Error Handling + +Return user-friendly errors from the tool delegate rather than throwing exceptions: + +```csharp +[Description("Show a local notification to the user.")] +static string ShowNotification( + [Description("The notification title.")] string title, + [Description("The notification message.")] string message) +{ + try + { + if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(message)) + { + return "Error: Title and message are required."; + } + + // Display the notification in the client application. + return $"Notification shown: {title}"; + } + catch (Exception ex) + { + return $"Error showing notification: {ex.Message}"; + } +} +``` + +### Keep Tools Focused + +Prefer small, purpose-built tools with clear names and descriptions over broad tools that can perform many unrelated actions: + +```csharp +AITool[] frontendTools = +[ + AIFunctionFactory.Create( + GetUserLocation, + name: "get_user_location", + description: "Get the user's current city and coordinates."), + AIFunctionFactory.Create( + GetPreferredUnits, + name: "get_preferred_units", + description: "Get the user's preferred measurement units.") +]; +``` + +Focused tools are easier for the model to choose correctly and easier for your client code to validate. + +## Troubleshooting + +### Tools Not Being Called + +If frontend tools aren't being called: + +1. Ensure the tool has a clear name and description +2. Verify the tool is passed to `AsAIAgent(tools: ...)` +3. Check server logs for tool declaration or tool-call errors + +```csharp +AITool locationTool = AIFunctionFactory.Create( + GetUserLocation, + name: "get_user_location", + description: "Get the user's current location from the client."); + +AIAgent agent = chatClient.AsAIAgent( + name: "agui-client", + description: "AG-UI Client Agent", + tools: [locationTool]); +``` + +### Execution Errors + +If a frontend tool fails during execution: + +1. Validate arguments before processing them +2. Catch exceptions inside the tool delegate +3. Return user-friendly error messages as the tool result +4. Log details on the client for debugging + +```csharp +[Description("Set the client's theme.")] +static string SetTheme([Description("Theme name: light or dark.")] string theme) +{ + if (theme is not ("light" or "dark")) + { + return $"Error: Unsupported theme '{theme}'. Use 'light' or 'dark'."; + } + + try + { + // Apply the theme in the client application. + return $"Theme changed to {theme}."; + } + catch (Exception ex) + { + return $"Error changing theme: {ex.Message}"; + } +} +``` + ## Next Steps Now that you understand frontend tools, you can: diff --git a/agent-framework/integrations/ag-ui/getting-started.md b/agent-framework/integrations/ag-ui/getting-started.md index 42853d19..4e9af736 100644 --- a/agent-framework/integrations/ag-ui/getting-started.md +++ b/agent-framework/integrations/ag-ui/getting-started.md @@ -344,6 +344,115 @@ The AG-UI protocol uses: - Thread IDs (as `ConversationId`) for maintaining conversation context - Run IDs (as `ResponseId`) for tracking individual executions +## Common Patterns + +### Custom Server Configuration + +Customize the ASP.NET Core server port, AG-UI endpoint route, and JSON options before mapping the agent: + +```csharp +using System.Text.Json.Serialization; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +// Listen on a custom port +builder.WebHost.UseUrls("http://localhost:8888"); + +builder.Services.AddHttpClient().AddLogging(); +builder.Services.ConfigureHttpJsonOptions(options => +{ + options.SerializerOptions.WriteIndented = false; + options.SerializerOptions.TypeInfoResolverChain.Add(AppJsonSerializerContext.Default); +}); +builder.Services.AddAGUIServer(); + +WebApplication app = builder.Build(); +AIAgent agent = CreateAgent(); + +// Expose the agent on a custom route +app.MapAGUIServer("/agent", agent); + +await app.RunAsync(); + +[JsonSerializable(typeof(ChatRequestMetadata))] +internal sealed partial class AppJsonSerializerContext : JsonSerializerContext; + +internal sealed class ChatRequestMetadata +{ + public string UserId { get; set; } = string.Empty; +} +``` + +The AG-UI endpoint handles HTTP POST requests and streams AG-UI events as SSE. Configure JSON serialization once with ASP.NET Core's HTTP JSON options so the AG-UI endpoint and your tools use consistent settings. + +### Multiple Agents + +Map each agent to a different route: + +```csharp +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +builder.Services.AddHttpClient().AddLogging(); +builder.Services.AddAGUIServer(); + +WebApplication app = builder.Build(); + +AIAgent weatherAgent = CreateWeatherAgent(); +AIAgent financeAgent = CreateFinanceAgent(); + +app.MapAGUIServer("/weather", weatherAgent); +app.MapAGUIServer("/finance", financeAgent); + +await app.RunAsync(); +``` + +Clients connect to the route for the agent they want to use, such as `http://localhost:8888/weather` or `http://localhost:8888/finance`. + +## Troubleshooting + +### Connection Refused + +Ensure the server is running before starting the client: + +```bash +# Terminal 1 +dotnet run --urls http://localhost:8888 + +# Terminal 2 (after server starts) +dotnet run +``` + +Verify that `AGUI_SERVER_URL` matches the server address and route your app exposes. + +### Authentication Errors + +Make sure you're authenticated with Azure: + +```bash +az login +``` + +Verify that your Azure OpenAI endpoint and deployment name are set correctly and that your account has the required role assignment on the Azure OpenAI resource. + +### Streaming Not Working + +Check that your client timeout is sufficient: + +```csharp +using HttpClient httpClient = new() +{ + Timeout = TimeSpan.FromSeconds(60) +}; + +AGUIChatClient chatClient = new(new AGUIChatClientOptions(httpClient, serverUrl)); +``` + +For long-running agents, increase the timeout accordingly. Also confirm the client uses `RunStreamingAsync`, the server endpoint is mapped with `MapAGUIServer`, and any proxy between the client and server allows Server-Sent Events. + ## Next Steps Now that you understand the basics of AG-UI, you can: diff --git a/agent-framework/integrations/ag-ui/mcp-apps.md b/agent-framework/integrations/ag-ui/mcp-apps.md index 2f716912..1991da5b 100644 --- a/agent-framework/integrations/ag-ui/mcp-apps.md +++ b/agent-framework/integrations/ag-ui/mcp-apps.md @@ -13,8 +13,100 @@ ms.service: agent-framework ::: zone pivot="programming-language-csharp" -> [!NOTE] -> MCP Apps compatibility documentation for the .NET AG-UI integration is coming soon. +Agent Framework .NET AG-UI endpoints are compatible with the AG-UI ecosystem's [MCP Apps](https://docs.ag-ui.com/agentic-protocols) feature. MCP Apps allows frontend applications to embed MCP-powered tools and resources alongside your AG-UI agent — no changes needed on the .NET side. + +## Architecture + +MCP Apps support is provided by CopilotKit's TypeScript `MCPAppsMiddleware` (`@ag-ui/mcp-apps-middleware`), which sits between the frontend and your Agent Framework backend: + +``` +┌─────────────────────────┐ +│ Frontend │ +│ (CopilotKit / AG-UI) │ +└────────┬────────────────┘ + │ + ▼ +┌─────────────────────────┐ +│ CopilotKit Runtime / │ +│ Node.js Proxy │ +│ + MCPAppsMiddleware │ +└────────┬────────────────┘ + │ AG-UI protocol + ▼ +┌─────────────────────────┐ +│ Agent Framework │ +│ ASP.NET Core AG-UI │ +│ Endpoint │ +└─────────────────────────┘ +``` + +The middleware layer handles MCP tool discovery, iframe-proxied resource requests, and `ui/resourceUri` resolution. Your .NET AG-UI endpoint receives standard AG-UI requests and is unaware of the MCP Apps layer. + +## No .NET-Side Changes Required + +MCP Apps integration is entirely handled by the TypeScript middleware. Your existing `MapAGUIServer()` setup works as-is: + +```csharp +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +builder.Services.AddAGUIServer(); + +WebApplication app = builder.Build(); +AIAgent agent = CreateAgent(); + +// This endpoint is MCP Apps-compatible with no additional configuration +app.MapAGUIServer("/", agent); + +await app.RunAsync(); +``` + +This approach is consistent with how MCP Apps works with all other AG-UI integrations — the MCP Apps layer is always in the TypeScript middleware, not in the .NET backend. + +## Setting Up the Middleware + +To use MCP Apps with your Agent Framework backend, set up a CopilotKit Runtime or Node.js proxy that includes `MCPAppsMiddleware` and points at your .NET endpoint: + +```typescript +// Example Node.js proxy configuration (TypeScript) +import { MCPAppsMiddleware } from "@ag-ui/mcp-apps-middleware"; + +const middleware = new MCPAppsMiddleware({ + agents: [ + { + name: "my-agent", + url: "http://localhost:8888/", // Your MAF AG-UI endpoint + }, + ], + mcpApps: [ + // MCP app configurations + ], +}); +``` + +For full setup instructions, see the [CopilotKit MCP Apps documentation](https://docs.copilotkit.ai/built-in-agent/generative-ui/mcp-apps) and the [AG-UI agentic protocols documentation](https://docs.ag-ui.com/agentic-protocols). + +## What Is Not in Scope + +The following are explicitly **not** part of the .NET AG-UI integration: + +- **No .NET `MCPAppsMiddleware`**: MCP Apps middleware runs in the TypeScript layer only. +- **No ASP.NET Core handling of iframe-proxied MCP requests**: Resource proxying is handled by the Node.js middleware. +- **No .NET-side `ui/resourceUri` discovery**: Resource URI resolution is a middleware concern. + +If your application doesn't need the MCP Apps middleware layer, your Agent Framework AG-UI endpoint works directly with any AG-UI-compatible client. + +## Next steps + +> [!div class="nextstepaction"] +> [State Management](./state-management.md) + +## Additional Resources + +- [AG-UI Agentic Protocols Documentation](https://docs.ag-ui.com/agentic-protocols) +- [CopilotKit MCP Apps Documentation](https://docs.copilotkit.ai/built-in-agent/generative-ui/mcp-apps) +- [Agent Framework GitHub Repository](https://github.com/microsoft/agent-framework) ::: zone-end diff --git a/agent-framework/integrations/ag-ui/state-management.md b/agent-framework/integrations/ag-ui/state-management.md index 8f8aec9e..e965ee7e 100644 --- a/agent-framework/integrations/ag-ui/state-management.md +++ b/agent-framework/integrations/ag-ui/state-management.md @@ -98,8 +98,11 @@ internal sealed partial class RecipeSerializerContext : JsonSerializerContext; Create middleware that handles state management by detecting when the client sends state and coordinating the agent's responses: ```csharp +using System.Linq; using System.Runtime.CompilerServices; using System.Text.Json; +using AGUI.Abstractions; +using AGUI.Server; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; @@ -129,31 +132,15 @@ internal sealed class SharedStateAgent : DelegatingAIAgent AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - // 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) + // Read the client-supplied state from the originating AG-UI RunAgentInput. The AG-UI hosting + // layer stashes the input on ChatOptions; TryGetRunAgentInput (from AGUI.Server) retrieves it, + // and RunAgentInput.State carries the client's current state as a JsonElement. + if (options is not ChatClientAgentRunOptions { ChatOptions: { } chatOptions } chatRunOptions || + !chatOptions.TryGetRunAgentInput(out RunAgentInput? agentInput) || + agentInput.State is not { ValueKind: JsonValueKind.Object } state || + !state.EnumerateObject().Any()) { - // 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; - } - - // Check if state has properties (not empty {}) - bool hasProperties = false; - foreach (JsonProperty _ in state.EnumerateObject()) - { - hasProperties = true; - break; - } - - if (!hasProperties) - { - // Empty state - treat as no state + // No (or empty) client state: pass through to the inner agent unchanged. await foreach (var update in this.InnerAgent.RunStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false)) { yield return update; @@ -213,13 +200,17 @@ internal sealed class SharedStateAgent : DelegatingAIAgent yield break; } - // Serialize and emit as STATE_SNAPSHOT via DataContent - byte[] stateBytes = JsonSerializer.SerializeToUtf8Bytes( - stateSnapshot, - this._jsonSerializerOptions.GetTypeInfo(typeof(JsonElement))); + // Emit the updated state as an AG-UI STATE_SNAPSHOT event. An AIAgent surfaces raw AG-UI events + // by wrapping a ChatResponseUpdate whose RawRepresentation is the event, so the + // AgentResponseUpdate -> ChatResponseUpdate bridge forwards it to the server's event stream. yield return new AgentResponseUpdate { - Contents = [new DataContent(stateBytes, "application/json")] + Role = ChatRole.Assistant, + RawRepresentation = new ChatResponseUpdate + { + Role = ChatRole.Assistant, + RawRepresentation = new StateSnapshotEvent { Snapshot = stateSnapshot } + } }; // Second run: Generate user-friendly summary @@ -301,20 +292,20 @@ await app.RunAsync(); ### Key Concepts -- **State Detection**: Middleware checks for `ag_ui_state` in `ChatOptions.AdditionalProperties` to detect when the client is requesting state management +- **State Detection**: Middleware calls `chatOptions.TryGetRunAgentInput(out var input)` (from `AGUI.Server`) and reads the client-supplied `input.State` (the AG-UI `RunAgentInput.State`) to detect when the client is sharing state - **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_SNAPSHOT Events**: Emitted by setting `ChatResponseUpdate.RawRepresentation` to a `StateSnapshotEvent` (from `AGUI.Abstractions`). The AG-UI server forwards raw AG-UI events straight to the SSE stream - **State Context**: Current state is injected as a system message to provide context to the agent ### How It Works -1. Client sends request with state in `ChatOptions.AdditionalProperties["ag_ui_state"]` +1. Client sends request; the AG-UI hosting layer exposes the originating `RunAgentInput` (including its `State`) on `ChatOptions`, which the middleware reads with `chatOptions.TryGetRunAgentInput(out var input)` 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) +5. Middleware emits the state as a `StateSnapshotEvent` via `ChatResponseUpdate.RawRepresentation` (becomes a 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 @@ -323,15 +314,167 @@ await app.RunAsync(); ### Client Implementation (C#) -> [!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`. - -For a complete client implementation example, see the Python client pattern below which demonstrates the full bidirectional state flow. +An AG-UI client shares state by setting `RunAgentInput.State` on the outgoing request and reading the +server's `StateSnapshotEvent`s back. The stateless `AGUIChatClient` lets you populate the outgoing +`RunAgentInput` through `ChatOptions.RawRepresentationFactory`, and surfaces incoming AG-UI events on +`ChatResponseUpdate.RawRepresentation`: + +```csharp +using System.Text.Json; +using AGUI.Abstractions; +using AGUI.Client; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; +using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; + +AGUIChatClient chatClient = new(new AGUIChatClientOptions(httpClient, serverUrl)); +AIAgent agent = chatClient.AsAIAgent(name: "recipe-client", description: "Recipe state client"); +AgentSession session = await agent.CreateSessionAsync(); + +// Initialize the shared state with an empty object (not null) so the server treats the first +// turn as a state-sharing request. +RecipeState currentState = new(); +string? threadId = null; +string? previousRunId = null; + +List messages = [new(ChatRole.User, "Create a vegetarian pasta recipe.")]; + +// Send the current state on the wire via RunAgentInput.State, and carry the AG-UI thread/run ids +// for continuation (the AGUIChatClient is stateless and never surfaces a ConversationId). +var runOptions = new ChatClientAgentRunOptions +{ + ChatOptions = new ChatOptions + { + RawRepresentationFactory = _ => new RunAgentInput + { + ThreadId = threadId ?? string.Empty, + ParentRunId = previousRunId, + State = JsonSerializer.SerializeToElement(new { recipe = currentState }), + }, + }, +}; + +await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, session, runOptions)) +{ + ChatResponseUpdate chatUpdate = update.AsChatResponseUpdate(); + + switch (chatUpdate.RawRepresentation) + { + case RunStartedEvent runStarted: + threadId = runStarted.ThreadId; + previousRunId = runStarted.RunId; + break; + + case StateSnapshotEvent snapshot: + // Apply the new shared state pushed by the agent. + RecipeResponse? updated = snapshot.Snapshot.Deserialize(); + if (updated is not null) + { + currentState = updated.Recipe; + Console.WriteLine($"State updated: {currentState.Title}"); + } + break; + } + + // Assistant summary text streams as normal TextContent. + foreach (AIContent content in update.Contents) + { + if (content is TextContent text) + { + Console.Write(text.Text); + } + } +} +``` + +> [!NOTE] +> State travels on the AG-UI wire, not through `ConversationId`. Outbound, the client sets +> `RunAgentInput.State`; inbound, `STATE_SNAPSHOT` / `STATE_DELTA` events arrive as +> `ChatResponseUpdate.RawRepresentation` (a `StateSnapshotEvent` / `StateDeltaEvent`). This keeps the +> client stateless while the agent remains the source of truth for shared state. + +## Predictive State Updates + +Predictive state updates let a client render an in-progress result *before* the agent finishes — for +example, showing a document as it is being written. Instead of emitting one `STATE_SNAPSHOT` at the +end, the agent streams a series of progressively larger snapshots as it produces content. + +The pattern is a `DelegatingAIAgent` that watches for a document-writing tool call and re-emits the +growing document as successive `StateSnapshotEvent`s: + +```csharp +using System.Runtime.CompilerServices; +using System.Text.Json; +using AGUI.Abstractions; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +internal sealed class PredictiveStateUpdatesAgent(AIAgent innerAgent) : DelegatingAIAgent(innerAgent) +{ + private const int ChunkSize = 20; // characters per streamed chunk + + protected override Task RunCoreAsync( + IEnumerable messages, AgentSession? session = null, + AgentRunOptions? options = null, CancellationToken cancellationToken = default) + => this.RunCoreStreamingAsync(messages, session, options, cancellationToken) + .ToAgentResponseAsync(cancellationToken); + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + string? lastEmitted = null; + + await foreach (var update in this.InnerAgent.RunStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false)) + { + yield return update; + + // When the model calls write_document, progressively emit the document as it grows. + foreach (var content in update.Contents) + { + if (content is FunctionCallContent { Name: "write_document" } call && + call.Arguments?.TryGetValue("document", out var doc) == true && + doc?.ToString() is { Length: > 0 } document && + document != lastEmitted) + { + for (int i = ChunkSize; i < document.Length + ChunkSize; i += ChunkSize) + { + string chunk = document[..Math.Min(i, document.Length)]; + JsonElement snapshot = JsonSerializer.SerializeToElement(new { document = chunk }); + yield return new AgentResponseUpdate + { + Role = ChatRole.Assistant, + RawRepresentation = new ChatResponseUpdate + { + Role = ChatRole.Assistant, + RawRepresentation = new StateSnapshotEvent { Snapshot = snapshot } + } + }; + await Task.Delay(50, cancellationToken).ConfigureAwait(false); + } + + lastEmitted = document; + } + } + } + } +} +``` + +Register it the same way as any other agent — wrap your document-writing agent and map it: + +```csharp +AIAgent documentAgent = CreateDocumentAgent(); // an agent with a write_document tool +app.MapAGUIServer("/predictive", new PredictiveStateUpdatesAgent(documentAgent)); +``` + +> [!TIP] +> For incremental edits rather than full snapshots, emit a `StateDeltaEvent` whose `Delta` is an +> RFC 6902 JSON Patch (for example, `[ { "op": "replace", "path": "/steps/0/status", "value": "completed" } ]`). +> The client applies the patch to its current state. Snapshots are simplest for append-style content +> like a streamed document; deltas are efficient for small updates to a large object such as a plan. ::: zone-end diff --git a/agent-framework/integrations/ag-ui/testing-with-dojo.md b/agent-framework/integrations/ag-ui/testing-with-dojo.md index 20ed22f2..e71a0a2b 100644 --- a/agent-framework/integrations/ag-ui/testing-with-dojo.md +++ b/agent-framework/integrations/ag-ui/testing-with-dojo.md @@ -262,6 +262,197 @@ if err := http.ListenAndServe(":8888", mux); err != nil { :::zone-end ::: zone pivot="programming-language-csharp" -Coming soon. +## Prerequisites + +Before you begin, ensure you have: + +- .NET 8.0 or later +- [Azure OpenAI service endpoint and deployment configured](/azure/ai-foundry/openai/how-to/create-resource) +- [Azure CLI installed](/cli/azure/install-azure-cli) and [authenticated](/cli/azure/authenticate-azure-cli) +- Node.js and pnpm (for running the Dojo frontend) +- A .NET AG-UI server built by following the [Getting Started](getting-started.md) tutorial + +## Installation + +### 1. Clone the AG-UI Repository + +First, clone the AG-UI repository which contains the Dojo application: + +```bash +git clone https://github.com/ag-ui-protocol/ag-ui.git +cd ag-ui +``` + +### 2. Navigate to the Dojo Application + +```bash +cd apps/dojo +``` + +### 3. Install Dojo Frontend Dependencies + +Use pnpm to install the required frontend dependencies: + +```bash +pnpm install +``` + +### 4. Configure Environment Variables + +In your .NET AG-UI server project, set the Azure OpenAI environment variables used by the Getting Started tutorial: + +```bash +export AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" +export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +> [!NOTE] +> If using `DefaultAzureCredential` for authentication, make sure you're authenticated with Azure (e.g., via `az login`). For more information, see the [Azure Identity documentation](/dotnet/api/overview/azure/identity-readme). + +## Running the Dojo Application + +### 1. Start the Backend Server + +In your .NET AG-UI server project, start the backend server: + +```bash +dotnet run --urls http://localhost:8888 +``` + +The server will start on `http://localhost:8888`. + +### 2. Start the Dojo Frontend + +Open a new terminal window, navigate to the root of the AG-UI repository, and then to the Dojo application directory: + +```bash +cd apps/dojo +pnpm install +pnpm dev +``` + +The Dojo frontend will be available at `http://localhost:3000`. + +### 3. Connect to Your Agent + +1. Open `http://localhost:3000` in your browser +2. Configure the server URL to `http://localhost:8888` +3. Select the Microsoft Agent Framework entry or the endpoint for your agent +4. Start exploring your .NET AG-UI agent + +## Available Example Agents + +Dojo can exercise any AG-UI-compatible endpoint. The AG-UI examples demonstrate all 7 AG-UI features through different agent endpoints: + +| Endpoint | Feature | Description | +|----------|---------|-------------| +| `/agentic_chat` | Feature 1: Agentic Chat | Basic conversational agent with tool calling | +| `/backend_tool_rendering` | Feature 2: Backend Tool Rendering | Agent with custom tool UI rendering | +| `/human_in_the_loop` | Feature 3: Human in the Loop | Agent with approval workflows | +| `/agentic_generative_ui` | Feature 4: Agentic Generative UI | Agent that breaks down tasks into steps with streaming updates | +| `/tool_based_generative_ui` | Feature 5: Tool-based Generative UI | Agent that generates custom UI components | +| `/shared_state` | Feature 6: Shared State | Agent with bidirectional state synchronization | +| `/predictive_state_updates` | Feature 7: Predictive State Updates | Agent with predictive state updates during tool execution | + +To test these patterns in .NET, expose corresponding `AIAgent` instances from your ASP.NET Core server with `MapAGUIServer`. + +## Testing Your Own Agents + +To test your own agents with Dojo: + +### 1. Create Your Agent + +Create a new agent following the [Getting Started](getting-started.md) guide: + +```csharp +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; + +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."); + +AIAgent agent = new AIProjectClient( + new Uri(endpoint), + new DefaultAzureCredential()) + .AsAIAgent( + model: deploymentName, + name: "my_test_agent", + instructions: "You are a helpful assistant."); +``` + +> [!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. + +### 2. Add the Agent to Your Server + +In your ASP.NET Core application, register the agent endpoint: + +```csharp +using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +builder.Services.AddHttpClient().AddLogging(); +builder.Services.AddAGUIServer(); + +WebApplication app = builder.Build(); + +// Create your agent, then register it with AG-UI +app.MapAGUIServer("/my_agent", agent); + +await app.RunAsync(); +``` + +### 3. Test in Dojo + +1. Start your server +2. Open Dojo at `http://localhost:3000` +3. Set the server URL to `http://localhost:8888` +4. Your agent will appear in the endpoint dropdown as `my_agent` +5. Select it and start testing + +## Troubleshooting + +### Server Connection Issues + +If Dojo can't connect to your server: + +- Verify the server is running on the correct port (default: 8888) +- Check that the server URL in Dojo matches your server address +- Ensure no firewall is blocking the connection +- Look for CORS errors in the browser console + +### Agent Not Appearing + +If your agent doesn't appear in the Dojo dropdown: + +- Verify the agent endpoint is registered correctly with `app.MapAGUIServer("/my_agent", agent)` +- Check server logs for any startup errors +- Ensure `builder.Services.AddAGUIServer()` is called before building the app + +### Environment Variable Issues + +If you see authentication errors: + +- Verify the required environment variables are set in the .NET server process +- Check that the Azure OpenAI endpoint and deployment name are valid +- Run `az login` when using `DefaultAzureCredential` +- Restart the server after changing environment variables + +## Next Steps + +- Explore the [example agents](https://github.com/ag-ui-protocol/ag-ui/tree/main/integrations/microsoft-agent-framework/python/examples/agents) to see implementation patterns +- Learn about [Backend Tool Rendering](backend-tool-rendering.md) to customize tool UIs + + + +## Additional Resources + +- [AG-UI Documentation](https://docs.ag-ui.com/introduction) +- [AG-UI GitHub Repository](https://github.com/ag-ui-protocol/ag-ui) +- [Dojo Application](https://dojo.ag-ui.com/) +- [Microsoft Agent Framework Integration Examples](https://github.com/ag-ui-protocol/ag-ui/tree/main/integrations/microsoft-agent-framework) ::: zone-end diff --git a/agent-framework/integrations/ag-ui/workflows.md b/agent-framework/integrations/ag-ui/workflows.md index 468e6b02..40573d1c 100644 --- a/agent-framework/integrations/ag-ui/workflows.md +++ b/agent-framework/integrations/ag-ui/workflows.md @@ -13,8 +13,80 @@ ms.service: agent-framework ::: zone pivot="programming-language-csharp" +## When to Use Workflows with AG-UI + +[Workflows](../../workflows/index.md) orchestrate multiple agents and tools in a defined +execution graph. In .NET you can expose a workflow through an AG-UI endpoint today by converting it to +an `AIAgent` and mapping it like any other agent. The constituent agents' responses stream to the web +client as AG-UI text and tool-call events. + > [!NOTE] -> Workflow support for the .NET AG-UI integration is coming soon. +> The .NET AG-UI integration currently streams a workflow's **agent output** (text and tool calls) as +> AG-UI events. The richer workflow-specific AG-UI events described in the Python guide — step tracking, +> activity snapshots, and workflow-level interrupts — are still evolving for .NET. For those scenarios, +> see the Python pivot of this article. + +## Exposing a Workflow over AG-UI + +Build a workflow from your agents, convert it to an `AIAgent` with `AsAIAgent()`, and map it with +`MapAGUIServer`: + +```csharp +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; +using Microsoft.Agents.AI.Workflows; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +builder.Services.AddHttpClient().AddLogging(); +builder.Services.AddAGUIServer(); + +WebApplication app = builder.Build(); + +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."); + +var projectClient = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential()); + +// Define the agents that make up the workflow. +AIAgent researcher = projectClient.AsAIAgent( + model: deploymentName, + name: "researcher", + instructions: "Research the user's topic and write a short, factual brief."); + +AIAgent reporter = projectClient.AsAIAgent( + model: deploymentName, + name: "reporter", + instructions: "Summarize the researcher's brief into a single clear paragraph."); + +// Build a sequential workflow (researcher -> reporter) and expose it as an agent. +AIAgent workflowAgent = AgentWorkflowBuilder.BuildSequential(researcher, reporter).AsAIAgent(); + +// Map the workflow agent to an AG-UI endpoint. +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. + +On the client side there is nothing workflow-specific to do: connect with `AGUIChatClient` exactly as in +the [Getting Started](getting-started.md) tutorial. Each agent's output streams as normal AG-UI text +events, and the `AuthorName` on each update identifies which agent in the workflow produced it. + +## Next steps + +- [State Management](state-management.md) +- [Human-in-the-Loop](human-in-the-loop.md) +- [Workflows user guide](../../workflows/index.md) + +## Additional Resources + +- [AG-UI Protocol Documentation](https://docs.ag-ui.com) ::: zone-end From efdb0567a8aef55a23fa01b3aeca92cf9e9fe2d2 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 22 Jul 2026 22:25:29 -0700 Subject: [PATCH 04/35] AG-UI C# docs: verified parity additions and corrections Based on tested .NET MAF behavior (not ported assumptions): - human-in-the-loop (C#): add Approval Requirements (.NET has no approval 'modes' - ApprovalRequiredAIFunction is always-require; not wrapping = never), Selective Approval (verified: unwrapped tool executes while wrapped tool interrupts), and Best Practices. - getting-started (C#): add 'Testing with curl' (verified minimal-body request works; server generates threadId). - testing-with-dojo: fix clone URL ag-oss/ag-ui -> ag-ui-protocol/ag-ui in both the Python and C# zones. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eeb2168d-2ecc-4f8d-9830-c287072eb7e1 --- .../integrations/ag-ui/getting-started.md | 38 ++++++++ .../integrations/ag-ui/human-in-the-loop.md | 86 +++++++++++++++++++ .../integrations/ag-ui/testing-with-dojo.md | 2 +- 3 files changed, 125 insertions(+), 1 deletion(-) diff --git a/agent-framework/integrations/ag-ui/getting-started.md b/agent-framework/integrations/ag-ui/getting-started.md index 4e9af736..88aa8d4d 100644 --- a/agent-framework/integrations/ag-ui/getting-started.md +++ b/agent-framework/integrations/ag-ui/getting-started.md @@ -314,6 +314,44 @@ The client displays different content types with distinct colors: - **Green**: Run completion notifications - **Red**: Error messages +## Testing with curl (Optional) + +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: + +```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 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 5baa5b5c..9b589785 100644 --- a/agent-framework/integrations/ag-ui/human-in-the-loop.md +++ b/agent-framework/integrations/ag-ui/human-in-the-loop.md @@ -629,6 +629,92 @@ The server middleware must remove approval protocol messages after processing: - **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 +## Approval Requirements + +In .NET, whether a tool requires approval is expressed by how you register it — there is no per-tool +"approval mode": + +- **Always require approval**: wrap the function in `ApprovalRequiredAIFunction`. +- **Never require approval**: register the function normally (do not wrap it). + +```csharp +// Requires approval before it runs. +AITool transfer = new ApprovalRequiredAIFunction( + AIFunctionFactory.Create(TransferFunds, name: "transfer_funds", description: "Transfer money to another account.")); + +// Runs without approval. +AITool balance = AIFunctionFactory.Create( + GetAccountBalance, name: "get_account_balance", description: "Get the current account balance."); +``` + +When the model calls an approval-required tool, the run ends with an `interrupt` outcome that carries an +approval request — the tool call plus a response schema of `{ "approved": boolean }`. The run resumes +once the client sends the approval decision back. + +## Selective Approval + +Mix approval-required and unrestricted tools on the same agent by wrapping only the sensitive ones: + +```csharp +AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions +{ + Name = "BankingAgent", + ChatOptions = new ChatOptions + { + Instructions = "You are a banking assistant. Check balances and transfer funds when asked.", + Tools = + [ + AIFunctionFactory.Create(GetAccountBalance, name: "get_account_balance", + description: "Get the current account balance."), + new ApprovalRequiredAIFunction(AIFunctionFactory.Create(TransferFunds, name: "transfer_funds", + description: "Transfer money to another account.")), + ] + } +}); +``` + +If the model calls both tools in a single turn, the unrestricted tool (`get_account_balance`) executes +and streams its `TOOL_CALL_RESULT`, while the approval-required tool (`transfer_funds`) raises an +approval interrupt and waits for the user's decision before running. + +## Best Practices + +### Clear Tool Descriptions + +Provide detailed descriptions so users understand what they are approving: + +```csharp +using System.ComponentModel; + +[Description("Permanently delete a database and all its contents. This action cannot be undone.")] +static string DeleteDatabase( + [Description("Name of the database to permanently delete.")] string databaseName) +{ + // Implementation + return $"Database '{databaseName}' deleted."; +} + +AITool deleteTool = new ApprovalRequiredAIFunction( + AIFunctionFactory.Create(DeleteDatabase, name: "delete_database")); +``` + +### Informative Arguments + +Use descriptive parameter names and `[Description]` attributes. The approval request surfaces the tool +name and its arguments to the client, so meaningful names and descriptions help users make an informed +decision: + +```csharp +[Description("Purchase items from the store.")] +static string PurchaseItem( + [Description("Name of the item to purchase.")] string itemName, + [Description("Number of items to purchase.")] int quantity, + [Description("Total cost in USD, including tax and shipping.")] double totalCost) +{ + return $"Purchased {quantity} x {itemName} for {totalCost:C}."; +} +``` + ## Next steps > [!div class="nextstepaction"] diff --git a/agent-framework/integrations/ag-ui/testing-with-dojo.md b/agent-framework/integrations/ag-ui/testing-with-dojo.md index e71a0a2b..c19eff41 100644 --- a/agent-framework/integrations/ag-ui/testing-with-dojo.md +++ b/agent-framework/integrations/ag-ui/testing-with-dojo.md @@ -31,7 +31,7 @@ Before you begin, ensure you have: First, clone the AG-UI repository which contains the Dojo application and Microsoft Agent Framework integration examples: ```bash -git clone https://github.com/ag-oss/ag-ui.git +git clone https://github.com/ag-ui-protocol/ag-ui.git cd ag-ui ``` From 97e280562b838ab74df682909a4de23d34fcc7ff Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 22 Jul 2026 22:49:55 -0700 Subject: [PATCH 05/35] Rewrite HITL C# to the idiomatic, verified pattern The prior C# HITL sample used non-existent types (FunctionApprovalRequestContent/ FunctionApprovalResponseContent) and ~400 lines of custom request_approval middleware. Replaced with the idiomatic pattern, verified end-to-end against real .NET MAF: - Server: wrap the tool in ApprovalRequiredAIFunction + MapAGUIServer (the hosting layer emits the approval interrupt natively). - Client: read ToolApprovalRequestContent, CreateResponse(approved), resume (AGUIChatClient transports the decision via the AG-UI resume mechanism automatically). Both server and client snippets compile against the shipped packages; the client round-trip was run against a live server (tool executes after approval). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eeb2168d-2ecc-4f8d-9830-c287072eb7e1 --- .../integrations/ag-ui/human-in-the-loop.md | 633 +++--------------- 1 file changed, 94 insertions(+), 539 deletions(-) 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 9b589785..8ee61d88 100644 --- a/agent-framework/integrations/ag-ui/human-in-the-loop.md +++ b/agent-framework/integrations/ag-ui/human-in-the-loop.md @@ -35,599 +35,154 @@ 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 — you don't write any approval protocol +code on the server. ```csharp using System.ComponentModel; +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; 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: +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +builder.Services.AddHttpClient().AddLogging(); +builder.Services.AddAGUIServer(); -```csharp -using System.Text.Json.Serialization; - -public sealed class ApprovalRequest -{ - [JsonPropertyName("approval_id")] - public required string ApprovalId { get; init; } +WebApplication app = builder.Build(); - [JsonPropertyName("function_name")] - public required string FunctionName { get; init; } +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."); - [JsonPropertyName("function_arguments")] - public JsonElement? FunctionArguments { get; init; } +// 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}'."; - [JsonPropertyName("message")] - public string? Message { get; init; } -} +#pragma warning disable MEAI001 // ApprovalRequiredAIFunction is an evaluation-only API. +AITool sendEmail = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(SendEmail)); +#pragma warning restore MEAI001 -public sealed class ApprovalResponse -{ - [JsonPropertyName("approval_id")] - public required string ApprovalId { get; init; } +AIAgent agent = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential()) + .AsAIAgent( + model: deploymentName, + name: "AGUIAssistant", + instructions: "You are a helpful assistant. Use the send_email tool when asked to send email.", + tools: [sendEmail]); - [JsonPropertyName("approved")] - public required bool Approved { get; init; } -} +app.MapAGUIServer("/", agent); -[JsonSerializable(typeof(ApprovalRequest))] -[JsonSerializable(typeof(ApprovalResponse))] -[JsonSerializable(typeof(Dictionary))] -internal partial class ApprovalJsonContext : JsonSerializerContext -{ -} +await app.RunAsync(); ``` -### Implement Approval Middleware +> [!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. -Create middleware that translates between Microsoft.Extensions.AI approval types and AG-UI protocol: +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. -> [!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'". +## Client Implementation + +A client handles the interrupt by reading the `ToolApprovalRequestContent`, creating a decision with +`CreateResponse(approved)`, and sending it back on the next turn. `AGUIChatClient` transports the +decision over the AG-UI resume mechanism for you — there is no approval protocol to implement on the +client either. ```csharp -using System.Runtime.CompilerServices; -using System.Text.Json; +using AGUI.Abstractions; +using AGUI.Client; using Microsoft.Agents.AI; 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; - } - } - } +string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; +using HttpClient httpClient = new() { BaseAddress = new Uri(serverUrl) }; - // If no approval response found, return messages unchanged - if (approvalResult == null) - { - return messages; - } +AGUIChatClient chatClient = new(new AGUIChatClientOptions(httpClient, "/")); +AIAgent agent = chatClient.AsAIAgent(); +AgentSession session = await agent.CreateSessionAsync(); - // 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]; +List messages = [new(ChatRole.User, "Email alice@example.com to say the report is ready.")]; - 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); - } - } +#pragma warning disable MEAI001 // Tool-approval content is an evaluation-only API. +// First turn: run until the agent requests approval. +ToolApprovalRequestContent? approvalRequest = null; +string? threadId = null; +string? runId = null; +await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, session)) +{ + ChatResponseUpdate chatUpdate = update.AsChatResponseUpdate(); - return newMessages; + // The AGUIChatClient is stateless; the AG-UI thread/run ids arrive on the RUN_STARTED event. + if (chatUpdate.RawRepresentation is RunStartedEvent runStarted) + { + threadId = runStarted.ThreadId; + runId = runStarted.RunId; } - // Local function: Convert FunctionApprovalRequestContent to client tool calls - static async IAsyncEnumerable ConvertFunctionApprovalsToToolCalls( - AgentResponseUpdate update, - JsonSerializerOptions jsonSerializerOptions) + foreach (AIContent content in update.Contents) { - // Check if this update contains a FunctionApprovalRequestContent - FunctionApprovalRequestContent? approvalRequestContent = null; - foreach (var content in update.Contents) + if (content is ToolApprovalRequestContent request) { - if (content is FunctionApprovalRequestContent request) - { - approvalRequestContent = request; - break; - } + approvalRequest = request; + var call = request.ToolCall as FunctionCallContent; + Console.WriteLine($"Approval requested for '{call?.Name}'."); } - - // If no approval request, yield the update unchanged - if (approvalRequestContent == null) + else if (content is TextContent text) { - yield return update; - yield break; + Console.Write(text.Text); } - - // Convert the approval request to a "client tool call" - var functionCall = approvalRequestContent.FunctionCall; - var approvalId = approvalRequestContent.Id; - - // Serialize the function arguments as JsonElement - var argsElement = functionCall.Arguments?.Count > 0 - ? JsonSerializer.SerializeToElement(functionCall.Arguments, jsonSerializerOptions.GetTypeInfo(typeof(IDictionary))) - : (JsonElement?)null; - - 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 }) - ]); } } -``` -## Client Implementation - -### Implement Client-Side Middleware - -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 - -> [!IMPORTANT] -> Use `AdditionalProperties` on `AIContent` objects to track the correlation between approval requests and responses, avoiding external state dictionaries. - -```csharp -using System.Runtime.CompilerServices; -using System.Text.Json; -using Microsoft.Agents.AI; -using AGUI.Client; -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) +// Second turn: send the decision. The agent resumes and runs (or skips) the tool. +if (approvalRequest is not null) { - // Process messages: Convert approval responses back to tool results - var processedMessages = ConvertApprovalResponsesToToolResults(messages, jsonSerializerOptions); + ToolApprovalResponseContent decision = approvalRequest.CreateResponse(approved: true); - // Invoke inner agent - await foreach (var update in innerAgent.RunStreamingAsync(processedMessages, session, options, cancellationToken)) + List resume = [new(ChatRole.User, [decision])]; + ChatClientAgentRunOptions options = new() { - // Process updates: Convert tool calls to approval requests - await foreach (var processedUpdate in ConvertToolCallsToApprovalRequests(update, jsonSerializerOptions)) + // Carry the AG-UI thread and previous run id so the server resumes the same conversation. + ChatOptions = new ChatOptions { - yield return processedUpdate; - } - } - - // Local function: Convert FunctionApprovalResponseContent back to tool results - static IEnumerable ConvertApprovalResponsesToToolResults( - IEnumerable messages, - JsonSerializerOptions jsonSerializerOptions) - { - List processedMessages = []; - - foreach (var message in messages) - { - List convertedContents = []; - bool hasApprovalResponse = false; - - foreach (var content in message.Contents) + RawRepresentationFactory = _ => new RunAgentInput { - 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); - } - } - - if (hasApprovalResponse && convertedContents.Count > 0) - { - processedMessages.Add(new ChatMessage(ChatRole.Tool, convertedContents)); - } - else - { - processedMessages.Add(message); + ThreadId = threadId ?? string.Empty, + ParentRunId = runId, } } + }; - return processedMessages; - } - - // Local function: Convert request_approval tool calls to FunctionApprovalRequestContent - static async IAsyncEnumerable ConvertToolCallsToApprovalRequests( - AgentResponseUpdate update, - JsonSerializerOptions jsonSerializerOptions) - { - FunctionCallContent? approvalToolCall = null; - foreach (var content in update.Contents) - { - if (content is FunctionCallContent { Name: "request_approval" } toolCall) - { - approvalToolCall = toolCall; - break; - } - } - - if (approvalToolCall == null) - { - yield return update; - yield break; - } - - if (approvalToolCall.Arguments?.TryGetValue("request", out JsonElement request) != true || - request.Deserialize(jsonSerializerOptions.GetTypeInfo(typeof(ApprovalRequest))) is not ApprovalRequest approvalRequest) - { - yield return update; - yield break; - } - - 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 AGUI.Client; -using Microsoft.Extensions.AI; - -#pragma warning disable MEAI001 // Type is for evaluation purposes only -List approvalResponses = []; -List approvalToolCalls = []; - -do -{ - approvalResponses.Clear(); - approvalToolCalls.Clear(); - - await foreach (AgentResponseUpdate update in wrappedAgent.RunStreamingAsync( - messages, session, cancellationToken: cancellationToken)) + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(resume, session, options)) { 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) + if (content is TextContent text) { - // Track the original request_approval tool call - approvalToolCalls.Add(requestApprovalCall); - } - else if (content is TextContent textContent) - { - 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 -============================================================ +To reject instead, call `approvalRequest.CreateResponse(approved: false)`; the agent continues without +running the tool. -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 - -### Client Tool Pattern - -The C# implementation uses a "client tool call" pattern: - -- **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 - -This allows the standard `ApprovalRequiredAIFunction` pattern to work across the HTTP+SSE boundary while maintaining consistency with the agent framework's approval model. - -### 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 +> [!TIP] +> A UI framework can handle this round-trip for you. The Blazor AI components render an approval prompt +> and resume the run automatically when the user approves or rejects — on the server you only wrap the +> tool in `ApprovalRequiredAIFunction`. ## Approval Requirements @@ -1240,4 +795,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 From 2897adfbc952d1d4bb042ef1e7eff379fd67e964 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 22 Jul 2026 22:50:38 -0700 Subject: [PATCH 06/35] HITL: update Overview to the idiomatic native approval flow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eeb2168d-2ecc-4f8d-9830-c287072eb7e1 --- .../integrations/ag-ui/human-in-the-loop.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) 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 8ee61d88..04cf7773 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,12 @@ 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. + +No custom approval-protocol code is required on either side — the AG-UI client and server handle the round-trip natively. ## Prerequisites From 40775493309171ecf9c7b95a94680d121973c570 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 22 Jul 2026 23:00:01 -0700 Subject: [PATCH 07/35] Audit fixes: state-management namespace + backend-tools type ordering Verified by compiling the doc snippets against the shipped packages: - state-management: models block declared 'namespace RecipeAssistant;' while the SharedStateAgent block was namespace-less, so the agent could not resolve RecipeResponse. Removed the namespace for consistency. (SharedStateAgent + PredictiveStateUpdatesAgent now compile.) - backend-tool-rendering: the server program declared helper types in the middle, before later top-level statements (CS8803 - does not compile). Moved the types after RunAsync(). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eeb2168d-2ecc-4f8d-9830-c287072eb7e1 --- .../ag-ui/backend-tool-rendering.md | 56 +++++++++---------- .../integrations/ag-ui/state-management.md | 2 - 2 files changed, 28 insertions(+), 30 deletions(-) diff --git a/agent-framework/integrations/ag-ui/backend-tool-rendering.md b/agent-framework/integrations/ag-ui/backend-tool-rendering.md index 47aa2c1b..658662a7 100644 --- a/agent-framework/integrations/ag-ui/backend-tool-rendering.md +++ b/agent-framework/integrations/ag-ui/backend-tool-rendering.md @@ -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( @@ -154,6 +127,33 @@ AIAgent agent = new AIProjectClient( 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] @@ -916,4 +916,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/state-management.md b/agent-framework/integrations/ag-ui/state-management.md index e965ee7e..a1dd17a4 100644 --- a/agent-framework/integrations/ag-ui/state-management.md +++ b/agent-framework/integrations/ag-ui/state-management.md @@ -52,8 +52,6 @@ First, define classes for your state structure: ```csharp using System.Text.Json.Serialization; -namespace RecipeAssistant; - // State response wrapper internal sealed class RecipeResponse { From 26674e89e3df3fc57920d43d45b2d93628a09bf9 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 22 Jul 2026 23:01:50 -0700 Subject: [PATCH 08/35] frontend-tools: fix AIFunction.Metadata.Name/Description -> AIFunction.Name/Description AIFunction.Metadata was removed from Microsoft.Extensions.AI; the tool-inspection middleware snippet did not compile. Verified the fix compiles against the shipped packages. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eeb2168d-2ecc-4f8d-9830-c287072eb7e1 --- agent-framework/integrations/ag-ui/frontend-tools.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent-framework/integrations/ag-ui/frontend-tools.md b/agent-framework/integrations/ag-ui/frontend-tools.md index 00906969..c8a13b3f 100644 --- a/agent-framework/integrations/ag-ui/frontend-tools.md +++ b/agent-framework/integrations/ag-ui/frontend-tools.md @@ -103,7 +103,7 @@ static async IAsyncEnumerable InspectToolsMiddleware( { if (tool is AIFunction function) { - Console.WriteLine($" - {function.Metadata.Name}: {function.Metadata.Description}"); + Console.WriteLine($" - {function.Name}: {function.Description}"); } } } From 8db56a531231b5a8f00d2b9170e5a9568a110b13 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 23 Jul 2026 10:44:35 -0700 Subject: [PATCH 09/35] Merge Javier's idiomatic state docs: declarative AGUIStreamOptions + predictive page Reconcile @javiercn's AG-UI C# doc draft into this branch, adopting the idiomatic, author-blessed state APIs (verified end-to-end in the AgenticUI sample): - state-management.md (C# zone): replace the custom DelegatingAIAgent + RawRepresentation approach with declarative AGUIStreamOptions.MapResultAsStateSnapshot / MapResultAsStateDelta wired via MapAGUIServer(...).WithMetadata. Python and Go zones left untouched. - predictive-state-updates.md: new dedicated page (AGUIStreamOptions.MapCall streaming pipeline), matching the Python docs' structure. - TOC.yml: add the Predictive State Updates entry. Co-authored-by: Javier Calvarro Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eeb2168d-2ecc-4f8d-9830-c287072eb7e1 --- agent-framework/TOC.yml | 2 + .../ag-ui/predictive-state-updates.md | 248 ++++++++ .../integrations/ag-ui/state-management.md | 536 +++++++----------- 3 files changed, 453 insertions(+), 333 deletions(-) create mode 100644 agent-framework/integrations/ag-ui/predictive-state-updates.md diff --git a/agent-framework/TOC.yml b/agent-framework/TOC.yml index 2c789a6b..0fec024e 100644 --- a/agent-framework/TOC.yml +++ b/agent-framework/TOC.yml @@ -228,6 +228,8 @@ items: href: integrations/ag-ui/mcp-apps.md - name: State Management href: integrations/ag-ui/state-management.md + - name: Predictive State Updates + href: integrations/ag-ui/predictive-state-updates.md - name: Testing with Dojo href: integrations/ag-ui/testing-with-dojo.md - name: Hosting diff --git a/agent-framework/integrations/ag-ui/predictive-state-updates.md b/agent-framework/integrations/ag-ui/predictive-state-updates.md new file mode 100644 index 00000000..f902e50d --- /dev/null +++ b/agent-framework/integrations/ag-ui/predictive-state-updates.md @@ -0,0 +1,248 @@ +--- +title: Predictive State Updates with AG-UI +description: Stream a tool's arguments as optimistic state snapshots so the UI updates before the tool call completes +zone_pivot_groups: programming-languages +author: moonbox3 +ms.topic: tutorial +ms.author: evmattso +ms.date: 07/10/2026 +ms.service: agent-framework +--- + +# Predictive State Updates with AG-UI + +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 of the result. + +A common example is a document editor: as the model writes the document, the client shows the text appearing in real time, then asks the user to confirm the change once the model is done. + +::: zone pivot="programming-language-csharp" + +## Prerequisites + +Before you begin, ensure you have completed the [Getting Started](getting-started.md) tutorial and read [State Management](state-management.md), since predictive state updates build on the same `AGUIStreamOptions` state-event mechanism. + +You need: + +- .NET 10.0 or later +- `Microsoft.Agents.AI.Hosting.AGUI.AspNetCore` and `Microsoft.Agents.AI.OpenAI` packages +- An Azure OpenAI endpoint and deployment + +## How It Works + +Unlike the [shared-state](state-management.md) 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 `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; +``` + +## 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 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"; + +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? 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; + }); +} +``` + +> [!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 + +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 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.ConfigureHttpJsonOptions(options => + 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(); + +JsonSerializerOptions jsonSerializerOptions = app.Services + .GetRequiredService>() + .Value.SerializerOptions; + +app.MapPost("/", ( + [FromBody] RunAgentInput input, + HttpContext httpContext, + CancellationToken cancellationToken) => +{ + AGUIStreamOptions streamOptions = CreatePredictiveStreamOptions(jsonSerializerOptions); + + ChatRequestContext ctx = input.ToChatRequestContext(jsonSerializerOptions, streamOptions); + ctx.Messages.Insert(0, new ChatMessage(ChatRole.System, PredictiveSystemPrompt)); + (ctx.ChatOptions.Tools ??= []).Add(writeDocument); + + var updates = chatClient.GetStreamingResponseAsync(ctx.Messages, ctx.ChatOptions, cancellationToken); + IAsyncEnumerable events = updates.AsAGUIEventStreamAsync(ctx, cancellationToken); + + return TypedResults.ServerSentEvents(events); +}); + +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. + +> [!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. + +### 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. + +## 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). + +## Next Steps + +- **[Manage State](state-management.md)**: Learn about state snapshots, deltas, and shared state. +- **[Human-in-the-Loop](human-in-the-loop.md)**: Add approval workflows for tool calls. +- **[Test with Dojo](testing-with-dojo.md)**: Run this scenario against the AG-UI Dojo app. + +## Additional Resources + +- [AG-UI Overview](index.md) +- [State Management](state-management.md) +- [Agent Framework Documentation](../../overview/index.md) + +::: zone-end + +::: zone pivot="programming-language-python" + +Predictive state updates for Python are covered in the [State Management](state-management.md) tutorial, which shows how to configure predicted state with `predict_state_config` and stream tool arguments as optimistic state updates. + +::: zone-end diff --git a/agent-framework/integrations/ag-ui/state-management.md b/agent-framework/integrations/ag-ui/state-management.md index a1dd17a4..319ec3df 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 . You don't write a custom agent or emit protocol content by hand. + ### Define Your State Model First, define classes for your state structure: @@ -52,427 +54,295 @@ First, define classes for your state structure: ```csharp using System.Text.Json.Serialization; -// State response wrapper +namespace RecipeAssistant; + +// 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.Linq; -using System.Runtime.CompilerServices; -using System.Text.Json; -using AGUI.Abstractions; -using AGUI.Server; -using Microsoft.Agents.AI; +using System.ComponentModel; using Microsoft.Extensions.AI; -internal sealed class SharedStateAgent : DelegatingAIAgent -{ - private readonly JsonSerializerOptions _jsonSerializerOptions; - - public SharedStateAgent(AIAgent innerAgent, JsonSerializerOptions jsonSerializerOptions) - : base(innerAgent) - { - this._jsonSerializerOptions = jsonSerializerOptions; - } - - protected override Task RunCoreAsync( - IEnumerable messages, - AgentSession? session = null, - AgentRunOptions? options = null, - CancellationToken cancellationToken = default) - { - return this.RunStreamingAsync(messages, session, options, cancellationToken) - .ToAgentResponseAsync(cancellationToken); - } - - protected override async IAsyncEnumerable RunCoreStreamingAsync( - IEnumerable messages, - AgentSession? session = null, - AgentRunOptions? options = null, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - // Read the client-supplied state from the originating AG-UI RunAgentInput. The AG-UI hosting - // layer stashes the input on ChatOptions; TryGetRunAgentInput (from AGUI.Server) retrieves it, - // and RunAgentInput.State carries the client's current state as a JsonElement. - if (options is not ChatClientAgentRunOptions { ChatOptions: { } chatOptions } chatRunOptions || - !chatOptions.TryGetRunAgentInput(out RunAgentInput? agentInput) || - agentInput.State is not { ValueKind: JsonValueKind.Object } state || - !state.EnumerateObject().Any()) - { - // No (or empty) client state: pass through to the inner agent unchanged. - await foreach (var update in this.InnerAgent.RunStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false)) - { - yield return update; - } - yield break; - } - - // 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); - - // Yield all non-text updates (tool calls, etc.) - bool hasNonTextContent = update.Contents.Any(c => c is not TextContent); - if (hasNonTextContent) - { - yield return update; - } - } - - var response = allUpdates.ToAgentResponse(); - - // Try to deserialize the structured state response - JsonElement stateSnapshot; - try - { - stateSnapshot = JsonSerializer.Deserialize(response.Text, this._jsonSerializerOptions); - } - catch (JsonException) - { - yield break; - } - - // Emit the updated state as an AG-UI STATE_SNAPSHOT event. An AIAgent surfaces raw AG-UI events - // by wrapping a ChatResponseUpdate whose RawRepresentation is the event, so the - // AgentResponseUpdate -> ChatResponseUpdate bridge forwards it to the server's event stream. - yield return new AgentResponseUpdate - { - Role = ChatRole.Assistant, - RawRepresentation = new ChatResponseUpdate - { - Role = ChatRole.Assistant, - RawRepresentation = new StateSnapshotEvent { Snapshot = stateSnapshot } - } - }; +[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 }; - // 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.")])); - - await foreach (var update in this.InnerAgent.RunStreamingAsync(secondRunMessages, session, options, cancellationToken).ConfigureAwait(false)) - { - yield return update; - } - } -} +AITool generateRecipe = AIFunctionFactory.Create( + GenerateRecipe, + name: "generate_recipe", + description: "Generate or update the shared recipe and display it to the user.", + RecipeSerializerContext.Default.Options); ``` -### Configure the Agent with State Management +### Create the Agent + +Build the agent directly from your chat client with . Put the system prompt and tools on : ```csharp using Microsoft.Agents.AI; -using Azure.AI.Projects; +using Azure.AI.OpenAI; using Azure.Identity; +using Microsoft.Extensions.AI; -AIAgent CreateRecipeAgent(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); -} +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 Agent Endpoint +### Map the Tool Result to a State Event + +Create an , 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; using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); -builder.Services.AddHttpClient().AddLogging(); 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"); + +builder.AddAIAgent("RecipeAgent", (_, _) => recipeAgent).WithInMemorySessionStore(); + WebApplication app = builder.Build(); -var jsonOptions = app.Services.GetRequiredService>().Value; -AIAgent recipeAgent = CreateRecipeAgent(jsonOptions.SerializerOptions); -app.MapAGUIServer("/", recipeAgent); +app.MapAGUIServer("RecipeAgent", "/").WithMetadata(streamOptions); await app.RunAsync(); ``` -### Key Concepts - -- **State Detection**: Middleware calls `chatOptions.TryGetRunAgentInput(out var input)` (from `AGUI.Server`) and reads the client-supplied `input.State` (the AG-UI `RunAgentInput.State`) to detect when the client is sharing state -- **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 by setting `ChatResponseUpdate.RawRepresentation` to a `StateSnapshotEvent` (from `AGUI.Abstractions`). The AG-UI server forwards raw AG-UI events straight to the SSE stream -- **State Context**: Current state is injected as a system message to provide context to the agent - -### How It Works +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. -1. Client sends request; the AG-UI hosting layer exposes the originating `RunAgentInput` (including its `State`) on `ChatOptions`, which the middleware reads with `chatOptions.TryGetRunAgentInput(out var input)` -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 emits the state as a `StateSnapshotEvent` via `ChatResponseUpdate.RawRepresentation` (becomes a 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 +### Reading Client State -> [!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. - -### Client Implementation (C#) - -An AG-UI client shares state by setting `RunAgentInput.State` on the outgoing request and reading the -server's `StateSnapshotEvent`s back. The stateless `AGUIChatClient` lets you populate the outgoing -`RunAgentInput` through `ChatOptions.RawRepresentationFactory`, and surfaces incoming AG-UI events on -`ChatResponseUpdate.RawRepresentation`: +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.Client; -using Microsoft.Agents.AI; +using AGUI.Server; using Microsoft.Extensions.AI; -string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; -using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; +static bool TryGetClientState(ChatOptions chatOptions, out JsonElement state) +{ + if (chatOptions.TryGetRunAgentInput(out RunAgentInput? input) && + input.State is { ValueKind: not JsonValueKind.Undefined } clientState) + { + state = clientState; + return true; + } -AGUIChatClient chatClient = new(new AGUIChatClientOptions(httpClient, serverUrl)); -AIAgent agent = chatClient.AsAIAgent(name: "recipe-client", description: "Recipe state client"); -AgentSession session = await agent.CreateSessionAsync(); + 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 -// Initialize the shared state with an empty object (not null) so the server treats the first -// turn as a state-sharing request. -RecipeState currentState = new(); -string? threadId = null; -string? previousRunId = null; +- **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`. -List messages = [new(ChatRole.User, "Create a vegetarian pasta recipe.")]; +## State Deltas with Agentic Generative UI -// Send the current state on the wire via RunAgentInput.State, and carry the AG-UI thread/run ids -// for continuation (the AGUIChatClient is stateless and never surfaces a ConversationId). -var runOptions = new ChatClientAgentRunOptions +`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 { - ChatOptions = new ChatOptions - { - RawRepresentationFactory = _ => new RunAgentInput - { - ThreadId = threadId ?? string.Empty, - ParentRunId = previousRunId, - State = JsonSerializer.SerializeToElement(new { recipe = currentState }), - }, - }, -}; + [JsonPropertyName("steps")] + public List Steps { get; set; } = []; +} -await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, session, runOptions)) +internal sealed class Step { - ChatResponseUpdate chatUpdate = update.AsChatResponseUpdate(); + [JsonPropertyName("description")] + public required string Description { get; set; } - switch (chatUpdate.RawRepresentation) - { - case RunStartedEvent runStarted: - threadId = runStarted.ThreadId; - previousRunId = runStarted.RunId; - break; - - case StateSnapshotEvent snapshot: - // Apply the new shared state pushed by the agent. - RecipeResponse? updated = snapshot.Snapshot.Deserialize(); - if (updated is not null) - { - currentState = updated.Recipe; - Console.WriteLine($"State updated: {currentState.Title}"); - } - break; - } + [JsonPropertyName("status")] + public StepStatus Status { get; set; } = StepStatus.Pending; +} - // Assistant summary text streams as normal TextContent. - foreach (AIContent content in update.Contents) - { - if (content is TextContent text) - { - Console.Write(text.Text); - } - } +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum StepStatus +{ + Pending, + Completed } -``` -> [!NOTE] -> State travels on the AG-UI wire, not through `ConversationId`. Outbound, the client sets -> `RunAgentInput.State`; inbound, `STATE_SNAPSHOT` / `STATE_DELTA` events arrive as -> `ChatResponseUpdate.RawRepresentation` (a `StateSnapshotEvent` / `StateDeltaEvent`). This keeps the -> client stateless while the agent remains the source of truth for shared state. +internal sealed class JsonPatchOperation +{ + [JsonPropertyName("op")] + public required string Op { get; set; } -## Predictive State Updates + [JsonPropertyName("path")] + public required string Path { get; set; } -Predictive state updates let a client render an in-progress result *before* the agent finishes — for -example, showing a document as it is being written. Instead of emitting one `STATE_SNAPSHOT` at the -end, the agent streams a series of progressively larger snapshots as it produces content. + [JsonPropertyName("value")] + public object? Value { get; set; } +} +``` -The pattern is a `DelegatingAIAgent` that watches for a document-writing tool call and re-emits the -growing document as successive `StateSnapshotEvent`s: +The `create_plan` tool returns the full plan; `update_plan_step` returns a list of JSON Patch operations: ```csharp -using System.Runtime.CompilerServices; -using System.Text.Json; -using AGUI.Abstractions; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; +using System.ComponentModel; -internal sealed class PredictiveStateUpdatesAgent(AIAgent innerAgent) : DelegatingAIAgent(innerAgent) +[Description("Create a plan with multiple steps.")] +public static Plan CreatePlan( + [Description("List of step descriptions to create the plan.")] List steps) { - private const int ChunkSize = 20; // characters per streamed chunk - - protected override Task RunCoreAsync( - IEnumerable messages, AgentSession? session = null, - AgentRunOptions? options = null, CancellationToken cancellationToken = default) - => this.RunCoreStreamingAsync(messages, session, options, cancellationToken) - .ToAgentResponseAsync(cancellationToken); - - protected override async IAsyncEnumerable RunCoreStreamingAsync( - IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, - [EnumeratorCancellation] CancellationToken cancellationToken = default) + return new Plan { - string? lastEmitted = null; + Steps = [.. steps.Select(s => new Step { Description = s, Status = StepStatus.Pending })] + }; +} - await foreach (var update in this.InnerAgent.RunStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false)) - { - yield return update; - - // When the model calls write_document, progressively emit the document as it grows. - foreach (var content in update.Contents) - { - if (content is FunctionCallContent { Name: "write_document" } call && - call.Arguments?.TryGetValue("document", out var doc) == true && - doc?.ToString() is { Length: > 0 } document && - document != lastEmitted) - { - for (int i = ChunkSize; i < document.Length + ChunkSize; i += ChunkSize) - { - string chunk = document[..Math.Min(i, document.Length)]; - JsonElement snapshot = JsonSerializer.SerializeToElement(new { document = chunk }); - yield return new AgentResponseUpdate - { - Role = ChatRole.Assistant, - RawRepresentation = new ChatResponseUpdate - { - Role = ChatRole.Assistant, - RawRepresentation = new StateSnapshotEvent { Snapshot = snapshot } - } - }; - await Task.Delay(50, cancellationToken).ConfigureAwait(false); - } - - lastEmitted = document; - } - } - } - } +[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 it the same way as any other agent — wrap your document-writing agent and map it: +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. ```csharp -AIAgent documentAgent = CreateDocumentAgent(); // an agent with a write_document tool -app.MapAGUIServer("/predictive", new PredictiveStateUpdatesAgent(documentAgent)); +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 + { + 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, + }, +}); + +AGUIStreamOptions planStreamOptions = new AGUIStreamOptions() + .MapResultAsStateSnapshot("create_plan") // full plan -> STATE_SNAPSHOT + .MapResultAsStateDelta("update_plan_step"); // JSON Patch -> STATE_DELTA + +builder.AddAIAgent("AgenticUIAgent", (_, _) => planAgent).WithInMemorySessionStore(); +app.MapAGUIServer("AgenticUIAgent", "/agentic_generative_ui").WithMetadata(planStreamOptions); ``` -> [!TIP] -> For incremental edits rather than full snapshots, emit a `StateDeltaEvent` whose `Delta` is an -> RFC 6902 JSON Patch (for example, `[ { "op": "replace", "path": "/steps/0/status", "value": "completed" } ]`). -> The client applies the patch to its current state. Snapshots are simplest for append-style content -> like a streamed document; deltas are efficient for small updates to a large object such as a plan. +> [!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. + +For a related pattern that streams a tool's arguments into state as the model generates them, see [Predictive State Updates](predictive-state-updates.md). + ::: zone-end @@ -1128,4 +998,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 From f479683addc079a1e0ed53d126280423a749adf2 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 23 Jul 2026 10:52:37 -0700 Subject: [PATCH 10/35] getting-started: adopt idiomatic AddAIAgent wiring; fix WithInMemorySessionStore isolation Adopt @javiercn's idiomatic server wiring in getting-started (AddAIAgent + session store + MapAGUIServer(name, pattern)) and align the backend to Azure OpenAI for consistency with the other C# pages. Verified fix on top of the draft: `.WithInMemorySessionStore()` defaults to withIsolation:true, which requires a SessionIsolationKeyProvider and throws HTTP 500 at runtime without one. Use `.WithInMemorySessionStore(withIsolation: false)` for the single-user local server (getting-started and state-management), and note that multi-user deployments keep isolation on with a provider. Verified end-to-end (AddAIAgent + WithInMemorySessionStore(withIsolation:false) + MapAGUIServer(name)) against GitHub Models: endpoint streams correctly. Co-authored-by: Javier Calvarro Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eeb2168d-2ecc-4f8d-9830-c287072eb7e1 --- .../integrations/ag-ui/getting-started.md | 43 +++++++++++-------- .../integrations/ag-ui/state-management.md | 4 +- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/agent-framework/integrations/ag-ui/getting-started.md b/agent-framework/integrations/ag-ui/getting-started.md index 88aa8d4d..87d41eb3 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,41 @@ 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; using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); -builder.Services.AddHttpClient().AddLogging(); builder.Services.AddAGUIServer(); -WebApplication app = builder.Build(); - 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( +const string AgentName = "AGUIAssistant"; + +// 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", + name: AgentName, instructions: "You are a helpful assistant."); -// Map the AG-UI agent endpoint -app.MapAGUIServer("/", agent); +// Register the agent with the host and keep conversation state across requests using an in-memory +// session store (keyed by AG-UI thread ID). `withIsolation: false` is appropriate for a single-user +// local server; in multi-user deployments keep isolation enabled (the default) and register a +// SessionIsolationKeyProvider to scope sessions per principal. +builder.AddAIAgent(AgentName, (_, _) => agent).WithInMemorySessionStore(withIsolation: false); + +WebApplication app = builder.Build(); + +// Map the AG-UI agent endpoint by name. +app.MapAGUIServer(AgentName, "/"); await app.RunAsync(); ``` @@ -105,12 +113,13 @@ await app.RunAsync(); ### Key Concepts -- **`AddAGUIServer`**: Registers AG-UI services with the dependency injection container -- **`MapAGUIServer`**: 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 +- **`AddAIAgent` / `WithInMemorySessionStore`**: Registers the agent with the host and persists conversation sessions (keyed by AG-UI thread ID) across requests +- **`MapAGUIServer`**: Extension method that maps the named 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 diff --git a/agent-framework/integrations/ag-ui/state-management.md b/agent-framework/integrations/ag-ui/state-management.md index 319ec3df..2e6d839e 100644 --- a/agent-framework/integrations/ag-ui/state-management.md +++ b/agent-framework/integrations/ag-ui/state-management.md @@ -187,7 +187,7 @@ builder.Services.AddAGUIServer(); AGUIStreamOptions streamOptions = new AGUIStreamOptions() .MapResultAsStateSnapshot("generate_recipe"); -builder.AddAIAgent("RecipeAgent", (_, _) => recipeAgent).WithInMemorySessionStore(); +builder.AddAIAgent("RecipeAgent", (_, _) => recipeAgent).WithInMemorySessionStore(withIsolation: false); WebApplication app = builder.Build(); @@ -334,7 +334,7 @@ AGUIStreamOptions planStreamOptions = new AGUIStreamOptions() .MapResultAsStateSnapshot("create_plan") // full plan -> STATE_SNAPSHOT .MapResultAsStateDelta("update_plan_step"); // JSON Patch -> STATE_DELTA -builder.AddAIAgent("AgenticUIAgent", (_, _) => planAgent).WithInMemorySessionStore(); +builder.AddAIAgent("AgenticUIAgent", (_, _) => planAgent).WithInMemorySessionStore(withIsolation: false); app.MapAGUIServer("AgenticUIAgent", "/agentic_generative_ui").WithMetadata(planStreamOptions); ``` From 0fe813a7ca91e223a6ad68cfa187f0866721db01 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 23 Jul 2026 11:47:58 -0700 Subject: [PATCH 11/35] Simplify AG-UI server snippets to the direct MapAGUIServer overload (no session store) The session store is optional (AG-UI clients resend history; sessions are ephemeral without one). Match the verified AgenticUI sample: use the simplest idiomatic form -- MapAGUIServer("/", agent) and MapAGUIServer("/route", agent).WithMetadata(streamOptions) for state -- instead of AddAIAgent + WithInMemorySessionStore. Session persistence + principal isolation are documented as an opt-in note (WithInMemorySessionStore(withIsolation:false) for single-user; SessionIsolationKeyProvider / UseClaimsBasedSessionIsolation for multi-user). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eeb2168d-2ecc-4f8d-9830-c287072eb7e1 --- .../integrations/ag-ui/getting-started.md | 28 +++++++++---------- .../integrations/ag-ui/state-management.md | 9 ++---- 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/agent-framework/integrations/ag-ui/getting-started.md b/agent-framework/integrations/ag-ui/getting-started.md index 87d41eb3..cb7983d5 100644 --- a/agent-framework/integrations/ag-ui/getting-started.md +++ b/agent-framework/integrations/ag-ui/getting-started.md @@ -72,7 +72,6 @@ Create a file named `Program.cs`: using Azure.AI.OpenAI; using Azure.Identity; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.Hosting; using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); @@ -83,27 +82,19 @@ 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."); -const string AgentName = "AGUIAssistant"; - // Build an agent directly from the Azure OpenAI chat client. AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), new DefaultAzureCredential()) .GetChatClient(deploymentName) .AsAIAgent( - name: AgentName, + name: "AGUIAssistant", instructions: "You are a helpful assistant."); -// Register the agent with the host and keep conversation state across requests using an in-memory -// session store (keyed by AG-UI thread ID). `withIsolation: false` is appropriate for a single-user -// local server; in multi-user deployments keep isolation enabled (the default) and register a -// SessionIsolationKeyProvider to scope sessions per principal. -builder.AddAIAgent(AgentName, (_, _) => agent).WithInMemorySessionStore(withIsolation: false); - WebApplication app = builder.Build(); -// Map the AG-UI agent endpoint by name. -app.MapAGUIServer(AgentName, "/"); +// Map the agent to an AG-UI endpoint (HTTP POST + SSE streaming). +app.MapAGUIServer("/", agent); await app.RunAsync(); ``` @@ -111,11 +102,20 @@ 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. +> [!NOTE] +> Sessions are **ephemeral** here: AG-UI clients resend the conversation each turn, so no server-side +> state is required. To persist sessions server-side (keyed by the AG-UI thread ID), register the agent +> with a session store — `builder.AddAIAgent("AGUIAssistant", (_, _) => agent).WithInMemorySessionStore(withIsolation: false);` +> — and map it by name with `app.MapAGUIServer("AGUIAssistant", "/");`. The `withIsolation: false` form +> suits a single-user or local server. A thread ID is **not** an authorization token, so multi-user hosts +> must scope sessions per principal: keep isolation enabled (the default) and register a +> `SessionIsolationKeyProvider` (for example via `UseClaimsBasedSessionIsolation(...)`). See +> [Security Considerations](security-considerations.md). + ### Key Concepts - **`AddAGUIServer`**: Registers AG-UI server services with the dependency injection container -- **`AddAIAgent` / `WithInMemorySessionStore`**: Registers the agent with the host and persists conversation sessions (keyed by AG-UI thread ID) across requests -- **`MapAGUIServer`**: Extension method that maps the named agent to an AG-UI endpoint with automatic request/response handling and SSE streaming +- **`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 diff --git a/agent-framework/integrations/ag-ui/state-management.md b/agent-framework/integrations/ag-ui/state-management.md index 2e6d839e..ab2552a6 100644 --- a/agent-framework/integrations/ag-ui/state-management.md +++ b/agent-framework/integrations/ag-ui/state-management.md @@ -175,7 +175,6 @@ Create an , register the tool name as a stat ```csharp using AGUI.Server; -using Microsoft.Agents.AI.Hosting; using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); @@ -187,11 +186,10 @@ builder.Services.AddAGUIServer(); AGUIStreamOptions streamOptions = new AGUIStreamOptions() .MapResultAsStateSnapshot("generate_recipe"); -builder.AddAIAgent("RecipeAgent", (_, _) => recipeAgent).WithInMemorySessionStore(withIsolation: false); - WebApplication app = builder.Build(); -app.MapAGUIServer("RecipeAgent", "/").WithMetadata(streamOptions); +// Attach the stream options to the endpoint; MapAGUIServer emits the state events for you. +app.MapAGUIServer("/", recipeAgent).WithMetadata(streamOptions); await app.RunAsync(); ``` @@ -334,8 +332,7 @@ AGUIStreamOptions planStreamOptions = new AGUIStreamOptions() .MapResultAsStateSnapshot("create_plan") // full plan -> STATE_SNAPSHOT .MapResultAsStateDelta("update_plan_step"); // JSON Patch -> STATE_DELTA -builder.AddAIAgent("AgenticUIAgent", (_, _) => planAgent).WithInMemorySessionStore(withIsolation: false); -app.MapAGUIServer("AgenticUIAgent", "/agentic_generative_ui").WithMetadata(planStreamOptions); +app.MapAGUIServer("/agentic_generative_ui", planAgent).WithMetadata(planStreamOptions); ``` > [!NOTE] From c0766ab926145a5448e13533611c150b5a5774e7 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 23 Jul 2026 12:17:27 -0700 Subject: [PATCH 12/35] Standardize AG-UI C# backend on AzureOpenAIClient (parity with Python) + fix missing usings Backend-client consistency: the Python AG-UI docs use OpenAIChatCompletionClient (a chat client) across the section, and the AG-UI state/predictive patterns are chat-client-level. Convert the remaining Foundry (AIProjectClient) pages to AzureOpenAIClient for cross-language parity: - backend-tool-rendering, human-in-the-loop, testing-with-dojo: AIProjectClient -> AzureOpenAIClient .GetChatClient(deployment).AsAIAgent(...). (workflows.md stays on AIProjectClient to match its Python counterpart, which uses FoundryChatClient.) Build-verification fixes (compiled every complete snippet against the shipped packages): - Add missing `using OpenAI.Chat;` where GetChatClient(...).AsAIAgent(...) is used (getting-started server block and state-management "Create the Agent"). AsAIAgent(this ChatClient) lives in the OpenAI.Chat namespace; without the using the snippet does not compile. - Predictive verified fine as-is (AsIChatClient resolves via Microsoft.Extensions.AI; adding OpenAI.Chat there would clash with Microsoft.Extensions.AI.ChatMessage). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eeb2168d-2ecc-4f8d-9830-c287072eb7e1 --- .../integrations/ag-ui/backend-tool-rendering.md | 7 ++++--- agent-framework/integrations/ag-ui/getting-started.md | 1 + agent-framework/integrations/ag-ui/human-in-the-loop.md | 7 ++++--- agent-framework/integrations/ag-ui/state-management.md | 1 + agent-framework/integrations/ag-ui/testing-with-dojo.md | 7 ++++--- 5 files changed, 14 insertions(+), 9 deletions(-) diff --git a/agent-framework/integrations/ag-ui/backend-tool-rendering.md b/agent-framework/integrations/ag-ui/backend-tool-rendering.md index 658662a7..ae3a96fd 100644 --- a/agent-framework/integrations/ag-ui/backend-tool-rendering.md +++ b/agent-framework/integrations/ag-ui/backend-tool-rendering.md @@ -43,12 +43,13 @@ 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(); @@ -114,11 +115,11 @@ AITool[] tools = ]; // 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); diff --git a/agent-framework/integrations/ag-ui/getting-started.md b/agent-framework/integrations/ag-ui/getting-started.md index cb7983d5..c9df2e14 100644 --- a/agent-framework/integrations/ag-ui/getting-started.md +++ b/agent-framework/integrations/ag-ui/getting-started.md @@ -73,6 +73,7 @@ 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.AddAGUIServer(); 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 04cf7773..dc7fa633 100644 --- a/agent-framework/integrations/ag-ui/human-in-the-loop.md +++ b/agent-framework/integrations/ag-ui/human-in-the-loop.md @@ -43,11 +43,12 @@ code on the server. ```csharp using System.ComponentModel; -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 OpenAI.Chat; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); builder.Services.AddHttpClient().AddLogging(); @@ -72,9 +73,9 @@ static string SendEmail( AITool sendEmail = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(SendEmail)); #pragma warning restore MEAI001 -AIAgent agent = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential()) +AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) + .GetChatClient(deploymentName) .AsAIAgent( - model: deploymentName, name: "AGUIAssistant", instructions: "You are a helpful assistant. Use the send_email tool when asked to send email.", tools: [sendEmail]); diff --git a/agent-framework/integrations/ag-ui/state-management.md b/agent-framework/integrations/ag-ui/state-management.md index ab2552a6..e0659af4 100644 --- a/agent-framework/integrations/ag-ui/state-management.md +++ b/agent-framework/integrations/ag-ui/state-management.md @@ -133,6 +133,7 @@ using Microsoft.Agents.AI; using Azure.AI.OpenAI; using Azure.Identity; using Microsoft.Extensions.AI; +using OpenAI.Chat; const string SharedStateSystemPrompt = """ diff --git a/agent-framework/integrations/ag-ui/testing-with-dojo.md b/agent-framework/integrations/ag-ui/testing-with-dojo.md index c19eff41..86dbf099 100644 --- a/agent-framework/integrations/ag-ui/testing-with-dojo.md +++ b/agent-framework/integrations/ag-ui/testing-with-dojo.md @@ -365,20 +365,21 @@ To test your own agents with Dojo: Create a new agent following the [Getting Started](getting-started.md) guide: ```csharp -using Azure.AI.Projects; +using Azure.AI.OpenAI; using Azure.Identity; using Microsoft.Agents.AI; +using OpenAI.Chat; 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."); -AIAgent agent = new AIProjectClient( +AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), new DefaultAzureCredential()) + .GetChatClient(deploymentName) .AsAIAgent( - model: deploymentName, name: "my_test_agent", instructions: "You are a helpful assistant."); ``` From f43bcc68bb03ce8ac2bb0b903e937ac1c28e8eb8 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 23 Jul 2026 12:19:09 -0700 Subject: [PATCH 13/35] getting-started: add Persisting Sessions subsection (when to enable a store + isolation) Default is ephemeral (client owns the transcript). Document when to register a server-side session store (stateful backends, durable/background runs, thin clients, cross-device resume, audit) and pair it with the multi-user isolation requirement (thread ID is not an auth token; use SessionIsolationKeyProvider / UseClaimsBasedSessionIsolation; withIsolation:false only for single-user/local). Python AG-UI docs have no equivalent storage-backend section, so this is .NET-specific content. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eeb2168d-2ecc-4f8d-9830-c287072eb7e1 --- .../integrations/ag-ui/getting-started.md | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/agent-framework/integrations/ag-ui/getting-started.md b/agent-framework/integrations/ag-ui/getting-started.md index c9df2e14..748783d5 100644 --- a/agent-framework/integrations/ag-ui/getting-started.md +++ b/agent-framework/integrations/ag-ui/getting-started.md @@ -104,14 +104,7 @@ await app.RunAsync(); > `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] -> Sessions are **ephemeral** here: AG-UI clients resend the conversation each turn, so no server-side -> state is required. To persist sessions server-side (keyed by the AG-UI thread ID), register the agent -> with a session store — `builder.AddAIAgent("AGUIAssistant", (_, _) => agent).WithInMemorySessionStore(withIsolation: false);` -> — and map it by name with `app.MapAGUIServer("AGUIAssistant", "/");`. The `withIsolation: false` form -> suits a single-user or local server. A thread ID is **not** an authorization token, so multi-user hosts -> must scope sessions per principal: keep isolation enabled (the default) and register a -> `SessionIsolationKeyProvider` (for example via `UseClaimsBasedSessionIsolation(...)`). See -> [Security Considerations](security-considerations.md). +> Sessions are **ephemeral** here: AG-UI clients resend the conversation each turn, so no server-side state is required. To persist conversation state server-side (and the principal-isolation it requires in multi-user hosts), see [Persisting Sessions](#persisting-sessions). ### Key Concepts @@ -460,6 +453,31 @@ await app.RunAsync(); Clients connect to the route for the agent they want to use, such as `http://localhost:8888/weather` or `http://localhost:8888/finance`. +### Persisting Sessions + +By default the AG-UI endpoint is **stateless**: the client sends the full conversation in each request, so `MapAGUIServer` runs the agent, streams the response, and keeps nothing between requests. This is the natural fit for AG-UI — the frontend owns the transcript — and is a perfectly valid production model. + +Register a server-side session store when the **server** needs to own conversation state independently of the request. Common cases: + +- **Stateful backends** that keep history server-side (for example provider-managed threads), so you don't re-send the whole transcript each turn. +- **Durable or background runs** that outlive the HTTP request — here a persisted session is required, not optional. +- **Thin clients** that send only the new message plus a thread ID rather than the full history. +- **Cross-device resume**, or keeping a server-authoritative record for audit/compliance. + +To persist sessions, register the agent with a store and map it by name. The store is keyed by the AG-UI thread ID: + +```csharp +// Single-user or local server. +builder.AddAIAgent("AGUIAssistant", (_, _) => agent).WithInMemorySessionStore(withIsolation: false); + +WebApplication app = builder.Build(); + +app.MapAGUIServer("AGUIAssistant", "/"); +``` + +> [!WARNING] +> An AG-UI **thread ID is not an authorization token** — it arrives from the wire and can be guessed. With a persistent store, any caller who knows another user's thread ID can resume that thread. So multi-user hosts must scope sessions per principal: keep isolation **enabled** (the default) and register a `SessionIsolationKeyProvider` — typically via `UseClaimsBasedSessionIsolation(...)` from `Microsoft.Agents.AI.Hosting.AspNetCore`. Use `withIsolation: false` only for single-user or local development. See [Security Considerations](security-considerations.md). + ## Troubleshooting ### Connection Refused From 455617cb62e0623099304b9aa367dec5911f3c87 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 23 Jul 2026 12:45:58 -0700 Subject: [PATCH 14/35] Docs CI: fix xref-not-found (AGUI.Server), pivot-id-unused, code-block-indented - Convert unresolvable to code spans (AGUI.* SDK is not indexed on Learn) -> clears 3 xref-not-found warnings. - Add a Go zone stub to predictive-state-updates.md -> clears pivot-id-unused. - Dedent the interrupt JSON block in workflows.md -> clears code-block-indented. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eeb2168d-2ecc-4f8d-9830-c287072eb7e1 --- .../ag-ui/predictive-state-updates.md | 8 +++- .../integrations/ag-ui/state-management.md | 4 +- .../integrations/ag-ui/workflows.md | 44 +++++++++---------- 3 files changed, 31 insertions(+), 25 deletions(-) diff --git a/agent-framework/integrations/ag-ui/predictive-state-updates.md b/agent-framework/integrations/ag-ui/predictive-state-updates.md index f902e50d..e913242a 100644 --- a/agent-framework/integrations/ag-ui/predictive-state-updates.md +++ b/agent-framework/integrations/ag-ui/predictive-state-updates.md @@ -32,7 +32,7 @@ You need: Unlike the [shared-state](state-management.md) 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 `MapCall` mapping intercepts the call. +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. @@ -246,3 +246,9 @@ A UI toolkit such as [CopilotKit](https://copilotkit.ai/) subscribes to the stat Predictive state updates for Python are covered in the [State Management](state-management.md) tutorial, which shows how to configure predicted state with `predict_state_config` and stream tool arguments as optimistic state updates. ::: zone-end + +::: zone pivot="programming-language-go" + +Predictive state updates are not currently provided as a dedicated helper in the Go AG-UI integration. See [State Management](state-management.md) for the supported Go state patterns. + +::: zone-end diff --git a/agent-framework/integrations/ag-ui/state-management.md b/agent-framework/integrations/ag-ui/state-management.md index e0659af4..8c7c61a0 100644 --- a/agent-framework/integrations/ag-ui/state-management.md +++ b/agent-framework/integrations/ag-ui/state-management.md @@ -45,7 +45,7 @@ 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 . You don't write a custom agent or emit protocol content by hand. +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 @@ -172,7 +172,7 @@ AIAgent recipeAgent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureC ### Map the Tool Result to a State Event -Create an , 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: +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; diff --git a/agent-framework/integrations/ag-ui/workflows.md b/agent-framework/integrations/ag-ui/workflows.md index 40573d1c..e84f1819 100644 --- a/agent-framework/integrations/ag-ui/workflows.md +++ b/agent-framework/integrations/ag-ui/workflows.md @@ -210,29 +210,29 @@ Workflows can pause execution to collect human input or tool approvals. The AG-U 2. The AG-UI bridge emits a `CUSTOM` event with `name="request_info"` containing the request data. 3. The run finishes with a `RUN_FINISHED` event whose `outcome.interrupts` field contains the pending requests: - ```json - { - "type": "RUN_FINISHED", - "threadId": "abc123", - "runId": "run_xyz", - "outcome": { - "type": "interrupt", - "interrupts": [ - { - "id": "request-id-1", - "reason": "input_required", - "message": "Provide the requested information.", - "responseSchema": { "type": "string" }, - "metadata": { - "agent_framework": { - "request_type": "HandoffAgentUserRequest" - } - } +```json +{ + "type": "RUN_FINISHED", + "threadId": "abc123", + "runId": "run_xyz", + "outcome": { + "type": "interrupt", + "interrupts": [ + { + "id": "request-id-1", + "reason": "input_required", + "message": "Provide the requested information.", + "responseSchema": { "type": "string" }, + "metadata": { + "agent_framework": { + "request_type": "HandoffAgentUserRequest" } - ] + } } - } - ``` + ] + } +} +``` 4. The client renders UI for the user to respond (a text input, an approval button, etc.). @@ -454,4 +454,4 @@ mux.Handle("/", aguiprovider.NewJSONHTTPHandler(workflowAgent, aguiprovider.Hand > [!TIP] > See the [workflow as an agent sample](https://github.com/microsoft/agent-framework-go/blob/main/examples/03-workflows/agents/workflow_as_an_agent/main.go) and the [AG-UI server sample](https://github.com/microsoft/agent-framework-go/blob/main/examples/02-agents/agui/step01_getting_started/server/main.go) for complete runnable examples. -::: zone-end \ No newline at end of file +::: zone-end From e17645e1798484d53f67ebcf6478dafc98aedbce Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 23 Jul 2026 13:28:25 -0700 Subject: [PATCH 15/35] Review cleanup: fix errors, cut noise, tighten AG-UI C# docs Correctness: - workflows.md: convert the workflow agents from the removed AIProjectClient (fed with an AZURE_OPENAI_ENDPOINT, a Foundry/AOAI endpoint mismatch) to the shipped AzureOpenAIClient chat-client pattern used by the rest of the section (and matching the verified sample). - frontend-tools.md: fix a numbered list that skipped "2"; correct the type name ChatAgentRunOptions -> ChatClientAgentRunOptions (x2). Noise / simplicity: - Remove the unnecessary `builder.Services.AddHttpClient().AddLogging();` boilerplate from 6 server snippets (an AG-UI server does not need it; the flagship getting-started already dropped it). - frontend-tools.md: remove a tangential, incomplete "inspect tools via middleware" section (referenced an undefined `baseAgent` and ended with a dangling bullet). - getting-started.md: drop the "Color-Coded Output" console trivia; simplify "Custom Server Configuration" by removing the unused ChatRequestMetadata/AppJsonSerializerContext types. - backend-tool-rendering.md: replace ~180 lines of generic, non-AG-UI "Tool Best Practices"/"Tool Organization" code examples (incl. a try/catch around code that can't throw) with a concise bulleted list. Clarity: - human-in-the-loop.md: soften prose that claimed "no protocol to implement on the client" while the sample carries threadId/ParentRunId across turns; explain the continuity plumbing instead. - predictive-state-updates.md: note that the .NET 10 floor is due to TypedResults.ServerSentEvents. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eeb2168d-2ecc-4f8d-9830-c287072eb7e1 --- .../ag-ui/backend-tool-rendering.md | 179 +----------------- .../integrations/ag-ui/frontend-tools.md | 49 +---- .../integrations/ag-ui/getting-started.md | 29 +-- .../integrations/ag-ui/human-in-the-loop.md | 8 +- .../ag-ui/predictive-state-updates.md | 2 +- .../integrations/ag-ui/testing-with-dojo.md | 1 - .../integrations/ag-ui/workflows.md | 13 +- 7 files changed, 20 insertions(+), 261 deletions(-) diff --git a/agent-framework/integrations/ag-ui/backend-tool-rendering.md b/agent-framework/integrations/ag-ui/backend-tool-rendering.md index ae3a96fd..e89083d1 100644 --- a/agent-framework/integrations/ag-ui/backend-tool-rendering.md +++ b/agent-framework/integrations/ag-ui/backend-tool-rendering.md @@ -52,7 +52,6 @@ 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.AddAGUIServer(); @@ -273,180 +272,10 @@ great restaurants in the area: The Golden Fork (Italian, 4.5 stars)... ## Tool Implementation Best Practices -### Error Handling - -Handle errors gracefully in your tools. Return failures as data so the model can explain the issue to the user instead of failing the entire run: - -```csharp -internal sealed class WeatherResult -{ - public bool Success { get; set; } - public string Location { get; set; } = string.Empty; - public string? Forecast { get; set; } - public string? Error { get; set; } -} - -[Description("Get the current weather for a location.")] -static WeatherResult GetWeather( - [Description("The city, region, or address for the weather lookup.")] string location) -{ - try - { - return new WeatherResult - { - Success = true, - Location = location, - Forecast = "Sunny, 22°C" - }; - } - catch (Exception ex) - { - return new WeatherResult - { - Success = false, - Location = location, - Error = $"Unable to retrieve weather for {location}: {ex.Message}" - }; - } -} - -AITool weatherTool = AIFunctionFactory.Create( - GetWeather, - serializerOptions: jsonOptions.SerializerOptions); -``` - -### Rich Return Types - -Return structured objects when appropriate. Typed return values are serialized with the JSON options you pass to `AIFunctionFactory.Create()`: - -```csharp -internal sealed class SentimentResult -{ - public string Text { get; set; } = string.Empty; - public string Sentiment { get; set; } = string.Empty; - public double Confidence { get; set; } - public SentimentScores Scores { get; set; } = new(); -} - -internal sealed class SentimentScores -{ - public double Positive { get; set; } - public double Neutral { get; set; } - public double Negative { get; set; } -} - -[JsonSerializable(typeof(SentimentResult))] -internal sealed partial class SentimentJsonSerializerContext : JsonSerializerContext; - -builder.Services.ConfigureHttpJsonOptions(options => - options.SerializerOptions.TypeInfoResolverChain.Add(SentimentJsonSerializerContext.Default)); - -[Description("Analyze the sentiment of text.")] -static SentimentResult AnalyzeSentiment( - [Description("The text to analyze.")] string text) -{ - return new SentimentResult - { - Text = text, - Sentiment = "positive", - Confidence = 0.87, - Scores = new SentimentScores - { - Positive = 0.87, - Neutral = 0.10, - Negative = 0.03 - } - }; -} - -AITool sentimentTool = AIFunctionFactory.Create( - AnalyzeSentiment, - serializerOptions: jsonOptions.SerializerOptions); -``` - -### Descriptive Documentation - -Use `[Description]` on tool methods and parameters so the model understands when to call the tool and how to provide arguments: - -```csharp -internal sealed class FlightBookingResult -{ - public string ConfirmationNumber { get; set; } = string.Empty; - public string Status { get; set; } = string.Empty; -} - -[Description("Book a flight for specified passengers from origin to destination. Use this when the user wants to reserve airline tickets.")] -static FlightBookingResult BookFlight( - [Description("Departure city and airport code, e.g., 'New York, JFK'.")] string origin, - [Description("Arrival city and airport code, e.g., 'London, LHR'.")] string destination, - [Description("Departure date in YYYY-MM-DD format.")] string date, - [Description("Number of passengers.")] int passengers = 1) -{ - return new FlightBookingResult - { - ConfirmationNumber = "ABC123", - Status = $"Booked {passengers} passenger(s) from {origin} to {destination} on {date}." - }; -} - -AITool bookingTool = AIFunctionFactory.Create( - BookFlight, - serializerOptions: jsonOptions.SerializerOptions); -``` - -## Tool Organization - -For related tools, group methods in a static class and register each method with `AIFunctionFactory.Create()`: - -```csharp -internal static class WeatherTools -{ - [Description("Get current weather for a location.")] - public static WeatherReport GetCurrentWeather( - [Description("The city, region, or address.")] string location) - { - return new WeatherReport - { - Location = location, - Conditions = "Sunny", - TemperatureCelsius = 22 - }; - } - - [Description("Get a multi-day weather forecast for a location.")] - public static ForecastReport GetForecast( - [Description("The city, region, or address.")] string location, - [Description("Number of days to forecast.")] int days = 3) - { - return new ForecastReport - { - Location = location, - Days = days, - Summary = "Sunny with light clouds." - }; - } -} - -internal sealed class WeatherReport -{ - public string Location { get; set; } = string.Empty; - public string Conditions { get; set; } = string.Empty; - public int TemperatureCelsius { get; set; } -} - -internal sealed class ForecastReport -{ - public string Location { get; set; } = string.Empty; - public int Days { get; set; } - public string Summary { get; set; } = string.Empty; -} - -AITool[] tools = -[ - AIFunctionFactory.Create(WeatherTools.GetCurrentWeather, serializerOptions: jsonOptions.SerializerOptions), - AIFunctionFactory.Create(WeatherTools.GetForecast, serializerOptions: jsonOptions.SerializerOptions) -]; -``` +- **Return failures as data.** Have a tool return a structured result with an error field instead of throwing, so the model can explain the problem to the user rather than failing the run. +- **Use rich return types.** Return structured objects; they are serialized with the JSON options you pass to `AIFunctionFactory.Create()`. When using source-generated serialization, register the types on a `JsonSerializerContext`. +- **Write clear descriptions.** Put `[Description]` on the tool method and every parameter so the model knows when to call the tool and how to supply arguments. +- **Group related tools.** Put related methods on a static class and register each one with `AIFunctionFactory.Create()`. ## Next Steps diff --git a/agent-framework/integrations/ag-ui/frontend-tools.md b/agent-framework/integrations/ag-ui/frontend-tools.md index c8a13b3f..c31a22b9 100644 --- a/agent-framework/integrations/ag-ui/frontend-tools.md +++ b/agent-framework/integrations/ag-ui/frontend-tools.md @@ -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.Name}: {function.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 748783d5..76bf1a9c 100644 --- a/agent-framework/integrations/ag-ui/getting-started.md +++ b/agent-framework/integrations/ag-ui/getting-started.md @@ -308,15 +308,6 @@ days to orbit the Sun. User (:q or quit to exit): :q ``` -### Color-Coded Output - -The client displays different content types with distinct colors: - -- **Yellow**: Run started notifications -- **Cyan**: Agent text responses (streamed in real-time) -- **Green**: Run completion notifications -- **Red**: Error messages - ## Testing with curl (Optional) You can exercise the server directly with curl before running the client. The AG-UI endpoint accepts a @@ -392,7 +383,6 @@ The AG-UI protocol uses: Customize the ASP.NET Core server port, AG-UI endpoint route, and JSON options before mapping the agent: ```csharp -using System.Text.Json.Serialization; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; @@ -401,12 +391,8 @@ WebApplicationBuilder builder = WebApplication.CreateBuilder(args); // Listen on a custom port builder.WebHost.UseUrls("http://localhost:8888"); -builder.Services.AddHttpClient().AddLogging(); -builder.Services.ConfigureHttpJsonOptions(options => -{ - options.SerializerOptions.WriteIndented = false; - options.SerializerOptions.TypeInfoResolverChain.Add(AppJsonSerializerContext.Default); -}); +// Customize JSON serialization once so the AG-UI endpoint and your tools stay consistent. +builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.WriteIndented = false); builder.Services.AddAGUIServer(); WebApplication app = builder.Build(); @@ -416,14 +402,6 @@ AIAgent agent = CreateAgent(); app.MapAGUIServer("/agent", agent); await app.RunAsync(); - -[JsonSerializable(typeof(ChatRequestMetadata))] -internal sealed partial class AppJsonSerializerContext : JsonSerializerContext; - -internal sealed class ChatRequestMetadata -{ - public string UserId { get; set; } = string.Empty; -} ``` The AG-UI endpoint handles HTTP POST requests and streams AG-UI events as SSE. Configure JSON serialization once with ASP.NET Core's HTTP JSON options so the AG-UI endpoint and your tools use consistent settings. @@ -437,7 +415,6 @@ using Microsoft.Agents.AI; using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); -builder.Services.AddHttpClient().AddLogging(); builder.Services.AddAGUIServer(); WebApplication app = builder.Build(); @@ -1012,4 +989,4 @@ a := aguiprovider.NewAgent( > [!TIP] > See the [AG-UI getting started server](https://github.com/microsoft/agent-framework-go/blob/main/examples/02-agents/agui/step01_getting_started/server/main.go) and [client](https://github.com/microsoft/agent-framework-go/blob/main/examples/02-agents/agui/step01_getting_started/client/main.go) samples for complete runnable examples. -::: zone-end \ No newline at end of file +::: zone-end 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 dc7fa633..74562ee3 100644 --- a/agent-framework/integrations/ag-ui/human-in-the-loop.md +++ b/agent-framework/integrations/ag-ui/human-in-the-loop.md @@ -51,7 +51,6 @@ using Microsoft.Extensions.AI; using OpenAI.Chat; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); -builder.Services.AddHttpClient().AddLogging(); builder.Services.AddAGUIServer(); WebApplication app = builder.Build(); @@ -95,9 +94,10 @@ response schema of `{ "approved": boolean }`. The tool runs only after the clien ## Client Implementation A client handles the interrupt by reading the `ToolApprovalRequestContent`, creating a decision with -`CreateResponse(approved)`, and sending it back on the next turn. `AGUIChatClient` transports the -decision over the AG-UI resume mechanism for you — there is no approval protocol to implement on the -client either. +`CreateResponse(approved)`, and sending it back on the next turn. You don't hand-encode the AG-UI +resume message — `AGUIChatClient` transports the decision for you — but because the client is +stateless, carry the AG-UI `threadId` and `ParentRunId` across the two turns so the server resumes +the same run, as shown below. ```csharp using AGUI.Abstractions; diff --git a/agent-framework/integrations/ag-ui/predictive-state-updates.md b/agent-framework/integrations/ag-ui/predictive-state-updates.md index e913242a..86c184e8 100644 --- a/agent-framework/integrations/ag-ui/predictive-state-updates.md +++ b/agent-framework/integrations/ag-ui/predictive-state-updates.md @@ -23,7 +23,7 @@ Before you begin, ensure you have completed the [Getting Started](getting-starte You need: -- .NET 10.0 or later +- .NET 10.0 or later (this scenario maps the endpoint manually and uses the built-in `TypedResults.ServerSentEvents(...)`, which requires .NET 10) - `Microsoft.Agents.AI.Hosting.AGUI.AspNetCore` and `Microsoft.Agents.AI.OpenAI` packages - An Azure OpenAI endpoint and deployment diff --git a/agent-framework/integrations/ag-ui/testing-with-dojo.md b/agent-framework/integrations/ag-ui/testing-with-dojo.md index 86dbf099..2b858091 100644 --- a/agent-framework/integrations/ag-ui/testing-with-dojo.md +++ b/agent-framework/integrations/ag-ui/testing-with-dojo.md @@ -395,7 +395,6 @@ In your ASP.NET Core application, register the agent endpoint: using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); -builder.Services.AddHttpClient().AddLogging(); builder.Services.AddAGUIServer(); WebApplication app = builder.Build(); diff --git a/agent-framework/integrations/ag-ui/workflows.md b/agent-framework/integrations/ag-ui/workflows.md index e84f1819..67eab2b0 100644 --- a/agent-framework/integrations/ag-ui/workflows.md +++ b/agent-framework/integrations/ag-ui/workflows.md @@ -32,14 +32,14 @@ Build a workflow from your agents, convert it to an `AIAgent` with `AsAIAgent()` `MapAGUIServer`: ```csharp -using Azure.AI.Projects; +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.AddHttpClient().AddLogging(); builder.Services.AddAGUIServer(); WebApplication app = builder.Build(); @@ -49,16 +49,15 @@ 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."); -var projectClient = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential()); +ChatClient chatClient = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) + .GetChatClient(deploymentName); // Define the agents that make up the workflow. -AIAgent researcher = projectClient.AsAIAgent( - model: deploymentName, +AIAgent researcher = chatClient.AsAIAgent( name: "researcher", instructions: "Research the user's topic and write a short, factual brief."); -AIAgent reporter = projectClient.AsAIAgent( - model: deploymentName, +AIAgent reporter = chatClient.AsAIAgent( name: "reporter", instructions: "Summarize the researcher's brief into a single clear paragraph."); From d280c50f5501a39b1998163d1cc9928ec46a93af Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 23 Jul 2026 16:15:44 -0700 Subject: [PATCH 16/35] Simplify AG-UI C# getting-started client to match Python scope Trim the getting-started console client to the same scope as the Python sample: use the update.Text convenience instead of iterating Contents, pass only the new message and let AgentSession track history, set the system prompt via AsAIAgent(instructions:), and drop the run-started/ finished notifications and color scaffolding. Verified end-to-end against a live AG-UI server (streaming + multi-turn session memory). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eeb2168d-2ecc-4f8d-9830-c287072eb7e1 --- .../integrations/ag-ui/getting-started.md | 108 +++++------------- 1 file changed, 29 insertions(+), 79 deletions(-) diff --git a/agent-framework/integrations/ag-ui/getting-started.md b/agent-framework/integrations/ag-ui/getting-started.md index 76bf1a9c..6329fca2 100644 --- a/agent-framework/integrations/ag-ui/getting-started.md +++ b/agent-framework/integrations/ag-ui/getting-started.md @@ -163,97 +163,49 @@ Create a file named `Program.cs`: ```csharp // Copyright (c) Microsoft. All rights reserved. -using Microsoft.Agents.AI; using AGUI.Client; +using Microsoft.Agents.AI; using Microsoft.Extensions.AI; string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; -Console.WriteLine($"Connecting to AG-UI server at: {serverUrl}\n"); - -// Create the AG-UI client agent -using HttpClient httpClient = new() -{ - Timeout = TimeSpan.FromSeconds(60) -}; +Console.WriteLine($"Connecting to AG-UI server at: {serverUrl}"); +// The AG-UI client agent talks to the server over HTTP. AsAIAgent turns the +// AGUIChatClient into an agent; the AgentSession tracks the conversation history. +using HttpClient httpClient = new(); AGUIChatClient chatClient = new(new AGUIChatClientOptions(httpClient, serverUrl)); AIAgent agent = chatClient.AsAIAgent( + instructions: "You are a helpful assistant.", name: "agui-client", description: "AG-UI Client Agent"); AgentSession session = await agent.CreateSessionAsync(); -List messages = -[ - new(ChatRole.System, "You are a helpful assistant.") -]; -try +while (true) { - while (true) + Console.Write("\nUser (:q or quit to exit): "); + string? message = Console.ReadLine(); + + if (string.IsNullOrWhiteSpace(message)) { - // Get user input - Console.Write("\nUser (:q or quit to exit): "); - string? message = Console.ReadLine(); - - if (string.IsNullOrWhiteSpace(message)) - { - Console.WriteLine("Request cannot be empty."); - continue; - } - - if (message is ":q" or "quit") - { - break; - } - - messages.Add(new ChatMessage(ChatRole.User, message)); - - // Stream the response - bool isFirstUpdate = true; - string? threadId = null; - - await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, session)) - { - ChatResponseUpdate chatUpdate = update.AsChatResponseUpdate(); - - // 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.ResetColor(); - isFirstUpdate = false; - } - - // Display streaming text content - foreach (AIContent content in update.Contents) - { - if (content is TextContent textContent) - { - Console.ForegroundColor = ConsoleColor.Cyan; - Console.Write(textContent.Text); - Console.ResetColor(); - } - else if (content is ErrorContent errorContent) - { - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine($"\n[Error: {errorContent.Message}]"); - Console.ResetColor(); - } - } - } - - Console.ForegroundColor = ConsoleColor.Green; - Console.WriteLine($"\n[Run Finished - Thread: {threadId}]"); - Console.ResetColor(); + continue; } -} -catch (Exception ex) -{ - Console.WriteLine($"\nAn error occurred: {ex.Message}"); + + if (message is ":q" or "quit") + { + break; + } + + // Pass only the new message; the session supplies the prior turns. + Console.Write("Assistant: "); + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(message, session)) + { + Console.Write(update.Text); + } + + Console.WriteLine(); } ``` @@ -261,11 +213,9 @@ catch (Exception ex) - **Server-Sent Events (SSE)**: The protocol uses SSE for streaming responses - **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` -- **Session Management**: The `AgentSession` maintains conversation context across requests -- **Content Types**: Responses include `TextContent` for messages and `ErrorContent` for errors +- **AsAIAgent**: Extension method that creates an `AIAgent` from the client; `instructions` sets the system prompt +- **RunStreamingAsync**: Streams the response as `AgentResponseUpdate` objects; `update.Text` yields the streamed text +- **AgentSession**: Maintains conversation history across turns — pass the session and only the new message each turn ### Configure and Run the Client From c3b882db68dcf2b1890ee18af34776a70de86f30 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 23 Jul 2026 16:27:44 -0700 Subject: [PATCH 17/35] Use single-line if statements in AG-UI getting-started client Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eeb2168d-2ecc-4f8d-9830-c287072eb7e1 --- agent-framework/integrations/ag-ui/getting-started.md | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/agent-framework/integrations/ag-ui/getting-started.md b/agent-framework/integrations/ag-ui/getting-started.md index 6329fca2..821df026 100644 --- a/agent-framework/integrations/ag-ui/getting-started.md +++ b/agent-framework/integrations/ag-ui/getting-started.md @@ -188,15 +188,9 @@ while (true) Console.Write("\nUser (:q or quit to exit): "); string? message = Console.ReadLine(); - if (string.IsNullOrWhiteSpace(message)) - { - continue; - } + if (string.IsNullOrWhiteSpace(message)) continue; - if (message is ":q" or "quit") - { - break; - } + if (message is ":q" or "quit") break; // Pass only the new message; the session supplies the prior turns. Console.Write("Assistant: "); From 435fdd48c09f5b620ea82cc0a3c28c25b09d9f65 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 24 Jul 2026 14:11:54 -0700 Subject: [PATCH 18/35] Document conditional (argument-based) tool approval in AG-UI HITL Add a Conditional Approval section to human-in-the-loop.md showing how to auto-approve some calls to an approval-required tool based on their arguments using AIAgentBuilder.UseToolApproval + ToolApprovalAgentOptions.AutoApprovalRules (shipped in Microsoft.Agents.AI 1.15.0). Verified end-to-end over AG-UI: a $500 transfer auto-approves and streams its result with no interrupt, while a $5,000 call raises an approval interrupt. Also reframe the Approval Requirements intro to point at the new section instead of stating there is no per-tool approval mode. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eeb2168d-2ecc-4f8d-9830-c287072eb7e1 --- .../integrations/ag-ui/human-in-the-loop.md | 70 ++++++++++++++++++- 1 file changed, 68 insertions(+), 2 deletions(-) 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 74562ee3..32695d8c 100644 --- a/agent-framework/integrations/ag-ui/human-in-the-loop.md +++ b/agent-framework/integrations/ag-ui/human-in-the-loop.md @@ -188,12 +188,15 @@ running the tool. ## Approval Requirements -In .NET, whether a tool requires approval is expressed by how you register it — there is no per-tool -"approval mode": +In .NET, whether a tool requires approval is expressed by how you register it: - **Always require approval**: wrap the function in `ApprovalRequiredAIFunction`. - **Never require approval**: register the function normally (do not wrap it). +For approval that depends on the tool's *arguments* — for example, approve small transfers automatically +but prompt for large ones — keep the tool wrapped in `ApprovalRequiredAIFunction` and add an auto-approval +rule, as shown in [Conditional Approval](#conditional-approval). + ```csharp // Requires approval before it runs. AITool transfer = new ApprovalRequiredAIFunction( @@ -234,6 +237,69 @@ If the model calls both tools in a single turn, the unrestricted tool (`get_acco and streams its `TOOL_CALL_RESULT`, while the approval-required tool (`transfer_funds`) raises an approval interrupt and waits for the user's decision before running. +## Conditional Approval + +Selective approval is *per tool* — a tool either always requires approval or never does. To approve some +calls to the *same* tool automatically based on their **arguments** (for example, auto-approve transfers +under $1,000 but prompt for anything larger), layer the `UseToolApproval` middleware over the agent and +supply one or more auto-approval rules. + +Each rule receives a `ToolAutoApprovalRuleContext` — which exposes the pending `FunctionCallContent` +(tool name and arguments) — and returns `true` to auto-approve the call or `false` to keep evaluating. +Rules run after any standing "always approve" decisions but before the user is prompted; the first rule +that returns `true` approves the call. A call that no rule approves raises the usual approval interrupt. + +```csharp +using System.Text.Json; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +AITool transfer = new ApprovalRequiredAIFunction(AIFunctionFactory.Create( + TransferFunds, name: "transfer_funds", description: "Transfer money to another account.")); + +AIAgent baseAgent = chatClient.AsAIAgent(new ChatClientAgentOptions +{ + Name = "BankingAgent", + ChatOptions = new ChatOptions + { + Instructions = "You are a banking assistant. Use transfer_funds to move money.", + Tools = [transfer] + } +}); + +// Auto-approve transfers under $1,000 based on the tool call's arguments; larger +// transfers fall through to a human approval interrupt. +static ValueTask AutoApproveSmallTransfers(ToolAutoApprovalRuleContext context) +{ + if (context.FunctionCallContent.Name == "transfer_funds" && + context.FunctionCallContent.Arguments is { } arguments && + arguments.TryGetValue("amount", out object? value) && value is not null) + { + decimal amount = value is JsonElement json ? json.GetDecimal() : Convert.ToDecimal(value); + return ValueTask.FromResult(amount < 1000m); + } + + return ValueTask.FromResult(false); +} + +// Layer the approval middleware over the agent, then map it as usual. +AIAgent agent = new AIAgentBuilder(baseAgent) + .UseToolApproval(new ToolApprovalAgentOptions { AutoApprovalRules = [AutoApproveSmallTransfers] }) + .Build(); + +app.MapAGUIServer("/", agent); +``` + +Now a `transfer_funds` call for `$500` is approved by the rule and runs to completion (streaming its +`TOOL_CALL_RESULT`) without ever reaching the client, while a `$5,000` call raises an approval interrupt +for the user to confirm — all from a single tool registration. + +> [!WARNING] +> Auto-approval rules can match a tool call by name alone. A rule written for one tool will auto-approve +> **any** registered tool whose name satisfies its condition, silently bypassing the approval prompt. +> Scope each rule tightly — check the tool name *and* the specific arguments — and make sure no unrelated +> tool shares a name your rules approve. + ## Best Practices ### Clear Tool Descriptions From 9bebec2763fdcb68f5a84f6ff59d01afb2714db0 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 24 Jul 2026 20:05:11 -0700 Subject: [PATCH 19/35] Remove Persisting Sessions section from AG-UI getting-started Server-side session storage and principal isolation are a general MAF hosting concern (shared across AG-UI, A2A, and Foundry hosting), not an AG-UI-specific topic, so they don't belong in the AG-UI getting-started. The getting-started server stays stateless (the idiomatic AG-UI default, where the client resends the transcript each turn). Also removes the ephemeral-sessions NOTE that linked to the deleted section. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eeb2168d-2ecc-4f8d-9830-c287072eb7e1 --- .../integrations/ag-ui/getting-started.md | 28 ------------------- 1 file changed, 28 deletions(-) diff --git a/agent-framework/integrations/ag-ui/getting-started.md b/agent-framework/integrations/ag-ui/getting-started.md index 821df026..411f142a 100644 --- a/agent-framework/integrations/ag-ui/getting-started.md +++ b/agent-framework/integrations/ag-ui/getting-started.md @@ -103,9 +103,6 @@ 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. -> [!NOTE] -> Sessions are **ephemeral** here: AG-UI clients resend the conversation each turn, so no server-side state is required. To persist conversation state server-side (and the principal-isolation it requires in multi-user hosts), see [Persisting Sessions](#persisting-sessions). - ### Key Concepts - **`AddAGUIServer`**: Registers AG-UI server services with the dependency injection container @@ -374,31 +371,6 @@ await app.RunAsync(); Clients connect to the route for the agent they want to use, such as `http://localhost:8888/weather` or `http://localhost:8888/finance`. -### Persisting Sessions - -By default the AG-UI endpoint is **stateless**: the client sends the full conversation in each request, so `MapAGUIServer` runs the agent, streams the response, and keeps nothing between requests. This is the natural fit for AG-UI — the frontend owns the transcript — and is a perfectly valid production model. - -Register a server-side session store when the **server** needs to own conversation state independently of the request. Common cases: - -- **Stateful backends** that keep history server-side (for example provider-managed threads), so you don't re-send the whole transcript each turn. -- **Durable or background runs** that outlive the HTTP request — here a persisted session is required, not optional. -- **Thin clients** that send only the new message plus a thread ID rather than the full history. -- **Cross-device resume**, or keeping a server-authoritative record for audit/compliance. - -To persist sessions, register the agent with a store and map it by name. The store is keyed by the AG-UI thread ID: - -```csharp -// Single-user or local server. -builder.AddAIAgent("AGUIAssistant", (_, _) => agent).WithInMemorySessionStore(withIsolation: false); - -WebApplication app = builder.Build(); - -app.MapAGUIServer("AGUIAssistant", "/"); -``` - -> [!WARNING] -> An AG-UI **thread ID is not an authorization token** — it arrives from the wire and can be guessed. With a persistent store, any caller who knows another user's thread ID can resume that thread. So multi-user hosts must scope sessions per principal: keep isolation **enabled** (the default) and register a `SessionIsolationKeyProvider` — typically via `UseClaimsBasedSessionIsolation(...)` from `Microsoft.Agents.AI.Hosting.AspNetCore`. Use `withIsolation: false` only for single-user or local development. See [Security Considerations](security-considerations.md). - ## Troubleshooting ### Connection Refused From 79bc8f216807d70ff6509c6eec54e9eb653ec9e1 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 24 Jul 2026 20:46:19 -0700 Subject: [PATCH 20/35] AG-UI HITL: reference generic tool-approval doc instead of duplicating approval modes Tool approval (always/never, selective, and conditional/argument-based) is a general Agent Framework capability, not AG-UI-specific. Replace the duplicated Approval Requirements / Selective Approval / Conditional Approval sections with a concise reference to agents/tools/tool-approval and a short note on what AG-UI adds on top (the RUN_FINISHED interrupt + {approved: boolean} resume transport). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eeb2168d-2ecc-4f8d-9830-c287072eb7e1 --- .../integrations/ag-ui/human-in-the-loop.md | 123 ++---------------- 1 file changed, 14 insertions(+), 109 deletions(-) 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 32695d8c..1fa3541a 100644 --- a/agent-framework/integrations/ag-ui/human-in-the-loop.md +++ b/agent-framework/integrations/ag-ui/human-in-the-loop.md @@ -186,119 +186,24 @@ running the tool. > and resume the run automatically when the user approves or rejects — on the server you only wrap the > tool in `ApprovalRequiredAIFunction`. -## Approval Requirements +## Approval modes -In .NET, whether a tool requires approval is expressed by how you register it: +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: -- **Always require approval**: wrap the function in `ApprovalRequiredAIFunction`. -- **Never require approval**: register the function normally (do not wrap it). +- **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`. -For approval that depends on the tool's *arguments* — for example, approve small transfers automatically -but prompt for large ones — keep the tool wrapped in `ApprovalRequiredAIFunction` and add an auto-approval -rule, as shown in [Conditional Approval](#conditional-approval). +For the APIs, examples, and guidance, see [Using function tools with human-in-the-loop approvals](../../agents/tools/tool-approval.md). -```csharp -// Requires approval before it runs. -AITool transfer = new ApprovalRequiredAIFunction( - AIFunctionFactory.Create(TransferFunds, name: "transfer_funds", description: "Transfer money to another account.")); - -// Runs without approval. -AITool balance = AIFunctionFactory.Create( - GetAccountBalance, name: "get_account_balance", description: "Get the current account balance."); -``` - -When the model calls an approval-required tool, the run ends with an `interrupt` outcome that carries an -approval request — the tool call plus a response schema of `{ "approved": boolean }`. The run resumes -once the client sends the approval decision back. - -## Selective Approval - -Mix approval-required and unrestricted tools on the same agent by wrapping only the sensitive ones: - -```csharp -AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions -{ - Name = "BankingAgent", - ChatOptions = new ChatOptions - { - Instructions = "You are a banking assistant. Check balances and transfer funds when asked.", - Tools = - [ - AIFunctionFactory.Create(GetAccountBalance, name: "get_account_balance", - description: "Get the current account balance."), - new ApprovalRequiredAIFunction(AIFunctionFactory.Create(TransferFunds, name: "transfer_funds", - description: "Transfer money to another account.")), - ] - } -}); -``` - -If the model calls both tools in a single turn, the unrestricted tool (`get_account_balance`) executes -and streams its `TOOL_CALL_RESULT`, while the approval-required tool (`transfer_funds`) raises an -approval interrupt and waits for the user's decision before running. - -## Conditional Approval - -Selective approval is *per tool* — a tool either always requires approval or never does. To approve some -calls to the *same* tool automatically based on their **arguments** (for example, auto-approve transfers -under $1,000 but prompt for anything larger), layer the `UseToolApproval` middleware over the agent and -supply one or more auto-approval rules. - -Each rule receives a `ToolAutoApprovalRuleContext` — which exposes the pending `FunctionCallContent` -(tool name and arguments) — and returns `true` to auto-approve the call or `false` to keep evaluating. -Rules run after any standing "always approve" decisions but before the user is prompted; the first rule -that returns `true` approves the call. A call that no rule approves raises the usual approval interrupt. - -```csharp -using System.Text.Json; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; - -AITool transfer = new ApprovalRequiredAIFunction(AIFunctionFactory.Create( - TransferFunds, name: "transfer_funds", description: "Transfer money to another account.")); - -AIAgent baseAgent = chatClient.AsAIAgent(new ChatClientAgentOptions -{ - Name = "BankingAgent", - ChatOptions = new ChatOptions - { - Instructions = "You are a banking assistant. Use transfer_funds to move money.", - Tools = [transfer] - } -}); - -// Auto-approve transfers under $1,000 based on the tool call's arguments; larger -// transfers fall through to a human approval interrupt. -static ValueTask AutoApproveSmallTransfers(ToolAutoApprovalRuleContext context) -{ - if (context.FunctionCallContent.Name == "transfer_funds" && - context.FunctionCallContent.Arguments is { } arguments && - arguments.TryGetValue("amount", out object? value) && value is not null) - { - decimal amount = value is JsonElement json ? json.GetDecimal() : Convert.ToDecimal(value); - return ValueTask.FromResult(amount < 1000m); - } - - return ValueTask.FromResult(false); -} - -// Layer the approval middleware over the agent, then map it as usual. -AIAgent agent = new AIAgentBuilder(baseAgent) - .UseToolApproval(new ToolApprovalAgentOptions { AutoApprovalRules = [AutoApproveSmallTransfers] }) - .Build(); - -app.MapAGUIServer("/", agent); -``` - -Now a `transfer_funds` call for `$500` is approved by the rule and runs to completion (streaming its -`TOOL_CALL_RESULT`) without ever reaching the client, while a `$5,000` call raises an approval interrupt -for the user to confirm — all from a single tool registration. - -> [!WARNING] -> Auto-approval rules can match a tool call by name alone. A rule written for one tool will auto-approve -> **any** registered tool whose name satisfies its condition, silently bypassing the approval prompt. -> Scope each rule tightly — check the tool name *and* the specific arguments — and make sure no unrelated -> tool shares a name your rules approve. +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. ## Best Practices From be55375d6de96c97c8a6060fdf0afe41ab518475 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 24 Jul 2026 21:23:54 -0700 Subject: [PATCH 21/35] Restore original AG-UI getting-started client sample and correct expected output Revert the getting-started client Program.cs to the original interactive REPL (full-history messages, AsChatResponseUpdate, run-started/finished notifications, color output) and restore its Key Concepts list. Verified the sample builds and runs end-to-end against a live AG-UI server (GitHub Models). Also correct the Expected Output to match the sample's actual output: a stateless AG-UI server returns no ConversationId, so the Thread value is blank (only the run ID is populated). Added a note explaining the blank Thread and that assistant wording varies. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eeb2168d-2ecc-4f8d-9830-c287072eb7e1 --- .../integrations/ag-ui/getting-started.md | 121 +++++++++++++----- 1 file changed, 91 insertions(+), 30 deletions(-) diff --git a/agent-framework/integrations/ag-ui/getting-started.md b/agent-framework/integrations/ag-ui/getting-started.md index 411f142a..abcad871 100644 --- a/agent-framework/integrations/ag-ui/getting-started.md +++ b/agent-framework/integrations/ag-ui/getting-started.md @@ -160,43 +160,97 @@ Create a file named `Program.cs`: ```csharp // Copyright (c) Microsoft. All rights reserved. -using AGUI.Client; using Microsoft.Agents.AI; +using AGUI.Client; using Microsoft.Extensions.AI; string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; -Console.WriteLine($"Connecting to AG-UI server at: {serverUrl}"); +Console.WriteLine($"Connecting to AG-UI server at: {serverUrl}\n"); + +// Create the AG-UI client agent +using HttpClient httpClient = new() +{ + Timeout = TimeSpan.FromSeconds(60) +}; -// The AG-UI client agent talks to the server over HTTP. AsAIAgent turns the -// AGUIChatClient into an agent; the AgentSession tracks the conversation history. -using HttpClient httpClient = new(); AGUIChatClient chatClient = new(new AGUIChatClientOptions(httpClient, serverUrl)); AIAgent agent = chatClient.AsAIAgent( - instructions: "You are a helpful assistant.", name: "agui-client", description: "AG-UI Client Agent"); AgentSession session = await agent.CreateSessionAsync(); +List messages = +[ + new(ChatRole.System, "You are a helpful assistant.") +]; -while (true) +try { - Console.Write("\nUser (:q or quit to exit): "); - string? message = Console.ReadLine(); - - if (string.IsNullOrWhiteSpace(message)) continue; - - if (message is ":q" or "quit") break; - - // Pass only the new message; the session supplies the prior turns. - Console.Write("Assistant: "); - await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(message, session)) + while (true) { - Console.Write(update.Text); + // Get user input + Console.Write("\nUser (:q or quit to exit): "); + string? message = Console.ReadLine(); + + if (string.IsNullOrWhiteSpace(message)) + { + Console.WriteLine("Request cannot be empty."); + continue; + } + + if (message is ":q" or "quit") + { + break; + } + + messages.Add(new ChatMessage(ChatRole.User, message)); + + // Stream the response + bool isFirstUpdate = true; + string? threadId = null; + + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, session)) + { + ChatResponseUpdate chatUpdate = update.AsChatResponseUpdate(); + + // 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.ResetColor(); + isFirstUpdate = false; + } + + // Display streaming text content + foreach (AIContent content in update.Contents) + { + if (content is TextContent textContent) + { + Console.ForegroundColor = ConsoleColor.Cyan; + Console.Write(textContent.Text); + Console.ResetColor(); + } + else if (content is ErrorContent errorContent) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"\n[Error: {errorContent.Message}]"); + Console.ResetColor(); + } + } + } + + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine($"\n[Run Finished - Thread: {threadId}]"); + Console.ResetColor(); } - - Console.WriteLine(); +} +catch (Exception ex) +{ + Console.WriteLine($"\nAn error occurred: {ex.Message}"); } ``` @@ -204,9 +258,11 @@ while (true) - **Server-Sent Events (SSE)**: The protocol uses SSE for streaming responses - **AGUIChatClient**: Client class that connects to AG-UI servers and implements `IChatClient` -- **AsAIAgent**: Extension method that creates an `AIAgent` from the client; `instructions` sets the system prompt -- **RunStreamingAsync**: Streams the response as `AgentResponseUpdate` objects; `update.Text` yields the streamed text -- **AgentSession**: Maintains conversation history across turns — pass the session and only the new message each turn +- **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` +- **Session Management**: The `AgentSession` maintains conversation context across requests +- **Content Types**: Responses include `TextContent` for messages and `ErrorContent` for errors ### Configure and Run the Client @@ -232,23 +288,28 @@ 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 - Thread: , Run: run_OXhljyhFd6LNjtYlQGHaYknF] 2 + 2 equals 4. -[Run Finished - Thread: thread_abc123] +[Run Finished - Thread: ] 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 - Thread: , 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 - Thread: ] User (:q or quit to exit): :q ``` +> [!NOTE] +> The `Thread` value is blank because a stateless AG-UI server does not return a +> conversation ID (`ConversationId`); only the run ID (`ResponseId`) is populated. The assistant's +> wording will vary from run to run. + ## Testing with curl (Optional) You can exercise the server directly with curl before running the client. The AG-UI endpoint accepts a From 306b587b5afbf58ad5c5c54887c94bd05cc73454 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 24 Jul 2026 21:49:00 -0700 Subject: [PATCH 22/35] getting-started: drop the always-null Thread from the client sample output AGUIChatClient intentionally leaves ChatResponseUpdate.ConversationId null so a stateless AG-UI server keeps receiving full history (see agent-framework#4869), so the sample's [Run Started - Thread: ...] always printed a blank thread. Remove the Thread from the run notifications (keep the populated Run/ResponseId) rather than reach into the raw RunStartedEvent, and update Key Concepts and the Expected Output to match. Verified against a live AG-UI server. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eeb2168d-2ecc-4f8d-9830-c287072eb7e1 --- .../integrations/ag-ui/getting-started.md | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/agent-framework/integrations/ag-ui/getting-started.md b/agent-framework/integrations/ag-ui/getting-started.md index abcad871..34565f51 100644 --- a/agent-framework/integrations/ag-ui/getting-started.md +++ b/agent-framework/integrations/ag-ui/getting-started.md @@ -209,7 +209,6 @@ try // Stream the response bool isFirstUpdate = true; - string? threadId = null; await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, session)) { @@ -218,9 +217,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; } @@ -244,7 +242,7 @@ try } Console.ForegroundColor = ConsoleColor.Green; - Console.WriteLine($"\n[Run Finished - Thread: {threadId}]"); + Console.WriteLine("\n[Run Finished]"); Console.ResetColor(); } } @@ -260,7 +258,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 @@ -291,24 +289,22 @@ Connecting to AG-UI server at: http://localhost:8888 User (:q or quit to exit): What is 2 + 2? -[Run Started - Thread: , Run: run_OXhljyhFd6LNjtYlQGHaYknF] +[Run Started - Run: run_OXhljyhFd6LNjtYlQGHaYknF] 2 + 2 equals 4. -[Run Finished - Thread: ] +[Run Finished] User (:q or quit to exit): Tell me a fun fact about space -[Run Started - Thread: , Run: run_9fTgYc51ITc5xsGetz1zTKnh] +[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 - Thread: ] +[Run Finished] User (:q or quit to exit): :q ``` > [!NOTE] -> The `Thread` value is blank because a stateless AG-UI server does not return a -> conversation ID (`ConversationId`); only the run ID (`ResponseId`) is populated. The assistant's -> wording will vary from run to run. +> The run ID and the assistant's exact wording vary from run to run. ## Testing with curl (Optional) From 90128d49c5680898c38502243f64704588c19997 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 24 Jul 2026 21:59:00 -0700 Subject: [PATCH 23/35] Fold Predictive State Updates into state-management doc (drop separate page) Match the Python docs, which document predictive state updates inline in the State Management tutorial. Move the C# predictive content into the state-management C# zone as a Predictive State Updates section, delete the standalone predictive-state-updates.md, and remove its TOC entry. The Python and Go zones already pointed back to State Management, so no content is lost. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eeb2168d-2ecc-4f8d-9830-c287072eb7e1 --- .../ag-ui/predictive-state-updates.md | 254 ------------------ 1 file changed, 254 deletions(-) delete mode 100644 agent-framework/integrations/ag-ui/predictive-state-updates.md diff --git a/agent-framework/integrations/ag-ui/predictive-state-updates.md b/agent-framework/integrations/ag-ui/predictive-state-updates.md deleted file mode 100644 index 86c184e8..00000000 --- a/agent-framework/integrations/ag-ui/predictive-state-updates.md +++ /dev/null @@ -1,254 +0,0 @@ ---- -title: Predictive State Updates with AG-UI -description: Stream a tool's arguments as optimistic state snapshots so the UI updates before the tool call completes -zone_pivot_groups: programming-languages -author: moonbox3 -ms.topic: tutorial -ms.author: evmattso -ms.date: 07/10/2026 -ms.service: agent-framework ---- - -# Predictive State Updates with AG-UI - -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 of the result. - -A common example is a document editor: as the model writes the document, the client shows the text appearing in real time, then asks the user to confirm the change once the model is done. - -::: zone pivot="programming-language-csharp" - -## Prerequisites - -Before you begin, ensure you have completed the [Getting Started](getting-started.md) tutorial and read [State Management](state-management.md), since predictive state updates build on the same `AGUIStreamOptions` state-event mechanism. - -You need: - -- .NET 10.0 or later (this scenario maps the endpoint manually and uses the built-in `TypedResults.ServerSentEvents(...)`, which requires .NET 10) -- `Microsoft.Agents.AI.Hosting.AGUI.AspNetCore` and `Microsoft.Agents.AI.OpenAI` packages -- An Azure OpenAI endpoint and deployment - -## How It Works - -Unlike the [shared-state](state-management.md) 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; -``` - -## 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 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"; - -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? 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; - }); -} -``` - -> [!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 - -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 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.ConfigureHttpJsonOptions(options => - 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(); - -JsonSerializerOptions jsonSerializerOptions = app.Services - .GetRequiredService>() - .Value.SerializerOptions; - -app.MapPost("/", ( - [FromBody] RunAgentInput input, - HttpContext httpContext, - CancellationToken cancellationToken) => -{ - AGUIStreamOptions streamOptions = CreatePredictiveStreamOptions(jsonSerializerOptions); - - ChatRequestContext ctx = input.ToChatRequestContext(jsonSerializerOptions, streamOptions); - ctx.Messages.Insert(0, new ChatMessage(ChatRole.System, PredictiveSystemPrompt)); - (ctx.ChatOptions.Tools ??= []).Add(writeDocument); - - var updates = chatClient.GetStreamingResponseAsync(ctx.Messages, ctx.ChatOptions, cancellationToken); - IAsyncEnumerable events = updates.AsAGUIEventStreamAsync(ctx, cancellationToken); - - return TypedResults.ServerSentEvents(events); -}); - -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. - -> [!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. - -### 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. - -## 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). - -## Next Steps - -- **[Manage State](state-management.md)**: Learn about state snapshots, deltas, and shared state. -- **[Human-in-the-Loop](human-in-the-loop.md)**: Add approval workflows for tool calls. -- **[Test with Dojo](testing-with-dojo.md)**: Run this scenario against the AG-UI Dojo app. - -## Additional Resources - -- [AG-UI Overview](index.md) -- [State Management](state-management.md) -- [Agent Framework Documentation](../../overview/index.md) - -::: zone-end - -::: zone pivot="programming-language-python" - -Predictive state updates for Python are covered in the [State Management](state-management.md) tutorial, which shows how to configure predicted state with `predict_state_config` and stream tool arguments as optimistic state updates. - -::: zone-end - -::: zone pivot="programming-language-go" - -Predictive state updates are not currently provided as a dedicated helper in the Go AG-UI integration. See [State Management](state-management.md) for the supported Go state patterns. - -::: zone-end From 148bb1928d529a80047d369895da5a1c0a9676fc Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 24 Jul 2026 21:59:25 -0700 Subject: [PATCH 24/35] Fold Predictive State Updates content into state-management doc + drop TOC entry Follow-up to the predictive-state-updates.md deletion: add the C# predictive content to the state-management C# zone (matching the Python inline approach) and remove the stale TOC entry that pointed at the deleted page. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eeb2168d-2ecc-4f8d-9830-c287072eb7e1 --- agent-framework/TOC.yml | 2 - .../integrations/ag-ui/state-management.md | 208 +++++++++++++++++- 2 files changed, 207 insertions(+), 3 deletions(-) diff --git a/agent-framework/TOC.yml b/agent-framework/TOC.yml index 0fec024e..2c789a6b 100644 --- a/agent-framework/TOC.yml +++ b/agent-framework/TOC.yml @@ -228,8 +228,6 @@ items: href: integrations/ag-ui/mcp-apps.md - name: State Management href: integrations/ag-ui/state-management.md - - name: Predictive State Updates - href: integrations/ag-ui/predictive-state-updates.md - name: Testing with Dojo href: integrations/ag-ui/testing-with-dojo.md - name: Hosting diff --git a/agent-framework/integrations/ag-ui/state-management.md b/agent-framework/integrations/ag-ui/state-management.md index 8c7c61a0..6d2bb686 100644 --- a/agent-framework/integrations/ag-ui/state-management.md +++ b/agent-framework/integrations/ag-ui/state-management.md @@ -339,8 +339,214 @@ app.MapAGUIServer("/agentic_generative_ui", planAgent).WithMetadata(planStreamOp > [!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. -For a related pattern that streams a tool's arguments into state as the model generates them, see [Predictive State Updates](predictive-state-updates.md). +For a related pattern that streams a tool's arguments into state as the model generates them, continue with Predictive State Updates below. +## Predictive State Updates + +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 that shows the text appearing in real time, then asks the user to confirm the change once the model is done. + +> [!NOTE] +> This scenario maps the endpoint manually and uses the built-in `TypedResults.ServerSentEvents(...)`, which requires **.NET 10.0 or later**. + +### How It Works + +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; +``` + +### 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 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"; + +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? 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; + }); +} +``` + +> [!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 + +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 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.ConfigureHttpJsonOptions(options => + 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(); + +JsonSerializerOptions jsonSerializerOptions = app.Services + .GetRequiredService>() + .Value.SerializerOptions; + +app.MapPost("/", ( + [FromBody] RunAgentInput input, + HttpContext httpContext, + CancellationToken cancellationToken) => +{ + AGUIStreamOptions streamOptions = CreatePredictiveStreamOptions(jsonSerializerOptions); + + ChatRequestContext ctx = input.ToChatRequestContext(jsonSerializerOptions, streamOptions); + ctx.Messages.Insert(0, new ChatMessage(ChatRole.System, PredictiveSystemPrompt)); + (ctx.ChatOptions.Tools ??= []).Add(writeDocument); + + var updates = chatClient.GetStreamingResponseAsync(ctx.Messages, ctx.ChatOptions, cancellationToken); + IAsyncEnumerable events = updates.AsAGUIEventStreamAsync(ctx, cancellationToken); + + return TypedResults.ServerSentEvents(events); +}); + +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. + +> [!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. + +### 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. + +### 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 From e6e346da54cc8548e1357a07097f05b5f6e768ce Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 24 Jul 2026 22:02:43 -0700 Subject: [PATCH 25/35] Remove C# MCP Apps content from this PR (revert to coming-soon stub) The MCP Apps C# content mostly documented CopilotKit's TypeScript MCPAppsMiddleware, which .NET doesn't ship, so it doesn't belong in this PR. Revert the C# zone to the pre-existing "coming soon" note (its state in main). Python and Go zones are unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eeb2168d-2ecc-4f8d-9830-c287072eb7e1 --- .../integrations/ag-ui/mcp-apps.md | 96 +------------------ 1 file changed, 2 insertions(+), 94 deletions(-) diff --git a/agent-framework/integrations/ag-ui/mcp-apps.md b/agent-framework/integrations/ag-ui/mcp-apps.md index 1991da5b..2f716912 100644 --- a/agent-framework/integrations/ag-ui/mcp-apps.md +++ b/agent-framework/integrations/ag-ui/mcp-apps.md @@ -13,100 +13,8 @@ ms.service: agent-framework ::: zone pivot="programming-language-csharp" -Agent Framework .NET AG-UI endpoints are compatible with the AG-UI ecosystem's [MCP Apps](https://docs.ag-ui.com/agentic-protocols) feature. MCP Apps allows frontend applications to embed MCP-powered tools and resources alongside your AG-UI agent — no changes needed on the .NET side. - -## Architecture - -MCP Apps support is provided by CopilotKit's TypeScript `MCPAppsMiddleware` (`@ag-ui/mcp-apps-middleware`), which sits between the frontend and your Agent Framework backend: - -``` -┌─────────────────────────┐ -│ Frontend │ -│ (CopilotKit / AG-UI) │ -└────────┬────────────────┘ - │ - ▼ -┌─────────────────────────┐ -│ CopilotKit Runtime / │ -│ Node.js Proxy │ -│ + MCPAppsMiddleware │ -└────────┬────────────────┘ - │ AG-UI protocol - ▼ -┌─────────────────────────┐ -│ Agent Framework │ -│ ASP.NET Core AG-UI │ -│ Endpoint │ -└─────────────────────────┘ -``` - -The middleware layer handles MCP tool discovery, iframe-proxied resource requests, and `ui/resourceUri` resolution. Your .NET AG-UI endpoint receives standard AG-UI requests and is unaware of the MCP Apps layer. - -## No .NET-Side Changes Required - -MCP Apps integration is entirely handled by the TypeScript middleware. Your existing `MapAGUIServer()` setup works as-is: - -```csharp -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; - -WebApplicationBuilder builder = WebApplication.CreateBuilder(args); -builder.Services.AddAGUIServer(); - -WebApplication app = builder.Build(); -AIAgent agent = CreateAgent(); - -// This endpoint is MCP Apps-compatible with no additional configuration -app.MapAGUIServer("/", agent); - -await app.RunAsync(); -``` - -This approach is consistent with how MCP Apps works with all other AG-UI integrations — the MCP Apps layer is always in the TypeScript middleware, not in the .NET backend. - -## Setting Up the Middleware - -To use MCP Apps with your Agent Framework backend, set up a CopilotKit Runtime or Node.js proxy that includes `MCPAppsMiddleware` and points at your .NET endpoint: - -```typescript -// Example Node.js proxy configuration (TypeScript) -import { MCPAppsMiddleware } from "@ag-ui/mcp-apps-middleware"; - -const middleware = new MCPAppsMiddleware({ - agents: [ - { - name: "my-agent", - url: "http://localhost:8888/", // Your MAF AG-UI endpoint - }, - ], - mcpApps: [ - // MCP app configurations - ], -}); -``` - -For full setup instructions, see the [CopilotKit MCP Apps documentation](https://docs.copilotkit.ai/built-in-agent/generative-ui/mcp-apps) and the [AG-UI agentic protocols documentation](https://docs.ag-ui.com/agentic-protocols). - -## What Is Not in Scope - -The following are explicitly **not** part of the .NET AG-UI integration: - -- **No .NET `MCPAppsMiddleware`**: MCP Apps middleware runs in the TypeScript layer only. -- **No ASP.NET Core handling of iframe-proxied MCP requests**: Resource proxying is handled by the Node.js middleware. -- **No .NET-side `ui/resourceUri` discovery**: Resource URI resolution is a middleware concern. - -If your application doesn't need the MCP Apps middleware layer, your Agent Framework AG-UI endpoint works directly with any AG-UI-compatible client. - -## Next steps - -> [!div class="nextstepaction"] -> [State Management](./state-management.md) - -## Additional Resources - -- [AG-UI Agentic Protocols Documentation](https://docs.ag-ui.com/agentic-protocols) -- [CopilotKit MCP Apps Documentation](https://docs.copilotkit.ai/built-in-agent/generative-ui/mcp-apps) -- [Agent Framework GitHub Repository](https://github.com/microsoft/agent-framework) +> [!NOTE] +> MCP Apps compatibility documentation for the .NET AG-UI integration is coming soon. ::: zone-end From 0f7f3ee52817be69e5f174db36c1445ff1047560 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 24 Jul 2026 23:16:06 -0700 Subject: [PATCH 26/35] Drop testing-with-dojo.md changes from this PR Revert the file entirely to main. The C# Dojo content is removed (the .NET Dojo experience isn't well-supported yet: no prebuilt .NET example agents, and the example-agents table listed Python endpoints), returning the C# zone to its "coming soon" stub. Also drop the unrelated Python-zone clone-URL fix (ag-oss -> ag-ui-protocol) as out of scope for this PR. Net: this PR no longer touches testing-with-dojo.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eeb2168d-2ecc-4f8d-9830-c287072eb7e1 --- .../integrations/ag-ui/testing-with-dojo.md | 195 +----------------- 1 file changed, 2 insertions(+), 193 deletions(-) diff --git a/agent-framework/integrations/ag-ui/testing-with-dojo.md b/agent-framework/integrations/ag-ui/testing-with-dojo.md index 2b858091..20ed22f2 100644 --- a/agent-framework/integrations/ag-ui/testing-with-dojo.md +++ b/agent-framework/integrations/ag-ui/testing-with-dojo.md @@ -31,7 +31,7 @@ Before you begin, ensure you have: First, clone the AG-UI repository which contains the Dojo application and Microsoft Agent Framework integration examples: ```bash -git clone https://github.com/ag-ui-protocol/ag-ui.git +git clone https://github.com/ag-oss/ag-ui.git cd ag-ui ``` @@ -262,197 +262,6 @@ if err := http.ListenAndServe(":8888", mux); err != nil { :::zone-end ::: zone pivot="programming-language-csharp" -## Prerequisites - -Before you begin, ensure you have: - -- .NET 8.0 or later -- [Azure OpenAI service endpoint and deployment configured](/azure/ai-foundry/openai/how-to/create-resource) -- [Azure CLI installed](/cli/azure/install-azure-cli) and [authenticated](/cli/azure/authenticate-azure-cli) -- Node.js and pnpm (for running the Dojo frontend) -- A .NET AG-UI server built by following the [Getting Started](getting-started.md) tutorial - -## Installation - -### 1. Clone the AG-UI Repository - -First, clone the AG-UI repository which contains the Dojo application: - -```bash -git clone https://github.com/ag-ui-protocol/ag-ui.git -cd ag-ui -``` - -### 2. Navigate to the Dojo Application - -```bash -cd apps/dojo -``` - -### 3. Install Dojo Frontend Dependencies - -Use pnpm to install the required frontend dependencies: - -```bash -pnpm install -``` - -### 4. Configure Environment Variables - -In your .NET AG-UI server project, set the Azure OpenAI environment variables used by the Getting Started tutorial: - -```bash -export AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" -export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" -``` - -> [!NOTE] -> If using `DefaultAzureCredential` for authentication, make sure you're authenticated with Azure (e.g., via `az login`). For more information, see the [Azure Identity documentation](/dotnet/api/overview/azure/identity-readme). - -## Running the Dojo Application - -### 1. Start the Backend Server - -In your .NET AG-UI server project, start the backend server: - -```bash -dotnet run --urls http://localhost:8888 -``` - -The server will start on `http://localhost:8888`. - -### 2. Start the Dojo Frontend - -Open a new terminal window, navigate to the root of the AG-UI repository, and then to the Dojo application directory: - -```bash -cd apps/dojo -pnpm install -pnpm dev -``` - -The Dojo frontend will be available at `http://localhost:3000`. - -### 3. Connect to Your Agent - -1. Open `http://localhost:3000` in your browser -2. Configure the server URL to `http://localhost:8888` -3. Select the Microsoft Agent Framework entry or the endpoint for your agent -4. Start exploring your .NET AG-UI agent - -## Available Example Agents - -Dojo can exercise any AG-UI-compatible endpoint. The AG-UI examples demonstrate all 7 AG-UI features through different agent endpoints: - -| Endpoint | Feature | Description | -|----------|---------|-------------| -| `/agentic_chat` | Feature 1: Agentic Chat | Basic conversational agent with tool calling | -| `/backend_tool_rendering` | Feature 2: Backend Tool Rendering | Agent with custom tool UI rendering | -| `/human_in_the_loop` | Feature 3: Human in the Loop | Agent with approval workflows | -| `/agentic_generative_ui` | Feature 4: Agentic Generative UI | Agent that breaks down tasks into steps with streaming updates | -| `/tool_based_generative_ui` | Feature 5: Tool-based Generative UI | Agent that generates custom UI components | -| `/shared_state` | Feature 6: Shared State | Agent with bidirectional state synchronization | -| `/predictive_state_updates` | Feature 7: Predictive State Updates | Agent with predictive state updates during tool execution | - -To test these patterns in .NET, expose corresponding `AIAgent` instances from your ASP.NET Core server with `MapAGUIServer`. - -## Testing Your Own Agents - -To test your own agents with Dojo: - -### 1. Create Your Agent - -Create a new agent following the [Getting Started](getting-started.md) guide: - -```csharp -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using OpenAI.Chat; - -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."); - -AIAgent agent = new AzureOpenAIClient( - new Uri(endpoint), - new DefaultAzureCredential()) - .GetChatClient(deploymentName) - .AsAIAgent( - name: "my_test_agent", - instructions: "You are a helpful assistant."); -``` - -> [!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. - -### 2. Add the Agent to Your Server - -In your ASP.NET Core application, register the agent endpoint: - -```csharp -using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; - -WebApplicationBuilder builder = WebApplication.CreateBuilder(args); -builder.Services.AddAGUIServer(); - -WebApplication app = builder.Build(); - -// Create your agent, then register it with AG-UI -app.MapAGUIServer("/my_agent", agent); - -await app.RunAsync(); -``` - -### 3. Test in Dojo - -1. Start your server -2. Open Dojo at `http://localhost:3000` -3. Set the server URL to `http://localhost:8888` -4. Your agent will appear in the endpoint dropdown as `my_agent` -5. Select it and start testing - -## Troubleshooting - -### Server Connection Issues - -If Dojo can't connect to your server: - -- Verify the server is running on the correct port (default: 8888) -- Check that the server URL in Dojo matches your server address -- Ensure no firewall is blocking the connection -- Look for CORS errors in the browser console - -### Agent Not Appearing - -If your agent doesn't appear in the Dojo dropdown: - -- Verify the agent endpoint is registered correctly with `app.MapAGUIServer("/my_agent", agent)` -- Check server logs for any startup errors -- Ensure `builder.Services.AddAGUIServer()` is called before building the app - -### Environment Variable Issues - -If you see authentication errors: - -- Verify the required environment variables are set in the .NET server process -- Check that the Azure OpenAI endpoint and deployment name are valid -- Run `az login` when using `DefaultAzureCredential` -- Restart the server after changing environment variables - -## Next Steps - -- Explore the [example agents](https://github.com/ag-ui-protocol/ag-ui/tree/main/integrations/microsoft-agent-framework/python/examples/agents) to see implementation patterns -- Learn about [Backend Tool Rendering](backend-tool-rendering.md) to customize tool UIs - - - -## Additional Resources - -- [AG-UI Documentation](https://docs.ag-ui.com/introduction) -- [AG-UI GitHub Repository](https://github.com/ag-ui-protocol/ag-ui) -- [Dojo Application](https://dojo.ag-ui.com/) -- [Microsoft Agent Framework Integration Examples](https://github.com/ag-ui-protocol/ag-ui/tree/main/integrations/microsoft-agent-framework) +Coming soon. ::: zone-end From f9a98a61819559df9cff3e336d779db1044e16ca Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 24 Jul 2026 23:46:51 -0700 Subject: [PATCH 27/35] workflows: trim C# zone to an honest, focused section The C# workflow-over-AG-UI story is "a workflow-as-agent is just an AIAgent, map it with MapAGUIServer like any agent" plus the honest limitation that .NET streams only agent output, not workflow-specific AG-UI events (step tracking, interrupts) - those remain Python-only (tracked by agent-framework#2494). Replace the longer version with a compact section: verified minimal example + AuthorName note + limitation note. Also revert the out-of-scope Python (JSON re-indent) and Go (whitespace) edits so the PR only touches the C# zone. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eeb2168d-2ecc-4f8d-9830-c287072eb7e1 --- .../integrations/ag-ui/workflows.md | 94 ++++++++----------- 1 file changed, 38 insertions(+), 56 deletions(-) diff --git a/agent-framework/integrations/ag-ui/workflows.md b/agent-framework/integrations/ag-ui/workflows.md index 67eab2b0..298e32df 100644 --- a/agent-framework/integrations/ag-ui/workflows.md +++ b/agent-framework/integrations/ag-ui/workflows.md @@ -13,23 +13,9 @@ ms.service: agent-framework ::: zone pivot="programming-language-csharp" -## When to Use Workflows with AG-UI - -[Workflows](../../workflows/index.md) orchestrate multiple agents and tools in a defined -execution graph. In .NET you can expose a workflow through an AG-UI endpoint today by converting it to -an `AIAgent` and mapping it like any other agent. The constituent agents' responses stream to the web -client as AG-UI text and tool-call events. - -> [!NOTE] -> The .NET AG-UI integration currently streams a workflow's **agent output** (text and tool calls) as -> AG-UI events. The richer workflow-specific AG-UI events described in the Python guide — step tracking, -> activity snapshots, and workflow-level interrupts — are still evolving for .NET. For those scenarios, -> see the Python pivot of this article. - -## Exposing a Workflow over AG-UI - -Build a workflow from your agents, convert it to an `AIAgent` with `AsAIAgent()`, and map it with -`MapAGUIServer`: +[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; @@ -42,8 +28,6 @@ using OpenAI.Chat; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); builder.Services.AddAGUIServer(); -WebApplication app = builder.Build(); - string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"] ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] @@ -52,19 +36,15 @@ string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] ChatClient chatClient = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) .GetChatClient(deploymentName); -// Define the agents that make up the workflow. AIAgent researcher = chatClient.AsAIAgent( - name: "researcher", - instructions: "Research the user's topic and write a short, factual brief."); - + 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."); + name: "reporter", instructions: "Summarize the researcher's brief into a single clear paragraph."); -// Build a sequential workflow (researcher -> reporter) and expose it as an agent. +// A workflow-as-agent is just an AIAgent — map it like any other agent. AIAgent workflowAgent = AgentWorkflowBuilder.BuildSequential(researcher, reporter).AsAIAgent(); -// Map the workflow agent to an AG-UI endpoint. +WebApplication app = builder.Build(); app.MapAGUIServer("/", workflowAgent); await app.RunAsync(); @@ -73,9 +53,15 @@ 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. -On the client side there is nothing workflow-specific to do: connect with `AGUIChatClient` exactly as in -the [Getting Started](getting-started.md) tutorial. Each agent's output streams as normal AG-UI text -events, and the `AuthorName` on each update identifies which agent in the workflow produced it. +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] +> The .NET integration streams a workflow's **agent output** (text and tool calls) over AG-UI, but not the +> workflow-specific AG-UI events — step tracking (`STEP_STARTED` / `STEP_FINISHED`), activity snapshots, +> and workflow-level interrupts — shown in the Python version of this article. Those events are still +> evolving for .NET (tracked by [agent-framework#2494](https://github.com/microsoft/agent-framework/issues/2494)). ## Next steps @@ -83,10 +69,6 @@ events, and the `AuthorName` on each update identifies which agent in the workfl - [Human-in-the-Loop](human-in-the-loop.md) - [Workflows user guide](../../workflows/index.md) -## Additional Resources - -- [AG-UI Protocol Documentation](https://docs.ag-ui.com) - ::: zone-end ::: zone pivot="programming-language-python" @@ -209,29 +191,29 @@ Workflows can pause execution to collect human input or tool approvals. The AG-U 2. The AG-UI bridge emits a `CUSTOM` event with `name="request_info"` containing the request data. 3. The run finishes with a `RUN_FINISHED` event whose `outcome.interrupts` field contains the pending requests: -```json -{ - "type": "RUN_FINISHED", - "threadId": "abc123", - "runId": "run_xyz", - "outcome": { - "type": "interrupt", - "interrupts": [ - { - "id": "request-id-1", - "reason": "input_required", - "message": "Provide the requested information.", - "responseSchema": { "type": "string" }, - "metadata": { - "agent_framework": { - "request_type": "HandoffAgentUserRequest" + ```json + { + "type": "RUN_FINISHED", + "threadId": "abc123", + "runId": "run_xyz", + "outcome": { + "type": "interrupt", + "interrupts": [ + { + "id": "request-id-1", + "reason": "input_required", + "message": "Provide the requested information.", + "responseSchema": { "type": "string" }, + "metadata": { + "agent_framework": { + "request_type": "HandoffAgentUserRequest" + } + } } - } + ] } - ] - } -} -``` + } + ``` 4. The client renders UI for the user to respond (a text input, an approval button, etc.). @@ -453,4 +435,4 @@ mux.Handle("/", aguiprovider.NewJSONHTTPHandler(workflowAgent, aguiprovider.Hand > [!TIP] > See the [workflow as an agent sample](https://github.com/microsoft/agent-framework-go/blob/main/examples/03-workflows/agents/workflow_as_an_agent/main.go) and the [AG-UI server sample](https://github.com/microsoft/agent-framework-go/blob/main/examples/02-agents/agui/step01_getting_started/server/main.go) for complete runnable examples. -::: zone-end +::: zone-end \ No newline at end of file From 77d40582be16baaf59b3371fb7104682445084f1 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 24 Jul 2026 23:58:48 -0700 Subject: [PATCH 28/35] Address PR review comments: trim non-AG-UI content and noise - getting-started: drop the removed-package history note, the run-ID/wording note, the redundant JSON-serialization sentence, the obvious "clients connect to route" line, and the generic Troubleshooting section; revert an incidental trailing-newline change. - human-in-the-loop: simplify the server intro, drop the "no custom protocol" line, remove the MEAI001 #pragma suppressions from snippets, and delete the generic Best Practices section. - backend-tool-rendering: drop the non-AG-UI "group related tools" bullet. - frontend-tools: remove the generic Best Practices section. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eeb2168d-2ecc-4f8d-9830-c287072eb7e1 --- .../ag-ui/backend-tool-rendering.md | 1 - .../integrations/ag-ui/frontend-tools.md | 80 ------------------- .../integrations/ag-ui/getting-started.md | 53 +----------- .../integrations/ag-ui/human-in-the-loop.md | 52 +----------- 4 files changed, 4 insertions(+), 182 deletions(-) diff --git a/agent-framework/integrations/ag-ui/backend-tool-rendering.md b/agent-framework/integrations/ag-ui/backend-tool-rendering.md index e89083d1..c73b212a 100644 --- a/agent-framework/integrations/ag-ui/backend-tool-rendering.md +++ b/agent-framework/integrations/ag-ui/backend-tool-rendering.md @@ -275,7 +275,6 @@ great restaurants in the area: The Golden Fork (Italian, 4.5 stars)... - **Return failures as data.** Have a tool return a structured result with an error field instead of throwing, so the model can explain the problem to the user rather than failing the run. - **Use rich return types.** Return structured objects; they are serialized with the JSON options you pass to `AIFunctionFactory.Create()`. When using source-generated serialization, register the types on a `JsonSerializerContext`. - **Write clear descriptions.** Put `[Description]` on the tool method and every parameter so the model knows when to call the tool and how to supply arguments. -- **Group related tools.** Put related methods on a static class and register each one with `AIFunctionFactory.Create()`. ## Next Steps diff --git a/agent-framework/integrations/ag-ui/frontend-tools.md b/agent-framework/integrations/ag-ui/frontend-tools.md index c31a22b9..ffd11c69 100644 --- a/agent-framework/integrations/ag-ui/frontend-tools.md +++ b/agent-framework/integrations/ag-ui/frontend-tools.md @@ -131,86 +131,6 @@ The server doesn't need special configuration to support frontend tools. Use the - Waits for results from the client - Incorporates results into the agent's decision-making -## Best Practices - -### Security - -Frontend tools can access local resources, so validate and limit what they can do. Treat all model-generated tool-call arguments as untrusted input: - -```csharp -[Description("Read an allowed user preference.")] -static string ReadUserPreference( - [Description("The preference name to read.")] string preferenceName) -{ - string[] allowedPreferences = ["theme", "locale", "timezone"]; - - if (!allowedPreferences.Contains(preferenceName, StringComparer.OrdinalIgnoreCase)) - { - return $"Error: Preference '{preferenceName}' is not allowed."; - } - - return preferenceName.ToLowerInvariant() switch - { - "theme" => "dark", - "locale" => "en-US", - "timezone" => "Pacific Standard Time", - _ => "Error: Preference not found." - }; -} - -AITool preferenceTool = AIFunctionFactory.Create( - ReadUserPreference, - name: "read_user_preference", - description: "Read a permitted user preference from the client."); -``` - -### Error Handling - -Return user-friendly errors from the tool delegate rather than throwing exceptions: - -```csharp -[Description("Show a local notification to the user.")] -static string ShowNotification( - [Description("The notification title.")] string title, - [Description("The notification message.")] string message) -{ - try - { - if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(message)) - { - return "Error: Title and message are required."; - } - - // Display the notification in the client application. - return $"Notification shown: {title}"; - } - catch (Exception ex) - { - return $"Error showing notification: {ex.Message}"; - } -} -``` - -### Keep Tools Focused - -Prefer small, purpose-built tools with clear names and descriptions over broad tools that can perform many unrelated actions: - -```csharp -AITool[] frontendTools = -[ - AIFunctionFactory.Create( - GetUserLocation, - name: "get_user_location", - description: "Get the user's current city and coordinates."), - AIFunctionFactory.Create( - GetPreferredUnits, - name: "get_preferred_units", - description: "Get the user's preferred measurement units.") -]; -``` - -Focused tools are easier for the model to choose correctly and easier for your client code to validate. - ## Troubleshooting ### Tools Not Being Called diff --git a/agent-framework/integrations/ag-ui/getting-started.md b/agent-framework/integrations/ag-ui/getting-started.md index 34565f51..e3646d4f 100644 --- a/agent-framework/integrations/ag-ui/getting-started.md +++ b/agent-framework/integrations/ag-ui/getting-started.md @@ -150,8 +150,7 @@ dotnet add package Microsoft.Agents.AI --prerelease > [!NOTE] > The `Microsoft.Agents.AI` package provides the `AsAIAgent()` extension method. The AG-UI client -> type (`AGUIChatClient`) now ships in the AG-UI C# SDK package `AGUI.Client` (previously it was part -> of the removed `Microsoft.Agents.AI.AGUI` package). +> type (`AGUIChatClient`) ships in the AG-UI C# SDK package `AGUI.Client`. ### Client Code @@ -303,9 +302,6 @@ universe than grains of sand on all the beaches on Earth. User (:q or quit to exit): :q ``` -> [!NOTE] -> The run ID and the assistant's exact wording vary from run to run. - ## Testing with curl (Optional) You can exercise the server directly with curl before running the client. The AG-UI endpoint accepts a @@ -402,8 +398,6 @@ app.MapAGUIServer("/agent", agent); await app.RunAsync(); ``` -The AG-UI endpoint handles HTTP POST requests and streams AG-UI events as SSE. Configure JSON serialization once with ASP.NET Core's HTTP JSON options so the AG-UI endpoint and your tools use consistent settings. - ### Multiple Agents Map each agent to a different route: @@ -426,49 +420,6 @@ app.MapAGUIServer("/finance", financeAgent); await app.RunAsync(); ``` -Clients connect to the route for the agent they want to use, such as `http://localhost:8888/weather` or `http://localhost:8888/finance`. - -## Troubleshooting - -### Connection Refused - -Ensure the server is running before starting the client: - -```bash -# Terminal 1 -dotnet run --urls http://localhost:8888 - -# Terminal 2 (after server starts) -dotnet run -``` - -Verify that `AGUI_SERVER_URL` matches the server address and route your app exposes. - -### Authentication Errors - -Make sure you're authenticated with Azure: - -```bash -az login -``` - -Verify that your Azure OpenAI endpoint and deployment name are set correctly and that your account has the required role assignment on the Azure OpenAI resource. - -### Streaming Not Working - -Check that your client timeout is sufficient: - -```csharp -using HttpClient httpClient = new() -{ - Timeout = TimeSpan.FromSeconds(60) -}; - -AGUIChatClient chatClient = new(new AGUIChatClientOptions(httpClient, serverUrl)); -``` - -For long-running agents, increase the timeout accordingly. Also confirm the client uses `RunStreamingAsync`, the server endpoint is mapped with `MapAGUIServer`, and any proxy between the client and server allows Server-Sent Events. - ## Next Steps Now that you understand the basics of AG-UI, you can: @@ -962,4 +913,4 @@ a := aguiprovider.NewAgent( > [!TIP] > See the [AG-UI getting started server](https://github.com/microsoft/agent-framework-go/blob/main/examples/02-agents/agui/step01_getting_started/server/main.go) and [client](https://github.com/microsoft/agent-framework-go/blob/main/examples/02-agents/agui/step01_getting_started/client/main.go) samples for complete runnable examples. -::: zone-end +::: zone-end \ No newline at end of file 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 1fa3541a..e2da32bf 100644 --- a/agent-framework/integrations/ag-ui/human-in-the-loop.md +++ b/agent-framework/integrations/ag-ui/human-in-the-loop.md @@ -24,8 +24,6 @@ The C# AG-UI approval pattern works as follows: 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. -No custom approval-protocol code is required on either side — the AG-UI client and server handle the round-trip natively. - ## Prerequisites - Azure OpenAI resource with a deployed model @@ -36,10 +34,7 @@ No custom approval-protocol code is required on either side — the AG-UI client ## Server Implementation -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 — you don't write any approval protocol -code on the server. +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; @@ -68,9 +63,7 @@ static string SendEmail( [Description("The email body.")] string body) => $"Email sent to {to} with subject '{subject}'."; -#pragma warning disable MEAI001 // ApprovalRequiredAIFunction is an evaluation-only API. AITool sendEmail = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(SendEmail)); -#pragma warning restore MEAI001 AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) .GetChatClient(deploymentName) @@ -114,7 +107,6 @@ AgentSession session = await agent.CreateSessionAsync(); List messages = [new(ChatRole.User, "Email alice@example.com to say the report is ready.")]; -#pragma warning disable MEAI001 // Tool-approval content is an evaluation-only API. // First turn: run until the agent requests approval. ToolApprovalRequestContent? approvalRequest = null; string? threadId = null; @@ -175,11 +167,9 @@ if (approvalRequest is not null) } } } -#pragma warning restore MEAI001 ``` -To reject instead, call `approvalRequest.CreateResponse(approved: false)`; the agent continues without -running the tool. +To reject instead, call `approvalRequest.CreateResponse(approved: false)`; the agent continues without running the tool. > [!TIP] > A UI framework can handle this round-trip for you. The Blazor AI components render an approval prompt @@ -205,44 +195,6 @@ AG-UI client approves and resumes — the [Server](#server-implementation) and [ 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. -## Best Practices - -### Clear Tool Descriptions - -Provide detailed descriptions so users understand what they are approving: - -```csharp -using System.ComponentModel; - -[Description("Permanently delete a database and all its contents. This action cannot be undone.")] -static string DeleteDatabase( - [Description("Name of the database to permanently delete.")] string databaseName) -{ - // Implementation - return $"Database '{databaseName}' deleted."; -} - -AITool deleteTool = new ApprovalRequiredAIFunction( - AIFunctionFactory.Create(DeleteDatabase, name: "delete_database")); -``` - -### Informative Arguments - -Use descriptive parameter names and `[Description]` attributes. The approval request surfaces the tool -name and its arguments to the client, so meaningful names and descriptions help users make an informed -decision: - -```csharp -[Description("Purchase items from the store.")] -static string PurchaseItem( - [Description("Name of the item to purchase.")] string itemName, - [Description("Number of items to purchase.")] int quantity, - [Description("Total cost in USD, including tax and shipping.")] double totalCost) -{ - return $"Purchased {quantity} x {itemName} for {totalCost:C}."; -} -``` - ## Next steps > [!div class="nextstepaction"] From a4f80de91a4d8d23d2841cc368181e5715b00065 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Sat, 25 Jul 2026 00:04:15 -0700 Subject: [PATCH 29/35] backend-tool-rendering: remove generic Tool Implementation Best Practices section Per follow-up review comment - the tool-authoring best practices aren't AG-UI-specific. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eeb2168d-2ecc-4f8d-9830-c287072eb7e1 --- .../integrations/ag-ui/backend-tool-rendering.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/agent-framework/integrations/ag-ui/backend-tool-rendering.md b/agent-framework/integrations/ag-ui/backend-tool-rendering.md index c73b212a..a6615c4c 100644 --- a/agent-framework/integrations/ag-ui/backend-tool-rendering.md +++ b/agent-framework/integrations/ag-ui/backend-tool-rendering.md @@ -270,12 +270,6 @@ great restaurants in the area: The Golden Fork (Italian, 4.5 stars)... - **`FunctionCallContent`**: Represents a tool being called with its `Name` and `Arguments` (parameter key-value pairs) - **`FunctionResultContent`**: Contains the tool's `Result` or `Exception`, identified by `CallId` -## Tool Implementation Best Practices - -- **Return failures as data.** Have a tool return a structured result with an error field instead of throwing, so the model can explain the problem to the user rather than failing the run. -- **Use rich return types.** Return structured objects; they are serialized with the JSON options you pass to `AIFunctionFactory.Create()`. When using source-generated serialization, register the types on a `JsonSerializerContext`. -- **Write clear descriptions.** Put `[Description]` on the tool method and every parameter so the model knows when to call the tool and how to supply arguments. - ## Next Steps Now that you can add function tools, you can: From 87a191281d656f7cf0438af33f9a25d3d96ceebe Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Sat, 25 Jul 2026 00:05:58 -0700 Subject: [PATCH 30/35] frontend-tools: remove generic Troubleshooting section (C# zone) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eeb2168d-2ecc-4f8d-9830-c287072eb7e1 --- .../integrations/ag-ui/frontend-tools.md | 52 ------------------- 1 file changed, 52 deletions(-) diff --git a/agent-framework/integrations/ag-ui/frontend-tools.md b/agent-framework/integrations/ag-ui/frontend-tools.md index ffd11c69..1ab4425f 100644 --- a/agent-framework/integrations/ag-ui/frontend-tools.md +++ b/agent-framework/integrations/ag-ui/frontend-tools.md @@ -131,58 +131,6 @@ The server doesn't need special configuration to support frontend tools. Use the - Waits for results from the client - Incorporates results into the agent's decision-making -## Troubleshooting - -### Tools Not Being Called - -If frontend tools aren't being called: - -1. Ensure the tool has a clear name and description -2. Verify the tool is passed to `AsAIAgent(tools: ...)` -3. Check server logs for tool declaration or tool-call errors - -```csharp -AITool locationTool = AIFunctionFactory.Create( - GetUserLocation, - name: "get_user_location", - description: "Get the user's current location from the client."); - -AIAgent agent = chatClient.AsAIAgent( - name: "agui-client", - description: "AG-UI Client Agent", - tools: [locationTool]); -``` - -### Execution Errors - -If a frontend tool fails during execution: - -1. Validate arguments before processing them -2. Catch exceptions inside the tool delegate -3. Return user-friendly error messages as the tool result -4. Log details on the client for debugging - -```csharp -[Description("Set the client's theme.")] -static string SetTheme([Description("Theme name: light or dark.")] string theme) -{ - if (theme is not ("light" or "dark")) - { - return $"Error: Unsupported theme '{theme}'. Use 'light' or 'dark'."; - } - - try - { - // Apply the theme in the client application. - return $"Theme changed to {theme}."; - } - catch (Exception ex) - { - return $"Error changing theme: {ex.Message}"; - } -} -``` - ## Next Steps Now that you understand frontend tools, you can: From 3050cbb1ee2d4fb9f8e10b10fd8ff40f1d115dda Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Sat, 25 Jul 2026 00:07:09 -0700 Subject: [PATCH 31/35] getting-started: remove generic Common Patterns section (C# zone) Custom server configuration and multiple-agent mapping aren't AG-UI-specific (and follow directly from the basic MapAGUIServer usage already shown). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eeb2168d-2ecc-4f8d-9830-c287072eb7e1 --- .../integrations/ag-ui/getting-started.md | 50 ------------------- 1 file changed, 50 deletions(-) diff --git a/agent-framework/integrations/ag-ui/getting-started.md b/agent-framework/integrations/ag-ui/getting-started.md index e3646d4f..f412203d 100644 --- a/agent-framework/integrations/ag-ui/getting-started.md +++ b/agent-framework/integrations/ag-ui/getting-started.md @@ -370,56 +370,6 @@ The AG-UI protocol uses: - Thread IDs (as `ConversationId`) for maintaining conversation context - Run IDs (as `ResponseId`) for tracking individual executions -## Common Patterns - -### Custom Server Configuration - -Customize the ASP.NET Core server port, AG-UI endpoint route, and JSON options before mapping the agent: - -```csharp -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; - -WebApplicationBuilder builder = WebApplication.CreateBuilder(args); - -// Listen on a custom port -builder.WebHost.UseUrls("http://localhost:8888"); - -// Customize JSON serialization once so the AG-UI endpoint and your tools stay consistent. -builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.WriteIndented = false); -builder.Services.AddAGUIServer(); - -WebApplication app = builder.Build(); -AIAgent agent = CreateAgent(); - -// Expose the agent on a custom route -app.MapAGUIServer("/agent", agent); - -await app.RunAsync(); -``` - -### Multiple Agents - -Map each agent to a different route: - -```csharp -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; - -WebApplicationBuilder builder = WebApplication.CreateBuilder(args); -builder.Services.AddAGUIServer(); - -WebApplication app = builder.Build(); - -AIAgent weatherAgent = CreateWeatherAgent(); -AIAgent financeAgent = CreateFinanceAgent(); - -app.MapAGUIServer("/weather", weatherAgent); -app.MapAGUIServer("/finance", financeAgent); - -await app.RunAsync(); -``` - ## Next Steps Now that you understand the basics of AG-UI, you can: From 11010019c2c574b6ff81fbf9cdbd0cc30775758e Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Sat, 25 Jul 2026 00:09:57 -0700 Subject: [PATCH 32/35] human-in-the-loop: remove the Blazor AI components TIP Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eeb2168d-2ecc-4f8d-9830-c287072eb7e1 --- agent-framework/integrations/ag-ui/human-in-the-loop.md | 5 ----- 1 file changed, 5 deletions(-) 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 e2da32bf..b64b29fd 100644 --- a/agent-framework/integrations/ag-ui/human-in-the-loop.md +++ b/agent-framework/integrations/ag-ui/human-in-the-loop.md @@ -171,11 +171,6 @@ if (approvalRequest is not null) To reject instead, call `approvalRequest.CreateResponse(approved: false)`; the agent continues without running the tool. -> [!TIP] -> A UI framework can handle this round-trip for you. The Blazor AI components render an approval prompt -> and resume the run automatically when the user approves or rejects — on the server you only wrap the -> tool in `ApprovalRequiredAIFunction`. - ## Approval modes Marking a tool for approval — and deciding *when* a call needs it — is a general Agent Framework From 03f39fa81f8ec8bf72ecf2b83a222856be7975ac Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Sat, 25 Jul 2026 00:53:50 -0700 Subject: [PATCH 33/35] human-in-the-loop: simplify the approval resume (drop unnecessary boilerplate) Verified that AGUIChatClient resumes an interrupted run when you reuse the same AgentSession and send the ToolApprovalResponseContent - it auto-builds the AG-UI Resume payload and recovers the thread id itself. The RunStartedEvent capture and the ChatOptions.RawRepresentationFactory (ThreadId/ParentRunId) block were unnecessary, so remove them. The resume turn is now just RunStreamingAsync(resume, session). Verified end-to-end against a live HITL endpoint (approve -> tool runs). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eeb2168d-2ecc-4f8d-9830-c287072eb7e1 --- .../integrations/ag-ui/human-in-the-loop.md | 33 +++---------------- 1 file changed, 4 insertions(+), 29 deletions(-) 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 b64b29fd..7986c81e 100644 --- a/agent-framework/integrations/ag-ui/human-in-the-loop.md +++ b/agent-framework/integrations/ag-ui/human-in-the-loop.md @@ -88,12 +88,10 @@ response schema of `{ "approved": boolean }`. The tool runs only after the clien 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` transports the decision for you — but because the client is -stateless, carry the AG-UI `threadId` and `ParentRunId` across the two turns so the server resumes -the same run, as shown below. +resume message — `AGUIChatClient` converts the decision into the AG-UI resume for you, and reusing the +same `AgentSession` resumes the run. ```csharp -using AGUI.Abstractions; using AGUI.Client; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; @@ -109,19 +107,8 @@ List messages = [new(ChatRole.User, "Email alice@example.com to say // First turn: run until the agent requests approval. ToolApprovalRequestContent? approvalRequest = null; -string? threadId = null; -string? runId = null; await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, session)) { - ChatResponseUpdate chatUpdate = update.AsChatResponseUpdate(); - - // The AGUIChatClient is stateless; the AG-UI thread/run ids arrive on the RUN_STARTED event. - if (chatUpdate.RawRepresentation is RunStartedEvent runStarted) - { - threadId = runStarted.ThreadId; - runId = runStarted.RunId; - } - foreach (AIContent content in update.Contents) { if (content is ToolApprovalRequestContent request) @@ -141,22 +128,10 @@ await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, s if (approvalRequest is not null) { ToolApprovalResponseContent decision = approvalRequest.CreateResponse(approved: true); - List resume = [new(ChatRole.User, [decision])]; - ChatClientAgentRunOptions options = new() - { - // Carry the AG-UI thread and previous run id so the server resumes the same conversation. - ChatOptions = new ChatOptions - { - RawRepresentationFactory = _ => new RunAgentInput - { - ThreadId = threadId ?? string.Empty, - ParentRunId = runId, - } - } - }; - await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(resume, session, options)) + // 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) { From bfe57b5bb26fe513107d49b547fbbe7f03c9b9f4 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Sat, 25 Jul 2026 09:02:09 -0700 Subject: [PATCH 34/35] Style pass: reduce em dashes and prose semicolons in AG-UI C# docs Follow the Microsoft writing style guidance and remove common AI tells: replace em-dash asides and clause-joining semicolons in the C# prose (and a few code comments) with plain sentences, colons, or commas. Prose only - no code or behavior changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eeb2168d-2ecc-4f8d-9830-c287072eb7e1 --- .../integrations/ag-ui/getting-started.md | 2 +- .../integrations/ag-ui/human-in-the-loop.md | 18 +++++++++--------- .../integrations/ag-ui/state-management.md | 14 +++++++------- .../integrations/ag-ui/workflows.md | 10 +++++----- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/agent-framework/integrations/ag-ui/getting-started.md b/agent-framework/integrations/ag-ui/getting-started.md index f412203d..946a501f 100644 --- a/agent-framework/integrations/ag-ui/getting-started.md +++ b/agent-framework/integrations/ag-ui/getting-started.md @@ -305,7 +305,7 @@ User (:q or quit to exit): :q ## Testing with curl (Optional) 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 +`RunAgentInput` JSON body. The only required field is `messages`. If you omit `threadId`, the server generates one for you: ```bash 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 7986c81e..9d73df97 100644 --- a/agent-framework/integrations/ag-ui/human-in-the-loop.md +++ b/agent-framework/integrations/ag-ui/human-in-the-loop.md @@ -22,7 +22,7 @@ The C# AG-UI approval pattern works as follows: 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. +4. **Resume**: `AGUIChatClient` transports the decision over the AG-UI resume mechanism. The agent continues and runs (or skips) the tool. ## Prerequisites @@ -88,7 +88,7 @@ response schema of `{ "approved": boolean }`. The tool runs only after the clien 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 +resume message. `AGUIChatClient` converts the decision into the AG-UI resume for you, and reusing the same `AgentSession` resumes the run. ```csharp @@ -130,7 +130,7 @@ if (approvalRequest is not null) 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. + // 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) @@ -148,22 +148,22 @@ To reject instead, call `approvalRequest.CreateResponse(approved: false)`; the a ## Approval modes -Marking a tool for approval — and deciding *when* a call needs it — is a general Agent Framework +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: - **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 +- **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`. For the APIs, examples, and guidance, see [Using function tools with human-in-the-loop approvals](../../agents/tools/tool-approval.md). 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. +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 diff --git a/agent-framework/integrations/ag-ui/state-management.md b/agent-framework/integrations/ag-ui/state-management.md index 6d2bb686..02f5c343 100644 --- a/agent-framework/integrations/ag-ui/state-management.md +++ b/agent-framework/integrations/ag-ui/state-management.md @@ -107,7 +107,7 @@ internal sealed partial class RecipeSerializerContext : JsonSerializerContext; ### Emit a State Snapshot from a Tool -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: +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; @@ -189,7 +189,7 @@ AGUIStreamOptions streamOptions = new AGUIStreamOptions() WebApplication app = builder.Build(); -// Attach the stream options to the endpoint; MapAGUIServer emits the state events for you. +// Attach the stream options to the endpoint. MapAGUIServer emits the state events for you. app.MapAGUIServer("/", recipeAgent).WithMetadata(streamOptions); await app.RunAsync(); @@ -221,7 +221,7 @@ static bool TryGetClientState(ChatOptions chatOptions, out JsonElement state) } ``` -`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. +`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 @@ -343,14 +343,14 @@ For a related pattern that streams a tool's arguments into state as the model ge ## Predictive State Updates -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 that shows the text appearing in real time, then asks the user to confirm the change once the model is done. +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. > [!NOTE] > This scenario maps the endpoint manually and uses the built-in `TypedResults.ServerSentEvents(...)`, which requires **.NET 10.0 or later**. ### How It Works -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: +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. @@ -358,7 +358,7 @@ Unlike the [shared-state](#emit-a-state-snapshot-from-a-tool) scenario — where 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. +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 @@ -539,7 +539,7 @@ await app.RunAsync(); ### 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. +- **`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. diff --git a/agent-framework/integrations/ag-ui/workflows.md b/agent-framework/integrations/ag-ui/workflows.md index 298e32df..28712644 100644 --- a/agent-framework/integrations/ag-ui/workflows.md +++ b/agent-framework/integrations/ag-ui/workflows.md @@ -41,7 +41,7 @@ AIAgent researcher = chatClient.AsAIAgent( 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. +// 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(); @@ -53,15 +53,15 @@ 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). +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] > The .NET integration streams a workflow's **agent output** (text and tool calls) over AG-UI, but not the -> workflow-specific AG-UI events — step tracking (`STEP_STARTED` / `STEP_FINISHED`), activity snapshots, -> and workflow-level interrupts — shown in the Python version of this article. Those events are still -> evolving for .NET (tracked by [agent-framework#2494](https://github.com/microsoft/agent-framework/issues/2494)). +> 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 From 6538204288cff7b2bcc7833b989ee0623c6b0771 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Sat, 25 Jul 2026 18:59:19 -0700 Subject: [PATCH 35/35] Fix stale illustrative output and mangled tool name in AG-UI C# docs - backend-tool-rendering.md: the getting-started client no longer plumbs the thread id, so drop "Thread: ..." from the expected-output block; name the backend tool "search_restaurants" (matching the Python and Go zones) so the displayed function name is accurate instead of a compiler-mangled local name. - human-in-the-loop.md: give the approval tool an explicit "send_email" name so it matches the surrounding prose instead of a compiler-mangled local name. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eeb2168d-2ecc-4f8d-9830-c287072eb7e1 --- .../integrations/ag-ui/backend-tool-rendering.md | 7 ++++--- agent-framework/integrations/ag-ui/human-in-the-loop.md | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/agent-framework/integrations/ag-ui/backend-tool-rendering.md b/agent-framework/integrations/ag-ui/backend-tool-rendering.md index a6615c4c..56ad8f6b 100644 --- a/agent-framework/integrations/ag-ui/backend-tool-rendering.md +++ b/agent-framework/integrations/ag-ui/backend-tool-rendering.md @@ -110,6 +110,7 @@ AITool[] tools = [ AIFunctionFactory.Create( SearchRestaurants, + name: "search_restaurants", serializerOptions: jsonOptions.SerializerOptions) ]; @@ -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 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 9d73df97..5c134d9e 100644 --- a/agent-framework/integrations/ag-ui/human-in-the-loop.md +++ b/agent-framework/integrations/ag-ui/human-in-the-loop.md @@ -63,7 +63,7 @@ static string SendEmail( [Description("The email body.")] string body) => $"Email sent to {to} with subject '{subject}'."; -AITool sendEmail = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(SendEmail)); +AITool sendEmail = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(SendEmail, name: "send_email")); AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) .GetChatClient(deploymentName)