mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: Preserve caller context (#3803)
* fix stuck orchestration * add previously removed RunAsync<T> method to DurableAIAgent * suppress IDE0005 warning * update changelog and remove unused constructor of AgentResponse<T> * updatge the changelog * address PR review feedback
This commit is contained in:
committed by
GitHub
Unverified
parent
6dda25c499
commit
19ff980b6e
@@ -389,6 +389,9 @@
|
||||
<File Path="src/Shared/Throw/README.md" />
|
||||
<File Path="src/Shared/Throw/Throw.cs" />
|
||||
</Folder>
|
||||
<Folder Name="/Solution Items/src/Shared/StructuredOutput/">
|
||||
<File Path="src/Shared/StructuredOutput/StructuredOutputSchemaUtilities.cs" />
|
||||
</Folder>
|
||||
<Folder Name="/Solution Items/tests/">
|
||||
<File Path="tests/.editorconfig" />
|
||||
<File Path="tests/Directory.Build.props" />
|
||||
|
||||
@@ -20,4 +20,7 @@
|
||||
<ItemGroup Condition="'$(InjectSharedFoundryAgents)' == 'true'">
|
||||
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\Foundry\Agents\*.cs" LinkBase="Shared\Foundry" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Condition="'$(InjectSharedStructuredOutput)' == 'true'">
|
||||
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\StructuredOutput\*.cs" LinkBase="Shared\StructuredOutput" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -129,7 +128,7 @@ public abstract partial class AIAgent
|
||||
|
||||
var responseFormat = ChatResponseFormat.ForJsonSchema<T>(serializerOptions);
|
||||
|
||||
(responseFormat, bool isWrappedInObject) = EnsureObjectSchema(responseFormat);
|
||||
(responseFormat, bool isWrappedInObject) = StructuredOutputSchemaUtilities.WrapNonObjectSchema(responseFormat);
|
||||
|
||||
options = options?.Clone() ?? new AgentRunOptions();
|
||||
options.ResponseFormat = responseFormat;
|
||||
@@ -138,66 +137,4 @@ public abstract partial class AIAgent
|
||||
|
||||
return new AgentResponse<T>(response, serializerOptions) { IsWrappedInObject = isWrappedInObject };
|
||||
}
|
||||
|
||||
private static bool SchemaRepresentsObject(JsonElement? schema)
|
||||
{
|
||||
if (schema is not { } schemaElement)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (schemaElement.ValueKind is JsonValueKind.Object)
|
||||
{
|
||||
foreach (var property in schemaElement.EnumerateObject())
|
||||
{
|
||||
if (property.NameEquals("type"u8))
|
||||
{
|
||||
return property.Value.ValueKind == JsonValueKind.String
|
||||
&& property.Value.ValueEquals("object"u8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static (ChatResponseFormatJson ResponseFormat, bool IsWrappedInObject) EnsureObjectSchema(ChatResponseFormatJson responseFormat)
|
||||
{
|
||||
if (responseFormat.Schema is null)
|
||||
{
|
||||
throw new InvalidOperationException("The response format must have a valid JSON schema.");
|
||||
}
|
||||
|
||||
var schema = responseFormat.Schema.Value;
|
||||
bool isWrappedInObject = false;
|
||||
|
||||
if (!SchemaRepresentsObject(responseFormat.Schema))
|
||||
{
|
||||
// For non-object-representing schemas, we wrap them in an object schema, because all
|
||||
// the real LLM providers today require an object schema as the root. This is currently
|
||||
// true even for providers that support native structured output.
|
||||
isWrappedInObject = true;
|
||||
schema = JsonSerializer.SerializeToElement(new JsonObject
|
||||
{
|
||||
{ "$schema", "https://json-schema.org/draft/2020-12/schema" },
|
||||
{ "type", "object" },
|
||||
{ "properties", new JsonObject { { "data", JsonElementToJsonNode(schema) } } },
|
||||
{ "additionalProperties", false },
|
||||
{ "required", new JsonArray("data") },
|
||||
}, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonObject)));
|
||||
|
||||
responseFormat = ChatResponseFormat.ForJsonSchema(schema, responseFormat.SchemaName, responseFormat.SchemaDescription);
|
||||
}
|
||||
|
||||
return (responseFormat, isWrappedInObject);
|
||||
}
|
||||
|
||||
private static JsonNode? JsonElementToJsonNode(JsonElement element) =>
|
||||
element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Null => null,
|
||||
JsonValueKind.Array => JsonArray.Create(element),
|
||||
JsonValueKind.Object => JsonObject.Create(element),
|
||||
_ => JsonValue.Create(element)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
@@ -37,26 +36,13 @@ public class AgentResponse<T> : AgentResponse
|
||||
this._serializerOptions = serializerOptions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
/// <param name="serializerOptions">The <see cref="JsonSerializerOptions"/> to use when deserializing the result.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="serializerOptions"/> is <see langword="null"/>.</exception>
|
||||
public AgentResponse(ChatResponse response, JsonSerializerOptions serializerOptions) : base(response)
|
||||
{
|
||||
_ = Throw.IfNull(serializerOptions);
|
||||
|
||||
this._serializerOptions = serializerOptions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the JSON schema has an extra object wrapper.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The wrapper is required for any non-JSON-object-typed values such as numbers, enum values, and arrays.
|
||||
/// </remarks>
|
||||
internal bool IsWrappedInObject { get; init; }
|
||||
public bool IsWrappedInObject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the result value of the agent response as an instance of <typeparamref name="T"/>.
|
||||
@@ -74,7 +60,7 @@ public class AgentResponse<T> : AgentResponse
|
||||
|
||||
if (this.IsWrappedInObject)
|
||||
{
|
||||
json = UnwrapDataProperty(json!);
|
||||
json = StructuredOutputSchemaUtilities.UnwrapResponseData(json!);
|
||||
}
|
||||
|
||||
T? deserialized = DeserializeFirstTopLevelObject(json!, (JsonTypeInfo<T>)this._serializerOptions.GetTypeInfo(typeof(T)));
|
||||
@@ -87,19 +73,6 @@ public class AgentResponse<T> : AgentResponse
|
||||
}
|
||||
}
|
||||
|
||||
private static string UnwrapDataProperty(string json)
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
if (document.RootElement.ValueKind == JsonValueKind.Object &&
|
||||
document.RootElement.TryGetProperty("data", out JsonElement dataElement))
|
||||
{
|
||||
return dataElement.GetRawText();
|
||||
}
|
||||
|
||||
// If root is not an object or "data" property is not found, return the original JSON as a fallback
|
||||
return json;
|
||||
}
|
||||
|
||||
private static T? DeserializeFirstTopLevelObject(string json, JsonTypeInfo<T> typeInfo)
|
||||
{
|
||||
#if NET
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
<InjectSharedStructuredOutput>true</InjectSharedStructuredOutput>
|
||||
<InjectDiagnosticClassesOnLegacy>true</InjectDiagnosticClassesOnLegacy>
|
||||
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
|
||||
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
- Moved AgentSession.Serialize to AIAgent.SerializeSession ([#3650](https://github.com/microsoft/agent-framework/pull/3650))
|
||||
- Renamed serializedSession parameter to serializedState on DeserializeSessionAsync for consistency ([#3681](https://github.com/microsoft/agent-framework/pull/3681))
|
||||
- Introduce Core method pattern for Session management methods on AIAgent ([#3699](https://github.com/microsoft/agent-framework/pull/3699))
|
||||
- Updated to use base `AgentRunOptions.ResponseFormat` for structured output configuration ([#3658](https://github.com/microsoft/agent-framework/pull/3658))
|
||||
- Marked all `RunAsync<T>` overloads as `new`, added missing ones, and added support for primitives and arrays ([#3803](https://github.com/microsoft/agent-framework/pull/3803))
|
||||
|
||||
## v1.0.0-preview.251204.1
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Text.Json;
|
||||
using Microsoft.DurableTask;
|
||||
using Microsoft.DurableTask.Entities;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
@@ -168,4 +169,127 @@ public sealed class DurableAIAgent : AIAgent
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <typeparamref name="T"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of structured output to request.</typeparam>
|
||||
/// <param name="session">
|
||||
/// The conversation session to use for this invocation. If <see langword="null"/>, a new session will be created.
|
||||
/// The session will be updated with any response messages generated during invocation.
|
||||
/// </param>
|
||||
/// <param name="serializerOptions">Optional JSON serializer options to use for deserializing the response.</param>
|
||||
/// <param name="options">Optional configuration parameters for controlling the agent's invocation behavior.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentResponse{T}"/> with the agent's output.</returns>
|
||||
/// <remarks>
|
||||
/// This method is specific to durable agents because the Durable Task Framework uses a custom
|
||||
/// synchronization context for orchestration execution, and all continuations must run on the
|
||||
/// orchestration thread to avoid breaking the durable orchestration and potential deadlocks.
|
||||
/// </remarks>
|
||||
public new Task<AgentResponse<T>> RunAsync<T>(
|
||||
AgentSession? session = null,
|
||||
JsonSerializerOptions? serializerOptions = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
this.RunAsync<T>([], session, serializerOptions, options, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Runs the agent with a text message from the user, requesting a response of the specified type <typeparamref name="T"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of structured output to request.</typeparam>
|
||||
/// <param name="message">The user message to send to the agent.</param>
|
||||
/// <param name="session">
|
||||
/// The conversation session to use for this invocation. If <see langword="null"/>, a new session will be created.
|
||||
/// The session will be updated with the input message and any response messages generated during invocation.
|
||||
/// </param>
|
||||
/// <param name="serializerOptions">Optional JSON serializer options to use for deserializing the response.</param>
|
||||
/// <param name="options">Optional configuration parameters for controlling the agent's invocation behavior.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentResponse{T}"/> with the agent's output.</returns>
|
||||
/// <exception cref="ArgumentException"><paramref name="message"/> is <see langword="null"/>, empty, or contains only whitespace.</exception>
|
||||
/// <remarks>
|
||||
/// <inheritdoc cref="RunAsync{T}(AgentSession?, JsonSerializerOptions?, AgentRunOptions?, CancellationToken)" path="/remarks" />
|
||||
/// </remarks>
|
||||
public new Task<AgentResponse<T>> RunAsync<T>(
|
||||
string message,
|
||||
AgentSession? session = null,
|
||||
JsonSerializerOptions? serializerOptions = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = Throw.IfNull(message);
|
||||
|
||||
return this.RunAsync<T>(new ChatMessage(ChatRole.User, message), session, serializerOptions, options, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the agent with a single chat message, requesting a response of the specified type <typeparamref name="T"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of structured output to request.</typeparam>
|
||||
/// <param name="message">The chat message to send to the agent.</param>
|
||||
/// <param name="session">
|
||||
/// The conversation session to use for this invocation. If <see langword="null"/>, a new session will be created.
|
||||
/// The session will be updated with the input message and any response messages generated during invocation.
|
||||
/// </param>
|
||||
/// <param name="serializerOptions">Optional JSON serializer options to use for deserializing the response.</param>
|
||||
/// <param name="options">Optional configuration parameters for controlling the agent's invocation behavior.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentResponse{T}"/> with the agent's output.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="message"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>
|
||||
/// <inheritdoc cref="RunAsync{T}(AgentSession?, JsonSerializerOptions?, AgentRunOptions?, CancellationToken)" path="/remarks" />
|
||||
/// </remarks>
|
||||
public new Task<AgentResponse<T>> RunAsync<T>(
|
||||
ChatMessage message,
|
||||
AgentSession? session = null,
|
||||
JsonSerializerOptions? serializerOptions = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = Throw.IfNull(message);
|
||||
|
||||
return this.RunAsync<T>([message], session, serializerOptions, options, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the agent with a collection of chat messages, requesting a response of the specified type <typeparamref name="T"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of structured output to request.</typeparam>
|
||||
/// <param name="messages">The collection of messages to send to the agent for processing.</param>
|
||||
/// <param name="session">
|
||||
/// The conversation session to use for this invocation. If <see langword="null"/>, a new session will be created.
|
||||
/// The session will be updated with the input messages and any response messages generated during invocation.
|
||||
/// </param>
|
||||
/// <param name="serializerOptions">Optional JSON serializer options to use for deserializing the response.</param>
|
||||
/// <param name="options">Optional configuration parameters for controlling the agent's invocation behavior.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentResponse{T}"/> with the agent's output.</returns>
|
||||
/// <remarks>
|
||||
/// <inheritdoc cref="RunAsync{T}(AgentSession?, JsonSerializerOptions?, AgentRunOptions?, CancellationToken)" path="/remarks" />
|
||||
/// </remarks>
|
||||
public new async Task<AgentResponse<T>> RunAsync<T>(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
JsonSerializerOptions? serializerOptions = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
serializerOptions ??= AgentAbstractionsJsonUtilities.DefaultOptions;
|
||||
|
||||
var responseFormat = ChatResponseFormat.ForJsonSchema<T>(serializerOptions);
|
||||
|
||||
(responseFormat, bool isWrappedInObject) = StructuredOutputSchemaUtilities.WrapNonObjectSchema(responseFormat);
|
||||
|
||||
options = options?.Clone() ?? new DurableAgentRunOptions();
|
||||
options.ResponseFormat = responseFormat;
|
||||
|
||||
// ConfigureAwait(false) cannot be used here because the Durable Task Framework uses
|
||||
// a custom synchronization context that requires all continuations to execute on the
|
||||
// orchestration thread. Scheduling the continuation on an arbitrary thread would break
|
||||
// the orchestration.
|
||||
AgentResponse response = await this.RunAsync(messages, session, options, cancellationToken);
|
||||
|
||||
return new AgentResponse<T>(response, serializerOptions) { IsWrappedInObject = isWrappedInObject };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
<InjectSharedStructuredOutput>true</InjectSharedStructuredOutput>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Durable Task dependencies -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.DurableTask.Client" />
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
#pragma warning disable IDE0005 // Using directive is unnecessary.
|
||||
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Internal utilities for working with structured output JSON schemas.
|
||||
/// </summary>
|
||||
internal static class StructuredOutputSchemaUtilities
|
||||
{
|
||||
private const string DataPropertyName = "data";
|
||||
|
||||
/// <summary>
|
||||
/// Ensures the given response format has an object schema at the root, wrapping non-object schemas if necessary.
|
||||
/// </summary>
|
||||
/// <param name="responseFormat">The response format to check.</param>
|
||||
/// <returns>A tuple containing the (possibly wrapped) response format and whether wrapping occurred.</returns>
|
||||
/// <exception cref="InvalidOperationException">The response format does not have a valid JSON schema.</exception>
|
||||
internal static (ChatResponseFormatJson ResponseFormat, bool IsWrappedInObject) WrapNonObjectSchema(ChatResponseFormatJson responseFormat)
|
||||
{
|
||||
if (responseFormat.Schema is null)
|
||||
{
|
||||
throw new InvalidOperationException("The response format must have a valid JSON schema.");
|
||||
}
|
||||
|
||||
var schema = responseFormat.Schema.Value;
|
||||
bool isWrappedInObject = false;
|
||||
|
||||
if (!SchemaRepresentsObject(responseFormat.Schema))
|
||||
{
|
||||
// For non-object-representing schemas, we wrap them in an object schema, because all
|
||||
// the real LLM providers today require an object schema as the root. This is currently
|
||||
// true even for providers that support native structured output.
|
||||
isWrappedInObject = true;
|
||||
schema = JsonSerializer.SerializeToElement(new JsonObject
|
||||
{
|
||||
{ "$schema", "https://json-schema.org/draft/2020-12/schema" },
|
||||
{ "type", "object" },
|
||||
{ "properties", new JsonObject { { DataPropertyName, JsonElementToJsonNode(schema) } } },
|
||||
{ "additionalProperties", false },
|
||||
{ "required", new JsonArray(DataPropertyName) },
|
||||
}, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonObject)));
|
||||
|
||||
responseFormat = ChatResponseFormat.ForJsonSchema(schema, responseFormat.SchemaName, responseFormat.SchemaDescription);
|
||||
}
|
||||
|
||||
return (responseFormat, isWrappedInObject);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unwraps the <c>"data"</c> property from a JSON object that was previously wrapped by <see cref="WrapNonObjectSchema"/>.
|
||||
/// </summary>
|
||||
/// <param name="json">The JSON string to unwrap.</param>
|
||||
/// <returns>The raw JSON text of the <c>"data"</c> property, or the original JSON if no wrapping is detected.</returns>
|
||||
internal static string UnwrapResponseData(string json)
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
if (document.RootElement.ValueKind == JsonValueKind.Object &&
|
||||
document.RootElement.TryGetProperty(DataPropertyName, out JsonElement dataElement))
|
||||
{
|
||||
return dataElement.GetRawText();
|
||||
}
|
||||
|
||||
// If root is not an object or "data" property is not found, return the original JSON as a fallback
|
||||
return json;
|
||||
}
|
||||
|
||||
private static bool SchemaRepresentsObject(JsonElement? schema)
|
||||
{
|
||||
if (schema is not { } schemaElement)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (schemaElement.ValueKind is JsonValueKind.Object)
|
||||
{
|
||||
foreach (var property in schemaElement.EnumerateObject())
|
||||
{
|
||||
if (property.NameEquals("type"u8))
|
||||
{
|
||||
return property.Value.ValueKind == JsonValueKind.String
|
||||
&& property.Value.ValueEquals("object"u8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static JsonNode? JsonElementToJsonNode(JsonElement element) =>
|
||||
element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Null => null,
|
||||
JsonValueKind.Array => JsonArray.Create(element),
|
||||
JsonValueKind.Object => JsonObject.Create(element),
|
||||
_ => JsonValue.Create(element)
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user