diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs index bd0b3114f1..568aded9bc 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs @@ -13,10 +13,18 @@ namespace Microsoft.Agents.AI.Workflows; /// public sealed class HandoffsWorkflowBuilder { - internal const string FunctionPrefix = "handoff_to_"; + /// + /// The prefix for function calls that trigger handoffs to other agents; the full name is then `{FunctionPrefix}<agent_id>`, + /// where `<agent_id>` is the ID of the target agent to hand off to. + /// + public const string FunctionPrefix = "handoff_to_"; + private readonly AIAgent _initialAgent; private readonly Dictionary> _targets = []; private readonly HashSet _allAgents = new(AIAgentIDEqualityComparer.Instance); + + private bool _emitAgentResponseEvents; + private bool _emitAgentResponseUpdateEvents; private HandoffToolCallFilteringBehavior _toolCallFilteringBehavior = HandoffToolCallFilteringBehavior.HandoffOnly; /// @@ -47,9 +55,13 @@ in your conversation with the user. """; /// - /// Sets additional instructions to provide to an agent that has handoffs about how and when to - /// perform them. + /// Sets instructions to provide to each agent that has handoffs about how and when perform them. /// + /// + /// In the vast majority of cases, the will be sufficient, and there will be no need to customize. + /// If you do provide alternate instructions, remember to explain the mechanics of the handoff function tool call, using see + /// constant. + /// /// The instructions to provide, or to restore the default instructions. public HandoffsWorkflowBuilder WithHandoffInstructions(string? instructions) { @@ -57,6 +69,29 @@ public HandoffsWorkflowBuilder WithHandoffInstructions(string? instructions) return this; } + /// + /// Sets a value indicating whether agent streaming update events should be emitted during execution. + /// If , the value will be taken from the + /// + /// + /// + public HandoffsWorkflowBuilder EmitAgentResponseUpdateEvents(bool emitAgentResponseUpdateEvents) + { + this._emitAgentResponseUpdateEvents = true; + return this; + } + + /// + /// Sets a value indicating whether aggregated agent response events should be emitted during execution. + /// + /// + /// + public HandoffsWorkflowBuilder EmitAgentResponseEvents(bool emitAgentResponseEvents) + { + this._emitAgentResponseEvents = true; + return this; + } + /// /// Sets the behavior for filtering and contents from /// s flowing through the handoff workflow. Defaults to . @@ -175,7 +210,10 @@ public Workflow Build() HandoffsEndExecutor end = new(); WorkflowBuilder builder = new(start); - HandoffAgentExecutorOptions options = new(this.HandoffInstructions, this._toolCallFilteringBehavior); + HandoffAgentExecutorOptions options = new(this.HandoffInstructions, + this._emitAgentResponseEvents, + this._emitAgentResponseUpdateEvents, + this._toolCallFilteringBehavior); // Create an AgentExecutor for each again. Dictionary executors = this._allAgents.ToDictionary(a => a.Id, a => new HandoffAgentExecutor(a, options)); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs index 9cc72d7310..ee5d4c818c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs @@ -12,6 +12,15 @@ namespace Microsoft.Agents.AI.Workflows.Specialized; internal record AIAgentHostState(JsonElement? ThreadState, bool? CurrentTurnEmitEvents); +internal static class TurnExtensions +{ + public static bool ShouldEmitStreamingEvents(this TurnToken token, bool? agentSetting) + => token.EmitEvents ?? agentSetting ?? false; + + public static bool ShouldEmitStreamingEvents(bool? turnTokenSetting, bool? agentSetting) + => turnTokenSetting ?? agentSetting ?? false; +} + internal sealed class AIAgentHostExecutor : ChatProtocolExecutor { private readonly AIAgent _agent; @@ -88,9 +97,6 @@ private ValueTask HandleFunctionResultAsync( return this.ContinueTurnAsync(implicitTurnMessages, context, this._currentTurnEmitEvents ?? false, cancellationToken); } - public bool ShouldEmitStreamingEvents(bool? emitEvents) - => emitEvents ?? this._options.EmitAgentUpdateEvents ?? false; - private async ValueTask EnsureSessionAsync(IWorkflowContext context, CancellationToken cancellationToken) => this._session ??= await this._agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); @@ -159,7 +165,10 @@ await context.SendMessageAsync(response.Messages is List list ? lis } protected override ValueTask TakeTurnAsync(List messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default) - => this.ContinueTurnAsync(messages, context, this.ShouldEmitStreamingEvents(emitEvents), cancellationToken); + => this.ContinueTurnAsync(messages, + context, + TurnExtensions.ShouldEmitStreamingEvents(turnTokenSetting: emitEvents, this._options.EmitAgentUpdateEvents), + cancellationToken); private async ValueTask InvokeAgentAsync(IEnumerable messages, IWorkflowContext context, bool emitEvents, CancellationToken cancellationToken = default) { diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs index d1367b83ad..21519e07ee 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs @@ -14,14 +14,20 @@ namespace Microsoft.Agents.AI.Workflows.Specialized; internal sealed class HandoffAgentExecutorOptions { - public HandoffAgentExecutorOptions(string? handoffInstructions, HandoffToolCallFilteringBehavior toolCallFilteringBehavior) + public HandoffAgentExecutorOptions(string? handoffInstructions, bool emitAgentResponseEvents, bool? emitAgentResponseUpdateEvents, HandoffToolCallFilteringBehavior toolCallFilteringBehavior) { this.HandoffInstructions = handoffInstructions; + this.EmitAgentResponseEvents = emitAgentResponseEvents; + this.EmitAgentResponseUpdateEvents = emitAgentResponseUpdateEvents; this.ToolCallFilteringBehavior = toolCallFilteringBehavior; } public string? HandoffInstructions { get; set; } + public bool EmitAgentResponseEvents { get; set; } + + public bool? EmitAgentResponseUpdateEvents { get; set; } + public HandoffToolCallFilteringBehavior ToolCallFilteringBehavior { get; set; } = HandoffToolCallFilteringBehavior.HandoffOnly; } @@ -250,7 +256,14 @@ await AddUpdateAsync( } } - allMessages.AddRange(updates.ToAgentResponse().Messages); + AgentResponse agentResponse = updates.ToAgentResponse(); + + if (options.EmitAgentResponseEvents) + { + await context.YieldOutputAsync(agentResponse, cancellationToken).ConfigureAwait(false); + } + + allMessages.AddRange(agentResponse.Messages); roleChanges.ResetUserToAssistantForChangedRoles(); @@ -259,7 +272,7 @@ await AddUpdateAsync( async Task AddUpdateAsync(AgentResponseUpdate update, CancellationToken cancellationToken) { updates.Add(update); - if (message.TurnToken.EmitEvents is true) + if (message.TurnToken.ShouldEmitStreamingEvents(options.EmitAgentResponseUpdateEvents)) { await context.YieldOutputAsync(update, cancellationToken).ConfigureAwait(false); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowChatHistoryProvider.cs index 2815ed99f0..69c09abb27 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowChatHistoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowChatHistoryProvider.cs @@ -43,7 +43,7 @@ internal void AddMessages(AgentSession session, params IEnumerable => this._sessionState.GetOrInitializeState(session).Messages.AddRange(messages); protected override ValueTask> ProvideChatHistoryAsync(InvokingContext context, CancellationToken cancellationToken = default) - => new(this._sessionState.GetOrInitializeState(context.Session).Messages); + => new(this._sessionState.GetOrInitializeState(context.Session).Messages.AsReadOnly()); protected override ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default) { @@ -62,6 +62,12 @@ public IEnumerable GetFromBookmark(AgentSession session) } } + public IEnumerable GetAllMessages(AgentSession session) + { + var state = this._sessionState.GetOrInitializeState(session); + return state.Messages.AsReadOnly(); + } + public void UpdateBookmark(AgentSession session) { var state = this._sessionState.GetOrInitializeState(session); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs index 7679123970..295c08ceeb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs @@ -119,13 +119,17 @@ Task RunCoreAsync( MessageMerger merger = new(); await foreach (AgentResponseUpdate update in workflowSession.InvokeStageAsync(cancellationToken) - .ConfigureAwait(false) - .WithCancellation(cancellationToken)) + .ConfigureAwait(false) + .WithCancellation(cancellationToken)) { merger.AddUpdate(update); } - return merger.ComputeMerged(workflowSession.LastResponseId!, this.Id, this.Name); + AgentResponse response = merger.ComputeMerged(workflowSession.LastResponseId!, this.Id, this.Name); + workflowSession.ChatHistoryProvider.AddMessages(workflowSession, response.Messages); + workflowSession.ChatHistoryProvider.UpdateBookmark(workflowSession); + + return response; } protected override async @@ -138,11 +142,18 @@ IAsyncEnumerable RunCoreStreamingAsync( await this.ValidateWorkflowAsync().ConfigureAwait(false); WorkflowSession workflowSession = await this.UpdateSessionAsync(messages, session, cancellationToken).ConfigureAwait(false); + MessageMerger merger = new(); + await foreach (AgentResponseUpdate update in workflowSession.InvokeStageAsync(cancellationToken) .ConfigureAwait(false) .WithCancellation(cancellationToken)) { + merger.AddUpdate(update); yield return update; } + + AgentResponse response = merger.ComputeMerged(workflowSession.LastResponseId!, this.Id, this.Name); + workflowSession.ChatHistoryProvider.AddMessages(workflowSession, response.Messages); + workflowSession.ChatHistoryProvider.UpdateBookmark(workflowSession); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs index 40a18dbadb..a01bd68e4b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs @@ -110,7 +110,7 @@ public AgentResponseUpdate CreateUpdate(string responseId, object raw, params AI { Throw.IfNullOrEmpty(parts); - AgentResponseUpdate update = new(ChatRole.Assistant, parts) + return new(ChatRole.Assistant, parts) { CreatedAt = DateTimeOffset.UtcNow, MessageId = Guid.NewGuid().ToString("N"), @@ -118,27 +118,19 @@ public AgentResponseUpdate CreateUpdate(string responseId, object raw, params AI ResponseId = responseId, RawRepresentation = raw }; - - this.ChatHistoryProvider.AddMessages(this, update.ToChatMessage()); - - return update; } public AgentResponseUpdate CreateUpdate(string responseId, object raw, ChatMessage message) { Throw.IfNull(message); - AgentResponseUpdate update = new(message.Role, message.Contents) + return new(message.Role, message.Contents) { CreatedAt = message.CreatedAt ?? DateTimeOffset.UtcNow, MessageId = message.MessageId ?? Guid.NewGuid().ToString("N"), ResponseId = responseId, RawRepresentation = raw }; - - this.ChatHistoryProvider.AddMessages(this, update.ToChatMessage()); - - return update; } private async ValueTask CreateOrResumeRunAsync(List messages, CancellationToken cancellationToken = default) @@ -170,94 +162,86 @@ internal async IAsyncEnumerable InvokeStageAsync( [EnumeratorCancellation] CancellationToken cancellationToken = default) { - try - { - this.LastResponseId = Guid.NewGuid().ToString("N"); - List messages = this.ChatHistoryProvider.GetFromBookmark(this).ToList(); + this.LastResponseId = Guid.NewGuid().ToString("N"); + List messages = this.ChatHistoryProvider.GetFromBookmark(this).ToList(); #pragma warning disable CA2007 // Analyzer misfiring and not seeing .ConfigureAwait(false) below. - await using StreamingRun run = - await this.CreateOrResumeRunAsync(messages, cancellationToken).ConfigureAwait(false); + await using StreamingRun run = + await this.CreateOrResumeRunAsync(messages, cancellationToken).ConfigureAwait(false); #pragma warning restore CA2007 - await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false); - await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false, cancellationToken) - .ConfigureAwait(false) - .WithCancellation(cancellationToken)) + await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false); + await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false, cancellationToken) + .ConfigureAwait(false) + .WithCancellation(cancellationToken)) + { + switch (evt) { - switch (evt) - { - case AgentResponseUpdateEvent agentUpdate: - yield return agentUpdate.Update; - break; - - case RequestInfoEvent requestInfo: - FunctionCallContent fcContent = requestInfo.Request.ToFunctionCall(); - AgentResponseUpdate update = this.CreateUpdate(this.LastResponseId, evt, fcContent); - yield return update; - break; - - case WorkflowErrorEvent workflowError: - Exception? exception = workflowError.Exception; - if (exception is TargetInvocationException tie && tie.InnerException != null) - { - exception = tie.InnerException; - } - - if (exception != null) - { - string message = this._includeExceptionDetails - ? exception.Message - : "An error occurred while executing the workflow."; - - ErrorContent errorContent = new(message); - yield return this.CreateUpdate(this.LastResponseId, evt, errorContent); - } - - break; - - case SuperStepCompletedEvent stepCompleted: - this.LastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint; + case AgentResponseUpdateEvent agentUpdate: + yield return agentUpdate.Update; + break; + + case RequestInfoEvent requestInfo: + FunctionCallContent fcContent = requestInfo.Request.ToFunctionCall(); + AgentResponseUpdate update = this.CreateUpdate(this.LastResponseId, evt, fcContent); + yield return update; + break; + + case WorkflowErrorEvent workflowError: + Exception? exception = workflowError.Exception; + if (exception is TargetInvocationException tie && tie.InnerException != null) + { + exception = tie.InnerException; + } + + if (exception != null) + { + string message = this._includeExceptionDetails + ? exception.Message + : "An error occurred while executing the workflow."; + + ErrorContent errorContent = new(message); + yield return this.CreateUpdate(this.LastResponseId, evt, errorContent); + } + + break; + + case SuperStepCompletedEvent stepCompleted: + this.LastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint; + goto default; + + case WorkflowOutputEvent output: + IEnumerable? updateMessages = output.Data switch + { + IEnumerable chatMessages => chatMessages, + ChatMessage chatMessage => [chatMessage], + _ => null + }; + + if (!this._includeWorkflowOutputsInResponse || updateMessages == null) + { goto default; - - case WorkflowOutputEvent output: - IEnumerable? updateMessages = output.Data switch - { - IEnumerable chatMessages => chatMessages, - ChatMessage chatMessage => [chatMessage], - _ => null - }; - - if (!this._includeWorkflowOutputsInResponse || updateMessages == null) - { - goto default; - } - - foreach (ChatMessage message in updateMessages) - { - yield return this.CreateUpdate(this.LastResponseId, evt, message); - } - break; - - default: - // Emit all other workflow events for observability (DevUI, logging, etc.) - yield return new AgentResponseUpdate(ChatRole.Assistant, []) - { - CreatedAt = DateTimeOffset.UtcNow, - MessageId = Guid.NewGuid().ToString("N"), - Role = ChatRole.Assistant, - ResponseId = this.LastResponseId, - RawRepresentation = evt - }; - break; - } + } + + foreach (ChatMessage message in updateMessages) + { + yield return this.CreateUpdate(this.LastResponseId, evt, message); + } + break; + + default: + // Emit all other workflow events for observability (DevUI, logging, etc.) + yield return new AgentResponseUpdate(ChatRole.Assistant, []) + { + CreatedAt = DateTimeOffset.UtcNow, + MessageId = Guid.NewGuid().ToString("N"), + Role = ChatRole.Assistant, + ResponseId = this.LastResponseId, + RawRepresentation = evt + }; + break; } } - finally - { - // Do we want to try to undo the step, and not update the bookmark? - this.ChatHistoryProvider.UpdateBookmark(this); - } } public string? LastResponseId { get; set; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs index 063bd77cda..9cd9eb45e6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs @@ -10,20 +10,8 @@ namespace Microsoft.Agents.AI.Workflows.UnitTests; -public class AIAgentHostExecutorTests +public class AIAgentHostExecutorTests : AIAgentHostingExecutorTestsBase { - private const string TestAgentId = nameof(TestAgentId); - private const string TestAgentName = nameof(TestAgentName); - - private static readonly string[] s_messageStrings = [ - "", - "Hello world!", - "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", - "Quisque dignissim ante odio, at facilisis orci porta a. Duis mi augue, fringilla eu egestas a, pellentesque sed lacus." - ]; - - private static List TestMessages => TestReplayAgent.ToChatMessages(s_messageStrings); - [Theory] [InlineData(null, null)] [InlineData(null, true)] @@ -50,30 +38,7 @@ public async Task Test_AgentHostExecutor_EmitsStreamingUpdatesIFFConfiguredAsync bool expectingEvents = turnSetting ?? executorSetting ?? false; AgentResponseUpdateEvent[] updates = testContext.Events.OfType().ToArray(); - if (expectingEvents) - { - // The way TestReplayAgent is set up, it will emit one update per non-empty AIContent - List expectedUpdateContents = TestMessages.SelectMany(message => message.Contents).ToList(); - - updates.Should().HaveCount(expectedUpdateContents.Count); - for (int i = 0; i < updates.Length; i++) - { - AgentResponseUpdateEvent updateEvent = updates[i]; - AIContent expectedUpdateContent = expectedUpdateContents[i]; - - updateEvent.ExecutorId.Should().Be(agent.GetDescriptiveId()); - - AgentResponseUpdate update = updateEvent.Update; - update.AuthorName.Should().Be(TestAgentName); - update.AgentId.Should().Be(TestAgentId); - update.Contents.Should().HaveCount(1); - update.Contents[0].Should().BeEquivalentTo(expectedUpdateContent); - } - } - else - { - updates.Should().BeEmpty(); - } + CheckResponseUpdateEventsAgainstTestMessages(updates, expectingEvents, agent.GetDescriptiveId()); } [Theory] @@ -92,30 +57,7 @@ public async Task Test_AgentHostExecutor_EmitsResponseIFFConfiguredAsync(bool ex // Assert AgentResponseEvent[] updates = testContext.Events.OfType().ToArray(); - if (executorSetting) - { - updates.Should().HaveCount(1); - - AgentResponseEvent responseEvent = updates[0]; - responseEvent.ExecutorId.Should().Be(agent.GetDescriptiveId()); - - AgentResponse response = responseEvent.Response; - response.AgentId.Should().Be(TestAgentId); - response.Messages.Should().HaveCount(TestMessages.Count - 1); - - for (int i = 0; i < response.Messages.Count; i++) - { - ChatMessage responseMessage = response.Messages[i]; - ChatMessage expectedMessage = TestMessages[i + 1]; // Skip the first empty message - - responseMessage.AuthorName.Should().Be(TestAgentName); - responseMessage.Text.Should().Be(expectedMessage.Text); - } - } - else - { - updates.Should().BeEmpty(); - } + CheckResponseEventsAgainstTestMessages(updates, expectingResponse: executorSetting, agent.GetDescriptiveId()); } private static ChatMessage UserMessage => new(ChatRole.User, "Hello from User!") { AuthorName = "User" }; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostingExecutorTestsBase.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostingExecutorTestsBase.cs new file mode 100644 index 0000000000..2285074ce3 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostingExecutorTestsBase.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +public abstract class AIAgentHostingExecutorTestsBase +{ + protected const string TestAgentId = nameof(TestAgentId); + protected const string TestAgentName = nameof(TestAgentName); + + private static readonly string[] s_messageStrings = [ + "", + "Hello world!", + "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", + "Quisque dignissim ante odio, at facilisis orci porta a. Duis mi augue, fringilla eu egestas a, pellentesque sed lacus." + ]; + + protected static List TestMessages => TestReplayAgent.ToChatMessages(s_messageStrings); + + protected static void CheckResponseUpdateEventsAgainstTestMessages(AgentResponseUpdateEvent[] updates, bool expectingEvents, string expectedExecutorId) + { + if (expectingEvents) + { + // The way TestReplayAgent is set up, it will emit one update per non-empty AIContent + List expectedUpdateContents = TestMessages.SelectMany(message => message.Contents).ToList(); + + updates.Should().HaveCount(expectedUpdateContents.Count); + for (int i = 0; i < updates.Length; i++) + { + AgentResponseUpdateEvent updateEvent = updates[i]; + AIContent expectedUpdateContent = expectedUpdateContents[i]; + + updateEvent.ExecutorId.Should().Be(expectedExecutorId); + + AgentResponseUpdate update = updateEvent.Update; + update.AuthorName.Should().Be(TestAgentName); + update.AgentId.Should().Be(TestAgentId); + update.Contents.Should().HaveCount(1); + update.Contents[0].Should().BeEquivalentTo(expectedUpdateContent); + } + } + else + { + updates.Should().BeEmpty(); + } + } + + protected static void CheckResponseEventsAgainstTestMessages(AgentResponseEvent[] updates, bool expectingResponse, string expectedExecutorId) + { + if (expectingResponse) + { + updates.Should().HaveCount(1); + + AgentResponseEvent responseEvent = updates[0]; + responseEvent.ExecutorId.Should().Be(expectedExecutorId); + + AgentResponse response = responseEvent.Response; + response.AgentId.Should().Be(TestAgentId); + response.Messages.Should().HaveCount(TestMessages.Count - 1); + + for (int i = 0; i < response.Messages.Count; i++) + { + ChatMessage responseMessage = response.Messages[i]; + ChatMessage expectedMessage = TestMessages[i + 1]; // Skip the first empty message + + responseMessage.AuthorName.Should().Be(TestAgentName); + responseMessage.Text.Should().Be(expectedMessage.Text); + } + } + else + { + updates.Should().BeEmpty(); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs new file mode 100644 index 0000000000..8bdbe23c5f --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Specialized; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +public class HandoffAgentExecutorTests : AIAgentHostingExecutorTestsBase +{ + [Theory] + [InlineData(null, null)] + [InlineData(null, true)] + [InlineData(null, false)] + [InlineData(true, null)] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, null)] + [InlineData(false, true)] + [InlineData(false, false)] + public async Task Test_HandoffAgentExecutor_EmitsStreamingUpdatesIFFConfiguredAsync(bool? executorSetting, bool? turnSetting) + { + // Arrange + TestRunContext testContext = new(); + TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName); + + HandoffAgentExecutorOptions options = new("", + emitAgentResponseEvents: false, + emitAgentResponseUpdateEvents: executorSetting, + HandoffToolCallFilteringBehavior.None); + + HandoffAgentExecutor executor = new(agent, options); + testContext.ConfigureExecutor(executor); + + // Act + HandoffState message = new(new(turnSetting), null, []); + await executor.HandleAsync(message, testContext.BindWorkflowContext(executor.Id)); + + // Assert + bool expectingStreamingUpdates = turnSetting ?? executorSetting ?? false; + + AgentResponseUpdateEvent[] updates = testContext.Events.OfType().ToArray(); + CheckResponseUpdateEventsAgainstTestMessages(updates, expectingStreamingUpdates, agent.GetDescriptiveId()); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Test_HandoffAgentExecutor_EmitsResponseIFFConfiguredAsync(bool executorSetting) + { + // Arrange + TestRunContext testContext = new(); + TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName); + + HandoffAgentExecutorOptions options = new("", + emitAgentResponseEvents: executorSetting, + emitAgentResponseUpdateEvents: false, + HandoffToolCallFilteringBehavior.None); + + HandoffAgentExecutor executor = new(agent, options); + testContext.ConfigureExecutor(executor); + + // Act + HandoffState message = new(new(false), null, []); + await executor.HandleAsync(message, testContext.BindWorkflowContext(executor.Id)); + + // Assert + AgentResponseEvent[] updates = testContext.Events.OfType().ToArray(); + CheckResponseEventsAgainstTestMessages(updates, expectingResponse: executorSetting, agent.GetDescriptiveId()); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs index 40e4f2098f..55f0cb10bc 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs @@ -28,7 +28,7 @@ public ExpectedException(string? message, Exception? innerException) : base(mess } } -public class WorkflowHostSmokeTests +public class WorkflowHostSmokeTests : AIAgentHostingExecutorTestsBase { private sealed class AlwaysFailsAIAgent(bool failByThrowing) : AIAgent { @@ -112,4 +112,70 @@ public async Task Test_AsAgent_ErrorContentStreamedOutAsync(bool includeExceptio hadErrorContent.Should().BeTrue(); } + + private async Task Run_AsAgent_OutgoingMessagesInHistoryAsync(Workflow workflow, bool runAsync) + { + // Arrange + AIAgent workflowAgent = workflow.AsAIAgent(); + + // Act + AgentSession session = await workflowAgent.CreateSessionAsync(); + AgentResponse response; + if (runAsync) + { + List updates = []; + await foreach (AgentResponseUpdate update in workflowAgent.RunStreamingAsync(session)) + { + // Skip WorkflowEvent updates, which do not get persisted in ChatHistory; we cannot skip + // them after because of a deleterious interaction with .ToAgentResponse() due to the + // empty initial message (which is created without a MessageId). When running through the + // message merger, it does the right thing internally. + if (!string.IsNullOrEmpty(update.Text)) + { + updates.Add(update); + } + } + + response = updates.ToAgentResponse(); + } + else + { + response = await workflowAgent.RunAsync(session); + } + + // Assert + WorkflowSession workflowSession = session.Should().BeOfType().Subject; + + ChatMessage[] responseMessages = response.Messages.Where(message => message.Contents.Any()) + .ToArray(); + + ChatMessage[] sessionMessages = workflowSession.ChatHistoryProvider.GetAllMessages(workflowSession) + .ToArray(); + + // Since we never sent an incoming message, the expectation is that there should be nothing in the session + // except the response + responseMessages.Should().BeEquivalentTo(sessionMessages, options => options.WithStrictOrdering()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public Task Test_SingleAgent_AsAgent_OutgoingMessagesInHistoryAsync(bool runAsync) + { + // Arrange + TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName); + Workflow singleAgentWorkflow = new WorkflowBuilder(agent).Build(); + return this.Run_AsAgent_OutgoingMessagesInHistoryAsync(singleAgentWorkflow, runAsync); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public Task Test_Handoffs_AsAgent_OutgoingMessagesInHistoryAsync(bool runAsync) + { + // Arrange + TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName); + Workflow handoffWorkflow = new HandoffsWorkflowBuilder(agent).Build(); + return this.Run_AsAgent_OutgoingMessagesInHistoryAsync(handoffWorkflow, runAsync); + } }