From febdce30c765f91fbfead7d78952fac30c6ae352 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Tue, 18 Aug 2026 15:54:52 +0200 Subject: [PATCH 01/17] Add workflow lifecycle mapping for AG-UI Map workflow supersteps and safe lifecycle metadata through an opt-in AG-UI agent decorator while preserving existing response, interrupt, tool, and error conversion behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../AGUIEndpointRouteBuilderExtensions.cs | 2 +- .../AGUIWorkflowEventExtensions.cs | 343 +++++++++++++ ...t.Agents.AI.Hosting.AGUI.AspNetCore.csproj | 1 + .../AGUIWorkflowEventExtensionsTests.cs | 479 ++++++++++++++++++ ...I.Hosting.AGUI.AspNetCore.UnitTests.csproj | 1 + 5 files changed, 825 insertions(+), 1 deletion(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIWorkflowEventExtensions.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIWorkflowEventExtensionsTests.cs 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..1c55c39ce7c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs @@ -153,7 +153,7 @@ public static IEndpointConventionBuilder MapAGUIServer( session: session, options: new ChatClientAgentRunOptions { ChatOptions = ctx.ChatOptions }, cancellationToken: cancellationToken) - .AsChatResponseUpdatesAsync() + .AsAGUIChatResponseUpdatesAsync() .AsAGUIEventStreamAsync(ctx, cancellationToken); // Wrap the event stream to save the session after streaming completes. diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIWorkflowEventExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIWorkflowEventExtensions.cs new file mode 100644 index 00000000000..4918e559b27 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIWorkflowEventExtensions.cs @@ -0,0 +1,343 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using System.Threading; +using System.Threading.Tasks; +using AGUI.Abstractions; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; + +/// +/// Provides AG-UI event mapping for workflow-hosted agents. +/// +public static class AGUIWorkflowEventExtensions +{ + /// + /// Decorates an agent so supported workflow lifecycle events are exposed as AG-UI events. + /// + /// The workflow-hosted agent to decorate. + /// An agent that maps workflow lifecycle events while preserving ordinary response updates. + /// + /// + /// Supersteps are mapped to AG-UI step events. Workflow warnings, non-chat outputs, and executor + /// lifecycle events are mapped to structured custom events. + /// + /// + /// Executor lifecycle events are not mapped to AG-UI activity events because workflow events do not + /// currently expose a stable invocation identifier. Mapping by executor identifier could misattribute + /// repeated or concurrent invocations. + /// + /// + /// Workflow run lifecycle, request/interrupt content, response-compatible outputs, and errors continue + /// through their existing mappings. Internal checkpoint/debug state and exception details are not added + /// to AG-UI event payloads. + /// + /// + /// Non-chat output data is included only when it is a JSON scalar, , or + /// . Pre-serialize custom output objects to when their + /// payload should be sent to the client. Warning messages are intentionally replaced with a stable, + /// non-sensitive message. + /// + /// + public static AIAgent WithAGUIWorkflowEvents(this AIAgent agent) + { + ArgumentNullException.ThrowIfNull(agent); + return new AGUIWorkflowEventAgent(agent); + } + + internal static async IAsyncEnumerable AsAGUIChatResponseUpdatesAsync( + this IAsyncEnumerable updates) + { + ArgumentNullException.ThrowIfNull(updates); + + await foreach (AgentResponseUpdate update in updates.ConfigureAwait(false)) + { + ChatResponseUpdate chatUpdate = update.AsChatResponseUpdate(); + if (update.RawRepresentation is BaseEvent) + { + chatUpdate.RawRepresentation = update.RawRepresentation; + } + + yield return chatUpdate; + } + } +} + +internal sealed class AGUIWorkflowEventAgent(AIAgent innerAgent) : DelegatingAIAgent(innerAgent) +{ + private const string WarningEventName = "maf.workflow.warning"; + private const string OutputEventName = "maf.workflow.output"; + private const string ExecutorInvokedEventName = "maf.workflow.executor.invoked"; + private const string ExecutorCompletedEventName = "maf.workflow.executor.completed"; + private const string ExecutorFailedEventName = "maf.workflow.executor.failed"; + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + => this.InnerAgent.RunAsync(messages, session, options, cancellationToken); + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + List activeSteps = []; + bool terminalErrorForwarded = false; + + await foreach (AgentResponseUpdate update in this.InnerAgent + .RunStreamingAsync(messages, session, options, cancellationToken) + .ConfigureAwait(false)) + { + switch (update.RawRepresentation) + { + case SuperStepStartedEvent started when !activeSteps.Contains(started.StepNumber): + activeSteps.Add(started.StepNumber); + yield return CloneWithEvent(update, new StepStartedEvent { StepName = GetStepName(started.StepNumber) }); + break; + + case SuperStepCompletedEvent completed when activeSteps.Remove(completed.StepNumber): + yield return CloneWithEvent(update, new StepFinishedEvent { StepName = GetStepName(completed.StepNumber) }); + break; + + case WorkflowWarningEvent warning: + yield return CloneWithEvent( + update, + CreateCustomEvent( + WarningEventName, + new AGUIWorkflowWarningPayload("The workflow reported a warning."))); + break; + + case WorkflowOutputEvent output when !IsResponseCompatible(output): + yield return CloneWithEvent( + update, + CreateCustomEvent( + OutputEventName, + new AGUIWorkflowOutputPayload( + output.ExecutorId, + [.. output.Tags.Select(static tag => tag.Value).Where(static value => value is not null).OrderBy(static value => value, StringComparer.Ordinal)!], + TrySerializeOutput(output.Data)))); + break; + + case ExecutorInvokedEvent invoked: + yield return CloneWithEvent( + update, + CreateCustomEvent(ExecutorInvokedEventName, new AGUIWorkflowExecutorPayload(invoked.ExecutorId))); + break; + + case ExecutorCompletedEvent completed: + yield return CloneWithEvent( + update, + CreateCustomEvent(ExecutorCompletedEventName, new AGUIWorkflowExecutorPayload(completed.ExecutorId))); + break; + + case ExecutorFailedEvent failed: + foreach (AgentResponseUpdate stepFinished in CloseActiveSteps(update, activeSteps)) + { + yield return stepFinished; + } + + yield return CloneWithEvent( + update, + CreateCustomEvent(ExecutorFailedEventName, new AGUIWorkflowExecutorPayload(failed.ExecutorId)), + includeContents: false); + AgentResponseUpdate? executorFailure = FilterDuplicateErrorContent(update, ref terminalErrorForwarded); + if (executorFailure is not null) + { + yield return executorFailure; + } + break; + + case WorkflowErrorEvent: + foreach (AgentResponseUpdate stepFinished in CloseActiveSteps(update, activeSteps)) + { + yield return stepFinished; + } + + AgentResponseUpdate? workflowError = FilterDuplicateErrorContent(update, ref terminalErrorForwarded); + if (workflowError is not null) + { + yield return workflowError; + } + break; + + default: + yield return update; + break; + } + } + + foreach (AgentResponseUpdate stepFinished in CloseActiveSteps(template: null, activeSteps)) + { + yield return stepFinished; + } + } + + private static IEnumerable CloseActiveSteps(AgentResponseUpdate? template, List activeSteps) + { + for (int i = activeSteps.Count - 1; i >= 0; i--) + { + yield return CloneWithEvent( + template, + new StepFinishedEvent { StepName = GetStepName(activeSteps[i]) }, + includeContents: false); + } + + activeSteps.Clear(); + } + + private static AgentResponseUpdate CloneWithEvent( + AgentResponseUpdate? update, + BaseEvent evt, + bool includeContents = true) + => new(update?.Role, includeContents ? update?.Contents : []) + { + AdditionalProperties = update?.AdditionalProperties, + AgentId = update?.AgentId, + AuthorName = update?.AuthorName, + ContinuationToken = update?.ContinuationToken, + CreatedAt = update?.CreatedAt, + FinishReason = update?.FinishReason, + MessageId = update?.MessageId, + RawRepresentation = evt, + ResponseId = update?.ResponseId, + }; + + private static AgentResponseUpdate? FilterDuplicateErrorContent( + AgentResponseUpdate update, + ref bool terminalErrorForwarded) + { + bool hasError = update.Contents.Any(static content => content is ErrorContent); + if (!hasError) + { + return update; + } + + if (!terminalErrorForwarded) + { + terminalErrorForwarded = true; + return update; + } + + AIContent[] remainingContents = [.. update.Contents.Where(static content => content is not ErrorContent)]; + return remainingContents.Length > 0 + ? CloneWithContents(update, remainingContents) + : null; + } + + private static AgentResponseUpdate CloneWithContents( + AgentResponseUpdate update, + IList contents) + => new(update.Role, contents) + { + AdditionalProperties = update.AdditionalProperties, + AgentId = update.AgentId, + AuthorName = update.AuthorName, + ContinuationToken = update.ContinuationToken, + CreatedAt = update.CreatedAt, + FinishReason = update.FinishReason, + MessageId = update.MessageId, + RawRepresentation = update.RawRepresentation, + ResponseId = update.ResponseId, + }; + + private static string GetStepName(int stepNumber) => $"superstep:{stepNumber}"; + + private static bool IsResponseCompatible(WorkflowOutputEvent output) + => output is AgentResponseEvent or AgentResponseUpdateEvent + || output.Data is string + || output.Data is AIContent + || output.Data is IEnumerable + || output.Data is ChatMessage + || output.Data is IEnumerable; + + private static CustomEvent CreateCustomEvent(string name, AGUIWorkflowWarningPayload payload) + => new() + { + Name = name, + Value = JsonSerializer.SerializeToElement(payload, AGUIWorkflowEventsJsonContext.Default.AGUIWorkflowWarningPayload), + }; + + private static CustomEvent CreateCustomEvent(string name, AGUIWorkflowOutputPayload payload) + => new() + { + Name = name, + Value = JsonSerializer.SerializeToElement(payload, AGUIWorkflowEventsJsonContext.Default.AGUIWorkflowOutputPayload), + }; + + private static CustomEvent CreateCustomEvent(string name, AGUIWorkflowExecutorPayload payload) + => new() + { + Name = name, + Value = JsonSerializer.SerializeToElement(payload, AGUIWorkflowEventsJsonContext.Default.AGUIWorkflowExecutorPayload), + }; + + private static JsonElement? TrySerializeOutput(object? value) + => value switch + { + JsonElement element => element.Clone(), + JsonDocument document => document.RootElement.Clone(), + bool boolean => SerializeScalar(boolean, AGUIWorkflowEventsJsonContext.Default.Boolean), + byte number => SerializeScalar(number, AGUIWorkflowEventsJsonContext.Default.Byte), + sbyte number => SerializeScalar(number, AGUIWorkflowEventsJsonContext.Default.SByte), + short number => SerializeScalar(number, AGUIWorkflowEventsJsonContext.Default.Int16), + ushort number => SerializeScalar(number, AGUIWorkflowEventsJsonContext.Default.UInt16), + int number => SerializeScalar(number, AGUIWorkflowEventsJsonContext.Default.Int32), + uint number => SerializeScalar(number, AGUIWorkflowEventsJsonContext.Default.UInt32), + long number => SerializeScalar(number, AGUIWorkflowEventsJsonContext.Default.Int64), + ulong number => SerializeScalar(number, AGUIWorkflowEventsJsonContext.Default.UInt64), + float number when float.IsFinite(number) => SerializeScalar(number, AGUIWorkflowEventsJsonContext.Default.Single), + double number when double.IsFinite(number) => SerializeScalar(number, AGUIWorkflowEventsJsonContext.Default.Double), + decimal number => SerializeScalar(number, AGUIWorkflowEventsJsonContext.Default.Decimal), + char character => SerializeScalar(character, AGUIWorkflowEventsJsonContext.Default.Char), + DateTime dateTime => SerializeScalar(dateTime, AGUIWorkflowEventsJsonContext.Default.DateTime), + DateTimeOffset dateTimeOffset => SerializeScalar(dateTimeOffset, AGUIWorkflowEventsJsonContext.Default.DateTimeOffset), + Guid guid => SerializeScalar(guid, AGUIWorkflowEventsJsonContext.Default.Guid), + TimeSpan timeSpan => SerializeScalar(timeSpan, AGUIWorkflowEventsJsonContext.Default.TimeSpan), + Uri uri => SerializeScalar(uri, AGUIWorkflowEventsJsonContext.Default.Uri), + _ => null, + }; + + private static JsonElement SerializeScalar(T value, JsonTypeInfo typeInfo) + => JsonSerializer.SerializeToElement(value, typeInfo); +} + +internal sealed record AGUIWorkflowWarningPayload(string Message); + +internal sealed record AGUIWorkflowOutputPayload(string ExecutorId, string[] Tags, JsonElement? Data); + +internal sealed record AGUIWorkflowExecutorPayload(string ExecutorId); + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(AGUIWorkflowWarningPayload))] +[JsonSerializable(typeof(AGUIWorkflowOutputPayload))] +[JsonSerializable(typeof(AGUIWorkflowExecutorPayload))] +[JsonSerializable(typeof(bool))] +[JsonSerializable(typeof(byte))] +[JsonSerializable(typeof(sbyte))] +[JsonSerializable(typeof(short))] +[JsonSerializable(typeof(ushort))] +[JsonSerializable(typeof(int))] +[JsonSerializable(typeof(uint))] +[JsonSerializable(typeof(long))] +[JsonSerializable(typeof(ulong))] +[JsonSerializable(typeof(float))] +[JsonSerializable(typeof(double))] +[JsonSerializable(typeof(decimal))] +[JsonSerializable(typeof(char))] +[JsonSerializable(typeof(DateTime))] +[JsonSerializable(typeof(DateTimeOffset))] +[JsonSerializable(typeof(Guid))] +[JsonSerializable(typeof(TimeSpan))] +[JsonSerializable(typeof(Uri))] +internal sealed partial class AGUIWorkflowEventsJsonContext : JsonSerializerContext; 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/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIWorkflowEventExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIWorkflowEventExtensionsTests.cs new file mode 100644 index 00000000000..7be94153944 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIWorkflowEventExtensionsTests.cs @@ -0,0 +1,479 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using System.Threading; +using System.Threading.Tasks; +using AGUI.Abstractions; +using AGUI.Server; +using FluentAssertions; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests; + +/// +/// Tests workflow lifecycle mapping to AG-UI events. +/// +public sealed class AGUIWorkflowEventExtensionsTests +{ + [Fact] + public async Task WithAGUIWorkflowEvents_MapsBalancedStepsAndPreservesOutputOnceAsync() + { + // Arrange + AgentResponseUpdate textOne = CreateUpdate(raw: null, new TextContent("one")); + AgentResponseUpdate textTwo = CreateUpdate(raw: null, new TextContent("two")); + AIAgent agent = new ScriptedAgent( + CreateUpdate(new WorkflowStartedEvent("workflow")), + CreateUpdate(new SuperStepStartedEvent(1)), + CreateUpdate(new ExecutorInvokedEvent("agent-1", "input")), + textOne, + CreateUpdate(new ExecutorCompletedEvent("agent-1", "result")), + CreateUpdate(new SuperStepCompletedEvent(1)), + CreateUpdate(new SuperStepStartedEvent(2)), + CreateUpdate(new ExecutorInvokedEvent("agent-2", "input")), + textTwo, + CreateUpdate(new ExecutorCompletedEvent("agent-2", "result")), + CreateUpdate(new SuperStepCompletedEvent(2))); + + // Act + List updates = await agent + .WithAGUIWorkflowEvents() + .RunStreamingAsync(new ChatMessage(ChatRole.User, "start")) + .ToListAsync(); + + // Assert + updates.Select(static update => update.RawRepresentation).OfType() + .Select(static evt => evt.StepName) + .Should().Equal("superstep:1", "superstep:2"); + updates.Select(static update => update.RawRepresentation).OfType() + .Select(static evt => evt.StepName) + .Should().Equal("superstep:1", "superstep:2"); + updates.Where(static update => update.Text.Length > 0).Should().Equal(textOne, textTwo); + updates.Count(static update => update.Text == "one").Should().Be(1); + updates.Count(static update => update.Text == "two").Should().Be(1); + updates.Select(static update => update.RawRepresentation).OfType().Should().BeEmpty(); + updates.Select(static update => update.RawRepresentation).OfType().Should().BeEmpty(); + } + + [Fact] + public async Task WithAGUIWorkflowEvents_UsesCustomEventsForConcurrentRepeatedExecutorsAsync() + { + // Arrange + AIAgent agent = new ScriptedAgent( + CreateUpdate(new ExecutorInvokedEvent("worker", "first")), + CreateUpdate(new ExecutorInvokedEvent("worker", "second")), + CreateUpdate(new ExecutorCompletedEvent("worker", "first-result")), + CreateUpdate(new ExecutorCompletedEvent("worker", "second-result"))); + + // Act + List updates = await agent + .WithAGUIWorkflowEvents() + .RunStreamingAsync(new ChatMessage(ChatRole.User, "start")) + .ToListAsync(); + + // Assert + CustomEvent[] events = [.. updates.Select(static update => update.RawRepresentation).OfType()]; + events.Select(static evt => evt.Name).Should().Equal( + "maf.workflow.executor.invoked", + "maf.workflow.executor.invoked", + "maf.workflow.executor.completed", + "maf.workflow.executor.completed"); + events.Should().AllSatisfy(static evt => evt.Value!.Value.GetProperty("executorId").GetString().Should().Be("worker")); + updates.Select(static update => update.RawRepresentation).OfType().Should().BeEmpty(); + updates.Select(static update => update.RawRepresentation).OfType().Should().BeEmpty(); + } + + [Fact] + public async Task WithAGUIWorkflowEvents_ClosesActiveStepBeforeExecutorFailureAsync() + { + // Arrange + AgentResponseUpdate failure = CreateUpdate( + new ExecutorFailedEvent("worker", new InvalidOperationException("secret")), + new ErrorContent("An error occurred while executing the workflow.")); + AIAgent agent = new ScriptedAgent( + CreateUpdate(new SuperStepStartedEvent(7)), + failure, + CreateUpdate(new SuperStepCompletedEvent(7))); + + // Act + List updates = await agent + .WithAGUIWorkflowEvents() + .RunStreamingAsync(new ChatMessage(ChatRole.User, "start")) + .ToListAsync(); + + // Assert + updates.Should().HaveCount(5); + updates[0].RawRepresentation.Should().BeOfType(); + updates[1].RawRepresentation.Should().BeOfType(); + CustomEvent failedEvent = updates[2].RawRepresentation.Should().BeOfType().Subject; + failedEvent.Name.Should().Be("maf.workflow.executor.failed"); + failedEvent.Value!.Value.GetRawText().Should().NotContain("secret"); + updates[3].Should().BeSameAs(failure); + updates[3].Contents.Should().ContainSingle().Which.Should().BeOfType(); + updates[4].RawRepresentation.Should().BeOfType(); + updates.Select(static update => update.RawRepresentation).OfType().Should().ContainSingle(); + } + + [Fact] + public async Task WithAGUIWorkflowEvents_ClosesActiveStepBeforeWorkflowErrorAsync() + { + // Arrange + AgentResponseUpdate error = CreateUpdate( + new WorkflowErrorEvent(new InvalidOperationException("secret")), + new ErrorContent("An error occurred while executing the workflow.")); + AIAgent agent = new ScriptedAgent(CreateUpdate(new SuperStepStartedEvent(3)), error); + + // Act + List updates = await agent + .WithAGUIWorkflowEvents() + .RunStreamingAsync(new ChatMessage(ChatRole.User, "start")) + .ToListAsync(); + + // Assert + updates.Select(static update => update.RawRepresentation).Should().HaveCount(3); + updates[1].RawRepresentation.Should().BeOfType(); + updates[2].Should().BeSameAs(error); + updates[2].Contents.Should().ContainSingle().Which.Should().BeOfType(); + } + + [Fact] + public async Task WithAGUIWorkflowEvents_MapsWarningAndSerializableNonChatOutputAsync() + { + // Arrange + using JsonDocument document = JsonDocument.Parse("""{"name":"value","count":42}"""); + AIAgent agent = new ScriptedAgent( + CreateUpdate(new WorkflowWarningEvent("retrying")), + CreateUpdate(new WorkflowOutputEvent(document.RootElement, "worker", OutputTag.Intermediate))); + + // Act + List updates = await agent + .WithAGUIWorkflowEvents() + .RunStreamingAsync(new ChatMessage(ChatRole.User, "start")) + .ToListAsync(); + + // Assert + CustomEvent warning = updates[0].RawRepresentation.Should().BeOfType().Subject; + warning.Name.Should().Be("maf.workflow.warning"); + warning.Value!.Value.GetProperty("message").GetString().Should().Be("The workflow reported a warning."); + + CustomEvent output = updates[1].RawRepresentation.Should().BeOfType().Subject; + output.Name.Should().Be("maf.workflow.output"); + JsonElement outputValue = output.Value!.Value; + outputValue.GetProperty("executorId").GetString().Should().Be("worker"); + outputValue.GetProperty("tags").EnumerateArray().Select(static tag => tag.GetString()).Should().Equal("intermediate"); + outputValue.GetProperty("data").GetProperty("name").GetString().Should().Be("value"); + outputValue.GetProperty("data").GetProperty("count").GetInt32().Should().Be(42); + } + + [Fact] + public async Task WithAGUIWorkflowEvents_OmitsUnsafeNonChatOutputPayloadAsync() + { + // Arrange + AIAgent agent = new ScriptedAgent( + CreateUpdate(new WorkflowOutputEvent(new ThrowingOutputPayload(), "worker"))); + + // Act + AgentResponseUpdate update = await agent + .WithAGUIWorkflowEvents() + .RunStreamingAsync(new ChatMessage(ChatRole.User, "start")) + .SingleAsync(); + + // Assert + CustomEvent output = update.RawRepresentation.Should().BeOfType().Subject; + output.Value!.Value.GetProperty("data").ValueKind.Should().Be(JsonValueKind.Null); + } + + [Fact] + public async Task WithAGUIWorkflowEvents_OmitsNonFiniteOutputPayloadAsync() + { + // Arrange + AIAgent agent = new ScriptedAgent( + CreateUpdate(new WorkflowOutputEvent(double.NaN, "worker"))); + + // Act + AgentResponseUpdate update = await agent + .WithAGUIWorkflowEvents() + .RunStreamingAsync(new ChatMessage(ChatRole.User, "start")) + .SingleAsync(); + + // Assert + CustomEvent output = update.RawRepresentation.Should().BeOfType().Subject; + output.Value!.Value.GetProperty("data").ValueKind.Should().Be(JsonValueKind.Null); + } + + [Fact] + public async Task WithAGUIWorkflowEvents_PreservesResponseCompatibleAndInterruptUpdatesAsync() + { + // Arrange + AgentResponseUpdate workflowText = CreateUpdate( + new WorkflowOutputEvent("text", "worker"), + new TextContent("text")); + RequestPort port = RequestPort.Create("approval"); + AgentResponseUpdate interrupt = CreateUpdate( + new RequestInfoEvent(ExternalRequest.Create(port, "approve", "request-1")), + new FunctionCallContent("request-1", "approval", new Dictionary())); + AgentResponseUpdate toolResult = CreateUpdate( + raw: null, + new FunctionResultContent("request-1", "approved")); + AIAgent agent = new ScriptedAgent(workflowText, interrupt, toolResult); + + // Act + List updates = await agent + .WithAGUIWorkflowEvents() + .RunStreamingAsync(new ChatMessage(ChatRole.User, "start")) + .ToListAsync(); + + // Assert + updates.Should().Equal(workflowText, interrupt, toolResult); + updates.Count(static update => update.Text == "text").Should().Be(1); + updates.SelectMany(static update => update.Contents).OfType().Should().ContainSingle(); + updates.SelectMany(static update => update.Contents).OfType().Should().ContainSingle(); + } + + [Fact] + public async Task WithAGUIWorkflowEvents_ClosesActiveStepWhenStreamCompletesAsync() + { + // Arrange + AIAgent agent = new ScriptedAgent(CreateUpdate(new SuperStepStartedEvent(4))); + + // Act + List updates = await agent + .WithAGUIWorkflowEvents() + .RunStreamingAsync(new ChatMessage(ChatRole.User, "start")) + .ToListAsync(); + + // Assert + updates.Select(static update => update.RawRepresentation).Should().HaveCount(2); + updates[0].RawRepresentation.Should().BeOfType(); + updates[1].RawRepresentation.Should().BeOfType(); + } + + [Fact] + public async Task WithAGUIWorkflowEvents_ForwardsOnlyOneTerminalErrorContentAsync() + { + // Arrange + AgentResponseUpdate executorError = CreateUpdate( + new ExecutorFailedEvent("worker", new InvalidOperationException("secret")), + new ErrorContent("An error occurred while executing the workflow.")); + AgentResponseUpdate workflowError = CreateUpdate( + new WorkflowErrorEvent(new InvalidOperationException("secret")), + new ErrorContent("An error occurred while executing the workflow.")); + AIAgent agent = new ScriptedAgent( + CreateUpdate(new SuperStepStartedEvent(5)), + executorError, + workflowError); + + // Act + List updates = await agent + .WithAGUIWorkflowEvents() + .RunStreamingAsync(new ChatMessage(ChatRole.User, "start")) + .ToListAsync(); + + // Assert + updates.SelectMany(static update => update.Contents).OfType().Should().ContainSingle(); + updates.Should().Contain(executorError); + updates.Should().NotContain(workflowError); + updates.Select(static update => update.RawRepresentation).OfType().Should().ContainSingle(); + } + + [Fact] + public async Task AsAGUIChatResponseUpdatesAsync_PreservesMappedBaseEventAsRawRepresentationAsync() + { + // Arrange + StepStartedEvent stepStarted = new() { StepName = "superstep:1" }; + AgentResponseUpdate update = CreateUpdate(stepStarted); + + // Act + ChatResponseUpdate chatUpdate = await ToAsyncEnumerableAsync(update) + .AsAGUIChatResponseUpdatesAsync() + .SingleAsync(); + + // Assert + chatUpdate.RawRepresentation.Should().BeSameAs(stepStarted); + } + + [Fact] + public async Task AGUIServer_DoesNotRecursivelyUnwrapNestedRawRepresentationAsync() + { + // Arrange + StepStartedEvent stepStarted = new() { StepName = "superstep:1" }; + AgentResponseUpdate update = CreateUpdate(stepStarted); + ChatResponseUpdate nestedUpdate = update.AsChatResponseUpdate(); + RunAgentInput input = new() + { + Messages = [], + RunId = "run", + ThreadId = "thread", + }; + ChatRequestContext context = input.ToChatRequestContext(new JsonSerializerOptions(JsonSerializerDefaults.Web) + { + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }); + + // Act + List events = await ToAsyncEnumerableAsync(nestedUpdate) + .AsAGUIEventStreamAsync(context) + .ToListAsync(); + + // Assert + events.OfType().Should().BeEmpty(); + } + + [Fact] + public async Task WithAGUIWorkflowEvents_MapsRealSequentialWorkflowStepsAndTextAsync() + { + // Arrange + Workflow workflow = new SequentialWorkflowBuilder( + new ConstantAgent("first", "one"), + new ConstantAgent("second", "two")) + .Build(); + AIAgent agent = workflow + .AsAIAgent() + .WithAGUIWorkflowEvents(); + + // Act + List updates = await agent + .RunStreamingAsync(new ChatMessage(ChatRole.User, "start")) + .ToListAsync(); + + // Assert + string[] starts = [.. updates.Select(static update => update.RawRepresentation).OfType().Select(static evt => evt.StepName)]; + string[] finishes = [.. updates.Select(static update => update.RawRepresentation).OfType().Select(static evt => evt.StepName)]; + starts.Should().NotBeEmpty(); + finishes.Should().Equal(starts); + updates.Where(static update => update.Text is "one" or "two").Select(static update => update.Text) + .Should().Equal("one", "two"); + updates.Count(static update => update.Text == "one").Should().Be(1); + updates.Count(static update => update.Text == "two").Should().Be(1); + } + + [Fact] + public void WithAGUIWorkflowEvents_WithNullAgent_ThrowsArgumentNullException() + { + // Arrange + AIAgent agent = null!; + + // Act + Action act = () => agent.WithAGUIWorkflowEvents(); + + // Assert + act.Should().Throw(); + } + + private static AgentResponseUpdate CreateUpdate(object? raw, params AIContent[] contents) + => new(ChatRole.Assistant, contents) + { + CreatedAt = DateTimeOffset.UtcNow, + MessageId = Guid.NewGuid().ToString("N"), + RawRepresentation = raw, + ResponseId = "response", + }; + + private static async IAsyncEnumerable ToAsyncEnumerableAsync( + AgentResponseUpdate update) + { + await Task.Yield(); + yield return update; + } + + private static async IAsyncEnumerable ToAsyncEnumerableAsync( + ChatResponseUpdate update) + { + await Task.Yield(); + yield return update; + } + + private sealed class ThrowingOutputPayload + { + public string Value => throw new InvalidOperationException("secret"); + } + + private sealed class ScriptedAgent(params AgentResponseUpdate[] updates) : AIAgent + { + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + => this.RunCoreStreamingAsync(messages, session, options, cancellationToken).ToAgentResponseAsync(cancellationToken); + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + foreach (AgentResponseUpdate update in updates) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return update; + } + } + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + => new(new ScriptedAgentSession()); + + 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 ScriptedAgentSession()); + + private sealed class ScriptedAgentSession : AgentSession; + } + + private sealed class ConstantAgent(string name, string text) : 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, text))); + + 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, text) + { + AuthorName = name, + MessageId = Guid.NewGuid().ToString("N"), + ResponseId = Guid.NewGuid().ToString("N"), + }; + } + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + => new(new ConstantAgentSession()); + + 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 ConstantAgentSession()); + + private sealed class ConstantAgentSession : AgentSession; + } +} 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 @@ + From 5ade34cbc74f6694926927e40ec85291b897fdc3 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Tue, 18 Aug 2026 16:26:04 +0200 Subject: [PATCH 02/17] Scope AG-UI workflow mapping to executor steps Map only executor invoked, completed, and failed workflow events in the AG-UI response stream. Preserve the existing conversion for every other update. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 681114d3-3e0d-47f8-8b87-4e5873ecdd6d --- .../AGUIWorkflowEventExtensions.cs | 343 ------------- .../WorkflowAGUIExtensions.cs | 67 +++ .../AGUIWorkflowEventExtensionsTests.cs | 479 ------------------ .../WorkflowAGUIExtensionsTests.cs | 107 ++++ 4 files changed, 174 insertions(+), 822 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIWorkflowEventExtensions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/WorkflowAGUIExtensions.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIWorkflowEventExtensionsTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/WorkflowAGUIExtensionsTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIWorkflowEventExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIWorkflowEventExtensions.cs deleted file mode 100644 index 4918e559b27..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIWorkflowEventExtensions.cs +++ /dev/null @@ -1,343 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Runtime.CompilerServices; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Text.Json.Serialization.Metadata; -using System.Threading; -using System.Threading.Tasks; -using AGUI.Abstractions; -using Microsoft.Agents.AI.Workflows; -using Microsoft.Extensions.AI; - -namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; - -/// -/// Provides AG-UI event mapping for workflow-hosted agents. -/// -public static class AGUIWorkflowEventExtensions -{ - /// - /// Decorates an agent so supported workflow lifecycle events are exposed as AG-UI events. - /// - /// The workflow-hosted agent to decorate. - /// An agent that maps workflow lifecycle events while preserving ordinary response updates. - /// - /// - /// Supersteps are mapped to AG-UI step events. Workflow warnings, non-chat outputs, and executor - /// lifecycle events are mapped to structured custom events. - /// - /// - /// Executor lifecycle events are not mapped to AG-UI activity events because workflow events do not - /// currently expose a stable invocation identifier. Mapping by executor identifier could misattribute - /// repeated or concurrent invocations. - /// - /// - /// Workflow run lifecycle, request/interrupt content, response-compatible outputs, and errors continue - /// through their existing mappings. Internal checkpoint/debug state and exception details are not added - /// to AG-UI event payloads. - /// - /// - /// Non-chat output data is included only when it is a JSON scalar, , or - /// . Pre-serialize custom output objects to when their - /// payload should be sent to the client. Warning messages are intentionally replaced with a stable, - /// non-sensitive message. - /// - /// - public static AIAgent WithAGUIWorkflowEvents(this AIAgent agent) - { - ArgumentNullException.ThrowIfNull(agent); - return new AGUIWorkflowEventAgent(agent); - } - - internal static async IAsyncEnumerable AsAGUIChatResponseUpdatesAsync( - this IAsyncEnumerable updates) - { - ArgumentNullException.ThrowIfNull(updates); - - await foreach (AgentResponseUpdate update in updates.ConfigureAwait(false)) - { - ChatResponseUpdate chatUpdate = update.AsChatResponseUpdate(); - if (update.RawRepresentation is BaseEvent) - { - chatUpdate.RawRepresentation = update.RawRepresentation; - } - - yield return chatUpdate; - } - } -} - -internal sealed class AGUIWorkflowEventAgent(AIAgent innerAgent) : DelegatingAIAgent(innerAgent) -{ - private const string WarningEventName = "maf.workflow.warning"; - private const string OutputEventName = "maf.workflow.output"; - private const string ExecutorInvokedEventName = "maf.workflow.executor.invoked"; - private const string ExecutorCompletedEventName = "maf.workflow.executor.completed"; - private const string ExecutorFailedEventName = "maf.workflow.executor.failed"; - - protected override Task RunCoreAsync( - IEnumerable messages, - AgentSession? session = null, - AgentRunOptions? options = null, - CancellationToken cancellationToken = default) - => this.InnerAgent.RunAsync(messages, session, options, cancellationToken); - - protected override async IAsyncEnumerable RunCoreStreamingAsync( - IEnumerable messages, - AgentSession? session = null, - AgentRunOptions? options = null, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - List activeSteps = []; - bool terminalErrorForwarded = false; - - await foreach (AgentResponseUpdate update in this.InnerAgent - .RunStreamingAsync(messages, session, options, cancellationToken) - .ConfigureAwait(false)) - { - switch (update.RawRepresentation) - { - case SuperStepStartedEvent started when !activeSteps.Contains(started.StepNumber): - activeSteps.Add(started.StepNumber); - yield return CloneWithEvent(update, new StepStartedEvent { StepName = GetStepName(started.StepNumber) }); - break; - - case SuperStepCompletedEvent completed when activeSteps.Remove(completed.StepNumber): - yield return CloneWithEvent(update, new StepFinishedEvent { StepName = GetStepName(completed.StepNumber) }); - break; - - case WorkflowWarningEvent warning: - yield return CloneWithEvent( - update, - CreateCustomEvent( - WarningEventName, - new AGUIWorkflowWarningPayload("The workflow reported a warning."))); - break; - - case WorkflowOutputEvent output when !IsResponseCompatible(output): - yield return CloneWithEvent( - update, - CreateCustomEvent( - OutputEventName, - new AGUIWorkflowOutputPayload( - output.ExecutorId, - [.. output.Tags.Select(static tag => tag.Value).Where(static value => value is not null).OrderBy(static value => value, StringComparer.Ordinal)!], - TrySerializeOutput(output.Data)))); - break; - - case ExecutorInvokedEvent invoked: - yield return CloneWithEvent( - update, - CreateCustomEvent(ExecutorInvokedEventName, new AGUIWorkflowExecutorPayload(invoked.ExecutorId))); - break; - - case ExecutorCompletedEvent completed: - yield return CloneWithEvent( - update, - CreateCustomEvent(ExecutorCompletedEventName, new AGUIWorkflowExecutorPayload(completed.ExecutorId))); - break; - - case ExecutorFailedEvent failed: - foreach (AgentResponseUpdate stepFinished in CloseActiveSteps(update, activeSteps)) - { - yield return stepFinished; - } - - yield return CloneWithEvent( - update, - CreateCustomEvent(ExecutorFailedEventName, new AGUIWorkflowExecutorPayload(failed.ExecutorId)), - includeContents: false); - AgentResponseUpdate? executorFailure = FilterDuplicateErrorContent(update, ref terminalErrorForwarded); - if (executorFailure is not null) - { - yield return executorFailure; - } - break; - - case WorkflowErrorEvent: - foreach (AgentResponseUpdate stepFinished in CloseActiveSteps(update, activeSteps)) - { - yield return stepFinished; - } - - AgentResponseUpdate? workflowError = FilterDuplicateErrorContent(update, ref terminalErrorForwarded); - if (workflowError is not null) - { - yield return workflowError; - } - break; - - default: - yield return update; - break; - } - } - - foreach (AgentResponseUpdate stepFinished in CloseActiveSteps(template: null, activeSteps)) - { - yield return stepFinished; - } - } - - private static IEnumerable CloseActiveSteps(AgentResponseUpdate? template, List activeSteps) - { - for (int i = activeSteps.Count - 1; i >= 0; i--) - { - yield return CloneWithEvent( - template, - new StepFinishedEvent { StepName = GetStepName(activeSteps[i]) }, - includeContents: false); - } - - activeSteps.Clear(); - } - - private static AgentResponseUpdate CloneWithEvent( - AgentResponseUpdate? update, - BaseEvent evt, - bool includeContents = true) - => new(update?.Role, includeContents ? update?.Contents : []) - { - AdditionalProperties = update?.AdditionalProperties, - AgentId = update?.AgentId, - AuthorName = update?.AuthorName, - ContinuationToken = update?.ContinuationToken, - CreatedAt = update?.CreatedAt, - FinishReason = update?.FinishReason, - MessageId = update?.MessageId, - RawRepresentation = evt, - ResponseId = update?.ResponseId, - }; - - private static AgentResponseUpdate? FilterDuplicateErrorContent( - AgentResponseUpdate update, - ref bool terminalErrorForwarded) - { - bool hasError = update.Contents.Any(static content => content is ErrorContent); - if (!hasError) - { - return update; - } - - if (!terminalErrorForwarded) - { - terminalErrorForwarded = true; - return update; - } - - AIContent[] remainingContents = [.. update.Contents.Where(static content => content is not ErrorContent)]; - return remainingContents.Length > 0 - ? CloneWithContents(update, remainingContents) - : null; - } - - private static AgentResponseUpdate CloneWithContents( - AgentResponseUpdate update, - IList contents) - => new(update.Role, contents) - { - AdditionalProperties = update.AdditionalProperties, - AgentId = update.AgentId, - AuthorName = update.AuthorName, - ContinuationToken = update.ContinuationToken, - CreatedAt = update.CreatedAt, - FinishReason = update.FinishReason, - MessageId = update.MessageId, - RawRepresentation = update.RawRepresentation, - ResponseId = update.ResponseId, - }; - - private static string GetStepName(int stepNumber) => $"superstep:{stepNumber}"; - - private static bool IsResponseCompatible(WorkflowOutputEvent output) - => output is AgentResponseEvent or AgentResponseUpdateEvent - || output.Data is string - || output.Data is AIContent - || output.Data is IEnumerable - || output.Data is ChatMessage - || output.Data is IEnumerable; - - private static CustomEvent CreateCustomEvent(string name, AGUIWorkflowWarningPayload payload) - => new() - { - Name = name, - Value = JsonSerializer.SerializeToElement(payload, AGUIWorkflowEventsJsonContext.Default.AGUIWorkflowWarningPayload), - }; - - private static CustomEvent CreateCustomEvent(string name, AGUIWorkflowOutputPayload payload) - => new() - { - Name = name, - Value = JsonSerializer.SerializeToElement(payload, AGUIWorkflowEventsJsonContext.Default.AGUIWorkflowOutputPayload), - }; - - private static CustomEvent CreateCustomEvent(string name, AGUIWorkflowExecutorPayload payload) - => new() - { - Name = name, - Value = JsonSerializer.SerializeToElement(payload, AGUIWorkflowEventsJsonContext.Default.AGUIWorkflowExecutorPayload), - }; - - private static JsonElement? TrySerializeOutput(object? value) - => value switch - { - JsonElement element => element.Clone(), - JsonDocument document => document.RootElement.Clone(), - bool boolean => SerializeScalar(boolean, AGUIWorkflowEventsJsonContext.Default.Boolean), - byte number => SerializeScalar(number, AGUIWorkflowEventsJsonContext.Default.Byte), - sbyte number => SerializeScalar(number, AGUIWorkflowEventsJsonContext.Default.SByte), - short number => SerializeScalar(number, AGUIWorkflowEventsJsonContext.Default.Int16), - ushort number => SerializeScalar(number, AGUIWorkflowEventsJsonContext.Default.UInt16), - int number => SerializeScalar(number, AGUIWorkflowEventsJsonContext.Default.Int32), - uint number => SerializeScalar(number, AGUIWorkflowEventsJsonContext.Default.UInt32), - long number => SerializeScalar(number, AGUIWorkflowEventsJsonContext.Default.Int64), - ulong number => SerializeScalar(number, AGUIWorkflowEventsJsonContext.Default.UInt64), - float number when float.IsFinite(number) => SerializeScalar(number, AGUIWorkflowEventsJsonContext.Default.Single), - double number when double.IsFinite(number) => SerializeScalar(number, AGUIWorkflowEventsJsonContext.Default.Double), - decimal number => SerializeScalar(number, AGUIWorkflowEventsJsonContext.Default.Decimal), - char character => SerializeScalar(character, AGUIWorkflowEventsJsonContext.Default.Char), - DateTime dateTime => SerializeScalar(dateTime, AGUIWorkflowEventsJsonContext.Default.DateTime), - DateTimeOffset dateTimeOffset => SerializeScalar(dateTimeOffset, AGUIWorkflowEventsJsonContext.Default.DateTimeOffset), - Guid guid => SerializeScalar(guid, AGUIWorkflowEventsJsonContext.Default.Guid), - TimeSpan timeSpan => SerializeScalar(timeSpan, AGUIWorkflowEventsJsonContext.Default.TimeSpan), - Uri uri => SerializeScalar(uri, AGUIWorkflowEventsJsonContext.Default.Uri), - _ => null, - }; - - private static JsonElement SerializeScalar(T value, JsonTypeInfo typeInfo) - => JsonSerializer.SerializeToElement(value, typeInfo); -} - -internal sealed record AGUIWorkflowWarningPayload(string Message); - -internal sealed record AGUIWorkflowOutputPayload(string ExecutorId, string[] Tags, JsonElement? Data); - -internal sealed record AGUIWorkflowExecutorPayload(string ExecutorId); - -[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] -[JsonSerializable(typeof(AGUIWorkflowWarningPayload))] -[JsonSerializable(typeof(AGUIWorkflowOutputPayload))] -[JsonSerializable(typeof(AGUIWorkflowExecutorPayload))] -[JsonSerializable(typeof(bool))] -[JsonSerializable(typeof(byte))] -[JsonSerializable(typeof(sbyte))] -[JsonSerializable(typeof(short))] -[JsonSerializable(typeof(ushort))] -[JsonSerializable(typeof(int))] -[JsonSerializable(typeof(uint))] -[JsonSerializable(typeof(long))] -[JsonSerializable(typeof(ulong))] -[JsonSerializable(typeof(float))] -[JsonSerializable(typeof(double))] -[JsonSerializable(typeof(decimal))] -[JsonSerializable(typeof(char))] -[JsonSerializable(typeof(DateTime))] -[JsonSerializable(typeof(DateTimeOffset))] -[JsonSerializable(typeof(Guid))] -[JsonSerializable(typeof(TimeSpan))] -[JsonSerializable(typeof(Uri))] -internal sealed partial class AGUIWorkflowEventsJsonContext : JsonSerializerContext; 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..6d9ebb6ed2d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/WorkflowAGUIExtensions.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +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 +{ + internal static async IAsyncEnumerable AsAGUIChatResponseUpdatesAsync( + this IAsyncEnumerable updates) + { + ArgumentNullException.ThrowIfNull(updates); + + await foreach (AgentResponseUpdate update in updates.ConfigureAwait(false)) + { + switch (update.RawRepresentation) + { + case ExecutorInvokedEvent invoked: + yield return CreateEventUpdate( + update, + new StepStartedEvent { StepName = invoked.ExecutorId }); + break; + + case ExecutorCompletedEvent completed: + yield return CreateEventUpdate( + update, + new StepFinishedEvent { StepName = completed.ExecutorId }); + break; + + case ExecutorFailedEvent failed: + yield return CreateEventUpdate( + update, + new StepFinishedEvent { StepName = failed.ExecutorId }, + includeContents: false); + yield return update.AsChatResponseUpdate(); + break; + + default: + yield return update.AsChatResponseUpdate(); + break; + } + } + } + + private static ChatResponseUpdate CreateEventUpdate( + AgentResponseUpdate 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, + }; +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIWorkflowEventExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIWorkflowEventExtensionsTests.cs deleted file mode 100644 index 7be94153944..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIWorkflowEventExtensionsTests.cs +++ /dev/null @@ -1,479 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Runtime.CompilerServices; -using System.Text.Json; -using System.Text.Json.Serialization.Metadata; -using System.Threading; -using System.Threading.Tasks; -using AGUI.Abstractions; -using AGUI.Server; -using FluentAssertions; -using Microsoft.Agents.AI.Workflows; -using Microsoft.Extensions.AI; - -namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests; - -/// -/// Tests workflow lifecycle mapping to AG-UI events. -/// -public sealed class AGUIWorkflowEventExtensionsTests -{ - [Fact] - public async Task WithAGUIWorkflowEvents_MapsBalancedStepsAndPreservesOutputOnceAsync() - { - // Arrange - AgentResponseUpdate textOne = CreateUpdate(raw: null, new TextContent("one")); - AgentResponseUpdate textTwo = CreateUpdate(raw: null, new TextContent("two")); - AIAgent agent = new ScriptedAgent( - CreateUpdate(new WorkflowStartedEvent("workflow")), - CreateUpdate(new SuperStepStartedEvent(1)), - CreateUpdate(new ExecutorInvokedEvent("agent-1", "input")), - textOne, - CreateUpdate(new ExecutorCompletedEvent("agent-1", "result")), - CreateUpdate(new SuperStepCompletedEvent(1)), - CreateUpdate(new SuperStepStartedEvent(2)), - CreateUpdate(new ExecutorInvokedEvent("agent-2", "input")), - textTwo, - CreateUpdate(new ExecutorCompletedEvent("agent-2", "result")), - CreateUpdate(new SuperStepCompletedEvent(2))); - - // Act - List updates = await agent - .WithAGUIWorkflowEvents() - .RunStreamingAsync(new ChatMessage(ChatRole.User, "start")) - .ToListAsync(); - - // Assert - updates.Select(static update => update.RawRepresentation).OfType() - .Select(static evt => evt.StepName) - .Should().Equal("superstep:1", "superstep:2"); - updates.Select(static update => update.RawRepresentation).OfType() - .Select(static evt => evt.StepName) - .Should().Equal("superstep:1", "superstep:2"); - updates.Where(static update => update.Text.Length > 0).Should().Equal(textOne, textTwo); - updates.Count(static update => update.Text == "one").Should().Be(1); - updates.Count(static update => update.Text == "two").Should().Be(1); - updates.Select(static update => update.RawRepresentation).OfType().Should().BeEmpty(); - updates.Select(static update => update.RawRepresentation).OfType().Should().BeEmpty(); - } - - [Fact] - public async Task WithAGUIWorkflowEvents_UsesCustomEventsForConcurrentRepeatedExecutorsAsync() - { - // Arrange - AIAgent agent = new ScriptedAgent( - CreateUpdate(new ExecutorInvokedEvent("worker", "first")), - CreateUpdate(new ExecutorInvokedEvent("worker", "second")), - CreateUpdate(new ExecutorCompletedEvent("worker", "first-result")), - CreateUpdate(new ExecutorCompletedEvent("worker", "second-result"))); - - // Act - List updates = await agent - .WithAGUIWorkflowEvents() - .RunStreamingAsync(new ChatMessage(ChatRole.User, "start")) - .ToListAsync(); - - // Assert - CustomEvent[] events = [.. updates.Select(static update => update.RawRepresentation).OfType()]; - events.Select(static evt => evt.Name).Should().Equal( - "maf.workflow.executor.invoked", - "maf.workflow.executor.invoked", - "maf.workflow.executor.completed", - "maf.workflow.executor.completed"); - events.Should().AllSatisfy(static evt => evt.Value!.Value.GetProperty("executorId").GetString().Should().Be("worker")); - updates.Select(static update => update.RawRepresentation).OfType().Should().BeEmpty(); - updates.Select(static update => update.RawRepresentation).OfType().Should().BeEmpty(); - } - - [Fact] - public async Task WithAGUIWorkflowEvents_ClosesActiveStepBeforeExecutorFailureAsync() - { - // Arrange - AgentResponseUpdate failure = CreateUpdate( - new ExecutorFailedEvent("worker", new InvalidOperationException("secret")), - new ErrorContent("An error occurred while executing the workflow.")); - AIAgent agent = new ScriptedAgent( - CreateUpdate(new SuperStepStartedEvent(7)), - failure, - CreateUpdate(new SuperStepCompletedEvent(7))); - - // Act - List updates = await agent - .WithAGUIWorkflowEvents() - .RunStreamingAsync(new ChatMessage(ChatRole.User, "start")) - .ToListAsync(); - - // Assert - updates.Should().HaveCount(5); - updates[0].RawRepresentation.Should().BeOfType(); - updates[1].RawRepresentation.Should().BeOfType(); - CustomEvent failedEvent = updates[2].RawRepresentation.Should().BeOfType().Subject; - failedEvent.Name.Should().Be("maf.workflow.executor.failed"); - failedEvent.Value!.Value.GetRawText().Should().NotContain("secret"); - updates[3].Should().BeSameAs(failure); - updates[3].Contents.Should().ContainSingle().Which.Should().BeOfType(); - updates[4].RawRepresentation.Should().BeOfType(); - updates.Select(static update => update.RawRepresentation).OfType().Should().ContainSingle(); - } - - [Fact] - public async Task WithAGUIWorkflowEvents_ClosesActiveStepBeforeWorkflowErrorAsync() - { - // Arrange - AgentResponseUpdate error = CreateUpdate( - new WorkflowErrorEvent(new InvalidOperationException("secret")), - new ErrorContent("An error occurred while executing the workflow.")); - AIAgent agent = new ScriptedAgent(CreateUpdate(new SuperStepStartedEvent(3)), error); - - // Act - List updates = await agent - .WithAGUIWorkflowEvents() - .RunStreamingAsync(new ChatMessage(ChatRole.User, "start")) - .ToListAsync(); - - // Assert - updates.Select(static update => update.RawRepresentation).Should().HaveCount(3); - updates[1].RawRepresentation.Should().BeOfType(); - updates[2].Should().BeSameAs(error); - updates[2].Contents.Should().ContainSingle().Which.Should().BeOfType(); - } - - [Fact] - public async Task WithAGUIWorkflowEvents_MapsWarningAndSerializableNonChatOutputAsync() - { - // Arrange - using JsonDocument document = JsonDocument.Parse("""{"name":"value","count":42}"""); - AIAgent agent = new ScriptedAgent( - CreateUpdate(new WorkflowWarningEvent("retrying")), - CreateUpdate(new WorkflowOutputEvent(document.RootElement, "worker", OutputTag.Intermediate))); - - // Act - List updates = await agent - .WithAGUIWorkflowEvents() - .RunStreamingAsync(new ChatMessage(ChatRole.User, "start")) - .ToListAsync(); - - // Assert - CustomEvent warning = updates[0].RawRepresentation.Should().BeOfType().Subject; - warning.Name.Should().Be("maf.workflow.warning"); - warning.Value!.Value.GetProperty("message").GetString().Should().Be("The workflow reported a warning."); - - CustomEvent output = updates[1].RawRepresentation.Should().BeOfType().Subject; - output.Name.Should().Be("maf.workflow.output"); - JsonElement outputValue = output.Value!.Value; - outputValue.GetProperty("executorId").GetString().Should().Be("worker"); - outputValue.GetProperty("tags").EnumerateArray().Select(static tag => tag.GetString()).Should().Equal("intermediate"); - outputValue.GetProperty("data").GetProperty("name").GetString().Should().Be("value"); - outputValue.GetProperty("data").GetProperty("count").GetInt32().Should().Be(42); - } - - [Fact] - public async Task WithAGUIWorkflowEvents_OmitsUnsafeNonChatOutputPayloadAsync() - { - // Arrange - AIAgent agent = new ScriptedAgent( - CreateUpdate(new WorkflowOutputEvent(new ThrowingOutputPayload(), "worker"))); - - // Act - AgentResponseUpdate update = await agent - .WithAGUIWorkflowEvents() - .RunStreamingAsync(new ChatMessage(ChatRole.User, "start")) - .SingleAsync(); - - // Assert - CustomEvent output = update.RawRepresentation.Should().BeOfType().Subject; - output.Value!.Value.GetProperty("data").ValueKind.Should().Be(JsonValueKind.Null); - } - - [Fact] - public async Task WithAGUIWorkflowEvents_OmitsNonFiniteOutputPayloadAsync() - { - // Arrange - AIAgent agent = new ScriptedAgent( - CreateUpdate(new WorkflowOutputEvent(double.NaN, "worker"))); - - // Act - AgentResponseUpdate update = await agent - .WithAGUIWorkflowEvents() - .RunStreamingAsync(new ChatMessage(ChatRole.User, "start")) - .SingleAsync(); - - // Assert - CustomEvent output = update.RawRepresentation.Should().BeOfType().Subject; - output.Value!.Value.GetProperty("data").ValueKind.Should().Be(JsonValueKind.Null); - } - - [Fact] - public async Task WithAGUIWorkflowEvents_PreservesResponseCompatibleAndInterruptUpdatesAsync() - { - // Arrange - AgentResponseUpdate workflowText = CreateUpdate( - new WorkflowOutputEvent("text", "worker"), - new TextContent("text")); - RequestPort port = RequestPort.Create("approval"); - AgentResponseUpdate interrupt = CreateUpdate( - new RequestInfoEvent(ExternalRequest.Create(port, "approve", "request-1")), - new FunctionCallContent("request-1", "approval", new Dictionary())); - AgentResponseUpdate toolResult = CreateUpdate( - raw: null, - new FunctionResultContent("request-1", "approved")); - AIAgent agent = new ScriptedAgent(workflowText, interrupt, toolResult); - - // Act - List updates = await agent - .WithAGUIWorkflowEvents() - .RunStreamingAsync(new ChatMessage(ChatRole.User, "start")) - .ToListAsync(); - - // Assert - updates.Should().Equal(workflowText, interrupt, toolResult); - updates.Count(static update => update.Text == "text").Should().Be(1); - updates.SelectMany(static update => update.Contents).OfType().Should().ContainSingle(); - updates.SelectMany(static update => update.Contents).OfType().Should().ContainSingle(); - } - - [Fact] - public async Task WithAGUIWorkflowEvents_ClosesActiveStepWhenStreamCompletesAsync() - { - // Arrange - AIAgent agent = new ScriptedAgent(CreateUpdate(new SuperStepStartedEvent(4))); - - // Act - List updates = await agent - .WithAGUIWorkflowEvents() - .RunStreamingAsync(new ChatMessage(ChatRole.User, "start")) - .ToListAsync(); - - // Assert - updates.Select(static update => update.RawRepresentation).Should().HaveCount(2); - updates[0].RawRepresentation.Should().BeOfType(); - updates[1].RawRepresentation.Should().BeOfType(); - } - - [Fact] - public async Task WithAGUIWorkflowEvents_ForwardsOnlyOneTerminalErrorContentAsync() - { - // Arrange - AgentResponseUpdate executorError = CreateUpdate( - new ExecutorFailedEvent("worker", new InvalidOperationException("secret")), - new ErrorContent("An error occurred while executing the workflow.")); - AgentResponseUpdate workflowError = CreateUpdate( - new WorkflowErrorEvent(new InvalidOperationException("secret")), - new ErrorContent("An error occurred while executing the workflow.")); - AIAgent agent = new ScriptedAgent( - CreateUpdate(new SuperStepStartedEvent(5)), - executorError, - workflowError); - - // Act - List updates = await agent - .WithAGUIWorkflowEvents() - .RunStreamingAsync(new ChatMessage(ChatRole.User, "start")) - .ToListAsync(); - - // Assert - updates.SelectMany(static update => update.Contents).OfType().Should().ContainSingle(); - updates.Should().Contain(executorError); - updates.Should().NotContain(workflowError); - updates.Select(static update => update.RawRepresentation).OfType().Should().ContainSingle(); - } - - [Fact] - public async Task AsAGUIChatResponseUpdatesAsync_PreservesMappedBaseEventAsRawRepresentationAsync() - { - // Arrange - StepStartedEvent stepStarted = new() { StepName = "superstep:1" }; - AgentResponseUpdate update = CreateUpdate(stepStarted); - - // Act - ChatResponseUpdate chatUpdate = await ToAsyncEnumerableAsync(update) - .AsAGUIChatResponseUpdatesAsync() - .SingleAsync(); - - // Assert - chatUpdate.RawRepresentation.Should().BeSameAs(stepStarted); - } - - [Fact] - public async Task AGUIServer_DoesNotRecursivelyUnwrapNestedRawRepresentationAsync() - { - // Arrange - StepStartedEvent stepStarted = new() { StepName = "superstep:1" }; - AgentResponseUpdate update = CreateUpdate(stepStarted); - ChatResponseUpdate nestedUpdate = update.AsChatResponseUpdate(); - RunAgentInput input = new() - { - Messages = [], - RunId = "run", - ThreadId = "thread", - }; - ChatRequestContext context = input.ToChatRequestContext(new JsonSerializerOptions(JsonSerializerDefaults.Web) - { - TypeInfoResolver = new DefaultJsonTypeInfoResolver(), - }); - - // Act - List events = await ToAsyncEnumerableAsync(nestedUpdate) - .AsAGUIEventStreamAsync(context) - .ToListAsync(); - - // Assert - events.OfType().Should().BeEmpty(); - } - - [Fact] - public async Task WithAGUIWorkflowEvents_MapsRealSequentialWorkflowStepsAndTextAsync() - { - // Arrange - Workflow workflow = new SequentialWorkflowBuilder( - new ConstantAgent("first", "one"), - new ConstantAgent("second", "two")) - .Build(); - AIAgent agent = workflow - .AsAIAgent() - .WithAGUIWorkflowEvents(); - - // Act - List updates = await agent - .RunStreamingAsync(new ChatMessage(ChatRole.User, "start")) - .ToListAsync(); - - // Assert - string[] starts = [.. updates.Select(static update => update.RawRepresentation).OfType().Select(static evt => evt.StepName)]; - string[] finishes = [.. updates.Select(static update => update.RawRepresentation).OfType().Select(static evt => evt.StepName)]; - starts.Should().NotBeEmpty(); - finishes.Should().Equal(starts); - updates.Where(static update => update.Text is "one" or "two").Select(static update => update.Text) - .Should().Equal("one", "two"); - updates.Count(static update => update.Text == "one").Should().Be(1); - updates.Count(static update => update.Text == "two").Should().Be(1); - } - - [Fact] - public void WithAGUIWorkflowEvents_WithNullAgent_ThrowsArgumentNullException() - { - // Arrange - AIAgent agent = null!; - - // Act - Action act = () => agent.WithAGUIWorkflowEvents(); - - // Assert - act.Should().Throw(); - } - - private static AgentResponseUpdate CreateUpdate(object? raw, params AIContent[] contents) - => new(ChatRole.Assistant, contents) - { - CreatedAt = DateTimeOffset.UtcNow, - MessageId = Guid.NewGuid().ToString("N"), - RawRepresentation = raw, - ResponseId = "response", - }; - - private static async IAsyncEnumerable ToAsyncEnumerableAsync( - AgentResponseUpdate update) - { - await Task.Yield(); - yield return update; - } - - private static async IAsyncEnumerable ToAsyncEnumerableAsync( - ChatResponseUpdate update) - { - await Task.Yield(); - yield return update; - } - - private sealed class ThrowingOutputPayload - { - public string Value => throw new InvalidOperationException("secret"); - } - - private sealed class ScriptedAgent(params AgentResponseUpdate[] updates) : AIAgent - { - protected override Task RunCoreAsync( - IEnumerable messages, - AgentSession? session = null, - AgentRunOptions? options = null, - CancellationToken cancellationToken = default) - => this.RunCoreStreamingAsync(messages, session, options, cancellationToken).ToAgentResponseAsync(cancellationToken); - - protected override async IAsyncEnumerable RunCoreStreamingAsync( - IEnumerable messages, - AgentSession? session = null, - AgentRunOptions? options = null, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - foreach (AgentResponseUpdate update in updates) - { - cancellationToken.ThrowIfCancellationRequested(); - yield return update; - } - } - - protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) - => new(new ScriptedAgentSession()); - - 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 ScriptedAgentSession()); - - private sealed class ScriptedAgentSession : AgentSession; - } - - private sealed class ConstantAgent(string name, string text) : 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, text))); - - 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, text) - { - AuthorName = name, - MessageId = Guid.NewGuid().ToString("N"), - ResponseId = Guid.NewGuid().ToString("N"), - }; - } - - protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) - => new(new ConstantAgentSession()); - - 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 ConstantAgentSession()); - - private sealed class ConstantAgentSession : AgentSession; - } -} 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..08680dea1eb --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/WorkflowAGUIExtensionsTests.cs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +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 executor lifecycle mapping to AG-UI step events. +/// +public sealed class WorkflowAGUIExtensionsTests +{ + [Fact] + public async Task AsAGUIChatResponseUpdatesAsync_MapsExecutorInvokedToStepStartedAsync() + { + // Arrange + AgentResponseUpdate update = CreateUpdate(new ExecutorInvokedEvent("reviewer", "input")); + + // Act + ChatResponseUpdate result = await ToAsyncEnumerableAsync(update) + .AsAGUIChatResponseUpdatesAsync() + .SingleAsync(); + + // Assert + result.RawRepresentation.Should().BeOfType() + .Which.StepName.Should().Be("reviewer"); + } + + [Fact] + public async Task AsAGUIChatResponseUpdatesAsync_MapsExecutorCompletedToStepFinishedAsync() + { + // Arrange + AgentResponseUpdate update = CreateUpdate(new ExecutorCompletedEvent("reviewer", "result")); + + // Act + ChatResponseUpdate result = await ToAsyncEnumerableAsync(update) + .AsAGUIChatResponseUpdatesAsync() + .SingleAsync(); + + // Assert + result.RawRepresentation.Should().BeOfType() + .Which.StepName.Should().Be("reviewer"); + } + + [Fact] + public async Task AsAGUIChatResponseUpdatesAsync_MapsExecutorFailedAndPreservesErrorAsync() + { + // 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) + .AsAGUIChatResponseUpdatesAsync() + .ToListAsync(); + + // Assert + results.Should().HaveCount(2); + results[0].RawRepresentation.Should().BeOfType() + .Which.StepName.Should().Be("reviewer"); + results[0].Contents.Should().BeEmpty(); + results[1].Contents.Should().ContainSingle().Which.Should().BeSameAs(error); + } + + [Fact] + public async Task AsAGUIChatResponseUpdatesAsync_ForwardsOtherUpdatesThroughExistingConversionAsync() + { + // Arrange + WorkflowStartedEvent workflowStarted = new("workflow"); + TextContent text = new("hello"); + AgentResponseUpdate update = CreateUpdate(workflowStarted, text); + + // Act + ChatResponseUpdate result = await ToAsyncEnumerableAsync(update) + .AsAGUIChatResponseUpdatesAsync() + .SingleAsync(); + + // Assert + result.RawRepresentation.Should().BeSameAs(update); + result.Contents.Should().ContainSingle().Which.Should().BeSameAs(text); + } + + 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; + } +} From a04cec0ec4ba40e803a6e8a99490aa4716d54112 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Tue, 18 Aug 2026 19:48:45 +0200 Subject: [PATCH 03/17] Map workflow events after chat conversion Keep the standard AgentResponseUpdate to ChatResponseUpdate conversion and apply the minimal executor lifecycle mapping as a dedicated AG-UI stream transformation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 681114d3-3e0d-47f8-8b87-4e5873ecdd6d --- .../AGUIEndpointRouteBuilderExtensions.cs | 3 +- .../WorkflowAGUIExtensions.cs | 30 ++++++++--------- .../WorkflowAGUIExtensionsTests.cs | 33 ++++++++++++++----- 3 files changed, 41 insertions(+), 25 deletions(-) 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 1c55c39ce7c..83742ac22ad 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs @@ -153,7 +153,8 @@ public static IEndpointConventionBuilder MapAGUIServer( session: session, options: new ChatClientAgentRunOptions { ChatOptions = ctx.ChatOptions }, cancellationToken: cancellationToken) - .AsAGUIChatResponseUpdatesAsync() + .AsChatResponseUpdatesAsync() + .MapWorkflowEventsToAGUI() .AsAGUIEventStreamAsync(ctx, cancellationToken); // Wrap the event stream to save the session after streaming completes. diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/WorkflowAGUIExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/WorkflowAGUIExtensions.cs index 6d9ebb6ed2d..f6bb8f03277 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/WorkflowAGUIExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/WorkflowAGUIExtensions.cs @@ -11,44 +11,44 @@ namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; internal static class WorkflowAGUIExtensions { - internal static async IAsyncEnumerable AsAGUIChatResponseUpdatesAsync( - this IAsyncEnumerable updates) +#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); - await foreach (AgentResponseUpdate update in updates.ConfigureAwait(false)) + await foreach (ChatResponseUpdate update in updates.ConfigureAwait(false)) { switch (update.RawRepresentation) { - case ExecutorInvokedEvent invoked: - yield return CreateEventUpdate( - update, - new StepStartedEvent { StepName = invoked.ExecutorId }); + case AgentResponseUpdate { RawRepresentation: ExecutorInvokedEvent invoked }: + update.RawRepresentation = new StepStartedEvent { StepName = invoked.ExecutorId }; + yield return update; break; - case ExecutorCompletedEvent completed: - yield return CreateEventUpdate( - update, - new StepFinishedEvent { StepName = completed.ExecutorId }); + case AgentResponseUpdate { RawRepresentation: ExecutorCompletedEvent completed }: + update.RawRepresentation = new StepFinishedEvent { StepName = completed.ExecutorId }; + yield return update; break; - case ExecutorFailedEvent failed: + case AgentResponseUpdate { RawRepresentation: ExecutorFailedEvent failed }: yield return CreateEventUpdate( update, new StepFinishedEvent { StepName = failed.ExecutorId }, includeContents: false); - yield return update.AsChatResponseUpdate(); + yield return update; break; default: - yield return update.AsChatResponseUpdate(); + yield return update; break; } } } +#pragma warning restore VSTHRD200 private static ChatResponseUpdate CreateEventUpdate( - AgentResponseUpdate update, + ChatResponseUpdate update, BaseEvent evt, bool includeContents = true) => new() 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 index 08680dea1eb..beea099ba88 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/WorkflowAGUIExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/WorkflowAGUIExtensionsTests.cs @@ -17,14 +17,15 @@ namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests; public sealed class WorkflowAGUIExtensionsTests { [Fact] - public async Task AsAGUIChatResponseUpdatesAsync_MapsExecutorInvokedToStepStartedAsync() + public async Task MapWorkflowEventsToAGUI_MapsExecutorInvokedToStepStartedAsync() { // Arrange AgentResponseUpdate update = CreateUpdate(new ExecutorInvokedEvent("reviewer", "input")); // Act ChatResponseUpdate result = await ToAsyncEnumerableAsync(update) - .AsAGUIChatResponseUpdatesAsync() + .AsChatResponseUpdatesAsync() + .MapWorkflowEventsToAGUI() .SingleAsync(); // Assert @@ -33,14 +34,15 @@ public async Task AsAGUIChatResponseUpdatesAsync_MapsExecutorInvokedToStepStarte } [Fact] - public async Task AsAGUIChatResponseUpdatesAsync_MapsExecutorCompletedToStepFinishedAsync() + public async Task MapWorkflowEventsToAGUI_MapsExecutorCompletedToStepFinishedAsync() { // Arrange AgentResponseUpdate update = CreateUpdate(new ExecutorCompletedEvent("reviewer", "result")); // Act ChatResponseUpdate result = await ToAsyncEnumerableAsync(update) - .AsAGUIChatResponseUpdatesAsync() + .AsChatResponseUpdatesAsync() + .MapWorkflowEventsToAGUI() .SingleAsync(); // Assert @@ -49,7 +51,7 @@ public async Task AsAGUIChatResponseUpdatesAsync_MapsExecutorCompletedToStepFini } [Fact] - public async Task AsAGUIChatResponseUpdatesAsync_MapsExecutorFailedAndPreservesErrorAsync() + public async Task MapWorkflowEventsToAGUI_MapsExecutorFailedAndPreservesErrorAsync() { // Arrange ErrorContent error = new("An error occurred while executing the workflow."); @@ -59,7 +61,8 @@ public async Task AsAGUIChatResponseUpdatesAsync_MapsExecutorFailedAndPreservesE // Act List results = await ToAsyncEnumerableAsync(update) - .AsAGUIChatResponseUpdatesAsync() + .AsChatResponseUpdatesAsync() + .MapWorkflowEventsToAGUI() .ToListAsync(); // Assert @@ -67,11 +70,12 @@ public async Task AsAGUIChatResponseUpdatesAsync_MapsExecutorFailedAndPreservesE results[0].RawRepresentation.Should().BeOfType() .Which.StepName.Should().Be("reviewer"); results[0].Contents.Should().BeEmpty(); + results[1].RawRepresentation.Should().BeSameAs(update); results[1].Contents.Should().ContainSingle().Which.Should().BeSameAs(error); } [Fact] - public async Task AsAGUIChatResponseUpdatesAsync_ForwardsOtherUpdatesThroughExistingConversionAsync() + public async Task MapWorkflowEventsToAGUI_ForwardsOtherUpdatesUnchangedAsync() { // Arrange WorkflowStartedEvent workflowStarted = new("workflow"); @@ -79,11 +83,15 @@ public async Task AsAGUIChatResponseUpdatesAsync_ForwardsOtherUpdatesThroughExis AgentResponseUpdate update = CreateUpdate(workflowStarted, text); // Act - ChatResponseUpdate result = await ToAsyncEnumerableAsync(update) - .AsAGUIChatResponseUpdatesAsync() + 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); } @@ -104,4 +112,11 @@ private static async IAsyncEnumerable ToAsyncEnumerableAsyn await Task.Yield(); yield return update; } + + private static async IAsyncEnumerable ToAsyncEnumerableAsync( + ChatResponseUpdate update) + { + await Task.Yield(); + yield return update; + } } From 9416263dc2be06269244641a79edf37da1609315 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Wed, 19 Aug 2026 13:12:24 +0200 Subject: [PATCH 04/17] Add sequential workflow AG-UI sample Demonstrate ordered executor step events through an AG-UI client/server sample and verify the sample workflow over HTTP/SSE. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 681114d3-3e0d-47f8-8b87-4e5873ecdd6d --- dotnet/agent-framework-dotnet.slnx | 5 +- dotnet/samples/02-agents/AGUI/README.md | 11 ++++ .../Client/Client.csproj | 15 +++++ .../Client/Program.cs | 37 ++++++++++++ .../AGUI/Step06_WorkflowSequential/README.md | 15 +++++ .../Server/Program.cs | 36 ++++++++++++ .../Server/SequentialWorkflow.cs | 21 +++++++ .../Server/Server.csproj | 21 +++++++ ...ng.AGUI.AspNetCore.IntegrationTests.csproj | 2 + .../Workflows/DeterministicAgent.cs | 54 +++++++++++++++++ .../Workflows/SequentialWorkflowTests.cs | 58 +++++++++++++++++++ .../Workflows/WorkflowTestHost.cs | 47 +++++++++++++++ 12 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Client/Client.csproj create mode 100644 dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Client/Program.cs create mode 100644 dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/README.md create mode 100644 dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Server/Program.cs create mode 100644 dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Server/SequentialWorkflow.cs create mode 100644 dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Server/Server.csproj create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/DeterministicAgent.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/SequentialWorkflowTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/WorkflowTestHost.cs diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 40c791159ba..ed91d78485b 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -123,6 +123,10 @@ + + + + @@ -670,4 +674,3 @@ - diff --git a/dotnet/samples/02-agents/AGUI/README.md b/dotnet/samples/02-agents/AGUI/README.md index b0e724bf14e..405846623df 100644 --- a/dotnet/samples/02-agents/AGUI/README.md +++ b/dotnet/samples/02-agents/AGUI/README.md @@ -186,6 +186,17 @@ 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 +``` + 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..76f15d84fbf --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Client/Client.csproj @@ -0,0 +1,15 @@ + + + + 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..66488096fae --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Client/Program.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +using AGUI.Abstractions; +using AGUI.Client; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; +using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; +AGUIChatClient chatClient = new(new(httpClient, serverUrl)); +AIAgent agent = chatClient.AsAIAgent(name: "workflow-client"); +AgentSession session = await agent.CreateSessionAsync(); + +Console.Write("Request: "); +string request = Console.ReadLine() ?? "Write a short welcome message for a developer conference."; + +await foreach (AgentResponseUpdate update in agent.RunStreamingAsync( + new ChatMessage(ChatRole.User, request), + session)) +{ + switch (update.AsChatResponseUpdate().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(); 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/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..355237adc03 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,8 @@ + 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/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/WorkflowTestHost.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/WorkflowTestHost.cs new file mode 100644 index 00000000000..ecb9a603c84 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/WorkflowTestHost.cs @@ -0,0 +1,47 @@ +// 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) + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Services.AddAGUIServer(); + + WebApplication app = builder.Build(); + 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(); + } +} From 863494954fee018634360d5c5c5d87d208c60deb Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Wed, 19 Aug 2026 13:14:57 +0200 Subject: [PATCH 05/17] Add concurrent workflow AG-UI sample Demonstrate independent executor step lifecycles for concurrently running agents and verify the sample workflow over HTTP/SSE. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 681114d3-3e0d-47f8-8b87-4e5873ecdd6d --- dotnet/agent-framework-dotnet.slnx | 4 ++ dotnet/samples/02-agents/AGUI/README.md | 11 ++++ .../Client/Client.csproj | 15 +++++ .../Client/Program.cs | 37 ++++++++++++ .../AGUI/Step07_WorkflowConcurrent/README.md | 13 +++++ .../Server/ConcurrentWorkflow.cs | 20 +++++++ .../Server/Program.cs | 36 ++++++++++++ .../Server/Server.csproj | 21 +++++++ ...ng.AGUI.AspNetCore.IntegrationTests.csproj | 2 + .../Workflows/ConcurrentWorkflowTests.cs | 57 +++++++++++++++++++ 10 files changed, 216 insertions(+) create mode 100644 dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Client/Client.csproj create mode 100644 dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Client/Program.cs create mode 100644 dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/README.md create mode 100644 dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Server/ConcurrentWorkflow.cs create mode 100644 dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Server/Program.cs create mode 100644 dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Server/Server.csproj create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/ConcurrentWorkflowTests.cs diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index ed91d78485b..2c19f84f9a2 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -127,6 +127,10 @@ + + + + diff --git a/dotnet/samples/02-agents/AGUI/README.md b/dotnet/samples/02-agents/AGUI/README.md index 405846623df..62af95f5ec1 100644 --- a/dotnet/samples/02-agents/AGUI/README.md +++ b/dotnet/samples/02-agents/AGUI/README.md @@ -197,6 +197,17 @@ 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 +``` + The server runs on port 8888 by default. #### Client (`Step05_StateManagement/Client`) 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..76f15d84fbf --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Client/Client.csproj @@ -0,0 +1,15 @@ + + + + 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..0253be79cfc --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Client/Program.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +using AGUI.Abstractions; +using AGUI.Client; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; +using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; +AGUIChatClient chatClient = new(new(httpClient, serverUrl)); +AIAgent agent = chatClient.AsAIAgent(name: "workflow-client"); +AgentSession session = await agent.CreateSessionAsync(); + +Console.Write("Request: "); +string request = Console.ReadLine() ?? "Assess the tradeoffs of adopting a new framework."; + +await foreach (AgentResponseUpdate update in agent.RunStreamingAsync( + new ChatMessage(ChatRole.User, request), + session)) +{ + switch (update.AsChatResponseUpdate().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(); 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/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 355237adc03..32c116e962e 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 @@ -29,6 +29,8 @@ + 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); + } +} From 778e3462591b1af332959b409213073d2f32e253 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Wed, 19 Aug 2026 13:18:42 +0200 Subject: [PATCH 06/17] Add failing workflow AG-UI sample Demonstrate balanced AG-UI step lifecycle events when a workflow executor fails and verify the sample over HTTP/SSE. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 681114d3-3e0d-47f8-8b87-4e5873ecdd6d --- dotnet/agent-framework-dotnet.slnx | 4 ++ dotnet/samples/02-agents/AGUI/README.md | 10 +++ .../Client/Client.csproj | 15 +++++ .../Step08_WorkflowFailure/Client/Program.cs | 30 +++++++++ .../AGUI/Step08_WorkflowFailure/README.md | 11 ++++ .../Server/FailingWorkflow.cs | 66 +++++++++++++++++++ .../Step08_WorkflowFailure/Server/Program.cs | 16 +++++ .../Server/Server.csproj | 15 +++++ ...ng.AGUI.AspNetCore.IntegrationTests.csproj | 2 + .../Workflows/FailingWorkflowTests.cs | 52 +++++++++++++++ 10 files changed, 221 insertions(+) create mode 100644 dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Client/Client.csproj create mode 100644 dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Client/Program.cs create mode 100644 dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/README.md create mode 100644 dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Server/FailingWorkflow.cs create mode 100644 dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Server/Program.cs create mode 100644 dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Server/Server.csproj create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/FailingWorkflowTests.cs diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 2c19f84f9a2..76b89a5b61f 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -131,6 +131,10 @@ + + + + diff --git a/dotnet/samples/02-agents/AGUI/README.md b/dotnet/samples/02-agents/AGUI/README.md index 62af95f5ec1..146b22c55f1 100644 --- a/dotnet/samples/02-agents/AGUI/README.md +++ b/dotnet/samples/02-agents/AGUI/README.md @@ -208,6 +208,16 @@ dotnet run --project Server --urls http://localhost:8888 dotnet run --project Client ``` +### Step08_WorkflowFailure + +A deterministic failing workflow. The client shows that the failed executor step is closed. + +```bash +cd Step08_WorkflowFailure +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/Step08_WorkflowFailure/Client/Client.csproj b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Client/Client.csproj new file mode 100644 index 00000000000..76f15d84fbf --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Client/Client.csproj @@ -0,0 +1,15 @@ + + + + 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..571e9ac0d2b --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Client/Program.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using AGUI.Abstractions; +using AGUI.Client; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; +using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; +AGUIChatClient chatClient = new(new(httpClient, serverUrl)); +AIAgent agent = chatClient.AsAIAgent(name: "workflow-client"); +AgentSession session = await agent.CreateSessionAsync(); + +await foreach (AgentResponseUpdate update in agent.RunStreamingAsync( + new ChatMessage(ChatRole.User, "Run the failing workflow."), + session)) +{ + switch (update.AsChatResponseUpdate().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; + } +} 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..2e1fab0d690 --- /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 still emits `STEP_FINISHED` for the failed +executor so clients do not leave the step active. + +## 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..1089c38b87f --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Server/FailingWorkflow.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +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 IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + => 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/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 32c116e962e..495a5af68ca 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 @@ -31,6 +31,8 @@ Link="Workflows\Samples\SequentialWorkflow.cs" /> + 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..75ba555f6e0 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/FailingWorkflowTests.cs @@ -0,0 +1,52 @@ +// 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.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")); + 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 + int stepStarted = updates.FindIndex(static update => + update.AsChatResponseUpdate().RawRepresentation is StepStartedEvent evt + && evt.StepName.StartsWith("FailingStep_", StringComparison.Ordinal)); + int stepFinished = updates.FindIndex(static update => + update.AsChatResponseUpdate().RawRepresentation is StepFinishedEvent evt + && evt.StepName.StartsWith("FailingStep_", StringComparison.Ordinal)); + stepStarted.Should().BeGreaterThanOrEqualTo(0); + stepFinished.Should().BeGreaterThan(stepStarted); + int startedCount = updates.Select(static update => update.AsChatResponseUpdate().RawRepresentation) + .OfType() + .Count(static evt => evt.StepName.StartsWith("FailingStep_", StringComparison.Ordinal)); + int finishedCount = updates.Select(static update => update.AsChatResponseUpdate().RawRepresentation) + .OfType() + .Count(static evt => evt.StepName.StartsWith("FailingStep_", StringComparison.Ordinal)); + startedCount.Should().BeGreaterThan(0); + finishedCount.Should().Be(startedCount); + } +} From 84e8314e519fec44a1e735fdab0c748aac8064e3 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Wed, 19 Aug 2026 13:20:22 +0200 Subject: [PATCH 07/17] Add tool-enabled workflow AG-UI sample Demonstrate executor steps alongside backend tool calls, results, and text, with HTTP/SSE integration coverage preventing duplicate content. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 681114d3-3e0d-47f8-8b87-4e5873ecdd6d --- dotnet/agent-framework-dotnet.slnx | 4 + dotnet/samples/02-agents/AGUI/README.md | 11 +++ .../Step09_WorkflowTools/Client/Client.csproj | 15 +++ .../Step09_WorkflowTools/Client/Program.cs | 45 +++++++++ .../AGUI/Step09_WorkflowTools/README.md | 13 +++ .../Step09_WorkflowTools/Server/Program.cs | 39 ++++++++ .../Step09_WorkflowTools/Server/Server.csproj | 21 ++++ .../Server/ToolWorkflow.cs | 20 ++++ ...ng.AGUI.AspNetCore.IntegrationTests.csproj | 2 + .../Workflows/ToolWorkflowTests.cs | 98 +++++++++++++++++++ 10 files changed, 268 insertions(+) create mode 100644 dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Client/Client.csproj create mode 100644 dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Client/Program.cs create mode 100644 dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/README.md create mode 100644 dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Server/Program.cs create mode 100644 dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Server/Server.csproj create mode 100644 dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Server/ToolWorkflow.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/ToolWorkflowTests.cs diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 76b89a5b61f..3ceb4072c0d 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -135,6 +135,10 @@ + + + + diff --git a/dotnet/samples/02-agents/AGUI/README.md b/dotnet/samples/02-agents/AGUI/README.md index 146b22c55f1..1f254a37800 100644 --- a/dotnet/samples/02-agents/AGUI/README.md +++ b/dotnet/samples/02-agents/AGUI/README.md @@ -218,6 +218,17 @@ 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 +``` + The server runs on port 8888 by default. #### Client (`Step05_StateManagement/Client`) 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..76f15d84fbf --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Client/Client.csproj @@ -0,0 +1,15 @@ + + + + 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..3312e38ceb0 --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Client/Program.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft. All rights reserved. + +using AGUI.Abstractions; +using AGUI.Client; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; +using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; +AGUIChatClient chatClient = new(new(httpClient, serverUrl)); +AIAgent agent = chatClient.AsAIAgent(name: "workflow-client"); +AgentSession session = await agent.CreateSessionAsync(); + +await foreach (AgentResponseUpdate update in agent.RunStreamingAsync( + new ChatMessage(ChatRole.User, "What is the weather in Seattle?"), + session)) +{ + switch (update.AsChatResponseUpdate().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(); 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/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 495a5af68ca..e69949cbe0f 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 @@ -33,6 +33,8 @@ Link="Workflows\Samples\ConcurrentWorkflow.cs" /> + 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; + } +} From 3a44f18173c6e9539b0031dc3a173a9932979480 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Wed, 19 Aug 2026 13:24:43 +0200 Subject: [PATCH 08/17] Add nested workflow AG-UI sample Characterize duplicate local executor IDs from parallel subworkflows and the resulting active-step collision tracked by issue #7763. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 681114d3-3e0d-47f8-8b87-4e5873ecdd6d --- dotnet/agent-framework-dotnet.slnx | 4 + dotnet/samples/02-agents/AGUI/README.md | 11 +++ .../Client/Client.csproj | 15 ++++ .../Step10_WorkflowNested/Client/Program.cs | 41 +++++++++ .../AGUI/Step10_WorkflowNested/README.md | 15 ++++ .../Server/NestedWorkflow.cs | 83 +++++++++++++++++++ .../Step10_WorkflowNested/Server/Program.cs | 18 ++++ .../Server/Server.csproj | 15 ++++ ...ng.AGUI.AspNetCore.IntegrationTests.csproj | 2 + .../Workflows/NestedWorkflowTests.cs | 38 +++++++++ 10 files changed, 242 insertions(+) create mode 100644 dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Client/Client.csproj create mode 100644 dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Client/Program.cs create mode 100644 dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/README.md create mode 100644 dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Server/NestedWorkflow.cs create mode 100644 dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Server/Program.cs create mode 100644 dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Server/Server.csproj create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/NestedWorkflowTests.cs diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 3ceb4072c0d..1a25f38be66 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -139,6 +139,10 @@ + + + + diff --git a/dotnet/samples/02-agents/AGUI/README.md b/dotnet/samples/02-agents/AGUI/README.md index 1f254a37800..d96a7ef6088 100644 --- a/dotnet/samples/02-agents/AGUI/README.md +++ b/dotnet/samples/02-agents/AGUI/README.md @@ -229,6 +229,17 @@ 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 +``` + The server runs on port 8888 by default. #### Client (`Step05_StateManagement/Client`) 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..76f15d84fbf --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Client/Client.csproj @@ -0,0 +1,15 @@ + + + + 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..a18b1da5ae8 --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Client/Program.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft. All rights reserved. + +using AGUI.Abstractions; +using AGUI.Client; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; +using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; +AGUIChatClient chatClient = new(new(httpClient, serverUrl)); +AIAgent agent = chatClient.AsAIAgent(name: "workflow-client"); +AgentSession session = await agent.CreateSessionAsync(); + +try +{ + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync( + new ChatMessage(ChatRole.User, "Review this proposal."), + session)) + { + switch (update.AsChatResponseUpdate().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(); 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..88489fe253d --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Server/NestedWorkflow.cs @@ -0,0 +1,83 @@ +// 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() + { + ChatForwardingExecutor start = new("Start"); + ExecutorBinding security = CreateAnalysisWorkflow("Security").BindAsExecutor("SecurityPipeline"); + ExecutorBinding style = CreateAnalysisWorkflow("Style").BindAsExecutor("StylePipeline"); + + return new WorkflowBuilder(start) + .AddFanOutEdge(start, [security, style]) + .WithOutputFrom(security, style) + .Build(); + } + + private static Workflow CreateAnalysisWorkflow(string analysisType) + => new SequentialWorkflowBuilder(new AnalysisAgent(analysisType)).Build(); +} + +internal sealed class AnalysisAgent(string analysisType) : 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 Task.Yield(); + 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; +} 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/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 e69949cbe0f..070951498d0 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 @@ -35,6 +35,8 @@ Link="Workflows\Samples\FailingWorkflow.cs" /> + 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..ff5919b480a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/NestedWorkflowTests.cs @@ -0,0 +1,38 @@ +// 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.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*"); + } +} From ae70aa44cd7a025ca342867b9a29aad28e1d53b1 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Wed, 19 Aug 2026 13:57:51 +0200 Subject: [PATCH 09/17] Add approval interruption workflow sample Map workflow input requests to AG-UI interruptions, preserve resumable workflow sessions, and demonstrate approval pause and resume over HTTP/SSE. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 681114d3-3e0d-47f8-8b87-4e5873ecdd6d --- dotnet/agent-framework-dotnet.slnx | 4 + dotnet/samples/02-agents/AGUI/README.md | 10 +++ .../Client/Client.csproj | 16 ++++ .../Step11_WorkflowApproval/Client/Program.cs | 52 ++++++++++++ .../AGUI/Step11_WorkflowApproval/README.md | 14 ++++ .../Server/ApprovalWorkflow.cs | 66 +++++++++++++++ .../Step11_WorkflowApproval/Server/Program.cs | 21 +++++ .../Server/Server.csproj | 19 +++++ .../AGUIEndpointRouteBuilderExtensions.cs | 6 +- .../ConfigureAGUIJsonOptions.cs | 1 + .../WorkflowAGUIExtensions.cs | 41 ++++++++++ .../WorkflowHostAgent.cs | 6 ++ ...ng.AGUI.AspNetCore.IntegrationTests.csproj | 6 ++ .../Workflows/ApprovalWorkflowTests.cs | 80 +++++++++++++++++++ .../Workflows/WorkflowTestHost.cs | 18 ++++- 15 files changed, 357 insertions(+), 3 deletions(-) create mode 100644 dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Client/Client.csproj create mode 100644 dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Client/Program.cs create mode 100644 dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/README.md create mode 100644 dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/ApprovalWorkflow.cs create mode 100644 dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/Program.cs create mode 100644 dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/Server.csproj create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/ApprovalWorkflowTests.cs diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 1a25f38be66..36f526f8632 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -143,6 +143,10 @@ + + + + diff --git a/dotnet/samples/02-agents/AGUI/README.md b/dotnet/samples/02-agents/AGUI/README.md index d96a7ef6088..91fa06a7238 100644 --- a/dotnet/samples/02-agents/AGUI/README.md +++ b/dotnet/samples/02-agents/AGUI/README.md @@ -240,6 +240,16 @@ 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 +``` + The server runs on port 8888 by default. #### Client (`Step05_StateManagement/Client`) 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..a31dc3c685f --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Client/Client.csproj @@ -0,0 +1,16 @@ + + + + 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..9d903bd36af --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Client/Program.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Net.Http.Json; +using System.Text.Json; +using AGUI.Abstractions; +using AGUI.Client; +using AGUI.Server; +using Microsoft.Extensions.AI; + +string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; +using HttpClient httpClient = new() { BaseAddress = new Uri(serverUrl), Timeout = TimeSpan.FromSeconds(60) }; + +RunAgentInput initialInput = new() +{ + Messages = new[] { new ChatMessage(ChatRole.User, "Submit expense EXP-100.") }.AsAGUIMessages().ToList(), + RunId = Guid.NewGuid().ToString("N"), + ThreadId = Guid.NewGuid().ToString("N"), +}; +List firstTurn = await SendAsync(initialInput); +RunFinishedEvent finished = firstTurn.OfType().Single(); +AGUIInterrupt interrupt = ((RunFinishedInterruptOutcome)finished.Outcome!).Interrupts.Single(); + +Console.Write($"{interrupt.Message ?? "Approve expense?"} [y/N]: "); +bool approved = string.Equals(Console.ReadLine(), "y", StringComparison.OrdinalIgnoreCase); + +RunAgentInput resumeInput = new() +{ + Messages = [], + ParentRunId = finished.RunId, + Resume = + [ + new AGUIResume + { + InterruptId = interrupt.Id, + Payload = JsonSerializer.SerializeToElement(new { approved }), + Status = "resolved", + }, + ], + RunId = Guid.NewGuid().ToString("N"), + ThreadId = finished.ThreadId, +}; + +List secondTurn = await SendAsync(resumeInput); +Console.WriteLine(string.Concat(secondTurn.OfType().Select(static evt => evt.Delta))); + +async Task> SendAsync(RunAgentInput input) +{ + using JsonContent content = JsonContent.Create(input, AGUIJsonSerializerContext.Default.RunAgentInput); + using HttpResponseMessage response = await httpClient.PostAsync(new Uri("", UriKind.Relative), content); + response.EnsureSuccessStatusCode(); + return await response.ReadAGUIEventStreamAsync().ToListAsync(); +} 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..2b1787d5e98 --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/README.md @@ -0,0 +1,14 @@ +# Approval Workflow over AG-UI + +This sample pauses a workflow for approval before submitting an expense. The AG-UI client reads the +interruption from `RUN_FINISHED`, asks the user for a decision, and resumes the same workflow thread. + +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..48f00867d7d --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/ApprovalWorkflow.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +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.WorkflowApproval; + +/// +/// Creates the approval workflow used by the sample and its integration test. +/// +public static class ApprovalWorkflow +{ + /// + /// Creates a workflow that pauses for approval before submitting an expense. + /// + /// The approval workflow. + public static Workflow Create() + { + ExpenseApprovalExecutor executor = new(); + return new WorkflowBuilder(executor) + .AddExternalCall(executor, "ApprovalInput") + .WithOutputFrom(executor) + .Build(); + } +} + +/// +/// The expense approval request presented to the client. +/// +/// The expense identifier. +/// The expense amount. +public sealed record ExpenseApprovalRequest(string ExpenseId, decimal Amount); + +[SendsMessage(typeof(ExpenseApprovalRequest))] +internal sealed partial class ExpenseApprovalExecutor() + : ChatProtocolExecutor("ExpenseApproval", new ChatProtocolExecutorOptions { AutoSendTurnToken = false }) +{ + protected override ValueTask TakeTurnAsync( + List messages, + IWorkflowContext context, + bool? emitEvents, + CancellationToken cancellationToken = default) + => context.SendMessageAsync(new ExpenseApprovalRequest("EXP-100", 125.00m), cancellationToken); + + [MessageHandler] + public async ValueTask HandleApprovalAsync( + JsonElement response, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + bool approved = response.GetProperty("approved").GetBoolean(); + string result = approved ? "Expense approved and submitted." : "Expense rejected."; + AgentResponseUpdate update = new(ChatRole.Assistant, result) + { + MessageId = "expense-result", + ResponseId = "expense-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/Step11_WorkflowApproval/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/Program.cs new file mode 100644 index 00000000000..6b4c91654be --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/Program.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft. All rights reserved. + +using AGUI.WorkflowApproval; +using Microsoft.Agents.AI; +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( + "ApprovalWorkflow", + static (_, _) => ApprovalWorkflow.Create().AsAIAgent( + name: "ApprovalWorkflow", + includeWorkflowOutputsInResponse: true)) + .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..6d6151d24c4 --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/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 83742ac22ad..8f9624a1635 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs @@ -147,9 +147,13 @@ public static IEndpointConventionBuilder MapAGUIServer( var session = await hostAgent.GetOrCreateSessionAsync(threadId, cancellationToken).ConfigureAwait(false); + IEnumerable messages = aiAgent.GetService() is not null + ? ctx.Messages.MapAGUIInterruptResponsesToWorkflow() + : ctx.Messages; + var events = hostAgent .RunStreamingAsync( - ctx.Messages, + messages, session: session, options: new ChatClientAgentRunOptions { ChatOptions = ctx.ChatOptions }, cancellationToken: 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/WorkflowAGUIExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/WorkflowAGUIExtensions.cs index f6bb8f03277..9183ca15293 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/WorkflowAGUIExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/WorkflowAGUIExtensions.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using AGUI.Abstractions; using Microsoft.Agents.AI.Workflows; @@ -16,11 +17,27 @@ internal static async IAsyncEnumerable MapWorkflowEventsToAG this IAsyncEnumerable updates) { ArgumentNullException.ThrowIfNull(updates); + List interruptions = []; await foreach (ChatResponseUpdate update in updates.ConfigureAwait(false)) { switch (update.RawRepresentation) { + 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; @@ -44,6 +61,11 @@ internal static async IAsyncEnumerable MapWorkflowEventsToAG break; } } + + foreach (ChatResponseUpdate interruption in interruptions) + { + yield return interruption; + } } #pragma warning restore VSTHRD200 @@ -64,4 +86,23 @@ private static ChatResponseUpdate CreateEventUpdate( Role = update.Role, ContinuationToken = update.ContinuationToken, }; + + internal static List MapAGUIInterruptResponsesToWorkflow( + 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/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs index 01f96379674..e159d5e5270 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs @@ -55,6 +55,12 @@ public WorkflowHostAgent(Workflow workflow, string? id = null, string? name = nu public override string? Name { get; } public override string? Description { get; } + /// + public override object? GetService(Type serviceType, object? serviceKey = null) + => serviceKey is null && serviceType == typeof(Workflow) + ? this._workflow + : base.GetService(serviceType, serviceKey); + private string GenerateNewId() { string result; 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 070951498d0..011f1f6c99c 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,10 @@ + + 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..fbfb7fa6ef4 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/ApprovalWorkflowTests.cs @@ -0,0 +1,80 @@ +// 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.Server; +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 ClientApprovesInterruptionAndWorkflowResumesAsync() + { + // Arrange + AIAgent workflowAgent = ApprovalWorkflow.Create().AsAIAgent( + name: "ApprovalWorkflow", + includeWorkflowOutputsInResponse: true); + await using WorkflowTestHost host = await WorkflowTestHost.StartAsync(workflowAgent, persistSession: true); + RunAgentInput initialInput = new() + { + Messages = new[] { new ChatMessage(ChatRole.User, "submit") }.AsAGUIMessages().ToList(), + RunId = "approval-run-1", + ThreadId = "approval-thread", + }; + + // Act - initial run pauses for approval. + List firstTurn = await SendAsync(host.Client, initialInput); + RunFinishedEvent finished = firstTurn.OfType().Single(); + RunFinishedInterruptOutcome outcome = finished.Outcome.Should() + .BeOfType().Subject; + AGUIInterrupt interrupt = outcome.Interrupts.Should().ContainSingle().Subject; + interrupt.Reason.Should().Be(InterruptReasons.InputRequired); + + RunAgentInput resumeInput = new() + { + Messages = [], + ParentRunId = finished.RunId, + Resume = + [ + new AGUIResume + { + InterruptId = interrupt.Id, + Payload = JsonSerializer.SerializeToElement(new { approved = true }), + Status = "resolved", + }, + ], + RunId = "approval-run-2", + ThreadId = finished.ThreadId, + }; + List secondTurn = await SendAsync(host.Client, resumeInput); + + // Assert + string text = string.Concat(secondTurn.OfType().Select(static evt => evt.Delta)); + text.Should().Contain( + "Expense approved and submitted.", + "events were {0}", + string.Join(", ", secondTurn.Select(static evt => evt.GetType().Name))); + secondTurn.OfType() + .Should().Contain(static evt => evt.StepName == "ExpenseApproval"); + } + + 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/WorkflowTestHost.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/WorkflowTestHost.cs index ecb9a603c84..362667dd1de 100644 --- 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 @@ -22,14 +22,28 @@ private WorkflowTestHost(WebApplication app, HttpClient client) public HttpClient Client { get; } - public static async Task StartAsync(AIAgent agent) + 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(); - app.MapAGUIServer("/agent", agent); + if (persistSession) + { + app.MapAGUIServer(agent.Name!, "/agent"); + } + else + { + app.MapAGUIServer("/agent", agent); + } await app.StartAsync(); TestServer server = app.Services.GetRequiredService() as TestServer From 1d01a0e3c03319ef691e2e4c675792bafc3a83ef Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Wed, 19 Aug 2026 14:01:13 +0200 Subject: [PATCH 10/17] Add multiple-input workflow AG-UI sample Demonstrate three same-turn interruptions, partial out-of-order responses, persisted workflow state, and final completion after the remaining input arrives. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 681114d3-3e0d-47f8-8b87-4e5873ecdd6d --- dotnet/agent-framework-dotnet.slnx | 4 + dotnet/samples/02-agents/AGUI/README.md | 11 ++ .../Client/Client.csproj | 16 +++ .../Client/Program.cs | 64 +++++++++++ .../Step12_WorkflowMultipleInputs/README.md | 15 +++ .../Server/MultipleInputsWorkflow.cs | 102 ++++++++++++++++++ .../Server/Program.cs | 18 ++++ .../Server/Server.csproj | 19 ++++ ...ng.AGUI.AspNetCore.IntegrationTests.csproj | 2 + .../Workflows/MultipleInputsWorkflowTests.cs | 96 +++++++++++++++++ 10 files changed, 347 insertions(+) create mode 100644 dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Client/Client.csproj create mode 100644 dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Client/Program.cs create mode 100644 dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/README.md create mode 100644 dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Server/MultipleInputsWorkflow.cs create mode 100644 dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Server/Program.cs create mode 100644 dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Server/Server.csproj create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/MultipleInputsWorkflowTests.cs diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 36f526f8632..7a3b5016385 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -147,6 +147,10 @@ + + + + diff --git a/dotnet/samples/02-agents/AGUI/README.md b/dotnet/samples/02-agents/AGUI/README.md index 91fa06a7238..08175f600ec 100644 --- a/dotnet/samples/02-agents/AGUI/README.md +++ b/dotnet/samples/02-agents/AGUI/README.md @@ -250,6 +250,17 @@ 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/Step12_WorkflowMultipleInputs/Client/Client.csproj b/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Client/Client.csproj new file mode 100644 index 00000000000..a31dc3c685f --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Client/Client.csproj @@ -0,0 +1,16 @@ + + + + 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..427abbbf572 --- /dev/null +++ b/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Client/Program.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Net.Http.Json; +using System.Text.Json; +using AGUI.Abstractions; +using AGUI.Client; +using AGUI.Server; +using Microsoft.Extensions.AI; + +string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; +using HttpClient httpClient = new() { BaseAddress = new Uri(serverUrl), Timeout = TimeSpan.FromSeconds(60) }; + +RunAgentInput initialInput = new() +{ + Messages = new[] { new ChatMessage(ChatRole.User, "Plan my conference trip.") }.AsAGUIMessages().ToList(), + RunId = Guid.NewGuid().ToString("N"), + ThreadId = Guid.NewGuid().ToString("N"), +}; +List firstTurn = await SendAsync(initialInput); +RunFinishedEvent firstFinished = firstTurn.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")); + +RunFinishedEvent partialFinished = (await SendAsync(CreateResume( + firstFinished, + [ + Resume(travelers, new { kind = "travelers", count = 2, accessibility = "none" }), + Resume(dates, new { kind = "dates", departure = "2026-10-10", returnDate = "2026-10-14" }), + ]))).OfType().Single(); + +List finalTurn = await SendAsync(CreateResume( + partialFinished, + [Resume(preferences, new { kind = "preferences", budget = 2500, cabin = "economy", hotel = "downtown" })])); + +Console.WriteLine(string.Concat(finalTurn.OfType().Select(static evt => evt.Delta))); + +RunAgentInput CreateResume(RunFinishedEvent previous, IList resumes) + => new() + { + 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", + }; + +async Task> SendAsync(RunAgentInput input) +{ + using JsonContent content = JsonContent.Create(input, AGUIJsonSerializerContext.Default.RunAgentInput); + using HttpResponseMessage response = await httpClient.PostAsync(new Uri("", UriKind.Relative), content); + response.EnsureSuccessStatusCode(); + return await response.ReadAGUIEventStreamAsync().ToListAsync(); +} 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/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 011f1f6c99c..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 @@ -43,6 +43,8 @@ Link="Workflows\Samples\NestedWorkflow.cs" /> + 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..224731d448c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/MultipleInputsWorkflowTests.cs @@ -0,0 +1,96 @@ +// 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.Server; +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(); + } +} From bc7bdc331e546d17e0fdffe351ec9f8e37072663 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Wed, 19 Aug 2026 14:05:07 +0200 Subject: [PATCH 11/17] Make nested workflow collision deterministic Synchronize the duplicate Analyze executors so the integration test reliably characterizes overlapping nested step IDs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 681114d3-3e0d-47f8-8b87-4e5873ecdd6d --- .../Server/NestedWorkflow.cs | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Server/NestedWorkflow.cs b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Server/NestedWorkflow.cs index 88489fe253d..ec38137c992 100644 --- a/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Server/NestedWorkflow.cs +++ b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Server/NestedWorkflow.cs @@ -22,9 +22,10 @@ public static class NestedWorkflow /// A parent workflow containing security and style subworkflows. public static Workflow Create() { + AnalysisGate gate = new(); ChatForwardingExecutor start = new("Start"); - ExecutorBinding security = CreateAnalysisWorkflow("Security").BindAsExecutor("SecurityPipeline"); - ExecutorBinding style = CreateAnalysisWorkflow("Style").BindAsExecutor("StylePipeline"); + ExecutorBinding security = CreateAnalysisWorkflow("Security", gate).BindAsExecutor("SecurityPipeline"); + ExecutorBinding style = CreateAnalysisWorkflow("Style", gate).BindAsExecutor("StylePipeline"); return new WorkflowBuilder(start) .AddFanOutEdge(start, [security, style]) @@ -32,11 +33,11 @@ public static Workflow Create() .Build(); } - private static Workflow CreateAnalysisWorkflow(string analysisType) - => new SequentialWorkflowBuilder(new AnalysisAgent(analysisType)).Build(); + private static Workflow CreateAnalysisWorkflow(string analysisType, AnalysisGate gate) + => new SequentialWorkflowBuilder(new AnalysisAgent(analysisType, gate)).Build(); } -internal sealed class AnalysisAgent(string analysisType) : AIAgent +internal sealed class AnalysisAgent(string analysisType, AnalysisGate gate) : AIAgent { protected override string? IdCore => "Analyze"; @@ -56,7 +57,7 @@ protected override async IAsyncEnumerable RunCoreStreamingA AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - await Task.Yield(); + await gate.SignalAndWaitAsync(cancellationToken).ConfigureAwait(false); yield return new AgentResponseUpdate(ChatRole.Assistant, $"{analysisType} analysis complete.") { MessageId = $"{analysisType}-message", @@ -81,3 +82,19 @@ protected override ValueTask DeserializeSessionCoreAsync( 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); + } +} From 670f1327708d7ec51c51fa3f77662282221506c5 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Wed, 19 Aug 2026 17:12:28 +0200 Subject: [PATCH 12/17] Terminate failed workflow runs with RUN_ERROR Close active AG-UI message, reasoning, tool, and step streams before emitting terminal RUN_ERROR, and suppress the SDK's synthetic success outcome. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 681114d3-3e0d-47f8-8b87-4e5873ecdd6d --- dotnet/samples/02-agents/AGUI/README.md | 3 +- .../AGUI/Step08_WorkflowFailure/README.md | 4 +- .../Server/FailingWorkflow.cs | 15 ++- .../AGUIEndpointRouteBuilderExtensions.cs | 3 +- .../WorkflowAGUIExtensions.cs | 127 +++++++++++++++++- .../Workflows/FailingWorkflowTests.cs | 52 +++++-- .../WorkflowAGUIExtensionsTests.cs | 52 ++++++- 7 files changed, 231 insertions(+), 25 deletions(-) diff --git a/dotnet/samples/02-agents/AGUI/README.md b/dotnet/samples/02-agents/AGUI/README.md index 08175f600ec..14e290a649b 100644 --- a/dotnet/samples/02-agents/AGUI/README.md +++ b/dotnet/samples/02-agents/AGUI/README.md @@ -210,7 +210,8 @@ dotnet run --project Client ### Step08_WorkflowFailure -A deterministic failing workflow. The client shows that the failed executor step is closed. +A deterministic failing workflow. The client shows the failed executor step closing before terminal +`RUN_ERROR`. ```bash cd Step08_WorkflowFailure diff --git a/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/README.md b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/README.md index 2e1fab0d690..4d80203d4c7 100644 --- a/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/README.md +++ b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/README.md @@ -1,7 +1,7 @@ # Failing Workflow over AG-UI -This sample demonstrates an executor failure. The AG-UI stream still emits `STEP_FINISHED` for the failed -executor so clients do not leave the step active. +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 diff --git a/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Server/FailingWorkflow.cs b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Server/FailingWorkflow.cs index 1089c38b87f..5ff7252e1cf 100644 --- a/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Server/FailingWorkflow.cs +++ b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Server/FailingWorkflow.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Runtime.CompilerServices; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -40,12 +41,20 @@ protected override Task RunCoreAsync( CancellationToken cancellationToken = default) => throw new InvalidOperationException("The sample executor failed."); - protected override IAsyncEnumerable RunCoreStreamingAsync( + protected override async IAsyncEnumerable RunCoreStreamingAsync( IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, - CancellationToken cancellationToken = default) - => throw new InvalidOperationException("The sample executor failed."); + [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()); 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 8f9624a1635..7701461a74f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs @@ -159,7 +159,8 @@ public static IEndpointConventionBuilder MapAGUIServer( cancellationToken: cancellationToken) .AsChatResponseUpdatesAsync() .MapWorkflowEventsToAGUI() - .AsAGUIEventStreamAsync(ctx, cancellationToken); + .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/WorkflowAGUIExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/WorkflowAGUIExtensions.cs index 9183ca15293..f978d59b542 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/WorkflowAGUIExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/WorkflowAGUIExtensions.cs @@ -53,7 +53,14 @@ when update.Contents.OfType().SingleOrDefault() is { } requ update, new StepFinishedEvent { StepName = failed.ExecutorId }, includeContents: false); - yield return update; + yield return CreateEventUpdate( + update, + new RunErrorEvent + { + Message = update.Contents.OfType().SingleOrDefault()?.Message + ?? "An error occurred while executing the workflow.", + }, + includeContents: false); break; default: @@ -69,6 +76,124 @@ when update.Contents.OfType().SingleOrDefault() is { } requ } #pragma warning restore VSTHRD200 + 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, 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 index 75ba555f6e0..8900f4274cf 100644 --- 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 @@ -3,6 +3,8 @@ 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; @@ -22,31 +24,53 @@ public async Task ClientReceivesStepFinishedForFailedExecutorAsync() Workflow workflow = FailingWorkflow.Create(new FailingAgent()); await using WorkflowTestHost host = await WorkflowTestHost.StartAsync( workflow.AsAIAgent(name: "FailingWorkflow")); - AGUIChatClient chatClient = new(new(host.Client, "")); - AIAgent clientAgent = chatClient.AsAIAgent(name: "client"); - AgentSession session = await clientAgent.CreateSessionAsync(); + RunAgentInput input = new() + { + Messages = new[] { new ChatMessage(ChatRole.User, "start") }.AsAGUIMessages().ToList(), + RunId = "failure-run", + ThreadId = "failure-thread", + }; // Act - List updates = await clientAgent - .RunStreamingAsync(new ChatMessage(ChatRole.User, "start"), session) - .ToListAsync(); + List events = await SendAsync(host.Client, input); // Assert - int stepStarted = updates.FindIndex(static update => - update.AsChatResponseUpdate().RawRepresentation is StepStartedEvent evt - && evt.StepName.StartsWith("FailingStep_", StringComparison.Ordinal)); - int stepFinished = updates.FindIndex(static update => - update.AsChatResponseUpdate().RawRepresentation is StepFinishedEvent evt - && evt.StepName.StartsWith("FailingStep_", StringComparison.Ordinal)); + 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); - int startedCount = updates.Select(static update => update.AsChatResponseUpdate().RawRepresentation) + 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 = updates.Select(static update => update.AsChatResponseUpdate().RawRepresentation) + 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.UnitTests/WorkflowAGUIExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/WorkflowAGUIExtensionsTests.cs index beea099ba88..be2a1043deb 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/WorkflowAGUIExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/WorkflowAGUIExtensionsTests.cs @@ -51,7 +51,7 @@ public async Task MapWorkflowEventsToAGUI_MapsExecutorCompletedToStepFinishedAsy } [Fact] - public async Task MapWorkflowEventsToAGUI_MapsExecutorFailedAndPreservesErrorAsync() + public async Task MapWorkflowEventsToAGUI_MapsExecutorFailedToStepFinishedAndRunErrorAsync() { // Arrange ErrorContent error = new("An error occurred while executing the workflow."); @@ -70,8 +70,44 @@ public async Task MapWorkflowEventsToAGUI_MapsExecutorFailedAndPreservesErrorAsy results[0].RawRepresentation.Should().BeOfType() .Which.StepName.Should().Be("reviewer"); results[0].Contents.Should().BeEmpty(); - results[1].RawRepresentation.Should().BeSameAs(update); - results[1].Contents.Should().ContainSingle().Which.Should().BeSameAs(error); + 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] @@ -119,4 +155,14 @@ private static async IAsyncEnumerable ToAsyncEnumerableAsync await Task.Yield(); yield return update; } + + private static async IAsyncEnumerable ToAsyncEnumerableAsync( + IEnumerable events) + { + await Task.Yield(); + foreach (BaseEvent evt in events) + { + yield return evt; + } + } } From e7cb2e2392f2845a68c04a2a688215ce8e1b8ce5 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Wed, 19 Aug 2026 17:13:10 +0200 Subject: [PATCH 13/17] Remove unused workflow test imports Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 681114d3-3e0d-47f8-8b87-4e5873ecdd6d --- .../Workflows/ApprovalWorkflowTests.cs | 1 - .../Workflows/MultipleInputsWorkflowTests.cs | 1 - .../Workflows/NestedWorkflowTests.cs | 2 -- 3 files changed, 4 deletions(-) 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 index fbfb7fa6ef4..6a58074607d 100644 --- 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 @@ -9,7 +9,6 @@ using System.Threading.Tasks; using AGUI.Abstractions; using AGUI.Client; -using AGUI.Server; using AGUI.WorkflowApproval; using FluentAssertions; using Microsoft.Agents.AI.Workflows; 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 index 224731d448c..c9250986fbe 100644 --- 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 @@ -9,7 +9,6 @@ using System.Threading.Tasks; using AGUI.Abstractions; using AGUI.Client; -using AGUI.Server; using AGUI.WorkflowMultipleInputs; using FluentAssertions; using Microsoft.Agents.AI.Workflows; 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 index ff5919b480a..44b82ceaf49 100644 --- 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 @@ -1,10 +1,8 @@ // 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.WorkflowNested; using FluentAssertions; From 3f824b135701d7dde6965bfb6608dd575fa6022c Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Fri, 21 Aug 2026 13:22:38 +0200 Subject: [PATCH 14/17] Clarify workflow and remote agent sample roles Show each server constructing a Workflow before adapting it with AsAIAgent, use Producer and Reviewer in the sequential scenario, and distinguish remote client agents and sessions by name. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 681114d3-3e0d-47f8-8b87-4e5873ecdd6d --- .../Step06_WorkflowSequential/Client/Program.cs | 8 ++++---- .../AGUI/Step06_WorkflowSequential/README.md | 4 ++-- .../Step06_WorkflowSequential/Server/Program.cs | 7 ++++--- .../Server/SequentialWorkflow.cs | 6 +++--- .../Step07_WorkflowConcurrent/Client/Program.cs | 8 ++++---- .../Step07_WorkflowConcurrent/Server/Program.cs | 3 ++- .../Step08_WorkflowFailure/Client/Program.cs | 8 ++++---- .../Step08_WorkflowFailure/Server/Program.cs | 3 ++- .../AGUI/Step09_WorkflowTools/Client/Program.cs | 8 ++++---- .../AGUI/Step09_WorkflowTools/Server/Program.cs | 3 ++- .../AGUI/Step10_WorkflowNested/Client/Program.cs | 8 ++++---- .../AGUI/Step10_WorkflowNested/Server/Program.cs | 3 ++- .../Step11_WorkflowApproval/Server/Program.cs | 10 +++++++--- .../Server/Program.cs | 6 +++++- .../Workflows/SequentialWorkflowTests.cs | 16 ++++++++-------- 15 files changed, 57 insertions(+), 44 deletions(-) diff --git a/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Client/Program.cs index 66488096fae..c575b1a6dd0 100644 --- a/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Client/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Client/Program.cs @@ -8,15 +8,15 @@ string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; AGUIChatClient chatClient = new(new(httpClient, serverUrl)); -AIAgent agent = chatClient.AsAIAgent(name: "workflow-client"); -AgentSession session = await agent.CreateSessionAsync(); +AIAgent remoteAgent = chatClient.AsAIAgent(name: "workflow-client"); +AgentSession remoteSession = await remoteAgent.CreateSessionAsync(); Console.Write("Request: "); string request = Console.ReadLine() ?? "Write a short welcome message for a developer conference."; -await foreach (AgentResponseUpdate update in agent.RunStreamingAsync( +await foreach (AgentResponseUpdate update in remoteAgent.RunStreamingAsync( new ChatMessage(ChatRole.User, request), - session)) + remoteSession)) { switch (update.AsChatResponseUpdate().RawRepresentation) { diff --git a/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/README.md b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/README.md index a37ea78aa8f..066f3e6dafe 100644 --- a/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/README.md +++ b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/README.md @@ -1,6 +1,6 @@ # Sequential Workflow over AG-UI -This sample hosts a two-agent sequential workflow over AG-UI. The `Writer` drafts a response and the +This sample hosts a two-agent sequential workflow over AG-UI. The `Producer` drafts a response and the `Reviewer` produces the final answer. The client prints AG-UI step lifecycle events alongside streamed text. ## Run @@ -12,4 +12,4 @@ 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`. +Expected step events include `STEP_STARTED` and `STEP_FINISHED` for `Producer` 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 index 76979c8120f..8c573b7d348 100644 --- a/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Server/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Server/Program.cs @@ -22,14 +22,15 @@ new DefaultAzureCredential()) .GetChatClient(deploymentName); -AIAgent writer = chatClient.AsAIAgent( - name: "Writer", +AIAgent producer = chatClient.AsAIAgent( + name: "Producer", 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"); +Workflow workflow = SequentialWorkflow.Create(producer, reviewer); +AIAgent workflowAgent = workflow.AsAIAgent(name: "SequentialWorkflow"); WebApplication app = builder.Build(); app.MapAGUIServer("/", workflowAgent); diff --git a/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Server/SequentialWorkflow.cs b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Server/SequentialWorkflow.cs index ba461b3ea68..38761eaf57b 100644 --- a/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Server/SequentialWorkflow.cs +++ b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Server/SequentialWorkflow.cs @@ -13,9 +13,9 @@ public static class SequentialWorkflow /// /// Creates a workflow that asks one agent to draft content and another to review it. /// - /// The writer agent. + /// The producer agent. /// The reviewer agent. /// The sequential workflow. - public static Workflow Create(AIAgent writer, AIAgent reviewer) - => new SequentialWorkflowBuilder(writer, reviewer).Build(); + public static Workflow Create(AIAgent producer, AIAgent reviewer) + => new SequentialWorkflowBuilder(producer, reviewer).Build(); } diff --git a/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Client/Program.cs index 0253be79cfc..82de375f7e1 100644 --- a/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Client/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Client/Program.cs @@ -8,15 +8,15 @@ string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; AGUIChatClient chatClient = new(new(httpClient, serverUrl)); -AIAgent agent = chatClient.AsAIAgent(name: "workflow-client"); -AgentSession session = await agent.CreateSessionAsync(); +AIAgent remoteAgent = chatClient.AsAIAgent(name: "workflow-client"); +AgentSession remoteSession = await remoteAgent.CreateSessionAsync(); Console.Write("Request: "); string request = Console.ReadLine() ?? "Assess the tradeoffs of adopting a new framework."; -await foreach (AgentResponseUpdate update in agent.RunStreamingAsync( +await foreach (AgentResponseUpdate update in remoteAgent.RunStreamingAsync( new ChatMessage(ChatRole.User, request), - session)) + remoteSession)) { switch (update.AsChatResponseUpdate().RawRepresentation) { diff --git a/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Server/Program.cs index 33063a37679..edeb49accdc 100644 --- a/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Server/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Server/Program.cs @@ -29,7 +29,8 @@ name: "Critic", instructions: "Identify risks and missing considerations in the user's request."); -AIAgent workflowAgent = ConcurrentWorkflow.Create(researcher, critic).AsAIAgent(name: "ConcurrentWorkflow"); +Workflow workflow = ConcurrentWorkflow.Create(researcher, critic); +AIAgent workflowAgent = workflow.AsAIAgent(name: "ConcurrentWorkflow"); WebApplication app = builder.Build(); app.MapAGUIServer("/", workflowAgent); diff --git a/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Client/Program.cs index 571e9ac0d2b..09b4902209f 100644 --- a/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Client/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Client/Program.cs @@ -8,12 +8,12 @@ string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; AGUIChatClient chatClient = new(new(httpClient, serverUrl)); -AIAgent agent = chatClient.AsAIAgent(name: "workflow-client"); -AgentSession session = await agent.CreateSessionAsync(); +AIAgent remoteAgent = chatClient.AsAIAgent(name: "workflow-client"); +AgentSession remoteSession = await remoteAgent.CreateSessionAsync(); -await foreach (AgentResponseUpdate update in agent.RunStreamingAsync( +await foreach (AgentResponseUpdate update in remoteAgent.RunStreamingAsync( new ChatMessage(ChatRole.User, "Run the failing workflow."), - session)) + remoteSession)) { switch (update.AsChatResponseUpdate().RawRepresentation) { diff --git a/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Server/Program.cs index 879716a34f1..872169bc6b9 100644 --- a/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Server/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Server/Program.cs @@ -9,7 +9,8 @@ builder.Services.AddHttpClient().AddLogging(); builder.Services.AddAGUIServer(); -AIAgent workflowAgent = FailingWorkflow.Create(new FailingAgent()).AsAIAgent(name: "FailingWorkflow"); +Workflow workflow = FailingWorkflow.Create(new FailingAgent()); +AIAgent workflowAgent = workflow.AsAIAgent(name: "FailingWorkflow"); WebApplication app = builder.Build(); app.MapAGUIServer("/", workflowAgent); diff --git a/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Client/Program.cs index 3312e38ceb0..58adf490612 100644 --- a/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Client/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Client/Program.cs @@ -8,12 +8,12 @@ string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; AGUIChatClient chatClient = new(new(httpClient, serverUrl)); -AIAgent agent = chatClient.AsAIAgent(name: "workflow-client"); -AgentSession session = await agent.CreateSessionAsync(); +AIAgent remoteAgent = chatClient.AsAIAgent(name: "workflow-client"); +AgentSession remoteSession = await remoteAgent.CreateSessionAsync(); -await foreach (AgentResponseUpdate update in agent.RunStreamingAsync( +await foreach (AgentResponseUpdate update in remoteAgent.RunStreamingAsync( new ChatMessage(ChatRole.User, "What is the weather in Seattle?"), - session)) + remoteSession)) { switch (update.AsChatResponseUpdate().RawRepresentation) { diff --git a/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Server/Program.cs index c029c894762..4fd27de615a 100644 --- a/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Server/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Server/Program.cs @@ -32,7 +32,8 @@ static string GetWeather([Description("The city to inspect.")] string city) 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"); +Workflow workflow = ToolWorkflow.Create(weatherAgent); +AIAgent workflowAgent = workflow.AsAIAgent(name: "ToolWorkflow"); WebApplication app = builder.Build(); app.MapAGUIServer("/", workflowAgent); diff --git a/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Client/Program.cs index a18b1da5ae8..24acbc17b21 100644 --- a/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Client/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Client/Program.cs @@ -8,14 +8,14 @@ string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; AGUIChatClient chatClient = new(new(httpClient, serverUrl)); -AIAgent agent = chatClient.AsAIAgent(name: "workflow-client"); -AgentSession session = await agent.CreateSessionAsync(); +AIAgent remoteAgent = chatClient.AsAIAgent(name: "workflow-client"); +AgentSession remoteSession = await remoteAgent.CreateSessionAsync(); try { - await foreach (AgentResponseUpdate update in agent.RunStreamingAsync( + await foreach (AgentResponseUpdate update in remoteAgent.RunStreamingAsync( new ChatMessage(ChatRole.User, "Review this proposal."), - session)) + remoteSession)) { switch (update.AsChatResponseUpdate().RawRepresentation) { diff --git a/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Server/Program.cs index 74d39b42316..9d6538849f1 100644 --- a/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Server/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Server/Program.cs @@ -9,7 +9,8 @@ builder.Services.AddHttpClient().AddLogging(); builder.Services.AddAGUIServer(); -AIAgent workflowAgent = NestedWorkflow.Create().AsAIAgent( +Workflow workflow = NestedWorkflow.Create(); +AIAgent workflowAgent = workflow.AsAIAgent( name: "NestedWorkflow", includeWorkflowOutputsInResponse: true); diff --git a/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/Program.cs index 6b4c91654be..f6ea576018e 100644 --- a/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/Program.cs @@ -11,9 +11,13 @@ builder.Services.AddAGUIServer(); builder.Services.AddAIAgent( "ApprovalWorkflow", - static (_, _) => ApprovalWorkflow.Create().AsAIAgent( - name: "ApprovalWorkflow", - includeWorkflowOutputsInResponse: true)) + static (_, _) => + { + Workflow workflow = ApprovalWorkflow.Create(); + return workflow.AsAIAgent( + name: "ApprovalWorkflow", + includeWorkflowOutputsInResponse: true); + }) .WithInMemorySessionStore(withIsolation: false); WebApplication app = builder.Build(); diff --git a/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Server/Program.cs index 6a7998ae368..4a75a10c041 100644 --- a/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Server/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Server/Program.cs @@ -10,7 +10,11 @@ builder.Services.AddAGUIServer(); builder.Services.AddAIAgent( "MultipleInputsWorkflow", - static (_, _) => MultipleInputsWorkflow.Create().AsAIAgent(name: "MultipleInputsWorkflow")) + static (_, _) => + { + Workflow workflow = MultipleInputsWorkflow.Create(); + return workflow.AsAIAgent(name: "MultipleInputsWorkflow"); + }) .WithInMemorySessionStore(withIsolation: false); WebApplication app = builder.Build(); 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 index 74fe2b94e04..955b9d9fdc2 100644 --- 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 @@ -19,9 +19,9 @@ public sealed class SequentialWorkflowTests public async Task ClientReceivesOrderedExecutorStepsAndTextAsync() { // Arrange - AIAgent writer = new DeterministicAgent("Writer", "draft"); + AIAgent producer = new DeterministicAgent("Producer", "draft"); AIAgent reviewer = new DeterministicAgent("Reviewer", "final"); - Workflow workflow = SequentialWorkflow.Create(writer, reviewer); + Workflow workflow = SequentialWorkflow.Create(producer, reviewer); await using WorkflowTestHost host = await WorkflowTestHost.StartAsync( workflow.AsAIAgent(name: "SequentialWorkflow")); AGUIChatClient chatClient = new(new(host.Client, "")); @@ -43,15 +43,15 @@ public async Task ClientReceivesOrderedExecutorStepsAndTextAsync() .OfType() .Select(static evt => evt.StepName)]; - int writerStart = Array.FindIndex(started, static name => name.StartsWith("Writer_", StringComparison.Ordinal)); + int producerStart = Array.FindIndex(started, static name => name.StartsWith("Producer_", 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 producerFinish = Array.FindIndex(finished, static name => name.StartsWith("Producer_", 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); + producerStart.Should().BeGreaterThanOrEqualTo(0); + reviewerStart.Should().BeGreaterThan(producerStart); + producerFinish.Should().BeGreaterThanOrEqualTo(0); + reviewerFinish.Should().BeGreaterThan(producerFinish); updates.Count(static update => update.Text == "draft").Should().Be(1); updates.Count(static update => update.Text == "final").Should().Be(1); } From 3e5f838ab2fd028de6a8f39cf728f3f1144f83c2 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Fri, 21 Aug 2026 15:07:20 +0200 Subject: [PATCH 15/17] Revert "Clarify workflow and remote agent sample roles" This reverts commit 3f824b135701d7dde6965bfb6608dd575fa6022c. --- .../Step06_WorkflowSequential/Client/Program.cs | 8 ++++---- .../AGUI/Step06_WorkflowSequential/README.md | 4 ++-- .../Step06_WorkflowSequential/Server/Program.cs | 7 +++---- .../Server/SequentialWorkflow.cs | 6 +++--- .../Step07_WorkflowConcurrent/Client/Program.cs | 8 ++++---- .../Step07_WorkflowConcurrent/Server/Program.cs | 3 +-- .../Step08_WorkflowFailure/Client/Program.cs | 8 ++++---- .../Step08_WorkflowFailure/Server/Program.cs | 3 +-- .../AGUI/Step09_WorkflowTools/Client/Program.cs | 8 ++++---- .../AGUI/Step09_WorkflowTools/Server/Program.cs | 3 +-- .../AGUI/Step10_WorkflowNested/Client/Program.cs | 8 ++++---- .../AGUI/Step10_WorkflowNested/Server/Program.cs | 3 +-- .../Step11_WorkflowApproval/Server/Program.cs | 10 +++------- .../Server/Program.cs | 6 +----- .../Workflows/SequentialWorkflowTests.cs | 16 ++++++++-------- 15 files changed, 44 insertions(+), 57 deletions(-) diff --git a/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Client/Program.cs index c575b1a6dd0..66488096fae 100644 --- a/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Client/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Client/Program.cs @@ -8,15 +8,15 @@ string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; AGUIChatClient chatClient = new(new(httpClient, serverUrl)); -AIAgent remoteAgent = chatClient.AsAIAgent(name: "workflow-client"); -AgentSession remoteSession = await remoteAgent.CreateSessionAsync(); +AIAgent agent = chatClient.AsAIAgent(name: "workflow-client"); +AgentSession session = await agent.CreateSessionAsync(); Console.Write("Request: "); string request = Console.ReadLine() ?? "Write a short welcome message for a developer conference."; -await foreach (AgentResponseUpdate update in remoteAgent.RunStreamingAsync( +await foreach (AgentResponseUpdate update in agent.RunStreamingAsync( new ChatMessage(ChatRole.User, request), - remoteSession)) + session)) { switch (update.AsChatResponseUpdate().RawRepresentation) { diff --git a/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/README.md b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/README.md index 066f3e6dafe..a37ea78aa8f 100644 --- a/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/README.md +++ b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/README.md @@ -1,6 +1,6 @@ # Sequential Workflow over AG-UI -This sample hosts a two-agent sequential workflow over AG-UI. The `Producer` drafts a response and the +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 @@ -12,4 +12,4 @@ dotnet run --project Server --urls http://localhost:8888 dotnet run --project Client ``` -Expected step events include `STEP_STARTED` and `STEP_FINISHED` for `Producer` followed by `Reviewer`. +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 index 8c573b7d348..76979c8120f 100644 --- a/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Server/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Server/Program.cs @@ -22,15 +22,14 @@ new DefaultAzureCredential()) .GetChatClient(deploymentName); -AIAgent producer = chatClient.AsAIAgent( - name: "Producer", +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."); -Workflow workflow = SequentialWorkflow.Create(producer, reviewer); -AIAgent workflowAgent = workflow.AsAIAgent(name: "SequentialWorkflow"); +AIAgent workflowAgent = SequentialWorkflow.Create(writer, reviewer).AsAIAgent(name: "SequentialWorkflow"); WebApplication app = builder.Build(); app.MapAGUIServer("/", workflowAgent); diff --git a/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Server/SequentialWorkflow.cs b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Server/SequentialWorkflow.cs index 38761eaf57b..ba461b3ea68 100644 --- a/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Server/SequentialWorkflow.cs +++ b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Server/SequentialWorkflow.cs @@ -13,9 +13,9 @@ public static class SequentialWorkflow /// /// Creates a workflow that asks one agent to draft content and another to review it. /// - /// The producer agent. + /// The writer agent. /// The reviewer agent. /// The sequential workflow. - public static Workflow Create(AIAgent producer, AIAgent reviewer) - => new SequentialWorkflowBuilder(producer, reviewer).Build(); + public static Workflow Create(AIAgent writer, AIAgent reviewer) + => new SequentialWorkflowBuilder(writer, reviewer).Build(); } diff --git a/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Client/Program.cs index 82de375f7e1..0253be79cfc 100644 --- a/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Client/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Client/Program.cs @@ -8,15 +8,15 @@ string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; AGUIChatClient chatClient = new(new(httpClient, serverUrl)); -AIAgent remoteAgent = chatClient.AsAIAgent(name: "workflow-client"); -AgentSession remoteSession = await remoteAgent.CreateSessionAsync(); +AIAgent agent = chatClient.AsAIAgent(name: "workflow-client"); +AgentSession session = await agent.CreateSessionAsync(); Console.Write("Request: "); string request = Console.ReadLine() ?? "Assess the tradeoffs of adopting a new framework."; -await foreach (AgentResponseUpdate update in remoteAgent.RunStreamingAsync( +await foreach (AgentResponseUpdate update in agent.RunStreamingAsync( new ChatMessage(ChatRole.User, request), - remoteSession)) + session)) { switch (update.AsChatResponseUpdate().RawRepresentation) { diff --git a/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Server/Program.cs index edeb49accdc..33063a37679 100644 --- a/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Server/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Server/Program.cs @@ -29,8 +29,7 @@ name: "Critic", instructions: "Identify risks and missing considerations in the user's request."); -Workflow workflow = ConcurrentWorkflow.Create(researcher, critic); -AIAgent workflowAgent = workflow.AsAIAgent(name: "ConcurrentWorkflow"); +AIAgent workflowAgent = ConcurrentWorkflow.Create(researcher, critic).AsAIAgent(name: "ConcurrentWorkflow"); WebApplication app = builder.Build(); app.MapAGUIServer("/", workflowAgent); diff --git a/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Client/Program.cs index 09b4902209f..571e9ac0d2b 100644 --- a/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Client/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Client/Program.cs @@ -8,12 +8,12 @@ string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; AGUIChatClient chatClient = new(new(httpClient, serverUrl)); -AIAgent remoteAgent = chatClient.AsAIAgent(name: "workflow-client"); -AgentSession remoteSession = await remoteAgent.CreateSessionAsync(); +AIAgent agent = chatClient.AsAIAgent(name: "workflow-client"); +AgentSession session = await agent.CreateSessionAsync(); -await foreach (AgentResponseUpdate update in remoteAgent.RunStreamingAsync( +await foreach (AgentResponseUpdate update in agent.RunStreamingAsync( new ChatMessage(ChatRole.User, "Run the failing workflow."), - remoteSession)) + session)) { switch (update.AsChatResponseUpdate().RawRepresentation) { diff --git a/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Server/Program.cs index 872169bc6b9..879716a34f1 100644 --- a/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Server/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Server/Program.cs @@ -9,8 +9,7 @@ builder.Services.AddHttpClient().AddLogging(); builder.Services.AddAGUIServer(); -Workflow workflow = FailingWorkflow.Create(new FailingAgent()); -AIAgent workflowAgent = workflow.AsAIAgent(name: "FailingWorkflow"); +AIAgent workflowAgent = FailingWorkflow.Create(new FailingAgent()).AsAIAgent(name: "FailingWorkflow"); WebApplication app = builder.Build(); app.MapAGUIServer("/", workflowAgent); diff --git a/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Client/Program.cs index 58adf490612..3312e38ceb0 100644 --- a/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Client/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Client/Program.cs @@ -8,12 +8,12 @@ string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; AGUIChatClient chatClient = new(new(httpClient, serverUrl)); -AIAgent remoteAgent = chatClient.AsAIAgent(name: "workflow-client"); -AgentSession remoteSession = await remoteAgent.CreateSessionAsync(); +AIAgent agent = chatClient.AsAIAgent(name: "workflow-client"); +AgentSession session = await agent.CreateSessionAsync(); -await foreach (AgentResponseUpdate update in remoteAgent.RunStreamingAsync( +await foreach (AgentResponseUpdate update in agent.RunStreamingAsync( new ChatMessage(ChatRole.User, "What is the weather in Seattle?"), - remoteSession)) + session)) { switch (update.AsChatResponseUpdate().RawRepresentation) { diff --git a/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Server/Program.cs index 4fd27de615a..c029c894762 100644 --- a/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Server/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Server/Program.cs @@ -32,8 +32,7 @@ static string GetWeather([Description("The city to inspect.")] string city) name: "WeatherAgent", instructions: "Use the weather tool and answer with the returned forecast.", tools: [AIFunctionFactory.Create(GetWeather)]); -Workflow workflow = ToolWorkflow.Create(weatherAgent); -AIAgent workflowAgent = workflow.AsAIAgent(name: "ToolWorkflow"); +AIAgent workflowAgent = ToolWorkflow.Create(weatherAgent).AsAIAgent(name: "ToolWorkflow"); WebApplication app = builder.Build(); app.MapAGUIServer("/", workflowAgent); diff --git a/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Client/Program.cs index 24acbc17b21..a18b1da5ae8 100644 --- a/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Client/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Client/Program.cs @@ -8,14 +8,14 @@ string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; AGUIChatClient chatClient = new(new(httpClient, serverUrl)); -AIAgent remoteAgent = chatClient.AsAIAgent(name: "workflow-client"); -AgentSession remoteSession = await remoteAgent.CreateSessionAsync(); +AIAgent agent = chatClient.AsAIAgent(name: "workflow-client"); +AgentSession session = await agent.CreateSessionAsync(); try { - await foreach (AgentResponseUpdate update in remoteAgent.RunStreamingAsync( + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync( new ChatMessage(ChatRole.User, "Review this proposal."), - remoteSession)) + session)) { switch (update.AsChatResponseUpdate().RawRepresentation) { diff --git a/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Server/Program.cs index 9d6538849f1..74d39b42316 100644 --- a/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Server/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Server/Program.cs @@ -9,8 +9,7 @@ builder.Services.AddHttpClient().AddLogging(); builder.Services.AddAGUIServer(); -Workflow workflow = NestedWorkflow.Create(); -AIAgent workflowAgent = workflow.AsAIAgent( +AIAgent workflowAgent = NestedWorkflow.Create().AsAIAgent( name: "NestedWorkflow", includeWorkflowOutputsInResponse: true); diff --git a/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/Program.cs index f6ea576018e..6b4c91654be 100644 --- a/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/Program.cs @@ -11,13 +11,9 @@ builder.Services.AddAGUIServer(); builder.Services.AddAIAgent( "ApprovalWorkflow", - static (_, _) => - { - Workflow workflow = ApprovalWorkflow.Create(); - return workflow.AsAIAgent( - name: "ApprovalWorkflow", - includeWorkflowOutputsInResponse: true); - }) + static (_, _) => ApprovalWorkflow.Create().AsAIAgent( + name: "ApprovalWorkflow", + includeWorkflowOutputsInResponse: true)) .WithInMemorySessionStore(withIsolation: false); WebApplication app = builder.Build(); diff --git a/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Server/Program.cs index 4a75a10c041..6a7998ae368 100644 --- a/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Server/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Server/Program.cs @@ -10,11 +10,7 @@ builder.Services.AddAGUIServer(); builder.Services.AddAIAgent( "MultipleInputsWorkflow", - static (_, _) => - { - Workflow workflow = MultipleInputsWorkflow.Create(); - return workflow.AsAIAgent(name: "MultipleInputsWorkflow"); - }) + static (_, _) => MultipleInputsWorkflow.Create().AsAIAgent(name: "MultipleInputsWorkflow")) .WithInMemorySessionStore(withIsolation: false); WebApplication app = builder.Build(); 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 index 955b9d9fdc2..74fe2b94e04 100644 --- 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 @@ -19,9 +19,9 @@ public sealed class SequentialWorkflowTests public async Task ClientReceivesOrderedExecutorStepsAndTextAsync() { // Arrange - AIAgent producer = new DeterministicAgent("Producer", "draft"); + AIAgent writer = new DeterministicAgent("Writer", "draft"); AIAgent reviewer = new DeterministicAgent("Reviewer", "final"); - Workflow workflow = SequentialWorkflow.Create(producer, reviewer); + Workflow workflow = SequentialWorkflow.Create(writer, reviewer); await using WorkflowTestHost host = await WorkflowTestHost.StartAsync( workflow.AsAIAgent(name: "SequentialWorkflow")); AGUIChatClient chatClient = new(new(host.Client, "")); @@ -43,15 +43,15 @@ public async Task ClientReceivesOrderedExecutorStepsAndTextAsync() .OfType() .Select(static evt => evt.StepName)]; - int producerStart = Array.FindIndex(started, static name => name.StartsWith("Producer_", StringComparison.Ordinal)); + 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 producerFinish = Array.FindIndex(finished, static name => name.StartsWith("Producer_", 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)); - producerStart.Should().BeGreaterThanOrEqualTo(0); - reviewerStart.Should().BeGreaterThan(producerStart); - producerFinish.Should().BeGreaterThanOrEqualTo(0); - reviewerFinish.Should().BeGreaterThan(producerFinish); + 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); } From 0f04079751271e007c0144ca3f4e9e439aef9d7e Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Fri, 21 Aug 2026 15:10:37 +0200 Subject: [PATCH 16/17] Use IChatClient in workflow sample clients Call AGUIChatClient through the IChatClient abstraction directly and remove the unnecessary AIAgent and AgentSession client adapters. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 681114d3-3e0d-47f8-8b87-4e5873ecdd6d --- .../Client/Client.csproj | 1 - .../Client/Program.cs | 12 ++-- .../Client/Client.csproj | 1 - .../Client/Program.cs | 12 ++-- .../Client/Client.csproj | 1 - .../Step08_WorkflowFailure/Client/Program.cs | 12 ++-- .../Step09_WorkflowTools/Client/Client.csproj | 1 - .../Step09_WorkflowTools/Client/Program.cs | 12 ++-- .../Client/Client.csproj | 1 - .../Step10_WorkflowNested/Client/Program.cs | 12 ++-- .../Client/Client.csproj | 2 - .../Step11_WorkflowApproval/Client/Program.cs | 64 ++++++++--------- .../Client/Client.csproj | 2 - .../Client/Program.cs | 70 +++++++++---------- 14 files changed, 84 insertions(+), 119 deletions(-) diff --git a/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Client/Client.csproj b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Client/Client.csproj index 76f15d84fbf..24da3ebca35 100644 --- a/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Client/Client.csproj +++ b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Client/Client.csproj @@ -8,7 +8,6 @@ - diff --git a/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Client/Program.cs index 66488096fae..0f9ff43967f 100644 --- a/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Client/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Client/Program.cs @@ -2,23 +2,19 @@ using AGUI.Abstractions; using AGUI.Client; -using Microsoft.Agents.AI; using Microsoft.Extensions.AI; string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; -AGUIChatClient chatClient = new(new(httpClient, serverUrl)); -AIAgent agent = chatClient.AsAIAgent(name: "workflow-client"); -AgentSession session = await agent.CreateSessionAsync(); +IChatClient chatClient = new AGUIChatClient(new(httpClient, serverUrl)); Console.Write("Request: "); string request = Console.ReadLine() ?? "Write a short welcome message for a developer conference."; -await foreach (AgentResponseUpdate update in agent.RunStreamingAsync( - new ChatMessage(ChatRole.User, request), - session)) +await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync( + [new ChatMessage(ChatRole.User, request)])) { - switch (update.AsChatResponseUpdate().RawRepresentation) + switch (update.RawRepresentation) { case StepStartedEvent started: Console.WriteLine($"\n[Step started: {started.StepName}]"); diff --git a/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Client/Client.csproj b/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Client/Client.csproj index 76f15d84fbf..24da3ebca35 100644 --- a/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Client/Client.csproj +++ b/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Client/Client.csproj @@ -8,7 +8,6 @@ - diff --git a/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Client/Program.cs index 0253be79cfc..6554320168b 100644 --- a/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Client/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Client/Program.cs @@ -2,23 +2,19 @@ using AGUI.Abstractions; using AGUI.Client; -using Microsoft.Agents.AI; using Microsoft.Extensions.AI; string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; -AGUIChatClient chatClient = new(new(httpClient, serverUrl)); -AIAgent agent = chatClient.AsAIAgent(name: "workflow-client"); -AgentSession session = await agent.CreateSessionAsync(); +IChatClient chatClient = new AGUIChatClient(new(httpClient, serverUrl)); Console.Write("Request: "); string request = Console.ReadLine() ?? "Assess the tradeoffs of adopting a new framework."; -await foreach (AgentResponseUpdate update in agent.RunStreamingAsync( - new ChatMessage(ChatRole.User, request), - session)) +await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync( + [new ChatMessage(ChatRole.User, request)])) { - switch (update.AsChatResponseUpdate().RawRepresentation) + switch (update.RawRepresentation) { case StepStartedEvent started: Console.WriteLine($"\n[Step started: {started.StepName}]"); diff --git a/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Client/Client.csproj b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Client/Client.csproj index 76f15d84fbf..24da3ebca35 100644 --- a/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Client/Client.csproj +++ b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Client/Client.csproj @@ -8,7 +8,6 @@ - diff --git a/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Client/Program.cs index 571e9ac0d2b..8b29f149c62 100644 --- a/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Client/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Client/Program.cs @@ -2,20 +2,16 @@ using AGUI.Abstractions; using AGUI.Client; -using Microsoft.Agents.AI; using Microsoft.Extensions.AI; string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; -AGUIChatClient chatClient = new(new(httpClient, serverUrl)); -AIAgent agent = chatClient.AsAIAgent(name: "workflow-client"); -AgentSession session = await agent.CreateSessionAsync(); +IChatClient chatClient = new AGUIChatClient(new(httpClient, serverUrl)); -await foreach (AgentResponseUpdate update in agent.RunStreamingAsync( - new ChatMessage(ChatRole.User, "Run the failing workflow."), - session)) +await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync( + [new ChatMessage(ChatRole.User, "Run the failing workflow.")])) { - switch (update.AsChatResponseUpdate().RawRepresentation) + switch (update.RawRepresentation) { case StepStartedEvent started: Console.WriteLine($"[Step started: {started.StepName}]"); diff --git a/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Client/Client.csproj b/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Client/Client.csproj index 76f15d84fbf..24da3ebca35 100644 --- a/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Client/Client.csproj +++ b/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Client/Client.csproj @@ -8,7 +8,6 @@ - diff --git a/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Client/Program.cs index 3312e38ceb0..772dcbadc0f 100644 --- a/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Client/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Client/Program.cs @@ -2,20 +2,16 @@ using AGUI.Abstractions; using AGUI.Client; -using Microsoft.Agents.AI; using Microsoft.Extensions.AI; string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; -AGUIChatClient chatClient = new(new(httpClient, serverUrl)); -AIAgent agent = chatClient.AsAIAgent(name: "workflow-client"); -AgentSession session = await agent.CreateSessionAsync(); +IChatClient chatClient = new AGUIChatClient(new(httpClient, serverUrl)); -await foreach (AgentResponseUpdate update in agent.RunStreamingAsync( - new ChatMessage(ChatRole.User, "What is the weather in Seattle?"), - session)) +await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync( + [new ChatMessage(ChatRole.User, "What is the weather in Seattle?")])) { - switch (update.AsChatResponseUpdate().RawRepresentation) + switch (update.RawRepresentation) { case StepStartedEvent started: Console.WriteLine($"\n[Step started: {started.StepName}]"); diff --git a/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Client/Client.csproj b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Client/Client.csproj index 76f15d84fbf..24da3ebca35 100644 --- a/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Client/Client.csproj +++ b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Client/Client.csproj @@ -8,7 +8,6 @@ - diff --git a/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Client/Program.cs index a18b1da5ae8..b6cd450278d 100644 --- a/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Client/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Client/Program.cs @@ -2,22 +2,18 @@ using AGUI.Abstractions; using AGUI.Client; -using Microsoft.Agents.AI; using Microsoft.Extensions.AI; string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; -AGUIChatClient chatClient = new(new(httpClient, serverUrl)); -AIAgent agent = chatClient.AsAIAgent(name: "workflow-client"); -AgentSession session = await agent.CreateSessionAsync(); +IChatClient chatClient = new AGUIChatClient(new(httpClient, serverUrl)); try { - await foreach (AgentResponseUpdate update in agent.RunStreamingAsync( - new ChatMessage(ChatRole.User, "Review this proposal."), - session)) + await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync( + [new ChatMessage(ChatRole.User, "Review this proposal.")])) { - switch (update.AsChatResponseUpdate().RawRepresentation) + switch (update.RawRepresentation) { case StepStartedEvent started: Console.WriteLine($"\n[Step started: {started.StepName}]"); diff --git a/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Client/Client.csproj b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Client/Client.csproj index a31dc3c685f..24da3ebca35 100644 --- a/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Client/Client.csproj +++ b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Client/Client.csproj @@ -8,9 +8,7 @@ - - diff --git a/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Client/Program.cs index 9d903bd36af..c2dae1ac3cd 100644 --- a/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Client/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Client/Program.cs @@ -1,52 +1,46 @@ // Copyright (c) Microsoft. All rights reserved. -using System.Net.Http.Json; using System.Text.Json; using AGUI.Abstractions; using AGUI.Client; -using AGUI.Server; using Microsoft.Extensions.AI; string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; -using HttpClient httpClient = new() { BaseAddress = new Uri(serverUrl), Timeout = TimeSpan.FromSeconds(60) }; +using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; +using IChatClient chatClient = new AGUIChatClient(new(httpClient, serverUrl)); -RunAgentInput initialInput = new() -{ - Messages = new[] { new ChatMessage(ChatRole.User, "Submit expense EXP-100.") }.AsAGUIMessages().ToList(), - RunId = Guid.NewGuid().ToString("N"), - ThreadId = Guid.NewGuid().ToString("N"), -}; -List firstTurn = await SendAsync(initialInput); -RunFinishedEvent finished = firstTurn.OfType().Single(); +List firstTurn = await chatClient + .GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "Submit expense EXP-100.")]) + .ToListAsync(); +RunFinishedEvent finished = firstTurn.Select(static update => update.RawRepresentation) + .OfType() + .Single(); AGUIInterrupt interrupt = ((RunFinishedInterruptOutcome)finished.Outcome!).Interrupts.Single(); Console.Write($"{interrupt.Message ?? "Approve expense?"} [y/N]: "); bool approved = string.Equals(Console.ReadLine(), "y", StringComparison.OrdinalIgnoreCase); -RunAgentInput resumeInput = new() +ChatOptions resumeOptions = new() { - Messages = [], - ParentRunId = finished.RunId, - Resume = - [ - new AGUIResume - { - InterruptId = interrupt.Id, - Payload = JsonSerializer.SerializeToElement(new { approved }), - Status = "resolved", - }, - ], - RunId = Guid.NewGuid().ToString("N"), - ThreadId = finished.ThreadId, + RawRepresentationFactory = _ => new RunAgentInput + { + Messages = [], + ParentRunId = finished.RunId, + Resume = + [ + new AGUIResume + { + InterruptId = interrupt.Id, + Payload = JsonSerializer.SerializeToElement(new { approved }), + Status = "resolved", + }, + ], + RunId = Guid.NewGuid().ToString("N"), + ThreadId = finished.ThreadId, + }, }; -List secondTurn = await SendAsync(resumeInput); -Console.WriteLine(string.Concat(secondTurn.OfType().Select(static evt => evt.Delta))); - -async Task> SendAsync(RunAgentInput input) -{ - using JsonContent content = JsonContent.Create(input, AGUIJsonSerializerContext.Default.RunAgentInput); - using HttpResponseMessage response = await httpClient.PostAsync(new Uri("", UriKind.Relative), content); - response.EnsureSuccessStatusCode(); - return await response.ReadAGUIEventStreamAsync().ToListAsync(); -} +List secondTurn = await chatClient + .GetStreamingResponseAsync([], resumeOptions) + .ToListAsync(); +Console.WriteLine(string.Concat(secondTurn.Select(static update => update.Text))); diff --git a/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Client/Client.csproj b/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Client/Client.csproj index a31dc3c685f..24da3ebca35 100644 --- a/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Client/Client.csproj +++ b/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Client/Client.csproj @@ -8,9 +8,7 @@ - - diff --git a/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Client/Program.cs index 427abbbf572..50ae50ed87b 100644 --- a/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Client/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Client/Program.cs @@ -1,50 +1,58 @@ // Copyright (c) Microsoft. All rights reserved. -using System.Net.Http.Json; using System.Text.Json; using AGUI.Abstractions; using AGUI.Client; -using AGUI.Server; using Microsoft.Extensions.AI; string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; -using HttpClient httpClient = new() { BaseAddress = new Uri(serverUrl), Timeout = TimeSpan.FromSeconds(60) }; +using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; +using IChatClient chatClient = new AGUIChatClient(new(httpClient, serverUrl)); -RunAgentInput initialInput = new() -{ - Messages = new[] { new ChatMessage(ChatRole.User, "Plan my conference trip.") }.AsAGUIMessages().ToList(), - RunId = Guid.NewGuid().ToString("N"), - ThreadId = Guid.NewGuid().ToString("N"), -}; -List firstTurn = await SendAsync(initialInput); -RunFinishedEvent firstFinished = firstTurn.OfType().Single(); +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")); -RunFinishedEvent partialFinished = (await SendAsync(CreateResume( - firstFinished, - [ - Resume(travelers, new { kind = "travelers", count = 2, accessibility = "none" }), - Resume(dates, new { kind = "dates", departure = "2026-10-10", returnDate = "2026-10-14" }), - ]))).OfType().Single(); +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 SendAsync(CreateResume( - partialFinished, - [Resume(preferences, new { kind = "preferences", budget = 2500, cabin = "economy", hotel = "downtown" })])); +List finalTurn = await chatClient.GetStreamingResponseAsync( + [], + CreateResumeOptions( + partialFinished, + [Resume(preferences, new { kind = "preferences", budget = 2500, cabin = "economy", hotel = "downtown" })])) + .ToListAsync(); -Console.WriteLine(string.Concat(finalTurn.OfType().Select(static evt => evt.Delta))); +Console.WriteLine(string.Concat(finalTurn.Select(static update => update.Text))); -RunAgentInput CreateResume(RunFinishedEvent previous, IList resumes) +ChatOptions CreateResumeOptions(RunFinishedEvent previous, IList resumes) => new() { - Messages = [], - ParentRunId = previous.RunId, - Resume = resumes, - RunId = Guid.NewGuid().ToString("N"), - ThreadId = previous.ThreadId, + RawRepresentationFactory = _ => new RunAgentInput + { + Messages = [], + ParentRunId = previous.RunId, + Resume = resumes, + RunId = Guid.NewGuid().ToString("N"), + ThreadId = previous.ThreadId, + }, }; static AGUIResume Resume(AGUIInterrupt interrupt, object payload) @@ -54,11 +62,3 @@ static AGUIResume Resume(AGUIInterrupt interrupt, object payload) Payload = JsonSerializer.SerializeToElement(payload), Status = "resolved", }; - -async Task> SendAsync(RunAgentInput input) -{ - using JsonContent content = JsonContent.Create(input, AGUIJsonSerializerContext.Default.RunAgentInput); - using HttpResponseMessage response = await httpClient.PostAsync(new Uri("", UriKind.Relative), content); - response.EnsureSuccessStatusCode(); - return await response.ReadAGUIEventStreamAsync().ToListAsync(); -} From 59ff780136ed0e6d8e18db0dcfd15a5a18e06874 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Fri, 21 Aug 2026 15:55:37 +0200 Subject: [PATCH 17/17] Use native tool approval in workflow sample Build the approval workflow around one expense-review agent with an explicit checklist and approval-required submission tool. Handle the native approval request/response pair through IChatClient, deduplicate the workflow-correlated request, and map generic interrupt responses unconditionally to function results. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 681114d3-3e0d-47f8-8b87-4e5873ecdd6d --- .../Client/Program.cs | 5 +- .../Client/Program.cs | 5 +- .../Step08_WorkflowFailure/Client/Program.cs | 5 +- .../Step09_WorkflowTools/Client/Program.cs | 5 +- .../Step10_WorkflowNested/Client/Program.cs | 5 +- .../Step11_WorkflowApproval/Client/Program.cs | 70 ++++---- .../AGUI/Step11_WorkflowApproval/README.md | 8 +- .../Server/ApprovalWorkflow.cs | 65 ++------ .../Step11_WorkflowApproval/Server/Program.cs | 47 +++++- .../Server/Server.csproj | 10 +- .../Client/Program.cs | 5 +- .../AGUIEndpointRouteBuilderExtensions.cs | 4 +- .../WorkflowAGUIExtensions.cs | 23 ++- .../WorkflowHostAgent.cs | 6 - .../Workflows/ApprovalWorkflowTests.cs | 157 ++++++++++++------ .../WorkflowAGUIExtensionsTests.cs | 65 +++++++- 16 files changed, 329 insertions(+), 156 deletions(-) diff --git a/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Client/Program.cs index 0f9ff43967f..382634936e6 100644 --- a/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Client/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Client/Program.cs @@ -6,7 +6,7 @@ string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; -IChatClient chatClient = new AGUIChatClient(new(httpClient, serverUrl)); +using IChatClient chatClient = CreateChatClient(httpClient, serverUrl); Console.Write("Request: "); string request = Console.ReadLine() ?? "Write a short welcome message for a developer conference."; @@ -31,3 +31,6 @@ [new ChatMessage(ChatRole.User, request)])) } Console.WriteLine(); + +static IChatClient CreateChatClient(HttpClient httpClient, string serverUrl) + => new AGUIChatClient(new(httpClient, serverUrl)); diff --git a/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Client/Program.cs index 6554320168b..0abbcebf1b0 100644 --- a/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Client/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Client/Program.cs @@ -6,7 +6,7 @@ string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; -IChatClient chatClient = new AGUIChatClient(new(httpClient, serverUrl)); +using IChatClient chatClient = CreateChatClient(httpClient, serverUrl); Console.Write("Request: "); string request = Console.ReadLine() ?? "Assess the tradeoffs of adopting a new framework."; @@ -31,3 +31,6 @@ [new ChatMessage(ChatRole.User, request)])) } Console.WriteLine(); + +static IChatClient CreateChatClient(HttpClient httpClient, string serverUrl) + => new AGUIChatClient(new(httpClient, serverUrl)); diff --git a/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Client/Program.cs index 8b29f149c62..2ad3d58cc2d 100644 --- a/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Client/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Client/Program.cs @@ -6,7 +6,7 @@ string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; -IChatClient chatClient = new AGUIChatClient(new(httpClient, serverUrl)); +using IChatClient chatClient = CreateChatClient(httpClient, serverUrl); await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync( [new ChatMessage(ChatRole.User, "Run the failing workflow.")])) @@ -24,3 +24,6 @@ [new ChatMessage(ChatRole.User, "Run the failing workflow.")])) break; } } + +static IChatClient CreateChatClient(HttpClient httpClient, string serverUrl) + => new AGUIChatClient(new(httpClient, serverUrl)); diff --git a/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Client/Program.cs index 772dcbadc0f..c872e2547f0 100644 --- a/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Client/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Client/Program.cs @@ -6,7 +6,7 @@ string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; -IChatClient chatClient = new AGUIChatClient(new(httpClient, serverUrl)); +using IChatClient chatClient = CreateChatClient(httpClient, serverUrl); await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync( [new ChatMessage(ChatRole.User, "What is the weather in Seattle?")])) @@ -39,3 +39,6 @@ [new ChatMessage(ChatRole.User, "What is the weather in Seattle?")])) } Console.WriteLine(); + +static IChatClient CreateChatClient(HttpClient httpClient, string serverUrl) + => new AGUIChatClient(new(httpClient, serverUrl)); diff --git a/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Client/Program.cs index b6cd450278d..21e3d6bf0fd 100644 --- a/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Client/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Client/Program.cs @@ -6,7 +6,7 @@ string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; -IChatClient chatClient = new AGUIChatClient(new(httpClient, serverUrl)); +using IChatClient chatClient = CreateChatClient(httpClient, serverUrl); try { @@ -35,3 +35,6 @@ [new ChatMessage(ChatRole.User, "Review this proposal.")])) } Console.WriteLine(); + +static IChatClient CreateChatClient(HttpClient httpClient, string serverUrl) + => new AGUIChatClient(new(httpClient, serverUrl)); diff --git a/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Client/Program.cs index c2dae1ac3cd..fdfac1c4d68 100644 --- a/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Client/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Client/Program.cs @@ -1,46 +1,60 @@ // 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 = new AGUIChatClient(new(httpClient, serverUrl)); +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, "Submit expense EXP-100.")]) + .GetStreamingResponseAsync( + [new ChatMessage(ChatRole.User, $"Review and submit this expense report:\n{reportJson}")], + options) .ToListAsync(); -RunFinishedEvent finished = firstTurn.Select(static update => update.RawRepresentation) - .OfType() + +#pragma warning disable MEAI001 // Tool approval content is experimental. +ToolApprovalRequestContent approvalRequest = firstTurn + .SelectMany(static update => update.Contents) + .OfType() .Single(); -AGUIInterrupt interrupt = ((RunFinishedInterruptOutcome)finished.Outcome!).Interrupts.Single(); +FunctionCallContent toolCall = (FunctionCallContent)approvalRequest.ToolCall; -Console.Write($"{interrupt.Message ?? "Approve expense?"} [y/N]: "); +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."); -ChatOptions resumeOptions = new() +List approvalMessages = +[ + new(ChatRole.Assistant, [approvalRequest]), + new(ChatRole.Tool, [approvalResponse]), +]; + +await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync(approvalMessages, options)) { - RawRepresentationFactory = _ => new RunAgentInput + foreach (TextContent text in update.Contents.OfType()) { - Messages = [], - ParentRunId = finished.RunId, - Resume = - [ - new AGUIResume - { - InterruptId = interrupt.Id, - Payload = JsonSerializer.SerializeToElement(new { approved }), - Status = "resolved", - }, - ], - RunId = Guid.NewGuid().ToString("N"), - ThreadId = finished.ThreadId, - }, -}; + Console.Write(text.Text); + } +} +#pragma warning restore MEAI001 -List secondTurn = await chatClient - .GetStreamingResponseAsync([], resumeOptions) - .ToListAsync(); -Console.WriteLine(string.Concat(secondTurn.Select(static update => update.Text))); +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 index 2b1787d5e98..073b148ae90 100644 --- a/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/README.md +++ b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/README.md @@ -1,7 +1,11 @@ # Approval Workflow over AG-UI -This sample pauses a workflow for approval before submitting an expense. The AG-UI client reads the -interruption from `RUN_FINISHED`, asks the user for a decision, and resumes the same workflow thread. +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. diff --git a/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/ApprovalWorkflow.cs b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/ApprovalWorkflow.cs index 48f00867d7d..e0f324af8f0 100644 --- a/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/ApprovalWorkflow.cs +++ b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/ApprovalWorkflow.cs @@ -1,12 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. -using System.Collections.Generic; -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.WorkflowApproval; @@ -16,51 +11,25 @@ namespace AGUI.WorkflowApproval; public static class ApprovalWorkflow { /// - /// Creates a workflow that pauses for approval before submitting an expense. + /// Creates a workflow containing one expense-review agent. /// - /// The approval workflow. - public static Workflow Create() - { - ExpenseApprovalExecutor executor = new(); - return new WorkflowBuilder(executor) - .AddExternalCall(executor, "ApprovalInput") - .WithOutputFrom(executor) - .Build(); - } + /// The agent that checks and submits expense reports. + /// The expense approval workflow. + public static Workflow Create(AIAgent expenseReviewer) + => new SequentialWorkflowBuilder(expenseReviewer).Build(); } /// -/// The expense approval request presented to the client. +/// An expense report submitted to the workflow. /// -/// The expense identifier. -/// The expense amount. -public sealed record ExpenseApprovalRequest(string ExpenseId, decimal Amount); - -[SendsMessage(typeof(ExpenseApprovalRequest))] -internal sealed partial class ExpenseApprovalExecutor() - : ChatProtocolExecutor("ExpenseApproval", new ChatProtocolExecutorOptions { AutoSendTurnToken = false }) -{ - protected override ValueTask TakeTurnAsync( - List messages, - IWorkflowContext context, - bool? emitEvents, - CancellationToken cancellationToken = default) - => context.SendMessageAsync(new ExpenseApprovalRequest("EXP-100", 125.00m), cancellationToken); - - [MessageHandler] - public async ValueTask HandleApprovalAsync( - JsonElement response, - IWorkflowContext context, - CancellationToken cancellationToken = default) - { - bool approved = response.GetProperty("approved").GetBoolean(); - string result = approved ? "Expense approved and submitted." : "Expense rejected."; - AgentResponseUpdate update = new(ChatRole.Assistant, result) - { - MessageId = "expense-result", - ResponseId = "expense-response", - }; - await context.AddEventAsync(new AgentResponseUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false); - await context.SendMessageAsync(new TurnToken(false), cancellationToken).ConfigureAwait(false); - } -} +/// 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 index 6b4c91654be..7a556bc2ab6 100644 --- a/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/Program.cs @@ -1,19 +1,56 @@ // 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(); -builder.Services.AddAIAgent( - "ApprovalWorkflow", - static (_, _) => ApprovalWorkflow.Create().AsAIAgent( - name: "ApprovalWorkflow", - includeWorkflowOutputsInResponse: true)) + +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(); diff --git a/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/Server.csproj b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/Server.csproj index 6d6151d24c4..43fbf4e354a 100644 --- a/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/Server.csproj +++ b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/Server.csproj @@ -7,13 +7,15 @@ 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 index 50ae50ed87b..9c6de6ba573 100644 --- a/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Client/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Client/Program.cs @@ -7,7 +7,7 @@ string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; -using IChatClient chatClient = new AGUIChatClient(new(httpClient, serverUrl)); +using IChatClient chatClient = CreateChatClient(httpClient, serverUrl); List firstTurn = await chatClient .GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "Plan my conference trip.")]) @@ -62,3 +62,6 @@ static AGUIResume Resume(AGUIInterrupt interrupt, object payload) Payload = JsonSerializer.SerializeToElement(payload), Status = "resolved", }; + +static IChatClient CreateChatClient(HttpClient httpClient, string serverUrl) + => new AGUIChatClient(new(httpClient, serverUrl)); 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 7701461a74f..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,9 +147,7 @@ public static IEndpointConventionBuilder MapAGUIServer( var session = await hostAgent.GetOrCreateSessionAsync(threadId, cancellationToken).ConfigureAwait(false); - IEnumerable messages = aiAgent.GetService() is not null - ? ctx.Messages.MapAGUIInterruptResponsesToWorkflow() - : ctx.Messages; + IEnumerable messages = ctx.Messages.MapAGUIInterruptResponsesToFunctionResults(); var events = hostAgent .RunStreamingAsync( diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/WorkflowAGUIExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/WorkflowAGUIExtensions.cs index f978d59b542..de976298724 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/WorkflowAGUIExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/WorkflowAGUIExtensions.cs @@ -18,11 +18,20 @@ internal static async IAsyncEnumerable MapWorkflowEventsToAG { 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 = @@ -63,6 +72,10 @@ when update.Contents.OfType().SingleOrDefault() is { } requ includeContents: false); break; + case var _ when update.Contents.OfType().Any(): + uncorrelatedApprovalRequests.Add(update); + break; + default: yield return update; break; @@ -73,9 +86,17 @@ when update.Contents.OfType().SingleOrDefault() is { } requ { 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) { @@ -212,7 +233,7 @@ private static ChatResponseUpdate CreateEventUpdate( ContinuationToken = update.ContinuationToken, }; - internal static List MapAGUIInterruptResponsesToWorkflow( + internal static List MapAGUIInterruptResponsesToFunctionResults( this IEnumerable messages) => [.. messages.Select(static message => { diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs index e159d5e5270..01f96379674 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs @@ -55,12 +55,6 @@ public WorkflowHostAgent(Workflow workflow, string? id = null, string? name = nu public override string? Name { get; } public override string? Description { get; } - /// - public override object? GetService(Type serviceType, object? serviceKey = null) - => serviceKey is null && serviceType == typeof(Workflow) - ? this._workflow - : base.GetService(serviceType, serviceKey); - private string GenerateNewId() { string result; 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 index 6a58074607d..4df4139c60f 100644 --- 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 @@ -1,13 +1,11 @@ // 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.Runtime.CompilerServices; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; -using AGUI.Abstractions; using AGUI.Client; using AGUI.WorkflowApproval; using FluentAssertions; @@ -19,61 +17,116 @@ namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.Workflows public sealed class ApprovalWorkflowTests { [Fact] - public async Task ClientApprovesInterruptionAndWorkflowResumesAsync() + public async Task ClientApprovesToolRequestAndWorkflowResumesAsync() { // Arrange - AIAgent workflowAgent = ApprovalWorkflow.Create().AsAIAgent( - name: "ApprovalWorkflow", - includeWorkflowOutputsInResponse: true); + Workflow workflow = ApprovalWorkflow.Create(new DeterministicExpenseReviewer()); + AIAgent workflowAgent = workflow.AsAIAgent(name: "ApprovalWorkflow"); await using WorkflowTestHost host = await WorkflowTestHost.StartAsync(workflowAgent, persistSession: true); - RunAgentInput initialInput = new() - { - Messages = new[] { new ChatMessage(ChatRole.User, "submit") }.AsAGUIMessages().ToList(), - RunId = "approval-run-1", - ThreadId = "approval-thread", - }; - - // Act - initial run pauses for approval. - List firstTurn = await SendAsync(host.Client, initialInput); - RunFinishedEvent finished = firstTurn.OfType().Single(); - RunFinishedInterruptOutcome outcome = finished.Outcome.Should() - .BeOfType().Subject; - AGUIInterrupt interrupt = outcome.Interrupts.Should().ContainSingle().Subject; - interrupt.Reason.Should().Be(InterruptReasons.InputRequired); - - RunAgentInput resumeInput = new() - { - Messages = [], - ParentRunId = finished.RunId, - Resume = - [ - new AGUIResume - { - InterruptId = interrupt.Id, - Payload = JsonSerializer.SerializeToElement(new { approved = true }), - Status = "resolved", - }, - ], - RunId = "approval-run-2", - ThreadId = finished.ThreadId, - }; - List secondTurn = await SendAsync(host.Client, resumeInput); + 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 - string text = string.Concat(secondTurn.OfType().Select(static evt => evt.Delta)); - text.Should().Contain( - "Expense approved and submitted.", - "events were {0}", - string.Join(", ", secondTurn.Select(static evt => evt.GetType().Name))); - secondTurn.OfType() - .Should().Contain(static evt => evt.StepName == "ExpenseApproval"); + secondTurn.Should().Contain(static update => update.Text == "Expense report EXP-100 was submitted."); } - private static async Task> SendAsync(HttpClient client, RunAgentInput input) + private sealed class DeterministicExpenseReviewer : AIAgent { - 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(); + 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.UnitTests/WorkflowAGUIExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/WorkflowAGUIExtensionsTests.cs index be2a1043deb..91f99ebf6ba 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/WorkflowAGUIExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/WorkflowAGUIExtensionsTests.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.Json; using System.Threading.Tasks; using AGUI.Abstractions; using FluentAssertions; @@ -12,7 +13,7 @@ namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests; /// -/// Tests workflow executor lifecycle mapping to AG-UI step events. +/// Tests workflow lifecycle and interruption mapping to AG-UI. /// public sealed class WorkflowAGUIExtensionsTests { @@ -132,6 +133,58 @@ public async Task MapWorkflowEventsToAGUI_ForwardsOtherUpdatesUnchangedAsync() 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) { @@ -149,6 +202,16 @@ private static async IAsyncEnumerable ToAsyncEnumerableAsyn 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) {