From a6c49c2c83429fdf378845debdbac8882d10eecd Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Wed, 4 Feb 2026 00:08:34 +0000 Subject: [PATCH 01/16] add support for so --- .../AIAgent.cs | 7 +- .../AIAgentStructuredOutput.cs | 270 ++++++++++++++++++ .../AgentResponse.cs | 23 ++ .../AgentResponse{T}.cs | 103 ++++++- .../AgentRunOptions.cs | 15 + .../MEAI/NewChatResponseFormat.cs | 100 +++++++ .../MEAI/NewChatResponseFormatJson.cs | 87 ++++++ .../DurableAIAgent.cs | 115 +------- .../DurableAIAgentProxy.cs | 7 +- .../DurableAgentRunOptions.cs | 7 - .../ChatClient/ChatClientAgent.cs | 6 + .../ChatClientAgentCustomOptions.cs | 129 +++++++-- .../ChatClientAgentRunResponse{T}.cs | 45 --- .../ChatClientAgentStructuredOutput.cs | 165 ----------- .../StructuredOutputRunTests.cs | 116 ++++++++ .../Support/Constants.cs | 2 +- .../Support/SessionCleanup.cs | 2 +- ...jectClientAgentStructuredOutputRunTests.cs | 104 +++++++ .../AIProjectClientFixture.cs | 15 +- ...gentsPersistentStructuredOutputRunTests.cs | 9 + .../ChatClient/ChatClientAgentTests.cs | 59 ---- ...tClientAgent_SO_WithFormatResponseTests.cs | 212 ++++++++++++++ .../ChatClientAgent_SO_WithRunAsyncTests.cs | 97 +++++++ .../Models/Animal.cs | 17 ++ ...OpenAIAssistantStructuredOutputRunTests.cs | 9 + ...IChatCompletionStructuredOutputRunTests.cs | 9 + .../OpenAIResponseStructuredOutputRunTests.cs | 9 + 27 files changed, 1305 insertions(+), 434 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/MEAI/NewChatResponseFormat.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/MEAI/NewChatResponseFormatJson.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentRunResponse{T}.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentStructuredOutput.cs create mode 100644 dotnet/tests/AgentConformance.IntegrationTests/StructuredOutputRunTests.cs create mode 100644 dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs create mode 100644 dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentStructuredOutputRunTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_SO_WithFormatResponseTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_SO_WithRunAsyncTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Models/Animal.cs create mode 100644 dotnet/tests/OpenAIAssistant.IntegrationTests/OpenAIAssistantStructuredOutputRunTests.cs create mode 100644 dotnet/tests/OpenAIChatCompletion.IntegrationTests/OpenAIChatCompletionStructuredOutputRunTests.cs create mode 100644 dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseStructuredOutputRunTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs index 9ec8d6b7e9..12ef0d3bd0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs @@ -20,7 +20,7 @@ namespace Microsoft.Agents.AI; /// may involve multiple agents working together. /// [DebuggerDisplay("{DebuggerDisplay,nq}")] -public abstract class AIAgent +public abstract partial class AIAgent { [DebuggerBrowsable(DebuggerBrowsableState.Never)] private string DebuggerDisplay => @@ -224,11 +224,6 @@ public Task RunAsync( /// A task that represents the asynchronous operation. The task result contains an with the agent's output. /// /// - /// This method delegates to to perform the actual agent invocation. It handles collections of messages, - /// allowing for complex conversational scenarios including multi-turn interactions, function calls, and - /// context-rich conversations. - /// - /// /// The messages are processed in the order provided and become part of the conversation history. /// The agent's response will also be added to if one is provided. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs new file mode 100644 index 0000000000..c5242806ab --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs @@ -0,0 +1,270 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Provides structured output methods for that enable requesting responses in a specific type format. +/// +public abstract partial class AIAgent +{ + #region RunAsync overloads + /// + /// 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 . + /// + /// The type of structured output to request. + /// + /// The conversation session to use for this invocation. If , a new session will be created. + /// The session will be updated with any response messages generated during invocation. + /// + /// Optional JSON serializer options to use for deserializing the response. + /// Optional configuration parameters for controlling the agent's invocation behavior. + /// The to monitor for cancellation requests. The default is . + /// A task that represents the asynchronous operation. The task result contains an with the agent's output. + /// + /// 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. + /// + public Task> RunAsync( + AgentSession? session = null, + JsonSerializerOptions? serializerOptions = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) => + this.RunAsync([], session, serializerOptions, options, cancellationToken); + + /// + /// Runs the agent with a text message from the user, requesting a response of the specified type . + /// + /// The type of structured output to request. + /// The user message to send to the agent. + /// + /// The conversation session to use for this invocation. If , a new session will be created. + /// The session will be updated with the input message and any response messages generated during invocation. + /// + /// Optional JSON serializer options to use for deserializing the response. + /// Optional configuration parameters for controlling the agent's invocation behavior. + /// The to monitor for cancellation requests. The default is . + /// A task that represents the asynchronous operation. The task result contains an with the agent's output. + /// is , empty, or contains only whitespace. + /// + /// The provided text will be wrapped in a with the role + /// before being sent to the agent. This is a convenience method for simple text-based interactions. + /// + public Task> RunAsync( + string message, + AgentSession? session = null, + JsonSerializerOptions? serializerOptions = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNullOrWhitespace(message); + + return this.RunAsync(new ChatMessage(ChatRole.User, message), session, serializerOptions, options, cancellationToken); + } + + /// + /// Runs the agent with a single chat message, requesting a response of the specified type . + /// + /// The type of structured output to request. + /// The chat message to send to the agent. + /// + /// The conversation session to use for this invocation. If , a new session will be created. + /// The session will be updated with the input message and any response messages generated during invocation. + /// + /// Optional JSON serializer options to use for deserializing the response. + /// Optional configuration parameters for controlling the agent's invocation behavior. + /// The to monitor for cancellation requests. The default is . + /// A task that represents the asynchronous operation. The task result contains an with the agent's output. + /// is . + public Task> RunAsync( + ChatMessage message, + AgentSession? session = null, + JsonSerializerOptions? serializerOptions = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(message); + + return this.RunAsync([message], session, serializerOptions, options, cancellationToken); + } + + /// + /// Runs the agent with a collection of chat messages, requesting a response of the specified type . + /// + /// The type of structured output to request. + /// The collection of messages to send to the agent for processing. + /// + /// The conversation session to use for this invocation. If , a new session will be created. + /// The session will be updated with the input messages and any response messages generated during invocation. + /// + /// Optional JSON serializer options to use for deserializing the response. + /// Optional configuration parameters for controlling the agent's invocation behavior. + /// The to monitor for cancellation requests. The default is . + /// A task that represents the asynchronous operation. The task result contains an with the agent's output. + /// + /// + /// This method handles collections of messages, allowing for complex conversational scenarios including + /// multi-turn interactions, function calls, and context-rich conversations. + /// + /// + /// The messages are processed in the order provided and become part of the conversation history. + /// The agent's response will also be added to if one is provided. + /// + /// + public async Task> RunAsync( + IEnumerable messages, + AgentSession? session = null, + JsonSerializerOptions? serializerOptions = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + var responseFormat = NewChatResponseFormat.ForJsonSchema(serializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions); + + options = options is null ? new AgentRunOptions() : new AgentRunOptions(options); + options.ResponseFormat = responseFormat; + + AgentResponse response = await this.RunAsync(messages, session, options, cancellationToken).ConfigureAwait(false); + + return new AgentResponse(response, responseFormat); + } + #endregion + + #region RunAsync(Type type) overloads + /// + /// 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 . + /// + /// The type of structured output to request. + /// + /// The conversation session to use for this invocation. If , a new session will be created. + /// The session will be updated with any response messages generated during invocation. + /// + /// Optional JSON serializer options to use for deserializing the response. + /// Optional configuration parameters for controlling the agent's invocation behavior. + /// The to monitor for cancellation requests. The default is . + /// A task that represents the asynchronous operation. The task result contains an with the agent's output. + /// + /// 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. + /// + public Task> RunAsync( + Type resultType, + AgentSession? session = null, + JsonSerializerOptions? serializerOptions = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) => + this.RunAsync(resultType, [], session, serializerOptions, options, cancellationToken); + + /// + /// Runs the agent with a text message from the user, requesting a response of the specified . + /// + /// The type of structured output to request. + /// The user message to send to the agent. + /// + /// The conversation session to use for this invocation. If , a new session will be created. + /// The session will be updated with the input message and any response messages generated during invocation. + /// + /// Optional JSON serializer options to use for deserializing the response. + /// Optional configuration parameters for controlling the agent's invocation behavior. + /// The to monitor for cancellation requests. The default is . + /// A task that represents the asynchronous operation. The task result contains an with the agent's output. + /// is . + /// is , empty, or contains only whitespace. + /// + /// The provided text will be wrapped in a with the role + /// before being sent to the agent. This is a convenience method for simple text-based interactions. + /// + public Task> RunAsync( + Type resultType, + string message, + AgentSession? session = null, + JsonSerializerOptions? serializerOptions = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(resultType); + _ = Throw.IfNullOrWhitespace(message); + + return this.RunAsync(resultType, new ChatMessage(ChatRole.User, message), session, serializerOptions, options, cancellationToken); + } + + /// + /// Runs the agent with a single chat message, requesting a response of the specified . + /// + /// The type of structured output to request. + /// The chat message to send to the agent. + /// + /// The conversation session to use for this invocation. If , a new session will be created. + /// The session will be updated with the input message and any response messages generated during invocation. + /// + /// Optional JSON serializer options to use for deserializing the response. + /// Optional configuration parameters for controlling the agent's invocation behavior. + /// The to monitor for cancellation requests. The default is . + /// A task that represents the asynchronous operation. The task result contains an with the agent's output. + /// is . + /// is . + public Task> RunAsync( + Type resultType, + ChatMessage message, + AgentSession? session = null, + JsonSerializerOptions? serializerOptions = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(resultType); + _ = Throw.IfNull(message); + + return this.RunAsync(resultType, [message], session, serializerOptions, options, cancellationToken); + } + + /// + /// Runs the agent with a collection of chat messages, requesting a response of the specified . + /// + /// The type of structured output to request. + /// The collection of messages to send to the agent for processing. + /// + /// The conversation session to use for this invocation. If , a new session will be created. + /// The session will be updated with the input messages and any response messages generated during invocation. + /// + /// Optional JSON serializer options to use for deserializing the response. + /// Optional configuration parameters for controlling the agent's invocation behavior. + /// The to monitor for cancellation requests. The default is . + /// A task that represents the asynchronous operation. The task result contains an with the agent's output. + /// is . + /// + /// + /// This method handles collections of messages, allowing for complex conversational scenarios including + /// multi-turn interactions, function calls, and context-rich conversations. + /// + /// + /// The messages are processed in the order provided and become part of the conversation history. + /// The agent's response will also be added to if one is provided. + /// + /// + public async Task> RunAsync( + Type resultType, + IEnumerable messages, + AgentSession? session = null, + JsonSerializerOptions? serializerOptions = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(resultType); + + var responseFormat = NewChatResponseFormat.ForJsonSchema(resultType, serializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions); + + options = options is null ? new AgentRunOptions() : new AgentRunOptions(options); + options.ResponseFormat = responseFormat; + + AgentResponse response = await this.RunAsync(messages, session, options, cancellationToken).ConfigureAwait(false); + + return new AgentResponse(response, responseFormat); + } + #endregion +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentResponse.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentResponse.cs index d79dbee135..766ad19fca 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentResponse.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentResponse.cs @@ -68,6 +68,29 @@ public AgentResponse(ChatResponse response) this.ContinuationToken = response.ContinuationToken; } + /// + /// Initializes a new instance of the class from an existing . + /// + /// The from which to copy properties. + /// is . + /// + /// This constructor creates a copy of an existing agent response, preserving all + /// metadata and storing the original response in for access to + /// the underlying implementation details. + /// + public 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; + } + /// /// Initializes a new instance of the class with the specified collection of messages. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentResponse{T}.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentResponse{T}.cs index 2a18aadb37..0daf1d15b5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentResponse{T}.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentResponse{T}.cs @@ -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,99 @@ namespace Microsoft.Agents.AI; /// Represents the response of the specified type to an run request. /// /// The type of value expected from the agent. -public abstract class AgentResponse : AgentResponse +public class AgentResponse : AgentResponse { - /// Initializes a new instance of the class. - protected AgentResponse() + /// + /// Initializes a new instance of the class. + /// + public AgentResponse() { } /// - /// Initializes a new instance of the class from an existing . + /// Initializes a new instance of the class. /// - /// The from which to populate this . - protected AgentResponse(ChatResponse response) : base(response) + /// The from which to populate this . + /// The JSON response format configuration used to deserialize the agent's response. + public AgentResponse(AgentResponse response, NewChatResponseFormatJson? responseFormat = null) : base(response) { + this.ResponseFormat = responseFormat; } /// /// Gets the result value of the agent response as an instance of . /// - public abstract T Result { get; } + [JsonIgnore] + public virtual T Result + { + get + { + return (T)this.Deserialize(this.ResponseFormat?.SchemaType ?? typeof(T), this.ResponseFormat?.SchemaSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions); + } + } + + private NewChatResponseFormatJson? ResponseFormat { get; } + + private object Deserialize(Type targetType, JsonSerializerOptions serializerOptions) + { + _ = Throw.IfNull(serializerOptions); + + var structuredOutput = this.GetResultCore(targetType, 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!, + }; + } + + private object? GetResultCore(Type targetType, JsonSerializerOptions serializerOptions, out FailureReason? failureReason) + { + var json = this.Text; + if (string.IsNullOrEmpty(json)) + { + failureReason = FailureReason.ResultDidNotContainJson; + return default; + } + + object? deserialized = DeserializeFirstTopLevelObject(json!, serializerOptions.GetTypeInfo(targetType)); + + if (deserialized is null) + { + failureReason = FailureReason.DeserializationProducedNull; + return default; + } + + failureReason = default; + return deserialized; + } + + private static object? DeserializeFirstTopLevelObject(string json, JsonTypeInfo 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.Shared.Rent(utf8ByteLength); + try + { + var utf8SpanLength = Encoding.UTF8.GetBytes(json, 0, json.Length, buffer, 0); + var reader = new Utf8JsonReader(new ReadOnlySpan(buffer, 0, utf8SpanLength), new() { AllowMultipleValues = true }); + return JsonSerializer.Deserialize(ref reader, typeInfo); + } + finally + { + ArrayPool.Shared.Return(buffer); + } +#else + return JsonSerializer.Deserialize(json, typeInfo); +#endif + } + + private enum FailureReason + { + ResultDidNotContainJson, + DeserializationProducedNull, + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunOptions.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunOptions.cs index 611d15036c..6a17d60e42 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunOptions.cs @@ -34,6 +34,7 @@ public AgentRunOptions(AgentRunOptions options) this.ContinuationToken = options.ContinuationToken; this.AllowBackgroundResponses = options.AllowBackgroundResponses; this.AdditionalProperties = options.AdditionalProperties?.Clone(); + this.ResponseFormat = options.ResponseFormat; } /// @@ -90,4 +91,18 @@ public AgentRunOptions(AgentRunOptions options) /// preserving implementation-specific details or extending the options with custom data. /// public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } + + /// + /// Gets or sets the response format. + /// + /// + /// If , no response format is specified and the agent will use its default. + /// This property can be set to to specify that the response should be unstructured text, + /// to to specify that the response should be structured JSON data, or + /// an instance of 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 , + /// it can be ignored. + /// + public ChatResponseFormat? ResponseFormat { get; set; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/MEAI/NewChatResponseFormat.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/MEAI/NewChatResponseFormat.cs new file mode 100644 index 0000000000..e78cd5e434 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/MEAI/NewChatResponseFormat.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ComponentModel; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +#pragma warning disable CA1052 // Static holder types should be Static or NotInheritable + +/// Represents the response format that is desired by the caller. +[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] +[JsonDerivedType(typeof(NewChatResponseFormatJson), typeDiscriminator: "json")] +public partial class NewChatResponseFormat +{ + private static readonly AIJsonSchemaCreateOptions s_inferenceOptions = new() + { + IncludeSchemaKeyword = true, + }; + + /// Initializes a new instance of the class. + /// Prevents external instantiation. Close the inheritance hierarchy for now until we have good reason to open it. + private protected NewChatResponseFormat() + { + } + + /// Gets a singleton instance representing structured JSON data but without any particular schema. + public static NewChatResponseFormatJson Json { get; } = new(schema: null); + + /// Creates a representing structured JSON data with the specified schema. + /// The JSON schema. + /// An optional name of the schema. For example, if the schema represents a particular class, this could be the name of the class. + /// An optional description of the schema. + /// The instance. + public static NewChatResponseFormatJson ForJsonSchema( + JsonElement schema, string? schemaName = null, string? schemaDescription = null) => + new(schema, schemaName, schemaDescription); + + /// Creates a representing structured JSON data with a schema based on . + /// The type for which a schema should be exported and used as the response schema. + /// The JSON serialization options to use. + /// An optional name of the schema. By default, this will be inferred from . + /// An optional description of the schema. By default, this will be inferred from . + /// The instance. + /// + /// Many AI services that support structured output require that the JSON schema have a top-level 'type=object'. + /// If is a primitive type like , , or , + /// or if it's a type that serializes as a JSON array, attempting to use the resulting schema with such services may fail. + /// In such cases, consider instead using a that wraps the actual type in a class or struct so that + /// it serializes as a JSON object with the original type as a property of that object. + /// + public static NewChatResponseFormatJson ForJsonSchema(JsonSerializerOptions? serializerOptions = null, string? schemaName = null, string? schemaDescription = null) => + ForJsonSchema(typeof(T), serializerOptions, schemaName, schemaDescription); + + /// Creates a representing structured JSON data with a schema based on . + /// The for which a schema should be exported and used as the response schema. + /// The JSON serialization options to use. + /// An optional name of the schema. By default, this will be inferred from . + /// An optional description of the schema. By default, this will be inferred from . + /// The instance. + /// + /// Many AI services that support structured output require that the JSON schema have a top-level 'type=object'. + /// If is a primitive type like , , or , + /// or if it's a type that serializes as a JSON array, attempting to use the resulting schema with such services may fail. + /// In such cases, consider instead using a that wraps the actual type in a class or struct so that + /// it serializes as a JSON object with the original type as a property of that object. + /// + /// is . + public static NewChatResponseFormatJson ForJsonSchema( + Type schemaType, JsonSerializerOptions? serializerOptions = null, string? schemaName = null, string? schemaDescription = null) + { + _ = Throw.IfNull(schemaType); + + var schema = AIJsonUtilities.CreateJsonSchema( + schemaType, + serializerOptions: serializerOptions ?? AIJsonUtilities.DefaultOptions, + inferenceOptions: s_inferenceOptions); + + return new( + schemaType, + schema, + schemaName ?? schemaType.GetCustomAttribute()?.DisplayName ?? InvalidNameCharsRegex().Replace(schemaType.Name, "_"), + schemaDescription ?? schemaType.GetCustomAttribute()?.Description, + serializerOptions); + } + + /// Regex that flags any character other than ASCII digits, ASCII letters, or underscore. +#if NET + [GeneratedRegex("[^0-9A-Za-z_]")] + private static partial Regex InvalidNameCharsRegex(); +#else + private static Regex InvalidNameCharsRegex() => s_invalidNameCharsRegex; + private static readonly Regex s_invalidNameCharsRegex = new("[^0-9A-Za-z_]", RegexOptions.Compiled); +#endif +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/MEAI/NewChatResponseFormatJson.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/MEAI/NewChatResponseFormatJson.cs new file mode 100644 index 0000000000..9526c1f0af --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/MEAI/NewChatResponseFormatJson.cs @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// Represents a response format for structured JSON data. +[DebuggerDisplay("{DebuggerDisplay,nq}")] +public sealed class NewChatResponseFormatJson : NewChatResponseFormat +{ + /// Initializes a new instance of the class with the specified schema. + /// The schema to associate with the JSON response. + /// A name for the schema. + /// A description of the schema. + [JsonConstructor] + public NewChatResponseFormatJson( + JsonElement? schema, string? schemaName = null, string? schemaDescription = null) + { + if (schema is null && (schemaName is not null || schemaDescription is not null)) + { + Throw.ArgumentException( + schemaName is not null ? nameof(schemaName) : nameof(schemaDescription), + "Schema name and description can only be specified if a schema is provided."); + } + + this.Schema = schema; + this.SchemaName = schemaName; + this.SchemaDescription = schemaDescription; + } + + /// + /// Initializes a new instance of the class with a schema derived from the specified type. + /// + /// The from which the schema was derived. + /// The JSON schema to associate with the JSON response. + /// An optional name for the schema. + /// An optional description of the schema. + /// The JSON serializer options to use for deserialization. + public NewChatResponseFormatJson( + Type schemaType, JsonElement schema, string? schemaName = null, string? schemaDescription = null, JsonSerializerOptions? serializerOptions = null) + { + this.SchemaType = schemaType; + this.Schema = schema; + this.SchemaName = schemaName; + this.SchemaDescription = schemaDescription; + this.SchemaSerializerOptions = serializerOptions; + } + + /// + /// Gets the from which the JSON schema was derived, or if the schema was not derived from a type. + /// + [JsonIgnore] + public Type? SchemaType { get; } + + /// Gets the JSON schema associated with the response, or if there is none. + public JsonElement? Schema { get; } + + /// Gets a name for the schema. + public string? SchemaName { get; } + + /// Gets a description of the schema. + public string? SchemaDescription { get; } + + /// + /// Gets the JSON serializer options to use when deserializing responses that conform to this schema, or if default options should be used. + /// + [JsonIgnore] + public JsonSerializerOptions? SchemaSerializerOptions { get; } + + /// Gets a string representing this instance to display in the debugger. + [DebuggerBrowsable(DebuggerBrowsableState.Never)] + private string DebuggerDisplay => this.Schema?.ToString() ?? "JSON"; + + /// + /// Implicitly converts a to a . + /// + /// The instance to convert. + public static implicit operator ChatResponseFormatJson(NewChatResponseFormatJson format) + { + return new ChatResponseFormatJson(format.Schema, format.SchemaName, format.SchemaDescription); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs index f944131b4e..bad8fce7cb 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs @@ -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 @@ protected override async Task RunCoreAsync( { enableToolCalls = durableOptions.EnableToolCalls; enableToolNames = durableOptions.EnableToolNames; - responseFormat = durableOptions.ResponseFormat; } else if (options is ChatClientAgentRunOptions chatClientOptions && chatClientOptions.ChatOptions?.Tools != null) { @@ -100,6 +97,12 @@ protected override async Task RunCoreAsync( 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 @@ protected override async IAsyncEnumerable RunCoreStreamingA yield return update; } } - - /// - /// Runs the agent with a message and returns the deserialized output as an instance of . - /// - /// The message to send to the agent. - /// The agent session to use. - /// Optional JSON serializer options. - /// Optional run options. - /// The cancellation token. - /// The type of the output. - /// - /// Thrown when the provided already contains a response schema. - /// Thrown when the provided is not a . - /// - /// - /// Thrown when the agent response is empty or cannot be deserialized. - /// - /// The output from the agent. - public async Task> RunAsync( - string message, - AgentSession? session = null, - JsonSerializerOptions? serializerOptions = null, - AgentRunOptions? options = null, - CancellationToken cancellationToken = default) - { - return await this.RunAsync( - messages: [new ChatMessage(ChatRole.User, message) { CreatedAt = DateTimeOffset.UtcNow }], - session, - serializerOptions, - options, - cancellationToken); - } - - /// - /// Runs the agent with messages and returns the deserialized output as an instance of . - /// - /// The messages to send to the agent. - /// The agent session to use. - /// Optional JSON serializer options. - /// Optional run options. - /// The cancellation token. - /// The type of the output. - /// - /// Thrown when the provided already contains a response schema. - /// Thrown when the provided is not a . - /// - /// - /// Thrown when the agent response is empty or cannot be deserialized. - /// - /// The output from the agent. - [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> RunAsync( - IEnumerable 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.", - 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.", - paramName: nameof(options)); - } - - // Create the JSON schema for the response type - durableOptions.ResponseFormat = ChatResponseFormat.ForJsonSchema(); - - 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(response.Text, serializerOptions)) - ?? throw new InvalidOperationException($"Failed to deserialize agent response to type {typeof(T).Name}."); - - return new DurableAIAgentResponse(response, result); - } - - private sealed class DurableAIAgentResponse(AgentResponse response, T result) - : AgentResponse(response.AsChatResponse()) - { - public override T Result { get; } = result; - } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgentProxy.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgentProxy.cs index d61bd6df77..2d9260ca5c 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgentProxy.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgentProxy.cs @@ -47,7 +47,6 @@ protected override async Task RunCoreAsync( { enableToolCalls = durableOptions.EnableToolCalls; enableToolNames = durableOptions.EnableToolNames; - responseFormat = durableOptions.ResponseFormat; isFireAndForget = durableOptions.IsFireAndForget; } else if (options is ChatClientAgentRunOptions chatClientOptions) @@ -56,6 +55,12 @@ protected override async Task RunCoreAsync( 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; diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentRunOptions.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentRunOptions.cs index 0f1984ad62..a83e19b046 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentRunOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentRunOptions.cs @@ -1,7 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using Microsoft.Extensions.AI; - namespace Microsoft.Agents.AI.DurableTask; /// @@ -19,11 +17,6 @@ public sealed class DurableAgentRunOptions : AgentRunOptions /// public IList? EnableToolNames { get; set; } - /// - /// Gets or sets the response format for the agent's response. - /// - public ChatResponseFormat? ResponseFormat { get; set; } - /// /// Gets or sets whether to fire and forget the agent run request. /// diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs index 937a6d0f0b..48b99898dc 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs @@ -634,6 +634,12 @@ await session.AIContextProvider.InvokedAsync(new(inputMessages, aiContextProvide 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) diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentCustomOptions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentCustomOptions.cs index 25ae5a903e..64a7a7e7f0 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentCustomOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentCustomOptions.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; using System.Text.Json; using System.Threading; @@ -162,19 +163,14 @@ public IAsyncEnumerable RunStreamingAsync( /// /// The JSON serialization options to use. /// Configuration parameters for controlling the agent's invocation behavior. - /// - /// to set a JSON schema on the ; otherwise, . The default is . - /// 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. - /// /// The to monitor for cancellation requests. The default is . - /// A task that represents the asynchronous operation. The task result contains an with the agent's output. - public Task> RunAsync( + /// A task that represents the asynchronous operation. The task result contains an with the agent's output. + public Task> RunAsync( AgentSession? session, JsonSerializerOptions? serializerOptions, ChatClientAgentRunOptions? options, - bool? useJsonSchemaResponseFormat = null, CancellationToken cancellationToken = default) => - this.RunAsync(session, serializerOptions, (AgentRunOptions?)options, useJsonSchemaResponseFormat, cancellationToken); + this.RunAsync(session, serializerOptions, (AgentRunOptions?)options, cancellationToken); /// /// Runs the agent with a text message from the user, requesting a response of the specified type . @@ -186,20 +182,15 @@ public Task> RunAsync( /// /// The JSON serialization options to use. /// Configuration parameters for controlling the agent's invocation behavior. - /// - /// to set a JSON schema on the ; otherwise, . The default is . - /// 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. - /// /// The to monitor for cancellation requests. The default is . - /// A task that represents the asynchronous operation. The task result contains an with the agent's output. - public Task> RunAsync( + /// A task that represents the asynchronous operation. The task result contains an with the agent's output. + public Task> RunAsync( string message, AgentSession? session, JsonSerializerOptions? serializerOptions, ChatClientAgentRunOptions? options, - bool? useJsonSchemaResponseFormat = null, CancellationToken cancellationToken = default) => - this.RunAsync(message, session, serializerOptions, (AgentRunOptions?)options, useJsonSchemaResponseFormat, cancellationToken); + this.RunAsync(message, session, serializerOptions, (AgentRunOptions?)options, cancellationToken); /// /// Runs the agent with a single chat message, requesting a response of the specified type . @@ -211,20 +202,15 @@ public Task> RunAsync( /// /// The JSON serialization options to use. /// Configuration parameters for controlling the agent's invocation behavior. - /// - /// to set a JSON schema on the ; otherwise, . The default is . - /// 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. - /// /// The to monitor for cancellation requests. The default is . - /// A task that represents the asynchronous operation. The task result contains an with the agent's output. - public Task> RunAsync( + /// A task that represents the asynchronous operation. The task result contains an with the agent's output. + public Task> RunAsync( ChatMessage message, AgentSession? session, JsonSerializerOptions? serializerOptions, ChatClientAgentRunOptions? options, - bool? useJsonSchemaResponseFormat = null, CancellationToken cancellationToken = default) => - this.RunAsync(message, session, serializerOptions, (AgentRunOptions?)options, useJsonSchemaResponseFormat, cancellationToken); + this.RunAsync(message, session, serializerOptions, (AgentRunOptions?)options, cancellationToken); /// /// Runs the agent with a collection of chat messages, requesting a response of the specified type . @@ -236,18 +222,99 @@ public Task> RunAsync( /// /// The JSON serialization options to use. /// Configuration parameters for controlling the agent's invocation behavior. - /// - /// to set a JSON schema on the ; otherwise, . The default is . - /// 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. + /// The to monitor for cancellation requests. The default is . + /// A task that represents the asynchronous operation. The task result contains an with the agent's output. + public Task> RunAsync( + IEnumerable messages, + AgentSession? session, + JsonSerializerOptions? serializerOptions, + ChatClientAgentRunOptions? options, + CancellationToken cancellationToken = default) => + this.RunAsync(messages, session, serializerOptions, (AgentRunOptions?)options, cancellationToken); + + /// + /// 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 . + /// + /// The type of structured output to request. + /// + /// The conversation session to use for this invocation. If , a new session will be created. + /// The session will be updated with any response messages generated during invocation. + /// + /// The JSON serialization options to use. + /// Configuration parameters for controlling the agent's invocation behavior. + /// The to monitor for cancellation requests. The default is . + /// A task that represents the asynchronous operation. The task result contains an with the agent's output. + public Task> RunAsync( + Type resultType, + AgentSession? session, + JsonSerializerOptions? serializerOptions, + ChatClientAgentRunOptions? options, + CancellationToken cancellationToken = default) => + this.RunAsync(resultType, session, serializerOptions, (AgentRunOptions?)options, cancellationToken); + + /// + /// Runs the agent with a text message from the user, requesting a response of the specified . + /// + /// The type of structured output to request. + /// The user message to send to the agent. + /// + /// The conversation session to use for this invocation. If , a new session will be created. + /// The session will be updated with the input message and any response messages generated during invocation. + /// + /// The JSON serialization options to use. + /// Configuration parameters for controlling the agent's invocation behavior. + /// The to monitor for cancellation requests. The default is . + /// A task that represents the asynchronous operation. The task result contains an with the agent's output. + public Task> RunAsync( + Type resultType, + string message, + AgentSession? session, + JsonSerializerOptions? serializerOptions, + ChatClientAgentRunOptions? options, + CancellationToken cancellationToken = default) => + this.RunAsync(resultType, message, session, serializerOptions, (AgentRunOptions?)options, cancellationToken); + + /// + /// Runs the agent with a single chat message, requesting a response of the specified . + /// + /// The type of structured output to request. + /// The chat message to send to the agent. + /// + /// The conversation session to use for this invocation. If , a new session will be created. + /// The session will be updated with the input message and any response messages generated during invocation. + /// + /// The JSON serialization options to use. + /// Configuration parameters for controlling the agent's invocation behavior. + /// The to monitor for cancellation requests. The default is . + /// A task that represents the asynchronous operation. The task result contains an with the agent's output. + public Task> RunAsync( + Type resultType, + ChatMessage message, + AgentSession? session, + JsonSerializerOptions? serializerOptions, + ChatClientAgentRunOptions? options, + CancellationToken cancellationToken = default) => + this.RunAsync(resultType, message, session, serializerOptions, (AgentRunOptions?)options, cancellationToken); + + /// + /// Runs the agent with a collection of chat messages, requesting a response of the specified . + /// + /// The type of structured output to request. + /// The collection of messages to send to the agent for processing. + /// + /// The conversation session to use for this invocation. If , a new session will be created. + /// The session will be updated with the input messages and any response messages generated during invocation. /// + /// The JSON serialization options to use. + /// Configuration parameters for controlling the agent's invocation behavior. /// The to monitor for cancellation requests. The default is . - /// A task that represents the asynchronous operation. The task result contains an with the agent's output. - public Task> RunAsync( + /// A task that represents the asynchronous operation. The task result contains an with the agent's output. + public Task> RunAsync( + Type resultType, IEnumerable messages, AgentSession? session, JsonSerializerOptions? serializerOptions, ChatClientAgentRunOptions? options, - bool? useJsonSchemaResponseFormat = null, CancellationToken cancellationToken = default) => - this.RunAsync(messages, session, serializerOptions, (AgentRunOptions?)options, useJsonSchemaResponseFormat, cancellationToken); + this.RunAsync(resultType, messages, session, serializerOptions, (AgentRunOptions?)options, cancellationToken); } diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentRunResponse{T}.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentRunResponse{T}.cs deleted file mode 100644 index a4fadff0c7..0000000000 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentRunResponse{T}.cs +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using Microsoft.Extensions.AI; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI; - -/// -/// Represents the response of the specified type to an run request. -/// -/// The type of value expected from the chat response. -/// -/// 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 property. -/// -public sealed class ChatClientAgentResponse : AgentResponse -{ - private readonly ChatResponse _response; - - /// - /// Initializes a new instance of the class from an existing . - /// - /// The from which to populate this . - /// is . - /// - /// This constructor creates an agent response that wraps an existing , preserving all - /// metadata and storing the original response in for access to - /// the underlying implementation details. - /// - public ChatClientAgentResponse(ChatResponse response) : base(response) - { - _ = Throw.IfNull(response); - - this._response = response; - } - - /// - /// Gets the result value of the agent response as an instance of . - /// - /// - /// If the response did not contain JSON, or if deserialization fails, this property will throw. - /// - public override T Result => this._response.Result; -} diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentStructuredOutput.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentStructuredOutput.cs deleted file mode 100644 index d933939884..0000000000 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentStructuredOutput.cs +++ /dev/null @@ -1,165 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.AI; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI; - -/// -/// Provides an that delegates to an implementation. -/// -public sealed partial class ChatClientAgent -{ - /// - /// 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 . - /// - /// - /// The conversation session to use for this invocation. If , a new session will be created. - /// The session will be updated with any response messages generated during invocation. - /// - /// The JSON serialization options to use. - /// Optional configuration parameters for controlling the agent's invocation behavior. - /// - /// to set a JSON schema on the ; otherwise, . The default is . - /// 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. - /// - /// The to monitor for cancellation requests. The default is . - /// A task that represents the asynchronous operation. The task result contains an with the agent's output. - /// - /// 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. - /// - public Task> RunAsync( - AgentSession? session = null, - JsonSerializerOptions? serializerOptions = null, - AgentRunOptions? options = null, - bool? useJsonSchemaResponseFormat = null, - CancellationToken cancellationToken = default) => - this.RunAsync([], session, serializerOptions, options, useJsonSchemaResponseFormat, cancellationToken); - - /// - /// Runs the agent with a text message from the user, requesting a response of the specified type . - /// - /// The user message to send to the agent. - /// - /// The conversation session to use for this invocation. If , a new session will be created. - /// The session will be updated with the input message and any response messages generated during invocation. - /// - /// The JSON serialization options to use. - /// Optional configuration parameters for controlling the agent's invocation behavior. - /// - /// to set a JSON schema on the ; otherwise, . The default is . - /// 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. - /// - /// The to monitor for cancellation requests. The default is . - /// A task that represents the asynchronous operation. The task result contains an with the agent's output. - /// is , empty, or contains only whitespace. - /// - /// The provided text will be wrapped in a with the role - /// before being sent to the agent. This is a convenience method for simple text-based interactions. - /// - public Task> RunAsync( - string message, - AgentSession? session = null, - JsonSerializerOptions? serializerOptions = null, - AgentRunOptions? options = null, - bool? useJsonSchemaResponseFormat = null, - CancellationToken cancellationToken = default) - { - _ = Throw.IfNullOrWhitespace(message); - - return this.RunAsync(new ChatMessage(ChatRole.User, message), session, serializerOptions, options, useJsonSchemaResponseFormat, cancellationToken); - } - - /// - /// Runs the agent with a single chat message, requesting a response of the specified type . - /// - /// The chat message to send to the agent. - /// - /// The conversation session to use for this invocation. If , a new session will be created. - /// The session will be updated with the input message and any response messages generated during invocation. - /// - /// The JSON serialization options to use. - /// Optional configuration parameters for controlling the agent's invocation behavior. - /// - /// to set a JSON schema on the ; otherwise, . The default is . - /// 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. - /// - /// The to monitor for cancellation requests. The default is . - /// A task that represents the asynchronous operation. The task result contains an with the agent's output. - /// is . - public Task> RunAsync( - ChatMessage message, - AgentSession? session = null, - JsonSerializerOptions? serializerOptions = null, - AgentRunOptions? options = null, - bool? useJsonSchemaResponseFormat = null, - CancellationToken cancellationToken = default) - { - _ = Throw.IfNull(message); - - return this.RunAsync([message], session, serializerOptions, options, useJsonSchemaResponseFormat, cancellationToken); - } - - /// - /// Runs the agent with a collection of chat messages, requesting a response of the specified type . - /// - /// The collection of messages to send to the agent for processing. - /// - /// The conversation session to use for this invocation. If , a new session will be created. - /// The session will be updated with the input messages and any response messages generated during invocation. - /// - /// The JSON serialization options to use. - /// Optional configuration parameters for controlling the agent's invocation behavior. - /// - /// to set a JSON schema on the ; otherwise, . The default is . - /// 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. - /// - /// The to monitor for cancellation requests. The default is . - /// A task that represents the asynchronous operation. The task result contains an with the agent's output. - /// The type of structured output to request. - /// - /// - /// 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. - /// - /// - /// The messages are processed in the order provided and become part of the conversation history. - /// The agent's response will also be added to if one is provided. - /// - /// - public Task> RunAsync( - IEnumerable messages, - AgentSession? session = null, - JsonSerializerOptions? serializerOptions = null, - AgentRunOptions? options = null, - bool? useJsonSchemaResponseFormat = null, - CancellationToken cancellationToken = default) - { - async Task> GetResponseAsync(IChatClient chatClient, List threadMessages, ChatOptions? chatOptions, CancellationToken ct) - { - return await chatClient.GetResponseAsync( - threadMessages, - serializerOptions ?? AgentJsonUtilities.DefaultOptions, - chatOptions, - useJsonSchemaResponseFormat, - ct).ConfigureAwait(false); - } - - static ChatClientAgentResponse CreateResponse(ChatResponse chatResponse) - { - return new ChatClientAgentResponse(chatResponse) - { - ContinuationToken = WrapContinuationToken(chatResponse.ContinuationToken) - }; - } - - return this.RunCoreAsync(GetResponseAsync, CreateResponse, messages, session, options, cancellationToken); - } -} diff --git a/dotnet/tests/AgentConformance.IntegrationTests/StructuredOutputRunTests.cs b/dotnet/tests/AgentConformance.IntegrationTests/StructuredOutputRunTests.cs new file mode 100644 index 0000000000..eb2def2769 --- /dev/null +++ b/dotnet/tests/AgentConformance.IntegrationTests/StructuredOutputRunTests.cs @@ -0,0 +1,116 @@ +// 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; + +/// +/// Conformance tests for structured output handling for run methods on agents. +/// +/// The type of test fixture used by the concrete test implementation. +/// Function to create the test fixture with. +public abstract class StructuredOutputRunTests(Func createAgentFixture) : AgentTests(createAgentFixture) + where TAgentFixture : IAgentFixture +{ + [RetryFact(Constants.RetryCount, Constants.RetryDelay)] + public virtual async Task RunWithResponseFormatReturnsExpectedResultAsync() + { + // Arrange + var agent = this.Fixture.Agent; + var session = await agent.GetNewSessionAsync(); + await using var cleanup = new SessionCleanup(session, this.Fixture); + + var options = new AgentRunOptions + { + ResponseFormat = NewChatResponseFormat.ForJsonSchema(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.GetNewSessionAsync(); + await using var cleanup = new SessionCleanup(session, this.Fixture); + + // Act + AgentResponse 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.NotNull(response.Result); + Assert.Equal("Paris", response.Result.Name); + } + + [RetryFact(Constants.RetryCount, Constants.RetryDelay)] + public virtual async Task RunWithTypeAsSystemTypeReturnsExpectedResultAsync() + { + // Arrange + var agent = this.Fixture.Agent; + var session = await agent.GetNewSessionAsync(); + await using var cleanup = new SessionCleanup(session, this.Fixture); + + // Act + AgentResponse response = await agent.RunAsync( + typeof(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.IsType(response.Result); + CityInfo cityInfo = (CityInfo)response.Result; + Assert.Equal("Paris", cityInfo.Name); + } + + protected static bool TryDeserialize(string json, JsonSerializerOptions jsonSerializerOptions, out T structuredOutput) + { + try + { + T? deserialized = JsonSerializer.Deserialize(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; } +} diff --git a/dotnet/tests/AgentConformance.IntegrationTests/Support/Constants.cs b/dotnet/tests/AgentConformance.IntegrationTests/Support/Constants.cs index 178b1951ba..232b5fdb10 100644 --- a/dotnet/tests/AgentConformance.IntegrationTests/Support/Constants.cs +++ b/dotnet/tests/AgentConformance.IntegrationTests/Support/Constants.cs @@ -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; diff --git a/dotnet/tests/AgentConformance.IntegrationTests/Support/SessionCleanup.cs b/dotnet/tests/AgentConformance.IntegrationTests/Support/SessionCleanup.cs index c59b999fd2..91e858e53f 100644 --- a/dotnet/tests/AgentConformance.IntegrationTests/Support/SessionCleanup.cs +++ b/dotnet/tests/AgentConformance.IntegrationTests/Support/SessionCleanup.cs @@ -11,7 +11,7 @@ namespace AgentConformance.IntegrationTests.Support; /// /// The session to delete. /// The fixture that provides agent specific capabilities. -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); diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs new file mode 100644 index 0000000000..079e18b0db --- /dev/null +++ b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs @@ -0,0 +1,104 @@ +// 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>(() => new AIProjectClientStructuredOutputFixture()) +{ + private const string NotSupported = "AIProjectClient does not support specifying structured output type at invokation time."; + + /// + /// Verifies that response format provided at agent initialization is used when invoking RunAsync. + /// + /// + [RetryFact(Constants.RetryCount, Constants.RetryDelay)] + public async Task RunWithResponseFormatAtAgentInitializationReturnsExpectedResultAsync() + { + // Arrange + var agent = this.Fixture.Agent; + var session = await agent.GetNewSessionAsync(); + await using var cleanup = new SessionCleanup(session, this.Fixture); + + var options = new AgentRunOptions() + { + ResponseFormat = NewChatResponseFormat.ForJsonSchema(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); + } + + /// + /// Verifies that generic RunAsync works with AIProjectClient when structured output is configured at agent initialization. + /// + /// + /// 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. + /// + [RetryFact(Constants.RetryCount, Constants.RetryDelay)] + public async Task RunGenericWithResponseFormatAtAgentInitializationReturnsExpectedResultAsync() + { + // Arrange + var agent = this.Fixture.Agent; + var session = await agent.GetNewSessionAsync(); + await using var cleanup = new SessionCleanup(session, this.Fixture); + + // Act + AgentResponse 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.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 RunWithTypeAsSystemTypeReturnsExpectedResultAsync() => + base.RunWithTypeAsSystemTypeReturnsExpectedResultAsync(); +} + +/// +/// Represents a fixture for testing AIProjectClient with structured output of type provided at agent initialization. +/// +public class AIProjectClientStructuredOutputFixture : AIProjectClientFixture +{ + public override Task InitializeAsync() + { + var agentOptions = new ChatClientAgentOptions + { + ChatOptions = new ChatOptions() + { + ResponseFormat = NewChatResponseFormat.ForJsonSchema(AgentAbstractionsJsonUtilities.DefaultOptions) + }, + }; + + return this.InitializeAsync(agentOptions); + } +} diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs index 4b78d30f1c..618ab1199b 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs +++ b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs @@ -119,6 +119,13 @@ public async Task CreateChatClientAgentAsync( return await this._client.CreateAIAgentAsync(GenerateUniqueAgentName(name), model: s_config.DeploymentName, instructions: instructions, tools: aiTools); } + public async Task 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 Task DisposeAsync() 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); + } } diff --git a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentStructuredOutputRunTests.cs b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentStructuredOutputRunTests.cs new file mode 100644 index 0000000000..8b628770c7 --- /dev/null +++ b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentStructuredOutputRunTests.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft. All rights reserved. + +using AgentConformance.IntegrationTests; + +namespace AzureAIAgentsPersistent.IntegrationTests; + +public class AzureAIAgentsPersistentStructuredOutputRunTests() : StructuredOutputRunTests(() => new()) +{ +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs index 6c2be9689a..6c69aacf81 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs @@ -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 async Task RunAsyncInvokesAIContextProviderAndSucceedsWithEmptyAIContextA #endregion - #region RunAsync Structured Output Tests - - /// - /// Verify the invocation of with specified type parameter is - /// propagated to the underlying call and the expected structured output is returned. - /// - [Fact] - public async Task RunAsyncWithTypeParameterInvokesChatClientMethodForStructuredOutputAsync() - { - // Arrange - Animal expectedSO = new() { Id = 1, FullName = "Tigger", Species = Species.Tiger }; - - Mock mockService = new(); - mockService.Setup(s => s - .GetResponseAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny())) - .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedSO, JsonContext2.Default.Animal))) - { - ResponseId = "test", - }); - - ChatClientAgent agent = new(mockService.Object, options: new()); - - // Act - AgentResponse agentResponse = await agent.RunAsync(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 /// @@ -1485,22 +1444,4 @@ private static async IAsyncEnumerable ToAsyncEnumerableAsync(IEnumerable mockService = new(); + mockService.Setup(s => s + .GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions, CancellationToken>((msgs, opts, ct) => capturedResponseFormat = opts?.ResponseFormat) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "test")) + { + ResponseId = "test", + }); + + ChatResponseFormatJson responseFormat = NewChatResponseFormat.ForJsonSchema(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(capturedResponseFormat); + Assert.Same(responseFormat, capturedResponseFormat); + } + + [Fact] + public async Task RunAsync_ResponseFormatProvidedAtAgentInvokation_IsPropagatedToChatClientAsync() + { + // Arrange + ChatResponseFormat? capturedResponseFormat = null; + + Mock mockService = new(); + mockService.Setup(s => s + .GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions, CancellationToken>((msgs, opts, ct) => capturedResponseFormat = opts?.ResponseFormat) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "test")) + { + ResponseId = "test", + }); + + ChatResponseFormatJson responseFormat = NewChatResponseFormat.ForJsonSchema(JsonContext4.Default.Options); + + ChatClientAgent agent = new(mockService.Object); + + ChatClientAgentRunOptions runOptions = new() + { + ResponseFormat = responseFormat + }; + + // Act + AgentResponse agentResponse = await agent.RunAsync(messages: [new(ChatRole.User, "Hello")], options: runOptions); + + // Assert + Assert.NotNull(capturedResponseFormat); + Assert.Same(responseFormat, capturedResponseFormat); + } + + [Fact] + public async Task RunAsync_ResponseFormatProvidedAtAgentInvokation_OverridesOneProvidedAtAgentInitializationAsync() + { + // Arrange + ChatResponseFormat? capturedResponseFormat = null; + + Mock mockService = new(); + mockService.Setup(s => s + .GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions, CancellationToken>((msgs, opts, ct) => capturedResponseFormat = opts?.ResponseFormat) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "test")) + { + ResponseId = "test", + }); + + ChatResponseFormatJson initializationResponseFormat = NewChatResponseFormat.ForJsonSchema(JsonContext4.Default.Options); + ChatResponseFormatJson invocationResponseFormat = NewChatResponseFormat.ForJsonSchema(JsonContext4.Default.Options); + + ChatClientAgent agent = new(mockService.Object, options: new ChatClientAgentOptions + { + ChatOptions = new ChatOptions() + { + ResponseFormat = initializationResponseFormat + }, + }); + + ChatClientAgentRunOptions runOptions = new() + { + ResponseFormat = invocationResponseFormat + }; + + // Act + AgentResponse agentResponse = 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 mockService = new(); + mockService.Setup(s => s + .GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions, CancellationToken>((msgs, opts, ct) => capturedResponseFormat = opts?.ResponseFormat) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "test")) + { + ResponseId = "test", + }); + + ChatResponseFormatJson chatOptionsResponseFormat = NewChatResponseFormat.ForJsonSchema(JsonContext4.Default.Options); + ChatResponseFormatJson runOptionsResponseFormat = NewChatResponseFormat.ForJsonSchema(JsonContext4.Default.Options); + + ChatClientAgent agent = new(mockService.Object); + + ChatClientAgentRunOptions runOptions = new() + { + ChatOptions = new ChatOptions + { + ResponseFormat = chatOptionsResponseFormat + }, + ResponseFormat = runOptionsResponseFormat + }; + + // Act + AgentResponse agentResponse = 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 mockService = new(); + mockService.Setup(s => s + .GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedAnimal, JsonContext4.Default.Animal))) + { + ResponseId = "test", + }); + + ChatResponseFormatJson responseFormat = NewChatResponseFormat.ForJsonSchema(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; +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_SO_WithRunAsyncTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_SO_WithRunAsyncTests.cs new file mode 100644 index 0000000000..e301278e97 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_SO_WithRunAsyncTests.cs @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; +using System.Threading.Tasks; +using Moq; +using System.Threading; + +namespace Microsoft.Agents.AI.UnitTests; + +public partial class ChatClientAgent_SO_WithRunAsyncTests +{ + [Fact] + public async Task RunAsync_WithGenericType_SetsJsonSchemaResponseFormatAndDeserializesResultAsync() + { + // Arrange + ChatResponseFormat? capturedResponseFormat = null; + ChatResponseFormatJson expectedResponseFormat = ChatResponseFormat.ForJsonSchema(JsonContext3.Default.Options); + Animal expectedSO = new() { Id = 1, FullName = "Tigger", Species = Species.Tiger }; + + Mock mockService = new(); + mockService.Setup(s => s + .GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, 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 agentResponse = await agent.RunAsync( + 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); + } + + [Fact] + public async Task RunAsync_WithNonGenericType_SetsJsonSchemaResponseFormatAndDeserializesResultAsync() + { + // Arrange + ChatResponseFormat? capturedResponseFormat = null; + ChatResponseFormatJson expectedResponseFormat = ChatResponseFormat.ForJsonSchema(JsonContext3.Default.Options); + Animal expectedSO = new() { Id = 1, FullName = "Tigger", Species = Species.Tiger }; + + Mock mockService = new(); + mockService.Setup(s => s + .GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, 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 agentResponse = await agent.RunAsync( + typeof(Animal), + messages: [new(ChatRole.User, "Hello")], + serializerOptions: JsonContext3.Default.Options); + + // Assert + Assert.NotNull(capturedResponseFormat); + Assert.Equal(expectedResponseFormat.Schema?.GetRawText(), ((ChatResponseFormatJson)capturedResponseFormat).Schema?.GetRawText()); + + Assert.IsType(agentResponse.Result); + Animal animal = (Animal)agentResponse.Result; + 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))] + [JsonSerializable(typeof(JsonElement))] + [JsonSerializable(typeof(object))] + private sealed partial class JsonContext3 : JsonSerializerContext; +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Models/Animal.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Models/Animal.cs new file mode 100644 index 0000000000..2ef43266f0 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Models/Animal.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.UnitTests; + +public sealed class Animal +{ + public int Id { get; set; } + public string? FullName { get; set; } + public Species Species { get; set; } +} + +public enum Species +{ + Bear, + Tiger, + Walrus, +} diff --git a/dotnet/tests/OpenAIAssistant.IntegrationTests/OpenAIAssistantStructuredOutputRunTests.cs b/dotnet/tests/OpenAIAssistant.IntegrationTests/OpenAIAssistantStructuredOutputRunTests.cs new file mode 100644 index 0000000000..caa42ecc8d --- /dev/null +++ b/dotnet/tests/OpenAIAssistant.IntegrationTests/OpenAIAssistantStructuredOutputRunTests.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft. All rights reserved. + +using AgentConformance.IntegrationTests; + +namespace OpenAIAssistant.IntegrationTests; + +public class OpenAIAssistantStructuredOutputRunTests() : StructuredOutputRunTests(() => new()) +{ +} diff --git a/dotnet/tests/OpenAIChatCompletion.IntegrationTests/OpenAIChatCompletionStructuredOutputRunTests.cs b/dotnet/tests/OpenAIChatCompletion.IntegrationTests/OpenAIChatCompletionStructuredOutputRunTests.cs new file mode 100644 index 0000000000..b7c66f1f26 --- /dev/null +++ b/dotnet/tests/OpenAIChatCompletion.IntegrationTests/OpenAIChatCompletionStructuredOutputRunTests.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft. All rights reserved. + +using AgentConformance.IntegrationTests; + +namespace OpenAIChatCompletion.IntegrationTests; + +public class OpenAIChatCompletionStructuredOutputRunTests() : StructuredOutputRunTests(() => new(useReasoningChatModel: false)) +{ +} diff --git a/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseStructuredOutputRunTests.cs b/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseStructuredOutputRunTests.cs new file mode 100644 index 0000000000..497c16eb5a --- /dev/null +++ b/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseStructuredOutputRunTests.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft. All rights reserved. + +using AgentConformance.IntegrationTests; + +namespace ResponseResult.IntegrationTests; + +public class OpenAIResponseStructuredOutputRunTests() : StructuredOutputRunTests(() => new(store: false)) +{ +} From 5826e35c468d6c8f763a1bb775b8f933441ac83d Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Wed, 4 Feb 2026 00:37:46 +0000 Subject: [PATCH 02/16] restore lost xml comment part --- dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs index 12ef0d3bd0..166366364e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs @@ -224,6 +224,11 @@ public Task RunAsync( /// A task that represents the asynchronous operation. The task result contains an with the agent's output. /// /// + /// This method delegates to to perform the actual agent invocation. It handles collections of messages, + /// allowing for complex conversational scenarios including multi-turn interactions, function calls, and + /// context-rich conversations. + /// + /// /// The messages are processed in the order provided and become part of the conversation history. /// The agent's response will also be added to if one is provided. /// From 84a9c74c3f56927d35da92c2aa433ca4694d4307 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Wed, 4 Feb 2026 01:16:52 +0000 Subject: [PATCH 03/16] fix using ordering --- .../ChatClient/ChatClientAgent_SO_WithRunAsyncTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_SO_WithRunAsyncTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_SO_WithRunAsyncTests.cs index e301278e97..e5985afab5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_SO_WithRunAsyncTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_SO_WithRunAsyncTests.cs @@ -3,10 +3,10 @@ using System.Collections.Generic; using System.Text.Json; using System.Text.Json.Serialization; -using Microsoft.Extensions.AI; +using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.AI; using Moq; -using System.Threading; namespace Microsoft.Agents.AI.UnitTests; From 0d8ea68b4539d212e23930daa472f68d771bc8bb Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:36:45 +0000 Subject: [PATCH 04/16] Update dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../AIAgentStructuredOutput.cs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs index c5242806ab..33c63ce346 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs @@ -127,11 +127,23 @@ public async Task> RunAsync( { var responseFormat = NewChatResponseFormat.ForJsonSchema(serializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions); - options = options is null ? new AgentRunOptions() : new AgentRunOptions(options); - options.ResponseFormat = responseFormat; + AgentRunOptions effectiveOptions; + if (options is null) + { + effectiveOptions = new AgentRunOptions(); + } + else if (options.GetType() == typeof(AgentRunOptions)) + { + effectiveOptions = new AgentRunOptions(options); + } + else + { + effectiveOptions = options; + } - AgentResponse response = await this.RunAsync(messages, session, options, cancellationToken).ConfigureAwait(false); + effectiveOptions.ResponseFormat = responseFormat; + AgentResponse response = await this.RunAsync(messages, session, effectiveOptions, cancellationToken).ConfigureAwait(false); return new AgentResponse(response, responseFormat); } #endregion From 28de92c1bbe52fbd4adabb7735ea085824c23d40 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:37:32 +0000 Subject: [PATCH 05/16] Update dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../AIAgentStructuredOutput.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs index 33c63ce346..354cc194fe 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs @@ -271,10 +271,10 @@ public async Task> RunAsync( var responseFormat = NewChatResponseFormat.ForJsonSchema(resultType, serializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions); - options = options is null ? new AgentRunOptions() : new AgentRunOptions(options); - options.ResponseFormat = responseFormat; + AgentRunOptions effectiveOptions = options ?? new AgentRunOptions(); + effectiveOptions.ResponseFormat = responseFormat; - AgentResponse response = await this.RunAsync(messages, session, options, cancellationToken).ConfigureAwait(false); + AgentResponse response = await this.RunAsync(messages, session, effectiveOptions, cancellationToken).ConfigureAwait(false); return new AgentResponse(response, responseFormat); } From bbc1241497df9fe12ad5e0c70b1772ea06cbf021 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:37:50 +0000 Subject: [PATCH 06/16] Update dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_SO_WithFormatResponseTests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../ChatClient/ChatClientAgent_SO_WithFormatResponseTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_SO_WithFormatResponseTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_SO_WithFormatResponseTests.cs index cdcf75fea2..56aac28fce 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_SO_WithFormatResponseTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_SO_WithFormatResponseTests.cs @@ -49,7 +49,7 @@ public async Task RunAsync_ResponseFormatProvidedAtAgentInitialization_IsPropaga } [Fact] - public async Task RunAsync_ResponseFormatProvidedAtAgentInvokation_IsPropagatedToChatClientAsync() + public async Task RunAsync_ResponseFormatProvidedAtAgentInvocation_IsPropagatedToChatClientAsync() { // Arrange ChatResponseFormat? capturedResponseFormat = null; From 919d0520092358de945306e7a9268f4930d07ab3 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Wed, 4 Feb 2026 09:45:31 +0000 Subject: [PATCH 07/16] addressw pr review comments --- .../AIProjectClientAgentStructuredOutputRunTests.cs | 2 +- .../ChatClientAgent_SO_WithFormatResponseTests.cs | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs index 079e18b0db..b71c4e1387 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs +++ b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs @@ -11,7 +11,7 @@ namespace AzureAI.IntegrationTests; public class AIProjectClientAgentStructuredOutputRunTests() : StructuredOutputRunTests>(() => new AIProjectClientStructuredOutputFixture()) { - private const string NotSupported = "AIProjectClient does not support specifying structured output type at invokation time."; + private const string NotSupported = "AIProjectClient does not support specifying structured output type at invocation time."; /// /// Verifies that response format provided at agent initialization is used when invoking RunAsync. diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_SO_WithFormatResponseTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_SO_WithFormatResponseTests.cs index cdcf75fea2..7dde836fe4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_SO_WithFormatResponseTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_SO_WithFormatResponseTests.cs @@ -41,7 +41,7 @@ public async Task RunAsync_ResponseFormatProvidedAtAgentInitialization_IsPropaga }); // Act - AgentResponse agentResponse = await agent.RunAsync(messages: [new(ChatRole.User, "Hello")]); + await agent.RunAsync(messages: [new(ChatRole.User, "Hello")]); // Assert Assert.NotNull(capturedResponseFormat); @@ -49,7 +49,7 @@ public async Task RunAsync_ResponseFormatProvidedAtAgentInitialization_IsPropaga } [Fact] - public async Task RunAsync_ResponseFormatProvidedAtAgentInvokation_IsPropagatedToChatClientAsync() + public async Task RunAsync_ResponseFormatProvidedAtAgentInvocation_IsPropagatedToChatClientAsync() { // Arrange ChatResponseFormat? capturedResponseFormat = null; @@ -76,7 +76,7 @@ public async Task RunAsync_ResponseFormatProvidedAtAgentInvokation_IsPropagatedT }; // Act - AgentResponse agentResponse = await agent.RunAsync(messages: [new(ChatRole.User, "Hello")], options: runOptions); + await agent.RunAsync(messages: [new(ChatRole.User, "Hello")], options: runOptions); // Assert Assert.NotNull(capturedResponseFormat); @@ -84,7 +84,7 @@ public async Task RunAsync_ResponseFormatProvidedAtAgentInvokation_IsPropagatedT } [Fact] - public async Task RunAsync_ResponseFormatProvidedAtAgentInvokation_OverridesOneProvidedAtAgentInitializationAsync() + public async Task RunAsync_ResponseFormatProvidedAtAgentInvocation_OverridesOneProvidedAtAgentInitializationAsync() { // Arrange ChatResponseFormat? capturedResponseFormat = null; @@ -118,7 +118,7 @@ public async Task RunAsync_ResponseFormatProvidedAtAgentInvokation_OverridesOneP }; // Act - AgentResponse agentResponse = await agent.RunAsync(messages: [new(ChatRole.User, "Hello")], options: runOptions); + await agent.RunAsync(messages: [new(ChatRole.User, "Hello")], options: runOptions); // Assert Assert.NotNull(capturedResponseFormat); @@ -159,7 +159,7 @@ public async Task RunAsync_ResponseFormatProvidedAtAgentRunOptions_OverridesOneP }; // Act - AgentResponse agentResponse = await agent.RunAsync(messages: [new(ChatRole.User, "Hello")], options: runOptions); + await agent.RunAsync(messages: [new(ChatRole.User, "Hello")], options: runOptions); // Assert Assert.NotNull(capturedResponseFormat); From 0bd9421e06b621b10a29604dfdada9ebd0a6aeda Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Wed, 4 Feb 2026 10:27:49 +0000 Subject: [PATCH 08/16] address pr review feedback --- .../AIAgentStructuredOutput.cs | 25 ++--- .../AgentRunOptions.cs | 17 ++++ .../DurableAgentRunOptions.cs | 22 +++++ .../ChatClient/ChatClientAgentRunOptions.cs | 14 +++ .../AgentRunOptionsTests.cs | 53 +++++++++++ .../DurableAgentRunOptionsTests.cs | 94 +++++++++++++++++++ .../ChatClientAgentRunOptionsTests.cs | 87 +++++++++++++++++ 7 files changed, 293 insertions(+), 19 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAgentRunOptionsTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs index 354cc194fe..11d1d8621f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs @@ -127,23 +127,10 @@ public async Task> RunAsync( { var responseFormat = NewChatResponseFormat.ForJsonSchema(serializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions); - AgentRunOptions effectiveOptions; - if (options is null) - { - effectiveOptions = new AgentRunOptions(); - } - else if (options.GetType() == typeof(AgentRunOptions)) - { - effectiveOptions = new AgentRunOptions(options); - } - else - { - effectiveOptions = options; - } + options = options?.Clone() ?? new AgentRunOptions(); + options.ResponseFormat = responseFormat; - effectiveOptions.ResponseFormat = responseFormat; - - AgentResponse response = await this.RunAsync(messages, session, effectiveOptions, cancellationToken).ConfigureAwait(false); + AgentResponse response = await this.RunAsync(messages, session, options, cancellationToken).ConfigureAwait(false); return new AgentResponse(response, responseFormat); } #endregion @@ -271,10 +258,10 @@ public async Task> RunAsync( var responseFormat = NewChatResponseFormat.ForJsonSchema(resultType, serializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions); - AgentRunOptions effectiveOptions = options ?? new AgentRunOptions(); - effectiveOptions.ResponseFormat = responseFormat; + options = options?.Clone() ?? new AgentRunOptions(); + options.ResponseFormat = responseFormat; - AgentResponse response = await this.RunAsync(messages, session, effectiveOptions, cancellationToken).ConfigureAwait(false); + AgentResponse response = await this.RunAsync(messages, session, options, cancellationToken).ConfigureAwait(false); return new AgentResponse(response, responseFormat); } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunOptions.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunOptions.cs index 6a17d60e42..08e2aa6518 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunOptions.cs @@ -105,4 +105,21 @@ public AgentRunOptions(AgentRunOptions options) /// it can be ignored. /// public ChatResponseFormat? ResponseFormat { get; set; } + + /// + /// Produces a clone of the current instance. + /// + /// + /// A clone of the current instance. + /// + /// + /// + /// The clone will have the same values for all properties as the original instance. Any collections, like , + /// are shallow-cloned, meaning a new collection instance is created, but any references contained by the collections are shared with the original. + /// + /// + /// Derived types should override to return an instance of the derived type. + /// + /// + public virtual AgentRunOptions Clone() => new(this); } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentRunOptions.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentRunOptions.cs index a83e19b046..f698eab3d8 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentRunOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentRunOptions.cs @@ -7,6 +7,25 @@ namespace Microsoft.Agents.AI.DurableTask; /// public sealed class DurableAgentRunOptions : AgentRunOptions { + /// + /// Initializes a new instance of the class. + /// + public DurableAgentRunOptions() + { + } + + /// + /// Initializes a new instance of the class by copying values from the specified options. + /// + /// The options instance from which to copy values. + private DurableAgentRunOptions(DurableAgentRunOptions options) + : base(options) + { + this.EnableToolCalls = options.EnableToolCalls; + this.EnableToolNames = options.EnableToolNames is not null ? new List(options.EnableToolNames) : null; + this.IsFireAndForget = options.IsFireAndForget; + } + /// /// Gets or sets whether to enable tool calls for this request. /// @@ -26,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. /// public bool IsFireAndForget { get; set; } + + /// + public override AgentRunOptions Clone() => new DurableAgentRunOptions(this); } diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentRunOptions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentRunOptions.cs index 0f2c9da485..cf35aa80a1 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentRunOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentRunOptions.cs @@ -26,6 +26,17 @@ public ChatClientAgentRunOptions(ChatOptions? chatOptions = null) this.ChatOptions = chatOptions; } + /// + /// Initializes a new instance of the class by copying values from the specified options. + /// + /// The options instance from which to copy values. + private ChatClientAgentRunOptions(ChatClientAgentRunOptions options) + : base(options) + { + this.ChatOptions = options.ChatOptions?.Clone(); + this.ChatClientFactory = options.ChatClientFactory; + } + /// /// Gets or sets the chat options to apply to the agent invocation. /// @@ -50,4 +61,7 @@ public ChatClientAgentRunOptions(ChatOptions? chatOptions = null) /// chat client will be used without modification. /// public Func? ChatClientFactory { get; set; } + + /// + public override AgentRunOptions Clone() => new ChatClientAgentRunOptions(this); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunOptionsTests.cs index 7460ea4623..7a54d068a6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunOptionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunOptionsTests.cs @@ -77,4 +77,57 @@ public void JsonSerializationRoundtrips() Assert.IsType(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(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")); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAgentRunOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAgentRunOptionsTests.cs new file mode 100644 index 0000000000..77012f4957 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAgentRunOptionsTests.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.DurableTask.UnitTests; + +/// +/// Unit tests for the class. +/// +public sealed class DurableAgentRunOptionsTests +{ + [Fact] + public void CloneReturnsNewInstanceWithSameValues() + { + // Arrange + DurableAgentRunOptions options = new() + { + EnableToolCalls = false, + EnableToolNames = new List { "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(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 { "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")); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentRunOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentRunOptionsTests.cs index 1aa49dc328..7a00a9f796 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentRunOptionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentRunOptionsTests.cs @@ -332,4 +332,91 @@ await Assert.ThrowsAsync(async () => } #endregion + + #region Clone Tests + + /// + /// Verify that Clone returns a new instance with the same property values. + /// + [Fact] + public void CloneReturnsNewInstanceWithSameValues() + { + // Arrange + var chatOptions = new ChatOptions { MaxOutputTokens = 100, Temperature = 0.7f }; + Func 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(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"]); + } + + /// + /// Verify that modifying the cloned ChatOptions does not affect the original. + /// + [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); + } + + /// + /// Verify that modifying the cloned AdditionalProperties does not affect the original. + /// + [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 } From 90dc17f9862bda605352478a2eee5261cf279c87 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Wed, 4 Feb 2026 10:44:59 +0000 Subject: [PATCH 09/16] address pr review comments --- .../AIAgentStructuredOutput.cs | 8 ++++---- dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs index 11d1d8621f..34fe0f2a76 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs @@ -147,7 +147,7 @@ public async Task> RunAsync( /// Optional JSON serializer options to use for deserializing the response. /// Optional configuration parameters for controlling the agent's invocation behavior. /// The to monitor for cancellation requests. The default is . - /// A task that represents the asynchronous operation. The task result contains an with the agent's output. + /// A task that represents the asynchronous operation. The task result contains an with the agent's output. /// /// 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. @@ -172,7 +172,7 @@ public Task> RunAsync( /// Optional JSON serializer options to use for deserializing the response. /// Optional configuration parameters for controlling the agent's invocation behavior. /// The to monitor for cancellation requests. The default is . - /// A task that represents the asynchronous operation. The task result contains an with the agent's output. + /// A task that represents the asynchronous operation. The task result contains an with the agent's output. /// is . /// is , empty, or contains only whitespace. /// @@ -205,7 +205,7 @@ public Task> RunAsync( /// Optional JSON serializer options to use for deserializing the response. /// Optional configuration parameters for controlling the agent's invocation behavior. /// The to monitor for cancellation requests. The default is . - /// A task that represents the asynchronous operation. The task result contains an with the agent's output. + /// A task that represents the asynchronous operation. The task result contains an with the agent's output. /// is . /// is . public Task> RunAsync( @@ -234,7 +234,7 @@ public Task> RunAsync( /// Optional JSON serializer options to use for deserializing the response. /// Optional configuration parameters for controlling the agent's invocation behavior. /// The to monitor for cancellation requests. The default is . - /// A task that represents the asynchronous operation. The task result contains an with the agent's output. + /// A task that represents the asynchronous operation. The task result contains an with the agent's output. /// is . /// /// diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md b/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md index 2c1460b213..bcc6c1edbf 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md @@ -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 From 5f323266ee4ecdeb0d9e3e3d9a882587218fdeea Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Wed, 4 Feb 2026 11:15:05 +0000 Subject: [PATCH 10/16] fix compilation issues after the latest merge with main --- .../StructuredOutputRunTests.cs | 6 +++--- .../AIProjectClientAgentStructuredOutputRunTests.cs | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/dotnet/tests/AgentConformance.IntegrationTests/StructuredOutputRunTests.cs b/dotnet/tests/AgentConformance.IntegrationTests/StructuredOutputRunTests.cs index eb2def2769..11eb1b854e 100644 --- a/dotnet/tests/AgentConformance.IntegrationTests/StructuredOutputRunTests.cs +++ b/dotnet/tests/AgentConformance.IntegrationTests/StructuredOutputRunTests.cs @@ -22,7 +22,7 @@ public virtual async Task RunWithResponseFormatReturnsExpectedResultAsync() { // Arrange var agent = this.Fixture.Agent; - var session = await agent.GetNewSessionAsync(); + var session = await agent.CreateSessionAsync(); await using var cleanup = new SessionCleanup(session, this.Fixture); var options = new AgentRunOptions @@ -46,7 +46,7 @@ public virtual async Task RunWithGenericTypeReturnsExpectedResultAsync() { // Arrange var agent = this.Fixture.Agent; - var session = await agent.GetNewSessionAsync(); + var session = await agent.CreateSessionAsync(); await using var cleanup = new SessionCleanup(session, this.Fixture); // Act @@ -68,7 +68,7 @@ public virtual async Task RunWithTypeAsSystemTypeReturnsExpectedResultAsync() { // Arrange var agent = this.Fixture.Agent; - var session = await agent.GetNewSessionAsync(); + var session = await agent.CreateSessionAsync(); await using var cleanup = new SessionCleanup(session, this.Fixture); // Act diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs index b71c4e1387..8131b3aae3 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs +++ b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs @@ -22,7 +22,7 @@ public async Task RunWithResponseFormatAtAgentInitializationReturnsExpectedResul { // Arrange var agent = this.Fixture.Agent; - var session = await agent.GetNewSessionAsync(); + var session = await agent.CreateSessionAsync(); await using var cleanup = new SessionCleanup(session, this.Fixture); var options = new AgentRunOptions() @@ -54,7 +54,7 @@ public async Task RunGenericWithResponseFormatAtAgentInitializationReturnsExpect { // Arrange var agent = this.Fixture.Agent; - var session = await agent.GetNewSessionAsync(); + var session = await agent.CreateSessionAsync(); await using var cleanup = new SessionCleanup(session, this.Fixture); // Act From 9da88174e24a6fcf9b9d6e050ba7ae0e7d30dacc Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Wed, 4 Feb 2026 13:27:56 +0000 Subject: [PATCH 11/16] remove unnecessry options --- .../AIProjectClientAgentStructuredOutputRunTests.cs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs index 8131b3aae3..f26f5b51eb 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs +++ b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs @@ -25,13 +25,8 @@ public async Task RunWithResponseFormatAtAgentInitializationReturnsExpectedResul var session = await agent.CreateSessionAsync(); await using var cleanup = new SessionCleanup(session, this.Fixture); - var options = new AgentRunOptions() - { - ResponseFormat = NewChatResponseFormat.ForJsonSchema(AgentAbstractionsJsonUtilities.DefaultOptions) - }; - // Act - var response = await agent.RunAsync(new ChatMessage(ChatRole.User, "Provide information about the capital of France."), session, options); + var response = await agent.RunAsync(new ChatMessage(ChatRole.User, "Provide information about the capital of France."), session); // Assert Assert.NotNull(response); From b4e44b98626fa708e023944ecaf50e5af74b470c Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Wed, 4 Feb 2026 14:37:33 +0000 Subject: [PATCH 12/16] remove RunAsync methods --- .../AIAgentStructuredOutput.cs | 134 ------------------ .../ChatClientAgentCustomOptions.cs | 87 ------------ .../StructuredOutputRunTests.cs | 25 ---- ...jectClientAgentStructuredOutputRunTests.cs | 4 - .../ChatClientAgent_SO_WithRunAsyncTests.cs | 39 ----- 5 files changed, 289 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs index 34fe0f2a76..5f6a4e7fec 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs @@ -15,7 +15,6 @@ namespace Microsoft.Agents.AI; /// public abstract partial class AIAgent { - #region RunAsync overloads /// /// 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 . /// @@ -133,137 +132,4 @@ public async Task> RunAsync( AgentResponse response = await this.RunAsync(messages, session, options, cancellationToken).ConfigureAwait(false); return new AgentResponse(response, responseFormat); } - #endregion - - #region RunAsync(Type type) overloads - /// - /// 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 . - /// - /// The type of structured output to request. - /// - /// The conversation session to use for this invocation. If , a new session will be created. - /// The session will be updated with any response messages generated during invocation. - /// - /// Optional JSON serializer options to use for deserializing the response. - /// Optional configuration parameters for controlling the agent's invocation behavior. - /// The to monitor for cancellation requests. The default is . - /// A task that represents the asynchronous operation. The task result contains an with the agent's output. - /// - /// 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. - /// - public Task> RunAsync( - Type resultType, - AgentSession? session = null, - JsonSerializerOptions? serializerOptions = null, - AgentRunOptions? options = null, - CancellationToken cancellationToken = default) => - this.RunAsync(resultType, [], session, serializerOptions, options, cancellationToken); - - /// - /// Runs the agent with a text message from the user, requesting a response of the specified . - /// - /// The type of structured output to request. - /// The user message to send to the agent. - /// - /// The conversation session to use for this invocation. If , a new session will be created. - /// The session will be updated with the input message and any response messages generated during invocation. - /// - /// Optional JSON serializer options to use for deserializing the response. - /// Optional configuration parameters for controlling the agent's invocation behavior. - /// The to monitor for cancellation requests. The default is . - /// A task that represents the asynchronous operation. The task result contains an with the agent's output. - /// is . - /// is , empty, or contains only whitespace. - /// - /// The provided text will be wrapped in a with the role - /// before being sent to the agent. This is a convenience method for simple text-based interactions. - /// - public Task> RunAsync( - Type resultType, - string message, - AgentSession? session = null, - JsonSerializerOptions? serializerOptions = null, - AgentRunOptions? options = null, - CancellationToken cancellationToken = default) - { - _ = Throw.IfNull(resultType); - _ = Throw.IfNullOrWhitespace(message); - - return this.RunAsync(resultType, new ChatMessage(ChatRole.User, message), session, serializerOptions, options, cancellationToken); - } - - /// - /// Runs the agent with a single chat message, requesting a response of the specified . - /// - /// The type of structured output to request. - /// The chat message to send to the agent. - /// - /// The conversation session to use for this invocation. If , a new session will be created. - /// The session will be updated with the input message and any response messages generated during invocation. - /// - /// Optional JSON serializer options to use for deserializing the response. - /// Optional configuration parameters for controlling the agent's invocation behavior. - /// The to monitor for cancellation requests. The default is . - /// A task that represents the asynchronous operation. The task result contains an with the agent's output. - /// is . - /// is . - public Task> RunAsync( - Type resultType, - ChatMessage message, - AgentSession? session = null, - JsonSerializerOptions? serializerOptions = null, - AgentRunOptions? options = null, - CancellationToken cancellationToken = default) - { - _ = Throw.IfNull(resultType); - _ = Throw.IfNull(message); - - return this.RunAsync(resultType, [message], session, serializerOptions, options, cancellationToken); - } - - /// - /// Runs the agent with a collection of chat messages, requesting a response of the specified . - /// - /// The type of structured output to request. - /// The collection of messages to send to the agent for processing. - /// - /// The conversation session to use for this invocation. If , a new session will be created. - /// The session will be updated with the input messages and any response messages generated during invocation. - /// - /// Optional JSON serializer options to use for deserializing the response. - /// Optional configuration parameters for controlling the agent's invocation behavior. - /// The to monitor for cancellation requests. The default is . - /// A task that represents the asynchronous operation. The task result contains an with the agent's output. - /// is . - /// - /// - /// This method handles collections of messages, allowing for complex conversational scenarios including - /// multi-turn interactions, function calls, and context-rich conversations. - /// - /// - /// The messages are processed in the order provided and become part of the conversation history. - /// The agent's response will also be added to if one is provided. - /// - /// - public async Task> RunAsync( - Type resultType, - IEnumerable messages, - AgentSession? session = null, - JsonSerializerOptions? serializerOptions = null, - AgentRunOptions? options = null, - CancellationToken cancellationToken = default) - { - _ = Throw.IfNull(resultType); - - var responseFormat = NewChatResponseFormat.ForJsonSchema(resultType, serializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions); - - options = options?.Clone() ?? new AgentRunOptions(); - options.ResponseFormat = responseFormat; - - AgentResponse response = await this.RunAsync(messages, session, options, cancellationToken).ConfigureAwait(false); - - return new AgentResponse(response, responseFormat); - } - #endregion } diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentCustomOptions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentCustomOptions.cs index 64a7a7e7f0..e5d6296700 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentCustomOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentCustomOptions.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using System; using System.Collections.Generic; using System.Text.Json; using System.Threading; @@ -231,90 +230,4 @@ public Task> RunAsync( ChatClientAgentRunOptions? options, CancellationToken cancellationToken = default) => this.RunAsync(messages, session, serializerOptions, (AgentRunOptions?)options, cancellationToken); - - /// - /// 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 . - /// - /// The type of structured output to request. - /// - /// The conversation session to use for this invocation. If , a new session will be created. - /// The session will be updated with any response messages generated during invocation. - /// - /// The JSON serialization options to use. - /// Configuration parameters for controlling the agent's invocation behavior. - /// The to monitor for cancellation requests. The default is . - /// A task that represents the asynchronous operation. The task result contains an with the agent's output. - public Task> RunAsync( - Type resultType, - AgentSession? session, - JsonSerializerOptions? serializerOptions, - ChatClientAgentRunOptions? options, - CancellationToken cancellationToken = default) => - this.RunAsync(resultType, session, serializerOptions, (AgentRunOptions?)options, cancellationToken); - - /// - /// Runs the agent with a text message from the user, requesting a response of the specified . - /// - /// The type of structured output to request. - /// The user message to send to the agent. - /// - /// The conversation session to use for this invocation. If , a new session will be created. - /// The session will be updated with the input message and any response messages generated during invocation. - /// - /// The JSON serialization options to use. - /// Configuration parameters for controlling the agent's invocation behavior. - /// The to monitor for cancellation requests. The default is . - /// A task that represents the asynchronous operation. The task result contains an with the agent's output. - public Task> RunAsync( - Type resultType, - string message, - AgentSession? session, - JsonSerializerOptions? serializerOptions, - ChatClientAgentRunOptions? options, - CancellationToken cancellationToken = default) => - this.RunAsync(resultType, message, session, serializerOptions, (AgentRunOptions?)options, cancellationToken); - - /// - /// Runs the agent with a single chat message, requesting a response of the specified . - /// - /// The type of structured output to request. - /// The chat message to send to the agent. - /// - /// The conversation session to use for this invocation. If , a new session will be created. - /// The session will be updated with the input message and any response messages generated during invocation. - /// - /// The JSON serialization options to use. - /// Configuration parameters for controlling the agent's invocation behavior. - /// The to monitor for cancellation requests. The default is . - /// A task that represents the asynchronous operation. The task result contains an with the agent's output. - public Task> RunAsync( - Type resultType, - ChatMessage message, - AgentSession? session, - JsonSerializerOptions? serializerOptions, - ChatClientAgentRunOptions? options, - CancellationToken cancellationToken = default) => - this.RunAsync(resultType, message, session, serializerOptions, (AgentRunOptions?)options, cancellationToken); - - /// - /// Runs the agent with a collection of chat messages, requesting a response of the specified . - /// - /// The type of structured output to request. - /// The collection of messages to send to the agent for processing. - /// - /// The conversation session to use for this invocation. If , a new session will be created. - /// The session will be updated with the input messages and any response messages generated during invocation. - /// - /// The JSON serialization options to use. - /// Configuration parameters for controlling the agent's invocation behavior. - /// The to monitor for cancellation requests. The default is . - /// A task that represents the asynchronous operation. The task result contains an with the agent's output. - public Task> RunAsync( - Type resultType, - IEnumerable messages, - AgentSession? session, - JsonSerializerOptions? serializerOptions, - ChatClientAgentRunOptions? options, - CancellationToken cancellationToken = default) => - this.RunAsync(resultType, messages, session, serializerOptions, (AgentRunOptions?)options, cancellationToken); } diff --git a/dotnet/tests/AgentConformance.IntegrationTests/StructuredOutputRunTests.cs b/dotnet/tests/AgentConformance.IntegrationTests/StructuredOutputRunTests.cs index 11eb1b854e..e6758e495c 100644 --- a/dotnet/tests/AgentConformance.IntegrationTests/StructuredOutputRunTests.cs +++ b/dotnet/tests/AgentConformance.IntegrationTests/StructuredOutputRunTests.cs @@ -63,31 +63,6 @@ public virtual async Task RunWithGenericTypeReturnsExpectedResultAsync() Assert.Equal("Paris", response.Result.Name); } - [RetryFact(Constants.RetryCount, Constants.RetryDelay)] - public virtual async Task RunWithTypeAsSystemTypeReturnsExpectedResultAsync() - { - // Arrange - var agent = this.Fixture.Agent; - var session = await agent.CreateSessionAsync(); - await using var cleanup = new SessionCleanup(session, this.Fixture); - - // Act - AgentResponse response = await agent.RunAsync( - typeof(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.IsType(response.Result); - CityInfo cityInfo = (CityInfo)response.Result; - Assert.Equal("Paris", cityInfo.Name); - } - protected static bool TryDeserialize(string json, JsonSerializerOptions jsonSerializerOptions, out T structuredOutput) { try diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs index f26f5b51eb..3fd308066e 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs +++ b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs @@ -73,10 +73,6 @@ public override Task RunWithGenericTypeReturnsExpectedResultAsync() => [Fact(Skip = NotSupported)] public override Task RunWithResponseFormatReturnsExpectedResultAsync() => base.RunWithResponseFormatReturnsExpectedResultAsync(); - - [Fact(Skip = NotSupported)] - public override Task RunWithTypeAsSystemTypeReturnsExpectedResultAsync() => - base.RunWithTypeAsSystemTypeReturnsExpectedResultAsync(); } /// diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_SO_WithRunAsyncTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_SO_WithRunAsyncTests.cs index e5985afab5..d4f6b51ab3 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_SO_WithRunAsyncTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_SO_WithRunAsyncTests.cs @@ -50,45 +50,6 @@ public async Task RunAsync_WithGenericType_SetsJsonSchemaResponseFormatAndDeseri Assert.Equal(expectedSO.Species, animal.Species); } - [Fact] - public async Task RunAsync_WithNonGenericType_SetsJsonSchemaResponseFormatAndDeserializesResultAsync() - { - // Arrange - ChatResponseFormat? capturedResponseFormat = null; - ChatResponseFormatJson expectedResponseFormat = ChatResponseFormat.ForJsonSchema(JsonContext3.Default.Options); - Animal expectedSO = new() { Id = 1, FullName = "Tigger", Species = Species.Tiger }; - - Mock mockService = new(); - mockService.Setup(s => s - .GetResponseAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny())) - .Callback, 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 agentResponse = await agent.RunAsync( - typeof(Animal), - messages: [new(ChatRole.User, "Hello")], - serializerOptions: JsonContext3.Default.Options); - - // Assert - Assert.NotNull(capturedResponseFormat); - Assert.Equal(expectedResponseFormat.Schema?.GetRawText(), ((ChatResponseFormatJson)capturedResponseFormat).Schema?.GetRawText()); - - Assert.IsType(agentResponse.Result); - Animal animal = (Animal)agentResponse.Result; - 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))] [JsonSerializable(typeof(JsonElement))] From a7690ddd18b92344c71710e2a9cc7bc6e024f359 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Wed, 4 Feb 2026 15:15:38 +0000 Subject: [PATCH 13/16] address code review feedback --- .../AIAgentStructuredOutput.cs | 7 +- .../AgentResponse{T}.cs | 46 +++----- .../MEAI/NewChatResponseFormat.cs | 100 ------------------ .../MEAI/NewChatResponseFormatJson.cs | 87 --------------- .../StructuredOutputRunTests.cs | 2 +- ...jectClientAgentStructuredOutputRunTests.cs | 2 +- ...tClientAgent_SO_WithFormatResponseTests.cs | 14 +-- .../ChatClientAgent_SO_WithRunAsyncTests.cs | 2 - 8 files changed, 29 insertions(+), 231 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/MEAI/NewChatResponseFormat.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/MEAI/NewChatResponseFormatJson.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs index 5f6a4e7fec..796b796317 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs @@ -124,12 +124,13 @@ public async Task> RunAsync( AgentRunOptions? options = null, CancellationToken cancellationToken = default) { - var responseFormat = NewChatResponseFormat.ForJsonSchema(serializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions); + serializerOptions ??= AgentAbstractionsJsonUtilities.DefaultOptions; options = options?.Clone() ?? new AgentRunOptions(); - options.ResponseFormat = responseFormat; + options.ResponseFormat = ChatResponseFormat.ForJsonSchema(serializerOptions); AgentResponse response = await this.RunAsync(messages, session, options, cancellationToken).ConfigureAwait(false); - return new AgentResponse(response, responseFormat); + + return new AgentResponse(response, serializerOptions); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentResponse{T}.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentResponse{T}.cs index 0daf1d15b5..60138ba713 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentResponse{T}.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentResponse{T}.cs @@ -11,7 +11,6 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; -using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI; @@ -21,21 +20,16 @@ namespace Microsoft.Agents.AI; /// The type of value expected from the agent. public class AgentResponse : AgentResponse { - /// - /// Initializes a new instance of the class. - /// - public AgentResponse() - { - } + private readonly JsonSerializerOptions _serializerOptions; /// /// Initializes a new instance of the class. /// /// The from which to populate this . - /// The JSON response format configuration used to deserialize the agent's response. - public AgentResponse(AgentResponse response, NewChatResponseFormatJson? responseFormat = null) : base(response) + /// The to use when deserializing the result. + public AgentResponse(AgentResponse response, JsonSerializerOptions serializerOptions) : base(response) { - this.ResponseFormat = responseFormat; + this._serializerOptions = serializerOptions; } /// @@ -46,26 +40,17 @@ public virtual T Result { get { - return (T)this.Deserialize(this.ResponseFormat?.SchemaType ?? typeof(T), this.ResponseFormat?.SchemaSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions); + var structuredOutput = this.GetResultCore(this._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!, + }; } } - private NewChatResponseFormatJson? ResponseFormat { get; } - - private object Deserialize(Type targetType, JsonSerializerOptions serializerOptions) - { - _ = Throw.IfNull(serializerOptions); - - var structuredOutput = this.GetResultCore(targetType, 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!, - }; - } - - private object? GetResultCore(Type targetType, JsonSerializerOptions serializerOptions, out FailureReason? failureReason) + private T? GetResultCore(JsonSerializerOptions serializerOptions, out FailureReason? failureReason) { var json = this.Text; if (string.IsNullOrEmpty(json)) @@ -74,7 +59,8 @@ private object Deserialize(Type targetType, JsonSerializerOptions serializerOpti return default; } - object? deserialized = DeserializeFirstTopLevelObject(json!, serializerOptions.GetTypeInfo(targetType)); + // 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)serializerOptions.GetTypeInfo(typeof(T))); if (deserialized is null) { @@ -86,7 +72,7 @@ private object Deserialize(Type targetType, JsonSerializerOptions serializerOpti return deserialized; } - private static object? DeserializeFirstTopLevelObject(string json, JsonTypeInfo typeInfo) + private static T? DeserializeFirstTopLevelObject(string json, JsonTypeInfo typeInfo) { #if NET // We need to deserialize only the first top-level object as a workaround for a common LLM backend @@ -112,6 +98,6 @@ private object Deserialize(Type targetType, JsonSerializerOptions serializerOpti private enum FailureReason { ResultDidNotContainJson, - DeserializationProducedNull, + DeserializationProducedNull } } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/MEAI/NewChatResponseFormat.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/MEAI/NewChatResponseFormat.cs deleted file mode 100644 index e78cd5e434..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/MEAI/NewChatResponseFormat.cs +++ /dev/null @@ -1,100 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.ComponentModel; -using System.Reflection; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Text.RegularExpressions; -using Microsoft.Extensions.AI; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI; - -#pragma warning disable CA1052 // Static holder types should be Static or NotInheritable - -/// Represents the response format that is desired by the caller. -[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] -[JsonDerivedType(typeof(NewChatResponseFormatJson), typeDiscriminator: "json")] -public partial class NewChatResponseFormat -{ - private static readonly AIJsonSchemaCreateOptions s_inferenceOptions = new() - { - IncludeSchemaKeyword = true, - }; - - /// Initializes a new instance of the class. - /// Prevents external instantiation. Close the inheritance hierarchy for now until we have good reason to open it. - private protected NewChatResponseFormat() - { - } - - /// Gets a singleton instance representing structured JSON data but without any particular schema. - public static NewChatResponseFormatJson Json { get; } = new(schema: null); - - /// Creates a representing structured JSON data with the specified schema. - /// The JSON schema. - /// An optional name of the schema. For example, if the schema represents a particular class, this could be the name of the class. - /// An optional description of the schema. - /// The instance. - public static NewChatResponseFormatJson ForJsonSchema( - JsonElement schema, string? schemaName = null, string? schemaDescription = null) => - new(schema, schemaName, schemaDescription); - - /// Creates a representing structured JSON data with a schema based on . - /// The type for which a schema should be exported and used as the response schema. - /// The JSON serialization options to use. - /// An optional name of the schema. By default, this will be inferred from . - /// An optional description of the schema. By default, this will be inferred from . - /// The instance. - /// - /// Many AI services that support structured output require that the JSON schema have a top-level 'type=object'. - /// If is a primitive type like , , or , - /// or if it's a type that serializes as a JSON array, attempting to use the resulting schema with such services may fail. - /// In such cases, consider instead using a that wraps the actual type in a class or struct so that - /// it serializes as a JSON object with the original type as a property of that object. - /// - public static NewChatResponseFormatJson ForJsonSchema(JsonSerializerOptions? serializerOptions = null, string? schemaName = null, string? schemaDescription = null) => - ForJsonSchema(typeof(T), serializerOptions, schemaName, schemaDescription); - - /// Creates a representing structured JSON data with a schema based on . - /// The for which a schema should be exported and used as the response schema. - /// The JSON serialization options to use. - /// An optional name of the schema. By default, this will be inferred from . - /// An optional description of the schema. By default, this will be inferred from . - /// The instance. - /// - /// Many AI services that support structured output require that the JSON schema have a top-level 'type=object'. - /// If is a primitive type like , , or , - /// or if it's a type that serializes as a JSON array, attempting to use the resulting schema with such services may fail. - /// In such cases, consider instead using a that wraps the actual type in a class or struct so that - /// it serializes as a JSON object with the original type as a property of that object. - /// - /// is . - public static NewChatResponseFormatJson ForJsonSchema( - Type schemaType, JsonSerializerOptions? serializerOptions = null, string? schemaName = null, string? schemaDescription = null) - { - _ = Throw.IfNull(schemaType); - - var schema = AIJsonUtilities.CreateJsonSchema( - schemaType, - serializerOptions: serializerOptions ?? AIJsonUtilities.DefaultOptions, - inferenceOptions: s_inferenceOptions); - - return new( - schemaType, - schema, - schemaName ?? schemaType.GetCustomAttribute()?.DisplayName ?? InvalidNameCharsRegex().Replace(schemaType.Name, "_"), - schemaDescription ?? schemaType.GetCustomAttribute()?.Description, - serializerOptions); - } - - /// Regex that flags any character other than ASCII digits, ASCII letters, or underscore. -#if NET - [GeneratedRegex("[^0-9A-Za-z_]")] - private static partial Regex InvalidNameCharsRegex(); -#else - private static Regex InvalidNameCharsRegex() => s_invalidNameCharsRegex; - private static readonly Regex s_invalidNameCharsRegex = new("[^0-9A-Za-z_]", RegexOptions.Compiled); -#endif -} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/MEAI/NewChatResponseFormatJson.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/MEAI/NewChatResponseFormatJson.cs deleted file mode 100644 index 9526c1f0af..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/MEAI/NewChatResponseFormatJson.cs +++ /dev/null @@ -1,87 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Diagnostics; -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.Extensions.AI; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI; - -/// Represents a response format for structured JSON data. -[DebuggerDisplay("{DebuggerDisplay,nq}")] -public sealed class NewChatResponseFormatJson : NewChatResponseFormat -{ - /// Initializes a new instance of the class with the specified schema. - /// The schema to associate with the JSON response. - /// A name for the schema. - /// A description of the schema. - [JsonConstructor] - public NewChatResponseFormatJson( - JsonElement? schema, string? schemaName = null, string? schemaDescription = null) - { - if (schema is null && (schemaName is not null || schemaDescription is not null)) - { - Throw.ArgumentException( - schemaName is not null ? nameof(schemaName) : nameof(schemaDescription), - "Schema name and description can only be specified if a schema is provided."); - } - - this.Schema = schema; - this.SchemaName = schemaName; - this.SchemaDescription = schemaDescription; - } - - /// - /// Initializes a new instance of the class with a schema derived from the specified type. - /// - /// The from which the schema was derived. - /// The JSON schema to associate with the JSON response. - /// An optional name for the schema. - /// An optional description of the schema. - /// The JSON serializer options to use for deserialization. - public NewChatResponseFormatJson( - Type schemaType, JsonElement schema, string? schemaName = null, string? schemaDescription = null, JsonSerializerOptions? serializerOptions = null) - { - this.SchemaType = schemaType; - this.Schema = schema; - this.SchemaName = schemaName; - this.SchemaDescription = schemaDescription; - this.SchemaSerializerOptions = serializerOptions; - } - - /// - /// Gets the from which the JSON schema was derived, or if the schema was not derived from a type. - /// - [JsonIgnore] - public Type? SchemaType { get; } - - /// Gets the JSON schema associated with the response, or if there is none. - public JsonElement? Schema { get; } - - /// Gets a name for the schema. - public string? SchemaName { get; } - - /// Gets a description of the schema. - public string? SchemaDescription { get; } - - /// - /// Gets the JSON serializer options to use when deserializing responses that conform to this schema, or if default options should be used. - /// - [JsonIgnore] - public JsonSerializerOptions? SchemaSerializerOptions { get; } - - /// Gets a string representing this instance to display in the debugger. - [DebuggerBrowsable(DebuggerBrowsableState.Never)] - private string DebuggerDisplay => this.Schema?.ToString() ?? "JSON"; - - /// - /// Implicitly converts a to a . - /// - /// The instance to convert. - public static implicit operator ChatResponseFormatJson(NewChatResponseFormatJson format) - { - return new ChatResponseFormatJson(format.Schema, format.SchemaName, format.SchemaDescription); - } -} diff --git a/dotnet/tests/AgentConformance.IntegrationTests/StructuredOutputRunTests.cs b/dotnet/tests/AgentConformance.IntegrationTests/StructuredOutputRunTests.cs index e6758e495c..5901986aa1 100644 --- a/dotnet/tests/AgentConformance.IntegrationTests/StructuredOutputRunTests.cs +++ b/dotnet/tests/AgentConformance.IntegrationTests/StructuredOutputRunTests.cs @@ -27,7 +27,7 @@ public virtual async Task RunWithResponseFormatReturnsExpectedResultAsync() var options = new AgentRunOptions { - ResponseFormat = NewChatResponseFormat.ForJsonSchema(AgentAbstractionsJsonUtilities.DefaultOptions) + ResponseFormat = ChatResponseFormat.ForJsonSchema(AgentAbstractionsJsonUtilities.DefaultOptions) }; // Act diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs index 3fd308066e..3b69b5693a 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs +++ b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs @@ -86,7 +86,7 @@ public override Task InitializeAsync() { ChatOptions = new ChatOptions() { - ResponseFormat = NewChatResponseFormat.ForJsonSchema(AgentAbstractionsJsonUtilities.DefaultOptions) + ResponseFormat = ChatResponseFormat.ForJsonSchema(AgentAbstractionsJsonUtilities.DefaultOptions) }, }; diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_SO_WithFormatResponseTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_SO_WithFormatResponseTests.cs index 7dde836fe4..dae6f434a5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_SO_WithFormatResponseTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_SO_WithFormatResponseTests.cs @@ -30,7 +30,7 @@ public async Task RunAsync_ResponseFormatProvidedAtAgentInitialization_IsPropaga ResponseId = "test", }); - ChatResponseFormatJson responseFormat = NewChatResponseFormat.ForJsonSchema(JsonContext4.Default.Options); + ChatResponseFormatJson responseFormat = ChatResponseFormat.ForJsonSchema(JsonContext4.Default.Options); ChatClientAgent agent = new(mockService.Object, options: new ChatClientAgentOptions { @@ -66,7 +66,7 @@ public async Task RunAsync_ResponseFormatProvidedAtAgentInvocation_IsPropagatedT ResponseId = "test", }); - ChatResponseFormatJson responseFormat = NewChatResponseFormat.ForJsonSchema(JsonContext4.Default.Options); + ChatResponseFormatJson responseFormat = ChatResponseFormat.ForJsonSchema(JsonContext4.Default.Options); ChatClientAgent agent = new(mockService.Object); @@ -101,8 +101,8 @@ public async Task RunAsync_ResponseFormatProvidedAtAgentInvocation_OverridesOneP ResponseId = "test", }); - ChatResponseFormatJson initializationResponseFormat = NewChatResponseFormat.ForJsonSchema(JsonContext4.Default.Options); - ChatResponseFormatJson invocationResponseFormat = NewChatResponseFormat.ForJsonSchema(JsonContext4.Default.Options); + ChatResponseFormatJson initializationResponseFormat = ChatResponseFormat.ForJsonSchema(JsonContext4.Default.Options); + ChatResponseFormatJson invocationResponseFormat = ChatResponseFormat.ForJsonSchema(JsonContext4.Default.Options); ChatClientAgent agent = new(mockService.Object, options: new ChatClientAgentOptions { @@ -144,8 +144,8 @@ public async Task RunAsync_ResponseFormatProvidedAtAgentRunOptions_OverridesOneP ResponseId = "test", }); - ChatResponseFormatJson chatOptionsResponseFormat = NewChatResponseFormat.ForJsonSchema(JsonContext4.Default.Options); - ChatResponseFormatJson runOptionsResponseFormat = NewChatResponseFormat.ForJsonSchema(JsonContext4.Default.Options); + ChatResponseFormatJson chatOptionsResponseFormat = ChatResponseFormat.ForJsonSchema(JsonContext4.Default.Options); + ChatResponseFormatJson runOptionsResponseFormat = ChatResponseFormat.ForJsonSchema(JsonContext4.Default.Options); ChatClientAgent agent = new(mockService.Object); @@ -184,7 +184,7 @@ public async Task RunAsync_StructuredOutputResponse_IsAvailableAsTextOnAgentResp ResponseId = "test", }); - ChatResponseFormatJson responseFormat = NewChatResponseFormat.ForJsonSchema(JsonContext4.Default.Options); + ChatResponseFormatJson responseFormat = ChatResponseFormat.ForJsonSchema(JsonContext4.Default.Options); ChatClientAgent agent = new(mockService.Object, options: new ChatClientAgentOptions { diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_SO_WithRunAsyncTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_SO_WithRunAsyncTests.cs index d4f6b51ab3..1a3c9505b8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_SO_WithRunAsyncTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_SO_WithRunAsyncTests.cs @@ -52,7 +52,5 @@ public async Task RunAsync_WithGenericType_SetsJsonSchemaResponseFormatAndDeseri [JsonSourceGenerationOptions(UseStringEnumConverter = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] [JsonSerializable(typeof(Animal))] - [JsonSerializable(typeof(JsonElement))] - [JsonSerializable(typeof(object))] private sealed partial class JsonContext3 : JsonSerializerContext; } From d7f0d858291e7ca9e3d4f417ccd49030d3aabba4 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Wed, 4 Feb 2026 15:31:39 +0000 Subject: [PATCH 14/16] address pr review feedback --- ...tAgent_StructuredOutput_WithFormatResponseTests.cs} | 2 +- ...tClientAgent_StructuredOutput_WithRunAsyncTests.cs} | 2 +- .../Microsoft.Agents.AI.UnitTests/Models/Animal.cs | 9 +-------- .../Microsoft.Agents.AI.UnitTests/Models/Species.cs | 10 ++++++++++ 4 files changed, 13 insertions(+), 10 deletions(-) rename dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/{ChatClientAgent_SO_WithFormatResponseTests.cs => ChatClientAgent_StructuredOutput_WithFormatResponseTests.cs} (99%) rename dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/{ChatClientAgent_SO_WithRunAsyncTests.cs => ChatClientAgent_StructuredOutput_WithRunAsyncTests.cs} (96%) create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Models/Species.cs diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_SO_WithFormatResponseTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_StructuredOutput_WithFormatResponseTests.cs similarity index 99% rename from dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_SO_WithFormatResponseTests.cs rename to dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_StructuredOutput_WithFormatResponseTests.cs index dae6f434a5..7ec4a4f59f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_SO_WithFormatResponseTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_StructuredOutput_WithFormatResponseTests.cs @@ -10,7 +10,7 @@ namespace Microsoft.Agents.AI.UnitTests; -public partial class ChatClientAgent_SO_WithFormatResponseTests +public partial class ChatClientAgent_StructuredOutput_WithFormatResponseTests { [Fact] public async Task RunAsync_ResponseFormatProvidedAtAgentInitialization_IsPropagatedToChatClientAsync() diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_SO_WithRunAsyncTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_StructuredOutput_WithRunAsyncTests.cs similarity index 96% rename from dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_SO_WithRunAsyncTests.cs rename to dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_StructuredOutput_WithRunAsyncTests.cs index 1a3c9505b8..57e1bbf371 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_SO_WithRunAsyncTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_StructuredOutput_WithRunAsyncTests.cs @@ -10,7 +10,7 @@ namespace Microsoft.Agents.AI.UnitTests; -public partial class ChatClientAgent_SO_WithRunAsyncTests +public partial class ChatClientAgent_StructuredOutput_WithRunAsyncTests { [Fact] public async Task RunAsync_WithGenericType_SetsJsonSchemaResponseFormatAndDeserializesResultAsync() diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Models/Animal.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Models/Animal.cs index 2ef43266f0..331f336b8e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Models/Animal.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Models/Animal.cs @@ -2,16 +2,9 @@ namespace Microsoft.Agents.AI.UnitTests; -public sealed class Animal +internal sealed class Animal { public int Id { get; set; } public string? FullName { get; set; } public Species Species { get; set; } } - -public enum Species -{ - Bear, - Tiger, - Walrus, -} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Models/Species.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Models/Species.cs new file mode 100644 index 0000000000..14c493be72 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Models/Species.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.UnitTests; + +internal enum Species +{ + Bear, + Tiger, + Walrus, +} From 3e39df327f362f367b25ab0f92df29004d71f537 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Wed, 4 Feb 2026 17:51:12 +0000 Subject: [PATCH 15/16] make copy constructor protected --- .../Microsoft.Agents.AI.Abstractions/AgentRunOptions.cs | 2 +- .../AgentRunOptionsTests.cs | 8 +------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunOptions.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunOptions.cs index 08e2aa6518..08811df288 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunOptions.cs @@ -28,7 +28,7 @@ public AgentRunOptions() /// /// The options instance from which to copy values. /// is . - public AgentRunOptions(AgentRunOptions options) + protected AgentRunOptions(AgentRunOptions options) { _ = Throw.IfNull(options); this.ContinuationToken = options.ContinuationToken; diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunOptionsTests.cs index 7a54d068a6..028828c520 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunOptionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunOptionsTests.cs @@ -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 void CloningConstructorCopiesProperties() }; // Act - var clone = new AgentRunOptions(options); + var clone = options.Clone(); // Assert Assert.NotNull(clone); @@ -39,11 +38,6 @@ public void CloningConstructorCopiesProperties() Assert.Equal(42, clone.AdditionalProperties["key2"]); } - [Fact] - public void CloningConstructorThrowsIfNull() => - // Act & Assert - Assert.Throws(() => new AgentRunOptions(null!)); - [Fact] public void JsonSerializationRoundtrips() { From b2b0619d7dabdaebf30e817c550fa7b0a1699d9f Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Wed, 4 Feb 2026 19:18:37 +0000 Subject: [PATCH 16/16] address pr review feedback --- .../AgentResponse.cs | 2 +- .../AgentResponse{T}.cs | 42 +++++-------------- .../ChatClient/ChatClientAgent.cs | 2 +- 3 files changed, 12 insertions(+), 34 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentResponse.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentResponse.cs index 766ad19fca..bd07d7cb8f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentResponse.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentResponse.cs @@ -78,7 +78,7 @@ public AgentResponse(ChatResponse response) /// metadata and storing the original response in for access to /// the underlying implementation details. /// - public AgentResponse(AgentResponse response) + protected AgentResponse(AgentResponse response) { _ = Throw.IfNull(response); diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentResponse{T}.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentResponse{T}.cs index 60138ba713..c75e78211d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentResponse{T}.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentResponse{T}.cs @@ -40,36 +40,20 @@ public virtual T Result { get { - var structuredOutput = this.GetResultCore(this._serializerOptions, out var failureReason); - return failureReason switch + var json = this.Text; + if (string.IsNullOrEmpty(json)) { - 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!, - }; - } - } - - private T? GetResultCore(JsonSerializerOptions serializerOptions, out FailureReason? failureReason) - { - var json = this.Text; - if (string.IsNullOrEmpty(json)) - { - failureReason = FailureReason.ResultDidNotContainJson; - return default; - } + throw new InvalidOperationException("The response did not contain JSON to be deserialized."); + } - // 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)serializerOptions.GetTypeInfo(typeof(T))); + T? deserialized = DeserializeFirstTopLevelObject(json!, (JsonTypeInfo)this._serializerOptions.GetTypeInfo(typeof(T))); + if (deserialized is null) + { + throw new InvalidOperationException("The deserialized response is null."); + } - if (deserialized is null) - { - failureReason = FailureReason.DeserializationProducedNull; - return default; + return deserialized; } - - failureReason = default; - return deserialized; } private static T? DeserializeFirstTopLevelObject(string json, JsonTypeInfo typeInfo) @@ -94,10 +78,4 @@ public virtual T Result return JsonSerializer.Deserialize(json, typeInfo); #endif } - - private enum FailureReason - { - ResultDidNotContainJson, - DeserializationProducedNull - } } diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs index eac98af707..77ab64a073 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs @@ -637,7 +637,7 @@ await session.AIContextProvider.InvokedAsync(new(inputMessages, aiContextProvide if (agentRunOptions?.ResponseFormat is not null) { chatOptions ??= new ChatOptions(); - chatOptions?.ResponseFormat = agentRunOptions.ResponseFormat; + chatOptions.ResponseFormat = agentRunOptions.ResponseFormat; } ChatClientAgentContinuationToken? agentContinuationToken = null;