diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 40c791159ba..7a3b5016385 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -123,6 +123,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -670,4 +698,3 @@ - diff --git a/dotnet/samples/02-agents/AGUI/README.md b/dotnet/samples/02-agents/AGUI/README.md index b0e724bf14e..14e290a649b 100644 --- a/dotnet/samples/02-agents/AGUI/README.md +++ b/dotnet/samples/02-agents/AGUI/README.md @@ -186,6 +186,82 @@ cd Step05_StateManagement/Server dotnet run ``` +### Step06_WorkflowSequential + +A two-agent sequential workflow hosted over AG-UI. The client displays executor `STEP_STARTED` and +`STEP_FINISHED` events alongside the writer and reviewer text. + +```bash +cd Step06_WorkflowSequential +dotnet run --project Server --urls http://localhost:8888 +dotnet run --project Client +``` + +### Step07_WorkflowConcurrent + +A concurrent workflow that runs a researcher and critic together. The client displays each executor's steps +and both streamed responses. + +```bash +cd Step07_WorkflowConcurrent +dotnet run --project Server --urls http://localhost:8888 +dotnet run --project Client +``` + +### Step08_WorkflowFailure + +A deterministic failing workflow. The client shows the failed executor step closing before terminal +`RUN_ERROR`. + +```bash +cd Step08_WorkflowFailure +dotnet run --project Server --urls http://localhost:8888 +dotnet run --project Client +``` + +### Step09_WorkflowTools + +A workflow containing a backend-tool-enabled agent. The client displays executor steps, tool calls, tool +results, and text without duplication. + +```bash +cd Step09_WorkflowTools +dotnet run --project Server --urls http://localhost:8888 +dotnet run --project Client +``` + +### Step10_WorkflowNested + +Two parallel subworkflows whose local executor IDs are both `Analyze`. The sample characterizes the +currently ambiguous AG-UI step names tracked by issue #7763. + +```bash +cd Step10_WorkflowNested +dotnet run --project Server --urls http://localhost:8888 +dotnet run --project Client +``` + +### Step11_WorkflowApproval + +An expense workflow that pauses with an AG-UI approval interruption and resumes on the same persisted thread. + +```bash +cd Step11_WorkflowApproval +dotnet run --project Server --urls http://localhost:8888 +dotnet run --project Client +``` + +### Step12_WorkflowMultipleInputs + +A travel workflow that emits three input interruptions in one turn, accepts partial and out-of-order +responses, and completes after the remaining input arrives. + +```bash +cd Step12_WorkflowMultipleInputs +dotnet run --project Server --urls http://localhost:8888 +dotnet run --project Client +``` + The server runs on port 8888 by default. #### Client (`Step05_StateManagement/Client`) diff --git a/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Client/Client.csproj b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Client/Client.csproj new file mode 100644 index 00000000000..24da3ebca35 --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Client/Client.csproj @@ -0,0 +1,14 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + diff --git a/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Client/Program.cs new file mode 100644 index 00000000000..382634936e6 --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Client/Program.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +using AGUI.Abstractions; +using AGUI.Client; +using Microsoft.Extensions.AI; + +string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; +using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; +using IChatClient chatClient = CreateChatClient(httpClient, serverUrl); + +Console.Write("Request: "); +string request = Console.ReadLine() ?? "Write a short welcome message for a developer conference."; + +await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync( + [new ChatMessage(ChatRole.User, request)])) +{ + switch (update.RawRepresentation) + { + case StepStartedEvent started: + Console.WriteLine($"\n[Step started: {started.StepName}]"); + break; + case StepFinishedEvent finished: + Console.WriteLine($"\n[Step finished: {finished.StepName}]"); + break; + } + + foreach (TextContent text in update.Contents.OfType()) + { + Console.Write(text.Text); + } +} + +Console.WriteLine(); + +static IChatClient CreateChatClient(HttpClient httpClient, string serverUrl) + => new AGUIChatClient(new(httpClient, serverUrl)); diff --git a/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/README.md b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/README.md new file mode 100644 index 00000000000..a37ea78aa8f --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/README.md @@ -0,0 +1,15 @@ +# Sequential Workflow over AG-UI + +This sample hosts a two-agent sequential workflow over AG-UI. The `Writer` drafts a response and the +`Reviewer` produces the final answer. The client prints AG-UI step lifecycle events alongside streamed text. + +## Run + +Set `AZURE_OPENAI_ENDPOINT` and `AZURE_OPENAI_DEPLOYMENT_NAME`, then: + +```powershell +dotnet run --project Server --urls http://localhost:8888 +dotnet run --project Client +``` + +Expected step events include `STEP_STARTED` and `STEP_FINISHED` for `Writer` followed by `Reviewer`. diff --git a/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Server/Program.cs new file mode 100644 index 00000000000..76979c8120f --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Server/Program.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +using AGUI.WorkflowSequential; +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; +using Microsoft.Agents.AI.Workflows; +using OpenAI.Chat; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +builder.Services.AddHttpClient().AddLogging(); +builder.Services.AddAGUIServer(); + +string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"] + ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] + ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); + +ChatClient chatClient = new AzureOpenAIClient( + new Uri(endpoint), + new DefaultAzureCredential()) + .GetChatClient(deploymentName); + +AIAgent writer = chatClient.AsAIAgent( + name: "Writer", + instructions: "Draft a concise answer to the user's request."); +AIAgent reviewer = chatClient.AsAIAgent( + name: "Reviewer", + instructions: "Review the draft and return an improved final answer."); + +AIAgent workflowAgent = SequentialWorkflow.Create(writer, reviewer).AsAIAgent(name: "SequentialWorkflow"); + +WebApplication app = builder.Build(); +app.MapAGUIServer("/", workflowAgent); +await app.RunAsync(); diff --git a/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Server/SequentialWorkflow.cs b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Server/SequentialWorkflow.cs new file mode 100644 index 00000000000..ba461b3ea68 --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Server/SequentialWorkflow.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Workflows; + +namespace AGUI.WorkflowSequential; + +/// +/// Creates the sequential workflow used by the sample and its integration test. +/// +public static class SequentialWorkflow +{ + /// + /// Creates a workflow that asks one agent to draft content and another to review it. + /// + /// The writer agent. + /// The reviewer agent. + /// The sequential workflow. + public static Workflow Create(AIAgent writer, AIAgent reviewer) + => new SequentialWorkflowBuilder(writer, reviewer).Build(); +} diff --git a/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Server/Server.csproj b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Server/Server.csproj new file mode 100644 index 00000000000..43fbf4e354a --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Server/Server.csproj @@ -0,0 +1,21 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Client/Client.csproj b/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Client/Client.csproj new file mode 100644 index 00000000000..24da3ebca35 --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Client/Client.csproj @@ -0,0 +1,14 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + diff --git a/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Client/Program.cs new file mode 100644 index 00000000000..0abbcebf1b0 --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Client/Program.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +using AGUI.Abstractions; +using AGUI.Client; +using Microsoft.Extensions.AI; + +string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; +using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; +using IChatClient chatClient = CreateChatClient(httpClient, serverUrl); + +Console.Write("Request: "); +string request = Console.ReadLine() ?? "Assess the tradeoffs of adopting a new framework."; + +await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync( + [new ChatMessage(ChatRole.User, request)])) +{ + switch (update.RawRepresentation) + { + case StepStartedEvent started: + Console.WriteLine($"\n[Step started: {started.StepName}]"); + break; + case StepFinishedEvent finished: + Console.WriteLine($"\n[Step finished: {finished.StepName}]"); + break; + } + + foreach (TextContent text in update.Contents.OfType()) + { + Console.Write(text.Text); + } +} + +Console.WriteLine(); + +static IChatClient CreateChatClient(HttpClient httpClient, string serverUrl) + => new AGUIChatClient(new(httpClient, serverUrl)); diff --git a/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/README.md b/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/README.md new file mode 100644 index 00000000000..424ee5b64ae --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/README.md @@ -0,0 +1,13 @@ +# Concurrent Workflow over AG-UI + +This sample runs a `Researcher` and `Critic` concurrently. The AG-UI client displays each executor's +step lifecycle and the output from both agents. + +## Run + +Set `AZURE_OPENAI_ENDPOINT` and `AZURE_OPENAI_DEPLOYMENT_NAME`, then: + +```powershell +dotnet run --project Server --urls http://localhost:8888 +dotnet run --project Client +``` diff --git a/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Server/ConcurrentWorkflow.cs b/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Server/ConcurrentWorkflow.cs new file mode 100644 index 00000000000..1039fc23298 --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Server/ConcurrentWorkflow.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Workflows; + +namespace AGUI.WorkflowConcurrent; + +/// +/// Creates the concurrent workflow used by the sample and its integration test. +/// +public static class ConcurrentWorkflow +{ + /// + /// Creates a workflow that runs all supplied agents concurrently. + /// + /// The agents to run concurrently. + /// The concurrent workflow. + public static Workflow Create(params AIAgent[] agents) + => new ConcurrentWorkflowBuilder(agents).Build(); +} diff --git a/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Server/Program.cs new file mode 100644 index 00000000000..33063a37679 --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Server/Program.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +using AGUI.WorkflowConcurrent; +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; +using Microsoft.Agents.AI.Workflows; +using OpenAI.Chat; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +builder.Services.AddHttpClient().AddLogging(); +builder.Services.AddAGUIServer(); + +string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"] + ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] + ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); + +ChatClient chatClient = new AzureOpenAIClient( + new Uri(endpoint), + new DefaultAzureCredential()) + .GetChatClient(deploymentName); + +AIAgent researcher = chatClient.AsAIAgent( + name: "Researcher", + instructions: "Identify the important facts in the user's request."); +AIAgent critic = chatClient.AsAIAgent( + name: "Critic", + instructions: "Identify risks and missing considerations in the user's request."); + +AIAgent workflowAgent = ConcurrentWorkflow.Create(researcher, critic).AsAIAgent(name: "ConcurrentWorkflow"); + +WebApplication app = builder.Build(); +app.MapAGUIServer("/", workflowAgent); +await app.RunAsync(); diff --git a/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Server/Server.csproj b/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Server/Server.csproj new file mode 100644 index 00000000000..43fbf4e354a --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Server/Server.csproj @@ -0,0 +1,21 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Client/Client.csproj b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Client/Client.csproj new file mode 100644 index 00000000000..24da3ebca35 --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Client/Client.csproj @@ -0,0 +1,14 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + diff --git a/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Client/Program.cs new file mode 100644 index 00000000000..2ad3d58cc2d --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Client/Program.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using AGUI.Abstractions; +using AGUI.Client; +using Microsoft.Extensions.AI; + +string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; +using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; +using IChatClient chatClient = CreateChatClient(httpClient, serverUrl); + +await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync( + [new ChatMessage(ChatRole.User, "Run the failing workflow.")])) +{ + switch (update.RawRepresentation) + { + case StepStartedEvent started: + Console.WriteLine($"[Step started: {started.StepName}]"); + break; + case StepFinishedEvent finished: + Console.WriteLine($"[Step finished: {finished.StepName}]"); + break; + case RunErrorEvent error: + Console.WriteLine($"[Error: {error.Message}]"); + break; + } +} + +static IChatClient CreateChatClient(HttpClient httpClient, string serverUrl) + => new AGUIChatClient(new(httpClient, serverUrl)); diff --git a/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/README.md b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/README.md new file mode 100644 index 00000000000..4d80203d4c7 --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/README.md @@ -0,0 +1,11 @@ +# Failing Workflow over AG-UI + +This sample demonstrates an executor failure. The AG-UI stream emits `STEP_FINISHED` for the failed executor +and then terminates with `RUN_ERROR`; it does not append a successful `RUN_FINISHED`. + +## Run + +```powershell +dotnet run --project Server --urls http://localhost:8888 +dotnet run --project Client +``` diff --git a/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Server/FailingWorkflow.cs b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Server/FailingWorkflow.cs new file mode 100644 index 00000000000..5ff7252e1cf --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Server/FailingWorkflow.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; + +namespace AGUI.WorkflowFailure; + +/// +/// Creates the failing workflow used by the sample and its integration test. +/// +public static class FailingWorkflow +{ + /// + /// Creates a workflow containing the supplied failing agent. + /// + /// The agent that fails during execution. + /// The failing workflow. + public static Workflow Create(AIAgent agent) + => new SequentialWorkflowBuilder(agent).Build(); +} + +/// +/// An agent that throws a deterministic exception for the failure sample. +/// +public sealed class FailingAgent : AIAgent +{ + /// + public override string? Name => "FailingStep"; + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + => throw new InvalidOperationException("The sample executor failed."); + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + yield return new AgentResponseUpdate(ChatRole.Assistant, "Starting work before failure.") + { + MessageId = "failure-message", + ResponseId = "failure-response", + }; + await Task.Yield(); + throw new InvalidOperationException("The sample executor failed."); + } + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + => new(new FailingSession()); + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + => new(JsonSerializer.SerializeToElement(new Dictionary())); + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + => new(new FailingSession()); + + private sealed class FailingSession : AgentSession; +} diff --git a/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Server/Program.cs new file mode 100644 index 00000000000..879716a34f1 --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Server/Program.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft. All rights reserved. + +using AGUI.WorkflowFailure; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; +using Microsoft.Agents.AI.Workflows; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +builder.Services.AddHttpClient().AddLogging(); +builder.Services.AddAGUIServer(); + +AIAgent workflowAgent = FailingWorkflow.Create(new FailingAgent()).AsAIAgent(name: "FailingWorkflow"); + +WebApplication app = builder.Build(); +app.MapAGUIServer("/", workflowAgent); +await app.RunAsync(); diff --git a/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Server/Server.csproj b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Server/Server.csproj new file mode 100644 index 00000000000..757b0cf7a57 --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Server/Server.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + diff --git a/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Client/Client.csproj b/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Client/Client.csproj new file mode 100644 index 00000000000..24da3ebca35 --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Client/Client.csproj @@ -0,0 +1,14 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + diff --git a/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Client/Program.cs new file mode 100644 index 00000000000..c872e2547f0 --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Client/Program.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft. All rights reserved. + +using AGUI.Abstractions; +using AGUI.Client; +using Microsoft.Extensions.AI; + +string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; +using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; +using IChatClient chatClient = CreateChatClient(httpClient, serverUrl); + +await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync( + [new ChatMessage(ChatRole.User, "What is the weather in Seattle?")])) +{ + switch (update.RawRepresentation) + { + case StepStartedEvent started: + Console.WriteLine($"\n[Step started: {started.StepName}]"); + break; + case StepFinishedEvent finished: + Console.WriteLine($"\n[Step finished: {finished.StepName}]"); + break; + } + + foreach (AIContent content in update.Contents) + { + switch (content) + { + case FunctionCallContent call: + Console.WriteLine($"\n[Tool call: {call.Name}]"); + break; + case FunctionResultContent result: + Console.WriteLine($"\n[Tool result: {result.Result}]"); + break; + case TextContent text: + Console.Write(text.Text); + break; + } + } +} + +Console.WriteLine(); + +static IChatClient CreateChatClient(HttpClient httpClient, string serverUrl) + => new AGUIChatClient(new(httpClient, serverUrl)); diff --git a/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/README.md b/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/README.md new file mode 100644 index 00000000000..80487b32e0d --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/README.md @@ -0,0 +1,13 @@ +# Tool-enabled Workflow over AG-UI + +This sample hosts a workflow containing an agent with a backend weather tool. Executor steps, tool calls, +tool results, and text all travel through the same AG-UI stream. + +## Run + +Set `AZURE_OPENAI_ENDPOINT` and `AZURE_OPENAI_DEPLOYMENT_NAME`, then: + +```powershell +dotnet run --project Server --urls http://localhost:8888 +dotnet run --project Client +``` diff --git a/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Server/Program.cs new file mode 100644 index 00000000000..c029c894762 --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Server/Program.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ComponentModel; +using AGUI.WorkflowTools; +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; +using OpenAI.Chat; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +builder.Services.AddHttpClient().AddLogging(); +builder.Services.AddAGUIServer(); + +string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"] + ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] + ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); + +[Description("Gets a deterministic weather forecast for a city.")] +static string GetWeather([Description("The city to inspect.")] string city) + => $"The weather in {city} is sunny and 24 C."; + +ChatClient chatClient = new AzureOpenAIClient( + new Uri(endpoint), + new DefaultAzureCredential()) + .GetChatClient(deploymentName); + +AIAgent weatherAgent = chatClient.AsAIAgent( + name: "WeatherAgent", + instructions: "Use the weather tool and answer with the returned forecast.", + tools: [AIFunctionFactory.Create(GetWeather)]); +AIAgent workflowAgent = ToolWorkflow.Create(weatherAgent).AsAIAgent(name: "ToolWorkflow"); + +WebApplication app = builder.Build(); +app.MapAGUIServer("/", workflowAgent); +await app.RunAsync(); diff --git a/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Server/Server.csproj b/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Server/Server.csproj new file mode 100644 index 00000000000..43fbf4e354a --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Server/Server.csproj @@ -0,0 +1,21 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Server/ToolWorkflow.cs b/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Server/ToolWorkflow.cs new file mode 100644 index 00000000000..f4f6df1afe6 --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Server/ToolWorkflow.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Workflows; + +namespace AGUI.WorkflowTools; + +/// +/// Creates the tool-enabled workflow used by the sample and its integration test. +/// +public static class ToolWorkflow +{ + /// + /// Creates a workflow containing the supplied tool-enabled agent. + /// + /// The tool-enabled agent. + /// The workflow. + public static Workflow Create(AIAgent agent) + => new SequentialWorkflowBuilder(agent).Build(); +} diff --git a/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Client/Client.csproj b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Client/Client.csproj new file mode 100644 index 00000000000..24da3ebca35 --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Client/Client.csproj @@ -0,0 +1,14 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + diff --git a/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Client/Program.cs new file mode 100644 index 00000000000..21e3d6bf0fd --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Client/Program.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft. All rights reserved. + +using AGUI.Abstractions; +using AGUI.Client; +using Microsoft.Extensions.AI; + +string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; +using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; +using IChatClient chatClient = CreateChatClient(httpClient, serverUrl); + +try +{ + await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync( + [new ChatMessage(ChatRole.User, "Review this proposal.")])) + { + switch (update.RawRepresentation) + { + case StepStartedEvent started: + Console.WriteLine($"\n[Step started: {started.StepName}]"); + break; + case StepFinishedEvent finished: + Console.WriteLine($"\n[Step finished: {finished.StepName}]"); + break; + } + + foreach (TextContent text in update.Contents.OfType()) + { + Console.Write(text.Text); + } + } +} +catch (InvalidOperationException exception) +{ + Console.WriteLine($"\n[Known nested-step identity limitation (#7763): {exception.Message}]"); +} + +Console.WriteLine(); + +static IChatClient CreateChatClient(HttpClient httpClient, string serverUrl) + => new AGUIChatClient(new(httpClient, serverUrl)); diff --git a/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/README.md b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/README.md new file mode 100644 index 00000000000..6b55873acd9 --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/README.md @@ -0,0 +1,15 @@ +# Parallel Nested Workflows over AG-UI + +This sample runs `SecurityPipeline` and `StylePipeline` as parallel subworkflows. Each subworkflow contains +a locally scoped executor named `Analyze`. + +The client currently receives duplicate `Analyze_Analyze` step names because nested executor lifecycle events +do not carry their parent workflow scope. AG-UI rejects the second overlapping `STEP_STARTED` as already active. +The client reports this known limitation, which is tracked by issue #7763. + +## Run + +```powershell +dotnet run --project Server --urls http://localhost:8888 +dotnet run --project Client +``` diff --git a/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Server/NestedWorkflow.cs b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Server/NestedWorkflow.cs new file mode 100644 index 00000000000..ec38137c992 --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Server/NestedWorkflow.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; + +namespace AGUI.WorkflowNested; + +/// +/// Creates parallel nested workflows with duplicate local executor IDs. +/// +public static class NestedWorkflow +{ + /// + /// Creates the parent workflow. + /// + /// A parent workflow containing security and style subworkflows. + public static Workflow Create() + { + AnalysisGate gate = new(); + ChatForwardingExecutor start = new("Start"); + ExecutorBinding security = CreateAnalysisWorkflow("Security", gate).BindAsExecutor("SecurityPipeline"); + ExecutorBinding style = CreateAnalysisWorkflow("Style", gate).BindAsExecutor("StylePipeline"); + + return new WorkflowBuilder(start) + .AddFanOutEdge(start, [security, style]) + .WithOutputFrom(security, style) + .Build(); + } + + private static Workflow CreateAnalysisWorkflow(string analysisType, AnalysisGate gate) + => new SequentialWorkflowBuilder(new AnalysisAgent(analysisType, gate)).Build(); +} + +internal sealed class AnalysisAgent(string analysisType, AnalysisGate gate) : AIAgent +{ + protected override string? IdCore => "Analyze"; + + public override string? Name => "Analyze"; + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + => Task.FromResult(new AgentResponse( + new ChatMessage(ChatRole.Assistant, $"{analysisType} analysis complete."))); + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await gate.SignalAndWaitAsync(cancellationToken).ConfigureAwait(false); + yield return new AgentResponseUpdate(ChatRole.Assistant, $"{analysisType} analysis complete.") + { + MessageId = $"{analysisType}-message", + ResponseId = $"{analysisType}-response", + }; + } + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + => new(new AnalysisSession()); + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + => new(JsonSerializer.SerializeToElement(new Dictionary())); + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + => new(new AnalysisSession()); + + private sealed class AnalysisSession : AgentSession; +} + +internal sealed class AnalysisGate +{ + private readonly TaskCompletionSource _bothStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _startedCount; + + public async Task SignalAndWaitAsync(CancellationToken cancellationToken) + { + if (Interlocked.Increment(ref this._startedCount) == 2) + { + this._bothStarted.TrySetResult(); + } + + await this._bothStarted.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } +} diff --git a/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Server/Program.cs new file mode 100644 index 00000000000..74d39b42316 --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Server/Program.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +using AGUI.WorkflowNested; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; +using Microsoft.Agents.AI.Workflows; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +builder.Services.AddHttpClient().AddLogging(); +builder.Services.AddAGUIServer(); + +AIAgent workflowAgent = NestedWorkflow.Create().AsAIAgent( + name: "NestedWorkflow", + includeWorkflowOutputsInResponse: true); + +WebApplication app = builder.Build(); +app.MapAGUIServer("/", workflowAgent); +await app.RunAsync(); diff --git a/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Server/Server.csproj b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Server/Server.csproj new file mode 100644 index 00000000000..757b0cf7a57 --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Server/Server.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + diff --git a/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Client/Client.csproj b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Client/Client.csproj new file mode 100644 index 00000000000..24da3ebca35 --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Client/Client.csproj @@ -0,0 +1,14 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + diff --git a/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Client/Program.cs new file mode 100644 index 00000000000..fdfac1c4d68 --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Client/Program.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using AGUI.Client; +using Microsoft.Extensions.AI; + +string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; +using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; +using IChatClient chatClient = CreateChatClient(httpClient, serverUrl); +ChatOptions options = new(); + +var expenseReport = new +{ + id = "EXP-100", + employee = "Taylor", + amount = 125.00m, + businessPurpose = "Developer conference registration", + receiptAttached = true, +}; +string reportJson = JsonSerializer.Serialize(expenseReport); + +List firstTurn = await chatClient + .GetStreamingResponseAsync( + [new ChatMessage(ChatRole.User, $"Review and submit this expense report:\n{reportJson}")], + options) + .ToListAsync(); + +#pragma warning disable MEAI001 // Tool approval content is experimental. +ToolApprovalRequestContent approvalRequest = firstTurn + .SelectMany(static update => update.Contents) + .OfType() + .Single(); +FunctionCallContent toolCall = (FunctionCallContent)approvalRequest.ToolCall; + +Console.WriteLine($"The workflow completed its checks and wants to call {toolCall.Name}."); +Console.Write($"Approve submission of expense {expenseReport.id}? [y/N]: "); +bool approved = string.Equals(Console.ReadLine(), "y", StringComparison.OrdinalIgnoreCase); +ToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse( + approved, + approved ? "Approved by the sample user." : "Rejected by the sample user."); + +List approvalMessages = +[ + new(ChatRole.Assistant, [approvalRequest]), + new(ChatRole.Tool, [approvalResponse]), +]; + +await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync(approvalMessages, options)) +{ + foreach (TextContent text in update.Contents.OfType()) + { + Console.Write(text.Text); + } +} +#pragma warning restore MEAI001 + +Console.WriteLine(); + +static IChatClient CreateChatClient(HttpClient httpClient, string serverUrl) + => new AGUIChatClient(new(httpClient, serverUrl)); diff --git a/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/README.md b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/README.md new file mode 100644 index 00000000000..073b148ae90 --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/README.md @@ -0,0 +1,18 @@ +# Approval Workflow over AG-UI + +This sample hosts a workflow containing one expense-review agent. The agent checks that the report has a +business purpose, an attached receipt, a positive amount no greater than 500 USD, and a plausible business +expense. If every check passes, it calls an approval-required `SubmitExpense` tool. + +The AG-UI client sends the expense report, receives `ToolApprovalRequestContent`, creates the paired +`ToolApprovalResponseContent`, and resumes the same workflow thread through `IChatClient`. + +The sample uses an in-memory session store without user isolation for local demonstration only. Production +hosts must isolate persisted sessions by authenticated principal. + +## Run + +```powershell +dotnet run --project Server --urls http://localhost:8888 +dotnet run --project Client +``` diff --git a/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/ApprovalWorkflow.cs b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/ApprovalWorkflow.cs new file mode 100644 index 00000000000..e0f324af8f0 --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/ApprovalWorkflow.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Workflows; + +namespace AGUI.WorkflowApproval; + +/// +/// Creates the approval workflow used by the sample and its integration test. +/// +public static class ApprovalWorkflow +{ + /// + /// Creates a workflow containing one expense-review agent. + /// + /// The agent that checks and submits expense reports. + /// The expense approval workflow. + public static Workflow Create(AIAgent expenseReviewer) + => new SequentialWorkflowBuilder(expenseReviewer).Build(); +} + +/// +/// An expense report submitted to the workflow. +/// +/// The report identifier. +/// The submitting employee. +/// The total expense amount. +/// The business purpose. +/// Whether a receipt is attached. +public sealed record ExpenseReport( + string Id, + string Employee, + decimal Amount, + string BusinessPurpose, + bool ReceiptAttached); diff --git a/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/Program.cs new file mode 100644 index 00000000000..7a556bc2ab6 --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/Program.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ComponentModel; +using AGUI.WorkflowApproval; +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting; +using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; +using OpenAI.Chat; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +builder.Services.AddHttpClient().AddLogging(); +builder.Services.AddAGUIServer(); + +string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"] + ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] + ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); + +[Description("Submits an expense report after the user approves the operation.")] +static string SubmitExpense(ExpenseReport report) + => $"Expense report {report.Id} for {report.Employee} was submitted."; + +#pragma warning disable MEAI001 // ApprovalRequiredAIFunction is experimental. +AITool submitExpense = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(SubmitExpense)); +#pragma warning restore MEAI001 + +ChatClient chatClient = new AzureOpenAIClient( + new Uri(endpoint), + new DefaultAzureCredential()) + .GetChatClient(deploymentName); + +AIAgent expenseReviewer = chatClient.AsAIAgent( + name: "ExpenseReviewer", + instructions: """ + Review the expense report supplied by the user. Perform every check below: + 1. The report has a non-empty business purpose. + 2. A receipt is attached. + 3. The amount is positive and no greater than 500 USD. + 4. The expense is plausibly business-related. + + If any check fails, explain every failed check and do not call SubmitExpense. + If all checks pass, call SubmitExpense with the complete report. The tool requires user approval. + """, + tools: [submitExpense]); + +Workflow workflow = ApprovalWorkflow.Create(expenseReviewer); +AIAgent workflowAgent = workflow.AsAIAgent(name: "ApprovalWorkflow"); + +builder.Services.AddAIAgent("ApprovalWorkflow", (_, _) => workflowAgent) + .WithInMemorySessionStore(withIsolation: false); + +WebApplication app = builder.Build(); +app.MapAGUIServer("ApprovalWorkflow", "/"); +await app.RunAsync(); diff --git a/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/Server.csproj b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/Server.csproj new file mode 100644 index 00000000000..43fbf4e354a --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/Server.csproj @@ -0,0 +1,21 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Client/Client.csproj b/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Client/Client.csproj new file mode 100644 index 00000000000..24da3ebca35 --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Client/Client.csproj @@ -0,0 +1,14 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + diff --git a/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Client/Program.cs new file mode 100644 index 00000000000..9c6de6ba573 --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Client/Program.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using AGUI.Abstractions; +using AGUI.Client; +using Microsoft.Extensions.AI; + +string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; +using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; +using IChatClient chatClient = CreateChatClient(httpClient, serverUrl); + +List firstTurn = await chatClient + .GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "Plan my conference trip.")]) + .ToListAsync(); +RunFinishedEvent firstFinished = firstTurn.Select(static update => update.RawRepresentation) + .OfType() + .Single(); +AGUIInterrupt[] requests = [.. ((RunFinishedInterruptOutcome)firstFinished.Outcome!).Interrupts]; + +AGUIInterrupt dates = requests.Single(static item => item.Message!.Contains("TravelDates")); +AGUIInterrupt travelers = requests.Single(static item => item.Message!.Contains("TravelerDetails")); +AGUIInterrupt preferences = requests.Single(static item => item.Message!.Contains("TravelPreferences")); + +List partialTurn = await chatClient.GetStreamingResponseAsync( + [], + CreateResumeOptions( + firstFinished, + [ + Resume(travelers, new { kind = "travelers", count = 2, accessibility = "none" }), + Resume(dates, new { kind = "dates", departure = "2026-10-10", returnDate = "2026-10-14" }), + ])).ToListAsync(); +RunFinishedEvent partialFinished = partialTurn.Select(static update => update.RawRepresentation) + .OfType() + .Single(); + +List finalTurn = await chatClient.GetStreamingResponseAsync( + [], + CreateResumeOptions( + partialFinished, + [Resume(preferences, new { kind = "preferences", budget = 2500, cabin = "economy", hotel = "downtown" })])) + .ToListAsync(); + +Console.WriteLine(string.Concat(finalTurn.Select(static update => update.Text))); + +ChatOptions CreateResumeOptions(RunFinishedEvent previous, IList resumes) + => new() + { + RawRepresentationFactory = _ => new RunAgentInput + { + Messages = [], + ParentRunId = previous.RunId, + Resume = resumes, + RunId = Guid.NewGuid().ToString("N"), + ThreadId = previous.ThreadId, + }, + }; + +static AGUIResume Resume(AGUIInterrupt interrupt, object payload) + => new() + { + InterruptId = interrupt.Id, + Payload = JsonSerializer.SerializeToElement(payload), + Status = "resolved", + }; + +static IChatClient CreateChatClient(HttpClient httpClient, string serverUrl) + => new AGUIChatClient(new(httpClient, serverUrl)); diff --git a/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/README.md b/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/README.md new file mode 100644 index 00000000000..ca34322130f --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/README.md @@ -0,0 +1,15 @@ +# Multiple Workflow Inputs over AG-UI + +This travel-planning workflow requests dates, traveler details, and travel preferences in the same turn. +The client responds to two requests out of order, then submits the remaining preference response in a later +continuation. The workflow produces its final result only after all three inputs are available. + +The sample uses an in-memory session store without user isolation for local demonstration only. Production +hosts must isolate persisted sessions by authenticated principal. + +## Run + +```powershell +dotnet run --project Server --urls http://localhost:8888 +dotnet run --project Client +``` diff --git a/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Server/MultipleInputsWorkflow.cs b/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Server/MultipleInputsWorkflow.cs new file mode 100644 index 00000000000..ec53c12457d --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Server/MultipleInputsWorkflow.cs @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; + +namespace AGUI.WorkflowMultipleInputs; + +/// +/// Creates the multiple-input travel workflow used by the sample and its integration test. +/// +public static class MultipleInputsWorkflow +{ + /// + /// Creates a workflow that requests three independent pieces of travel information. + /// + /// The travel-planning workflow. + public static Workflow Create() + { + TravelPlannerExecutor executor = new(); + return new WorkflowBuilder(executor) + .AddExternalCall(executor, "TravelDates") + .AddExternalCall(executor, "TravelerDetails") + .AddExternalCall(executor, "TravelPreferences") + .WithOutputFrom(executor) + .Build(); + } +} + +/// Requests departure and return dates. +public sealed record TravelDatesRequest(string Destination); + +/// Requests traveler count and accessibility needs. +public sealed record TravelerDetailsRequest(string Destination); + +/// Requests budget, cabin, and hotel preferences. +public sealed record TravelPreferencesRequest(string Destination); + +[SendsMessage(typeof(TravelDatesRequest))] +[SendsMessage(typeof(TravelerDetailsRequest))] +[SendsMessage(typeof(TravelPreferencesRequest))] +internal sealed partial class TravelPlannerExecutor() + : ChatProtocolExecutor("TravelPlanner", new ChatProtocolExecutorOptions { AutoSendTurnToken = false }) +{ + private const string StateScope = "TravelInputs"; + private static readonly string[] s_inputKinds = ["dates", "travelers", "preferences"]; + + protected override async ValueTask TakeTurnAsync( + List messages, + IWorkflowContext context, + bool? emitEvents, + CancellationToken cancellationToken = default) + { + const string Destination = "Seattle"; + await context.SendMessageAsync(new TravelDatesRequest(Destination), cancellationToken).ConfigureAwait(false); + await context.SendMessageAsync(new TravelerDetailsRequest(Destination), cancellationToken).ConfigureAwait(false); + await context.SendMessageAsync(new TravelPreferencesRequest(Destination), cancellationToken).ConfigureAwait(false); + } + + [MessageHandler] + public async ValueTask HandleInputAsync( + JsonElement response, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + string kind = response.GetProperty("kind").GetString() + ?? throw new InvalidOperationException("The response kind is required."); + await context.QueueStateUpdateAsync(kind, response.Clone(), StateScope, cancellationToken).ConfigureAwait(false); + + string[] otherKinds = [.. s_inputKinds.Where(candidate => candidate != kind)]; + bool allOtherInputsAvailable = true; + foreach (string otherKind in otherKinds) + { + JsonElement? value = await context.ReadStateAsync( + otherKind, + StateScope, + cancellationToken).ConfigureAwait(false); + allOtherInputsAvailable &= value.HasValue; + } + + if (!allOtherInputsAvailable) + { + return; + } + + AgentResponseUpdate update = new( + ChatRole.Assistant, + "Travel plan ready for Seattle using all requested dates, traveler details, and preferences.") + { + MessageId = "travel-plan", + ResponseId = "travel-response", + }; + await context.AddEventAsync(new AgentResponseUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false); + await context.SendMessageAsync(new TurnToken(false), cancellationToken).ConfigureAwait(false); + } +} diff --git a/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Server/Program.cs new file mode 100644 index 00000000000..6a7998ae368 --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Server/Program.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +using AGUI.WorkflowMultipleInputs; +using Microsoft.Agents.AI.Hosting; +using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; +using Microsoft.Agents.AI.Workflows; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +builder.Services.AddHttpClient().AddLogging(); +builder.Services.AddAGUIServer(); +builder.Services.AddAIAgent( + "MultipleInputsWorkflow", + static (_, _) => MultipleInputsWorkflow.Create().AsAIAgent(name: "MultipleInputsWorkflow")) + .WithInMemorySessionStore(withIsolation: false); + +WebApplication app = builder.Build(); +app.MapAGUIServer("MultipleInputsWorkflow", "/"); +await app.RunAsync(); diff --git a/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Server/Server.csproj b/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Server/Server.csproj new file mode 100644 index 00000000000..6d6151d24c4 --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Server/Server.csproj @@ -0,0 +1,19 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs index 348d4a86625..99d227ba879 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs @@ -147,14 +147,18 @@ public static IEndpointConventionBuilder MapAGUIServer( var session = await hostAgent.GetOrCreateSessionAsync(threadId, cancellationToken).ConfigureAwait(false); + IEnumerable messages = ctx.Messages.MapAGUIInterruptResponsesToFunctionResults(); + var events = hostAgent .RunStreamingAsync( - ctx.Messages, + messages, session: session, options: new ChatClientAgentRunOptions { ChatOptions = ctx.ChatOptions }, cancellationToken: cancellationToken) .AsChatResponseUpdatesAsync() - .AsAGUIEventStreamAsync(ctx, cancellationToken); + .MapWorkflowEventsToAGUI() + .AsAGUIEventStreamAsync(ctx, cancellationToken) + .MakeRunErrorTerminalAsync(); // Wrap the event stream to save the session after streaming completes. var eventsWithSessionSave = SaveSessionAfterStreamingAsync(events, hostAgent, threadId, session, cancellationToken); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/ConfigureAGUIJsonOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/ConfigureAGUIJsonOptions.cs index 27a23ed4d62..1a06e5436e9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/ConfigureAGUIJsonOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/ConfigureAGUIJsonOptions.cs @@ -22,5 +22,6 @@ public void Configure(JsonOptions options) // configured ASP.NET Core JsonSerializerOptions). chain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!); chain.Add(AGUIJsonSerializerContext.Default.Options.TypeInfoResolver!); + AGUIJsonUtilities.RegisterInterruptContentTypes(options.SerializerOptions); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj index 3a46871daad..7303e976938 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj @@ -19,6 +19,7 @@ + diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/WorkflowAGUIExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/WorkflowAGUIExtensions.cs new file mode 100644 index 00000000000..de976298724 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/WorkflowAGUIExtensions.cs @@ -0,0 +1,254 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using AGUI.Abstractions; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; + +internal static class WorkflowAGUIExtensions +{ +#pragma warning disable VSTHRD200 // The name describes a stream transformation, consistent with the requested pipeline. + internal static async IAsyncEnumerable MapWorkflowEventsToAGUI( + this IAsyncEnumerable updates) + { + ArgumentNullException.ThrowIfNull(updates); + List interruptions = []; + List uncorrelatedApprovalRequests = []; + + await foreach (ChatResponseUpdate update in updates.ConfigureAwait(false)) + { + switch (update.RawRepresentation) + { + case AgentResponseUpdate { RawRepresentation: RequestInfoEvent } + when update.Contents.OfType().SingleOrDefault() is { } approvalRequest: + uncorrelatedApprovalRequests.RemoveAll(candidate => + candidate.Contents.OfType().Any(candidateRequest => + candidateRequest.ToolCall.CallId == approvalRequest.ToolCall.CallId)); + yield return update; + break; + + case AgentResponseUpdate { RawRepresentation: RequestInfoEvent requestInfo } + when update.Contents.OfType().SingleOrDefault() is { } request: + update.Contents = + [ + new InterruptRequestContent(requestInfo.Request.RequestId) + { + Message = $"Input required for {request.Name}.", + Reason = InterruptReasons.InputRequired, + ToolCallId = request.CallId, + }, + ]; + update.RawRepresentation = null; + interruptions.Add(update); + break; + + case AgentResponseUpdate { RawRepresentation: ExecutorInvokedEvent invoked }: + update.RawRepresentation = new StepStartedEvent { StepName = invoked.ExecutorId }; + yield return update; + break; + + case AgentResponseUpdate { RawRepresentation: ExecutorCompletedEvent completed }: + update.RawRepresentation = new StepFinishedEvent { StepName = completed.ExecutorId }; + yield return update; + break; + + case AgentResponseUpdate { RawRepresentation: ExecutorFailedEvent failed }: + yield return CreateEventUpdate( + update, + new StepFinishedEvent { StepName = failed.ExecutorId }, + includeContents: false); + yield return CreateEventUpdate( + update, + new RunErrorEvent + { + Message = update.Contents.OfType().SingleOrDefault()?.Message + ?? "An error occurred while executing the workflow.", + }, + includeContents: false); + break; + + case var _ when update.Contents.OfType().Any(): + uncorrelatedApprovalRequests.Add(update); + break; + + default: + yield return update; + break; + } + } + + foreach (ChatResponseUpdate interruption in interruptions) + { + yield return interruption; + } + + foreach (ChatResponseUpdate approvalRequest in uncorrelatedApprovalRequests) + { + yield return approvalRequest; + } + } +#pragma warning restore VSTHRD200 + + // TODO: Remove this adapter after consuming an AG-UI .NET release containing + // https://github.com/ag-ui-protocol/ag-ui/pull/2455, which makes RUN_ERROR terminal + // and prevents the SDK from appending RUN_FINISHED(success). + internal static async IAsyncEnumerable MakeRunErrorTerminalAsync( + this IAsyncEnumerable events) + { + ArgumentNullException.ThrowIfNull(events); + RunErrorEvent? runError = null; + List activeSteps = []; + List activeTextMessages = []; + List activeToolCalls = []; + List activeReasoning = []; + List activeReasoningMessages = []; + + await foreach (BaseEvent evt in events.ConfigureAwait(false)) + { + if (runError is null) + { + if (evt is RunErrorEvent error) + { + runError = error; + } + else + { + TrackLifecycle(evt); + yield return evt; + } + } + else if (IsMatchingClosure(evt)) + { + yield return evt; + } + } + + if (runError is not null) + { + foreach (string toolCallId in activeToolCalls) + { + yield return new ToolCallEndEvent { ToolCallId = toolCallId }; + } + + foreach (string messageId in activeTextMessages) + { + yield return new TextMessageEndEvent { MessageId = messageId }; + } + + foreach (string messageId in activeReasoningMessages) + { + yield return new ReasoningMessageEndEvent { MessageId = messageId }; + } + + foreach (string messageId in activeReasoning) + { + yield return new ReasoningEndEvent { MessageId = messageId }; + } + + foreach (string stepName in activeSteps) + { + yield return new StepFinishedEvent { StepName = stepName }; + } + + yield return runError; + } + + void TrackLifecycle(BaseEvent evt) + { + switch (evt) + { + case StepStartedEvent started: + Add(activeSteps, started.StepName); + break; + case StepFinishedEvent finished: + activeSteps.Remove(finished.StepName); + break; + case TextMessageStartEvent started: + Add(activeTextMessages, started.MessageId); + break; + case TextMessageEndEvent finished: + activeTextMessages.Remove(finished.MessageId); + break; + case ToolCallStartEvent started: + Add(activeToolCalls, started.ToolCallId); + break; + case ToolCallEndEvent finished: + activeToolCalls.Remove(finished.ToolCallId); + break; + case ReasoningStartEvent started: + Add(activeReasoning, started.MessageId); + break; + case ReasoningEndEvent finished: + activeReasoning.Remove(finished.MessageId); + break; + case ReasoningMessageStartEvent started: + Add(activeReasoningMessages, started.MessageId); + break; + case ReasoningMessageEndEvent finished: + activeReasoningMessages.Remove(finished.MessageId); + break; + } + } + + bool IsMatchingClosure(BaseEvent evt) + => evt switch + { + StepFinishedEvent finished => activeSteps.Remove(finished.StepName), + TextMessageEndEvent finished => activeTextMessages.Remove(finished.MessageId), + ToolCallEndEvent finished => activeToolCalls.Remove(finished.ToolCallId), + ReasoningEndEvent finished => activeReasoning.Remove(finished.MessageId), + ReasoningMessageEndEvent finished => activeReasoningMessages.Remove(finished.MessageId), + _ => false, + }; + + static void Add(List activeItems, string id) + { + if (!activeItems.Contains(id, StringComparer.Ordinal)) + { + activeItems.Add(id); + } + } + } + + private static ChatResponseUpdate CreateEventUpdate( + ChatResponseUpdate update, + BaseEvent evt, + bool includeContents = true) + => new() + { + AdditionalProperties = update.AdditionalProperties, + AuthorName = update.AuthorName, + Contents = includeContents ? update.Contents : [], + CreatedAt = update.CreatedAt, + FinishReason = update.FinishReason, + MessageId = update.MessageId, + RawRepresentation = evt, + ResponseId = update.ResponseId, + Role = update.Role, + ContinuationToken = update.ContinuationToken, + }; + + internal static List MapAGUIInterruptResponsesToFunctionResults( + this IEnumerable messages) + => [.. messages.Select(static message => + { + AIContent[] contents = [.. message.Contents.Select(static content => + content is InterruptResponseContent response + ? new FunctionResultContent(response.RequestId, response.Payload) + : content)]; + + return new ChatMessage(message.Role, contents) + { + AdditionalProperties = message.AdditionalProperties, + AuthorName = message.AuthorName, + CreatedAt = message.CreatedAt, + MessageId = message.MessageId, + RawRepresentation = message.RawRepresentation, + }; + })]; +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj index 019f0eb7a16..c0cf1534c60 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj @@ -27,6 +27,24 @@ + + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/ApprovalWorkflowTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/ApprovalWorkflowTests.cs new file mode 100644 index 00000000000..4df4139c60f --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/ApprovalWorkflowTests.cs @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using AGUI.Client; +using AGUI.WorkflowApproval; +using FluentAssertions; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.Workflows; + +public sealed class ApprovalWorkflowTests +{ + [Fact] + public async Task ClientApprovesToolRequestAndWorkflowResumesAsync() + { + // Arrange + Workflow workflow = ApprovalWorkflow.Create(new DeterministicExpenseReviewer()); + AIAgent workflowAgent = workflow.AsAIAgent(name: "ApprovalWorkflow"); + await using WorkflowTestHost host = await WorkflowTestHost.StartAsync(workflowAgent, persistSession: true); + using AGUIChatClient chatClient = new(new(host.Client, "")); + ChatOptions options = new(); + ExpenseReport report = new( + "EXP-100", + "Taylor", + 125.00m, + "Developer conference registration", + ReceiptAttached: true); + + // Act - the reviewer requests approval to submit the report. + List firstTurn = await chatClient + .GetStreamingResponseAsync( + [new ChatMessage(ChatRole.User, JsonSerializer.Serialize(report))], + options) + .ToListAsync(); + +#pragma warning disable MEAI001 // Tool approval content is experimental. + ToolApprovalRequestContent approvalRequest = firstTurn + .SelectMany(static update => update.Contents) + .OfType() + .Single(); + FunctionCallContent toolCall = approvalRequest.ToolCall.Should() + .BeOfType().Subject; + toolCall.Name.Should().Be("SubmitExpense"); + + ToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse( + approved: true, + reason: "Approved by integration test."); + List approvalMessages = + [ + new(ChatRole.Assistant, [approvalRequest]), + new(ChatRole.Tool, [approvalResponse]), + ]; + List secondTurn = await chatClient + .GetStreamingResponseAsync(approvalMessages, options) + .ToListAsync(); +#pragma warning restore MEAI001 + + // Assert + secondTurn.Should().Contain(static update => update.Text == "Expense report EXP-100 was submitted."); + } + + private sealed class DeterministicExpenseReviewer : AIAgent + { + public override string? Name => "ExpenseReviewer"; + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + => this.RunStreamingAsync(messages, session, options, cancellationToken).ToAgentResponseAsync(cancellationToken); + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { +#pragma warning disable MEAI001 // Tool approval content is experimental. + ToolApprovalResponseContent? approvalResponse = messages + .SelectMany(static message => message.Contents) + .OfType() + .LastOrDefault(); + + if (approvalResponse is not null) + { + yield return CreateUpdate(approvalResponse.Approved + ? new TextContent("Expense report EXP-100 was submitted.") + : new TextContent("Expense report EXP-100 was rejected.")); + yield break; + } + + FunctionCallContent toolCall = new( + "submit-expense-call", + "SubmitExpense", + new Dictionary { ["reportId"] = "EXP-100" }); + yield return CreateUpdate(new ToolApprovalRequestContent("submit-expense-approval", toolCall)); +#pragma warning restore MEAI001 + await Task.Yield(); + } + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + => new(new ExpenseSession()); + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + => new(JsonSerializer.SerializeToElement(new Dictionary())); + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + => new(new ExpenseSession()); + + private static AgentResponseUpdate CreateUpdate(AIContent content) + => new(ChatRole.Assistant, [content]) + { + MessageId = "expense-review-message", + ResponseId = "expense-review-response", + }; + + private sealed class ExpenseSession : AgentSession; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/ConcurrentWorkflowTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/ConcurrentWorkflowTests.cs new file mode 100644 index 00000000000..07784b3d9cf --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/ConcurrentWorkflowTests.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using AGUI.Abstractions; +using AGUI.Client; +using AGUI.WorkflowConcurrent; +using FluentAssertions; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.Workflows; + +public sealed class ConcurrentWorkflowTests +{ + [Fact] + public async Task ClientReceivesIndependentExecutorStepsAsync() + { + // Arrange + AIAgent researcher = new DeterministicAgent("Researcher", "facts"); + AIAgent critic = new DeterministicAgent("Critic", "risks"); + Workflow workflow = ConcurrentWorkflow.Create(researcher, critic); + await using WorkflowTestHost host = await WorkflowTestHost.StartAsync( + workflow.AsAIAgent(name: "ConcurrentWorkflow")); + AGUIChatClient chatClient = new(new(host.Client, "")); + AIAgent clientAgent = chatClient.AsAIAgent(name: "client"); + AgentSession session = await clientAgent.CreateSessionAsync(); + + // Act + List updates = await clientAgent + .RunStreamingAsync(new ChatMessage(ChatRole.User, "start"), session) + .ToListAsync(); + + // Assert + object?[] lifecycle = [.. updates + .Select(static update => update.AsChatResponseUpdate().RawRepresentation) + .Where(static raw => raw is StepStartedEvent or StepFinishedEvent)]; + + int researcherStart = Array.FindIndex(lifecycle, static raw => + raw is StepStartedEvent evt && evt.StepName.StartsWith("Researcher_", StringComparison.Ordinal)); + int criticStart = Array.FindIndex(lifecycle, static raw => + raw is StepStartedEvent evt && evt.StepName.StartsWith("Critic_", StringComparison.Ordinal)); + int researcherFinish = Array.FindIndex(lifecycle, static raw => + raw is StepFinishedEvent evt && evt.StepName.StartsWith("Researcher_", StringComparison.Ordinal)); + int criticFinish = Array.FindIndex(lifecycle, static raw => + raw is StepFinishedEvent evt && evt.StepName.StartsWith("Critic_", StringComparison.Ordinal)); + + researcherStart.Should().BeGreaterThanOrEqualTo(0); + criticStart.Should().BeGreaterThanOrEqualTo(0); + researcherFinish.Should().BeGreaterThan(researcherStart); + criticFinish.Should().BeGreaterThan(criticStart); + updates.Count(static update => update.Text == "facts").Should().Be(1); + updates.Count(static update => update.Text == "risks").Should().Be(1); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/DeterministicAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/DeterministicAgent.cs new file mode 100644 index 00000000000..9bb2023d93b --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/DeterministicAgent.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.Workflows; + +internal sealed class DeterministicAgent(string name, string response) : AIAgent +{ + public override string? Name => name; + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + => Task.FromResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, response))); + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.Yield(); + yield return new AgentResponseUpdate(ChatRole.Assistant, response) + { + AuthorName = name, + MessageId = $"{name}-message", + ResponseId = $"{name}-response", + }; + } + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + => new(new TestSession()); + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + => new(JsonSerializer.SerializeToElement(new Dictionary())); + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + => new(new TestSession()); + + private sealed class TestSession : AgentSession; +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/FailingWorkflowTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/FailingWorkflowTests.cs new file mode 100644 index 00000000000..8900f4274cf --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/FailingWorkflowTests.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Json; +using System.Threading.Tasks; +using AGUI.Abstractions; +using AGUI.Client; +using AGUI.WorkflowFailure; +using FluentAssertions; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.Workflows; + +public sealed class FailingWorkflowTests +{ + [Fact] + public async Task ClientReceivesStepFinishedForFailedExecutorAsync() + { + // Arrange + Workflow workflow = FailingWorkflow.Create(new FailingAgent()); + await using WorkflowTestHost host = await WorkflowTestHost.StartAsync( + workflow.AsAIAgent(name: "FailingWorkflow")); + RunAgentInput input = new() + { + Messages = new[] { new ChatMessage(ChatRole.User, "start") }.AsAGUIMessages().ToList(), + RunId = "failure-run", + ThreadId = "failure-thread", + }; + + // Act + List events = await SendAsync(host.Client, input); + + // Assert + int stepStarted = events.FindIndex(static evt => + evt is StepStartedEvent step + && step.StepName.StartsWith("FailingStep_", StringComparison.Ordinal)); + int stepFinished = events.FindIndex(static evt => + evt is StepFinishedEvent step + && step.StepName.StartsWith("FailingStep_", StringComparison.Ordinal)); + int textEnd = events.FindLastIndex(static evt => evt is TextMessageEndEvent); + int runError = events.FindIndex(static evt => evt is RunErrorEvent); + + stepStarted.Should().BeGreaterThanOrEqualTo(0); + stepFinished.Should().BeGreaterThan(stepStarted); + textEnd.Should().BeGreaterThan(stepFinished); + runError.Should().BeGreaterThan(stepFinished); + runError.Should().BeGreaterThan(textEnd); + events[runError].Should().BeOfType() + .Which.Message.Should().Be("An error occurred while executing the workflow."); + events.Skip(runError + 1).Should().BeEmpty(); + events.OfType().Should().BeEmpty(); + events.OfType().Should().ContainSingle(); + events.OfType().Should().ContainSingle(); + + int startedCount = events + .OfType() + .Count(static evt => evt.StepName.StartsWith("FailingStep_", StringComparison.Ordinal)); + int finishedCount = events + .OfType() + .Count(static evt => evt.StepName.StartsWith("FailingStep_", StringComparison.Ordinal)); + startedCount.Should().BeGreaterThan(0); + finishedCount.Should().Be(startedCount); + } + + private static async Task> SendAsync(HttpClient client, RunAgentInput input) + { + using JsonContent content = JsonContent.Create(input, AGUIJsonSerializerContext.Default.RunAgentInput); + using HttpResponseMessage response = await client.PostAsync(new Uri("", UriKind.Relative), content); + response.EnsureSuccessStatusCode(); + return await response.ReadAGUIEventStreamAsync().ToListAsync(); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/MultipleInputsWorkflowTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/MultipleInputsWorkflowTests.cs new file mode 100644 index 00000000000..c9250986fbe --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/MultipleInputsWorkflowTests.cs @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading.Tasks; +using AGUI.Abstractions; +using AGUI.Client; +using AGUI.WorkflowMultipleInputs; +using FluentAssertions; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.Workflows; + +public sealed class MultipleInputsWorkflowTests +{ + [Fact] + public async Task ClientRespondsPartiallyOutOfOrderThenCompletesWorkflowAsync() + { + // Arrange + AIAgent workflowAgent = MultipleInputsWorkflow.Create().AsAIAgent(name: "MultipleInputsWorkflow"); + await using WorkflowTestHost host = await WorkflowTestHost.StartAsync(workflowAgent, persistSession: true); + RunAgentInput initialInput = new() + { + Messages = new[] { new ChatMessage(ChatRole.User, "plan trip") }.AsAGUIMessages().ToList(), + RunId = "travel-run-1", + ThreadId = "travel-thread", + }; + + // Act - collect three same-turn interruptions. + List firstTurn = await SendAsync(host.Client, initialInput); + RunFinishedEvent firstFinished = firstTurn.OfType().Single(); + AGUIInterrupt[] requests = + [ + .. firstFinished.Outcome.Should().BeOfType().Subject.Interrupts, + ]; + requests.Should().HaveCount(3); + AGUIInterrupt dates = requests.Single(static item => item.Message!.Contains("TravelDates")); + AGUIInterrupt travelers = requests.Single(static item => item.Message!.Contains("TravelerDetails")); + AGUIInterrupt preferences = requests.Single(static item => item.Message!.Contains("TravelPreferences")); + + // Respond to traveler details before dates, leaving preferences pending. + List partialTurn = await SendAsync(host.Client, CreateResume( + firstFinished, + "travel-run-2", + [ + Resume(travelers, new { kind = "travelers", count = 2 }), + Resume(dates, new { kind = "dates", departure = "2026-10-10", returnDate = "2026-10-14" }), + ])); + partialTurn.OfType().Should().BeEmpty(); + RunFinishedEvent partialFinished = partialTurn.OfType().Single(); + + List finalTurn = await SendAsync(host.Client, CreateResume( + partialFinished, + "travel-run-3", + [Resume(preferences, new { kind = "preferences", budget = 2500 })])); + + // Assert + string text = string.Concat(finalTurn.OfType().Select(static evt => evt.Delta)); + text.Should().Contain("Travel plan ready for Seattle"); + } + + private static RunAgentInput CreateResume( + RunFinishedEvent previous, + string runId, + IList resumes) + => new() + { + Messages = [], + ParentRunId = previous.RunId, + Resume = resumes, + RunId = runId, + ThreadId = previous.ThreadId, + }; + + private static AGUIResume Resume(AGUIInterrupt interrupt, object payload) + => new() + { + InterruptId = interrupt.Id, + Payload = JsonSerializer.SerializeToElement(payload), + Status = "resolved", + }; + + private static async Task> SendAsync(HttpClient client, RunAgentInput input) + { + using JsonContent content = JsonContent.Create(input, AGUIJsonSerializerContext.Default.RunAgentInput); + using HttpResponseMessage response = await client.PostAsync(new Uri("", UriKind.Relative), content); + response.EnsureSuccessStatusCode(); + return await response.ReadAGUIEventStreamAsync().ToListAsync(); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/NestedWorkflowTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/NestedWorkflowTests.cs new file mode 100644 index 00000000000..44b82ceaf49 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/NestedWorkflowTests.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using System.Threading.Tasks; +using AGUI.Client; +using AGUI.WorkflowNested; +using FluentAssertions; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.Workflows; + +public sealed class NestedWorkflowTests +{ + [Fact] + public async Task ClientRejectsDuplicateActiveLocalExecutorIdsFromParallelSubworkflowsAsync() + { + // Arrange + Workflow workflow = NestedWorkflow.Create(); + await using WorkflowTestHost host = await WorkflowTestHost.StartAsync( + workflow.AsAIAgent(name: "NestedWorkflow", includeWorkflowOutputsInResponse: true)); + AGUIChatClient chatClient = new(new(host.Client, "")); + AIAgent clientAgent = chatClient.AsAIAgent(name: "client"); + AgentSession session = await clientAgent.CreateSessionAsync(); + + // Act + Func act = async () => _ = await clientAgent + .RunStreamingAsync(new ChatMessage(ChatRole.User, "start"), session) + .ToListAsync(); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*Step \"Analyze_Analyze\" is already active*"); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/SequentialWorkflowTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/SequentialWorkflowTests.cs new file mode 100644 index 00000000000..74fe2b94e04 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/SequentialWorkflowTests.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using AGUI.Abstractions; +using AGUI.Client; +using AGUI.WorkflowSequential; +using FluentAssertions; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.Workflows; + +public sealed class SequentialWorkflowTests +{ + [Fact] + public async Task ClientReceivesOrderedExecutorStepsAndTextAsync() + { + // Arrange + AIAgent writer = new DeterministicAgent("Writer", "draft"); + AIAgent reviewer = new DeterministicAgent("Reviewer", "final"); + Workflow workflow = SequentialWorkflow.Create(writer, reviewer); + await using WorkflowTestHost host = await WorkflowTestHost.StartAsync( + workflow.AsAIAgent(name: "SequentialWorkflow")); + AGUIChatClient chatClient = new(new(host.Client, "")); + AIAgent clientAgent = chatClient.AsAIAgent(name: "client"); + AgentSession session = await clientAgent.CreateSessionAsync(); + + // Act + List updates = await clientAgent + .RunStreamingAsync(new ChatMessage(ChatRole.User, "start"), session) + .ToListAsync(); + + // Assert + string[] started = [.. updates + .Select(static update => update.AsChatResponseUpdate().RawRepresentation) + .OfType() + .Select(static evt => evt.StepName)]; + string[] finished = [.. updates + .Select(static update => update.AsChatResponseUpdate().RawRepresentation) + .OfType() + .Select(static evt => evt.StepName)]; + + int writerStart = Array.FindIndex(started, static name => name.StartsWith("Writer_", StringComparison.Ordinal)); + int reviewerStart = Array.FindIndex(started, static name => name.StartsWith("Reviewer_", StringComparison.Ordinal)); + int writerFinish = Array.FindIndex(finished, static name => name.StartsWith("Writer_", StringComparison.Ordinal)); + int reviewerFinish = Array.FindIndex(finished, static name => name.StartsWith("Reviewer_", StringComparison.Ordinal)); + + writerStart.Should().BeGreaterThanOrEqualTo(0); + reviewerStart.Should().BeGreaterThan(writerStart); + writerFinish.Should().BeGreaterThanOrEqualTo(0); + reviewerFinish.Should().BeGreaterThan(writerFinish); + updates.Count(static update => update.Text == "draft").Should().Be(1); + updates.Count(static update => update.Text == "final").Should().Be(1); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/ToolWorkflowTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/ToolWorkflowTests.cs new file mode 100644 index 00000000000..82eff4ebafc --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/ToolWorkflowTests.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using AGUI.Abstractions; +using AGUI.Client; +using AGUI.WorkflowTools; +using FluentAssertions; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.Workflows; + +public sealed class ToolWorkflowTests +{ + [Fact] + public async Task ClientReceivesStepsToolContentAndTextOnceAsync() + { + // Arrange + Workflow workflow = ToolWorkflow.Create(new DeterministicToolAgent()); + await using WorkflowTestHost host = await WorkflowTestHost.StartAsync( + workflow.AsAIAgent(name: "ToolWorkflow")); + AGUIChatClient chatClient = new(new(host.Client, "")); + AIAgent clientAgent = chatClient.AsAIAgent(name: "client"); + AgentSession session = await clientAgent.CreateSessionAsync(); + + // Act + List updates = await clientAgent + .RunStreamingAsync(new ChatMessage(ChatRole.User, "weather"), session) + .ToListAsync(); + + // Assert + updates.Select(static update => update.AsChatResponseUpdate().RawRepresentation) + .OfType() + .Should().Contain(static evt => evt.StepName.StartsWith("WeatherAgent_")); + updates.Select(static update => update.AsChatResponseUpdate().RawRepresentation) + .OfType() + .Should().Contain(static evt => evt.StepName.StartsWith("WeatherAgent_")); + updates.SelectMany(static update => update.Contents).OfType().Should().ContainSingle(); + updates.SelectMany(static update => update.Contents).OfType().Should().ContainSingle(); + updates.Count(static update => update.Text == "Sunny").Should().Be(1); + } + + private sealed class DeterministicToolAgent : AIAgent + { + public override string? Name => "WeatherAgent"; + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + => this.RunStreamingAsync(messages, session, options, cancellationToken).ToAgentResponseAsync(cancellationToken); + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + yield return CreateUpdate(new FunctionCallContent( + "weather-call", + "GetWeather", + new Dictionary { ["city"] = "Seattle" })); + yield return CreateUpdate(new FunctionResultContent("weather-call", "Sunny")); + yield return CreateUpdate(new TextContent("Sunny")); + await Task.Yield(); + } + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + => new(new ToolSession()); + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + => new(JsonSerializer.SerializeToElement(new Dictionary())); + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + => new(new ToolSession()); + + private static AgentResponseUpdate CreateUpdate(AIContent content) + => new(ChatRole.Assistant, [content]) + { + MessageId = "weather-message", + ResponseId = "weather-response", + }; + + private sealed class ToolSession : AgentSession; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/WorkflowTestHost.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/WorkflowTestHost.cs new file mode 100644 index 00000000000..362667dd1de --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/WorkflowTestHost.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.Workflows; + +internal sealed class WorkflowTestHost : IAsyncDisposable +{ + private readonly WebApplication _app; + + private WorkflowTestHost(WebApplication app, HttpClient client) + { + this._app = app; + this.Client = client; + } + + public HttpClient Client { get; } + + public static async Task StartAsync(AIAgent agent, bool persistSession = false) + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Services.AddAGUIServer(); + + if (persistSession) + { + string agentName = agent.Name ?? throw new InvalidOperationException("A named agent is required for session persistence."); + builder.Services.AddAIAgent(agentName, (_, _) => agent) + .WithInMemorySessionStore(withIsolation: false); + } + + WebApplication app = builder.Build(); + if (persistSession) + { + app.MapAGUIServer(agent.Name!, "/agent"); + } + else + { + app.MapAGUIServer("/agent", agent); + } + await app.StartAsync(); + + TestServer server = app.Services.GetRequiredService() as TestServer + ?? throw new InvalidOperationException("TestServer not found."); + HttpClient client = server.CreateClient(); + client.BaseAddress = new Uri("http://localhost/agent"); + return new WorkflowTestHost(app, client); + } + + public async ValueTask DisposeAsync() + { + this.Client.Dispose(); + await this._app.DisposeAsync(); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj index ed65db63289..925f6f30891 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj @@ -14,6 +14,7 @@ + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/WorkflowAGUIExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/WorkflowAGUIExtensionsTests.cs new file mode 100644 index 00000000000..91f99ebf6ba --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/WorkflowAGUIExtensionsTests.cs @@ -0,0 +1,231 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using AGUI.Abstractions; +using FluentAssertions; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests; + +/// +/// Tests workflow lifecycle and interruption mapping to AG-UI. +/// +public sealed class WorkflowAGUIExtensionsTests +{ + [Fact] + public async Task MapWorkflowEventsToAGUI_MapsExecutorInvokedToStepStartedAsync() + { + // Arrange + AgentResponseUpdate update = CreateUpdate(new ExecutorInvokedEvent("reviewer", "input")); + + // Act + ChatResponseUpdate result = await ToAsyncEnumerableAsync(update) + .AsChatResponseUpdatesAsync() + .MapWorkflowEventsToAGUI() + .SingleAsync(); + + // Assert + result.RawRepresentation.Should().BeOfType() + .Which.StepName.Should().Be("reviewer"); + } + + [Fact] + public async Task MapWorkflowEventsToAGUI_MapsExecutorCompletedToStepFinishedAsync() + { + // Arrange + AgentResponseUpdate update = CreateUpdate(new ExecutorCompletedEvent("reviewer", "result")); + + // Act + ChatResponseUpdate result = await ToAsyncEnumerableAsync(update) + .AsChatResponseUpdatesAsync() + .MapWorkflowEventsToAGUI() + .SingleAsync(); + + // Assert + result.RawRepresentation.Should().BeOfType() + .Which.StepName.Should().Be("reviewer"); + } + + [Fact] + public async Task MapWorkflowEventsToAGUI_MapsExecutorFailedToStepFinishedAndRunErrorAsync() + { + // Arrange + ErrorContent error = new("An error occurred while executing the workflow."); + AgentResponseUpdate update = CreateUpdate( + new ExecutorFailedEvent("reviewer", new InvalidOperationException("internal")), + error); + + // Act + List results = await ToAsyncEnumerableAsync(update) + .AsChatResponseUpdatesAsync() + .MapWorkflowEventsToAGUI() + .ToListAsync(); + + // Assert + results.Should().HaveCount(2); + results[0].RawRepresentation.Should().BeOfType() + .Which.StepName.Should().Be("reviewer"); + results[0].Contents.Should().BeEmpty(); + results[1].RawRepresentation.Should().BeOfType() + .Which.Message.Should().Be(error.Message); + results[1].Contents.Should().BeEmpty(); + } + + [Fact] + public async Task MakeRunErrorTerminalAsync_EmitsCleanupThenErrorWithoutSuccessAsync() + { + // Arrange + BaseEvent[] events = + [ + new StepStartedEvent { StepName = "reviewer" }, + new TextMessageStartEvent { MessageId = "message", Role = "assistant" }, + new RunErrorEvent { Message = "failed" }, + new TextMessageStartEvent { MessageId = "orphan", Role = "assistant" }, + new TextMessageEndEvent { MessageId = "orphan" }, + new RunFinishedEvent + { + RunId = "run", + ThreadId = "thread", + Outcome = new RunFinishedSuccessOutcome(), + }, + ]; + + // Act + List results = await ToAsyncEnumerableAsync(events) + .MakeRunErrorTerminalAsync() + .ToListAsync(); + + // Assert + results.Should().HaveCount(5); + results[^3].Should().BeOfType() + .Which.MessageId.Should().Be("message"); + results[^2].Should().BeOfType() + .Which.StepName.Should().Be("reviewer"); + results[^1].Should().BeOfType(); + results.OfType().Should().ContainSingle() + .Which.MessageId.Should().Be("message"); + } + + [Fact] + public async Task MapWorkflowEventsToAGUI_ForwardsOtherUpdatesUnchangedAsync() + { + // Arrange + WorkflowStartedEvent workflowStarted = new("workflow"); + TextContent text = new("hello"); + AgentResponseUpdate update = CreateUpdate(workflowStarted, text); + + // Act + ChatResponseUpdate convertedUpdate = await ToAsyncEnumerableAsync(update) + .AsChatResponseUpdatesAsync() + .SingleAsync(); + ChatResponseUpdate result = await ToAsyncEnumerableAsync(convertedUpdate) + .MapWorkflowEventsToAGUI() + .SingleAsync(); + + // Assert + result.Should().BeSameAs(convertedUpdate); + result.RawRepresentation.Should().BeSameAs(update); + result.Contents.Should().ContainSingle().Which.Should().BeSameAs(text); + } + + [Fact] + public void MapAGUIInterruptResponsesToFunctionResults_MapsResponseUnconditionally() + { + // Arrange + JsonElement payload = JsonSerializer.SerializeToElement(new { approved = true }); + ChatMessage message = new( + ChatRole.User, + [new InterruptResponseContent("request-1") { Payload = payload }]); + + // Act + List results = new[] { message }.MapAGUIInterruptResponsesToFunctionResults(); + + // Assert + ChatMessage result = results.Should().ContainSingle().Subject; + result.Role.Should().Be(ChatRole.User); + FunctionResultContent functionResult = result.Contents.Should().ContainSingle() + .Which.Should().BeOfType().Subject; + functionResult.CallId.Should().Be("request-1"); + functionResult.Result.Should().Be(payload); + } + + [Fact] + public async Task MapWorkflowEventsToAGUI_PrefersWorkflowCorrelatedApprovalRequestAsync() + { + // Arrange + FunctionCallContent toolCall = new( + "call-1", + "SubmitExpense", + new Dictionary()); + ToolApprovalRequestContent originalRequest = new("agent-request", toolCall); + ToolApprovalRequestContent correlatedRequest = new("workflow-request", toolCall); + AgentResponseUpdate original = CreateUpdate(raw: new object(), originalRequest); + AgentResponseUpdate correlated = CreateUpdate( + new RequestInfoEvent(ExternalRequest.Create( + RequestPort.Create("approval"), + originalRequest, + "workflow-request")), + correlatedRequest); + + // Act + List results = await ToAsyncEnumerableAsync([original, correlated]) + .AsChatResponseUpdatesAsync() + .MapWorkflowEventsToAGUI() + .ToListAsync(); + + // Assert + ToolApprovalRequestContent request = results.SelectMany(static update => update.Contents) + .OfType() + .Should().ContainSingle().Subject; + request.RequestId.Should().Be("workflow-request"); + } + + private static AgentResponseUpdate CreateUpdate(object raw, params AIContent[] contents) + => new(ChatRole.Assistant, contents) + { + AuthorName = "author", + CreatedAt = DateTimeOffset.UtcNow, + MessageId = "message", + RawRepresentation = raw, + ResponseId = "response", + }; + + private static async IAsyncEnumerable ToAsyncEnumerableAsync( + AgentResponseUpdate update) + { + await Task.Yield(); + yield return update; + } + + private static async IAsyncEnumerable ToAsyncEnumerableAsync( + IEnumerable updates) + { + await Task.Yield(); + foreach (AgentResponseUpdate update in updates) + { + yield return update; + } + } + + private static async IAsyncEnumerable ToAsyncEnumerableAsync( + ChatResponseUpdate update) + { + await Task.Yield(); + yield return update; + } + + private static async IAsyncEnumerable ToAsyncEnumerableAsync( + IEnumerable events) + { + await Task.Yield(); + foreach (BaseEvent evt in events) + { + yield return evt; + } + } +}