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;
- }
- }
-}