.NET: Add Agent Filtering Middleware (#478)

* WIP

* Wip

* Updated ADR

* Updated ADR

* Update files

* Address copilot comments

* Update filters from Task<T> to Task only

* Project endpoint

* Add agent ctor filter

* Other Agent Framework investigation

* Remove SK Java, no support

* Update LlamaIndex info

* Removing unrelated files

* Implementation with specialization

* Remove the specialization option as extra unecessary complexity

* Move middleware responsibility to a decorator

* Update readme

* Function invocation wip

* Add Agent Builder

* Adding comparison samples

* Reorganize Samples and Processor vs Decorator

* Remove merge files

* Address formating warnigs

* Update ADR

* Step13 README's update

* Address PR feedback

* Address PR feedback

* Remove configure await from ADR samples

* Update variables

* Address feedback

* Address Agent level tool invocation with Options.ToolsTransformer strategy

* Removing the Processor approach

* Proposal design for Middleware in CreateAIAgent extensions

* Examples clean up and consolitation

* Update middlewares to work with ApprovalREquiredFunction

* Clean-up sample

* Update override function call sample

* Drop configuration from the extensions, looks overkill

* Builder interface ..

* Revert IAIBuilder interface approach

* Cleanup sample

* Adding unit tests

* Fix UT

* Cleanup sample

* Remove unneeded dependency

* Address PR comment + Readme Samples

* Add missing comments for Program.cs Middleware

* Address mor PR comments + add client factory for OpenAI extensions

* Add OpenAI UnitTests for extensions

* Add AzureAI PersistentChatClient UT

* Addess feedback

* Add function invoking UT

* Add builder extension UT

* Address feedback + Rearange abstractions + UT fixes

* Drop context based middleware for full decorating impl

* Update unit tests

* Update UT coverage

* Removing Middelware namespace

* Add missing UT

* Remove internal ToolTransformation Property

* Adjust xmldoc

* Remove transient file

* Address merge conflict

* Add xmldoc remark for clarity

* Address comment

* Address feedback

* Update UT

---------

Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
This commit is contained in:
Roger Barreto
2025-09-29 18:58:04 +01:00
committed by GitHub
Unverified
parent c102706146
commit 7a8b456294
40 changed files with 6122 additions and 90 deletions
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -170,7 +170,7 @@ dotnet_diagnostic.RCS1173.severity = warning # Use coalesce expression instead o
dotnet_diagnostic.RCS1186.severity = warning # Use Regex instance instead of static method.
dotnet_diagnostic.RCS1188.severity = warning # Remove redundant auto-property initialization.
dotnet_diagnostic.RCS1197.severity = suggestion # Optimize StringBuilder.AppendLine call.
dotnet_diagnostic.RCS1201.severity = warning # Use method chaining.
dotnet_diagnostic.RCS1201.severity = suggestion # Use method chaining.
dotnet_diagnostic.IDE0001.severity = warning # Simplify name
dotnet_diagnostic.IDE0002.severity = warning # Simplify member access
+4 -1
View File
@@ -48,6 +48,7 @@
<Project Path="samples/GettingStarted/Agents/Agent_Step10_AsMcpTool/Agent_Step10_AsMcpTool.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step11_UsingImages/Agent_Step11_UsingImages.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step12_AsFunctionTool/Agent_Step12_AsFunctionTool.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step14_Middleware/Agent_Step14_Middleware.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step13_Memory/Agent_Step13_Memory.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/AgentWithOpenAI/">
@@ -292,13 +293,15 @@
<Project Path="tests/OpenAIResponse.IntegrationTests/OpenAIResponse.IntegrationTests.csproj" />
</Folder>
<Folder Name="/Tests/UnitTests/">
<Project Path="tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Abstractions.UnitTests/Microsoft.Agents.AI.Abstractions.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Microsoft.Agents.Workflows.Declarative.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.Workflows.UnitTests/Microsoft.Agents.Workflows.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.AzureAI.UnitTests/Microsoft.Agents.AI.AzureAI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.A2A.Tests/Microsoft.Agents.AI.Hosting.A2A.Tests.csproj" Id="2a1c544d-237d-4436-8732-ba0c447ac06b" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.OpenAI.UnitTests/Microsoft.Agents.AI.OpenAI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Runtime.Abstractions.UnitTests/Microsoft.Agents.AI.Runtime.Abstractions.UnitTests.csproj" />
</Folder>
</Solution>
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<LangVersion>12</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<NoWarn>VSTHRD200;CA1707</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Azure.AI.OpenAI" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,271 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows multiple middleware layers working together with Azure OpenAI:
// chat client (global/per-request), agent run (PII filtering and guardrails),
// function invocation (logging and result overrides), and human-in-the-loop
// approval workflows for sensitive function calls.
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
// Get Azure AI Foundry configuration from environment variables
var endpoint = Environment.GetEnvironmentVariable("AZUREOPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZUREOPENAI_ENDPOINT is not set.");
var deploymentName = System.Environment.GetEnvironmentVariable("AZUREOPENAI_DEPLOYMENT_NAME") ?? "gpt-4o";
// Get a client to create/retrieve server side agents with
var azureOpenAIClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential())
.GetChatClient(deploymentName);
[Description("Get the weather for a given location.")]
static string GetWeather([Description("The location to get the weather for.")] string location)
=> $"The weather in {location} is cloudy with a high of 15°C.";
[Description("The current datetime offset.")]
static string GetDateTime()
=> DateTimeOffset.Now.ToString();
// Adding middleware to the chat client level
var chatClient = 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(
instructions: "You are an AI assistant that helps people find information.",
// Agent level tools
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)
.Build();
var thread = middlewareEnabledAgent.GetNewThread();
Console.WriteLine("\n\n=== Example 1: Wording Guardrail ===");
var guardRailedResponse = await middlewareEnabledAgent.RunAsync("Tell me something harmful.");
Console.WriteLine($"Guard railed response: {guardRailedResponse}");
Console.WriteLine("\n\n=== Example 2: PII detection ===");
var piiResponse = await middlewareEnabledAgent.RunAsync("My name is John Doe, call me at 123-456-7890 or email me at john@something.com");
Console.WriteLine($"Pii filtered response: {piiResponse}");
Console.WriteLine("\n\n=== Example 3: Agent function middleware ===");
// Agent function middleware support is limited to agents that wraps a upstream ChatClientAgent or derived from it.
// Add Per-request tools
var options = new ChatClientAgentRunOptions(new()
{
Tools = [AIFunctionFactory.Create(GetWeather, name: nameof(GetWeather))]
});
var functionCallResponse = await middlewareEnabledAgent.RunAsync("What's the current time and the weather in Seattle?", thread, options);
Console.WriteLine($"Function calling response: {functionCallResponse}");
// Special per-request middleware agent.
Console.WriteLine("\n\n=== Example 4: Per-request middleware with human in the loop function approval ===");
var optionsWithApproval = new ChatClientAgentRunOptions(new()
{
// Adding a function with approval required
Tools = [new ApprovalRequiredAIFunction(AIFunctionFactory.Create(GetWeather, name: nameof(GetWeather)))],
})
{
ChatClientFactory = (chatClient) => chatClient
.AsBuilder()
.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)
.Build()
.RunAsync("What's the current time and the weather in Seattle?", thread, optionsWithApproval);
Console.WriteLine($"Per-request middleware response: {response}");
// Function invocation middleware that logs before and after function calls.
async ValueTask<object?> FunctionCallMiddleware(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
{
Console.WriteLine($"Function Name: {context!.Function.Name} - Middleware 1 Pre-Invoke");
var result = await next(context, cancellationToken);
Console.WriteLine($"Function Name: {context!.Function.Name} - Middleware 1 Post-Invoke");
return result;
}
// Function invocation middleware that overrides the result of the GetWeather function.
async ValueTask<object?> FunctionCallOverrideWeather(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
{
Console.WriteLine($"Function Name: {context!.Function.Name} - Middleware 2 Pre-Invoke");
var result = await next(context, cancellationToken);
if (context.Function.Name == nameof(GetWeather))
{
// Override the result of the GetWeather function
result = "The weather is sunny with a high of 25°C.";
}
Console.WriteLine($"Function Name: {context!.Function.Name} - Middleware 2 Post-Invoke");
return result;
}
// There's no difference per-request middleware, except it's added to the agent and used for a single agent run.
// This middleware logs function names before and after they are invoked.
async ValueTask<object?> PerRequestFunctionCallingMiddleware(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
{
Console.WriteLine($"Agent Id: {agent.Id}");
Console.WriteLine($"Function Name: {context!.Function.Name} - Per-Request Pre-Invoke");
var result = await next(context, cancellationToken);
Console.WriteLine($"Function Name: {context!.Function.Name} - Per-Request Post-Invoke");
return result;
}
// This middleware redacts PII information from input and output messages.
async Task<AgentRunResponse> PIIMiddleware(IEnumerable<ChatMessage> messages, AgentThread? thread, AgentRunOptions? options, AIAgent innerAgent, CancellationToken cancellationToken)
{
// Redact PII information from input messages
var filteredMessages = FilterMessages(messages);
Console.WriteLine("Pii Middleware - Filtered Messages Pre-Run");
var response = await innerAgent.RunAsync(filteredMessages, thread, options, cancellationToken).ConfigureAwait(false);
// Redact PII information from output messages
response.Messages = FilterMessages(response.Messages);
Console.WriteLine("Pii Middleware - Filtered Messages Post-Run");
return response;
static IList<ChatMessage> FilterMessages(IEnumerable<ChatMessage> messages)
{
return messages.Select(m => new ChatMessage(m.Role, FilterPii(m.Text))).ToList();
}
static string FilterPii(string content)
{
// Regex patterns for PII detection (simplified for demonstration)
Regex[] piiPatterns = [
new(@"\b\d{3}-\d{3}-\d{4}\b", RegexOptions.Compiled), // Phone number (e.g., 123-456-7890)
new(@"\b[\w\.-]+@[\w\.-]+\.\w+\b", RegexOptions.Compiled), // Email address
new(@"\b[A-Z][a-z]+\s[A-Z][a-z]+\b", RegexOptions.Compiled) // Full name (e.g., John Doe)
];
foreach (var pattern in piiPatterns)
{
content = pattern.Replace(content, "[REDACTED: PII]");
}
return content;
}
}
// This middleware enforces guardrails by redacting certain keywords from input and output messages.
async Task<AgentRunResponse> GuardrailMiddleware(IEnumerable<ChatMessage> messages, AgentThread? thread, AgentRunOptions? options, AIAgent innerAgent, CancellationToken cancellationToken)
{
// Redact keywords from input messages
var filteredMessages = FilterMessages(messages);
Console.WriteLine("Guardrail Middleware - Filtered messages Pre-Run");
// Proceed with the agent run
var response = await innerAgent.RunAsync(filteredMessages, thread, options, cancellationToken);
// Redact keywords from output messages
response.Messages = FilterMessages(response.Messages);
Console.WriteLine("Guardrail Middleware - Filtered messages Post-Run");
return response;
List<ChatMessage> FilterMessages(IEnumerable<ChatMessage> messages)
{
return messages.Select(m => new ChatMessage(m.Role, FilterContent(m.Text))).ToList();
}
static string FilterContent(string content)
{
foreach (var keyword in new[] { "harmful", "illegal", "violence" })
{
if (content.Contains(keyword, StringComparison.OrdinalIgnoreCase))
{
return "[REDACTED: Forbidden content]";
}
}
return content;
}
}
// This middleware handles Human in the loop console interaction for any user approval required during function calling.
async Task<AgentRunResponse> ConsolePromptingApprovalMiddleware(IEnumerable<ChatMessage> messages, AgentThread? thread, AgentRunOptions? options, AIAgent innerAgent, CancellationToken cancellationToken)
{
var response = await innerAgent.RunAsync(messages, thread, options, cancellationToken);
var userInputRequests = response.UserInputRequests.ToList();
while (userInputRequests.Count > 0)
{
// Ask the user to approve each function call request.
// For simplicity, we are assuming here that only function approval requests are being made.
// Pass the user input responses back to the agent for further processing.
response.Messages = userInputRequests
.OfType<FunctionApprovalRequestContent>()
.Select(functionApprovalRequest =>
{
Console.WriteLine($"The agent would like to invoke the following function, please reply Y to approve: Name {functionApprovalRequest.FunctionCall.Name}");
return new ChatMessage(ChatRole.User, [functionApprovalRequest.CreateResponse(Console.ReadLine()?.Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false)]);
})
.ToList();
response = await innerAgent.RunAsync(response.Messages, thread, options, cancellationToken);
userInputRequests = response.UserInputRequests.ToList();
}
return response;
}
// This middleware handles chat client lower level invocations.
// This is useful for handling agent messages before they are sent to the LLM and also handle any response messages from the LLM before they are sent back to the agent.
async Task<ChatResponse> ChatClientMiddleware(IEnumerable<ChatMessage> message, ChatOptions? options, IChatClient innerChatClient, CancellationToken cancellationToken)
{
Console.WriteLine("Chat Client Middleware - Pre-Chat");
var response = await innerChatClient.GetResponseAsync(message, options, cancellationToken);
Console.WriteLine("Chat Client Middleware - Post-Chat");
return response;
}
// There's no difference per-request middleware, except it's added to the chat client and used for a single agent run.
// This middleware handles chat client lower level invocations.
// This is useful for handling agent messages before they are sent to the LLM and also handle any response messages from the LLM before they are sent back to the agent.
async Task<ChatResponse> PerRequestChatClientMiddleware(IEnumerable<ChatMessage> message, ChatOptions? options, IChatClient innerChatClient, CancellationToken cancellationToken)
{
Console.WriteLine("Per-Request Chat Client Middleware - Pre-Chat");
var response = await innerChatClient.GetResponseAsync(message, options, cancellationToken);
Console.WriteLine("Per-Request Chat Client Middleware - Post-Chat");
return response;
}
@@ -0,0 +1,41 @@
# Agent Middleware
This sample demonstrates how to add middleware to intercept:
- Chat client calls (global and perrequest)
- Agent runs (guardrails and PII filtering)
- Function calling (logging/override)
## What This Sample Shows
1. Azure OpenAI integration via `AzureOpenAIClient` and `AzureCliCredential`
2. Chat client middleware using `ChatClientBuilder.Use(...)`
3. Agent run middleware (PII redaction and wording guardrails)
4. Function invocation middleware (logging and overriding a tool result)
5. Perrequest chat client middleware
6. Perrequest function pipeline with approval
7. Combining agentlevel and perrequest middleware
## Function Invocation Middleware
Not all agents support function invocation middleware.
Attempting to use function middleware on agents that do not wrap a ChatClientAgent or derives from it will throw an InvalidOperationException.
## Prerequisites
1. Environment variables:
- `AZUREOPENAI_ENDPOINT`: Your Azure OpenAI endpoint
- `AZUREOPENAI_DEPLOYMENT_NAME`: Chat deployment name (optional; defaults to `gpt-4o`)
2. Sign in with Azure CLI (PowerShell):
```powershell
az login
```
## Running the Sample
Use PowerShell:
```powershell
cd dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware
dotnet run
```
@@ -39,6 +39,7 @@ Before you begin, ensure you have the following prerequisites:
|[Using images with a simple agent](./Agent_Step11_UsingImages/)|This sample demonstrates how to use image multi-modality with an AI agent|
|[Exposing a simple agent as a function tool](./Agent_Step12_AsFunctionTool/)|This sample demonstrates how to expose an agent as a function tool|
|[Using memory with an agent](./Agent_Step13_Memory/)|This sample demonstrates how to create a simple memory component and use it with an agent|
|[Using middleware with an agent](./Agent_Step14_Middleware/)|This sample demonstrates how to use middleware with an agent|
## Running the samples from the console
@@ -16,15 +16,16 @@ internal static class PersistentAgentResponseExtensions
/// <param name="persistentAgentResponse">The response containing the persistent agent to be converted. Cannot be <see langword="null"/>.</param>
/// <param name="persistentAgentsClient">The client used to interact with persistent agents. Cannot be <see langword="null"/>.</param>
/// <param name="chatOptions">The default <see cref="ChatOptions"/> to use when interacting with the agent.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
/// <returns>A <see cref="ChatClientAgent"/> instance that can be used to perform operations on the persistent agent.</returns>
public static ChatClientAgent AsAIAgent(this Response<PersistentAgent> persistentAgentResponse, PersistentAgentsClient persistentAgentsClient, ChatOptions? chatOptions = null)
public static ChatClientAgent AsAIAgent(this Response<PersistentAgent> persistentAgentResponse, PersistentAgentsClient persistentAgentsClient, ChatOptions? chatOptions = null, Func<IChatClient, IChatClient>? clientFactory = null)
{
if (persistentAgentResponse is null)
{
throw new ArgumentNullException(nameof(persistentAgentResponse));
}
return AsAIAgent(persistentAgentResponse.Value, persistentAgentsClient, chatOptions);
return AsAIAgent(persistentAgentResponse.Value, persistentAgentsClient, chatOptions, clientFactory);
}
/// <summary>
@@ -33,8 +34,9 @@ internal static class PersistentAgentResponseExtensions
/// <param name="persistentAgentMetadata">The persistent agent metadata to be converted. Cannot be <see langword="null"/>.</param>
/// <param name="persistentAgentsClient">The client used to interact with persistent agents. Cannot be <see langword="null"/>.</param>
/// <param name="chatOptions">The default <see cref="ChatOptions"/> to use when interacting with the agent.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
/// <returns>A <see cref="ChatClientAgent"/> instance that can be used to perform operations on the persistent agent.</returns>
public static ChatClientAgent AsAIAgent(this PersistentAgent persistentAgentMetadata, PersistentAgentsClient persistentAgentsClient, ChatOptions? chatOptions = null)
public static ChatClientAgent AsAIAgent(this PersistentAgent persistentAgentMetadata, PersistentAgentsClient persistentAgentsClient, ChatOptions? chatOptions = null, Func<IChatClient, IChatClient>? clientFactory = null)
{
if (persistentAgentMetadata is null)
{
@@ -48,6 +50,11 @@ internal static class PersistentAgentResponseExtensions
var chatClient = persistentAgentsClient.AsNewIChatClient(persistentAgentMetadata.Id);
if (clientFactory is not null)
{
chatClient = clientFactory(chatClient);
}
return new ChatClientAgent(chatClient, options: new()
{
Id = persistentAgentMetadata.Id,
@@ -17,12 +17,14 @@ public static class PersistentAgentsClientExtensions
/// <returns>A <see cref="ChatClientAgent"/> for the persistent agent.</returns>
/// <param name="agentId"> The ID of the server side agent to create a <see cref="ChatClientAgent"/> for.</param>
/// <param name="chatOptions">Options that should apply to all runs of the agent.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ChatClientAgent"/> instance that can be used to perform operations on the persistent agent.</returns>
public static ChatClientAgent GetAIAgent(
this PersistentAgentsClient persistentAgentsClient,
string agentId,
ChatOptions? chatOptions = null,
Func<IChatClient, IChatClient>? clientFactory = null,
CancellationToken cancellationToken = default)
{
if (persistentAgentsClient is null)
@@ -36,7 +38,7 @@ public static class PersistentAgentsClientExtensions
}
var persistentAgentResponse = persistentAgentsClient.Administration.GetAgent(agentId, cancellationToken);
return persistentAgentResponse.AsAIAgent(persistentAgentsClient, chatOptions);
return persistentAgentResponse.AsAIAgent(persistentAgentsClient, chatOptions, clientFactory);
}
/// <summary>
@@ -46,12 +48,14 @@ public static class PersistentAgentsClientExtensions
/// <returns>A <see cref="ChatClientAgent"/> for the persistent agent.</returns>
/// <param name="agentId"> The ID of the server side agent to create a <see cref="ChatClientAgent"/> for.</param>
/// <param name="chatOptions">Options that should apply to all runs of the agent.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ChatClientAgent"/> instance that can be used to perform operations on the persistent agent.</returns>
public static async Task<ChatClientAgent> GetAIAgentAsync(
this PersistentAgentsClient persistentAgentsClient,
string agentId,
ChatOptions? chatOptions = null,
Func<IChatClient, IChatClient>? clientFactory = null,
CancellationToken cancellationToken = default)
{
if (persistentAgentsClient is null)
@@ -65,7 +69,7 @@ public static class PersistentAgentsClientExtensions
}
var persistentAgentResponse = await persistentAgentsClient.Administration.GetAgentAsync(agentId, cancellationToken).ConfigureAwait(false);
return persistentAgentResponse.AsAIAgent(persistentAgentsClient, chatOptions);
return persistentAgentResponse.AsAIAgent(persistentAgentsClient, chatOptions, clientFactory);
}
/// <summary>
@@ -82,6 +86,7 @@ public static class PersistentAgentsClientExtensions
/// <param name="topP">The top-p setting for the agent.</param>
/// <param name="responseFormat">The response format for the agent.</param>
/// <param name="metadata">The metadata for the agent.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ChatClientAgent"/> instance that can be used to perform operations on the newly created agent.</returns>
public static async Task<ChatClientAgent> CreateAIAgentAsync(
@@ -96,6 +101,7 @@ public static class PersistentAgentsClientExtensions
float? topP = null,
BinaryData? responseFormat = null,
IReadOnlyDictionary<string, string>? metadata = null,
Func<IChatClient, IChatClient>? clientFactory = null,
CancellationToken cancellationToken = default)
{
if (persistentAgentsClient is null)
@@ -116,7 +122,7 @@ public static class PersistentAgentsClientExtensions
cancellationToken: cancellationToken).ConfigureAwait(false);
// Get a local proxy for the agent to work with.
return await persistentAgentsClient.GetAIAgentAsync(createPersistentAgentResponse.Value.Id, cancellationToken: cancellationToken).ConfigureAwait(false);
return await persistentAgentsClient.GetAIAgentAsync(createPersistentAgentResponse.Value.Id, clientFactory: clientFactory, cancellationToken: cancellationToken).ConfigureAwait(false);
}
/// <summary>
@@ -133,6 +139,7 @@ public static class PersistentAgentsClientExtensions
/// <param name="topP">The top-p setting for the agent.</param>
/// <param name="responseFormat">The response format for the agent.</param>
/// <param name="metadata">The metadata for the agent.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ChatClientAgent"/> instance that can be used to perform operations on the newly created agent.</returns>
public static ChatClientAgent CreateAIAgent(
@@ -147,6 +154,7 @@ public static class PersistentAgentsClientExtensions
float? topP = null,
BinaryData? responseFormat = null,
IReadOnlyDictionary<string, string>? metadata = null,
Func<IChatClient, IChatClient>? clientFactory = null,
CancellationToken cancellationToken = default)
{
if (persistentAgentsClient is null)
@@ -167,7 +175,7 @@ public static class PersistentAgentsClientExtensions
cancellationToken: cancellationToken);
// Get a local proxy for the agent to work with.
return persistentAgentsClient.GetAIAgent(createPersistentAgentResponse.Value.Id, cancellationToken: cancellationToken);
return persistentAgentsClient.GetAIAgent(createPersistentAgentResponse.Value.Id, clientFactory: clientFactory, cancellationToken: cancellationToken);
}
/// <summary>
@@ -17,15 +17,20 @@ public static class AssistantExtensions
/// <param name="assistantClientResult">The client result containing the assistant.</param>
/// <param name="assistantClient">The assistant client.</param>
/// <param name="chatOptions">Optional chat options.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
/// <returns>A <see cref="ChatClientAgent"/> instance that can be used to perform operations on the assistant.</returns>
public static ChatClientAgent AsAIAgent(this ClientResult<Assistant> assistantClientResult, AssistantClient assistantClient, ChatOptions? chatOptions = null)
public static ChatClientAgent AsAIAgent(
this ClientResult<Assistant> assistantClientResult,
AssistantClient assistantClient,
ChatOptions? chatOptions = null,
Func<IChatClient, IChatClient>? clientFactory = null)
{
if (assistantClientResult is null)
{
throw new ArgumentNullException(nameof(assistantClientResult));
}
return AsAIAgent(assistantClientResult.Value, assistantClient, chatOptions);
return AsAIAgent(assistantClientResult.Value, assistantClient, chatOptions, clientFactory);
}
/// <summary>
@@ -34,8 +39,13 @@ public static class AssistantExtensions
/// <param name="assistantMetadata">The assistant metadata.</param>
/// <param name="assistantClient">The assistant client.</param>
/// <param name="chatOptions">Optional chat options.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
/// <returns>A <see cref="ChatClientAgent"/> instance that can be used to perform operations on the assistant.</returns>
public static ChatClientAgent AsAIAgent(this Assistant assistantMetadata, AssistantClient assistantClient, ChatOptions? chatOptions = null)
public static ChatClientAgent AsAIAgent(
this Assistant assistantMetadata,
AssistantClient assistantClient,
ChatOptions? chatOptions = null,
Func<IChatClient, IChatClient>? clientFactory = null)
{
if (assistantMetadata is null)
{
@@ -48,6 +58,11 @@ public static class AssistantExtensions
var chatClient = assistantClient.AsIChatClient(assistantMetadata.Id);
if (clientFactory is not null)
{
chatClient = clientFactory(chatClient);
}
return new ChatClientAgent(chatClient, options: new()
{
Id = assistantMetadata.Id,
@@ -26,12 +26,14 @@ public static class OpenAIAssistantClientExtensions
/// <param name="assistantClient">The <see cref="AssistantClient"/> to create the <see cref="ChatClientAgent"/> with.</param>
/// <param name="agentId">The ID of the server side agent to create a <see cref="ChatClientAgent"/> for.</param>
/// <param name="chatOptions">Options that should apply to all runs of the agent.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ChatClientAgent"/> instance that can be used to perform operations on the assistant agent.</returns>
public static ChatClientAgent GetAIAgent(
this AssistantClient assistantClient,
string agentId,
ChatOptions? chatOptions = null,
Func<IChatClient, IChatClient>? clientFactory = null,
CancellationToken cancellationToken = default)
{
if (assistantClient is null)
@@ -45,7 +47,7 @@ public static class OpenAIAssistantClientExtensions
}
var assistant = assistantClient.GetAssistant(agentId, cancellationToken);
return assistant.AsAIAgent(assistantClient, chatOptions);
return assistant.AsAIAgent(assistantClient, chatOptions, clientFactory);
}
/// <summary>
@@ -54,12 +56,14 @@ public static class OpenAIAssistantClientExtensions
/// <param name="assistantClient">The <see cref="AssistantClient"/> to create the <see cref="ChatClientAgent"/> with.</param>
/// <param name="agentId"> The ID of the server side agent to create a <see cref="ChatClientAgent"/> for.</param>
/// <param name="chatOptions">Options that should apply to all runs of the agent.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ChatClientAgent"/> instance that can be used to perform operations on the assistant agent.</returns>
public static async Task<ChatClientAgent> GetAIAgentAsync(
this AssistantClient assistantClient,
string agentId,
ChatOptions? chatOptions = null,
Func<IChatClient, IChatClient>? clientFactory = null,
CancellationToken cancellationToken = default)
{
if (assistantClient is null)
@@ -74,7 +78,7 @@ public static class OpenAIAssistantClientExtensions
var assistanceResponse = await assistantClient.GetAssistantAsync(agentId, cancellationToken).ConfigureAwait(false);
return assistanceResponse.AsAIAgent(assistantClient, chatOptions);
return assistanceResponse.AsAIAgent(assistantClient, chatOptions, clientFactory);
}
/// <summary>
@@ -86,11 +90,20 @@ public static class OpenAIAssistantClientExtensions
/// <param name="name">Optional name for the agent for identification purposes.</param>
/// <param name="description">Optional description of the agent's capabilities and purpose.</param>
/// <param name="tools">Optional collection of AI tools that the agent can use during conversations.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
/// <param name="loggerFactory">Optional logger factory for enabling logging within the agent.</param>
/// <returns>An <see cref="AIAgent"/> instance backed by the OpenAI Assistant service.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="client"/> or <paramref name="model"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="model"/> is empty or whitespace.</exception>
public static AIAgent CreateAIAgent(this AssistantClient client, string model, string? instructions = null, string? name = null, string? description = null, IList<AITool>? tools = null, ILoggerFactory? loggerFactory = null) =>
public static AIAgent CreateAIAgent(
this AssistantClient client,
string model,
string? instructions = null,
string? name = null,
string? description = null,
IList<AITool>? tools = null,
Func<IChatClient, IChatClient>? clientFactory = null,
ILoggerFactory? loggerFactory = null) =>
client.CreateAIAgent(
model,
new ChatClientAgentOptions()
@@ -103,6 +116,7 @@ public static class OpenAIAssistantClientExtensions
Tools = tools,
}
},
clientFactory,
loggerFactory);
/// <summary>
@@ -111,11 +125,17 @@ public static class OpenAIAssistantClientExtensions
/// <param name="client">The OpenAI <see cref="AssistantClient" /> to use for the agent.</param>
/// <param name="model">The model identifier to use (e.g., "gpt-4").</param>
/// <param name="options">Full set of options to configure the agent.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
/// <param name="loggerFactory">Optional logger factory for enabling logging within the agent.</param>
/// <returns>An <see cref="AIAgent"/> instance backed by the OpenAI Assistant service.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="client"/> or <paramref name="model"/> or <paramref name="options"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="model"/> is empty or whitespace.</exception>
public static AIAgent CreateAIAgent(this AssistantClient client, string model, ChatClientAgentOptions options, ILoggerFactory? loggerFactory = null)
public static AIAgent CreateAIAgent(
this AssistantClient client,
string model,
ChatClientAgentOptions options,
Func<IChatClient, IChatClient>? clientFactory = null,
ILoggerFactory? loggerFactory = null)
{
Throw.IfNull(client);
Throw.IfNullOrEmpty(model);
@@ -163,7 +183,14 @@ public static class OpenAIAssistantClientExtensions
}
};
return new ChatClientAgent(client.AsIChatClient(assistantId), agentOptions, loggerFactory);
var chatClient = client.AsIChatClient(assistantId);
if (clientFactory is not null)
{
chatClient = clientFactory(chatClient);
}
return new ChatClientAgent(chatClient, agentOptions, loggerFactory);
}
/// <summary>
@@ -175,13 +202,21 @@ public static class OpenAIAssistantClientExtensions
/// <param name="name">Optional name for the agent for identification purposes.</param>
/// <param name="description">Optional description of the agent's capabilities and purpose.</param>
/// <param name="tools">Optional collection of AI tools that the agent can use during conversations.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
/// <param name="loggerFactory">Optional logger factory for enabling logging within the agent.</param>
/// <returns>An <see cref="AIAgent"/> instance backed by the OpenAI Assistant service.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="client"/> or <paramref name="model"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="model"/> is empty or whitespace.</exception>
public static async Task<AIAgent> CreateAIAgentAsync(this AssistantClient client, string model, string? instructions = null, string? name = null, string? description = null, IList<AITool>? tools = null, ILoggerFactory? loggerFactory = null) =>
await client.CreateAIAgentAsync(
model,
public static async Task<AIAgent> CreateAIAgentAsync(
this AssistantClient client,
string model,
string? instructions = null,
string? name = null,
string? description = null,
IList<AITool>? tools = null,
Func<IChatClient, IChatClient>? clientFactory = null,
ILoggerFactory? loggerFactory = null) =>
await client.CreateAIAgentAsync(model,
new ChatClientAgentOptions()
{
Name = name,
@@ -192,6 +227,7 @@ public static class OpenAIAssistantClientExtensions
Tools = tools,
}
},
clientFactory,
loggerFactory).ConfigureAwait(false);
/// <summary>
@@ -200,11 +236,17 @@ public static class OpenAIAssistantClientExtensions
/// <param name="client">The OpenAI <see cref="AssistantClient" /> to use for the agent.</param>
/// <param name="model">The model identifier to use (e.g., "gpt-4").</param>
/// <param name="options">Full set of options to configure the agent.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
/// <param name="loggerFactory">Optional logger factory for enabling logging within the agent.</param>
/// <returns>An <see cref="AIAgent"/> instance backed by the OpenAI Assistant service.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="client"/> or <paramref name="model"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="model"/> is empty or whitespace.</exception>
public static async Task<AIAgent> CreateAIAgentAsync(this AssistantClient client, string model, ChatClientAgentOptions options, ILoggerFactory? loggerFactory = null)
public static async Task<AIAgent> CreateAIAgentAsync(
this AssistantClient client,
string model,
ChatClientAgentOptions options,
Func<IChatClient, IChatClient>? clientFactory = null,
ILoggerFactory? loggerFactory = null)
{
Throw.IfNull(client);
Throw.IfNull(model);
@@ -252,6 +294,13 @@ public static class OpenAIAssistantClientExtensions
}
};
return new ChatClientAgent(client.AsIChatClient(assistantId), agentOptions, loggerFactory);
var chatClient = client.AsIChatClient(assistantId);
if (clientFactory is not null)
{
chatClient = clientFactory(chatClient);
}
return new ChatClientAgent(chatClient, agentOptions, loggerFactory);
}
}
@@ -28,10 +28,18 @@ public static class OpenAIChatClientExtensions
/// <param name="name">Optional name for the agent for identification purposes.</param>
/// <param name="description">Optional description of the agent's capabilities and purpose.</param>
/// <param name="tools">Optional collection of AI tools that the agent can use during conversations.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
/// <param name="loggerFactory">Optional logger factory for enabling logging within the agent.</param>
/// <returns>An <see cref="AIAgent"/> instance backed by the OpenAI Chat Completion service.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="client"/> is <see langword="null"/>.</exception>
public static AIAgent CreateAIAgent(this ChatClient client, string? instructions = null, string? name = null, string? description = null, IList<AITool>? tools = null, ILoggerFactory? loggerFactory = null) =>
public static AIAgent CreateAIAgent(
this ChatClient client,
string? instructions = null,
string? name = null,
string? description = null,
IList<AITool>? tools = null,
Func<IChatClient, IChatClient>? clientFactory = null,
ILoggerFactory? loggerFactory = null) =>
client.CreateAIAgent(
new ChatClientAgentOptions()
{
@@ -43,6 +51,7 @@ public static class OpenAIChatClientExtensions
Tools = tools,
}
},
clientFactory,
loggerFactory);
/// <summary>
@@ -50,15 +59,26 @@ public static class OpenAIChatClientExtensions
/// </summary>
/// <param name="client">The OpenAI <see cref="ChatClient"/> to use for the agent.</param>
/// <param name="options">Full set of options to configure the agent.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
/// <param name="loggerFactory">Optional logger factory for enabling logging within the agent.</param>
/// <returns>An <see cref="AIAgent"/> instance backed by the OpenAI Chat Completion service.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="client"/> or <paramref name="options"/> is <see langword="null"/>.</exception>
public static AIAgent CreateAIAgent(this ChatClient client, ChatClientAgentOptions options, ILoggerFactory? loggerFactory = null)
public static AIAgent CreateAIAgent(
this ChatClient client,
ChatClientAgentOptions options,
Func<IChatClient, IChatClient>? clientFactory = null,
ILoggerFactory? loggerFactory = null)
{
Throw.IfNull(client);
Throw.IfNull(options);
var chatClient = client.AsIChatClient();
if (clientFactory is not null)
{
chatClient = clientFactory(chatClient);
}
return new ChatClientAgent(chatClient, options, loggerFactory);
}
}
@@ -28,10 +28,18 @@ public static class OpenAIResponseClientExtensions
/// <param name="name">Optional name for the agent for identification purposes.</param>
/// <param name="description">Optional description of the agent's capabilities and purpose.</param>
/// <param name="tools">Optional collection of AI tools that the agent can use during conversations.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
/// <param name="loggerFactory">Optional logger factory for enabling logging within the agent.</param>
/// <returns>An <see cref="AIAgent"/> instance backed by the OpenAI Response service.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="client"/> is <see langword="null"/>.</exception>
public static AIAgent CreateAIAgent(this OpenAIResponseClient client, string? instructions = null, string? name = null, string? description = null, IList<AITool>? tools = null, ILoggerFactory? loggerFactory = null)
public static AIAgent CreateAIAgent(
this OpenAIResponseClient client,
string? instructions = null,
string? name = null,
string? description = null,
IList<AITool>? tools = null,
Func<IChatClient, IChatClient>? clientFactory = null,
ILoggerFactory? loggerFactory = null)
{
Throw.IfNull(client);
@@ -46,6 +54,7 @@ public static class OpenAIResponseClientExtensions
Tools = tools,
}
},
clientFactory,
loggerFactory);
}
@@ -54,14 +63,26 @@ public static class OpenAIResponseClientExtensions
/// </summary>
/// <param name="client">The <see cref="OpenAIResponseClient" /> to use for the agent.</param>
/// <param name="options">Full set of options to configure the agent.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
/// <param name="loggerFactory">Optional logger factory for enabling logging within the agent.</param>
/// <returns>An <see cref="AIAgent"/> instance backed by the OpenAI Response service.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="client"/> or <paramref name="options"/> is <see langword="null"/>.</exception>
public static AIAgent CreateAIAgent(this OpenAIResponseClient client, ChatClientAgentOptions options, ILoggerFactory? loggerFactory = null)
public static AIAgent CreateAIAgent(
this OpenAIResponseClient client,
ChatClientAgentOptions options,
Func<IChatClient, IChatClient>? clientFactory = null,
ILoggerFactory? loggerFactory = null)
{
Throw.IfNull(client);
Throw.IfNull(options);
return new ChatClientAgent(client.AsIChatClient(), options, loggerFactory);
var chatClient = client.AsIChatClient();
if (clientFactory is not null)
{
chatClient = clientFactory(chatClient);
}
return new ChatClientAgent(chatClient, options, loggerFactory);
}
}
@@ -0,0 +1,155 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>A builder for creating pipelines of <see cref="AIAgent"/>.</summary>
public sealed class AIAgentBuilder
{
private readonly Func<IServiceProvider, AIAgent> _innerAgentFactory;
/// <summary>The registered agent factory instances.</summary>
private List<Func<AIAgent, IServiceProvider, AIAgent>>? _agentFactories;
/// <summary>Initializes a new instance of the <see cref="AIAgentBuilder"/> class.</summary>
/// <param name="innerAgent">The inner <see cref="AIAgent"/> that represents the underlying backend.</param>
/// <exception cref="ArgumentNullException"><paramref name="innerAgent"/> is <see langword="null"/>.</exception>
public AIAgentBuilder(AIAgent innerAgent)
{
_ = Throw.IfNull(innerAgent);
this._innerAgentFactory = _ => innerAgent;
}
/// <summary>Initializes a new instance of the <see cref="AIAgentBuilder"/> class.</summary>
/// <param name="innerAgentFactory">A callback that produces the inner <see cref="AIAgent"/> that represents the underlying backend.</param>
public AIAgentBuilder(Func<IServiceProvider, AIAgent> innerAgentFactory)
{
this._innerAgentFactory = Throw.IfNull(innerAgentFactory);
}
/// <inheritdoc/>
public AIAgent Build(IServiceProvider? services = null)
{
services ??= EmptyServiceProvider.Instance;
var agent = this._innerAgentFactory(services);
// To match intuitive expectations, apply the factories in reverse order, so that the first factory added is the outermost.
if (this._agentFactories is not null)
{
for (var i = this._agentFactories.Count - 1; i >= 0; i--)
{
agent = this._agentFactories[i](agent, services);
if (agent is null)
{
Throw.InvalidOperationException(
$"The {nameof(AIAgentBuilder)} entry at index {i} returned null. " +
$"Ensure that the callbacks passed to {nameof(Use)} return non-null {nameof(AIAgent)} instances.");
}
}
}
return agent;
}
/// <inheritdoc/>
public AIAgentBuilder Use(Func<AIAgent, AIAgent> agentFactory)
{
_ = Throw.IfNull(agentFactory);
return this.Use((innerAgent, _) => agentFactory(innerAgent));
}
/// <inheritdoc/>
public AIAgentBuilder Use(Func<AIAgent, IServiceProvider, AIAgent> agentFactory)
{
_ = Throw.IfNull(agentFactory);
(this._agentFactories ??= []).Add(agentFactory);
return this;
}
/// <summary>
/// Adds to the agent pipeline an anonymous delegating agent based on a delegate that provides
/// an implementation for both <see cref="AIAgent.RunAsync(IEnumerable{ChatMessage}, AgentThread?, AgentRunOptions?, CancellationToken)"/> and <see cref="AIAgent.RunStreamingAsync(IEnumerable{ChatMessage}, AgentThread?, AgentRunOptions?, CancellationToken)"/>.
/// </summary>
/// <param name="sharedFunc">
/// A delegate that provides the implementation for both <see cref="AIAgent.RunAsync(IEnumerable{ChatMessage}, AgentThread?, AgentRunOptions?, CancellationToken)"/> and
/// <see cref="AIAgent.RunStreamingAsync(IEnumerable{ChatMessage}, AgentThread?, AgentRunOptions?, CancellationToken)"/>. This delegate is invoked with the list of messages, the agent
/// thread, the run options, a delegate that represents invoking the inner agent, and a cancellation token. The delegate should be passed
/// whatever messages, thread, options, and cancellation token should be passed along to the next stage in the pipeline.
/// It will handle both the non-streaming and streaming cases.
/// </param>
/// <returns>The updated <see cref="AIAgentBuilder"/> instance.</returns>
/// <remarks>
/// This overload can be used when the anonymous implementation needs to provide pre-processing and/or post-processing, but doesn't
/// need to interact with the results of the operation, which will come from the inner agent.
/// </remarks>
/// <exception cref="ArgumentNullException"><paramref name="sharedFunc"/> is <see langword="null"/>.</exception>
public AIAgentBuilder Use(Func<IEnumerable<ChatMessage>, AgentThread?, AgentRunOptions?, Func<IEnumerable<ChatMessage>, AgentThread?, AgentRunOptions?, CancellationToken, Task>, CancellationToken, Task> sharedFunc)
{
_ = Throw.IfNull(sharedFunc);
return this.Use((innerAgent, _) => new AnonymousDelegatingAIAgent(innerAgent, sharedFunc));
}
/// <summary>
/// Adds to the agent pipeline an anonymous delegating agent based on a delegate that provides
/// an implementation for both <see cref="AIAgent.RunAsync(IEnumerable{ChatMessage}, AgentThread?, AgentRunOptions?, CancellationToken)"/> and <see cref="AIAgent.RunStreamingAsync(IEnumerable{ChatMessage}, AgentThread?, AgentRunOptions?, CancellationToken)"/>.
/// </summary>
/// <param name="runFunc">
/// A delegate that provides the implementation for <see cref="AIAgent.RunAsync(IEnumerable{ChatMessage}, AgentThread?, AgentRunOptions?, CancellationToken)"/>. When <see langword="null"/>,
/// <paramref name="runStreamingFunc"/> must be non-null, and the implementation of <see cref="AIAgent.RunAsync(IEnumerable{ChatMessage}, AgentThread?, AgentRunOptions?, CancellationToken)"/>
/// will use <paramref name="runStreamingFunc"/> for the implementation.
/// </param>
/// <param name="runStreamingFunc">
/// A delegate that provides the implementation for <see cref="AIAgent.RunStreamingAsync(IEnumerable{ChatMessage}, AgentThread?, AgentRunOptions?, CancellationToken)"/>. When <see langword="null"/>,
/// <paramref name="runFunc"/> must be non-null, and the implementation of <see cref="AIAgent.RunStreamingAsync(IEnumerable{ChatMessage}, AgentThread?, AgentRunOptions?, CancellationToken)"/>
/// will use <paramref name="runFunc"/> for the implementation.
/// </param>
/// <returns>The updated <see cref="AIAgentBuilder"/> instance.</returns>
/// <remarks>
/// One or both delegates can be provided. If both are provided, they will be used for their respective methods:
/// <paramref name="runFunc"/> will provide the implementation of <see cref="AIAgent.RunAsync(IEnumerable{ChatMessage}, AgentThread?, AgentRunOptions?, CancellationToken)"/>, and
/// <paramref name="runStreamingFunc"/> will provide the implementation of <see cref="AIAgent.RunStreamingAsync(IEnumerable{ChatMessage}, AgentThread?, AgentRunOptions?, CancellationToken)"/>.
/// If only one of the delegates is provided, it will be used for both methods. That means that if <paramref name="runFunc"/>
/// is supplied without <paramref name="runStreamingFunc"/>, the implementation of <see cref="AIAgent.RunStreamingAsync(IEnumerable{ChatMessage}, AgentThread?, AgentRunOptions?, CancellationToken)"/>
/// will employ limited streaming, as it will be operating on the batch output produced by <paramref name="runFunc"/>. And if
/// <paramref name="runStreamingFunc"/> is supplied without <paramref name="runFunc"/>, the implementation of
/// <see cref="AIAgent.RunAsync(IEnumerable{ChatMessage}, AgentThread?, AgentRunOptions?, CancellationToken)"/> will be implemented by combining the updates from <paramref name="runStreamingFunc"/>.
/// </remarks>
/// <exception cref="ArgumentNullException">Both <paramref name="runFunc"/> and <paramref name="runStreamingFunc"/> are <see langword="null"/>.</exception>
public AIAgentBuilder Use(
Func<IEnumerable<ChatMessage>, AgentThread?, AgentRunOptions?, AIAgent, CancellationToken, Task<AgentRunResponse>>? runFunc,
Func<IEnumerable<ChatMessage>, AgentThread?, AgentRunOptions?, AIAgent, CancellationToken, IAsyncEnumerable<AgentRunResponseUpdate>>? runStreamingFunc)
{
AnonymousDelegatingAIAgent.ThrowIfBothDelegatesNull(runFunc, runStreamingFunc);
return this.Use((innerAgent, _) => new AnonymousDelegatingAIAgent(innerAgent, runFunc, runStreamingFunc));
}
/// <summary>
/// Provides an empty <see cref="IServiceProvider"/> implementation.
/// </summary>
private sealed class EmptyServiceProvider : IServiceProvider, IKeyedServiceProvider
{
/// <summary>Gets the singleton instance of <see cref="EmptyServiceProvider"/>.</summary>
public static EmptyServiceProvider Instance { get; } = new();
/// <inheritdoc/>
public object? GetService(Type serviceType) => null;
/// <inheritdoc/>
public object? GetKeyedService(Type serviceType, object? serviceKey) => null;
/// <inheritdoc/>
public object GetRequiredKeyedService(Type serviceType, object? serviceKey) =>
throw new InvalidOperationException($"No service for type '{serviceType}' has been registered.");
}
}
@@ -0,0 +1,25 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>Provides extension methods for working with <see cref="AIAgent"/> in the context of <see cref="AIAgentBuilder"/>.</summary>
public static class AIAgentBuilderAIAgentExtensions
{
/// <summary>Creates a new <see cref="AIAgentBuilder"/> using <paramref name="innerAgent"/> as its inner agent.</summary>
/// <param name="innerAgent">The agent to use as the inner agent.</param>
/// <returns>The new <see cref="AIAgentBuilder"/> instance.</returns>
/// <remarks>
/// This method is equivalent to using the <see cref="AIAgentBuilder"/> constructor directly,
/// specifying <paramref name="innerAgent"/> as the inner agent.
/// </remarks>
/// <exception cref="ArgumentNullException"><paramref name="innerAgent"/> is <see langword="null"/>.</exception>
public static AIAgentBuilder AsBuilder(this AIAgent innerAgent)
{
_ = Throw.IfNull(innerAgent);
return new AIAgentBuilder(innerAgent);
}
}
@@ -0,0 +1,41 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides extension methods for configuring an <see cref="AIAgentBuilder"/> instance.
/// </summary>
/// <remarks>This class contains methods that extend the functionality of the <see cref="AIAgentBuilder"/> to
/// allow additional customization and behavior injection.</remarks>
public static class AIAgentBuilderExtensions
{
/// <summary>
/// Adds a middleware to the AI agent pipeline that intercepts and processes <see cref="AIFunction"/> invocations.
/// </summary>
/// <param name="builder">The <see cref="AIAgentBuilder"/> to which the middleware is added.</param>
/// <param name="callback">A delegate that processes function invocations. The delegate receives the invocation context, the next
/// middleware in the pipeline, and a cancellation token, and returns a task representing the result of the
/// invocation.</param>
/// <returns>The <see cref="AIAgentBuilder"/> instance with the middleware added.</returns>
public static AIAgentBuilder Use(this AIAgentBuilder builder, Func<AIAgent, FunctionInvocationContext, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>>, CancellationToken, ValueTask<object?>> callback)
{
_ = Throw.IfNull(builder);
_ = Throw.IfNull(callback);
return builder.Use((innerAgent, _) =>
{
// Function calling requires a ChatClientAgent inner agent.
if (innerAgent.GetService<FunctionInvokingChatClient>() is null)
{
throw new InvalidOperationException($"The function invocation middleware can only be used with decorations of a {nameof(AIAgent)} that support usage of FunctionInvokingChatClient decorated chat clients.");
}
return new FunctionInvocationDelegatingAgent(innerAgent, callback);
});
}
}
@@ -0,0 +1,203 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>Represents a delegating AI agent that wraps an inner agent with implementations provided by delegates.</summary>
/// <remarks>
/// This internal class is a convenience implementation mainly used to support <see cref="AIAgentBuilder"/> Use methods that take delegates to intercept agent operations.
/// </remarks>
internal sealed class AnonymousDelegatingAIAgent : DelegatingAIAgent
{
/// <summary>The delegate to use as the implementation of <see cref="RunAsync"/>.</summary>
private readonly Func<IEnumerable<ChatMessage>, AgentThread?, AgentRunOptions?, AIAgent, CancellationToken, Task<AgentRunResponse>>? _runFunc;
/// <summary>The delegate to use as the implementation of <see cref="RunStreamingAsync"/>.</summary>
/// <remarks>
/// When non-<see langword="null"/>, this delegate is used as the implementation of <see cref="RunStreamingAsync"/> and
/// will be invoked with the same arguments as the method itself.
/// When <see langword="null"/>, <see cref="RunStreamingAsync"/> will delegate directly to the inner agent.
/// </remarks>
private readonly Func<IEnumerable<ChatMessage>, AgentThread?, AgentRunOptions?, AIAgent, CancellationToken, IAsyncEnumerable<AgentRunResponseUpdate>>? _runStreamingFunc;
/// <summary>The delegate to use as the implementation of both <see cref="RunAsync"/> and <see cref="RunStreamingAsync"/>.</summary>
private readonly Func<IEnumerable<ChatMessage>, AgentThread?, AgentRunOptions?, Func<IEnumerable<ChatMessage>, AgentThread?, AgentRunOptions?, CancellationToken, Task>, CancellationToken, Task>? _sharedFunc;
/// <summary>
/// Initializes a new instance of the <see cref="AnonymousDelegatingAIAgent"/> class.
/// </summary>
/// <param name="innerAgent">The inner agent.</param>
/// <param name="sharedFunc">
/// A delegate that provides the implementation for both <see cref="RunAsync"/> and <see cref="RunStreamingAsync"/>.
/// In addition to the arguments for the operation, it's provided with a delegate to the inner agent that should be
/// used to perform the operation on the inner agent. It will handle both the non-streaming and streaming cases.
/// </param>
/// <remarks>
/// This overload may be used when the anonymous implementation needs to provide pre-processing and/or post-processing, but doesn't
/// need to interact with the results of the operation, which will come from the inner agent.
/// </remarks>
/// <exception cref="ArgumentNullException"><paramref name="innerAgent"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentNullException"><paramref name="sharedFunc"/> is <see langword="null"/>.</exception>
public AnonymousDelegatingAIAgent(
AIAgent innerAgent,
Func<IEnumerable<ChatMessage>, AgentThread?, AgentRunOptions?, Func<IEnumerable<ChatMessage>, AgentThread?, AgentRunOptions?, CancellationToken, Task>, CancellationToken, Task> sharedFunc)
: base(innerAgent)
{
_ = Throw.IfNull(sharedFunc);
this._sharedFunc = sharedFunc;
}
/// <summary>
/// Initializes a new instance of the <see cref="AnonymousDelegatingAIAgent"/> class.
/// </summary>
/// <param name="innerAgent">The inner agent.</param>
/// <param name="runFunc">
/// A delegate that provides the implementation for <see cref="RunAsync"/>. When <see langword="null"/>,
/// <paramref name="runStreamingFunc"/> must be non-null, and the implementation of <see cref="RunAsync"/>
/// will use <paramref name="runStreamingFunc"/> for the implementation.
/// </param>
/// <param name="runStreamingFunc">
/// A delegate that provides the implementation for <see cref="RunStreamingAsync"/>. When <see langword="null"/>,
/// <paramref name="runFunc"/> must be non-null, and the implementation of <see cref="RunStreamingAsync"/>
/// will use <paramref name="runFunc"/> for the implementation.
/// </param>
/// <exception cref="ArgumentNullException"><paramref name="innerAgent"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentNullException">Both <paramref name="runFunc"/> and <paramref name="runStreamingFunc"/> are <see langword="null"/>.</exception>
public AnonymousDelegatingAIAgent(
AIAgent innerAgent,
Func<IEnumerable<ChatMessage>, AgentThread?, AgentRunOptions?, AIAgent, CancellationToken, Task<AgentRunResponse>>? runFunc,
Func<IEnumerable<ChatMessage>, AgentThread?, AgentRunOptions?, AIAgent, CancellationToken, IAsyncEnumerable<AgentRunResponseUpdate>>? runStreamingFunc)
: base(innerAgent)
{
ThrowIfBothDelegatesNull(runFunc, runStreamingFunc);
this._runFunc = runFunc;
this._runStreamingFunc = runStreamingFunc;
}
/// <inheritdoc/>
public override Task<AgentRunResponse> RunAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(messages);
if (this._sharedFunc is not null)
{
return GetRunViaSharedAsync(messages, thread, options, cancellationToken);
async Task<AgentRunResponse> GetRunViaSharedAsync(
IEnumerable<ChatMessage> messages, AgentThread? thread, AgentRunOptions? options, CancellationToken cancellationToken)
{
AgentRunResponse? response = null;
await this._sharedFunc(
messages,
thread,
options,
async (messages, thread, options, cancellationToken)
=> response = await this.InnerAgent.RunAsync(messages, thread, options, cancellationToken).ConfigureAwait(false),
cancellationToken)
.ConfigureAwait(false);
if (response is null)
{
Throw.InvalidOperationException("The shared delegate completed successfully without producing an AgentRunResponse.");
}
return response;
}
}
else if (this._runFunc is not null)
{
return this._runFunc(messages, thread, options, this.InnerAgent, cancellationToken);
}
else
{
Debug.Assert(this._runStreamingFunc is not null, "Expected non-null streaming delegate.");
return this._runStreamingFunc!(messages, thread, options, this.InnerAgent, cancellationToken)
.ToAgentRunResponseAsync(cancellationToken);
}
}
/// <inheritdoc/>
public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(messages);
if (this._sharedFunc is not null)
{
var updates = Channel.CreateBounded<AgentRunResponseUpdate>(1);
_ = ProcessAsync();
async Task ProcessAsync()
{
Exception? error = null;
try
{
await this._sharedFunc(messages, thread, options, async (messages, thread, options, cancellationToken) =>
{
await foreach (var update in this.InnerAgent.RunStreamingAsync(messages, thread, options, cancellationToken).ConfigureAwait(false))
{
await updates.Writer.WriteAsync(update, cancellationToken).ConfigureAwait(false);
}
}, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
error = ex;
throw;
}
finally
{
_ = updates.Writer.TryComplete(error);
}
}
return updates.Reader.ReadAllAsync(cancellationToken);
}
else if (this._runStreamingFunc is not null)
{
return this._runStreamingFunc(messages, thread, options, this.InnerAgent, cancellationToken);
}
else
{
Debug.Assert(this._runFunc is not null, "Expected non-null non-streaming delegate.");
return GetStreamingRunAsyncViaRunAsync(this._runFunc!(messages, thread, options, this.InnerAgent, cancellationToken));
static async IAsyncEnumerable<AgentRunResponseUpdate> GetStreamingRunAsyncViaRunAsync(Task<AgentRunResponse> task)
{
AgentRunResponse response = await task.ConfigureAwait(false);
foreach (var update in response.ToAgentRunResponseUpdates())
{
yield return update;
}
}
}
}
/// <summary>Throws an exception if both of the specified delegates are <see langword="null"/>.</summary>
/// <exception cref="ArgumentNullException">Both <paramref name="runFunc"/> and <paramref name="runStreamingFunc"/> are <see langword="null"/>.</exception>
internal static void ThrowIfBothDelegatesNull(object? runFunc, object? runStreamingFunc)
{
if (runFunc is null && runStreamingFunc is null)
{
Throw.ArgumentNullException(nameof(runFunc), $"At least one of the {nameof(runFunc)} or {nameof(runStreamingFunc)} delegates must be non-null.");
}
}
}
@@ -1,27 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>Provides extensions for configuring <see cref="AgentInvokedChatClient"/> instances.</summary>
public static class AgentChatClientBuilderExtensions
{
/// <summary>
/// Enables automatic function call invocation on the chat pipeline.
/// </summary>
/// <remarks>This works by adding an instance of <see cref="AgentInvokedChatClient"/> with default options.</remarks>
/// <param name="builder">The <see cref="ChatClientBuilder"/> being used to build the chat pipeline.</param>
/// <returns>The supplied <paramref name="builder"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
public static ChatClientBuilder UseAgentInvocation(
this ChatClientBuilder builder)
{
_ = Throw.IfNull(builder);
return builder.Use((innerClient, services) =>
new AgentInvokedChatClient(innerClient));
}
}
@@ -1,20 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
/// <summary>
/// Internal chat client that handle agent invocation details for the chat client pipeline.
/// </summary>
internal sealed class AgentInvokedChatClient : DelegatingChatClient
{
/// <summary>
/// Initializes a new instance of the <see cref="AgentInvokedChatClient"/> class.
/// </summary>
/// <param name="chatClient">The chat client to invoke agents.</param>
internal AgentInvokedChatClient(IChatClient chatClient)
: base(chatClient)
{
}
}
@@ -71,7 +71,7 @@ public sealed class ChatClientAgent : AIAgent
this._chatClientType = chatClient.GetType();
// If the user has not opted out of using our default decorators, we wrap the chat client.
this.ChatClient = options?.UseProvidedChatClientAsIs is true ? chatClient : chatClient.AsAgentInvokedChatClient(options);
this.ChatClient = options?.UseProvidedChatClientAsIs is true ? chatClient : chatClient.WithDefaultAgentMiddleware(options);
this._logger = (loggerFactory ?? chatClient.GetService<ILoggerFactory>() ?? NullLoggerFactory.Instance).CreateLogger<ChatClientAgent>();
}
@@ -112,6 +112,10 @@ public sealed class ChatClientAgent : AIAgent
(ChatClientAgentThread safeThread, ChatOptions? chatOptions, List<ChatMessage> threadMessages) =
await this.PrepareThreadAndMessagesAsync(thread, inputMessages, options, cancellationToken).ConfigureAwait(false);
var chatClient = this.ChatClient;
chatClient = ApplyRunOptionsTransformations(options, chatClient);
var agentName = this.GetLoggingAgentName();
this._logger.LogAgentChatClientInvokingAgent(nameof(RunAsync), this.Id, agentName, this._chatClientType);
@@ -120,7 +124,7 @@ public sealed class ChatClientAgent : AIAgent
ChatResponse chatResponse;
try
{
chatResponse = await this.ChatClient.GetResponseAsync(threadMessages, chatOptions, cancellationToken).ConfigureAwait(false);
chatResponse = await chatClient.GetResponseAsync(threadMessages, chatOptions, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
@@ -151,6 +155,30 @@ public sealed class ChatClientAgent : AIAgent
return new(chatResponse) { AgentId = this.Id };
}
/// <summary>
/// Configures the specified <see cref="IChatClient"/> instance based on the provided run options and chat options.
/// </summary>
/// <remarks>This method applies transformations and customizations to the chat client and chat options
/// based on the provided <paramref name="options"/>. If no applicable options are provided, the original <paramref
/// name="chatClient"/> is returned unchanged.</remarks>
/// <param name="options">The run options to apply. If <paramref name="options"/> is of type <see cref="ChatClientAgentRunOptions"/>,
/// additional configuration such as tool transformations and custom chat client creation may be applied.</param>
/// <param name="chatClient">The <see cref="IChatClient"/> instance to configure. If a custom chat client factory is provided in <see
/// cref="ChatClientAgentRunOptions.ChatClientFactory"/>, a new <see cref="IChatClient"/> instance may be created.</param>
/// <returns>The configured <see cref="IChatClient"/> instance. If a custom chat client factory is used, the returned
/// instance may differ from the input <paramref name="chatClient"/>.</returns>
private static IChatClient ApplyRunOptionsTransformations(AgentRunOptions? options, IChatClient chatClient)
{
if (options is ChatClientAgentRunOptions agentChatOptions && agentChatOptions.ChatClientFactory is not null)
{
// If we have a custom chat client factory, we should use it to create a new chat client with the transformed tools.
chatClient = agentChatOptions.ChatClientFactory(chatClient);
_ = Throw.IfNull(chatClient);
}
return chatClient;
}
/// <inheritdoc/>
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IEnumerable<ChatMessage> messages,
@@ -164,6 +192,11 @@ public sealed class ChatClientAgent : AIAgent
await this.PrepareThreadAndMessagesAsync(thread, inputMessages, options, cancellationToken).ConfigureAwait(false);
int messageCount = threadMessages.Count;
var chatClient = this.ChatClient;
chatClient = ApplyRunOptionsTransformations(options, chatClient);
var loggingAgentName = this.GetLoggingAgentName();
this._logger.LogAgentChatClientInvokingAgent(nameof(RunStreamingAsync), this.Id, loggingAgentName, this._chatClientType);
@@ -175,7 +208,7 @@ public sealed class ChatClientAgent : AIAgent
try
{
// Using the enumerator to ensure we consider the case where no updates are returned for notification.
responseUpdatesEnumerator = this.ChatClient.GetStreamingResponseAsync(threadMessages, chatOptions, cancellationToken).GetAsyncEnumerator(cancellationToken);
responseUpdatesEnumerator = chatClient.GetStreamingResponseAsync(threadMessages, chatOptions, cancellationToken).GetAsyncEnumerator(cancellationToken);
}
catch (Exception ex)
{
@@ -237,8 +270,8 @@ public sealed class ChatClientAgent : AIAgent
/// <inheritdoc/>
public override object? GetService(Type serviceType, object? serviceKey = null) =>
base.GetService(serviceType, serviceKey)
?? (serviceType == typeof(AIAgentMetadata) ? this._agentMetadata
base.GetService(serviceType, serviceKey) ??
(serviceType == typeof(AIAgentMetadata) ? this._agentMetadata
: serviceType == typeof(IChatClient) ? this.ChatClient
: this.ChatClient.GetService(serviceType, serviceKey));
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
@@ -20,4 +21,9 @@ public sealed class ChatClientAgentRunOptions : AgentRunOptions
/// <summary>Gets or sets optional chat options to pass to the agent's invocation.</summary>
public ChatOptions? ChatOptions { get; set; }
/// <summary>
/// Gets or sets the factory method used to modify instances of <see cref="IChatClient"/> per-request.
/// </summary>
public Func<IChatClient, IChatClient>? ChatClientFactory { get; set; }
}
@@ -10,16 +10,10 @@ namespace Microsoft.Extensions.AI;
internal static class ChatClientExtensions
{
internal static IChatClient AsAgentInvokedChatClient(this IChatClient chatClient, ChatClientAgentOptions? options)
internal static IChatClient WithDefaultAgentMiddleware(this IChatClient chatClient, ChatClientAgentOptions? options)
{
var chatBuilder = chatClient.AsBuilder();
// AgentInvokingChatClient should be the outermost decorator
if (chatClient is not AgentInvokedChatClient agentInvokingChatClient)
{
chatBuilder.UseAgentInvocation();
}
if (chatClient.GetService<FunctionInvokingChatClient>() is null)
{
_ = chatBuilder.Use((innerClient, services) =>
@@ -0,0 +1,78 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
/// <summary>
/// Internal agent decorator that adds function invocation middleware logic.
/// </summary>
internal sealed class FunctionInvocationDelegatingAgent : DelegatingAIAgent
{
private readonly Func<AIAgent, FunctionInvocationContext, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>>, CancellationToken, ValueTask<object?>> _delegateFunc;
internal FunctionInvocationDelegatingAgent(AIAgent innerAgent, Func<AIAgent, FunctionInvocationContext, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>>, CancellationToken, ValueTask<object?>> delegateFunc) : base(innerAgent)
{
this._delegateFunc = delegateFunc;
}
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> this.InnerAgent.RunAsync(messages, thread, this.AgentRunOptionsWithFunctionMiddleware(options), cancellationToken);
public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> this.InnerAgent.RunStreamingAsync(messages, thread, this.AgentRunOptionsWithFunctionMiddleware(options), cancellationToken);
// Decorate options to add the middleware function
private AgentRunOptions? AgentRunOptionsWithFunctionMiddleware(AgentRunOptions? options)
{
if (options is ChatClientAgentRunOptions aco)
{
var originalFactory = aco.ChatClientFactory;
aco.ChatClientFactory = (IChatClient chatClient) =>
{
var builder = chatClient.AsBuilder();
if (originalFactory is not null)
{
builder.Use(originalFactory);
}
return builder.ConfigureOptions(co
=> co.Tools = co.Tools?.Select(tool => tool is AIFunction aiFunction
? aiFunction is ApprovalRequiredAIFunction approvalRequiredAiFunction
? new ApprovalRequiredAIFunction(new MiddlewareEnabledFunction(this, approvalRequiredAiFunction, this._delegateFunc))
: new MiddlewareEnabledFunction(this.InnerAgent, aiFunction, this._delegateFunc)
: tool)
.ToList())
.Build();
};
}
return options;
}
private sealed class MiddlewareEnabledFunction(AIAgent innerAgent, AIFunction innerFunction, Func<AIAgent, FunctionInvocationContext, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>>, CancellationToken, ValueTask<object?>> next) : DelegatingAIFunction(innerFunction)
{
protected override async ValueTask<object?> InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken)
{
var context = FunctionInvokingChatClient.CurrentContext
?? new FunctionInvocationContext() // When there is no ambient context, create a new one to hold the arguments
{
Arguments = arguments,
Function = this.InnerFunction,
CallContent = new(string.Empty, this.InnerFunction.Name, new Dictionary<string, object?>(arguments)),
Iteration = 0, // Indicate this function was not invoked by a FICC and has no iteration flow.
};
return await next(innerAgent, context, CoreLogicAsync, cancellationToken).ConfigureAwait(false);
ValueTask<object?> CoreLogicAsync(FunctionInvocationContext ctx, CancellationToken cancellationToken)
=> base.InvokeCoreAsync(ctx.Arguments, cancellationToken);
}
}
}
@@ -18,6 +18,7 @@
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
<PackageReference Include="Microsoft.Extensions.AI" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="System.Diagnostics.DiagnosticSource" />
</ItemGroup>
@@ -0,0 +1,40 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>Provides extensions for configuring <see cref="OpenTelemetryAgent"/> instances.</summary>
public static class OpenTelemetryAIAgentBuilderExtensions
{
/// <summary>
/// Adds OpenTelemetry support to the agent pipeline for agent runs, following the OpenTelemetry Semantic Conventions for Generative AI systems.
/// </summary>
/// <remarks>
/// The draft specification this follows is available at <see href="https://opentelemetry.io/docs/specs/semconv/gen-ai/" />.
/// The specification is still experimental and subject to change; as such, the telemetry output by this agent is also subject to change.
/// </remarks>
/// <param name="builder">The <see cref="AIAgentBuilder"/>.</param>
/// <param name="loggerFactory">An optional <see cref="ILoggerFactory"/> to use to create a logger for logging events.</param>
/// <param name="sourceName">An optional source name that will be used on the telemetry data.</param>
/// <param name="configure">An optional callback that can be used to configure the <see cref="OpenTelemetryAgent"/> instance.</param>
/// <returns>The <paramref name="builder"/>.</returns>
public static AIAgentBuilder UseOpenTelemetry(
this AIAgentBuilder builder,
ILoggerFactory? loggerFactory = null,
string? sourceName = null,
Action<OpenTelemetryAgent>? configure = null) =>
Throw.IfNull(builder).Use((innerAgent, services) =>
{
loggerFactory ??= services.GetService<ILoggerFactory>();
var agent = new OpenTelemetryAgent(innerAgent, loggerFactory?.CreateLogger(typeof(OpenTelemetryAgent)), sourceName);
configure?.Invoke(agent);
return agent;
});
}
@@ -90,7 +90,7 @@ public sealed class AzureAgentProvider(string projectEndpoint, TokenCredential p
/// <inheritdoc/>
public override async Task<AIAgent> GetAgentAsync(string agentId, CancellationToken cancellationToken = default) =>
await this.GetAgentsClient().GetAIAgentAsync(agentId, chatOptions: null, cancellationToken).ConfigureAwait(false);
await this.GetAgentsClient().GetAIAgentAsync(agentId, chatOptions: null, cancellationToken: cancellationToken).ConfigureAwait(false);
/// <inheritdoc/>
public override async Task<ChatMessage> GetMessageAsync(string conversationId, string messageId, CancellationToken cancellationToken = default)
@@ -0,0 +1,403 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Azure;
using Azure.AI.Agents.Persistent;
using Azure.Core;
using Moq;
namespace Microsoft.Extensions.AI.Agents.AzureAI.UnitTests.Extensions;
public sealed class PersistentAgentsClientExtensionsTests
{
/// <summary>
/// Verify that GetAIAgent throws ArgumentNullException when client is null.
/// </summary>
[Fact]
public void GetAIAgent_WithNullClient_ThrowsArgumentNullException()
{
// Act & Assert
var exception = Assert.Throws<ArgumentNullException>(() =>
((PersistentAgentsClient)null!).GetAIAgent("test-agent"));
Assert.Equal("persistentAgentsClient", exception.ParamName);
}
/// <summary>
/// Verify that GetAIAgent throws ArgumentException when agentId is null or whitespace.
/// </summary>
[Fact]
public void GetAIAgent_WithNullOrWhitespaceAgentId_ThrowsArgumentException()
{
// Arrange
var mockClient = new Mock<PersistentAgentsClient>();
// Act & Assert - null agentId
var exception1 = Assert.Throws<ArgumentException>(() =>
mockClient.Object.GetAIAgent(null!));
Assert.Equal("agentId", exception1.ParamName);
// Act & Assert - empty agentId
var exception2 = Assert.Throws<ArgumentException>(() =>
mockClient.Object.GetAIAgent(""));
Assert.Equal("agentId", exception2.ParamName);
// Act & Assert - whitespace agentId
var exception3 = Assert.Throws<ArgumentException>(() =>
mockClient.Object.GetAIAgent(" "));
Assert.Equal("agentId", exception3.ParamName);
}
/// <summary>
/// Verify that GetAIAgentAsync throws ArgumentNullException when client is null.
/// </summary>
[Fact]
public async Task GetAIAgentAsync_WithNullClient_ThrowsArgumentNullExceptionAsync()
{
// Act & Assert
var exception = await Assert.ThrowsAsync<ArgumentNullException>(() =>
((PersistentAgentsClient)null!).GetAIAgentAsync("test-agent"));
Assert.Equal("persistentAgentsClient", exception.ParamName);
}
/// <summary>
/// Verify that GetAIAgentAsync throws ArgumentException when agentId is null or whitespace.
/// </summary>
[Fact]
public async Task GetAIAgentAsync_WithNullOrWhitespaceAgentId_ThrowsArgumentExceptionAsync()
{
// Arrange
var mockClient = new Mock<PersistentAgentsClient>();
// Act & Assert - null agentId
var exception1 = await Assert.ThrowsAsync<ArgumentException>(() =>
mockClient.Object.GetAIAgentAsync(null!));
Assert.Equal("agentId", exception1.ParamName);
// Act & Assert - empty agentId
var exception2 = await Assert.ThrowsAsync<ArgumentException>(() =>
mockClient.Object.GetAIAgentAsync(""));
Assert.Equal("agentId", exception2.ParamName);
// Act & Assert - whitespace agentId
var exception3 = await Assert.ThrowsAsync<ArgumentException>(() =>
mockClient.Object.GetAIAgentAsync(" "));
Assert.Equal("agentId", exception3.ParamName);
}
/// <summary>
/// Verify that CreateAIAgent throws ArgumentNullException when client is null.
/// </summary>
[Fact]
public void CreateAIAgent_WithNullClient_ThrowsArgumentNullException()
{
// Act & Assert
var exception = Assert.Throws<ArgumentNullException>(() =>
((PersistentAgentsClient)null!).CreateAIAgent("test-model"));
Assert.Equal("persistentAgentsClient", exception.ParamName);
}
/// <summary>
/// Verify that CreateAIAgentAsync throws ArgumentNullException when client is null.
/// </summary>
[Fact]
public async Task CreateAIAgentAsync_WithNullClient_ThrowsArgumentNullExceptionAsync()
{
// Act & Assert
var exception = await Assert.ThrowsAsync<ArgumentNullException>(() =>
((PersistentAgentsClient)null!).CreateAIAgentAsync("test-model"));
Assert.Equal("persistentAgentsClient", exception.ParamName);
}
/// <summary>
/// Verify that AsNewIChatClient throws ArgumentNullException when client is null.
/// </summary>
[Fact]
public void AsNewIChatClient_WithNullClient_ThrowsArgumentNullException()
{
// Act & Assert
var exception = Assert.Throws<ArgumentNullException>(() =>
((PersistentAgentsClient)null!).AsNewIChatClient("test-agent"));
Assert.Equal("client", exception.ParamName);
}
/// <summary>
/// Verify that AsNewIChatClient throws ArgumentException when assistantId is null or empty.
/// </summary>
[Fact]
public void AsNewIChatClient_WithNullOrEmptyAssistantId_ThrowsArgumentException()
{
// Arrange
var mockClient = new Mock<PersistentAgentsClient>();
// Act & Assert - null assistantId throws ArgumentNullException
var exception1 = Assert.Throws<ArgumentNullException>(() =>
mockClient.Object.AsNewIChatClient(null!));
Assert.Equal("assistantId", exception1.ParamName);
// Act & Assert - empty assistantId throws ArgumentException
var exception2 = Assert.Throws<ArgumentException>(() =>
mockClient.Object.AsNewIChatClient(""));
Assert.Equal("assistantId", exception2.ParamName);
}
/// <summary>
/// Verify that GetAIAgent with clientFactory parameter correctly applies the factory.
/// </summary>
[Fact]
public void GetAIAgent_WithClientFactory_AppliesFactoryCorrectly()
{
// Arrange
var client = CreateFakePersistentAgentsClient();
TestChatClient? testChatClient = null;
// Act
var agent = client.GetAIAgent(
agentId: "test-agent-id",
clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient));
// Assert
Assert.NotNull(agent);
var retrievedTestClient = agent.GetService<TestChatClient>();
Assert.NotNull(retrievedTestClient);
Assert.Same(testChatClient, retrievedTestClient);
}
/// <summary>
/// Verify that GetAIAgent without clientFactory works normally.
/// </summary>
[Fact]
public void GetAIAgent_WithoutClientFactory_WorksNormally()
{
// Arrange
var client = CreateFakePersistentAgentsClient();
// Act
var agent = client.GetAIAgent(agentId: "test-agent-id");
// Assert
Assert.NotNull(agent);
var retrievedTestClient = agent.GetService<TestChatClient>();
Assert.Null(retrievedTestClient);
}
/// <summary>
/// Verify that GetAIAgent with null clientFactory works normally.
/// </summary>
[Fact]
public void GetAIAgent_WithNullClientFactory_WorksNormally()
{
// Arrange
PersistentAgentsClient client = CreateFakePersistentAgentsClient();
// Act
var agent = client.GetAIAgent(agentId: "test-agent-id", clientFactory: null);
// Assert
Assert.NotNull(agent);
var retrievedTestClient = agent.GetService<TestChatClient>();
Assert.Null(retrievedTestClient);
}
/// <summary>
/// Verify that CreateAIAgent with clientFactory parameter correctly applies the factory.
/// </summary>
[Fact]
public void CreateAIAgent_WithClientFactory_AppliesFactoryCorrectly()
{
// Arrange
// Arrange
var client = CreateFakePersistentAgentsClient();
TestChatClient? testChatClient = null;
// Act
var agent = client.CreateAIAgent(
model: "test-model",
clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient));
// Assert
Assert.NotNull(agent);
var retrievedTestClient = agent.GetService<TestChatClient>();
Assert.NotNull(retrievedTestClient);
Assert.Same(testChatClient, retrievedTestClient);
}
/// <summary>
/// Verify that CreateAIAgentAsync with clientFactory parameter correctly applies the factory.
/// </summary>
[Fact]
public async Task CreateAIAgentAsync_WithClientFactory_AppliesFactoryCorrectlyAsync()
{
// Arrange
var client = CreateFakePersistentAgentsClient();
TestChatClient? testChatClient = null;
// Act
var agent = await client.CreateAIAgentAsync(
model: "test-model",
clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient));
// Assert
Assert.NotNull(agent);
var retrievedTestClient = agent.GetService<TestChatClient>();
Assert.NotNull(retrievedTestClient);
Assert.Same(testChatClient, retrievedTestClient);
}
/// <summary>
/// Verify that CreateAIAgent without clientFactory works normally.
/// </summary>
[Fact]
public void CreateAIAgent_WithoutClientFactory_WorksNormally()
{
// Arrange
var client = CreateFakePersistentAgentsClient();
// Act
var agent = client.CreateAIAgent(model: "test-model");
// Assert
Assert.NotNull(agent);
var retrievedTestClient = agent.GetService<TestChatClient>();
Assert.Null(retrievedTestClient);
}
/// <summary>
/// Verify that CreateAIAgent with null clientFactory works normally.
/// </summary>
[Fact]
public void CreateAIAgent_WithNullClientFactory_WorksNormally()
{
// Arrange
var client = CreateFakePersistentAgentsClient();
// Act
var agent = client.CreateAIAgent(model: "test-model", clientFactory: null);
// Assert
Assert.NotNull(agent);
var retrievedTestClient = agent.GetService<TestChatClient>();
Assert.Null(retrievedTestClient);
}
/// <summary>
/// Verify that CreateAIAgent without clientFactory works normally.
/// </summary>
[Fact]
public async Task CreateAIAgentAsync_WithoutClientFactory_WorksNormallyAsync()
{
// Arrange
var client = CreateFakePersistentAgentsClient();
// Act
var agent = await client.CreateAIAgentAsync(model: "test-model");
// Assert
Assert.NotNull(agent);
var retrievedTestClient = agent.GetService<TestChatClient>();
Assert.Null(retrievedTestClient);
}
/// <summary>
/// Verify that CreateAIAgent with null clientFactory works normally.
/// </summary>
[Fact]
public async Task CreateAIAgentAsync_WithNullClientFactory_WorksNormallyAsync()
{
// Arrange
var client = CreateFakePersistentAgentsClient();
// Act
var agent = await client.CreateAIAgentAsync(model: "test-model", clientFactory: null);
// Assert
Assert.NotNull(agent);
var retrievedTestClient = agent.GetService<TestChatClient>();
Assert.Null(retrievedTestClient);
}
/// <summary>
/// Test custom chat client that can be used to verify clientFactory functionality.
/// </summary>
private sealed class TestChatClient : DelegatingChatClient
{
public TestChatClient(IChatClient innerClient) : base(innerClient)
{
}
}
public sealed class FakePersistentAgentsAdministrationClient : PersistentAgentsAdministrationClient
{
public FakePersistentAgentsAdministrationClient()
{
}
public override async Task<Response<PersistentAgent>> CreateAgentAsync(string model, string? name = null, string? description = null, string? instructions = null, IEnumerable<ToolDefinition>? tools = null, ToolResources? toolResources = null, float? temperature = null, float? topP = null, BinaryData? responseFormat = null, IReadOnlyDictionary<string, string>? metadata = null, CancellationToken cancellationToken = default)
=> await Task.FromResult(this.FakeResponse);
public override Response<PersistentAgent> CreateAgent(string model, string? name = null, string? description = null, string? instructions = null, IEnumerable<ToolDefinition>? tools = null, ToolResources? toolResources = null, float? temperature = null, float? topP = null, BinaryData? responseFormat = null, IReadOnlyDictionary<string, string>? metadata = null, CancellationToken cancellationToken = default)
=> this.FakeResponse;
public override Response<PersistentAgent> GetAgent(string assistantId, CancellationToken cancellationToken = default)
=> this.FakeResponse;
public override async Task<Response<PersistentAgent>> GetAgentAsync(string assistantId, CancellationToken cancellationToken = default)
=> await Task.FromResult(this.FakeResponse);
private Response<PersistentAgent> FakeResponse => Response.FromValue(ModelReaderWriter.Read<PersistentAgent>(BinaryData.FromString("""{"id": "agent_abc123"}""")), new FakeResponse())!;
}
private static PersistentAgentsClient CreateFakePersistentAgentsClient()
{
var client = new PersistentAgentsClient("https://any.com", DelegatedTokenCredential.Create((_, _) => new AccessToken()));
((System.Reflection.TypeInfo)typeof(PersistentAgentsClient)).DeclaredFields.First(f => f.Name == "_client")
.SetValue(client, new FakePersistentAgentsAdministrationClient());
return client;
}
private sealed class FakeResponse : Response
{
public override int Status => throw new NotImplementedException();
public override string ReasonPhrase => throw new NotImplementedException();
public override Stream? ContentStream { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
public override string ClientRequestId { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
public override void Dispose()
{
throw new NotImplementedException();
}
protected override bool ContainsHeader(string name)
{
throw new NotImplementedException();
}
protected override IEnumerable<HttpHeader> EnumerateHeaders()
{
throw new NotImplementedException();
}
protected override bool TryGetHeader(string name, out string value)
{
throw new NotImplementedException();
}
protected override bool TryGetHeaderValues(string name, out IEnumerable<string> values)
{
throw new NotImplementedException();
}
}
}
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,261 @@
// Copyright (c) Microsoft. All rights reserved.
#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates
using System;
using System.ClientModel;
using System.ClientModel.Primitives;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using OpenAI;
using OpenAI.Assistants;
namespace Microsoft.Agents.AI.OpenAI.UnitTests.Extensions;
/// <summary>
/// Unit tests for the <see cref="OpenAIAssistantClientExtensions"/> class.
/// </summary>
public sealed class OpenAIAssistantClientExtensionsTests
{
/// <summary>
/// Verify that CreateAIAgent with clientFactory parameter correctly applies the factory.
/// </summary>
[Fact]
public void CreateAIAgent_WithClientFactory_AppliesFactoryCorrectly()
{
// Arrange
var assistantClient = new TestAssistantClient();
var testChatClient = new TestChatClient(assistantClient.AsIChatClient("test-model"));
const string ModelId = "test-model";
// Act
var agent = assistantClient.CreateAIAgent(
ModelId,
instructions: "Test instructions",
name: "Test Agent",
description: "Test description",
clientFactory: (innerClient) => testChatClient);
// Assert
Assert.NotNull(agent);
Assert.Equal("Test Agent", agent.Name);
Assert.Equal("Test description", agent.Description);
// Verify that the custom chat client can be retrieved from the agent's service collection
var retrievedTestClient = agent.GetService<TestChatClient>();
Assert.NotNull(retrievedTestClient);
Assert.Same(testChatClient, retrievedTestClient);
}
/// <summary>
/// Verify that CreateAIAgent with clientFactory using AsBuilder pattern works correctly.
/// </summary>
[Fact]
public void CreateAIAgent_WithClientFactoryUsingAsBuilder_AppliesFactoryCorrectly()
{
// Arrange
var assistantClient = new TestAssistantClient();
TestChatClient? testChatClient = null;
const string ModelId = "test-model";
// Act
var agent = assistantClient.CreateAIAgent(
ModelId,
instructions: "Test instructions",
clientFactory: (innerClient) =>
innerClient.AsBuilder()
.Use((innerClient) => testChatClient = new TestChatClient(innerClient))
.Build());
// Assert
Assert.NotNull(agent);
// Verify that the custom chat client can be retrieved from the agent's service collection
var retrievedTestClient = agent.GetService<TestChatClient>();
Assert.NotNull(retrievedTestClient);
Assert.Same(testChatClient, retrievedTestClient);
}
/// <summary>
/// Verify that CreateAIAgent with options and clientFactory parameter correctly applies the factory.
/// </summary>
[Fact]
public void CreateAIAgent_WithOptionsAndClientFactory_AppliesFactoryCorrectly()
{
// Arrange
var assistantClient = new TestAssistantClient();
var testChatClient = new TestChatClient(assistantClient.AsIChatClient("test-model"));
const string ModelId = "test-model";
var options = new ChatClientAgentOptions
{
Name = "Test Agent",
Description = "Test description",
Instructions = "Test instructions"
};
// Act
var agent = assistantClient.CreateAIAgent(
ModelId,
options,
clientFactory: (innerClient) => testChatClient);
// Assert
Assert.NotNull(agent);
Assert.Equal("Test Agent", agent.Name);
Assert.Equal("Test description", agent.Description);
// Verify that the custom chat client can be retrieved from the agent's service collection
var retrievedTestClient = agent.GetService<TestChatClient>();
Assert.NotNull(retrievedTestClient);
Assert.Same(testChatClient, retrievedTestClient);
}
/// <summary>
/// Verify that CreateAIAgent without clientFactory works normally.
/// </summary>
[Fact]
public void CreateAIAgent_WithoutClientFactory_WorksNormally()
{
// Arrange
var assistantClient = new TestAssistantClient();
const string ModelId = "test-model";
// Act
var agent = assistantClient.CreateAIAgent(
ModelId,
instructions: "Test instructions",
name: "Test Agent");
// Assert
Assert.NotNull(agent);
Assert.Equal("Test Agent", agent.Name);
// Verify that no TestChatClient is available since no factory was provided
var retrievedTestClient = agent.GetService<TestChatClient>();
Assert.Null(retrievedTestClient);
}
/// <summary>
/// Verify that CreateAIAgent with null clientFactory works normally.
/// </summary>
[Fact]
public void CreateAIAgent_WithNullClientFactory_WorksNormally()
{
// Arrange
var assistantClient = new TestAssistantClient();
const string ModelId = "test-model";
// Act
var agent = assistantClient.CreateAIAgent(
ModelId,
instructions: "Test instructions",
name: "Test Agent",
clientFactory: null);
// Assert
Assert.NotNull(agent);
Assert.Equal("Test Agent", agent.Name);
// Verify that no TestChatClient is available since no factory was provided
var retrievedTestClient = agent.GetService<TestChatClient>();
Assert.Null(retrievedTestClient);
}
/// <summary>
/// Verify that CreateAIAgent throws ArgumentNullException when client is null.
/// </summary>
[Fact]
public void CreateAIAgent_WithNullClient_ThrowsArgumentNullException()
{
// Act & Assert
var exception = Assert.Throws<ArgumentNullException>(() =>
((AssistantClient)null!).CreateAIAgent("test-model"));
Assert.Equal("client", exception.ParamName);
}
/// <summary>
/// Verify that CreateAIAgent throws ArgumentNullException when model is null.
/// </summary>
[Fact]
public void CreateAIAgent_WithNullModel_ThrowsArgumentNullException()
{
// Arrange
var assistantClient = new TestAssistantClient();
// Act & Assert
var exception = Assert.Throws<ArgumentNullException>(() =>
assistantClient.CreateAIAgent(null!));
Assert.Equal("model", exception.ParamName);
}
/// <summary>
/// Verify that CreateAIAgent with options throws ArgumentNullException when options is null.
/// </summary>
[Fact]
public void CreateAIAgent_WithNullOptions_ThrowsArgumentNullException()
{
// Arrange
var assistantClient = new TestAssistantClient();
// Act & Assert
var exception = Assert.Throws<ArgumentNullException>(() =>
assistantClient.CreateAIAgent("test-model", (ChatClientAgentOptions)null!));
Assert.Equal("options", exception.ParamName);
}
/// <summary>
/// Creates a test AssistantClient implementation for testing.
/// </summary>
private sealed class TestAssistantClient : AssistantClient
{
public TestAssistantClient()
{
}
public override ClientResult<Assistant> CreateAssistant(string model, AssistantCreationOptions? options = null, CancellationToken cancellationToken = default)
{
return ClientResult.FromValue(ModelReaderWriter.Read<Assistant>(BinaryData.FromString("""{"id": "asst_abc123"}""")), new FakePipelineResponse())!;
}
}
private sealed class TestChatClient : DelegatingChatClient
{
public TestChatClient(IChatClient innerClient) : base(innerClient)
{
}
}
private sealed class FakePipelineResponse : PipelineResponse
{
public override int Status => throw new NotImplementedException();
public override string ReasonPhrase => throw new NotImplementedException();
public override Stream? ContentStream { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
public override BinaryData Content => throw new NotImplementedException();
protected override PipelineResponseHeaders HeadersCore => throw new NotImplementedException();
public override BinaryData BufferContent(CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
public override ValueTask<BinaryData> BufferContentAsync(CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
public override void Dispose()
{
throw new NotImplementedException();
}
}
}
@@ -0,0 +1,227 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using OpenAI;
using OpenAIChatClient = OpenAI.Chat.ChatClient;
namespace Microsoft.Agents.AI.OpenAI.UnitTests.Extensions;
/// <summary>
/// Unit tests for the <see cref="OpenAIChatClientExtensions"/> class.
/// </summary>
public sealed class OpenAIChatClientExtensionsTests
{
/// <summary>
/// Test custom chat client that can be used to verify clientFactory functionality.
/// </summary>
private sealed class TestChatClient : IChatClient
{
private readonly IChatClient _innerClient;
public TestChatClient(IChatClient innerClient)
{
this._innerClient = innerClient;
}
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
=> this._innerClient.GetResponseAsync(messages, options, cancellationToken);
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await foreach (var update in this._innerClient.GetStreamingResponseAsync(messages, options, cancellationToken))
{
yield return update;
}
}
public object? GetService(Type serviceType, object? serviceKey = null)
{
// Return this instance when requested
if (serviceType == typeof(TestChatClient))
{
return this;
}
return this._innerClient.GetService(serviceType, serviceKey);
}
public void Dispose() => this._innerClient.Dispose();
}
/// <summary>
/// Creates a test ChatClient implementation for testing.
/// </summary>
private sealed class TestOpenAIChatClient : OpenAIChatClient
{
public TestOpenAIChatClient()
{
}
}
/// <summary>
/// Verify that CreateAIAgent with clientFactory parameter correctly applies the factory.
/// </summary>
[Fact]
public void CreateAIAgent_WithClientFactory_AppliesFactoryCorrectly()
{
// Arrange
var chatClient = new TestOpenAIChatClient();
var testChatClient = new TestChatClient(chatClient.AsIChatClient());
// Act
var agent = chatClient.CreateAIAgent(
instructions: "Test instructions",
name: "Test Agent",
description: "Test description",
clientFactory: (innerClient) => testChatClient);
// Assert
Assert.NotNull(agent);
Assert.Equal("Test Agent", agent.Name);
Assert.Equal("Test description", agent.Description);
// Verify that the custom chat client can be retrieved from the agent's service collection
var retrievedTestClient = agent.GetService<TestChatClient>();
Assert.NotNull(retrievedTestClient);
Assert.Same(testChatClient, retrievedTestClient);
}
/// <summary>
/// Verify that CreateAIAgent with clientFactory using AsBuilder pattern works correctly.
/// </summary>
[Fact]
public void CreateAIAgent_WithClientFactoryUsingAsBuilder_AppliesFactoryCorrectly()
{
// Arrange
var chatClient = new TestOpenAIChatClient();
TestChatClient? testChatClient = null;
// Act
var agent = chatClient.CreateAIAgent(
instructions: "Test instructions",
clientFactory: (innerClient) =>
innerClient.AsBuilder().Use((innerClient) => testChatClient = new TestChatClient(innerClient)).Build());
// Assert
Assert.NotNull(agent);
// Verify that the custom chat client can be retrieved from the agent's service collection
var retrievedTestClient = agent.GetService<TestChatClient>();
Assert.NotNull(retrievedTestClient);
Assert.Same(testChatClient, retrievedTestClient);
}
/// <summary>
/// Verify that CreateAIAgent with options and clientFactory parameter correctly applies the factory.
/// </summary>
[Fact]
public void CreateAIAgent_WithOptionsAndClientFactory_AppliesFactoryCorrectly()
{
// Arrange
var chatClient = new TestOpenAIChatClient();
var testChatClient = new TestChatClient(chatClient.AsIChatClient());
var options = new ChatClientAgentOptions
{
Name = "Test Agent",
Description = "Test description",
Instructions = "Test instructions"
};
// Act
var agent = chatClient.CreateAIAgent(
options,
clientFactory: (innerClient) => testChatClient);
// Assert
Assert.NotNull(agent);
Assert.Equal("Test Agent", agent.Name);
Assert.Equal("Test description", agent.Description);
// Verify that the custom chat client can be retrieved from the agent's service collection
var retrievedTestClient = agent.GetService<TestChatClient>();
Assert.NotNull(retrievedTestClient);
Assert.Same(testChatClient, retrievedTestClient);
}
/// <summary>
/// Verify that CreateAIAgent without clientFactory works normally.
/// </summary>
[Fact]
public void CreateAIAgent_WithoutClientFactory_WorksNormally()
{
// Arrange
var chatClient = new TestOpenAIChatClient();
// Act
var agent = chatClient.CreateAIAgent(
instructions: "Test instructions",
name: "Test Agent");
// Assert
Assert.NotNull(agent);
Assert.Equal("Test Agent", agent.Name);
// Verify that no TestChatClient is available since no factory was provided
var retrievedTestClient = agent.GetService<TestChatClient>();
Assert.Null(retrievedTestClient);
}
/// <summary>
/// Verify that CreateAIAgent with null clientFactory works normally.
/// </summary>
[Fact]
public void CreateAIAgent_WithNullClientFactory_WorksNormally()
{
// Arrange
var chatClient = new TestOpenAIChatClient();
// Act
var agent = chatClient.CreateAIAgent(
instructions: "Test instructions",
name: "Test Agent",
clientFactory: null);
// Assert
Assert.NotNull(agent);
Assert.Equal("Test Agent", agent.Name);
// Verify that no TestChatClient is available since no factory was provided
var retrievedTestClient = agent.GetService<TestChatClient>();
Assert.Null(retrievedTestClient);
}
/// <summary>
/// Verify that CreateAIAgent throws ArgumentNullException when client is null.
/// </summary>
[Fact]
public void CreateAIAgent_WithNullClient_ThrowsArgumentNullException()
{
// Act & Assert
var exception = Assert.Throws<ArgumentNullException>(() =>
((OpenAIChatClient)null!).CreateAIAgent());
Assert.Equal("client", exception.ParamName);
}
/// <summary>
/// Verify that CreateAIAgent with options throws ArgumentNullException when options is null.
/// </summary>
[Fact]
public void CreateAIAgent_WithNullOptions_ThrowsArgumentNullException()
{
// Arrange
var chatClient = new TestOpenAIChatClient();
// Act & Assert
var exception = Assert.Throws<ArgumentNullException>(() =>
chatClient.CreateAIAgent((ChatClientAgentOptions)null!));
Assert.Equal("options", exception.ParamName);
}
}
@@ -0,0 +1,172 @@
// Copyright (c) Microsoft. All rights reserved.
#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using OpenAI;
using OpenAI.Responses;
namespace Microsoft.Agents.AI.OpenAI.UnitTests.Extensions;
/// <summary>
/// Unit tests for the <see cref="OpenAIResponseClientExtensions"/> class.
/// </summary>
public sealed class OpenAIResponseClientExtensionsTests
{
/// <summary>
/// Test custom chat client that can be used to verify clientFactory functionality.
/// </summary>
private sealed class TestChatClient : IChatClient
{
private readonly IChatClient _innerClient;
public TestChatClient(IChatClient innerClient)
{
this._innerClient = innerClient;
}
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
=> this._innerClient.GetResponseAsync(messages, options, cancellationToken);
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await foreach (var update in this._innerClient.GetStreamingResponseAsync(messages, options, cancellationToken))
{
yield return update;
}
}
public object? GetService(Type serviceType, object? serviceKey = null)
{
// Return this instance when requested
if (serviceType == typeof(TestChatClient))
{
return this;
}
return this._innerClient.GetService(serviceType, serviceKey);
}
public void Dispose() => this._innerClient.Dispose();
}
/// <summary>
/// Creates a test OpenAIResponseClient implementation for testing.
/// </summary>
private sealed class TestOpenAIResponseClient : OpenAIResponseClient
{
public TestOpenAIResponseClient()
{
}
}
/// <summary>
/// Verify that CreateAIAgent with clientFactory parameter correctly applies the factory.
/// </summary>
[Fact]
public void CreateAIAgent_WithClientFactory_AppliesFactoryCorrectly()
{
// Arrange
var responseClient = new TestOpenAIResponseClient();
var testChatClient = new TestChatClient(responseClient.AsIChatClient());
// Act
var agent = responseClient.CreateAIAgent(
instructions: "Test instructions",
name: "Test Agent",
description: "Test description",
clientFactory: (innerClient) => testChatClient);
// Assert
Assert.NotNull(agent);
Assert.Equal("Test Agent", agent.Name);
Assert.Equal("Test description", agent.Description);
// Verify that the custom chat client can be retrieved from the agent's service collection
var retrievedTestClient = agent.GetService<TestChatClient>();
Assert.NotNull(retrievedTestClient);
Assert.Same(testChatClient, retrievedTestClient);
}
/// <summary>
/// Verify that CreateAIAgent without clientFactory works normally.
/// </summary>
[Fact]
public void CreateAIAgent_WithoutClientFactory_WorksNormally()
{
// Arrange
var responseClient = new TestOpenAIResponseClient();
// Act
var agent = responseClient.CreateAIAgent(
instructions: "Test instructions",
name: "Test Agent");
// Assert
Assert.NotNull(agent);
Assert.Equal("Test Agent", agent.Name);
// Verify that no TestChatClient is available since no factory was provided
var retrievedTestClient = agent.GetService<TestChatClient>();
Assert.Null(retrievedTestClient);
}
/// <summary>
/// Verify that CreateAIAgent with null clientFactory works normally.
/// </summary>
[Fact]
public void CreateAIAgent_WithNullClientFactory_WorksNormally()
{
// Arrange
var responseClient = new TestOpenAIResponseClient();
// Act
var agent = responseClient.CreateAIAgent(
instructions: "Test instructions",
name: "Test Agent",
clientFactory: null);
// Assert
Assert.NotNull(agent);
Assert.Equal("Test Agent", agent.Name);
// Verify that no TestChatClient is available since no factory was provided
var retrievedTestClient = agent.GetService<TestChatClient>();
Assert.Null(retrievedTestClient);
}
/// <summary>
/// Verify that CreateAIAgent throws ArgumentNullException when client is null.
/// </summary>
[Fact]
public void CreateAIAgent_WithNullClient_ThrowsArgumentNullException()
{
// Act & Assert
var exception = Assert.Throws<ArgumentNullException>(() =>
((OpenAIResponseClient)null!).CreateAIAgent());
Assert.Equal("client", exception.ParamName);
}
/// <summary>
/// Verify that CreateAIAgent with options throws ArgumentNullException when options is null.
/// </summary>
[Fact]
public void CreateAIAgent_WithNullOptions_ThrowsArgumentNullException()
{
// Arrange
var responseClient = new TestOpenAIResponseClient();
// Act & Assert
var exception = Assert.Throws<ArgumentNullException>(() =>
responseClient.CreateAIAgent((ChatClientAgentOptions)null!));
Assert.Equal("options", exception.ParamName);
}
}
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,437 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="AIAgentBuilder"/> class.
/// </summary>
public class AIAgentBuilderTests
{
/// <summary>
/// Verify that constructor throws ArgumentNullException when innerAgent is null.
/// </summary>
[Fact]
public void Constructor_WithNullInnerAgent_ThrowsArgumentNullException()
{
// Act & Assert
Assert.Throws<ArgumentNullException>("innerAgent", () => new AIAgentBuilder((AIAgent)null!));
}
/// <summary>
/// Verify that constructor throws ArgumentNullException when innerAgentFactory is null.
/// </summary>
[Fact]
public void Constructor_WithNullInnerAgentFactory_ThrowsArgumentNullException()
{
// Act & Assert
Assert.Throws<ArgumentNullException>("innerAgentFactory", () => new AIAgentBuilder((Func<IServiceProvider, AIAgent>)null!));
}
/// <summary>
/// Verify that Build returns the inner agent when no middleware is added.
/// </summary>
[Fact]
public void Build_WithNoMiddleware_ReturnsInnerAgent()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act
var result = builder.Build();
// Assert
Assert.Same(mockAgent.Object, result);
}
/// <summary>
/// Verify that Build works with factory function.
/// </summary>
[Fact]
public void Build_WithFactory_ReturnsAgentFromFactory()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(_ => mockAgent.Object);
// Act
var result = builder.Build();
// Assert
Assert.Same(mockAgent.Object, result);
}
/// <summary>
/// Verify that Use with simple factory works correctly.
/// </summary>
[Fact]
public void Use_WithSimpleFactory_AppliesMiddleware()
{
// Arrange
var mockInnerAgent = new Mock<AIAgent>();
var mockOuterAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockInnerAgent.Object);
// Act
var result = builder.Use(innerAgent =>
{
Assert.Same(mockInnerAgent.Object, innerAgent);
return mockOuterAgent.Object;
}).Build();
// Assert
Assert.Same(mockOuterAgent.Object, result);
}
/// <summary>
/// Verify that Use with service provider factory works correctly.
/// </summary>
[Fact]
public void Use_WithServiceProviderFactory_AppliesMiddleware()
{
// Arrange
var mockInnerAgent = new Mock<AIAgent>();
var mockOuterAgent = new Mock<AIAgent>();
var mockServiceProvider = new Mock<IServiceProvider>();
var builder = new AIAgentBuilder(mockInnerAgent.Object);
// Act
var result = builder.Use((innerAgent, services) =>
{
Assert.Same(mockInnerAgent.Object, innerAgent);
Assert.NotNull(services);
return mockOuterAgent.Object;
}).Build(mockServiceProvider.Object);
// Assert
Assert.Same(mockOuterAgent.Object, result);
}
/// <summary>
/// Verify that multiple middleware are applied in correct order (first added is outermost).
/// </summary>
[Fact]
public void Use_WithMultipleMiddleware_AppliesInCorrectOrder()
{
// Arrange
var mockInnerAgent = new Mock<AIAgent>();
var mockMiddleAgent = new Mock<AIAgent>();
var mockOuterAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockInnerAgent.Object);
// Act
var result = builder
.Use(innerAgent =>
{
// First middleware added (will be outermost) - should receive result of second middleware
Assert.Same(mockMiddleAgent.Object, innerAgent);
return mockOuterAgent.Object;
})
.Use(innerAgent =>
{
// Second middleware added (will be applied first) - should receive the original inner agent
Assert.Same(mockInnerAgent.Object, innerAgent);
return mockMiddleAgent.Object;
})
.Build();
// Assert
// The result should be from the first middleware since it's the outermost
Assert.Same(mockOuterAgent.Object, result);
}
/// <summary>
/// Verify that Use throws ArgumentNullException when agentFactory is null.
/// </summary>
[Fact]
public void Use_WithNullSimpleFactory_ThrowsArgumentNullException()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>("agentFactory", () => builder.Use((Func<AIAgent, AIAgent>)null!));
}
/// <summary>
/// Verify that Use throws ArgumentNullException when agentFactory with service provider is null.
/// </summary>
[Fact]
public void Use_WithNullServiceProviderFactory_ThrowsArgumentNullException()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>("agentFactory", () => builder.Use((Func<AIAgent, IServiceProvider, AIAgent>)null!));
}
/// <summary>
/// Verify that Build throws InvalidOperationException when middleware returns null.
/// </summary>
[Fact]
public void Build_WithMiddlewareReturningNull_ThrowsInvalidOperationException()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(() =>
builder.Use(_ => null!).Build());
Assert.Contains("returned null", exception.Message);
Assert.Contains("AIAgentBuilder", exception.Message);
}
/// <summary>
/// Verify that Build uses EmptyServiceProvider when services is null.
/// </summary>
[Fact]
public void Build_WithNullServices_UsesEmptyServiceProvider()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
IServiceProvider? capturedServices = null;
// Act
builder.Use((agent, services) =>
{
capturedServices = services;
return agent;
}).Build(null);
// Assert
Assert.NotNull(capturedServices);
Assert.Null(capturedServices.GetService(typeof(string))); // EmptyServiceProvider returns null for everything
}
/// <summary>
/// Verify that service provider is passed correctly to factories.
/// </summary>
[Fact]
public void PassesServiceProviderToFactories()
{
// Arrange
var expectedServiceProvider = new ServiceCollection().BuildServiceProvider();
var mockInnerAgent = new Mock<AIAgent>();
var mockOuterAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(services =>
{
Assert.Same(expectedServiceProvider, services);
return mockInnerAgent.Object;
});
builder.Use((innerAgent, serviceProvider) =>
{
Assert.Same(expectedServiceProvider, serviceProvider);
Assert.Same(mockInnerAgent.Object, innerAgent);
return mockOuterAgent.Object;
});
// Act
var result = builder.Build(expectedServiceProvider);
// Assert
Assert.Same(mockOuterAgent.Object, result);
}
/// <summary>
/// Verify that pipeline is built in the order added (first added is outermost).
/// </summary>
[Fact]
public void BuildsPipelineInOrderAdded()
{
// Arrange
var mockInnerAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockInnerAgent.Object);
builder.Use(next => new InnerAgentCapturingAgent("First", next));
builder.Use(next => new InnerAgentCapturingAgent("Second", next));
builder.Use(next => new InnerAgentCapturingAgent("Third", next));
// Act
var first = (InnerAgentCapturingAgent)builder.Build();
// Assert
Assert.Equal("First", first.TestName);
var second = (InnerAgentCapturingAgent)first.InnerAgent;
Assert.Equal("Second", second.TestName);
var third = (InnerAgentCapturingAgent)second.InnerAgent;
Assert.Equal("Third", third.TestName);
Assert.Same(mockInnerAgent.Object, third.InnerAgent);
}
/// <summary>
/// Verify that factories cannot return null.
/// </summary>
[Fact]
public void DoesNotAllowFactoriesToReturnNull()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
builder.Use(_ => null!);
// Act & Assert
var ex = Assert.Throws<InvalidOperationException>(() => builder.Build());
Assert.Contains("entry at index 0", ex.Message);
}
/// <summary>
/// Verify that EmptyServiceProvider is used when no services are provided and supports keyed services.
/// </summary>
[Fact]
public void UsesEmptyServiceProviderWhenNoServicesProvided()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act & Assert
builder.Use((innerAgent, serviceProvider) =>
{
Assert.Null(serviceProvider.GetService(typeof(object)));
var keyedServiceProvider = Assert.IsAssignableFrom<IKeyedServiceProvider>(serviceProvider);
Assert.Null(keyedServiceProvider.GetKeyedService(typeof(object), "key"));
Assert.Throws<InvalidOperationException>(() => keyedServiceProvider.GetRequiredKeyedService(typeof(object), "key"));
return innerAgent;
});
builder.Build();
}
#region Delegate Overload Tests
/// <summary>
/// Verify that Use with shared delegate throws ArgumentNullException when sharedFunc is null.
/// </summary>
[Fact]
public void Use_WithNullSharedFunc_ThrowsArgumentNullException()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>("sharedFunc", () =>
builder.Use((Func<IEnumerable<ChatMessage>, AgentThread?, AgentRunOptions?, Func<IEnumerable<ChatMessage>, AgentThread?, AgentRunOptions?, CancellationToken, Task>, CancellationToken, Task>)null!));
}
/// <summary>
/// Verify that Use with both delegates null throws ArgumentNullException.
/// </summary>
[Fact]
public void Use_WithBothDelegatesNull_ThrowsArgumentNullException()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act & Assert
var exception = Assert.Throws<ArgumentNullException>(() =>
builder.Use(null, null));
Assert.Contains("runFunc", exception.Message);
}
/// <summary>
/// Verify that Use with shared delegate creates AnonymousDelegatingAIAgent.
/// </summary>
[Fact]
public void Use_WithSharedDelegate_CreatesAnonymousDelegatingAgent()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act
var result = builder.Use((_, _, _, _, _) => Task.CompletedTask).Build();
// Assert
Assert.IsType<AnonymousDelegatingAIAgent>(result);
}
/// <summary>
/// Verify that Use with runFunc only creates AnonymousDelegatingAIAgent.
/// </summary>
[Fact]
public void Use_WithRunFuncOnly_CreatesAnonymousDelegatingAgent()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act
var result = builder.Use((_, _, _, _, _) => Task.FromResult(new AgentRunResponse()), null).Build();
// Assert
Assert.IsType<AnonymousDelegatingAIAgent>(result);
}
/// <summary>
/// Verify that Use with runStreamingFunc only creates AnonymousDelegatingAIAgent.
/// </summary>
[Fact]
public void Use_WithStreamingFuncOnly_CreatesAnonymousDelegatingAgent()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act
var result = builder.Use(null, (_, _, _, _, _) => AsyncEnumerable.Empty<AgentRunResponseUpdate>()).Build();
// Assert
Assert.IsType<AnonymousDelegatingAIAgent>(result);
}
/// <summary>
/// Verify that Use with both delegates creates AnonymousDelegatingAIAgent.
/// </summary>
[Fact]
public void Use_WithBothDelegates_CreatesAnonymousDelegatingAgent()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act
var result = builder.Use(
(_, _, _, _, _) => Task.FromResult(new AgentRunResponse()),
(_, _, _, _, _) => AsyncEnumerable.Empty<AgentRunResponseUpdate>()).Build();
// Assert
Assert.IsType<AnonymousDelegatingAIAgent>(result);
}
#endregion
/// <summary>
/// Helper class for testing pipeline order.
/// </summary>
private sealed class InnerAgentCapturingAgent : DelegatingAIAgent
{
public string TestName { get; }
public new AIAgent InnerAgent => base.InnerAgent;
public InnerAgentCapturingAgent(string name, AIAgent innerAgent) : base(innerAgent)
{
this.TestName = name;
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,12 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
@@ -38,4 +44,292 @@ public class ChatClientAgentRunOptionsTests
Assert.Same(chatOptions, retrievedOptions);
Assert.Equal(200, retrievedOptions.MaxOutputTokens); // Ensure the change is reflected
}
#region ChatClientFactory Tests
/// <summary>
/// Tests that ChatClientFactory is called and transforms the client for RunAsync.
/// </summary>
[Fact]
public async Task RunAsync_WithChatClientFactory_UsesTransformedClientAsync()
{
// Arrange
var originalClient = new Mock<IChatClient>();
var transformedClient = new Mock<IChatClient>();
var factoryCallCount = 0;
// Setup the original client to throw if called (should not be used)
originalClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Throws(new InvalidOperationException("Original client should not be called"));
// Setup the transformed client to return a response
transformedClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Transformed response")]));
// Create the factory that transforms the client
IChatClient ClientFactory(IChatClient client)
{
factoryCallCount++;
Assert.Same(originalClient.Object, client); // Verify original client is passed
return transformedClient.Object;
}
var agent = new ChatClientAgent(originalClient.Object, new ChatClientAgentOptions() { UseProvidedChatClientAsIs = true });
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
var options = new ChatClientAgentRunOptions { ChatClientFactory = ClientFactory };
// Act
var response = await agent.RunAsync(messages, null, options, CancellationToken.None);
// Assert
Assert.NotNull(response);
Assert.Equal(1, factoryCallCount); // Factory should be called exactly once
transformedClient.Verify(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()), Times.Once);
originalClient.Verify(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()), Times.Never);
}
/// <summary>
/// Tests that ChatClientFactory is called and transforms the client for RunStreamingAsync.
/// </summary>
[Fact]
public async Task RunStreamingAsync_WithChatClientFactory_UsesTransformedClientAsync()
{
// Arrange
var originalClient = new Mock<IChatClient>();
var transformedClient = new Mock<IChatClient>();
var factoryCallCount = 0;
// Setup the original client to throw if called (should not be used)
originalClient.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Throws(new InvalidOperationException("Original client should not be called"));
// Setup the transformed client to return streaming responses
var streamingResponses = new[]
{
new ChatResponseUpdate { Contents = [new TextContent("Streaming ")] },
new ChatResponseUpdate { Contents = [new TextContent("response")] }
};
transformedClient.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns(streamingResponses.ToAsyncEnumerable());
// Create the factory that transforms the client
IChatClient ClientFactory(IChatClient client)
{
factoryCallCount++;
Assert.Same(originalClient.Object, client); // Verify original client is passed
return transformedClient.Object;
}
var agent = new ChatClientAgent(originalClient.Object, new ChatClientAgentOptions() { UseProvidedChatClientAsIs = true });
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
var options = new ChatClientAgentRunOptions { ChatClientFactory = ClientFactory };
// Act
var responseUpdates = new List<AgentRunResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync(messages, null, options, CancellationToken.None))
{
responseUpdates.Add(update);
}
// Assert
Assert.NotEmpty(responseUpdates);
Assert.Equal(1, factoryCallCount); // Factory should be called exactly once
transformedClient.Verify(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()), Times.Once);
originalClient.Verify(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()), Times.Never);
}
/// <summary>
/// Tests that without ChatClientFactory, the original client is used for RunAsync.
/// </summary>
[Fact]
public async Task RunAsync_WithoutChatClientFactory_UsesOriginalClientAsync()
{
// Arrange
var originalClient = new Mock<IChatClient>();
originalClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Original response")]));
var agent = new ChatClientAgent(originalClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
// Act - No ChatClientFactory provided
var response = await agent.RunAsync(messages, null, null, CancellationToken.None);
// Assert
Assert.NotNull(response);
originalClient.Verify(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()), Times.Once);
}
/// <summary>
/// Tests that without ChatClientFactory, the original client is used for RunStreamingAsync.
/// </summary>
[Fact]
public async Task RunStreamingAsync_WithoutChatClientFactory_UsesOriginalClientAsync()
{
// Arrange
var originalClient = new Mock<IChatClient>();
var streamingResponses = new[]
{
new ChatResponseUpdate { Contents = [new TextContent("Original ")] },
new ChatResponseUpdate { Contents = [new TextContent("streaming")] }
};
originalClient.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns(streamingResponses.ToAsyncEnumerable());
var agent = new ChatClientAgent(originalClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
// Act - No ChatClientFactory provided
var responseUpdates = new List<AgentRunResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync(messages, null, null, CancellationToken.None))
{
responseUpdates.Add(update);
}
// Assert
Assert.NotEmpty(responseUpdates);
originalClient.Verify(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()), Times.Once);
}
/// <summary>
/// Tests that ChatClientFactory is called for each separate RunAsync call.
/// </summary>
[Fact]
public async Task RunAsync_MultipleCalls_ChatClientFactoryCalledEachTimeAsync()
{
// Arrange
var originalClient = new Mock<IChatClient>();
var transformedClient = new Mock<IChatClient>();
var factoryCallCount = 0;
transformedClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Response")]));
IChatClient ClientFactory(IChatClient client)
{
factoryCallCount++;
return transformedClient.Object;
}
var agent = new ChatClientAgent(originalClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
var options = new ChatClientAgentRunOptions { ChatClientFactory = ClientFactory };
// Act - Call RunAsync multiple times
await agent.RunAsync(messages, null, options, CancellationToken.None);
await agent.RunAsync(messages, null, options, CancellationToken.None);
// Assert
Assert.Equal(2, factoryCallCount); // Factory should be called for each run
transformedClient.Verify(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()), Times.Exactly(2));
}
/// <summary>
/// Tests that subsequent calls without ChatClientFactory use the original client.
/// </summary>
[Fact]
public async Task RunAsync_AfterFactoryCall_WithoutFactory_UsesOriginalClientAsync()
{
// Arrange
var originalClient = new Mock<IChatClient>();
var transformedClient = new Mock<IChatClient>();
originalClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Original response")]));
transformedClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Transformed response")]));
IChatClient ClientFactory(IChatClient client) => transformedClient.Object;
var agent = new ChatClientAgent(originalClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
var optionsWithFactory = new ChatClientAgentRunOptions { ChatClientFactory = ClientFactory };
// Act - First call with factory, second call without
await agent.RunAsync(messages, null, optionsWithFactory, CancellationToken.None);
await agent.RunAsync(messages, null, null, CancellationToken.None);
// Assert
transformedClient.Verify(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()), Times.Once);
originalClient.Verify(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()), Times.Once);
}
/// <summary>
/// Tests that ChatClientFactory returning null throws an exception.
/// </summary>
[Fact]
public async Task RunAsync_ChatClientFactoryReturnsNull_ThrowsExceptionAsync()
{
// Arrange
var originalClient = new Mock<IChatClient>();
IChatClient ClientFactory(IChatClient client) => null!;
var agent = new ChatClientAgent(originalClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
var options = new ChatClientAgentRunOptions { ChatClientFactory = ClientFactory };
// Act & Assert
await Assert.ThrowsAsync<ArgumentNullException>(async () =>
await agent.RunAsync(messages, null, options, CancellationToken.None));
}
#endregion
}
@@ -38,7 +38,7 @@ public class ChatClientAgentTests
Assert.Equal("test description", agent.Description);
Assert.Equal("test instructions", agent.Instructions);
Assert.NotNull(agent.ChatClient);
Assert.Equal("AgentInvokedChatClient", agent.ChatClient.GetType().Name);
Assert.Equal("FunctionInvokingChatClient", agent.ChatClient.GetType().Name);
}
#endregion
@@ -1321,14 +1321,36 @@ public class ChatClientAgentTests
});
// Act
var result = agent.GetService(typeof(IChatClient));
var result = agent.GetService<IChatClient>();
// Assert
Assert.NotNull(result);
Assert.IsType<IChatClient>(result, exactMatch: false);
// Note: The result will be the AgentInvokedChatClient wrapper, not the original mock
Assert.Equal("AgentInvokedChatClient", result.GetType().Name);
Assert.Equal("FunctionInvokingChatClient", result.GetType().Name);
}
/// <summary>
/// Verify that GetService returns IChatClient when requested.
/// </summary>
[Fact]
public void GetService_RequestingChatClientAgent_ReturnsChatClientAgent()
{
// Arrange
var mockChatClient = new Mock<IChatClient>();
var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions
{
Instructions = "Test instructions"
});
// Act
var result = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(result);
Assert.Same(result, agent);
}
/// <summary>
@@ -0,0 +1,851 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for FunctionCallMiddlewareAgent functionality.
/// </summary>
public sealed class FunctionInvocationDelegatingAgentTests
{
#region Basic Functionality Tests
/// <summary>
/// Tests that FunctionCallMiddlewareAgent can be created with valid parameters.
/// </summary>
[Fact]
public void Constructor_ValidParameters_CreatesInstance()
{
// Arrange
var mockChatClient = new Mock<IChatClient>();
var innerAgent = new ChatClientAgent(mockChatClient.Object);
static ValueTask<object?> CallbackAsync(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
=> next(context, cancellationToken);
// Act
var middleware = new FunctionInvocationDelegatingAgent(innerAgent, CallbackAsync);
// Assert
Assert.NotNull(middleware);
Assert.Equal(innerAgent.Id, middleware.Id);
Assert.Equal(innerAgent.Name, middleware.Name);
Assert.Equal(innerAgent.Description, middleware.Description);
}
/// <summary>
/// Tests that constructor throws ArgumentNullException for null inner agent.
/// </summary>
[Fact]
public void Constructor_NullInnerAgent_ThrowsArgumentNullException()
{
// Arrange
static ValueTask<object?> CallbackAsync(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
=> next(context, cancellationToken);
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new FunctionInvocationDelegatingAgent(null!, CallbackAsync));
}
#endregion
#region Function Invocation Tests
/// <summary>
/// Tests that middleware is invoked when functions are called during agent execution.
/// </summary>
[Fact]
public async Task RunAsync_WithFunctionCall_InvokesMiddlewareAsync()
{
// Arrange
var executionOrder = new List<string>();
var testFunction = AIFunctionFactory.Create(() =>
{
executionOrder.Add("Function-Executed");
return "Function result";
}, "TestFunction", "A test function");
var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary<string, object?>());
var mockChatClient = CreateMockChatClientWithFunctionCalls(functionCall);
var innerAgent = new ChatClientAgent(mockChatClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
async ValueTask<object?> MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
{
executionOrder.Add("Middleware-Pre");
var result = await next(context, cancellationToken);
executionOrder.Add("Middleware-Post");
return result;
}
var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync);
// Act
var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [testFunction] });
await middleware.RunAsync(messages, null, options, CancellationToken.None);
// Assert
Assert.Contains("Middleware-Pre", executionOrder);
Assert.Contains("Function-Executed", executionOrder);
Assert.Contains("Middleware-Post", executionOrder);
// Verify execution order
var middlewarePreIndex = executionOrder.IndexOf("Middleware-Pre");
var functionIndex = executionOrder.IndexOf("Function-Executed");
var middlewarePostIndex = executionOrder.IndexOf("Middleware-Post");
Assert.True(middlewarePreIndex < functionIndex);
Assert.True(functionIndex < middlewarePostIndex);
}
/// <summary>
/// Tests that multiple function calls trigger middleware for each invocation.
/// </summary>
[Fact]
public async Task RunAsync_WithMultipleFunctionCalls_InvokesMiddlewareForEachAsync()
{
// Arrange
var executionOrder = new List<string>();
var function1 = AIFunctionFactory.Create(() =>
{
executionOrder.Add("Function1-Executed");
return "Function1 result";
}, "Function1", "First test function");
var function2 = AIFunctionFactory.Create(() =>
{
executionOrder.Add("Function2-Executed");
return "Function2 result";
}, "Function2", "Second test function");
var functionCall1 = new FunctionCallContent("call_1", "Function1", new Dictionary<string, object?>());
var functionCall2 = new FunctionCallContent("call_2", "Function2", new Dictionary<string, object?>());
var mockChatClient = CreateMockChatClientWithFunctionCalls(functionCall1, functionCall2);
var innerAgent = new ChatClientAgent(mockChatClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
async ValueTask<object?> MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
{
executionOrder.Add($"Middleware-Pre-{context.Function.Name}");
var result = await next(context, cancellationToken);
executionOrder.Add($"Middleware-Post-{context.Function.Name}");
return result;
}
var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync);
// Act
var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [function1, function2] });
await middleware.RunAsync(messages, null, options, CancellationToken.None);
// Assert
Assert.Contains("Middleware-Pre-Function1", executionOrder);
Assert.Contains("Function1-Executed", executionOrder);
Assert.Contains("Middleware-Post-Function1", executionOrder);
Assert.Contains("Middleware-Pre-Function2", executionOrder);
Assert.Contains("Function2-Executed", executionOrder);
Assert.Contains("Middleware-Post-Function2", executionOrder);
}
#endregion
#region Context Validation Tests
/// <summary>
/// Tests that FunctionInvocationContext contains correct values during middleware execution.
/// </summary>
[Fact]
public async Task RunAsync_MiddlewareContext_ContainsCorrectValuesAsync()
{
// Arrange
var testFunction = AIFunctionFactory.Create(() => "Function result", "TestFunction", "A test function");
var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary<string, object?> { ["param"] = "value" });
var mockChatClient = CreateMockChatClientWithFunctionCalls(functionCall);
var innerAgent = new ChatClientAgent(mockChatClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
FunctionInvocationContext? capturedContext = null;
AIAgent? capturedAgent = null;
async ValueTask<object?> MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
{
capturedContext = context;
capturedAgent = agent;
return await next(context, cancellationToken);
}
var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync);
// Act
var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [testFunction] });
await middleware.RunAsync(messages, null, options, CancellationToken.None);
// Assert
Assert.NotNull(capturedContext);
Assert.Equal("TestFunction", capturedContext.Function.Name);
Assert.Same(innerAgent, capturedAgent); // The agent passed should be the inner agent
Assert.NotNull(capturedContext.Arguments);
// Note: Additional context properties would need to be verified based on actual FunctionInvocationContext structure
}
#endregion
#region AIAgentBuilder Use Method Tests
/// <summary>
/// Verify that AIAgentBuilder.Use method works correctly with function invocation middleware.
/// </summary>
[Fact]
public async Task AIAgentBuilder_Use_FunctionInvocationMiddleware_WorksCorrectlyAsync()
{
// Arrange
var mockChatClient = new Mock<IChatClient>();
var testFunction = AIFunctionFactory.Create(() => "test result", name: "TestFunction");
var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary<string, object?>());
var executionOrder = new List<string>();
// Mock the chat client to return a function call, then a response
mockChatClient.Setup(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, [functionCall])));
var innerAgent = new ChatClientAgent(mockChatClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
// Act
var agent = new AIAgentBuilder(innerAgent)
.Use((agent, context, next, cancellationToken) =>
{
executionOrder.Add("Middleware-Pre");
var result = next(context, cancellationToken);
executionOrder.Add("Middleware-Post");
return result;
})
.Build();
var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [testFunction] });
await agent.RunAsync(messages, null, options, CancellationToken.None);
// Assert
Assert.Contains("Middleware-Pre", executionOrder);
Assert.Contains("Middleware-Post", executionOrder);
}
/// <summary>
/// Verify that multiple function invocation middleware are executed.
/// </summary>
[Fact]
public async Task AIAgentBuilder_Use_MultipleFunctionMiddleware_BothExecuteAsync()
{
// Arrange
var mockChatClient = new Mock<IChatClient>();
var testFunction = AIFunctionFactory.Create(() => "test result", name: "TestFunction");
var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary<string, object?>());
var firstMiddlewareExecuted = false;
var secondMiddlewareExecuted = false;
// Mock the chat client to return a function call, then a response
mockChatClient.Setup(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, [functionCall])));
var innerAgent = new ChatClientAgent(mockChatClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
// Act
var agent = new AIAgentBuilder(innerAgent)
.Use((agent, context, next, cancellationToken) =>
{
firstMiddlewareExecuted = true;
return next(context, cancellationToken);
})
.Use((agent, context, next, cancellationToken) =>
{
secondMiddlewareExecuted = true;
return next(context, cancellationToken);
})
.Build();
var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [testFunction] });
await agent.RunAsync(messages, null, options, CancellationToken.None);
// Assert
Assert.True(firstMiddlewareExecuted, "First middleware should have executed");
Assert.True(secondMiddlewareExecuted, "Second middleware should have executed");
}
/// <summary>
/// Verify that AIAgentBuilder.Use method throws InvalidOperationException when inner agent is doesn't use a FunctinInvocking.
/// </summary>
[Fact]
public void AIAgentBuilder_Use_NonFICCEnabledAgent_ThrowsInvalidOperationException()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
// Act & Assert
var builder = new AIAgentBuilder(mockAgent.Object);
var exception = Assert.Throws<InvalidOperationException>(() =>
{
builder.Use((agent, context, next, cancellationToken) => next(context, cancellationToken));
builder.Build();
});
}
/// <summary>
/// Verify that AIAgentBuilder.Use method throws InvalidOperationException when inner agent is doesn't use a FunctinInvokingChatClient.
/// </summary>
[Fact]
public void AIAgentBuilder_Use_NonFICCDecoratedChatClientInAgent_ThrowsInvalidOperationException()
{
// Arrange
var mockChatClient = new Mock<IChatClient>();
var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions() { UseProvidedChatClientAsIs = true });
// Act & Assert
var builder = new AIAgentBuilder(agent);
var exception = Assert.Throws<InvalidOperationException>(() =>
{
builder.Use((agent, context, next, cancellationToken) => next(context, cancellationToken));
builder.Build();
});
}
/// <summary>
/// Tests function invocation middleware when FunctionInvokingChatClient.CurrentContext is null (direct function invocation).
/// </summary>
[Fact]
public async Task RunAsync_DirectFunctionInvocation_MiddlewareHandlesNullCurrentContextAsync()
{
// Arrange
var executionOrder = new List<string>();
var capturedContext = new List<FunctionInvocationContext>();
var testFunction = AIFunctionFactory.Create(() =>
{
executionOrder.Add("Function-Executed");
return "Function result";
}, "TestFunction", "A test function");
var mockChatClient = new Mock<IChatClient>();
// Setup mock to directly invoke the function (bypassing FunctionInvokingChatClient)
mockChatClient.Setup(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
.Returns<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>(async (messages, options, ct) =>
{
// Directly invoke the function to simulate null CurrentContext scenario
if (options?.Tools?.FirstOrDefault() is AIFunction function)
{
executionOrder.Add("Direct-Function-Invocation");
await function.InvokeAsync(new AIFunctionArguments(), ct);
}
return new ChatResponse([new ChatMessage(ChatRole.Assistant, "Response after direct invocation")]);
});
var innerAgent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions
{
UseProvidedChatClientAsIs = true
});
async ValueTask<object?> MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
{
executionOrder.Add("Middleware-Pre");
capturedContext.Add(context);
var result = await next(context, cancellationToken);
executionOrder.Add("Middleware-Post");
return result;
}
var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
// Act
var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [testFunction] });
await middleware.RunAsync(messages, null, options, CancellationToken.None);
// Assert
Assert.Contains("Direct-Function-Invocation", executionOrder);
Assert.Contains("Middleware-Pre", executionOrder);
Assert.Contains("Function-Executed", executionOrder);
Assert.Contains("Middleware-Post", executionOrder);
// Verify that the context was created with Iteration = -1 (indicating no ambient context)
Assert.Single(capturedContext);
Assert.Equal(0, capturedContext[0].Iteration);
Assert.Equal("TestFunction", capturedContext[0].Function.Name);
Assert.NotNull(capturedContext[0].Arguments);
}
#endregion
#region Error Handling Tests
/// <summary>
/// Tests that exceptions thrown by middleware during pre-invocation surface to the caller.
/// </summary>
[Fact]
public async Task RunAsync_MiddlewareThrowsPreInvocation_ExceptionSurfacesAsync()
{
// Arrange
var testFunction = AIFunctionFactory.Create(() => "Function result", "TestFunction", "A test function");
var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary<string, object?>());
var mockChatClient = CreateMockChatClientWithFunctionCalls(functionCall);
var innerAgent = new ChatClientAgent(mockChatClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
var expectedException = new InvalidOperationException("Pre-invocation error");
ValueTask<object?> MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
{
throw expectedException;
}
var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync);
// Act & Assert
var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [testFunction] });
var actualException = await Assert.ThrowsAsync<InvalidOperationException>(
() => middleware.RunAsync(messages, null, options, CancellationToken.None));
Assert.Same(expectedException, actualException);
}
/// <summary>
/// Tests that exceptions thrown by the function are handled by middleware.
/// </summary>
[Fact]
public async Task RunAsync_FunctionThrowsException_MiddlewareCanHandleAsync()
{
// Arrange
var functionException = new InvalidOperationException("Function error");
string ThrowingFunction() => throw functionException;
var testFunction = AIFunctionFactory.Create(ThrowingFunction, "TestFunction", "A test function");
var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary<string, object?>());
var mockChatClient = CreateMockChatClientWithFunctionCalls(functionCall);
var innerAgent = new ChatClientAgent(mockChatClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
var middlewareHandledException = false;
async ValueTask<object?> MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
{
try
{
return await next(context, cancellationToken);
}
catch (InvalidOperationException)
{
middlewareHandledException = true;
return "Error handled by middleware";
}
}
var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync);
// Act
var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [testFunction] });
await middleware.RunAsync(messages, null, options, CancellationToken.None);
// Assert
Assert.True(middlewareHandledException);
}
#endregion
#region Result Modification Tests
/// <summary>
/// Tests that middleware can modify function results.
/// </summary>
[Fact]
public async Task RunAsync_MiddlewareModifiesResult_ModifiedResultUsedAsync()
{
// Arrange
var testFunction = AIFunctionFactory.Create(() => "Original result", "TestFunction", "A test function");
var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary<string, object?>());
var mockChatClient = CreateMockChatClientWithFunctionCalls(functionCall);
var innerAgent = new ChatClientAgent(mockChatClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
const string ModifiedResult = "Modified by middleware";
async ValueTask<object?> MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
{
await next(context, cancellationToken);
return ModifiedResult; // Return the modified result instead of setting context property
}
var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync);
// Act
var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [testFunction] });
var response = await middleware.RunAsync(messages, null, options, CancellationToken.None);
// Assert
Assert.NotNull(response);
// The modified result should be reflected in the response messages
var functionResultContent = response.Messages
.SelectMany(m => m.Contents)
.OfType<FunctionResultContent>()
.FirstOrDefault();
Assert.NotNull(functionResultContent);
Assert.Equal(ModifiedResult, functionResultContent.Result);
}
#endregion
#region Middleware Chaining Tests
/// <summary>
/// Tests execution order with multiple function middleware instances in a chain.
/// </summary>
[Fact]
public async Task RunAsync_MultipleFunctionMiddleware_ExecutesInCorrectOrderAsync()
{
// Arrange
var executionOrder = new List<string>();
var testFunction = AIFunctionFactory.Create(() =>
{
executionOrder.Add("Function-Executed");
return "Function result";
}, "TestFunction", "A test function");
var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary<string, object?>());
var mockChatClient = new Mock<IChatClient>();
// Setup sequence: first call returns function call, subsequent calls return final response
var responseWithFunctionCall = new ChatResponse([
new ChatMessage(ChatRole.Assistant, [functionCall])
]);
var finalResponse = new ChatResponse([
new ChatMessage(ChatRole.Assistant, "Final response")
]);
mockChatClient.SetupSequence(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(responseWithFunctionCall)
.ReturnsAsync(finalResponse);
var innerAgent = new ChatClientAgent(mockChatClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
async ValueTask<object?> FirstMiddlewareAsync(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
{
executionOrder.Add("First-Pre");
var result = await next(context, cancellationToken);
executionOrder.Add("First-Post");
return result;
}
async ValueTask<object?> SecondMiddlewareAsync(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
{
executionOrder.Add("Second-Pre");
var result = await next(context, cancellationToken);
executionOrder.Add("Second-Post");
return result;
}
// Create nested middleware chain
var firstMiddleware = new FunctionInvocationDelegatingAgent(innerAgent, FirstMiddlewareAsync);
var secondMiddleware = new FunctionInvocationDelegatingAgent(firstMiddleware, SecondMiddlewareAsync);
// Act
var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [testFunction] });
await secondMiddleware.RunAsync(messages, null, options, CancellationToken.None);
// Assert
var expectedOrder = new[] { "First-Pre", "Second-Pre", "Function-Executed", "Second-Post", "First-Post" };
Assert.Equal(expectedOrder, executionOrder);
}
/// <summary>
/// Tests that function middleware works correctly when combined with running middleware.
/// </summary>
[Fact]
public async Task RunAsync_FunctionMiddlewareWithRunningMiddleware_BothExecuteAsync()
{
// Arrange
var executionOrder = new List<string>();
var testFunction = AIFunctionFactory.Create(() =>
{
executionOrder.Add("Function-Executed");
return "Function result";
}, "TestFunction", "A test function");
var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary<string, object?>());
var mockChatClient = CreateMockChatClientWithFunctionCalls(functionCall);
var innerAgent = new ChatClientAgent(mockChatClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
async Task<AgentRunResponse> RunningMiddlewareCallbackAsync(IEnumerable<ChatMessage> messages, AgentThread? thread, AgentRunOptions? options, AIAgent innerAgent, CancellationToken cancellationToken)
{
executionOrder.Add("Running-Pre");
var result = await innerAgent.RunAsync(messages, thread, options, cancellationToken);
executionOrder.Add("Running-Post");
return result;
}
async ValueTask<object?> FunctionMiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
{
executionOrder.Add("Function-Pre");
var result = await next(context, cancellationToken);
executionOrder.Add("Function-Post");
return result;
}
// Create middleware chain: Function -> Running -> Inner using AIAgentBuilder
var runningMiddleware = new AIAgentBuilder(innerAgent)
.Use(RunningMiddlewareCallbackAsync, null)
.Build();
var functionMiddleware = new FunctionInvocationDelegatingAgent(runningMiddleware, FunctionMiddlewareCallbackAsync);
// Act
var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [testFunction] });
await functionMiddleware.RunAsync(messages, null, options, CancellationToken.None);
// Assert
Assert.Contains("Running-Pre", executionOrder);
Assert.Contains("Running-Post", executionOrder);
Assert.Contains("Function-Pre", executionOrder);
Assert.Contains("Function-Post", executionOrder);
Assert.Contains("Function-Executed", executionOrder);
}
#endregion
#region Streaming Tests
/// <summary>
/// Tests that function middleware works correctly with streaming responses.
/// </summary>
[Fact]
public async Task RunStreamingAsync_WithFunctionCall_InvokesMiddlewareAsync()
{
// Arrange
var executionOrder = new List<string>();
var testFunction = AIFunctionFactory.Create(() =>
{
executionOrder.Add("Function-Executed");
return "Function result";
}, "TestFunction", "A test function");
var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary<string, object?>());
var mockChatClient = CreateMockChatClientWithFunctionCalls(functionCall);
// Setup streaming response with function calls
var streamingResponse = new ChatResponseUpdate[]
{
new() { Contents = [functionCall] }, // Include function call in streaming response
new() { Contents = [new TextContent("Streaming response")] }
};
mockChatClient.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns(streamingResponse.ToAsyncEnumerable());
var innerAgent = new ChatClientAgent(mockChatClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
async ValueTask<object?> MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
{
executionOrder.Add("Middleware-Pre");
var result = await next(context, cancellationToken);
executionOrder.Add("Middleware-Post");
return result;
}
var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync);
// Act
var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [testFunction] });
var responseUpdates = new List<AgentRunResponseUpdate>();
await foreach (var update in middleware.RunStreamingAsync(messages, null, options, CancellationToken.None))
{
responseUpdates.Add(update);
}
// Assert
Assert.NotEmpty(responseUpdates);
Assert.Contains("Middleware-Pre", executionOrder);
Assert.Contains("Function-Executed", executionOrder);
Assert.Contains("Middleware-Post", executionOrder);
}
#endregion
#region Edge Cases
/// <summary>
/// Tests that middleware is not invoked when no function calls are made.
/// </summary>
[Fact]
public async Task RunAsync_NoFunctionCalls_MiddlewareNotInvokedAsync()
{
// Arrange
var middlewareInvoked = false;
var mockChatClient = CreateMockChatClient(
new ChatResponse([new ChatMessage(ChatRole.Assistant, "Regular response")]));
var innerAgent = new ChatClientAgent(mockChatClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
async ValueTask<object?> MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
{
middlewareInvoked = true;
return await next(context, cancellationToken);
}
var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync);
// Act
await middleware.RunAsync(messages, null, null, CancellationToken.None);
// Assert
Assert.False(middlewareInvoked);
}
/// <summary>
/// Tests that middleware handles cancellation tokens correctly.
/// </summary>
[Fact]
public async Task RunAsync_CancellationToken_PropagatedToMiddlewareAsync()
{
// Arrange
var testFunction = AIFunctionFactory.Create(() => "Function result", "TestFunction", "A test function");
var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary<string, object?>());
var mockChatClient = CreateMockChatClientWithFunctionCalls(functionCall);
var innerAgent = new ChatClientAgent(mockChatClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
var cancellationTokenSource = new CancellationTokenSource();
var expectedToken = cancellationTokenSource.Token;
CancellationToken? capturedToken = null;
async ValueTask<object?> MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
{
capturedToken = cancellationToken;
return await next(context, cancellationToken);
}
var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync);
// Act
var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [testFunction] });
await middleware.RunAsync(messages, null, options, expectedToken);
// Assert
Assert.Equal(expectedToken, capturedToken);
}
/// <summary>
/// Tests that middleware can prevent function execution by not calling next().
/// </summary>
[Fact]
public async Task RunAsync_MiddlewareDoesNotCallNext_FunctionNotExecutedAsync()
{
// Arrange
var functionExecuted = false;
var testFunction = AIFunctionFactory.Create(() =>
{
functionExecuted = true;
return "Function result";
}, "TestFunction", "A test function");
var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary<string, object?>());
var mockChatClient = CreateMockChatClientWithFunctionCalls(functionCall);
var innerAgent = new ChatClientAgent(mockChatClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
ValueTask<object?> MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
{
// Don't call next() - this should prevent function execution
// Return the blocked result directly
return new ValueTask<object?>("Blocked by middleware");
}
var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync);
// Act
var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [testFunction] });
var response = await middleware.RunAsync(messages, null, options, CancellationToken.None);
// Assert
Assert.False(functionExecuted);
Assert.NotNull(response);
// Verify the middleware result is used
var functionResultContent = response.Messages
.SelectMany(m => m.Contents)
.OfType<FunctionResultContent>()
.FirstOrDefault();
Assert.NotNull(functionResultContent);
Assert.Equal("Blocked by middleware", functionResultContent.Result);
}
#endregion
/// <summary>
/// Creates a mock IChatClient with predefined responses for testing.
/// </summary>
/// <param name="responses">The responses to return in sequence.</param>
/// <returns>A configured mock IChatClient.</returns>
private static Mock<IChatClient> CreateMockChatClient(params ChatResponse[] responses)
{
var mockChatClient = new Mock<IChatClient>();
var responseQueue = new Queue<ChatResponse>(responses);
mockChatClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(() => responseQueue.Count > 0 ? responseQueue.Dequeue() : responses.LastOrDefault() ?? CreateDefaultResponse());
return mockChatClient;
}
/// <summary>
/// Creates a mock IChatClient that returns responses with function calls for testing function middleware.
/// </summary>
/// <param name="functionCalls">The function calls to include in responses.</param>
/// <returns>A configured mock IChatClient.</returns>
private static Mock<IChatClient> CreateMockChatClientWithFunctionCalls(params FunctionCallContent[] functionCalls)
{
var mockChatClient = new Mock<IChatClient>();
var responseWithFunctionCalls = new ChatResponse([
new ChatMessage(ChatRole.Assistant, functionCalls.Cast<AIContent>().ToList())
]);
mockChatClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(responseWithFunctionCalls);
return mockChatClient;
}
/// <summary>
/// Creates a default ChatResponse for fallback scenarios.
/// </summary>
/// <returns>A default ChatResponse.</returns>
private static ChatResponse CreateDefaultResponse()
{
return new ChatResponse([new ChatMessage(ChatRole.Assistant, "Default response")]);
}
}
@@ -10,6 +10,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="OpenTelemetry" />
<PackageReference Include="OpenTelemetry.Exporter.InMemory" />
<PackageReference Include="System.Linq.Async" />
@@ -0,0 +1,146 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Extensions.Logging;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="OpenTelemetryAIAgentBuilderExtensions"/> class.
/// </summary>
public class OpenTelemetryAIAgentBuilderExtensionsTests
{
/// <summary>
/// Verify that UseOpenTelemetry throws ArgumentNullException when builder is null.
/// </summary>
[Fact]
public void UseOpenTelemetry_WithNullBuilder_ThrowsArgumentNullException()
{
// Act & Assert
Assert.Throws<ArgumentNullException>("builder", () =>
OpenTelemetryAIAgentBuilderExtensions.UseOpenTelemetry(null!));
}
/// <summary>
/// Verify that UseOpenTelemetry returns an OpenTelemetryAgent.
/// </summary>
[Fact]
public void UseOpenTelemetry_WithValidBuilder_ReturnsOpenTelemetryAgent()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act
var result = builder.UseOpenTelemetry().Build();
// Assert
Assert.IsType<OpenTelemetryAgent>(result);
}
/// <summary>
/// Verify that UseOpenTelemetry with logger factory works correctly.
/// </summary>
[Fact]
public void UseOpenTelemetry_WithLoggerFactory_UsesProvidedLoggerFactory()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
using var loggerFactory = LoggerFactory.Create(builder => { });
var builder = new AIAgentBuilder(mockAgent.Object);
// Act
var result = builder.UseOpenTelemetry(loggerFactory).Build();
// Assert
Assert.IsType<OpenTelemetryAgent>(result);
}
/// <summary>
/// Verify that UseOpenTelemetry with source name works correctly.
/// </summary>
[Fact]
public void UseOpenTelemetry_WithSourceName_WorksCorrectly()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
const string SourceName = "TestSource";
// Act
var result = builder.UseOpenTelemetry(sourceName: SourceName).Build();
// Assert
Assert.IsType<OpenTelemetryAgent>(result);
}
/// <summary>
/// Verify that UseOpenTelemetry with configure action works correctly.
/// </summary>
[Fact]
public void UseOpenTelemetry_WithConfigureAction_CallsConfigureAction()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
var configureWasCalled = false;
// Act
var result = builder.UseOpenTelemetry(configure: agent =>
{
configureWasCalled = true;
Assert.NotNull(agent);
Assert.IsType<OpenTelemetryAgent>(agent);
}).Build();
// Assert
Assert.True(configureWasCalled);
Assert.IsType<OpenTelemetryAgent>(result);
}
/// <summary>
/// Verify that UseOpenTelemetry returns the same builder instance for chaining.
/// </summary>
[Fact]
public void UseOpenTelemetry_ReturnsBuilderForChaining()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act
var result = builder.UseOpenTelemetry();
// Assert
Assert.Same(builder, result);
}
/// <summary>
/// Verify that UseOpenTelemetry with all parameters works correctly.
/// </summary>
[Fact]
public void UseOpenTelemetry_WithAllParameters_WorksCorrectly()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
using var loggerFactory = LoggerFactory.Create(builder => { });
var builder = new AIAgentBuilder(mockAgent.Object);
const string SourceName = "TestSource";
var configureWasCalled = false;
// Act
var result = builder.UseOpenTelemetry(
loggerFactory: loggerFactory,
sourceName: SourceName,
configure: agent =>
{
configureWasCalled = true;
Assert.NotNull(agent);
}).Build();
// Assert
Assert.True(configureWasCalled);
Assert.IsType<OpenTelemetryAgent>(result);
}
}