From eb06faea2dcc021edb2b6f98722b9fb35eff5e36 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Mon, 8 Dec 2025 01:42:06 +0100 Subject: [PATCH] .NET: AG-UI Docs samples (#2194) * Add AG-UI Blazor sample * Add AG-UI getting started samples * Cleanups * Update the dojo samples * cleanups * Fix readme * Address feedback and further cleanups * Fix build * Missing fixes --- dotnet/agent-framework-dotnet.slnx | 23 ++ .../AGUIDojoServerSerializerContext.cs | 12 + .../AgenticUI/AgenticPlanningTools.cs | 52 +++ .../AgenticUI/AgenticUIAgent.cs | 88 +++++ .../AgenticUI/JsonPatchOperation.cs | 20 ++ .../AGUIDojoServer/AgenticUI/Plan.cs | 11 + .../AGUIDojoServer/AgenticUI/Step.cs | 14 + .../AGUIDojoServer/AgenticUI/StepStatus.cs | 12 + .../{ => BackendToolRendering}/WeatherInfo.cs | 2 +- .../AGUIDojoServer/ChatClientAgentFactory.cs | 91 +++++- .../PredictiveStateUpdates/DocumentState.cs | 11 + .../PredictiveStateUpdatesAgent.cs | 104 ++++++ .../AGUIDojoServer/Program.cs | 6 +- .../{ => SharedState}/Ingredient.cs | 2 +- .../{ => SharedState}/Recipe.cs | 2 +- .../{ => SharedState}/RecipeResponse.cs | 2 +- .../{ => SharedState}/SharedStateAgent.cs | 2 +- .../Client/AGUIWebChatClient.csproj | 14 + .../AGUIWebChat/Client/Components/App.razor | 23 ++ .../Components/Layout/LoadingSpinner.razor | 1 + .../Layout/LoadingSpinner.razor.css | 89 +++++ .../Client/Components/Layout/MainLayout.razor | 9 + .../Components/Layout/MainLayout.razor.css | 20 ++ .../Client/Components/Pages/Chat/Chat.razor | 94 ++++++ .../Components/Pages/Chat/Chat.razor.css | 11 + .../Components/Pages/Chat/ChatCitation.razor | 38 +++ .../Pages/Chat/ChatCitation.razor.css | 37 +++ .../Components/Pages/Chat/ChatHeader.razor | 17 + .../Pages/Chat/ChatHeader.razor.css | 25 ++ .../Components/Pages/Chat/ChatInput.razor | 51 +++ .../Components/Pages/Chat/ChatInput.razor.css | 57 ++++ .../Components/Pages/Chat/ChatInput.razor.js | 43 +++ .../Pages/Chat/ChatMessageItem.razor | 73 +++++ .../Pages/Chat/ChatMessageItem.razor.css | 67 ++++ .../Pages/Chat/ChatMessageList.razor | 42 +++ .../Pages/Chat/ChatMessageList.razor.css | 22 ++ .../Pages/Chat/ChatMessageList.razor.js | 34 ++ .../Pages/Chat/ChatSuggestions.razor | 78 +++++ .../Pages/Chat/ChatSuggestions.razor.css | 9 + .../Client/Components/Routes.razor | 6 + .../Client/Components/_Imports.razor | 12 + dotnet/samples/AGUIWebChat/Client/Program.cs | 34 ++ .../Client/Properties/launchSettings.json | 15 + .../AGUIWebChat/Client/wwwroot/app.css | 93 ++++++ .../AGUIWebChat/Client/wwwroot/favicon.png | Bin 0 -> 1148 bytes dotnet/samples/AGUIWebChat/README.md | 185 +++++++++++ .../Server/AGUIWebChatServer.csproj | 21 ++ dotnet/samples/AGUIWebChat/Server/Program.cs | 35 ++ .../Server/Properties/launchSettings.json | 14 + dotnet/samples/GettingStarted/AGUI/README.md | 304 ++++++++++++++++++ .../Client/Client.csproj | 15 + .../Step01_GettingStarted/Client/Program.cs | 94 ++++++ .../Step01_GettingStarted/Server/Program.cs | 34 ++ .../Server/Properties/launchSettings.json | 23 ++ .../Server/Server.csproj | 21 ++ .../Server/appsettings.Development.json | 8 + .../Server/appsettings.json | 9 + .../Step02_BackendTools/Client/Client.csproj | 15 + .../Step02_BackendTools/Client/Program.cs | 126 ++++++++ .../Step02_BackendTools/Server/Program.cs | 117 +++++++ .../Server/Properties/launchSettings.json | 23 ++ .../Step02_BackendTools/Server/Server.csproj | 21 ++ .../Server/appsettings.Development.json | 8 + .../Server/appsettings.json | 9 + .../Step03_FrontendTools/Client/Client.csproj | 15 + .../Step03_FrontendTools/Client/Program.cs | 119 +++++++ .../Step03_FrontendTools/Server/Program.cs | 34 ++ .../Server/Properties/launchSettings.json | 23 ++ .../Step03_FrontendTools/Server/Server.csproj | 21 ++ .../Server/appsettings.Development.json | 8 + .../Server/appsettings.json | 9 + .../Step04_HumanInLoop/Client/Client.csproj | 15 + .../AGUI/Step04_HumanInLoop/Client/Program.cs | 152 +++++++++ .../ServerFunctionApprovalClientAgent.cs | 265 +++++++++++++++ .../AGUI/Step04_HumanInLoop/Server/Program.cs | 69 ++++ .../Server/Properties/launchSettings.json | 23 ++ .../Step04_HumanInLoop/Server/Server.csproj | 21 ++ .../ServerFunctionApprovalServerAgent.cs | 262 +++++++++++++++ .../Server/appsettings.Development.json | 9 + .../Server/appsettings.json | 9 + .../Client/Client.csproj | 15 + .../Step05_StateManagement/Client/Program.cs | 231 +++++++++++++ .../Client/StatefulAgent.cs | 88 +++++ .../Step05_StateManagement/Server/Program.cs | 59 ++++ .../Server/Properties/launchSettings.json | 23 ++ .../Server/RecipeModels.cs | 43 +++ .../Server/Server.csproj | 21 ++ .../Server/SharedStateAgent.cs | 137 ++++++++ .../Server/appsettings.Development.json | 8 + .../Server/appsettings.json | 9 + 90 files changed, 4227 insertions(+), 11 deletions(-) create mode 100644 dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/AgenticPlanningTools.cs create mode 100644 dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/AgenticUIAgent.cs create mode 100644 dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/JsonPatchOperation.cs create mode 100644 dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/Plan.cs create mode 100644 dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/Step.cs create mode 100644 dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/StepStatus.cs rename dotnet/samples/AGUIClientServer/AGUIDojoServer/{ => BackendToolRendering}/WeatherInfo.cs (91%) create mode 100644 dotnet/samples/AGUIClientServer/AGUIDojoServer/PredictiveStateUpdates/DocumentState.cs create mode 100644 dotnet/samples/AGUIClientServer/AGUIDojoServer/PredictiveStateUpdates/PredictiveStateUpdatesAgent.cs rename dotnet/samples/AGUIClientServer/AGUIDojoServer/{ => SharedState}/Ingredient.cs (91%) rename dotnet/samples/AGUIClientServer/AGUIDojoServer/{ => SharedState}/Recipe.cs (94%) rename dotnet/samples/AGUIClientServer/AGUIDojoServer/{ => SharedState}/RecipeResponse.cs (89%) rename dotnet/samples/AGUIClientServer/AGUIDojoServer/{ => SharedState}/SharedStateAgent.cs (99%) create mode 100644 dotnet/samples/AGUIWebChat/Client/AGUIWebChatClient.csproj create mode 100644 dotnet/samples/AGUIWebChat/Client/Components/App.razor create mode 100644 dotnet/samples/AGUIWebChat/Client/Components/Layout/LoadingSpinner.razor create mode 100644 dotnet/samples/AGUIWebChat/Client/Components/Layout/LoadingSpinner.razor.css create mode 100644 dotnet/samples/AGUIWebChat/Client/Components/Layout/MainLayout.razor create mode 100644 dotnet/samples/AGUIWebChat/Client/Components/Layout/MainLayout.razor.css create mode 100644 dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/Chat.razor create mode 100644 dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/Chat.razor.css create mode 100644 dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatCitation.razor create mode 100644 dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatCitation.razor.css create mode 100644 dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatHeader.razor create mode 100644 dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatHeader.razor.css create mode 100644 dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatInput.razor create mode 100644 dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatInput.razor.css create mode 100644 dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatInput.razor.js create mode 100644 dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatMessageItem.razor create mode 100644 dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatMessageItem.razor.css create mode 100644 dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatMessageList.razor create mode 100644 dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatMessageList.razor.css create mode 100644 dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatMessageList.razor.js create mode 100644 dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatSuggestions.razor create mode 100644 dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatSuggestions.razor.css create mode 100644 dotnet/samples/AGUIWebChat/Client/Components/Routes.razor create mode 100644 dotnet/samples/AGUIWebChat/Client/Components/_Imports.razor create mode 100644 dotnet/samples/AGUIWebChat/Client/Program.cs create mode 100644 dotnet/samples/AGUIWebChat/Client/Properties/launchSettings.json create mode 100644 dotnet/samples/AGUIWebChat/Client/wwwroot/app.css create mode 100644 dotnet/samples/AGUIWebChat/Client/wwwroot/favicon.png create mode 100644 dotnet/samples/AGUIWebChat/README.md create mode 100644 dotnet/samples/AGUIWebChat/Server/AGUIWebChatServer.csproj create mode 100644 dotnet/samples/AGUIWebChat/Server/Program.cs create mode 100644 dotnet/samples/AGUIWebChat/Server/Properties/launchSettings.json create mode 100644 dotnet/samples/GettingStarted/AGUI/README.md create mode 100644 dotnet/samples/GettingStarted/AGUI/Step01_GettingStarted/Client/Client.csproj create mode 100644 dotnet/samples/GettingStarted/AGUI/Step01_GettingStarted/Client/Program.cs create mode 100644 dotnet/samples/GettingStarted/AGUI/Step01_GettingStarted/Server/Program.cs create mode 100644 dotnet/samples/GettingStarted/AGUI/Step01_GettingStarted/Server/Properties/launchSettings.json create mode 100644 dotnet/samples/GettingStarted/AGUI/Step01_GettingStarted/Server/Server.csproj create mode 100644 dotnet/samples/GettingStarted/AGUI/Step01_GettingStarted/Server/appsettings.Development.json create mode 100644 dotnet/samples/GettingStarted/AGUI/Step01_GettingStarted/Server/appsettings.json create mode 100644 dotnet/samples/GettingStarted/AGUI/Step02_BackendTools/Client/Client.csproj create mode 100644 dotnet/samples/GettingStarted/AGUI/Step02_BackendTools/Client/Program.cs create mode 100644 dotnet/samples/GettingStarted/AGUI/Step02_BackendTools/Server/Program.cs create mode 100644 dotnet/samples/GettingStarted/AGUI/Step02_BackendTools/Server/Properties/launchSettings.json create mode 100644 dotnet/samples/GettingStarted/AGUI/Step02_BackendTools/Server/Server.csproj create mode 100644 dotnet/samples/GettingStarted/AGUI/Step02_BackendTools/Server/appsettings.Development.json create mode 100644 dotnet/samples/GettingStarted/AGUI/Step02_BackendTools/Server/appsettings.json create mode 100644 dotnet/samples/GettingStarted/AGUI/Step03_FrontendTools/Client/Client.csproj create mode 100644 dotnet/samples/GettingStarted/AGUI/Step03_FrontendTools/Client/Program.cs create mode 100644 dotnet/samples/GettingStarted/AGUI/Step03_FrontendTools/Server/Program.cs create mode 100644 dotnet/samples/GettingStarted/AGUI/Step03_FrontendTools/Server/Properties/launchSettings.json create mode 100644 dotnet/samples/GettingStarted/AGUI/Step03_FrontendTools/Server/Server.csproj create mode 100644 dotnet/samples/GettingStarted/AGUI/Step03_FrontendTools/Server/appsettings.Development.json create mode 100644 dotnet/samples/GettingStarted/AGUI/Step03_FrontendTools/Server/appsettings.json create mode 100644 dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Client/Client.csproj create mode 100644 dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Client/Program.cs create mode 100644 dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Client/ServerFunctionApprovalClientAgent.cs create mode 100644 dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Server/Program.cs create mode 100644 dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Server/Properties/launchSettings.json create mode 100644 dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Server/Server.csproj create mode 100644 dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Server/ServerFunctionApprovalServerAgent.cs create mode 100644 dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Server/appsettings.Development.json create mode 100644 dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Server/appsettings.json create mode 100644 dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Client/Client.csproj create mode 100644 dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Client/Program.cs create mode 100644 dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Client/StatefulAgent.cs create mode 100644 dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/Program.cs create mode 100644 dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/Properties/launchSettings.json create mode 100644 dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/RecipeModels.cs create mode 100644 dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/Server.csproj create mode 100644 dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/SharedStateAgent.cs create mode 100644 dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/appsettings.Development.json create mode 100644 dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/appsettings.json diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 46286a22d6..c9a4a0a0ab 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -84,6 +84,29 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/AGUIDojoServerSerializerContext.cs b/dotnet/samples/AGUIClientServer/AGUIDojoServer/AGUIDojoServerSerializerContext.cs index af86dc2598..c60db0efd0 100644 --- a/dotnet/samples/AGUIClientServer/AGUIDojoServer/AGUIDojoServerSerializerContext.cs +++ b/dotnet/samples/AGUIClientServer/AGUIDojoServer/AGUIDojoServerSerializerContext.cs @@ -1,6 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. using System.Text.Json.Serialization; +using AGUIDojoServer.AgenticUI; +using AGUIDojoServer.BackendToolRendering; +using AGUIDojoServer.PredictiveStateUpdates; +using AGUIDojoServer.SharedState; namespace AGUIDojoServer; @@ -8,4 +12,12 @@ namespace AGUIDojoServer; [JsonSerializable(typeof(Recipe))] [JsonSerializable(typeof(Ingredient))] [JsonSerializable(typeof(RecipeResponse))] +[JsonSerializable(typeof(Plan))] +[JsonSerializable(typeof(Step))] +[JsonSerializable(typeof(StepStatus))] +[JsonSerializable(typeof(StepStatus?))] +[JsonSerializable(typeof(JsonPatchOperation))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(DocumentState))] internal sealed partial class AGUIDojoServerSerializerContext : JsonSerializerContext; diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/AgenticPlanningTools.cs b/dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/AgenticPlanningTools.cs new file mode 100644 index 0000000000..98fe96b442 --- /dev/null +++ b/dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/AgenticPlanningTools.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ComponentModel; + +namespace AGUIDojoServer.AgenticUI; + +internal static class AgenticPlanningTools +{ + [Description("Create a plan with multiple steps.")] + public static Plan CreatePlan([Description("List of step descriptions to create the plan.")] List steps) + { + return new Plan + { + Steps = [.. steps.Select(s => new Step { Description = s, Status = StepStatus.Pending })] + }; + } + + [Description("Update a step in the plan with new description or status.")] + public static async Task> UpdatePlanStepAsync( + [Description("The index of the step to update.")] int index, + [Description("The new description for the step (optional).")] string? description = null, + [Description("The new status for the step (optional).")] StepStatus? status = null) + { + var changes = new List(); + + if (description is not null) + { + changes.Add(new JsonPatchOperation + { + Op = "replace", + Path = $"/steps/{index}/description", + Value = description + }); + } + + if (status.HasValue) + { + // Status must be lowercase to match AG-UI frontend expectations: "pending" or "completed" + string statusValue = status.Value == StepStatus.Pending ? "pending" : "completed"; + changes.Add(new JsonPatchOperation + { + Op = "replace", + Path = $"/steps/{index}/status", + Value = statusValue + }); + } + + await Task.Delay(1000); + + return changes; + } +} diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/AgenticUIAgent.cs b/dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/AgenticUIAgent.cs new file mode 100644 index 0000000000..05a7d86f15 --- /dev/null +++ b/dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/AgenticUIAgent.cs @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Text.Json; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace AGUIDojoServer.AgenticUI; + +[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by ChatClientAgentFactory.CreateAgenticUI")] +internal sealed class AgenticUIAgent : DelegatingAIAgent +{ + private readonly JsonSerializerOptions _jsonSerializerOptions; + + public AgenticUIAgent(AIAgent innerAgent, JsonSerializerOptions jsonSerializerOptions) + : base(innerAgent) + { + this._jsonSerializerOptions = jsonSerializerOptions; + } + + public override Task RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + { + return this.RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken); + } + + public override async IAsyncEnumerable RunStreamingAsync( + IEnumerable messages, + AgentThread? thread = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + // Track function calls that should trigger state events + var trackedFunctionCalls = new Dictionary(); + + await foreach (var update in this.InnerAgent.RunStreamingAsync(messages, thread, options, cancellationToken).ConfigureAwait(false)) + { + // Process contents: track function calls and emit state events for results + List stateEventsToEmit = new(); + foreach (var content in update.Contents) + { + if (content is FunctionCallContent callContent) + { + if (callContent.Name == "create_plan" || callContent.Name == "update_plan_step") + { + trackedFunctionCalls[callContent.CallId] = callContent; + break; + } + } + else if (content is FunctionResultContent resultContent) + { + // Check if this result matches a tracked function call + if (trackedFunctionCalls.TryGetValue(resultContent.CallId, out var matchedCall)) + { + var bytes = JsonSerializer.SerializeToUtf8Bytes((JsonElement)resultContent.Result!, this._jsonSerializerOptions); + + // Determine event type based on the function name + if (matchedCall.Name == "create_plan") + { + stateEventsToEmit.Add(new DataContent(bytes, "application/json")); + } + else if (matchedCall.Name == "update_plan_step") + { + stateEventsToEmit.Add(new DataContent(bytes, "application/json-patch+json")); + } + } + } + } + + yield return update; + + yield return new AgentRunResponseUpdate( + new ChatResponseUpdate(role: ChatRole.System, stateEventsToEmit) + { + MessageId = "delta_" + Guid.NewGuid().ToString("N"), + CreatedAt = update.CreatedAt, + ResponseId = update.ResponseId, + AuthorName = update.AuthorName, + Role = update.Role, + ContinuationToken = update.ContinuationToken, + AdditionalProperties = update.AdditionalProperties, + }) + { + AgentId = update.AgentId + }; + } + } +} diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/JsonPatchOperation.cs b/dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/JsonPatchOperation.cs new file mode 100644 index 0000000000..1cd8f5dcd2 --- /dev/null +++ b/dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/JsonPatchOperation.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace AGUIDojoServer.AgenticUI; + +internal sealed class JsonPatchOperation +{ + [JsonPropertyName("op")] + public required string Op { get; set; } + + [JsonPropertyName("path")] + public required string Path { get; set; } + + [JsonPropertyName("value")] + public object? Value { get; set; } + + [JsonPropertyName("from")] + public string? From { get; set; } +} diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/Plan.cs b/dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/Plan.cs new file mode 100644 index 0000000000..a8ffcc6c37 --- /dev/null +++ b/dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/Plan.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace AGUIDojoServer.AgenticUI; + +internal sealed class Plan +{ + [JsonPropertyName("steps")] + public List Steps { get; set; } = []; +} diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/Step.cs b/dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/Step.cs new file mode 100644 index 0000000000..26bc9860a5 --- /dev/null +++ b/dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/Step.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace AGUIDojoServer.AgenticUI; + +internal sealed class Step +{ + [JsonPropertyName("description")] + public required string Description { get; set; } + + [JsonPropertyName("status")] + public StepStatus Status { get; set; } = StepStatus.Pending; +} diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/StepStatus.cs b/dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/StepStatus.cs new file mode 100644 index 0000000000..f88d71bef0 --- /dev/null +++ b/dotnet/samples/AGUIClientServer/AGUIDojoServer/AgenticUI/StepStatus.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace AGUIDojoServer.AgenticUI; + +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum StepStatus +{ + Pending, + Completed +} diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/WeatherInfo.cs b/dotnet/samples/AGUIClientServer/AGUIDojoServer/BackendToolRendering/WeatherInfo.cs similarity index 91% rename from dotnet/samples/AGUIClientServer/AGUIDojoServer/WeatherInfo.cs rename to dotnet/samples/AGUIClientServer/AGUIDojoServer/BackendToolRendering/WeatherInfo.cs index e5b4811739..d6e3be9b80 100644 --- a/dotnet/samples/AGUIClientServer/AGUIDojoServer/WeatherInfo.cs +++ b/dotnet/samples/AGUIClientServer/AGUIDojoServer/BackendToolRendering/WeatherInfo.cs @@ -2,7 +2,7 @@ using System.Text.Json.Serialization; -namespace AGUIDojoServer; +namespace AGUIDojoServer.BackendToolRendering; internal sealed class WeatherInfo { diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs b/dotnet/samples/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs index 5145c5559d..5d6d95d554 100644 --- a/dotnet/samples/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs +++ b/dotnet/samples/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs @@ -2,10 +2,15 @@ using System.ComponentModel; using System.Text.Json; +using AGUIDojoServer.AgenticUI; +using AGUIDojoServer.BackendToolRendering; +using AGUIDojoServer.PredictiveStateUpdates; +using AGUIDojoServer.SharedState; using Azure.AI.OpenAI; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; +using OpenAI; using ChatClient = OpenAI.Chat.ChatClient; namespace AGUIDojoServer; @@ -66,13 +71,46 @@ internal static class ChatClientAgentFactory description: "An agent that uses tools to generate user interfaces using Azure OpenAI"); } - public static ChatClientAgent CreateAgenticUI() + public static AIAgent CreateAgenticUI(JsonSerializerOptions options) { ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!); + var baseAgent = chatClient.AsIChatClient().CreateAIAgent(new ChatClientAgentOptions + { + Name = "AgenticUIAgent", + Description = "An agent that generates agentic user interfaces using Azure OpenAI", + ChatOptions = new ChatOptions + { + Instructions = """ + When planning use tools only, without any other messages. + IMPORTANT: + - Use the `create_plan` tool to set the initial state of the steps + - Use the `update_plan_step` tool to update the status of each step + - Do NOT repeat the plan or summarise it in a message + - Do NOT confirm the creation or updates in a message + - Do NOT ask the user for additional information or next steps + - Do NOT leave a plan hanging, always complete the plan via `update_plan_step` if one is ongoing. + - Continue calling update_plan_step until all steps are marked as completed. - return chatClient.AsIChatClient().CreateAIAgent( - name: "AgenticUIAgent", - description: "An agent that generates agentic user interfaces using Azure OpenAI"); + Only one plan can be active at a time, so do not call the `create_plan` tool + again until all the steps in current plan are completed. + """, + Tools = [ + AIFunctionFactory.Create( + AgenticPlanningTools.CreatePlan, + name: "create_plan", + description: "Create a plan with multiple steps.", + AGUIDojoServerSerializerContext.Default.Options), + AIFunctionFactory.Create( + AgenticPlanningTools.UpdatePlanStepAsync, + name: "update_plan_step", + description: "Update a step in the plan with new description or status.", + AGUIDojoServerSerializerContext.Default.Options) + ], + AllowMultipleToolCalls = false + } + }); + + return new AgenticUIAgent(baseAgent, options); } public static AIAgent CreateSharedState(JsonSerializerOptions options) @@ -86,6 +124,44 @@ internal static class ChatClientAgentFactory return new SharedStateAgent(baseAgent, options); } + public static AIAgent CreatePredictiveStateUpdates(JsonSerializerOptions options) + { + ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!); + + var baseAgent = chatClient.AsIChatClient().CreateAIAgent(new ChatClientAgentOptions + { + Name = "PredictiveStateUpdatesAgent", + Description = "An agent that demonstrates predictive state updates using Azure OpenAI", + ChatOptions = new ChatOptions + { + Instructions = """ + You are a document editor assistant. When asked to write or edit content: + + IMPORTANT: + - Use the `write_document` tool with the full document text in Markdown format + - Format the document extensively so it's easy to read + - You can use all kinds of markdown (headings, lists, bold, etc.) + - However, do NOT use italic or strike-through formatting + - You MUST write the full document, even when changing only a few words + - When making edits to the document, try to make them minimal - do not change every word + - Keep stories SHORT! + - After you are done writing the document you MUST call a confirm_changes tool after you call write_document + + After the user confirms the changes, provide a brief summary of what you wrote. + """, + Tools = [ + AIFunctionFactory.Create( + WriteDocument, + name: "write_document", + description: "Write a document. Use markdown formatting to format the document.", + AGUIDojoServerSerializerContext.Default.Options) + ] + } + }); + + return new PredictiveStateUpdatesAgent(baseAgent, options); + } + [Description("Get the weather for a given location.")] private static WeatherInfo GetWeather([Description("The location to get the weather for.")] string location) => new() { @@ -95,4 +171,11 @@ internal static class ChatClientAgentFactory WindSpeed = 10, FeelsLike = 25 }; + + [Description("Write a document in markdown format.")] + private static string WriteDocument([Description("The document content to write.")] string document) + { + // Simply return success - the document is tracked via state updates + return "Document written successfully"; + } } diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/PredictiveStateUpdates/DocumentState.cs b/dotnet/samples/AGUIClientServer/AGUIDojoServer/PredictiveStateUpdates/DocumentState.cs new file mode 100644 index 0000000000..ad053fe4a2 --- /dev/null +++ b/dotnet/samples/AGUIClientServer/AGUIDojoServer/PredictiveStateUpdates/DocumentState.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace AGUIDojoServer.PredictiveStateUpdates; + +internal sealed class DocumentState +{ + [JsonPropertyName("document")] + public string Document { get; set; } = string.Empty; +} diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/PredictiveStateUpdates/PredictiveStateUpdatesAgent.cs b/dotnet/samples/AGUIClientServer/AGUIDojoServer/PredictiveStateUpdates/PredictiveStateUpdatesAgent.cs new file mode 100644 index 0000000000..8ac9928fbe --- /dev/null +++ b/dotnet/samples/AGUIClientServer/AGUIDojoServer/PredictiveStateUpdates/PredictiveStateUpdatesAgent.cs @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Text.Json; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace AGUIDojoServer.PredictiveStateUpdates; + +[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by ChatClientAgentFactory.CreatePredictiveStateUpdates")] +internal sealed class PredictiveStateUpdatesAgent : DelegatingAIAgent +{ + private readonly JsonSerializerOptions _jsonSerializerOptions; + private const int ChunkSize = 10; // Characters per chunk for streaming effect + + public PredictiveStateUpdatesAgent(AIAgent innerAgent, JsonSerializerOptions jsonSerializerOptions) + : base(innerAgent) + { + this._jsonSerializerOptions = jsonSerializerOptions; + } + + public override Task RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + { + return this.RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken); + } + + public override async IAsyncEnumerable RunStreamingAsync( + IEnumerable messages, + AgentThread? thread = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + // Track the last emitted document state to avoid duplicates + string? lastEmittedDocument = null; + + await foreach (var update in this.InnerAgent.RunStreamingAsync(messages, thread, options, cancellationToken).ConfigureAwait(false)) + { + // Check if we're seeing a write_document tool call and emit predictive state + bool hasToolCall = false; + string? documentContent = null; + + foreach (var content in update.Contents) + { + if (content is FunctionCallContent callContent && callContent.Name == "write_document") + { + hasToolCall = true; + // Try to extract the document argument directly from the dictionary + if (callContent.Arguments?.TryGetValue("document", out var documentValue) == true) + { + documentContent = documentValue?.ToString(); + } + } + } + + // Always yield the original update first + yield return update; + + // If we got a complete tool call with document content, "fake" stream it in chunks + if (hasToolCall && documentContent != null && documentContent != lastEmittedDocument) + { + // Chunk the document content and emit progressive state updates + int startIndex = 0; + if (lastEmittedDocument != null && documentContent.StartsWith(lastEmittedDocument, StringComparison.Ordinal)) + { + // Only stream the new portion that was added + startIndex = lastEmittedDocument.Length; + } + + // Stream the document in chunks + for (int i = startIndex; i < documentContent.Length; i += ChunkSize) + { + int length = Math.Min(ChunkSize, documentContent.Length - i); + string chunk = documentContent.Substring(0, i + length); + + // Prepare predictive state update as DataContent + var stateUpdate = new DocumentState { Document = chunk }; + byte[] stateBytes = JsonSerializer.SerializeToUtf8Bytes( + stateUpdate, + this._jsonSerializerOptions.GetTypeInfo(typeof(DocumentState))); + + yield return new AgentRunResponseUpdate( + new ChatResponseUpdate(role: ChatRole.Assistant, [new DataContent(stateBytes, "application/json")]) + { + MessageId = "snapshot" + Guid.NewGuid().ToString("N"), + CreatedAt = update.CreatedAt, + ResponseId = update.ResponseId, + AdditionalProperties = update.AdditionalProperties, + AuthorName = update.AuthorName, + ContinuationToken = update.ContinuationToken, + }) + { + AgentId = update.AgentId + }; + + // Small delay to simulate streaming + await Task.Delay(50, cancellationToken).ConfigureAwait(false); + } + + lastEmittedDocument = documentContent; + } + } + } +} diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/Program.cs b/dotnet/samples/AGUIClientServer/AGUIDojoServer/Program.cs index 7e9ccca9b9..e3b0020362 100644 --- a/dotnet/samples/AGUIClientServer/AGUIDojoServer/Program.cs +++ b/dotnet/samples/AGUIClientServer/AGUIDojoServer/Program.cs @@ -35,11 +35,13 @@ app.MapAGUI("/human_in_the_loop", ChatClientAgentFactory.CreateHumanInTheLoop()) app.MapAGUI("/tool_based_generative_ui", ChatClientAgentFactory.CreateToolBasedGenerativeUI()); -app.MapAGUI("/agentic_generative_ui", ChatClientAgentFactory.CreateAgenticUI()); - var jsonOptions = app.Services.GetRequiredService>(); +app.MapAGUI("/agentic_generative_ui", ChatClientAgentFactory.CreateAgenticUI(jsonOptions.Value.SerializerOptions)); + app.MapAGUI("/shared_state", ChatClientAgentFactory.CreateSharedState(jsonOptions.Value.SerializerOptions)); +app.MapAGUI("/predictive_state_updates", ChatClientAgentFactory.CreatePredictiveStateUpdates(jsonOptions.Value.SerializerOptions)); + await app.RunAsync(); public partial class Program; diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/Ingredient.cs b/dotnet/samples/AGUIClientServer/AGUIDojoServer/SharedState/Ingredient.cs similarity index 91% rename from dotnet/samples/AGUIClientServer/AGUIDojoServer/Ingredient.cs rename to dotnet/samples/AGUIClientServer/AGUIDojoServer/SharedState/Ingredient.cs index 4be57405ae..d56d88d958 100644 --- a/dotnet/samples/AGUIClientServer/AGUIDojoServer/Ingredient.cs +++ b/dotnet/samples/AGUIClientServer/AGUIDojoServer/SharedState/Ingredient.cs @@ -2,7 +2,7 @@ using System.Text.Json.Serialization; -namespace AGUIDojoServer; +namespace AGUIDojoServer.SharedState; internal sealed class Ingredient { diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/Recipe.cs b/dotnet/samples/AGUIClientServer/AGUIDojoServer/SharedState/Recipe.cs similarity index 94% rename from dotnet/samples/AGUIClientServer/AGUIDojoServer/Recipe.cs rename to dotnet/samples/AGUIClientServer/AGUIDojoServer/SharedState/Recipe.cs index 9af4f6eae9..a8485da839 100644 --- a/dotnet/samples/AGUIClientServer/AGUIDojoServer/Recipe.cs +++ b/dotnet/samples/AGUIClientServer/AGUIDojoServer/SharedState/Recipe.cs @@ -2,7 +2,7 @@ using System.Text.Json.Serialization; -namespace AGUIDojoServer; +namespace AGUIDojoServer.SharedState; internal sealed class Recipe { diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/RecipeResponse.cs b/dotnet/samples/AGUIClientServer/AGUIDojoServer/SharedState/RecipeResponse.cs similarity index 89% rename from dotnet/samples/AGUIClientServer/AGUIDojoServer/RecipeResponse.cs rename to dotnet/samples/AGUIClientServer/AGUIDojoServer/SharedState/RecipeResponse.cs index 0e9b2f2fff..dadf3b7a2b 100644 --- a/dotnet/samples/AGUIClientServer/AGUIDojoServer/RecipeResponse.cs +++ b/dotnet/samples/AGUIClientServer/AGUIDojoServer/SharedState/RecipeResponse.cs @@ -2,7 +2,7 @@ using System.Text.Json.Serialization; -namespace AGUIDojoServer; +namespace AGUIDojoServer.SharedState; #pragma warning disable CA1812 // Used for the JsonSchema response format internal sealed class RecipeResponse diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/SharedStateAgent.cs b/dotnet/samples/AGUIClientServer/AGUIDojoServer/SharedState/SharedStateAgent.cs similarity index 99% rename from dotnet/samples/AGUIClientServer/AGUIDojoServer/SharedStateAgent.cs rename to dotnet/samples/AGUIClientServer/AGUIDojoServer/SharedState/SharedStateAgent.cs index ea2f1d319f..c10450fcfb 100644 --- a/dotnet/samples/AGUIClientServer/AGUIDojoServer/SharedStateAgent.cs +++ b/dotnet/samples/AGUIClientServer/AGUIDojoServer/SharedState/SharedStateAgent.cs @@ -6,7 +6,7 @@ using System.Text.Json; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; -namespace AGUIDojoServer; +namespace AGUIDojoServer.SharedState; [SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by ChatClientAgentFactory.CreateSharedState")] internal sealed class SharedStateAgent : DelegatingAIAgent diff --git a/dotnet/samples/AGUIWebChat/Client/AGUIWebChatClient.csproj b/dotnet/samples/AGUIWebChat/Client/AGUIWebChatClient.csproj new file mode 100644 index 0000000000..b28e53df6e --- /dev/null +++ b/dotnet/samples/AGUIWebChat/Client/AGUIWebChatClient.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + true + + + + + + + diff --git a/dotnet/samples/AGUIWebChat/Client/Components/App.razor b/dotnet/samples/AGUIWebChat/Client/Components/App.razor new file mode 100644 index 0000000000..a64d576883 --- /dev/null +++ b/dotnet/samples/AGUIWebChat/Client/Components/App.razor @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + +@code { + private readonly IComponentRenderMode renderMode = new InteractiveServerRenderMode(prerender: false); +} diff --git a/dotnet/samples/AGUIWebChat/Client/Components/Layout/LoadingSpinner.razor b/dotnet/samples/AGUIWebChat/Client/Components/Layout/LoadingSpinner.razor new file mode 100644 index 0000000000..116455ce45 --- /dev/null +++ b/dotnet/samples/AGUIWebChat/Client/Components/Layout/LoadingSpinner.razor @@ -0,0 +1 @@ +
diff --git a/dotnet/samples/AGUIWebChat/Client/Components/Layout/LoadingSpinner.razor.css b/dotnet/samples/AGUIWebChat/Client/Components/Layout/LoadingSpinner.razor.css new file mode 100644 index 0000000000..e599d27e86 --- /dev/null +++ b/dotnet/samples/AGUIWebChat/Client/Components/Layout/LoadingSpinner.razor.css @@ -0,0 +1,89 @@ +/* Used under CC0 license */ + +.lds-ellipsis { + color: #666; + animation: fade-in 1s; +} + +@keyframes fade-in { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + + .lds-ellipsis, + .lds-ellipsis div { + box-sizing: border-box; + } + +.lds-ellipsis { + margin: auto; + display: block; + position: relative; + width: 80px; + height: 80px; +} + + .lds-ellipsis div { + position: absolute; + top: 33.33333px; + width: 10px; + height: 10px; + border-radius: 50%; + background: currentColor; + animation-timing-function: cubic-bezier(0, 1, 1, 0); + } + + .lds-ellipsis div:nth-child(1) { + left: 8px; + animation: lds-ellipsis1 0.6s infinite; + } + + .lds-ellipsis div:nth-child(2) { + left: 8px; + animation: lds-ellipsis2 0.6s infinite; + } + + .lds-ellipsis div:nth-child(3) { + left: 32px; + animation: lds-ellipsis2 0.6s infinite; + } + + .lds-ellipsis div:nth-child(4) { + left: 56px; + animation: lds-ellipsis3 0.6s infinite; + } + +@keyframes lds-ellipsis1 { + 0% { + transform: scale(0); + } + + 100% { + transform: scale(1); + } +} + +@keyframes lds-ellipsis3 { + 0% { + transform: scale(1); + } + + 100% { + transform: scale(0); + } +} + +@keyframes lds-ellipsis2 { + 0% { + transform: translate(0, 0); + } + + 100% { + transform: translate(24px, 0); + } +} diff --git a/dotnet/samples/AGUIWebChat/Client/Components/Layout/MainLayout.razor b/dotnet/samples/AGUIWebChat/Client/Components/Layout/MainLayout.razor new file mode 100644 index 0000000000..f3da3cbae5 --- /dev/null +++ b/dotnet/samples/AGUIWebChat/Client/Components/Layout/MainLayout.razor @@ -0,0 +1,9 @@ +@inherits LayoutComponentBase + +@Body + +
+ An unhandled error has occurred. + Reload + 🗙 +
diff --git a/dotnet/samples/AGUIWebChat/Client/Components/Layout/MainLayout.razor.css b/dotnet/samples/AGUIWebChat/Client/Components/Layout/MainLayout.razor.css new file mode 100644 index 0000000000..60cec92d5e --- /dev/null +++ b/dotnet/samples/AGUIWebChat/Client/Components/Layout/MainLayout.razor.css @@ -0,0 +1,20 @@ +#blazor-error-ui { + color-scheme: light only; + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + box-sizing: border-box; + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } diff --git a/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/Chat.razor b/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/Chat.razor new file mode 100644 index 0000000000..31eb7e406c --- /dev/null +++ b/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/Chat.razor @@ -0,0 +1,94 @@ +@page "/" +@using System.ComponentModel +@inject IChatClient ChatClient +@inject NavigationManager Nav +@implements IDisposable + +Chat + + + + + +
Ask the assistant a question to start a conversation.
+
+
+
+ + +
+ +@code { + private const string SystemPrompt = @" + You are a helpful assistant. + "; + + private int statefulMessageCount; + private readonly ChatOptions chatOptions = new(); + private readonly List messages = new(); + private CancellationTokenSource? currentResponseCancellation; + private ChatMessage? currentResponseMessage; + private ChatInput? chatInput; + private ChatSuggestions? chatSuggestions; + + protected override void OnInitialized() + { + statefulMessageCount = 0; + messages.Add(new(ChatRole.System, SystemPrompt)); + } + + private async Task AddUserMessageAsync(ChatMessage userMessage) + { + CancelAnyCurrentResponse(); + + // Add the user message to the conversation + messages.Add(userMessage); + chatSuggestions?.Clear(); + await chatInput!.FocusAsync(); + + // Stream and display a new response from the IChatClient + var responseText = new TextContent(""); + currentResponseMessage = new ChatMessage(ChatRole.Assistant, [responseText]); + StateHasChanged(); + currentResponseCancellation = new(); + await foreach (var update in ChatClient.GetStreamingResponseAsync(messages.Skip(statefulMessageCount), chatOptions, currentResponseCancellation.Token)) + { + messages.AddMessages(update, filter: c => c is not TextContent); + responseText.Text += update.Text; + chatOptions.ConversationId = update.ConversationId; + ChatMessageItem.NotifyChanged(currentResponseMessage); + } + + // Store the final response in the conversation, and begin getting suggestions + messages.Add(currentResponseMessage!); + statefulMessageCount = chatOptions.ConversationId is not null ? messages.Count : 0; + currentResponseMessage = null; + chatSuggestions?.Update(messages); + } + + private void CancelAnyCurrentResponse() + { + // If a response was cancelled while streaming, include it in the conversation so it's not lost + if (currentResponseMessage is not null) + { + messages.Add(currentResponseMessage); + } + + currentResponseCancellation?.Cancel(); + currentResponseMessage = null; + } + + private async Task ResetConversationAsync() + { + CancelAnyCurrentResponse(); + messages.Clear(); + messages.Add(new(ChatRole.System, SystemPrompt)); + chatOptions.ConversationId = null; + statefulMessageCount = 0; + chatSuggestions?.Clear(); + await chatInput!.FocusAsync(); + } + + public void Dispose() + => currentResponseCancellation?.Cancel(); +} diff --git a/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/Chat.razor.css b/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/Chat.razor.css new file mode 100644 index 0000000000..08841605f6 --- /dev/null +++ b/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/Chat.razor.css @@ -0,0 +1,11 @@ +.chat-container { + position: sticky; + bottom: 0; + padding-left: 1.5rem; + padding-right: 1.5rem; + padding-top: 0.75rem; + padding-bottom: 1.5rem; + border-top-width: 1px; + background-color: #F3F4F6; + border-color: #E5E7EB; +} diff --git a/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatCitation.razor b/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatCitation.razor new file mode 100644 index 0000000000..ccb5853cec --- /dev/null +++ b/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatCitation.razor @@ -0,0 +1,38 @@ +@using System.Web +@if (!string.IsNullOrWhiteSpace(viewerUrl)) +{ + + + + +
+
@File
+
@Quote
+
+
+} + +@code { + [Parameter] + public required string File { get; set; } + + [Parameter] + public int? PageNumber { get; set; } + + [Parameter] + public required string Quote { get; set; } + + private string? viewerUrl; + + protected override void OnParametersSet() + { + viewerUrl = null; + + // If you ingest other types of content besides PDF files, construct a URL to an appropriate viewer here + if (File.EndsWith(".pdf")) + { + var search = Quote?.Trim('.', ',', ' ', '\n', '\r', '\t', '"', '\''); + viewerUrl = $"lib/pdf_viewer/viewer.html?file=/Data/{HttpUtility.UrlEncode(File)}#page={PageNumber}&search={HttpUtility.UrlEncode(search)}&phrase=true"; + } + } +} diff --git a/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatCitation.razor.css b/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatCitation.razor.css new file mode 100644 index 0000000000..763c82aec4 --- /dev/null +++ b/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatCitation.razor.css @@ -0,0 +1,37 @@ +.citation { + display: inline-flex; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + padding-left: 0.75rem; + padding-right: 0.75rem; + margin-top: 1rem; + margin-right: 1rem; + border-bottom: 2px solid #a770de; + gap: 0.5rem; + border-radius: 0.25rem; + font-size: 0.875rem; + line-height: 1.25rem; + background-color: #ffffff; +} + + .citation[href]:hover { + outline: 1px solid #865cb1; + } + + .citation svg { + width: 1.5rem; + height: 1.5rem; + } + + .citation:active { + background-color: rgba(0,0,0,0.05); + } + +.citation-content { + display: flex; + flex-direction: column; +} + +.citation-file { + font-weight: 600; +} diff --git a/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatHeader.razor b/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatHeader.razor new file mode 100644 index 0000000000..a339038e2a --- /dev/null +++ b/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatHeader.razor @@ -0,0 +1,17 @@ +
+
+ +
+ +

AGUI WebChat

+
+ +@code { + [Parameter] + public EventCallback OnNewChat { get; set; } +} diff --git a/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatHeader.razor.css b/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatHeader.razor.css new file mode 100644 index 0000000000..97f0a8d43a --- /dev/null +++ b/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatHeader.razor.css @@ -0,0 +1,25 @@ +.chat-header-container { + top: 0; + padding: 1.5rem; +} + +.chat-header-controls { + margin-bottom: 1.5rem; +} + +h1 { + overflow: hidden; + text-overflow: ellipsis; +} + +.new-chat-icon { + width: 1.25rem; + height: 1.25rem; + color: rgb(55, 65, 81); +} + +@media (min-width: 768px) { + .chat-header-container { + position: sticky; + } +} diff --git a/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatInput.razor b/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatInput.razor new file mode 100644 index 0000000000..e87ac6ccf4 --- /dev/null +++ b/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatInput.razor @@ -0,0 +1,51 @@ +@inject IJSRuntime JS + + + + + +@code { + private ElementReference textArea; + private string? messageText; + + [Parameter] + public EventCallback OnSend { get; set; } + + public ValueTask FocusAsync() + => textArea.FocusAsync(); + + private async Task SendMessageAsync() + { + if (messageText is { Length: > 0 } text) + { + messageText = null; + await OnSend.InvokeAsync(new ChatMessage(ChatRole.User, text)); + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + try + { + var module = await JS.InvokeAsync("import", "./Components/Pages/Chat/ChatInput.razor.js"); + await module.InvokeVoidAsync("init", textArea); + await module.DisposeAsync(); + } + catch (JSDisconnectedException) + { + } + } + } +} diff --git a/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatInput.razor.css b/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatInput.razor.css new file mode 100644 index 0000000000..375dd711d9 --- /dev/null +++ b/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatInput.razor.css @@ -0,0 +1,57 @@ +.input-box { + display: flex; + flex-direction: column; + background: white; + border: 1px solid rgb(229, 231, 235); + border-radius: 8px; + padding: 0.5rem 0.75rem; + margin-top: 0.75rem; +} + + .input-box:focus-within { + outline: 2px solid #4152d5; + } + +textarea { + resize: none; + border: none; + outline: none; + flex-grow: 1; +} + + textarea:placeholder-shown + .tools { + --send-button-color: #aaa; + } + +.tools { + display: flex; + margin-top: 1rem; + align-items: center; +} + +.tool-icon { + width: 1.25rem; + height: 1.25rem; +} + +.send-button { + color: var(--send-button-color); + margin-left: auto; +} + + .send-button:hover { + color: black; + } + +.attach { + background-color: white; + border-style: dashed; + color: #888; + border-color: #888; + padding: 3px 8px; +} + + .attach:hover { + background-color: #f0f0f0; + color: black; + } diff --git a/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatInput.razor.js b/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatInput.razor.js new file mode 100644 index 0000000000..e4bd8af20a --- /dev/null +++ b/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatInput.razor.js @@ -0,0 +1,43 @@ +export function init(elem) { + elem.focus(); + + // Auto-resize whenever the user types or if the value is set programmatically + elem.addEventListener('input', () => resizeToFit(elem)); + afterPropertyWritten(elem, 'value', () => resizeToFit(elem)); + + // Auto-submit the form on 'enter' keypress + elem.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + elem.dispatchEvent(new CustomEvent('change', { bubbles: true })); + elem.closest('form').dispatchEvent(new CustomEvent('submit', { bubbles: true, cancelable: true })); + } + }); +} + +function resizeToFit(elem) { + const lineHeight = parseFloat(getComputedStyle(elem).lineHeight); + + elem.rows = 1; + const numLines = Math.ceil(elem.scrollHeight / lineHeight); + elem.rows = Math.min(5, Math.max(1, numLines)); +} + +function afterPropertyWritten(target, propName, callback) { + const descriptor = getPropertyDescriptor(target, propName); + Object.defineProperty(target, propName, { + get: function () { + return descriptor.get.apply(this, arguments); + }, + set: function () { + const result = descriptor.set.apply(this, arguments); + callback(); + return result; + } + }); +} + +function getPropertyDescriptor(target, propertyName) { + return Object.getOwnPropertyDescriptor(target, propertyName) + || getPropertyDescriptor(Object.getPrototypeOf(target), propertyName); +} diff --git a/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatMessageItem.razor b/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatMessageItem.razor new file mode 100644 index 0000000000..6f4e1357c9 --- /dev/null +++ b/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatMessageItem.razor @@ -0,0 +1,73 @@ +@using System.Runtime.CompilerServices +@using System.Text.RegularExpressions +@using System.Linq + +@if (Message.Role == ChatRole.User) +{ +
+ @Message.Text +
+} +else if (Message.Role == ChatRole.Assistant) +{ + foreach (var content in Message.Contents) + { + if (content is TextContent { Text: { Length: > 0 } text }) + { +
+
+
+ + + +
+
+
Assistant
+
+
@((MarkupString)text)
+
+
+ } + else if (content is FunctionCallContent { Name: "Search" } fcc && fcc.Arguments?.TryGetValue("searchPhrase", out var searchPhrase) is true) + { + + } + } +} + +@code { + private static readonly ConditionalWeakTable SubscribersLookup = new(); + + [Parameter, EditorRequired] + public required ChatMessage Message { get; set; } + + [Parameter] + public bool InProgress { get; set;} + + protected override void OnInitialized() + { + SubscribersLookup.AddOrUpdate(Message, this); + } + + public static void NotifyChanged(ChatMessage source) + { + if (SubscribersLookup.TryGetValue(source, out var subscriber)) + { + subscriber.StateHasChanged(); + } + } +} diff --git a/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatMessageItem.razor.css b/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatMessageItem.razor.css new file mode 100644 index 0000000000..16443cf657 --- /dev/null +++ b/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatMessageItem.razor.css @@ -0,0 +1,67 @@ +.user-message { + background: rgb(182 215 232); + align-self: flex-end; + min-width: 25%; + max-width: calc(100% - 5rem); + padding: 0.5rem 1.25rem; + border-radius: 0.25rem; + color: #1F2937; + white-space: pre-wrap; +} + +.assistant-message, .assistant-search { + display: grid; + grid-template-rows: min-content; + grid-template-columns: 2rem minmax(0, 1fr); + gap: 0.25rem; +} + +.assistant-message-header { + font-weight: 600; +} + +.assistant-message-text { + grid-column-start: 2; +} + +.assistant-message-icon { + display: flex; + justify-content: center; + align-items: center; + border-radius: 9999px; + width: 1.5rem; + height: 1.5rem; + color: #ffffff; + background: #9b72ce; +} + + .assistant-message-icon svg { + width: 1rem; + height: 1rem; + } + +.assistant-search { + font-size: 0.875rem; + line-height: 1.25rem; +} + +.assistant-search-icon { + display: flex; + justify-content: center; + align-items: center; + width: 1.5rem; + height: 1.5rem; +} + + .assistant-search-icon svg { + width: 1rem; + height: 1rem; + } + +.assistant-search-content { + align-content: center; +} + +.assistant-search-phrase { + font-weight: 600; +} diff --git a/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatMessageList.razor b/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatMessageList.razor new file mode 100644 index 0000000000..d245f455f1 --- /dev/null +++ b/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatMessageList.razor @@ -0,0 +1,42 @@ +@inject IJSRuntime JS + +
+ + @foreach (var message in Messages) + { + + } + + @if (InProgressMessage is not null) + { + + + } + else if (IsEmpty) + { +
@NoMessagesContent
+ } +
+
+ +@code { + [Parameter] + public required IEnumerable Messages { get; set; } + + [Parameter] + public ChatMessage? InProgressMessage { get; set; } + + [Parameter] + public RenderFragment? NoMessagesContent { get; set; } + + private bool IsEmpty => !Messages.Any(m => (m.Role == ChatRole.User || m.Role == ChatRole.Assistant) && !string.IsNullOrEmpty(m.Text)); + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + // Activates the auto-scrolling behavior + await JS.InvokeVoidAsync("import", "./Components/Pages/Chat/ChatMessageList.razor.js"); + } + } +} diff --git a/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatMessageList.razor.css b/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatMessageList.razor.css new file mode 100644 index 0000000000..4be50ddfc3 --- /dev/null +++ b/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatMessageList.razor.css @@ -0,0 +1,22 @@ +.message-list-container { + margin: 2rem 1.5rem; + flex-grow: 1; +} + +.message-list { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.no-messages { + text-align: center; + font-size: 1.25rem; + color: #999; + margin-top: calc(40vh - 18rem); +} + +chat-messages > ::deep div:last-of-type { + /* Adds some vertical buffer to so that suggestions don't overlap the output when they appear */ + margin-bottom: 2rem; +} diff --git a/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatMessageList.razor.js b/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatMessageList.razor.js new file mode 100644 index 0000000000..9755d47c29 --- /dev/null +++ b/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatMessageList.razor.js @@ -0,0 +1,34 @@ +// The following logic provides auto-scroll behavior for the chat messages list. +// If you don't want that behavior, you can simply not load this module. + +window.customElements.define('chat-messages', class ChatMessages extends HTMLElement { + static _isFirstAutoScroll = true; + + connectedCallback() { + this._observer = new MutationObserver(mutations => this._scheduleAutoScroll(mutations)); + this._observer.observe(this, { childList: true, attributes: true }); + } + + disconnectedCallback() { + this._observer.disconnect(); + } + + _scheduleAutoScroll(mutations) { + // Debounce the calls in case multiple DOM updates occur together + cancelAnimationFrame(this._nextAutoScroll); + this._nextAutoScroll = requestAnimationFrame(() => { + const addedUserMessage = mutations.some(m => Array.from(m.addedNodes).some(n => n.parentElement === this && n.classList?.contains('user-message'))); + const elem = this.lastElementChild; + if (ChatMessages._isFirstAutoScroll || addedUserMessage || this._elemIsNearScrollBoundary(elem, 300)) { + elem.scrollIntoView({ behavior: ChatMessages._isFirstAutoScroll ? 'instant' : 'smooth' }); + ChatMessages._isFirstAutoScroll = false; + } + }); + } + + _elemIsNearScrollBoundary(elem, threshold) { + const maxScrollPos = document.body.scrollHeight - window.innerHeight; + const remainingScrollDistance = maxScrollPos - window.scrollY; + return remainingScrollDistance < elem.offsetHeight + threshold; + } +}); diff --git a/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatSuggestions.razor b/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatSuggestions.razor new file mode 100644 index 0000000000..69ca922a8c --- /dev/null +++ b/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatSuggestions.razor @@ -0,0 +1,78 @@ +@inject IChatClient ChatClient + +@if (suggestions is not null) +{ +
+ @foreach (var suggestion in suggestions) + { + + } +
+} + +@code { + private static string Prompt = @" + Suggest up to 3 follow-up questions that I could ask you to help me complete my task. + Each suggestion must be a complete sentence, maximum 6 words. + Each suggestion must be phrased as something that I (the user) would ask you (the assistant) in response to your previous message, + for example 'How do I do that?' or 'Explain ...'. + If there are no suggestions, reply with an empty list. + "; + + private string[]? suggestions; + private CancellationTokenSource? cancellation; + + [Parameter] + public EventCallback OnSelected { get; set; } + + public void Clear() + { + suggestions = null; + cancellation?.Cancel(); + } + + public void Update(IReadOnlyList messages) + { + // Runs in the background and handles its own cancellation/errors + _ = UpdateSuggestionsAsync(messages); + } + + private async Task UpdateSuggestionsAsync(IReadOnlyList messages) + { + cancellation?.Cancel(); + cancellation = new CancellationTokenSource(); + + try + { + var response = await ChatClient.GetResponseAsync( + [.. ReduceMessages(messages), new(ChatRole.User, Prompt)], + cancellationToken: cancellation.Token); + if (!response.TryGetResult(out suggestions)) + { + suggestions = null; + } + + StateHasChanged(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + await DispatchExceptionAsync(ex); + } + } + + private async Task AddSuggestionAsync(string text) + { + await OnSelected.InvokeAsync(new(ChatRole.User, text)); + } + + private IEnumerable ReduceMessages(IReadOnlyList messages) + { + // Get any leading system messages, plus up to 5 user/assistant messages + // This should be enough context to generate suggestions without unnecessarily resending entire conversations when long + var systemMessages = messages.TakeWhile(m => m.Role == ChatRole.System); + var otherMessages = messages.Where((m, index) => m.Role == ChatRole.User || m.Role == ChatRole.Assistant).Where(m => !string.IsNullOrEmpty(m.Text)).TakeLast(5); + return systemMessages.Concat(otherMessages); + } +} diff --git a/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatSuggestions.razor.css b/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatSuggestions.razor.css new file mode 100644 index 0000000000..dcc7ee8bd8 --- /dev/null +++ b/dotnet/samples/AGUIWebChat/Client/Components/Pages/Chat/ChatSuggestions.razor.css @@ -0,0 +1,9 @@ +.suggestions { + text-align: right; + white-space: nowrap; + gap: 0.5rem; + justify-content: flex-end; + flex-wrap: wrap; + display: flex; + margin-bottom: 0.75rem; +} diff --git a/dotnet/samples/AGUIWebChat/Client/Components/Routes.razor b/dotnet/samples/AGUIWebChat/Client/Components/Routes.razor new file mode 100644 index 0000000000..faa2a8c2d5 --- /dev/null +++ b/dotnet/samples/AGUIWebChat/Client/Components/Routes.razor @@ -0,0 +1,6 @@ + + + + + + diff --git a/dotnet/samples/AGUIWebChat/Client/Components/_Imports.razor b/dotnet/samples/AGUIWebChat/Client/Components/_Imports.razor new file mode 100644 index 0000000000..82be3d448e --- /dev/null +++ b/dotnet/samples/AGUIWebChat/Client/Components/_Imports.razor @@ -0,0 +1,12 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using AGUIWebChatClient +@using AGUIWebChatClient.Components +@using AGUIWebChatClient.Components.Layout +@using Microsoft.Extensions.AI diff --git a/dotnet/samples/AGUIWebChat/Client/Program.cs b/dotnet/samples/AGUIWebChat/Client/Program.cs new file mode 100644 index 0000000000..c145227062 --- /dev/null +++ b/dotnet/samples/AGUIWebChat/Client/Program.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft. All rights reserved. + +using AGUIWebChatClient.Components; +using Microsoft.Agents.AI.AGUI; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); + +string serverUrl = builder.Configuration["SERVER_URL"] ?? "http://localhost:5100"; + +builder.Services.AddHttpClient("aguiserver", httpClient => httpClient.BaseAddress = new Uri(serverUrl)); + +builder.Services.AddChatClient(sp => new AGUIChatClient( + sp.GetRequiredService().CreateClient("aguiserver"), "ag-ui")); + +WebApplication app = builder.Build(); + +// Configure the HTTP request pipeline. +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + app.UseHsts(); +} + +app.UseHttpsRedirection(); +app.UseAntiforgery(); +app.MapStaticAssets(); +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +app.Run(); diff --git a/dotnet/samples/AGUIWebChat/Client/Properties/launchSettings.json b/dotnet/samples/AGUIWebChat/Client/Properties/launchSettings.json new file mode 100644 index 0000000000..348e16bc3b --- /dev/null +++ b/dotnet/samples/AGUIWebChat/Client/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "SERVER_URL": "http://localhost:5100" + } + } + } +} diff --git a/dotnet/samples/AGUIWebChat/Client/wwwroot/app.css b/dotnet/samples/AGUIWebChat/Client/wwwroot/app.css new file mode 100644 index 0000000000..5fd82f3bb0 --- /dev/null +++ b/dotnet/samples/AGUIWebChat/Client/wwwroot/app.css @@ -0,0 +1,93 @@ +html { + min-height: 100vh; +} + +html, .main-background-gradient { + background: linear-gradient(to bottom, rgb(225 227 233), #f4f4f4 25rem); +} + +body { + display: flex; + flex-direction: column; + min-height: 100vh; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; +} + +html::after { + content: ''; + background-image: linear-gradient(to right, #3a4ed5, #3acfd5 15%, #d53abf 85%, red); + width: 100%; + height: 2px; + position: fixed; + top: 0; +} + +h1 { + font-size: 2.25rem; + line-height: 2.5rem; + font-weight: 600; +} + +h1:focus { + outline: none; +} + +.valid.modified:not([type=checkbox]) { + outline: 1px solid #26b050; +} + +.invalid { + outline: 1px solid #e50000; +} + +.validation-message { + color: #e50000; +} + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } + +.btn-default { + display: flex; + padding: 0.25rem 0.75rem; + gap: 0.25rem; + align-items: center; + border-radius: 0.25rem; + border: 1px solid #9CA3AF; + font-size: 0.875rem; + line-height: 1.25rem; + font-weight: 600; + background-color: #D1D5DB; +} + + .btn-default:hover { + background-color: #E5E7EB; + } + +.btn-subtle { + display: flex; + padding: 0.25rem 0.75rem; + gap: 0.25rem; + align-items: center; + border-radius: 0.25rem; + border: 1px solid #D1D5DB; + font-size: 0.875rem; + line-height: 1.25rem; +} + + .btn-subtle:hover { + border-color: #93C5FD; + background-color: #DBEAFE; + } + +.page-width { + max-width: 1024px; + margin: auto; +} diff --git a/dotnet/samples/AGUIWebChat/Client/wwwroot/favicon.png b/dotnet/samples/AGUIWebChat/Client/wwwroot/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..8422b59695935d180d11d5dbe99653e711097819 GIT binary patch literal 1148 zcmV-?1cUpDP)9h26h2-Cs%i*@Moc3?#6qJID|D#|3|2Hn7gTIYEkr|%Xjp);YgvFmB&0#2E2b=| zkVr)lMv9=KqwN&%obTp-$<51T%rx*NCwceh-E+=&e(oLO`@Z~7gybJ#U|^tB2Pai} zRN@5%1qsZ1e@R(XC8n~)nU1S0QdzEYlWPdUpH{wJ2Pd4V8kI3BM=)sG^IkUXF2-j{ zrPTYA6sxpQ`Q1c6mtar~gG~#;lt=s^6_OccmRd>o{*=>)KS=lM zZ!)iG|8G0-9s3VLm`bsa6e ze*TlRxAjXtm^F8V`M1%s5d@tYS>&+_ga#xKGb|!oUBx3uc@mj1%=MaH4GR0tPBG_& z9OZE;->dO@`Q)nr<%dHAsEZRKl zedN6+3+uGHejJp;Q==pskSAcRcyh@6mjm2z-uG;s%dM-u0*u##7OxI7wwyCGpS?4U zBFAr(%GBv5j$jS@@t@iI8?ZqE36I^4t+P^J9D^ELbS5KMtZ z{Qn#JnSd$15nJ$ggkF%I4yUQC+BjDF^}AtB7w348EL>7#sAsLWs}ndp8^DsAcOIL9 zTOO!!0!k2`9BLk25)NeZp7ev>I1Mn={cWI3Yhx2Q#DnAo4IphoV~R^c0x&nw*MoIV zPthX?{6{u}sMS(MxD*dmd5rU(YazQE59b|TsB5Tm)I4a!VaN@HYOR)DwH1U5y(E)z zQqQU*B%MwtRQ$%x&;1p%ANmc|PkoFJZ%<-uq%PX&C!c-7ypis=eP+FCeuv+B@h#{4 zGx1m0PjS~FJt}3mdt4c!lel`1;4W|03kcZRG+DzkTy|7-F~eDsV2Tx!73dM0H0CTh zl)F-YUkE1zEzEW(;JXc|KR5{ox%YTh{$%F$a36JP6Nb<0%#NbSh$dMYF-{ z1_x(Vx)}fs?5_|!5xBTWiiIQHG<%)*e=45Fhjw_tlnmlixq;mUdC$R8v#j( zhQ$9YR-o%i5Uc`S?6EC51!bTRK=Xkyb<18FkCKnS2;o*qlij1YA@-nRpq#OMTX&RbL<^2q@0qja!uIvI;j$6>~k@IMwD42=8$$!+R^@5o6HX(*n~ httpClient.BaseAddress = new Uri(serverUrl)); + +builder.Services.AddChatClient(sp => new AGUIChatClient( + sp.GetRequiredService().CreateClient("aguiserver"), "ag-ui")); +``` + +The Blazor UI (`Client/Components/Pages/Chat/Chat.razor`) uses the `IChatClient` to: +- Send user messages to the agent +- Stream responses back in real-time +- Maintain conversation history +- Display messages with appropriate styling + +### UI Components + +The chat interface is built from several Blazor components: + +- **Chat.razor** - Main chat page coordinating the conversation flow +- **ChatHeader.razor** - Header with "New chat" button +- **ChatMessageList.razor** - Scrollable list of messages with auto-scroll +- **ChatMessageItem.razor** - Individual message rendering (user vs assistant) +- **ChatInput.razor** - Text input with auto-resize and keyboard shortcuts +- **ChatSuggestions.razor** - AI-generated follow-up question suggestions +- **LoadingSpinner.razor** - Animated loading indicator during streaming + +## Configuration + +### Server Configuration + +The server URL and port are configured in `Server/Properties/launchSettings.json`: + +```json +{ + "profiles": { + "http": { + "applicationUrl": "http://localhost:5100" + } + } +} +``` + +### Client Configuration + +The client connects to the server URL specified in `Client/Properties/launchSettings.json`: + +```json +{ + "profiles": { + "http": { + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "SERVER_URL": "http://localhost:5100" + } + } + } +} +``` + +To change the server URL, modify the `SERVER_URL` environment variable in the client's launch settings or provide it at runtime: + +```powershell +$env:SERVER_URL="http://your-server:5100" +dotnet run +``` + +## Customization + +### Changing the Agent Instructions + +Edit the instructions in `Server/Program.cs`: + +```csharp +ChatClientAgent agent = chatClient.AsIChatClient().CreateAIAgent( + name: "ChatAssistant", + instructions: "You are a helpful coding assistant specializing in C# and .NET."); +``` + +### Styling the UI + +The chat interface uses CSS files colocated with each Razor component. Key styles: + +- `wwwroot/app.css` - Global styles, buttons, color scheme +- `Components/Pages/Chat/Chat.razor.css` - Chat container layout +- `Components/Pages/Chat/ChatMessageItem.razor.css` - Message bubbles and icons +- `Components/Pages/Chat/ChatInput.razor.css` - Input box styling + +### Disabling Suggestions + +To disable the AI-generated follow-up suggestions, comment out the suggestions component in `Chat.razor`: + +```razor +@* *@ +``` diff --git a/dotnet/samples/AGUIWebChat/Server/AGUIWebChatServer.csproj b/dotnet/samples/AGUIWebChat/Server/AGUIWebChatServer.csproj new file mode 100644 index 0000000000..c45adfd4a8 --- /dev/null +++ b/dotnet/samples/AGUIWebChat/Server/AGUIWebChatServer.csproj @@ -0,0 +1,21 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + + + + diff --git a/dotnet/samples/AGUIWebChat/Server/Program.cs b/dotnet/samples/AGUIWebChat/Server/Program.cs new file mode 100644 index 0000000000..1683a7e3ed --- /dev/null +++ b/dotnet/samples/AGUIWebChat/Server/Program.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates a basic AG-UI server hosting a chat agent for the Blazor web client. + +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; +using Microsoft.Extensions.AI; +using OpenAI.Chat; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +builder.Services.AddHttpClient().AddLogging(); +builder.Services.AddAGUI(); + +WebApplication app = builder.Build(); + +string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"] ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); + +// Create the AI agent +AzureOpenAIClient azureOpenAIClient = new( + new Uri(endpoint), + new DefaultAzureCredential()); + +ChatClient chatClient = azureOpenAIClient.GetChatClient(deploymentName); + +ChatClientAgent agent = chatClient.AsIChatClient().CreateAIAgent( + name: "ChatAssistant", + instructions: "You are a helpful assistant."); + +// Map the AG-UI agent endpoint +app.MapAGUI("/ag-ui", agent); + +await app.RunAsync(); diff --git a/dotnet/samples/AGUIWebChat/Server/Properties/launchSettings.json b/dotnet/samples/AGUIWebChat/Server/Properties/launchSettings.json new file mode 100644 index 0000000000..4d84174f7a --- /dev/null +++ b/dotnet/samples/AGUIWebChat/Server/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5100", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/dotnet/samples/GettingStarted/AGUI/README.md b/dotnet/samples/GettingStarted/AGUI/README.md new file mode 100644 index 0000000000..a624fe81f3 --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/README.md @@ -0,0 +1,304 @@ +# AG-UI Getting Started Samples + +This directory contains samples that demonstrate how to build AG-UI (Agent UI Protocol) servers and clients using the Microsoft Agent Framework. + +## Prerequisites + +- .NET 9.0 or later +- Azure OpenAI service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) +- User has the `Cognitive Services OpenAI Contributor` role for the Azure OpenAI resource + +## Environment Variables + +All samples require the following environment variables: + +```bash +export AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" +export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +For the client samples, you can optionally set: + +```bash +export AGUI_SERVER_URL="http://localhost:8888" +``` + +## Samples + +### Step01_GettingStarted + +A basic AG-UI server and client that demonstrate the foundational concepts. + +#### Server (`Step01_GettingStarted/Server`) + +A basic AG-UI server that hosts an AI agent accessible via HTTP. Demonstrates: + +- Creating an ASP.NET Core web application +- Setting up an AG-UI server endpoint with `MapAGUI` +- Creating an AI agent from an Azure OpenAI chat client +- Streaming responses via Server-Sent Events (SSE) + +**Run the server:** + +```bash +cd Step01_GettingStarted/Server +dotnet run --urls http://localhost:8888 +``` + +#### Client (`Step01_GettingStarted/Client`) + +An interactive console client that connects to an AG-UI server. Demonstrates: + +- Creating an AG-UI client with `AGUIChatClient` +- Managing conversation threads +- Streaming responses with `RunStreamingAsync` +- Displaying colored console output for different content types +- Supporting both interactive and automated modes + +**Prerequisites:** The Step01_GettingStarted server (or any AG-UI server) must be running. + +**Run the client:** + +```bash +cd Step01_GettingStarted/Client +dotnet run +``` + +Type messages and press Enter to interact with the agent. Type `:q` or `quit` to exit. + +### Step02_BackendTools + +An AG-UI server with function tools that execute on the backend. + +#### Server (`Step02_BackendTools/Server`) + +Demonstrates: + +- Creating function tools using `AIFunctionFactory.Create` +- Using `[Description]` attributes for tool documentation +- Defining explicit request/response types for type safety +- Setting up JSON serialization contexts for source generation +- Backend tool rendering (tools execute on the server) + +**Run the server:** + +```bash +cd Step02_BackendTools/Server +dotnet run --urls http://localhost:8888 +``` + +#### Client (`Step02_BackendTools/Client`) + +A client that works with the backend tools server. Try asking: "Find Italian restaurants in Seattle" or "Search for Mexican food in Portland". + +**Run the client:** + +```bash +cd Step02_BackendTools/Client +dotnet run +``` + +### Step03_FrontendTools + +Demonstrates frontend tool rendering (tools defined on client, executed on server). + +#### Server (`Step03_FrontendTools/Server`) + +A basic AG-UI server that accepts tool definitions from the client. + +**Run the server:** + +```bash +cd Step03_FrontendTools/Server +dotnet run --urls http://localhost:8888 +``` + +#### Client (`Step03_FrontendTools/Client`) + +A client that defines and sends tools to the server for execution. + +**Run the client:** + +```bash +cd Step03_FrontendTools/Client +dotnet run +``` + +### Step04_HumanInLoop + +Demonstrates human-in-the-loop approval workflows for sensitive operations. This sample includes both a server and client component. + +#### Server (`Step04_HumanInLoop/Server`) + +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 + +**Run the server:** + +```bash +cd Step04_HumanInLoop/Server +dotnet run --urls http://localhost:8888 +``` + +#### 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 + +**Run the client:** + +```bash +cd Step04_HumanInLoop/Client +dotnet run +``` + +Try asking the agent to perform sensitive operations like "Approve expense report EXP-12345". + +### Step05_StateManagement + +An AG-UI server and client that demonstrate state management with predictive updates. + +#### Server (`Step05_StateManagement/Server`) + +Demonstrates: + +- Defining state schemas using C# records +- Using `SharedStateAgent` middleware for state management +- Streaming predictive state updates with `AgentState` content +- Managing shared state between client and server +- Using JSON serialization contexts for state types + +**Run the server:** + +```bash +cd Step05_StateManagement/Server +dotnet run +``` + +The server runs on port 8888 by default. + +#### Client (`Step05_StateManagement/Client`) + +A client that displays and updates shared state from the server. Try asking: "Create a recipe for chocolate chip cookies" or "Suggest a pasta dish". + +**Run the client:** + +```bash +cd Step05_StateManagement/Client +dotnet run +``` + +## How AG-UI Works + +### Server-Side + +1. Client sends HTTP POST request with messages +2. ASP.NET Core endpoint receives the request via `MapAGUI` +3. Agent processes messages using Agent Framework +4. Responses are streamed back as Server-Sent Events (SSE) + +### Client-Side + +1. `AGUIAgent` sends HTTP POST request to server +2. Server responds with SSE stream +3. Client parses events into `AgentRunResponseUpdate` objects +4. Updates are displayed based on content type +5. `ConversationId` maintains conversation context + +### Protocol Features + +- **HTTP POST** for requests +- **Server-Sent Events (SSE)** for streaming responses +- **JSON** for event serialization +- **Thread IDs** (as `ConversationId`) for conversation context +- **Run IDs** (as `ResponseId`) for tracking individual executions + +## Troubleshooting + +### Connection Refused + +Ensure the server is running before starting the client: + +```bash +# Terminal 1 +cd AGUI_Step01_ServerBasic +dotnet run --urls http://localhost:8888 + +# Terminal 2 (after server starts) +cd AGUI_Step02_ClientBasic +dotnet run +``` + +### Port Already in Use + +If port 8888 is already in use, choose a different port: + +```bash +# Server +dotnet run --urls http://localhost:8889 + +# Client (set environment variable) +export AGUI_SERVER_URL="http://localhost:8889" +dotnet run +``` + +### Authentication Errors + +Make sure you're authenticated with Azure: + +```bash +az login +``` + +Verify you have the `Cognitive Services OpenAI Contributor` role on the Azure OpenAI resource. + +### Missing Environment Variables + +If you see "AZURE_OPENAI_ENDPOINT is not set" errors, ensure environment variables are set in your current shell session before running the samples. + +### Streaming Not Working + +Check that the client timeout is sufficient (default is 60 seconds). For long-running operations, you may need to increase the timeout in the client code. + +## Next Steps + +After completing these samples, explore more AG-UI capabilities: + +### Currently Available in C# + +The samples above demonstrate the AG-UI features currently available in C#: + +- ✅ **Basic Server and Client**: Setting up AG-UI communication +- ✅ **Backend Tool Rendering**: Function tools that execute on the server +- ✅ **Streaming Responses**: Real-time Server-Sent Events +- ✅ **State Management**: State schemas with predictive updates +- ✅ **Human-in-the-Loop**: Approval workflows for sensitive operations + +### Coming Soon to C# + +The following advanced AG-UI features are available in the Python implementation and are planned for future C# releases: + +- ⏳ **Generative UI**: Custom UI component generation +- ⏳ **Advanced State Patterns**: Complex state synchronization scenarios + +For the most up-to-date AG-UI features, see the [Python samples](../../../../python/samples/) for working examples. + +### Related Documentation + +- [AG-UI Overview](https://learn.microsoft.com/agent-framework/integrations/ag-ui/) - Complete AG-UI documentation +- [Getting Started Tutorial](https://learn.microsoft.com/agent-framework/integrations/ag-ui/getting-started) - Step-by-step walkthrough +- [Backend Tool Rendering](https://learn.microsoft.com/agent-framework/integrations/ag-ui/backend-tool-rendering) - Function tools tutorial +- [Human-in-the-Loop](https://learn.microsoft.com/agent-framework/integrations/ag-ui/human-in-the-loop) - Approval workflows tutorial +- [State Management](https://learn.microsoft.com/agent-framework/integrations/ag-ui/state-management) - State management tutorial +- [Agent Framework Overview](https://learn.microsoft.com/agent-framework/overview/agent-framework-overview) - Core framework concepts diff --git a/dotnet/samples/GettingStarted/AGUI/Step01_GettingStarted/Client/Client.csproj b/dotnet/samples/GettingStarted/AGUI/Step01_GettingStarted/Client/Client.csproj new file mode 100644 index 0000000000..a76a2b37ef --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/Step01_GettingStarted/Client/Client.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + diff --git a/dotnet/samples/GettingStarted/AGUI/Step01_GettingStarted/Client/Program.cs b/dotnet/samples/GettingStarted/AGUI/Step01_GettingStarted/Client/Program.cs new file mode 100644 index 0000000000..d942314806 --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/Step01_GettingStarted/Client/Program.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.AGUI; +using Microsoft.Extensions.AI; + +string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; + +Console.WriteLine($"Connecting to AG-UI server at: {serverUrl}\n"); + +// Create the AG-UI client agent +using HttpClient httpClient = new() +{ + Timeout = TimeSpan.FromSeconds(60) +}; + +AGUIChatClient chatClient = new(httpClient, serverUrl); + +AIAgent agent = chatClient.CreateAIAgent( + name: "agui-client", + description: "AG-UI Client Agent"); + +AgentThread thread = agent.GetNewThread(); +List messages = +[ + new(ChatRole.System, "You are a helpful assistant.") +]; + +try +{ + while (true) + { + // Get user input + Console.Write("\nUser (:q or quit to exit): "); + string? message = Console.ReadLine(); + + if (string.IsNullOrWhiteSpace(message)) + { + Console.WriteLine("Request cannot be empty."); + continue; + } + + if (message is ":q" or "quit") + { + break; + } + + messages.Add(new ChatMessage(ChatRole.User, message)); + + // Stream the response + bool isFirstUpdate = true; + string? threadId = null; + + await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(messages, thread)) + { + ChatResponseUpdate chatUpdate = update.AsChatResponseUpdate(); + + // First update indicates run started + if (isFirstUpdate) + { + threadId = chatUpdate.ConversationId; + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($"\n[Run Started - Thread: {chatUpdate.ConversationId}, Run: {chatUpdate.ResponseId}]"); + Console.ResetColor(); + isFirstUpdate = false; + } + + // Display streaming text content + foreach (AIContent content in update.Contents) + { + if (content is TextContent textContent) + { + Console.ForegroundColor = ConsoleColor.Cyan; + Console.Write(textContent.Text); + Console.ResetColor(); + } + else if (content is ErrorContent errorContent) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"\n[Error: {errorContent.Message}]"); + Console.ResetColor(); + } + } + } + + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine($"\n[Run Finished - Thread: {threadId}]"); + Console.ResetColor(); + } +} +catch (Exception ex) +{ + Console.WriteLine($"\nAn error occurred: {ex.Message}"); +} diff --git a/dotnet/samples/GettingStarted/AGUI/Step01_GettingStarted/Server/Program.cs b/dotnet/samples/GettingStarted/AGUI/Step01_GettingStarted/Server/Program.cs new file mode 100644 index 0000000000..1bfb9a97aa --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/Step01_GettingStarted/Server/Program.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; +using Microsoft.Extensions.AI; +using OpenAI.Chat; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +builder.Services.AddHttpClient().AddLogging(); +builder.Services.AddAGUI(); + +WebApplication app = builder.Build(); + +string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"] + ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] + ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); + +// Create the AI agent +ChatClient chatClient = new AzureOpenAIClient( + new Uri(endpoint), + new DefaultAzureCredential()) + .GetChatClient(deploymentName); + +AIAgent agent = chatClient.AsIChatClient().CreateAIAgent( + name: "AGUIAssistant", + instructions: "You are a helpful assistant."); + +// Map the AG-UI agent endpoint +app.MapAGUI("/", agent); + +await app.RunAsync(); diff --git a/dotnet/samples/GettingStarted/AGUI/Step01_GettingStarted/Server/Properties/launchSettings.json b/dotnet/samples/GettingStarted/AGUI/Step01_GettingStarted/Server/Properties/launchSettings.json new file mode 100644 index 0000000000..2bac1b9426 --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/Step01_GettingStarted/Server/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5253", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7047;http://localhost:5253", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/dotnet/samples/GettingStarted/AGUI/Step01_GettingStarted/Server/Server.csproj b/dotnet/samples/GettingStarted/AGUI/Step01_GettingStarted/Server/Server.csproj new file mode 100644 index 0000000000..b1e7fe33cf --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/Step01_GettingStarted/Server/Server.csproj @@ -0,0 +1,21 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/AGUI/Step01_GettingStarted/Server/appsettings.Development.json b/dotnet/samples/GettingStarted/AGUI/Step01_GettingStarted/Server/appsettings.Development.json new file mode 100644 index 0000000000..0c208ae918 --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/Step01_GettingStarted/Server/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/dotnet/samples/GettingStarted/AGUI/Step01_GettingStarted/Server/appsettings.json b/dotnet/samples/GettingStarted/AGUI/Step01_GettingStarted/Server/appsettings.json new file mode 100644 index 0000000000..10f68b8c8b --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/Step01_GettingStarted/Server/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/dotnet/samples/GettingStarted/AGUI/Step02_BackendTools/Client/Client.csproj b/dotnet/samples/GettingStarted/AGUI/Step02_BackendTools/Client/Client.csproj new file mode 100644 index 0000000000..a76a2b37ef --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/Step02_BackendTools/Client/Client.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + diff --git a/dotnet/samples/GettingStarted/AGUI/Step02_BackendTools/Client/Program.cs b/dotnet/samples/GettingStarted/AGUI/Step02_BackendTools/Client/Program.cs new file mode 100644 index 0000000000..1919a9565f --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/Step02_BackendTools/Client/Program.cs @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.AGUI; +using Microsoft.Extensions.AI; + +string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; + +Console.WriteLine($"Connecting to AG-UI server at: {serverUrl}\n"); + +// Create the AG-UI client agent +using HttpClient httpClient = new() +{ + Timeout = TimeSpan.FromSeconds(60) +}; + +AGUIChatClient chatClient = new(httpClient, serverUrl); + +AIAgent agent = chatClient.CreateAIAgent( + name: "agui-client", + description: "AG-UI Client Agent"); + +AgentThread thread = agent.GetNewThread(); +List messages = +[ + new(ChatRole.System, "You are a helpful assistant.") +]; + +try +{ + while (true) + { + // Get user input + Console.Write("\nUser (:q or quit to exit): "); + string? message = Console.ReadLine(); + + if (string.IsNullOrWhiteSpace(message)) + { + Console.WriteLine("Request cannot be empty."); + continue; + } + + if (message is ":q" or "quit") + { + break; + } + + messages.Add(new ChatMessage(ChatRole.User, message)); + + // Stream the response + bool isFirstUpdate = true; + string? threadId = null; + + await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(messages, thread)) + { + ChatResponseUpdate chatUpdate = update.AsChatResponseUpdate(); + + // First update indicates run started + if (isFirstUpdate) + { + threadId = chatUpdate.ConversationId; + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($"\n[Run Started - Thread: {chatUpdate.ConversationId}, Run: {chatUpdate.ResponseId}]"); + Console.ResetColor(); + isFirstUpdate = false; + } + + // Display streaming content + foreach (AIContent content in update.Contents) + { + switch (content) + { + case TextContent textContent: + Console.ForegroundColor = ConsoleColor.Cyan; + Console.Write(textContent.Text); + Console.ResetColor(); + break; + + case FunctionCallContent functionCallContent: + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine($"\n[Function Call - Name: {functionCallContent.Name}]"); + + // Display individual parameters + if (functionCallContent.Arguments != null) + { + foreach (var kvp in functionCallContent.Arguments) + { + Console.WriteLine($" Parameter: {kvp.Key} = {kvp.Value}"); + } + } + Console.ResetColor(); + break; + + case FunctionResultContent functionResultContent: + Console.ForegroundColor = ConsoleColor.Magenta; + Console.WriteLine($"\n[Function Result - CallId: {functionResultContent.CallId}]"); + + if (functionResultContent.Exception != null) + { + Console.WriteLine($" Exception: {functionResultContent.Exception}"); + } + else + { + Console.WriteLine($" Result: {functionResultContent.Result}"); + } + Console.ResetColor(); + break; + + case ErrorContent errorContent: + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"\n[Error: {errorContent.Message}]"); + Console.ResetColor(); + break; + } + } + } + + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine($"\n[Run Finished - Thread: {threadId}]"); + Console.ResetColor(); + } +} +catch (Exception ex) +{ + Console.WriteLine($"\nAn error occurred: {ex.Message}"); +} diff --git a/dotnet/samples/GettingStarted/AGUI/Step02_BackendTools/Server/Program.cs b/dotnet/samples/GettingStarted/AGUI/Step02_BackendTools/Server/Program.cs new file mode 100644 index 0000000000..2867721d02 --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/Step02_BackendTools/Server/Program.cs @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ComponentModel; +using System.Text.Json.Serialization; +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Options; +using OpenAI.Chat; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +builder.Services.AddHttpClient().AddLogging(); +builder.Services.ConfigureHttpJsonOptions(options => + options.SerializerOptions.TypeInfoResolverChain.Add(SampleJsonSerializerContext.Default)); +builder.Services.AddAGUI(); + +WebApplication app = builder.Build(); + +string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"] + ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] + ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); + +// Define the function tool +[Description("Search for restaurants in a location.")] +static RestaurantSearchResponse SearchRestaurants( + [Description("The restaurant search request")] RestaurantSearchRequest request) +{ + // Simulated restaurant data + string cuisine = request.Cuisine == "any" ? "Italian" : request.Cuisine; + + return new RestaurantSearchResponse + { + Location = request.Location, + Cuisine = request.Cuisine, + Results = + [ + new RestaurantInfo + { + Name = "The Golden Fork", + Cuisine = cuisine, + Rating = 4.5, + Address = $"123 Main St, {request.Location}" + }, + new RestaurantInfo + { + Name = "Spice Haven", + Cuisine = cuisine == "Italian" ? "Indian" : cuisine, + Rating = 4.7, + Address = $"456 Oak Ave, {request.Location}" + }, + new RestaurantInfo + { + Name = "Green Leaf", + Cuisine = "Vegetarian", + Rating = 4.3, + Address = $"789 Elm Rd, {request.Location}" + } + ] + }; +} + +// Get JsonSerializerOptions from the configured HTTP JSON options +Microsoft.AspNetCore.Http.Json.JsonOptions jsonOptions = app.Services.GetRequiredService>().Value; + +// Create tool with serializer options +AITool[] tools = +[ + AIFunctionFactory.Create( + SearchRestaurants, + serializerOptions: jsonOptions.SerializerOptions) +]; + +// Create the AI agent with tools +ChatClient chatClient = new AzureOpenAIClient( + new Uri(endpoint), + new DefaultAzureCredential()) + .GetChatClient(deploymentName); + +ChatClientAgent agent = chatClient.AsIChatClient().CreateAIAgent( + name: "AGUIAssistant", + instructions: "You are a helpful assistant with access to restaurant information.", + tools: tools); + +// Map the AG-UI agent endpoint +app.MapAGUI("/", agent); + +await app.RunAsync(); + +// Define request/response types for the tool +internal sealed class RestaurantSearchRequest +{ + public string Location { get; set; } = string.Empty; + public string Cuisine { get; set; } = "any"; +} + +internal sealed class RestaurantSearchResponse +{ + public string Location { get; set; } = string.Empty; + public string Cuisine { get; set; } = string.Empty; + public RestaurantInfo[] Results { get; set; } = []; +} + +internal sealed class RestaurantInfo +{ + public string Name { get; set; } = string.Empty; + public string Cuisine { get; set; } = string.Empty; + public double Rating { get; set; } + public string Address { get; set; } = string.Empty; +} + +// JSON serialization context for source generation +[JsonSerializable(typeof(RestaurantSearchRequest))] +[JsonSerializable(typeof(RestaurantSearchResponse))] +internal sealed partial class SampleJsonSerializerContext : JsonSerializerContext; diff --git a/dotnet/samples/GettingStarted/AGUI/Step02_BackendTools/Server/Properties/launchSettings.json b/dotnet/samples/GettingStarted/AGUI/Step02_BackendTools/Server/Properties/launchSettings.json new file mode 100644 index 0000000000..2bac1b9426 --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/Step02_BackendTools/Server/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5253", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7047;http://localhost:5253", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/dotnet/samples/GettingStarted/AGUI/Step02_BackendTools/Server/Server.csproj b/dotnet/samples/GettingStarted/AGUI/Step02_BackendTools/Server/Server.csproj new file mode 100644 index 0000000000..b1e7fe33cf --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/Step02_BackendTools/Server/Server.csproj @@ -0,0 +1,21 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/AGUI/Step02_BackendTools/Server/appsettings.Development.json b/dotnet/samples/GettingStarted/AGUI/Step02_BackendTools/Server/appsettings.Development.json new file mode 100644 index 0000000000..0c208ae918 --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/Step02_BackendTools/Server/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/dotnet/samples/GettingStarted/AGUI/Step02_BackendTools/Server/appsettings.json b/dotnet/samples/GettingStarted/AGUI/Step02_BackendTools/Server/appsettings.json new file mode 100644 index 0000000000..10f68b8c8b --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/Step02_BackendTools/Server/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/dotnet/samples/GettingStarted/AGUI/Step03_FrontendTools/Client/Client.csproj b/dotnet/samples/GettingStarted/AGUI/Step03_FrontendTools/Client/Client.csproj new file mode 100644 index 0000000000..a76a2b37ef --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/Step03_FrontendTools/Client/Client.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + diff --git a/dotnet/samples/GettingStarted/AGUI/Step03_FrontendTools/Client/Program.cs b/dotnet/samples/GettingStarted/AGUI/Step03_FrontendTools/Client/Program.cs new file mode 100644 index 0000000000..d295ed7116 --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/Step03_FrontendTools/Client/Program.cs @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ComponentModel; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.AGUI; +using Microsoft.Extensions.AI; + +string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; + +Console.WriteLine($"Connecting to AG-UI server at: {serverUrl}\n"); + +// Define a frontend function tool +[Description("Get the user's current location from GPS.")] +static string GetUserLocation() +{ + // Access client-side GPS + return "Amsterdam, Netherlands (52.37°N, 4.90°E)"; +} + +// Create frontend tools +AITool[] frontendTools = [AIFunctionFactory.Create(GetUserLocation)]; + +// Create the AG-UI client agent with tools +using HttpClient httpClient = new() +{ + Timeout = TimeSpan.FromSeconds(60) +}; + +AGUIChatClient chatClient = new(httpClient, serverUrl); + +AIAgent agent = chatClient.CreateAIAgent( + name: "agui-client", + description: "AG-UI Client Agent", + tools: frontendTools); + +AgentThread thread = agent.GetNewThread(); +List messages = +[ + new(ChatRole.System, "You are a helpful assistant.") +]; + +try +{ + while (true) + { + // Get user input + Console.Write("\nUser (:q or quit to exit): "); + string? message = Console.ReadLine(); + + if (string.IsNullOrWhiteSpace(message)) + { + Console.WriteLine("Request cannot be empty."); + continue; + } + + if (message is ":q" or "quit") + { + break; + } + + messages.Add(new ChatMessage(ChatRole.User, message)); + + // Stream the response + bool isFirstUpdate = true; + string? threadId = null; + + await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(messages, thread)) + { + ChatResponseUpdate chatUpdate = update.AsChatResponseUpdate(); + + // First update indicates run started + if (isFirstUpdate) + { + threadId = chatUpdate.ConversationId; + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($"\n[Run Started - Thread: {chatUpdate.ConversationId}, Run: {chatUpdate.ResponseId}]"); + Console.ResetColor(); + isFirstUpdate = false; + } + + // Display streaming content + foreach (AIContent content in update.Contents) + { + if (content is TextContent textContent) + { + Console.ForegroundColor = ConsoleColor.Cyan; + Console.Write(textContent.Text); + Console.ResetColor(); + } + else if (content is FunctionCallContent functionCallContent) + { + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine($"\n[Client Tool Call - Name: {functionCallContent.Name}]"); + Console.ResetColor(); + } + else if (content is FunctionResultContent functionResultContent) + { + Console.ForegroundColor = ConsoleColor.Magenta; + Console.WriteLine($"[Client Tool Result: {functionResultContent.Result}]"); + Console.ResetColor(); + } + else if (content is ErrorContent errorContent) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"\n[Error: {errorContent.Message}]"); + Console.ResetColor(); + } + } + } + + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine($"\n[Run Finished - Thread: {threadId}]"); + Console.ResetColor(); + } +} +catch (Exception ex) +{ + Console.WriteLine($"\nAn error occurred: {ex.Message}"); +} diff --git a/dotnet/samples/GettingStarted/AGUI/Step03_FrontendTools/Server/Program.cs b/dotnet/samples/GettingStarted/AGUI/Step03_FrontendTools/Server/Program.cs new file mode 100644 index 0000000000..1bfb9a97aa --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/Step03_FrontendTools/Server/Program.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; +using Microsoft.Extensions.AI; +using OpenAI.Chat; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +builder.Services.AddHttpClient().AddLogging(); +builder.Services.AddAGUI(); + +WebApplication app = builder.Build(); + +string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"] + ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] + ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); + +// Create the AI agent +ChatClient chatClient = new AzureOpenAIClient( + new Uri(endpoint), + new DefaultAzureCredential()) + .GetChatClient(deploymentName); + +AIAgent agent = chatClient.AsIChatClient().CreateAIAgent( + name: "AGUIAssistant", + instructions: "You are a helpful assistant."); + +// Map the AG-UI agent endpoint +app.MapAGUI("/", agent); + +await app.RunAsync(); diff --git a/dotnet/samples/GettingStarted/AGUI/Step03_FrontendTools/Server/Properties/launchSettings.json b/dotnet/samples/GettingStarted/AGUI/Step03_FrontendTools/Server/Properties/launchSettings.json new file mode 100644 index 0000000000..2bac1b9426 --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/Step03_FrontendTools/Server/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5253", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7047;http://localhost:5253", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/dotnet/samples/GettingStarted/AGUI/Step03_FrontendTools/Server/Server.csproj b/dotnet/samples/GettingStarted/AGUI/Step03_FrontendTools/Server/Server.csproj new file mode 100644 index 0000000000..b1e7fe33cf --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/Step03_FrontendTools/Server/Server.csproj @@ -0,0 +1,21 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/AGUI/Step03_FrontendTools/Server/appsettings.Development.json b/dotnet/samples/GettingStarted/AGUI/Step03_FrontendTools/Server/appsettings.Development.json new file mode 100644 index 0000000000..0c208ae918 --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/Step03_FrontendTools/Server/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/dotnet/samples/GettingStarted/AGUI/Step03_FrontendTools/Server/appsettings.json b/dotnet/samples/GettingStarted/AGUI/Step03_FrontendTools/Server/appsettings.json new file mode 100644 index 0000000000..10f68b8c8b --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/Step03_FrontendTools/Server/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Client/Client.csproj b/dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Client/Client.csproj new file mode 100644 index 0000000000..a76a2b37ef --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Client/Client.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + diff --git a/dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Client/Program.cs b/dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Client/Program.cs new file mode 100644 index 0000000000..656989458d --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Client/Program.cs @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.AGUI; +using Microsoft.Extensions.AI; + +string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:5100"; + +// Connect to the AG-UI server +using HttpClient httpClient = new() +{ + Timeout = TimeSpan.FromSeconds(60) +}; + +AGUIChatClient chatClient = new(httpClient, serverUrl); + +// Create agent +ChatClientAgent baseAgent = chatClient.CreateAIAgent( + 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 = []; +AgentThread? thread = null; + +Console.ForegroundColor = ConsoleColor.White; +Console.WriteLine("Ask a question (or type 'exit' to quit):"); +Console.ResetColor(); + +string? input; +while ((input = Console.ReadLine()) != null && !input.Equals("exit", StringComparison.OrdinalIgnoreCase)) +{ + if (string.IsNullOrWhiteSpace(input)) + { + continue; + } + + messages.Add(new ChatMessage(ChatRole.User, input)); + Console.WriteLine(); + +#pragma warning disable MEAI001 + List approvalResponses = []; + + do + { + approvalResponses.Clear(); + + List chatResponseUpdates = []; + await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(messages, thread, cancellationToken: default)) + { + chatResponseUpdates.Add(update); + foreach (AIContent content in update.Contents) + { + switch (content) + { + case FunctionApprovalRequestContent approvalRequest: + DisplayApprovalRequest(approvalRequest); + + Console.Write($"\nApprove '{approvalRequest.FunctionCall.Name}'? (yes/no): "); + string? userInput = Console.ReadLine(); + bool approved = userInput?.ToUpperInvariant() is "YES" or "Y"; + + FunctionApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved); + + if (approvalRequest.AdditionalProperties != null) + { + approvalResponse.AdditionalProperties = new AdditionalPropertiesDictionary(); + foreach (var kvp in approvalRequest.AdditionalProperties) + { + approvalResponse.AdditionalProperties[kvp.Key] = kvp.Value; + } + } + + approvalResponses.Add(approvalResponse); + break; + + case TextContent textContent: + Console.ForegroundColor = ConsoleColor.Cyan; + Console.Write(textContent.Text); + Console.ResetColor(); + break; + + case FunctionCallContent functionCall: + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine($"[Tool Call - Name: {functionCall.Name}]"); + if (functionCall.Arguments is { } arguments) + { + Console.WriteLine($" Parameters: {JsonSerializer.Serialize(arguments)}"); + } + Console.ResetColor(); + break; + + case FunctionResultContent functionResult: + Console.ForegroundColor = ConsoleColor.Magenta; + Console.WriteLine($"[Tool Result: {functionResult.Result}]"); + Console.ResetColor(); + break; + + case ErrorContent error: + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"[Error: {error.Message}]"); + Console.ResetColor(); + break; + } + } + } + + AgentRunResponse response = chatResponseUpdates.ToAgentRunResponse(); + messages.AddRange(response.Messages); + foreach (AIContent approvalResponse in approvalResponses) + { + messages.Add(new ChatMessage(ChatRole.Tool, [approvalResponse])); + } + } + while (approvalResponses.Count > 0); +#pragma warning restore MEAI001 + + Console.WriteLine("\n"); + Console.ForegroundColor = ConsoleColor.White; + Console.WriteLine("Ask another question (or type 'exit' to quit):"); + Console.ResetColor(); +} + +#pragma warning disable MEAI001 +static void DisplayApprovalRequest(FunctionApprovalRequestContent approvalRequest) +{ + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine(); + Console.WriteLine("============================================================"); + Console.WriteLine("APPROVAL REQUIRED"); + Console.WriteLine("============================================================"); + Console.WriteLine($"Function: {approvalRequest.FunctionCall.Name}"); + + if (approvalRequest.FunctionCall.Arguments != null) + { + Console.WriteLine("Arguments:"); + foreach (var arg in approvalRequest.FunctionCall.Arguments) + { + Console.WriteLine($" {arg.Key} = {arg.Value}"); + } + } + + Console.WriteLine("============================================================"); + Console.ResetColor(); +} +#pragma warning restore MEAI001 diff --git a/dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Client/ServerFunctionApprovalClientAgent.cs b/dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Client/ServerFunctionApprovalClientAgent.cs new file mode 100644 index 0000000000..41538085db --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Client/ServerFunctionApprovalClientAgent.cs @@ -0,0 +1,265 @@ +// 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 FunctionApprovalRequestContent/FunctionApprovalResponseContent +/// 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; + } + + public override Task RunAsync( + IEnumerable messages, + AgentThread? thread = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + return this.RunStreamingAsync(messages, thread, options, cancellationToken) + .ToAgentRunResponseAsync(cancellationToken); + } + + public override async IAsyncEnumerable RunStreamingAsync( + IEnumerable messages, + AgentThread? thread = 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, thread, options, cancellationToken).ConfigureAwait(false)) + { + yield return ProcessIncomingServerApprovalRequests(update, this._jsonSerializerOptions); + } + } + +#pragma warning disable MEAI001 // Type is for evaluation purposes only + private static FunctionResultContent ConvertApprovalResponseToToolResult(FunctionApprovalResponseContent approvalResponse, JsonSerializerOptions jsonOptions) + { + return new FunctionResultContent( + callId: approvalResponse.Id, + result: JsonSerializer.SerializeToElement( + new ApprovalResponse + { + ApprovalId = approvalResponse.Id, + 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 FunctionApprovalRequestContent approvalRequest && + approvalRequest.AdditionalProperties?.TryGetValue("original_function", out var originalFunction) == true && + originalFunction is FunctionCallContent original) + { + approvalRequests[approvalRequest.Id] = approvalRequest; + transformedContents ??= CopyContentsUpToIndex(message.Contents, contentIndex); + transformedContents.Add(original); + } + // Handle pending approval responses (transform to tool result) + else if (content is FunctionApprovalResponseContent approvalResponse && + approvalRequests.TryGetValue(approvalResponse.Id, out var correspondingRequest)) + { + transformedContents ??= CopyContentsUpToIndex(message.Contents, contentIndex); + transformedContents.Add(ConvertApprovalResponseToToolResult(approvalResponse, jsonSerializerOptions)); + approvalRequests.Remove(approvalResponse.Id); + 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 if (transformedContents != null) + { + 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 if (result != null) + { + // 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 AgentRunResponseUpdate ProcessIncomingServerApprovalRequests( + AgentRunResponseUpdate 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 FunctionApprovalRequestContent( + id: 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 AgentRunResponseUpdate(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/GettingStarted/AGUI/Step04_HumanInLoop/Server/Program.cs b/dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Server/Program.cs new file mode 100644 index 0000000000..1af163435a --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Server/Program.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ComponentModel; +using Azure.AI.OpenAI; +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.AddAGUI(); + +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"] + ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); + +// Define approval-required tool +[Description("Approve the expense report.")] +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 + +// Create base agent +ChatClient openAIChatClient = new AzureOpenAIClient( + new Uri(endpoint), + new DefaultAzureCredential()) + .GetChatClient(deploymentName); + +ChatClientAgent baseAgent = openAIChatClient.AsIChatClient().CreateAIAgent( + name: "AGUIAssistant", + instructions: "You are a helpful assistant in charge of approving expenses", + tools: tools); + +// Wrap with ServerFunctionApprovalAgent +var agent = new ServerFunctionApprovalAgent(baseAgent, jsonOptions.SerializerOptions); + +app.MapAGUI("/", agent); +await app.RunAsync(); diff --git a/dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Server/Properties/launchSettings.json b/dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Server/Properties/launchSettings.json new file mode 100644 index 0000000000..e75f8f51e3 --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Server/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5100", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7047;http://localhost:5100", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Server/Server.csproj b/dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Server/Server.csproj new file mode 100644 index 0000000000..b1e7fe33cf --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Server/Server.csproj @@ -0,0 +1,21 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Server/ServerFunctionApprovalServerAgent.cs b/dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Server/ServerFunctionApprovalServerAgent.cs new file mode 100644 index 0000000000..f515e97531 --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Server/ServerFunctionApprovalServerAgent.cs @@ -0,0 +1,262 @@ +// 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 FunctionApprovalRequestContent/FunctionApprovalResponseContent +/// 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; + } + + public override Task RunAsync( + IEnumerable messages, + AgentThread? thread = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + return this.RunStreamingAsync(messages, thread, options, cancellationToken) + .ToAgentRunResponseAsync(cancellationToken); + } + + public override async IAsyncEnumerable RunStreamingAsync( + IEnumerable messages, + AgentThread? thread = 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, thread, options, cancellationToken).ConfigureAwait(false)) + { + yield return ProcessOutgoingApprovalRequests(update, this._jsonSerializerOptions); + } + } + +#pragma warning disable MEAI001 // Type is for evaluation purposes only + private static FunctionApprovalRequestContent 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; + + if (request == null) + { + throw new InvalidOperationException("Failed to deserialize approval request from tool call"); + } + + return new FunctionApprovalRequestContent( + id: request.ApprovalId, + new FunctionCallContent( + callId: request.ApprovalId, + name: request.FunctionName, + arguments: request.FunctionArguments)); + } + + private static FunctionApprovalResponseContent ConvertToolResultToApprovalResponse(FunctionResultContent result, FunctionApprovalRequestContent 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; + + if (approvalResponse == null) + { + 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 = new(); // 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) == true) + { + 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 if (result != null) + { + result.Add(message); + } + } + } +#pragma warning restore MEAI001 + + return result ?? messages; + } + + private static AgentRunResponseUpdate ProcessOutgoingApprovalRequests( + AgentRunResponseUpdate 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 FunctionApprovalRequestContent request) + { + updatedContents ??= [.. update.Contents]; + var functionCall = request.FunctionCall; + var approvalId = request.Id; + + 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 AgentRunResponseUpdate(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/GettingStarted/AGUI/Step04_HumanInLoop/Server/appsettings.Development.json b/dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Server/appsettings.Development.json new file mode 100644 index 0000000000..3e805edef8 --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Server/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware": "Information" + } + } +} diff --git a/dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Server/appsettings.json b/dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Server/appsettings.json new file mode 100644 index 0000000000..10f68b8c8b --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Server/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Client/Client.csproj b/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Client/Client.csproj new file mode 100644 index 0000000000..a76a2b37ef --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Client/Client.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + diff --git a/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Client/Program.cs b/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Client/Program.cs new file mode 100644 index 0000000000..49ffa0587d --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Client/Program.cs @@ -0,0 +1,231 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.AGUI; +using Microsoft.Extensions.AI; +using RecipeClient; + +string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; + +Console.WriteLine($"Connecting to AG-UI server at: {serverUrl}\n"); + +// Create the AG-UI client agent +using HttpClient httpClient = new() +{ + Timeout = TimeSpan.FromSeconds(60) +}; + +AGUIChatClient chatClient = new(httpClient, serverUrl); + +AIAgent baseAgent = chatClient.CreateAIAgent( + 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()); + +AgentThread thread = agent.GetNewThread(); +List messages = +[ + new(ChatRole.System, "You are a helpful recipe assistant.") +]; + +try +{ + while (true) + { + // Get user input + Console.Write("\nUser (:q to quit, :state to show state): "); + string? message = Console.ReadLine(); + + if (string.IsNullOrWhiteSpace(message)) + { + Console.WriteLine("Request cannot be empty."); + continue; + } + + if (message is ":q" or "quit") + { + break; + } + + if (message.Equals(":state", StringComparison.OrdinalIgnoreCase)) + { + DisplayState(agent.State.Recipe); + continue; + } + + messages.Add(new ChatMessage(ChatRole.User, message)); + + // Stream the response + bool isFirstUpdate = true; + string? threadId = null; + bool stateReceived = false; + + Console.WriteLine(); + + await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(messages, thread)) + { + ChatResponseUpdate chatUpdate = update.AsChatResponseUpdate(); + + // First update indicates run started + if (isFirstUpdate) + { + threadId = chatUpdate.ConversationId; + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($"[Run Started - Thread: {chatUpdate.ConversationId}, Run: {chatUpdate.ResponseId}]"); + Console.ResetColor(); + isFirstUpdate = false; + } + + // Display streaming content + foreach (AIContent content in update.Contents) + { + switch (content) + { + case TextContent textContent: + Console.ForegroundColor = ConsoleColor.Cyan; + Console.Write(textContent.Text); + 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}]"); + Console.ResetColor(); + break; + } + } + } + + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine($"\n[Run Finished - Thread: {threadId}]"); + Console.ResetColor(); + + // Display final state if received + if (stateReceived) + { + DisplayState(agent.State.Recipe); + } + } +} +catch (Exception ex) +{ + Console.WriteLine($"\nAn error occurred: {ex.Message}"); +} + +static void DisplayState(RecipeState? state) +{ + 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(new string('=', 60)); + Console.ResetColor(); + + if (!string.IsNullOrEmpty(state.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.WriteLine($" Skill Level: {state.SkillLevel}"); + } + + if (state.PrepTimeMinutes > 0) + { + Console.WriteLine($" Prep Time: {state.PrepTimeMinutes} minutes"); + } + + if (state.CookTimeMinutes > 0) + { + Console.WriteLine($" Cook Time: {state.CookTimeMinutes} minutes"); + } + + if (state.Ingredients.Count > 0) + { + Console.WriteLine("\n Ingredients:"); + foreach (var ingredient in state.Ingredients) + { + Console.WriteLine($" - {ingredient}"); + } + } + + if (state.Steps.Count > 0) + { + Console.WriteLine("\n Steps:"); + for (int i = 0; i < state.Steps.Count; i++) + { + Console.WriteLine($" {i + 1}. {state.Steps[i]}"); + } + } + } + + Console.ForegroundColor = ConsoleColor.Blue; + Console.WriteLine("\n" + new string('=', 60)); + Console.ResetColor(); +} + +// State wrapper +internal sealed class AgentState +{ + [JsonPropertyName("recipe")] + public RecipeState Recipe { get; set; } = new(); +} + +// Recipe state model +internal sealed class RecipeState +{ + [JsonPropertyName("title")] + public string Title { get; set; } = string.Empty; + + [JsonPropertyName("cuisine")] + public string Cuisine { get; set; } = string.Empty; + + [JsonPropertyName("ingredients")] + public List Ingredients { get; set; } = []; + + [JsonPropertyName("steps")] + public List Steps { get; set; } = []; + + [JsonPropertyName("prep_time_minutes")] + public int PrepTimeMinutes { get; set; } + + [JsonPropertyName("cook_time_minutes")] + public int CookTimeMinutes { get; set; } + + [JsonPropertyName("skill_level")] + public string SkillLevel { get; set; } = string.Empty; +} + +// JSON serialization context +[JsonSerializable(typeof(AgentState))] +[JsonSerializable(typeof(RecipeState))] +[JsonSerializable(typeof(JsonElement))] +internal sealed partial class RecipeSerializerContext : JsonSerializerContext; diff --git a/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Client/StatefulAgent.cs b/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Client/StatefulAgent.cs new file mode 100644 index 0000000000..8321efaa73 --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Client/StatefulAgent.cs @@ -0,0 +1,88 @@ +// 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(); + } + + /// + public override Task RunAsync( + IEnumerable messages, + AgentThread? thread = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + return this.RunStreamingAsync(messages, thread, options, cancellationToken) + .ToAgentRunResponseAsync(cancellationToken); + } + + /// + public override async IAsyncEnumerable RunStreamingAsync( + IEnumerable messages, + AgentThread? thread = 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 (AgentRunResponseUpdate update in this.InnerAgent.RunStreamingAsync(messagesWithState, thread, 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 + TState? newState = JsonSerializer.Deserialize( + dataContent.Data.Span, + this._jsonSerializerOptions.GetTypeInfo(typeof(TState))) as TState; + if (newState != null) + { + this.State = newState; + } + } + } + + yield return update; + } + } +} diff --git a/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/Program.cs b/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/Program.cs new file mode 100644 index 0000000000..40c51887d1 --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/Program.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Options; +using OpenAI.Chat; +using RecipeAssistant; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +builder.Services.AddHttpClient().AddLogging(); +builder.Services.ConfigureHttpJsonOptions(options => + options.SerializerOptions.TypeInfoResolverChain.Add(RecipeSerializerContext.Default)); +builder.Services.AddAGUI(); + +// Configure to listen on port 8888 +builder.WebHost.UseUrls("http://localhost:8888"); + +WebApplication app = builder.Build(); + +string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"] + ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] + ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); + +// Get JsonSerializerOptions +var jsonOptions = app.Services.GetRequiredService>().Value; + +// Create base agent +ChatClient chatClient = new AzureOpenAIClient( + new Uri(endpoint), + new DefaultAzureCredential()) + .GetChatClient(deploymentName); + +AIAgent baseAgent = chatClient.AsIChatClient().CreateAIAgent( + 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. + """); + +// Wrap with state management middleware +AIAgent agent = new SharedStateAgent(baseAgent, jsonOptions.SerializerOptions); + +// Map the AG-UI agent endpoint +app.MapAGUI("/", agent); + +await app.RunAsync(); diff --git a/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/Properties/launchSettings.json b/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/Properties/launchSettings.json new file mode 100644 index 0000000000..2bac1b9426 --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5253", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7047;http://localhost:5253", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/RecipeModels.cs b/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/RecipeModels.cs new file mode 100644 index 0000000000..fc1d8320d2 --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/RecipeModels.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace RecipeAssistant; + +// State wrapper +internal sealed class AgentState +{ + [JsonPropertyName("recipe")] + public RecipeState Recipe { get; set; } = new(); +} + +// Recipe state model +internal sealed class RecipeState +{ + [JsonPropertyName("title")] + public string Title { get; set; } = string.Empty; + + [JsonPropertyName("cuisine")] + public string Cuisine { get; set; } = string.Empty; + + [JsonPropertyName("ingredients")] + public List Ingredients { get; set; } = []; + + [JsonPropertyName("steps")] + public List Steps { get; set; } = []; + + [JsonPropertyName("prep_time_minutes")] + public int PrepTimeMinutes { get; set; } + + [JsonPropertyName("cook_time_minutes")] + public int CookTimeMinutes { get; set; } + + [JsonPropertyName("skill_level")] + public string SkillLevel { get; set; } = string.Empty; +} + +// JSON serialization context +[JsonSerializable(typeof(AgentState))] +[JsonSerializable(typeof(RecipeState))] +[JsonSerializable(typeof(System.Text.Json.JsonElement))] +internal sealed partial class RecipeSerializerContext : JsonSerializerContext; diff --git a/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/Server.csproj b/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/Server.csproj new file mode 100644 index 0000000000..b1e7fe33cf --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/Server.csproj @@ -0,0 +1,21 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/SharedStateAgent.cs b/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/SharedStateAgent.cs new file mode 100644 index 0000000000..4588c7bd60 --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/SharedStateAgent.cs @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Runtime.CompilerServices; +using System.Text.Json; +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; + } + + public override Task RunAsync( + IEnumerable messages, + AgentThread? thread = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + return this.RunStreamingAsync(messages, thread, options, cancellationToken) + .ToAgentRunResponseAsync(cancellationToken); + } + + public override async IAsyncEnumerable RunStreamingAsync( + IEnumerable messages, + AgentThread? thread = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + // Check if the client sent state in the request + if (options is not ChatClientAgentRunOptions { ChatOptions.AdditionalProperties: { } properties } chatRunOptions || + !properties.TryGetValue("ag_ui_state", out object? stateObj) || + stateObj is not JsonElement state || + state.ValueKind != JsonValueKind.Object) + { + // No state management requested, pass through to inner agent + await foreach (var update in this.InnerAgent.RunStreamingAsync(messages, thread, 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, thread, 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, thread, 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.ToAgentRunResponse(); + + // Try to deserialize the structured state response + if (response.TryDeserialize(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 AgentRunResponseUpdate + { + 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, thread, options, cancellationToken).ConfigureAwait(false)) + { + yield return update; + } + } +} diff --git a/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/appsettings.Development.json b/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/appsettings.Development.json new file mode 100644 index 0000000000..0c208ae918 --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/appsettings.json b/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/appsettings.json new file mode 100644 index 0000000000..10f68b8c8b --- /dev/null +++ b/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +}