.NET: Allow agents to opt into concurrent tool invocation (#7650)

* .NET: allow agents to opt into concurrent tool invocation

* .NET: address concurrent invocation review feedback
This commit is contained in:
ump45nose
2026-08-17 11:15:27 +00:00
committed by GitHub
parent 9c3a1a4af7
commit 047ec7eaff
4 changed files with 64 additions and 2 deletions
@@ -61,6 +61,19 @@ public sealed class ChatClientAgentOptions
/// </remarks>
public bool UseProvidedChatClientAsIs { get; set; }
/// <summary>
/// Gets or sets a value indicating whether functions may be invoked concurrently when a model response
/// contains multiple function calls.
/// </summary>
/// <remarks>
/// This setting is independent of <see cref="ChatOptions.AllowMultipleToolCalls"/>, which controls whether
/// a model may return multiple tool calls in a single response. The default is <see langword="false"/>.
/// This option has no effect when <see cref="UseProvidedChatClientAsIs"/> is <see langword="true"/>.
/// When using a custom chat client stack, configure <see cref="FunctionInvokingChatClient.AllowConcurrentInvocation"/>
/// directly on its <see cref="FunctionInvokingChatClient"/> instance.
/// </remarks>
public bool AllowConcurrentInvocation { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to set the <see cref="ChatClientAgent.ChatHistoryProvider"/> to <see langword="null"/>
/// if the underlying AI service indicates that it manages chat history (for example, by returning a conversation id in the response), but a <see cref="ChatHistoryProvider"/> is configured for the agent.
@@ -288,6 +301,7 @@ public sealed class ChatClientAgentOptions
ChatHistoryProvider = this.ChatHistoryProvider,
AIContextProviders = this.AIContextProviders is null ? null : new List<AIContextProvider>(this.AIContextProviders),
UseProvidedChatClientAsIs = this.UseProvidedChatClientAsIs,
AllowConcurrentInvocation = this.AllowConcurrentInvocation,
ClearOnChatHistoryProviderConflict = this.ClearOnChatHistoryProviderConflict,
WarnOnChatHistoryProviderConflict = this.WarnOnChatHistoryProviderConflict,
ThrowOnChatHistoryProviderConflict = this.ThrowOnChatHistoryProviderConflict,
@@ -90,15 +90,23 @@ public static class ChatClientExtensions
new InvocableFunctionBypassingChatClient(innerClient, services.GetService<ILoggerFactory>()));
}
if (chatClient.GetService<FunctionInvokingChatClient>() is null)
var functionInvokingChatClient = chatClient.GetService<FunctionInvokingChatClient>();
if (functionInvokingChatClient is null)
{
chatBuilder.Use((innerClient, services) =>
{
var loggerFactory = services.GetService<ILoggerFactory>();
return new FunctionInvokingChatClient(innerClient, loggerFactory, services);
return new FunctionInvokingChatClient(innerClient, loggerFactory, services)
{
AllowConcurrentInvocation = options?.AllowConcurrentInvocation is true,
};
});
}
else if (options?.AllowConcurrentInvocation is true)
{
functionInvokingChatClient.AllowConcurrentInvocation = true;
}
// MessageInjectingChatClient is injected when EnableMessageInjection is enabled.
// It is registered after FunctionInvokingChatClient so that it sits between FIC and the inner client.
@@ -24,6 +24,7 @@ public class ChatClientAgentOptionsTests
Assert.Null(options.ChatHistoryProvider);
Assert.Null(options.AIContextProviders);
Assert.False(options.UseProvidedChatClientAsIs);
Assert.False(options.AllowConcurrentInvocation);
Assert.True(options.ClearOnChatHistoryProviderConflict);
Assert.True(options.WarnOnChatHistoryProviderConflict);
Assert.True(options.ThrowOnChatHistoryProviderConflict);
@@ -131,6 +132,7 @@ public class ChatClientAgentOptionsTests
ChatHistoryProvider = mockChatHistoryProvider,
AIContextProviders = [mockAIContextProvider],
UseProvidedChatClientAsIs = true,
AllowConcurrentInvocation = true,
ClearOnChatHistoryProviderConflict = false,
WarnOnChatHistoryProviderConflict = false,
ThrowOnChatHistoryProviderConflict = false,
@@ -149,6 +151,7 @@ public class ChatClientAgentOptionsTests
Assert.Same(original.ChatHistoryProvider, clone.ChatHistoryProvider);
Assert.Equal(original.AIContextProviders, clone.AIContextProviders);
Assert.Equal(original.UseProvidedChatClientAsIs, clone.UseProvidedChatClientAsIs);
Assert.Equal(original.AllowConcurrentInvocation, clone.AllowConcurrentInvocation);
Assert.Equal(original.ClearOnChatHistoryProviderConflict, clone.ClearOnChatHistoryProviderConflict);
Assert.Equal(original.WarnOnChatHistoryProviderConflict, clone.WarnOnChatHistoryProviderConflict);
Assert.Equal(original.ThrowOnChatHistoryProviderConflict, clone.ThrowOnChatHistoryProviderConflict);
@@ -72,6 +72,43 @@ public sealed class ChatClientExtensionsTests
Assert.Same(chatClientMock.Object, agent.ChatClient);
}
[Fact]
public void CreateAIAgent_WithConcurrentInvocation_EnablesConcurrentFunctionInvocation()
{
// Arrange
var chatClientMock = new Mock<IChatClient>();
var options = new ChatClientAgentOptions { AllowConcurrentInvocation = true };
// Act
var agent = chatClientMock.Object.AsAIAgent(options);
// Assert
var functionInvokingClient = agent.ChatClient.GetService<FunctionInvokingChatClient>();
Assert.NotNull(functionInvokingClient);
Assert.True(functionInvokingClient.AllowConcurrentInvocation);
}
[Theory]
[InlineData(false, true)]
[InlineData(true, false)]
public void CreateAIAgent_WithExistingFunctionInvokingChatClient_ConfiguresConcurrentInvocation(bool initiallyEnabled, bool allowConcurrentInvocation)
{
// Arrange
var chatClientMock = new Mock<IChatClient>();
var chatClient = chatClientMock.Object.AsBuilder()
.UseFunctionInvocation(configure: client => client.AllowConcurrentInvocation = initiallyEnabled)
.Build();
var options = new ChatClientAgentOptions { AllowConcurrentInvocation = allowConcurrentInvocation };
// Act
var agent = chatClient.AsAIAgent(options);
// Assert
var functionInvokingClient = agent.ChatClient.GetService<FunctionInvokingChatClient>();
Assert.NotNull(functionInvokingClient);
Assert.True(functionInvokingClient.AllowConcurrentInvocation);
}
[Fact]
public void CreateAIAgent_WithNullClient_Throws()
{