-
Notifications
You must be signed in to change notification settings - Fork 1.6k
.NET: DRAFT feat: Implement message filtering to exclude non-portable content typ… #5410
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
92c45d0
11cc98a
f1f0d78
9d75507
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<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
|
||
|
|
||
| forwardedMessages.Should().BeEmpty(); | ||
| } | ||
|
|
||
| #endregion | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
AdditionalProperties = message.AdditionalPropertiescopies the dictionary by reference into the forwardedChatMessage. SinceAdditionalPropertiesDictionaryis mutable and is cloned elsewhere when merging (e.g.,MessageMergerusesnew(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.