From 100654b71ccf4d2707af33c4ff25a15155cb8bdd Mon Sep 17 00:00:00 2001 From: Tamir Dresher Date: Tue, 8 Sep 2026 17:50:32 +0300 Subject: [PATCH] Preserve durable agent state schema 1.2 compatibility Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 532fe4f5-939b-4962-989d-a1883dccd283 --- .../Microsoft.Agents.AI.DurableTask/Logs.cs | 9 + .../State/DurableAgentState.cs | 57 +- .../State/DurableAgentStateCompaction.cs | 12 + .../State/DurableAgentStateContent.cs | 10 +- .../State/DurableAgentStateData.cs | 37 +- .../State/DurableAgentStateEntry.cs | 18 +- .../State/DurableAgentStateErrorContent.cs | 15 +- .../State/DurableAgentStateErrorResponse.cs | 12 + .../State/DurableAgentStateJsonContext.cs | 3 + .../State/DurableAgentStateJsonConverter.cs | 90 ++- .../State/DurableAgentStateMessage.cs | 46 +- .../State/DurableAgentStateMessageIdentity.cs | 60 ++ .../State/DurableAgentStateRequest.cs | 21 +- .../State/DurableAgentStateResponse.cs | 72 +- .../State/DurableAgentStateSchemaVersion.cs | 111 +++ .../State/DurableAgentStateTruncation.cs | 29 + .../State/DurableAgentStateUnknownContent.cs | 688 +++++++++++++++++- .../State/DurableAgentStateUsage.cs | 40 +- .../State/README.md | 34 +- ...oft.Agents.AI.DurableTask.UnitTests.csproj | 6 + .../State/DurableAgentStateContentTests.cs | 485 +++++++++++- .../State/DurableAgentStateMessageTests.cs | 212 ++++++ .../State/DurableAgentStateResponseTests.cs | 147 +++- .../State/DurableAgentStateTests.cs | 392 +++++++++- schemas/durable-agent-entity-state.json | 103 ++- .../python-durable-agent-state-1.2.json | 168 +++++ 26 files changed, 2776 insertions(+), 101 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateCompaction.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorResponse.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessageIdentity.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateSchemaVersion.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTruncation.cs create mode 100644 schemas/fixtures/python-durable-agent-state-1.2.json diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Logs.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Logs.cs index 2dd1e2a..077df3b 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Logs.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Logs.cs @@ -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( diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentState.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentState.cs index 35aef33..1bcf6fd 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentState.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentState.cs @@ -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; @@ -10,6 +11,10 @@ namespace Microsoft.Agents.AI.DurableTask.State; [JsonConverter(typeof(DurableAgentStateJsonConverter))] internal sealed class DurableAgentState { + internal const string CurrentSchemaVersion = "1.2.0"; + private static readonly DurableAgentStateSchemaVersion s_currentSchemaVersion = + DurableAgentStateSchemaVersion.ParseSupported(CurrentSchemaVersion); + /// /// Gets the data of the durable agent. /// @@ -20,8 +25,56 @@ internal sealed class DurableAgentState /// Gets the schema version of the durable agent state. /// /// - /// The version is specified in semver (i.e. "major.minor.patch") format. + /// New states default to . Deserialization assigns the + /// persisted value through this init-only property, and constructs a new + /// state when an older compatible version must be promoted for a write. Future compatible + /// versions are preserved rather than rewritten. /// [JsonPropertyName("schemaVersion")] - public string SchemaVersion { get; init; } = "1.1.0"; + public string SchemaVersion { get; init; } = CurrentSchemaVersion; + + /// + /// Gets application-defined root extension metadata from the schema's extensionData property. + /// + [JsonPropertyName("extensionData")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? ExtensionData { get; init; } + + /// + /// Gets unknown root properties that are outside the declared schema. + /// + [JsonExtensionData] + public IDictionary? UnknownProperties { get; set; } + + /// + /// Creates an independent copy suitable for an atomic entity operation. + /// + 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; + } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateCompaction.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateCompaction.cs new file mode 100644 index 0000000..578ae38 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateCompaction.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Represents a compacted transcript message written by another durable agent implementation. +/// +/// +/// This layer serializes, deserializes, and converts the shared compaction contract. Agent entity +/// replay and retention integration are deferred to later layers. +/// +internal sealed class DurableAgentStateCompaction : DurableAgentStateEntry; diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContent.cs index 3ae7d12..015e5f4 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContent.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContent.cs @@ -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; @@ -42,10 +43,10 @@ internal abstract class DurableAgentStateContent JsonSerializer.SerializeToElement(value: null, jsonTypeInfo: s_objectTypeInfo); /// - /// Gets any additional data found during deserialization that does not map to known properties. + /// Gets unknown content properties that are outside the declared schema. /// [JsonExtensionData] - public IDictionary? ExtensionData { get; set; } + public IDictionary? UnknownProperties { get; set; } /// /// Converts this durable agent state content to an . @@ -57,8 +58,9 @@ internal abstract class DurableAgentStateContent /// Creates a from an . /// /// The to convert. + /// The logger used to report safe unknown-content fallbacks. /// A representing the original . - public static DurableAgentStateContent FromAIContent(AIContent content) + public static DurableAgentStateContent FromAIContent(AIContent content, ILogger? logger = null) { return content switch { @@ -72,7 +74,7 @@ public static DurableAgentStateContent FromAIContent(AIContent content) TextReasoningContent textReasoningContent => DurableAgentStateTextReasoningContent.FromTextReasoningContent(textReasoningContent), UriContent uriContent => DurableAgentStateUriContent.FromUriContent(uriContent), UsageContent usageContent => DurableAgentStateUsageContent.FromUsageContent(usageContent), - _ => DurableAgentStateUnknownContent.FromUnknownContent(content) + _ => DurableAgentStateUnknownContent.FromUnknownContent(content, logger) }; } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateData.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateData.cs index 745f619..5476176 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateData.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateData.cs @@ -17,16 +17,49 @@ internal sealed class DurableAgentStateData [JsonPropertyName("conversationHistory")] public IList ConversationHistory { get; init; } = []; + /// + /// Gets or sets the serialized inner agent session. + /// + [JsonPropertyName("session")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Session { get; set; } + + /// + /// Gets or sets the highest workflow conversation position ingested from each executor. + /// + /// + /// The .NET workflow path does not populate these watermarks yet, but they are preserved for + /// cross-language schema compatibility. + /// + [JsonPropertyName("ingestedPositions")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? IngestedPositions { get; set; } + + /// + /// Gets or sets bounded evidence that retention removed conversation messages. + /// + [JsonPropertyName("truncation")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DurableAgentStateTruncation? Truncation { get; set; } + /// /// Gets or sets the expiration time (UTC) for this agent entity. /// If the entity is idle beyond this time, it will be automatically deleted. /// [JsonPropertyName("expirationTimeUtc")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public DateTime? ExpirationTimeUtc { get; set; } /// - /// Gets any additional data found during deserialization that does not map to known properties. + /// Gets application-defined data-level metadata from the schema's extensionData property. + /// + [JsonPropertyName("extensionData")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? ExtensionData { get; init; } + + /// + /// Gets unknown data properties that are outside the declared schema. /// [JsonExtensionData] - public IDictionary? ExtensionData { get; set; } + public IDictionary? UnknownProperties { get; set; } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateEntry.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateEntry.cs index 2f04c90..dc75542 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateEntry.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateEntry.cs @@ -12,6 +12,8 @@ namespace Microsoft.Agents.AI.DurableTask.State; [JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] [JsonDerivedType(typeof(DurableAgentStateRequest), "request")] [JsonDerivedType(typeof(DurableAgentStateResponse), "response")] +[JsonDerivedType(typeof(DurableAgentStateErrorResponse), "errorResponse")] +[JsonDerivedType(typeof(DurableAgentStateCompaction), "compaction")] internal abstract class DurableAgentStateEntry { /// @@ -19,10 +21,11 @@ internal abstract class DurableAgentStateEntry /// /// /// This ID is used to correlate back to its - /// . + /// . Compaction entries do not have a correlation ID. /// [JsonPropertyName("correlationId")] - public required string CorrelationId { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? CorrelationId { get; init; } /// /// Gets the timestamp when this entry was created. @@ -37,8 +40,15 @@ internal abstract class DurableAgentStateEntry public IReadOnlyList Messages { get; init; } = []; /// - /// Gets any additional data found during deserialization that does not map to known properties. + /// Gets application-defined entry metadata from the schema's extensionData property. + /// + [JsonPropertyName("extensionData")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? ExtensionData { get; init; } + + /// + /// Gets unknown entry properties that are outside the declared schema. /// [JsonExtensionData] - public IDictionary? ExtensionData { get; set; } + public IDictionary? UnknownProperties { get; set; } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorContent.cs index 17e5fea..833e4e5 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorContent.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorContent.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.AI; @@ -29,7 +30,7 @@ internal sealed class DurableAgentStateErrorContent : DurableAgentStateContent /// [JsonPropertyName("details")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Details { get; init; } + public JsonElement? Details { get; init; } /// /// Creates a from an . @@ -41,7 +42,11 @@ public static DurableAgentStateErrorContent FromErrorContent(ErrorContent conten { return new DurableAgentStateErrorContent() { - Details = content.Details, + Details = content.Details is null + ? null + : JsonSerializer.SerializeToElement( + content.Details, + DurableAgentStateJsonContext.Default.String), ErrorCode = content.ErrorCode, Message = content.Message }; @@ -52,7 +57,11 @@ public override AIContent ToAIContent() { return new ErrorContent(this.Message) { - Details = this.Details, + Details = this.Details is JsonElement details + ? details.ValueKind == JsonValueKind.String + ? details.GetString() + : details.GetRawText() + : null, ErrorCode = this.ErrorCode }; } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorResponse.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorResponse.cs new file mode 100644 index 0000000..bb5742c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorResponse.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Represents a failed turn recorded by another durable agent implementation. +/// +/// +/// .NET durable agents do not currently create pollable error responses, but preserve this shared-schema +/// entry kind when reading state written by another language implementation. +/// +internal sealed class DurableAgentStateErrorResponse : DurableAgentStateResponse; diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonContext.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonContext.cs index 4ad9a62..87a8dc5 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonContext.cs @@ -11,7 +11,10 @@ namespace Microsoft.Agents.AI.DurableTask.State; [JsonSerializable(typeof(DurableAgentStateContent))] [JsonSerializable(typeof(DurableAgentStateData))] [JsonSerializable(typeof(DurableAgentStateEntry))] +[JsonSerializable(typeof(DurableAgentStateErrorResponse))] +[JsonSerializable(typeof(DurableAgentStateCompaction))] [JsonSerializable(typeof(DurableAgentStateMessage))] +[JsonSerializable(typeof(DurableAgentStateTruncation))] // Function call and result content [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(IDictionary))] diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs index 4c7796b..17fd8d3 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs @@ -12,6 +12,7 @@ internal sealed class DurableAgentStateJsonConverter : JsonConverter public override DurableAgentState? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) @@ -30,15 +31,10 @@ internal sealed class DurableAgentStateJsonConverter : JsonConverter? extensionData = + element.Value.TryGetProperty(ExtensionDataPropertyName, out JsonElement extensionDataElement) + ? ReadExtensionData(extensionDataElement) + : null; + Dictionary? unknownProperties = null; + foreach (JsonProperty property in element.Value.EnumerateObject()) + { + if (property.NameEquals(SchemaVersionPropertyName) || + property.NameEquals(DataPropertyName) || + property.NameEquals(ExtensionDataPropertyName)) + { + continue; + } + + unknownProperties ??= []; + unknownProperties[property.Name] = property.Value.Clone(); + } return new DurableAgentState { - SchemaVersion = schemaVersion.ToString(), - Data = data ?? new DurableAgentStateData() + SchemaVersion = schemaVersionText!, + Data = data ?? new DurableAgentStateData(), + ExtensionData = extensionData, + UnknownProperties = unknownProperties, }; } /// public override void Write(Utf8JsonWriter writer, DurableAgentState value, JsonSerializerOptions options) { + _ = DurableAgentStateSchemaVersion.ParseSupported(value.SchemaVersion); + writer.WriteStartObject(); writer.WritePropertyName(SchemaVersionPropertyName); writer.WriteStringValue(value.SchemaVersion); @@ -66,6 +83,57 @@ public override void Write(Utf8JsonWriter writer, DurableAgentState value, JsonS writer, value.Data, DurableAgentStateJsonContext.Default.DurableAgentStateData); + if (value.ExtensionData is not null) + { + writer.WritePropertyName(ExtensionDataPropertyName); + WriteExtensionData(writer, value.ExtensionData); + } + + if (value.UnknownProperties is not null) + { + foreach ((string propertyName, JsonElement propertyValue) in value.UnknownProperties) + { + if (propertyName is not SchemaVersionPropertyName and + not DataPropertyName and + not ExtensionDataPropertyName) + { + writer.WritePropertyName(propertyName); + propertyValue.WriteTo(writer); + } + } + } + + writer.WriteEndObject(); + } + + private static Dictionary? ReadExtensionData(JsonElement element) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + + if (element.ValueKind != JsonValueKind.Object) + { + throw new JsonException("The durable agent state 'extensionData' property must be an object."); + } + + return element.EnumerateObject().ToDictionary( + property => property.Name, + property => property.Value.Clone()); + } + + private static void WriteExtensionData( + Utf8JsonWriter writer, + IDictionary extensionData) + { + writer.WriteStartObject(); + foreach ((string propertyName, JsonElement propertyValue) in extensionData) + { + writer.WritePropertyName(propertyName); + propertyValue.WriteTo(writer); + } + writer.WriteEndObject(); } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs index 294453c..3810486 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs @@ -3,6 +3,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; namespace Microsoft.Agents.AI.DurableTask.State; @@ -25,6 +26,20 @@ internal sealed class DurableAgentStateMessage [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public DateTimeOffset? CreatedAt { get; init; } + /// + /// Gets the stable message identifier. + /// + [JsonPropertyName("messageId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? MessageId { get; set; } + + /// + /// Gets message-level additional properties from the schema's extensionData property. + /// + [JsonPropertyName("extensionData")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? AdditionalProperties { get; init; } + /// /// Gets the contents of this message. /// @@ -38,24 +53,39 @@ internal sealed class DurableAgentStateMessage public required string Role { get; init; } /// - /// Gets any additional data found during deserialization that does not map to known properties. + /// Gets unknown message properties that are outside the declared schema. /// [JsonExtensionData] - public IDictionary? ExtensionData { get; set; } + public IDictionary? UnknownProperties { get; set; } /// /// Creates a from a . /// /// The to convert. + /// The stable identifier to use when the message does not already have one. + /// The logger used to report safe unknown-content fallbacks. /// A representing the original message. - public static DurableAgentStateMessage FromChatMessage(ChatMessage message) + public static DurableAgentStateMessage FromChatMessage( + ChatMessage message, + string? generatedMessageId = null, + ILogger? logger = null) { + Dictionary? additionalProperties = message.AdditionalProperties? + .ToDictionary( + pair => pair.Key, + pair => JsonSerializer.SerializeToElement( + pair.Value, + DurableAgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object)))); + return new DurableAgentStateMessage() { CreatedAt = message.CreatedAt, AuthorName = message.AuthorName, + MessageId = message.MessageId ?? generatedMessageId, + AdditionalProperties = additionalProperties, Role = message.Role.ToString(), - Contents = message.Contents.Select(DurableAgentStateContent.FromAIContent).ToList() + Contents = message.Contents.Select(content => + DurableAgentStateContent.FromAIContent(content, logger)).ToList() }; } @@ -65,10 +95,18 @@ public static DurableAgentStateMessage FromChatMessage(ChatMessage message) /// A representing this message. public ChatMessage ToChatMessage() { + AdditionalPropertiesDictionary? additionalProperties = this.AdditionalProperties is null + ? null + : new AdditionalPropertiesDictionary( + this.AdditionalProperties.Select(pair => + new KeyValuePair(pair.Key, pair.Value))); + return new ChatMessage() { CreatedAt = this.CreatedAt, AuthorName = this.AuthorName, + MessageId = this.MessageId, + AdditionalProperties = additionalProperties, Contents = this.Contents.Select(c => c.ToAIContent()).ToList(), Role = new(this.Role) }; diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessageIdentity.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessageIdentity.cs new file mode 100644 index 0000000..2b9e03a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessageIdentity.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Globalization; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Assigns deterministic identities to durable messages that predate schema 1.2. +/// +internal static class DurableAgentStateMessageIdentity +{ + public static void EnsureMessageIds(IEnumerable history) + { + foreach (DurableAgentStateEntry entry in history) + { + string entryType = entry switch + { + DurableAgentStateErrorResponse => "errorResponse", + DurableAgentStateRequest => "request", + DurableAgentStateResponse => "response", + DurableAgentStateCompaction => "compaction", + _ => throw new InvalidOperationException( + $"Unsupported durable agent state entry type '{entry.GetType()}'."), + }; + + for (int index = 0; index < entry.Messages.Count; index++) + { + DurableAgentStateMessage message = entry.Messages[index]; + if (message.MessageId is null) + { + message.MessageId = Create(entryType, entry.CorrelationId, entry.CreatedAt, index); + } + } + } + } + + public static string Create( + string entryType, + string? correlationId, + DateTimeOffset createdAt, + int storedIndex) + { + string scope = string.IsNullOrEmpty(correlationId) + ? FormatPythonIsoTimestamp(createdAt) + : correlationId; + return $"durable_{entryType}_{scope}_{storedIndex}"; + } + + internal static string FormatPythonIsoTimestamp(DateTimeOffset timestamp) + { + long microseconds = timestamp.Ticks % TimeSpan.TicksPerSecond / 10; + string fraction = microseconds == 0 + ? string.Empty + : $".{microseconds.ToString("D6", CultureInfo.InvariantCulture)}"; + return string.Concat( + timestamp.ToString("yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture), + fraction, + timestamp.ToString("zzz", CultureInfo.InvariantCulture)); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateRequest.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateRequest.cs index 6349b97..863e7fa 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateRequest.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateRequest.cs @@ -3,6 +3,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; namespace Microsoft.Agents.AI.DurableTask.State; @@ -15,6 +16,7 @@ internal sealed class DurableAgentStateRequest : DurableAgentStateEntry /// Gets the ID of the orchestration that initiated this request (if any). /// [JsonPropertyName("orchestrationId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? OrchestrationId { get; init; } /// @@ -24,6 +26,7 @@ internal sealed class DurableAgentStateRequest : DurableAgentStateEntry /// If omitted, the expectation is that the agent will respond in plain text. /// [JsonPropertyName("responseType")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? ResponseType { get; init; } /// @@ -41,15 +44,27 @@ internal sealed class DurableAgentStateRequest : DurableAgentStateEntry /// Creates a from a . /// /// The to convert. + /// The logger used to report safe unknown-content fallbacks. /// A representing the original request. - public static DurableAgentStateRequest FromRunRequest(RunRequest request) + public static DurableAgentStateRequest FromRunRequest( + RunRequest request, + ILogger? logger = null) { + DateTimeOffset createdAt = request.Messages.Min(m => m.CreatedAt) ?? DateTimeOffset.UtcNow; return new DurableAgentStateRequest() { CorrelationId = request.CorrelationId, OrchestrationId = request.OrchestrationId, - Messages = request.Messages.Select(DurableAgentStateMessage.FromChatMessage).ToList(), - CreatedAt = request.Messages.Min(m => m.CreatedAt) ?? DateTimeOffset.UtcNow, + Messages = request.Messages.Select( + (message, index) => DurableAgentStateMessage.FromChatMessage( + message, + DurableAgentStateMessageIdentity.Create( + "request", + request.CorrelationId, + createdAt, + index), + logger)).ToList(), + CreatedAt = createdAt, ResponseType = request.ResponseFormat is ChatResponseFormatJson ? "json" : "text", ResponseSchema = (request.ResponseFormat as ChatResponseFormatJson)?.Schema }; diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateResponse.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateResponse.cs index fb9f23d..5114953 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateResponse.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateResponse.cs @@ -2,13 +2,14 @@ using System.Text.Json.Serialization; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; namespace Microsoft.Agents.AI.DurableTask.State; /// /// Represents a durable agent state entry that is a response from the agent. /// -internal sealed class DurableAgentStateResponse : DurableAgentStateEntry +internal class DurableAgentStateResponse : DurableAgentStateEntry { /// /// Gets the usage details for this state response. @@ -22,21 +23,42 @@ internal sealed class DurableAgentStateResponse : DurableAgentStateEntry /// /// The correlation ID linking this response to its request. /// The to convert. + /// The logger used to report safe unknown-content fallbacks. /// A representing the original response. - public static DurableAgentStateResponse FromResponse(string correlationId, AgentResponse response) + public static DurableAgentStateResponse FromResponse( + string correlationId, + AgentResponse response, + ILogger? logger = null) { + List messages = response.Messages.ToList(); + DateTimeOffset createdAt = response.CreatedAt ?? GetCreatedAt(messages); return new DurableAgentStateResponse() { CorrelationId = correlationId, - CreatedAt = response.CreatedAt ?? response.Messages.Max(m => m.CreatedAt) ?? DateTimeOffset.UtcNow, - Messages = response.Messages - .Where(HasSerializableContent) - .Select(DurableAgentStateMessage.FromChatMessage) - .ToList(), + CreatedAt = createdAt, + Messages = CreateStoredMessages(messages, correlationId, createdAt, logger), Usage = DurableAgentStateUsage.FromUsage(response.Usage) }; } + /// + /// Creates a response entry from response messages before aggregate response metadata is available. + /// + public static DurableAgentStateResponse FromMessages( + string correlationId, + IEnumerable messages, + ILogger? logger = null) + { + List messageList = messages.ToList(); + DateTimeOffset createdAt = GetCreatedAt(messageList); + return new DurableAgentStateResponse() + { + CorrelationId = correlationId, + CreatedAt = createdAt, + Messages = CreateStoredMessages(messageList, correlationId, createdAt, logger), + }; + } + /// /// Converts this back to an . /// @@ -51,17 +73,31 @@ public AgentResponse ToResponse() }; } - // Checks whether a ChatMessage has any content that will produce meaningful serialized data. - // Known derived AIContent types (TextContent, FunctionCallContent, etc.) are always serializable. - // Base AIContent instances only carry RawRepresentation (which is [JsonIgnore]), Annotations, and - // AdditionalProperties. We keep the message if any base AIContent has annotations or additional - // properties set. NOTE: if AIContent gains new serializable properties in the future, this check - // should be updated accordingly. - private static bool HasSerializableContent(ChatMessage message) + private static List CreateStoredMessages( + IEnumerable messages, + string correlationId, + DateTimeOffset createdAt, + ILogger? logger) + { + return messages + .Select((message, storedIndex) => DurableAgentStateMessage.FromChatMessage( + message, + DurableAgentStateMessageIdentity.Create( + "response", + correlationId, + createdAt, + storedIndex), + logger)) + .ToList(); + } + + private static DateTimeOffset GetCreatedAt(IReadOnlyList messages) { - return message.Contents.Any(c => - c.GetType() != typeof(AIContent) || - c.Annotations?.Count > 0 || - c.AdditionalProperties?.Count > 0); + return messages + .Select(message => message.CreatedAt) + .Where(createdAt => createdAt.HasValue) + .Select(createdAt => createdAt!.Value) + .DefaultIfEmpty(DateTimeOffset.UtcNow) + .Max(); } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateSchemaVersion.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateSchemaVersion.cs new file mode 100644 index 0000000..d156ca5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateSchemaVersion.cs @@ -0,0 +1,111 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Numerics; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Centralizes strict parsing, supported-major validation, and ordering for durable state versions. +/// +internal readonly record struct DurableAgentStateSchemaVersion(BigInteger Major, BigInteger Minor, BigInteger Patch) + : IComparable +{ + private const int SupportedMajorVersion = 1; + + /// + /// Parses and validates a supported durable agent state schema version. + /// + public static DurableAgentStateSchemaVersion ParseSupported(string? value) + { + if (!TryParse(value, out DurableAgentStateSchemaVersion version)) + { + throw new InvalidOperationException("The durable agent state has an invalid 'schemaVersion' property."); + } + + if (version.Major != SupportedMajorVersion) + { + throw new InvalidOperationException($"The durable agent state schema version '{value}' is not supported."); + } + + return version; + } + + /// + /// Parses the schema's strict numeric major.minor.patch grammar. + /// + public static bool TryParse(string? value, out DurableAgentStateSchemaVersion version) + { + version = default; + if (string.IsNullOrEmpty(value)) + { + return false; + } + + ReadOnlySpan remaining = value.AsSpan(); + if (!TryReadComponent(ref remaining, out BigInteger major) || + !TryReadComponent(ref remaining, out BigInteger minor) || + !TryReadFinalComponent(remaining, out BigInteger patch)) + { + return false; + } + + version = new(major, minor, patch); + return true; + } + + /// + public int CompareTo(DurableAgentStateSchemaVersion other) + { + int majorComparison = this.Major.CompareTo(other.Major); + if (majorComparison != 0) + { + return majorComparison; + } + + int minorComparison = this.Minor.CompareTo(other.Minor); + return minorComparison != 0 + ? minorComparison + : this.Patch.CompareTo(other.Patch); + } + + private static bool TryReadComponent(ref ReadOnlySpan value, out BigInteger component) + { + int separatorIndex = value.IndexOf('.'); + if (separatorIndex <= 0 || + !TryParseNumericIdentifier(value[..separatorIndex], out component)) + { + component = default; + return false; + } + + value = value[(separatorIndex + 1)..]; + return true; + } + + private static bool TryReadFinalComponent(ReadOnlySpan value, out BigInteger component) + { + component = default; + return value.IndexOf('.') < 0 && TryParseNumericIdentifier(value, out component); + } + + private static bool TryParseNumericIdentifier(ReadOnlySpan value, out BigInteger component) + { + component = 0; + if (value.IsEmpty || (value.Length > 1 && value[0] == '0')) + { + return false; + } + + foreach (char character in value) + { + if (character is < '0' or > '9') + { + return false; + } + + component = (component * 10) + (character - '0'); + } + + return true; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTruncation.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTruncation.cs new file mode 100644 index 0000000..c71b9d5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTruncation.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Bounded evidence that durable conversation entries were removed by retention. +/// +internal sealed class DurableAgentStateTruncation +{ + /// + /// Gets or sets the total number of messages removed over the lifetime of the session. + /// + [JsonPropertyName("evictedMessageCount")] + public int EvictedMessageCount { get; set; } + + /// + /// Gets or sets when the first eviction occurred. + /// + [JsonPropertyName("firstEvictedAt")] + public DateTimeOffset FirstEvictedAt { get; set; } + + /// + /// Gets or sets when the latest eviction occurred. + /// + [JsonPropertyName("lastEvictedAt")] + public DateTimeOffset LastEvictedAt { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUnknownContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUnknownContent.cs index 00a180b..05bd4e4 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUnknownContent.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUnknownContent.cs @@ -1,8 +1,12 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Runtime.InteropServices; using System.Text.Json; +using System.Text.Json.Nodes; 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; @@ -11,6 +15,19 @@ namespace Microsoft.Agents.AI.DurableTask.State; /// internal sealed class DurableAgentStateUnknownContent : DurableAgentStateContent { + private const string DurableEnvelopePropertyName = "$microsoftAgentFrameworkDurableTask"; + private const string KindPropertyName = "kind"; + private const string VersionPropertyName = "version"; + private const string AnnotationsPropertyName = "annotations"; + private const string AdditionalPropertiesPropertyName = "additionalProperties"; + private const string RawRepresentationPropertyName = "rawRepresentation"; + private const string AnnotatedRegionsPropertyName = "annotatedRegions"; + private const string OmittedPropertyName = "omitted"; + private const string UnknownContentKind = "unknownAIContent"; + private const int DurableEnvelopeVersion = 1; + + private static readonly JsonElement s_minimalUnknownContent = CreateMinimalUnknownContent(); + /// /// Gets the serialized unknown content. /// @@ -21,23 +38,678 @@ internal sealed class DurableAgentStateUnknownContent : DurableAgentStateContent /// Creates a from an . /// /// The to convert. + /// The logger used to report safe serialization fallbacks. /// A representing the original content. - public static DurableAgentStateUnknownContent FromUnknownContent(AIContent content) + public static DurableAgentStateUnknownContent FromUnknownContent( + AIContent content, + ILogger? logger = null) { - return new DurableAgentStateUnknownContent() + ArgumentNullException.ThrowIfNull(content); + + if (TryGetOpaqueContent(content, logger, out JsonElement opaqueContent)) + { + return new DurableAgentStateUnknownContent { Content = opaqueContent }; + } + + JsonObject envelope = CreateEnvelope(UnknownContentKind); + JsonObject omissions = []; + + AddAnnotations(content.Annotations, envelope, omissions, logger); + AddAdditionalProperties(content.AdditionalProperties, envelope, omissions, logger); + AddRawRepresentation(content.RawRepresentation, envelope, omissions, logger); + + if (omissions.Count > 0) + { + envelope[OmittedPropertyName] = omissions; + } + + return new DurableAgentStateUnknownContent { - Content = JsonSerializer.SerializeToElement( - value: content, - jsonTypeInfo: AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AIContent))) + Content = SerializeEnvelope(envelope, content, logger), }; } /// public override AIContent ToAIContent() { - AIContent? content = this.Content.Deserialize( - jsonTypeInfo: AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AIContent))) as AIContent; + if (TryGetEnvelope(this.Content, out JsonElement envelope, out string? kind) && + kind == UnknownContentKind && + TryReadUnknownContent(envelope, out AIContent unknownContent)) + { + return unknownContent; + } + + return CreateOpaqueAIContent(this.Content); + } + + private static JsonObject CreateEnvelope(string kind) + { + return new JsonObject + { + [KindPropertyName] = kind, + [VersionPropertyName] = DurableEnvelopeVersion, + }; + } + + private static JsonElement CreateMinimalUnknownContent() + { + JsonObject root = new() + { + [DurableEnvelopePropertyName] = CreateEnvelope(UnknownContentKind), + }; + return JsonSerializer.SerializeToElement( + root, + DurableAgentStateJsonContext.Default.JsonObject); + } + + private static JsonElement SerializeEnvelope( + JsonObject envelope, + AIContent source, + ILogger? logger) + { + JsonObject root = new() + { + [DurableEnvelopePropertyName] = envelope, + }; + + try + { + return JsonSerializer.SerializeToElement( + root, + DurableAgentStateJsonContext.Default.JsonObject); + } + catch (Exception exception) when (IsRecoverableSerializationFailure(exception)) + { + LogSerializationFallback(logger, source, exception); + return s_minimalUnknownContent.Clone(); + } + } + + private static bool TryGetOpaqueContent( + AIContent content, + ILogger? logger, + out JsonElement opaqueContent) + { + opaqueContent = default; + try + { + if (content.GetType() != typeof(AIContent) || + content.Annotations is { Count: > 0 } || + content.RawRepresentation is not JsonElement rawRepresentation || + content.AdditionalProperties is not { Count: 1 } additionalProperties || + !additionalProperties.TryGetValue("content", out object? storedContent) || + storedContent is not JsonElement storedElement) + { + return false; + } + + if (rawRepresentation.GetRawText() != storedElement.GetRawText()) + { + return false; + } + + opaqueContent = rawRepresentation.Clone(); + return true; + } + catch (Exception exception) when (IsRecoverableSerializationFailure(exception)) + { + LogSerializationFallback(logger, content, exception); + return false; + } + } + + private static void AddAnnotations( + IList? annotations, + JsonObject envelope, + JsonObject omissions, + ILogger? logger) + { + if (annotations is null) + { + return; + } + + if (!TryGetCount(annotations, logger, out int count)) + { + omissions[AnnotationsPropertyName] = true; + return; + } + + JsonArray projection = []; + int omittedCount = 0; + for (int index = 0; index < count; index++) + { + if (!TryGetItem(annotations, index, logger, out AIAnnotation? annotation) || + annotation is null) + { + omittedCount++; + continue; + } + + projection.Add((JsonNode)CreateAnnotationProjection(annotation, logger)); + } + + if (projection.Count > 0) + { + envelope[AnnotationsPropertyName] = projection; + } + + if (omittedCount > 0) + { + omissions[AnnotationsPropertyName] = omittedCount; + } + } + + private static JsonObject CreateAnnotationProjection( + AIAnnotation annotation, + ILogger? logger) + { + JsonObject projection = []; + JsonObject omissions = []; + + AddAdditionalProperties( + annotation.AdditionalProperties, + projection, + omissions, + logger); + AddAnnotatedRegions( + annotation.AnnotatedRegions, + projection, + omissions, + logger); + AddRawRepresentation( + annotation.RawRepresentation, + projection, + omissions, + logger); + + if (omissions.Count > 0) + { + projection[OmittedPropertyName] = omissions; + } + + return projection; + } + + private static void AddAdditionalProperties( + AdditionalPropertiesDictionary? additionalProperties, + JsonObject projection, + JsonObject omissions, + ILogger? logger) + { + if (additionalProperties is null) + { + return; + } + + KeyValuePair[] entries; + try + { + entries = [.. additionalProperties]; + } + catch (Exception exception) when (IsRecoverableSerializationFailure(exception)) + { + LogSerializationFallback(logger, additionalProperties, exception); + omissions[AdditionalPropertiesPropertyName] = true; + return; + } + + JsonObject projectedProperties = []; + int omittedCount = 0; + foreach ((string key, object? value) in entries) + { + if (TryConvertToJsonNode(value, logger, out JsonNode? jsonValue)) + { + projectedProperties[key] = jsonValue; + } + else + { + omittedCount++; + } + } + + if (projectedProperties.Count > 0) + { + projection[AdditionalPropertiesPropertyName] = projectedProperties; + } + + if (omittedCount > 0) + { + omissions[AdditionalPropertiesPropertyName] = omittedCount; + } + } + + private static void AddAnnotatedRegions( + IList? annotatedRegions, + JsonObject projection, + JsonObject omissions, + ILogger? logger) + { + if (annotatedRegions is null) + { + return; + } + + if (!TryGetCount(annotatedRegions, logger, out int count)) + { + omissions[AnnotatedRegionsPropertyName] = true; + return; + } + + JsonArray projectedRegions = []; + int omittedCount = 0; + for (int index = 0; index < count; index++) + { + if (!TryGetItem(annotatedRegions, index, logger, out AnnotatedRegion? region) || + region is null || + !TryConvertToJsonNode(region, logger, out JsonNode? jsonValue)) + { + omittedCount++; + continue; + } + + projectedRegions.Add(jsonValue); + } + + if (projectedRegions.Count > 0) + { + projection[AnnotatedRegionsPropertyName] = projectedRegions; + } + + if (omittedCount > 0) + { + omissions[AnnotatedRegionsPropertyName] = omittedCount; + } + } + + private static void AddRawRepresentation( + object? rawRepresentation, + JsonObject projection, + JsonObject omissions, + ILogger? logger) + { + if (rawRepresentation is null) + { + return; + } + + if (TryConvertToJsonNode(rawRepresentation, logger, out JsonNode? jsonValue)) + { + projection[RawRepresentationPropertyName] = jsonValue; + } + else + { + omissions[RawRepresentationPropertyName] = true; + } + } + + private static bool TryConvertToJsonNode( + object? value, + ILogger? logger, + out JsonNode? jsonValue) + { + try + { + JsonElement element = ToJsonElement(value).Clone(); + jsonValue = ToJsonNode(element); + return true; + } + catch (Exception exception) when (IsRecoverableSerializationFailure(exception)) + { + LogSerializationFallback(logger, value, exception); + jsonValue = null; + return false; + } + } + + private static bool TryGetCount( + IList values, + ILogger? logger, + out int count) + { + try + { + count = values.Count; + return true; + } + catch (Exception exception) when (IsRecoverableSerializationFailure(exception)) + { + LogSerializationFallback(logger, values, exception); + count = 0; + return false; + } + } + + private static bool TryGetItem( + IList values, + int index, + ILogger? logger, + out T? value) + { + try + { + value = values[index]; + return true; + } + catch (Exception exception) when (IsRecoverableSerializationFailure(exception)) + { + LogSerializationFallback(logger, values, exception); + value = default; + return false; + } + } + + private static JsonNode? ToJsonNode(JsonElement element) + { + return JsonNode.Parse(element.GetRawText()); + } + + private static bool TryGetEnvelope( + JsonElement content, + out JsonElement envelope, + out string? kind) + { + envelope = default; + kind = null; + if (content.ValueKind != JsonValueKind.Object || + !HasExactlyOneProperty(content, DurableEnvelopePropertyName) || + !content.TryGetProperty(DurableEnvelopePropertyName, out envelope) || + envelope.ValueKind != JsonValueKind.Object || + !envelope.TryGetProperty(KindPropertyName, out JsonElement kindElement) || + kindElement.ValueKind != JsonValueKind.String || + !envelope.TryGetProperty(VersionPropertyName, out JsonElement versionElement) || + versionElement.ValueKind != JsonValueKind.Number || + !versionElement.TryGetInt32(out int version) || + version != DurableEnvelopeVersion) + { + return false; + } + + kind = kindElement.GetString(); + return kind is not null; + } + + private static bool TryReadUnknownContent( + JsonElement envelope, + out AIContent content) + { + content = null!; + if (!HasOnlyProperties( + envelope, + KindPropertyName, + VersionPropertyName, + AnnotationsPropertyName, + AdditionalPropertiesPropertyName, + RawRepresentationPropertyName, + OmittedPropertyName) || + !TryReadAnnotations(envelope, out List? annotations) || + !TryReadAdditionalProperties( + envelope, + out AdditionalPropertiesDictionary? additionalProperties) || + !TryReadRawRepresentation(envelope, out object? rawRepresentation) || + !HasValidOmissions(envelope)) + { + return false; + } + + content = new AIContent + { + Annotations = annotations, + AdditionalProperties = additionalProperties, + RawRepresentation = rawRepresentation, + }; + return true; + } + + private static bool TryReadAnnotations( + JsonElement envelope, + out List? annotations) + { + annotations = null; + if (!envelope.TryGetProperty(AnnotationsPropertyName, out JsonElement annotationsElement)) + { + return true; + } + + if (annotationsElement.ValueKind != JsonValueKind.Array) + { + return false; + } + + List result = []; + JsonTypeInfo regionTypeInfo = + AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AnnotatedRegion)); + foreach (JsonElement annotationElement in annotationsElement.EnumerateArray()) + { + if (annotationElement.ValueKind != JsonValueKind.Object || + !HasOnlyProperties( + annotationElement, + AdditionalPropertiesPropertyName, + AnnotatedRegionsPropertyName, + RawRepresentationPropertyName, + OmittedPropertyName) || + !TryReadAdditionalProperties( + annotationElement, + out AdditionalPropertiesDictionary? additionalProperties) || + !TryReadRawRepresentation(annotationElement, out object? rawRepresentation) || + !HasValidOmissions(annotationElement)) + { + return false; + } + + List? regions = null; + if (annotationElement.TryGetProperty( + AnnotatedRegionsPropertyName, + out JsonElement regionsElement)) + { + if (regionsElement.ValueKind != JsonValueKind.Array) + { + return false; + } + + regions = []; + foreach (JsonElement regionElement in regionsElement.EnumerateArray()) + { + try + { + if (regionElement.Deserialize(regionTypeInfo) is not AnnotatedRegion region) + { + return false; + } + + regions.Add(region); + } + catch (Exception exception) when (IsRecoverableSerializationFailure(exception)) + { + return false; + } + } + } + + result.Add(new AIAnnotation + { + AdditionalProperties = additionalProperties, + AnnotatedRegions = regions, + RawRepresentation = rawRepresentation, + }); + } + + annotations = result; + return true; + } + + private static bool TryReadAdditionalProperties( + JsonElement envelope, + out AdditionalPropertiesDictionary? additionalProperties) + { + additionalProperties = null; + if (!envelope.TryGetProperty( + AdditionalPropertiesPropertyName, + out JsonElement additionalPropertiesElement)) + { + return true; + } + + if (additionalPropertiesElement.ValueKind != JsonValueKind.Object) + { + return false; + } + + AdditionalPropertiesDictionary result = []; + foreach (JsonProperty property in additionalPropertiesElement.EnumerateObject()) + { + result[property.Name] = property.Value.Clone(); + } + + additionalProperties = result; + return true; + } + + private static bool TryReadRawRepresentation( + JsonElement envelope, + out object? rawRepresentation) + { + rawRepresentation = null; + if (envelope.TryGetProperty( + RawRepresentationPropertyName, + out JsonElement rawRepresentationElement)) + { + rawRepresentation = rawRepresentationElement.Clone(); + } + + return true; + } + + private static bool HasValidOmissions(JsonElement envelope) + { + if (!envelope.TryGetProperty(OmittedPropertyName, out JsonElement omittedElement)) + { + return true; + } + + if (omittedElement.ValueKind != JsonValueKind.Object) + { + return false; + } + + foreach (JsonProperty property in omittedElement.EnumerateObject()) + { + if (property.Name is not ( + AnnotationsPropertyName or + AdditionalPropertiesPropertyName or + RawRepresentationPropertyName or + AnnotatedRegionsPropertyName) || + (property.Value.ValueKind != JsonValueKind.True && + property.Value.ValueKind != JsonValueKind.False && + (property.Value.ValueKind != JsonValueKind.Number || + !property.Value.TryGetInt32(out int count) || + count < 0))) + { + return false; + } + } + + return true; + } + + private static bool HasExactlyOneProperty(JsonElement element, string propertyName) + { + int count = 0; + foreach (JsonProperty property in element.EnumerateObject()) + { + count++; + if (!property.NameEquals(propertyName) || count > 1) + { + return false; + } + } + + return count == 1; + } + + private static bool HasOnlyProperties(JsonElement element, params string[] allowedNames) + { + foreach (JsonProperty property in element.EnumerateObject()) + { + if (!allowedNames.Contains(property.Name, StringComparer.Ordinal)) + { + return false; + } + } + + return true; + } + + private static AIContent CreateOpaqueAIContent(JsonElement content) + { + return new AIContent + { + RawRepresentation = content.Clone(), + AdditionalProperties = new AdditionalPropertiesDictionary + { + ["content"] = content.Clone(), + }, + }; + } + + private static void LogSerializationFallback( + ILogger? logger, + object? value, + Exception exception) + { + if (logger is null) + { + return; + } + + try + { + logger.LogUnknownContentSerializationFallback( + value?.GetType().FullName ?? "null", + GetFailureCategory(exception)); + } + catch (Exception loggingException) when (IsRecoverableSerializationFailure(loggingException)) + { + } + } + + private static string GetFailureCategory(Exception exception) + { + return exception switch + { + ObjectDisposedException => "disposedValue", + JsonException => "invalidJson", + NotSupportedException => "unsupportedType", + InvalidOperationException => "invalidOperation", + ArgumentException => "invalidValue", + FormatException => "invalidFormat", + OverflowException => "numericOverflow", + IOException => "ioFailure", + _ => "customSerializationFailure", + }; + } + + private static bool IsRecoverableSerializationFailure(Exception exception) + { + if (exception is OperationCanceledException or + OutOfMemoryException or + StackOverflowException or + AccessViolationException or + AppDomainUnloadedException or + BadImageFormatException or + CannotUnloadAppDomainException or + InvalidProgramException or + SEHException) + { + return false; + } + + if (exception is AggregateException aggregateException) + { + return aggregateException.InnerExceptions.All(IsRecoverableSerializationFailure); + } - return content ?? throw new InvalidOperationException($"The content '{this.Content}' is not valid AI content."); + return exception.InnerException is null || + IsRecoverableSerializationFailure(exception.InnerException); } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUsage.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUsage.cs index 1b3714f..d5b6e6e 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUsage.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUsage.cs @@ -34,10 +34,17 @@ internal sealed class DurableAgentStateUsage public long? TotalTokenCount { get; init; } /// - /// Gets any additional data found during deserialization that does not map to known properties. + /// Gets provider-specific usage counts from the schema's extensionData property. + /// + [JsonPropertyName("extensionData")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? ExtensionData { get; init; } + + /// + /// Gets unknown usage properties that are outside the declared schema. /// [JsonExtensionData] - public IDictionary? ExtensionData { get; set; } + public IDictionary? UnknownProperties { get; set; } /// /// Creates a from a . @@ -51,7 +58,12 @@ usage is not null { InputTokenCount = usage.InputTokenCount, OutputTokenCount = usage.OutputTokenCount, - TotalTokenCount = usage.TotalTokenCount + TotalTokenCount = usage.TotalTokenCount, + ExtensionData = usage.AdditionalCounts?.ToDictionary( + pair => pair.Key, + pair => JsonSerializer.SerializeToElement( + pair.Value, + DurableAgentStateJsonContext.Default.Int64)), } : null; @@ -61,11 +73,31 @@ usage is not null /// A representing this usage. public UsageDetails ToUsageDetails() { + AdditionalPropertiesDictionary? additionalCounts = null; + foreach (IDictionary? values in new[] { this.ExtensionData, this.UnknownProperties }) + { + if (values is null) + { + continue; + } + + foreach ((string name, JsonElement value) in values) + { + if (value.ValueKind == JsonValueKind.Number && + value.TryGetInt64(out long count)) + { + additionalCounts ??= []; + additionalCounts[name] = count; + } + } + } + return new() { InputTokenCount = this.InputTokenCount, OutputTokenCount = this.OutputTokenCount, - TotalTokenCount = this.TotalTokenCount + TotalTokenCount = this.TotalTokenCount, + AdditionalCounts = additionalCounts, }; } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md index 58166f0..46d82bf 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md @@ -1,12 +1,13 @@ # Durable Agent State -Durable agents are represented as durable entities, with each session of conversation history stored as JSON-serialized state for an individual entity instance. +Durable agents are represented as durable entities, with conversation history stored as JSON-serialized +state for an individual entity instance. ## State Schema The [schema](../../../../schemas/durable-agent-entity-state.json) for durable agent state is a distillation of the prompt and response messages accumulated over the lifetime of a session. While these messages and content originate from Microsoft Agent Framework types (for .NET, see [ChatMessage](https://github.com/dotnet/extensions/blob/main/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatMessage.cs) and [AIContent](https://github.com/dotnet/extensions/blob/main/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs)), durable agent state uses its own, parallel, types in order to (1) better manage the versioning and compatibility of serialized state over time, (2) account for agent implementations across languages/platforms (e.g. .NET and Python), as well as (3) ensure consistency for external tools that make use of state data. -> When new AI content types are added to the Microsoft Agent Framework, equivalent types should be added to the entity state schema as well. The durable agent state "unknown" type can be used when an AI content type is encountered but no equivalent type exists. +> When new AI content types are added to the Microsoft Agent Framework, equivalent types should be added to the entity state schema as well. The durable agent state "unknown" type is used when an AI content type is encountered but no equivalent type exists. Arbitrary `unknown.content` JSON is opaque: generic producer fields, including `$runtimeType`, are never interpreted by .NET and round-trip unchanged. .NET uses the single namespaced `$microsoftAgentFrameworkDurableTask` property only for its versioned metadata envelope. That envelope contains no runtime type name and can restore only the common `AIContent` contract (`Annotations`, `AdditionalProperties`, and safely serializable `RawRepresentation`); it can never select or construct a CLR type. Common metadata values are converted independently. Unsupported, cyclic, disposed, invalid, or getter/converter-failing values are omitted while safe siblings remain, an omission count/flag is recorded, and a warning logs only the value's type and a fixed failure category. The final durable state therefore contains only JSON-safe data. ## State Versioning @@ -14,16 +15,41 @@ The serialized state contains a root `schemaVersion` property, which represents Some versioning considerations: -- Versions should use semver notation (e.g. `".."`) +- Versions use the strict numeric SemVer core grammar `".."`: exactly three + non-negative decimal components, with no leading zeroes except the single digit `0`. Prerelease + suffixes, build metadata, a `v` prefix, missing/extra components, and whitespace are rejected. - Durable agents should use the version property to determine how to deserialize that state and should not attempt to deserialize semver-incompatible versions - Newer versions of durable agents should strive to be compatible with older schema versions (e.g. new properties and objects should be optional) - Durable agents should preserve existing, but unrecognized, properties when serializing state +Schema version 1.2 adds optional message identity and extension metadata, opaque session state, workflow +`ingestedPositions`, and bounded truncation evidence. The .NET workflow path preserves but does not currently +populate `ingestedPositions`. Older 1.x state remains readable. `DurableAgentState.Clone()` promotes older +supported versions to 1.2 when a caller uses that write-clone path, while later same-major versions remain +unchanged. Wiring that path into entity execution is deferred. Major versions remain fail-closed. New +`DurableAgentState` instances default to the current version, while deserialization preserves the persisted +version through an init-only property. + +The schema's declared `extensionData` objects and forward-compatible unknown JSON properties are distinct. +The .NET model names declared metadata `ExtensionData` (or message `AdditionalProperties`) and names +`[JsonExtensionData]` catch-all dictionaries `UnknownProperties`. Both coexist and round-trip independently +at root, data, entry, message, and usage boundaries. Content types also use `UnknownProperties`; the current +schema does not declare a content-level `extensionData` field. Unknown fields are never folded into an +application-defined `extensionData` object. + +Usage `extensionData` is preserved as arbitrary JSON for forward compatibility. When a durable response is +projected to `UsageDetails`, only integral numeric extension values representable as `Int64` become additional +counts; strings, objects, arrays, fractional numbers, and out-of-range numbers remain in durable state but are +ignored by the runtime projection. Malformed known count fields fail deserialization rather than being silently reinterpreted. + +This layer defines and round-trips the schema contracts only. Agent entity integration for session ownership, +replay filtering, compaction, retention, and provider behavior is deferred to later stack layers. + ## Sample State ```json { - "schemaVersion": "1.0.0", + "schemaVersion": "1.2.0", "data": { "conversationHistory": [ { diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj index c548159..d3c993b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj @@ -10,4 +10,10 @@ + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateContentTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateContentTests.cs index 2fda117..6e1badc 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateContentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateContentTests.cs @@ -1,9 +1,11 @@ // Copyright (c) Microsoft. All rights reserved. using System.Text.Json; +using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; using Microsoft.Agents.AI.DurableTask.State; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; namespace Microsoft.Agents.AI.DurableTask.Tests.Unit.State; @@ -42,6 +44,29 @@ public void ErrorContentSerializationDeserialization() Assert.Equal(errorContent.ErrorCode, convertedErrorContent.ErrorCode); } + [Fact] + public void ErrorContentPreservesNonStringPythonDetails() + { + const string Json = """ + { + "$type": "error", + "message": "failed", + "details": { + "retryable": true + } + } + """; + DurableAgentStateContent stored = Assert.IsType( + JsonSerializer.Deserialize(Json, s_stateContentTypeInfo)); + + ErrorContent restored = Assert.IsType(stored.ToAIContent()); + string roundTrip = JsonSerializer.Serialize(stored, s_stateContentTypeInfo); + + using JsonDocument details = JsonDocument.Parse(restored.Details!); + Assert.True(details.RootElement.GetProperty("retryable").GetBoolean()); + Assert.Contains("\"details\":{\"retryable\":true}", roundTrip, StringComparison.Ordinal); + } + [Fact] public void TextContentSerializationDeserialization() { @@ -299,26 +324,464 @@ public void UsageContentSerializationDeserialization() } [Fact] - public void UnknownContentSerializationDeserialization() + public void UsageAdditionalCountsRoundTripThroughExtensionData() { - // Arrange - TextContent originalContent = new("Some unknown content"); + UsageDetails usageDetails = new() + { + InputTokenCount = 10, + AdditionalCounts = new AdditionalPropertiesDictionary + { + ["providerCount"] = 7, + }, + }; - DurableAgentStateContent durableContent = DurableAgentStateUnknownContent.FromUnknownContent(originalContent); + DurableAgentStateUsage stored = Assert.IsType( + DurableAgentStateUsage.FromUsage(usageDetails)); + string json = JsonSerializer.Serialize( + stored, + DurableAgentStateJsonContext.Default.GetTypeInfo(typeof(DurableAgentStateUsage))!); + DurableAgentStateUsage restored = Assert.IsType( + JsonSerializer.Deserialize( + json, + DurableAgentStateJsonContext.Default.GetTypeInfo(typeof(DurableAgentStateUsage))!)); + UsageDetails converted = restored.ToUsageDetails(); + + Assert.Contains("\"extensionData\":{\"providerCount\":7}", json, StringComparison.Ordinal); + Assert.Equal(7, converted.AdditionalCounts?["providerCount"]); + } - // Act - string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo); + [Fact] + public void UsageProjectionIgnoresMalformedExtensionsAndPreservesTheirJson() + { + const string Json = """ + { + "inputTokenCount": 10, + "extensionData": { + "providerCount": 7, + "futureString": "seven", + "futureObject": { "count": 8 }, + "futureArray": [9], + "fractional": 1.5, + "tooLarge": 9223372036854775808 + }, + "futureTopLevelCount": 11, + "futureTopLevelObject": { "count": 12 } + } + """; + JsonTypeInfo usageTypeInfo = + DurableAgentStateJsonContext.Default.GetTypeInfo(typeof(DurableAgentStateUsage))!; + DurableAgentStateUsage stored = Assert.IsType( + JsonSerializer.Deserialize(Json, usageTypeInfo)); + + UsageDetails usage = stored.ToUsageDetails(); + string roundTrip = JsonSerializer.Serialize(stored, usageTypeInfo); + + Assert.Equal(10, usage.InputTokenCount); + Assert.Equal(7, usage.AdditionalCounts?["providerCount"]); + Assert.Equal(11, usage.AdditionalCounts?["futureTopLevelCount"]); + Assert.DoesNotContain("futureString", usage.AdditionalCounts?.Keys ?? []); + Assert.DoesNotContain("futureObject", usage.AdditionalCounts?.Keys ?? []); + Assert.DoesNotContain("futureArray", usage.AdditionalCounts?.Keys ?? []); + Assert.DoesNotContain("fractional", usage.AdditionalCounts?.Keys ?? []); + Assert.DoesNotContain("tooLarge", usage.AdditionalCounts?.Keys ?? []); + Assert.DoesNotContain("futureTopLevelObject", usage.AdditionalCounts?.Keys ?? []); + Assert.Contains("\"futureString\":\"seven\"", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"futureObject\":{\"count\":8}", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"futureArray\":[9]", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"futureTopLevelObject\":{\"count\":12}", roundTrip, StringComparison.Ordinal); + } - DurableAgentStateContent? convertedJsonContent = - (DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo); + [Theory] + [InlineData("\"ten\"")] + [InlineData("{}")] + [InlineData("1.5")] + [InlineData("9223372036854775808")] + public void UsageDeserializationRejectsMalformedKnownNumericFields(string invalidValue) + { + string json = $$""" + { + "inputTokenCount": {{invalidValue}} + } + """; + JsonTypeInfo usageTypeInfo = + DurableAgentStateJsonContext.Default.GetTypeInfo(typeof(DurableAgentStateUsage))!; - // Assert - Assert.NotNull(convertedJsonContent); + Assert.Throws(() => JsonSerializer.Deserialize(json, usageTypeInfo)); + } - AIContent convertedContent = convertedJsonContent.ToAIContent(); + [Fact] + public void KnownContentDiscriminatorDoesNotUseUnknownEnvelope() + { + TextContent originalContent = new("Some unknown content"); + DurableAgentStateContent durableContent = + DurableAgentStateContent.FromAIContent(originalContent); + string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo); + DurableAgentStateContent? convertedJsonContent = + (DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo); + DurableAgentStateTextContent convertedState = + Assert.IsType(convertedJsonContent); + AIContent convertedContent = convertedState.ToAIContent(); TextContent convertedTextContent = Assert.IsType(convertedContent); Assert.Equal(originalContent.Text, convertedTextContent.Text); + Assert.Contains("\"$type\":\"text\"", jsonContent, StringComparison.Ordinal); + Assert.DoesNotContain("$microsoftAgentFrameworkDurableTask", jsonContent, StringComparison.Ordinal); + Assert.DoesNotContain("$runtimeType", jsonContent, StringComparison.Ordinal); + } + + [Fact] + public void UnknownContentWithUnrecognizedPayloadFallsBackWithoutDataLoss() + { + DurableAgentStateUnknownContent stored = new() + { + Content = JsonSerializer.SerializeToElement( + new { type = "future_content", value = 42 }), + }; + + AIContent restored = stored.ToAIContent(); + + JsonElement content = + Assert.IsType(restored.AdditionalProperties?["content"]); + Assert.Equal("future_content", content.GetProperty("type").GetString()); + Assert.Equal(42, content.GetProperty("value").GetInt32()); + } + + [Fact] + public void PythonShapedOpaqueUnknownContentWithRuntimeTypeRoundTripsUnchanged() + { + using JsonDocument document = JsonDocument.Parse( + """ + { + "$runtimeType": "producer-owned-user-value", + "type": "future_python_content", + "annotations": [{ "kind": "citation", "value": "python-ref" }], + "additionalProperties": { "producer": "python" }, + "future": { "nested": [1, 2, 3] } + } + """); + JsonElement original = document.RootElement.Clone(); + DurableAgentStateUnknownContent stored = new() { Content = original }; + + AIContent restored = Assert.IsType(stored.ToAIContent()); + DurableAgentStateUnknownContent roundTripped = Assert.IsType( + DurableAgentStateContent.FromAIContent(restored)); + + Assert.True(JsonElement.DeepEquals(original, roundTripped.Content)); + Assert.Equal( + "producer-owned-user-value", + roundTripped.Content.GetProperty("$runtimeType").GetString()); + Assert.Equal( + 3, + roundTripped.Content.GetProperty("future").GetProperty("nested").GetArrayLength()); + } + + [Fact] + public void FutureDurableEnvelopeFieldsRemainOpaque() + { + using JsonDocument document = JsonDocument.Parse( + """ + { + "$microsoftAgentFrameworkDurableTask": { + "kind": "unknownAIContent", + "version": 1, + "futureMetadata": { "preserve": true } + } + } + """); + JsonElement original = document.RootElement.Clone(); + DurableAgentStateUnknownContent stored = new() { Content = original }; + + AIContent restored = Assert.IsType(stored.ToAIContent()); + DurableAgentStateUnknownContent roundTripped = Assert.IsType( + DurableAgentStateContent.FromAIContent(restored)); + + Assert.True(JsonElement.DeepEquals(original, roundTripped.Content)); + } + + [Fact] + public void UnregisteredAIContentSubtypePersistsCommonContractAsUnknown() + { + FutureContent original = new() + { + FutureValue = "not part of the common contract", + RawRepresentation = new { kind = "future", value = 42 }, + AdditionalProperties = new() + { + ["providerFlag"] = true, + }, + Annotations = + [ + new AIAnnotation + { + AdditionalProperties = new() + { + ["citation"] = "ref-1", + }, + }, + ], + }; + + DurableAgentStateUnknownContent stored = Assert.IsType( + DurableAgentStateContent.FromAIContent(original)); + string json = JsonSerializer.Serialize(stored, s_stateContentTypeInfo); + DurableAgentStateContent roundTripped = Assert.IsType( + JsonSerializer.Deserialize(json, s_stateContentTypeInfo)); + + using JsonDocument document = JsonDocument.Parse(json); + JsonElement persistedContent = document.RootElement.GetProperty("content"); + JsonElement envelope = + persistedContent.GetProperty("$microsoftAgentFrameworkDurableTask"); + Assert.Equal("unknownAIContent", envelope.GetProperty("kind").GetString()); + Assert.Equal(1, envelope.GetProperty("version").GetInt32()); + Assert.False(persistedContent.TryGetProperty("$runtimeType", out _)); + Assert.DoesNotContain(typeof(FutureContent).FullName!, json, StringComparison.Ordinal); + Assert.False(envelope.TryGetProperty(nameof(FutureContent.FutureValue), out _)); + + AIContent restored = Assert.IsType(roundTripped.ToAIContent()); + Assert.True( + Assert.IsType(restored.AdditionalProperties?["providerFlag"]).GetBoolean()); + Assert.Equal( + "ref-1", + Assert.IsType( + Assert.Single(restored.Annotations!).AdditionalProperties?["citation"]).GetString()); + JsonElement rawRepresentation = Assert.IsType(restored.RawRepresentation); + Assert.Equal("future", rawRepresentation.GetProperty("kind").GetString()); + Assert.Equal(42, rawRepresentation.GetProperty("value").GetInt32()); + } + + [Fact] + public void UnknownContentOmitsUnsafeValuesAndPreservesSafeMetadata() + { + CyclicPayload cyclicPayload = new(); + cyclicPayload.Self = cyclicPayload; + JsonElement disposedElement; + using (JsonDocument disposedDocument = JsonDocument.Parse("""{"value":"disposed-secret"}""")) + { + disposedElement = disposedDocument.RootElement; + } + + using MemoryStream stream = new([1, 2, 3]); + CollectingLogger logger = new(); + FutureContent original = new() + { + RawRepresentation = new ThrowingGetterPayload(), + AdditionalProperties = new() + { + ["safeString"] = "kept", + ["safeObject"] = new { value = 42 }, + ["cyclic"] = cyclicPayload, + ["delegate"] = () => { }, + ["stream"] = stream, + ["disposedJson"] = disposedElement, + ["invalidNumber"] = double.NaN, + ["customConverter"] = new ThrowingConverterPayload(), + }, + Annotations = + [ + new AIAnnotation + { + RawRepresentation = disposedElement, + AdditionalProperties = new() + { + ["safeAnnotation"] = "annotation-kept", + ["badAnnotation"] = new ThrowingConverterPayload(), + }, + }, + ], + }; + + DurableAgentStateUnknownContent stored = Assert.IsType( + DurableAgentStateContent.FromAIContent(original, logger)); + string json = JsonSerializer.Serialize(stored, s_stateContentTypeInfo); + DurableAgentState state = new(); + state.Data.ConversationHistory.Add( + new DurableAgentStateRequest + { + CreatedAt = DateTimeOffset.UtcNow, + Messages = + [ + new DurableAgentStateMessage + { + Role = "assistant", + Contents = [stored], + }, + ], + }); + Exception? finalSerializationException = Record.Exception( + () => JsonSerializer.Serialize( + state, + DurableAgentStateJsonContext.Default.DurableAgentState)); + + using JsonDocument document = JsonDocument.Parse(json); + JsonElement envelope = document.RootElement.GetProperty("content") + .GetProperty("$microsoftAgentFrameworkDurableTask"); + JsonElement additionalProperties = envelope.GetProperty("additionalProperties"); + Assert.Equal("kept", additionalProperties.GetProperty("safeString").GetString()); + Assert.Equal(42, additionalProperties.GetProperty("safeObject").GetProperty("value").GetInt32()); + Assert.False(additionalProperties.TryGetProperty("cyclic", out _)); + Assert.False(additionalProperties.TryGetProperty("delegate", out _)); + Assert.False(additionalProperties.TryGetProperty("stream", out _)); + Assert.False(additionalProperties.TryGetProperty("disposedJson", out _)); + Assert.False(additionalProperties.TryGetProperty("invalidNumber", out _)); + Assert.False(additionalProperties.TryGetProperty("customConverter", out _)); + Assert.True(envelope.GetProperty("omitted").GetProperty("rawRepresentation").GetBoolean()); + Assert.Equal(6, envelope.GetProperty("omitted").GetProperty("additionalProperties").GetInt32()); + + JsonElement annotation = envelope.GetProperty("annotations")[0]; + Assert.Equal( + "annotation-kept", + annotation.GetProperty("additionalProperties").GetProperty("safeAnnotation").GetString()); + Assert.False( + annotation.GetProperty("additionalProperties").TryGetProperty("badAnnotation", out _)); + Assert.Equal( + 1, + annotation.GetProperty("omitted").GetProperty("additionalProperties").GetInt32()); + Assert.True( + annotation.GetProperty("omitted").GetProperty("rawRepresentation").GetBoolean()); + + AIContent restored = Assert.IsType(stored.ToAIContent()); + Assert.Equal( + "kept", + Assert.IsType(restored.AdditionalProperties?["safeString"]).GetString()); + Assert.Equal( + "annotation-kept", + Assert.IsType( + Assert.Single(restored.Annotations!).AdditionalProperties?["safeAnnotation"]).GetString()); + + Assert.Null(finalSerializationException); + Assert.True(logger.WarningCount >= 8); + Assert.All(logger.Exceptions, exception => Assert.Null(exception)); + Assert.All( + logger.Messages, + message => + { + Assert.DoesNotContain("disposed-secret", message, StringComparison.Ordinal); + Assert.DoesNotContain("getter-secret", message, StringComparison.Ordinal); + Assert.DoesNotContain("converter-secret", message, StringComparison.Ordinal); + Assert.DoesNotContain("safeString", message, StringComparison.Ordinal); + }); + } + + [Fact] + public void UnknownSubtypePropertyGetterIsNeverInvoked() + { + ThrowingFutureContent.GetterInvocationCount = 0; + ThrowingFutureContent original = new() + { + AdditionalProperties = new() + { + ["safe"] = true, + }, + }; + + DurableAgentStateUnknownContent stored = Assert.IsType( + DurableAgentStateContent.FromAIContent(original)); + string json = JsonSerializer.Serialize(stored, s_stateContentTypeInfo); + AIContent restored = stored.ToAIContent(); + + Assert.Equal(0, ThrowingFutureContent.GetterInvocationCount); + Assert.IsType(restored); + Assert.True( + Assert.IsType(restored.AdditionalProperties?["safe"]).GetBoolean()); + Assert.DoesNotContain("$runtimeType", json, StringComparison.Ordinal); + Assert.DoesNotContain("getter-secret", json, StringComparison.Ordinal); + } + + [Fact] + public void UnknownContentDoesNotSwallowCancellation() + { + FutureContent original = new() + { + RawRepresentation = new CancelingGetterPayload(), + }; + + Assert.ThrowsAny( + () => DurableAgentStateContent.FromAIContent(original)); + } + + private sealed class FutureContent : AIContent + { + public string? FutureValue { get; init; } + } + + private sealed class CyclicPayload + { + public CyclicPayload? Self { get; set; } + } + + private sealed class ThrowingFutureContent : AIContent + { + public static int GetterInvocationCount { get; set; } + + public string Dangerous + { + get + { + GetterInvocationCount++; + throw new InvalidOperationException("getter-secret"); + } + } + } + + private sealed class ThrowingGetterPayload + { + public string Dangerous => throw new InvalidOperationException("getter-secret"); + } + + private sealed class CancelingGetterPayload + { + public string Dangerous => throw new OperationCanceledException(); + } + + [JsonConverter(typeof(ThrowingConverterPayloadConverter))] + public sealed class ThrowingConverterPayload; + + public sealed class ThrowingConverterPayloadConverter : JsonConverter + { + public override ThrowingConverterPayload? Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) + { + throw new NotSupportedException(); + } + + public override void Write( + Utf8JsonWriter writer, + ThrowingConverterPayload value, + JsonSerializerOptions options) + { + throw new FormatException("converter-secret"); + } + } + + private sealed class CollectingLogger : ILogger + { + public int WarningCount { get; private set; } + + public List Messages { get; } = []; + + public List Exceptions { get; } = []; + + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + if (logLevel == LogLevel.Warning) + { + this.WarningCount++; + this.Messages.Add(formatter(state, exception)); + this.Exceptions.Add(exception); + } + } } } diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMessageTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMessageTests.cs index 343644d..acc7042 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMessageTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMessageTests.cs @@ -44,4 +44,216 @@ public void MessageSerializationDeserialization() Assert.Equal(textContent.Text, convertedTextContent.Text); } + + [Fact] + public void MessageIdAndAdditionalPropertiesRoundTrip() + { + ChatMessage message = new(ChatRole.User, "hello") + { + MessageId = "message-1", + AdditionalProperties = new AdditionalPropertiesDictionary + { + ["excluded"] = true, + ["summary"] = "summary-1", + }, + }; + + DurableAgentStateMessage stored = DurableAgentStateMessage.FromChatMessage(message); + ChatMessage restored = stored.ToChatMessage(); + + Assert.Equal("message-1", restored.MessageId); + Assert.NotNull(restored.AdditionalProperties); + Assert.Equal(JsonValueKind.True, Assert.IsType(restored.AdditionalProperties["excluded"]).ValueKind); + Assert.Equal("summary-1", Assert.IsType(restored.AdditionalProperties["summary"]).GetString()); + } + + [Fact] + public void StandaloneConversionDoesNotInventRandomIdentity() + { + DurableAgentStateMessage stored = + DurableAgentStateMessage.FromChatMessage(new ChatMessage(ChatRole.User, "hello")); + + Assert.Null(stored.MessageId); + Assert.Null(stored.ToChatMessage().MessageId); + } + + [Fact] + public void EntryFactoriesSynthesizeDeterministicMessageIds() + { + RunRequest request = new("hello") { CorrelationId = "correlation" }; + + DurableAgentStateRequest first = DurableAgentStateRequest.FromRunRequest(request); + DurableAgentStateRequest second = DurableAgentStateRequest.FromRunRequest(request); + + Assert.Equal("durable_request_correlation_0", first.Messages[0].MessageId); + Assert.Equal(first.Messages[0].MessageId, second.Messages[0].MessageId); + } + + [Fact] + public void EntryFactoriesPreserveProducerMessageIds() + { + RunRequest request = new( + [new ChatMessage(ChatRole.User, "hello") { MessageId = "producer-id" }]) + { + CorrelationId = "correlation", + }; + + DurableAgentStateRequest stored = DurableAgentStateRequest.FromRunRequest(request); + + Assert.Equal("producer-id", stored.Messages[0].MessageId); + } + + [Fact] + public void RequestFactoryUsesStoredPositionsWithoutFiltering() + { + RunRequest request = new( + [ + new ChatMessage(ChatRole.User, [new AIContent()]), + new ChatMessage(ChatRole.User, "hello"), + ]) + { + CorrelationId = "correlation", + }; + + DurableAgentStateRequest stored = DurableAgentStateRequest.FromRunRequest(request); + + Assert.Equal( + ["durable_request_correlation_0", "durable_request_correlation_1"], + stored.Messages.Select(message => message.MessageId)); + } + + [Fact] + public void LegacyCorrelationlessCompactionUsesStoredPositions() + { + DateTimeOffset createdAt = + DateTimeOffset.Parse("2026-07-27T12:34:56.123456+00:00"); + DurableAgentStateCompaction compaction = new() + { + CreatedAt = createdAt, + Messages = + [ + new DurableAgentStateMessage + { + Role = ChatRole.Assistant.Value, + Contents = [], + }, + new DurableAgentStateMessage + { + Role = ChatRole.Assistant.Value, + Contents = [new DurableAgentStateTextContent { Text = "summary" }], + }, + ], + }; + + DurableAgentStateMessageIdentity.EnsureMessageIds([compaction]); + + Assert.Equal( + [ + "durable_compaction_2026-07-27T12:34:56.123456+00:00_0", + "durable_compaction_2026-07-27T12:34:56.123456+00:00_1", + ], + compaction.Messages.Select(message => message.MessageId)); + } + + [Fact] + public void LegacyEmptyCorrelationIdUsesTimestampScope() + { + DateTimeOffset createdAt = + DateTimeOffset.Parse("2026-07-27T12:34:56.123456+00:00"); + + string messageId = DurableAgentStateMessageIdentity.Create( + "compaction", + string.Empty, + createdAt, + storedIndex: 1); + + Assert.Equal( + "durable_compaction_2026-07-27T12:34:56.123456+00:00_1", + messageId); + } + + [Fact] + public void AdditionalPropertiesAreCopiedOnBothConversions() + { + AdditionalPropertiesDictionary producerProperties = new() + { + ["marker"] = "original", + }; + ChatMessage message = new(ChatRole.User, "hello") + { + AdditionalProperties = producerProperties, + }; + + DurableAgentStateMessage stored = DurableAgentStateMessage.FromChatMessage(message); + producerProperties["marker"] = "producer-mutated"; + ChatMessage restored = stored.ToChatMessage(); + restored.AdditionalProperties!["marker"] = "consumer-mutated"; + + Assert.Equal("original", stored.AdditionalProperties?["marker"].GetString()); + } + + [Theory] + [InlineData("2026-07-27T12:34:56+00:00", "2026-07-27T12:34:56+00:00")] + [InlineData("2026-07-27T12:34:56.1234567+00:00", "2026-07-27T12:34:56.123456+00:00")] + [InlineData("2026-07-27T12:34:56.1000000+05:30", "2026-07-27T12:34:56.100000+05:30")] + public void CorrelationlessTimestampScopeMatchesPythonIsoFormat(string input, string expected) + { + DateTimeOffset timestamp = DateTimeOffset.Parse(input); + + string actual = DurableAgentStateMessageIdentity.FormatPythonIsoTimestamp(timestamp); + + Assert.Equal(expected, actual); + } + + [Fact] + public void VersionOnePointTwoFixtureRoundTrips() + { + const string Json = """ + { + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": [{ + "$type": "request", + "correlationId": "request-1", + "createdAt": "2026-01-01T00:00:00Z", + "messages": [{ + "role": "user", + "messageId": "message-1", + "extensionData": { "excluded": true }, + "contents": [{ "$type": "text", "text": "hello" }] + }] + }], + "session": { + "conversationId": "service-1", + "stateBag": {} + }, + "ingestedPositions": { + "input": 0, + "writer": 1 + }, + "truncation": { + "evictedMessageCount": 2, + "firstEvictedAt": "2026-01-01T00:00:00Z", + "lastEvictedAt": "2026-01-02T00:00:00Z" + } + } + } + """; + + DurableAgentState? state = JsonSerializer.Deserialize( + Json, + DurableAgentStateJsonContext.Default.DurableAgentState); + string roundTrip = JsonSerializer.Serialize( + state, + DurableAgentStateJsonContext.Default.DurableAgentState); + + Assert.NotNull(state); + Assert.Equal("1.2.0", state.SchemaVersion); + Assert.Equal("message-1", state.Data.ConversationHistory[0].Messages[0].MessageId); + Assert.Equal(0, state.Data.IngestedPositions?["input"]); + Assert.Equal(1, state.Data.IngestedPositions?["writer"]); + Assert.Contains("\"conversationId\":\"service-1\"", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"ingestedPositions\":{\"input\":0,\"writer\":1}", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"evictedMessageCount\":2", roundTrip, StringComparison.Ordinal); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateResponseTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateResponseTests.cs index a974f9d..66caf9f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateResponseTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateResponseTests.cs @@ -8,7 +8,7 @@ namespace Microsoft.Agents.AI.DurableTask.Tests.Unit.State; public sealed class DurableAgentStateResponseTests { [Fact] - public void FromResponseDropsMessagesContainingOnlyOpaqueContent() + public void FromResponsePreservesMessagesContainingOnlyOpaqueContent() { // Arrange: one message with real text, one with only opaque AIContent ChatMessage usefulMessage = new(ChatRole.Assistant, "Hello, world!") @@ -32,15 +32,14 @@ public void FromResponseDropsMessagesContainingOnlyOpaqueContent() // Act DurableAgentStateResponse durableResponse = DurableAgentStateResponse.FromResponse("corr-123", response); - // Assert: only the useful message survives - DurableAgentStateMessage durableMessage = Assert.Single(durableResponse.Messages); - Assert.Equal(ChatRole.Assistant.Value, durableMessage.Role); + Assert.Equal(2, durableResponse.Messages.Count); + Assert.Equal(ChatRole.Assistant.Value, durableResponse.Messages[1].Role); - // Round-trip to verify the content is correct AgentResponse convertedResponse = durableResponse.ToResponse(); - ChatMessage convertedMessage = Assert.Single(convertedResponse.Messages); - TextContent textContent = Assert.IsType(Assert.Single(convertedMessage.Contents)); + Assert.Equal(2, convertedResponse.Messages.Count); + TextContent textContent = Assert.IsType(Assert.Single(convertedResponse.Messages[0].Contents)); Assert.Equal("Hello, world!", textContent.Text); + Assert.IsType(Assert.Single(convertedResponse.Messages[1].Contents)); } [Fact] @@ -68,7 +67,7 @@ public void FromResponseKeepsMessagesWithMixedContent() } [Fact] - public void FromResponseDropsAllMessagesWhenAllAreOpaque() + public void FromResponsePreservesAllMessagesWhenAllAreOpaque() { // Arrange: all messages contain only opaque AIContent ChatMessage opaque1 = new(ChatRole.Assistant, [ @@ -90,8 +89,7 @@ public void FromResponseDropsAllMessagesWhenAllAreOpaque() // Act DurableAgentStateResponse durableResponse = DurableAgentStateResponse.FromResponse("corr-789", response); - // Assert: no messages stored - Assert.Empty(durableResponse.Messages); + Assert.Equal(2, durableResponse.Messages.Count); } [Fact] @@ -139,4 +137,133 @@ public void FromResponseKeepsBaseAIContentWithAdditionalProperties() // Assert: message is kept because the AIContent has additional properties Assert.Single(durableResponse.Messages); } + + [Fact] + public void FromResponseUsesFinalPersistedPositionForGeneratedMessageId() + { + ChatMessage metadataOnly = new(ChatRole.Assistant, []) + { + AdditionalProperties = new() { ["kind"] = "metadata" }, + }; + ChatMessage text = new(ChatRole.Assistant, "kept"); + AgentResponse response = new([metadataOnly, text]); + + DurableAgentStateResponse stored = + DurableAgentStateResponse.FromResponse("correlation", response); + + Assert.Equal(2, stored.Messages.Count); + Assert.Equal("durable_response_correlation_0", stored.Messages[0].MessageId); + Assert.Equal("durable_response_correlation_1", stored.Messages[1].MessageId); + } + + [Fact] + public void FromMessagesUsesFinalPersistedPositionForGeneratedMessageId() + { + ChatMessage metadataOnly = new(ChatRole.Assistant, []) + { + MessageId = "producer-metadata-id", + }; + ChatMessage text = new(ChatRole.Assistant, "kept"); + + DurableAgentStateResponse stored = + DurableAgentStateResponse.FromMessages("correlation", [metadataOnly, text]); + + Assert.Equal(2, stored.Messages.Count); + Assert.Equal("producer-metadata-id", stored.Messages[0].MessageId); + Assert.Equal("durable_response_correlation_1", stored.Messages[1].MessageId); + } + + [Fact] + public void FromResponsePreservesProducerIdAfterFiltering() + { + ChatMessage metadataOnly = new(ChatRole.Assistant, []); + ChatMessage text = new(ChatRole.Assistant, "kept") + { + MessageId = "producer-id", + }; + + DurableAgentStateResponse stored = + DurableAgentStateResponse.FromResponse("correlation", new AgentResponse([metadataOnly, text])); + + Assert.Equal("producer-id", stored.Messages[1].MessageId); + } + + [Fact] + public void MetadataOnlyResponsePersistsAndRoundTrips() + { + DateTimeOffset createdAt = DateTimeOffset.Parse("2026-09-06T12:34:56+00:00"); + ChatMessage metadataOnly = new(ChatRole.Assistant, []) + { + AuthorName = "agent", + CreatedAt = createdAt, + MessageId = "producer-message-id", + AdditionalProperties = new() + { + ["trace"] = "value", + }, + }; + + DurableAgentStateResponse stored = + DurableAgentStateResponse.FromResponse("correlation", new AgentResponse([metadataOnly])); + string json = System.Text.Json.JsonSerializer.Serialize( + stored, + DurableAgentStateJsonContext.Default.DurableAgentStateResponse); + DurableAgentStateResponse restored = Assert.IsType( + System.Text.Json.JsonSerializer.Deserialize( + json, + DurableAgentStateJsonContext.Default.DurableAgentStateResponse)); + ChatMessage roundTripped = Assert.Single(restored.ToResponse().Messages); + + Assert.Empty(roundTripped.Contents); + Assert.Equal(ChatRole.Assistant, roundTripped.Role); + Assert.Equal("agent", roundTripped.AuthorName); + Assert.Equal(createdAt, roundTripped.CreatedAt); + Assert.Equal("producer-message-id", roundTripped.MessageId); + Assert.Equal( + "value", + Assert.IsType( + roundTripped.AdditionalProperties?["trace"]).GetString()); + } + + [Fact] + public void ToResponseRetainsMetadataOnlyMessageForPolling() + { + DurableAgentStateResponse stored = new() + { + CorrelationId = "correlation", + CreatedAt = DateTimeOffset.Parse("2026-09-06T12:00:00+00:00"), + Messages = + [ + new DurableAgentStateMessage + { + Role = ChatRole.Assistant.Value, + MessageId = "pollable-metadata", + AdditionalProperties = new Dictionary + { + ["status"] = System.Text.Json.JsonSerializer.SerializeToElement("complete"), + }, + Contents = [], + }, + ], + }; + + ChatMessage message = Assert.Single(stored.ToResponse().Messages); + + Assert.Equal("pollable-metadata", message.MessageId); + Assert.Empty(message.Contents); + Assert.Equal( + "complete", + Assert.IsType( + message.AdditionalProperties?["status"]).GetString()); + } + + [Fact] + public void EmptyResponseGetsCreatedAtWithoutThrowing() + { + DurableAgentStateResponse stored = + DurableAgentStateResponse.FromResponse("correlation", new AgentResponse()); + + Assert.Empty(stored.Messages); + Assert.NotEqual(default, stored.CreatedAt); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateTests.cs index f8ce5c6..e829ca6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateTests.cs @@ -2,11 +2,20 @@ using System.Text.Json; using Microsoft.Agents.AI.DurableTask.State; +using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.DurableTask.Tests.Unit.State; public sealed class DurableAgentStateTests { + [Fact] + public void NewStateDefaultsToCurrentSchemaVersion() + { + DurableAgentState state = new(); + + Assert.Equal(DurableAgentState.CurrentSchemaVersion, state.SchemaVersion); + } + [Fact] public void InvalidVersion() { @@ -22,6 +31,96 @@ public void InvalidVersion() () => JsonSerializer.Deserialize(JsonText, DurableAgentStateJsonContext.Default.DurableAgentState)); } + [Theory] + [InlineData("1.0.0")] + [InlineData("1.1.9")] + [InlineData("1.2.0")] + [InlineData("1.2.7")] + [InlineData("1.3.0")] + [InlineData("1.9.2")] + [InlineData("1.2147483648.0")] + [InlineData("1.2.2147483648")] + public void StrictNumericSemVerIsAccepted(string version) + { + string json = $$""" + { + "schemaVersion": "{{version}}", + "data": { + "conversationHistory": [] + } + } + """; + + DurableAgentState state = Assert.IsType( + JsonSerializer.Deserialize(json, DurableAgentStateJsonContext.Default.DurableAgentState)); + + Assert.Equal(version, state.SchemaVersion); + } + + [Theory] + [InlineData("1.2")] + [InlineData("1.2.0.0")] + [InlineData("v1.2.0")] + [InlineData("")] + [InlineData(" ")] + [InlineData("-1.2.0")] + [InlineData("1.-2.0")] + [InlineData("1.2.-3")] + [InlineData("01.2.0")] + [InlineData("1.02.0")] + [InlineData("1.2.00")] + [InlineData("1.2.0-alpha")] + [InlineData("1.2.0+build")] + [InlineData("1.2.0-alpha+build")] + public void InvalidSchemaVersionGrammarIsRejected(string version) + { + string json = $$""" + { + "schemaVersion": {{JsonSerializer.Serialize(version)}}, + "data": { + "conversationHistory": [] + } + } + """; + + Assert.Throws( + () => JsonSerializer.Deserialize( + json, + DurableAgentStateJsonContext.Default.DurableAgentState)); + } + + [Fact] + public void NonStringSchemaVersionIsRejected() + { + const string JsonText = """ + { + "schemaVersion": 10200, + "data": { + "conversationHistory": [] + } + } + """; + + Assert.Throws( + () => JsonSerializer.Deserialize( + JsonText, + DurableAgentStateJsonContext.Default.DurableAgentState)); + } + + [Fact] + public void InvalidSchemaVersionCannotBeSerialized() + { + DurableAgentState state = new() + { + SchemaVersion = "1.2", + }; + + Assert.Throws( + () => JsonSerializer.Serialize( + state, + DurableAgentStateJsonContext.Default.DurableAgentState)); + } + [Fact] public void BreakingVersion() { @@ -53,7 +152,7 @@ public void MissingData() } [Fact] - public void ExtraData() + public void UnknownDataPropertiesRoundTrip() { // Arrange const string JsonText = """ @@ -70,10 +169,10 @@ public void ExtraData() DurableAgentState? state = JsonSerializer.Deserialize(JsonText, DurableAgentStateJsonContext.Default.DurableAgentState); // Assert - Assert.NotNull(state?.Data?.ExtensionData); + Assert.NotNull(state?.Data?.UnknownProperties); - Assert.True(state.Data.ExtensionData!.ContainsKey("extraField")); - Assert.Equal("someValue", state.Data.ExtensionData["extraField"]!.ToString()); + Assert.True(state.Data.UnknownProperties!.ContainsKey("extraField")); + Assert.Equal("someValue", state.Data.UnknownProperties["extraField"].ToString()); // Act string jsonState = JsonSerializer.Serialize(state, DurableAgentStateJsonContext.Default.DurableAgentState); @@ -86,6 +185,89 @@ public void ExtraData() Assert.Equal("someValue", extraFieldElement.ToString()); } + [Fact] + public void DeclaredExtensionDataAndUnknownPropertiesRoundTripIndependently() + { + const string JsonText = """ + { + "schemaVersion": "1.2.0", + "extensionData": { "rootMetadata": "root" }, + "futureRoot": 1, + "data": { + "extensionData": { "dataMetadata": "data" }, + "futureData": 2, + "conversationHistory": [{ + "$type": "response", + "correlationId": "correlation", + "createdAt": "2026-09-07T12:00:00+00:00", + "extensionData": { "entryMetadata": "entry" }, + "futureEntry": 3, + "usage": { + "extensionData": { "providerCount": 4 }, + "futureUsage": 5 + }, + "messages": [{ + "role": "assistant", + "extensionData": { "messageMetadata": "message" }, + "futureMessage": 6, + "contents": [{ + "$type": "text", + "text": "answer", + "futureContent": 7 + }] + }] + }] + } + } + """; + + DurableAgentState state = Assert.IsType( + JsonSerializer.Deserialize(JsonText, DurableAgentStateJsonContext.Default.DurableAgentState)); + DurableAgentStateResponse response = + Assert.IsType(Assert.Single(state.Data.ConversationHistory)); + DurableAgentStateMessage message = Assert.Single(response.Messages); + DurableAgentStateTextContent content = + Assert.IsType(Assert.Single(message.Contents)); + DurableAgentStateUsage usage = Assert.IsType(response.Usage); + + Assert.Equal("root", state.ExtensionData?["rootMetadata"].GetString()); + Assert.Equal(1, state.UnknownProperties?["futureRoot"].GetInt32()); + Assert.Equal("data", state.Data.ExtensionData?["dataMetadata"].GetString()); + Assert.Equal(2, state.Data.UnknownProperties?["futureData"].GetInt32()); + Assert.Equal("entry", response.ExtensionData?["entryMetadata"].GetString()); + Assert.Equal(3, response.UnknownProperties?["futureEntry"].GetInt32()); + Assert.Equal("message", message.AdditionalProperties?["messageMetadata"].GetString()); + Assert.Equal(6, message.UnknownProperties?["futureMessage"].GetInt32()); + Assert.Equal(7, content.UnknownProperties?["futureContent"].GetInt32()); + Assert.Equal(4, usage.ExtensionData?["providerCount"].GetInt32()); + Assert.Equal(5, usage.UnknownProperties?["futureUsage"].GetInt32()); + + string roundTrip = JsonSerializer.Serialize( + state, + DurableAgentStateJsonContext.Default.DurableAgentState); + using JsonDocument document = JsonDocument.Parse(roundTrip); + JsonElement root = document.RootElement; + JsonElement data = root.GetProperty("data"); + JsonElement entry = data.GetProperty("conversationHistory")[0]; + JsonElement roundTrippedMessage = entry.GetProperty("messages")[0]; + JsonElement roundTrippedContent = roundTrippedMessage.GetProperty("contents")[0]; + JsonElement roundTrippedUsage = entry.GetProperty("usage"); + + Assert.Equal("root", root.GetProperty("extensionData").GetProperty("rootMetadata").GetString()); + Assert.Equal(1, root.GetProperty("futureRoot").GetInt32()); + Assert.Equal("data", data.GetProperty("extensionData").GetProperty("dataMetadata").GetString()); + Assert.Equal(2, data.GetProperty("futureData").GetInt32()); + Assert.Equal("entry", entry.GetProperty("extensionData").GetProperty("entryMetadata").GetString()); + Assert.Equal(3, entry.GetProperty("futureEntry").GetInt32()); + Assert.Equal( + "message", + roundTrippedMessage.GetProperty("extensionData").GetProperty("messageMetadata").GetString()); + Assert.Equal(6, roundTrippedMessage.GetProperty("futureMessage").GetInt32()); + Assert.Equal(7, roundTrippedContent.GetProperty("futureContent").GetInt32()); + Assert.Equal(4, roundTrippedUsage.GetProperty("extensionData").GetProperty("providerCount").GetInt32()); + Assert.Equal(5, roundTrippedUsage.GetProperty("futureUsage").GetInt32()); + } + [Fact] public void BasicState() { @@ -167,4 +349,206 @@ public void BasicState() Assert.Equal("Hi user!", textContent.Text); }); } + + [Theory] + [InlineData("1.0.0")] + [InlineData("1.0.7")] + [InlineData("1.1.0")] + [InlineData("1.1.9")] + public void CloneForWritePromotesOlderCompatibleStateToCurrentVersion(string version) + { + string json = $$""" + { + "schemaVersion": "{{version}}", + "data": { + "conversationHistory": [], + "ingestedPositions": { "writer": 2 } + } + } + """; + DurableAgentState state = Assert.IsType( + JsonSerializer.Deserialize(json, DurableAgentStateJsonContext.Default.DurableAgentState)); + + DurableAgentState promoted = state.Clone(); + string roundTrip = JsonSerializer.Serialize( + promoted, + DurableAgentStateJsonContext.Default.DurableAgentState); + + Assert.Equal(DurableAgentState.CurrentSchemaVersion, promoted.SchemaVersion); + Assert.Equal(2, promoted.Data.IngestedPositions?["writer"]); + Assert.Contains("\"schemaVersion\":\"1.2.0\"", roundTrip, StringComparison.Ordinal); + } + + [Theory] + [InlineData("1.2.0")] + [InlineData("1.2.7")] + [InlineData("1.3.0")] + [InlineData("1.9.2")] + public void CloneForWritePreservesCurrentAndFutureCompatibleVersions(string version) + { + string json = $$""" + { + "schemaVersion": "{{version}}", + "data": { + "conversationHistory": [] + } + } + """; + DurableAgentState state = Assert.IsType( + JsonSerializer.Deserialize(json, DurableAgentStateJsonContext.Default.DurableAgentState)); + + DurableAgentState clone = state.Clone(); + string roundTrip = JsonSerializer.Serialize( + clone, + DurableAgentStateJsonContext.Default.DurableAgentState); + + Assert.Equal(version, clone.SchemaVersion); + Assert.Contains($"\"schemaVersion\":\"{version}\"", roundTrip, StringComparison.Ordinal); + } + + [Fact] + public void FutureCompatibleVersionAndUnknownFieldsSurviveMutationAndRoundTrip() + { + const string JsonText = """ + { + "schemaVersion": "1.3.0", + "data": { + "conversationHistory": [ + { + "$type": "response", + "correlationId": "future", + "createdAt": "2026-09-06T12:00:00+00:00", + "futureEntry": { "keep": true }, + "messages": [ + { + "role": "assistant", + "contents": [], + "futureMessage": [1, 2, 3] + } + ] + } + ], + "futureData": "preserve" + }, + "futureRoot": 42 + } + """; + DurableAgentState state = Assert.IsType( + JsonSerializer.Deserialize(JsonText, DurableAgentStateJsonContext.Default.DurableAgentState)); + + DurableAgentState mutated = state.Clone(); + mutated.Data.IngestedPositions = new Dictionary { ["writer"] = 7 }; + string roundTrip = JsonSerializer.Serialize( + mutated, + DurableAgentStateJsonContext.Default.DurableAgentState); + using JsonDocument document = JsonDocument.Parse(roundTrip); + + Assert.Equal("1.3.0", document.RootElement.GetProperty("schemaVersion").GetString()); + Assert.Equal(42, document.RootElement.GetProperty("futureRoot").GetInt32()); + JsonElement data = document.RootElement.GetProperty("data"); + Assert.Equal("preserve", data.GetProperty("futureData").GetString()); + Assert.Equal(7, data.GetProperty("ingestedPositions").GetProperty("writer").GetInt32()); + JsonElement response = data.GetProperty("conversationHistory")[0]; + Assert.True(response.GetProperty("futureEntry").GetProperty("keep").GetBoolean()); + Assert.Equal(3, response.GetProperty("messages")[0].GetProperty("futureMessage").GetArrayLength()); + } + + [Fact] + public void PythonPr59ShapeFixtureMigratesIdsAndPreservesExtensions() + { + string json = File.ReadAllText( + Path.Combine(AppContext.BaseDirectory, "Fixtures", "python-durable-agent-state-1.2.json")); + using JsonDocument sourceDocument = JsonDocument.Parse(json); + JsonElement sourceUnknownContent = sourceDocument.RootElement.GetProperty("data") + .GetProperty("conversationHistory")[1] + .GetProperty("messages")[3] + .GetProperty("contents")[0] + .GetProperty("content"); + DurableAgentState state = Assert.IsType( + JsonSerializer.Deserialize(json, DurableAgentStateJsonContext.Default.DurableAgentState)); + + DurableAgentState migrated = state.Clone(); + string roundTrip = JsonSerializer.Serialize( + migrated, + DurableAgentStateJsonContext.Default.DurableAgentState); + using JsonDocument document = JsonDocument.Parse(roundTrip); + + Assert.Equal("producer-request-id", migrated.Data.ConversationHistory[0].Messages[0].MessageId); + Assert.Equal("python-metadata-only", migrated.Data.ConversationHistory[1].Messages[0].MessageId); + Assert.Equal("durable_response_corr-python_1", migrated.Data.ConversationHistory[1].Messages[1].MessageId); + Assert.Equal("durable_response_corr-python_2", migrated.Data.ConversationHistory[1].Messages[2].MessageId); + Assert.Equal("python-unknown-content", migrated.Data.ConversationHistory[1].Messages[3].MessageId); + Assert.Equal("durable_errorResponse_corr-error_0", migrated.Data.ConversationHistory[2].Messages[0].MessageId); + Assert.Equal( + "durable_compaction_2026-07-27T12:34:56.123456+00:00_0", + migrated.Data.ConversationHistory[3].Messages[0].MessageId); + DurableAgentStateResponse response = + Assert.IsType(migrated.Data.ConversationHistory[1]); + ChatMessage metadataOnly = response.ToResponse().Messages[0]; + Assert.Empty(metadataOnly.Contents); + Assert.Equal("python-metadata-only", metadataOnly.MessageId); + Assert.Equal("python-agent", metadataOnly.AuthorName); + Assert.Equal( + DateTimeOffset.Parse("2026-07-27T12:34:51+00:00"), + metadataOnly.CreatedAt); + Assert.Equal( + "python", + Assert.IsType(metadataOnly.AdditionalProperties?["metadataOrigin"]).GetString()); + DurableAgentStateUnknownContent unknown = Assert.IsType( + migrated.Data.ConversationHistory[1].Messages[3].Contents[0]); + JsonElement pythonContent = unknown.Content; + Assert.Equal( + "python-owned-user-field", + pythonContent.GetProperty("$runtimeType").GetString()); + Assert.Equal("future_python_content", pythonContent.GetProperty("type").GetString()); + Assert.Equal("python-value", pythonContent.GetProperty("payload").GetString()); + Assert.Equal( + 3, + pythonContent.GetProperty("future_payload").GetProperty("nested").GetArrayLength()); + JsonElement persistedUnknownContent = document.RootElement.GetProperty("data") + .GetProperty("conversationHistory")[1] + .GetProperty("messages")[3] + .GetProperty("contents")[0] + .GetProperty("content"); + Assert.True(JsonElement.DeepEquals(sourceUnknownContent, persistedUnknownContent)); + UsageDetails usage = Assert.IsType(response.ToResponse().Usage); + Assert.Equal(7, usage.AdditionalCounts?["providerCount"]); + Assert.Equal(11, usage.AdditionalCounts?["futureNumeric"]); + Assert.DoesNotContain("futureString", usage.AdditionalCounts?.Keys ?? []); + Assert.DoesNotContain("futureObject", usage.AdditionalCounts?.Keys ?? []); + Assert.DoesNotContain("futureArray", usage.AdditionalCounts?.Keys ?? []); + Assert.Contains("\"futureString\":\"seven\"", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"futureObject\":{\"count\":8}", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"futureArray\":[9]", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"type\":\"future_python_content\"", roundTrip, StringComparison.Ordinal); + Assert.Contains("\"payload\":\"python-value\"", roundTrip, StringComparison.Ordinal); + Assert.Equal(3, migrated.Data.IngestedPositions?["writer"]); + Assert.Equal("interop-fixture", migrated.ExtensionData?["rootProducer"].GetString()); + Assert.True(migrated.UnknownProperties?["futureRootProperty"].GetProperty("preserve").GetBoolean()); + Assert.Equal("python", migrated.Data.ExtensionData?["dataProducer"].GetString()); + Assert.True(migrated.Data.UnknownProperties?["futureDataProperty"].GetProperty("preserve").GetBoolean()); + Assert.True(document.RootElement.TryGetProperty("extensionData", out _)); + Assert.True(document.RootElement.TryGetProperty("futureRootProperty", out _)); + Assert.True(document.RootElement.GetProperty("data").TryGetProperty("extensionData", out _)); + Assert.True(document.RootElement.GetProperty("data").TryGetProperty("futureDataProperty", out _)); + } + + [Fact] + public void OptionalRequestPropertiesAreOmittedWhenAbsent() + { + DurableAgentState state = new(); + state.Data.ConversationHistory.Add( + new DurableAgentStateRequest + { + CreatedAt = DateTimeOffset.UtcNow, + }); + + string json = JsonSerializer.Serialize( + state, + DurableAgentStateJsonContext.Default.DurableAgentState); + + Assert.DoesNotContain("\"orchestrationId\"", json, StringComparison.Ordinal); + Assert.DoesNotContain("\"responseType\"", json, StringComparison.Ordinal); + Assert.DoesNotContain("\"expirationTimeUtc\"", json, StringComparison.Ordinal); + } } diff --git a/schemas/durable-agent-entity-state.json b/schemas/durable-agent-entity-state.json index 53ac064..35bdca9 100644 --- a/schemas/durable-agent-entity-state.json +++ b/schemas/durable-agent-entity-state.json @@ -8,7 +8,11 @@ "properties": { "inputTokenCount": { "type": "integer" }, "outputTokenCount": { "type": "integer" }, - "totalTokenCount": { "type": "integer" } + "totalTokenCount": { "type": "integer" }, + "extensionData": { + "type": "object", + "description": "Provider-specific usage values that must round-trip without interpretation. Runtimes project only numeric integral values representable by their usage-count type and ignore other values when constructing runtime usage objects." + } } }, "dataContent": { @@ -110,10 +114,10 @@ }, "unknownContent": { "type": "object", - "description": "The unknown content of a message exchanged with the agent.", + "description": "The unknown content of a message exchanged with the agent. The content value is opaque and producers and consumers must preserve its fields without interpreting generic payload properties such as $runtimeType. The namespaced $microsoftAgentFrameworkDurableTask property is reserved for a versioned, non-operative durable-extension metadata envelope; it never identifies or requests construction of a runtime type.", "properties": { "$type": { "type": "string", "const": "unknown" }, - "content": { "description": "The unknown message content serialized as JSON." } + "content": { "description": "The opaque unknown message content serialized as JSON." } }, "required": ["$type", "content"] }, @@ -140,9 +144,18 @@ "role": { "type": "string", "enum": ["user", "assistant", "system", "tool"] }, "contents": { "type": "array", + "description": "The message content. An empty array is a valid metadata-only message.", "items": { "$ref": "#/$defs/chatContentItem" } }, - "createdAt": { "type": "string", "format": "date-time", "description": "When this message was created (RFC 3339)." } + "createdAt": { "type": "string", "format": "date-time", "description": "When this message was created (RFC 3339)." }, + "messageId": { + "type": "string", + "description": "Stable identity for this message. Assigned by the runtime when the producer left it unset." + }, + "extensionData": { + "type": "object", + "description": "Message-level additional properties that must round-trip without interpretation." + } }, "required": ["role"] }, @@ -156,7 +169,11 @@ "properties": { "createdAt": { "type": "string", "format": "date-time", "description": "When this exchange was created (RFC 3339)." }, "correlationId": { "type": "string", "description": "An optional correlation ID to group related exchanges." }, - "messages": { "$ref": "#/$defs/chatMessages" } + "messages": { "$ref": "#/$defs/chatMessages" }, + "extensionData": { + "type": "object", + "description": "Entry-level additional properties that must round-trip without interpretation." + } } }, "agentRequest": { @@ -164,6 +181,7 @@ { "$ref": "#/$defs/conversationEntry" } ], "description": "The request (i.e. prompt) sent to the agent.", + "required": ["$type"], "properties": { "$type": { "type": "string", "const": "request" }, "orchestrationId": { @@ -185,6 +203,7 @@ { "$ref": "#/$defs/conversationEntry" } ], "description": "The response received from the agent.", + "required": ["$type"], "properties": { "$type": { "type": "string", "const": "response" }, "usage": { @@ -192,6 +211,29 @@ } } }, + "agentErrorResponse": { + "allOf": [ + { "$ref": "#/$defs/conversationEntry" } + ], + "description": "A failed turn. Consumers must not replay it to the model.", + "required": ["$type"], + "properties": { + "$type": { "type": "string", "const": "errorResponse" }, + "usage": { + "$ref": "#/$defs/usage" + } + } + }, + "compaction": { + "allOf": [ + { "$ref": "#/$defs/conversationEntry" } + ], + "description": "A compacted transcript message written by a compatible runtime. It answers no request and has no correlation ID.", + "required": ["$type"], + "properties": { + "$type": { "type": "string", "const": "compaction" } + } + }, "data": { "type": "object", "description": "The durable agent's state data.", @@ -199,7 +241,46 @@ "conversationHistory": { "type": "array", "description": "Ordered list of conversation entries.", - "items": { "$ref": "#/$defs/conversationEntry" } + "items": { + "oneOf": [ + { "$ref": "#/$defs/agentRequest" }, + { "$ref": "#/$defs/agentResponse" }, + { "$ref": "#/$defs/agentErrorResponse" }, + { "$ref": "#/$defs/compaction" } + ] + } + }, + "session": { + "type": "object", + "description": "Opaque runtime-defined serialized session; this schema layer does not interpret it." + }, + "ingestedPositions": { + "type": "object", + "description": "Highest chained-conversation position this entity has taken from each workflow executor, keyed by executor id. The .NET workflow path preserves but does not currently populate these watermarks.", + "additionalProperties": { "type": "integer", "minimum": 0 } + }, + "truncation": { + "type": "object", + "description": "Producer-supplied bounded evidence that messages were removed from this conversation.", + "properties": { + "evictedMessageCount": { + "type": "integer", + "minimum": 1 + }, + "firstEvictedAt": { + "type": "string", + "format": "date-time" + }, + "lastEvictedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": ["evictedMessageCount", "firstEvictedAt", "lastEvictedAt"] + }, + "extensionData": { + "type": "object", + "description": "Data-level additional properties that must round-trip without interpretation." } } } @@ -208,10 +289,14 @@ "properties": { "schemaVersion": { "type": "string", - "description": "Semantic version of this state schema. By convention, this should be the first property.", - "pattern": "^\\d+\\.\\d+\\.\\d+$" + "description": "Numeric SemVer core version of this state schema (major.minor.patch only, without leading zeroes, prerelease, or build metadata). By convention, this should be the first property.", + "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$" }, - "data": { "$ref": "#/$defs/data" } + "data": { "$ref": "#/$defs/data" }, + "extensionData": { + "type": "object", + "description": "Root-level additional properties that must round-trip without interpretation." + } }, "required": ["schemaVersion", "data"] } diff --git a/schemas/fixtures/python-durable-agent-state-1.2.json b/schemas/fixtures/python-durable-agent-state-1.2.json new file mode 100644 index 0000000..ada0f64 --- /dev/null +++ b/schemas/fixtures/python-durable-agent-state-1.2.json @@ -0,0 +1,168 @@ +{ + "schemaVersion": "1.2.0", + "data": { + "conversationHistory": [ + { + "$type": "request", + "correlationId": "corr-python", + "createdAt": "2026-07-27T12:34:50+00:00", + "messages": [ + { + "role": "user", + "messageId": "producer-request-id", + "extensionData": { + "origin": "python" + }, + "contents": [ + { + "$type": "text", + "text": "hello" + } + ] + } + ], + "extensionData": { + "requestFuture": true + } + }, + { + "$type": "response", + "correlationId": "corr-python", + "createdAt": "2026-07-27T12:34:51+00:00", + "messages": [ + { + "role": "assistant", + "authorName": "python-agent", + "createdAt": "2026-07-27T12:34:51+00:00", + "messageId": "python-metadata-only", + "extensionData": { + "metadataOrigin": "python" + }, + "contents": [] + }, + { + "role": "assistant", + "contents": [ + { + "$type": "text", + "text": "world" + } + ] + }, + { + "role": "assistant", + "contents": [ + { + "$type": "reasoning", + "text": "private reasoning" + } + ] + }, + { + "role": "assistant", + "messageId": "python-unknown-content", + "contents": [ + { + "$type": "unknown", + "content": { + "$runtimeType": "python-owned-user-field", + "type": "future_python_content", + "payload": "python-value", + "annotations": [ + { + "kind": "citation", + "value": "python-ref" + } + ], + "additional_properties": { + "producer": "python" + }, + "future_payload": { + "nested": [ + 1, + 2, + 3 + ] + } + } + } + ] + } + ], + "usage": { + "inputTokenCount": 4, + "outputTokenCount": 2, + "totalTokenCount": 6, + "extensionData": { + "providerCount": 7, + "futureNumeric": 11, + "futureString": "seven", + "futureObject": { + "count": 8 + }, + "futureArray": [ + 9 + ] + } + } + }, + { + "$type": "errorResponse", + "correlationId": "corr-error", + "createdAt": "2026-07-27T12:34:52+00:00", + "messages": [ + { + "role": "assistant", + "contents": [ + { + "$type": "error", + "message": "failed", + "errorCode": "Example" + } + ] + } + ] + }, + { + "$type": "compaction", + "createdAt": "2026-07-27T12:34:56.123456+00:00", + "messages": [ + { + "role": "assistant", + "contents": [ + { + "$type": "text", + "text": "summary" + } + ] + } + ] + } + ], + "session": { + "type": "session", + "session_id": "@dafx-agent@session", + "state": { + "custom": { + "value": 1 + } + } + }, + "ingestedPositions": { + "input": 0, + "writer": 3 + }, + "extensionData": { + "dataProducer": "python" + }, + "futureDataProperty": { + "preserve": true + } + }, + "extensionData": { + "rootProducer": "interop-fixture" + }, + "futureRootProperty": { + "preserve": true + } +}