From 0b2ccd61263cff227af0455c3b2a39d8053c9c80 Mon Sep 17 00:00:00 2001 From: chetantoshniwal Date: Wed, 25 Mar 2026 13:02:16 -0700 Subject: [PATCH 1/2] .NET: Fix ChatOptions mutation in AIContextProviderChatClient across calls (#4891) * Fix UseAIContextProviders tool accumulation across calls (#4864) Clone ChatOptions before mutating it in InvokeProvidersAsync to prevent context provider tools from accumulating when the same ChatOptions instance is reused across multiple API calls. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: chetantoshniwal * Apply suggestion from @westey-m --------- Co-authored-by: MAF Dashboard Bot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: westey <164392973+westey-m@users.noreply.github.com> --- .../AIContextProviderChatClient.cs | 2 + .../AIContextProviderChatClientTests.cs | 161 ++++++++++++++++++ 2 files changed, 163 insertions(+) diff --git a/dotnet/src/Microsoft.Agents.AI/AIContextProviderDecorators/AIContextProviderChatClient.cs b/dotnet/src/Microsoft.Agents.AI/AIContextProviderDecorators/AIContextProviderChatClient.cs index 305abe0465..bf93832232 100644 --- a/dotnet/src/Microsoft.Agents.AI/AIContextProviderDecorators/AIContextProviderChatClient.cs +++ b/dotnet/src/Microsoft.Agents.AI/AIContextProviderDecorators/AIContextProviderChatClient.cs @@ -161,6 +161,8 @@ internal sealed class AIContextProviderChatClient : DelegatingChatClient } // Materialize the accumulated context back into messages and options. + // Clone options to avoid mutating the caller's instance across calls. + options = options?.Clone(); var enrichedMessages = aiContext.Messages ?? []; var tools = aiContext.Tools as IList ?? aiContext.Tools?.ToList(); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AIContextProviderDecorators/AIContextProviderChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AIContextProviderDecorators/AIContextProviderChatClientTests.cs index 3b06bbb772..5e65c4a1a6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AIContextProviderDecorators/AIContextProviderChatClientTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AIContextProviderDecorators/AIContextProviderChatClientTests.cs @@ -250,6 +250,129 @@ public class AIContextProviderChatClientTests #endregion + #region Shared Options Tests + + [Fact] + public async Task GetResponseAsync_SharedOptions_ProviderToolsDoNotAccumulateAcrossCallsAsync() + { + // Arrange: track tool count seen by the inner client on each call + var toolCountsSeenByInner = new List(); + + var innerClient = CreateMockChatClient( + onGetResponse: (_, options, _) => + { + toolCountsSeenByInner.Add(options?.Tools?.Count ?? 0); + return Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Response")])); + }); + + var provider = new TestAIContextProvider("key1", provideTools: [new TestAITool()]); + var chatClient = new AIContextProviderChatClient(innerClient, [provider]); + + var sharedOptions = new ChatOptions + { + Tools = new List { new TestAITool() } + }; + + // Act: make 3 calls reusing the same ChatOptions + for (int i = 0; i < 3; i++) + { + await RunWithAgentContextAsync(chatClient, sharedOptions); + } + + // Assert: each call should see exactly 2 tools (1 baseline + 1 injected) + Assert.Equal(3, toolCountsSeenByInner.Count); + Assert.All(toolCountsSeenByInner, count => Assert.Equal(2, count)); + } + + [Fact] + public async Task GetResponseAsync_SharedOptions_OriginalToolsNotMutatedAsync() + { + // Arrange + var innerClient = CreateMockChatClient( + onGetResponse: (_, _, _) => + Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Response")]))); + + var provider = new TestAIContextProvider("key1", provideTools: [new TestAITool()]); + var chatClient = new AIContextProviderChatClient(innerClient, [provider]); + + var baselineTool = new TestAITool(); + var originalTools = new List { baselineTool }; + var sharedOptions = new ChatOptions + { + Tools = originalTools + }; + + // Act + await RunWithAgentContextAsync(chatClient, sharedOptions); + + // Assert: the original list should still contain only the baseline tool + Assert.Single(originalTools); + Assert.Same(baselineTool, originalTools[0]); + Assert.Same(originalTools, sharedOptions.Tools); + Assert.Same(baselineTool, originalTools[0]); + } + + [Fact] + public async Task GetStreamingResponseAsync_SharedOptions_ProviderToolsDoNotAccumulateAcrossCallsAsync() + { + // Arrange + var toolCountsSeenByInner = new List(); + + var innerClient = CreateMockStreamingChatClient( + onGetStreamingResponse: (_, options, _) => + { + toolCountsSeenByInner.Add(options?.Tools?.Count ?? 0); + return ToAsyncEnumerableAsync( + new ChatResponseUpdate(ChatRole.Assistant, "Response")); + }); + + var provider = new TestAIContextProvider("key1", provideTools: [new TestAITool()]); + var chatClient = new AIContextProviderChatClient(innerClient, [provider]); + + var sharedOptions = new ChatOptions + { + Tools = new List { new TestAITool() } + }; + + // Act: make 3 streaming calls reusing the same ChatOptions + for (int i = 0; i < 3; i++) + { + await RunStreamingWithAgentContextAsync(chatClient, [], sharedOptions); + } + + // Assert: each call should see exactly 2 tools (1 baseline + 1 injected) + Assert.Equal(3, toolCountsSeenByInner.Count); + Assert.All(toolCountsSeenByInner, count => Assert.Equal(2, count)); + } + + [Fact] + public async Task GetStreamingResponseAsync_SharedOptions_OriginalToolsNotMutatedAsync() + { + // Arrange + var innerClient = CreateMockStreamingChatClient( + onGetStreamingResponse: (_, _, _) => ToAsyncEnumerableAsync( + new ChatResponseUpdate(ChatRole.Assistant, "Response"))); + + var provider = new TestAIContextProvider("key1", provideTools: [new TestAITool()]); + var chatClient = new AIContextProviderChatClient(innerClient, [provider]); + + var baselineTool = new TestAITool(); + var originalTools = new List { baselineTool }; + var sharedOptions = new ChatOptions + { + Tools = originalTools + }; + + // Act + await RunStreamingWithAgentContextAsync(chatClient, [], sharedOptions); + + // Assert: the original list should still contain only the baseline tool + Assert.Single(originalTools); + Assert.Same(baselineTool, originalTools[0]); + } + + #endregion + #region Builder Extension Tests [Fact] @@ -341,6 +464,44 @@ public class AIContextProviderChatClientTests await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession); } + /// + /// Runs a chat client within an agent context with the specified options. + /// + private static async Task RunWithAgentContextAsync(AIContextProviderChatClient chatClient, ChatOptions options) + { + var agent = new TestAIAgent + { + RunAsyncFunc = async (messages, session, agentOptions, ct) => + { + var response = await chatClient.GetResponseAsync(messages, options, ct); + return new AgentResponse(response); + } + }; + + await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession); + } + + /// + /// Runs a streaming chat client within an agent context with the specified options. + /// + private static async Task RunStreamingWithAgentContextAsync(AIContextProviderChatClient chatClient, List updates, ChatOptions options) + { + var agent = new TestAIAgent + { + RunAsyncFunc = async (messages, session, agentOptions, ct) => + { + await foreach (var update in chatClient.GetStreamingResponseAsync(messages, options, ct)) + { + updates.Add(update); + } + + return new AgentResponse([new ChatMessage(ChatRole.Assistant, "done")]); + } + }; + + await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession); + } + private static IChatClient CreateMockChatClient( Func, ChatOptions?, CancellationToken, Task> onGetResponse) { From 0756c457022ce98065f0786db8cdea7f56c859e3 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 25 Mar 2026 16:35:17 -0400 Subject: [PATCH 2/2] .NET: [BREAKING] Update type names and source generator to reduce conflicts (#4903) * refactor: [BREAKING] Config => ExecutorConfig Make the Config name less likely to collide with other classes by renaming to ExecutorConfig. Makes Configured and related classes internal as they do not need to be part of the public surface. * fix: Make RouteBuilder explicit in SourceGen to avoid conflicts --- .../Generation/SourceBuilder.cs | 1 + .../ConfigurationExtensions.cs | 4 +-- .../Configured.cs | 26 +++++++++---------- .../ExecutorBindingExtensions.cs | 4 +-- .../{Config.cs => ExecutorConfig.cs} | 4 +-- 5 files changed, 20 insertions(+), 19 deletions(-) rename dotnet/src/Microsoft.Agents.AI.Workflows/{Config.cs => ExecutorConfig.cs} (88%) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Generation/SourceBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Generation/SourceBuilder.cs index 9a74c88447..23d748e629 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Generation/SourceBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Generation/SourceBuilder.cs @@ -38,6 +38,7 @@ internal static class SourceBuilder sb.AppendLine("using System.Collections.Generic;"); sb.AppendLine("using Microsoft.Agents.AI.Workflows;"); sb.AppendLine(); + sb.AppendLine("using RouteBuilder = Microsoft.Agents.AI.Workflows.RouteBuilder;"); // Namespace if (!string.IsNullOrWhiteSpace(info.Namespace)) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ConfigurationExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ConfigurationExtensions.cs index e18bae72a5..d6e6df5dbd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/ConfigurationExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ConfigurationExtensions.cs @@ -3,9 +3,9 @@ namespace Microsoft.Agents.AI.Workflows; /// -/// Provides extensions methods for creating objects +/// Provides extension methods for creating objects /// -public static class ConfigurationExtensions +internal static class ConfigurationExtensions { /// /// Creates a new configuration that treats the subject as its base type, allowing configuration to be applied at diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Configured.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Configured.cs index 3f876926be..b154bd6ca7 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Configured.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Configured.cs @@ -8,7 +8,7 @@ namespace Microsoft.Agents.AI.Workflows; /// /// Provides methods for creating instances. /// -public static class Configured +internal static class Configured { /// /// Creates a instance from an existing subject instance. @@ -50,10 +50,10 @@ public static class Configured /// A representation of a preconfigured, lazy-instantiatable instance of . /// /// The type of the preconfigured subject. -/// A factory to intantiate the subject when desired. +/// A factory to instantiate the subject when desired. /// The unique identifier for the configured subject. /// -public class Configured(Func> factoryAsync, string id, object? raw = null) +internal class Configured(Func> factoryAsync, string id, object? raw = null) { /// /// Gets the raw representation of the configured object, if any. @@ -66,14 +66,14 @@ public class Configured(Func> fact public string Id => id; /// - /// Gets the factory function to create an instance of given a . + /// Gets the factory function to create an instance of given a . /// - public Func> FactoryAsync => factoryAsync; + public Func> FactoryAsync => factoryAsync; /// /// The configuration for this configured instance. /// - public Config Configuration => new(this.Id); + public ExecutorConfig Configuration => new(this.Id); /// /// Gets a "partially" applied factory function that only requires no parameters to create an instance of @@ -87,11 +87,11 @@ public class Configured(Func> fact /// /// The type of the preconfigured subject. /// The type of configuration options for the preconfigured subject. -/// A factory to intantiate the subject when desired. +/// A factory to instantiate the subject when desired. /// The unique identifier for the configured subject. /// Additional configuration options for the subject. /// -public class Configured(Func, string, ValueTask> factoryAsync, string id, TOptions? options = default, object? raw = null) +internal class Configured(Func, string, ValueTask> factoryAsync, string id, TOptions? options = default, object? raw = null) { /// /// The raw representation of the configured object, if any. @@ -109,14 +109,14 @@ public class Configured(Func, string, Value public TOptions? Options => options; /// - /// Gets the factory function to create an instance of given a . + /// Gets the factory function to create an instance of given a . /// - public Func, string, ValueTask> FactoryAsync => factoryAsync; + public Func, string, ValueTask> FactoryAsync => factoryAsync; /// /// The configuration for this configured instance. /// - public Config Configuration => new(this.Id, this.Options); + public ExecutorConfig Configuration => new(this.Id, this.Options); /// /// Gets a "partially" applied factory function that only requires no parameters to create an instance of @@ -124,11 +124,11 @@ public class Configured(Func, string, Value /// internal Func> BoundFactoryAsync => (sessionId) => this.CreateValidatingMemoizedFactory()(this.Configuration, sessionId); - private Func> CreateValidatingMemoizedFactory() + private Func> CreateValidatingMemoizedFactory() { return FactoryAsync; - async ValueTask FactoryAsync(Config configuration, string sessionId) + async ValueTask FactoryAsync(ExecutorConfig configuration, string sessionId) { if (this.Id != configuration.Id) { diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorBindingExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorBindingExtensions.cs index a0170e7757..afca74af5b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorBindingExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorBindingExtensions.cs @@ -113,7 +113,7 @@ public static class ExecutorBindingExtensions /// An id for the executor to be instantiated. /// An optional parameter specifying the options. /// An instance that resolves to the result of the factory call when messages get sent to it. - public static ExecutorBinding BindExecutor(this Func, string, ValueTask> factoryAsync, string id, TOptions? options = null) + public static ExecutorBinding BindExecutor(this Func, string, ValueTask> factoryAsync, string id, TOptions? options = null) where TExecutor : Executor where TOptions : ExecutorOptions { @@ -139,7 +139,7 @@ public static class ExecutorBindingExtensions /// An instance that resolves to the result of the factory call when messages get sent to it. [Obsolete("Use BindExecutor() instead")] [EditorBrowsable(EditorBrowsableState.Never)] - public static ExecutorBinding ConfigureFactory(this Func, string, ValueTask> factoryAsync, string id, TOptions? options = null) + public static ExecutorBinding ConfigureFactory(this Func, string, ValueTask> factoryAsync, string id, TOptions? options = null) where TExecutor : Executor where TOptions : ExecutorOptions => factoryAsync.BindExecutor(id, options); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Config.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorConfig.cs similarity index 88% rename from dotnet/src/Microsoft.Agents.AI.Workflows/Config.cs rename to dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorConfig.cs index 09792d2a64..48bfd12bb9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Config.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorConfig.cs @@ -6,7 +6,7 @@ namespace Microsoft.Agents.AI.Workflows; /// Represents a configuration for an object with a string identifier. For example, object. /// /// A unique identifier for the configurable object. -public class Config(string id) +public class ExecutorConfig(string id) { /// /// Gets a unique identifier for the configurable object. @@ -23,7 +23,7 @@ public class Config(string id) /// The type of options for the configurable object. /// A unique identifier for the configurable object. /// The options for the configurable object. -public class Config(string id, TOptions? options = default) : Config(id) +public class ExecutorConfig(string id, TOptions? options = default) : ExecutorConfig(id) { /// /// Gets the options for the configured object.