Skip to content
Draft
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 @@ -3,6 +3,7 @@
## [Unreleased]

- Fail durable workflows with a `MaxSuperstepsExceededException` when they reach the configurable `MaxSupersteps` limit with work still queued, instead of returning a successful partial result ([#84](https://github.com/microsoft/agent-framework-durable-extension/pull/84))
- Added passive .NET DTO, converter, validation, and source-generation support for the proposed durable agent state 2.0 contract ([tamirdresher/agent-framework-durable-extension#1](https://github.com/tamirdresher/agent-framework-durable-extension/pull/1))
- Fixed `ConfigureDurableAgents` and `ConfigureDurableWorkflows` ignoring the `workerBuilder` or `clientBuilder` supplied to a later call when no earlier call supplied one, so the Durable Task worker and client are now registered whichever configuration call provides them. The first non-null delegate wins; later ones are still ignored so a builder passed to several calls is only applied once. Registering an agent that a workflow already referenced now promotes it to an explicitly registered agent instead of throwing, so agents and workflows can be configured in either order ([#67](https://github.com/microsoft/agent-framework-durable-extension/pull/67))
- [BREAKING] Fixed `AddWorkflow` silently overwriting an existing workflow registered under the same name, which left the workflow and executor registries inconsistent. Registering a different workflow under a name that is already taken now throws, while re-registering the same workflow instance remains a no-op. An application that registers duplicate workflow names starts today but will now fail at startup ([#66](https://github.com/microsoft/agent-framework-durable-extension/pull/66))
- 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))
Expand Down
9 changes: 9 additions & 0 deletions dotnet/src/Microsoft.Agents.AI.DurableTask/Logs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,15 @@ public static partial void LogTTLExpirationTimeCleared(
this ILogger logger,
AgentSessionId sessionId);

[LoggerMessage(
EventId = 16,
Level = LogLevel.Warning,
Message = "Unknown AI content metadata with runtime type '{RuntimeType}' could not be serialized. The value was omitted from durable state with failure category '{FailureCategory}'.")]
public static partial void LogUnknownContentSerializationFallback(
this ILogger logger,
string runtimeType,
string failureCategory);

// Durable workflow logs (EventIds 100-199)

[LoggerMessage(
Expand Down
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;

namespace Microsoft.Agents.AI.DurableTask.State;
Expand All @@ -10,6 +11,12 @@ namespace Microsoft.Agents.AI.DurableTask.State;
[JsonConverter(typeof(DurableAgentStateJsonConverter))]
internal sealed class DurableAgentState
{
internal const string CurrentSchemaVersion = "1.2.0";
internal const string RevisedSchemaVersion = "2.0.0";
internal const int RevisedSchemaMajorVersion = 2;
private static readonly DurableAgentStateSchemaVersion s_currentSchemaVersion =
DurableAgentStateSchemaVersion.ParseSupported(CurrentSchemaVersion);

/// <summary>
/// Gets the data of the durable agent.
/// </summary>
Expand All @@ -20,8 +27,56 @@ internal sealed class DurableAgentState
/// Gets the schema version of the durable agent state.
/// </summary>
/// <remarks>
/// The version is specified in semver (i.e. "major.minor.patch") format.
/// New states default to <see cref="CurrentSchemaVersion"/>. Deserialization assigns the
/// persisted value through this init-only property, and <see cref="Clone"/> constructs a new
/// state when an older declared version must be promoted for a legacy write. Only exact schema
/// snapshots reviewed by the shared contract are accepted; later versions fail closed.
/// </remarks>
[JsonPropertyName("schemaVersion")]
public string SchemaVersion { get; init; } = "1.1.0";
public string SchemaVersion { get; init; } = CurrentSchemaVersion;

/// <summary>
/// Gets application-defined root extension metadata from the schema's <c>extensionData</c> property.
/// </summary>
[JsonPropertyName("extensionData")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IDictionary<string, JsonElement>? ExtensionData { get; init; }

/// <summary>
/// Gets unknown root properties that are outside the declared schema.
/// </summary>
[JsonExtensionData]
public IDictionary<string, JsonElement>? UnknownProperties { get; set; }

/// <summary>
/// Creates an independent copy suitable for an atomic entity operation.
/// </summary>
public DurableAgentState Clone()
{
byte[] serialized = JsonSerializer.SerializeToUtf8Bytes(
this,
DurableAgentStateJsonContext.Default.DurableAgentState);
DurableAgentState clone = JsonSerializer.Deserialize(
serialized,
DurableAgentStateJsonContext.Default.DurableAgentState)
?? throw new JsonException("The durable agent state could not be cloned.");
DurableAgentStateMessageIdentity.EnsureMessageIds(clone.Data.ConversationHistory);

return new DurableAgentState
{
SchemaVersion = SelectSchemaVersionForWrite(clone.SchemaVersion),
Data = clone.Data,
ExtensionData = clone.ExtensionData,
UnknownProperties = clone.UnknownProperties,
};
}

private static string SelectSchemaVersionForWrite(string schemaVersion)
{
DurableAgentStateSchemaVersion sourceVersion =
DurableAgentStateSchemaVersion.ParseSupported(schemaVersion);
return sourceVersion.CompareTo(s_currentSchemaVersion) < 0
? CurrentSchemaVersion
: schemaVersion;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Copyright (c) Microsoft. All rights reserved.

namespace Microsoft.Agents.AI.DurableTask.State;

/// <summary>
/// Represents a compacted transcript message written by another durable agent implementation.
/// </summary>
/// <remarks>
/// This layer serializes, deserializes, and converts the shared compaction contract. Agent entity
/// replay and retention integration are deferred to later layers.
/// </remarks>
internal sealed class DurableAgentStateCompaction : DurableAgentStateEntry;
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Copyright (c) Microsoft. All rights reserved.

using System.Text.Json;
using System.Text.Json.Serialization;

namespace Microsoft.Agents.AI.DurableTask.State;

/// <summary>
/// Immutable evidence that a correlation completed, retained independently from its result payload.
/// </summary>
internal sealed class DurableAgentStateCompletionReceipt
{
public const string SucceededOutcome = "succeeded";
public const string FailedOutcome = "failed";
public const string AvailableResult = "available";
public const string UnavailableResult = "unavailable";

[JsonPropertyName("correlationId")]
public required string CorrelationId { get; init; }

[JsonPropertyName("outcome")]
public required string Outcome { get; init; }

[JsonPropertyName("completedAt")]
public required DateTimeOffset CompletedAt { get; init; }

[JsonPropertyName("resultState")]
public required string ResultState { get; init; }

[JsonPropertyName("resultExpiresAt")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? ResultExpiresAt { get; init; }

[JsonPropertyName("resultUnavailableAt")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? ResultUnavailableAt { get; init; }

[JsonExtensionData]
public IDictionary<string, JsonElement>? UnknownProperties { get; set; }

public void Validate(string dictionaryKey)
{
DurableAgentStateContract.ValidateIdentifier(dictionaryKey, "completionReceipts key");
DurableAgentStateContract.ValidateIdentifier(this.CorrelationId, "completionReceipts.correlationId");
if (!string.Equals(dictionaryKey, this.CorrelationId, StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"The durable agent state completion receipt key '{dictionaryKey}' does not match correlation ID '{this.CorrelationId}'.");
}

if (this.Outcome is not SucceededOutcome and not FailedOutcome)
{
throw new InvalidOperationException(
$"The durable agent state completion outcome '{this.Outcome}' is not supported.");
}

if (this.CompletedAt == default)
{
throw new InvalidOperationException(
"A durable agent completion receipt requires a completion timestamp.");
}

if (this.ResultState is not AvailableResult and not UnavailableResult)
{
throw new InvalidOperationException(
$"The durable agent state result state '{this.ResultState}' is not supported.");
}

if (this.ResultExpiresAt < this.CompletedAt)
{
throw new InvalidOperationException(
"The durable agent state result expiry cannot precede completion.");
}

if (this.ResultState == AvailableResult && this.ResultUnavailableAt is not null)
{
throw new InvalidOperationException(
"An available durable agent result cannot have an unavailable timestamp.");
}

if (this.ResultState == UnavailableResult &&
(this.ResultUnavailableAt is null || this.ResultUnavailableAt < this.CompletedAt))
{
throw new InvalidOperationException(
"An unavailable durable agent result requires an unavailable timestamp at or after completion.");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;

namespace Microsoft.Agents.AI.DurableTask.State;

Expand Down Expand Up @@ -42,37 +43,57 @@ internal abstract class DurableAgentStateContent
JsonSerializer.SerializeToElement(value: null, jsonTypeInfo: s_objectTypeInfo);

/// <summary>
/// Gets any additional data found during deserialization that does not map to known properties.
/// Gets unknown content properties that are outside the declared schema.
/// </summary>
[JsonExtensionData]
public IDictionary<string, JsonElement>? ExtensionData { get; set; }
public IDictionary<string, JsonElement>? UnknownProperties { get; set; }

/// <summary>
/// Converts this durable agent state content to an <see cref="AIContent"/>.
/// </summary>
/// <returns>A converted <see cref="AIContent"/> instance.</returns>
public abstract AIContent ToAIContent();

/// <summary>
/// Validates semantic constraints introduced by the schema 2.0 contract.
/// </summary>
public virtual void ValidateV2()
{
}

/// <summary>
/// Creates a <see cref="DurableAgentStateContent"/> from an <see cref="AIContent"/>.
/// </summary>
/// <param name="content">The <see cref="AIContent"/> to convert.</param>
/// <param name="logger">The logger used to report safe unknown-content fallbacks.</param>
/// <returns>A <see cref="DurableAgentStateContent"/> representing the original <see cref="AIContent"/>.</returns>
public static DurableAgentStateContent FromAIContent(AIContent content)
public static DurableAgentStateContent FromAIContent(AIContent content, ILogger? logger = null)
=> FromAIContent(content, allowLosslessV2: false, logger);

internal static DurableAgentStateContent FromAIContentV2(AIContent content, ILogger? logger = null)
=> FromAIContent(content, allowLosslessV2: true, logger);

private static DurableAgentStateContent FromAIContent(
AIContent content,
bool allowLosslessV2,
ILogger? logger)
{
return content switch
{
DataContent dataContent => DurableAgentStateDataContent.FromDataContent(dataContent),
ErrorContent errorContent => DurableAgentStateErrorContent.FromErrorContent(errorContent),
FunctionCallContent functionCallContent => DurableAgentStateFunctionCallContent.FromFunctionCallContent(functionCallContent),
FunctionCallContent functionCallContent =>
DurableAgentStateFunctionCallContent.FromFunctionCallContent(
functionCallContent,
allowLosslessV2),
FunctionResultContent functionResultContent => DurableAgentStateFunctionResultContent.FromFunctionResultContent(functionResultContent),
HostedFileContent hostedFileContent => DurableAgentStateHostedFileContent.FromHostedFileContent(hostedFileContent),
HostedVectorStoreContent hostedVectorStoreContent => DurableAgentStateHostedVectorStoreContent.FromHostedVectorStoreContent(hostedVectorStoreContent),
TextContent textContent => DurableAgentStateTextContent.FromTextContent(textContent),
TextReasoningContent textReasoningContent => DurableAgentStateTextReasoningContent.FromTextReasoningContent(textReasoningContent),
UriContent uriContent => DurableAgentStateUriContent.FromUriContent(uriContent),
UsageContent usageContent => DurableAgentStateUsageContent.FromUsageContent(usageContent),
_ => DurableAgentStateUnknownContent.FromUnknownContent(content)
_ => DurableAgentStateUnknownContent.FromUnknownContent(content, logger)
};
}

Expand All @@ -95,7 +116,7 @@ protected static JsonElement ToJsonElement(object? value)
return value switch
{
null => s_nullElement,
JsonElement element => element,
JsonElement element => element.Clone(),
_ => JsonSerializer.SerializeToElement(value: value, jsonTypeInfo: s_objectTypeInfo)
};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright (c) Microsoft. All rights reserved.

namespace Microsoft.Agents.AI.DurableTask.State;

internal static class DurableAgentStateContract
{
public const int MaxIdentifierLength = 256;
public const int MaxMetadataKeyLength = 256;
public const int MaxMetadataStringLength = 16 * 1024;

public static void ValidateIdentifier(string? value, string propertyName)
{
if (string.IsNullOrWhiteSpace(value) ||
value.EnumerateRunes().Take(MaxIdentifierLength + 1).Count() > MaxIdentifierLength ||
value.Any(char.IsControl))
{
throw new InvalidOperationException(
$"The durable agent state '{propertyName}' property must be a non-empty string of at most {MaxIdentifierLength} characters without control characters.");
}
}
}
Loading
Loading