.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:
SergeyMenshykh
2026-02-13 10:04:07 +00:00
committed by GitHub
Unverified
parent 6dda25c499
commit 19ff980b6e
9 changed files with 244 additions and 94 deletions
+3
View File
@@ -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" />
+3
View File
@@ -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)
};
}