From c37cdbf133ce049ef97b8ad9e40570bb0d294a4c Mon Sep 17 00:00:00 2001 From: yashvanthange Date: Sat, 29 Aug 2026 16:03:25 +0530 Subject: [PATCH 1/2] .NET: keep the first request seen for a repeated request ID AIAgentUnservicedRequestsCollector.ProcessAIContents threw when a CallId or RequestId was already recorded. It runs once per streamed AgentResponseUpdate, so an agent that re-emits one call across updates never reached its request: the throw escaped InvokeAgentAsync and ended the run in ExecutorFailedEvent and WorkflowErrorEvent. AIAgentHostExecutor and HandoffAgentExecutor both build this collector, so both were affected. A repeated ID inside one run is one pending request. The collector now keeps the first content seen, matching AIContentExternalHandler, 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 cannot be serviced alongside the recorded one, so SubmitAsync reports those IDs in a WorkflowWarningEvent instead of dropping them silently. HandoffAgentExecutor collected handoff candidates on every emission, so one re-emitted handoff call read as two competing handoffs. It now skips a candidate whose CallId is already collected. --- .../AIAgentUnservicedRequestsCollector.cs | 62 +++++++++--- .../Specialized/HandoffAgentExecutor.cs | 5 +- .../AIAgentHostExecutorTests.cs | 77 +++++++++++++++ .../HandoffAgentExecutorTests.cs | 62 ++++++++++++ .../WorkflowHostSmokeTests.cs | 96 ++++++++++++++++++- 5 files changed, 287 insertions(+), 15 deletions(-) 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))) { 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..4ab7736829a 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,67 @@ 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) : 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) + { + string handoffFunctionName = (options as ChatClientAgentRunOptions)?.ChatOptions?.Tools? + .FirstOrDefault(tool => tool.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name + ?? throw new InvalidOperationException("No handoff tool was offered to the agent."); + + for (int emission = 0; emission < emissionCount; emission++) + { + 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_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. /// From 1ee6996b9298aea5bf8b6367a64347901a2a3627 Mon Sep 17 00:00:00 2001 From: yashvanthange Date: Sat, 29 Aug 2026 16:15:00 +0530 Subject: [PATCH 2/2] .NET: keep the handoff target in the repeated-call comparison The handoff candidate filter coalesced on call ID alone, so two different handoff targets sharing one call ID collapsed into the first and the duplicate handoff warning no longer fired for them. It now compares the target name alongside the ID, which is the same rule the collector applies to a repeated call, so only a genuine re-emission is coalesced. --- .../Specialized/HandoffAgentExecutor.cs | 7 +++- .../HandoffAgentExecutorTests.cs | 42 +++++++++++++++++-- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs index 65ff469fb5f..508cfe8fd2d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs @@ -446,9 +446,12 @@ bool CollectHandoffRequestsFilter(FunctionCallContent candidateHandoffRequest) { bool isHandoffRequest = this._handoffFunctionNames.Contains(candidateHandoffRequest.Name); - // A stream that re-emits one handoff call must not read as two competing handoffs. + // A stream that re-emits one handoff call must not read as two competing handoffs. The name is + // compared alongside the ID so that two different targets sharing an ID stay visible below. if (isHandoffRequest - && !candidateRequests.Any(candidate => string.Equals(candidate.Request.CallId, candidateHandoffRequest.CallId, StringComparison.Ordinal))) + && !candidateRequests.Any(candidate => + 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/HandoffAgentExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs index 4ab7736829a..16f3cc6a89b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs @@ -181,7 +181,7 @@ public async Task Test_HandoffAgentExecutor_ComposesWithHITLSubworkflowAsync() /// 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) : AIAgent + private sealed class RepeatingHandoffAgent(int emissionCount, bool varyTarget = false) : AIAgent { private sealed class Session : AgentSession { @@ -202,12 +202,20 @@ protected override Task RunCoreAsync(IEnumerable mes protected override async IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - string handoffFunctionName = (options as ChatClientAgentRunOptions)?.ChatOptions?.Tools? - .FirstOrDefault(tool => tool.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name - ?? throw new InvalidOperationException("No handoff tool was offered to the agent."); + 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)]); } } @@ -238,6 +246,32 @@ public async Task Test_HandoffAgentExecutor_RepeatedHandoffCallIsNotTreatedAsDup .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() {