From 79d1966eeeaccac6315b92d40de905c632ad0609 Mon Sep 17 00:00:00 2001
From: Willow Lopez <100782273+Oxygen56@users.noreply.github.com>
Date: Thu, 2 Jul 2026 19:38:37 +0800
Subject: [PATCH] .NET: filter workflow agent tool call messages
---
.../Microsoft.Agents.AI.Workflows/Futures.cs | 2 +-
.../WorkflowHostAgent.cs | 8 +-
.../WorkflowHostingExtensions.cs | 30 +++++++
.../WorkflowSession.cs | 90 +++++++++++++++++--
.../WorkflowHostSmokeTests.cs | 82 +++++++++++++++++
5 files changed, 203 insertions(+), 9 deletions(-)
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Futures.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Futures.cs
index b2c83f112ad..b79afc1aa38 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/Futures.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Futures.cs
@@ -26,7 +26,7 @@ public static class Futures
/// [Obsolete] in v2.0.0 when the new behavior becomes default, and removed in v3.0.0.
///
///
- /// Interaction with . When this flag
+ /// Interaction with . When this flag
/// is , joins
/// in being forwarded out of the agent surface
/// unconditionally — neither honors the host's includeWorkflowOutputsInResponse
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs
index 295c08ceeb2..9cc60045cfb 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs
@@ -20,11 +20,12 @@ internal sealed class WorkflowHostAgent : AIAgent
private readonly IWorkflowExecutionEnvironment _executionEnvironment;
private readonly bool _includeExceptionDetails;
private readonly bool _includeWorkflowOutputsInResponse;
+ private readonly bool _filterToolCallMessages;
private readonly Task _describeTask;
private readonly ConcurrentDictionary _assignedSessionIds = [];
- public WorkflowHostAgent(Workflow workflow, string? id = null, string? name = null, string? description = null, IWorkflowExecutionEnvironment? executionEnvironment = null, bool includeExceptionDetails = false, bool includeWorkflowOutputsInResponse = false)
+ public WorkflowHostAgent(Workflow workflow, string? id = null, string? name = null, string? description = null, IWorkflowExecutionEnvironment? executionEnvironment = null, bool includeExceptionDetails = false, bool includeWorkflowOutputsInResponse = false, bool filterToolCallMessages = false)
{
this._workflow = Throw.IfNull(workflow);
@@ -42,6 +43,7 @@ public WorkflowHostAgent(Workflow workflow, string? id = null, string? name = nu
this._includeExceptionDetails = includeExceptionDetails;
this._includeWorkflowOutputsInResponse = includeWorkflowOutputsInResponse;
+ this._filterToolCallMessages = filterToolCallMessages;
this._id = id;
this.Name = name;
@@ -74,7 +76,7 @@ private async ValueTask ValidateWorkflowAsync()
}
protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default)
- => new(new WorkflowSession(this._workflow, this.GenerateNewId(), this._executionEnvironment, this._includeExceptionDetails, this._includeWorkflowOutputsInResponse));
+ => new(new WorkflowSession(this._workflow, this.GenerateNewId(), this._executionEnvironment, this._includeExceptionDetails, this._includeWorkflowOutputsInResponse, this._filterToolCallMessages));
protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
{
@@ -89,7 +91,7 @@ protected override ValueTask SerializeSessionCoreAsync(AgentSession
}
protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
- => new(new WorkflowSession(this._workflow, serializedState, this._executionEnvironment, this._includeExceptionDetails, this._includeWorkflowOutputsInResponse, jsonSerializerOptions));
+ => new(new WorkflowSession(this._workflow, serializedState, this._executionEnvironment, this._includeExceptionDetails, this._includeWorkflowOutputsInResponse, this._filterToolCallMessages, jsonSerializerOptions));
private async ValueTask UpdateSessionAsync(IEnumerable messages, AgentSession? session = null, CancellationToken cancellationToken = default)
{
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostingExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostingExtensions.cs
index 210537588b1..73a5126c545 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostingExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostingExtensions.cs
@@ -37,6 +37,36 @@ public static AIAgent AsAIAgent(
return new WorkflowHostAgent(workflow, id, name, description, executionEnvironment, includeExceptionDetails, includeWorkflowOutputsInResponse);
}
+ ///
+ /// Convert a workflow with the appropriate primary input type to an .
+ ///
+ /// The workflow to be hosted by the resulting
+ /// If , will remove and
+ /// from messages surfaced by the hosted workflow agent.
+ /// A unique id for the hosting .
+ /// A name for the hosting .
+ /// A description for the hosting .
+ /// Specify the execution environment to use when running the workflows. See
+ /// , and
+ /// for the in-process environments.
+ /// If , will include
+ /// in the representing the workflow error.
+ /// If , will transform outgoing workflow outputs
+ /// into content in s or the as appropriate.
+ ///
+ public static AIAgent AsAIAgent(
+ this Workflow workflow,
+ bool filterToolCallMessages,
+ string? id = null,
+ string? name = null,
+ string? description = null,
+ IWorkflowExecutionEnvironment? executionEnvironment = null,
+ bool includeExceptionDetails = false,
+ bool includeWorkflowOutputsInResponse = false)
+ {
+ return new WorkflowHostAgent(workflow, id, name, description, executionEnvironment, includeExceptionDetails, includeWorkflowOutputsInResponse, filterToolCallMessages);
+ }
+
internal static FunctionCallContent ToFunctionCall(this ExternalRequest request)
{
Dictionary parameters = new()
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs
index d7234ab1e02..ca3a0816282 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs
@@ -30,6 +30,7 @@ internal sealed class WorkflowSession : AgentSession
private readonly bool _includeExceptionDetails;
private readonly bool _includeWorkflowOutputsInResponse;
+ private readonly bool _filterToolCallMessages;
private InMemoryCheckpointManager? _inMemoryCheckpointManager;
@@ -68,11 +69,12 @@ internal static bool VerifyCheckpointingConfiguration(IWorkflowExecutionEnvironm
return true;
}
- public WorkflowSession(Workflow workflow, string sessionId, IWorkflowExecutionEnvironment executionEnvironment, bool includeExceptionDetails = false, bool includeWorkflowOutputsInResponse = false)
+ public WorkflowSession(Workflow workflow, string sessionId, IWorkflowExecutionEnvironment executionEnvironment, bool includeExceptionDetails = false, bool includeWorkflowOutputsInResponse = false, bool filterToolCallMessages = false)
{
this._workflow = Throw.IfNull(workflow);
this._includeExceptionDetails = includeExceptionDetails;
this._includeWorkflowOutputsInResponse = includeWorkflowOutputsInResponse;
+ this._filterToolCallMessages = filterToolCallMessages;
IWorkflowExecutionEnvironment env = Throw.IfNull(executionEnvironment);
if (VerifyCheckpointingConfiguration(env, out InProcessExecutionEnvironment? inProcEnv))
@@ -96,11 +98,12 @@ private CheckpointManager EnsureExternalizedInMemoryCheckpointing()
return new(this._inMemoryCheckpointManager ??= new());
}
- public WorkflowSession(Workflow workflow, JsonElement serializedSession, IWorkflowExecutionEnvironment executionEnvironment, bool includeExceptionDetails = false, bool includeWorkflowOutputsInResponse = false, JsonSerializerOptions? jsonSerializerOptions = null)
+ public WorkflowSession(Workflow workflow, JsonElement serializedSession, IWorkflowExecutionEnvironment executionEnvironment, bool includeExceptionDetails = false, bool includeWorkflowOutputsInResponse = false, bool filterToolCallMessages = false, JsonSerializerOptions? jsonSerializerOptions = null)
{
this._workflow = Throw.IfNull(workflow);
this._includeExceptionDetails = includeExceptionDetails;
this._includeWorkflowOutputsInResponse = includeWorkflowOutputsInResponse;
+ this._filterToolCallMessages = filterToolCallMessages;
IWorkflowExecutionEnvironment env = Throw.IfNull(executionEnvironment);
@@ -173,6 +176,71 @@ public AgentResponseUpdate CreateUpdate(string responseId, object raw, ChatMessa
};
}
+ private AgentResponseUpdate? FilterToolCallContents(AgentResponseUpdate update)
+ {
+ if (!this._filterToolCallMessages || !ContainsToolCallContent(update.Contents))
+ {
+ return update;
+ }
+
+ List retainedContents = update.Contents
+ .Where(static content => !IsToolCallContent(content))
+ .ToList();
+
+ if (retainedContents.Count == 0)
+ {
+ return null;
+ }
+
+ return new AgentResponseUpdate
+ {
+ AdditionalProperties = update.AdditionalProperties,
+ AgentId = update.AgentId,
+ AuthorName = update.AuthorName,
+ Contents = retainedContents,
+ ContinuationToken = update.ContinuationToken,
+ CreatedAt = update.CreatedAt,
+ FinishReason = update.FinishReason,
+ MessageId = update.MessageId,
+ RawRepresentation = update.RawRepresentation,
+ ResponseId = update.ResponseId,
+ Role = update.Role,
+ };
+ }
+
+ private ChatMessage? FilterToolCallContents(ChatMessage message)
+ {
+ if (!this._filterToolCallMessages || !ContainsToolCallContent(message.Contents))
+ {
+ return message;
+ }
+
+ List retainedContents = message.Contents
+ .Where(static content => !IsToolCallContent(content))
+ .ToList();
+
+ if (retainedContents.Count == 0)
+ {
+ return null;
+ }
+
+ ChatMessage filteredMessage = message.Clone();
+ filteredMessage.Contents = retainedContents;
+ return filteredMessage;
+ }
+
+ private AgentResponseUpdate? CreateFilteredUpdate(string responseId, object raw, ChatMessage message)
+ {
+ ChatMessage? filteredMessage = this.FilterToolCallContents(message);
+ return filteredMessage == null ? null : this.CreateUpdate(responseId, raw, filteredMessage);
+ }
+
+ private static bool ContainsToolCallContent(IEnumerable contents)
+ => contents.Any(static content => IsToolCallContent(content));
+
+ private static bool IsToolCallContent(AIContent content)
+ => content is FunctionCallContent or FunctionResultContent;
+
private async ValueTask CreateOrResumeRunAsync(List messages, CancellationToken cancellationToken = default)
{
// The workflow is validated to be a ChatProtocol workflow by the WorkflowHostAgent before creating the session,
@@ -480,7 +548,11 @@ IAsyncEnumerable InvokeStageAsync(
switch (evt)
{
case AgentResponseUpdateEvent agentUpdate:
- yield return agentUpdate.Update;
+ AgentResponseUpdate? filteredUpdate = this.FilterToolCallContents(agentUpdate.Update);
+ if (filteredUpdate != null)
+ {
+ yield return filteredUpdate;
+ }
break;
case RequestInfoEvent requestInfo:
@@ -553,7 +625,11 @@ IAsyncEnumerable InvokeStageAsync(
// as an output executor.
foreach (ChatMessage message in agentResponse.Response.Messages)
{
- yield return this.CreateUpdate(this.LastResponseId, evt, message);
+ AgentResponseUpdate? filteredMessageUpdate = this.CreateFilteredUpdate(this.LastResponseId, evt, message);
+ if (filteredMessageUpdate != null)
+ {
+ yield return filteredMessageUpdate;
+ }
}
break;
@@ -576,7 +652,11 @@ IAsyncEnumerable InvokeStageAsync(
foreach (ChatMessage message in updateMessages)
{
- yield return this.CreateUpdate(this.LastResponseId, evt, message);
+ AgentResponseUpdate? filteredMessageUpdate = this.CreateFilteredUpdate(this.LastResponseId, evt, message);
+ if (filteredMessageUpdate != null)
+ {
+ yield return filteredMessageUpdate;
+ }
}
break;
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs
index 4402142faee..8b62f7f049f 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs
@@ -216,6 +216,9 @@ public override ValueTask HandleAsync(string message, IWorkflowContext context,
public class WorkflowHostSmokeTests : AIAgentHostingExecutorTestsBase
{
+ private const string ToolCallText = "Before tool call.";
+ private const string FinalText = "Final answer.";
+
private sealed class AlwaysFailsAIAgent(bool failByThrowing) : AIAgent
{
private sealed class Session : AgentSession
@@ -263,6 +266,38 @@ private static Workflow CreateWorkflow(bool failByThrowing)
return new WorkflowBuilder(agent).Build();
}
+ private static Workflow CreateToolCallWorkflow()
+ {
+ List messages =
+ [
+ new(ChatRole.Assistant,
+ [
+ new TextContent(ToolCallText),
+ new FunctionCallContent("call_1", "get_data"),
+ ])
+ {
+ MessageId = "tool-call-message",
+ },
+ new(ChatRole.Tool, [new FunctionResultContent("call_1", "tool result")])
+ {
+ MessageId = "tool-result-message",
+ },
+ new(ChatRole.Assistant, [new TextContent(FinalText)])
+ {
+ MessageId = "final-message",
+ },
+ ];
+
+ TestReplayAgent agent = new(messages, TestAgentId, TestAgentName);
+ ExecutorBinding binding = agent.BindAsExecutor(new AIAgentHostOptions
+ {
+ EmitAgentUpdateEvents = true,
+ EmitAgentResponseEvents = true,
+ });
+
+ return new WorkflowBuilder(binding).Build();
+ }
+
[Theory]
[InlineData(true, true)]
[InlineData(true, false)]
@@ -299,6 +334,53 @@ public async Task Test_AsAgent_ErrorContentStreamedOutAsync(bool includeExceptio
hadErrorContent.Should().BeTrue();
}
+ [Fact]
+ public async Task Test_AsAgent_DefaultPreservesToolCallMessagesAsync()
+ {
+ // Arrange
+ Workflow workflow = CreateToolCallWorkflow();
+ AIAgent workflowAgent = workflow.AsAIAgent("WorkflowAgent");
+
+ // Act
+ AgentResponse response = await workflowAgent.RunAsync(new ChatMessage(ChatRole.User, "Hello"));
+
+ // Assert
+ List contents = [.. response.Messages.SelectMany(message => message.Contents)];
+ contents.Should().Contain(content => content is FunctionCallContent);
+ contents.Should().Contain(content => content is FunctionResultContent);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task Test_AsAgent_FilterToolCallMessagesRemovesToolCallContentsAsync(bool runStreaming)
+ {
+ // Arrange
+ Workflow workflow = CreateToolCallWorkflow();
+ AIAgent workflowAgent = workflow.AsAIAgent(filterToolCallMessages: true, id: "WorkflowAgent");
+
+ // Act
+ List contents;
+ if (runStreaming)
+ {
+ List updates = await workflowAgent.RunStreamingAsync(new ChatMessage(ChatRole.User, "Hello")).ToListAsync();
+ contents = [.. updates.SelectMany(update => update.Contents)];
+ }
+ else
+ {
+ AgentResponse response = await workflowAgent.RunAsync(new ChatMessage(ChatRole.User, "Hello"));
+ contents = [.. response.Messages.SelectMany(message => message.Contents)];
+ }
+
+ // Assert
+ contents.Should().NotContain(content => content is FunctionCallContent);
+ contents.Should().NotContain(content => content is FunctionResultContent);
+
+ List textContents = [.. contents.OfType().Select(content => content.Text)];
+ textContents.Should().Contain(ToolCallText);
+ textContents.Should().Contain(FinalText);
+ }
+
///
/// Tests that when a workflow emits a RequestInfoEvent with FunctionCallContent data,
/// the AgentResponseUpdate preserves the original FunctionCallContent type.