mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: [BREAKING] Add support for multiple AIContextProviders on a ChatClientAgent (#3863)
* Add support for multiple AIContextProviders on a ChatClientAgent * Address PR comments and fix tests * Address PR comments.
This commit is contained in:
committed by
GitHub
Unverified
parent
f44fe17479
commit
210d0b8828
+2
-2
@@ -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.
|
||||
|
||||
+1
-1
@@ -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();
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -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();
|
||||
|
||||
+1
-1
@@ -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();
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="AIContextProvider"/> which aggregates multiple AI context providers into one.
|
||||
/// Tools and messages from all providers are combined, and instructions are concatenated.
|
||||
/// </summary>
|
||||
internal sealed class AggregatingAIContextProvider : AIContextProvider
|
||||
{
|
||||
private readonly List<AIContextProvider> _providers;
|
||||
|
||||
public AggregatingAIContextProvider(List<AIContextProvider> providers)
|
||||
{
|
||||
this._providers = providers;
|
||||
}
|
||||
|
||||
protected override async ValueTask<AIContext> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ namespace Microsoft.Agents.AI;
|
||||
public sealed partial class ChatClientAgent : AIAgent
|
||||
{
|
||||
private readonly ChatClientAgentOptions? _agentOptions;
|
||||
private readonly HashSet<string> _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<AIContextProvider> ?? 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<ILoggerFactory>() ?? NullLoggerFactory.Instance).CreateLogger<ChatClientAgent>();
|
||||
}
|
||||
@@ -133,6 +138,14 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
/// </remarks>
|
||||
public ChatHistoryProvider? ChatHistoryProvider { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of <see cref="AIContextProvider"/> instances used by this agent, to support cases where additional context is needed for each agent run.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This property may be null in case no additional context providers were configured.
|
||||
/// </remarks>
|
||||
public IReadOnlyList<AIContextProvider>? AIContextProviders { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
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<ChatMessage> 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<ChatMessage> 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<AITool> ?? 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<ChatMessage> ?? 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";
|
||||
|
||||
/// <summary>
|
||||
/// Validates that all configured providers have unique <see cref="AIContextProvider.StateKey"/> values
|
||||
/// and returns a <see cref="HashSet{T}"/> of the AIContextProvider state keys.
|
||||
/// </summary>
|
||||
private static HashSet<string> ValidateAndCollectStateKeys(IEnumerable<AIContextProvider>? aiContextProviders, ChatHistoryProvider? chatHistoryProvider)
|
||||
{
|
||||
HashSet<string> 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
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the <see cref="AIContextProvider"/> instance to use for providing additional context for each agent run.
|
||||
/// Gets or sets the list of <see cref="AIContextProvider"/> instances to use for providing additional context for each agent run.
|
||||
/// </summary>
|
||||
public AIContextProvider? AIContextProvider { get; set; }
|
||||
public IEnumerable<AIContextProvider>? AIContextProviders { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to use the provided <see cref="IChatClient"/> 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<AIContextProvider>(this.AIContextProviders),
|
||||
};
|
||||
}
|
||||
|
||||
+3
-3
@@ -2310,10 +2310,10 @@ public sealed class AzureAIProjectChatClientExtensionsTests
|
||||
#region CreateChatClientAgentOptions - Options Preservation Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateChatClientAgentOptions preserves AIContextProvider.
|
||||
/// Verify that CreateChatClientAgentOptions preserves AIContextProviders.
|
||||
/// </summary>
|
||||
[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
|
||||
|
||||
+6
-6
@@ -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<AITool>? expected, IList<AITool>? actual)
|
||||
|
||||
@@ -45,6 +45,154 @@ public partial class ChatClientAgentTests
|
||||
Assert.Equal("FunctionInvokingChatClient", agent.ChatClient.GetType().Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that the constructor throws when two AIContextProviders use the same StateKey.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_ThrowsWhenDuplicateAIContextProviderStateKeys()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var provider1 = new TestAIContextProvider("SharedKey");
|
||||
var provider2 = new TestAIContextProvider("SharedKey");
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<InvalidOperationException>(() =>
|
||||
new ChatClientAgent(chatClient, options: new()
|
||||
{
|
||||
AIContextProviders = [provider1, provider2]
|
||||
}));
|
||||
|
||||
Assert.Contains("SharedKey", ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that the constructor throws when an AIContextProvider uses the same StateKey as the default InMemoryChatHistoryProvider
|
||||
/// and no explicit ChatHistoryProvider is configured.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_ThrowsWhenAIContextProviderStateKeyClashesWithDefaultInMemoryChatHistoryProvider()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var contextProvider = new TestAIContextProvider(nameof(InMemoryChatHistoryProvider));
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<InvalidOperationException>(() =>
|
||||
new ChatClientAgent(chatClient, options: new()
|
||||
{
|
||||
AIContextProviders = [contextProvider]
|
||||
}));
|
||||
|
||||
Assert.Contains(nameof(InMemoryChatHistoryProvider), ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that the constructor throws when a ChatHistoryProvider uses the same StateKey as an AIContextProvider.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_ThrowsWhenChatHistoryProviderStateKeyClashesWithAIContextProvider()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var contextProvider = new TestAIContextProvider("SharedKey");
|
||||
var historyProvider = new TestChatHistoryProvider("SharedKey");
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<InvalidOperationException>(() =>
|
||||
new ChatClientAgent(chatClient, options: new()
|
||||
{
|
||||
AIContextProviders = [contextProvider],
|
||||
ChatHistoryProvider = historyProvider
|
||||
}));
|
||||
|
||||
Assert.Contains("SharedKey", ex.Message);
|
||||
Assert.Contains(nameof(ChatHistoryProvider), ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that the constructor succeeds when all providers use unique StateKeys.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_SucceedsWithUniqueProviderStateKeys()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().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
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that RunAsync throws when an override ChatHistoryProvider's StateKey clashes with an AIContextProvider.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_ThrowsWhenOverrideChatHistoryProviderStateKeyClashesWithAIContextProviderAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).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<ChatHistoryProvider>(overrideHistoryProvider);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
agent.RunAsync([new(ChatRole.User, "test")], session, options: new AgentRunOptions { AdditionalProperties = additionalProperties }));
|
||||
|
||||
Assert.Contains("SharedKey", ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that RunAsync succeeds when an override ChatHistoryProvider uses the same StateKey as the default ChatHistoryProvider.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_SucceedsWhenOverrideChatHistoryProviderSharesKeyWithDefaultAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).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<ChatHistoryProvider>(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<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.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<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.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<InvalidOperationException>(() => 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<ValueTask<AIContext>>("InvokingCoreAsync", Times.Once(), ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that RunAsync invokes multiple AIContextProviders in sequence, each receiving the accumulated context.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsyncInvokesMultipleAIContextProvidersInOrderAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatMessage[] requestMessages = [new(ChatRole.User, "user message")];
|
||||
ChatMessage[] responseMessages = [new(ChatRole.Assistant, "response")];
|
||||
Mock<IChatClient> mockService = new();
|
||||
List<ChatMessage> capturedMessages = [];
|
||||
string capturedInstructions = string.Empty;
|
||||
List<AITool> capturedTools = [];
|
||||
mockService
|
||||
.Setup(s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, 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<AIContextProvider>();
|
||||
mockProvider1.SetupGet(p => p.StateKey).Returns("Provider1");
|
||||
mockProvider1
|
||||
.Protected()
|
||||
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns((AIContextProvider.InvokingContext ctx, CancellationToken _) =>
|
||||
new ValueTask<AIContext>(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<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.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<AIContextProvider>();
|
||||
mockProvider2.SetupGet(p => p.StateKey).Returns("Provider2");
|
||||
mockProvider2
|
||||
.Protected()
|
||||
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns((AIContextProvider.InvokingContext ctx, CancellationToken _) =>
|
||||
{
|
||||
provider2ReceivedContext = ctx.AIContext;
|
||||
return new ValueTask<AIContext>(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<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.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<ValueTask<AIContext>>("InvokingCoreAsync", Times.Once(), ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>());
|
||||
mockProvider2
|
||||
.Protected()
|
||||
.Verify<ValueTask<AIContext>>("InvokingCoreAsync", Times.Once(), ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>());
|
||||
|
||||
// Both providers should have been notified of success
|
||||
mockProvider1
|
||||
.Protected()
|
||||
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(), ItExpr.Is<AIContextProvider.InvokedContext>(x =>
|
||||
x.ResponseMessages == responseMessages &&
|
||||
x.InvokeException == null), ItExpr.IsAny<CancellationToken>());
|
||||
mockProvider2
|
||||
.Protected()
|
||||
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(), ItExpr.Is<AIContextProvider.InvokedContext>(x =>
|
||||
x.ResponseMessages == responseMessages &&
|
||||
x.InvokeException == null), ItExpr.IsAny<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that RunAsync invokes InvokedCoreAsync on all AIContextProviders when the downstream GetResponse call fails.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsyncInvokesMultipleAIContextProvidersOnFailureAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatMessage[] requestMessages = [new(ChatRole.User, "user message")];
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService
|
||||
.Setup(s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("downstream failure"));
|
||||
|
||||
var mockProvider1 = new Mock<AIContextProvider>();
|
||||
mockProvider1.SetupGet(p => p.StateKey).Returns("Provider1");
|
||||
mockProvider1
|
||||
.Protected()
|
||||
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns((AIContextProvider.InvokingContext ctx, CancellationToken _) =>
|
||||
new ValueTask<AIContext>(new AIContext
|
||||
{
|
||||
Messages = ctx.AIContext.Messages?.ToList(),
|
||||
Instructions = ctx.AIContext.Instructions,
|
||||
Tools = ctx.AIContext.Tools
|
||||
}));
|
||||
mockProvider1
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(new ValueTask());
|
||||
|
||||
var mockProvider2 = new Mock<AIContextProvider>();
|
||||
mockProvider2.SetupGet(p => p.StateKey).Returns("Provider2");
|
||||
mockProvider2
|
||||
.Protected()
|
||||
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns((AIContextProvider.InvokingContext ctx, CancellationToken _) =>
|
||||
new ValueTask<AIContext>(new AIContext
|
||||
{
|
||||
Messages = ctx.AIContext.Messages?.ToList(),
|
||||
Instructions = ctx.AIContext.Instructions,
|
||||
Tools = ctx.AIContext.Tools
|
||||
}));
|
||||
mockProvider2
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(new ValueTask());
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
AIContextProviders = [mockProvider1.Object, mockProvider2.Object],
|
||||
ChatOptions = new() { Instructions = "base instructions" }
|
||||
});
|
||||
|
||||
// Act
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync(requestMessages));
|
||||
|
||||
// Assert - both providers should have been notified of the failure
|
||||
mockProvider1
|
||||
.Protected()
|
||||
.Verify<ValueTask<AIContext>>("InvokingCoreAsync", Times.Once(), ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>());
|
||||
mockProvider2
|
||||
.Protected()
|
||||
.Verify<ValueTask<AIContext>>("InvokingCoreAsync", Times.Once(), ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>());
|
||||
|
||||
mockProvider1
|
||||
.Protected()
|
||||
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(), ItExpr.Is<AIContextProvider.InvokedContext>(x =>
|
||||
x.InvokeException is InvalidOperationException), ItExpr.IsAny<CancellationToken>());
|
||||
mockProvider2
|
||||
.Protected()
|
||||
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(), ItExpr.Is<AIContextProvider.InvokedContext>(x =>
|
||||
x.InvokeException is InvalidOperationException), ItExpr.IsAny<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that RunStreamingAsync invokes multiple AIContextProviders in sequence.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunStreamingAsyncInvokesMultipleAIContextProvidersAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatMessage[] requestMessages = [new(ChatRole.User, "user message")];
|
||||
ChatResponseUpdate[] responseUpdates = [new(ChatRole.Assistant, "response")];
|
||||
Mock<IChatClient> mockService = new();
|
||||
List<ChatMessage> capturedMessages = [];
|
||||
string capturedInstructions = string.Empty;
|
||||
mockService
|
||||
.Setup(s => s.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
|
||||
{
|
||||
capturedMessages.AddRange(msgs);
|
||||
capturedInstructions = opts.Instructions ?? string.Empty;
|
||||
})
|
||||
.Returns(ToAsyncEnumerableAsync(responseUpdates));
|
||||
|
||||
var mockProvider1 = new Mock<AIContextProvider>();
|
||||
mockProvider1.SetupGet(p => p.StateKey).Returns("Provider1");
|
||||
mockProvider1
|
||||
.Protected()
|
||||
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns((AIContextProvider.InvokingContext ctx, CancellationToken _) =>
|
||||
new ValueTask<AIContext>(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<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(new ValueTask());
|
||||
|
||||
var mockProvider2 = new Mock<AIContextProvider>();
|
||||
mockProvider2.SetupGet(p => p.StateKey).Returns("Provider2");
|
||||
mockProvider2
|
||||
.Protected()
|
||||
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns((AIContextProvider.InvokingContext ctx, CancellationToken _) =>
|
||||
new ValueTask<AIContext>(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<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.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<ValueTask<AIContext>>("InvokingCoreAsync", Times.Once(), ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>());
|
||||
mockProvider2
|
||||
.Protected()
|
||||
.Verify<ValueTask<AIContext>>("InvokingCoreAsync", Times.Once(), ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>());
|
||||
mockProvider1
|
||||
.Protected()
|
||||
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(), ItExpr.Is<AIContextProvider.InvokedContext>(x =>
|
||||
x.InvokeException == null), ItExpr.IsAny<CancellationToken>());
|
||||
mockProvider2
|
||||
.Protected()
|
||||
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(), ItExpr.Is<AIContextProvider.InvokedContext>(x =>
|
||||
x.InvokeException == null), ItExpr.IsAny<CancellationToken>());
|
||||
}
|
||||
|
||||
#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<AIContext> InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
=> new(context.AIContext);
|
||||
}
|
||||
|
||||
private sealed class TestChatHistoryProvider(string stateKey) : ChatHistoryProvider
|
||||
{
|
||||
public override string StateKey => stateKey;
|
||||
|
||||
protected override ValueTask<IEnumerable<ChatMessage>> InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
=> new(context.RequestMessages);
|
||||
|
||||
protected override ValueTask InvokedCoreAsync(InvokedContext context, CancellationToken cancellationToken = default)
|
||||
=> default;
|
||||
}
|
||||
}
|
||||
|
||||
+12
-4
@@ -339,6 +339,7 @@ public class ChatClientAgent_BackgroundResponsesTests
|
||||
|
||||
// Create a mock chat history provider that would normally provide messages
|
||||
var mockChatHistoryProvider = new Mock<ChatHistoryProvider>();
|
||||
mockChatHistoryProvider.SetupGet(p => p.StateKey).Returns("ChatHistoryProvider");
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
@@ -346,6 +347,7 @@ public class ChatClientAgent_BackgroundResponsesTests
|
||||
|
||||
// Create a mock AI context provider that would normally provide context
|
||||
var mockContextProvider = new Mock<AIContextProvider>();
|
||||
mockContextProvider.SetupGet(p => p.StateKey).Returns("Provider1");
|
||||
mockContextProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
@@ -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<ChatHistoryProvider>();
|
||||
mockChatHistoryProvider.SetupGet(p => p.StateKey).Returns("ChatHistoryProvider");
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
@@ -413,6 +416,7 @@ public class ChatClientAgent_BackgroundResponsesTests
|
||||
|
||||
// Create a mock AI context provider that would normally provide context
|
||||
var mockContextProvider = new Mock<AIContextProvider>();
|
||||
mockContextProvider.SetupGet(p => p.StateKey).Returns("Provider1");
|
||||
mockContextProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
@@ -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<ChatMessage> capturedMessagesAddedToProvider = [];
|
||||
var mockChatHistoryProvider = new Mock<ChatHistoryProvider>();
|
||||
mockChatHistoryProvider.SetupGet(p => p.StateKey).Returns("ChatHistoryProvider");
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
@@ -643,6 +648,7 @@ public class ChatClientAgent_BackgroundResponsesTests
|
||||
|
||||
AIContextProvider.InvokedContext? capturedInvokedContext = null;
|
||||
var mockContextProvider = new Mock<AIContextProvider>();
|
||||
mockContextProvider.SetupGet(p => p.StateKey).Returns("Provider1");
|
||||
mockContextProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
@@ -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<ChatMessage> capturedMessagesAddedToProvider = [];
|
||||
var mockChatHistoryProvider = new Mock<ChatHistoryProvider>();
|
||||
mockChatHistoryProvider.SetupGet(p => p.StateKey).Returns("ChatHistoryProvider");
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
@@ -705,6 +712,7 @@ public class ChatClientAgent_BackgroundResponsesTests
|
||||
|
||||
AIContextProvider.InvokedContext? capturedInvokedContext = null;
|
||||
var mockContextProvider = new Mock<AIContextProvider>();
|
||||
mockContextProvider.SetupGet(p => p.StateKey).Returns("Provider1");
|
||||
mockContextProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user