From 92c45d0603b2fa5f7e3148505b7b146bce663565 Mon Sep 17 00:00:00 2001 From: "Taylor Rockey (HE / HIM)" Date: Tue, 21 Apr 2026 12:03:57 -0700 Subject: [PATCH 1/4] feat: Implement message filtering to exclude non-portable content types before forwarding Co-authored-by: Copilot --- .../Specialized/AIAgentHostExecutor.cs | 63 ++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs index b7d2911537..a486cdac9a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs @@ -181,7 +181,12 @@ private async ValueTask ContinueTurnAsync(List messages, IWorkflowC AgentResponse response = await this.InvokeAgentAsync(filteredMessages, context, emitEvents, cancellationToken).ConfigureAwait(false); - await context.SendMessageAsync(response.Messages is List list ? list : response.Messages.ToList(), cancellationToken) + // 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. + var forwardableMessages = FilterForwardableMessages(response.Messages); + await context.SendMessageAsync(forwardableMessages, cancellationToken) .ConfigureAwait(false); // If we have no outstanding requests, we can yield a turn token back to the workflow. @@ -241,4 +246,60 @@ await this.EnsureSessionAsync(context, cancellationToken).ConfigureAwait(false), return response; } + + /// + /// 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. + /// + private static readonly HashSet s_forwardableContentTypes = + [ + typeof(TextContent), + typeof(DataContent), + typeof(UriContent), + typeof(FunctionCallContent), + typeof(FunctionResultContent), + typeof(ToolApprovalRequestContent), + typeof(ToolApprovalResponseContent), + typeof(HostedFileContent), + typeof(ErrorContent), + ]; + + /// + /// Filters response messages to only include those with portable conversational content, + /// and strips so that provider-specific output + /// items (e.g. mcp_list_tools, reasoning, fabric_dataagent_preview_call) + /// are not round-tripped by the M.E.AI library when the messages are sent to another agent. + /// + private static List FilterForwardableMessages(IList messages) + { + List result = []; + + foreach (ChatMessage message in messages) + { + // Extract only the content items that are portable across agents. + List forwardableContents = message.Contents + .Where(c => s_forwardableContentTypes.Contains(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, + }); + } + + return result; + } } From 11cc98ae6b39c763d42d124c045ab0ec3ae6a148 Mon Sep 17 00:00:00 2001 From: "Taylor Rockey (HE / HIM)" Date: Tue, 21 Apr 2026 12:57:53 -0700 Subject: [PATCH 2/4] Added unit tests to cover forwarded message filtering within AI Agent executors Co-authored-by: Copilot --- .../AIAgentHostExecutorTests.cs | 225 ++++++++++++++++++ 1 file changed, 225 insertions(+) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs index 9cd9eb45e6..91caf30f09 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs @@ -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 @@ List ExtractAndValidateRequestContents() where TRequest : AICo lastResponseEvent.Response.Text.Should().Be("Done"); } + + #region FilterForwardableMessages tests + + /// + /// 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.). + /// + private sealed class MixedContentAgent(List responseMessages, string? id = null, string? name = null) : AIAgent + { + protected override string? IdCore => id; + public override string? Name => name; + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + => new(new MixedContentSession()); + + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => new(new MixedContentSession()); + + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => default; + + protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + => Task.FromResult(new AgentResponse(responseMessages.ToList()) { AgentId = this.Id }); + + protected override async IAsyncEnumerable RunCoreStreamingAsync(IEnumerable 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; + } + + /// + /// A custom AIContent subclass that simulates an unrecognized provider-specific content type + /// (e.g. mcp_list_tools, web_search_call, fabric_dataagent_preview_call). + /// + 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 + { + 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 sentEnvelopes = testContext.QueuedMessages[executor.Id]; + + // Extract forwarded ChatMessage lists (filter out TurnToken) + List forwardedMessages = sentEnvelopes + .Select(e => e.Message) + .OfType>() + .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)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 + { + 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 forwardedMessages = testContext.QueuedMessages[executor.Id] + .Select(e => e.Message) + .OfType>() + .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 + { + new(ChatRole.Assistant, + [ + new TextContent("Visible text"), + new TextReasoningContent("Hidden reasoning"), + new FunctionCallContent("call_1", "my_function", new Dictionary { ["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 forwardedMessages = testContext.QueuedMessages[executor.Id] + .Select(e => e.Message) + .OfType>() + .SelectMany(list => list) + .ToList(); + + forwardedMessages.Should().HaveCount(1); + ChatMessage forwarded = forwardedMessages[0]; + forwarded.Contents.Should().HaveCount(2); + forwarded.Contents[0].Should().BeOfType(); + forwarded.Contents[1].Should().BeOfType(); + forwarded.RawRepresentation.Should().BeNull(); + } + + [Fact] + public async Task Test_AgentHostExecutor_DropsMessageWithOnlyNonPortableContentAsync() + { + // Arrange: agent returns only non-portable content + var responseMessages = new List + { + 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()); + testContext.ConfigureExecutor(executor); + + // Act + await executor.TakeTurnAsync(new(), testContext.BindWorkflowContext(executor.Id)); + + // Assert: no ChatMessage lists should be forwarded (only TurnToken) + List forwardedMessages = testContext.QueuedMessages[executor.Id] + .Select(e => e.Message) + .OfType>() + .SelectMany(list => list) + .ToList(); + + forwardedMessages.Should().BeEmpty(); + } + + #endregion } From f1f0d78c9588d67c7d7c00cab28587f500a74e6e Mon Sep 17 00:00:00 2001 From: Taylor Rockey Date: Tue, 21 Apr 2026 13:37:17 -0700 Subject: [PATCH 3/4] Update dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Specialized/AIAgentHostExecutor.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs index a486cdac9a..6cdbf09fd1 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs @@ -185,9 +185,12 @@ private async ValueTask ContinueTurnAsync(List messages, IWorkflowC // 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. - var forwardableMessages = FilterForwardableMessages(response.Messages); - await context.SendMessageAsync(forwardableMessages, cancellationToken) - .ConfigureAwait(false); + List 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) From 9d75507d30e6379191f58e37d371763d721e6d34 Mon Sep 17 00:00:00 2001 From: Taylor Rockey Date: Tue, 21 Apr 2026 13:37:49 -0700 Subject: [PATCH 4/4] Update dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Specialized/AIAgentHostExecutor.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs index 6cdbf09fd1..611f462389 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs @@ -283,7 +283,7 @@ private static List FilterForwardableMessages(IList me { // Extract only the content items that are portable across agents. List forwardableContents = message.Contents - .Where(c => s_forwardableContentTypes.Contains(c.GetType())) + .Where(c => s_forwardableContentTypes.Any(t => t.IsAssignableFrom(c.GetType()))) .ToList(); if (forwardableContents.Count == 0)