Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ namespace Microsoft.Agents.AI;
/// may involve multiple agents working together.
/// </remarks>
[DebuggerDisplay("{DebuggerDisplay,nq}")]
public abstract class AIAgent
public abstract partial class AIAgent
{
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
private string DebuggerDisplay =>
Expand Down
282 changes: 282 additions & 0 deletions dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs

Large diffs are not rendered by default.

23 changes: 23 additions & 0 deletions dotnet/src/Microsoft.Agents.AI.Abstractions/AgentResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,29 @@ public AgentResponse(ChatResponse response)
this.ContinuationToken = response.ContinuationToken;
}

/// <summary>
/// Initializes a new instance of the <see cref="AgentResponse"/> class from an existing <see cref="AgentResponse"/>.
/// </summary>
/// <param name="response">The <see cref="AgentResponse"/> from which to copy properties.</param>
/// <exception cref="ArgumentNullException"><paramref name="response"/> is <see langword="null"/>.</exception>
/// <remarks>
/// This constructor creates a copy of an existing agent response, preserving all
/// metadata and storing the original response in <see cref="RawRepresentation"/> for access to
/// the underlying implementation details.
/// </remarks>
public AgentResponse(AgentResponse response)
Comment thread
SergeyMenshykh marked this conversation as resolved.
Outdated
{
_ = Throw.IfNull(response);

this.AdditionalProperties = response.AdditionalProperties;
this.CreatedAt = response.CreatedAt;
this.Messages = response.Messages;
this.RawRepresentation = response;
this.ResponseId = response.ResponseId;
this.Usage = response.Usage;
this.ContinuationToken = response.ContinuationToken;
}

/// <summary>
/// Initializes a new instance of the <see cref="AgentResponse"/> class with the specified collection of messages.
/// </summary>
Expand Down
103 changes: 95 additions & 8 deletions dotnet/src/Microsoft.Agents.AI.Abstractions/AgentResponse{T}.cs
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);
}
}
Comment thread
SergeyMenshykh marked this conversation as resolved.
Outdated

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,
}
}
15 changes: 15 additions & 0 deletions dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/// <summary>
Expand Down Expand Up @@ -90,4 +91,18 @@ public AgentRunOptions(AgentRunOptions options)
/// preserving implementation-specific details or extending the options with custom data.
/// </remarks>
public AdditionalPropertiesDictionary? AdditionalProperties { get; set; }

/// <summary>
/// Gets or sets the response format.
/// </summary>
/// <remarks>
/// If <see langword="null"/>, no response format is specified and the agent will use its default.
/// This property can be set to <see cref="ChatResponseFormat.Text"/> to specify that the response should be unstructured text,
/// to <see cref="ChatResponseFormat.Json"/> to specify that the response should be structured JSON data, or
/// an instance of <see cref="ChatResponseFormatJson"/> constructed with a specific JSON schema to request that the
/// response be structured JSON data according to that schema. It is up to the agent implementation if or how
/// to honor the request. If the agent implementation doesn't recognize the specific kind of <see cref="ChatResponseFormat"/>,
/// it can be ignored.
/// </remarks>
public ChatResponseFormat? ResponseFormat { get; set; }
}
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
Comment thread
SergeyMenshykh marked this conversation as resolved.
Outdated
{
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
}
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);
}
}
Loading
Loading