diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs index bd0b3114f1..ccb993b188 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 @@ public sealed class HandoffsWorkflowBuilder """; /// - /// 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 to 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 sealed class HandoffsWorkflowBuilder 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 = true) + { + this._emitAgentResponseUpdateEvents = emitAgentResponseUpdateEvents; + return this; + } + + /// + /// Sets a value indicating whether aggregated agent response events should be emitted during execution. + /// + /// + /// + public HandoffsWorkflowBuilder EmitAgentResponseEvents(bool emitAgentResponseEvents = true) + { + this._emitAgentResponseEvents = emitAgentResponseEvents; + return this; + } + /// /// Sets the behavior for filtering and contents from /// s flowing through the handoff workflow. Defaults to . @@ -175,7 +210,10 @@ public sealed class HandoffsWorkflowBuilder 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 cf9ddbe3a3..3f3d83fbee 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; @@ -104,9 +113,6 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor }, context, 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); @@ -175,7 +181,10 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor } 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 9883358e9e..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; } @@ -252,11 +258,7 @@ internal sealed class HandoffAgentExecutor( AgentResponse agentResponse = updates.ToAgentResponse(); - // Since there is no good way to configure the agent output behaviour due to how we add it to Handoff orchestration - // configurations, treat the emitEvents flag as simply determining whether to stream updates or to emit the whole response - // It would make little sense to avoid emitting any agent responses since this is only used in Orchestration workflows, - // which are Agent-only, and thus would do nothing. - if (message.TurnToken.EmitEvents is not true) + if (options.EmitAgentResponseEvents) { await context.YieldOutputAsync(agentResponse, cancellationToken).ConfigureAwait(false); } @@ -270,7 +272,7 @@ internal sealed class HandoffAgentExecutor( 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/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs index f6d48062b3..9cd9eb45e6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs @@ -10,106 +10,6 @@ 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(); - } - } -} - -public class HandoffAgentExecutorTests : AIAgentHostingExecutorTestsBase -{ - [Theory] - [InlineData(null)] - [InlineData(true)] - [InlineData(false)] - public async Task Test_HandoffAgentExecutor_EmitsCorrectOutputTypeAsync(bool? turnSetting) - { - // Arrange - TestRunContext testContext = new(); - TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName); - HandoffAgentExecutor executor = new(agent, new("", HandoffToolCallFilteringBehavior.None)); - testContext.ConfigureExecutor(executor); - - // Act - HandoffState message = new(new(turnSetting), null, []); - await executor.HandleAsync(message, testContext.BindWorkflowContext(executor.Id)); - - // Assert - bool expectingStreamingUpdates = turnSetting is true; - - AgentResponseEvent[] responses = testContext.Events.OfType().ToArray(); - CheckResponseEventsAgainstTestMessages(responses, !expectingStreamingUpdates, agent.GetDescriptiveId()); - - AgentResponseUpdateEvent[] updates = testContext.Events.OfType().ToArray(); - CheckResponseUpdateEventsAgainstTestMessages(updates, expectingStreamingUpdates, agent.GetDescriptiveId()); - } -} - public class AIAgentHostExecutorTests : AIAgentHostingExecutorTestsBase { [Theory] 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()); + } +}