diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentUnservicedRequestsCollector.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentUnservicedRequestsCollector.cs index 7e4f8c8c9d4..730f7510ea1 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentUnservicedRequestsCollector.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentUnservicedRequestsCollector.cs @@ -14,8 +14,9 @@ internal sealed class AIAgentUnservicedRequestsCollector(AIContentExternalHandle { private readonly Dictionary _userInputRequests = []; private readonly Dictionary _functionCalls = []; + private readonly List _displacedRequestIds = []; - public Task SubmitAsync(IWorkflowContext context, CancellationToken cancellationToken) + public async Task SubmitAsync(IWorkflowContext context, CancellationToken cancellationToken) { Task userInputTask = userInputHandler != null && this._userInputRequests.Count > 0 ? userInputHandler.ProcessRequestContentsAsync(this._userInputRequests, context, cancellationToken) @@ -25,7 +26,17 @@ public Task SubmitAsync(IWorkflowContext context, CancellationToken cancellation ? functionCallHandler.ProcessRequestContentsAsync(this._functionCalls, context, cancellationToken) : Task.CompletedTask; - return Task.WhenAll(userInputTask, functionCallTask); + await Task.WhenAll(userInputTask, functionCallTask).ConfigureAwait(false); + + if (this._displacedRequestIds.Count > 0) + { + // A different request reusing an outstanding ID cannot be serviced alongside the one already + // recorded under it, so report the ones that were dropped rather than losing them silently. + await context.AddEventAsync( + new WorkflowWarningEvent( + $"Ignored request content reusing an outstanding request ID ([{string.Join(", ", this._displacedRequestIds)}])."), + cancellationToken).ConfigureAwait(false); + } } public void ProcessAgentResponseUpdate(AgentResponseUpdate update, Func? functionCallFilter = null) @@ -34,19 +45,34 @@ public void ProcessAgentResponseUpdate(AgentResponseUpdate update, Func this.ProcessAIContents(response.Messages.SelectMany(message => message.Contents)); + /// + /// Records the requests these contents leave unserviced, and clears the ones they answer. + /// + /// + /// The first content seen for a request ID is the one kept, matching + /// , which treats a repeat as an + /// idempotent re-emission, and ApprovalResponseBindingChatClient, which binds a response against the + /// first request recorded for the ID. A later content that is not the same request is reported by + /// . + /// public void ProcessAIContents(IEnumerable contents, Func? functionCallFilter = null) { foreach (AIContent content in contents) { if (content is ToolApprovalRequestContent userInputRequest) { - if (this._userInputRequests.ContainsKey(userInputRequest.RequestId)) + if (this._userInputRequests.TryGetValue(userInputRequest.RequestId, out ToolApprovalRequestContent? recordedRequest)) { - throw new InvalidOperationException($"ToolApprovalRequestContent with duplicate RequestId: {userInputRequest.RequestId}"); + // The approval's ID is derived from the call it guards, so a different call under the + // same ID is a different request rather than a re-emission of this one. + this.NoteDisplacedRequest( + userInputRequest.RequestId, + string.Equals(recordedRequest.ToolCall.CallId, userInputRequest.ToolCall.CallId, StringComparison.Ordinal)); + } + else + { + this._userInputRequests.Add(userInputRequest.RequestId, userInputRequest); } - - // It is an error to simultaneously have multiple outstanding user input requests with the same ID. - this._userInputRequests.Add(userInputRequest.RequestId, userInputRequest); } else if (content is ToolApprovalResponseContent userInputResponse) { @@ -61,12 +87,18 @@ public void ProcessAIContents(IEnumerable contents, Func contents, Func InvokeAgentAsync(IEnumerable + string.Equals(candidate.Request.CallId, candidateHandoffRequest.CallId, StringComparison.Ordinal) + && string.Equals(candidate.Request.Name, candidateHandoffRequest.Name, StringComparison.Ordinal))) { candidateRequests.Add((candidateHandoffRequest, update.ResponseId)); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs index e5c45ebe4e0..4e6210671f1 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs @@ -289,6 +289,83 @@ List ExtractAndValidateRequestContents() where TRequest : AICo lastResponseEvent.Response.Text.Should().Be("Done"); } + [Fact] + public async Task Test_AgentHostExecutor_ReusedApprovalRequestIdIsReportedAsync() + { + // Arrange + const string RequestId = "shared-request-id"; + TestRunContext testContext = new(); + RequestEmittingAgent agent = new( + [ + new ToolApprovalRequestContent(RequestId, new McpServerToolCallContent("first-call", "firstTool", "http://localhost")), + new ToolApprovalRequestContent(RequestId, new McpServerToolCallContent("second-call", "secondTool", "http://localhost")), + ]); + AIAgentHostExecutor executor = new(agent, new() { InterceptUserInputRequests = false, EmitAgentUpdateEvents = true }); + testContext.ConfigureExecutor(executor); + + // Act + await executor.TakeTurnAsync(new(), testContext.BindWorkflowContext(executor.Id)); + + // Assert + WorkflowWarningEvent warning = testContext.Events.OfType().Should().ContainSingle().Which; + warning.Data.Should().BeOfType().Which.Should().Contain(RequestId); + + ToolApprovalRequestContent raised = + testContext.ExternalRequests.Should().ContainSingle().Which.Data.As() + .Should().NotBeNull().And.Subject.As(); + raised.ToolCall.CallId.Should().Be("first-call", "the first request recorded for an ID is the one raised"); + } + + [Fact] + public async Task Test_AgentHostExecutor_ReusedCallIdIsReportedAsync() + { + // Arrange + const string CallId = "shared-call-id"; + TestRunContext testContext = new(); + RequestEmittingAgent agent = new( + [ + new FunctionCallContent(CallId, "firstFunction"), + new FunctionCallContent(CallId, "secondFunction"), + ]); + AIAgentHostExecutor executor = new(agent, new() { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true }); + testContext.ConfigureExecutor(executor); + + // Act + await executor.TakeTurnAsync(new(), testContext.BindWorkflowContext(executor.Id)); + + // Assert + WorkflowWarningEvent warning = testContext.Events.OfType().Should().ContainSingle().Which; + warning.Data.Should().BeOfType().Which.Should().Contain(CallId); + + FunctionCallContent raised = + testContext.ExternalRequests.Should().ContainSingle().Which.Data.As() + .Should().NotBeNull().And.Subject.As(); + raised.Name.Should().Be("firstFunction", "the first request recorded for an ID is the one raised"); + } + + [Fact] + public async Task Test_AgentHostExecutor_ReEmittedRequestIsNotReportedAsync() + { + // Arrange + const string CallId = "re-emitted-call-id"; + TestRunContext testContext = new(); + RequestEmittingAgent agent = new( + [ + new FunctionCallContent(CallId, "sameFunction"), + new FunctionCallContent(CallId, "sameFunction"), + ]); + AIAgentHostExecutor executor = new(agent, new() { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true }); + testContext.ConfigureExecutor(executor); + + // Act + await executor.TakeTurnAsync(new(), testContext.BindWorkflowContext(executor.Id)); + + // Assert + testContext.Events.OfType() + .Should().BeEmpty("the same call repeated across updates is one request, not a displaced one"); + testContext.ExternalRequests.Should().ContainSingle(); + } + #region FilterForwardableMessages tests /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs index 70f802399d6..16f3cc6a89b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; using System.Runtime.CompilerServices; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using FluentAssertions; @@ -176,6 +177,101 @@ public async Task Test_HandoffAgentExecutor_ComposesWithHITLSubworkflowAsync() testContext.BindWorkflowContext(executor.Id)); } + /// + /// An agent that emits the workflow's handoff call once per update, modelling a stream that spreads one call + /// over several updates. + /// + private sealed class RepeatingHandoffAgent(int emissionCount, bool varyTarget = false) : AIAgent + { + private sealed class Session : AgentSession + { + public Session() { } + } + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + => new(new Session()); + + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => new(new Session()); + + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => default; + + 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) + { + List handoffFunctionNames = + [.. (options as ChatClientAgentRunOptions)?.ChatOptions?.Tools? + .Where(tool => tool.Name.StartsWith("handoff_to_", StringComparison.Ordinal)) + .Select(tool => tool.Name) ?? []]; + + if (handoffFunctionNames.Count == 0) + { + throw new InvalidOperationException("No handoff tool was offered to the agent."); + } + + for (int emission = 0; emission < emissionCount; emission++) + { + // Every emission reuses one call ID; only the target varies, and only when asked. + string handoffFunctionName = handoffFunctionNames[varyTarget ? emission % handoffFunctionNames.Count : 0]; + yield return new AgentResponseUpdate(ChatRole.Assistant, [new FunctionCallContent("handoff-call", handoffFunctionName)]); + } + } + } + + [Fact] + public async Task Test_HandoffAgentExecutor_RepeatedHandoffCallIsNotTreatedAsDuplicateAsync() + { + // Arrange + AIAgent handoffAgent = new RepeatingHandoffAgent(emissionCount: 2); + HandoffTarget handoff = new(new TestEchoAgent()); + + HandoffAgentExecutorOptions options = new(handoffInstructions: null, + emitAgentResponseEvents: false, + emitAgentResponseUpdateEvents: true, + HandoffToolCallFilteringBehavior.None); + HandoffAgentExecutor executor = new(handoffAgent, [handoff], options); + + TestRunContext runContext = await PrepareHandoffSharedStateAsync(); + runContext.ConfigureExecutor(executor); + IWorkflowContext testContext = runContext.BindWorkflowContext(executor.Id); + + // Act + await executor.HandleAsync(new HandoffState(new(true), null), testContext); + + // Assert + runContext.Events.OfType() + .Should().BeEmpty("one handoff call re-emitted across updates is a single handoff request"); + } + + [Fact] + public async Task Test_HandoffAgentExecutor_DifferentTargetsSharingACallIdStillWarnAsync() + { + // Arrange + AIAgent handoffAgent = new RepeatingHandoffAgent(emissionCount: 2, varyTarget: true); + HandoffTarget firstHandoff = new(new TestEchoAgent("firstTarget")); + HandoffTarget secondHandoff = new(new TestEchoAgent("secondTarget")); + + HandoffAgentExecutorOptions options = new(handoffInstructions: null, + emitAgentResponseEvents: false, + emitAgentResponseUpdateEvents: true, + HandoffToolCallFilteringBehavior.None); + HandoffAgentExecutor executor = new(handoffAgent, [firstHandoff, secondHandoff], options); + + TestRunContext runContext = await PrepareHandoffSharedStateAsync(); + runContext.ConfigureExecutor(executor); + IWorkflowContext testContext = runContext.BindWorkflowContext(executor.Id); + + // Act + await executor.HandleAsync(new HandoffState(new(true), null), testContext); + + // Assert + runContext.Events.OfType() + .Should().ContainSingle("two different handoff targets remain two competing handoffs even under one call ID"); + } + [Fact] public async Task Test_HandoffAgentExecutor_PreservesExistingInstructionsAndToolsAsync() { diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs index 92ebc5915d8..6d3d95bf752 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs @@ -34,7 +34,7 @@ public ExpectedException(string? message, Exception? innerException) : base(mess /// internal sealed class RequestEmittingAgent : AIAgent { - private readonly AIContent _requestContent; + private readonly IReadOnlyList _requestContents; private readonly bool _completeOnResponse; /// @@ -48,8 +48,19 @@ internal sealed class RequestEmittingAgent : AIAgent /// where the agent processes the tool result and produces a final answer. /// public RequestEmittingAgent(AIContent requestContent, bool completeOnResponse = false) + : this([requestContent], completeOnResponse) { - this._requestContent = requestContent; + } + + /// + /// Creates a new that emits one update per content, modelling a stream + /// that spreads its request content over several updates. + /// + /// The contents to emit, one update each. + /// See the single-content constructor. + public RequestEmittingAgent(IReadOnlyList requestContents, bool completeOnResponse = false) + { + this._requestContents = requestContents; this._completeOnResponse = completeOnResponse; } @@ -80,7 +91,10 @@ protected override async IAsyncEnumerable RunCoreStreamingA else { // Emit the request content - yield return new AgentResponseUpdate(ChatRole.Assistant, [this._requestContent]); + foreach (AIContent requestContent in this._requestContents) + { + yield return new AgentResponseUpdate(ChatRole.Assistant, [requestContent]); + } } } } @@ -385,6 +399,82 @@ public async Task Test_AsAgent_ToolApprovalRequestContentPreservedInRequestInfoA retrievedContent.RequestId.Should().EndWith($":{RequestId}"); } + /// + /// Tests that a function call repeated across updates is raised once, keeping the first content seen, + /// instead of failing the run. + /// + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Test_AsAgent_RepeatedFunctionCallIsCoalescedAsync(bool emitAgentUpdateEvents) + { + // Arrange + const string CallId = "repeated-call-id"; + const string FunctionName = "testFunction"; + RequestEmittingAgent requestAgent = new( + [ + new FunctionCallContent(CallId, FunctionName, new Dictionary { ["first"] = 1 }), + new FunctionCallContent(CallId, FunctionName, new Dictionary { ["second"] = 2 }), + ]); + ExecutorBinding agentBinding = requestAgent.BindAsExecutor( + new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = emitAgentUpdateEvents }); + Workflow workflow = new WorkflowBuilder(agentBinding).Build(); + + // Act + List updates = await workflow.AsAIAgent("WorkflowAgent", includeExceptionDetails: true) + .RunStreamingAsync(new ChatMessage(ChatRole.User, "Hello")) + .ToListAsync(); + + // Assert + updates.SelectMany(update => update.Contents.OfType()) + .Should().BeEmpty("a repeated call ID is one pending request, not a failure"); + + FunctionCallContent raised = updates + .Where(update => update.RawRepresentation is RequestInfoEvent) + .SelectMany(update => update.Contents.OfType()) + .Should().ContainSingle() + .Which; + + raised.CallId.Should().EndWith($":{CallId}"); + raised.Arguments.Should().ContainKey("first", "the first content seen for a request ID is the one kept"); + raised.Arguments.Should().NotContainKey("second"); + } + + /// + /// Tests that an approval request repeated across updates is raised once instead of failing the run. + /// + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Test_AsAgent_RepeatedToolApprovalRequestIsCoalescedAsync(bool emitAgentUpdateEvents) + { + // Arrange + const string RequestId = "repeated-request-id"; + McpServerToolCallContent mcpCall = new("call-id", "testToolName", "http://localhost"); + RequestEmittingAgent requestAgent = new( + [ + new ToolApprovalRequestContent(RequestId, mcpCall), + new ToolApprovalRequestContent(RequestId, mcpCall), + ]); + ExecutorBinding agentBinding = requestAgent.BindAsExecutor( + new AIAgentHostOptions { InterceptUserInputRequests = false, EmitAgentUpdateEvents = emitAgentUpdateEvents }); + Workflow workflow = new WorkflowBuilder(agentBinding).Build(); + + // Act + List updates = await workflow.AsAIAgent("WorkflowAgent", includeExceptionDetails: true) + .RunStreamingAsync(new ChatMessage(ChatRole.User, "Hello")) + .ToListAsync(); + + // Assert + updates.SelectMany(update => update.Contents.OfType()) + .Should().BeEmpty("a repeated request ID is one pending request, not a failure"); + + updates.Where(update => update.RawRepresentation is RequestInfoEvent) + .SelectMany(update => update.Contents.OfType()) + .Should().ContainSingle() + .Which.RequestId.Should().EndWith($":{RequestId}"); + } + /// /// Tests the full roundtrip: workflow emits a request, external caller responds, workflow processes response. ///