mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: [Breaking] Structured Output improvements (#3761)
* .NET: Delete AgentResponse.{Try}Deserialize<T> methods (#3518)
* delete deserialize method of agent response
* order usings
* Update dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step05_StructuredOutput/Program.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/SharedStateAgent.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update dotnet/samples/AGUIClientServer/AGUIDojoServer/SharedState/SharedStateAgent.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update dotnet/samples/M365Agent/Agents/WeatherForecastAgent.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* .NET:[Breaking] Add support for structured output (#3658)
* add support for so
* restore lost xml comment part
* fix using ordering
* Update dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_SO_WithFormatResponseTests.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* addressw pr review comments
* address pr review feedback
* address pr review comments
* fix compilation issues after the latest merge with main
* remove unnecessry options
* remove RunAsync<object> methods
* address code review feedback
* address pr review feedback
* make copy constructor protected
* address pr review feedback
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* .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>
* .NET: Support primitives and arrays for SO (#3696)
* wrap primitives and arrays
* fix file encoding
* address review comments
* add adr
* add missed change
* fix compilation issue
* address review comments
* rename adr file name
* reflect decision to have SO decorator as a reference implementation in samples
* .NET: Move SO agent to samples (#3820)
* move SO agent to samples
* change file encoding
* fix files encoding
* .NET: Preserve caller context (#3803)
* fix stuck orchestration
* add previously removed RunAsync<T> method to DurableAIAgent
* suppress IDE0005 warning
* update changelog and remove unused constructor of AgentResponse<T>
* updatge the changelog
* address PR review feedback
* .NET: Disable irrelevant integration test (#3913)
* disable irrelevant integration test
* Update dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* forgotten change
* address pr review feedback
* disable intermittently failing integration test.
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: westey <164392973+westey-m@users.noreply.github.com>
This commit is contained in:
co-authored by
Copilot
westey
parent
3168eb4870
commit
9506fb28f6
@@ -389,6 +389,9 @@
|
||||
<File Path="src/Shared/Throw/README.md" />
|
||||
<File Path="src/Shared/Throw/Throw.cs" />
|
||||
</Folder>
|
||||
<Folder Name="/Solution Items/src/Shared/StructuredOutput/">
|
||||
<File Path="src/Shared/StructuredOutput/StructuredOutputSchemaUtilities.cs" />
|
||||
</Folder>
|
||||
<Folder Name="/Solution Items/tests/">
|
||||
<File Path="tests/.editorconfig" />
|
||||
<File Path="tests/Directory.Build.props" />
|
||||
|
||||
@@ -20,4 +20,7 @@
|
||||
<ItemGroup Condition="'$(InjectSharedFoundryAgents)' == 'true'">
|
||||
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\Foundry\Agents\*.cs" LinkBase="Shared\Foundry" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Condition="'$(InjectSharedStructuredOutput)' == 'true'">
|
||||
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\StructuredOutput\*.cs" LinkBase="Shared\StructuredOutput" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -78,7 +78,7 @@ internal sealed class SharedStateAgent : DelegatingAIAgent
|
||||
|
||||
var response = allUpdates.ToAgentResponse();
|
||||
|
||||
if (response.TryDeserialize(this._jsonSerializerOptions, out JsonElement stateSnapshot))
|
||||
if (TryDeserialize(response.Text, this._jsonSerializerOptions, out JsonElement stateSnapshot))
|
||||
{
|
||||
byte[] stateBytes = JsonSerializer.SerializeToUtf8Bytes(
|
||||
stateSnapshot,
|
||||
@@ -103,4 +103,25 @@ internal sealed class SharedStateAgent : DelegatingAIAgent
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryDeserialize<T>(string json, JsonSerializerOptions jsonSerializerOptions, out T structuredOutput)
|
||||
{
|
||||
try
|
||||
{
|
||||
T? result = JsonSerializer.Deserialize<T>(json, jsonSerializerOptions);
|
||||
if (result is null)
|
||||
{
|
||||
structuredOutput = default!;
|
||||
return false;
|
||||
}
|
||||
|
||||
structuredOutput = result;
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
structuredOutput = default!;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-1
@@ -107,7 +107,7 @@ internal sealed class SharedStateAgent : DelegatingAIAgent
|
||||
var response = allUpdates.ToAgentResponse();
|
||||
|
||||
// Try to deserialize the structured state response
|
||||
if (response.TryDeserialize(this._jsonSerializerOptions, out JsonElement stateSnapshot))
|
||||
if (TryDeserialize(response.Text, this._jsonSerializerOptions, out JsonElement stateSnapshot))
|
||||
{
|
||||
// Serialize and emit as STATE_SNAPSHOT via DataContent
|
||||
byte[] stateBytes = JsonSerializer.SerializeToUtf8Bytes(
|
||||
@@ -134,4 +134,25 @@ internal sealed class SharedStateAgent : DelegatingAIAgent
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryDeserialize<T>(string json, JsonSerializerOptions jsonSerializerOptions, out T structuredOutput)
|
||||
{
|
||||
try
|
||||
{
|
||||
T? deserialized = JsonSerializer.Deserialize<T>(json, jsonSerializerOptions);
|
||||
if (deserialized is null)
|
||||
{
|
||||
structuredOutput = default!;
|
||||
return false;
|
||||
}
|
||||
|
||||
structuredOutput = deserialized;
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
structuredOutput = default!;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace SampleApp;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for adding structured output capabilities to <see cref="AIAgentBuilder"/> instances.
|
||||
/// </summary>
|
||||
internal 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)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
return 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());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
@@ -23,52 +25,159 @@ ChatClient chatClient = new AzureOpenAIClient(
|
||||
new DefaultAzureCredential())
|
||||
.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, 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 = (await updates.ToAgentResponseAsync()).Deserialize<PersonInfo>(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 as JSON in scenarios when JSON as text is required
|
||||
// and no object instance is needed (e.g., for logging, forwarding to another service, or storing in a database).
|
||||
Console.WriteLine("Assistant Output (JSON):");
|
||||
Console.WriteLine(response.Text);
|
||||
Console.WriteLine();
|
||||
|
||||
// Deserialize the JSON text to work with the structured object in scenarios when you need to access properties,
|
||||
// perform operations, or pass the data to methods that require the typed object instance.
|
||||
CityInfo cityInfo = JsonSerializer.Deserialize<CityInfo>(response.Text)!;
|
||||
|
||||
Console.WriteLine("Assistant Output (Deserialized):");
|
||||
Console.WriteLine($"Name: {cityInfo.Name}");
|
||||
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.
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace SampleApp;
|
||||
|
||||
/// <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 = chatClient ?? throw new ArgumentNullException(nameof(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;
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace SampleApp;
|
||||
|
||||
/// <summary>
|
||||
/// Represents configuration options for a <see cref="StructuredOutputAgent"/>.
|
||||
/// </summary>
|
||||
#pragma warning disable CA1812 // Instantiated via AIAgentBuilderExtensions.UseStructuredOutput optionsFactory parameter
|
||||
internal sealed class StructuredOutputAgentOptions
|
||||
#pragma warning restore CA1812
|
||||
{
|
||||
/// <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; }
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace SampleApp;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an agent response that contains structured output and
|
||||
/// the original agent response from which the structured output was generated.
|
||||
/// </summary>
|
||||
internal sealed 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>
|
||||
public 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; }
|
||||
}
|
||||
+2
-1
@@ -64,7 +64,8 @@ IAsyncEnumerable<AgentResponseUpdate> updates = agentWithPersonInfo.RunStreaming
|
||||
|
||||
// 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 = (await updates.ToAgentResponseAsync()).Deserialize<PersonInfo>(JsonSerializerOptions.Web);
|
||||
PersonInfo personInfo = JsonSerializer.Deserialize<PersonInfo>((await updates.ToAgentResponseAsync()).Text, JsonSerializerOptions.Web)
|
||||
?? throw new InvalidOperationException("Failed to deserialize the streamed response into PersonInfo.");
|
||||
|
||||
Console.WriteLine("Assistant Output:");
|
||||
Console.WriteLine($"Name: {personInfo.Name}");
|
||||
|
||||
+2
-1
@@ -330,7 +330,8 @@ internal sealed class CriticExecutor : Executor<ChatMessage, CriticDecision>
|
||||
|
||||
// Convert the stream to a response and deserialize the structured output
|
||||
AgentResponse response = await updates.ToAgentResponseAsync(cancellationToken);
|
||||
CriticDecision decision = response.Deserialize<CriticDecision>(JsonSerializerOptions.Web);
|
||||
CriticDecision decision = JsonSerializer.Deserialize<CriticDecision>(response.Text, JsonSerializerOptions.Web)
|
||||
?? throw new JsonException("Failed to deserialize CriticDecision from response text.");
|
||||
|
||||
Console.WriteLine($"Decision: {(decision.Approved ? "✅ APPROVED" : "❌ NEEDS REVISION")}");
|
||||
if (!string.IsNullOrEmpty(decision.Feedback))
|
||||
|
||||
@@ -54,7 +54,7 @@ public class WeatherForecastAgent : DelegatingAIAgent
|
||||
|
||||
// If the agent returned a valid structured output response
|
||||
// we might be able to enhance the response with an adaptive card.
|
||||
if (response.TryDeserialize<WeatherForecastAgentResponse>(JsonSerializerOptions.Web, out var structuredOutput))
|
||||
if (TryDeserialize<WeatherForecastAgentResponse>(response.Text, JsonSerializerOptions.Web, out var structuredOutput))
|
||||
{
|
||||
var textContentMessage = response.Messages.FirstOrDefault(x => x.Contents.OfType<TextContent>().Any());
|
||||
if (textContentMessage is not null)
|
||||
@@ -112,4 +112,25 @@ public class WeatherForecastAgent : DelegatingAIAgent
|
||||
});
|
||||
return card;
|
||||
}
|
||||
|
||||
private static bool TryDeserialize<T>(string json, JsonSerializerOptions jsonSerializerOptions, out T structuredOutput)
|
||||
{
|
||||
try
|
||||
{
|
||||
T? result = JsonSerializer.Deserialize<T>(json, jsonSerializerOptions);
|
||||
if (result is null)
|
||||
{
|
||||
structuredOutput = default!;
|
||||
return false;
|
||||
}
|
||||
|
||||
structuredOutput = result;
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
structuredOutput = default!;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ namespace Microsoft.Agents.AI;
|
||||
/// may involve multiple agents working together.
|
||||
/// </remarks>
|
||||
[DebuggerDisplay("{DebuggerDisplay,nq}")]
|
||||
public abstract class AIAgent
|
||||
public abstract partial class AIAgent
|
||||
{
|
||||
private static readonly AsyncLocal<AgentRunContext?> s_currentContext = new();
|
||||
|
||||
|
||||
+33
-58
@@ -11,155 +11,130 @@ using Microsoft.Shared.Diagnostics;
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an <see cref="AIAgent"/> that delegates to an <see cref="IChatClient"/> implementation.
|
||||
/// Provides structured output methods for <see cref="AIAgent"/> that enable requesting responses in a specific type format.
|
||||
/// </summary>
|
||||
public sealed partial class ChatClientAgent
|
||||
public abstract partial class AIAgent
|
||||
{
|
||||
/// <summary>
|
||||
/// Run the agent with no message assuming that all required instructions are already provided to the agent or on the session, and requesting a response of the specified type <typeparamref name="T"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of structured output to request.</typeparam>
|
||||
/// <param name="session">
|
||||
/// The conversation session to use for this invocation. If <see langword="null"/>, a new session will be created.
|
||||
/// The session will be updated with any response messages generated during invocation.
|
||||
/// </param>
|
||||
/// <param name="serializerOptions">The JSON serialization options to use.</param>
|
||||
/// <param name="serializerOptions">Optional JSON serializer options to use for deserializing the response.</param>
|
||||
/// <param name="options">Optional configuration parameters for controlling the agent's invocation behavior.</param>
|
||||
/// <param name="useJsonSchemaResponseFormat">
|
||||
/// <see langword="true" /> to set a JSON schema on the <see cref="ChatResponseFormat"/>; otherwise, <see langword="false" />. The default is <see langword="true" />.
|
||||
/// Using a JSON schema improves reliability if the underlying model supports native structured output with a schema, but might cause an error if the model does not support it.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentResponse"/> with the agent's output.</returns>
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentResponse{T}"/> with the agent's output.</returns>
|
||||
/// <remarks>
|
||||
/// This overload is useful when the agent has sufficient context from previous messages in the session
|
||||
/// or from its initial configuration to generate a meaningful response without additional input.
|
||||
/// </remarks>
|
||||
public Task<ChatClientAgentResponse<T>> RunAsync<T>(
|
||||
public Task<AgentResponse<T>> RunAsync<T>(
|
||||
AgentSession? session = null,
|
||||
JsonSerializerOptions? serializerOptions = null,
|
||||
AgentRunOptions? options = null,
|
||||
bool? useJsonSchemaResponseFormat = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
this.RunAsync<T>([], session, serializerOptions, options, useJsonSchemaResponseFormat, cancellationToken);
|
||||
this.RunAsync<T>([], session, serializerOptions, options, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Runs the agent with a text message from the user, requesting a response of the specified type <typeparamref name="T"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of structured output to request.</typeparam>
|
||||
/// <param name="message">The user message to send to the agent.</param>
|
||||
/// <param name="session">
|
||||
/// The conversation session to use for this invocation. If <see langword="null"/>, a new session will be created.
|
||||
/// The session will be updated with the input message and any response messages generated during invocation.
|
||||
/// </param>
|
||||
/// <param name="serializerOptions">The JSON serialization options to use.</param>
|
||||
/// <param name="serializerOptions">Optional JSON serializer options to use for deserializing the response.</param>
|
||||
/// <param name="options">Optional configuration parameters for controlling the agent's invocation behavior.</param>
|
||||
/// <param name="useJsonSchemaResponseFormat">
|
||||
/// <see langword="true" /> to set a JSON schema on the <see cref="ChatResponseFormat"/>; otherwise, <see langword="false" />. The default is <see langword="true" />.
|
||||
/// Using a JSON schema improves reliability if the underlying model supports native structured output with a schema, but might cause an error if the model does not support it.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentResponse"/> with the agent's output.</returns>
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentResponse{T}"/> with the agent's output.</returns>
|
||||
/// <exception cref="ArgumentException"><paramref name="message"/> is <see langword="null"/>, empty, or contains only whitespace.</exception>
|
||||
/// <remarks>
|
||||
/// The provided text will be wrapped in a <see cref="ChatMessage"/> with the <see cref="ChatRole.User"/> role
|
||||
/// before being sent to the agent. This is a convenience method for simple text-based interactions.
|
||||
/// </remarks>
|
||||
public Task<ChatClientAgentResponse<T>> RunAsync<T>(
|
||||
public Task<AgentResponse<T>> RunAsync<T>(
|
||||
string message,
|
||||
AgentSession? session = null,
|
||||
JsonSerializerOptions? serializerOptions = null,
|
||||
AgentRunOptions? options = null,
|
||||
bool? useJsonSchemaResponseFormat = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = Throw.IfNullOrWhitespace(message);
|
||||
|
||||
return this.RunAsync<T>(new ChatMessage(ChatRole.User, message), session, serializerOptions, options, useJsonSchemaResponseFormat, cancellationToken);
|
||||
return this.RunAsync<T>(new ChatMessage(ChatRole.User, message), session, serializerOptions, options, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the agent with a single chat message, requesting a response of the specified type <typeparamref name="T"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of structured output to request.</typeparam>
|
||||
/// <param name="message">The chat message to send to the agent.</param>
|
||||
/// <param name="session">
|
||||
/// The conversation session to use for this invocation. If <see langword="null"/>, a new session will be created.
|
||||
/// The session will be updated with the input message and any response messages generated during invocation.
|
||||
/// </param>
|
||||
/// <param name="serializerOptions">The JSON serialization options to use.</param>
|
||||
/// <param name="serializerOptions">Optional JSON serializer options to use for deserializing the response.</param>
|
||||
/// <param name="options">Optional configuration parameters for controlling the agent's invocation behavior.</param>
|
||||
/// <param name="useJsonSchemaResponseFormat">
|
||||
/// <see langword="true" /> to set a JSON schema on the <see cref="ChatResponseFormat"/>; otherwise, <see langword="false" />. The default is <see langword="true" />.
|
||||
/// Using a JSON schema improves reliability if the underlying model supports native structured output with a schema, but might cause an error if the model does not support it.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentResponse"/> with the agent's output.</returns>
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentResponse{T}"/> with the agent's output.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="message"/> is <see langword="null"/>.</exception>
|
||||
public Task<ChatClientAgentResponse<T>> RunAsync<T>(
|
||||
public Task<AgentResponse<T>> RunAsync<T>(
|
||||
ChatMessage message,
|
||||
AgentSession? session = null,
|
||||
JsonSerializerOptions? serializerOptions = null,
|
||||
AgentRunOptions? options = null,
|
||||
bool? useJsonSchemaResponseFormat = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = Throw.IfNull(message);
|
||||
|
||||
return this.RunAsync<T>([message], session, serializerOptions, options, useJsonSchemaResponseFormat, cancellationToken);
|
||||
return this.RunAsync<T>([message], session, serializerOptions, options, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the agent with a collection of chat messages, requesting a response of the specified type <typeparamref name="T"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of structured output to request.</typeparam>
|
||||
/// <param name="messages">The collection of messages to send to the agent for processing.</param>
|
||||
/// <param name="session">
|
||||
/// The conversation session to use for this invocation. If <see langword="null"/>, a new session will be created.
|
||||
/// The session will be updated with the input messages and any response messages generated during invocation.
|
||||
/// </param>
|
||||
/// <param name="serializerOptions">The JSON serialization options to use.</param>
|
||||
/// <param name="serializerOptions">Optional JSON serializer options to use for deserializing the response.</param>
|
||||
/// <param name="options">Optional configuration parameters for controlling the agent's invocation behavior.</param>
|
||||
/// <param name="useJsonSchemaResponseFormat">
|
||||
/// <see langword="true" /> to set a JSON schema on the <see cref="ChatResponseFormat"/>; otherwise, <see langword="false" />. The default is <see langword="true" />.
|
||||
/// Using a JSON schema improves reliability if the underlying model supports native structured output with a schema, but might cause an error if the model does not support it.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentResponse"/> with the agent's output.</returns>
|
||||
/// <typeparam name="T">The type of structured output to request.</typeparam>
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentResponse{T}"/> with the agent's output.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is the primary invocation method that implementations must override. It handles collections of messages,
|
||||
/// allowing for complex conversational scenarios including multi-turn interactions, function calls, and
|
||||
/// context-rich conversations.
|
||||
/// This method handles collections of messages, allowing for complex conversational scenarios including
|
||||
/// multi-turn interactions, function calls, and context-rich conversations.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The messages are processed in the order provided and become part of the conversation history.
|
||||
/// The agent's response will also be added to <paramref name="session"/> if one is provided.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public Task<ChatClientAgentResponse<T>> RunAsync<T>(
|
||||
public async Task<AgentResponse<T>> RunAsync<T>(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
JsonSerializerOptions? serializerOptions = null,
|
||||
AgentRunOptions? options = null,
|
||||
bool? useJsonSchemaResponseFormat = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
async Task<ChatResponse<T>> GetResponseAsync(IChatClient chatClient, List<ChatMessage> threadMessages, ChatOptions? chatOptions, CancellationToken ct)
|
||||
{
|
||||
return await chatClient.GetResponseAsync<T>(
|
||||
threadMessages,
|
||||
serializerOptions ?? AgentJsonUtilities.DefaultOptions,
|
||||
chatOptions,
|
||||
useJsonSchemaResponseFormat,
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
serializerOptions ??= AgentAbstractionsJsonUtilities.DefaultOptions;
|
||||
|
||||
static ChatClientAgentResponse<T> CreateResponse(ChatResponse<T> chatResponse)
|
||||
{
|
||||
return new ChatClientAgentResponse<T>(chatResponse)
|
||||
{
|
||||
ContinuationToken = WrapContinuationToken(chatResponse.ContinuationToken)
|
||||
};
|
||||
}
|
||||
var responseFormat = ChatResponseFormat.ForJsonSchema<T>(serializerOptions);
|
||||
|
||||
return this.RunCoreAsync(GetResponseAsync, CreateResponse, messages, session, options, cancellationToken);
|
||||
(responseFormat, bool isWrappedInObject) = StructuredOutputSchemaUtilities.WrapNonObjectSchema(responseFormat);
|
||||
|
||||
options = options?.Clone() ?? new AgentRunOptions();
|
||||
options.ResponseFormat = responseFormat;
|
||||
|
||||
AgentResponse response = await this.RunAsync(messages, session, options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new AgentResponse<T>(response, serializerOptions) { IsWrappedInObject = isWrappedInObject };
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,11 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
#if NET
|
||||
using System.Buffers;
|
||||
#endif
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
#if NET
|
||||
using System.Text;
|
||||
#endif
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
@@ -76,6 +67,29 @@ public class AgentResponse
|
||||
this.ContinuationToken = response.ContinuationToken;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentResponse"/> class from an existing <see cref="AgentResponse"/>.
|
||||
/// </summary>
|
||||
/// <param name="response">The <see cref="AgentResponse"/> from which to copy properties.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="response"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>
|
||||
/// This constructor creates a copy of an existing agent response, preserving all
|
||||
/// metadata and storing the original response in <see cref="RawRepresentation"/> for access to
|
||||
/// the underlying implementation details.
|
||||
/// </remarks>
|
||||
protected AgentResponse(AgentResponse response)
|
||||
{
|
||||
_ = Throw.IfNull(response);
|
||||
|
||||
this.AdditionalProperties = response.AdditionalProperties;
|
||||
this.CreatedAt = response.CreatedAt;
|
||||
this.Messages = response.Messages;
|
||||
this.RawRepresentation = response;
|
||||
this.ResponseId = response.ResponseId;
|
||||
this.Usage = response.Usage;
|
||||
this.ContinuationToken = response.ContinuationToken;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentResponse"/> class with the specified collection of messages.
|
||||
/// </summary>
|
||||
@@ -274,117 +288,4 @@ public class AgentResponse
|
||||
|
||||
return updates;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes the response text into the given type.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The output type to deserialize into.</typeparam>
|
||||
/// <returns>The result as the requested type.</returns>
|
||||
/// <exception cref="InvalidOperationException">The result is not parsable into the requested type.</exception>
|
||||
public T Deserialize<T>() =>
|
||||
this.Deserialize<T>(AgentAbstractionsJsonUtilities.DefaultOptions);
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes the response text into the given type using the specified serializer options.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The output type to deserialize into.</typeparam>
|
||||
/// <param name="serializerOptions">The JSON serialization options to use.</param>
|
||||
/// <returns>The result as the requested type.</returns>
|
||||
/// <exception cref="InvalidOperationException">The result is not parsable into the requested type.</exception>
|
||||
public T Deserialize<T>(JsonSerializerOptions serializerOptions)
|
||||
{
|
||||
_ = Throw.IfNull(serializerOptions);
|
||||
|
||||
var structuredOutput = this.GetResultCore<T>(serializerOptions, out var failureReason);
|
||||
return failureReason switch
|
||||
{
|
||||
FailureReason.ResultDidNotContainJson => throw new InvalidOperationException("The response did not contain JSON to be deserialized."),
|
||||
FailureReason.DeserializationProducedNull => throw new InvalidOperationException("The deserialized response is null."),
|
||||
_ => structuredOutput!,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to deserialize response text into the given type.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The output type to deserialize into.</typeparam>
|
||||
/// <param name="structuredOutput">The parsed structured output.</param>
|
||||
/// <returns><see langword="true" /> if parsing was successful; otherwise, <see langword="false" />.</returns>
|
||||
public bool TryDeserialize<T>([NotNullWhen(true)] out T? structuredOutput) =>
|
||||
this.TryDeserialize(AgentAbstractionsJsonUtilities.DefaultOptions, out structuredOutput);
|
||||
|
||||
/// <summary>
|
||||
/// Tries to deserialize response text into the given type using the specified serializer options.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The output type to deserialize into.</typeparam>
|
||||
/// <param name="serializerOptions">The JSON serialization options to use.</param>
|
||||
/// <param name="structuredOutput">The parsed structured output.</param>
|
||||
/// <returns><see langword="true" /> if parsing was successful; otherwise, <see langword="false" />.</returns>
|
||||
public bool TryDeserialize<T>(JsonSerializerOptions serializerOptions, [NotNullWhen(true)] out T? structuredOutput)
|
||||
{
|
||||
_ = Throw.IfNull(serializerOptions);
|
||||
|
||||
try
|
||||
{
|
||||
structuredOutput = this.GetResultCore<T>(serializerOptions, out var failureReason);
|
||||
return failureReason is null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
structuredOutput = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static T? DeserializeFirstTopLevelObject<T>(string json, JsonTypeInfo<T> typeInfo)
|
||||
{
|
||||
#if NET
|
||||
// We need to deserialize only the first top-level object as a workaround for a common LLM backend
|
||||
// issue. GPT 3.5 Turbo commonly returns multiple top-level objects after doing a function call.
|
||||
// See https://community.openai.com/t/2-json-objects-returned-when-using-function-calling-and-json-mode/574348
|
||||
var utf8ByteLength = Encoding.UTF8.GetByteCount(json);
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(utf8ByteLength);
|
||||
try
|
||||
{
|
||||
var utf8SpanLength = Encoding.UTF8.GetBytes(json, 0, json.Length, buffer, 0);
|
||||
var reader = new Utf8JsonReader(new ReadOnlySpan<byte>(buffer, 0, utf8SpanLength), new() { AllowMultipleValues = true });
|
||||
return JsonSerializer.Deserialize(ref reader, typeInfo);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
#else
|
||||
return JsonSerializer.Deserialize(json, typeInfo);
|
||||
#endif
|
||||
}
|
||||
|
||||
private T? GetResultCore<T>(JsonSerializerOptions serializerOptions, out FailureReason? failureReason)
|
||||
{
|
||||
var json = this.Text;
|
||||
if (string.IsNullOrEmpty(json))
|
||||
{
|
||||
failureReason = FailureReason.ResultDidNotContainJson;
|
||||
return default;
|
||||
}
|
||||
|
||||
// If there's an exception here, we want it to propagate, since the Result property is meant to throw directly
|
||||
|
||||
T? deserialized = DeserializeFirstTopLevelObject(json!, (JsonTypeInfo<T>)serializerOptions.GetTypeInfo(typeof(T)));
|
||||
|
||||
if (deserialized is null)
|
||||
{
|
||||
failureReason = FailureReason.DeserializationProducedNull;
|
||||
return default;
|
||||
}
|
||||
|
||||
failureReason = default;
|
||||
return deserialized;
|
||||
}
|
||||
|
||||
private enum FailureReason
|
||||
{
|
||||
ResultDidNotContainJson,
|
||||
DeserializationProducedNull
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
using System;
|
||||
#if NET
|
||||
using System.Buffers;
|
||||
#endif
|
||||
|
||||
#if NET
|
||||
using System.Text;
|
||||
#endif
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
@@ -8,23 +19,80 @@ namespace Microsoft.Agents.AI;
|
||||
/// Represents the response of the specified type <typeparamref name="T"/> to an <see cref="AIAgent"/> run request.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of value expected from the agent.</typeparam>
|
||||
public abstract class AgentResponse<T> : AgentResponse
|
||||
public class AgentResponse<T> : AgentResponse
|
||||
{
|
||||
/// <summary>Initializes a new instance of the <see cref="AgentResponse{T}"/> class.</summary>
|
||||
protected AgentResponse()
|
||||
private readonly JsonSerializerOptions _serializerOptions;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentResponse{T}"/> class.
|
||||
/// </summary>
|
||||
/// <param name="response">The <see cref="AgentResponse"/> from which to populate this <see cref="AgentResponse{T}"/>.</param>
|
||||
/// <param name="serializerOptions">The <see cref="JsonSerializerOptions"/> to use when deserializing the result.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="serializerOptions"/> is <see langword="null"/>.</exception>
|
||||
public AgentResponse(AgentResponse response, JsonSerializerOptions serializerOptions) : base(response)
|
||||
{
|
||||
_ = Throw.IfNull(serializerOptions);
|
||||
|
||||
this._serializerOptions = serializerOptions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentResponse{T}"/> class from an existing <see cref="ChatResponse"/>.
|
||||
/// Gets or sets a value indicating whether the JSON schema has an extra object wrapper.
|
||||
/// </summary>
|
||||
/// <param name="response">The <see cref="ChatResponse"/> from which to populate this <see cref="AgentResponse{T}"/>.</param>
|
||||
protected AgentResponse(ChatResponse response) : base(response)
|
||||
{
|
||||
}
|
||||
/// <remarks>
|
||||
/// The wrapper is required for any non-JSON-object-typed values such as numbers, enum values, and arrays.
|
||||
/// </remarks>
|
||||
public bool IsWrappedInObject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the result value of the agent response as an instance of <typeparamref name="T"/>.
|
||||
/// </summary>
|
||||
public abstract T Result { get; }
|
||||
[JsonIgnore]
|
||||
public virtual T Result
|
||||
{
|
||||
get
|
||||
{
|
||||
var json = this.Text;
|
||||
if (string.IsNullOrEmpty(json))
|
||||
{
|
||||
throw new InvalidOperationException("The response did not contain JSON to be deserialized.");
|
||||
}
|
||||
|
||||
if (this.IsWrappedInObject)
|
||||
{
|
||||
json = StructuredOutputSchemaUtilities.UnwrapResponseData(json!);
|
||||
}
|
||||
|
||||
T? deserialized = DeserializeFirstTopLevelObject(json!, (JsonTypeInfo<T>)this._serializerOptions.GetTypeInfo(typeof(T)));
|
||||
if (deserialized is null)
|
||||
{
|
||||
throw new InvalidOperationException("The deserialized response is null.");
|
||||
}
|
||||
|
||||
return deserialized;
|
||||
}
|
||||
}
|
||||
|
||||
private static T? DeserializeFirstTopLevelObject(string json, JsonTypeInfo<T> typeInfo)
|
||||
{
|
||||
#if NET
|
||||
// We need to deserialize only the first top-level object as a workaround for a common LLM backend
|
||||
// issue. GPT 3.5 Turbo commonly returns multiple top-level objects after doing a function call.
|
||||
// See https://community.openai.com/t/2-json-objects-returned-when-using-function-calling-and-json-mode/574348
|
||||
var utf8ByteLength = Encoding.UTF8.GetByteCount(json);
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(utf8ByteLength);
|
||||
try
|
||||
{
|
||||
var utf8SpanLength = Encoding.UTF8.GetBytes(json, 0, json.Length, buffer, 0);
|
||||
var reader = new Utf8JsonReader(new ReadOnlySpan<byte>(buffer, 0, utf8SpanLength), new() { AllowMultipleValues = true });
|
||||
return JsonSerializer.Deserialize(ref reader, typeInfo);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
#else
|
||||
return JsonSerializer.Deserialize(json, typeInfo);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,12 +28,13 @@ public class AgentRunOptions
|
||||
/// </summary>
|
||||
/// <param name="options">The options instance from which to copy values.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="options"/> is <see langword="null"/>.</exception>
|
||||
public AgentRunOptions(AgentRunOptions options)
|
||||
protected AgentRunOptions(AgentRunOptions options)
|
||||
{
|
||||
_ = Throw.IfNull(options);
|
||||
this.ContinuationToken = options.ContinuationToken;
|
||||
this.AllowBackgroundResponses = options.AllowBackgroundResponses;
|
||||
this.AdditionalProperties = options.AdditionalProperties?.Clone();
|
||||
this.ResponseFormat = options.ResponseFormat;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -90,4 +91,35 @@ public class AgentRunOptions
|
||||
/// preserving implementation-specific details or extending the options with custom data.
|
||||
/// </remarks>
|
||||
public AdditionalPropertiesDictionary? AdditionalProperties { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the response format.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// If <see langword="null"/>, no response format is specified and the agent will use its default.
|
||||
/// This property can be set to <see cref="ChatResponseFormat.Text"/> to specify that the response should be unstructured text,
|
||||
/// to <see cref="ChatResponseFormat.Json"/> to specify that the response should be structured JSON data, or
|
||||
/// an instance of <see cref="ChatResponseFormatJson"/> constructed with a specific JSON schema to request that the
|
||||
/// response be structured JSON data according to that schema. It is up to the agent implementation if or how
|
||||
/// to honor the request. If the agent implementation doesn't recognize the specific kind of <see cref="ChatResponseFormat"/>,
|
||||
/// it can be ignored.
|
||||
/// </remarks>
|
||||
public ChatResponseFormat? ResponseFormat { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Produces a clone of the current <see cref="AgentRunOptions"/> instance.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// A clone of the current <see cref="AgentRunOptions"/> instance.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The clone will have the same values for all properties as the original instance. Any collections, like <see cref="AdditionalProperties"/>,
|
||||
/// are shallow-cloned, meaning a new collection instance is created, but any references contained by the collections are shared with the original.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Derived types should override <see cref="Clone"/> to return an instance of the derived type.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public virtual AgentRunOptions Clone() => new(this);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
<InjectSharedStructuredOutput>true</InjectSharedStructuredOutput>
|
||||
<InjectDiagnosticClassesOnLegacy>true</InjectDiagnosticClassesOnLegacy>
|
||||
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
|
||||
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
- Introduce Core method pattern for Session management methods on AIAgent ([#3699](https://github.com/microsoft/agent-framework/pull/3699))
|
||||
- Changed AIAgent.SerializeSession to AIAgent.SerializeSessionAsync ([#3879](https://github.com/microsoft/agent-framework/pull/3879))
|
||||
- Changed ChatHistory and AIContext Providers to have pipeline semantics ([#3806](https://github.com/microsoft/agent-framework/pull/3806))
|
||||
- Marked all `RunAsync<T>` overloads as `new`, added missing ones, and added support for primitives and arrays ([#3803](https://github.com/microsoft/agent-framework/pull/3803))
|
||||
|
||||
## v1.0.0-preview.251204.1
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Microsoft.DurableTask;
|
||||
using Microsoft.DurableTask.Entities;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
@@ -114,7 +113,6 @@ public sealed class DurableAIAgent : AIAgent
|
||||
{
|
||||
enableToolCalls = durableOptions.EnableToolCalls;
|
||||
enableToolNames = durableOptions.EnableToolNames;
|
||||
responseFormat = durableOptions.ResponseFormat;
|
||||
}
|
||||
else if (options is ChatClientAgentRunOptions chatClientOptions && chatClientOptions.ChatOptions?.Tools != null)
|
||||
{
|
||||
@@ -122,6 +120,12 @@ public sealed class DurableAIAgent : AIAgent
|
||||
responseFormat = chatClientOptions.ChatOptions?.ResponseFormat;
|
||||
}
|
||||
|
||||
// Override the response format if specified in the agent run options
|
||||
if (options?.ResponseFormat is { } format)
|
||||
{
|
||||
responseFormat = format;
|
||||
}
|
||||
|
||||
RunRequest request = new([.. messages], responseFormat, enableToolCalls, enableToolNames)
|
||||
{
|
||||
OrchestrationId = this._context.InstanceId
|
||||
@@ -168,108 +172,125 @@ public sealed class DurableAIAgent : AIAgent
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the agent with a message and returns the deserialized output as an instance of <typeparamref name="T"/>.
|
||||
/// Run the agent with no message assuming that all required instructions are already provided to the agent or on the session, and requesting a response of the specified type <typeparamref name="T"/>.
|
||||
/// </summary>
|
||||
/// <param name="message">The message to send to the agent.</param>
|
||||
/// <param name="session">The agent session to use.</param>
|
||||
/// <param name="serializerOptions">Optional JSON serializer options.</param>
|
||||
/// <param name="options">Optional run options.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <typeparam name="T">The type of the output.</typeparam>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// Thrown when the provided <paramref name="options"/> already contains a response schema.
|
||||
/// Thrown when the provided <paramref name="options"/> is not a <see cref="DurableAgentRunOptions"/>.
|
||||
/// </exception>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown when the agent response is empty or cannot be deserialized.
|
||||
/// </exception>
|
||||
/// <returns>The output from the agent.</returns>
|
||||
public async Task<AgentResponse<T>> RunAsync<T>(
|
||||
/// <typeparam name="T">The type of structured output to request.</typeparam>
|
||||
/// <param name="session">
|
||||
/// The conversation session to use for this invocation. If <see langword="null"/>, a new session will be created.
|
||||
/// The session will be updated with any response messages generated during invocation.
|
||||
/// </param>
|
||||
/// <param name="serializerOptions">Optional JSON serializer options to use for deserializing the response.</param>
|
||||
/// <param name="options">Optional configuration parameters for controlling the agent's invocation behavior.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentResponse{T}"/> with the agent's output.</returns>
|
||||
/// <remarks>
|
||||
/// This method is specific to durable agents because the Durable Task Framework uses a custom
|
||||
/// synchronization context for orchestration execution, and all continuations must run on the
|
||||
/// orchestration thread to avoid breaking the durable orchestration and potential deadlocks.
|
||||
/// </remarks>
|
||||
public new Task<AgentResponse<T>> RunAsync<T>(
|
||||
AgentSession? session = null,
|
||||
JsonSerializerOptions? serializerOptions = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
this.RunAsync<T>([], session, serializerOptions, options, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Runs the agent with a text message from the user, requesting a response of the specified type <typeparamref name="T"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of structured output to request.</typeparam>
|
||||
/// <param name="message">The user message to send to the agent.</param>
|
||||
/// <param name="session">
|
||||
/// The conversation session to use for this invocation. If <see langword="null"/>, a new session will be created.
|
||||
/// The session will be updated with the input message and any response messages generated during invocation.
|
||||
/// </param>
|
||||
/// <param name="serializerOptions">Optional JSON serializer options to use for deserializing the response.</param>
|
||||
/// <param name="options">Optional configuration parameters for controlling the agent's invocation behavior.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentResponse{T}"/> with the agent's output.</returns>
|
||||
/// <exception cref="ArgumentException"><paramref name="message"/> is <see langword="null"/>, empty, or contains only whitespace.</exception>
|
||||
/// <remarks>
|
||||
/// <inheritdoc cref="RunAsync{T}(AgentSession?, JsonSerializerOptions?, AgentRunOptions?, CancellationToken)" path="/remarks" />
|
||||
/// </remarks>
|
||||
public new Task<AgentResponse<T>> RunAsync<T>(
|
||||
string message,
|
||||
AgentSession? session = null,
|
||||
JsonSerializerOptions? serializerOptions = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await this.RunAsync<T>(
|
||||
messages: [new ChatMessage(ChatRole.User, message) { CreatedAt = DateTimeOffset.UtcNow }],
|
||||
session,
|
||||
serializerOptions,
|
||||
options,
|
||||
cancellationToken);
|
||||
_ = Throw.IfNull(message);
|
||||
|
||||
return this.RunAsync<T>(new ChatMessage(ChatRole.User, message), session, serializerOptions, options, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the agent with messages and returns the deserialized output as an instance of <typeparamref name="T"/>.
|
||||
/// Runs the agent with a single chat message, requesting a response of the specified type <typeparamref name="T"/>.
|
||||
/// </summary>
|
||||
/// <param name="messages">The messages to send to the agent.</param>
|
||||
/// <param name="session">The agent session to use.</param>
|
||||
/// <param name="serializerOptions">Optional JSON serializer options.</param>
|
||||
/// <param name="options">Optional run options.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <typeparam name="T">The type of the output.</typeparam>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// Thrown when the provided <paramref name="options"/> already contains a response schema.
|
||||
/// Thrown when the provided <paramref name="options"/> is not a <see cref="DurableAgentRunOptions"/>.
|
||||
/// </exception>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown when the agent response is empty or cannot be deserialized.
|
||||
/// </exception>
|
||||
/// <returns>The output from the agent.</returns>
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Fallback to reflection-based deserialization is intentional for library flexibility with user-defined types.")]
|
||||
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050", Justification = "Fallback to reflection-based deserialization is intentional for library flexibility with user-defined types.")]
|
||||
public async Task<AgentResponse<T>> RunAsync<T>(
|
||||
/// <typeparam name="T">The type of structured output to request.</typeparam>
|
||||
/// <param name="message">The chat message to send to the agent.</param>
|
||||
/// <param name="session">
|
||||
/// The conversation session to use for this invocation. If <see langword="null"/>, a new session will be created.
|
||||
/// The session will be updated with the input message and any response messages generated during invocation.
|
||||
/// </param>
|
||||
/// <param name="serializerOptions">Optional JSON serializer options to use for deserializing the response.</param>
|
||||
/// <param name="options">Optional configuration parameters for controlling the agent's invocation behavior.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentResponse{T}"/> with the agent's output.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="message"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>
|
||||
/// <inheritdoc cref="RunAsync{T}(AgentSession?, JsonSerializerOptions?, AgentRunOptions?, CancellationToken)" path="/remarks" />
|
||||
/// </remarks>
|
||||
public new Task<AgentResponse<T>> RunAsync<T>(
|
||||
ChatMessage message,
|
||||
AgentSession? session = null,
|
||||
JsonSerializerOptions? serializerOptions = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = Throw.IfNull(message);
|
||||
|
||||
return this.RunAsync<T>([message], session, serializerOptions, options, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the agent with a collection of chat messages, requesting a response of the specified type <typeparamref name="T"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of structured output to request.</typeparam>
|
||||
/// <param name="messages">The collection of messages to send to the agent for processing.</param>
|
||||
/// <param name="session">
|
||||
/// The conversation session to use for this invocation. If <see langword="null"/>, a new session will be created.
|
||||
/// The session will be updated with the input messages and any response messages generated during invocation.
|
||||
/// </param>
|
||||
/// <param name="serializerOptions">Optional JSON serializer options to use for deserializing the response.</param>
|
||||
/// <param name="options">Optional configuration parameters for controlling the agent's invocation behavior.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentResponse{T}"/> with the agent's output.</returns>
|
||||
/// <remarks>
|
||||
/// <inheritdoc cref="RunAsync{T}(AgentSession?, JsonSerializerOptions?, AgentRunOptions?, CancellationToken)" path="/remarks" />
|
||||
/// </remarks>
|
||||
public new async Task<AgentResponse<T>> RunAsync<T>(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
JsonSerializerOptions? serializerOptions = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
options ??= new DurableAgentRunOptions();
|
||||
if (options is not DurableAgentRunOptions durableOptions)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Response schema is only supported with DurableAgentRunOptions when using durable agents. " +
|
||||
"Cannot specify a response schema when calling RunAsync<T>.",
|
||||
paramName: nameof(options));
|
||||
}
|
||||
serializerOptions ??= AgentAbstractionsJsonUtilities.DefaultOptions;
|
||||
|
||||
if (durableOptions.ResponseFormat is not null)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"A response schema is already defined in the provided DurableAgentRunOptions. " +
|
||||
"Cannot specify a response schema when calling RunAsync<T>.",
|
||||
paramName: nameof(options));
|
||||
}
|
||||
var responseFormat = ChatResponseFormat.ForJsonSchema<T>(serializerOptions);
|
||||
|
||||
// Create the JSON schema for the response type
|
||||
durableOptions.ResponseFormat = ChatResponseFormat.ForJsonSchema<T>();
|
||||
(responseFormat, bool isWrappedInObject) = StructuredOutputSchemaUtilities.WrapNonObjectSchema(responseFormat);
|
||||
|
||||
AgentResponse response = await this.RunAsync(messages, session, durableOptions, cancellationToken);
|
||||
options = options?.Clone() ?? new DurableAgentRunOptions();
|
||||
options.ResponseFormat = responseFormat;
|
||||
|
||||
// Deserialize the response text to the requested type
|
||||
if (string.IsNullOrEmpty(response.Text))
|
||||
{
|
||||
throw new InvalidOperationException("Agent response is empty and cannot be deserialized.");
|
||||
}
|
||||
// ConfigureAwait(false) cannot be used here because the Durable Task Framework uses
|
||||
// a custom synchronization context that requires all continuations to execute on the
|
||||
// orchestration thread. Scheduling the continuation on an arbitrary thread would break
|
||||
// the orchestration.
|
||||
AgentResponse response = await this.RunAsync(messages, session, options, cancellationToken);
|
||||
|
||||
serializerOptions ??= DurableAgentJsonUtilities.DefaultOptions;
|
||||
|
||||
// Prefer source-generated metadata when available to support AOT/trimming scenarios.
|
||||
// Fallback to reflection-based deserialization for types without source-generated metadata.
|
||||
// This is necessary since T is a user-provided type that may not have [JsonSerializable] coverage.
|
||||
JsonTypeInfo? typeInfo = serializerOptions.GetTypeInfo(typeof(T));
|
||||
T? result = (typeInfo is JsonTypeInfo typedInfo
|
||||
? (T?)JsonSerializer.Deserialize(response.Text, typedInfo)
|
||||
: JsonSerializer.Deserialize<T>(response.Text, serializerOptions))
|
||||
?? throw new InvalidOperationException($"Failed to deserialize agent response to type {typeof(T).Name}.");
|
||||
|
||||
return new DurableAIAgentResponse<T>(response, result);
|
||||
}
|
||||
|
||||
private sealed class DurableAIAgentResponse<T>(AgentResponse response, T result)
|
||||
: AgentResponse<T>(response.AsChatResponse())
|
||||
{
|
||||
public override T Result { get; } = result;
|
||||
return new AgentResponse<T>(response, serializerOptions) { IsWrappedInObject = isWrappedInObject };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,6 @@ internal class DurableAIAgentProxy(string name, IDurableAgentClient agentClient)
|
||||
{
|
||||
enableToolCalls = durableOptions.EnableToolCalls;
|
||||
enableToolNames = durableOptions.EnableToolNames;
|
||||
responseFormat = durableOptions.ResponseFormat;
|
||||
isFireAndForget = durableOptions.IsFireAndForget;
|
||||
}
|
||||
else if (options is ChatClientAgentRunOptions chatClientOptions)
|
||||
@@ -71,6 +70,12 @@ internal class DurableAIAgentProxy(string name, IDurableAgentClient agentClient)
|
||||
responseFormat = chatClientOptions.ChatOptions?.ResponseFormat;
|
||||
}
|
||||
|
||||
// Override the response format if specified in the agent run options
|
||||
if (options?.ResponseFormat is { } format)
|
||||
{
|
||||
responseFormat = format;
|
||||
}
|
||||
|
||||
RunRequest request = new([.. messages], responseFormat, enableToolCalls, enableToolNames);
|
||||
AgentSessionId sessionId = durableSession.SessionId;
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
@@ -9,6 +7,25 @@ namespace Microsoft.Agents.AI.DurableTask;
|
||||
/// </summary>
|
||||
public sealed class DurableAgentRunOptions : AgentRunOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DurableAgentRunOptions"/> class.
|
||||
/// </summary>
|
||||
public DurableAgentRunOptions()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DurableAgentRunOptions"/> class by copying values from the specified options.
|
||||
/// </summary>
|
||||
/// <param name="options">The options instance from which to copy values.</param>
|
||||
private DurableAgentRunOptions(DurableAgentRunOptions options)
|
||||
: base(options)
|
||||
{
|
||||
this.EnableToolCalls = options.EnableToolCalls;
|
||||
this.EnableToolNames = options.EnableToolNames is not null ? new List<string>(options.EnableToolNames) : null;
|
||||
this.IsFireAndForget = options.IsFireAndForget;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to enable tool calls for this request.
|
||||
/// </summary>
|
||||
@@ -19,11 +36,6 @@ public sealed class DurableAgentRunOptions : AgentRunOptions
|
||||
/// </summary>
|
||||
public IList<string>? EnableToolNames { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the response format for the agent's response.
|
||||
/// </summary>
|
||||
public ChatResponseFormat? ResponseFormat { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to fire and forget the agent run request.
|
||||
/// </summary>
|
||||
@@ -33,4 +45,7 @@ public sealed class DurableAgentRunOptions : AgentRunOptions
|
||||
/// long-running tasks where the caller does not need to wait for the agent to complete the run.
|
||||
/// </remarks>
|
||||
public bool IsFireAndForget { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override AgentRunOptions Clone() => new DurableAgentRunOptions(this);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
<InjectSharedStructuredOutput>true</InjectSharedStructuredOutput>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Durable Task dependencies -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.DurableTask.Client" />
|
||||
|
||||
@@ -612,6 +612,12 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
chatOptions.AllowBackgroundResponses = agentRunOptions.AllowBackgroundResponses;
|
||||
}
|
||||
|
||||
if (agentRunOptions?.ResponseFormat is not null)
|
||||
{
|
||||
chatOptions ??= new ChatOptions();
|
||||
chatOptions.ResponseFormat = agentRunOptions.ResponseFormat;
|
||||
}
|
||||
|
||||
ChatClientAgentContinuationToken? agentContinuationToken = null;
|
||||
|
||||
if ((agentRunOptions?.ContinuationToken ?? chatOptions?.ContinuationToken) is { } continuationToken)
|
||||
|
||||
@@ -162,19 +162,14 @@ public partial class ChatClientAgent
|
||||
/// </param>
|
||||
/// <param name="serializerOptions">The JSON serialization options to use.</param>
|
||||
/// <param name="options">Configuration parameters for controlling the agent's invocation behavior.</param>
|
||||
/// <param name="useJsonSchemaResponseFormat">
|
||||
/// <see langword="true" /> to set a JSON schema on the <see cref="ChatResponseFormat"/>; otherwise, <see langword="false" />. The default is <see langword="true" />.
|
||||
/// Using a JSON schema improves reliability if the underlying model supports native structured output with a schema, but might cause an error if the model does not support it.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="ChatClientAgentResponse{T}"/> with the agent's output.</returns>
|
||||
public Task<ChatClientAgentResponse<T>> RunAsync<T>(
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentResponse{T}"/> with the agent's output.</returns>
|
||||
public Task<AgentResponse<T>> RunAsync<T>(
|
||||
AgentSession? session,
|
||||
JsonSerializerOptions? serializerOptions,
|
||||
ChatClientAgentRunOptions? options,
|
||||
bool? useJsonSchemaResponseFormat = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
this.RunAsync<T>(session, serializerOptions, (AgentRunOptions?)options, useJsonSchemaResponseFormat, cancellationToken);
|
||||
this.RunAsync<T>(session, serializerOptions, (AgentRunOptions?)options, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Runs the agent with a text message from the user, requesting a response of the specified type <typeparamref name="T"/>.
|
||||
@@ -186,20 +181,15 @@ public partial class ChatClientAgent
|
||||
/// </param>
|
||||
/// <param name="serializerOptions">The JSON serialization options to use.</param>
|
||||
/// <param name="options">Configuration parameters for controlling the agent's invocation behavior.</param>
|
||||
/// <param name="useJsonSchemaResponseFormat">
|
||||
/// <see langword="true" /> to set a JSON schema on the <see cref="ChatResponseFormat"/>; otherwise, <see langword="false" />. The default is <see langword="true" />.
|
||||
/// Using a JSON schema improves reliability if the underlying model supports native structured output with a schema, but might cause an error if the model does not support it.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="ChatClientAgentResponse{T}"/> with the agent's output.</returns>
|
||||
public Task<ChatClientAgentResponse<T>> RunAsync<T>(
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentResponse{T}"/> with the agent's output.</returns>
|
||||
public Task<AgentResponse<T>> RunAsync<T>(
|
||||
string message,
|
||||
AgentSession? session,
|
||||
JsonSerializerOptions? serializerOptions,
|
||||
ChatClientAgentRunOptions? options,
|
||||
bool? useJsonSchemaResponseFormat = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
this.RunAsync<T>(message, session, serializerOptions, (AgentRunOptions?)options, useJsonSchemaResponseFormat, cancellationToken);
|
||||
this.RunAsync<T>(message, session, serializerOptions, (AgentRunOptions?)options, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Runs the agent with a single chat message, requesting a response of the specified type <typeparamref name="T"/>.
|
||||
@@ -211,20 +201,15 @@ public partial class ChatClientAgent
|
||||
/// </param>
|
||||
/// <param name="serializerOptions">The JSON serialization options to use.</param>
|
||||
/// <param name="options">Configuration parameters for controlling the agent's invocation behavior.</param>
|
||||
/// <param name="useJsonSchemaResponseFormat">
|
||||
/// <see langword="true" /> to set a JSON schema on the <see cref="ChatResponseFormat"/>; otherwise, <see langword="false" />. The default is <see langword="true" />.
|
||||
/// Using a JSON schema improves reliability if the underlying model supports native structured output with a schema, but might cause an error if the model does not support it.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="ChatClientAgentResponse{T}"/> with the agent's output.</returns>
|
||||
public Task<ChatClientAgentResponse<T>> RunAsync<T>(
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentResponse{T}"/> with the agent's output.</returns>
|
||||
public Task<AgentResponse<T>> RunAsync<T>(
|
||||
ChatMessage message,
|
||||
AgentSession? session,
|
||||
JsonSerializerOptions? serializerOptions,
|
||||
ChatClientAgentRunOptions? options,
|
||||
bool? useJsonSchemaResponseFormat = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
this.RunAsync<T>(message, session, serializerOptions, (AgentRunOptions?)options, useJsonSchemaResponseFormat, cancellationToken);
|
||||
this.RunAsync<T>(message, session, serializerOptions, (AgentRunOptions?)options, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Runs the agent with a collection of chat messages, requesting a response of the specified type <typeparamref name="T"/>.
|
||||
@@ -236,18 +221,13 @@ public partial class ChatClientAgent
|
||||
/// </param>
|
||||
/// <param name="serializerOptions">The JSON serialization options to use.</param>
|
||||
/// <param name="options">Configuration parameters for controlling the agent's invocation behavior.</param>
|
||||
/// <param name="useJsonSchemaResponseFormat">
|
||||
/// <see langword="true" /> to set a JSON schema on the <see cref="ChatResponseFormat"/>; otherwise, <see langword="false" />. The default is <see langword="true" />.
|
||||
/// Using a JSON schema improves reliability if the underlying model supports native structured output with a schema, but might cause an error if the model does not support it.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="ChatClientAgentResponse{T}"/> with the agent's output.</returns>
|
||||
public Task<ChatClientAgentResponse<T>> RunAsync<T>(
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentResponse{T}"/> with the agent's output.</returns>
|
||||
public Task<AgentResponse<T>> RunAsync<T>(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
JsonSerializerOptions? serializerOptions,
|
||||
ChatClientAgentRunOptions? options,
|
||||
bool? useJsonSchemaResponseFormat = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
this.RunAsync<T>(messages, session, serializerOptions, (AgentRunOptions?)options, useJsonSchemaResponseFormat, cancellationToken);
|
||||
this.RunAsync<T>(messages, session, serializerOptions, (AgentRunOptions?)options, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,17 @@ public sealed class ChatClientAgentRunOptions : AgentRunOptions
|
||||
this.ChatOptions = chatOptions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChatClientAgentRunOptions"/> class by copying values from the specified options.
|
||||
/// </summary>
|
||||
/// <param name="options">The options instance from which to copy values.</param>
|
||||
private ChatClientAgentRunOptions(ChatClientAgentRunOptions options)
|
||||
: base(options)
|
||||
{
|
||||
this.ChatOptions = options.ChatOptions?.Clone();
|
||||
this.ChatClientFactory = options.ChatClientFactory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the chat options to apply to the agent invocation.
|
||||
/// </summary>
|
||||
@@ -50,4 +61,7 @@ public sealed class ChatClientAgentRunOptions : AgentRunOptions
|
||||
/// chat client will be used without modification.
|
||||
/// </value>
|
||||
public Func<IChatClient, IChatClient>? ChatClientFactory { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override AgentRunOptions Clone() => new ChatClientAgentRunOptions(this);
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the response of the specified type <typeparamref name="T"/> to an <see cref="ChatClientAgent"/> run request.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of value expected from the chat response.</typeparam>
|
||||
/// <remarks>
|
||||
/// Language models are not guaranteed to honor the requested schema. If the model's output is not
|
||||
/// parsable as the expected type, you can access the underlying JSON response on the <see cref="AgentResponse.Text"/> property.
|
||||
/// </remarks>
|
||||
public sealed class ChatClientAgentResponse<T> : AgentResponse<T>
|
||||
{
|
||||
private readonly ChatResponse<T> _response;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentResponse{T}"/> class from an existing <see cref="ChatResponse{T}"/>.
|
||||
/// </summary>
|
||||
/// <param name="response">The <see cref="ChatResponse{T}"/> from which to populate this <see cref="AgentResponse{T}"/>.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="response"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>
|
||||
/// This constructor creates an agent response that wraps an existing <see cref="ChatResponse{T}"/>, preserving all
|
||||
/// metadata and storing the original response in <see cref="ChatResponse.RawRepresentation"/> for access to
|
||||
/// the underlying implementation details.
|
||||
/// </remarks>
|
||||
public ChatClientAgentResponse(ChatResponse<T> response) : base(response)
|
||||
{
|
||||
_ = Throw.IfNull(response);
|
||||
|
||||
this._response = response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the result value of the agent response as an instance of <typeparamref name="T"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// If the response did not contain JSON, or if deserialization fails, this property will throw.
|
||||
/// </remarks>
|
||||
public override T Result => this._response.Result;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
#pragma warning disable IDE0005 // Using directive is unnecessary.
|
||||
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Internal utilities for working with structured output JSON schemas.
|
||||
/// </summary>
|
||||
internal static class StructuredOutputSchemaUtilities
|
||||
{
|
||||
private const string DataPropertyName = "data";
|
||||
|
||||
/// <summary>
|
||||
/// Ensures the given response format has an object schema at the root, wrapping non-object schemas if necessary.
|
||||
/// </summary>
|
||||
/// <param name="responseFormat">The response format to check.</param>
|
||||
/// <returns>A tuple containing the (possibly wrapped) response format and whether wrapping occurred.</returns>
|
||||
/// <exception cref="InvalidOperationException">The response format does not have a valid JSON schema.</exception>
|
||||
internal static (ChatResponseFormatJson ResponseFormat, bool IsWrappedInObject) WrapNonObjectSchema(ChatResponseFormatJson responseFormat)
|
||||
{
|
||||
if (responseFormat.Schema is null)
|
||||
{
|
||||
throw new InvalidOperationException("The response format must have a valid JSON schema.");
|
||||
}
|
||||
|
||||
var schema = responseFormat.Schema.Value;
|
||||
bool isWrappedInObject = false;
|
||||
|
||||
if (!SchemaRepresentsObject(responseFormat.Schema))
|
||||
{
|
||||
// For non-object-representing schemas, we wrap them in an object schema, because all
|
||||
// the real LLM providers today require an object schema as the root. This is currently
|
||||
// true even for providers that support native structured output.
|
||||
isWrappedInObject = true;
|
||||
schema = JsonSerializer.SerializeToElement(new JsonObject
|
||||
{
|
||||
{ "$schema", "https://json-schema.org/draft/2020-12/schema" },
|
||||
{ "type", "object" },
|
||||
{ "properties", new JsonObject { { DataPropertyName, JsonElementToJsonNode(schema) } } },
|
||||
{ "additionalProperties", false },
|
||||
{ "required", new JsonArray(DataPropertyName) },
|
||||
}, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonObject)));
|
||||
|
||||
responseFormat = ChatResponseFormat.ForJsonSchema(schema, responseFormat.SchemaName, responseFormat.SchemaDescription);
|
||||
}
|
||||
|
||||
return (responseFormat, isWrappedInObject);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unwraps the <c>"data"</c> property from a JSON object that was previously wrapped by <see cref="WrapNonObjectSchema"/>.
|
||||
/// </summary>
|
||||
/// <param name="json">The JSON string to unwrap.</param>
|
||||
/// <returns>The raw JSON text of the <c>"data"</c> property, or the original JSON if no wrapping is detected.</returns>
|
||||
internal static string UnwrapResponseData(string json)
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
if (document.RootElement.ValueKind == JsonValueKind.Object &&
|
||||
document.RootElement.TryGetProperty(DataPropertyName, out JsonElement dataElement))
|
||||
{
|
||||
return dataElement.GetRawText();
|
||||
}
|
||||
|
||||
// If root is not an object or "data" property is not found, return the original JSON as a fallback
|
||||
return json;
|
||||
}
|
||||
|
||||
private static bool SchemaRepresentsObject(JsonElement? schema)
|
||||
{
|
||||
if (schema is not { } schemaElement)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (schemaElement.ValueKind is JsonValueKind.Object)
|
||||
{
|
||||
foreach (var property in schemaElement.EnumerateObject())
|
||||
{
|
||||
if (property.NameEquals("type"u8))
|
||||
{
|
||||
return property.Value.ValueKind == JsonValueKind.String
|
||||
&& property.Value.ValueEquals("object"u8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static JsonNode? JsonElementToJsonNode(JsonElement element) =>
|
||||
element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Null => null,
|
||||
JsonValueKind.Array => JsonArray.Create(element),
|
||||
JsonValueKind.Object => JsonObject.Create(element),
|
||||
_ => JsonValue.Create(element)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using AgentConformance.IntegrationTests.Support;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace AgentConformance.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Conformance tests for structured output handling for run methods on agents.
|
||||
/// </summary>
|
||||
/// <typeparam name="TAgentFixture">The type of test fixture used by the concrete test implementation.</typeparam>
|
||||
/// <param name="createAgentFixture">Function to create the test fixture with.</param>
|
||||
public abstract class StructuredOutputRunTests<TAgentFixture>(Func<TAgentFixture> createAgentFixture) : AgentTests<TAgentFixture>(createAgentFixture)
|
||||
where TAgentFixture : IAgentFixture
|
||||
{
|
||||
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
|
||||
public virtual async Task RunWithResponseFormatReturnsExpectedResultAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this.Fixture.Agent;
|
||||
var session = await agent.CreateSessionAsync();
|
||||
await using var cleanup = new SessionCleanup(session, this.Fixture);
|
||||
|
||||
var options = new AgentRunOptions
|
||||
{
|
||||
ResponseFormat = ChatResponseFormat.ForJsonSchema<CityInfo>(AgentAbstractionsJsonUtilities.DefaultOptions)
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync(new ChatMessage(ChatRole.User, "Provide information about the capital of France."), session, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.Single(response.Messages);
|
||||
Assert.Contains("Paris", response.Text);
|
||||
Assert.True(TryDeserialize(response.Text, AgentAbstractionsJsonUtilities.DefaultOptions, out CityInfo cityInfo));
|
||||
Assert.Equal("Paris", cityInfo.Name);
|
||||
}
|
||||
|
||||
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
|
||||
public virtual async Task RunWithGenericTypeReturnsExpectedResultAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this.Fixture.Agent;
|
||||
var session = await agent.CreateSessionAsync();
|
||||
await using var cleanup = new SessionCleanup(session, this.Fixture);
|
||||
|
||||
// Act
|
||||
AgentResponse<CityInfo> response = await agent.RunAsync<CityInfo>(
|
||||
new ChatMessage(ChatRole.User, "Provide information about the capital of France."),
|
||||
session);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.Single(response.Messages);
|
||||
Assert.Contains("Paris", response.Text);
|
||||
|
||||
Assert.NotNull(response.Result);
|
||||
Assert.Equal("Paris", response.Result.Name);
|
||||
}
|
||||
|
||||
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
|
||||
public virtual async Task RunWithPrimitiveTypeReturnsExpectedResultAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this.Fixture.Agent;
|
||||
var session = await agent.CreateSessionAsync();
|
||||
await using var cleanup = new SessionCleanup(session, this.Fixture);
|
||||
|
||||
// Act - Request a primitive type, which requires wrapping in an object schema
|
||||
AgentResponse<int> response = await agent.RunAsync<int>(
|
||||
new ChatMessage(ChatRole.User, "What is the sum of 15 and 27? Respond with just the number."),
|
||||
session);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.Single(response.Messages);
|
||||
Assert.Equal(42, response.Result);
|
||||
}
|
||||
|
||||
protected static bool TryDeserialize<T>(string json, JsonSerializerOptions jsonSerializerOptions, out T structuredOutput)
|
||||
{
|
||||
try
|
||||
{
|
||||
T? deserialized = JsonSerializer.Deserialize<T>(json, jsonSerializerOptions);
|
||||
if (deserialized is null)
|
||||
{
|
||||
structuredOutput = default!;
|
||||
return false;
|
||||
}
|
||||
|
||||
structuredOutput = deserialized;
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
structuredOutput = default!;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class CityInfo
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace AgentConformance.IntegrationTests.Support;
|
||||
|
||||
internal static class Constants
|
||||
public static class Constants
|
||||
{
|
||||
public const int RetryCount = 3;
|
||||
public const int RetryDelay = 5000;
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace AgentConformance.IntegrationTests.Support;
|
||||
/// </summary>
|
||||
/// <param name="session">The session to delete.</param>
|
||||
/// <param name="fixture">The fixture that provides agent specific capabilities.</param>
|
||||
internal sealed class SessionCleanup(AgentSession session, IAgentFixture fixture) : IAsyncDisposable
|
||||
public sealed class SessionCleanup(AgentSession session, IAgentFixture fixture) : IAsyncDisposable
|
||||
{
|
||||
public async ValueTask DisposeAsync() =>
|
||||
await fixture.DeleteSessionAsync(session);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using AgentConformance.IntegrationTests;
|
||||
using AgentConformance.IntegrationTests.Support;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace AzureAI.IntegrationTests;
|
||||
|
||||
public class AIProjectClientAgentStructuredOutputRunTests() : StructuredOutputRunTests<AIProjectClientStructuredOutputFixture<CityInfo>>(() => new AIProjectClientStructuredOutputFixture<CityInfo>())
|
||||
{
|
||||
private const string NotSupported = "AIProjectClient does not support specifying structured output type at invocation time.";
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that response format provided at agent initialization is used when invoking RunAsync.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
|
||||
public async Task RunWithResponseFormatAtAgentInitializationReturnsExpectedResultAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this.Fixture.Agent;
|
||||
var session = await agent.CreateSessionAsync();
|
||||
await using var cleanup = new SessionCleanup(session, this.Fixture);
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync(new ChatMessage(ChatRole.User, "Provide information about the capital of France."), session);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.Single(response.Messages);
|
||||
Assert.Contains("Paris", response.Text);
|
||||
Assert.True(TryDeserialize(response.Text, AgentAbstractionsJsonUtilities.DefaultOptions, out CityInfo cityInfo));
|
||||
Assert.Equal("Paris", cityInfo.Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that generic RunAsync works with AIProjectClient when structured output is configured at agent initialization.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// AIProjectClient does not support specifying the structured output type at invocation time yet.
|
||||
/// The type T provided to RunAsync<T> is ignored by AzureAIProjectChatClient and is only used
|
||||
/// for deserializing the agent response by AgentResponse<T>.Result.
|
||||
/// </remarks>
|
||||
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
|
||||
public async Task RunGenericWithResponseFormatAtAgentInitializationReturnsExpectedResultAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this.Fixture.Agent;
|
||||
var session = await agent.CreateSessionAsync();
|
||||
await using var cleanup = new SessionCleanup(session, this.Fixture);
|
||||
|
||||
// Act
|
||||
AgentResponse<CityInfo> response = await agent.RunAsync<CityInfo>(
|
||||
new ChatMessage(ChatRole.User, "Provide information about the capital of France."),
|
||||
session);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.Single(response.Messages);
|
||||
Assert.Contains("Paris", response.Text);
|
||||
|
||||
Assert.NotNull(response.Result);
|
||||
Assert.Equal("Paris", response.Result.Name);
|
||||
}
|
||||
|
||||
[Fact(Skip = NotSupported)]
|
||||
public override Task RunWithGenericTypeReturnsExpectedResultAsync() =>
|
||||
base.RunWithGenericTypeReturnsExpectedResultAsync();
|
||||
|
||||
[Fact(Skip = NotSupported)]
|
||||
public override Task RunWithResponseFormatReturnsExpectedResultAsync() =>
|
||||
base.RunWithResponseFormatReturnsExpectedResultAsync();
|
||||
|
||||
[Fact(Skip = NotSupported)]
|
||||
public override Task RunWithPrimitiveTypeReturnsExpectedResultAsync() =>
|
||||
base.RunWithPrimitiveTypeReturnsExpectedResultAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a fixture for testing AIProjectClient with structured output of type <typeparamref name="T"/> provided at agent initialization.
|
||||
/// </summary>
|
||||
public class AIProjectClientStructuredOutputFixture<T> : AIProjectClientFixture
|
||||
{
|
||||
public override Task InitializeAsync()
|
||||
{
|
||||
var agentOptions = new ChatClientAgentOptions
|
||||
{
|
||||
ChatOptions = new ChatOptions()
|
||||
{
|
||||
ResponseFormat = ChatResponseFormat.ForJsonSchema<T>(AgentAbstractionsJsonUtilities.DefaultOptions)
|
||||
},
|
||||
};
|
||||
|
||||
return this.InitializeAsync(agentOptions);
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,13 @@ public class AIProjectClientFixture : IChatClientAgentFixture
|
||||
return await this._client.CreateAIAgentAsync(GenerateUniqueAgentName(name), model: s_config.DeploymentName, instructions: instructions, tools: aiTools);
|
||||
}
|
||||
|
||||
public async Task<ChatClientAgent> CreateChatClientAgentAsync(ChatClientAgentOptions options)
|
||||
{
|
||||
options.Name ??= GenerateUniqueAgentName("HelpfulAssistant");
|
||||
|
||||
return await this._client.CreateAIAgentAsync(model: s_config.DeploymentName, options);
|
||||
}
|
||||
|
||||
public static string GenerateUniqueAgentName(string baseName) =>
|
||||
$"{baseName}-{Guid.NewGuid().ToString("N").Substring(0, 8)}";
|
||||
|
||||
@@ -161,9 +168,15 @@ public class AIProjectClientFixture : IChatClientAgentFixture
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
public virtual async Task InitializeAsync()
|
||||
{
|
||||
this._client = new(new Uri(s_config.Endpoint), new AzureCliCredential());
|
||||
this._agent = await this.CreateChatClientAgentAsync();
|
||||
}
|
||||
|
||||
public async Task InitializeAsync(ChatClientAgentOptions options)
|
||||
{
|
||||
this._client = new(new Uri(s_config.Endpoint), new AzureCliCredential());
|
||||
this._agent = await this.CreateChatClientAgentAsync(options);
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading.Tasks;
|
||||
using AgentConformance.IntegrationTests;
|
||||
|
||||
namespace AzureAIAgentsPersistent.IntegrationTests;
|
||||
|
||||
public class AzureAIAgentsPersistentStructuredOutputRunTests() : StructuredOutputRunTests<AzureAIAgentsPersistentFixture>(() => new())
|
||||
{
|
||||
[Fact(Skip = "Fails intermittently, at build agent")]
|
||||
public override Task RunWithResponseFormatReturnsExpectedResultAsync() =>
|
||||
base.RunWithResponseFormatReturnsExpectedResultAsync();
|
||||
}
|
||||
+391
@@ -0,0 +1,391 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Abstractions.UnitTests.Models;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the structured output functionality in <see cref="AIAgent"/>.
|
||||
/// </summary>
|
||||
public class AIAgentStructuredOutputTests
|
||||
{
|
||||
private readonly Mock<AIAgent> _agentMock;
|
||||
|
||||
public AIAgentStructuredOutputTests()
|
||||
{
|
||||
this._agentMock = new Mock<AIAgent> { CallBase = true };
|
||||
}
|
||||
|
||||
#region Schema Wrapping Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when requesting an object type, the schema is NOT wrapped.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsyncGeneric_WithObjectType_DoesNotWrapSchemaAsync()
|
||||
{
|
||||
// Arrange
|
||||
Animal expectedAnimal = new() { Id = 1, FullName = "Test", Species = Species.Tiger };
|
||||
string responseJson = JsonSerializer.Serialize(expectedAnimal, TestJsonSerializerContext.Default.Animal);
|
||||
AgentResponse response = new(new ChatMessage(ChatRole.Assistant, responseJson));
|
||||
|
||||
this._agentMock
|
||||
.Protected()
|
||||
.Setup<Task<AgentResponse>>("RunCoreAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.IsAny<AgentSession?>(),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(response);
|
||||
|
||||
// Act
|
||||
AgentResponse<Animal> result = await this._agentMock.Object.RunAsync<Animal>(
|
||||
"Get me an animal",
|
||||
serializerOptions: TestJsonSerializerContext.Default.Options);
|
||||
|
||||
// Assert - Verify the result is NOT marked as wrapped
|
||||
Assert.False(result.IsWrappedInObject);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when requesting a primitive type (int), the schema IS wrapped.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsyncGeneric_WithPrimitiveType_WrapsSchemaAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ResponseJson = "{\"data\":42}";
|
||||
AgentResponse response = new(new ChatMessage(ChatRole.Assistant, ResponseJson));
|
||||
|
||||
this._agentMock
|
||||
.Protected()
|
||||
.Setup<Task<AgentResponse>>("RunCoreAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.IsAny<AgentSession?>(),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(response);
|
||||
|
||||
// Act
|
||||
AgentResponse<int> result = await this._agentMock.Object.RunAsync<int>(
|
||||
"Give me a number",
|
||||
serializerOptions: TestJsonSerializerContext.Default.Options);
|
||||
|
||||
// Assert - Verify the result is marked as wrapped
|
||||
Assert.True(result.IsWrappedInObject);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when requesting an array type, the schema IS wrapped.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsyncGeneric_WithArrayType_WrapsSchemaAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ResponseJson = "{\"data\":[\"a\",\"b\",\"c\"]}";
|
||||
AgentResponse response = new(new ChatMessage(ChatRole.Assistant, ResponseJson));
|
||||
|
||||
this._agentMock
|
||||
.Protected()
|
||||
.Setup<Task<AgentResponse>>("RunCoreAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.IsAny<AgentSession?>(),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(response);
|
||||
|
||||
// Act
|
||||
AgentResponse<string[]> result = await this._agentMock.Object.RunAsync<string[]>(
|
||||
"Give me an array of strings",
|
||||
serializerOptions: TestJsonSerializerContext.Default.Options);
|
||||
|
||||
// Assert - Verify the result is marked as wrapped
|
||||
Assert.True(result.IsWrappedInObject);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when requesting an enum type, the schema IS wrapped.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsyncGeneric_WithEnumType_WrapsSchemaAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ResponseJson = "{\"data\":\"Tiger\"}";
|
||||
AgentResponse response = new(new ChatMessage(ChatRole.Assistant, ResponseJson));
|
||||
|
||||
this._agentMock
|
||||
.Protected()
|
||||
.Setup<Task<AgentResponse>>("RunCoreAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.IsAny<AgentSession?>(),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(response);
|
||||
|
||||
// Act
|
||||
AgentResponse<Species> result = await this._agentMock.Object.RunAsync<Species>(
|
||||
"Give me a species",
|
||||
serializerOptions: TestJsonSerializerContext.Default.Options);
|
||||
|
||||
// Assert - Verify the result is marked as wrapped
|
||||
Assert.True(result.IsWrappedInObject);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AgentResponse<T>.Result Unwrapping Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AgentResponse{T}.Result correctly deserializes an object without unwrapping.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AgentResponseGeneric_Result_DeserializesObjectWithoutUnwrapping()
|
||||
{
|
||||
// Arrange
|
||||
Animal expectedAnimal = new() { Id = 1, FullName = "Tigger", Species = Species.Tiger };
|
||||
string responseJson = JsonSerializer.Serialize(expectedAnimal, TestJsonSerializerContext.Default.Animal);
|
||||
AgentResponse response = new(new ChatMessage(ChatRole.Assistant, responseJson));
|
||||
AgentResponse<Animal> typedResponse = new(response, TestJsonSerializerContext.Default.Options);
|
||||
|
||||
// Act
|
||||
Animal result = typedResponse.Result;
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedAnimal.Id, result.Id);
|
||||
Assert.Equal(expectedAnimal.FullName, result.FullName);
|
||||
Assert.Equal(expectedAnimal.Species, result.Species);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AgentResponse{T}.Result correctly unwraps and deserializes a primitive value.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AgentResponseGeneric_Result_UnwrapsPrimitiveFromDataProperty()
|
||||
{
|
||||
// Arrange
|
||||
const string ResponseJson = "{\"data\":42}";
|
||||
AgentResponse response = new(new ChatMessage(ChatRole.Assistant, ResponseJson));
|
||||
AgentResponse<int> typedResponse = new(response, TestJsonSerializerContext.Default.Options) { IsWrappedInObject = true };
|
||||
|
||||
// Act
|
||||
int result = typedResponse.Result;
|
||||
|
||||
// Assert
|
||||
Assert.Equal(42, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AgentResponse{T}.Result correctly unwraps and deserializes an array.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AgentResponseGeneric_Result_UnwrapsArrayFromDataProperty()
|
||||
{
|
||||
// Arrange
|
||||
const string ResponseJson = "{\"data\":[\"apple\",\"banana\",\"cherry\"]}";
|
||||
AgentResponse response = new(new ChatMessage(ChatRole.Assistant, ResponseJson));
|
||||
AgentResponse<string[]> typedResponse = new(response, TestJsonSerializerContext.Default.Options) { IsWrappedInObject = true };
|
||||
|
||||
// Act
|
||||
string[] result = typedResponse.Result;
|
||||
|
||||
// Assert
|
||||
Assert.Equal(["apple", "banana", "cherry"], result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AgentResponse{T}.Result correctly unwraps and deserializes an enum.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AgentResponseGeneric_Result_UnwrapsEnumFromDataProperty()
|
||||
{
|
||||
// Arrange
|
||||
const string ResponseJson = "{\"data\":\"Walrus\"}";
|
||||
AgentResponse response = new(new ChatMessage(ChatRole.Assistant, ResponseJson));
|
||||
AgentResponse<Species> typedResponse = new(response, TestJsonSerializerContext.Default.Options) { IsWrappedInObject = true };
|
||||
|
||||
// Act
|
||||
Species result = typedResponse.Result;
|
||||
|
||||
// Assert
|
||||
Assert.Equal(Species.Walrus, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AgentResponse{T}.Result falls back to original JSON when data property is missing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AgentResponseGeneric_Result_FallsBackWhenDataPropertyMissing()
|
||||
{
|
||||
// Arrange - simulate a case where wrapping was expected but response does not have data
|
||||
const string ResponseJson = "42";
|
||||
AgentResponse response = new(new ChatMessage(ChatRole.Assistant, ResponseJson));
|
||||
AgentResponse<int> typedResponse = new(response, TestJsonSerializerContext.Default.Options) { IsWrappedInObject = true };
|
||||
|
||||
// Act
|
||||
int result = typedResponse.Result;
|
||||
|
||||
// Assert - should still work by falling back to original JSON
|
||||
Assert.Equal(42, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AgentResponse{T}.Result throws when response text is empty.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AgentResponseGeneric_Result_ThrowsWhenTextIsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponse response = new(new ChatMessage(ChatRole.Assistant, string.Empty));
|
||||
AgentResponse<int> typedResponse = new(response, TestJsonSerializerContext.Default.Options);
|
||||
|
||||
// Act and Assert
|
||||
Assert.Throws<System.InvalidOperationException>(() => typedResponse.Result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AgentResponse{T}.Result throws when deserialized value is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AgentResponseGeneric_Result_ThrowsWhenDeserializedValueIsNull()
|
||||
{
|
||||
// Arrange
|
||||
const string ResponseJson = "null";
|
||||
AgentResponse response = new(new ChatMessage(ChatRole.Assistant, ResponseJson));
|
||||
AgentResponse<Animal> typedResponse = new(response, TestJsonSerializerContext.Default.Options);
|
||||
|
||||
// Act and Assert
|
||||
Assert.Throws<System.InvalidOperationException>(() => typedResponse.Result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region End-to-End Tests
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end test: Request a primitive type, verify wrapping, and verify correct deserialization.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsyncGeneric_PrimitiveEndToEnd_WrapsAndDeserializesCorrectlyAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ResponseJson = "{\"data\":123}";
|
||||
AgentResponse response = new(new ChatMessage(ChatRole.Assistant, ResponseJson));
|
||||
|
||||
this._agentMock
|
||||
.Protected()
|
||||
.Setup<Task<AgentResponse>>("RunCoreAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.IsAny<AgentSession?>(),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(response);
|
||||
|
||||
// Act
|
||||
AgentResponse<int> result = await this._agentMock.Object.RunAsync<int>(
|
||||
"Give me a number",
|
||||
serializerOptions: TestJsonSerializerContext.Default.Options);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsWrappedInObject);
|
||||
Assert.Equal(123, result.Result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end test: Request an array type, verify wrapping, and verify correct deserialization.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsyncGeneric_ArrayEndToEnd_WrapsAndDeserializesCorrectlyAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ResponseJson = "{\"data\":[\"one\",\"two\",\"three\"]}";
|
||||
AgentResponse response = new(new ChatMessage(ChatRole.Assistant, ResponseJson));
|
||||
|
||||
this._agentMock
|
||||
.Protected()
|
||||
.Setup<Task<AgentResponse>>("RunCoreAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.IsAny<AgentSession?>(),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(response);
|
||||
|
||||
// Act
|
||||
AgentResponse<string[]> result = await this._agentMock.Object.RunAsync<string[]>(
|
||||
"Give me an array of strings",
|
||||
serializerOptions: TestJsonSerializerContext.Default.Options);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsWrappedInObject);
|
||||
Assert.Equal(["one", "two", "three"], result.Result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end test: Request an object type, verify no wrapping, and verify correct deserialization.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsyncGeneric_ObjectEndToEnd_NoWrappingAndDeserializesCorrectlyAsync()
|
||||
{
|
||||
// Arrange
|
||||
Animal expectedAnimal = new() { Id = 99, FullName = "Leo", Species = Species.Bear };
|
||||
string responseJson = JsonSerializer.Serialize(expectedAnimal, TestJsonSerializerContext.Default.Animal);
|
||||
AgentResponse response = new(new ChatMessage(ChatRole.Assistant, responseJson));
|
||||
|
||||
this._agentMock
|
||||
.Protected()
|
||||
.Setup<Task<AgentResponse>>("RunCoreAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.IsAny<AgentSession?>(),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(response);
|
||||
|
||||
// Act
|
||||
AgentResponse<Animal> result = await this._agentMock.Object.RunAsync<Animal>(
|
||||
"Give me an animal",
|
||||
serializerOptions: TestJsonSerializerContext.Default.Options);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsWrappedInObject);
|
||||
Assert.Equal(expectedAnimal.Id, result.Result.Id);
|
||||
Assert.Equal(expectedAnimal.FullName, result.Result.FullName);
|
||||
Assert.Equal(expectedAnimal.Species, result.Result.Species);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end test: Request an enum type, verify wrapping, and verify correct deserialization.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsyncGeneric_EnumEndToEnd_WrapsAndDeserializesCorrectlyAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ResponseJson = "{\"data\":\"Bear\"}";
|
||||
AgentResponse response = new(new ChatMessage(ChatRole.Assistant, ResponseJson));
|
||||
|
||||
this._agentMock
|
||||
.Protected()
|
||||
.Setup<Task<AgentResponse>>("RunCoreAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.IsAny<AgentSession?>(),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(response);
|
||||
|
||||
// Act
|
||||
AgentResponse<Species> result = await this._agentMock.Object.RunAsync<Species>(
|
||||
"Give me a species",
|
||||
serializerOptions: TestJsonSerializerContext.Default.Options);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsWrappedInObject);
|
||||
Assert.Equal(Species.Bear, result.Result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -214,30 +214,6 @@ public class AgentResponseTests
|
||||
Assert.Equal(100, usageContent.Details.TotalTokenCount);
|
||||
}
|
||||
|
||||
#if NETFRAMEWORK
|
||||
/// <summary>
|
||||
/// Since Json Serialization using reflection is disabled in .net core builds, and we are using a custom type here that wouldn't
|
||||
/// be registered with the default source generated serializer, this test will only pass in .net framework builds where reflection-based
|
||||
/// serialization is available.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ParseAsStructuredOutputSuccess()
|
||||
{
|
||||
// Arrange.
|
||||
var expectedResult = new Animal { Id = 1, FullName = "Tigger", Species = Species.Tiger };
|
||||
var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult, TestJsonSerializerContext.Default.Animal)));
|
||||
|
||||
// Act.
|
||||
var animal = response.Deserialize<Animal>();
|
||||
|
||||
// Assert.
|
||||
Assert.NotNull(animal);
|
||||
Assert.Equal(expectedResult.Id, animal.Id);
|
||||
Assert.Equal(expectedResult.FullName, animal.FullName);
|
||||
Assert.Equal(expectedResult.Species, animal.Species);
|
||||
}
|
||||
#endif
|
||||
|
||||
[Fact]
|
||||
public void ParseAsStructuredOutputWithJSOSuccess()
|
||||
{
|
||||
@@ -246,7 +222,7 @@ public class AgentResponseTests
|
||||
var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult, TestJsonSerializerContext.Default.Animal)));
|
||||
|
||||
// Act.
|
||||
var animal = response.Deserialize<Animal>(TestJsonSerializerContext.Default.Options);
|
||||
var animal = JsonSerializer.Deserialize<Animal>(response.Text, TestJsonSerializerContext.Default.Options);
|
||||
|
||||
// Assert.
|
||||
Assert.NotNull(animal);
|
||||
@@ -255,98 +231,6 @@ public class AgentResponseTests
|
||||
Assert.Equal(expectedResult.Species, animal.Species);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAsStructuredOutputFailsWithEmptyString()
|
||||
{
|
||||
// Arrange.
|
||||
var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, string.Empty));
|
||||
|
||||
// Act & Assert.
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => response.Deserialize<Animal>(TestJsonSerializerContext.Default.Options));
|
||||
Assert.Equal("The response did not contain JSON to be deserialized.", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAsStructuredOutputFailsWithInvalidJson()
|
||||
{
|
||||
// Arrange.
|
||||
var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, "invalid json"));
|
||||
|
||||
// Act & Assert.
|
||||
Assert.Throws<JsonException>(() => response.Deserialize<Animal>(TestJsonSerializerContext.Default.Options));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAsStructuredOutputFailsWithIncorrectTypedJson()
|
||||
{
|
||||
// Arrange.
|
||||
var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, "[]"));
|
||||
|
||||
// Act & Assert.
|
||||
Assert.Throws<JsonException>(() => response.Deserialize<Animal>(TestJsonSerializerContext.Default.Options));
|
||||
}
|
||||
|
||||
#if NETFRAMEWORK
|
||||
/// <summary>
|
||||
/// Since Json Serialization using reflection is disabled in .net core builds, and we are using a custom type here that wouldn't
|
||||
/// be registered with the default source generated serializer, this test will only pass in .net framework builds where reflection-based
|
||||
/// serialization is available.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryParseAsStructuredOutputSuccess()
|
||||
{
|
||||
// Arrange.
|
||||
var expectedResult = new Animal { Id = 1, FullName = "Tigger", Species = Species.Tiger };
|
||||
var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult, TestJsonSerializerContext.Default.Animal)));
|
||||
|
||||
// Act.
|
||||
response.TryDeserialize(out Animal? animal);
|
||||
|
||||
// Assert.
|
||||
Assert.NotNull(animal);
|
||||
Assert.Equal(expectedResult.Id, animal.Id);
|
||||
Assert.Equal(expectedResult.FullName, animal.FullName);
|
||||
Assert.Equal(expectedResult.Species, animal.Species);
|
||||
}
|
||||
#endif
|
||||
|
||||
[Fact]
|
||||
public void TryParseAsStructuredOutputWithJSOSuccess()
|
||||
{
|
||||
// Arrange.
|
||||
var expectedResult = new Animal { Id = 1, FullName = "Tigger", Species = Species.Tiger };
|
||||
var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult, TestJsonSerializerContext.Default.Animal)));
|
||||
|
||||
// Act.
|
||||
response.TryDeserialize(TestJsonSerializerContext.Default.Options, out Animal? animal);
|
||||
|
||||
// Assert.
|
||||
Assert.NotNull(animal);
|
||||
Assert.Equal(expectedResult.Id, animal.Id);
|
||||
Assert.Equal(expectedResult.FullName, animal.FullName);
|
||||
Assert.Equal(expectedResult.Species, animal.Species);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseAsStructuredOutputFailsWithEmptyText()
|
||||
{
|
||||
// Arrange.
|
||||
var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, string.Empty));
|
||||
|
||||
// Act & Assert.
|
||||
Assert.False(response.TryDeserialize<Animal>(TestJsonSerializerContext.Default.Options, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseAsStructuredOutputFailsWithIncorrectTypedJson()
|
||||
{
|
||||
// Arrange.
|
||||
var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, "[]"));
|
||||
|
||||
// Act & Assert.
|
||||
Assert.False(response.TryDeserialize<Animal>(TestJsonSerializerContext.Default.Options, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAgentResponseUpdatesWithNoMessagesProducesEmptyArray()
|
||||
{
|
||||
@@ -395,16 +279,4 @@ public class AgentResponseTests
|
||||
Assert.NotNull(update.AdditionalProperties);
|
||||
Assert.Equal("value", update.AdditionalProperties!["key"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_ThrowsWhenDeserializationReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponse response = new(new ChatMessage(ChatRole.Assistant, "null"));
|
||||
|
||||
// Act & Assert
|
||||
InvalidOperationException exception = Assert.Throws<InvalidOperationException>(
|
||||
() => response.Deserialize<Animal>(TestJsonSerializerContext.Default.Options));
|
||||
Assert.Equal("The deserialized response is null.", exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
@@ -27,7 +26,7 @@ public class AgentRunOptionsTests
|
||||
};
|
||||
|
||||
// Act
|
||||
var clone = new AgentRunOptions(options);
|
||||
var clone = options.Clone();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(clone);
|
||||
@@ -39,11 +38,6 @@ public class AgentRunOptionsTests
|
||||
Assert.Equal(42, clone.AdditionalProperties["key2"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloningConstructorThrowsIfNull() =>
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new AgentRunOptions(null!));
|
||||
|
||||
[Fact]
|
||||
public void JsonSerializationRoundtrips()
|
||||
{
|
||||
@@ -77,4 +71,57 @@ public class AgentRunOptionsTests
|
||||
Assert.IsType<JsonElement>(value2);
|
||||
Assert.Equal(42, ((JsonElement)value2!).GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloneReturnsNewInstanceWithSameValues()
|
||||
{
|
||||
// Arrange
|
||||
var options = new AgentRunOptions
|
||||
{
|
||||
ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }),
|
||||
AllowBackgroundResponses = true,
|
||||
AdditionalProperties = new AdditionalPropertiesDictionary
|
||||
{
|
||||
["key1"] = "value1",
|
||||
["key2"] = 42
|
||||
},
|
||||
ResponseFormat = ChatResponseFormat.Json
|
||||
};
|
||||
|
||||
// Act
|
||||
AgentRunOptions clone = options.Clone();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(clone);
|
||||
Assert.IsType<AgentRunOptions>(clone);
|
||||
Assert.NotSame(options, clone);
|
||||
Assert.Same(options.ContinuationToken, clone.ContinuationToken);
|
||||
Assert.Equal(options.AllowBackgroundResponses, clone.AllowBackgroundResponses);
|
||||
Assert.NotNull(clone.AdditionalProperties);
|
||||
Assert.NotSame(options.AdditionalProperties, clone.AdditionalProperties);
|
||||
Assert.Equal("value1", clone.AdditionalProperties["key1"]);
|
||||
Assert.Equal(42, clone.AdditionalProperties["key2"]);
|
||||
Assert.Same(options.ResponseFormat, clone.ResponseFormat);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloneCreatesIndependentAdditionalPropertiesDictionary()
|
||||
{
|
||||
// Arrange
|
||||
var options = new AgentRunOptions
|
||||
{
|
||||
AdditionalProperties = new AdditionalPropertiesDictionary
|
||||
{
|
||||
["key1"] = "value1"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
AgentRunOptions clone = options.Clone();
|
||||
clone.AdditionalProperties!["key2"] = "value2";
|
||||
|
||||
// Assert
|
||||
Assert.True(clone.AdditionalProperties.ContainsKey("key2"));
|
||||
Assert.False(options.AdditionalProperties.ContainsKey("key2"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ namespace Microsoft.Agents.AI.Abstractions.UnitTests;
|
||||
[JsonSerializable(typeof(AgentResponseUpdate))]
|
||||
[JsonSerializable(typeof(AgentRunOptions))]
|
||||
[JsonSerializable(typeof(Animal))]
|
||||
[JsonSerializable(typeof(Species))]
|
||||
[JsonSerializable(typeof(JsonElement))]
|
||||
[JsonSerializable(typeof(Dictionary<string, object?>))]
|
||||
[JsonSerializable(typeof(string[]))]
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="DurableAgentRunOptions"/> class.
|
||||
/// </summary>
|
||||
public sealed class DurableAgentRunOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void CloneReturnsNewInstanceWithSameValues()
|
||||
{
|
||||
// Arrange
|
||||
DurableAgentRunOptions options = new()
|
||||
{
|
||||
EnableToolCalls = false,
|
||||
EnableToolNames = new List<string> { "tool1", "tool2" },
|
||||
IsFireAndForget = true,
|
||||
AllowBackgroundResponses = true,
|
||||
ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }),
|
||||
AdditionalProperties = new AdditionalPropertiesDictionary
|
||||
{
|
||||
["key1"] = "value1",
|
||||
["key2"] = 42
|
||||
},
|
||||
ResponseFormat = ChatResponseFormat.Json
|
||||
};
|
||||
|
||||
// Act
|
||||
AgentRunOptions cloneAsBase = options.Clone();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(cloneAsBase);
|
||||
Assert.IsType<DurableAgentRunOptions>(cloneAsBase);
|
||||
DurableAgentRunOptions clone = (DurableAgentRunOptions)cloneAsBase;
|
||||
Assert.NotSame(options, clone);
|
||||
Assert.Equal(options.EnableToolCalls, clone.EnableToolCalls);
|
||||
Assert.NotNull(clone.EnableToolNames);
|
||||
Assert.NotSame(options.EnableToolNames, clone.EnableToolNames);
|
||||
Assert.Equal(2, clone.EnableToolNames.Count);
|
||||
Assert.Contains("tool1", clone.EnableToolNames);
|
||||
Assert.Contains("tool2", clone.EnableToolNames);
|
||||
Assert.Equal(options.IsFireAndForget, clone.IsFireAndForget);
|
||||
Assert.Equal(options.AllowBackgroundResponses, clone.AllowBackgroundResponses);
|
||||
Assert.Same(options.ContinuationToken, clone.ContinuationToken);
|
||||
Assert.NotNull(clone.AdditionalProperties);
|
||||
Assert.NotSame(options.AdditionalProperties, clone.AdditionalProperties);
|
||||
Assert.Equal("value1", clone.AdditionalProperties["key1"]);
|
||||
Assert.Equal(42, clone.AdditionalProperties["key2"]);
|
||||
Assert.Same(options.ResponseFormat, clone.ResponseFormat);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloneCreatesIndependentEnableToolNamesList()
|
||||
{
|
||||
// Arrange
|
||||
DurableAgentRunOptions options = new()
|
||||
{
|
||||
EnableToolNames = new List<string> { "tool1" }
|
||||
};
|
||||
|
||||
// Act
|
||||
DurableAgentRunOptions clone = (DurableAgentRunOptions)options.Clone();
|
||||
clone.EnableToolNames!.Add("tool2");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, clone.EnableToolNames.Count);
|
||||
Assert.Single(options.EnableToolNames);
|
||||
Assert.DoesNotContain("tool2", options.EnableToolNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloneCreatesIndependentAdditionalPropertiesDictionary()
|
||||
{
|
||||
// Arrange
|
||||
DurableAgentRunOptions options = new()
|
||||
{
|
||||
AdditionalProperties = new AdditionalPropertiesDictionary
|
||||
{
|
||||
["key1"] = "value1"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
DurableAgentRunOptions clone = (DurableAgentRunOptions)options.Clone();
|
||||
clone.AdditionalProperties!["key2"] = "value2";
|
||||
|
||||
// Assert
|
||||
Assert.True(clone.AdditionalProperties.ContainsKey("key2"));
|
||||
Assert.False(options.AdditionalProperties.ContainsKey("key2"));
|
||||
}
|
||||
}
|
||||
+87
@@ -332,4 +332,91 @@ public class ChatClientAgentRunOptionsTests
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Clone Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that Clone returns a new instance with the same property values.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CloneReturnsNewInstanceWithSameValues()
|
||||
{
|
||||
// Arrange
|
||||
var chatOptions = new ChatOptions { MaxOutputTokens = 100, Temperature = 0.7f };
|
||||
Func<IChatClient, IChatClient> factory = c => c;
|
||||
var runOptions = new ChatClientAgentRunOptions(chatOptions)
|
||||
{
|
||||
ChatClientFactory = factory,
|
||||
AllowBackgroundResponses = true,
|
||||
ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }),
|
||||
AdditionalProperties = new AdditionalPropertiesDictionary
|
||||
{
|
||||
["key1"] = "value1"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
AgentRunOptions cloneAsBase = runOptions.Clone();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(cloneAsBase);
|
||||
Assert.IsType<ChatClientAgentRunOptions>(cloneAsBase);
|
||||
ChatClientAgentRunOptions clone = (ChatClientAgentRunOptions)cloneAsBase;
|
||||
Assert.NotSame(runOptions, clone);
|
||||
Assert.NotNull(clone.ChatOptions);
|
||||
Assert.NotSame(runOptions.ChatOptions, clone.ChatOptions);
|
||||
Assert.Equal(100, clone.ChatOptions!.MaxOutputTokens);
|
||||
Assert.Equal(0.7f, clone.ChatOptions.Temperature);
|
||||
Assert.Same(factory, clone.ChatClientFactory);
|
||||
Assert.Equal(runOptions.AllowBackgroundResponses, clone.AllowBackgroundResponses);
|
||||
Assert.Same(runOptions.ContinuationToken, clone.ContinuationToken);
|
||||
Assert.NotNull(clone.AdditionalProperties);
|
||||
Assert.NotSame(runOptions.AdditionalProperties, clone.AdditionalProperties);
|
||||
Assert.Equal("value1", clone.AdditionalProperties["key1"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that modifying the cloned ChatOptions does not affect the original.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CloneCreatesIndependentChatOptions()
|
||||
{
|
||||
// Arrange
|
||||
var chatOptions = new ChatOptions { MaxOutputTokens = 100 };
|
||||
var runOptions = new ChatClientAgentRunOptions(chatOptions);
|
||||
|
||||
// Act
|
||||
ChatClientAgentRunOptions clone = (ChatClientAgentRunOptions)runOptions.Clone();
|
||||
clone.ChatOptions!.MaxOutputTokens = 200;
|
||||
|
||||
// Assert
|
||||
Assert.Equal(100, runOptions.ChatOptions!.MaxOutputTokens);
|
||||
Assert.Equal(200, clone.ChatOptions.MaxOutputTokens);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that modifying the cloned AdditionalProperties does not affect the original.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CloneCreatesIndependentAdditionalPropertiesDictionary()
|
||||
{
|
||||
// Arrange
|
||||
var runOptions = new ChatClientAgentRunOptions
|
||||
{
|
||||
AdditionalProperties = new AdditionalPropertiesDictionary
|
||||
{
|
||||
["key1"] = "value1"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
ChatClientAgentRunOptions clone = (ChatClientAgentRunOptions)runOptions.Clone();
|
||||
clone.AdditionalProperties!["key2"] = "value2";
|
||||
|
||||
// Assert
|
||||
Assert.True(clone.AdditionalProperties.ContainsKey("key2"));
|
||||
Assert.False(runOptions.AdditionalProperties.ContainsKey("key2"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -943,45 +942,6 @@ public partial class ChatClientAgentTests
|
||||
|
||||
#endregion
|
||||
|
||||
#region RunAsync Structured Output Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify the invocation of <see cref="ChatClientAgent"/> with specified type parameter is
|
||||
/// propagated to the underlying <see cref="IChatClient"/> call and the expected structured output is returned.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsyncWithTypeParameterInvokesChatClientMethodForStructuredOutputAsync()
|
||||
{
|
||||
// Arrange
|
||||
Animal expectedSO = new() { Id = 1, FullName = "Tigger", Species = Species.Tiger };
|
||||
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(s => s
|
||||
.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedSO, JsonContext2.Default.Animal)))
|
||||
{
|
||||
ResponseId = "test",
|
||||
});
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new());
|
||||
|
||||
// Act
|
||||
AgentResponse<Animal> agentResponse = await agent.RunAsync<Animal>(messages: [new(ChatRole.User, "Hello")], serializerOptions: JsonContext2.Default.Options);
|
||||
|
||||
// Assert
|
||||
Assert.Single(agentResponse.Messages);
|
||||
|
||||
Assert.NotNull(agentResponse.Result);
|
||||
Assert.Equal(expectedSO.Id, agentResponse.Result.Id);
|
||||
Assert.Equal(expectedSO.FullName, agentResponse.Result.FullName);
|
||||
Assert.Equal(expectedSO.Species, agentResponse.Result.Species);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Property Override Tests
|
||||
|
||||
/// <summary>
|
||||
@@ -1999,20 +1959,6 @@ public partial class ChatClientAgentTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Animal
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? FullName { get; set; }
|
||||
public Species Species { get; set; }
|
||||
}
|
||||
|
||||
private enum Species
|
||||
{
|
||||
Bear,
|
||||
Tiger,
|
||||
Walrus,
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(UseStringEnumConverter = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
|
||||
[JsonSerializable(typeof(Animal))]
|
||||
private sealed partial class JsonContext2 : JsonSerializerContext;
|
||||
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
public partial class ChatClientAgent_StructuredOutput_WithFormatResponseTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RunAsync_ResponseFormatProvidedAtAgentInitialization_IsPropagatedToChatClientAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatResponseFormat? capturedResponseFormat = null;
|
||||
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(s => s
|
||||
.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) => capturedResponseFormat = opts?.ResponseFormat)
|
||||
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "test"))
|
||||
{
|
||||
ResponseId = "test",
|
||||
});
|
||||
|
||||
ChatResponseFormatJson responseFormat = ChatResponseFormat.ForJsonSchema<Animal>(JsonContext4.Default.Options);
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new ChatClientAgentOptions
|
||||
{
|
||||
ChatOptions = new ChatOptions()
|
||||
{
|
||||
ResponseFormat = responseFormat
|
||||
}
|
||||
});
|
||||
|
||||
// Act
|
||||
await agent.RunAsync(messages: [new(ChatRole.User, "Hello")]);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedResponseFormat);
|
||||
Assert.Same(responseFormat, capturedResponseFormat);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ResponseFormatProvidedAtAgentInvocation_IsPropagatedToChatClientAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatResponseFormat? capturedResponseFormat = null;
|
||||
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(s => s
|
||||
.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) => capturedResponseFormat = opts?.ResponseFormat)
|
||||
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "test"))
|
||||
{
|
||||
ResponseId = "test",
|
||||
});
|
||||
|
||||
ChatResponseFormatJson responseFormat = ChatResponseFormat.ForJsonSchema<Animal>(JsonContext4.Default.Options);
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object);
|
||||
|
||||
ChatClientAgentRunOptions runOptions = new()
|
||||
{
|
||||
ResponseFormat = responseFormat
|
||||
};
|
||||
|
||||
// Act
|
||||
await agent.RunAsync(messages: [new(ChatRole.User, "Hello")], options: runOptions);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedResponseFormat);
|
||||
Assert.Same(responseFormat, capturedResponseFormat);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ResponseFormatProvidedAtAgentInvocation_OverridesOneProvidedAtAgentInitializationAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatResponseFormat? capturedResponseFormat = null;
|
||||
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(s => s
|
||||
.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) => capturedResponseFormat = opts?.ResponseFormat)
|
||||
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "test"))
|
||||
{
|
||||
ResponseId = "test",
|
||||
});
|
||||
|
||||
ChatResponseFormatJson initializationResponseFormat = ChatResponseFormat.ForJsonSchema<Animal>(JsonContext4.Default.Options);
|
||||
ChatResponseFormatJson invocationResponseFormat = ChatResponseFormat.ForJsonSchema<Animal>(JsonContext4.Default.Options);
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new ChatClientAgentOptions
|
||||
{
|
||||
ChatOptions = new ChatOptions()
|
||||
{
|
||||
ResponseFormat = initializationResponseFormat
|
||||
},
|
||||
});
|
||||
|
||||
ChatClientAgentRunOptions runOptions = new()
|
||||
{
|
||||
ResponseFormat = invocationResponseFormat
|
||||
};
|
||||
|
||||
// Act
|
||||
await agent.RunAsync(messages: [new(ChatRole.User, "Hello")], options: runOptions);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedResponseFormat);
|
||||
Assert.Same(invocationResponseFormat, capturedResponseFormat);
|
||||
Assert.NotSame(initializationResponseFormat, capturedResponseFormat);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ResponseFormatProvidedAtAgentRunOptions_OverridesOneProvidedViaChatOptionsAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatResponseFormat? capturedResponseFormat = null;
|
||||
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(s => s
|
||||
.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) => capturedResponseFormat = opts?.ResponseFormat)
|
||||
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "test"))
|
||||
{
|
||||
ResponseId = "test",
|
||||
});
|
||||
|
||||
ChatResponseFormatJson chatOptionsResponseFormat = ChatResponseFormat.ForJsonSchema<Animal>(JsonContext4.Default.Options);
|
||||
ChatResponseFormatJson runOptionsResponseFormat = ChatResponseFormat.ForJsonSchema<Animal>(JsonContext4.Default.Options);
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object);
|
||||
|
||||
ChatClientAgentRunOptions runOptions = new()
|
||||
{
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
ResponseFormat = chatOptionsResponseFormat
|
||||
},
|
||||
ResponseFormat = runOptionsResponseFormat
|
||||
};
|
||||
|
||||
// Act
|
||||
await agent.RunAsync(messages: [new(ChatRole.User, "Hello")], options: runOptions);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedResponseFormat);
|
||||
Assert.Same(runOptionsResponseFormat, capturedResponseFormat);
|
||||
Assert.NotSame(chatOptionsResponseFormat, capturedResponseFormat);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_StructuredOutputResponse_IsAvailableAsTextOnAgentResponseAsync()
|
||||
{
|
||||
// Arrange
|
||||
Animal expectedAnimal = new() { FullName = "Wally the Walrus", Id = 1, Species = Species.Walrus };
|
||||
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(s => s
|
||||
.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedAnimal, JsonContext4.Default.Animal)))
|
||||
{
|
||||
ResponseId = "test",
|
||||
});
|
||||
|
||||
ChatResponseFormatJson responseFormat = ChatResponseFormat.ForJsonSchema<Animal>(JsonContext4.Default.Options);
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new ChatClientAgentOptions
|
||||
{
|
||||
ChatOptions = new ChatOptions()
|
||||
{
|
||||
ResponseFormat = responseFormat
|
||||
},
|
||||
});
|
||||
|
||||
// Act
|
||||
AgentResponse agentResponse = await agent.RunAsync(messages: [new(ChatRole.User, "Hello")]);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agentResponse?.Text);
|
||||
|
||||
Animal? deserialised = JsonSerializer.Deserialize(agentResponse.Text, JsonContext4.Default.Animal);
|
||||
Assert.NotNull(deserialised);
|
||||
Assert.Equal(expectedAnimal.Id, deserialised.Id);
|
||||
Assert.Equal(expectedAnimal.FullName, deserialised.FullName);
|
||||
Assert.Equal(expectedAnimal.Species, deserialised.Species);
|
||||
}
|
||||
|
||||
[JsonSerializable(typeof(Animal))]
|
||||
private sealed partial class JsonContext4 : JsonSerializerContext;
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
public partial class ChatClientAgent_StructuredOutput_WithRunAsyncTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RunAsync_WithGenericType_SetsJsonSchemaResponseFormatAndDeserializesResultAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatResponseFormat? capturedResponseFormat = null;
|
||||
ChatResponseFormatJson expectedResponseFormat = ChatResponseFormat.ForJsonSchema<Animal>(JsonContext3.Default.Options);
|
||||
Animal expectedSO = new() { Id = 1, FullName = "Tigger", Species = Species.Tiger };
|
||||
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(s => s
|
||||
.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) => capturedResponseFormat = opts?.ResponseFormat)
|
||||
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedSO, JsonContext3.Default.Animal)))
|
||||
{
|
||||
ResponseId = "test",
|
||||
});
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object);
|
||||
|
||||
// Act
|
||||
AgentResponse<Animal> agentResponse = await agent.RunAsync<Animal>(
|
||||
messages: [new(ChatRole.User, "Hello")],
|
||||
serializerOptions: JsonContext3.Default.Options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedResponseFormat);
|
||||
Assert.Equal(expectedResponseFormat.Schema?.GetRawText(), ((ChatResponseFormatJson)capturedResponseFormat).Schema?.GetRawText());
|
||||
|
||||
Animal animal = agentResponse.Result;
|
||||
Assert.NotNull(animal);
|
||||
Assert.Equal(expectedSO.Id, animal.Id);
|
||||
Assert.Equal(expectedSO.FullName, animal.FullName);
|
||||
Assert.Equal(expectedSO.Species, animal.Species);
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(UseStringEnumConverter = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
|
||||
[JsonSerializable(typeof(Animal))]
|
||||
private sealed partial class JsonContext3 : JsonSerializerContext;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
internal sealed class Animal
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? FullName { get; set; }
|
||||
public Species Species { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
internal enum Species
|
||||
{
|
||||
Bear,
|
||||
Tiger,
|
||||
Walrus,
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using AgentConformance.IntegrationTests;
|
||||
|
||||
namespace OpenAIAssistant.IntegrationTests;
|
||||
|
||||
public class OpenAIAssistantStructuredOutputRunTests() : StructuredOutputRunTests<OpenAIAssistantFixture>(() => new())
|
||||
{
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using AgentConformance.IntegrationTests;
|
||||
|
||||
namespace OpenAIChatCompletion.IntegrationTests;
|
||||
|
||||
public class OpenAIChatCompletionStructuredOutputRunTests() : StructuredOutputRunTests<OpenAIChatCompletionFixture>(() => new(useReasoningChatModel: false))
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using AgentConformance.IntegrationTests;
|
||||
|
||||
namespace ResponseResult.IntegrationTests;
|
||||
|
||||
public class OpenAIResponseStructuredOutputRunTests() : StructuredOutputRunTests<OpenAIResponseFixture>(() => new(store: false))
|
||||
{
|
||||
}
|
||||
Reference in New Issue
Block a user