mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: Add decorator for structured output support (#3694)
* add decorator that adds structured output support to agents that don't natively support it. * Update dotnet/src/Microsoft.Agents.AI/StructuredOutput/StructuredOutputAgentResponse.cs Co-authored-by: westey <164392973+westey-m@users.noreply.github.com> * Update dotnet/samples/GettingStarted/Agents/Agent_Step05_StructuredOutput/Program.cs Co-authored-by: westey <164392973+westey-m@users.noreply.github.com> * address pr review feedback --------- Co-authored-by: westey <164392973+westey-m@users.noreply.github.com>
This commit is contained in:
@@ -8,11 +8,13 @@ using System.Text.Json.Serialization;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Chat;
|
||||
using SampleApp;
|
||||
using ChatMessage = Microsoft.Extensions.AI.ChatMessage;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
|
||||
// Create chat client to be used by chat client agents.
|
||||
ChatClient chatClient = new AzureOpenAIClient(
|
||||
@@ -20,52 +22,150 @@ ChatClient chatClient = new AzureOpenAIClient(
|
||||
new AzureCliCredential())
|
||||
.GetChatClient(deploymentName);
|
||||
|
||||
// Create the ChatClientAgent with the specified name and instructions.
|
||||
ChatClientAgent agent = chatClient.AsAIAgent(name: "HelpfulAssistant", instructions: "You are a helpful assistant.");
|
||||
// Demonstrates how to work with structured output via ResponseFormat with the non-generic RunAsync method.
|
||||
// This approach is useful when:
|
||||
// a. Structured output is used for inter-agent communication, where one agent produces structured output
|
||||
// and passes it as text to another agent as input, without the need for the caller to directly work with the structured output.
|
||||
// b. The type of the structured output is not known at compile time, so the generic RunAsync<T> method cannot be used.
|
||||
// c. The type of the structured output is represented by JSON schema only, without a corresponding class or type in the code.
|
||||
await UseStructuredOutputWithResponseFormatAsync(chatClient);
|
||||
|
||||
// Set PersonInfo as the type parameter of RunAsync method to specify the expected structured output from the agent and invoke the agent with some unstructured input.
|
||||
AgentResponse<PersonInfo> response = await agent.RunAsync<PersonInfo>("Please provide information about John Smith, who is a 35-year-old software engineer.");
|
||||
// Demonstrates how to work with structured output via the generic RunAsync<T> method.
|
||||
// This approach is useful when the caller needs to directly work with the structured output in the code
|
||||
// via an instance of the corresponding class or type and the type is known at compile time.
|
||||
await UseStructuredOutputWithRunAsync(chatClient);
|
||||
|
||||
// Access the structured output via the Result property of the agent response.
|
||||
Console.WriteLine("Assistant Output:");
|
||||
Console.WriteLine($"Name: {response.Result.Name}");
|
||||
Console.WriteLine($"Age: {response.Result.Age}");
|
||||
Console.WriteLine($"Occupation: {response.Result.Occupation}");
|
||||
// Demonstrates how to work with structured output when streaming using the RunStreamingAsync method.
|
||||
await UseStructuredOutputWithRunStreamingAsync(chatClient);
|
||||
|
||||
// Create the ChatClientAgent with the specified name, instructions, and expected structured output the agent should produce.
|
||||
ChatClientAgent agentWithPersonInfo = chatClient.AsAIAgent(new ChatClientAgentOptions()
|
||||
// Demonstrates how to add structured output support to agents that don't natively support it using the structured output middleware.
|
||||
// This approach is useful when working with agents that don't support structured output natively, like A2A, or agents using models
|
||||
// that don't have the capability to produce structured output, allowing you to still leverage structured output features by transforming
|
||||
// the text output from the agent into structured data using a chat client.
|
||||
await UseStructuredOutputWithMiddlewareAsync(chatClient);
|
||||
|
||||
static async Task UseStructuredOutputWithResponseFormatAsync(ChatClient chatClient)
|
||||
{
|
||||
Name = "HelpfulAssistant",
|
||||
ChatOptions = new() { Instructions = "You are a helpful assistant.", ResponseFormat = Microsoft.Extensions.AI.ChatResponseFormat.ForJsonSchema<PersonInfo>() }
|
||||
});
|
||||
Console.WriteLine("=== Structured Output with ResponseFormat ===");
|
||||
|
||||
// Invoke the agent with some unstructured input while streaming, to extract the structured information from.
|
||||
var updates = agentWithPersonInfo.RunStreamingAsync("Please provide information about John Smith, who is a 35-year-old software engineer.");
|
||||
// Create the agent
|
||||
AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions()
|
||||
{
|
||||
Name = "HelpfulAssistant",
|
||||
ChatOptions = new()
|
||||
{
|
||||
Instructions = "You are a helpful assistant.",
|
||||
// Specify CityInfo as the type parameter of ForJsonSchema to indicate the expected structured output from the agent.
|
||||
ResponseFormat = Microsoft.Extensions.AI.ChatResponseFormat.ForJsonSchema<CityInfo>()
|
||||
}
|
||||
});
|
||||
|
||||
// Assemble all the parts of the streamed output, since we can only deserialize once we have the full json,
|
||||
// then deserialize the response into the PersonInfo class.
|
||||
PersonInfo personInfo = JsonSerializer.Deserialize<PersonInfo>((await updates.ToAgentResponseAsync()).Text, JsonSerializerOptions.Web)!;
|
||||
// Invoke the agent with some unstructured input to extract the structured information from.
|
||||
AgentResponse response = await agent.RunAsync("Provide information about the capital of France.");
|
||||
|
||||
Console.WriteLine("Assistant Output:");
|
||||
Console.WriteLine($"Name: {personInfo.Name}");
|
||||
Console.WriteLine($"Age: {personInfo.Age}");
|
||||
Console.WriteLine($"Occupation: {personInfo.Occupation}");
|
||||
// Access the structured output via the Text property of the agent response.
|
||||
Console.WriteLine("Assistant Output:");
|
||||
Console.WriteLine(response.Text);
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
static async Task UseStructuredOutputWithRunAsync(ChatClient chatClient)
|
||||
{
|
||||
Console.WriteLine("=== Structured Output with RunAsync<T> ===");
|
||||
|
||||
// Create the agent
|
||||
AIAgent agent = chatClient.AsAIAgent(name: "HelpfulAssistant", instructions: "You are a helpful assistant.");
|
||||
|
||||
// Set CityInfo as the type parameter of RunAsync method to specify the expected structured output from the agent and invoke it with some unstructured input.
|
||||
AgentResponse<CityInfo> response = await agent.RunAsync<CityInfo>("Provide information about the capital of France.");
|
||||
|
||||
// Access the structured output via the Result property of the agent response.
|
||||
CityInfo cityInfo = response.Result;
|
||||
|
||||
Console.WriteLine("Assistant Output:");
|
||||
Console.WriteLine($"Name: {cityInfo.Name}");
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
static async Task UseStructuredOutputWithRunStreamingAsync(ChatClient chatClient)
|
||||
{
|
||||
Console.WriteLine("=== Structured Output with RunStreamingAsync ===");
|
||||
|
||||
// Create the agent
|
||||
AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions()
|
||||
{
|
||||
Name = "HelpfulAssistant",
|
||||
ChatOptions = new()
|
||||
{
|
||||
Instructions = "You are a helpful assistant.",
|
||||
// Specify CityInfo as the type parameter of ForJsonSchema to indicate the expected structured output from the agent.
|
||||
ResponseFormat = Microsoft.Extensions.AI.ChatResponseFormat.ForJsonSchema<CityInfo>()
|
||||
}
|
||||
});
|
||||
|
||||
// Invoke the agent with some unstructured input while streaming, to extract the structured information from.
|
||||
IAsyncEnumerable<AgentResponseUpdate> updates = agent.RunStreamingAsync("Provide information about the capital of France.");
|
||||
|
||||
// Assemble all the parts of the streamed output.
|
||||
AgentResponse nonGenericResponse = await updates.ToAgentResponseAsync();
|
||||
|
||||
// Access the structured output by deserializing JSON in the Text property.
|
||||
CityInfo cityInfo = JsonSerializer.Deserialize<CityInfo>(nonGenericResponse.Text)!;
|
||||
|
||||
Console.WriteLine("Assistant Output:");
|
||||
Console.WriteLine($"Name: {cityInfo.Name}");
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
static async Task UseStructuredOutputWithMiddlewareAsync(ChatClient chatClient)
|
||||
{
|
||||
Console.WriteLine("=== Structured Output with UseStructuredOutput Middleware ===");
|
||||
|
||||
// Create chat client that will transform the agent text response into structured output.
|
||||
IChatClient meaiChatClient = chatClient.AsIChatClient();
|
||||
|
||||
// Create the agent
|
||||
AIAgent agent = meaiChatClient.AsAIAgent(name: "HelpfulAssistant", instructions: "You are a helpful assistant.");
|
||||
|
||||
// Add structured output middleware via UseStructuredOutput method to add structured output support to the agent.
|
||||
// This middleware transforms the agent's text response into structured data using a chat client.
|
||||
// Since our agent does support structured output natively, we will add a middleware that removes ResponseFormat
|
||||
// from the AgentRunOptions to emulate an agent that doesn't support structured output natively
|
||||
agent = agent
|
||||
.AsBuilder()
|
||||
.UseStructuredOutput(meaiChatClient)
|
||||
.Use(ResponseFormatRemovalMiddleware, null)
|
||||
.Build();
|
||||
|
||||
// Set CityInfo as the type parameter of RunAsync method to specify the expected structured output from the agent and invoke it with some unstructured input.
|
||||
AgentResponse<CityInfo> response = await agent.RunAsync<CityInfo>("Provide information about the capital of France.");
|
||||
|
||||
// Access the structured output via the Result property of the agent response.
|
||||
CityInfo cityInfo = response.Result;
|
||||
|
||||
Console.WriteLine("Assistant Output:");
|
||||
Console.WriteLine($"Name: {cityInfo.Name}");
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
static Task<AgentResponse> ResponseFormatRemovalMiddleware(IEnumerable<ChatMessage> messages, AgentSession? session, AgentRunOptions? options, AIAgent innerAgent, CancellationToken cancellationToken)
|
||||
{
|
||||
// Remove any ResponseFormat from the options to emulate an agent that doesn't support structured output natively.
|
||||
options = options?.Clone();
|
||||
options?.ResponseFormat = null;
|
||||
|
||||
return innerAgent.RunAsync(messages, session, options, cancellationToken);
|
||||
}
|
||||
|
||||
namespace SampleApp
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents information about a person, including their name, age, and occupation, matched to the JSON schema used in the agent.
|
||||
/// Represents information about a city, including its name.
|
||||
/// </summary>
|
||||
[Description("Information about a person including their name, age, and occupation")]
|
||||
public class PersonInfo
|
||||
[Description("Information about a city")]
|
||||
public sealed class CityInfo
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("age")]
|
||||
public int? Age { get; set; }
|
||||
|
||||
[JsonPropertyName("occupation")]
|
||||
public string? Occupation { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Structured Output with ChatClientAgent
|
||||
|
||||
This sample demonstrates how to configure ChatClientAgent to produce structured output in JSON format using various approaches.
|
||||
|
||||
## What this sample demonstrates
|
||||
|
||||
- **ResponseFormat approach**: Configuring agents with JSON schema response format via `ChatResponseFormat.ForJsonSchema<T>()` for inter-agent communication or when the type is not known at compile time
|
||||
- **Generic RunAsync<T> method**: Using the generic `RunAsync<T>` method for structured output when the caller needs to work directly with typed objects
|
||||
- **Structured output with Streaming**: Using `RunStreamingAsync` to stream responses while still obtaining structured output by assembling and deserializing the streamed content
|
||||
- **StructuredOutput middleware**: Adding structured output support to agents that don't natively support it (like A2A agents or models without structured output capability) by transforming text output into structured data using a chat client
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, ensure you have the following prerequisites:
|
||||
|
||||
- .NET 10 SDK or later
|
||||
- Azure OpenAI service endpoint and deployment configured
|
||||
- Azure CLI installed and authenticated (for Azure credential authentication)
|
||||
- User has the `Cognitive Services OpenAI Contributor` role for the Azure OpenAI resource
|
||||
|
||||
**Note**: This sample uses Azure OpenAI models. For more information, see [how to deploy Azure OpenAI models with Azure AI Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/deploy-models-openai).
|
||||
|
||||
**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure OpenAI resource and have the `Cognitive Services OpenAI Contributor` role. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively).
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
```powershell
|
||||
$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Replace with your Azure OpenAI resource endpoint
|
||||
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini
|
||||
```
|
||||
|
||||
## Run the sample
|
||||
|
||||
Navigate to the sample directory and run:
|
||||
|
||||
```powershell
|
||||
cd dotnet/samples/GettingStarted/Agents/Agent_Step05_StructuredOutput
|
||||
dotnet run
|
||||
```
|
||||
|
||||
## Expected behavior
|
||||
|
||||
The sample will demonstrate four different approaches to structured output:
|
||||
|
||||
1. **Structured Output with ResponseFormat**: Creates an agent with `ResponseFormat` set to `ForJsonSchema<CityInfo>()`, invokes it with unstructured input, and accesses the structured output via the `Text` property
|
||||
2. **Structured Output with RunAsync<T>**: Creates an agent and uses the generic `RunAsync<CityInfo>()` method to get a typed `AgentResponse<CityInfo>` with the result accessible via the `Result` property
|
||||
3. **Structured Output with RunStreamingAsync**: Creates an agent with JSON schema response format, streams the response using `RunStreamingAsync`, assembles the updates using `ToAgentResponseAsync()`, and deserializes the JSON text into a typed object
|
||||
4. **Structured Output with StructuredOutput Middleware**: Uses the `UseStructuredOutput` method on `AIAgentBuilder` to add structured output support to agents that don't natively support it
|
||||
|
||||
Each approach will output information about the capital of France (Paris) in a structured format.
|
||||
@@ -11,6 +11,7 @@ using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
@@ -32,6 +33,16 @@ public class AgentResponse<T> : AgentResponse
|
||||
this._serializerOptions = serializerOptions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentResponse{T}"/> class.
|
||||
/// </summary>
|
||||
/// <param name="response">The <see cref="ChatResponse"/> from which to populate this <see cref="AgentResponse{T}"/>.</param>
|
||||
/// <param name="serializerOptions">The <see cref="JsonSerializerOptions"/> to use when deserializing the result.</param>
|
||||
public AgentResponse(ChatResponse response, JsonSerializerOptions serializerOptions) : base(response)
|
||||
{
|
||||
this._serializerOptions = serializerOptions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the result value of the agent response as an instance of <typeparamref name="T"/>.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for adding structured output capabilities to <see cref="AIAgentBuilder"/> instances.
|
||||
/// </summary>
|
||||
public static class AIAgentBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds structured output capabilities to the agent pipeline, enabling conversion of text responses to structured JSON format.
|
||||
/// </summary>
|
||||
/// <param name="builder">The <see cref="AIAgentBuilder"/> to which structured output support will be added.</param>
|
||||
/// <param name="chatClient">
|
||||
/// The chat client used to transform text responses into structured JSON format.
|
||||
/// If <see langword="null"/>, the chat client will be resolved from the service provider.
|
||||
/// </param>
|
||||
/// <param name="optionsFactory">
|
||||
/// An optional factory function that returns the <see cref="StructuredOutputAgentOptions"/> instance to use.
|
||||
/// This allows for fine-tuning the structured output behavior such as setting the response format or system message.
|
||||
/// </param>
|
||||
/// <returns>The <see cref="AIAgentBuilder"/> with structured output capabilities added, enabling method chaining.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A <see cref="ChatResponseFormatJson"/> must be specified either through the
|
||||
/// <see cref="AgentRunOptions.ResponseFormat"/> at runtime or the <see cref="StructuredOutputAgentOptions.ChatOptions"/>
|
||||
/// provided during configuration.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static AIAgentBuilder UseStructuredOutput(
|
||||
this AIAgentBuilder builder,
|
||||
IChatClient? chatClient = null,
|
||||
Func<StructuredOutputAgentOptions>? optionsFactory = null) =>
|
||||
Throw.IfNull(builder).Use((innerAgent, services) =>
|
||||
{
|
||||
chatClient ??= services?.GetService<IChatClient>()
|
||||
?? throw new InvalidOperationException($"No {nameof(IChatClient)} was provided and none could be resolved from the service provider. Either provide an {nameof(IChatClient)} explicitly or register one in the dependency injection container.");
|
||||
|
||||
return new StructuredOutputAgent(innerAgent, chatClient, optionsFactory?.Invoke());
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// 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.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// A delegating AI agent that converts text responses from an inner AI agent into structured output using a chat client.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The <see cref="StructuredOutputAgent"/> wraps an inner agent and uses a chat client to transform
|
||||
/// the inner agent's text response into a structured JSON format based on the specified response format.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This agent requires a <see cref="ChatResponseFormatJson"/> to be specified either through the
|
||||
/// <see cref="AgentRunOptions.ResponseFormat"/> or the <see cref="StructuredOutputAgentOptions.ChatOptions"/>
|
||||
/// provided during construction.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class StructuredOutputAgent : DelegatingAIAgent
|
||||
{
|
||||
private readonly IChatClient _chatClient;
|
||||
private readonly StructuredOutputAgentOptions? _agentOptions;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="StructuredOutputAgent"/> class.
|
||||
/// </summary>
|
||||
/// <param name="innerAgent">The underlying agent that generates text responses to be converted to structured output.</param>
|
||||
/// <param name="chatClient">The chat client used to transform text responses into structured JSON format.</param>
|
||||
/// <param name="options">Optional configuration options for the structured output agent.</param>
|
||||
public StructuredOutputAgent(AIAgent innerAgent, IChatClient chatClient, StructuredOutputAgentOptions? options = null)
|
||||
: base(innerAgent)
|
||||
{
|
||||
this._chatClient = Throw.IfNull(chatClient);
|
||||
this._agentOptions = options;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Run the inner agent first, to get back the text response we want to convert.
|
||||
var textResponse = await this.InnerAgent.RunAsync(messages, session, options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Invoke the chat client to transform the text output into structured data.
|
||||
ChatResponse soResponse = await this._chatClient.GetResponseAsync(
|
||||
messages: this.GetChatMessages(textResponse.Text),
|
||||
options: this.GetChatOptions(options),
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new StructuredOutputAgentResponse(soResponse, textResponse);
|
||||
}
|
||||
|
||||
private List<ChatMessage> GetChatMessages(string? textResponseText)
|
||||
{
|
||||
List<ChatMessage> chatMessages = [];
|
||||
|
||||
if (this._agentOptions?.ChatClientSystemMessage is not null)
|
||||
{
|
||||
chatMessages.Add(new ChatMessage(ChatRole.System, this._agentOptions.ChatClientSystemMessage));
|
||||
}
|
||||
|
||||
chatMessages.Add(new ChatMessage(ChatRole.User, textResponseText));
|
||||
|
||||
return chatMessages;
|
||||
}
|
||||
|
||||
private ChatOptions GetChatOptions(AgentRunOptions? options)
|
||||
{
|
||||
ChatResponseFormat responseFormat = options?.ResponseFormat
|
||||
?? this._agentOptions?.ChatOptions?.ResponseFormat
|
||||
?? throw new InvalidOperationException($"A response format of type '{nameof(ChatResponseFormatJson)}' must be specified, but none was specified.");
|
||||
|
||||
if (responseFormat is not ChatResponseFormatJson jsonResponseFormat)
|
||||
{
|
||||
throw new NotSupportedException($"A response format of type '{nameof(ChatResponseFormatJson)}' must be specified, but was '{responseFormat.GetType().Name}'.");
|
||||
}
|
||||
|
||||
var chatOptions = this._agentOptions?.ChatOptions?.Clone() ?? new ChatOptions();
|
||||
chatOptions.ResponseFormat = jsonResponseFormat;
|
||||
return chatOptions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Represents configuration options for a <see cref="StructuredOutputAgent"/>.
|
||||
/// </summary>
|
||||
public sealed class StructuredOutputAgentOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the system message to use when invoking the chat client for structured output conversion.
|
||||
/// </summary>
|
||||
public string? ChatClientSystemMessage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the chat options to use for the structured output conversion by the chat client
|
||||
/// used by the agent.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This property is optional. The <see cref="ChatOptions.ResponseFormat"/> should be set to a
|
||||
/// <see cref="ChatResponseFormatJson"/> instance to specify the expected JSON schema for the structured output.
|
||||
/// Note that if <see cref="AgentRunOptions.ResponseFormat"/> is provided when running the agent,
|
||||
/// it will take precedence and override the <see cref="ChatOptions.ResponseFormat"/> specified here.
|
||||
/// </remarks>
|
||||
public ChatOptions? ChatOptions { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an agent response that contains structured output and
|
||||
/// the original agent response from which the structured output was generated.
|
||||
/// </summary>
|
||||
public class StructuredOutputAgentResponse : AgentResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="StructuredOutputAgentResponse"/> class.
|
||||
/// </summary>
|
||||
/// <param name="chatResponse">The <see cref="ChatResponse"/> containing the structured output.</param>
|
||||
/// <param name="agentResponse">The original <see cref="AgentResponse"/> from the inner agent.</param>
|
||||
internal StructuredOutputAgentResponse(ChatResponse chatResponse, AgentResponse agentResponse) : base(chatResponse)
|
||||
{
|
||||
this.OriginalResponse = agentResponse;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the original non-structured response from the inner agent used by chat client to produce the structured output.
|
||||
/// </summary>
|
||||
public AgentResponse OriginalResponse { get; }
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="AIAgentBuilderExtensions"/> class.
|
||||
/// </summary>
|
||||
public sealed class AIAgentBuilderExtensionsTests
|
||||
{
|
||||
private readonly Mock<IChatClient> _chatClientMock;
|
||||
private readonly TestAIAgent _innerAgent;
|
||||
|
||||
public AIAgentBuilderExtensionsTests()
|
||||
{
|
||||
this._chatClientMock = new Mock<IChatClient>();
|
||||
this._innerAgent = new TestAIAgent();
|
||||
|
||||
this._chatClientMock
|
||||
.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "{\"result\": \"test\"}")]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UseStructuredOutput_WithNullBuilder_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
AIAgentBuilder builder = null!;
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>("builder", () =>
|
||||
builder.UseStructuredOutput(this._chatClientMock.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UseStructuredOutput_WithExplicitChatClient_BuildsStructuredOutputAgent()
|
||||
{
|
||||
// Arrange
|
||||
AIAgentBuilder builder = this._innerAgent.AsBuilder();
|
||||
|
||||
// Act
|
||||
AIAgent agent = builder.UseStructuredOutput(this._chatClientMock.Object).Build();
|
||||
|
||||
// Assert
|
||||
Assert.IsType<StructuredOutputAgent>(agent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UseStructuredOutput_WithNoChatClientParameter_ResolvesChatClientFromServices()
|
||||
{
|
||||
// Arrange
|
||||
ServiceCollection services = new();
|
||||
services.AddSingleton(this._chatClientMock.Object);
|
||||
ServiceProvider serviceProvider = services.BuildServiceProvider();
|
||||
|
||||
AIAgentBuilder builder = this._innerAgent.AsBuilder();
|
||||
|
||||
// Act
|
||||
AIAgent agent = builder.UseStructuredOutput().Build(serviceProvider);
|
||||
|
||||
// Assert
|
||||
Assert.IsType<StructuredOutputAgent>(agent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UseStructuredOutput_WithNoChatClientAvailable_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Arrange
|
||||
AIAgentBuilder builder = this._innerAgent.AsBuilder();
|
||||
|
||||
// Act & Assert
|
||||
InvalidOperationException exception = Assert.Throws<InvalidOperationException>(() =>
|
||||
builder.UseStructuredOutput().Build(services: null));
|
||||
|
||||
Assert.Contains("IChatClient", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UseStructuredOutput_WithOptionsFactory_AppliesConfiguration()
|
||||
{
|
||||
// Arrange
|
||||
AIAgentBuilder builder = this._innerAgent.AsBuilder();
|
||||
bool factoryInvoked = false;
|
||||
|
||||
// Act
|
||||
AIAgent agent = builder.UseStructuredOutput(
|
||||
this._chatClientMock.Object,
|
||||
() =>
|
||||
{
|
||||
factoryInvoked = true;
|
||||
return new StructuredOutputAgentOptions
|
||||
{
|
||||
ChatClientSystemMessage = "Custom system message"
|
||||
};
|
||||
}).Build();
|
||||
|
||||
// Assert
|
||||
Assert.True(factoryInvoked);
|
||||
Assert.IsType<StructuredOutputAgent>(agent);
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="StructuredOutputAgentResponse"/> class.
|
||||
/// </summary>
|
||||
public sealed class StructuredOutputAgentResponseTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_WithValidParameters_SetsOriginalResponse()
|
||||
{
|
||||
// Arrange
|
||||
ChatResponse chatResponse = new([new ChatMessage(ChatRole.Assistant, "Structured output")]);
|
||||
AgentResponse originalResponse = new([new ChatMessage(ChatRole.Assistant, "Original response")]);
|
||||
|
||||
// Act
|
||||
StructuredOutputAgentResponse structuredResponse = new(chatResponse, originalResponse);
|
||||
|
||||
// Assert
|
||||
Assert.Same(originalResponse, structuredResponse.OriginalResponse);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithValidParameters_InheritsFromAgentResponse()
|
||||
{
|
||||
// Arrange
|
||||
ChatResponse chatResponse = new([new ChatMessage(ChatRole.Assistant, "Structured output")]);
|
||||
AgentResponse originalResponse = new([new ChatMessage(ChatRole.Assistant, "Original response")]);
|
||||
|
||||
// Act
|
||||
StructuredOutputAgentResponse structuredResponse = new(chatResponse, originalResponse);
|
||||
|
||||
// Assert
|
||||
Assert.IsAssignableFrom<AgentResponse>(structuredResponse);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OriginalResponse_ReturnsCorrectAgentResponse()
|
||||
{
|
||||
// Arrange
|
||||
ChatResponse chatResponse = new([new ChatMessage(ChatRole.Assistant, "Structured output")]);
|
||||
AgentResponse originalResponse = new([new ChatMessage(ChatRole.Assistant, "Original response")])
|
||||
{
|
||||
AgentId = "agent-1",
|
||||
ResponseId = "original-response-123"
|
||||
};
|
||||
|
||||
// Act
|
||||
StructuredOutputAgentResponse structuredResponse = new(chatResponse, originalResponse);
|
||||
|
||||
// Assert
|
||||
Assert.Same(originalResponse, structuredResponse.OriginalResponse);
|
||||
Assert.Equal("agent-1", structuredResponse.OriginalResponse.AgentId);
|
||||
Assert.Equal("original-response-123", structuredResponse.OriginalResponse.ResponseId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Text_ReturnsStructuredOutputText()
|
||||
{
|
||||
// Arrange
|
||||
const string StructuredJson = "{\"name\": \"Test\", \"value\": 42}";
|
||||
ChatResponse chatResponse = new([new ChatMessage(ChatRole.Assistant, StructuredJson)]);
|
||||
AgentResponse originalResponse = new([new ChatMessage(ChatRole.Assistant, "Original text response")]);
|
||||
|
||||
// Act
|
||||
StructuredOutputAgentResponse structuredResponse = new(chatResponse, originalResponse);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(StructuredJson, structuredResponse.Text);
|
||||
}
|
||||
}
|
||||
+540
@@ -0,0 +1,540 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="StructuredOutputAgent"/> class.
|
||||
/// </summary>
|
||||
public sealed class StructuredOutputAgentTests
|
||||
{
|
||||
private readonly Mock<IChatClient> _chatClientMock;
|
||||
private readonly TestAIAgent _innerAgent;
|
||||
private readonly List<ChatMessage> _testMessages;
|
||||
private readonly AgentSession _testSession;
|
||||
private readonly AgentResponse _innerAgentResponse;
|
||||
private readonly ChatResponse _chatClientResponse;
|
||||
|
||||
public StructuredOutputAgentTests()
|
||||
{
|
||||
this._chatClientMock = new Mock<IChatClient>();
|
||||
this._innerAgent = new TestAIAgent();
|
||||
this._testMessages = [new ChatMessage(ChatRole.User, "Test message")];
|
||||
this._testSession = new Mock<AgentSession>().Object;
|
||||
this._innerAgentResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "Inner agent response text")]);
|
||||
this._chatClientResponse = new ChatResponse([new ChatMessage(ChatRole.Assistant, "{\"result\": \"structured output\"}")]);
|
||||
|
||||
this._innerAgent.RunAsyncFunc = (_, _, _, _) => Task.FromResult(this._innerAgentResponse);
|
||||
|
||||
this._chatClientMock
|
||||
.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(this._chatClientResponse);
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithNullInnerAgent_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>("innerAgent", () =>
|
||||
new StructuredOutputAgent(null!, this._chatClientMock.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithNullChatClient_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>("chatClient", () =>
|
||||
new StructuredOutputAgent(this._innerAgent, null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithValidParameters_Succeeds()
|
||||
{
|
||||
// Act
|
||||
StructuredOutputAgent agent = new(this._innerAgent, this._chatClientMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithValidParametersAndOptions_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
StructuredOutputAgentOptions options = new()
|
||||
{
|
||||
ChatClientSystemMessage = "Custom system message",
|
||||
ChatOptions = new ChatOptions()
|
||||
};
|
||||
|
||||
// Act
|
||||
StructuredOutputAgent agent = new(this._innerAgent, this._chatClientMock.Object, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RunAsync Tests - Response Format Validation
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_WithNoResponseFormat_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
StructuredOutputAgent agent = new(this._innerAgent, this._chatClientMock.Object);
|
||||
|
||||
// Act & Assert
|
||||
InvalidOperationException exception = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => agent.RunAsync(this._testMessages, this._testSession, options: null));
|
||||
|
||||
Assert.Contains("ChatResponseFormatJson", exception.Message);
|
||||
Assert.Contains("none was specified", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_WithTextResponseFormat_ThrowsNotSupportedExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
StructuredOutputAgent agent = new(this._innerAgent, this._chatClientMock.Object);
|
||||
AgentRunOptions runOptions = new() { ResponseFormat = ChatResponseFormat.Text };
|
||||
|
||||
// Act & Assert
|
||||
NotSupportedException exception = await Assert.ThrowsAsync<NotSupportedException>(
|
||||
() => agent.RunAsync(this._testMessages, this._testSession, runOptions));
|
||||
|
||||
Assert.Contains("ChatResponseFormatJson", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_WithJsonResponseFormatInRunOptions_SucceedsAsync()
|
||||
{
|
||||
// Arrange
|
||||
StructuredOutputAgent agent = new(this._innerAgent, this._chatClientMock.Object);
|
||||
AgentRunOptions runOptions = new() { ResponseFormat = CreateJsonResponseFormat() };
|
||||
|
||||
// Act
|
||||
AgentResponse result = await agent.RunAsync(this._testMessages, this._testSession, runOptions);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.IsType<StructuredOutputAgentResponse>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_WithJsonResponseFormatInAgentOptions_SucceedsAsync()
|
||||
{
|
||||
// Arrange
|
||||
StructuredOutputAgentOptions agentOptions = new()
|
||||
{
|
||||
ChatOptions = new ChatOptions { ResponseFormat = CreateJsonResponseFormat() }
|
||||
};
|
||||
StructuredOutputAgent agent = new(this._innerAgent, this._chatClientMock.Object, agentOptions);
|
||||
|
||||
// Act
|
||||
AgentResponse result = await agent.RunAsync(this._testMessages, this._testSession, options: null);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.IsType<StructuredOutputAgentResponse>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_RunOptionsResponseFormatTakesPrecedenceOverAgentOptions_UsesRunOptionsFormatAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatResponseFormatJson agentOptionsFormat = CreateJsonResponseFormat();
|
||||
ChatResponseFormatJson runOptionsFormat = CreateJsonResponseFormat();
|
||||
|
||||
StructuredOutputAgentOptions agentOptions = new()
|
||||
{
|
||||
ChatOptions = new ChatOptions { ResponseFormat = agentOptionsFormat }
|
||||
};
|
||||
StructuredOutputAgent agent = new(this._innerAgent, this._chatClientMock.Object, agentOptions);
|
||||
AgentRunOptions runOptions = new() { ResponseFormat = runOptionsFormat };
|
||||
|
||||
ChatOptions? capturedChatOptions = null;
|
||||
this._chatClientMock
|
||||
.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, options, _) =>
|
||||
capturedChatOptions = options)
|
||||
.ReturnsAsync(this._chatClientResponse);
|
||||
|
||||
// Act
|
||||
await agent.RunAsync(this._testMessages, this._testSession, runOptions);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedChatOptions);
|
||||
Assert.Same(runOptionsFormat, capturedChatOptions.ResponseFormat);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RunAsync Tests - Inner Agent Invocation
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_InvokesInnerAgentWithCorrectParametersAsync()
|
||||
{
|
||||
// Arrange
|
||||
IEnumerable<ChatMessage>? capturedMessages = null;
|
||||
AgentSession? capturedSession = null;
|
||||
AgentRunOptions? capturedOptions = null;
|
||||
CancellationToken capturedCancellationToken = default;
|
||||
using CancellationTokenSource cts = new();
|
||||
CancellationToken expectedToken = cts.Token;
|
||||
|
||||
this._innerAgent.RunAsyncFunc = (messages, session, options, cancellationToken) =>
|
||||
{
|
||||
capturedMessages = messages;
|
||||
capturedSession = session;
|
||||
capturedOptions = options;
|
||||
capturedCancellationToken = cancellationToken;
|
||||
return Task.FromResult(this._innerAgentResponse);
|
||||
};
|
||||
|
||||
AgentRunOptions runOptions = new() { ResponseFormat = CreateJsonResponseFormat() };
|
||||
StructuredOutputAgent agent = new(this._innerAgent, this._chatClientMock.Object);
|
||||
|
||||
// Act
|
||||
await agent.RunAsync(this._testMessages, this._testSession, runOptions, expectedToken);
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._testMessages, capturedMessages);
|
||||
Assert.Same(this._testSession, capturedSession);
|
||||
Assert.Same(runOptions, capturedOptions);
|
||||
Assert.Equal(expectedToken, capturedCancellationToken);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RunAsync Tests - Chat Client Invocation
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_WithoutSystemMessage_SendsOnlyUserMessageToChatClientAsync()
|
||||
{
|
||||
// Arrange
|
||||
IEnumerable<ChatMessage>? capturedMessages = null;
|
||||
|
||||
this._chatClientMock
|
||||
.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((messages, _, _) =>
|
||||
capturedMessages = messages)
|
||||
.ReturnsAsync(this._chatClientResponse);
|
||||
|
||||
StructuredOutputAgent agent = new(this._innerAgent, this._chatClientMock.Object);
|
||||
AgentRunOptions runOptions = new() { ResponseFormat = CreateJsonResponseFormat() };
|
||||
|
||||
// Act
|
||||
await agent.RunAsync(this._testMessages, this._testSession, runOptions);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedMessages);
|
||||
List<ChatMessage> messageList = [.. capturedMessages];
|
||||
Assert.Single(messageList);
|
||||
Assert.Equal(ChatRole.User, messageList[0].Role);
|
||||
Assert.Equal(this._innerAgentResponse.Text, messageList[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_WithSystemMessage_SendsSystemAndUserMessagesToChatClientAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string CustomSystemMessage = "Custom conversion instruction";
|
||||
IEnumerable<ChatMessage>? capturedMessages = null;
|
||||
|
||||
this._chatClientMock
|
||||
.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((messages, _, _) =>
|
||||
capturedMessages = messages)
|
||||
.ReturnsAsync(this._chatClientResponse);
|
||||
|
||||
StructuredOutputAgentOptions agentOptions = new()
|
||||
{
|
||||
ChatClientSystemMessage = CustomSystemMessage,
|
||||
ChatOptions = new ChatOptions { ResponseFormat = CreateJsonResponseFormat() }
|
||||
};
|
||||
StructuredOutputAgent agent = new(this._innerAgent, this._chatClientMock.Object, agentOptions);
|
||||
|
||||
// Act
|
||||
await agent.RunAsync(this._testMessages, this._testSession, options: null);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedMessages);
|
||||
List<ChatMessage> messageList = [.. capturedMessages];
|
||||
Assert.Equal(2, messageList.Count);
|
||||
Assert.Equal(ChatRole.System, messageList[0].Role);
|
||||
Assert.Equal(CustomSystemMessage, messageList[0].Text);
|
||||
Assert.Equal(ChatRole.User, messageList[1].Role);
|
||||
Assert.Equal(this._innerAgentResponse.Text, messageList[1].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_PassesCancellationTokenToChatClientAsync()
|
||||
{
|
||||
// Arrange
|
||||
CancellationToken capturedToken = default;
|
||||
using CancellationTokenSource cts = new();
|
||||
CancellationToken expectedToken = cts.Token;
|
||||
|
||||
this._chatClientMock
|
||||
.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, _, cancellationToken) =>
|
||||
capturedToken = cancellationToken)
|
||||
.ReturnsAsync(this._chatClientResponse);
|
||||
|
||||
StructuredOutputAgent agent = new(this._innerAgent, this._chatClientMock.Object);
|
||||
AgentRunOptions runOptions = new() { ResponseFormat = CreateJsonResponseFormat() };
|
||||
|
||||
// Act
|
||||
await agent.RunAsync(this._testMessages, this._testSession, runOptions, expectedToken);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedToken, capturedToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ClonesChatOptionsFromAgentOptionsAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ModelId = "test-model";
|
||||
ChatOptions? capturedChatOptions = null;
|
||||
|
||||
this._chatClientMock
|
||||
.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, options, _) =>
|
||||
capturedChatOptions = options)
|
||||
.ReturnsAsync(this._chatClientResponse);
|
||||
|
||||
ChatOptions originalChatOptions = new()
|
||||
{
|
||||
ResponseFormat = CreateJsonResponseFormat(),
|
||||
ModelId = ModelId
|
||||
};
|
||||
StructuredOutputAgentOptions agentOptions = new()
|
||||
{
|
||||
ChatOptions = originalChatOptions
|
||||
};
|
||||
StructuredOutputAgent agent = new(this._innerAgent, this._chatClientMock.Object, agentOptions);
|
||||
|
||||
// Act
|
||||
await agent.RunAsync(this._testMessages, this._testSession, options: null);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedChatOptions);
|
||||
Assert.NotSame(originalChatOptions, capturedChatOptions);
|
||||
Assert.Equal(ModelId, capturedChatOptions.ModelId);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RunAsync Tests - Response
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsStructuredOutputAgentResponseAsync()
|
||||
{
|
||||
// Arrange
|
||||
StructuredOutputAgent agent = new(this._innerAgent, this._chatClientMock.Object);
|
||||
AgentRunOptions runOptions = new() { ResponseFormat = CreateJsonResponseFormat() };
|
||||
|
||||
// Act
|
||||
AgentResponse result = await agent.RunAsync(this._testMessages, this._testSession, runOptions);
|
||||
|
||||
// Assert
|
||||
Assert.IsType<StructuredOutputAgentResponse>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_StructuredOutputResponseContainsOriginalResponseAsync()
|
||||
{
|
||||
// Arrange
|
||||
StructuredOutputAgent agent = new(this._innerAgent, this._chatClientMock.Object);
|
||||
AgentRunOptions runOptions = new() { ResponseFormat = CreateJsonResponseFormat() };
|
||||
|
||||
// Act
|
||||
AgentResponse result = await agent.RunAsync(this._testMessages, this._testSession, runOptions);
|
||||
|
||||
// Assert
|
||||
StructuredOutputAgentResponse structuredResponse = Assert.IsType<StructuredOutputAgentResponse>(result);
|
||||
Assert.Same(this._innerAgentResponse, structuredResponse.OriginalResponse);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_StructuredOutputResponseContainsChatClientResponseDataAsync()
|
||||
{
|
||||
// Arrange
|
||||
StructuredOutputAgent agent = new(this._innerAgent, this._chatClientMock.Object);
|
||||
AgentRunOptions runOptions = new() { ResponseFormat = CreateJsonResponseFormat() };
|
||||
|
||||
// Act
|
||||
AgentResponse result = await agent.RunAsync(this._testMessages, this._testSession, runOptions);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("{\"result\": \"structured output\"}", result.Text);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Handling Tests
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_InnerAgentThrowsException_PropagatesExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
InvalidOperationException expectedException = new("Inner agent error");
|
||||
this._innerAgent.RunAsyncFunc = (_, _, _, _) => throw expectedException;
|
||||
|
||||
StructuredOutputAgent agent = new(this._innerAgent, this._chatClientMock.Object);
|
||||
AgentRunOptions runOptions = new() { ResponseFormat = CreateJsonResponseFormat() };
|
||||
|
||||
// Act & Assert
|
||||
InvalidOperationException actualException = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => agent.RunAsync(this._testMessages, this._testSession, runOptions));
|
||||
|
||||
Assert.Same(expectedException, actualException);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ChatClientThrowsException_PropagatesExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
InvalidOperationException expectedException = new("Chat client error");
|
||||
|
||||
this._chatClientMock
|
||||
.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(expectedException);
|
||||
|
||||
StructuredOutputAgent agent = new(this._innerAgent, this._chatClientMock.Object);
|
||||
AgentRunOptions runOptions = new() { ResponseFormat = CreateJsonResponseFormat() };
|
||||
|
||||
// Act & Assert
|
||||
InvalidOperationException actualException = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => agent.RunAsync(this._testMessages, this._testSession, runOptions));
|
||||
|
||||
Assert.Same(expectedException, actualException);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_CancellationRequested_ThrowsOperationCanceledExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
using CancellationTokenSource cts = new();
|
||||
cts.Cancel();
|
||||
|
||||
this._chatClientMock
|
||||
.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new OperationCanceledException());
|
||||
|
||||
StructuredOutputAgent agent = new(this._innerAgent, this._chatClientMock.Object);
|
||||
AgentRunOptions runOptions = new() { ResponseFormat = CreateJsonResponseFormat() };
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<OperationCanceledException>(
|
||||
() => agent.RunAsync(this._testMessages, this._testSession, runOptions, cts.Token));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ChatOptions Creation Tests
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_CreatesNewChatOptionsWhenAgentOptionsIsNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatOptions? capturedChatOptions = null;
|
||||
ChatResponseFormatJson expectedFormat = CreateJsonResponseFormat();
|
||||
|
||||
this._chatClientMock
|
||||
.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, options, _) =>
|
||||
capturedChatOptions = options)
|
||||
.ReturnsAsync(this._chatClientResponse);
|
||||
|
||||
StructuredOutputAgent agent = new(this._innerAgent, this._chatClientMock.Object, options: null);
|
||||
AgentRunOptions runOptions = new() { ResponseFormat = expectedFormat };
|
||||
|
||||
// Act
|
||||
await agent.RunAsync(this._testMessages, this._testSession, runOptions);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedChatOptions);
|
||||
Assert.Same(expectedFormat, capturedChatOptions.ResponseFormat);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_CreatesNewChatOptionsWhenAgentOptionsChatOptionsIsNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatOptions? capturedChatOptions = null;
|
||||
ChatResponseFormatJson expectedFormat = CreateJsonResponseFormat();
|
||||
|
||||
this._chatClientMock
|
||||
.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, options, _) =>
|
||||
capturedChatOptions = options)
|
||||
.ReturnsAsync(this._chatClientResponse);
|
||||
|
||||
StructuredOutputAgentOptions agentOptions = new() { ChatOptions = null };
|
||||
StructuredOutputAgent agent = new(this._innerAgent, this._chatClientMock.Object, agentOptions);
|
||||
AgentRunOptions runOptions = new() { ResponseFormat = expectedFormat };
|
||||
|
||||
// Act
|
||||
await agent.RunAsync(this._testMessages, this._testSession, runOptions);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedChatOptions);
|
||||
Assert.Same(expectedFormat, capturedChatOptions.ResponseFormat);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private static ChatResponseFormatJson CreateJsonResponseFormat()
|
||||
{
|
||||
// Create a simple JSON schema for testing
|
||||
const string SchemaJson = """{"type":"object","properties":{"result":{"type":"string"}},"required":["result"]}""";
|
||||
using JsonDocument doc = JsonDocument.Parse(SchemaJson);
|
||||
|
||||
return ChatResponseFormat.ForJsonSchema(
|
||||
doc.RootElement.Clone(),
|
||||
schemaName: "TestSchema",
|
||||
schemaDescription: "Test schema for unit tests");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user