From c67d3523ae33fc67c98ec9b9cdd407133d42e829 Mon Sep 17 00:00:00 2001 From: Chris Gillum Date: Fri, 13 Mar 2026 11:16:46 -0700 Subject: [PATCH] .NET: [Durable Agents] Filter empty AIContent from durable agent state responses (#4670) * Filter empty AIContent from durable agent state responses Prevent opaque AIContent objects (e.g., with only RawRepresentation set) from being stored in durable entity state, where they serialize to empty JSON payloads. Base AIContent instances are kept only if they have Annotations or AdditionalProperties. Fixes https://github.com/microsoft/agent-framework/issues/4481 * Update CHANGELOG.md and fix linter violation --- .../CHANGELOG.md | 8 + .../State/DurableAgentStateResponse.cs | 20 ++- .../State/DurableAgentStateResponseTests.cs | 142 ++++++++++++++++++ 3 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateResponseTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md b/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md index e3e90fdae0..74a52faf6f 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md @@ -4,6 +4,12 @@ ### Changed +- Filter empty `AIContent` from durable agent state responses ([#4670](https://github.com/microsoft/agent-framework/pull/4670)) + +## v1.0.0-preview.260311.1 + +### Changed + - Added TTL configuration for durable agent entities ([#2679](https://github.com/microsoft/agent-framework/pull/2679)) - Switch to new "Run" method name ([#2843](https://github.com/microsoft/agent-framework/pull/2843)) - Removed AgentThreadMetadata and used AgentSessionId directly instead ([#3067](https://github.com/microsoft/agent-framework/pull/3067)); @@ -16,6 +22,8 @@ - Marked all `RunAsync` overloads as `new`, added missing ones, and added support for primitives and arrays ([#3803](https://github.com/microsoft/agent-framework/pull/3803)) - Improve session cast error message quality and consistency ([#3973](https://github.com/microsoft/agent-framework/pull/3973)) +NOTE: Some of the above changes may have been part of earlier releases not mentioned in this file. + ## v1.0.0-preview.251204.1 - Added orchestration ID to durable agent entity state ([#2137](https://github.com/microsoft/agent-framework/pull/2137)) diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateResponse.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateResponse.cs index 612ff4b48f..fb9f23df95 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateResponse.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateResponse.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.DurableTask.State; @@ -28,7 +29,10 @@ internal sealed class DurableAgentStateResponse : DurableAgentStateEntry { CorrelationId = correlationId, CreatedAt = response.CreatedAt ?? response.Messages.Max(m => m.CreatedAt) ?? DateTimeOffset.UtcNow, - Messages = response.Messages.Select(DurableAgentStateMessage.FromChatMessage).ToList(), + Messages = response.Messages + .Where(HasSerializableContent) + .Select(DurableAgentStateMessage.FromChatMessage) + .ToList(), Usage = DurableAgentStateUsage.FromUsage(response.Usage) }; } @@ -46,4 +50,18 @@ internal sealed class DurableAgentStateResponse : DurableAgentStateEntry Usage = this.Usage?.ToUsageDetails(), }; } + + // 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) + { + return message.Contents.Any(c => + c.GetType() != typeof(AIContent) || + c.Annotations?.Count > 0 || + c.AdditionalProperties?.Count > 0); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateResponseTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateResponseTests.cs new file mode 100644 index 0000000000..a974f9d974 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateResponseTests.cs @@ -0,0 +1,142 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.DurableTask.State; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.DurableTask.Tests.Unit.State; + +public sealed class DurableAgentStateResponseTests +{ + [Fact] + public void FromResponseDropsMessagesContainingOnlyOpaqueContent() + { + // Arrange: one message with real text, one with only opaque AIContent + ChatMessage usefulMessage = new(ChatRole.Assistant, "Hello, world!") + { + CreatedAt = DateTimeOffset.UtcNow + }; + ChatMessage opaqueOnlyMessage = new(ChatRole.Assistant, [ + new AIContent + { + RawRepresentation = new { kind = "sessionEvent", sessionId = "s123" } + }]) + { + CreatedAt = DateTimeOffset.UtcNow.AddSeconds(1) + }; + + AgentResponse response = new(new List { usefulMessage, opaqueOnlyMessage }) + { + CreatedAt = DateTimeOffset.UtcNow + }; + + // 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); + + // 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("Hello, world!", textContent.Text); + } + + [Fact] + public void FromResponseKeepsMessagesWithMixedContent() + { + // Arrange: one message with both real text and opaque AIContent + ChatMessage mixedMessage = new(ChatRole.Assistant, [ + new TextContent("Some useful text"), + new AIContent { RawRepresentation = new { kind = "metadata" } }]) + { + CreatedAt = DateTimeOffset.UtcNow + }; + + AgentResponse response = new(new List { mixedMessage }) + { + CreatedAt = DateTimeOffset.UtcNow + }; + + // Act + DurableAgentStateResponse durableResponse = DurableAgentStateResponse.FromResponse("corr-456", response); + + // Assert: the message is kept because it contains at least one serializable content + DurableAgentStateMessage durableMessage = Assert.Single(durableResponse.Messages); + Assert.Equal(ChatRole.Assistant.Value, durableMessage.Role); + } + + [Fact] + public void FromResponseDropsAllMessagesWhenAllAreOpaque() + { + // Arrange: all messages contain only opaque AIContent + ChatMessage opaque1 = new(ChatRole.Assistant, [ + new AIContent { RawRepresentation = new { kind = "event1" } }]) + { + CreatedAt = DateTimeOffset.UtcNow + }; + ChatMessage opaque2 = new(ChatRole.Assistant, [ + new AIContent { RawRepresentation = new { kind = "event2" } }]) + { + CreatedAt = DateTimeOffset.UtcNow.AddSeconds(1) + }; + + AgentResponse response = new(new List { opaque1, opaque2 }) + { + CreatedAt = DateTimeOffset.UtcNow + }; + + // Act + DurableAgentStateResponse durableResponse = DurableAgentStateResponse.FromResponse("corr-789", response); + + // Assert: no messages stored + Assert.Empty(durableResponse.Messages); + } + + [Fact] + public void FromResponseKeepsBaseAIContentWithAnnotations() + { + // Arrange: base AIContent with annotations should be kept + AIContent contentWithAnnotations = new() + { + RawRepresentation = new { kind = "event" }, + Annotations = [new AIAnnotation() { AdditionalProperties = new() { ["cite"] = "ref-1" } }] + }; + ChatMessage message = new(ChatRole.Assistant, [contentWithAnnotations]) + { + CreatedAt = DateTimeOffset.UtcNow + }; + + AgentResponse response = new([message]) { CreatedAt = DateTimeOffset.UtcNow }; + + // Act + DurableAgentStateResponse durableResponse = DurableAgentStateResponse.FromResponse("corr-ann", response); + + // Assert: message is kept because the AIContent has annotations + Assert.Single(durableResponse.Messages); + } + + [Fact] + public void FromResponseKeepsBaseAIContentWithAdditionalProperties() + { + // Arrange: base AIContent with additional properties should be kept + AIContent contentWithProps = new() + { + RawRepresentation = new { kind = "event" }, + AdditionalProperties = new() { ["custom_key"] = "custom_value" } + }; + ChatMessage message = new(ChatRole.Assistant, [contentWithProps]) + { + CreatedAt = DateTimeOffset.UtcNow + }; + + AgentResponse response = new([message]) { CreatedAt = DateTimeOffset.UtcNow }; + + // Act + DurableAgentStateResponse durableResponse = DurableAgentStateResponse.FromResponse("corr-props", response); + + // Assert: message is kept because the AIContent has additional properties + Assert.Single(durableResponse.Messages); + } +}