.NET: feat: Implement message filtering to exclude non-portable content typ… (#5410)

* feat: Implement message filtering to exclude non-portable content types before forwarding

Co-authored-by: Copilot <copilot@github.com>

* Added unit tests to cover forwarded message filtering within AI Agent executors

Co-authored-by: Copilot <copilot@github.com>

* Update dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: Disable forwarding of incoming messages in AIAgentHostExecutor tests

Co-authored-by: Copilot <copilot@github.com>

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Jacob Alber <jaalber@microsoft.com>
This commit is contained in:
Taylor Rockey
2026-05-05 07:43:45 -07:00
committed by GitHub
Unverified
parent e7dc3b91f1
commit 162985f2a3
2 changed files with 291 additions and 2 deletions
@@ -181,8 +181,16 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
AgentResponse response = await this.InvokeAgentAsync(filteredMessages, context, emitEvents, cancellationToken).ConfigureAwait(false);
await context.SendMessageAsync(response.Messages is List<ChatMessage> list ? list : response.Messages.ToList(), cancellationToken)
.ConfigureAwait(false);
// Filter out server-side artifacts (reasoning tokens, web search calls, etc.)
// that are internal to this agent. Forwarding them to other agents in the workflow
// causes invalid request errors when the receiving agent uses the Responses API,
// because these item types are not valid as input items.
List<ChatMessage> forwardableMessages = FilterForwardableMessages(response.Messages).ToList();
if (forwardableMessages.Count > 0)
{
await context.SendMessageAsync(forwardableMessages, cancellationToken)
.ConfigureAwait(false);
}
// If we have no outstanding requests, we can yield a turn token back to the workflow.
if (!this.HasOutstandingRequests)
@@ -241,4 +249,60 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
return response;
}
/// <summary>
/// Content types that represent meaningful conversational content portable across agents.
/// Messages containing only content types not in this set (e.g. reasoning tokens, web search
/// calls) are filtered out before forwarding, as they are output-only items that cause
/// schema validation errors when sent as input to the Responses API.
/// </summary>
private static readonly HashSet<Type> s_forwardableContentTypes =
[
typeof(TextContent),
typeof(DataContent),
typeof(UriContent),
typeof(FunctionCallContent),
typeof(FunctionResultContent),
typeof(ToolApprovalRequestContent),
typeof(ToolApprovalResponseContent),
typeof(HostedFileContent),
typeof(ErrorContent),
];
/// <summary>
/// Filters response messages to only include those with portable conversational content,
/// and strips <see cref="ChatMessage.RawRepresentation"/> so that provider-specific output
/// items (e.g. <c>mcp_list_tools</c>, <c>reasoning</c>, <c>fabric_dataagent_preview_call</c>)
/// are not round-tripped by the M.E.AI library when the messages are sent to another agent.
/// </summary>
private static List<ChatMessage> FilterForwardableMessages(IList<ChatMessage> messages)
{
List<ChatMessage> result = [];
foreach (ChatMessage message in messages)
{
// Extract only the content items that are portable across agents.
List<AIContent> forwardableContents = message.Contents
.Where(c => s_forwardableContentTypes.Any(t => t.IsAssignableFrom(c.GetType())))
.ToList();
if (forwardableContents.Count == 0)
{
continue;
}
// Build a clean message without the provider-specific RawRepresentation,
// which would otherwise cause the M.E.AI library to round-trip the original
// output-only items (e.g. mcp_list_tools) as input to the next agent.
result.Add(new ChatMessage(message.Role, forwardableContents)
{
AuthorName = message.AuthorName,
MessageId = message.MessageId,
CreatedAt = message.CreatedAt,
AdditionalProperties = message.AdditionalProperties is null ? null : new(message.AdditionalProperties),
});
}
return result;
}
}
@@ -3,8 +3,12 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.Execution;
using Microsoft.Agents.AI.Workflows.Specialized;
using Microsoft.Extensions.AI;
@@ -217,4 +221,225 @@ public class AIAgentHostExecutorTests : AIAgentHostingExecutorTestsBase
lastResponseEvent.Response.Text.Should().Be("Done");
}
#region FilterForwardableMessages tests
/// <summary>
/// An agent that returns response messages containing a mix of content types,
/// including non-portable server-side artifacts like TextReasoningContent and
/// unrecognized AIContent subclasses (simulating mcp_list_tools, web_search_call, etc.).
/// </summary>
private sealed class MixedContentAgent(List<ChatMessage> responseMessages, string? id = null, string? name = null) : AIAgent
{
protected override string? IdCore => id;
public override string? Name => name;
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
=> new(new MixedContentSession());
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> new(new MixedContentSession());
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> default;
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> Task.FromResult(new AgentResponse(responseMessages.ToList()) { AgentId = this.Id });
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
foreach (ChatMessage msg in responseMessages)
{
foreach (AIContent content in msg.Contents)
{
yield return new AgentResponseUpdate
{
AgentId = this.Id,
AuthorName = this.Name,
MessageId = msg.MessageId ?? Guid.NewGuid().ToString("N"),
ResponseId = Guid.NewGuid().ToString("N"),
Contents = [content],
Role = msg.Role,
};
}
}
}
private sealed class MixedContentSession : AgentSession;
}
/// <summary>
/// A custom AIContent subclass that simulates an unrecognized provider-specific content type
/// (e.g. mcp_list_tools, web_search_call, fabric_dataagent_preview_call).
/// </summary>
private sealed class UnrecognizedServerContent(string description) : AIContent
{
public string Description => description;
}
[Fact]
public async Task Test_AgentHostExecutor_FiltersNonPortableContentFromForwardedMessagesAsync()
{
// Arrange: agent returns a mix of text, reasoning, and unrecognized content
var responseMessages = new List<ChatMessage>
{
new(ChatRole.Assistant, [new TextContent("Useful response text")])
{
AuthorName = TestAgentName,
MessageId = Guid.NewGuid().ToString("N"),
RawRepresentation = "original_response_item_1",
},
new(ChatRole.Assistant, [new TextReasoningContent("internal thinking")])
{
AuthorName = TestAgentName,
MessageId = Guid.NewGuid().ToString("N"),
RawRepresentation = "original_reasoning_item",
},
new(ChatRole.Assistant, [new UnrecognizedServerContent("mcp_list_tools payload")])
{
AuthorName = TestAgentName,
MessageId = Guid.NewGuid().ToString("N"),
RawRepresentation = "original_mcp_list_tools_item",
},
};
TestRunContext testContext = new();
MixedContentAgent agent = new(responseMessages, TestAgentId, TestAgentName);
AIAgentHostExecutor executor = new(agent, new());
testContext.ConfigureExecutor(executor);
// Act
await executor.TakeTurnAsync(new(), testContext.BindWorkflowContext(executor.Id));
// Assert: only the text message should be forwarded
testContext.QueuedMessages.Should().ContainKey(executor.Id);
List<MessageEnvelope> sentEnvelopes = testContext.QueuedMessages[executor.Id];
// Extract forwarded ChatMessage lists (filter out TurnToken)
List<ChatMessage> forwardedMessages = sentEnvelopes
.Select(e => e.Message)
.OfType<List<ChatMessage>>()
.SelectMany(list => list)
.ToList();
forwardedMessages.Should().HaveCount(1);
forwardedMessages[0].Role.Should().Be(ChatRole.Assistant);
forwardedMessages[0].Contents.Should().HaveCount(1);
forwardedMessages[0].Contents[0].Should().BeOfType<TextContent>();
((TextContent)forwardedMessages[0].Contents[0]).Text.Should().Be("Useful response text");
}
[Fact]
public async Task Test_AgentHostExecutor_StripsRawRepresentationFromForwardedMessagesAsync()
{
// Arrange: agent returns a text message with RawRepresentation set
var responseMessages = new List<ChatMessage>
{
new(ChatRole.Assistant, [new TextContent("Response")])
{
AuthorName = TestAgentName,
MessageId = Guid.NewGuid().ToString("N"),
RawRepresentation = "provider_specific_response_item",
},
};
TestRunContext testContext = new();
MixedContentAgent agent = new(responseMessages, TestAgentId, TestAgentName);
AIAgentHostExecutor executor = new(agent, new());
testContext.ConfigureExecutor(executor);
// Act
await executor.TakeTurnAsync(new(), testContext.BindWorkflowContext(executor.Id));
// Assert: forwarded message should NOT have RawRepresentation
List<ChatMessage> forwardedMessages = testContext.QueuedMessages[executor.Id]
.Select(e => e.Message)
.OfType<List<ChatMessage>>()
.SelectMany(list => list)
.ToList();
forwardedMessages.Should().HaveCount(1);
forwardedMessages[0].RawRepresentation.Should().BeNull();
forwardedMessages[0].AuthorName.Should().Be(TestAgentName);
}
[Fact]
public async Task Test_AgentHostExecutor_PreservesForwardableContentInMixedMessagesAsync()
{
// Arrange: a single message with both text and reasoning content
var responseMessages = new List<ChatMessage>
{
new(ChatRole.Assistant,
[
new TextContent("Visible text"),
new TextReasoningContent("Hidden reasoning"),
new FunctionCallContent("call_1", "my_function", new Dictionary<string, object?> { ["arg"] = "val" }),
])
{
AuthorName = TestAgentName,
MessageId = Guid.NewGuid().ToString("N"),
RawRepresentation = "original_mixed_item",
},
};
TestRunContext testContext = new();
MixedContentAgent agent = new(responseMessages, TestAgentId, TestAgentName);
AIAgentHostExecutor executor = new(agent, new());
testContext.ConfigureExecutor(executor);
// Act
await executor.TakeTurnAsync(new(), testContext.BindWorkflowContext(executor.Id));
// Assert: message should be forwarded with only the text and function call content
List<ChatMessage> forwardedMessages = testContext.QueuedMessages[executor.Id]
.Select(e => e.Message)
.OfType<List<ChatMessage>>()
.SelectMany(list => list)
.ToList();
forwardedMessages.Should().HaveCount(1);
ChatMessage forwarded = forwardedMessages[0];
forwarded.Contents.Should().HaveCount(2);
forwarded.Contents[0].Should().BeOfType<TextContent>();
forwarded.Contents[1].Should().BeOfType<FunctionCallContent>();
forwarded.RawRepresentation.Should().BeNull();
}
[Fact]
public async Task Test_AgentHostExecutor_DropsMessageWithOnlyNonPortableContentAsync()
{
// Arrange: agent returns only non-portable content
var responseMessages = new List<ChatMessage>
{
new(ChatRole.Assistant, [new TextReasoningContent("reasoning only")])
{
AuthorName = TestAgentName,
MessageId = Guid.NewGuid().ToString("N"),
},
new(ChatRole.Assistant, [new UnrecognizedServerContent("web_search_call")])
{
AuthorName = TestAgentName,
MessageId = Guid.NewGuid().ToString("N"),
},
};
TestRunContext testContext = new();
MixedContentAgent agent = new(responseMessages, TestAgentId, TestAgentName);
AIAgentHostExecutor executor = new(agent, new() { ForwardIncomingMessages = false });
testContext.ConfigureExecutor(executor);
// Act
await executor.TakeTurnAsync(new(), testContext.BindWorkflowContext(executor.Id));
// Assert: no ChatMessage lists should be forwarded (only TurnToken)
List<ChatMessage> forwardedMessages = testContext.QueuedMessages[executor.Id]
.Select(e => e.Message)
.OfType<List<ChatMessage>>()
.SelectMany(list => list)
.ToList();
forwardedMessages.Should().BeEmpty();
}
#endregion
}