diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 0d4e07415d4..b5d8c38fc8d 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -54,11 +54,11 @@ - - - - - + + + + + diff --git a/dotnet/samples/02-agents/AGUI/README.md b/dotnet/samples/02-agents/AGUI/README.md index 004b9f3b4e4..3168545aabc 100644 --- a/dotnet/samples/02-agents/AGUI/README.md +++ b/dotnet/samples/02-agents/AGUI/README.md @@ -51,7 +51,7 @@ dotnet run --urls http://localhost:8888 An interactive console client that connects to an AG-UI server. Demonstrates: - Creating an AG-UI client with `AGUIChatClient` -- Managing conversation threads +- Managing multi-turn conversations with an `AgentSession` - Streaming responses with `RunStreamingAsync` - Displaying colored console output for different content types - Supporting both interactive and automated modes @@ -133,28 +133,24 @@ Demonstrates human-in-the-loop approval workflows for sensitive operations. This An AG-UI server that implements approval workflows. Demonstrates: -- Wrapping tools with `ApprovalRequiredAIFunction` -- Converting `FunctionApprovalRequestContent` to approval requests -- Middleware pattern with `ServerFunctionApprovalServerAgent` -- Complete function call capture and restoration +- Wrapping a tool with `ApprovalRequiredAIFunction` so it requires approval before running +- Mapping a plain agent with `MapAGUIServer`, which natively emits an approval interrupt when the model calls the approval-required tool and resumes the run once the client sends the decision back **Run the server:** ```bash cd Step04_HumanInLoop/Server -dotnet run --urls http://localhost:8888 +dotnet run --urls http://localhost:5100 ``` #### Client (`Step04_HumanInLoop/Client`) An interactive client that handles approval requests from the server. Demonstrates: -- Using `ServerFunctionApprovalClientAgent` middleware -- Detecting `FunctionApprovalRequestContent` -- Displaying approval details to users -- Prompting for approval/rejection -- Sending approval responses with `FunctionApprovalResponseContent` -- Resuming conversation after approval +- Detecting `ToolApprovalRequestContent` in the streamed response +- Displaying approval details to the user and prompting for approval or rejection +- Sending the decision back as a `ToolApprovalResponseContent` created with `approvalRequest.CreateResponse(approved)` +- Resuming the run so the server continues after the decision is received **Run the client:** @@ -167,15 +163,15 @@ Try asking the agent to perform sensitive operations like "Approve expense repor ### Step05_StateManagement -An AG-UI server and client that demonstrate state management with predictive updates. +An AG-UI server and client that demonstrate shared state management. #### Server (`Step05_StateManagement/Server`) Demonstrates: -- Defining state schemas using C# records -- Using `SharedStateAgent` middleware for state management -- Streaming predictive state updates with `AgentState` content +- Exposing a `generate_recipe` tool that returns the complete recipe +- Mapping the tool result to a `STATE_SNAPSHOT` event with `AGUIStreamOptions.MapResultAsStateSnapshot` +- Reading the client's current recipe from `RunAgentInput.State` - Managing shared state between client and server - Using JSON serialization contexts for state types @@ -210,7 +206,7 @@ dotnet run ### Client-Side -1. `AGUIAgent` sends HTTP POST request to server +1. `AGUIChatClient` sends HTTP POST request to server 2. Server responds with SSE stream 3. Client parses events into `AgentResponseUpdate` objects 4. Updates are displayed based on content type @@ -228,7 +224,7 @@ dotnet run `ConversationId` keeps request/response continuity. It is not proof that the caller owns that conversation. In multi-user deployments, authenticate each AG-UI request and authorize conversation access using your application's real boundary, such as the authenticated user, tenant, or workspace. -If your ASP.NET Core host shares session storage across users, pair `MapAGUI` with an isolation strategy such as `UseClaimsBasedSessionIsolation(...)` so the storage key includes a principal-specific dimension instead of relying on the conversation identifier alone. +If your ASP.NET Core host shares session storage across users, pair `MapAGUIServer` with an isolation strategy such as `UseClaimsBasedSessionIsolation(...)` so the storage key includes a principal-specific dimension instead of relying on the conversation identifier alone. ## Troubleshooting diff --git a/dotnet/samples/02-agents/AGUI/Step01_GettingStarted/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step01_GettingStarted/Client/Program.cs index bb18aa9a959..a788c36bdf9 100644 --- a/dotnet/samples/02-agents/AGUI/Step01_GettingStarted/Client/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step01_GettingStarted/Client/Program.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using AGUI.Abstractions; using AGUI.Client; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; @@ -50,7 +49,6 @@ // Stream the response bool isFirstUpdate = true; - string? threadId = null; await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, session)) { @@ -59,11 +57,8 @@ // First update indicates run started if (isFirstUpdate) { - // AGUIChatClient is stateless and never surfaces a ConversationId; the thread - // id is carried on the AG-UI RUN_STARTED event's raw representation. - threadId = (chatUpdate.RawRepresentation as RunStartedEvent)?.ThreadId; Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine($"\n[Run Started - Thread: {threadId}, Run: {chatUpdate.ResponseId}]"); + Console.WriteLine($"\n[Run Started - Run: {chatUpdate.ResponseId}]"); Console.ResetColor(); isFirstUpdate = false; } @@ -87,7 +82,7 @@ } Console.ForegroundColor = ConsoleColor.Green; - Console.WriteLine($"\n[Run Finished - Thread: {threadId}]"); + Console.WriteLine("\n[Run Finished]"); Console.ResetColor(); } } diff --git a/dotnet/samples/02-agents/AGUI/Step01_GettingStarted/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step01_GettingStarted/Server/Program.cs index 407bf1c4e82..4fcd27ef4b5 100644 --- a/dotnet/samples/02-agents/AGUI/Step01_GettingStarted/Server/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step01_GettingStarted/Server/Program.cs @@ -7,7 +7,6 @@ using OpenAI.Chat; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); -builder.Services.AddHttpClient().AddLogging(); builder.Services.AddAGUIServer(); // WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production, diff --git a/dotnet/samples/02-agents/AGUI/Step02_BackendTools/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step02_BackendTools/Client/Program.cs index e6a8e834fbc..0433e8c0770 100644 --- a/dotnet/samples/02-agents/AGUI/Step02_BackendTools/Client/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step02_BackendTools/Client/Program.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using AGUI.Abstractions; using AGUI.Client; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; @@ -50,7 +49,6 @@ // Stream the response bool isFirstUpdate = true; - string? threadId = null; await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, session)) { @@ -59,11 +57,8 @@ // First update indicates run started if (isFirstUpdate) { - // AGUIChatClient is stateless and never surfaces a ConversationId; the thread - // id is carried on the AG-UI RUN_STARTED event's raw representation. - threadId = (chatUpdate.RawRepresentation as RunStartedEvent)?.ThreadId; Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine($"\n[Run Started - Thread: {threadId}, Run: {chatUpdate.ResponseId}]"); + Console.WriteLine($"\n[Run Started - Run: {chatUpdate.ResponseId}]"); Console.ResetColor(); isFirstUpdate = false; } @@ -119,7 +114,7 @@ } Console.ForegroundColor = ConsoleColor.Green; - Console.WriteLine($"\n[Run Finished - Thread: {threadId}]"); + Console.WriteLine("\n[Run Finished]"); Console.ResetColor(); } } diff --git a/dotnet/samples/02-agents/AGUI/Step02_BackendTools/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step02_BackendTools/Server/Program.cs index 83cf2500c9d..8a06328bd4c 100644 --- a/dotnet/samples/02-agents/AGUI/Step02_BackendTools/Server/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step02_BackendTools/Server/Program.cs @@ -11,7 +11,6 @@ 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(); @@ -75,6 +74,7 @@ static RestaurantSearchResponse SearchRestaurants( [ AIFunctionFactory.Create( SearchRestaurants, + name: "search_restaurants", serializerOptions: jsonOptions.SerializerOptions) ]; diff --git a/dotnet/samples/02-agents/AGUI/Step03_FrontendTools/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step03_FrontendTools/Client/Program.cs index 5b01e70ded5..3a0cdfda588 100644 --- a/dotnet/samples/02-agents/AGUI/Step03_FrontendTools/Client/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step03_FrontendTools/Client/Program.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using System.ComponentModel; -using AGUI.Abstractions; using AGUI.Client; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; @@ -63,7 +62,6 @@ static string GetUserLocation() // Stream the response bool isFirstUpdate = true; - string? threadId = null; await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, session)) { @@ -72,11 +70,8 @@ static string GetUserLocation() // First update indicates run started if (isFirstUpdate) { - // AGUIChatClient is stateless and never surfaces a ConversationId; the thread - // id is carried on the AG-UI RUN_STARTED event's raw representation. - threadId = (chatUpdate.RawRepresentation as RunStartedEvent)?.ThreadId; Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine($"\n[Run Started - Thread: {threadId}, Run: {chatUpdate.ResponseId}]"); + Console.WriteLine($"\n[Run Started - Run: {chatUpdate.ResponseId}]"); Console.ResetColor(); isFirstUpdate = false; } @@ -112,7 +107,7 @@ static string GetUserLocation() } Console.ForegroundColor = ConsoleColor.Green; - Console.WriteLine($"\n[Run Finished - Thread: {threadId}]"); + Console.WriteLine("\n[Run Finished]"); Console.ResetColor(); } } diff --git a/dotnet/samples/02-agents/AGUI/Step03_FrontendTools/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step03_FrontendTools/Server/Program.cs index 407bf1c4e82..4fcd27ef4b5 100644 --- a/dotnet/samples/02-agents/AGUI/Step03_FrontendTools/Server/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step03_FrontendTools/Server/Program.cs @@ -7,7 +7,6 @@ using OpenAI.Chat; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); -builder.Services.AddHttpClient().AddLogging(); builder.Services.AddAGUIServer(); // WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production, diff --git a/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Client/Program.cs index 788ad4fb3d3..c4ce0d3f54f 100644 --- a/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Client/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Client/Program.cs @@ -15,17 +15,12 @@ AGUIChatClient chatClient = new(new(httpClient, serverUrl)); -// Create agent -ChatClientAgent baseAgent = chatClient.AsAIAgent( +// Create agent. No custom approval agent is required: the loop below handles the approval interrupt +// directly, and AGUIChatClient transports the decision back to the server via the AG-UI resume mechanism. +AIAgent agent = chatClient.AsAIAgent( name: "AGUIAssistant", instructions: "You are a helpful assistant."); -// Use default JSON serializer options -JsonSerializerOptions jsonSerializerOptions = JsonSerializerOptions.Default; - -// Wrap the agent with ServerFunctionApprovalClientAgent -ServerFunctionApprovalClientAgent agent = new(baseAgent, jsonSerializerOptions); - List messages = []; AgentSession? session = null; @@ -44,7 +39,6 @@ messages.Add(new ChatMessage(ChatRole.User, input)); Console.WriteLine(); -#pragma warning disable MEAI001 List approvalResponses = []; do @@ -68,15 +62,6 @@ ToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved); - if (approvalRequest.AdditionalProperties != null) - { - approvalResponse.AdditionalProperties = []; - foreach (var kvp in approvalRequest.AdditionalProperties) - { - approvalResponse.AdditionalProperties[kvp.Key] = kvp.Value; - } - } - approvalResponses.Add(approvalResponse); break; @@ -115,11 +100,10 @@ messages.AddRange(response.Messages); foreach (AIContent approvalResponse in approvalResponses) { - messages.Add(new ChatMessage(ChatRole.Tool, [approvalResponse])); + messages.Add(new ChatMessage(ChatRole.User, [approvalResponse])); } } while (approvalResponses.Count > 0); -#pragma warning restore MEAI001 Console.WriteLine("\n"); Console.ForegroundColor = ConsoleColor.White; @@ -127,7 +111,6 @@ Console.ResetColor(); } -#pragma warning disable MEAI001 static void DisplayApprovalRequest(ToolApprovalRequestContent approvalRequest, FunctionCallContent fcc) { Console.ForegroundColor = ConsoleColor.Yellow; @@ -149,4 +132,3 @@ static void DisplayApprovalRequest(ToolApprovalRequestContent approvalRequest, F Console.WriteLine("============================================================"); Console.ResetColor(); } -#pragma warning restore MEAI001 diff --git a/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Client/ServerFunctionApprovalClientAgent.cs b/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Client/ServerFunctionApprovalClientAgent.cs deleted file mode 100644 index 135d87bffde..00000000000 --- a/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Client/ServerFunctionApprovalClientAgent.cs +++ /dev/null @@ -1,265 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Runtime.CompilerServices; -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using ServerFunctionApproval; - -/// -/// A delegating agent that handles server function approval requests and responses. -/// Transforms between ToolApprovalRequestContent/ToolApprovalResponseContent -/// and the server's request_approval tool call pattern. -/// -internal sealed class ServerFunctionApprovalClientAgent : DelegatingAIAgent -{ - private readonly JsonSerializerOptions _jsonSerializerOptions; - - public ServerFunctionApprovalClientAgent(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.RunCoreStreamingAsync(messages, session, options, cancellationToken) - .ToAgentResponseAsync(cancellationToken); - } - - protected override async IAsyncEnumerable RunCoreStreamingAsync( - IEnumerable messages, - AgentSession? session = null, - AgentRunOptions? options = null, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - // Process and transform approval messages, creating a new message list - var processedMessages = ProcessOutgoingServerFunctionApprovals(messages.ToList(), this._jsonSerializerOptions); - - // Run the inner agent and intercept any approval requests - await foreach (var update in this.InnerAgent.RunStreamingAsync( - processedMessages, session, options, cancellationToken).ConfigureAwait(false)) - { - yield return ProcessIncomingServerApprovalRequests(update, this._jsonSerializerOptions); - } - } - -#pragma warning disable MEAI001 // Type is for evaluation purposes only - private static FunctionResultContent ConvertApprovalResponseToToolResult(ToolApprovalResponseContent approvalResponse, JsonSerializerOptions jsonOptions) - { - return new FunctionResultContent( - callId: approvalResponse.RequestId, - result: JsonSerializer.SerializeToElement( - new ApprovalResponse - { - ApprovalId = approvalResponse.RequestId, - Approved = approvalResponse.Approved - }, - jsonOptions)); - } - - private static List CopyMessagesUpToIndex(List messages, int index) - { - var result = new List(index); - for (int i = 0; i < index; i++) - { - result.Add(messages[i]); - } - return result; - } - - private static List CopyContentsUpToIndex(IList contents, int index) - { - var result = new List(index); - for (int i = 0; i < index; i++) - { - result.Add(contents[i]); - } - return result; - } - - private static List ProcessOutgoingServerFunctionApprovals( - List messages, - JsonSerializerOptions jsonSerializerOptions) - { - List? result = null; - - Dictionary approvalRequests = []; - for (var messageIndex = 0; messageIndex < messages.Count; messageIndex++) - { - var message = messages[messageIndex]; - List? transformedContents = null; - - // Process each content item in the message - HashSet approvalCalls = []; - for (var contentIndex = 0; contentIndex < message.Contents.Count; contentIndex++) - { - var content = message.Contents[contentIndex]; - - // Handle pending approval requests (transform to tool call) - if (content is ToolApprovalRequestContent approvalRequest && - approvalRequest.AdditionalProperties?.TryGetValue("original_function", out var originalFunction) == true && - originalFunction is FunctionCallContent original) - { - approvalRequests[approvalRequest.RequestId] = approvalRequest; - transformedContents ??= CopyContentsUpToIndex(message.Contents, contentIndex); - transformedContents.Add(original); - } - // Handle pending approval responses (transform to tool result) - else if (content is ToolApprovalResponseContent approvalResponse && - approvalRequests.TryGetValue(approvalResponse.RequestId, out var correspondingRequest)) - { - transformedContents ??= CopyContentsUpToIndex(message.Contents, contentIndex); - transformedContents.Add(ConvertApprovalResponseToToolResult(approvalResponse, jsonSerializerOptions)); - approvalRequests.Remove(approvalResponse.RequestId); - correspondingRequest.AdditionalProperties?.Remove("original_function"); - } - // Skip historical approval content - else if (content is FunctionCallContent { Name: "request_approval" } approvalCall) - { - transformedContents ??= CopyContentsUpToIndex(message.Contents, contentIndex); - approvalCalls.Add(approvalCall.CallId); - } - else if (content is FunctionResultContent functionResult && - approvalCalls.Contains(functionResult.CallId)) - { - transformedContents ??= CopyContentsUpToIndex(message.Contents, contentIndex); - approvalCalls.Remove(functionResult.CallId); - } - else - { - transformedContents?.Add(content); - } - } - - if (transformedContents?.Count == 0) - { - continue; - } - else if (transformedContents != null) - { - // We made changes to contents, so use transformedContents - var newMessage = new ChatMessage(message.Role, transformedContents) - { - AuthorName = message.AuthorName, - MessageId = message.MessageId, - CreatedAt = message.CreatedAt, - RawRepresentation = message.RawRepresentation, - AdditionalProperties = message.AdditionalProperties - }; - result ??= CopyMessagesUpToIndex(messages, messageIndex); - result.Add(newMessage); - } - else - { - // We're already copying messages, so copy this unchanged message too - result?.Add(message); - } - // If result is null, we haven't made any changes yet, so keep processing - } - - return result ?? messages; - } - - private static AgentResponseUpdate ProcessIncomingServerApprovalRequests( - AgentResponseUpdate update, - JsonSerializerOptions jsonSerializerOptions) - { - IList? updatedContents = null; - for (var i = 0; i < update.Contents.Count; i++) - { - var content = update.Contents[i]; - if (content is FunctionCallContent { Name: "request_approval" } request) - { - updatedContents ??= [.. update.Contents]; - - // Serialize the function arguments as JsonElement - ApprovalRequest? approvalRequest; - if (request.Arguments?.TryGetValue("request", out var reqObj) == true && - reqObj is JsonElement je) - { - approvalRequest = (ApprovalRequest?)je.Deserialize(jsonSerializerOptions.GetTypeInfo(typeof(ApprovalRequest))); - } - else - { - approvalRequest = null; - } - - if (approvalRequest == null) - { - throw new InvalidOperationException("Failed to deserialize approval request."); - } - - var functionCallArgs = (Dictionary?)approvalRequest.FunctionArguments? - .Deserialize(jsonSerializerOptions.GetTypeInfo(typeof(Dictionary))); - - var approvalRequestContent = new ToolApprovalRequestContent( - requestId: approvalRequest.ApprovalId, - new FunctionCallContent( - callId: approvalRequest.ApprovalId, - name: approvalRequest.FunctionName, - arguments: functionCallArgs)); - - approvalRequestContent.AdditionalProperties ??= []; - approvalRequestContent.AdditionalProperties["original_function"] = content; - - updatedContents[i] = approvalRequestContent; - } - } - - if (updatedContents is not null) - { - var chatUpdate = update.AsChatResponseUpdate(); - return new AgentResponseUpdate(new ChatResponseUpdate() - { - Role = chatUpdate.Role, - Contents = updatedContents, - MessageId = chatUpdate.MessageId, - AuthorName = chatUpdate.AuthorName, - CreatedAt = chatUpdate.CreatedAt, - RawRepresentation = chatUpdate.RawRepresentation, - ResponseId = chatUpdate.ResponseId, - AdditionalProperties = chatUpdate.AdditionalProperties - }) - { - AgentId = update.AgentId, - ContinuationToken = update.ContinuationToken, - }; - } - - return update; - } -} -#pragma warning restore MEAI001 - -namespace ServerFunctionApproval -{ - public sealed class ApprovalRequest - { - [JsonPropertyName("approval_id")] - public required string ApprovalId { get; init; } - - [JsonPropertyName("function_name")] - public required string FunctionName { get; init; } - - [JsonPropertyName("function_arguments")] - public JsonElement? FunctionArguments { get; init; } - - [JsonPropertyName("message")] - public string? Message { get; init; } - } - - public sealed class ApprovalResponse - { - [JsonPropertyName("approval_id")] - public required string ApprovalId { get; init; } - - [JsonPropertyName("approved")] - public required bool Approved { get; init; } - } -} diff --git a/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Server/Program.cs index 570c02b8bbb..a73465c1d3b 100644 --- a/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Server/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Server/Program.cs @@ -5,26 +5,10 @@ using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; -using Microsoft.AspNetCore.Http.Json; -using Microsoft.AspNetCore.HttpLogging; using Microsoft.Extensions.AI; -using Microsoft.Extensions.Options; using OpenAI.Chat; -using ServerFunctionApproval; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); - -builder.Services.AddHttpLogging(logging => -{ - logging.LoggingFields = HttpLoggingFields.RequestPropertiesAndHeaders | HttpLoggingFields.RequestBody - | HttpLoggingFields.ResponsePropertiesAndHeaders | HttpLoggingFields.ResponseBody; - logging.RequestBodyLogLimit = int.MaxValue; - logging.ResponseBodyLogLimit = int.MaxValue; -}); - -builder.Services.AddHttpClient().AddLogging(); -builder.Services.ConfigureHttpJsonOptions(options => - options.SerializerOptions.TypeInfoResolverChain.Add(ApprovalJsonContext.Default)); builder.Services.AddAGUIServer(); // WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production, @@ -34,8 +18,6 @@ WebApplication app = builder.Build(); -app.UseHttpLogging(); - string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"] ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] @@ -48,13 +30,12 @@ static string ApproveExpenseReport(string expenseReportId) return $"Expense report {expenseReportId} approved"; } -// Get JsonSerializerOptions -var jsonOptions = app.Services.GetRequiredService>().Value; - -// Create approval-required tool -#pragma warning disable MEAI001 // Type is for evaluation purposes only -AITool[] tools = [new ApprovalRequiredAIFunction(AIFunctionFactory.Create(ApproveExpenseReport))]; -#pragma warning restore MEAI001 +// Wrap the tool in ApprovalRequiredAIFunction so the run interrupts for approval before it executes. +AITool[] tools = +[ + new ApprovalRequiredAIFunction( + AIFunctionFactory.Create(ApproveExpenseReport, name: "approve_expense_report")) +]; // Create base agent // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. @@ -70,8 +51,7 @@ static string ApproveExpenseReport(string expenseReportId) instructions: "You are a helpful assistant in charge of approving expenses", tools: tools); -// Wrap with ServerFunctionApprovalAgent -var agent = new ServerFunctionApprovalAgent(baseAgent, jsonOptions.SerializerOptions); - -app.MapAGUIServer("/", agent); +// No custom approval protocol is required: MapAGUIServer emits the approval interrupt natively when the +// model calls the approval-required tool, and resumes the run when the client sends the decision back. +app.MapAGUIServer("/", baseAgent); await app.RunAsync(); diff --git a/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Server/ServerFunctionApprovalServerAgent.cs b/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Server/ServerFunctionApprovalServerAgent.cs deleted file mode 100644 index 8c1f27eea9e..00000000000 --- a/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Server/ServerFunctionApprovalServerAgent.cs +++ /dev/null @@ -1,249 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Runtime.CompilerServices; -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using ServerFunctionApproval; - -/// -/// A delegating agent that handles function approval requests on the server side. -/// Transforms between ToolApprovalRequestContent/ToolApprovalResponseContent -/// and the request_approval tool call pattern for client communication. -/// -internal sealed class ServerFunctionApprovalAgent : DelegatingAIAgent -{ - private readonly JsonSerializerOptions _jsonSerializerOptions; - - public ServerFunctionApprovalAgent(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.RunCoreStreamingAsync(messages, session, options, cancellationToken) - .ToAgentResponseAsync(cancellationToken); - } - - protected override async IAsyncEnumerable RunCoreStreamingAsync( - IEnumerable messages, - AgentSession? session = null, - AgentRunOptions? options = null, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - // Process and transform incoming approval responses from client, creating a new message list - var processedMessages = ProcessIncomingFunctionApprovals(messages.ToList(), this._jsonSerializerOptions); - - // Run the inner agent and intercept any approval requests - await foreach (var update in this.InnerAgent.RunStreamingAsync( - processedMessages, session, options, cancellationToken).ConfigureAwait(false)) - { - yield return ProcessOutgoingApprovalRequests(update, this._jsonSerializerOptions); - } - } - -#pragma warning disable MEAI001 // Type is for evaluation purposes only - private static ToolApprovalRequestContent ConvertToolCallToApprovalRequest(FunctionCallContent toolCall, JsonSerializerOptions jsonSerializerOptions) - { - if (toolCall.Name != "request_approval" || toolCall.Arguments == null) - { - throw new InvalidOperationException("Invalid request_approval tool call"); - } - - var request = (toolCall.Arguments.TryGetValue("request", out var reqObj) && - reqObj is JsonElement argsElement && - argsElement.Deserialize(jsonSerializerOptions.GetTypeInfo(typeof(ApprovalRequest))) is ApprovalRequest approvalRequest && - approvalRequest != null ? approvalRequest : null) ?? throw new InvalidOperationException("Failed to deserialize approval request from tool call"); - return new ToolApprovalRequestContent( - requestId: request.ApprovalId, - new FunctionCallContent( - callId: request.ApprovalId, - name: request.FunctionName, - arguments: request.FunctionArguments)); - } - - private static ToolApprovalResponseContent ConvertToolResultToApprovalResponse(FunctionResultContent result, ToolApprovalRequestContent approval, JsonSerializerOptions jsonSerializerOptions) - { - var approvalResponse = (result.Result is JsonElement je ? - (ApprovalResponse?)je.Deserialize(jsonSerializerOptions.GetTypeInfo(typeof(ApprovalResponse))) : - result.Result is string str ? - (ApprovalResponse?)JsonSerializer.Deserialize(str, jsonSerializerOptions.GetTypeInfo(typeof(ApprovalResponse))) : - result.Result as ApprovalResponse) ?? throw new InvalidOperationException("Failed to deserialize approval response from tool result"); - return approval.CreateResponse(approvalResponse.Approved); - } -#pragma warning restore MEAI001 - - private static List CopyMessagesUpToIndex(List messages, int index) - { - var result = new List(index); - for (int i = 0; i < index; i++) - { - result.Add(messages[i]); - } - return result; - } - - private static List CopyContentsUpToIndex(IList contents, int index) - { - var result = new List(index); - for (int i = 0; i < index; i++) - { - result.Add(contents[i]); - } - return result; - } - - private static List ProcessIncomingFunctionApprovals( - List messages, - JsonSerializerOptions jsonSerializerOptions) - { - List? result = null; - - // Track approval ID to original call ID mapping - _ = new Dictionary(); -#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - Dictionary trackedRequestApprovalToolCalls = []; // Remote approvals - for (int messageIndex = 0; messageIndex < messages.Count; messageIndex++) - { - var message = messages[messageIndex]; - List? transformedContents = null; - for (int j = 0; j < message.Contents.Count; j++) - { - var content = message.Contents[j]; - if (content is FunctionCallContent { Name: "request_approval" } toolCall) - { - result ??= CopyMessagesUpToIndex(messages, messageIndex); - transformedContents ??= CopyContentsUpToIndex(message.Contents, j); - var approvalRequest = ConvertToolCallToApprovalRequest(toolCall, jsonSerializerOptions); - transformedContents.Add(approvalRequest); - trackedRequestApprovalToolCalls[toolCall.CallId] = approvalRequest; - result.Add(new ChatMessage(message.Role, transformedContents) - { - AuthorName = message.AuthorName, - MessageId = message.MessageId, - CreatedAt = message.CreatedAt, - RawRepresentation = message.RawRepresentation, - AdditionalProperties = message.AdditionalProperties - }); - } - else if (content is FunctionResultContent toolResult && - trackedRequestApprovalToolCalls.TryGetValue(toolResult.CallId, out var approval)) - { - result ??= CopyMessagesUpToIndex(messages, messageIndex); - transformedContents ??= CopyContentsUpToIndex(message.Contents, j); - var approvalResponse = ConvertToolResultToApprovalResponse(toolResult, approval, jsonSerializerOptions); - transformedContents.Add(approvalResponse); - result.Add(new ChatMessage(message.Role, transformedContents) - { - AuthorName = message.AuthorName, - MessageId = message.MessageId, - CreatedAt = message.CreatedAt, - RawRepresentation = message.RawRepresentation, - AdditionalProperties = message.AdditionalProperties - }); - } - else - { - result?.Add(message); - } - } - } -#pragma warning restore MEAI001 - - return result ?? messages; - } - - private static AgentResponseUpdate ProcessOutgoingApprovalRequests( - AgentResponseUpdate update, - JsonSerializerOptions jsonSerializerOptions) - { - IList? updatedContents = null; - for (var i = 0; i < update.Contents.Count; i++) - { - var content = update.Contents[i]; -#pragma warning disable MEAI001 // Type is for evaluation purposes only - if (content is ToolApprovalRequestContent request && request.ToolCall is FunctionCallContent functionCall) - { - updatedContents ??= [.. update.Contents]; - var approvalId = request.RequestId; - - var approvalData = new ApprovalRequest - { - ApprovalId = approvalId, - FunctionName = functionCall.Name, - FunctionArguments = functionCall.Arguments, - Message = $"Approve execution of '{functionCall.Name}'?" - }; - - updatedContents[i] = new FunctionCallContent( - callId: approvalId, - name: "request_approval", - arguments: new Dictionary { ["request"] = approvalData }); - } -#pragma warning restore MEAI001 - } - - if (updatedContents is not null) - { - var chatUpdate = update.AsChatResponseUpdate(); - // Yield a tool call update that represents the approval request - return new AgentResponseUpdate(new ChatResponseUpdate() - { - Role = chatUpdate.Role, - Contents = updatedContents, - MessageId = chatUpdate.MessageId, - AuthorName = chatUpdate.AuthorName, - CreatedAt = chatUpdate.CreatedAt, - RawRepresentation = chatUpdate.RawRepresentation, - ResponseId = chatUpdate.ResponseId, - AdditionalProperties = chatUpdate.AdditionalProperties - }) - { - AgentId = update.AgentId, - ContinuationToken = update.ContinuationToken - }; - } - - return update; - } -} - -namespace ServerFunctionApproval -{ - // Define approval models - public sealed class ApprovalRequest - { - [JsonPropertyName("approval_id")] - public required string ApprovalId { get; init; } - - [JsonPropertyName("function_name")] - public required string FunctionName { get; init; } - - [JsonPropertyName("function_arguments")] - public IDictionary? FunctionArguments { get; init; } - - [JsonPropertyName("message")] - public string? Message { get; init; } - } - - public sealed class ApprovalResponse - { - [JsonPropertyName("approval_id")] - public required string ApprovalId { get; init; } - - [JsonPropertyName("approved")] - public required bool Approved { get; init; } - } - - [JsonSerializable(typeof(ApprovalRequest))] - [JsonSerializable(typeof(ApprovalResponse))] - [JsonSerializable(typeof(Dictionary))] - public sealed partial class ApprovalJsonContext : JsonSerializerContext; -} diff --git a/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Client/Program.cs index fbda62cc92a..ac3f4b939b0 100644 --- a/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Client/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Client/Program.cs @@ -20,16 +20,15 @@ AGUIChatClient chatClient = new(new(httpClient, serverUrl)); -AIAgent baseAgent = chatClient.AsAIAgent( +AIAgent agent = chatClient.AsAIAgent( name: "recipe-client", description: "AG-UI Recipe Client Agent"); -// Wrap the base agent with state management -JsonSerializerOptions jsonOptions = new(JsonSerializerDefaults.Web) -{ - TypeInfoResolver = RecipeSerializerContext.Default -}; -StatefulAgent agent = new(baseAgent, jsonOptions, new AgentState()); +JsonSerializerOptions jsonOptions = RecipeSerializerContext.Default.Options; + +// The recipe lives on the client. It is sent to the server on every turn (so the agent edits the +// existing recipe) and refreshed from each STATE_SNAPSHOT the server streams back. +Recipe currentRecipe = new(); AgentSession session = await agent.CreateSessionAsync(); List messages = @@ -42,7 +41,7 @@ while (true) { // Get user input - Console.Write("\nUser (:q to quit, :state to show state): "); + Console.Write("\nUser (:q to quit, :state to show recipe): "); string? message = Console.ReadLine(); if (string.IsNullOrWhiteSpace(message)) @@ -58,36 +57,51 @@ if (message.Equals(":state", StringComparison.OrdinalIgnoreCase)) { - DisplayState(agent.State.Recipe); + DisplayRecipe(currentRecipe); continue; } messages.Add(new ChatMessage(ChatRole.User, message)); + // Send the client's current recipe on the AG-UI RunAgentInput.State so the agent builds on it. + JsonElement stateJson = JsonSerializer.SerializeToElement( + new RecipeResponse { Recipe = currentRecipe }, jsonOptions); + ChatClientAgentRunOptions runOptions = new() + { + ChatOptions = new ChatOptions + { + RawRepresentationFactory = _ => new RunAgentInput { State = stateJson } + } + }; + // Stream the response bool isFirstUpdate = true; - string? threadId = null; - bool stateReceived = false; - Console.WriteLine(); - await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, session)) + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, session, runOptions)) { ChatResponseUpdate chatUpdate = update.AsChatResponseUpdate(); // First update indicates run started if (isFirstUpdate) { - // AGUIChatClient is stateless and never surfaces a ConversationId; the thread - // id is carried on the AG-UI RUN_STARTED event's raw representation. - threadId = (chatUpdate.RawRepresentation as RunStartedEvent)?.ThreadId; Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine($"[Run Started - Thread: {threadId}, Run: {chatUpdate.ResponseId}]"); + Console.WriteLine($"[Run Started - Run: {chatUpdate.ResponseId}]"); Console.ResetColor(); isFirstUpdate = false; } - // Display streaming content + // A STATE_SNAPSHOT arrives as a StateSnapshotEvent on the update's raw representation. + if (chatUpdate.RawRepresentation is StateSnapshotEvent snapshot && + snapshot.Snapshot.Deserialize(jsonOptions) is { } response) + { + currentRecipe = response.Recipe; + Console.ForegroundColor = ConsoleColor.Blue; + Console.WriteLine("\n[State Snapshot Received]"); + Console.ResetColor(); + } + + // Display streaming text content foreach (AIContent content in update.Contents) { switch (content) @@ -98,14 +112,6 @@ Console.ResetColor(); break; - case DataContent dataContent when dataContent.MediaType == "application/json": - // This is a state snapshot - the StatefulAgent has already updated the state - stateReceived = true; - Console.ForegroundColor = ConsoleColor.Blue; - Console.WriteLine("\n[State Snapshot Received]"); - Console.ResetColor(); - break; - case ErrorContent errorContent: Console.ForegroundColor = ConsoleColor.Red; Console.WriteLine($"\n[Error: {errorContent.Message}]"); @@ -116,14 +122,10 @@ } Console.ForegroundColor = ConsoleColor.Green; - Console.WriteLine($"\n[Run Finished - Thread: {threadId}]"); + Console.WriteLine("\n[Run Finished]"); Console.ResetColor(); - // Display final state if received - if (stateReceived) - { - DisplayState(agent.State.Recipe); - } + DisplayRecipe(currentRecipe); } } catch (Exception ex) @@ -131,61 +133,53 @@ Console.WriteLine($"\nAn error occurred: {ex.Message}"); } -static void DisplayState(RecipeState? state) +static void DisplayRecipe(Recipe recipe) { - if (state == null) - { - Console.ForegroundColor = ConsoleColor.Gray; - Console.WriteLine("\n[No state available]"); - Console.ResetColor(); - return; - } - Console.ForegroundColor = ConsoleColor.Blue; Console.WriteLine("\n" + new string('=', 60)); - Console.WriteLine("CURRENT STATE"); + Console.WriteLine("CURRENT RECIPE"); Console.WriteLine(new string('=', 60)); Console.ResetColor(); - if (!string.IsNullOrEmpty(state.Title)) + if (string.IsNullOrEmpty(recipe.Title)) { - Console.WriteLine("\nRecipe:"); - Console.WriteLine($" Title: {state.Title}"); - if (!string.IsNullOrEmpty(state.Cuisine)) - { - Console.WriteLine($" Cuisine: {state.Cuisine}"); - } - - if (!string.IsNullOrEmpty(state.SkillLevel)) + Console.ForegroundColor = ConsoleColor.Gray; + Console.WriteLine("\n[No recipe yet]"); + Console.ResetColor(); + } + else + { + Console.WriteLine($"\n Title: {recipe.Title}"); + if (!string.IsNullOrEmpty(recipe.SkillLevel)) { - Console.WriteLine($" Skill Level: {state.SkillLevel}"); + Console.WriteLine($" Skill Level: {recipe.SkillLevel}"); } - if (state.PrepTimeMinutes > 0) + if (!string.IsNullOrEmpty(recipe.CookingTime)) { - Console.WriteLine($" Prep Time: {state.PrepTimeMinutes} minutes"); + Console.WriteLine($" Cooking Time: {recipe.CookingTime}"); } - if (state.CookTimeMinutes > 0) + if (recipe.SpecialPreferences.Count > 0) { - Console.WriteLine($" Cook Time: {state.CookTimeMinutes} minutes"); + Console.WriteLine($" Preferences: {string.Join(", ", recipe.SpecialPreferences)}"); } - if (state.Ingredients.Count > 0) + if (recipe.Ingredients.Count > 0) { Console.WriteLine("\n Ingredients:"); - foreach (var ingredient in state.Ingredients) + foreach (Ingredient ingredient in recipe.Ingredients) { - Console.WriteLine($" - {ingredient}"); + Console.WriteLine($" {ingredient.Icon} {ingredient.Name} - {ingredient.Amount}"); } } - if (state.Steps.Count > 0) + if (recipe.Instructions.Count > 0) { - Console.WriteLine("\n Steps:"); - for (int i = 0; i < state.Steps.Count; i++) + Console.WriteLine("\n Instructions:"); + for (int i = 0; i < recipe.Instructions.Count; i++) { - Console.WriteLine($" {i + 1}. {state.Steps[i]}"); + Console.WriteLine($" {i + 1}. {recipe.Instructions[i]}"); } } } @@ -195,40 +189,53 @@ static void DisplayState(RecipeState? state) Console.ResetColor(); } -// State wrapper -internal sealed class AgentState +namespace RecipeClient { - [JsonPropertyName("recipe")] - public RecipeState Recipe { get; set; } = new(); -} + // State response wrapper. Its shape mirrors what the server returns and renders as state. + internal sealed class RecipeResponse + { + [JsonPropertyName("recipe")] + public Recipe Recipe { get; set; } = new(); + } -// Recipe state model -internal sealed class RecipeState -{ - [JsonPropertyName("title")] - public string Title { get; set; } = string.Empty; + // 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("ingredients")] - public List Ingredients { get; set; } = []; + [JsonPropertyName("cooking_time")] + public string CookingTime { get; set; } = string.Empty; - [JsonPropertyName("steps")] - public List Steps { get; set; } = []; + [JsonPropertyName("special_preferences")] + public List SpecialPreferences { get; set; } = []; - [JsonPropertyName("prep_time_minutes")] - public int PrepTimeMinutes { get; set; } + [JsonPropertyName("ingredients")] + public List Ingredients { get; set; } = []; - [JsonPropertyName("cook_time_minutes")] - public int CookTimeMinutes { get; set; } + [JsonPropertyName("instructions")] + public List Instructions { get; set; } = []; + } - [JsonPropertyName("skill_level")] - public string SkillLevel { get; set; } = string.Empty; -} + // A single ingredient. + internal sealed class Ingredient + { + [JsonPropertyName("icon")] + public string Icon { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; -// JSON serialization context -[JsonSerializable(typeof(AgentState))] -[JsonSerializable(typeof(RecipeState))] -[JsonSerializable(typeof(JsonElement))] -internal sealed partial class RecipeSerializerContext : JsonSerializerContext; + [JsonPropertyName("amount")] + public string Amount { get; set; } = string.Empty; + } + + // JSON serialization context. + [JsonSerializable(typeof(RecipeResponse))] + [JsonSerializable(typeof(Recipe))] + [JsonSerializable(typeof(Ingredient))] + internal sealed partial class RecipeSerializerContext : JsonSerializerContext; +} diff --git a/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Client/StatefulAgent.cs b/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Client/StatefulAgent.cs deleted file mode 100644 index 8a8062befe6..00000000000 --- a/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Client/StatefulAgent.cs +++ /dev/null @@ -1,87 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Runtime.CompilerServices; -using System.Text.Json; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; - -namespace RecipeClient; - -/// -/// A delegating agent that manages client-side state and automatically attaches it to requests. -/// -/// The state type. -internal sealed class StatefulAgent : DelegatingAIAgent - where TState : class, new() -{ - private readonly JsonSerializerOptions _jsonSerializerOptions; - - /// - /// Gets or sets the current state. - /// - public TState State { get; set; } - - /// - /// Initializes a new instance of the class. - /// - /// The underlying agent to delegate to. - /// The JSON serializer options for state serialization. - /// The initial state. If null, a new instance will be created. - public StatefulAgent(AIAgent innerAgent, JsonSerializerOptions jsonSerializerOptions, TState? initialState = null) - : base(innerAgent) - { - this._jsonSerializerOptions = jsonSerializerOptions; - this.State = initialState ?? new TState(); - } - - /// - protected override Task RunCoreAsync( - IEnumerable messages, - AgentSession? session = null, - AgentRunOptions? options = null, - CancellationToken cancellationToken = default) - { - return 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) - { - // Add state to messages - List messagesWithState = [.. messages]; - - // Serialize the state using AgentState wrapper - byte[] stateBytes = JsonSerializer.SerializeToUtf8Bytes( - this.State, - this._jsonSerializerOptions.GetTypeInfo(typeof(TState))); - DataContent stateContent = new(stateBytes, "application/json"); - ChatMessage stateMessage = new(ChatRole.System, [stateContent]); - messagesWithState.Add(stateMessage); - - // Stream the response and update state when received - await foreach (AgentResponseUpdate update in this.InnerAgent.RunStreamingAsync(messagesWithState, session, options, cancellationToken)) - { - // Check if this update contains a state snapshot - foreach (AIContent content in update.Contents) - { - if (content is DataContent dataContent && dataContent.MediaType == "application/json") - { - // Deserialize the state - if (JsonSerializer.Deserialize( - dataContent.Data.Span, - this._jsonSerializerOptions.GetTypeInfo(typeof(TState))) is TState newState) - { - this.State = newState; - } - } - } - - yield return update; - } - } -} diff --git a/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Server/Program.cs index 2232311630c..fb4b9620900 100644 --- a/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Server/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Server/Program.cs @@ -1,15 +1,16 @@ // Copyright (c) Microsoft. All rights reserved. +using System.ComponentModel; +using AGUI.Server; using Azure.AI.OpenAI; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; -using Microsoft.Extensions.Options; +using Microsoft.Extensions.AI; using OpenAI.Chat; using RecipeAssistant; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); -builder.Services.AddHttpClient().AddLogging(); builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.TypeInfoResolverChain.Add(RecipeSerializerContext.Default)); builder.Services.AddAGUIServer(); @@ -29,10 +30,32 @@ string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); -// Get JsonSerializerOptions -var jsonOptions = app.Services.GetRequiredService>().Value; +// The tool returns the complete recipe. The hosting layer turns each result into a STATE_SNAPSHOT +// event via AGUIStreamOptions.MapResultAsStateSnapshot("generate_recipe") - no protocol content by hand. +[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 }; -// Create base agent +AITool generateRecipe = AIFunctionFactory.Create( + GenerateRecipe, + name: "generate_recipe", + description: "Generate or update the shared recipe and display it to the user.", + RecipeSerializerContext.Default.Options); + +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. + """; + +// Create the AI agent with the recipe tool. // 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. @@ -41,26 +64,22 @@ new DefaultAzureCredential()) .GetChatClient(deploymentName); -AIAgent baseAgent = chatClient.AsAIAgent( - name: "RecipeAgent", - instructions: """ - You are a helpful recipe assistant. When users ask you to create or suggest a recipe, - respond with a complete AgentState 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. - """); +AIAgent baseAgent = chatClient.AsAIAgent(new ChatClientAgentOptions +{ + Name = "RecipeAgent", + Description = "An agent that maintains a shared recipe state with the user.", + ChatOptions = new ChatOptions + { + Instructions = SharedStateSystemPrompt, + Tools = [generateRecipe], + }, +}); -// Wrap with state management middleware -AIAgent agent = new SharedStateAgent(baseAgent, jsonOptions.SerializerOptions); +// Wrap with a thin agent that injects the client's current recipe (input side of shared state). +AIAgent agent = new RecipeStateAgent(baseAgent); -// Map the AG-UI agent endpoint -app.MapAGUIServer("/", agent); +// Map the AG-UI endpoint. A generate_recipe result becomes a STATE_SNAPSHOT event (output side). +app.MapAGUIServer("/", agent) + .WithMetadata(new AGUIStreamOptions().MapResultAsStateSnapshot("generate_recipe")); await app.RunAsync(); diff --git a/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Server/RecipeModels.cs b/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Server/RecipeModels.cs index fc1d8320d22..613f684d312 100644 --- a/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Server/RecipeModels.cs +++ b/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Server/RecipeModels.cs @@ -4,40 +4,50 @@ namespace RecipeAssistant; -// State wrapper -internal sealed class AgentState +// 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 -[JsonSerializable(typeof(AgentState))] -[JsonSerializable(typeof(RecipeState))] -[JsonSerializable(typeof(System.Text.Json.JsonElement))] +// JSON serialization context for the tool payloads. +[JsonSerializable(typeof(RecipeResponse))] +[JsonSerializable(typeof(Recipe))] +[JsonSerializable(typeof(Ingredient))] internal sealed partial class RecipeSerializerContext : JsonSerializerContext; diff --git a/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Server/RecipeStateAgent.cs b/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Server/RecipeStateAgent.cs new file mode 100644 index 00000000000..d18df8a599f --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Server/RecipeStateAgent.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using AGUI.Abstractions; +using AGUI.Server; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace RecipeAssistant; + +/// +/// A thin agent that reads the client's current recipe from the AG-UI +/// and prepends it to the conversation as a system message, so the model edits the existing recipe +/// instead of starting over. This handles the input side of shared state only. The output side is +/// declarative: the inner agent's generate_recipe tool result becomes a STATE_SNAPSHOT +/// via AGUIStreamOptions.MapResultAsStateSnapshot. +/// +internal sealed class RecipeStateAgent(AIAgent innerAgent) : DelegatingAIAgent(innerAgent) +{ + 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 IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + if (options is ChatClientAgentRunOptions { ChatOptions: { } chatOptions } && + chatOptions.TryGetRunAgentInput(out RunAgentInput? input) && + input.State is { ValueKind: JsonValueKind.Object } state) + { + ChatMessage stateMessage = new( + ChatRole.System, + $"The user's current recipe state is:\n{state.GetRawText()}"); + messages = [stateMessage, .. messages]; + } + + return this.InnerAgent.RunStreamingAsync(messages, session, options, cancellationToken); + } +} diff --git a/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Server/SharedStateAgent.cs b/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Server/SharedStateAgent.cs deleted file mode 100644 index 032ce5976ec..00000000000 --- a/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Server/SharedStateAgent.cs +++ /dev/null @@ -1,159 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Runtime.CompilerServices; -using System.Text.Json; -using AGUI.Abstractions; -using AGUI.Server; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; - -namespace RecipeAssistant; - -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.RunCoreStreamingAsync(messages, session, options, cancellationToken) - .ToAgentResponseAsync(cancellationToken); - } - - protected override async IAsyncEnumerable RunCoreStreamingAsync( - IEnumerable messages, - AgentSession? session = null, - AgentRunOptions? options = null, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - // Check if the client sent state in the request - if (options is not ChatClientAgentRunOptions { ChatOptions: { } chatOptions } chatRunOptions || - !chatOptions.TryGetRunAgentInput(out RunAgentInput? agentInput) || - agentInput.State is not { ValueKind: JsonValueKind.Object } state) - { - // 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 - 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: "AgentState", - schemaDescription: "A response containing a recipe with title, skill level, cooking time, 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 - if (TryDeserialize(response.Text, this._jsonSerializerOptions, out JsonElement stateSnapshot)) - { - // Serialize and emit as STATE_SNAPSHOT via DataContent - byte[] stateBytes = JsonSerializer.SerializeToUtf8Bytes( - stateSnapshot, - this._jsonSerializerOptions.GetTypeInfo(typeof(JsonElement))); - yield return new AgentResponseUpdate - { - Contents = [new DataContent(stateBytes, "application/json")] - }; - } - else - { - yield break; - } - - // 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; - } - } - - private static bool TryDeserialize(string json, JsonSerializerOptions jsonSerializerOptions, out T structuredOutput) - { - try - { - T? deserialized = JsonSerializer.Deserialize(json, jsonSerializerOptions); - if (deserialized is null) - { - structuredOutput = default!; - return false; - } - - structuredOutput = deserialized; - return true; - } - catch - { - structuredOutput = default!; - return false; - } - } -}