mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.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:
committed by
GitHub
Unverified
parent
c102706146
commit
7a8b456294
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
+24
@@ -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 per‑request)
|
||||
- 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. Per‑request chat client middleware
|
||||
6. Per‑request function pipeline with approval
|
||||
7. Combining agent‑level and per‑request 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>
|
||||
|
||||
+18
-3
@@ -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,
|
||||
|
||||
+59
-10
@@ -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)
|
||||
|
||||
+403
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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>
|
||||
+261
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+227
@@ -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);
|
||||
}
|
||||
}
|
||||
+172
@@ -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);
|
||||
}
|
||||
}
|
||||
+11
@@ -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
+294
@@ -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" />
|
||||
|
||||
+146
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user