-
Notifications
You must be signed in to change notification settings - Fork 1.2k
.NET:[Breaking] Add support for structured output #3658
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
SergeyMenshykh
merged 18 commits into
microsoft:feature-so
from
SergeyMenshykh:add-so-methods
Feb 4, 2026
Merged
Changes from 10 commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
a6c49c2
add support for so
SergeyMenshykh 5826e35
restore lost xml comment part
SergeyMenshykh 84a9c74
fix using ordering
SergeyMenshykh 0d8ea68
Update dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredO…
SergeyMenshykh 28de92c
Update dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredO…
SergeyMenshykh bbc1241
Update dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClie…
SergeyMenshykh 919d052
addressw pr review comments
SergeyMenshykh 0988b8a
Merge branch 'add-so-methods' of https://github.com/SergeyMenshykh/ag…
SergeyMenshykh 0bd9421
address pr review feedback
SergeyMenshykh 90dc17f
address pr review comments
SergeyMenshykh b25c222
Merge branch 'feature-so' into add-so-methods
SergeyMenshykh 5f32326
fix compilation issues after the latest merge with main
SergeyMenshykh 9da8817
remove unnecessry options
SergeyMenshykh b4e44b9
remove RunAsync<object> methods
SergeyMenshykh a7690dd
address code review feedback
SergeyMenshykh d7f0d85
address pr review feedback
SergeyMenshykh 3e39df3
make copy constructor protected
SergeyMenshykh b2b0619
address pr review feedback
SergeyMenshykh File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
269 changes: 269 additions & 0 deletions
269
dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
103 changes: 95 additions & 8 deletions
103
dotnet/src/Microsoft.Agents.AI.Abstractions/AgentResponse{T}.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,30 +1,117 @@ | ||
| // 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; | ||
|
|
||
| /// <summary> | ||
| /// Represents the response of the specified type <typeparamref name="T"/> to an <see cref="AIAgent"/> run request. | ||
| /// </summary> | ||
| /// <typeparam name="T">The type of value expected from the agent.</typeparam> | ||
| public abstract class AgentResponse<T> : AgentResponse | ||
| public class AgentResponse<T> : AgentResponse | ||
| { | ||
| /// <summary>Initializes a new instance of the <see cref="AgentResponse{T}"/> class.</summary> | ||
| protected AgentResponse() | ||
| /// <summary> | ||
| /// Initializes a new instance of the <see cref="AgentResponse{T}"/> class. | ||
| /// </summary> | ||
| public AgentResponse() | ||
| { | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Initializes a new instance of the <see cref="AgentResponse{T}"/> class from an existing <see cref="ChatResponse"/>. | ||
| /// Initializes a new instance of the <see cref="AgentResponse{T}"/> class. | ||
| /// </summary> | ||
| /// <param name="response">The <see cref="ChatResponse"/> from which to populate this <see cref="AgentResponse{T}"/>.</param> | ||
| protected AgentResponse(ChatResponse response) : base(response) | ||
| /// <param name="response">The <see cref="AgentResponse"/> from which to populate this <see cref="AgentResponse{T}"/>.</param> | ||
| /// <param name="responseFormat">The JSON response format configuration used to deserialize the agent's response.</param> | ||
| public AgentResponse(AgentResponse response, NewChatResponseFormatJson? responseFormat = null) : base(response) | ||
| { | ||
| this.ResponseFormat = responseFormat; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Gets the result value of the agent response as an instance of <typeparamref name="T"/>. | ||
| /// </summary> | ||
| public abstract T Result { get; } | ||
| [JsonIgnore] | ||
| public virtual T Result | ||
| { | ||
| get | ||
| { | ||
| return (T)this.Deserialize(this.ResponseFormat?.SchemaType ?? typeof(T), this.ResponseFormat?.SchemaSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions); | ||
| } | ||
| } | ||
SergeyMenshykh marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| 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<byte>.Shared.Rent(utf8ByteLength); | ||
| try | ||
| { | ||
| var utf8SpanLength = Encoding.UTF8.GetBytes(json, 0, json.Length, buffer, 0); | ||
| var reader = new Utf8JsonReader(new ReadOnlySpan<byte>(buffer, 0, utf8SpanLength), new() { AllowMultipleValues = true }); | ||
| return JsonSerializer.Deserialize(ref reader, typeInfo); | ||
| } | ||
| finally | ||
| { | ||
| ArrayPool<byte>.Shared.Return(buffer); | ||
| } | ||
| #else | ||
| return JsonSerializer.Deserialize(json, typeInfo); | ||
| #endif | ||
| } | ||
|
|
||
| private enum FailureReason | ||
| { | ||
| ResultDidNotContainJson, | ||
| DeserializationProducedNull, | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
100 changes: 100 additions & 0 deletions
100
dotnet/src/Microsoft.Agents.AI.Abstractions/MEAI/NewChatResponseFormat.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
|
|
||
| /// <summary>Represents the response format that is desired by the caller.</summary> | ||
| [JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] | ||
| [JsonDerivedType(typeof(NewChatResponseFormatJson), typeDiscriminator: "json")] | ||
| public partial class NewChatResponseFormat | ||
SergeyMenshykh marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| { | ||
| private static readonly AIJsonSchemaCreateOptions s_inferenceOptions = new() | ||
| { | ||
| IncludeSchemaKeyword = true, | ||
| }; | ||
|
|
||
| /// <summary>Initializes a new instance of the <see cref="ChatResponseFormat"/> class.</summary> | ||
| /// <remarks>Prevents external instantiation. Close the inheritance hierarchy for now until we have good reason to open it.</remarks> | ||
| private protected NewChatResponseFormat() | ||
| { | ||
| } | ||
|
|
||
| /// <summary>Gets a singleton instance representing structured JSON data but without any particular schema.</summary> | ||
| public static NewChatResponseFormatJson Json { get; } = new(schema: null); | ||
|
|
||
| /// <summary>Creates a <see cref="NewChatResponseFormatJson"/> representing structured JSON data with the specified schema.</summary> | ||
| /// <param name="schema">The JSON schema.</param> | ||
| /// <param name="schemaName">An optional name of the schema. For example, if the schema represents a particular class, this could be the name of the class.</param> | ||
| /// <param name="schemaDescription">An optional description of the schema.</param> | ||
| /// <returns>The <see cref="NewChatResponseFormatJson"/> instance.</returns> | ||
| public static NewChatResponseFormatJson ForJsonSchema( | ||
| JsonElement schema, string? schemaName = null, string? schemaDescription = null) => | ||
| new(schema, schemaName, schemaDescription); | ||
|
|
||
| /// <summary>Creates a <see cref="NewChatResponseFormatJson"/> representing structured JSON data with a schema based on <typeparamref name="T"/>.</summary> | ||
| /// <typeparam name="T">The type for which a schema should be exported and used as the response schema.</typeparam> | ||
| /// <param name="serializerOptions">The JSON serialization options to use.</param> | ||
| /// <param name="schemaName">An optional name of the schema. By default, this will be inferred from <typeparamref name="T"/>.</param> | ||
| /// <param name="schemaDescription">An optional description of the schema. By default, this will be inferred from <typeparamref name="T"/>.</param> | ||
| /// <returns>The <see cref="NewChatResponseFormatJson"/> instance.</returns> | ||
| /// <remarks> | ||
| /// Many AI services that support structured output require that the JSON schema have a top-level 'type=object'. | ||
| /// If <typeparamref name="T"/> is a primitive type like <see cref="string"/>, <see cref="int"/>, or <see cref="bool"/>, | ||
| /// 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 <typeparamref name="T"/> 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. | ||
| /// </remarks> | ||
| public static NewChatResponseFormatJson ForJsonSchema<T>(JsonSerializerOptions? serializerOptions = null, string? schemaName = null, string? schemaDescription = null) => | ||
| ForJsonSchema(typeof(T), serializerOptions, schemaName, schemaDescription); | ||
|
|
||
| /// <summary>Creates a <see cref="NewChatResponseFormatJson"/> representing structured JSON data with a schema based on <paramref name="schemaType"/>.</summary> | ||
| /// <param name="schemaType">The <see cref="Type"/> for which a schema should be exported and used as the response schema.</param> | ||
| /// <param name="serializerOptions">The JSON serialization options to use.</param> | ||
| /// <param name="schemaName">An optional name of the schema. By default, this will be inferred from <paramref name="schemaType"/>.</param> | ||
| /// <param name="schemaDescription">An optional description of the schema. By default, this will be inferred from <paramref name="schemaType"/>.</param> | ||
| /// <returns>The <see cref="NewChatResponseFormatJson"/> instance.</returns> | ||
| /// <remarks> | ||
| /// Many AI services that support structured output require that the JSON schema have a top-level 'type=object'. | ||
| /// If <paramref name="schemaType"/> is a primitive type like <see cref="string"/>, <see cref="int"/>, or <see cref="bool"/>, | ||
| /// 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 <paramref name="schemaType"/> 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. | ||
| /// </remarks> | ||
| /// <exception cref="ArgumentNullException"><paramref name="schemaType"/> is <see langword="null"/>.</exception> | ||
| 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<DisplayNameAttribute>()?.DisplayName ?? InvalidNameCharsRegex().Replace(schemaType.Name, "_"), | ||
| schemaDescription ?? schemaType.GetCustomAttribute<DescriptionAttribute>()?.Description, | ||
| serializerOptions); | ||
| } | ||
|
|
||
| /// <summary>Regex that flags any character other than ASCII digits, ASCII letters, or underscore.</summary> | ||
| #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 | ||
| } | ||
87 changes: 87 additions & 0 deletions
87
dotnet/src/Microsoft.Agents.AI.Abstractions/MEAI/NewChatResponseFormatJson.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
|
|
||
| /// <summary>Represents a response format for structured JSON data.</summary> | ||
| [DebuggerDisplay("{DebuggerDisplay,nq}")] | ||
| public sealed class NewChatResponseFormatJson : NewChatResponseFormat | ||
| { | ||
| /// <summary>Initializes a new instance of the <see cref="NewChatResponseFormatJson"/> class with the specified schema.</summary> | ||
| /// <param name="schema">The schema to associate with the JSON response.</param> | ||
| /// <param name="schemaName">A name for the schema.</param> | ||
| /// <param name="schemaDescription">A description of the schema.</param> | ||
| [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; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Initializes a new instance of the <see cref="NewChatResponseFormatJson"/> class with a schema derived from the specified type. | ||
| /// </summary> | ||
| /// <param name="schemaType">The <see cref="Type"/> from which the schema was derived.</param> | ||
| /// <param name="schema">The JSON schema to associate with the JSON response.</param> | ||
| /// <param name="schemaName">An optional name for the schema.</param> | ||
| /// <param name="schemaDescription">An optional description of the schema.</param> | ||
| /// <param name="serializerOptions">The JSON serializer options to use for deserialization.</param> | ||
| 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; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Gets the <see cref="Type"/> from which the JSON schema was derived, or <see langword="null"/> if the schema was not derived from a type. | ||
| /// </summary> | ||
| [JsonIgnore] | ||
| public Type? SchemaType { get; } | ||
|
|
||
| /// <summary>Gets the JSON schema associated with the response, or <see langword="null"/> if there is none.</summary> | ||
| public JsonElement? Schema { get; } | ||
|
|
||
| /// <summary>Gets a name for the schema.</summary> | ||
| public string? SchemaName { get; } | ||
|
|
||
| /// <summary>Gets a description of the schema.</summary> | ||
| public string? SchemaDescription { get; } | ||
|
|
||
| /// <summary> | ||
| /// Gets the JSON serializer options to use when deserializing responses that conform to this schema, or <see langword="null"/> if default options should be used. | ||
| /// </summary> | ||
| [JsonIgnore] | ||
| public JsonSerializerOptions? SchemaSerializerOptions { get; } | ||
|
|
||
| /// <summary>Gets a string representing this instance to display in the debugger.</summary> | ||
| [DebuggerBrowsable(DebuggerBrowsableState.Never)] | ||
| private string DebuggerDisplay => this.Schema?.ToString() ?? "JSON"; | ||
|
|
||
| /// <summary> | ||
| /// Implicitly converts a <see cref="NewChatResponseFormatJson"/> to a <see cref="ChatResponseFormatJson"/>. | ||
| /// </summary> | ||
| /// <param name="format">The <see cref="NewChatResponseFormatJson"/> instance to convert.</param> | ||
| public static implicit operator ChatResponseFormatJson(NewChatResponseFormatJson format) | ||
| { | ||
| return new ChatResponseFormatJson(format.Schema, format.SchemaName, format.SchemaDescription); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.