Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## [Unreleased]

- Fixed a `JsonTypeInfo metadata ... was not provided` failure when persisting agent state for function calls or results that carry values the state serializer has no metadata for, such as the `AIContent` results returned by MCP tools ([#57](https://github.com/microsoft/agent-framework-durable-extension/pull/57))
- [BREAKING] Added `IWorkflowClient` overloads that start a registered workflow by name, and made workflow result deserialization case-insensitive so results can be read back when hosted in Azure Functions. External implementations of `IWorkflowClient` must implement the new members, and an untyped `null` first argument is now ambiguous between the `Workflow` and workflow-name overloads ([#48](https://github.com/microsoft/agent-framework-durable-extension/pull/48))
- [BREAKING] Removed the `AddAIAgents` and `AddWorkflows` bulk registration APIs and changed `AddWorkflow` to return `DurableWorkflowOptions` so multiple workflows can be registered fluently ([#39](https://github.com/microsoft/agent-framework-durable-extension/pull/39))
- Use "session" instead of "thread" terminology in documentation and API comments ([#47](https://github.com/microsoft/agent-framework-durable-extension/pull/47))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using Microsoft.Extensions.AI;

namespace Microsoft.Agents.AI.DurableTask.State;
Expand All @@ -23,6 +24,23 @@ namespace Microsoft.Agents.AI.DurableTask.State;
[JsonDerivedType(typeof(DurableAgentStateUnknownContent), "unknown")]
internal abstract class DurableAgentStateContent
{
/// <summary>
/// Type info for <see cref="object"/>, which dispatches on the runtime type of the value being
/// serialized.
/// </summary>
/// <remarks>
/// Serializing through <see cref="object"/> rather than the runtime type directly preserves the
/// <c>$type</c> discriminator for polymorphic types such as <see cref="AIContent"/>, which keeps the
/// persisted JSON self describing. This also matches how chat clients serialize loosely typed tool
/// values, so a value read back from durable state produces the same payload as the value that was
/// never persisted.
/// </remarks>
private static readonly JsonTypeInfo s_objectTypeInfo =
AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object));

private static readonly JsonElement s_nullElement =
JsonSerializer.SerializeToElement(value: null, jsonTypeInfo: s_objectTypeInfo);

/// <summary>
/// Gets any additional data found during deserialization that does not map to known properties.
/// </summary>
Expand Down Expand Up @@ -57,4 +75,28 @@ public static DurableAgentStateContent FromAIContent(AIContent content)
_ => DurableAgentStateUnknownContent.FromUnknownContent(content)
};
}

/// <summary>
/// Encodes a loosely typed value as a <see cref="JsonElement"/> so that it can be persisted.
/// </summary>
/// <param name="value">
/// The value to encode. Values that are already a <see cref="JsonElement"/> are returned unchanged.
/// </param>
/// <returns>The encoded value.</returns>
/// <remarks>
/// <see cref="DurableAgentStateJsonContext"/> is source generated and has no reflection fallback, so
/// <see cref="object"/> typed members must be reduced to JSON before the state is written. Otherwise
/// serialization throws for any runtime type the context was not generated for, which fails the entity
/// operation after the model call has already happened.
/// See https://github.com/microsoft/agent-framework-durable-extension/issues/33.
/// </remarks>
protected static JsonElement ToJsonElement(object? value)
{
return value switch
{
null => s_nullElement,
JsonElement element => element,
_ => JsonSerializer.SerializeToElement(value: value, jsonTypeInfo: s_objectTypeInfo)
};
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.

using System.Collections.Immutable;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;

Expand All @@ -12,12 +13,19 @@ namespace Microsoft.Agents.AI.DurableTask.State;
internal sealed class DurableAgentStateFunctionCallContent : DurableAgentStateContent
{
/// <summary>
/// The function call arguments.
/// The function call arguments, each encoded as JSON.
/// </summary>
/// <remarks>
/// Arguments produced by a chat client from a model response are already <see cref="JsonElement"/>
/// values, but callers can supply <see cref="FunctionCallContent"/> containing arbitrary objects (for
/// example when replaying history or resuming an approval). Those are encoded here using
/// <see cref="AIJsonUtilities.DefaultOptions"/> so that persisting the state cannot fail on a type the
/// state serializer has no metadata for.
/// </remarks>
/// TODO: Consider ensuring that empty dictionaries are omitted from serialization.
[JsonPropertyName("arguments")]
public required IReadOnlyDictionary<string, object?> Arguments { get; init; } =
ImmutableDictionary<string, object?>.Empty;
public required IReadOnlyDictionary<string, JsonElement> Arguments { get; init; } =
ImmutableDictionary<string, JsonElement>.Empty;

/// <summary>
/// Gets the function call identifier.
Expand All @@ -44,9 +52,18 @@ internal sealed class DurableAgentStateFunctionCallContent : DurableAgentStateCo
/// </returns>
public static DurableAgentStateFunctionCallContent FromFunctionCallContent(FunctionCallContent content)
{
Dictionary<string, JsonElement> arguments = [];
if (content.Arguments is not null)
{
foreach (KeyValuePair<string, object?> argument in content.Arguments)
{
arguments[argument.Key] = ToJsonElement(argument.Value);
}
}

return new DurableAgentStateFunctionCallContent()
{
Arguments = content.Arguments?.ToDictionary() ?? [],
Arguments = arguments,
CallId = content.CallId,
Name = content.Name
};
Expand All @@ -55,9 +72,12 @@ public static DurableAgentStateFunctionCallContent FromFunctionCallContent(Funct
/// <inheritdoc/>
public override AIContent ToAIContent()
{
return new FunctionCallContent(
this.CallId,
this.Name,
new Dictionary<string, object?>(this.Arguments));
Dictionary<string, object?> arguments = new(this.Arguments.Count);
foreach (KeyValuePair<string, JsonElement> argument in this.Arguments)
{
arguments[argument.Key] = argument.Value;
}

return new FunctionCallContent(this.CallId, this.Name, arguments);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.

using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;

Expand All @@ -21,11 +22,18 @@ internal sealed class DurableAgentStateFunctionResultContent : DurableAgentState
public required string CallId { get; init; }

/// <summary>
/// Gets the function result.
/// Gets the function result, encoded as JSON. Absent when the tool returned nothing.
/// </summary>
/// <remarks>
/// Tools created via <c>AIFunctionFactory</c> already marshal their return values into a
/// <see cref="JsonElement"/>. Custom <see cref="AIFunction"/> implementations may return arbitrary
/// objects, and MCP tools return <see cref="AIContent"/> or a collection of it. All of these are
/// encoded here using <see cref="AIJsonUtilities.DefaultOptions"/> so that every result shape is
/// persisted under this single property.
/// </remarks>
[JsonPropertyName("result")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public object? Result { get; init; }
public JsonElement? Result { get; init; }

/// <summary>
/// Creates a <see cref="DurableAgentStateFunctionResultContent"/> from a <see cref="FunctionResultContent"/>.
Expand All @@ -37,13 +45,18 @@ public static DurableAgentStateFunctionResultContent FromFunctionResultContent(F
return new DurableAgentStateFunctionResultContent()
{
CallId = content.CallId,
Result = content.Result

// A null result is left absent rather than encoded as a JSON null so that it round trips
// back to a null FunctionResultContent.Result.
Result = content.Result is null ? null : ToJsonElement(content.Result)
};
}

/// <inheritdoc/>
public override AIContent ToAIContent()
{
// Boxing a JsonElement? yields either a boxed JsonElement or null, matching the shape chat
// clients expect from a tool whose result was marshalled into JSON.
return new FunctionResultContent(this.CallId, this.Result);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Copyright (c) Microsoft. All rights reserved.

using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using Microsoft.Agents.AI.DurableTask.State;
using Microsoft.Extensions.AI;

namespace Microsoft.Agents.AI.DurableTask.Tests.Unit.State;

/// <summary>
/// Regression tests for function call arguments whose values are not plain JSON.
/// See https://github.com/microsoft/agent-framework-durable-extension/issues/33.
/// </summary>
public sealed class DurableAgentStateFunctionCallContentTests
{
private static readonly JsonTypeInfo s_stateContentTypeInfo =
DurableAgentStateJsonContext.Default.GetTypeInfo(typeof(DurableAgentStateContent))!;

private static FunctionCallContent RoundTrip(FunctionCallContent content)
{
DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(content);
string json = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo);

DurableAgentStateContent? deserialized =
(DurableAgentStateContent?)JsonSerializer.Deserialize(json, s_stateContentTypeInfo);

Assert.NotNull(deserialized);
return Assert.IsType<FunctionCallContent>(deserialized.ToAIContent());
}

[Fact]
public void JsonElementArgumentsRoundTrip()
{
// Chat clients parse model supplied arguments into JsonElement values.
JsonElement city = JsonSerializer.SerializeToElement("Seattle");
FunctionCallContent result = RoundTrip(new("call-1", "get_weather", new Dictionary<string, object?>
{
["city"] = city
}));

Assert.Equal("call-1", result.CallId);
Assert.Equal("get_weather", result.Name);
Assert.NotNull(result.Arguments);
Assert.Equal("Seattle", Assert.IsType<JsonElement>(result.Arguments["city"]).GetString());
}

[Fact]
public void ObjectArgumentRoundTrips()
{
// Callers can supply function calls containing arbitrary objects, for example when replaying
// history or resuming a function approval.
FunctionCallContent result = RoundTrip(new("call-2", "get_weather", new Dictionary<string, object?>
{
["location"] = new Location("Seattle", "WA")
}));

JsonElement location = Assert.IsType<JsonElement>(result.Arguments!["location"]);
Assert.Equal("Seattle", location.GetProperty("city").GetString());
Assert.Equal("WA", location.GetProperty("state").GetString());
}

[Fact]
public void BclValueArgumentRoundTrips()
{
// DateOnly is not registered on DurableAgentStateJsonContext, so an object typed argument
// holding one used to fail serialization outright.
FunctionCallContent result = RoundTrip(new("call-3", "get_forecast", new Dictionary<string, object?>
{
["date"] = new DateOnly(2026, 1, 31)
}));

Assert.Equal("2026-01-31", Assert.IsType<JsonElement>(result.Arguments!["date"]).GetString());
}

[Fact]
public void NullArgumentRoundTrips()
{
FunctionCallContent result = RoundTrip(new("call-4", "get_weather", new Dictionary<string, object?>
{
["city"] = null
}));

Assert.Equal(JsonValueKind.Null, Assert.IsType<JsonElement>(result.Arguments!["city"]).ValueKind);
}

[Fact]
public void NoArgumentsRoundTrip()
{
FunctionCallContent result = RoundTrip(new("call-5", "get_time", arguments: null));

Assert.Equal("get_time", result.Name);
Assert.Empty(result.Arguments!);
}

[Fact]
public void PreviouslyPersistedArgumentsAreStillReadable()
{
// State written before arguments were normalized stores the raw JSON under "arguments".
const string LegacyJson =
"""{"$type":"functionCall","arguments":{"city":"Seattle","days":3},"callId":"call-6","name":"get_forecast"}""";

DurableAgentStateContent? deserialized =
(DurableAgentStateContent?)JsonSerializer.Deserialize(LegacyJson, s_stateContentTypeInfo);

Assert.NotNull(deserialized);
FunctionCallContent result = Assert.IsType<FunctionCallContent>(deserialized.ToAIContent());

Assert.Equal("call-6", result.CallId);
Assert.Equal("Seattle", Assert.IsType<JsonElement>(result.Arguments!["city"]).GetString());
Assert.Equal(3, Assert.IsType<JsonElement>(result.Arguments["days"]).GetInt32());
}

private sealed record Location(string City, string State);
}
Loading
Loading