diff --git a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/Program.cs b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/Program.cs
index 54ee8b5008..7f83d40162 100644
--- a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/Program.cs
+++ b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/Program.cs
@@ -34,7 +34,7 @@ AIAgent agent = new AzureOpenAIClient(
{
ChatOptions = new() { Instructions = "You are good at telling jokes." },
Name = "Joker",
- AIContextProvider = new ChatHistoryMemoryProvider(
+ AIContextProviders = [new ChatHistoryMemoryProvider(
vectorStore,
collectionName: "chathistory",
vectorDimensions: 3072,
@@ -48,7 +48,7 @@ AIAgent agent = new AzureOpenAIClient(
storageScope: new() { UserId = "UID1", SessionId = Guid.NewGuid().ToString() },
// Configure the scope which would be used to search for relevant prior messages.
// In this case, we are searching for any messages for the user across all sessions.
- searchScope: new() { UserId = "UID1" }))
+ searchScope: new() { UserId = "UID1" }))]
});
// Start a new session for the agent conversation.
diff --git a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step02_MemoryUsingMem0/Program.cs b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step02_MemoryUsingMem0/Program.cs
index 588c79ba9a..4d4d8d2104 100644
--- a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step02_MemoryUsingMem0/Program.cs
+++ b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step02_MemoryUsingMem0/Program.cs
@@ -36,7 +36,7 @@ AIAgent agent = new AzureOpenAIClient(
// If each session should have its own Mem0 scope, you can create a new id per session via the stateInitializer, e.g.:
// new Mem0Provider(mem0HttpClient, stateInitializer: _ => new(new Mem0ProviderScope() { ThreadId = Guid.NewGuid().ToString() }))
// In our case we are storing memories scoped by application and user instead so that memories are retained across threads.
- AIContextProvider = new Mem0Provider(mem0HttpClient, stateInitializer: _ => new(new Mem0ProviderScope() { ApplicationId = "getting-started-agents", UserId = "sample-user" }))
+ AIContextProviders = [new Mem0Provider(mem0HttpClient, stateInitializer: _ => new(new Mem0ProviderScope() { ApplicationId = "getting-started-agents", UserId = "sample-user" }))]
});
AgentSession session = await agent.CreateSessionAsync();
diff --git a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step03_CustomMemory/Program.cs b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step03_CustomMemory/Program.cs
index 32b30040b9..4e195f6c4a 100644
--- a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step03_CustomMemory/Program.cs
+++ b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step03_CustomMemory/Program.cs
@@ -33,7 +33,7 @@ ChatClient chatClient = new AzureOpenAIClient(
AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions()
{
ChatOptions = new() { Instructions = "You are a friendly assistant. Always address the user by their name." },
- AIContextProvider = new UserInfoMemory(chatClient.AsIChatClient())
+ AIContextProviders = [new UserInfoMemory(chatClient.AsIChatClient())]
});
// Create a new session for the conversation.
diff --git a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/Program.cs b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/Program.cs
index 19d9578b36..dc80e20bee 100644
--- a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/Program.cs
+++ b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/Program.cs
@@ -62,7 +62,7 @@ AIAgent agent = azureOpenAIClient
.AsAIAgent(new ChatClientAgentOptions
{
ChatOptions = new() { Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available." },
- AIContextProvider = new TextSearchProvider(SearchAdapter, textSearchOptions),
+ AIContextProviders = [new TextSearchProvider(SearchAdapter, textSearchOptions)],
// Since we are using ChatCompletion which stores chat history locally, we can also add a message filter
// that removes messages produced by the TextSearchProvider before they are added to the chat history, so that
// we don't bloat chat history with all the search result messages.
diff --git a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/Program.cs b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/Program.cs
index 4e135db78c..1544c7e7cc 100644
--- a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/Program.cs
+++ b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/Program.cs
@@ -71,7 +71,7 @@ AIAgent agent = azureOpenAIClient
.AsAIAgent(new ChatClientAgentOptions
{
ChatOptions = new() { Instructions = "You are a helpful support specialist for the Microsoft Agent Framework. Answer questions using the provided context and cite the source document when available. Keep responses brief." },
- AIContextProvider = new TextSearchProvider(SearchAdapter, textSearchOptions)
+ AIContextProviders = [new TextSearchProvider(SearchAdapter, textSearchOptions)]
});
AgentSession session = await agent.CreateSessionAsync();
diff --git a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step03_CustomRAGDataSource/Program.cs b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step03_CustomRAGDataSource/Program.cs
index 6db583ca41..2d7d72a940 100644
--- a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step03_CustomRAGDataSource/Program.cs
+++ b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step03_CustomRAGDataSource/Program.cs
@@ -29,7 +29,7 @@ AIAgent agent = new AzureOpenAIClient(
.AsAIAgent(new ChatClientAgentOptions
{
ChatOptions = new() { Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available." },
- AIContextProvider = new TextSearchProvider(MockSearchAsync, textSearchOptions)
+ AIContextProviders = [new TextSearchProvider(MockSearchAsync, textSearchOptions)]
});
AgentSession session = await agent.CreateSessionAsync();
diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step20_AdditionalAIContext/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step20_AdditionalAIContext/Program.cs
index a1f26c882a..b25dc533a9 100644
--- a/dotnet/samples/GettingStarted/Agents/Agent_Step20_AdditionalAIContext/Program.cs
+++ b/dotnet/samples/GettingStarted/Agents/Agent_Step20_AdditionalAIContext/Program.cs
@@ -1,7 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
-// This sample shows how to inject additional AI context into a ChatClientAgent using a custom AIContextProvider component that is attached to the agent.
-// The sample also shows how to combine the results from multiple providers into a single class, in order to attach multiple of these to an agent.
+// This sample shows how to inject additional AI context into a ChatClientAgent using custom AIContextProvider components that are attached to the agent.
+// Multiple providers can be attached to an agent, and they will be called in sequence, each receiving the accumulated context from the previous one.
// This mechanism can be used for various purposes, such as injecting RAG search results or memories into the agent's context.
// Also note that Agent Framework already provides built-in AIContextProviders for many of these scenarios.
@@ -52,12 +52,12 @@ AIAgent agent = new AzureOpenAIClient(
// You may want to store these messages, depending on their content and your requirements.
StorageInputMessageFilter = messages => messages.Where(m => m.GetAgentRequestMessageSourceType() != AgentRequestMessageSourceType.AIContextProvider && m.GetAgentRequestMessageSourceType() != AgentRequestMessageSourceType.ChatHistory)
}),
- // Add an AI context provider that maintains a todo list for the agent and one that provides upcoming calendar entries.
- // Wrap these in an AI context provider that aggregates the other two.
- AIContextProvider = new AggregatingAIContextProvider([
+ // Add multiple AI context providers: one that maintains a todo list and one that provides upcoming calendar entries.
+ // The agent will call each provider in sequence, accumulating context from each.
+ AIContextProviders = [
new TodoListAIContextProvider(),
new CalendarSearchAIContextProvider(loadNextThreeCalendarEvents)
- ]),
+ ],
});
// Invoke the agent and output the text result.
@@ -178,30 +178,4 @@ namespace SampleApp
};
}
}
-
- ///
- /// An which aggregates multiple AI context providers into one.
- /// Tools and messages from all providers are combined, and instructions are concatenated.
- ///
- internal sealed class AggregatingAIContextProvider : AIContextProvider
- {
- private readonly List _providers;
-
- public AggregatingAIContextProvider(List providers)
- {
- this._providers = providers;
- }
-
- protected override async ValueTask InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default)
- {
- // Invoke all the sub providers.
- var currentAIContext = context.AIContext;
- foreach (var provider in this._providers)
- {
- currentAIContext = await provider.InvokingAsync(new InvokingContext(context.Agent, context.Session, currentAIContext), cancellationToken);
- }
-
- return currentAIContext;
- }
- }
}
diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI.Persistent/PersistentAgentsClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI.Persistent/PersistentAgentsClientExtensions.cs
index c9c898a18a..660e874711 100644
--- a/dotnet/src/Microsoft.Agents.AI.AzureAI.Persistent/PersistentAgentsClientExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.AzureAI.Persistent/PersistentAgentsClientExtensions.cs
@@ -191,7 +191,7 @@ public static class PersistentAgentsClientExtensions
Name = options.Name ?? persistentAgentMetadata.Name,
Description = options.Description ?? persistentAgentMetadata.Description,
ChatOptions = options.ChatOptions,
- AIContextProvider = options.AIContextProvider,
+ AIContextProviders = options.AIContextProviders,
ChatHistoryProvider = options.ChatHistoryProvider,
UseProvidedChatClientAsIs = options.UseProvidedChatClientAsIs
};
diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClientExtensions.cs
index 1c12f96796..c35f49b088 100644
--- a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClientExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClientExtensions.cs
@@ -594,7 +594,7 @@ public static partial class AzureAIProjectChatClientExtensions
var agentOptions = CreateChatClientAgentOptions(agentVersion, options?.ChatOptions, requireInvocableTools);
if (options is not null)
{
- agentOptions.AIContextProvider = options.AIContextProvider;
+ agentOptions.AIContextProviders = options.AIContextProviders;
agentOptions.ChatHistoryProvider = options.ChatHistoryProvider;
agentOptions.UseProvidedChatClientAsIs = options.UseProvidedChatClientAsIs;
}
diff --git a/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIAssistantClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIAssistantClientExtensions.cs
index 3f25f71d83..c56a63c76e 100644
--- a/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIAssistantClientExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIAssistantClientExtensions.cs
@@ -204,7 +204,7 @@ public static class OpenAIAssistantClientExtensions
Name = options.Name ?? assistantMetadata.Name,
Description = options.Description ?? assistantMetadata.Description,
ChatOptions = options.ChatOptions,
- AIContextProvider = options.AIContextProvider,
+ AIContextProviders = options.AIContextProviders,
ChatHistoryProvider = options.ChatHistoryProvider,
UseProvidedChatClientAsIs = options.UseProvidedChatClientAsIs
};
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs
index 9fa724e8c6..abfbc04a58 100644
--- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs
@@ -20,6 +20,7 @@ namespace Microsoft.Agents.AI;
public sealed partial class ChatClientAgent : AIAgent
{
private readonly ChatClientAgentOptions? _agentOptions;
+ private readonly HashSet _aiContextProviderStateKeys;
private readonly AIAgentMetadata _agentMetadata;
private readonly ILogger _logger;
private readonly Type _chatClientType;
@@ -109,6 +110,10 @@ public sealed partial class ChatClientAgent : AIAgent
// If one was not provided, and we later find out that the underlying service does not manage chat history server-side,
// we will use the default InMemoryChatHistoryProvider at that time.
this.ChatHistoryProvider = options?.ChatHistoryProvider;
+ 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.
+ this._aiContextProviderStateKeys = ValidateAndCollectStateKeys(this._agentOptions?.AIContextProviders, this.ChatHistoryProvider);
this._logger = (loggerFactory ?? chatClient.GetService() ?? NullLoggerFactory.Instance).CreateLogger();
}
@@ -133,6 +138,14 @@ public sealed partial class ChatClientAgent : AIAgent
///
public ChatHistoryProvider? ChatHistoryProvider { get; private set; }
+ ///
+ /// Gets the list of instances used by this agent, to support cases where additional context is needed for each agent run.
+ ///
+ ///
+ /// This property may be null in case no additional context providers were configured.
+ ///
+ public IReadOnlyList? AIContextProviders { get; }
+
///
protected override string? IdCore => this._agentOptions?.Id;
@@ -310,7 +323,7 @@ public sealed partial class ChatClientAgent : AIAgent
: serviceType == typeof(IChatClient) ? this.ChatClient
: serviceType == typeof(ChatOptions) ? this._agentOptions?.ChatOptions
: serviceType == typeof(ChatClientAgentOptions) ? this._agentOptions
- : this._agentOptions?.AIContextProvider?.GetService(serviceType, serviceKey)
+ : this.AIContextProviders?.Select(provider => provider.GetService(serviceType, serviceKey)).FirstOrDefault(s => s is not null)
?? this.ChatHistoryProvider?.GetService(serviceType, serviceKey)
?? this.ChatClient.GetService(serviceType, serviceKey));
@@ -440,10 +453,14 @@ public sealed partial class ChatClientAgent : AIAgent
IEnumerable responseMessages,
CancellationToken cancellationToken)
{
- if (this._agentOptions?.AIContextProvider is { } contextProvider)
+ if (this.AIContextProviders is { Count: > 0 } contextProviders)
{
- await contextProvider.InvokedAsync(new(this, session, inputMessages) { ResponseMessages = responseMessages },
- cancellationToken).ConfigureAwait(false);
+ AIContextProvider.InvokedContext invokedContext = new(this, session, inputMessages) { ResponseMessages = responseMessages };
+
+ foreach (var contextProvider in contextProviders)
+ {
+ await contextProvider.InvokedAsync(invokedContext, cancellationToken).ConfigureAwait(false);
+ }
}
}
@@ -456,10 +473,14 @@ public sealed partial class ChatClientAgent : AIAgent
IEnumerable inputMessages,
CancellationToken cancellationToken)
{
- if (this._agentOptions?.AIContextProvider is { } contextProvider)
+ if (this.AIContextProviders is { Count: > 0 } contextProviders)
{
- await contextProvider.InvokedAsync(new(this, session, inputMessages) { InvokeException = ex },
- cancellationToken).ConfigureAwait(false);
+ AIContextProvider.InvokedContext invokedContext = new(this, session, inputMessages) { InvokeException = ex };
+
+ foreach (var contextProvider in contextProviders)
+ {
+ await contextProvider.InvokedAsync(invokedContext, cancellationToken).ConfigureAwait(false);
+ }
}
}
@@ -679,7 +700,7 @@ public sealed partial class ChatClientAgent : AIAgent
// If we have an AIContextProvider, we should get context from it, and update our
// messages and options with the additional context.
// The AIContextProvider returns the accumulated AIContext (original + new contributions).
- if (this._agentOptions?.AIContextProvider is { } aiContextProvider)
+ if (this.AIContextProviders is { Count: > 0 } aiContextProviders)
{
var aiContext = new AIContext
{
@@ -687,8 +708,12 @@ public sealed partial class ChatClientAgent : AIAgent
Messages = inputMessagesForChatClient.ToList(),
Tools = chatOptions?.Tools as List ?? chatOptions?.Tools?.ToList()
};
- var invokingContext = new AIContextProvider.InvokingContext(this, typedSession, aiContext);
- aiContext = await aiContextProvider.InvokingAsync(invokingContext, cancellationToken).ConfigureAwait(false);
+
+ foreach (var aiContextProvider in aiContextProviders)
+ {
+ var invokingContext = new AIContextProvider.InvokingContext(this, typedSession, aiContext);
+ aiContext = await aiContextProvider.InvokingAsync(invokingContext, cancellationToken).ConfigureAwait(false);
+ }
// Use the returned messages, tools and instructions directly since the provider accumulated them.
inputMessagesForChatClient = aiContext.Messages as List ?? aiContext.Messages?.ToList() ?? [];
@@ -826,6 +851,13 @@ 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))
+ {
+ 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.");
+ }
+
provider = overrideProvider;
}
@@ -872,5 +904,43 @@ public sealed partial class ChatClientAgent : AIAgent
}
private string GetLoggingAgentName() => this.Name ?? "UnnamedAgent";
+
+ ///
+ /// Validates that all configured providers have unique values
+ /// and returns a of the AIContextProvider state keys.
+ ///
+ private static HashSet ValidateAndCollectStateKeys(IEnumerable? aiContextProviders, ChatHistoryProvider? chatHistoryProvider)
+ {
+ HashSet stateKeys = new(StringComparer.Ordinal);
+
+ if (aiContextProviders is not null)
+ {
+ foreach (var provider in aiContextProviders)
+ {
+ if (!stateKeys.Add(provider.StateKey))
+ {
+ 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 (chatHistoryProvider is null
+ && stateKeys.Contains(nameof(InMemoryChatHistoryProvider)))
+ {
+ throw new InvalidOperationException(
+ $"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))
+ {
+ 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.");
+ }
+
+ return stateKeys;
+ }
+
#endregion
}
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs
index 66f4f797c5..ddca9197ab 100644
--- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
+using System.Collections.Generic;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
@@ -40,9 +41,9 @@ public sealed class ChatClientAgentOptions
public ChatHistoryProvider? ChatHistoryProvider { get; set; }
///
- /// Gets or sets the instance to use for providing additional context for each agent run.
+ /// Gets or sets the list of instances to use for providing additional context for each agent run.
///
- public AIContextProvider? AIContextProvider { get; set; }
+ public IEnumerable? AIContextProviders { get; set; }
///
/// Gets or sets a value indicating whether to use the provided instance as is,
@@ -69,6 +70,6 @@ public sealed class ChatClientAgentOptions
Description = this.Description,
ChatOptions = this.ChatOptions?.Clone(),
ChatHistoryProvider = this.ChatHistoryProvider,
- AIContextProvider = this.AIContextProvider,
+ AIContextProviders = this.AIContextProviders is null ? null : new List(this.AIContextProviders),
};
}
diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientExtensionsTests.cs
index 3a5ca14939..2f2e276ae9 100644
--- a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientExtensionsTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientExtensionsTests.cs
@@ -2310,10 +2310,10 @@ public sealed class AzureAIProjectChatClientExtensionsTests
#region CreateChatClientAgentOptions - Options Preservation Tests
///
- /// Verify that CreateChatClientAgentOptions preserves AIContextProvider.
+ /// Verify that CreateChatClientAgentOptions preserves AIContextProviders.
///
[Fact]
- public async Task GetAIAgentAsync_WithAIContextProvider_PreservesProviderAsync()
+ public async Task GetAIAgentAsync_WithAIContextProviders_PreservesProviderAsync()
{
// Arrange
AIProjectClient client = this.CreateTestAgentClient();
@@ -2321,7 +2321,7 @@ public sealed class AzureAIProjectChatClientExtensionsTests
{
Name = "test-agent",
ChatOptions = new ChatOptions { Instructions = "Test" },
- AIContextProvider = new TestAIContextProvider()
+ AIContextProviders = [new TestAIContextProvider()]
};
// Act
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentOptionsTests.cs
index e9ad2b9a6b..f69fb3d636 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentOptionsTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentOptionsTests.cs
@@ -22,7 +22,7 @@ public class ChatClientAgentOptionsTests
Assert.Null(options.Description);
Assert.Null(options.ChatOptions);
Assert.Null(options.ChatHistoryProvider);
- Assert.Null(options.AIContextProvider);
+ Assert.Null(options.AIContextProviders);
}
[Fact]
@@ -34,7 +34,7 @@ public class ChatClientAgentOptionsTests
// Assert
Assert.Null(options.Name);
Assert.Null(options.Description);
- Assert.Null(options.AIContextProvider);
+ Assert.Null(options.AIContextProviders);
Assert.Null(options.ChatHistoryProvider);
Assert.NotNull(options.ChatOptions);
Assert.Null(options.ChatOptions.Instructions);
@@ -125,7 +125,7 @@ public class ChatClientAgentOptionsTests
ChatOptions = new() { Tools = tools },
Id = "test-id",
ChatHistoryProvider = mockChatHistoryProvider,
- AIContextProvider = mockAIContextProvider
+ AIContextProviders = [mockAIContextProvider]
};
// Act
@@ -137,7 +137,7 @@ public class ChatClientAgentOptionsTests
Assert.Equal(original.Name, clone.Name);
Assert.Equal(original.Description, clone.Description);
Assert.Same(original.ChatHistoryProvider, clone.ChatHistoryProvider);
- Assert.Same(original.AIContextProvider, clone.AIContextProvider);
+ Assert.Equal(original.AIContextProviders, clone.AIContextProviders);
// ChatOptions should be cloned, not the same reference
Assert.NotSame(original.ChatOptions, clone.ChatOptions);
@@ -158,7 +158,7 @@ public class ChatClientAgentOptionsTests
Name = "Test name",
Description = "Test description",
ChatHistoryProvider = mockChatHistoryProvider,
- AIContextProvider = mockAIContextProvider
+ AIContextProviders = [mockAIContextProvider]
};
// Act
@@ -171,7 +171,7 @@ public class ChatClientAgentOptionsTests
Assert.Equal(original.Description, clone.Description);
Assert.Null(original.ChatOptions);
Assert.Same(original.ChatHistoryProvider, clone.ChatHistoryProvider);
- Assert.Same(original.AIContextProvider, clone.AIContextProvider);
+ Assert.Equal(original.AIContextProviders, clone.AIContextProviders);
}
private static void AssertSameTools(IList? expected, IList? actual)
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs
index 7b727971af..1517b8c7fb 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs
@@ -45,6 +45,154 @@ public partial class ChatClientAgentTests
Assert.Equal("FunctionInvokingChatClient", agent.ChatClient.GetType().Name);
}
+ ///
+ /// Verify that the constructor throws when two AIContextProviders use the same StateKey.
+ ///
+ [Fact]
+ public void Constructor_ThrowsWhenDuplicateAIContextProviderStateKeys()
+ {
+ // Arrange
+ var chatClient = new Mock().Object;
+ var provider1 = new TestAIContextProvider("SharedKey");
+ var provider2 = new TestAIContextProvider("SharedKey");
+
+ // Act & Assert
+ var ex = Assert.Throws(() =>
+ new ChatClientAgent(chatClient, options: new()
+ {
+ AIContextProviders = [provider1, provider2]
+ }));
+
+ Assert.Contains("SharedKey", ex.Message);
+ }
+
+ ///
+ /// Verify that the constructor throws when an AIContextProvider uses the same StateKey as the default InMemoryChatHistoryProvider
+ /// and no explicit ChatHistoryProvider is configured.
+ ///
+ [Fact]
+ public void Constructor_ThrowsWhenAIContextProviderStateKeyClashesWithDefaultInMemoryChatHistoryProvider()
+ {
+ // Arrange
+ var chatClient = new Mock().Object;
+ var contextProvider = new TestAIContextProvider(nameof(InMemoryChatHistoryProvider));
+
+ // Act & Assert
+ var ex = Assert.Throws(() =>
+ new ChatClientAgent(chatClient, options: new()
+ {
+ AIContextProviders = [contextProvider]
+ }));
+
+ Assert.Contains(nameof(InMemoryChatHistoryProvider), ex.Message);
+ }
+
+ ///
+ /// Verify that the constructor throws when a ChatHistoryProvider uses the same StateKey as an AIContextProvider.
+ ///
+ [Fact]
+ public void Constructor_ThrowsWhenChatHistoryProviderStateKeyClashesWithAIContextProvider()
+ {
+ // Arrange
+ var chatClient = new Mock().Object;
+ var contextProvider = new TestAIContextProvider("SharedKey");
+ var historyProvider = new TestChatHistoryProvider("SharedKey");
+
+ // Act & Assert
+ var ex = Assert.Throws(() =>
+ new ChatClientAgent(chatClient, options: new()
+ {
+ AIContextProviders = [contextProvider],
+ ChatHistoryProvider = historyProvider
+ }));
+
+ Assert.Contains("SharedKey", ex.Message);
+ Assert.Contains(nameof(ChatHistoryProvider), ex.Message);
+ }
+
+ ///
+ /// Verify that the constructor succeeds when all providers use unique StateKeys.
+ ///
+ [Fact]
+ public void Constructor_SucceedsWithUniqueProviderStateKeys()
+ {
+ // Arrange
+ var chatClient = new Mock().Object;
+ var contextProvider1 = new TestAIContextProvider("Key1");
+ var contextProvider2 = new TestAIContextProvider("Key2");
+ var historyProvider = new TestChatHistoryProvider("Key3");
+
+ // Act & Assert - should not throw
+ _ = new ChatClientAgent(chatClient, options: new()
+ {
+ AIContextProviders = [contextProvider1, contextProvider2],
+ ChatHistoryProvider = historyProvider
+ });
+ }
+
+ ///
+ /// Verify that RunAsync throws when an override ChatHistoryProvider's StateKey clashes with an AIContextProvider.
+ ///
+ [Fact]
+ public async Task RunAsync_ThrowsWhenOverrideChatHistoryProviderStateKeyClashesWithAIContextProviderAsync()
+ {
+ // 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 TestAIContextProvider("SharedKey");
+ var overrideHistoryProvider = new TestChatHistoryProvider("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("SharedKey", ex.Message);
+ }
+
+ ///
+ /// Verify that RunAsync succeeds when an override ChatHistoryProvider uses the same StateKey as the default ChatHistoryProvider.
+ ///
+ [Fact]
+ public async Task RunAsync_SucceedsWhenOverrideChatHistoryProviderSharesKeyWithDefaultAsync()
+ {
+ // Arrange
+ Mock mockService = new();
+ mockService.Setup(
+ s => s.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
+
+ var defaultHistoryProvider = new TestChatHistoryProvider("SameKey");
+ var overrideHistoryProvider = new TestChatHistoryProvider("SameKey");
+
+ ChatClientAgent agent = new(mockService.Object, options: new()
+ {
+ ChatHistoryProvider = defaultHistoryProvider
+ });
+
+ // Act & Assert - should not throw
+ ChatClientAgentSession? session = await agent.CreateSessionAsync() as ChatClientAgentSession;
+ AdditionalPropertiesDictionary additionalProperties = new();
+ additionalProperties.Add(overrideHistoryProvider);
+
+ await agent.RunAsync([new(ChatRole.User, "test")], session, options: new AgentRunOptions { AdditionalProperties = additionalProperties });
+ }
+
#endregion
#region RunAsync Tests
@@ -357,7 +505,7 @@ public partial class ChatClientAgentTests
.Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
.Returns(new ValueTask());
- ChatClientAgent agent = new(mockService.Object, options: new() { AIContextProvider = mockProvider.Object, ChatOptions = new() { Instructions = "base instructions", Tools = [AIFunctionFactory.Create(() => { }, "base function")] } });
+ ChatClientAgent agent = new(mockService.Object, options: new() { AIContextProviders = [mockProvider.Object], ChatOptions = new() { Instructions = "base instructions", Tools = [AIFunctionFactory.Create(() => { }, "base function")] } });
// Act
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
@@ -426,7 +574,7 @@ public partial class ChatClientAgentTests
.Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
.Returns(new ValueTask());
- ChatClientAgent agent = new(mockService.Object, options: new() { AIContextProvider = mockProvider.Object, ChatOptions = new() { Instructions = "base instructions", Tools = [AIFunctionFactory.Create(() => { }, "base function")] } });
+ ChatClientAgent agent = new(mockService.Object, options: new() { AIContextProviders = [mockProvider.Object], ChatOptions = new() { Instructions = "base instructions", Tools = [AIFunctionFactory.Create(() => { }, "base function")] } });
// Act
await Assert.ThrowsAsync(() => agent.RunAsync(requestMessages));
@@ -482,7 +630,7 @@ public partial class ChatClientAgentTests
Tools = ctx.AIContext.Tools
}));
- ChatClientAgent agent = new(mockService.Object, options: new() { AIContextProvider = mockProvider.Object, ChatOptions = new() { Instructions = "base instructions", Tools = [AIFunctionFactory.Create(() => { }, "base function")] } });
+ ChatClientAgent agent = new(mockService.Object, options: new() { AIContextProviders = [mockProvider.Object], ChatOptions = new() { Instructions = "base instructions", Tools = [AIFunctionFactory.Create(() => { }, "base function")] } });
// Act
await agent.RunAsync([new(ChatRole.User, "user message")]);
@@ -500,6 +648,299 @@ public partial class ChatClientAgentTests
.Verify>("InvokingCoreAsync", Times.Once(), ItExpr.IsAny(), ItExpr.IsAny());
}
+ ///
+ /// Verify that RunAsync invokes multiple AIContextProviders in sequence, each receiving the accumulated context.
+ ///
+ [Fact]
+ public async Task RunAsyncInvokesMultipleAIContextProvidersInOrderAsync()
+ {
+ // Arrange
+ ChatMessage[] requestMessages = [new(ChatRole.User, "user message")];
+ ChatMessage[] responseMessages = [new(ChatRole.Assistant, "response")];
+ Mock mockService = new();
+ List capturedMessages = [];
+ string capturedInstructions = string.Empty;
+ List capturedTools = [];
+ mockService
+ .Setup(s => s.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .Callback, ChatOptions, CancellationToken>((msgs, opts, ct) =>
+ {
+ capturedMessages.AddRange(msgs);
+ capturedInstructions = opts.Instructions ?? string.Empty;
+ if (opts.Tools is not null)
+ {
+ capturedTools.AddRange(opts.Tools);
+ }
+ })
+ .ReturnsAsync(new ChatResponse(responseMessages));
+
+ // Provider 1: adds a system message and a tool
+ var mockProvider1 = new Mock();
+ mockProvider1.SetupGet(p => p.StateKey).Returns("Provider1");
+ mockProvider1
+ .Protected()
+ .Setup>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns((AIContextProvider.InvokingContext ctx, CancellationToken _) =>
+ new ValueTask(new AIContext
+ {
+ Messages = (ctx.AIContext.Messages ?? []).Concat([new ChatMessage(ChatRole.System, "provider1 context")]).ToList(),
+ Instructions = ctx.AIContext.Instructions + "\nprovider1 instructions",
+ Tools = (ctx.AIContext.Tools ?? []).Concat([AIFunctionFactory.Create(() => { }, "provider1 function")]).ToList()
+ }));
+ mockProvider1
+ .Protected()
+ .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns(new ValueTask());
+
+ // Provider 2: adds another system message and verifies it receives accumulated context from provider 1
+ AIContext? provider2ReceivedContext = null;
+ var mockProvider2 = new Mock();
+ mockProvider2.SetupGet(p => p.StateKey).Returns("Provider2");
+ mockProvider2
+ .Protected()
+ .Setup>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns((AIContextProvider.InvokingContext ctx, CancellationToken _) =>
+ {
+ provider2ReceivedContext = ctx.AIContext;
+ return new ValueTask(new AIContext
+ {
+ Messages = (ctx.AIContext.Messages ?? []).Concat([new ChatMessage(ChatRole.System, "provider2 context")]).ToList(),
+ Instructions = ctx.AIContext.Instructions + "\nprovider2 instructions",
+ Tools = (ctx.AIContext.Tools ?? []).Concat([AIFunctionFactory.Create(() => { }, "provider2 function")]).ToList()
+ });
+ });
+ mockProvider2
+ .Protected()
+ .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns(new ValueTask());
+
+ ChatClientAgent agent = new(mockService.Object, options: new()
+ {
+ AIContextProviders = [mockProvider1.Object, mockProvider2.Object],
+ ChatOptions = new() { Instructions = "base instructions", Tools = [AIFunctionFactory.Create(() => { }, "base function")] }
+ });
+
+ // Act
+ var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
+ await agent.RunAsync(requestMessages, session);
+
+ // Assert
+ // Provider 2 should have received accumulated context from provider 1
+ Assert.NotNull(provider2ReceivedContext);
+ Assert.Contains(provider2ReceivedContext.Messages!, m => m.Text == "provider1 context");
+ Assert.Contains("provider1 instructions", provider2ReceivedContext.Instructions);
+
+ // Final captured messages should contain user message + both provider contexts
+ Assert.Equal(3, capturedMessages.Count);
+ Assert.Equal("user message", capturedMessages[0].Text);
+ Assert.Equal("provider1 context", capturedMessages[1].Text);
+ Assert.Equal("provider2 context", capturedMessages[2].Text);
+
+ // Instructions should be accumulated
+ Assert.Equal("base instructions\nprovider1 instructions\nprovider2 instructions", capturedInstructions);
+
+ // Tools should contain base + both provider tools
+ Assert.Equal(3, capturedTools.Count);
+ Assert.Contains(capturedTools, t => t.Name == "base function");
+ Assert.Contains(capturedTools, t => t.Name == "provider1 function");
+ Assert.Contains(capturedTools, t => t.Name == "provider2 function");
+
+ // Both providers should have been invoked
+ mockProvider1
+ .Protected()
+ .Verify>("InvokingCoreAsync", Times.Once(), ItExpr.IsAny(), ItExpr.IsAny());
+ mockProvider2
+ .Protected()
+ .Verify>("InvokingCoreAsync", Times.Once(), ItExpr.IsAny(), ItExpr.IsAny());
+
+ // Both providers should have been notified of success
+ mockProvider1
+ .Protected()
+ .Verify("InvokedCoreAsync", Times.Once(), ItExpr.Is(x =>
+ x.ResponseMessages == responseMessages &&
+ x.InvokeException == null), ItExpr.IsAny());
+ mockProvider2
+ .Protected()
+ .Verify("InvokedCoreAsync", Times.Once(), ItExpr.Is(x =>
+ x.ResponseMessages == responseMessages &&
+ x.InvokeException == null), ItExpr.IsAny());
+ }
+
+ ///
+ /// Verify that RunAsync invokes InvokedCoreAsync on all AIContextProviders when the downstream GetResponse call fails.
+ ///
+ [Fact]
+ public async Task RunAsyncInvokesMultipleAIContextProvidersOnFailureAsync()
+ {
+ // Arrange
+ ChatMessage[] requestMessages = [new(ChatRole.User, "user message")];
+ Mock mockService = new();
+ mockService
+ .Setup(s => s.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .ThrowsAsync(new InvalidOperationException("downstream failure"));
+
+ var mockProvider1 = new Mock();
+ mockProvider1.SetupGet(p => p.StateKey).Returns("Provider1");
+ mockProvider1
+ .Protected()
+ .Setup>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns((AIContextProvider.InvokingContext ctx, CancellationToken _) =>
+ new ValueTask(new AIContext
+ {
+ Messages = ctx.AIContext.Messages?.ToList(),
+ Instructions = ctx.AIContext.Instructions,
+ Tools = ctx.AIContext.Tools
+ }));
+ mockProvider1
+ .Protected()
+ .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns(new ValueTask());
+
+ var mockProvider2 = new Mock();
+ mockProvider2.SetupGet(p => p.StateKey).Returns("Provider2");
+ mockProvider2
+ .Protected()
+ .Setup>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns((AIContextProvider.InvokingContext ctx, CancellationToken _) =>
+ new ValueTask(new AIContext
+ {
+ Messages = ctx.AIContext.Messages?.ToList(),
+ Instructions = ctx.AIContext.Instructions,
+ Tools = ctx.AIContext.Tools
+ }));
+ mockProvider2
+ .Protected()
+ .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns(new ValueTask());
+
+ ChatClientAgent agent = new(mockService.Object, options: new()
+ {
+ AIContextProviders = [mockProvider1.Object, mockProvider2.Object],
+ ChatOptions = new() { Instructions = "base instructions" }
+ });
+
+ // Act
+ await Assert.ThrowsAsync(() => agent.RunAsync(requestMessages));
+
+ // Assert - both providers should have been notified of the failure
+ mockProvider1
+ .Protected()
+ .Verify>("InvokingCoreAsync", Times.Once(), ItExpr.IsAny(), ItExpr.IsAny());
+ mockProvider2
+ .Protected()
+ .Verify>("InvokingCoreAsync", Times.Once(), ItExpr.IsAny(), ItExpr.IsAny());
+
+ mockProvider1
+ .Protected()
+ .Verify("InvokedCoreAsync", Times.Once(), ItExpr.Is(x =>
+ x.InvokeException is InvalidOperationException), ItExpr.IsAny());
+ mockProvider2
+ .Protected()
+ .Verify("InvokedCoreAsync", Times.Once(), ItExpr.Is(x =>
+ x.InvokeException is InvalidOperationException), ItExpr.IsAny());
+ }
+
+ ///
+ /// Verify that RunStreamingAsync invokes multiple AIContextProviders in sequence.
+ ///
+ [Fact]
+ public async Task RunStreamingAsyncInvokesMultipleAIContextProvidersAsync()
+ {
+ // Arrange
+ ChatMessage[] requestMessages = [new(ChatRole.User, "user message")];
+ ChatResponseUpdate[] responseUpdates = [new(ChatRole.Assistant, "response")];
+ Mock mockService = new();
+ List capturedMessages = [];
+ string capturedInstructions = string.Empty;
+ mockService
+ .Setup(s => s.GetStreamingResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .Callback, ChatOptions, CancellationToken>((msgs, opts, ct) =>
+ {
+ capturedMessages.AddRange(msgs);
+ capturedInstructions = opts.Instructions ?? string.Empty;
+ })
+ .Returns(ToAsyncEnumerableAsync(responseUpdates));
+
+ var mockProvider1 = new Mock();
+ mockProvider1.SetupGet(p => p.StateKey).Returns("Provider1");
+ mockProvider1
+ .Protected()
+ .Setup>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns((AIContextProvider.InvokingContext ctx, CancellationToken _) =>
+ new ValueTask(new AIContext
+ {
+ Messages = (ctx.AIContext.Messages ?? []).Concat([new ChatMessage(ChatRole.System, "provider1 context")]).ToList(),
+ Instructions = ctx.AIContext.Instructions + "\nprovider1 instructions",
+ Tools = ctx.AIContext.Tools
+ }));
+ mockProvider1
+ .Protected()
+ .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns(new ValueTask());
+
+ var mockProvider2 = new Mock();
+ mockProvider2.SetupGet(p => p.StateKey).Returns("Provider2");
+ mockProvider2
+ .Protected()
+ .Setup>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns((AIContextProvider.InvokingContext ctx, CancellationToken _) =>
+ new ValueTask(new AIContext
+ {
+ Messages = (ctx.AIContext.Messages ?? []).Concat([new ChatMessage(ChatRole.System, "provider2 context")]).ToList(),
+ Instructions = ctx.AIContext.Instructions + "\nprovider2 instructions",
+ Tools = ctx.AIContext.Tools
+ }));
+ mockProvider2
+ .Protected()
+ .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns(new ValueTask());
+
+ ChatClientAgent agent = new(
+ mockService.Object,
+ options: new()
+ {
+ ChatOptions = new() { Instructions = "base instructions" },
+ AIContextProviders = [mockProvider1.Object, mockProvider2.Object]
+ });
+
+ // Act
+ var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
+ var updates = agent.RunStreamingAsync(requestMessages, session);
+ _ = await updates.ToAgentResponseAsync();
+
+ // Assert
+ Assert.Equal(3, capturedMessages.Count);
+ Assert.Equal("user message", capturedMessages[0].Text);
+ Assert.Equal("provider1 context", capturedMessages[1].Text);
+ Assert.Equal("provider2 context", capturedMessages[2].Text);
+ Assert.Equal("base instructions\nprovider1 instructions\nprovider2 instructions", capturedInstructions);
+
+ // Both providers should have been invoked and notified
+ mockProvider1
+ .Protected()
+ .Verify>("InvokingCoreAsync", Times.Once(), ItExpr.IsAny(), ItExpr.IsAny());
+ mockProvider2
+ .Protected()
+ .Verify>("InvokingCoreAsync", Times.Once(), ItExpr.IsAny(), ItExpr.IsAny());
+ mockProvider1
+ .Protected()
+ .Verify("InvokedCoreAsync", Times.Once(), ItExpr.Is(x =>
+ x.InvokeException == null), ItExpr.IsAny());
+ mockProvider2
+ .Protected()
+ .Verify("InvokedCoreAsync", Times.Once(), ItExpr.Is(x =>
+ x.InvokeException == null), ItExpr.IsAny());
+ }
+
#endregion
#region RunAsync Structured Output Tests
@@ -1448,7 +1889,7 @@ public partial class ChatClientAgentTests
options: new()
{
ChatOptions = new() { Instructions = "base instructions", Tools = [AIFunctionFactory.Create(() => { }, "base function")] },
- AIContextProvider = mockProvider.Object
+ AIContextProviders = [mockProvider.Object]
});
// Act
@@ -1525,7 +1966,7 @@ public partial class ChatClientAgentTests
options: new()
{
ChatOptions = new() { Instructions = "base instructions", Tools = [AIFunctionFactory.Create(() => { }, "base function")] },
- AIContextProvider = mockProvider.Object
+ AIContextProviders = [mockProvider.Object]
});
// Act
@@ -1575,4 +2016,23 @@ public partial class ChatClientAgentTests
[JsonSourceGenerationOptions(UseStringEnumConverter = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(Animal))]
private sealed partial class JsonContext2 : JsonSerializerContext;
+
+ private sealed class TestAIContextProvider(string stateKey) : AIContextProvider
+ {
+ public override string StateKey => stateKey;
+
+ protected override ValueTask InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default)
+ => new(context.AIContext);
+ }
+
+ private sealed class TestChatHistoryProvider(string stateKey) : ChatHistoryProvider
+ {
+ public override string StateKey => stateKey;
+
+ protected override ValueTask> InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default)
+ => new(context.RequestMessages);
+
+ protected override ValueTask InvokedCoreAsync(InvokedContext context, CancellationToken cancellationToken = default)
+ => default;
+ }
}
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 8f2f5bebd1..84285ff9c4 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_BackgroundResponsesTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_BackgroundResponsesTests.cs
@@ -339,6 +339,7 @@ public class ChatClientAgent_BackgroundResponsesTests
// Create a mock chat history provider that would normally provide messages
var mockChatHistoryProvider = new Mock();
+ mockChatHistoryProvider.SetupGet(p => p.StateKey).Returns("ChatHistoryProvider");
mockChatHistoryProvider
.Protected()
.Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
@@ -346,6 +347,7 @@ public class ChatClientAgent_BackgroundResponsesTests
// Create a mock AI context provider that would normally provide context
var mockContextProvider = new Mock();
+ mockContextProvider.SetupGet(p => p.StateKey).Returns("Provider1");
mockContextProvider
.Protected()
.Setup>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
@@ -368,7 +370,7 @@ public class ChatClientAgent_BackgroundResponsesTests
ChatClientAgent agent = new(mockChatClient.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
- AIContextProvider = mockContextProvider.Object
+ AIContextProviders = [mockContextProvider.Object]
});
// Create a session
@@ -406,6 +408,7 @@ public class ChatClientAgent_BackgroundResponsesTests
// Create a mock chat history provider that would normally provide messages
var mockChatHistoryProvider = new Mock();
+ mockChatHistoryProvider.SetupGet(p => p.StateKey).Returns("ChatHistoryProvider");
mockChatHistoryProvider
.Protected()
.Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
@@ -413,6 +416,7 @@ public class ChatClientAgent_BackgroundResponsesTests
// Create a mock AI context provider that would normally provide context
var mockContextProvider = new Mock();
+ mockContextProvider.SetupGet(p => p.StateKey).Returns("Provider1");
mockContextProvider
.Protected()
.Setup>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
@@ -435,7 +439,7 @@ public class ChatClientAgent_BackgroundResponsesTests
ChatClientAgent agent = new(mockChatClient.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
- AIContextProvider = mockContextProvider.Object
+ AIContextProviders = [mockContextProvider.Object]
});
// Create a session
@@ -635,6 +639,7 @@ public class ChatClientAgent_BackgroundResponsesTests
List capturedMessagesAddedToProvider = [];
var mockChatHistoryProvider = new Mock();
+ mockChatHistoryProvider.SetupGet(p => p.StateKey).Returns("ChatHistoryProvider");
mockChatHistoryProvider
.Protected()
.Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
@@ -643,6 +648,7 @@ public class ChatClientAgent_BackgroundResponsesTests
AIContextProvider.InvokedContext? capturedInvokedContext = null;
var mockContextProvider = new Mock();
+ mockContextProvider.SetupGet(p => p.StateKey).Returns("Provider1");
mockContextProvider
.Protected()
.Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
@@ -652,7 +658,7 @@ public class ChatClientAgent_BackgroundResponsesTests
ChatClientAgent agent = new(mockChatClient.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
- AIContextProvider = mockContextProvider.Object
+ AIContextProviders = [mockContextProvider.Object]
});
ChatClientAgentSession? session = new();
@@ -697,6 +703,7 @@ public class ChatClientAgent_BackgroundResponsesTests
List capturedMessagesAddedToProvider = [];
var mockChatHistoryProvider = new Mock();
+ mockChatHistoryProvider.SetupGet(p => p.StateKey).Returns("ChatHistoryProvider");
mockChatHistoryProvider
.Protected()
.Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
@@ -705,6 +712,7 @@ public class ChatClientAgent_BackgroundResponsesTests
AIContextProvider.InvokedContext? capturedInvokedContext = null;
var mockContextProvider = new Mock();
+ mockContextProvider.SetupGet(p => p.StateKey).Returns("Provider1");
mockContextProvider
.Protected()
.Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
@@ -714,7 +722,7 @@ public class ChatClientAgent_BackgroundResponsesTests
ChatClientAgent agent = new(mockChatClient.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
- AIContextProvider = mockContextProvider.Object
+ AIContextProviders = [mockContextProvider.Object]
});
ChatClientAgentSession? session = new();