.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>
This commit is contained in:
SergeyMenshykh
2026-02-04 19:47:36 +00:00
committed by GitHub
Unverified
parent 0709f6df06
commit 1e20c69cbd
30 changed files with 975 additions and 333 deletions
@@ -20,7 +20,7 @@ namespace Microsoft.Agents.AI;
/// may involve multiple agents working together.
/// </remarks>
[DebuggerDisplay("{DebuggerDisplay,nq}")]
public abstract class AIAgent
public abstract partial class AIAgent
{
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
private string DebuggerDisplay =>
@@ -11,155 +11,126 @@ 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)
};
}
options = options?.Clone() ?? new AgentRunOptions();
options.ResponseFormat = ChatResponseFormat.ForJsonSchema<T>(serializerOptions);
return this.RunCoreAsync(GetResponseAsync, CreateResponse, messages, session, options, cancellationToken);
AgentResponse response = await this.RunAsync(messages, session, options, cancellationToken).ConfigureAwait(false);
return new AgentResponse<T>(response, serializerOptions);
}
}
@@ -68,6 +68,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>
@@ -1,6 +1,16 @@
// 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;
namespace Microsoft.Agents.AI;
@@ -8,23 +18,64 @@ 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 from an existing <see cref="ChatResponse"/>.
/// Initializes a new instance of the <see cref="AgentResponse{T}"/> class.
/// </summary>
/// <param name="response">The <see cref="ChatResponse"/> from which to populate this <see cref="AgentResponse{T}"/>.</param>
protected AgentResponse(ChatResponse response) : base(response)
/// <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>
public AgentResponse(AgentResponse response, JsonSerializerOptions serializerOptions) : base(response)
{
this._serializerOptions = serializerOptions;
}
/// <summary>
/// Gets the result value of the agent response as an instance of <typeparamref name="T"/>.
/// </summary>
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.");
}
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 @@
- Switch to new "Run" method name ([#2843](https://github.com/microsoft/agent-framework/pull/2843))
- Removed AgentThreadMetadata and used AgentSessionId directly instead ([#3067](https://github.com/microsoft/agent-framework/pull/3067));
- Renamed AgentThread to AgentSession ([#3430](https://github.com/microsoft/agent-framework/pull/3430))
- Updated to use base `AgentRunOptions.ResponseFormat` for structured output configuration ([#3658](https://github.com/microsoft/agent-framework/pull/3658))
## v1.0.0-preview.251204.1
@@ -1,9 +1,7 @@
// 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;
@@ -92,7 +90,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)
{
@@ -100,6 +97,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
@@ -144,110 +147,4 @@ public sealed class DurableAIAgent : AIAgent
yield return update;
}
}
/// <summary>
/// Runs the agent with a message and returns the deserialized output as an instance of <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>(
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);
}
/// <summary>
/// Runs the agent with messages and returns the deserialized output as an instance of <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>(
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));
}
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));
}
// Create the JSON schema for the response type
durableOptions.ResponseFormat = ChatResponseFormat.ForJsonSchema<T>();
AgentResponse response = await this.RunAsync(messages, session, durableOptions, cancellationToken);
// Deserialize the response text to the requested type
if (string.IsNullOrEmpty(response.Text))
{
throw new InvalidOperationException("Agent response is empty and cannot be deserialized.");
}
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;
}
}
@@ -47,7 +47,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)
@@ -56,6 +55,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);
}
@@ -634,6 +634,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,91 @@
// 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);
}
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,95 @@
// 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&lt;T&gt; is ignored by AzureAIProjectChatClient and is only used
/// for deserializing the agent response by AgentResponse&lt;T&gt;.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();
}
/// <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);
}
}
@@ -119,6 +119,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)}";
@@ -159,9 +166,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);
}
}
@@ -0,0 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
using AgentConformance.IntegrationTests;
namespace AzureAIAgentsPersistent.IntegrationTests;
public class AzureAIAgentsPersistentStructuredOutputRunTests() : StructuredOutputRunTests<AzureAIAgentsPersistentFixture>(() => new())
{
}
@@ -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"));
}
}
@@ -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"));
}
}
@@ -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,8 +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;
using Microsoft.Extensions.AI;
@@ -479,45 +477,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>
@@ -1485,22 +1444,4 @@ public partial class ChatClientAgentTests
yield return update;
}
}
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;
}
@@ -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;
}
@@ -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,
}
@@ -0,0 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
using AgentConformance.IntegrationTests;
namespace OpenAIAssistant.IntegrationTests;
public class OpenAIAssistantStructuredOutputRunTests() : StructuredOutputRunTests<OpenAIAssistantFixture>(() => new())
{
}
@@ -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))
{
}