diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs index 31f4993723..e210706767 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 => diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentStructuredOutput.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs similarity index 58% rename from dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentStructuredOutput.cs rename to dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs index d933939884..796b796317 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentStructuredOutput.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs @@ -11,155 +11,126 @@ namespace Microsoft.Agents.AI; /// -/// Provides an that delegates to an implementation. +/// Provides structured output methods for that enable requesting responses in a specific type format. /// -public sealed partial class ChatClientAgent +public abstract partial class AIAgent { /// /// 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. /// - /// The JSON serialization options to use. + /// Optional JSON serializer options to use for deserializing the response. /// 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. + /// 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( + 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); + 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. /// - /// The JSON serialization options to use. + /// Optional JSON serializer options to use for deserializing the response. /// 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. + /// 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( + 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); + 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. /// - /// The JSON serialization options to use. + /// Optional JSON serializer options to use for deserializing the response. /// 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. + /// A task that represents the asynchronous operation. The task result contains an with the agent's output. /// is . - public Task> RunAsync( + 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); + 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. /// - /// The JSON serialization options to use. + /// Optional JSON serializer options to use for deserializing the response. /// 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. + /// A task that represents the asynchronous operation. The task result contains an with the agent's output. /// /// - /// This is the primary invocation method that implementations must override. It handles collections of messages, - /// allowing for complex conversational scenarios including multi-turn interactions, function calls, and - /// context-rich conversations. + /// This method handles collections of messages, allowing for complex conversational scenarios including + /// multi-turn interactions, function calls, and context-rich conversations. /// /// /// 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( + public async 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); - } + serializerOptions ??= AgentAbstractionsJsonUtilities.DefaultOptions; + + options = options?.Clone() ?? new AgentRunOptions(); + options.ResponseFormat = ChatResponseFormat.ForJsonSchema(serializerOptions); - static ChatClientAgentResponse CreateResponse(ChatResponse chatResponse) - { - return new ChatClientAgentResponse(chatResponse) - { - ContinuationToken = WrapContinuationToken(chatResponse.ContinuationToken) - }; - } + AgentResponse response = await this.RunAsync(messages, session, options, cancellationToken).ConfigureAwait(false); - return this.RunCoreAsync(GetResponseAsync, CreateResponse, messages, session, options, cancellationToken); + return new AgentResponse(response, serializerOptions); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentResponse.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentResponse.cs index d79dbee135..bd07d7cb8f 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. + /// + protected AgentResponse(AgentResponse response) + { + _ = Throw.IfNull(response); + + this.AdditionalProperties = response.AdditionalProperties; + this.CreatedAt = response.CreatedAt; + this.Messages = response.Messages; + this.RawRepresentation = response; + this.ResponseId = response.ResponseId; + this.Usage = response.Usage; + this.ContinuationToken = response.ContinuationToken; + } + /// /// 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..c75e78211d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentResponse{T}.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentResponse{T}.cs @@ -1,6 +1,16 @@ // Copyright (c) Microsoft. All rights reserved. -using Microsoft.Extensions.AI; +using System; +#if NET +using System.Buffers; +#endif + +#if NET +using System.Text; +#endif +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; namespace Microsoft.Agents.AI; @@ -8,23 +18,64 @@ namespace Microsoft.Agents.AI; /// Represents the response of the specified type 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() - { - } + private readonly JsonSerializerOptions _serializerOptions; /// - /// 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 to use when deserializing the result. + public AgentResponse(AgentResponse response, JsonSerializerOptions serializerOptions) : base(response) { + this._serializerOptions = serializerOptions; } /// /// Gets the result value of the agent response as an instance of . /// - public abstract T Result { get; } + [JsonIgnore] + public virtual T Result + { + get + { + var json = this.Text; + if (string.IsNullOrEmpty(json)) + { + throw new InvalidOperationException("The response did not contain JSON to be deserialized."); + } + + T? deserialized = DeserializeFirstTopLevelObject(json!, (JsonTypeInfo)this._serializerOptions.GetTypeInfo(typeof(T))); + if (deserialized is null) + { + throw new InvalidOperationException("The deserialized response is null."); + } + + return deserialized; + } + } + + private static T? DeserializeFirstTopLevelObject(string json, JsonTypeInfo 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 + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunOptions.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunOptions.cs index 611d15036c..08811df288 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunOptions.cs @@ -28,12 +28,13 @@ 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; this.AllowBackgroundResponses = options.AllowBackgroundResponses; this.AdditionalProperties = options.AdditionalProperties?.Clone(); + this.ResponseFormat = options.ResponseFormat; } /// @@ -90,4 +91,35 @@ 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; } + + /// + /// 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/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 diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs index a97652bd93..840c229726 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 b6d3fa4900..f5cbec05de 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..f698eab3d8 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; /// @@ -9,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. /// @@ -19,11 +36,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. /// @@ -33,4 +45,7 @@ public sealed class DurableAgentRunOptions : AgentRunOptions /// long-running tasks where the caller does not need to wait for the agent to complete the run. /// public bool IsFireAndForget { get; set; } + + /// + public override AgentRunOptions Clone() => new DurableAgentRunOptions(this); } diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs index 504928d2d6..77ab64a073 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..e5d6296700 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentCustomOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentCustomOptions.cs @@ -162,19 +162,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 +181,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 +201,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 +221,13 @@ 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( 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(messages, session, serializerOptions, (AgentRunOptions?)options, cancellationToken); } 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/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/tests/AgentConformance.IntegrationTests/StructuredOutputRunTests.cs b/dotnet/tests/AgentConformance.IntegrationTests/StructuredOutputRunTests.cs new file mode 100644 index 0000000000..5901986aa1 --- /dev/null +++ b/dotnet/tests/AgentConformance.IntegrationTests/StructuredOutputRunTests.cs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json; +using System.Threading.Tasks; +using AgentConformance.IntegrationTests.Support; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace AgentConformance.IntegrationTests; + +/// +/// 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.CreateSessionAsync(); + await using var cleanup = new SessionCleanup(session, this.Fixture); + + var options = new AgentRunOptions + { + ResponseFormat = ChatResponseFormat.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.CreateSessionAsync(); + 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); + } + + 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..3b69b5693a --- /dev/null +++ b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using AgentConformance.IntegrationTests; +using AgentConformance.IntegrationTests.Support; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace AzureAI.IntegrationTests; + +public class AIProjectClientAgentStructuredOutputRunTests() : StructuredOutputRunTests>(() => new AIProjectClientStructuredOutputFixture()) +{ + 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. + /// + /// + [RetryFact(Constants.RetryCount, Constants.RetryDelay)] + public async Task RunWithResponseFormatAtAgentInitializationReturnsExpectedResultAsync() + { + // Arrange + var agent = this.Fixture.Agent; + var session = await agent.CreateSessionAsync(); + await using var cleanup = new SessionCleanup(session, this.Fixture); + + // Act + var response = await agent.RunAsync(new ChatMessage(ChatRole.User, "Provide information about the capital of France."), session); + + // Assert + Assert.NotNull(response); + Assert.Single(response.Messages); + Assert.Contains("Paris", response.Text); + Assert.True(TryDeserialize(response.Text, AgentAbstractionsJsonUtilities.DefaultOptions, out CityInfo cityInfo)); + Assert.Equal("Paris", cityInfo.Name); + } + + /// + /// 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.CreateSessionAsync(); + 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(); +} + +/// +/// 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 = ChatResponseFormat.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.Abstractions.UnitTests/AgentRunOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunOptionsTests.cs index 7460ea4623..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() { @@ -77,4 +71,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 } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs index e1ff5f8cbd..d2784803ab 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 = ChatResponseFormat.ForJsonSchema(JsonContext4.Default.Options); + + ChatClientAgent agent = new(mockService.Object, options: new ChatClientAgentOptions + { + ChatOptions = new ChatOptions() + { + ResponseFormat = responseFormat + } + }); + + // Act + await agent.RunAsync(messages: [new(ChatRole.User, "Hello")]); + + // Assert + Assert.NotNull(capturedResponseFormat); + Assert.Same(responseFormat, capturedResponseFormat); + } + + [Fact] + public async Task RunAsync_ResponseFormatProvidedAtAgentInvocation_IsPropagatedToChatClientAsync() + { + // Arrange + ChatResponseFormat? capturedResponseFormat = null; + + Mock 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 = ChatResponseFormat.ForJsonSchema(JsonContext4.Default.Options); + + ChatClientAgent agent = new(mockService.Object); + + ChatClientAgentRunOptions runOptions = new() + { + ResponseFormat = responseFormat + }; + + // Act + await agent.RunAsync(messages: [new(ChatRole.User, "Hello")], options: runOptions); + + // Assert + Assert.NotNull(capturedResponseFormat); + Assert.Same(responseFormat, capturedResponseFormat); + } + + [Fact] + public async Task RunAsync_ResponseFormatProvidedAtAgentInvocation_OverridesOneProvidedAtAgentInitializationAsync() + { + // Arrange + ChatResponseFormat? capturedResponseFormat = null; + + Mock 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 = ChatResponseFormat.ForJsonSchema(JsonContext4.Default.Options); + ChatResponseFormatJson invocationResponseFormat = ChatResponseFormat.ForJsonSchema(JsonContext4.Default.Options); + + ChatClientAgent agent = new(mockService.Object, options: new ChatClientAgentOptions + { + ChatOptions = new ChatOptions() + { + ResponseFormat = initializationResponseFormat + }, + }); + + ChatClientAgentRunOptions runOptions = new() + { + ResponseFormat = invocationResponseFormat + }; + + // Act + await agent.RunAsync(messages: [new(ChatRole.User, "Hello")], options: runOptions); + + // Assert + Assert.NotNull(capturedResponseFormat); + Assert.Same(invocationResponseFormat, capturedResponseFormat); + Assert.NotSame(initializationResponseFormat, capturedResponseFormat); + } + + [Fact] + public async Task RunAsync_ResponseFormatProvidedAtAgentRunOptions_OverridesOneProvidedViaChatOptionsAsync() + { + // Arrange + ChatResponseFormat? capturedResponseFormat = null; + + Mock 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 = ChatResponseFormat.ForJsonSchema(JsonContext4.Default.Options); + ChatResponseFormatJson runOptionsResponseFormat = ChatResponseFormat.ForJsonSchema(JsonContext4.Default.Options); + + ChatClientAgent agent = new(mockService.Object); + + ChatClientAgentRunOptions runOptions = new() + { + ChatOptions = new ChatOptions + { + ResponseFormat = chatOptionsResponseFormat + }, + ResponseFormat = runOptionsResponseFormat + }; + + // Act + await agent.RunAsync(messages: [new(ChatRole.User, "Hello")], options: runOptions); + + // Assert + Assert.NotNull(capturedResponseFormat); + Assert.Same(runOptionsResponseFormat, capturedResponseFormat); + Assert.NotSame(chatOptionsResponseFormat, capturedResponseFormat); + } + + [Fact] + public async Task RunAsync_StructuredOutputResponse_IsAvailableAsTextOnAgentResponseAsync() + { + // Arrange + Animal expectedAnimal = new() { FullName = "Wally the Walrus", Id = 1, Species = Species.Walrus }; + + Mock 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 = ChatResponseFormat.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_StructuredOutput_WithRunAsyncTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_StructuredOutput_WithRunAsyncTests.cs new file mode 100644 index 0000000000..57e1bbf371 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_StructuredOutput_WithRunAsyncTests.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.UnitTests; + +public partial class ChatClientAgent_StructuredOutput_WithRunAsyncTests +{ + [Fact] + public async Task RunAsync_WithGenericType_SetsJsonSchemaResponseFormatAndDeserializesResultAsync() + { + // Arrange + ChatResponseFormat? capturedResponseFormat = null; + ChatResponseFormatJson expectedResponseFormat = ChatResponseFormat.ForJsonSchema(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); + } + + [JsonSourceGenerationOptions(UseStringEnumConverter = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] + [JsonSerializable(typeof(Animal))] + 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..331f336b8e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Models/Animal.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.UnitTests; + +internal sealed class Animal +{ + public int Id { get; set; } + public string? FullName { get; set; } + public Species Species { get; set; } +} 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, +} 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)) +{ +}