Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
269 changes: 269 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)
{
_ = 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);
}
}

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,
}
}
32 changes: 32 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,35 @@ 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; }

/// <summary>
/// Produces a clone of the current <see cref="AgentRunOptions"/> instance.
/// </summary>
/// <returns>
/// A clone of the current <see cref="AgentRunOptions"/> instance.
/// </returns>
/// <remarks>
/// <para>
/// The clone will have the same values for all properties as the original instance. Any collections, like <see cref="AdditionalProperties"/>,
/// are shallow-cloned, meaning a new collection instance is created, but any references contained by the collections are shared with the original.
/// </para>
/// <para>
/// Derived types should override <see cref="Clone"/> to return an instance of the derived type.
/// </para>
/// </remarks>
public virtual AgentRunOptions Clone() => new(this);
}
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
{
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