From 3dc14dac79410ab407e2c21843e84b0a2958c73d Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Tue, 7 Oct 2025 10:47:33 +0100 Subject: [PATCH] .NET: Add a CreateAIAgent extension method to IChatClient (#1223) * Add a CreateAIAgent extension method to IChatClient * Fix unit tests * Add tests with null chat clients to ensure appropriate failure * Switch to similar constructor * Add ChatClientBuilder BuildAgent extensions * Address code review comments. --- .../AgentProviders/Agent_With_ONNX/Program.cs | 3 +- .../Agent_With_Ollama/Program.cs | 5 +- .../Agents/Agent_Step14_Middleware/Program.cs | 29 ++- .../ChatClient/ChatClientBuilderExtensions.cs | 85 +++++++ .../ChatClient/ChatClientExtensions.cs | 40 +++- .../ChatClientBuilderExtensionsTests.cs | 216 ++++++++++++++++++ .../ChatClient/ChatClientExtensionsTests.cs | 94 ++++++++ 7 files changed, 452 insertions(+), 20 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientBuilderExtensionsTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientExtensionsTests.cs diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_ONNX/Program.cs b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_ONNX/Program.cs index aed123899c..b8db89d2ef 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_ONNX/Program.cs +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_ONNX/Program.cs @@ -4,6 +4,7 @@ // WARNING: ONNX doesn't support function calling, so any function tools passed to the agent will be ignored. using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; using Microsoft.ML.OnnxRuntimeGenAI; // E.g. C:\repos\Phi-4-mini-instruct-onnx\cpu_and_mobile\cpu-int4-rtn-block-32-acc-level-4 @@ -14,7 +15,7 @@ const string JokerInstructions = "You are good at telling jokes."; // Get a chat client for ONNX and use it to construct an AIAgent. using OnnxRuntimeGenAIChatClient chatClient = new(modelPath); -AIAgent agent = new ChatClientAgent(chatClient, JokerInstructions, JokerName); +AIAgent agent = chatClient.CreateAIAgent(JokerInstructions, JokerName); // Invoke the agent and output the text result. Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.")); diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_Ollama/Program.cs b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_Ollama/Program.cs index 6802a8c590..8ba07cd634 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_Ollama/Program.cs +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_Ollama/Program.cs @@ -3,6 +3,7 @@ // This sample shows how to create and use a simple AI agent with Ollama as the backend. using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; using OllamaSharp; var endpoint = Environment.GetEnvironmentVariable("OLLAMA_ENDPOINT") ?? throw new InvalidOperationException("OLLAMA_ENDPOINT is not set."); @@ -12,8 +13,8 @@ const string JokerName = "Joker"; const string JokerInstructions = "You are good at telling jokes."; // Get a chat client for Ollama and use it to construct an AIAgent. -using OllamaApiClient chatClient = new(new Uri(endpoint), modelName); -AIAgent agent = new ChatClientAgent(chatClient, JokerInstructions, JokerName); +AIAgent agent = new OllamaApiClient(new Uri(endpoint), modelName) + .CreateAIAgent(JokerInstructions, JokerName); // Invoke the agent and output the text result. Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.")); diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/Program.cs index 096553bf91..ea59c84ba4 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/Program.cs @@ -10,6 +10,7 @@ using System.Text.RegularExpressions; using Azure.AI.OpenAI; using Azure.Identity; using Microsoft.Agents.AI; +using Microsoft.Agents.AI.ChatClient; using Microsoft.Extensions.AI; // Get Azure AI Foundry configuration from environment variables @@ -28,25 +29,21 @@ static string GetWeather([Description("The location to get the weather for.")] s static string GetDateTime() => DateTimeOffset.Now.ToString(); -// Adding middleware to the chat client level -var chatClient = azureOpenAIClient.AsIChatClient() +// Adding middleware to the chat client level and building an agent on top of it +var originalAgent = azureOpenAIClient.AsIChatClient() .AsBuilder() - .Use(getResponseFunc: ChatClientMiddleware, getStreamingResponseFunc: null) - .Build(); - -// For flexibility we create the agent without any middleware. -var originalAgent = new ChatClientAgent(chatClient, new ChatClientAgentOptions( + .Use(getResponseFunc: ChatClientMiddleware, getStreamingResponseFunc: null) + .BuildAIAgent( instructions: "You are an AI assistant that helps people find information.", - // Agent level tools - tools: [AIFunctionFactory.Create(GetDateTime, name: nameof(GetDateTime))])); + tools: [AIFunctionFactory.Create(GetDateTime, name: nameof(GetDateTime))]); // Adding middleware to the agent level var middlewareEnabledAgent = originalAgent .AsBuilder() - .Use(FunctionCallMiddleware) - .Use(FunctionCallOverrideWeather) - .Use(PIIMiddleware, null) - .Use(GuardrailMiddleware, null) + .Use(FunctionCallMiddleware) + .Use(FunctionCallOverrideWeather) + .Use(PIIMiddleware, null) + .Use(GuardrailMiddleware, null) .Build(); var thread = middlewareEnabledAgent.GetNewThread(); @@ -83,15 +80,15 @@ var optionsWithApproval = new ChatClientAgentRunOptions(new() { ChatClientFactory = (chatClient) => chatClient .AsBuilder() - .Use(PerRequestChatClientMiddleware, null) // Using the non-streaming for handling streaming as well + .Use(PerRequestChatClientMiddleware, null) // Using the non-streaming for handling streaming as well .Build() }; // var response = middlewareAgent // Using per-request middleware pipeline in addition to existing agent-level middleware var response = await originalAgent // Using per-request middleware pipeline without existing agent-level middleware .AsBuilder() - .Use(PerRequestFunctionCallingMiddleware) - .Use(ConsolePromptingApprovalMiddleware, null) + .Use(PerRequestFunctionCallingMiddleware) + .Use(ConsolePromptingApprovalMiddleware, null) .Build() .RunAsync("What's the current time and the weather in Seattle?", thread, optionsWithApproval); diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs new file mode 100644 index 0000000000..124f66760e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.ChatClient; + +/// +/// Provides extension methods for building a from a . +/// +public static class ChatClientBuilderExtensions +{ + /// + /// Build a from the pipeline described by this . + /// + /// A builder for creating pipelines of . + /// + /// Optional system instructions that guide the agent's behavior. These instructions are provided to the + /// with each invocation to establish the agent's role and behavior. + /// + /// + /// Optional name for the agent. This name is used for identification and logging purposes. + /// + /// + /// Optional human-readable description of the agent's purpose and capabilities. + /// This description can be useful for documentation and agent discovery scenarios. + /// + /// + /// Optional collection of tools that the agent can invoke during conversations. + /// These tools augment any tools that may be provided to the agent via when + /// the agent is run. + /// + /// + /// Optional logger factory for creating loggers used by the agent and its components. + /// + /// + /// Optional service provider for resolving dependencies required by AI functions and other agent components. + /// This is particularly important when using custom tools that require dependency injection. + /// + /// A new instance. + public static ChatClientAgent BuildAIAgent( + this ChatClientBuilder builder, + string? instructions = null, + string? name = null, + string? description = null, + IList? tools = null, + ILoggerFactory? loggerFactory = null, + IServiceProvider? services = null) => + Throw.IfNull(builder).Build(services).CreateAIAgent( + instructions: instructions, + name: name, + description: description, + tools: tools, + loggerFactory: loggerFactory, + services: services); + + /// + /// Creates a new instance. + /// + /// A builder for creating pipelines of . + /// + /// Configuration options that control all aspects of the agent's behavior, including chat settings, + /// message store factories, context provider factories, and other advanced configurations. + /// + /// + /// Optional logger factory for creating loggers used by the agent and its components. + /// + /// + /// Optional service provider for resolving dependencies required by AI functions and other agent components. + /// This is particularly important when using custom tools that require dependency injection. + /// + /// A new instance. + public static ChatClientAgent BuildAIAgent( + this ChatClientBuilder builder, + ChatClientAgentOptions? options, + ILoggerFactory? loggerFactory = null, + IServiceProvider? services = null) => + Throw.IfNull(builder).Build(services).CreateAIAgent( + options: options, + loggerFactory: loggerFactory, + services: services); +} diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs index 5b4dc016cd..f65d41efe7 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; using System.Diagnostics; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; @@ -9,8 +10,45 @@ using Microsoft.Extensions.Logging; namespace Microsoft.Extensions.AI; -internal static class ChatClientExtensions +/// +/// Provides extension methods for Creating an from an . +/// +public static class ChatClientExtensions { + /// + /// Creates a new instance. + /// + /// + /// A new instance. + public static ChatClientAgent CreateAIAgent( + this IChatClient chatClient, + string? instructions = null, + string? name = null, + string? description = null, + IList? tools = null, + ILoggerFactory? loggerFactory = null, + IServiceProvider? services = null) => + new( + chatClient, + instructions: instructions, + name: name, + description: description, + tools: tools, + loggerFactory: loggerFactory, + services: services); + + /// + /// Creates a new instance. + /// + /// + /// A new instance. + public static ChatClientAgent CreateAIAgent( + this IChatClient chatClient, + ChatClientAgentOptions? options, + ILoggerFactory? loggerFactory = null, + IServiceProvider? services = null) => + new(chatClient, options, loggerFactory, services); + internal static IChatClient WithDefaultAgentMiddleware(this IChatClient chatClient, ChatClientAgentOptions? options, IServiceProvider? services = null) { var chatBuilder = chatClient.AsBuilder(); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientBuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientBuilderExtensionsTests.cs new file mode 100644 index 0000000000..cbcaaf94de --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientBuilderExtensionsTests.cs @@ -0,0 +1,216 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using Microsoft.Agents.AI.ChatClient; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Contains unit tests for the class. +/// +public sealed class ChatClientBuilderExtensionsTests +{ + [Fact] + public void BuildAIAgent_WithBasicParameters_CreatesAgent() + { + // Arrange + var innerChatClientMock = new Mock(); + var builder = new ChatClientBuilder(innerChatClientMock.Object); + + // Act + var agent = builder.BuildAIAgent( + instructions: "Test instructions", + name: "TestAgent", + description: "Test description" + ); + + // Assert + Assert.NotNull(agent); + Assert.Equal("TestAgent", agent.Name); + Assert.Equal("Test description", agent.Description); + Assert.Equal("Test instructions", agent.Instructions); + } + + [Fact] + public void BuildAIAgent_WithTools_SetsToolsInOptions() + { + // Arrange + var innerChatClientMock = new Mock(); + var builder = new ChatClientBuilder(innerChatClientMock.Object); + var tools = new List { new Mock().Object }; + + // Act + var agent = builder.BuildAIAgent(tools: tools); + + // Assert + Assert.NotNull(agent); + Assert.NotNull(agent.ChatOptions); + Assert.Equal(tools, agent.ChatOptions.Tools); + } + + [Fact] + public void BuildAIAgent_WithAllParameters_CreatesAgentCorrectly() + { + // Arrange + var innerChatClientMock = new Mock(); + var builder = new ChatClientBuilder(innerChatClientMock.Object); + var tools = new List { new Mock().Object }; + var loggerFactoryMock = new Mock(); + var serviceProviderMock = new Mock(); + + // Act + var agent = builder.BuildAIAgent( + instructions: "Complex instructions", + name: "ComplexAgent", + description: "Complex description", + tools: tools, + loggerFactory: loggerFactoryMock.Object, + services: serviceProviderMock.Object + ); + + // Assert + Assert.NotNull(agent); + Assert.Equal("ComplexAgent", agent.Name); + Assert.Equal("Complex description", agent.Description); + Assert.Equal("Complex instructions", agent.Instructions); + Assert.NotNull(agent.ChatOptions); + Assert.Equal(tools, agent.ChatOptions.Tools); + } + + [Fact] + public void BuildAIAgent_WithOptions_CreatesAgentWithOptions() + { + // Arrange + var innerChatClientMock = new Mock(); + var builder = new ChatClientBuilder(innerChatClientMock.Object); + var options = new ChatClientAgentOptions + { + Name = "AgentWithOptions", + Description = "Desc", + Instructions = "Instr", + UseProvidedChatClientAsIs = true + }; + + // Act + var agent = builder.BuildAIAgent(options); + + // Assert + Assert.NotNull(agent); + Assert.Equal("AgentWithOptions", agent.Name); + Assert.Equal("Desc", agent.Description); + Assert.Equal("Instr", agent.Instructions); + } + + [Fact] + public void BuildAIAgent_WithOptionsAndServices_CreatesAgentCorrectly() + { + // Arrange + var innerChatClientMock = new Mock(); + var builder = new ChatClientBuilder(innerChatClientMock.Object); + var loggerFactoryMock = new Mock(); + var serviceProviderMock = new Mock(); + var options = new ChatClientAgentOptions + { + Name = "ServiceAgent", + Instructions = "Service instructions" + }; + + // Act + var agent = builder.BuildAIAgent( + options: options, + loggerFactory: loggerFactoryMock.Object, + services: serviceProviderMock.Object + ); + + // Assert + Assert.NotNull(agent); + Assert.Equal("ServiceAgent", agent.Name); + Assert.Equal("Service instructions", agent.Instructions); + } + + [Fact] + public void BuildAIAgent_WithNullBuilder_Throws() + { + // Arrange + ChatClientBuilder builder = null!; + + // Act & Assert + Assert.Throws(() => builder.BuildAIAgent(instructions: "instructions")); + } + + [Fact] + public void BuildAIAgent_WithNullBuilderAndOptions_Throws() + { + // Arrange + ChatClientBuilder builder = null!; + + // Act & Assert + Assert.Throws(() => builder.BuildAIAgent(options: new() { Instructions = "instructions" })); + } + + [Fact] + public void BuildAIAgent_WithMiddleware_BuildsCorrectPipeline() + { + // Arrange + var innerChatClientMock = new Mock(); + var middlewareChatClientMock = new Mock(); + var builder = new ChatClientBuilder(innerChatClientMock.Object); + + // Add middleware that returns our mock + builder.Use((client, services) => middlewareChatClientMock.Object); + + // Act + var agent = builder.BuildAIAgent( + new ChatClientAgentOptions + { + Instructions = "Middleware test", + UseProvidedChatClientAsIs = true + } + ); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Middleware test", agent.Instructions); + // When UseProvidedChatClientAsIs is true, the agent should use the middleware chat client directly + Assert.Same(middlewareChatClientMock.Object, agent.ChatClient); + } + + [Fact] + public void BuildAIAgent_WithNullOptions_CreatesAgentWithDefaults() + { + // Arrange + var innerChatClientMock = new Mock(); + var builder = new ChatClientBuilder(innerChatClientMock.Object); + + // Act + var agent = builder.BuildAIAgent(options: null); + + // Assert + Assert.NotNull(agent); + Assert.Null(agent.Name); + Assert.Null(agent.Description); + Assert.Null(agent.Instructions); + } + + [Fact] + public void BuildAIAgent_WithEmptyParameters_CreatesMinimalAgent() + { + // Arrange + var innerChatClientMock = new Mock(); + var builder = new ChatClientBuilder(innerChatClientMock.Object); + + // Act + var agent = builder.BuildAIAgent(); + + // Assert + Assert.NotNull(agent); + Assert.Null(agent.Name); + Assert.Null(agent.Description); + Assert.Null(agent.Instructions); + Assert.Null(agent.ChatOptions); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientExtensionsTests.cs new file mode 100644 index 0000000000..182de0be5b --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientExtensionsTests.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Contains unit tests for the ChatClientExtensions class. +/// +public sealed class ChatClientExtensionsTests +{ + [Fact] + public void CreateAIAgent_WithBasicParameters_CreatesAgent() + { + // Arrange + var chatClientMock = new Mock(); + + // Act + var agent = chatClientMock.Object.CreateAIAgent( + instructions: "Test instructions", + name: "TestAgent", + description: "Test description" + ); + + // Assert + Assert.NotNull(agent); + Assert.Equal("TestAgent", agent.Name); + Assert.Equal("Test description", agent.Description); + Assert.Equal("Test instructions", agent.Instructions); + } + + [Fact] + public void CreateAIAgent_WithTools_SetsToolsInOptions() + { + // Arrange + var chatClientMock = new Mock(); + var tools = new List { new Mock().Object }; + + // Act + var agent = chatClientMock.Object.CreateAIAgent(tools: tools); + + // Assert + Assert.NotNull(agent); + Assert.NotNull(agent.ChatOptions); + Assert.Equal(tools, agent.ChatOptions.Tools); + } + + [Fact] + public void CreateAIAgent_WithOptions_CreatesAgentWithOptions() + { + // Arrange + var chatClientMock = new Mock(); + var options = new ChatClientAgentOptions + { + Name = "AgentWithOptions", + Description = "Desc", + Instructions = "Instr", + UseProvidedChatClientAsIs = true + }; + + // Act + var agent = chatClientMock.Object.CreateAIAgent(options); + + // Assert + Assert.NotNull(agent); + Assert.Equal("AgentWithOptions", agent.Name); + Assert.Equal("Desc", agent.Description); + Assert.Equal("Instr", agent.Instructions); + Assert.Same(chatClientMock.Object, agent.ChatClient); + } + + [Fact] + public void CreateAIAgent_WithNullClient_Throws() + { + // Arrange + IChatClient chatClient = null!; + + // Act & Assert + Assert.Throws(() => chatClient.CreateAIAgent(instructions: "instructions")); + } + + [Fact] + public void CreateAIAgent_WithNullClientAndOptions_Throws() + { + // Arrange + IChatClient chatClient = null!; + + // Act & Assert + Assert.Throws(() => chatClient.CreateAIAgent(options: new() { Instructions = "instructions" })); + } +}