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. 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) {