From 08f3c3f22def8adc6626be649c75880accab92a5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 14:35:00 +0000 Subject: [PATCH 1/5] Initial plan From 2fdfb0ae68d963d3a80e2eff66006591d534db5b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 14:48:36 +0000 Subject: [PATCH 2/5] Fix FunctionInvokingChatClient to remove tools on last iteration - Add PrepareOptionsForLastIteration helper method that removes AIFunctionDeclaration tools from options on the last iteration - Modify GetResponseAsync to call PrepareOptionsForLastIteration when iteration >= MaximumIterationsPerRequest - Modify GetStreamingResponseAsync similarly - If no tools remain after filtering, also clear ToolMode - Add 4 comprehensive tests for the new behavior Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../FunctionInvokingChatClient.cs | 57 +++++ .../FunctionInvokingChatClientTests.cs | 207 ++++++++++++++++++ 2 files changed, 264 insertions(+) diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs index cc43c192f89..9ef15b00066 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs @@ -319,6 +319,14 @@ public override async Task GetResponseAsync( { functionCallContents?.Clear(); + // On the last iteration, we won't be processing any function calls, so we should not + // include AIFunctionDeclaration tools in the request to prevent the inner client from + // returning tool call requests that won't be handled. + if (iteration >= MaximumIterationsPerRequest) + { + PrepareOptionsForLastIteration(ref options); + } + // Make the call to the inner client. response = await base.GetResponseAsync(messages, options, cancellationToken); if (response is null) @@ -486,6 +494,14 @@ public override async IAsyncEnumerable GetStreamingResponseA updates.Clear(); functionCallContents?.Clear(); + // On the last iteration, we won't be processing any function calls, so we should not + // include AIFunctionDeclaration tools in the request to prevent the inner client from + // returning tool call requests that won't be handled. + if (iteration >= MaximumIterationsPerRequest) + { + PrepareOptionsForLastIteration(ref options); + } + bool hasApprovalRequiringFcc = false; int lastApprovalCheckedFCCIndex = 0; int lastYieldedUpdateIndex = 0; @@ -824,6 +840,47 @@ private static void UpdateOptionsForNextIteration(ref ChatOptions? options, stri } } + /// + /// Prepares options for the last iteration by removing AIFunctionDeclaration tools. + /// + /// The chat options to prepare. + /// + /// On the last iteration, we won't be processing any function calls, so we should not + /// include AIFunctionDeclaration tools in the request. This prevents the inner client + /// from returning tool call requests that won't be handled. + /// + private static void PrepareOptionsForLastIteration(ref ChatOptions? options) + { + if (options?.Tools is not { Count: > 0 }) + { + return; + } + + // Filter out AIFunctionDeclaration tools, keeping only non-function tools + List? remainingTools = null; + foreach (var tool in options.Tools) + { + if (tool is not AIFunctionDeclaration) + { + remainingTools ??= []; + remainingTools.Add(tool); + } + } + + // Only clone and modify if we're actually removing tools + if (remainingTools is null || remainingTools.Count < options.Tools.Count) + { + options = options.Clone(); + options.Tools = remainingTools; + + // If no tools remain, clear the ToolMode as well + if (remainingTools is null || remainingTools.Count == 0) + { + options.ToolMode = null; + } + } + } + /// Gets whether the function calling loop should exit based on the function call requests. /// The call requests. /// The map from tool names to tools. diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs index 4d086ebf61e..2bebeab9b17 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs @@ -395,6 +395,213 @@ public async Task ContinuesWithSuccessfulCallsUntilMaximumIterations() Assert.Equal(maxIterations, actualCallCount); } + [Fact] + public async Task LastIteration_RemovesFunctionDeclarationTools_NonStreaming() + { + // Arrange: Set up tracking of options passed to inner client + List capturedOptions = []; + var maxIterations = 2; + + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = (contents, options, cancellationToken) => + { + // Capture a clone of the options to avoid mutation + capturedOptions.Add(options?.Clone()); + + // Always return a function call to keep the loop going + var message = new ChatMessage(ChatRole.Assistant, [new FunctionCallContent($"callId{capturedOptions.Count}", "Func1")]); + return Task.FromResult(new ChatResponse(message)); + } + }; + + using var client = new FunctionInvokingChatClient(innerClient) + { + MaximumIterationsPerRequest = maxIterations + }; + + var options = new ChatOptions + { + Tools = [AIFunctionFactory.Create(() => "Result", "Func1")], + ToolMode = ChatToolMode.Auto + }; + + // Act + await client.GetResponseAsync("hello", options); + + // Assert: Should have made maxIterations + 1 calls (0 through maxIterations) + Assert.Equal(maxIterations + 1, capturedOptions.Count); + + // First maxIterations calls should have the tools + for (int i = 0; i < maxIterations; i++) + { + Assert.NotNull(capturedOptions[i]?.Tools); + Assert.Single(capturedOptions[i]!.Tools!); + } + + // Last call (at iteration == maxIterations) should have no tools + Assert.True(capturedOptions[maxIterations]?.Tools is null || capturedOptions[maxIterations]!.Tools!.Count == 0); + Assert.Null(capturedOptions[maxIterations]?.ToolMode); + } + + [Fact] + public async Task LastIteration_RemovesFunctionDeclarationTools_Streaming() + { + // Arrange: Set up tracking of options passed to inner client + List capturedOptions = []; + var maxIterations = 2; + + using var innerClient = new TestChatClient + { + GetStreamingResponseAsyncCallback = (contents, options, cancellationToken) => + { + // Capture a clone of the options to avoid mutation + capturedOptions.Add(options?.Clone()); + + // Always return a function call to keep the loop going + var message = new ChatMessage(ChatRole.Assistant, [new FunctionCallContent($"callId{capturedOptions.Count}", "Func1")]); + return YieldAsync(new ChatResponse(message).ToChatResponseUpdates()); + } + }; + + using var client = new FunctionInvokingChatClient(innerClient) + { + MaximumIterationsPerRequest = maxIterations + }; + + var options = new ChatOptions + { + Tools = [AIFunctionFactory.Create(() => "Result", "Func1")], + ToolMode = ChatToolMode.Auto + }; + + // Act + await client.GetStreamingResponseAsync("hello", options).ToChatResponseAsync(); + + // Assert: Should have made maxIterations + 1 calls (0 through maxIterations) + Assert.Equal(maxIterations + 1, capturedOptions.Count); + + // First maxIterations calls should have the tools + for (int i = 0; i < maxIterations; i++) + { + Assert.NotNull(capturedOptions[i]?.Tools); + Assert.Single(capturedOptions[i]!.Tools!); + } + + // Last call (at iteration == maxIterations) should have no tools + Assert.True(capturedOptions[maxIterations]?.Tools is null || capturedOptions[maxIterations]!.Tools!.Count == 0); + Assert.Null(capturedOptions[maxIterations]?.ToolMode); + } + + [Fact] + public async Task LastIteration_PreservesNonFunctionDeclarationTools() + { + // Arrange: Create a mock non-function tool + var mockTool = new MockNonFunctionTool("MockTool"); + List capturedOptions = []; + var maxIterations = 1; + + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = (contents, options, cancellationToken) => + { + // Capture a clone of the options + capturedOptions.Add(options?.Clone()); + + // Return a function call on first iteration, then text on second + if (capturedOptions.Count == 1) + { + var message = new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]); + return Task.FromResult(new ChatResponse(message)); + } + else + { + var message = new ChatMessage(ChatRole.Assistant, "Done"); + return Task.FromResult(new ChatResponse(message)); + } + } + }; + + using var client = new FunctionInvokingChatClient(innerClient) + { + MaximumIterationsPerRequest = maxIterations + }; + + var options = new ChatOptions + { + Tools = [AIFunctionFactory.Create(() => "Result", "Func1"), mockTool], + ToolMode = ChatToolMode.Auto + }; + + // Act + await client.GetResponseAsync("hello", options); + + // Assert: Should have made 2 calls + Assert.Equal(2, capturedOptions.Count); + + // First call should have both tools + Assert.NotNull(capturedOptions[0]?.Tools); + Assert.Equal(2, capturedOptions[0]!.Tools!.Count); + + // Last call should only have the non-function tool + Assert.NotNull(capturedOptions[1]?.Tools); + Assert.Single(capturedOptions[1]!.Tools!); + Assert.IsType(capturedOptions[1]!.Tools![0]); + + // ToolMode should be preserved since we still have tools + Assert.NotNull(capturedOptions[1]?.ToolMode); + } + + [Fact] + public async Task LastIteration_DoesNotModifyOriginalOptions() + { + // Arrange + List capturedOptions = []; + var maxIterations = 1; + + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = (contents, options, cancellationToken) => + { + capturedOptions.Add(options); + var message = new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]); + return Task.FromResult(new ChatResponse(message)); + } + }; + + using var client = new FunctionInvokingChatClient(innerClient) + { + MaximumIterationsPerRequest = maxIterations + }; + + var originalTool = AIFunctionFactory.Create(() => "Result", "Func1"); + var originalOptions = new ChatOptions + { + Tools = [originalTool], + ToolMode = ChatToolMode.Auto + }; + + // Act + await client.GetResponseAsync("hello", originalOptions); + + // Assert: Original options should not be modified + Assert.NotNull(originalOptions.Tools); + Assert.Single(originalOptions.Tools); + Assert.Same(originalTool, originalOptions.Tools[0]); + Assert.NotNull(originalOptions.ToolMode); + } + + private sealed class MockNonFunctionTool : AITool + { + public MockNonFunctionTool(string name) + { + Name = name; + } + + public override string Name { get; } + public override string Description => "A mock tool that is not an AIFunctionDeclaration"; + } + [Theory] [InlineData(false)] [InlineData(true)] From e5210688bf48a77e8ef41f75455f8db1466b301d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 14:53:07 +0000 Subject: [PATCH 3/5] Address code review feedback - Simplify condition logic in PrepareOptionsForLastIteration by using a local variable for remaining count - Simplify test assertions for better readability Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../ChatCompletion/FunctionInvokingChatClient.cs | 7 ++++--- .../FunctionInvokingChatClientTests.cs | 16 ++++++++++------ 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs index 9ef15b00066..f77f047e93d 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs @@ -867,14 +867,15 @@ private static void PrepareOptionsForLastIteration(ref ChatOptions? options) } } - // Only clone and modify if we're actually removing tools - if (remainingTools is null || remainingTools.Count < options.Tools.Count) + // If we removed any tools (including removing all of them), clone and update options + int remainingCount = remainingTools?.Count ?? 0; + if (remainingCount < options.Tools.Count) { options = options.Clone(); options.Tools = remainingTools; // If no tools remain, clear the ToolMode as well - if (remainingTools is null || remainingTools.Count == 0) + if (remainingCount == 0) { options.ToolMode = null; } diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs index 2bebeab9b17..3eabfc0fa68 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs @@ -439,9 +439,11 @@ public async Task LastIteration_RemovesFunctionDeclarationTools_NonStreaming() Assert.Single(capturedOptions[i]!.Tools!); } - // Last call (at iteration == maxIterations) should have no tools - Assert.True(capturedOptions[maxIterations]?.Tools is null || capturedOptions[maxIterations]!.Tools!.Count == 0); - Assert.Null(capturedOptions[maxIterations]?.ToolMode); + // Last call (at iteration == maxIterations) should have no tools and no ToolMode + var lastOptions = capturedOptions[maxIterations]; + Assert.NotNull(lastOptions); + Assert.Null(lastOptions!.Tools); + Assert.Null(lastOptions.ToolMode); } [Fact] @@ -488,9 +490,11 @@ public async Task LastIteration_RemovesFunctionDeclarationTools_Streaming() Assert.Single(capturedOptions[i]!.Tools!); } - // Last call (at iteration == maxIterations) should have no tools - Assert.True(capturedOptions[maxIterations]?.Tools is null || capturedOptions[maxIterations]!.Tools!.Count == 0); - Assert.Null(capturedOptions[maxIterations]?.ToolMode); + // Last call (at iteration == maxIterations) should have no tools and no ToolMode + var lastOptions = capturedOptions[maxIterations]; + Assert.NotNull(lastOptions); + Assert.Null(lastOptions!.Tools); + Assert.Null(lastOptions.ToolMode); } [Fact] From aec946ece4cf8a7d075b9697f9a593e02726d9c7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 16:29:20 +0000 Subject: [PATCH 4/5] Address PR feedback: remove comments and use HostedWebSearchTool - Remove all Arrange/Act/Assert comments from tests - Replace MockNonFunctionTool with HostedWebSearchTool - Delete the MockNonFunctionTool class Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../FunctionInvokingChatClientTests.cs | 44 ++----------------- 1 file changed, 3 insertions(+), 41 deletions(-) diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs index 3eabfc0fa68..5f86af52c49 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs @@ -398,7 +398,6 @@ public async Task ContinuesWithSuccessfulCallsUntilMaximumIterations() [Fact] public async Task LastIteration_RemovesFunctionDeclarationTools_NonStreaming() { - // Arrange: Set up tracking of options passed to inner client List capturedOptions = []; var maxIterations = 2; @@ -406,10 +405,8 @@ public async Task LastIteration_RemovesFunctionDeclarationTools_NonStreaming() { GetResponseAsyncCallback = (contents, options, cancellationToken) => { - // Capture a clone of the options to avoid mutation capturedOptions.Add(options?.Clone()); - // Always return a function call to keep the loop going var message = new ChatMessage(ChatRole.Assistant, [new FunctionCallContent($"callId{capturedOptions.Count}", "Func1")]); return Task.FromResult(new ChatResponse(message)); } @@ -426,20 +423,16 @@ public async Task LastIteration_RemovesFunctionDeclarationTools_NonStreaming() ToolMode = ChatToolMode.Auto }; - // Act await client.GetResponseAsync("hello", options); - // Assert: Should have made maxIterations + 1 calls (0 through maxIterations) Assert.Equal(maxIterations + 1, capturedOptions.Count); - // First maxIterations calls should have the tools for (int i = 0; i < maxIterations; i++) { Assert.NotNull(capturedOptions[i]?.Tools); Assert.Single(capturedOptions[i]!.Tools!); } - // Last call (at iteration == maxIterations) should have no tools and no ToolMode var lastOptions = capturedOptions[maxIterations]; Assert.NotNull(lastOptions); Assert.Null(lastOptions!.Tools); @@ -449,7 +442,6 @@ public async Task LastIteration_RemovesFunctionDeclarationTools_NonStreaming() [Fact] public async Task LastIteration_RemovesFunctionDeclarationTools_Streaming() { - // Arrange: Set up tracking of options passed to inner client List capturedOptions = []; var maxIterations = 2; @@ -457,10 +449,8 @@ public async Task LastIteration_RemovesFunctionDeclarationTools_Streaming() { GetStreamingResponseAsyncCallback = (contents, options, cancellationToken) => { - // Capture a clone of the options to avoid mutation capturedOptions.Add(options?.Clone()); - // Always return a function call to keep the loop going var message = new ChatMessage(ChatRole.Assistant, [new FunctionCallContent($"callId{capturedOptions.Count}", "Func1")]); return YieldAsync(new ChatResponse(message).ToChatResponseUpdates()); } @@ -477,20 +467,16 @@ public async Task LastIteration_RemovesFunctionDeclarationTools_Streaming() ToolMode = ChatToolMode.Auto }; - // Act await client.GetStreamingResponseAsync("hello", options).ToChatResponseAsync(); - // Assert: Should have made maxIterations + 1 calls (0 through maxIterations) Assert.Equal(maxIterations + 1, capturedOptions.Count); - // First maxIterations calls should have the tools for (int i = 0; i < maxIterations; i++) { Assert.NotNull(capturedOptions[i]?.Tools); Assert.Single(capturedOptions[i]!.Tools!); } - // Last call (at iteration == maxIterations) should have no tools and no ToolMode var lastOptions = capturedOptions[maxIterations]; Assert.NotNull(lastOptions); Assert.Null(lastOptions!.Tools); @@ -500,8 +486,7 @@ public async Task LastIteration_RemovesFunctionDeclarationTools_Streaming() [Fact] public async Task LastIteration_PreservesNonFunctionDeclarationTools() { - // Arrange: Create a mock non-function tool - var mockTool = new MockNonFunctionTool("MockTool"); + var hostedTool = new HostedWebSearchTool(); List capturedOptions = []; var maxIterations = 1; @@ -509,10 +494,8 @@ public async Task LastIteration_PreservesNonFunctionDeclarationTools() { GetResponseAsyncCallback = (contents, options, cancellationToken) => { - // Capture a clone of the options capturedOptions.Add(options?.Clone()); - // Return a function call on first iteration, then text on second if (capturedOptions.Count == 1) { var message = new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]); @@ -533,33 +516,25 @@ public async Task LastIteration_PreservesNonFunctionDeclarationTools() var options = new ChatOptions { - Tools = [AIFunctionFactory.Create(() => "Result", "Func1"), mockTool], + Tools = [AIFunctionFactory.Create(() => "Result", "Func1"), hostedTool], ToolMode = ChatToolMode.Auto }; - // Act await client.GetResponseAsync("hello", options); - // Assert: Should have made 2 calls Assert.Equal(2, capturedOptions.Count); - - // First call should have both tools Assert.NotNull(capturedOptions[0]?.Tools); Assert.Equal(2, capturedOptions[0]!.Tools!.Count); - // Last call should only have the non-function tool Assert.NotNull(capturedOptions[1]?.Tools); Assert.Single(capturedOptions[1]!.Tools!); - Assert.IsType(capturedOptions[1]!.Tools![0]); - - // ToolMode should be preserved since we still have tools + Assert.IsType(capturedOptions[1]!.Tools![0]); Assert.NotNull(capturedOptions[1]?.ToolMode); } [Fact] public async Task LastIteration_DoesNotModifyOriginalOptions() { - // Arrange List capturedOptions = []; var maxIterations = 1; @@ -585,27 +560,14 @@ public async Task LastIteration_DoesNotModifyOriginalOptions() ToolMode = ChatToolMode.Auto }; - // Act await client.GetResponseAsync("hello", originalOptions); - // Assert: Original options should not be modified Assert.NotNull(originalOptions.Tools); Assert.Single(originalOptions.Tools); Assert.Same(originalTool, originalOptions.Tools[0]); Assert.NotNull(originalOptions.ToolMode); } - private sealed class MockNonFunctionTool : AITool - { - public MockNonFunctionTool(string name) - { - Name = name; - } - - public override string Name { get; } - public override string Description => "A mock tool that is not an AIFunctionDeclaration"; - } - [Theory] [InlineData(false)] [InlineData(true)] From 72a08db89f357b5b9542e1924abd8b0b89adb04c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 20 Jan 2026 03:26:02 +0000 Subject: [PATCH 5/5] Add Debug-level logging when max iterations reached Add LogMaximumIterationsReached LoggerMessage that logs at Debug level when the function invocation loop is ending due to reaching the configured maximum iteration count. Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../ChatCompletion/FunctionInvokingChatClient.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs index f77f047e93d..2344c399d4f 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs @@ -324,6 +324,7 @@ public override async Task GetResponseAsync( // returning tool call requests that won't be handled. if (iteration >= MaximumIterationsPerRequest) { + LogMaximumIterationsReached(MaximumIterationsPerRequest); PrepareOptionsForLastIteration(ref options); } @@ -499,6 +500,7 @@ public override async IAsyncEnumerable GetStreamingResponseA // returning tool call requests that won't be handled. if (iteration >= MaximumIterationsPerRequest) { + LogMaximumIterationsReached(MaximumIterationsPerRequest); PrepareOptionsForLastIteration(ref options); } @@ -1751,6 +1753,9 @@ private static TimeSpan GetElapsedTime(long startingTimestamp) => [LoggerMessage(LogLevel.Error, "{MethodName} invocation failed.")] private partial void LogInvocationFailed(string methodName, Exception error); + [LoggerMessage(LogLevel.Debug, "Reached maximum iteration count of {MaximumIterationsPerRequest}. Stopping function invocation loop.")] + private partial void LogMaximumIterationsReached(int maximumIterationsPerRequest); + /// Provides information about the invocation of a function call. public sealed class FunctionInvocationResult {