diff --git a/dotnet/samples/01-get-started/04_memory/Program.cs b/dotnet/samples/01-get-started/04_memory/Program.cs index 3705e64f3a..a97941620f 100644 --- a/dotnet/samples/01-get-started/04_memory/Program.cs +++ b/dotnet/samples/01-get-started/04_memory/Program.cs @@ -89,6 +89,7 @@ namespace SampleApp internal sealed class UserInfoMemory : AIContextProvider { private readonly ProviderSessionState _sessionState; + private IReadOnlyList? _stateKeys; private readonly IChatClient _chatClient; public UserInfoMemory(IChatClient chatClient, Func? stateInitializer = null) @@ -99,7 +100,7 @@ public UserInfoMemory(IChatClient chatClient, Func? sta this._chatClient = chatClient; } - public override string StateKey => this._sessionState.StateKey; + public override IReadOnlyList StateKeys => this._stateKeys ??= [this._sessionState.StateKey]; public UserInfo GetUserInfo(AgentSession session) => this._sessionState.GetOrInitializeState(session); diff --git a/dotnet/samples/02-agents/Agents/Agent_Step04_3rdPartyChatHistoryStorage/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step04_3rdPartyChatHistoryStorage/Program.cs index 63fa5c0751..78a8952082 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step04_3rdPartyChatHistoryStorage/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step04_3rdPartyChatHistoryStorage/Program.cs @@ -79,6 +79,7 @@ namespace SampleApp internal sealed class VectorChatHistoryProvider : ChatHistoryProvider { private readonly ProviderSessionState _sessionState; + private IReadOnlyList? _stateKeys; private readonly VectorStore _vectorStore; public VectorChatHistoryProvider( @@ -92,7 +93,7 @@ public VectorChatHistoryProvider( this._vectorStore = vectorStore ?? throw new ArgumentNullException(nameof(vectorStore)); } - public override string StateKey => this._sessionState.StateKey; + public override IReadOnlyList StateKeys => this._stateKeys ??= [this._sessionState.StateKey]; public string GetSessionDbKey(AgentSession session) => this._sessionState.GetOrInitializeState(session).SessionDbKey; diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIContextProvider.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIContextProvider.cs index 82e5f2c360..5ccf139363 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIContextProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIContextProvider.cs @@ -36,6 +36,8 @@ private static IEnumerable DefaultExternalOnlyFilter(IEnumerable DefaultNoopFilter(IEnumerable messages) => messages; + private IReadOnlyList? _stateKeys; + /// /// Initializes a new instance of the class. /// @@ -68,14 +70,15 @@ protected AIContextProvider( protected Func, IEnumerable> StoreInputResponseMessageFilter { get; } /// - /// Gets the key used to store the provider state in the . + /// Gets the set of keys used to store the provider state in the . /// /// - /// The default value is the name of the concrete type (e.g. "TextSearchProvider"). - /// Implementations may override this to provide a custom key, for example when multiple - /// instances of the same provider type are used in the same session. + /// The default value is a single-element set containing the name of the concrete type (e.g. "TextSearchProvider"). + /// Implementations may override this to provide custom keys, for example when multiple + /// instances of the same provider type are used in the same session, or when a provider + /// stores state under more than one key. /// - public virtual string StateKey => this.GetType().Name; + public virtual IReadOnlyList StateKeys => this._stateKeys ??= [this.GetType().Name]; /// /// Called at the start of agent invocation to provide additional context. diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatHistoryProvider.cs index df9ff0069e..c7dfb4a233 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatHistoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatHistoryProvider.cs @@ -45,6 +45,7 @@ private static IEnumerable DefaultExcludeChatHistoryFilter(IEnumera private static IEnumerable DefaultNoopFilter(IEnumerable messages) => messages; + private IReadOnlyList? _stateKeys; private readonly Func, IEnumerable>? _provideOutputMessageFilter; private readonly Func, IEnumerable> _storeInputRequestMessageFilter; private readonly Func, IEnumerable> _storeInputResponseMessageFilter; @@ -66,14 +67,15 @@ protected ChatHistoryProvider( } /// - /// Gets the key used to store the provider state in the . + /// Gets the set of keys used to store the provider state in the . /// /// - /// The default value is the name of the concrete type (e.g. "InMemoryChatHistoryProvider"). - /// Implementations may override this to provide a custom key, for example when multiple - /// instances of the same provider type are used in the same session. + /// The default value is a single-element set containing the name of the concrete type (e.g. "InMemoryChatHistoryProvider"). + /// Implementations may override this to provide custom keys, for example when multiple + /// instances of the same provider type are used in the same session, or when a provider + /// stores state under more than one key. /// - public virtual string StateKey => this.GetType().Name; + public virtual IReadOnlyList StateKeys => this._stateKeys ??= [this.GetType().Name]; /// /// Called at the start of agent invocation to provide messages for the next agent invocation. diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs index e09dd6b0a0..7c7b28b7bd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs @@ -27,6 +27,7 @@ namespace Microsoft.Agents.AI; public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider { private readonly ProviderSessionState _sessionState; + private IReadOnlyList? _stateKeys; /// /// Initializes a new instance of the class. @@ -50,7 +51,7 @@ public InMemoryChatHistoryProvider(InMemoryChatHistoryProviderOptions? options = } /// - public override string StateKey => this._sessionState.StateKey; + public override IReadOnlyList StateKeys => this._stateKeys ??= [this._sessionState.StateKey]; /// /// Gets the chat reducer used to process or reduce chat messages. If null, no reduction logic will be applied. diff --git a/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosChatHistoryProvider.cs index afaa59ee53..c9238889c9 100644 --- a/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosChatHistoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosChatHistoryProvider.cs @@ -22,6 +22,7 @@ namespace Microsoft.Agents.AI; public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable { private readonly ProviderSessionState _sessionState; + private IReadOnlyList? _stateKeys; private readonly CosmosClient _cosmosClient; private readonly Container _container; private readonly bool _ownsClient; @@ -114,7 +115,7 @@ public CosmosChatHistoryProvider( } /// - public override string StateKey => this._sessionState.StateKey; + public override IReadOnlyList StateKeys => this._stateKeys ??= [this._sessionState.StateKey]; /// /// Initializes a new instance of the class using a connection string. diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs index 0f7041e834..35baa055d1 100644 --- a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs @@ -32,6 +32,7 @@ public sealed class FoundryMemoryProvider : AIContextProvider private const string DefaultContextPrompt = "## Memories\nConsider the following memories when answering user questions:"; private readonly ProviderSessionState _sessionState; + private IReadOnlyList? _stateKeys; private readonly string _contextPrompt; private readonly string _memoryStoreName; private readonly int _maxMemories; @@ -82,7 +83,7 @@ public FoundryMemoryProvider( } /// - public override string StateKey => this._sessionState.StateKey; + public override IReadOnlyList StateKeys => this._stateKeys ??= [this._sessionState.StateKey]; private static Func ValidateStateInitializer(Func stateInitializer) => session => diff --git a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs index 1e325b5683..678905e395 100644 --- a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs @@ -27,6 +27,7 @@ public sealed class Mem0Provider : MessageAIContextProvider private const string DefaultContextPrompt = "## Memories\nConsider the following memories when answering user questions:"; private readonly ProviderSessionState _sessionState; + private IReadOnlyList? _stateKeys; private readonly string _contextPrompt; private readonly bool _enableSensitiveTelemetryData; @@ -72,7 +73,7 @@ public Mem0Provider(HttpClient httpClient, Func stateIniti } /// - public override string StateKey => this._sessionState.StateKey; + public override IReadOnlyList StateKeys => this._stateKeys ??= [this._sessionState.StateKey]; private static Func ValidateStateInitializer(Func stateInitializer) => session => diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowChatHistoryProvider.cs index 1fd42f923e..2815ed99f0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowChatHistoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowChatHistoryProvider.cs @@ -12,6 +12,7 @@ namespace Microsoft.Agents.AI.Workflows; internal sealed class WorkflowChatHistoryProvider : ChatHistoryProvider { private readonly ProviderSessionState _sessionState; + private IReadOnlyList? _stateKeys; /// /// Initializes a new instance of the class. @@ -30,7 +31,7 @@ public WorkflowChatHistoryProvider(JsonSerializerOptions? jsonSerializerOptions } /// - public override string StateKey => this._sessionState.StateKey; + public override IReadOnlyList StateKeys => this._stateKeys ??= [this._sessionState.StateKey]; internal sealed class StoreState { diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs index d52ea52e43..7db4eff6d8 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs @@ -112,7 +112,7 @@ public ChatClientAgent(IChatClient chatClient, ChatClientAgentOptions? options, this.ChatHistoryProvider = options?.ChatHistoryProvider ?? new InMemoryChatHistoryProvider(); this.AIContextProviders = this._agentOptions?.AIContextProviders as IReadOnlyList ?? this._agentOptions?.AIContextProviders?.ToList(); - // Validate that no two providers share the same StateKey, since they would overwrite each other's state in the session. + // Validate that no two providers share any StateKeys, since they would overwrite each other's state in the session. this._aiContextProviderStateKeys = ValidateAndCollectStateKeys(this._agentOptions?.AIContextProviders, this.ChatHistoryProvider); this._logger = (loggerFactory ?? chatClient.GetService() ?? NullLoggerFactory.Instance).CreateLogger(); @@ -824,11 +824,17 @@ private Task NotifyChatHistoryProviderOfNewMessagesAsync( $"Only {nameof(ChatClientAgentSession.ConversationId)} or {nameof(this.ChatHistoryProvider)} may be used, but not both. The current {nameof(ChatClientAgentSession)} has a {nameof(ChatClientAgentSession.ConversationId)} indicating server-side chat history management, but an override {nameof(this.ChatHistoryProvider)} was provided via {nameof(AgentRunOptions.AdditionalProperties)}."); } - // Validate that the override provider's StateKey does not clash with any AIContextProvider's StateKey. - if (overrideProvider is not null && this._aiContextProviderStateKeys.Contains(overrideProvider.StateKey)) + // Validate that the override provider's StateKeys do not clash with any AIContextProvider's StateKeys. + if (overrideProvider is not null) { - throw new InvalidOperationException( - $"The ChatHistoryProvider '{overrideProvider.GetType().Name}' uses the state key '{overrideProvider.StateKey}' which is already used by one of the configured AIContextProviders. Each provider must use a unique state key to avoid overwriting each other's state."); + foreach (var key in overrideProvider.StateKeys) + { + if (this._aiContextProviderStateKeys.Contains(key)) + { + throw new InvalidOperationException( + $"The ChatHistoryProvider '{overrideProvider.GetType().Name}' uses state key '{key}' which is already used by one of the configured AIContextProviders. Each provider must use unique state keys to avoid overwriting each other's state."); + } + } } provider = overrideProvider; @@ -879,7 +885,7 @@ private static List GetResponseUpdates(ChatClientAgentContin private string GetLoggingAgentName() => this.Name ?? "UnnamedAgent"; /// - /// Validates that all configured providers have unique values + /// Validates that all configured providers have unique values /// and returns a of the AIContextProvider state keys. /// private static HashSet ValidateAndCollectStateKeys(IEnumerable? aiContextProviders, ChatHistoryProvider? chatHistoryProvider) @@ -890,10 +896,13 @@ private static HashSet ValidateAndCollectStateKeys(IEnumerable ValidateAndCollectStateKeys(IEnumerable _sessionState; + private IReadOnlyList? _stateKeys; #pragma warning disable CA2213 // VectorStore is not owned by this class - caller is responsible for disposal private readonly VectorStore _vectorStore; @@ -128,7 +129,7 @@ public ChatHistoryMemoryProvider( } /// - public override string StateKey => this._sessionState.StateKey; + public override IReadOnlyList StateKeys => this._stateKeys ??= [this._sessionState.StateKey]; /// protected override async ValueTask ProvideAIContextAsync(AIContextProvider.InvokingContext context, CancellationToken cancellationToken = default) diff --git a/dotnet/src/Microsoft.Agents.AI/TextSearchProvider.cs b/dotnet/src/Microsoft.Agents.AI/TextSearchProvider.cs index df53729fce..11611f0f69 100644 --- a/dotnet/src/Microsoft.Agents.AI/TextSearchProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/TextSearchProvider.cs @@ -40,6 +40,7 @@ public sealed class TextSearchProvider : MessageAIContextProvider private const string DefaultCitationsPrompt = "Include citations to the source document with document name and link if document name and link is available."; private readonly ProviderSessionState _sessionState; + private IReadOnlyList? _stateKeys; private readonly Func>> _searchAsync; private readonly ILogger? _logger; private readonly AITool[] _tools; @@ -88,7 +89,7 @@ public TextSearchProvider( } /// - public override string StateKey => this._sessionState.StateKey; + public override IReadOnlyList StateKeys => this._stateKeys ??= [this._sessionState.StateKey]; /// protected override async ValueTask ProvideAIContextAsync(AIContextProvider.InvokingContext context, CancellationToken cancellationToken = default) diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/InMemoryChatHistoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/InMemoryChatHistoryProviderTests.cs index 147ceaf195..94beb08bdf 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/InMemoryChatHistoryProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/InMemoryChatHistoryProviderTests.cs @@ -43,23 +43,25 @@ public void Constructor_Arguments_SetOnPropertiesCorrectly() } [Fact] - public void StateKey_ReturnsDefaultKey_WhenNoOptionsProvided() + public void StateKeys_ReturnsDefaultKey_WhenNoOptionsProvided() { // Arrange & Act var provider = new InMemoryChatHistoryProvider(); // Assert - Assert.Equal("InMemoryChatHistoryProvider", provider.StateKey); + Assert.Single(provider.StateKeys); + Assert.Contains("InMemoryChatHistoryProvider", provider.StateKeys); } [Fact] - public void StateKey_ReturnsCustomKey_WhenSetViaOptions() + public void StateKeys_ReturnsCustomKey_WhenSetViaOptions() { // Arrange & Act var provider = new InMemoryChatHistoryProvider(new() { StateKey = "custom-key" }); // Assert - Assert.Equal("custom-key", provider.StateKey); + Assert.Single(provider.StateKeys); + Assert.Contains("custom-key", provider.StateKeys); } [Fact] diff --git a/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosChatHistoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosChatHistoryProviderTests.cs index 736bf7f026..56d6293a58 100644 --- a/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosChatHistoryProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosChatHistoryProviderTests.cs @@ -150,7 +150,7 @@ private void SkipIfEmulatorNotAvailable() [SkippableFact] [Trait("Category", "CosmosDB")] - public void StateKey_ReturnsDefaultKey_WhenNoStateKeyProvided() + public void StateKeys_ReturnsDefaultKey_WhenNoStateKeyProvided() { // Arrange & Act this.SkipIfEmulatorNotAvailable(); @@ -159,12 +159,13 @@ public void StateKey_ReturnsDefaultKey_WhenNoStateKeyProvided() _ => new CosmosChatHistoryProvider.State("test-conversation")); // Assert - Assert.Equal("CosmosChatHistoryProvider", provider.StateKey); + Assert.Single(provider.StateKeys); + Assert.Contains("CosmosChatHistoryProvider", provider.StateKeys); } [SkippableFact] [Trait("Category", "CosmosDB")] - public void StateKey_ReturnsCustomKey_WhenSetViaConstructor() + public void StateKeys_ReturnsCustomKey_WhenSetViaConstructor() { // Arrange & Act this.SkipIfEmulatorNotAvailable(); @@ -174,7 +175,8 @@ public void StateKey_ReturnsCustomKey_WhenSetViaConstructor() stateKey: "custom-key"); // Assert - Assert.Equal("custom-key", provider.StateKey); + Assert.Single(provider.StateKeys); + Assert.Contains("custom-key", provider.StateKeys); } [SkippableFact] diff --git a/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Mem0ProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Mem0ProviderTests.cs index 9f9de9127b..3374270861 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Mem0ProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Mem0ProviderTests.cs @@ -67,17 +67,18 @@ public void Constructor_Throws_WhenStateInitializerIsNull() } [Fact] - public void StateKey_ReturnsDefaultKey_WhenNoOptionsProvided() + public void StateKeys_ReturnsDefaultKey_WhenNoOptionsProvided() { // Arrange & Act var provider = new Mem0Provider(this._httpClient, _ => new Mem0Provider.State(new Mem0ProviderScope { ThreadId = "tid" })); // Assert - Assert.Equal("Mem0Provider", provider.StateKey); + Assert.Single(provider.StateKeys); + Assert.Contains("Mem0Provider", provider.StateKeys); } [Fact] - public void StateKey_ReturnsCustomKey_WhenSetViaOptions() + public void StateKeys_ReturnsCustomKey_WhenSetViaOptions() { // Arrange & Act var provider = new Mem0Provider( @@ -86,7 +87,8 @@ public void StateKey_ReturnsCustomKey_WhenSetViaOptions() new Mem0ProviderOptions { StateKey = "custom-key" }); // Assert - Assert.Equal("custom-key", provider.StateKey); + Assert.Single(provider.StateKeys); + Assert.Contains("custom-key", provider.StateKeys); } [Fact] @@ -419,7 +421,7 @@ public async Task StateInitializer_IsCalledOnceAndStoredInStateBagAsync() } [Fact] - public async Task StateKey_CanBeConfiguredViaOptionsAsync() + public async Task StateKeys_CanBeConfiguredViaOptionsAsync() { // Arrange this._handler.EnqueueJsonResponse("[]"); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AIContextProviderDecorators/AIContextProviderChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AIContextProviderDecorators/AIContextProviderChatClientTests.cs index 51eb4be3ab..3b06bbb772 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AIContextProviderDecorators/AIContextProviderChatClientTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AIContextProviderDecorators/AIContextProviderChatClientTests.cs @@ -380,7 +380,7 @@ private static async IAsyncEnumerable ToAsyncEnumerableAsync /// private sealed class TestAIContextProvider : AIContextProvider { - private readonly string _stateKey; + private readonly IReadOnlyList _stateKeys; private readonly IEnumerable _provideMessages; private readonly string? _provideInstructions; private readonly IEnumerable? _provideTools; @@ -389,7 +389,7 @@ private sealed class TestAIContextProvider : AIContextProvider public InvokedContext? LastInvokedContext { get; private set; } - public override string StateKey => this._stateKey; + public override IReadOnlyList StateKeys => this._stateKeys; public TestAIContextProvider( string stateKey, @@ -397,7 +397,7 @@ public TestAIContextProvider( string? provideInstructions = null, IEnumerable? provideTools = null) { - this._stateKey = stateKey; + this._stateKeys = [stateKey]; this._provideMessages = provideMessages ?? []; this._provideInstructions = provideInstructions; this._provideTools = provideTools; diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs index 9713a91c2c..2b3cfe43e8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs @@ -105,8 +105,8 @@ public void Constructor_ThrowsWhenChatHistoryProviderStateKeyClashesWithAIContex ChatHistoryProvider = historyProvider })); - Assert.Contains("SharedKey", ex.Message); - Assert.Contains(nameof(ChatHistoryProvider), ex.Message); + Assert.Contains("ChatHistoryProvider", ex.Message); + Assert.Contains("state key 'SharedKey'", ex.Message); } /// @@ -159,11 +159,11 @@ public async Task RunAsync_ThrowsWhenOverrideChatHistoryProviderStateKeyClashesW var ex = await Assert.ThrowsAsync(() => agent.RunAsync([new(ChatRole.User, "test")], session, options: new AgentRunOptions { AdditionalProperties = additionalProperties })); - Assert.Contains("SharedKey", ex.Message); + Assert.Contains("state key 'SharedKey'", ex.Message); } /// - /// Verify that RunAsync succeeds when an override ChatHistoryProvider uses the same StateKey as the default ChatHistoryProvider. + /// Verify that RunAsync succeeds when an override ChatHistoryProvider uses the same StateKeys as the default ChatHistoryProvider. /// [Fact] public async Task RunAsync_SucceedsWhenOverrideChatHistoryProviderSharesKeyWithDefaultAsync() @@ -192,6 +192,102 @@ public async Task RunAsync_SucceedsWhenOverrideChatHistoryProviderSharesKeyWithD await agent.RunAsync([new(ChatRole.User, "test")], session, options: new AgentRunOptions { AdditionalProperties = additionalProperties }); } + /// + /// Verify that the constructor throws when two multi-key AIContextProviders have an overlapping key. + /// + [Fact] + public void Constructor_ThrowsWhenMultiKeyAIContextProvidersOverlap() + { + // Arrange + var chatClient = new Mock().Object; + var provider1 = new MultiKeyTestAIContextProvider("Key1", "SharedKey"); + var provider2 = new MultiKeyTestAIContextProvider("Key2", "SharedKey"); + + // Act & Assert + var ex = Assert.Throws(() => + new ChatClientAgent(chatClient, options: new() + { + AIContextProviders = [provider1, provider2] + })); + + Assert.Contains("state key 'SharedKey'", ex.Message); + } + + /// + /// Verify that the constructor throws when a multi-key ChatHistoryProvider has an overlapping key with an AIContextProvider. + /// + [Fact] + public void Constructor_ThrowsWhenMultiKeyChatHistoryProviderOverlapsWithAIContextProvider() + { + // Arrange + var chatClient = new Mock().Object; + var contextProvider = new MultiKeyTestAIContextProvider("Key1", "SharedKey"); + var historyProvider = new MultiKeyTestChatHistoryProvider("Key2", "SharedKey"); + + // Act & Assert + var ex = Assert.Throws(() => + new ChatClientAgent(chatClient, options: new() + { + AIContextProviders = [contextProvider], + ChatHistoryProvider = historyProvider + })); + + Assert.Contains("state key 'SharedKey'", ex.Message); + } + + /// + /// Verify that the constructor succeeds when multi-key providers have no overlapping keys. + /// + [Fact] + public void Constructor_SucceedsWithMultiKeyProvidersWithUniqueKeys() + { + // Arrange + var chatClient = new Mock().Object; + var contextProvider1 = new MultiKeyTestAIContextProvider("Key1", "Key2"); + var contextProvider2 = new MultiKeyTestAIContextProvider("Key3", "Key4"); + var historyProvider = new MultiKeyTestChatHistoryProvider("Key5", "Key6"); + + // Act & Assert - should not throw + _ = new ChatClientAgent(chatClient, options: new() + { + AIContextProviders = [contextProvider1, contextProvider2], + ChatHistoryProvider = historyProvider + }); + } + + /// + /// Verify that RunAsync throws when a multi-key override ChatHistoryProvider has an overlapping key with an AIContextProvider. + /// + [Fact] + public async Task RunAsync_ThrowsWhenMultiKeyOverrideChatHistoryProviderClashesWithAIContextProviderAsync() + { + // Arrange + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + var contextProvider = new MultiKeyTestAIContextProvider("Key1", "SharedKey"); + var overrideHistoryProvider = new MultiKeyTestChatHistoryProvider("Key2", "SharedKey"); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + AIContextProviders = [contextProvider] + }); + + // Act & Assert + ChatClientAgentSession? session = await agent.CreateSessionAsync() as ChatClientAgentSession; + AdditionalPropertiesDictionary additionalProperties = new(); + additionalProperties.Add(overrideHistoryProvider); + + var ex = await Assert.ThrowsAsync(() => + agent.RunAsync([new(ChatRole.User, "test")], session, options: new AgentRunOptions { AdditionalProperties = additionalProperties })); + + Assert.Contains("state key 'SharedKey'", ex.Message); + } + #endregion #region RunAsync Tests @@ -489,6 +585,7 @@ public async Task RunAsyncInvokesAIContextProviderAndUsesResultAsync() .ReturnsAsync(new ChatResponse(responseMessages)); var mockProvider = new Mock(null, null, null); + mockProvider.SetupGet(p => p.StateKeys).Returns(["TestProvider"]); mockProvider .Protected() .Setup>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) @@ -560,6 +657,7 @@ public async Task RunAsyncInvokesAIContextProviderWhenGetResponseFailsAsync() .Throws(new InvalidOperationException("downstream failure")); var mockProvider = new Mock(null, null, null); + mockProvider.SetupGet(p => p.StateKeys).Returns(["TestProvider"]); mockProvider .Protected() .Setup>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) @@ -618,6 +716,7 @@ public async Task RunAsyncInvokesAIContextProviderAndSucceedsWithEmptyAIContextA .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); var mockProvider = new Mock(null, null, null); + mockProvider.SetupGet(p => p.StateKeys).Returns(["TestProvider"]); mockProvider .Protected() .Setup>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) @@ -678,7 +777,7 @@ public async Task RunAsyncInvokesMultipleAIContextProvidersInOrderAsync() // Provider 1: adds a system message and a tool var mockProvider1 = new Mock(null, null, null); - mockProvider1.SetupGet(p => p.StateKey).Returns("Provider1"); + mockProvider1.SetupGet(p => p.StateKeys).Returns(["Provider1"]); mockProvider1 .Protected() .Setup>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) @@ -697,7 +796,7 @@ public async Task RunAsyncInvokesMultipleAIContextProvidersInOrderAsync() // Provider 2: adds another system message and verifies it receives accumulated context from provider 1 AIContext? provider2ReceivedContext = null; var mockProvider2 = new Mock(null, null, null); - mockProvider2.SetupGet(p => p.StateKey).Returns("Provider2"); + mockProvider2.SetupGet(p => p.StateKeys).Returns(["Provider2"]); mockProvider2 .Protected() .Setup>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) @@ -785,7 +884,7 @@ public async Task RunAsyncInvokesMultipleAIContextProvidersOnFailureAsync() .ThrowsAsync(new InvalidOperationException("downstream failure")); var mockProvider1 = new Mock(null, null, null); - mockProvider1.SetupGet(p => p.StateKey).Returns("Provider1"); + mockProvider1.SetupGet(p => p.StateKeys).Returns(["Provider1"]); mockProvider1 .Protected() .Setup>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) @@ -802,7 +901,7 @@ public async Task RunAsyncInvokesMultipleAIContextProvidersOnFailureAsync() .Returns(new ValueTask()); var mockProvider2 = new Mock(null, null, null); - mockProvider2.SetupGet(p => p.StateKey).Returns("Provider2"); + mockProvider2.SetupGet(p => p.StateKeys).Returns(["Provider2"]); mockProvider2 .Protected() .Setup>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) @@ -870,7 +969,7 @@ public async Task RunStreamingAsyncInvokesMultipleAIContextProvidersAsync() .Returns(ToAsyncEnumerableAsync(responseUpdates)); var mockProvider1 = new Mock(null, null, null); - mockProvider1.SetupGet(p => p.StateKey).Returns("Provider1"); + mockProvider1.SetupGet(p => p.StateKeys).Returns(["Provider1"]); mockProvider1 .Protected() .Setup>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) @@ -887,7 +986,7 @@ public async Task RunStreamingAsyncInvokesMultipleAIContextProvidersAsync() .Returns(new ValueTask()); var mockProvider2 = new Mock(null, null, null); - mockProvider2.SetupGet(p => p.StateKey).Returns("Provider2"); + mockProvider2.SetupGet(p => p.StateKeys).Returns(["Provider2"]); mockProvider2 .Protected() .Setup>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) @@ -1829,6 +1928,7 @@ public async Task RunStreamingAsyncInvokesAIContextProviderAndUsesResultAsync() .Returns(ToAsyncEnumerableAsync(responseUpdates)); var mockProvider = new Mock(null, null, null); + mockProvider.SetupGet(p => p.StateKeys).Returns(["TestProvider"]); mockProvider .Protected() .Setup>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) @@ -1908,6 +2008,7 @@ public async Task RunStreamingAsyncInvokesAIContextProviderWhenGetResponseFailsA .Throws(new InvalidOperationException("downstream failure")); var mockProvider = new Mock(null, null, null); + mockProvider.SetupGet(p => p.StateKeys).Returns(["TestProvider"]); mockProvider .Protected() .Setup>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) @@ -1965,7 +2066,17 @@ private sealed partial class JsonContext2 : JsonSerializerContext; private sealed class TestAIContextProvider(string stateKey) : AIContextProvider { - public override string StateKey => stateKey; + private readonly IReadOnlyList _stateKeys = [stateKey]; + + public override IReadOnlyList StateKeys => this._stateKeys; + + protected override ValueTask InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default) + => new(context.AIContext); + } + + private sealed class MultiKeyTestAIContextProvider(params string[] stateKeys) : AIContextProvider + { + public override IReadOnlyList StateKeys => stateKeys; protected override ValueTask InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default) => new(context.AIContext); @@ -1973,7 +2084,20 @@ protected override ValueTask InvokingCoreAsync(InvokingContext contex private sealed class TestChatHistoryProvider(string stateKey) : ChatHistoryProvider { - public override string StateKey => stateKey; + private readonly IReadOnlyList _stateKeys = [stateKey]; + + public override IReadOnlyList StateKeys => this._stateKeys; + + protected override ValueTask> InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default) + => new(context.RequestMessages); + + protected override ValueTask InvokedCoreAsync(InvokedContext context, CancellationToken cancellationToken = default) + => default; + } + + private sealed class MultiKeyTestChatHistoryProvider(params string[] stateKeys) : ChatHistoryProvider + { + public override IReadOnlyList StateKeys => stateKeys; protected override ValueTask> InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default) => new(context.RequestMessages); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_BackgroundResponsesTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_BackgroundResponsesTests.cs index ebb1791dfd..1177a3c82a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_BackgroundResponsesTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_BackgroundResponsesTests.cs @@ -339,7 +339,7 @@ public async Task RunAsync_WhenContinuationTokenProvided_SkipsSessionMessagePopu // Create a mock chat history provider that would normally provide messages var mockChatHistoryProvider = new Mock(null, null, null); - mockChatHistoryProvider.SetupGet(p => p.StateKey).Returns("ChatHistoryProvider"); + mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["ChatHistoryProvider"]); mockChatHistoryProvider .Protected() .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) @@ -347,7 +347,7 @@ public async Task RunAsync_WhenContinuationTokenProvided_SkipsSessionMessagePopu // Create a mock AI context provider that would normally provide context var mockContextProvider = new Mock(null, null, null); - mockContextProvider.SetupGet(p => p.StateKey).Returns("Provider1"); + mockContextProvider.SetupGet(p => p.StateKeys).Returns(["Provider1"]); mockContextProvider .Protected() .Setup>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) @@ -408,7 +408,7 @@ public async Task RunStreamingAsync_WhenContinuationTokenProvided_SkipsSessionMe // Create a mock chat history provider that would normally provide messages var mockChatHistoryProvider = new Mock(null, null, null); - mockChatHistoryProvider.SetupGet(p => p.StateKey).Returns("ChatHistoryProvider"); + mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["ChatHistoryProvider"]); mockChatHistoryProvider .Protected() .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) @@ -416,7 +416,7 @@ public async Task RunStreamingAsync_WhenContinuationTokenProvided_SkipsSessionMe // Create a mock AI context provider that would normally provide context var mockContextProvider = new Mock(null, null, null); - mockContextProvider.SetupGet(p => p.StateKey).Returns("Provider1"); + mockContextProvider.SetupGet(p => p.StateKeys).Returns(["Provider1"]); mockContextProvider .Protected() .Setup>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) @@ -639,7 +639,7 @@ public async Task RunStreamingAsync_WhenResumingStreaming_UsesUpdatesFromInitial List capturedMessagesAddedToProvider = []; var mockChatHistoryProvider = new Mock(null, null, null); - mockChatHistoryProvider.SetupGet(p => p.StateKey).Returns("ChatHistoryProvider"); + mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["ChatHistoryProvider"]); mockChatHistoryProvider .Protected() .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) @@ -648,7 +648,7 @@ public async Task RunStreamingAsync_WhenResumingStreaming_UsesUpdatesFromInitial AIContextProvider.InvokedContext? capturedInvokedContext = null; var mockContextProvider = new Mock(null, null, null); - mockContextProvider.SetupGet(p => p.StateKey).Returns("Provider1"); + mockContextProvider.SetupGet(p => p.StateKeys).Returns(["Provider1"]); mockContextProvider .Protected() .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) @@ -703,7 +703,7 @@ public async Task RunStreamingAsync_WhenResumingStreaming_UsesInputMessagesFromI List capturedMessagesAddedToProvider = []; var mockChatHistoryProvider = new Mock(null, null, null); - mockChatHistoryProvider.SetupGet(p => p.StateKey).Returns("ChatHistoryProvider"); + mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["ChatHistoryProvider"]); mockChatHistoryProvider .Protected() .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) @@ -712,7 +712,7 @@ public async Task RunStreamingAsync_WhenResumingStreaming_UsesInputMessagesFromI AIContextProvider.InvokedContext? capturedInvokedContext = null; var mockContextProvider = new Mock(null, null, null); - mockContextProvider.SetupGet(p => p.StateKey).Returns("Provider1"); + mockContextProvider.SetupGet(p => p.StateKeys).Returns(["Provider1"]); mockContextProvider .Protected() .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatHistoryManagementTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatHistoryManagementTests.cs index 59062cf49f..cc9b7acb19 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatHistoryManagementTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatHistoryManagementTests.cs @@ -186,6 +186,7 @@ public async Task RunAsync_UsesChatHistoryProvider_WhenProvidedAndNoConversation It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); Mock mockChatHistoryProvider = new(null, null, null); + mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]); mockChatHistoryProvider .Protected() .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) @@ -241,6 +242,7 @@ public async Task RunAsync_NotifiesChatHistoryProvider_OnFailureAsync() It.IsAny())).Throws(new InvalidOperationException("Test Error")); Mock mockChatHistoryProvider = new(null, null, null); + mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]); mockChatHistoryProvider .Protected() .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) @@ -430,6 +432,7 @@ public async Task RunAsync_UsesOverrideChatHistoryProvider_WhenProvidedViaAdditi // Arrange a chat history provider to override the factory provided one. Mock mockOverrideChatHistoryProvider = new(null, null, null); + mockOverrideChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]); mockOverrideChatHistoryProvider .Protected() .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) @@ -443,6 +446,7 @@ public async Task RunAsync_UsesOverrideChatHistoryProvider_WhenProvidedViaAdditi // Arrange a chat history provider to provide to the agent at construction time. // This one shouldn't be used since it is being overridden. Mock mockAgentOptionsChatHistoryProvider = new(null, null, null); + mockAgentOptionsChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]); mockAgentOptionsChatHistoryProvider .Protected() .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Data/TextSearchProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Data/TextSearchProviderTests.cs index a0d6bbb35f..a782993f6a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Data/TextSearchProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Data/TextSearchProviderTests.cs @@ -39,17 +39,18 @@ public TextSearchProviderTests() } [Fact] - public void StateKey_ReturnsDefaultKey_WhenNoOptionsProvided() + public void StateKeys_ReturnsDefaultKey_WhenNoOptionsProvided() { // Arrange & Act var provider = new TextSearchProvider((_, _) => Task.FromResult>([])); // Assert - Assert.Equal("TextSearchProvider", provider.StateKey); + Assert.Single(provider.StateKeys); + Assert.Contains("TextSearchProvider", provider.StateKeys); } [Fact] - public void StateKey_ReturnsCustomKey_WhenSetViaOptions() + public void StateKeys_ReturnsCustomKey_WhenSetViaOptions() { // Arrange & Act var provider = new TextSearchProvider( @@ -57,7 +58,8 @@ public void StateKey_ReturnsCustomKey_WhenSetViaOptions() new TextSearchProviderOptions { StateKey = "custom-key" }); // Assert - Assert.Equal("custom-key", provider.StateKey); + Assert.Single(provider.StateKeys); + Assert.Contains("custom-key", provider.StateKeys); } [Theory] diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Memory/ChatHistoryMemoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Memory/ChatHistoryMemoryProviderTests.cs index a0657d5a47..5211fa0956 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Memory/ChatHistoryMemoryProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Memory/ChatHistoryMemoryProviderTests.cs @@ -56,7 +56,7 @@ public ChatHistoryMemoryProviderTests() } [Fact] - public void StateKey_ReturnsDefaultKey_WhenNoOptionsProvided() + public void StateKeys_ReturnsDefaultKey_WhenNoOptionsProvided() { // Arrange & Act var provider = new ChatHistoryMemoryProvider( @@ -66,11 +66,12 @@ public void StateKey_ReturnsDefaultKey_WhenNoOptionsProvided() _ => new ChatHistoryMemoryProvider.State(new ChatHistoryMemoryProviderScope { UserId = "UID" })); // Assert - Assert.Equal("ChatHistoryMemoryProvider", provider.StateKey); + Assert.Single(provider.StateKeys); + Assert.Contains("ChatHistoryMemoryProvider", provider.StateKeys); } [Fact] - public void StateKey_ReturnsCustomKey_WhenSetViaOptions() + public void StateKeys_ReturnsCustomKey_WhenSetViaOptions() { // Arrange & Act var provider = new ChatHistoryMemoryProvider( @@ -81,7 +82,8 @@ public void StateKey_ReturnsCustomKey_WhenSetViaOptions() new ChatHistoryMemoryProviderOptions { StateKey = "custom-key" }); // Assert - Assert.Equal("custom-key", provider.StateKey); + Assert.Single(provider.StateKeys); + Assert.Contains("custom-key", provider.StateKeys); } [Fact]