Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -181,8 +181,16 @@ private async ValueTask ContinueTurnAsync(List<ChatMessage> messages, IWorkflowC

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)
Expand Down Expand Up @@ -241,4 +249,60 @@ await this.EnsureSessionAsync(context, cancellationToken).ConfigureAwait(false),

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,
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AdditionalProperties = message.AdditionalProperties copies the dictionary by reference into the forwarded ChatMessage. Since AdditionalPropertiesDictionary is mutable and is cloned elsewhere when merging (e.g., MessageMerger uses new(current)), this can unintentionally couple mutations between the original response message and the forwarded message. Consider cloning the dictionary (or omitting provider-specific entries) when constructing the sanitized message.

Suggested change
AdditionalProperties = message.AdditionalProperties,
AdditionalProperties = message.AdditionalProperties is null ? null : new(message.AdditionalProperties),

Copilot uses AI. Check for mistakes.
});
}

return result;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -217,4 +221,225 @@ List<object> ExtractAndValidateRequestContents<TRequest>() where TRequest : AICo

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());
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();
Comment on lines +434 to +439
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test’s assertion comment says “no ChatMessage lists should be forwarded (only TurnToken)”, but AIAgentHostExecutor forwards incoming messages by default (AIAgentHostOptions.ForwardIncomingMessages = true), so a List<ChatMessage> (often empty) can still be sent before the response forwarding. To make the test accurately validate “no forwarded chat messages”, consider either setting ForwardIncomingMessages = false for this test or asserting on the envelopes (e.g., that no non-empty List<ChatMessage> was sent).

Copilot uses AI. Check for mistakes.

forwardedMessages.Should().BeEmpty();
}

#endregion
}
Loading