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.