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 @@ namespace SampleApp 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 @@ namespace SampleApp 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 @@ public abstract class AIContextProvider private static IEnumerable DefaultNoopFilter(IEnumerable messages) => messages; + private IReadOnlyList? _stateKeys; + /// /// Initializes a new instance of the class. /// @@ -68,14 +70,15 @@ public abstract class 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 @@ public abstract class ChatHistoryProvider 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 @@ public abstract class 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 sealed class InMemoryChatHistoryProvider : ChatHistoryProvider } /// - 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 sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable } /// - 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 sealed class FoundryMemoryProvider : AIContextProvider } /// - 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 sealed class Mem0Provider : MessageAIContextProvider } /// - 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 @@ internal sealed class WorkflowChatHistoryProvider : ChatHistoryProvider } /// - 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 sealed partial class ChatClientAgent : AIAgent 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 @@ public sealed partial class ChatClientAgent : AIAgent $"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 @@ public sealed partial class ChatClientAgent : AIAgent 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 @@ public sealed partial class ChatClientAgent : AIAgent { foreach (var provider in aiContextProviders) { - if (!stateKeys.Add(provider.StateKey)) + foreach (var key in provider.StateKeys) { - throw new InvalidOperationException( - $"Multiple providers use the same state key '{provider.StateKey}'. Each provider must use a unique state key to avoid overwriting each other's state."); + if (!stateKeys.Add(key)) + { + throw new InvalidOperationException( + $"Multiple providers use the same state key '{key}'. Each provider must use a unique state key to avoid overwriting each other's state."); + } } } } @@ -905,11 +914,16 @@ public sealed partial class ChatClientAgent : AIAgent $"The default {nameof(InMemoryChatHistoryProvider)} uses the state key '{nameof(InMemoryChatHistoryProvider)}', 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. To resolve this, either configure a different state key for the AIContextProvider that is using '{nameof(InMemoryChatHistoryProvider)}' as its state key, or provide a custom ChatHistoryProvider with a unique state key."); } - if (chatHistoryProvider is not null - && stateKeys.Contains(chatHistoryProvider.StateKey)) + if (chatHistoryProvider is not null) { - throw new InvalidOperationException( - $"The ChatHistoryProvider '{chatHistoryProvider.GetType().Name}' uses the state key '{chatHistoryProvider.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. To resolve this, either configure a different state key for the AIContextProvider that is using '{chatHistoryProvider.StateKey}' as its state key, or reconfigure the custom ChatHistoryProvider with a unique state key."); + foreach (var key in chatHistoryProvider.StateKeys) + { + if (stateKeys.Contains(key)) + { + throw new InvalidOperationException( + $"The ChatHistoryProvider '{chatHistoryProvider.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. To resolve this, either configure different state keys for the AIContextProvider that shares keys with the ChatHistoryProvider, or reconfigure the custom ChatHistoryProvider with unique state keys."); + } + } } return stateKeys; diff --git a/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs index cd59d1aaa3..80d5e1144f 100644 --- a/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs @@ -54,6 +54,7 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo private const string ContentEmbeddingField = "ContentEmbedding"; private readonly ProviderSessionState _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 sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo } /// - 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 sealed class TextSearchProvider : MessageAIContextProvider } /// - 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 class InMemoryChatHistoryProviderTests } [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 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable [SkippableFact] [Trait("Category", "CosmosDB")] - public void StateKey_ReturnsDefaultKey_WhenNoStateKeyProvided() + public void StateKeys_ReturnsDefaultKey_WhenNoStateKeyProvided() { // Arrange & Act this.SkipIfEmulatorNotAvailable(); @@ -159,12 +159,13 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable _ => 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 sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable 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 sealed class Mem0ProviderTests : IDisposable } [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 sealed class Mem0ProviderTests : IDisposable 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 sealed class Mem0ProviderTests : IDisposable } [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 @@ public class AIContextProviderChatClientTests /// 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 @@ public class AIContextProviderChatClientTests 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 class AIContextProviderChatClientTests 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 partial class ChatClientAgentTests 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 partial class ChatClientAgentTests 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 partial class ChatClientAgentTests 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 partial class ChatClientAgentTests .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 partial class ChatClientAgentTests .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 partial class ChatClientAgentTests .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 partial class ChatClientAgentTests // 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 partial class ChatClientAgentTests // 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 partial class ChatClientAgentTests .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 partial class ChatClientAgentTests .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 partial class ChatClientAgentTests .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 partial class ChatClientAgentTests .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 partial class ChatClientAgentTests .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 partial class ChatClientAgentTests .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 @@ public partial class ChatClientAgentTests 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 @@ public partial class ChatClientAgentTests 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 class ChatClientAgent_BackgroundResponsesTests // 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 class ChatClientAgent_BackgroundResponsesTests // 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 class ChatClientAgent_BackgroundResponsesTests // 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 class ChatClientAgent_BackgroundResponsesTests // 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 class ChatClientAgent_BackgroundResponsesTests 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 class ChatClientAgent_BackgroundResponsesTests 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 class ChatClientAgent_BackgroundResponsesTests 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 class ChatClientAgent_BackgroundResponsesTests 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 class ChatClientAgent_ChatHistoryManagementTests 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 class ChatClientAgent_ChatHistoryManagementTests 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 class ChatClientAgent_ChatHistoryManagementTests // 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 class ChatClientAgent_ChatHistoryManagementTests // 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 sealed class 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 sealed class TextSearchProviderTests 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 class ChatHistoryMemoryProviderTests } [Fact] - public void StateKey_ReturnsDefaultKey_WhenNoOptionsProvided() + public void StateKeys_ReturnsDefaultKey_WhenNoOptionsProvided() { // Arrange & Act var provider = new ChatHistoryMemoryProvider( @@ -66,11 +66,12 @@ public class ChatHistoryMemoryProviderTests _ => 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 class ChatHistoryMemoryProviderTests new ChatHistoryMemoryProviderOptions { StateKey = "custom-key" }); // Assert - Assert.Equal("custom-key", provider.StateKey); + Assert.Single(provider.StateKeys); + Assert.Contains("custom-key", provider.StateKeys); } [Fact]