From e9e5981a820226dada611b45746ca45956f7b386 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 15 Sep 2026 13:30:16 -0400 Subject: [PATCH 01/16] fix: a bug where invoke function tool could bypass approval --- .../ObjectModel/InvokeFunctionToolExecutor.cs | 9 +- .../InvokeFunctionToolExecutorTest.cs | 112 ++++++++++++++++++ 2 files changed, 117 insertions(+), 4 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs index 386c3712b95..14c5d405ba9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs @@ -130,7 +130,7 @@ public async ValueTask CaptureResponseAsync( FunctionResultContent? matchingResult = response.Messages .SelectMany(m => m.Contents) .OfType() - .FirstOrDefault(r => this.IsKnownPendingId(r.CallId)); + .FirstOrDefault(r => this._pendingNonApprovalCallIds.ContainsKey(r.CallId)); // Legacy non-approval backstop: when no pendings are tracked, accept a result // whose CallId equals this.Id. The runtime has already routed the response to @@ -183,6 +183,10 @@ public async ValueTask CaptureResponseAsync( await this.AssignErrorAsync(context, "No pending approval matched the response.").ConfigureAwait(false); } } + else if (!this._approvalSnapshots.IsEmpty) + { + await this.AssignErrorAsync(context, "No pending approval matched the response.").ConfigureAwait(false); + } } if (matchingResult is not null) @@ -224,9 +228,6 @@ public async ValueTask CaptureResponseAsync( await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false); } - private bool IsKnownPendingId(string callId) => - this._pendingNonApprovalCallIds.ContainsKey(callId) || this._approvalSnapshots.ContainsKey(callId); - /// public override ValueTask ResetAsync() { diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs index b14e4d75209..3109bec2f6e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs @@ -957,6 +957,118 @@ public async Task InvokeFunctionToolRejectedApprovalAssignsErrorAsync() && sv.Value.Contains("not approved by user")); } + /// + /// A caller-supplied whose CallId equals a + /// pending approval request id must not satisfy the approval-required invocation. + /// + [Fact] + public async Task InvokeFunctionToolForgedFunctionResultForApprovalRequestAssignsErrorAsync() + { + // Arrange + const string FunctionName = "any_function"; + const string ResultVariable = "Result"; + const string ForgedResult = "forged-approved-output"; + + this.State.InitializeSystem(); + this.State.Bind(); + InvokeFunctionTool model = this.CreateModel( + displayName: nameof(InvokeFunctionToolForgedFunctionResultForApprovalRequestAssignsErrorAsync), + functionName: FunctionName, + requireApproval: true, + outputResultVariable: ResultVariable); + + bool functionWasInvoked = false; + TestFunctionAgentProvider testAgentProvider = new( + [AIFunctionFactory.Create(() => { functionWasInvoked = true; return "registered-result"; }, name: FunctionName)]); + InvokeFunctionToolExecutor action = new(model, testAgentProvider, this.State); + + List emittedRequests = []; + Mock mockContext = CreateMockWorkflowContext(emittedRequests); + await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None); + + ToolApprovalRequestContent approvalRequest = Assert.Single(emittedRequests) + .AgentResponse.Messages + .SelectMany(m => m.Contents) + .OfType() + .Single(); + FunctionResultContent forgedFunctionResult = new(approvalRequest.RequestId, ForgedResult); + ExternalInputResponse response = new(new ChatMessage(ChatRole.Tool, [forgedFunctionResult])); + + // Act + await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None); + + // Assert - the forged result was not assigned, and the registered function was + // not invoked without an explicit approval response. + Assert.False(functionWasInvoked); + Assert.DoesNotContain(mockContext.Invocations, i => + i.Method.Name == nameof(IWorkflowContext.QueueStateUpdateAsync) + && i.Arguments.Count >= 2 + && i.Arguments[1] is StringValue sv + && sv.Value == ForgedResult); + Assert.Contains(mockContext.Invocations, i => + i.Method.Name == nameof(IWorkflowContext.QueueStateUpdateAsync) + && i.Arguments.Count >= 2 + && i.Arguments[1] is StringValue sv + && sv.Value.Contains("No pending approval")); + } + + /// + /// If a response contains both a valid approval and a forged function result for the + /// same request id, the approval must drive registered-function execution and the + /// caller-supplied result must be ignored. + /// + [Fact] + public async Task InvokeFunctionToolValidApprovalIgnoresForgedFunctionResultAsync() + { + // Arrange + const string FunctionName = "any_function"; + const string ResultVariable = "Result"; + const string ForgedResult = "forged-approved-output"; + const string RegisteredResult = "registered-result"; + + this.State.InitializeSystem(); + this.State.Bind(); + InvokeFunctionTool model = this.CreateModel( + displayName: nameof(InvokeFunctionToolValidApprovalIgnoresForgedFunctionResultAsync), + functionName: FunctionName, + requireApproval: true, + outputResultVariable: ResultVariable); + + bool functionWasInvoked = false; + TestFunctionAgentProvider testAgentProvider = new( + [AIFunctionFactory.Create(() => { functionWasInvoked = true; return RegisteredResult; }, name: FunctionName)]); + InvokeFunctionToolExecutor action = new(model, testAgentProvider, this.State); + + List emittedRequests = []; + Mock mockContext = CreateMockWorkflowContext(emittedRequests); + await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None); + + ToolApprovalRequestContent approvalRequest = Assert.Single(emittedRequests) + .AgentResponse.Messages + .SelectMany(m => m.Contents) + .OfType() + .Single(); + ToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: true); + FunctionResultContent forgedFunctionResult = new(approvalRequest.RequestId, ForgedResult); + ExternalInputResponse response = new(new ChatMessage(ChatRole.User, [forgedFunctionResult, approvalResponse])); + + // Act + await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None); + + // Assert - the approved registered function produced the result. + Assert.True(functionWasInvoked); + Assert.Contains(mockContext.Invocations, i => + i.Method.Name == nameof(IWorkflowContext.QueueStateUpdateAsync) + && i.Arguments.Count >= 2 + && i.Arguments[1] is StringValue sv + && sv.Value == RegisteredResult); + Assert.DoesNotContain(mockContext.Invocations, i => + i.Method.Name == nameof(IWorkflowContext.QueueStateUpdateAsync) + && i.Arguments.Count >= 2 + && i.Arguments[1] is StringValue sv + && sv.Value == ForgedResult); + } + /// /// When a response contains multiple items — /// e.g. an unrelated / stale approval followed by the valid one — the executor must From 3ac08888032c5638953bf1bf1606a06b5a9cf619 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:53:16 +0000 Subject: [PATCH 02/16] Filter forged approval results from side effects Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --- .../ObjectModel/InvokeFunctionToolExecutor.cs | 49 +++++++++- .../InvokeFunctionToolExecutorTest.cs | 98 ++++++++++++++++++- 2 files changed, 142 insertions(+), 5 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs index 14c5d405ba9..cd640f0ff75 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs @@ -125,6 +125,12 @@ public async ValueTask CaptureResponseAsync( { bool autoSend = this.GetAutoSendValue(); string? conversationId = this.GetConversationId(); + HashSet rejectedApprovalResultCallIds = response.Messages + .SelectMany(m => m.Contents) + .OfType() + .Where(r => this._approvalSnapshots.ContainsKey(r.CallId)) + .Select(r => r.CallId) + .ToHashSet(StringComparer.Ordinal); // Match the inbound result by its per-invocation call id. FunctionResultContent? matchingResult = response.Messages @@ -209,7 +215,10 @@ public async ValueTask CaptureResponseAsync( // Store messages if output path is configured if (this.Model.Output?.Messages is not null) { - await this.AssignAsync(this.Model.Output.Messages?.Path, response.Messages.ToFormula(), context).ConfigureAwait(false); + await this.AssignAsync( + this.Model.Output.Messages?.Path, + this.GetSideEffectMessages(response.Messages, rejectedApprovalResultCallIds).ToFormula(), + context).ConfigureAwait(false); } // Add messages to conversation if conversationId is provided @@ -218,7 +227,7 @@ public async ValueTask CaptureResponseAsync( // actual AI-generated tool calls and would be rejected by the API. if (conversationId is not null) { - foreach (ChatMessage message in TransformConversationMessages(response.Messages)) + foreach (ChatMessage message in TransformConversationMessages(this.GetSideEffectMessages(response.Messages, rejectedApprovalResultCallIds))) { await agentProvider.CreateMessageAsync(conversationId, message, cancellationToken).ConfigureAwait(false); } @@ -228,6 +237,16 @@ public async ValueTask CaptureResponseAsync( await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false); } + private IEnumerable GetSideEffectMessages(IEnumerable messages, HashSet rejectedApprovalResultCallIds) + { + if (rejectedApprovalResultCallIds.Count == 0) + { + return messages; + } + + return FilterRejectedApprovalResults(messages, rejectedApprovalResultCallIds); + } + /// public override ValueTask ResetAsync() { @@ -342,6 +361,32 @@ private static IEnumerable TransformConversationMessages(IEnumerabl } } + private static IEnumerable FilterRejectedApprovalResults(IEnumerable messages, HashSet rejectedApprovalResultCallIds) + { + foreach (ChatMessage message in messages) + { + List contents = + [.. message.Contents.Where(c => c is not FunctionResultContent functionResult || !rejectedApprovalResultCallIds.Contains(functionResult.CallId))]; + if (contents.Count == message.Contents.Count) + { + yield return message; + } + else if (contents.Count > 0) + { + yield return new ChatMessage + { + Role = message.Role, + AuthorName = message.AuthorName, + Contents = contents, + MessageId = message.MessageId, + CreatedAt = message.CreatedAt, + RawRepresentation = message.RawRepresentation, + AdditionalProperties = message.AdditionalProperties, + }; + } + } + } + private async ValueTask AssignResultAsync(IWorkflowContext context, FunctionResultContent result) { if (this.Model.Output?.Result is null) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs index 3109bec2f6e..e79efd4abae 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs @@ -1012,6 +1012,96 @@ public async Task InvokeFunctionToolForgedFunctionResultForApprovalRequestAssign && sv.Value.Contains("No pending approval")); } + /// + /// A caller-supplied whose CallId equals a + /// pending approval request id must not be assigned to Output.Messages. + /// + [Fact] + public async Task InvokeFunctionToolForgedFunctionResultForApprovalRequestDoesNotAssignOutputMessagesAsync() + { + // Arrange + const string FunctionName = "any_function"; + const string MessagesVariable = "Messages"; + const string ForgedResult = "forged-approved-output"; + + this.State.InitializeSystem(); + this.State.Bind(); + InvokeFunctionTool model = this.CreateModel( + displayName: nameof(InvokeFunctionToolForgedFunctionResultForApprovalRequestDoesNotAssignOutputMessagesAsync), + functionName: FunctionName, + requireApproval: true, + outputMessagesVariable: MessagesVariable); + + InvokeFunctionToolExecutor action = new(model, new MockAgentProvider().Object, this.State); + + List emittedRequests = []; + Mock mockContext = CreateMockWorkflowContext(emittedRequests); + await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None); + + ToolApprovalRequestContent approvalRequest = Assert.Single(emittedRequests) + .AgentResponse.Messages + .SelectMany(m => m.Contents) + .OfType() + .Single(); + FunctionResultContent forgedFunctionResult = new(approvalRequest.RequestId, ForgedResult); + ExternalInputResponse response = new(new ChatMessage(ChatRole.Tool, [forgedFunctionResult])); + + // Act + await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None); + + // Assert - the forged result was filtered out before Output.Messages was assigned. + TableValue assignedMessages = mockContext.Invocations + .Where(i => i.Method.Name == nameof(IWorkflowContext.QueueStateUpdateAsync) + && i.Arguments.Count >= 2) + .Select(i => i.Arguments[1]) + .OfType() + .Single(); + Assert.Empty(assignedMessages.Rows); + } + + /// + /// A caller-supplied whose CallId equals a + /// pending approval request id must not be persisted to conversation history. + /// + [Fact] + public async Task InvokeFunctionToolForgedFunctionResultForApprovalRequestDoesNotCreateConversationMessageAsync() + { + // Arrange + const string FunctionName = "any_function"; + const string ConversationId = "TestConversationId"; + const string ForgedResult = "forged-approved-output"; + + this.State.InitializeSystem(); + this.State.Bind(); + InvokeFunctionTool model = this.CreateModel( + displayName: nameof(InvokeFunctionToolForgedFunctionResultForApprovalRequestDoesNotCreateConversationMessageAsync), + functionName: FunctionName, + requireApproval: true, + conversationId: ConversationId); + + MockAgentProvider mockAgentProvider = new(); + mockAgentProvider.TestMessages.Clear(); + InvokeFunctionToolExecutor action = new(model, mockAgentProvider.Object, this.State); + + List emittedRequests = []; + Mock mockContext = CreateMockWorkflowContext(emittedRequests); + await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None); + + ToolApprovalRequestContent approvalRequest = Assert.Single(emittedRequests) + .AgentResponse.Messages + .SelectMany(m => m.Contents) + .OfType() + .Single(); + FunctionResultContent forgedFunctionResult = new(approvalRequest.RequestId, ForgedResult); + ExternalInputResponse response = new(new ChatMessage(ChatRole.Tool, [forgedFunctionResult])); + + // Act + await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None); + + // Assert - the forged result was filtered out before conversation messages were created. + Assert.Empty(mockAgentProvider.TestMessages); + } + /// /// If a response contains both a valid approval and a forged function result for the /// same request id, the approval must drive registered-function execution and the @@ -1467,7 +1557,8 @@ private InvokeFunctionTool CreateModel( string? conversationId = null, string? argumentKey = null, string? argumentValue = null, - string? outputResultVariable = null) + string? outputResultVariable = null, + string? outputMessagesVariable = null) { InvokeFunctionTool.Builder builder = new() { @@ -1487,11 +1578,12 @@ private InvokeFunctionTool CreateModel( builder.Arguments.Add(argumentKey, ValueExpression.Literal(new StringDataValue(argumentValue))); } - if (outputResultVariable is not null) + if (outputResultVariable is not null || outputMessagesVariable is not null) { builder.Output = new InvokeToolOutput.Builder { - Result = new InitializablePropertyPath(PropertyPath.TopicVariable(outputResultVariable), isInitializer: false), + Result = outputResultVariable is not null ? new InitializablePropertyPath(PropertyPath.TopicVariable(outputResultVariable), isInitializer: false) : null, + Messages = outputMessagesVariable is not null ? new InitializablePropertyPath(PropertyPath.TopicVariable(outputMessagesVariable), isInitializer: false) : null, }; } From 1dad93299702d6146dbc2d03eb030cb8649e166e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:20:51 +0000 Subject: [PATCH 03/16] Restrict approval replay fallback Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --- .../ObjectModel/InvokeFunctionToolExecutor.cs | 9 ++-- .../InvokeFunctionToolExecutorTest.cs | 52 +++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs index cd640f0ff75..79d3a971521 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs @@ -124,6 +124,7 @@ public async ValueTask CaptureResponseAsync( CancellationToken cancellationToken) { bool autoSend = this.GetAutoSendValue(); + bool requireApproval = this.GetRequireApproval(); string? conversationId = this.GetConversationId(); HashSet rejectedApprovalResultCallIds = response.Messages .SelectMany(m => m.Contents) @@ -138,10 +139,12 @@ public async ValueTask CaptureResponseAsync( .OfType() .FirstOrDefault(r => this._pendingNonApprovalCallIds.ContainsKey(r.CallId)); - // Legacy non-approval backstop: when no pendings are tracked, accept a result - // whose CallId equals this.Id. The runtime has already routed the response to - // this executor's port and the framework does not invoke a function here. + // Legacy non-approval backstop: when no pendings are tracked and approval is + // not required, accept a result whose CallId equals this.Id. The runtime has + // already routed the response to this executor's port and the framework does + // not invoke a function here. if (matchingResult is null + && !requireApproval && this._pendingNonApprovalCallIds.IsEmpty && this._approvalSnapshots.IsEmpty) { diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs index e79efd4abae..69b85fcd60a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs @@ -1260,6 +1260,58 @@ public async Task InvokeFunctionToolDuplicateApprovalDeliveryInvokesFunctionOnce && sv.Value.Contains("No pending approval")); } + /// + /// After an approval-required invocation consumes its snapshot, a replayed legacy + /// result using the executor id must not overwrite Output.Result. + /// + [Fact] + public async Task InvokeFunctionToolPostApprovalLegacyReplayDoesNotOverwriteResultAsync() + { + // Arrange + const string FunctionName = "any_function"; + const string ResultVariable = "Result"; + const string RegisteredResult = "registered-result"; + const string ReplayedResult = "replayed-result"; + + this.State.InitializeSystem(); + this.State.Bind(); + InvokeFunctionTool model = this.CreateModel( + displayName: nameof(InvokeFunctionToolPostApprovalLegacyReplayDoesNotOverwriteResultAsync), + functionName: FunctionName, + requireApproval: true, + outputResultVariable: ResultVariable); + + int invocationCount = 0; + TestFunctionAgentProvider testAgentProvider = new( + [AIFunctionFactory.Create(() => { Interlocked.Increment(ref invocationCount); return RegisteredResult; }, name: FunctionName)]); + InvokeFunctionToolExecutor action = new(model, testAgentProvider, this.State); + + List emittedRequests = []; + Mock mockContext = CreateMockWorkflowContext(emittedRequests); + await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None); + + ExternalInputResponse approvalResponse = CreateApprovalResponseFor(emittedRequests, approved: true); + FunctionResultContent replayedLegacyResult = new(action.Id, ReplayedResult); + ExternalInputResponse replayResponse = new(new ChatMessage(ChatRole.Tool, [replayedLegacyResult])); + + // Act + await action.CaptureResponseAsync(mockContext.Object, approvalResponse, CancellationToken.None); + await action.CaptureResponseAsync(mockContext.Object, replayResponse, CancellationToken.None); + + // Assert + Assert.Equal(1, invocationCount); + Assert.Contains(mockContext.Invocations, i => + i.Method.Name == nameof(IWorkflowContext.QueueStateUpdateAsync) + && i.Arguments.Count >= 2 + && i.Arguments[1] is StringValue sv + && sv.Value == RegisteredResult); + Assert.DoesNotContain(mockContext.Invocations, i => + i.Method.Name == nameof(IWorkflowContext.QueueStateUpdateAsync) + && i.Arguments.Count >= 2 + && i.Arguments[1] is StringValue sv + && sv.Value == ReplayedResult); + } + /// /// A non-approval FunctionResultContent whose CallId equals this.Id is /// consumed and assigned to Output.Result when no pendings are tracked. From 6ef057474ca2a71ad896be253ed79f255441f4cf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:54:37 +0000 Subject: [PATCH 04/16] Fix netstandard approval build Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --- .../ObjectModel/InvokeFunctionToolExecutor.cs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs index 79d3a971521..d9ef5667858 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs @@ -126,12 +126,13 @@ public async ValueTask CaptureResponseAsync( bool autoSend = this.GetAutoSendValue(); bool requireApproval = this.GetRequireApproval(); string? conversationId = this.GetConversationId(); - HashSet rejectedApprovalResultCallIds = response.Messages - .SelectMany(m => m.Contents) - .OfType() - .Where(r => this._approvalSnapshots.ContainsKey(r.CallId)) - .Select(r => r.CallId) - .ToHashSet(StringComparer.Ordinal); + HashSet rejectedApprovalResultCallIds = new( + response.Messages + .SelectMany(m => m.Contents) + .OfType() + .Where(r => this._approvalSnapshots.ContainsKey(r.CallId)) + .Select(r => r.CallId), + StringComparer.Ordinal); // Match the inbound result by its per-invocation call id. FunctionResultContent? matchingResult = response.Messages From 16f91eb65b72211b74fc79504aaeb76bd8df30c1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:46:28 +0000 Subject: [PATCH 05/16] Stabilize concurrent checkpoint smoke tests Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --- .../SampleSmokeTest.cs | 45 +++++-------------- 1 file changed, 12 insertions(+), 33 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SampleSmokeTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SampleSmokeTest.cs index fa1ce7b974f..eb4e9589a55 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SampleSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SampleSmokeTest.cs @@ -117,17 +117,7 @@ internal async Task Test_RunSample_Step5Async(ExecutionEnvironment environment) { using StringWriter writer = new(); - VerifyingPlaybackResponder responder = new( - // Iteration 1 - ("Guess the number.", 50), - ("Your guess was too high. Try again.", 23), - - // Iteration 2 - ("Your guess was too high. Try again.", 23), - ("Your guess was too low. Try again.", 42) - ); - - string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext, environment.ToWorkflowExecutionEnvironment()); + string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: GetGuessResponse, environment.ToWorkflowExecutionEnvironment()); Assert.Equal("You guessed correctly! You Win!", guessResult); } @@ -139,17 +129,7 @@ internal async Task Test_RunSample_Step5aAsync(ExecutionEnvironment environment) { using StringWriter writer = new(); - VerifyingPlaybackResponder responder = new( - // Iteration 1 - ("Guess the number.", 50), - ("Your guess was too high. Try again.", 23), - - // Iteration 2 - ("Your guess was too high. Try again.", 23), - ("Your guess was too low. Try again.", 42) - ); - - string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext, environment.ToWorkflowExecutionEnvironment(), rehydrateToRestore: true); + string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: GetGuessResponse, environment.ToWorkflowExecutionEnvironment(), rehydrateToRestore: true); Assert.Equal("You guessed correctly! You Win!", guessResult); } @@ -161,21 +141,11 @@ internal async Task Test_RunSample_Step5bAsync(ExecutionEnvironment environment) { using StringWriter writer = new(); - VerifyingPlaybackResponder responder = new( - // Iteration 1 - ("Guess the number.", 50), - ("Your guess was too high. Try again.", 23), - - // Iteration 2 - ("Your guess was too high. Try again.", 23), - ("Your guess was too low. Try again.", 42) - ); - JsonSerializerOptions options = new(SampleJsonContext.Default.Options); options.MakeReadOnly(); CheckpointManager memoryJsonManager = CheckpointManager.CreateJson(new InMemoryJsonStore(), options); - string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext, environment.ToWorkflowExecutionEnvironment(), rehydrateToRestore: true, checkpointManager: memoryJsonManager); + string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: GetGuessResponse, environment.ToWorkflowExecutionEnvironment(), rehydrateToRestore: true, checkpointManager: memoryJsonManager); Assert.Equal("You guessed correctly! You Win!", guessResult); } @@ -555,6 +525,15 @@ internal async Task Test_RunSample_Step14a_SharedState_IsolatedAcrossSubworkflow Assert.IsType(actualError); } + + private static int GetGuessResponse(string prompt) => + prompt switch + { + "Guess the number." => 50, + "Your guess was too high. Try again." => 23, + "Your guess was too low. Try again." => 42, + _ => throw new InvalidOperationException($"Unexpected guess prompt: {prompt}") + }; } internal sealed class VerifyingPlaybackResponder From cee1123db54b49edc50da0f1134fcdde52abf20b Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Wed, 16 Sep 2026 13:01:33 -0400 Subject: [PATCH 06/16] fix: makes approval sequence consistent across snapshots --- .../ObjectModel/InvokeFunctionToolExecutor.cs | 60 ++++-- .../InvokeFunctionToolExecutorTest.cs | 187 ++++++++++++++++++ 2 files changed, 229 insertions(+), 18 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs index d9ef5667858..20e800337d1 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs @@ -29,6 +29,7 @@ internal sealed class InvokeFunctionToolExecutor( DeclarativeActionExecutor(model, state) { private const string ApprovalSnapshotStateKey = nameof(_approvalSnapshots); + private const string ApprovalRequiredCallIdsStateKey = nameof(_approvalRequiredCallIds); private const string PendingCallIdsStateKey = nameof(_pendingNonApprovalCallIds); private const string LegacyApprovalSnapshotStateKey = "_approvalSnapshot"; @@ -39,6 +40,13 @@ internal sealed class InvokeFunctionToolExecutor( /// private readonly ConcurrentDictionary _approvalSnapshots = new(StringComparer.Ordinal); + /// + /// Call ids that were classified as approval-required when emitted, retained after + /// approval snapshot consumption so replayed caller-supplied function results are + /// rejected consistently. + /// + private readonly ConcurrentDictionary _approvalRequiredCallIds = new(StringComparer.Ordinal); + /// /// Per-invocation call ids for in-flight non-approval requests; used to match the /// returning on the response path. Used as a set; @@ -94,6 +102,8 @@ public static class Steps // Capture the evaluated parameters keyed by request id; the matching response // resumes from this snapshot. this._approvalSnapshots[requestId] = new ApprovalSnapshot(functionName, arguments); + this._approvalRequiredCallIds.TryAdd(requestId, 0); + this._approvalRequiredCallIds.TryAdd(this.Id, 0); requestMessage.Contents.Add(new ToolApprovalRequestContent(requestId, functionCall)); } @@ -124,15 +134,7 @@ public async ValueTask CaptureResponseAsync( CancellationToken cancellationToken) { bool autoSend = this.GetAutoSendValue(); - bool requireApproval = this.GetRequireApproval(); string? conversationId = this.GetConversationId(); - HashSet rejectedApprovalResultCallIds = new( - response.Messages - .SelectMany(m => m.Contents) - .OfType() - .Where(r => this._approvalSnapshots.ContainsKey(r.CallId)) - .Select(r => r.CallId), - StringComparer.Ordinal); // Match the inbound result by its per-invocation call id. FunctionResultContent? matchingResult = response.Messages @@ -145,7 +147,7 @@ public async ValueTask CaptureResponseAsync( // already routed the response to this executor's port and the framework does // not invoke a function here. if (matchingResult is null - && !requireApproval + && this._approvalRequiredCallIds.IsEmpty && this._pendingNonApprovalCallIds.IsEmpty && this._approvalSnapshots.IsEmpty) { @@ -221,7 +223,7 @@ public async ValueTask CaptureResponseAsync( { await this.AssignAsync( this.Model.Output.Messages?.Path, - this.GetSideEffectMessages(response.Messages, rejectedApprovalResultCallIds).ToFormula(), + this.GetSideEffectMessages(response.Messages).ToFormula(), context).ConfigureAwait(false); } @@ -231,7 +233,7 @@ await this.AssignAsync( // actual AI-generated tool calls and would be rejected by the API. if (conversationId is not null) { - foreach (ChatMessage message in TransformConversationMessages(this.GetSideEffectMessages(response.Messages, rejectedApprovalResultCallIds))) + foreach (ChatMessage message in TransformConversationMessages(this.GetSideEffectMessages(response.Messages))) { await agentProvider.CreateMessageAsync(conversationId, message, cancellationToken).ConfigureAwait(false); } @@ -241,34 +243,38 @@ await this.AssignAsync( await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false); } - private IEnumerable GetSideEffectMessages(IEnumerable messages, HashSet rejectedApprovalResultCallIds) + private IEnumerable GetSideEffectMessages(IEnumerable messages) { - if (rejectedApprovalResultCallIds.Count == 0) + if (this._approvalRequiredCallIds.IsEmpty) { return messages; } - return FilterRejectedApprovalResults(messages, rejectedApprovalResultCallIds); + return this.FilterRejectedApprovalResults(messages); } /// public override ValueTask ResetAsync() { this._approvalSnapshots.Clear(); + this._approvalRequiredCallIds.Clear(); this._pendingNonApprovalCallIds.Clear(); return default; } /// /// - /// Persists pending approval snapshots and non-approval call ids so they survive - /// checkpoint/restore cycles. + /// Persists approval classifications, pending approval snapshots, and non-approval + /// call ids so they survive checkpoint/restore cycles. /// protected override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default) { Dictionary snapshotCopy = this._approvalSnapshots.ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.Ordinal); await context.QueueStateUpdateAsync(ApprovalSnapshotStateKey, snapshotCopy, null, cancellationToken).ConfigureAwait(false); + List approvalRequiredCopy = [.. this._approvalRequiredCallIds.Keys]; + await context.QueueStateUpdateAsync(ApprovalRequiredCallIdsStateKey, approvalRequiredCopy, null, cancellationToken).ConfigureAwait(false); + List pendingCopy = [.. this._pendingNonApprovalCallIds.Keys]; await context.QueueStateUpdateAsync(PendingCallIdsStateKey, pendingCopy, null, cancellationToken).ConfigureAwait(false); @@ -285,6 +291,7 @@ protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext co await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false); this._approvalSnapshots.Clear(); + this._approvalRequiredCallIds.Clear(); Dictionary? snapshots = await context.ReadStateAsync>( ApprovalSnapshotStateKey, null, cancellationToken).ConfigureAwait(false); if (snapshots is not null) @@ -295,6 +302,22 @@ protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext co } } + List? approvalRequired = await context.ReadStateAsync>( + ApprovalRequiredCallIdsStateKey, null, cancellationToken).ConfigureAwait(false); + if (approvalRequired is not null) + { + foreach (string id in approvalRequired) + { + this._approvalRequiredCallIds.TryAdd(id, 0); + } + } + + foreach (string snapshotId in this._approvalSnapshots.Keys) + { + this._approvalRequiredCallIds.TryAdd(snapshotId, 0); + this._approvalRequiredCallIds.TryAdd(this.Id, 0); + } + this._pendingNonApprovalCallIds.Clear(); List? pending = await context.ReadStateAsync>( PendingCallIdsStateKey, null, cancellationToken).ConfigureAwait(false); @@ -313,6 +336,7 @@ protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext co if (legacy is not null) { this._approvalSnapshots.TryAdd(this.Id, legacy); + this._approvalRequiredCallIds.TryAdd(this.Id, 0); await context.QueueStateUpdateAsync( LegacyApprovalSnapshotStateKey, null, null, cancellationToken).ConfigureAwait(false); } @@ -365,12 +389,12 @@ private static IEnumerable TransformConversationMessages(IEnumerabl } } - private static IEnumerable FilterRejectedApprovalResults(IEnumerable messages, HashSet rejectedApprovalResultCallIds) + private IEnumerable FilterRejectedApprovalResults(IEnumerable messages) { foreach (ChatMessage message in messages) { List contents = - [.. message.Contents.Where(c => c is not FunctionResultContent functionResult || !rejectedApprovalResultCallIds.Contains(functionResult.CallId))]; + [.. message.Contents.Where(c => c is not FunctionResultContent functionResult || !this._approvalRequiredCallIds.ContainsKey(functionResult.CallId))]; if (contents.Count == message.Contents.Count) { yield return message; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs index 69b85fcd60a..7bc7956d008 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs @@ -1312,6 +1312,166 @@ public async Task InvokeFunctionToolPostApprovalLegacyReplayDoesNotOverwriteResu && sv.Value == ReplayedResult); } + /// + /// After an approval-required invocation consumes its snapshot, a replayed legacy + /// result using the executor id must not be assigned to Output.Messages or + /// persisted to conversation history. + /// + [Fact] + public async Task InvokeFunctionToolPostApprovalLegacyReplayDoesNotAssignOutputMessagesOrCreateConversationMessageAsync() + { + // Arrange + const string FunctionName = "any_function"; + const string ConversationId = "TestConversationId"; + const string MessagesVariable = "Messages"; + const string ReplayedResult = "replayed-result"; + + this.State.InitializeSystem(); + this.State.Bind(); + InvokeFunctionTool model = this.CreateModel( + displayName: nameof(InvokeFunctionToolPostApprovalLegacyReplayDoesNotAssignOutputMessagesOrCreateConversationMessageAsync), + functionName: FunctionName, + requireApproval: true, + conversationId: ConversationId, + outputMessagesVariable: MessagesVariable); + + MockAgentProvider mockAgentProvider = new(); + InvokeFunctionToolExecutor action = new(model, mockAgentProvider.Object, this.State); + + List emittedRequests = []; + Mock mockContext = CreateMockWorkflowContext(emittedRequests); + await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None); + + ExternalInputResponse approvalResponse = CreateApprovalResponseFor(emittedRequests, approved: true); + FunctionResultContent replayedLegacyResult = new(action.Id, ReplayedResult); + ExternalInputResponse replayResponse = new(new ChatMessage(ChatRole.Tool, [replayedLegacyResult])); + + // Act + await action.CaptureResponseAsync(mockContext.Object, approvalResponse, CancellationToken.None); + mockContext.Invocations.Clear(); + mockAgentProvider.TestMessages.Clear(); + await action.CaptureResponseAsync(mockContext.Object, replayResponse, CancellationToken.None); + + // Assert + TableValue assignedMessages = mockContext.Invocations + .Where(i => i.Method.Name == nameof(IWorkflowContext.QueueStateUpdateAsync) + && i.Arguments.Count >= 2) + .Select(i => i.Arguments[1]) + .OfType() + .Single(); + Assert.Empty(assignedMessages.Rows); + Assert.Empty(mockAgentProvider.TestMessages); + } + + /// + /// After an approval-required invocation is rejected and consumes its snapshot, a replayed + /// result using the approval request id must not be assigned to Output.Messages or + /// persisted to conversation history. + /// + [Fact] + public async Task InvokeFunctionToolPostRejectionReplayDoesNotAssignOutputMessagesOrCreateConversationMessageAsync() + { + // Arrange + const string FunctionName = "any_function"; + const string ConversationId = "TestConversationId"; + const string MessagesVariable = "Messages"; + const string ReplayedResult = "replayed-result"; + + this.State.InitializeSystem(); + this.State.Bind(); + InvokeFunctionTool model = this.CreateModel( + displayName: nameof(InvokeFunctionToolPostRejectionReplayDoesNotAssignOutputMessagesOrCreateConversationMessageAsync), + functionName: FunctionName, + requireApproval: true, + conversationId: ConversationId, + outputMessagesVariable: MessagesVariable); + + MockAgentProvider mockAgentProvider = new(); + InvokeFunctionToolExecutor action = new(model, mockAgentProvider.Object, this.State); + + List emittedRequests = []; + Mock mockContext = CreateMockWorkflowContext(emittedRequests); + await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None); + + ToolApprovalRequestContent approvalRequest = GetApprovalRequest(emittedRequests); + ExternalInputResponse rejectionResponse = CreateApprovalResponseFor(emittedRequests, approved: false); + FunctionResultContent replayedApprovalResult = new(approvalRequest.RequestId, ReplayedResult); + ExternalInputResponse replayResponse = new(new ChatMessage(ChatRole.Tool, [replayedApprovalResult])); + + // Act + await action.CaptureResponseAsync(mockContext.Object, rejectionResponse, CancellationToken.None); + mockContext.Invocations.Clear(); + mockAgentProvider.TestMessages.Clear(); + await action.CaptureResponseAsync(mockContext.Object, replayResponse, CancellationToken.None); + + // Assert + TableValue assignedMessages = mockContext.Invocations + .Where(i => i.Method.Name == nameof(IWorkflowContext.QueueStateUpdateAsync) + && i.Arguments.Count >= 2) + .Select(i => i.Arguments[1]) + .OfType() + .Single(); + Assert.Empty(assignedMessages.Rows); + Assert.Empty(mockAgentProvider.TestMessages); + } + + /// + /// The approval mode captured at emit time must gate legacy replay matching even if + /// the mutable RequireApproval expression evaluates to false after approval. + /// + [Fact] + public async Task InvokeFunctionToolPostApprovalLegacyReplayUsesEmittedApprovalModeAsync() + { + // Arrange + const string FunctionName = "any_function"; + const string RequireApprovalVariable = "NeedsApproval"; + const string ResultVariable = "Result"; + const string RegisteredResult = "registered-result"; + const string ReplayedResult = "replayed-result"; + + this.State.Set(RequireApprovalVariable, FormulaValue.New(true)); + this.State.InitializeSystem(); + this.State.Bind(); + InvokeFunctionTool model = this.CreateModelWithVariableRequireApproval( + displayName: nameof(InvokeFunctionToolPostApprovalLegacyReplayUsesEmittedApprovalModeAsync), + functionName: FunctionName, + requireApprovalVariableName: RequireApprovalVariable, + outputResultVariable: ResultVariable); + + int invocationCount = 0; + TestFunctionAgentProvider testAgentProvider = new( + [AIFunctionFactory.Create(() => { Interlocked.Increment(ref invocationCount); return RegisteredResult; }, name: FunctionName)]); + InvokeFunctionToolExecutor action = new(model, testAgentProvider, this.State); + + List emittedRequests = []; + Mock mockContext = CreateMockWorkflowContext(emittedRequests); + await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None); + + this.State.Set(RequireApprovalVariable, FormulaValue.New(false)); + this.State.Bind(); + + ExternalInputResponse approvalResponse = CreateApprovalResponseFor(emittedRequests, approved: true); + FunctionResultContent replayedLegacyResult = new(action.Id, ReplayedResult); + ExternalInputResponse replayResponse = new(new ChatMessage(ChatRole.Tool, [replayedLegacyResult])); + + // Act + await action.CaptureResponseAsync(mockContext.Object, approvalResponse, CancellationToken.None); + await action.CaptureResponseAsync(mockContext.Object, replayResponse, CancellationToken.None); + + // Assert + Assert.Equal(1, invocationCount); + Assert.Contains(mockContext.Invocations, i => + i.Method.Name == nameof(IWorkflowContext.QueueStateUpdateAsync) + && i.Arguments.Count >= 2 + && i.Arguments[1] is StringValue sv + && sv.Value == RegisteredResult); + Assert.DoesNotContain(mockContext.Invocations, i => + i.Method.Name == nameof(IWorkflowContext.QueueStateUpdateAsync) + && i.Arguments.Count >= 2 + && i.Arguments[1] is StringValue sv + && sv.Value == ReplayedResult); + } + /// /// A non-approval FunctionResultContent whose CallId equals this.Id is /// consumed and assigned to Output.Result when no pendings are tracked. @@ -1424,6 +1584,15 @@ private static ExternalInputResponse CreateApprovalResponseFor(IReadOnlyList emittedRequests) + { + return Assert.Single(emittedRequests) + .AgentResponse.Messages + .SelectMany(m => m.Contents) + .OfType() + .Single(); + } + private static Mock CreateMockWorkflowContext(List? emittedRequests = null) { Mock mockContext = new(); @@ -1670,5 +1839,23 @@ private InvokeFunctionTool CreateModelWithVariableArgument( return AssignParent(builder); } + private InvokeFunctionTool CreateModelWithVariableRequireApproval( + string displayName, string functionName, string requireApprovalVariableName, string outputResultVariable) + { + InvokeFunctionTool.Builder builder = new() + { + Id = this.CreateActionId(), + DisplayName = this.FormatDisplayName(displayName), + FunctionName = new StringExpression.Builder(StringExpression.Literal(functionName)), + RequireApproval = new BoolExpression.Builder( + BoolExpression.Variable(PropertyPath.TopicVariable(requireApprovalVariableName))), + Output = new InvokeToolOutput.Builder + { + Result = new InitializablePropertyPath(PropertyPath.TopicVariable(outputResultVariable), isInitializer: false), + }, + }; + return AssignParent(builder); + } + #endregion } From b3a0b031f041db67359b9b8a4e65ca861d886297 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Wed, 16 Sep 2026 13:15:48 -0400 Subject: [PATCH 07/16] fix: avoid duplicating approval keys across snapshots --- .../ObjectModel/InvokeFunctionToolExecutor.cs | 58 +++++++----------- .../InvokeFunctionToolExecutorTest.cs | 59 +++++++++++++++++++ 2 files changed, 79 insertions(+), 38 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs index 20e800337d1..68b244c6ae6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs @@ -29,7 +29,7 @@ internal sealed class InvokeFunctionToolExecutor( DeclarativeActionExecutor(model, state) { private const string ApprovalSnapshotStateKey = nameof(_approvalSnapshots); - private const string ApprovalRequiredCallIdsStateKey = nameof(_approvalRequiredCallIds); + private const string HasApprovalRequiredInvocationStateKey = nameof(_hasApprovalRequiredInvocation); private const string PendingCallIdsStateKey = nameof(_pendingNonApprovalCallIds); private const string LegacyApprovalSnapshotStateKey = "_approvalSnapshot"; @@ -40,12 +40,7 @@ internal sealed class InvokeFunctionToolExecutor( /// private readonly ConcurrentDictionary _approvalSnapshots = new(StringComparer.Ordinal); - /// - /// Call ids that were classified as approval-required when emitted, retained after - /// approval snapshot consumption so replayed caller-supplied function results are - /// rejected consistently. - /// - private readonly ConcurrentDictionary _approvalRequiredCallIds = new(StringComparer.Ordinal); + private bool _hasApprovalRequiredInvocation; /// /// Per-invocation call ids for in-flight non-approval requests; used to match the @@ -102,8 +97,7 @@ public static class Steps // Capture the evaluated parameters keyed by request id; the matching response // resumes from this snapshot. this._approvalSnapshots[requestId] = new ApprovalSnapshot(functionName, arguments); - this._approvalRequiredCallIds.TryAdd(requestId, 0); - this._approvalRequiredCallIds.TryAdd(this.Id, 0); + this._hasApprovalRequiredInvocation = true; requestMessage.Contents.Add(new ToolApprovalRequestContent(requestId, functionCall)); } @@ -147,7 +141,7 @@ public async ValueTask CaptureResponseAsync( // already routed the response to this executor's port and the framework does // not invoke a function here. if (matchingResult is null - && this._approvalRequiredCallIds.IsEmpty + && !this._hasApprovalRequiredInvocation && this._pendingNonApprovalCallIds.IsEmpty && this._approvalSnapshots.IsEmpty) { @@ -212,10 +206,6 @@ public async ValueTask CaptureResponseAsync( AgentResponse resultResponse = new([new ChatMessage(ChatRole.Tool, [matchingResult])]); await context.AddEventAsync(new AgentResponseEvent(this.Id, resultResponse), cancellationToken).ConfigureAwait(false); } - - // Drop the per-invocation entry now that the response has been processed. - this._pendingNonApprovalCallIds.TryRemove(matchingResult.CallId, out _); - this._approvalSnapshots.TryRemove(matchingResult.CallId, out _); } // Store messages if output path is configured @@ -239,13 +229,20 @@ await this.AssignAsync( } } + if (matchingResult is not null) + { + // Drop the per-invocation entry now that the response side effects have been processed. + this._pendingNonApprovalCallIds.TryRemove(matchingResult.CallId, out _); + this._approvalSnapshots.TryRemove(matchingResult.CallId, out _); + } + // Completes the action after processing the function result. await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false); } private IEnumerable GetSideEffectMessages(IEnumerable messages) { - if (this._approvalRequiredCallIds.IsEmpty) + if (!this._hasApprovalRequiredInvocation) { return messages; } @@ -257,14 +254,14 @@ private IEnumerable GetSideEffectMessages(IEnumerable public override ValueTask ResetAsync() { this._approvalSnapshots.Clear(); - this._approvalRequiredCallIds.Clear(); + this._hasApprovalRequiredInvocation = false; this._pendingNonApprovalCallIds.Clear(); return default; } /// /// - /// Persists approval classifications, pending approval snapshots, and non-approval + /// Persists approval classification, pending approval snapshots, and non-approval /// call ids so they survive checkpoint/restore cycles. /// protected override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default) @@ -272,8 +269,7 @@ protected override async ValueTask OnCheckpointingAsync(IWorkflowContext context Dictionary snapshotCopy = this._approvalSnapshots.ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.Ordinal); await context.QueueStateUpdateAsync(ApprovalSnapshotStateKey, snapshotCopy, null, cancellationToken).ConfigureAwait(false); - List approvalRequiredCopy = [.. this._approvalRequiredCallIds.Keys]; - await context.QueueStateUpdateAsync(ApprovalRequiredCallIdsStateKey, approvalRequiredCopy, null, cancellationToken).ConfigureAwait(false); + await context.QueueStateUpdateAsync(HasApprovalRequiredInvocationStateKey, this._hasApprovalRequiredInvocation, null, cancellationToken).ConfigureAwait(false); List pendingCopy = [.. this._pendingNonApprovalCallIds.Keys]; await context.QueueStateUpdateAsync(PendingCallIdsStateKey, pendingCopy, null, cancellationToken).ConfigureAwait(false); @@ -289,9 +285,7 @@ protected override async ValueTask OnCheckpointingAsync(IWorkflowContext context protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default) { await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false); - this._approvalSnapshots.Clear(); - this._approvalRequiredCallIds.Clear(); Dictionary? snapshots = await context.ReadStateAsync>( ApprovalSnapshotStateKey, null, cancellationToken).ConfigureAwait(false); if (snapshots is not null) @@ -302,21 +296,9 @@ protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext co } } - List? approvalRequired = await context.ReadStateAsync>( - ApprovalRequiredCallIdsStateKey, null, cancellationToken).ConfigureAwait(false); - if (approvalRequired is not null) - { - foreach (string id in approvalRequired) - { - this._approvalRequiredCallIds.TryAdd(id, 0); - } - } - - foreach (string snapshotId in this._approvalSnapshots.Keys) - { - this._approvalRequiredCallIds.TryAdd(snapshotId, 0); - this._approvalRequiredCallIds.TryAdd(this.Id, 0); - } + this._hasApprovalRequiredInvocation = + await context.ReadStateAsync(HasApprovalRequiredInvocationStateKey, null, cancellationToken).ConfigureAwait(false) + || !this._approvalSnapshots.IsEmpty; this._pendingNonApprovalCallIds.Clear(); List? pending = await context.ReadStateAsync>( @@ -336,7 +318,7 @@ protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext co if (legacy is not null) { this._approvalSnapshots.TryAdd(this.Id, legacy); - this._approvalRequiredCallIds.TryAdd(this.Id, 0); + this._hasApprovalRequiredInvocation = true; await context.QueueStateUpdateAsync( LegacyApprovalSnapshotStateKey, null, null, cancellationToken).ConfigureAwait(false); } @@ -394,7 +376,7 @@ private IEnumerable FilterRejectedApprovalResults(IEnumerable contents = - [.. message.Contents.Where(c => c is not FunctionResultContent functionResult || !this._approvalRequiredCallIds.ContainsKey(functionResult.CallId))]; + [.. message.Contents.Where(c => c is not FunctionResultContent functionResult || this._pendingNonApprovalCallIds.ContainsKey(functionResult.CallId))]; if (contents.Count == message.Contents.Count) { yield return message; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs index 7bc7956d008..fb0ea6a5df5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs @@ -472,6 +472,59 @@ public async Task InvokeFunctionToolCaptureResponseClearsSnapshotAfterCompletion Assert.Empty(snapshots); } + /// + /// Completed approval-required invocations must not leave per-request tombstones in + /// checkpoint state. + /// + [Fact] + public async Task InvokeFunctionToolCheckpointAfterCompletedApprovalsPersistsBoundedApprovalStateAsync() + { + // Arrange + const string FunctionName = "any_function"; + + this.State.InitializeSystem(); + this.State.Bind(); + + InvokeFunctionTool model = this.CreateModel( + displayName: nameof(InvokeFunctionToolCheckpointAfterCompletedApprovalsPersistsBoundedApprovalStateAsync), + functionName: FunctionName, + requireApproval: true); + + TestFunctionAgentProvider testAgentProvider = new( + [AIFunctionFactory.Create(() => "result", name: FunctionName)]); + InvokeFunctionToolExecutor action = new(model, testAgentProvider, this.State); + + List completedRequestIds = []; + Dictionary stateStore = []; + List emittedRequests = []; + Mock mockContext = CreateMockWorkflowContextWithStateStore(stateStore, emittedRequests); + + // Act - complete multiple approval-required invocations through the same executor. + for (int i = 0; i < 3; i++) + { + emittedRequests.Clear(); + await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None); + + ToolApprovalRequestContent approvalRequest = GetApprovalRequest(emittedRequests); + completedRequestIds.Add(approvalRequest.RequestId); + await action.CaptureResponseAsync(mockContext.Object, CreateApprovalResponseFor(emittedRequests, approved: true), CancellationToken.None); + } + + await InvokeProtectedMethodAsync(action, "OnCheckpointingAsync", mockContext.Object, CancellationToken.None); + + // Assert - checkpoint state stores one approval-history flag, not completed request ids. + bool hasApprovalRequiredInvocation = Assert.IsType(stateStore["_hasApprovalRequiredInvocation"]); + Assert.True(hasApprovalRequiredInvocation); + + Dictionary persistedSnapshots = Assert.IsType>(stateStore["_approvalSnapshots"]); + Assert.Empty(persistedSnapshots); + + List persistedPendingNonApprovalIds = Assert.IsType>(stateStore["_pendingNonApprovalCallIds"]); + Assert.Empty(persistedPendingNonApprovalIds); + + Assert.DoesNotContain(stateStore.Values.OfType>(), persistedIds => completedRequestIds.Any(persistedIds.Contains)); + } + /// /// Each ExecuteAsync invocation must produce a unique per-invocation request id on /// both the FunctionCallContent.CallId and the ToolApprovalRequestContent.RequestId. @@ -1631,6 +1684,9 @@ private static Mock CreateMockWorkflowContextWithStateStore( mockContext.Setup(c => c.QueueStateUpdateAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny())) .Callback, string?, CancellationToken>((key, value, _, _) => stateStore[key] = value) .Returns(default(ValueTask)); + mockContext.Setup(c => c.QueueStateUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((key, value, _, _) => stateStore[key] = value) + .Returns(default(ValueTask)); mockContext.Setup(c => c.QueueStateUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Callback((key, value, _, _) => { @@ -1659,6 +1715,9 @@ private static Mock CreateMockWorkflowContextWithStateStore( mockContext.Setup(c => c.ReadStateAsync>(It.IsAny(), It.IsAny(), It.IsAny())) .Returns((key, _, _) => new ValueTask?>(stateStore.TryGetValue(key, out object? val) ? val as List : null)); + mockContext.Setup(c => c.ReadStateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((key, _, _) => + new ValueTask(stateStore.TryGetValue(key, out object? val) && val is bool boolValue && boolValue)); mockContext.Setup(c => c.ReadStateAsync(It.IsAny(), It.IsAny(), It.IsAny())) .Returns((key, _, _) => new ValueTask(stateStore.TryGetValue(key, out object? val) ? val as ApprovalSnapshot : null)); From 46f824ced9095b87ec18dc47861c09c319b3afd9 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Wed, 16 Sep 2026 13:33:18 -0400 Subject: [PATCH 08/16] fix: mis correlation of concurrent execution in different modes --- .../Events/ExternalInputResponse.cs | 14 +- .../ObjectModel/InvokeFunctionToolExecutor.cs | 35 +++-- .../ExternalRequest.cs | 34 ++++- .../IExternalResponseEnvelope.cs | 17 +++ .../PublicAPI/net10.0/PublicAPI.Unshipped.txt | 2 + .../InvokeFunctionToolExecutorTest.cs | 134 ++++++++++++++++++ 6 files changed, 219 insertions(+), 17 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/IExternalResponseEnvelope.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputResponse.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputResponse.cs index 0653a12ce5b..552a893aace 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputResponse.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputResponse.cs @@ -9,13 +9,15 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Events; /// /// Represents the response to a . /// -public sealed class ExternalInputResponse +public sealed class ExternalInputResponse : IExternalResponseEnvelope { /// /// The message being provided as external input to the workflow. /// public IList Messages { get; } + internal string? RequestId { get; } + internal bool HasMessages => this.Messages?.Count > 0; /// @@ -23,8 +25,8 @@ public sealed class ExternalInputResponse /// /// The external input message being provided to the workflow. public ExternalInputResponse(ChatMessage message) + : this([message], requestId: null) { - this.Messages = [message]; } /// @@ -33,7 +35,15 @@ public ExternalInputResponse(ChatMessage message) /// The external input messages being provided to the workflow. [JsonConstructor] public ExternalInputResponse(IList messages) + : this(messages, requestId: null) + { + } + + internal ExternalInputResponse(IList messages, string? requestId) { this.Messages = messages; + this.RequestId = requestId; } + + object IExternalResponseEnvelope.WithRequestId(string requestId) => new ExternalInputResponse(this.Messages, requestId); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs index 68b244c6ae6..4bb85748770 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs @@ -129,12 +129,13 @@ public async ValueTask CaptureResponseAsync( { bool autoSend = this.GetAutoSendValue(); string? conversationId = this.GetConversationId(); + string? responseRequestId = response.RequestId; // Match the inbound result by its per-invocation call id. FunctionResultContent? matchingResult = response.Messages .SelectMany(m => m.Contents) .OfType() - .FirstOrDefault(r => this._pendingNonApprovalCallIds.ContainsKey(r.CallId)); + .FirstOrDefault(r => this.IsCorrelatedPendingNonApprovalResult(r, responseRequestId)); // Legacy non-approval backstop: when no pendings are tracked and approval is // not required, accept a result whose CallId equals this.Id. The runtime has @@ -148,7 +149,8 @@ public async ValueTask CaptureResponseAsync( matchingResult = response.Messages .SelectMany(m => m.Contents) .OfType() - .FirstOrDefault(r => string.Equals(r.CallId, this.Id, StringComparison.Ordinal)); + .FirstOrDefault(r => string.Equals(r.CallId, this.Id, StringComparison.Ordinal) + && IsCorrelatedResponse(r.CallId, responseRequestId)); } // When the caller approved an approval-required function call but didn't execute it @@ -166,8 +168,9 @@ public async ValueTask CaptureResponseAsync( // Prefer an approval matching a pending snapshot; otherwise take the first // present approval. ToolApprovalResponseContent? approval = - approvals.FirstOrDefault(r => this._approvalSnapshots.ContainsKey(r.RequestId)) - ?? approvals.FirstOrDefault(); + approvals.FirstOrDefault(r => this._approvalSnapshots.ContainsKey(r.RequestId) + && IsCorrelatedResponse(r.RequestId, responseRequestId)) + ?? approvals.FirstOrDefault(r => IsCorrelatedResponse(r.RequestId, responseRequestId)); if (approval is not null) { @@ -213,7 +216,7 @@ public async ValueTask CaptureResponseAsync( { await this.AssignAsync( this.Model.Output.Messages?.Path, - this.GetSideEffectMessages(response.Messages).ToFormula(), + this.GetSideEffectMessages(response).ToFormula(), context).ConfigureAwait(false); } @@ -223,7 +226,7 @@ await this.AssignAsync( // actual AI-generated tool calls and would be rejected by the API. if (conversationId is not null) { - foreach (ChatMessage message in TransformConversationMessages(this.GetSideEffectMessages(response.Messages))) + foreach (ChatMessage message in TransformConversationMessages(this.GetSideEffectMessages(response))) { await agentProvider.CreateMessageAsync(conversationId, message, cancellationToken).ConfigureAwait(false); } @@ -240,14 +243,14 @@ await this.AssignAsync( await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false); } - private IEnumerable GetSideEffectMessages(IEnumerable messages) + private IEnumerable GetSideEffectMessages(ExternalInputResponse response) { if (!this._hasApprovalRequiredInvocation) { - return messages; + return response.Messages; } - return this.FilterRejectedApprovalResults(messages); + return this.FilterRejectedApprovalResults(response); } /// @@ -371,12 +374,13 @@ private static IEnumerable TransformConversationMessages(IEnumerabl } } - private IEnumerable FilterRejectedApprovalResults(IEnumerable messages) + private IEnumerable FilterRejectedApprovalResults(ExternalInputResponse response) { - foreach (ChatMessage message in messages) + foreach (ChatMessage message in response.Messages) { List contents = - [.. message.Contents.Where(c => c is not FunctionResultContent functionResult || this._pendingNonApprovalCallIds.ContainsKey(functionResult.CallId))]; + [.. message.Contents.Where(c => c is not FunctionResultContent functionResult + || this.IsCorrelatedPendingNonApprovalResult(functionResult, response.RequestId))]; if (contents.Count == message.Contents.Count) { yield return message; @@ -397,6 +401,13 @@ private IEnumerable FilterRejectedApprovalResults(IEnumerable + this._pendingNonApprovalCallIds.ContainsKey(functionResult.CallId) + && IsCorrelatedResponse(functionResult.CallId, responseRequestId); + + private static bool IsCorrelatedResponse(string contentId, string? responseRequestId) => + responseRequestId is null || string.Equals(contentId, responseRequestId, StringComparison.Ordinal); + private async ValueTask AssignResultAsync(IWorkflowContext context, FunctionResultContent result) { if (this.Model.Output?.Result is null) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ExternalRequest.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ExternalRequest.cs index e1c1765349c..d7d2538e8a0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/ExternalRequest.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ExternalRequest.cs @@ -3,6 +3,7 @@ using System; using System.Diagnostics.CodeAnalysis; using Microsoft.Agents.AI.Workflows.Checkpointing; +using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Workflows; @@ -77,13 +78,15 @@ public static ExternalRequest Create(RequestPort port, [NotNull] object data, st /// Thrown when the input data object does not match the expected response type. public ExternalResponse CreateResponse(object data) { - if (!Throw.IfNull(this.PortInfo).ResponseType.IsMatchPolymorphic(Throw.IfNull(data).GetType())) + object responseData = this.CorrelateResponseEnvelope(Throw.IfNull(data)); + + if (!Throw.IfNull(this.PortInfo).ResponseType.IsMatchPolymorphic(responseData.GetType())) { throw new InvalidOperationException( - $"Message type {data.GetType().Name} does not match expected response type {this.PortInfo.ResponseType.TypeName} of input port {this.PortInfo.PortId}."); + $"Message type {responseData.GetType().Name} does not match expected response type {this.PortInfo.ResponseType.TypeName} of input port {this.PortInfo.PortId}."); } - return new ExternalResponse(this.PortInfo, this.RequestId, new PortableValue(data)); + return new ExternalResponse(this.PortInfo, this.RequestId, new PortableValue(responseData)); } internal ExternalResponse RewrapResponse(ExternalResponse response) @@ -98,4 +101,29 @@ internal ExternalResponse RewrapResponse(ExternalResponse response) /// The data contained in the response. /// An instance corresponding to this request with the specified data. public ExternalResponse CreateResponse(T data) => this.CreateResponse((object)Throw.IfNull(data)); + + private object CorrelateResponseEnvelope(object data) + { + if (data is not IExternalResponseEnvelope responseEnvelope) + { + return data; + } + + return responseEnvelope.WithRequestId(this.GetInnerRequestContentId() ?? this.RequestId); + } + + private string? GetInnerRequestContentId() + { + if (!this.Data.Is(out IExternalRequestEnvelope? requestEnvelope)) + { + return null; + } + + return requestEnvelope.GetInnerRequestContent() switch + { + FunctionCallContent functionCall => functionCall.CallId, + ToolApprovalRequestContent toolApprovalRequest => toolApprovalRequest.RequestId, + _ => null, + }; + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/IExternalResponseEnvelope.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/IExternalResponseEnvelope.cs new file mode 100644 index 00000000000..375b1088c35 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/IExternalResponseEnvelope.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Workflows; + +/// +/// Optional interface implemented by response payload types that need correlation +/// with the request payload that produced them. +/// +public interface IExternalResponseEnvelope +{ + /// + /// Creates a response envelope correlated to the specified request id. + /// + /// The request id that produced this response. + /// A response envelope correlated to . + object WithRequestId(string requestId); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net10.0/PublicAPI.Unshipped.txt index ab058de62d4..8ab68d6aa87 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +Microsoft.Agents.AI.Workflows.IExternalResponseEnvelope +Microsoft.Agents.AI.Workflows.IExternalResponseEnvelope.WithRequestId(string! requestId) -> object! diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs index fb0ea6a5df5..73f7b463749 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs @@ -696,6 +696,117 @@ public async Task InvokeFunctionToolNonApprovalCallIdsAreDistinctPerInvocationAs Assert.NotEqual(action.Id, fcc2.CallId); } + /// + /// A non-approval function result must not satisfy a concurrent approval-required + /// invocation just because its call id is pending on the same executor. + /// + [Fact] + public async Task InvokeFunctionToolApprovalResponseRejectsConcurrentNonApprovalResultAsync() + { + // Arrange + const string FunctionName = "any_function"; + const string RequireApprovalVariable = "NeedsApproval"; + const string ResultVariable = "Result"; + const string CrossModeResult = "cross-mode-result"; + + this.State.Set(RequireApprovalVariable, FormulaValue.New(false)); + this.State.InitializeSystem(); + this.State.Bind(); + InvokeFunctionTool model = this.CreateModelWithVariableRequireApproval( + displayName: nameof(InvokeFunctionToolApprovalResponseRejectsConcurrentNonApprovalResultAsync), + functionName: FunctionName, + requireApprovalVariableName: RequireApprovalVariable, + outputResultVariable: ResultVariable); + + TestFunctionAgentProvider testAgentProvider = new( + [AIFunctionFactory.Create(() => "registered-result", name: FunctionName)]); + InvokeFunctionToolExecutor action = new(model, testAgentProvider, this.State); + + List emittedRequests = []; + Mock mockContext = CreateMockWorkflowContext(emittedRequests); + + await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None); + string nonApprovalCallId = GetFunctionCall(emittedRequests[0]).CallId; + + this.State.Set(RequireApprovalVariable, FormulaValue.New(true)); + this.State.Bind(); + await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None); + string approvalRequestId = GetApprovalRequest(emittedRequests[1]).RequestId; + + ExternalInputResponse crossModeResponse = CreateFunctionResultResponse( + requestId: approvalRequestId, + callId: nonApprovalCallId, + result: CrossModeResult); + + // Act + await action.CaptureResponseAsync(mockContext.Object, crossModeResponse, CancellationToken.None); + + // Assert + Assert.DoesNotContain(mockContext.Invocations, i => + i.Method.Name == nameof(IWorkflowContext.QueueStateUpdateAsync) + && i.Arguments.Count >= 2 + && i.Arguments[1] is StringValue sv + && sv.Value == CrossModeResult); + Assert.Contains(mockContext.Invocations, i => + i.Method.Name == nameof(IWorkflowContext.QueueStateUpdateAsync) + && i.Arguments.Count >= 2 + && i.Arguments[1] is StringValue sv + && sv.Value.Contains("No pending approval")); + } + + /// + /// A non-approval result remains valid while an approval-required invocation is pending + /// when the response is correlated to the non-approval request that emitted it. + /// + [Fact] + public async Task InvokeFunctionToolConcurrentNonApprovalResultIsAcceptedForItsOwnRequestAsync() + { + // Arrange + const string FunctionName = "any_function"; + const string RequireApprovalVariable = "NeedsApproval"; + const string ResultVariable = "Result"; + const string NonApprovalResult = "non-approval-result"; + + this.State.Set(RequireApprovalVariable, FormulaValue.New(false)); + this.State.InitializeSystem(); + this.State.Bind(); + InvokeFunctionTool model = this.CreateModelWithVariableRequireApproval( + displayName: nameof(InvokeFunctionToolConcurrentNonApprovalResultIsAcceptedForItsOwnRequestAsync), + functionName: FunctionName, + requireApprovalVariableName: RequireApprovalVariable, + outputResultVariable: ResultVariable); + + TestFunctionAgentProvider testAgentProvider = new( + [AIFunctionFactory.Create(() => "registered-result", name: FunctionName)]); + InvokeFunctionToolExecutor action = new(model, testAgentProvider, this.State); + + List emittedRequests = []; + Mock mockContext = CreateMockWorkflowContext(emittedRequests); + + await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None); + string nonApprovalCallId = GetFunctionCall(emittedRequests[0]).CallId; + + this.State.Set(RequireApprovalVariable, FormulaValue.New(true)); + this.State.Bind(); + await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None); + Assert.NotNull(GetApprovalRequest(emittedRequests[1])); + + ExternalInputResponse nonApprovalResponse = CreateFunctionResultResponse( + requestId: nonApprovalCallId, + callId: nonApprovalCallId, + result: NonApprovalResult); + + // Act + await action.CaptureResponseAsync(mockContext.Object, nonApprovalResponse, CancellationToken.None); + + // Assert + Assert.Contains(mockContext.Invocations, i => + i.Method.Name == nameof(IWorkflowContext.QueueStateUpdateAsync) + && i.Arguments.Count >= 2 + && i.Arguments[1] is StringValue sv + && sv.Value == NonApprovalResult); + } + /// /// A snapshot persisted at the legacy "_approvalSnapshot" key must be migrated /// under this.Id after restore so an approval response carrying @@ -1646,6 +1757,29 @@ private static ToolApprovalRequestContent GetApprovalRequest(IReadOnlyList m.Contents) + .OfType() + .Single(); + } + + private static FunctionCallContent GetFunctionCall(ExternalInputRequest emittedRequest) + { + return emittedRequest.AgentResponse.Messages + .SelectMany(m => m.Contents) + .OfType() + .Single(); + } + + private static ExternalInputResponse CreateFunctionResultResponse(string requestId, string callId, string result) + { + return new ExternalInputResponse( + [new ChatMessage(ChatRole.Tool, [new FunctionResultContent(callId, result)])], + requestId); + } + private static Mock CreateMockWorkflowContext(List? emittedRequests = null) { Mock mockContext = new(); From e6c443b5ef038054125f7de1abb1dcbecf5c9b69 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Wed, 16 Sep 2026 13:45:34 -0400 Subject: [PATCH 09/16] chore: linting --- .../Events/ExternalInputResponse.cs | 5 +++- .../PublicAPI/net10.0/PublicAPI.Unshipped.txt | 2 ++ .../PublicAPI/net472/PublicAPI.Unshipped.txt | 2 ++ .../PublicAPI/net8.0/PublicAPI.Unshipped.txt | 2 ++ .../PublicAPI/net9.0/PublicAPI.Unshipped.txt | 2 ++ .../netstandard2.0/PublicAPI.Unshipped.txt | 2 ++ .../PublicAPI/net472/PublicAPI.Unshipped.txt | 2 ++ .../PublicAPI/net8.0/PublicAPI.Unshipped.txt | 2 ++ .../PublicAPI/net9.0/PublicAPI.Unshipped.txt | 2 ++ .../netstandard2.0/PublicAPI.Unshipped.txt | 2 ++ .../DeclarativeWorkflowJsonOptionsTests.cs | 6 ++++- .../InvokeFunctionToolExecutorTest.cs | 24 ++++++++++++++----- 12 files changed, 45 insertions(+), 8 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputResponse.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputResponse.cs index 552a893aace..5b017cb7801 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputResponse.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputResponse.cs @@ -16,7 +16,10 @@ public sealed class ExternalInputResponse : IExternalResponseEnvelope /// public IList Messages { get; } - internal string? RequestId { get; } + /// + /// Gets the request id that produced this response, if available. + /// + public string? RequestId { get; init; } internal bool HasMessages => this.Messages?.Count > 0; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net10.0/PublicAPI.Unshipped.txt index ab058de62d4..cec93d55cd8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +Microsoft.Agents.AI.Workflows.Declarative.Events.ExternalInputResponse.RequestId.get -> string? +Microsoft.Agents.AI.Workflows.Declarative.Events.ExternalInputResponse.RequestId.init -> void diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net472/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net472/PublicAPI.Unshipped.txt index ab058de62d4..cec93d55cd8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net472/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net472/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +Microsoft.Agents.AI.Workflows.Declarative.Events.ExternalInputResponse.RequestId.get -> string? +Microsoft.Agents.AI.Workflows.Declarative.Events.ExternalInputResponse.RequestId.init -> void diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net8.0/PublicAPI.Unshipped.txt index ab058de62d4..cec93d55cd8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +Microsoft.Agents.AI.Workflows.Declarative.Events.ExternalInputResponse.RequestId.get -> string? +Microsoft.Agents.AI.Workflows.Declarative.Events.ExternalInputResponse.RequestId.init -> void diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net9.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net9.0/PublicAPI.Unshipped.txt index ab058de62d4..cec93d55cd8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net9.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net9.0/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +Microsoft.Agents.AI.Workflows.Declarative.Events.ExternalInputResponse.RequestId.get -> string? +Microsoft.Agents.AI.Workflows.Declarative.Events.ExternalInputResponse.RequestId.init -> void diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index ab058de62d4..cec93d55cd8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +Microsoft.Agents.AI.Workflows.Declarative.Events.ExternalInputResponse.RequestId.get -> string? +Microsoft.Agents.AI.Workflows.Declarative.Events.ExternalInputResponse.RequestId.init -> void diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net472/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net472/PublicAPI.Unshipped.txt index ab058de62d4..8ab68d6aa87 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net472/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net472/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +Microsoft.Agents.AI.Workflows.IExternalResponseEnvelope +Microsoft.Agents.AI.Workflows.IExternalResponseEnvelope.WithRequestId(string! requestId) -> object! diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net8.0/PublicAPI.Unshipped.txt index ab058de62d4..8ab68d6aa87 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +Microsoft.Agents.AI.Workflows.IExternalResponseEnvelope +Microsoft.Agents.AI.Workflows.IExternalResponseEnvelope.WithRequestId(string! requestId) -> object! diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net9.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net9.0/PublicAPI.Unshipped.txt index ab058de62d4..8ab68d6aa87 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net9.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net9.0/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +Microsoft.Agents.AI.Workflows.IExternalResponseEnvelope +Microsoft.Agents.AI.Workflows.IExternalResponseEnvelope.WithRequestId(string! requestId) -> object! diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index ab058de62d4..8ab68d6aa87 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +Microsoft.Agents.AI.Workflows.IExternalResponseEnvelope +Microsoft.Agents.AI.Workflows.IExternalResponseEnvelope.WithRequestId(string! requestId) -> object! diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowJsonOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowJsonOptionsTests.cs index eb3b2c8aad4..217b1f66faa 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowJsonOptionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowJsonOptionsTests.cs @@ -67,11 +67,15 @@ public void ExternalInputRequest_RoundTrip_WithApprovalAndFunctionCallContent() [Fact] public void ExternalInputResponse_RoundTrip() { - ExternalInputResponse copy = RoundTrip(new ExternalInputResponse(new ChatMessage(ChatRole.User, "ok"))); + ExternalInputResponse copy = RoundTrip(new ExternalInputResponse(new ChatMessage(ChatRole.User, "ok")) + { + RequestId = "request-1", + }); ChatMessage messageCopy = Assert.Single(copy.Messages); Assert.Equal(ChatRole.User, messageCopy.Role); Assert.Equal("ok", messageCopy.Text); + Assert.Equal("request-1", copy.RequestId); } [Fact] diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs index 73f7b463749..9842fa58082 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs @@ -734,7 +734,8 @@ public async Task InvokeFunctionToolApprovalResponseRejectsConcurrentNonApproval string approvalRequestId = GetApprovalRequest(emittedRequests[1]).RequestId; ExternalInputResponse crossModeResponse = CreateFunctionResultResponse( - requestId: approvalRequestId, + request: emittedRequests[1], + expectedRequestId: approvalRequestId, callId: nonApprovalCallId, result: CrossModeResult); @@ -792,7 +793,8 @@ public async Task InvokeFunctionToolConcurrentNonApprovalResultIsAcceptedForItsO Assert.NotNull(GetApprovalRequest(emittedRequests[1])); ExternalInputResponse nonApprovalResponse = CreateFunctionResultResponse( - requestId: nonApprovalCallId, + request: emittedRequests[0], + expectedRequestId: nonApprovalCallId, callId: nonApprovalCallId, result: NonApprovalResult); @@ -1773,11 +1775,21 @@ private static FunctionCallContent GetFunctionCall(ExternalInputRequest emittedR .Single(); } - private static ExternalInputResponse CreateFunctionResultResponse(string requestId, string callId, string result) + private static ExternalInputResponse CreateFunctionResultResponse( + ExternalInputRequest request, + string expectedRequestId, + string callId, + string result) { - return new ExternalInputResponse( - [new ChatMessage(ChatRole.Tool, [new FunctionResultContent(callId, result)])], - requestId); + RequestPort port = RequestPort.Create("test-port"); + ExternalRequest externalRequest = ExternalRequest.Create(port, request); + ExternalInputResponse response = new([new ChatMessage(ChatRole.Tool, [new FunctionResultContent(callId, result)])]); + ExternalResponse externalResponse = externalRequest.CreateResponse(response); + + Assert.True(externalResponse.TryGetDataAs(out ExternalInputResponse? correlatedResponse)); + Assert.NotNull(correlatedResponse); + Assert.Equal(expectedRequestId, correlatedResponse.RequestId); + return correlatedResponse; } private static Mock CreateMockWorkflowContext(List? emittedRequests = null) From 3685bfa358044732ea2008a401e79ffae2abb80c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:07:43 +0000 Subject: [PATCH 10/16] Fix external response correlation Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --- .../ExternalRequest.cs | 36 +++++++++++++++++- .../InProc/InProcessRunnerContext.cs | 2 + .../ExternalResponsePortCorrelationTests.cs | 28 ++++++++++++++ .../JsonSerializationTests.cs | 24 ++++++++++++ .../TestExternalEnvelopes.cs | 38 +++++++++++++++++++ .../TestJsonContext.cs | 2 + 6 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestExternalEnvelopes.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ExternalRequest.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ExternalRequest.cs index d7d2538e8a0..21be3a4021b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/ExternalRequest.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ExternalRequest.cs @@ -2,6 +2,7 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; using Microsoft.Agents.AI.Workflows.Checkpointing; using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; @@ -57,7 +58,10 @@ public static ExternalRequest Create(RequestPort port, [NotNull] object data, st requestId ??= Guid.NewGuid().ToString("N"); - return new ExternalRequest(port.ToPortInfo(), requestId, new PortableValue(data)); + return new ExternalRequest(port.ToPortInfo(), requestId, new PortableValue(data)) + { + InnerRequestContentId = GetInnerRequestContentId(data), + }; } /// @@ -91,9 +95,12 @@ public ExternalResponse CreateResponse(object data) internal ExternalResponse RewrapResponse(ExternalResponse response) { - return new ExternalResponse(this.PortInfo, this.RequestId, response.Data); + return new ExternalResponse(this.PortInfo, this.RequestId, this.CorrelateResponseEnvelope(response.Data)); } + [JsonInclude] + internal string? InnerRequestContentId { get; init; } + /// /// Creates a new corresponding to the request, with the speicified data payload. /// @@ -112,13 +119,38 @@ private object CorrelateResponseEnvelope(object data) return responseEnvelope.WithRequestId(this.GetInnerRequestContentId() ?? this.RequestId); } + private PortableValue CorrelateResponseEnvelope(PortableValue data) + { + if (!data.Is(out IExternalResponseEnvelope? responseEnvelope)) + { + return data; + } + + return new PortableValue(responseEnvelope.WithRequestId(this.GetInnerRequestContentId() ?? this.RequestId)); + } + private string? GetInnerRequestContentId() { + if (this.InnerRequestContentId is not null) + { + return this.InnerRequestContentId; + } + if (!this.Data.Is(out IExternalRequestEnvelope? requestEnvelope)) { return null; } + return GetInnerRequestContentId(requestEnvelope); + } + + private static string? GetInnerRequestContentId(object data) => + data is IExternalRequestEnvelope requestEnvelope + ? GetInnerRequestContentId(requestEnvelope) + : null; + + private static string? GetInnerRequestContentId(IExternalRequestEnvelope requestEnvelope) + { return requestEnvelope.GetInnerRequestContent() switch { FunctionCallContent functionCall => functionCall.CallId, diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs index 8ccfb957294..353c38f25c9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs @@ -167,6 +167,8 @@ async ValueTask PrepareExternalDeliveryAsync() $"Response port id '{response.PortInfo.PortId}' does not match the originating port id for request {response.RequestId}."); } + response = pendingRequest.RewrapResponse(response); + // Consume only after validation so a rejected response leaves the legitimate one able to complete. if (!this.CompleteRequest(response.RequestId)) { diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ExternalResponsePortCorrelationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ExternalResponsePortCorrelationTests.cs index 863950527ef..1a02fb51cf0 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ExternalResponsePortCorrelationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ExternalResponsePortCorrelationTests.cs @@ -5,6 +5,7 @@ using Microsoft.Agents.AI.Workflows.Checkpointing; using Microsoft.Agents.AI.Workflows.Execution; using Microsoft.Agents.AI.Workflows.InProc; +using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows.UnitTests; @@ -119,4 +120,31 @@ public async Task AddExternalResponseAsync_RejectsUnknownRequestIdAsync() var exception = await Assert.ThrowsAsync((System.Func>)actAsync); Assert.Contains("No pending request with ID no-such-request", exception.Message); } + + [Fact] + public void ExternalRequest_RewrapResponse_OverwritesCallerControlledEnvelopeRequestId() + { + const string OuterRequestId = "outer-request"; + const string InnerRequestId = "inner-request"; + const string ForgedRequestId = "forged-request"; + + RequestPort port = RequestPort.Create("EnvelopePort"); + ExternalRequest pending = ExternalRequest.Create( + port, + new TestExternalRequestEnvelope(new FunctionCallContent(InnerRequestId, "test_function")), + OuterRequestId); + ExternalResponse forged = new( + port.ToPortInfo(), + OuterRequestId, + new PortableValue(new TestExternalResponseEnvelope( + [new ChatMessage(ChatRole.Tool, [new FunctionResultContent(InnerRequestId, "ok")])], + ForgedRequestId))); + + ExternalResponse rewrapped = pending.RewrapResponse(forged); + + Assert.Equal(OuterRequestId, rewrapped.RequestId); + Assert.True(rewrapped.TryGetDataAs(out TestExternalResponseEnvelope? envelope)); + Assert.NotNull(envelope); + Assert.Equal(InnerRequestId, envelope.RequestId); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs index 580ff8209db..e827a78f9a5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs @@ -374,6 +374,30 @@ public void Test_ExternalRequest_JsonRoundtrip() ValidateExternalRequest(result, TestExternalRequest); } + [Fact] + public void Test_ExternalRequest_CreateResponse_JsonRestoredEnvelopeUsesInnerRequestId() + { + const string OuterRequestId = "outer-request"; + const string InnerRequestId = "inner-request"; + + RequestPort port = RequestPort.Create("EnvelopePort"); + ExternalRequest source = ExternalRequest.Create( + port, + new TestExternalRequestEnvelope(new FunctionCallContent(InnerRequestId, "test_function")), + OuterRequestId); + ExternalRequest restored = RunJsonRoundtrip(source, TestCustomSerializedJsonOptions); + + ExternalResponse response = restored.CreateResponse( + new TestExternalResponseEnvelope( + [new ChatMessage(ChatRole.Tool, [new FunctionResultContent(InnerRequestId, "ok")])], + requestId: null)); + + Assert.Equal(OuterRequestId, response.RequestId); + Assert.True(response.TryGetDataAs(out TestExternalResponseEnvelope? envelope)); + Assert.NotNull(envelope); + Assert.Equal(InnerRequestId, envelope.RequestId); + } + private static ExternalResponse TestExternalResponse => TestExternalRequest.CreateResponse(123); [Fact] diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestExternalEnvelopes.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestExternalEnvelopes.cs new file mode 100644 index 00000000000..4a83222dac2 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestExternalEnvelopes.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +internal sealed class TestExternalRequestEnvelope : IExternalRequestEnvelope +{ + [JsonConstructor] + public TestExternalRequestEnvelope(FunctionCallContent functionCall) + { + this.FunctionCall = functionCall; + } + + public FunctionCallContent FunctionCall { get; } + + AIContent? IExternalRequestEnvelope.GetInnerRequestContent() => this.FunctionCall; + + object IExternalRequestEnvelope.CreateResponse(IList messages) => new TestExternalResponseEnvelope(messages, requestId: null); +} + +internal sealed class TestExternalResponseEnvelope : IExternalResponseEnvelope +{ + [JsonConstructor] + public TestExternalResponseEnvelope(IList messages, string? requestId) + { + this.Messages = messages; + this.RequestId = requestId; + } + + public IList Messages { get; } + + public string? RequestId { get; } + + object IExternalResponseEnvelope.WithRequestId(string requestId) => new TestExternalResponseEnvelope(this.Messages, requestId); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestJsonContext.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestJsonContext.cs index aaba9423d7d..cc735c9df1a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestJsonContext.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestJsonContext.cs @@ -7,5 +7,7 @@ namespace Microsoft.Agents.AI.Workflows.UnitTests; // Checkpointing Types [JsonSerializable(typeof(TestJsonSerializable))] +[JsonSerializable(typeof(TestExternalRequestEnvelope))] +[JsonSerializable(typeof(TestExternalResponseEnvelope))] [ExcludeFromCodeCoverage] internal sealed partial class TestJsonContext : JsonSerializerContext; From 18b16491b2877e87cbf36215dbca002e6e7c83e5 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Wed, 16 Sep 2026 14:37:47 -0400 Subject: [PATCH 11/16] chore: formatting Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../TestExternalEnvelopes.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestExternalEnvelopes.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestExternalEnvelopes.cs index 4a83222dac2..bd14ade0c0b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestExternalEnvelopes.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestExternalEnvelopes.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; using System.Text.Json.Serialization; From 1f47e1c979fbb9ce0e4cb066c2e6c7bbece42d1b Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Wed, 16 Sep 2026 15:03:33 -0400 Subject: [PATCH 12/16] fix: wrap the external request to make the id accessible --- .../ExternalRequest.cs | 16 ++++++--- .../InProc/InProcessRunnerContext.cs | 3 +- .../Specialized/RequestInfoExecutor.cs | 2 +- .../ExternalResponsePortCorrelationTests.cs | 34 ++++++++++++++++++- 4 files changed, 47 insertions(+), 8 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ExternalRequest.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ExternalRequest.cs index 21be3a4021b..7bde0f0d05f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/ExternalRequest.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ExternalRequest.cs @@ -93,9 +93,9 @@ public ExternalResponse CreateResponse(object data) return new ExternalResponse(this.PortInfo, this.RequestId, new PortableValue(responseData)); } - internal ExternalResponse RewrapResponse(ExternalResponse response) + internal ExternalResponse RewrapResponse(ExternalResponse response, Type responseType) { - return new ExternalResponse(this.PortInfo, this.RequestId, this.CorrelateResponseEnvelope(response.Data)); + return new ExternalResponse(this.PortInfo, this.RequestId, this.CorrelateResponseEnvelope(response.Data, responseType)); } [JsonInclude] @@ -119,14 +119,20 @@ private object CorrelateResponseEnvelope(object data) return responseEnvelope.WithRequestId(this.GetInnerRequestContentId() ?? this.RequestId); } - private PortableValue CorrelateResponseEnvelope(PortableValue data) + private PortableValue CorrelateResponseEnvelope(PortableValue data, Type responseType) { - if (!data.Is(out IExternalResponseEnvelope? responseEnvelope)) + if (data.Value is IExternalResponseEnvelope responseEnvelope) + { + return new PortableValue(responseEnvelope.WithRequestId(this.GetInnerRequestContentId() ?? this.RequestId)); + } + + if (!typeof(IExternalResponseEnvelope).IsAssignableFrom(responseType) + || data.AsType(responseType) is not IExternalResponseEnvelope deserializedResponseEnvelope) { return data; } - return new PortableValue(responseEnvelope.WithRequestId(this.GetInnerRequestContentId() ?? this.RequestId)); + return new PortableValue(deserializedResponseEnvelope.WithRequestId(this.GetInnerRequestContentId() ?? this.RequestId)); } private string? GetInnerRequestContentId() diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs index 353c38f25c9..5888d342881 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs @@ -167,7 +167,8 @@ async ValueTask PrepareExternalDeliveryAsync() $"Response port id '{response.PortInfo.PortId}' does not match the originating port id for request {response.RequestId}."); } - response = pendingRequest.RewrapResponse(response); + Type pendingResponseType = this._workflow.Ports[pendingRequest.PortInfo.PortId].Response; + response = pendingRequest.RewrapResponse(response, pendingResponseType); // Consume only after validation so a rejected response leaves the legitimate one able to complete. if (!this.CompleteRequest(response.RequestId)) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/RequestInfoExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/RequestInfoExecutor.cs index 52dae66b881..dad575e7b0d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/RequestInfoExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/RequestInfoExecutor.cs @@ -132,7 +132,7 @@ public async ValueTask HandleAsync(object message, IWorkflowCon if (this._allowWrapped && this._wrappedRequests.TryGetValue(message.RequestId, out ExternalRequest? originalRequest)) { - await context.SendMessageAsync(originalRequest.RewrapResponse(message), cancellationToken: cancellationToken).ConfigureAwait(false); + await context.SendMessageAsync(originalRequest.RewrapResponse(message, this.Port.Response), cancellationToken: cancellationToken).ConfigureAwait(false); this._wrappedRequests.Remove(message.RequestId); } else diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ExternalResponsePortCorrelationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ExternalResponsePortCorrelationTests.cs index 1a02fb51cf0..589568b1192 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ExternalResponsePortCorrelationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ExternalResponsePortCorrelationTests.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Checkpointing; @@ -140,7 +141,38 @@ public void ExternalRequest_RewrapResponse_OverwritesCallerControlledEnvelopeReq [new ChatMessage(ChatRole.Tool, [new FunctionResultContent(InnerRequestId, "ok")])], ForgedRequestId))); - ExternalResponse rewrapped = pending.RewrapResponse(forged); + ExternalResponse rewrapped = pending.RewrapResponse(forged, port.Response); + + Assert.Equal(OuterRequestId, rewrapped.RequestId); + Assert.True(rewrapped.TryGetDataAs(out TestExternalResponseEnvelope? envelope)); + Assert.NotNull(envelope); + Assert.Equal(InnerRequestId, envelope.RequestId); + } + + [Fact] + public void ExternalRequest_RewrapResponse_OverwritesJsonRestoredEnvelopeRequestId() + { + const string OuterRequestId = "outer-request"; + const string InnerRequestId = "inner-request"; + const string ForgedRequestId = "forged-request"; + + RequestPort port = RequestPort.Create("EnvelopePort"); + ExternalRequest pending = ExternalRequest.Create( + port, + new TestExternalRequestEnvelope(new FunctionCallContent(InnerRequestId, "test_function")), + OuterRequestId); + ExternalResponse forged = new( + port.ToPortInfo(), + OuterRequestId, + new PortableValue(new TestExternalResponseEnvelope( + [new ChatMessage(ChatRole.Tool, [new FunctionResultContent(InnerRequestId, "ok")])], + ForgedRequestId))); + + JsonSerializerOptions jsonOptions = new(TestJsonContext.Default.Options); + jsonOptions.MakeReadOnly(); + ExternalResponse restored = JsonSerializationTests.RunJsonRoundtrip(forged, jsonOptions); + + ExternalResponse rewrapped = pending.RewrapResponse(restored, port.Response); Assert.Equal(OuterRequestId, rewrapped.RequestId); Assert.True(rewrapped.TryGetDataAs(out TestExternalResponseEnvelope? envelope)); From 496ba593b43eff19c1493f87f1e19b501307d480 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Thu, 17 Sep 2026 10:33:57 -0400 Subject: [PATCH 13/16] fix: safe port lookup --- .../InProc/InProcessRunnerContext.cs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs index 5888d342881..2caa11046bc 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs @@ -37,6 +37,7 @@ internal sealed class InProcessRunnerContext : IRunnerContext private readonly ConcurrentDictionary _joinedSubworkflowRunners = new(); private readonly ConcurrentDictionary _externalRequests = new(); + private readonly ConcurrentDictionary _registeredPorts; public InProcessRunnerContext( Workflow workflow, @@ -63,6 +64,7 @@ public InProcessRunnerContext( this._workflow = workflow; this._sessionId = sessionId; + this._registeredPorts = new(workflow.Ports); this._edgeMap = new(this, this._workflow, stepTracer); this._outputFilter = new(workflow); @@ -79,6 +81,11 @@ public IExternalRequestSink RegisterPort(string executorId, RequestPort port) throw new InvalidOperationException($"A port with ID {port.Id} already exists."); } + if (!this._registeredPorts.TryAdd(port.Id, port)) + { + throw new InvalidOperationException($"A port with ID {port.Id} already exists."); + } + return this; } @@ -167,7 +174,12 @@ async ValueTask PrepareExternalDeliveryAsync() $"Response port id '{response.PortInfo.PortId}' does not match the originating port id for request {response.RequestId}."); } - Type pendingResponseType = this._workflow.Ports[pendingRequest.PortInfo.PortId].Response; + if (!this._registeredPorts.TryGetValue(pendingRequest.PortInfo.PortId, out RequestPort? pendingPort)) + { + throw new InvalidOperationException($"Port {pendingRequest.PortInfo.PortId} not found in the workflow context."); + } + + Type pendingResponseType = pendingPort.Response; response = pendingRequest.RewrapResponse(response, pendingResponseType); // Consume only after validation so a rejected response leaves the legitimate one able to complete. From 90a44bd32beb03441394a991ff9f2c67312398cb Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Thu, 17 Sep 2026 14:01:32 -0400 Subject: [PATCH 14/16] fix: removes extraneous interface Signed-off-by: Vincent Biret --- .../Events/ExternalInputRequest.cs | 17 +++- .../Events/ExternalInputResponse.cs | 6 +- .../PublicAPI/net10.0/PublicAPI.Unshipped.txt | 1 - .../PublicAPI/net472/PublicAPI.Unshipped.txt | 1 - .../PublicAPI/net8.0/PublicAPI.Unshipped.txt | 1 - .../PublicAPI/net9.0/PublicAPI.Unshipped.txt | 1 - .../netstandard2.0/PublicAPI.Unshipped.txt | 1 - .../ExternalRequest.cs | 79 ++----------------- .../IExternalResponseEnvelope.cs | 17 ---- .../InProc/InProcessRunnerContext.cs | 15 +--- .../PublicAPI/net10.0/PublicAPI.Unshipped.txt | 2 - .../PublicAPI/net472/PublicAPI.Unshipped.txt | 2 - .../PublicAPI/net8.0/PublicAPI.Unshipped.txt | 2 - .../PublicAPI/net9.0/PublicAPI.Unshipped.txt | 2 - .../netstandard2.0/PublicAPI.Unshipped.txt | 2 - .../Specialized/RequestInfoExecutor.cs | 2 +- .../DeclarativeWorkflowJsonOptionsTests.cs | 5 +- .../Events/ExternalInputRequestTest.cs | 43 ++++++++++ .../InvokeFunctionToolExecutorTest.cs | 9 +-- .../ExternalResponsePortCorrelationTests.cs | 60 -------------- .../JsonSerializationTests.cs | 24 ------ .../TestExternalEnvelopes.cs | 18 +---- .../TestJsonContext.cs | 1 - 23 files changed, 75 insertions(+), 236 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/IExternalResponseEnvelope.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputRequest.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputRequest.cs index c9fbe20b8f7..39a667e4540 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputRequest.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputRequest.cs @@ -38,7 +38,9 @@ internal ExternalInputRequest(string text) /// requireApproval: true) over so that /// hosts which speak the approval protocol see the approval-bearing content. /// - AIContent? IExternalRequestEnvelope.GetInnerRequestContent() + AIContent? IExternalRequestEnvelope.GetInnerRequestContent() => this.GetInnerRequestContent(); + + private AIContent? GetInnerRequestContent() { IList? messages = this.AgentResponse?.Messages; if (messages is null) @@ -73,5 +75,16 @@ internal ExternalInputRequest(string text) /// object IExternalRequestEnvelope.CreateResponse(IList messages) - => new ExternalInputResponse(messages); + => new ExternalInputResponse(messages) + { + RequestId = GetContentId(this.GetInnerRequestContent()), + }; + + private static string? GetContentId(AIContent? content) + => content switch + { + FunctionCallContent functionCall => functionCall.CallId, + ToolApprovalRequestContent toolApprovalRequest => toolApprovalRequest.RequestId, + _ => null, + }; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputResponse.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputResponse.cs index 5b017cb7801..fe13433b3ae 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputResponse.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputResponse.cs @@ -9,7 +9,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Events; /// /// Represents the response to a . /// -public sealed class ExternalInputResponse : IExternalResponseEnvelope +public sealed class ExternalInputResponse { /// /// The message being provided as external input to the workflow. @@ -19,7 +19,8 @@ public sealed class ExternalInputResponse : IExternalResponseEnvelope /// /// Gets the request id that produced this response, if available. /// - public string? RequestId { get; init; } + [JsonInclude] + public string? RequestId { get; internal init; } internal bool HasMessages => this.Messages?.Count > 0; @@ -48,5 +49,4 @@ internal ExternalInputResponse(IList messages, string? requestId) this.RequestId = requestId; } - object IExternalResponseEnvelope.WithRequestId(string requestId) => new ExternalInputResponse(this.Messages, requestId); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net10.0/PublicAPI.Unshipped.txt index cec93d55cd8..a36677538eb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -1,3 +1,2 @@ #nullable enable Microsoft.Agents.AI.Workflows.Declarative.Events.ExternalInputResponse.RequestId.get -> string? -Microsoft.Agents.AI.Workflows.Declarative.Events.ExternalInputResponse.RequestId.init -> void diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net472/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net472/PublicAPI.Unshipped.txt index cec93d55cd8..a36677538eb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net472/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net472/PublicAPI.Unshipped.txt @@ -1,3 +1,2 @@ #nullable enable Microsoft.Agents.AI.Workflows.Declarative.Events.ExternalInputResponse.RequestId.get -> string? -Microsoft.Agents.AI.Workflows.Declarative.Events.ExternalInputResponse.RequestId.init -> void diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net8.0/PublicAPI.Unshipped.txt index cec93d55cd8..a36677538eb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -1,3 +1,2 @@ #nullable enable Microsoft.Agents.AI.Workflows.Declarative.Events.ExternalInputResponse.RequestId.get -> string? -Microsoft.Agents.AI.Workflows.Declarative.Events.ExternalInputResponse.RequestId.init -> void diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net9.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net9.0/PublicAPI.Unshipped.txt index cec93d55cd8..a36677538eb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net9.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net9.0/PublicAPI.Unshipped.txt @@ -1,3 +1,2 @@ #nullable enable Microsoft.Agents.AI.Workflows.Declarative.Events.ExternalInputResponse.RequestId.get -> string? -Microsoft.Agents.AI.Workflows.Declarative.Events.ExternalInputResponse.RequestId.init -> void diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index cec93d55cd8..a36677538eb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -1,3 +1,2 @@ #nullable enable Microsoft.Agents.AI.Workflows.Declarative.Events.ExternalInputResponse.RequestId.get -> string? -Microsoft.Agents.AI.Workflows.Declarative.Events.ExternalInputResponse.RequestId.init -> void diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ExternalRequest.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ExternalRequest.cs index 7bde0f0d05f..419c4a50f29 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/ExternalRequest.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ExternalRequest.cs @@ -2,9 +2,7 @@ using System; using System.Diagnostics.CodeAnalysis; -using System.Text.Json.Serialization; using Microsoft.Agents.AI.Workflows.Checkpointing; -using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Workflows; @@ -58,10 +56,7 @@ public static ExternalRequest Create(RequestPort port, [NotNull] object data, st requestId ??= Guid.NewGuid().ToString("N"); - return new ExternalRequest(port.ToPortInfo(), requestId, new PortableValue(data)) - { - InnerRequestContentId = GetInnerRequestContentId(data), - }; + return new ExternalRequest(port.ToPortInfo(), requestId, new PortableValue(data)); } /// @@ -82,25 +77,21 @@ public static ExternalRequest Create(RequestPort port, [NotNull] object data, st /// Thrown when the input data object does not match the expected response type. public ExternalResponse CreateResponse(object data) { - object responseData = this.CorrelateResponseEnvelope(Throw.IfNull(data)); - - if (!Throw.IfNull(this.PortInfo).ResponseType.IsMatchPolymorphic(responseData.GetType())) + data = Throw.IfNull(data); + if (!Throw.IfNull(this.PortInfo).ResponseType.IsMatchPolymorphic(data.GetType())) { throw new InvalidOperationException( - $"Message type {responseData.GetType().Name} does not match expected response type {this.PortInfo.ResponseType.TypeName} of input port {this.PortInfo.PortId}."); + $"Message type {data.GetType().Name} does not match expected response type {this.PortInfo.ResponseType.TypeName} of input port {this.PortInfo.PortId}."); } - return new ExternalResponse(this.PortInfo, this.RequestId, new PortableValue(responseData)); + return new ExternalResponse(this.PortInfo, this.RequestId, new PortableValue(data)); } - internal ExternalResponse RewrapResponse(ExternalResponse response, Type responseType) + internal ExternalResponse RewrapResponse(ExternalResponse response) { - return new ExternalResponse(this.PortInfo, this.RequestId, this.CorrelateResponseEnvelope(response.Data, responseType)); + return new ExternalResponse(this.PortInfo, this.RequestId, response.Data); } - [JsonInclude] - internal string? InnerRequestContentId { get; init; } - /// /// Creates a new corresponding to the request, with the speicified data payload. /// @@ -108,60 +99,4 @@ internal ExternalResponse RewrapResponse(ExternalResponse response, Type respons /// The data contained in the response. /// An instance corresponding to this request with the specified data. public ExternalResponse CreateResponse(T data) => this.CreateResponse((object)Throw.IfNull(data)); - - private object CorrelateResponseEnvelope(object data) - { - if (data is not IExternalResponseEnvelope responseEnvelope) - { - return data; - } - - return responseEnvelope.WithRequestId(this.GetInnerRequestContentId() ?? this.RequestId); - } - - private PortableValue CorrelateResponseEnvelope(PortableValue data, Type responseType) - { - if (data.Value is IExternalResponseEnvelope responseEnvelope) - { - return new PortableValue(responseEnvelope.WithRequestId(this.GetInnerRequestContentId() ?? this.RequestId)); - } - - if (!typeof(IExternalResponseEnvelope).IsAssignableFrom(responseType) - || data.AsType(responseType) is not IExternalResponseEnvelope deserializedResponseEnvelope) - { - return data; - } - - return new PortableValue(deserializedResponseEnvelope.WithRequestId(this.GetInnerRequestContentId() ?? this.RequestId)); - } - - private string? GetInnerRequestContentId() - { - if (this.InnerRequestContentId is not null) - { - return this.InnerRequestContentId; - } - - if (!this.Data.Is(out IExternalRequestEnvelope? requestEnvelope)) - { - return null; - } - - return GetInnerRequestContentId(requestEnvelope); - } - - private static string? GetInnerRequestContentId(object data) => - data is IExternalRequestEnvelope requestEnvelope - ? GetInnerRequestContentId(requestEnvelope) - : null; - - private static string? GetInnerRequestContentId(IExternalRequestEnvelope requestEnvelope) - { - return requestEnvelope.GetInnerRequestContent() switch - { - FunctionCallContent functionCall => functionCall.CallId, - ToolApprovalRequestContent toolApprovalRequest => toolApprovalRequest.RequestId, - _ => null, - }; - } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/IExternalResponseEnvelope.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/IExternalResponseEnvelope.cs deleted file mode 100644 index 375b1088c35..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/IExternalResponseEnvelope.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.AI.Workflows; - -/// -/// Optional interface implemented by response payload types that need correlation -/// with the request payload that produced them. -/// -public interface IExternalResponseEnvelope -{ - /// - /// Creates a response envelope correlated to the specified request id. - /// - /// The request id that produced this response. - /// A response envelope correlated to . - object WithRequestId(string requestId); -} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs index 2caa11046bc..353c38f25c9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs @@ -37,7 +37,6 @@ internal sealed class InProcessRunnerContext : IRunnerContext private readonly ConcurrentDictionary _joinedSubworkflowRunners = new(); private readonly ConcurrentDictionary _externalRequests = new(); - private readonly ConcurrentDictionary _registeredPorts; public InProcessRunnerContext( Workflow workflow, @@ -64,7 +63,6 @@ public InProcessRunnerContext( this._workflow = workflow; this._sessionId = sessionId; - this._registeredPorts = new(workflow.Ports); this._edgeMap = new(this, this._workflow, stepTracer); this._outputFilter = new(workflow); @@ -81,11 +79,6 @@ public IExternalRequestSink RegisterPort(string executorId, RequestPort port) throw new InvalidOperationException($"A port with ID {port.Id} already exists."); } - if (!this._registeredPorts.TryAdd(port.Id, port)) - { - throw new InvalidOperationException($"A port with ID {port.Id} already exists."); - } - return this; } @@ -174,13 +167,7 @@ async ValueTask PrepareExternalDeliveryAsync() $"Response port id '{response.PortInfo.PortId}' does not match the originating port id for request {response.RequestId}."); } - if (!this._registeredPorts.TryGetValue(pendingRequest.PortInfo.PortId, out RequestPort? pendingPort)) - { - throw new InvalidOperationException($"Port {pendingRequest.PortInfo.PortId} not found in the workflow context."); - } - - Type pendingResponseType = pendingPort.Response; - response = pendingRequest.RewrapResponse(response, pendingResponseType); + response = pendingRequest.RewrapResponse(response); // Consume only after validation so a rejected response leaves the legitimate one able to complete. if (!this.CompleteRequest(response.RequestId)) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net10.0/PublicAPI.Unshipped.txt index 8ab68d6aa87..ab058de62d4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -1,3 +1 @@ #nullable enable -Microsoft.Agents.AI.Workflows.IExternalResponseEnvelope -Microsoft.Agents.AI.Workflows.IExternalResponseEnvelope.WithRequestId(string! requestId) -> object! diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net472/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net472/PublicAPI.Unshipped.txt index 8ab68d6aa87..ab058de62d4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net472/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net472/PublicAPI.Unshipped.txt @@ -1,3 +1 @@ #nullable enable -Microsoft.Agents.AI.Workflows.IExternalResponseEnvelope -Microsoft.Agents.AI.Workflows.IExternalResponseEnvelope.WithRequestId(string! requestId) -> object! diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net8.0/PublicAPI.Unshipped.txt index 8ab68d6aa87..ab058de62d4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -1,3 +1 @@ #nullable enable -Microsoft.Agents.AI.Workflows.IExternalResponseEnvelope -Microsoft.Agents.AI.Workflows.IExternalResponseEnvelope.WithRequestId(string! requestId) -> object! diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net9.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net9.0/PublicAPI.Unshipped.txt index 8ab68d6aa87..ab058de62d4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net9.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net9.0/PublicAPI.Unshipped.txt @@ -1,3 +1 @@ #nullable enable -Microsoft.Agents.AI.Workflows.IExternalResponseEnvelope -Microsoft.Agents.AI.Workflows.IExternalResponseEnvelope.WithRequestId(string! requestId) -> object! diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index 8ab68d6aa87..ab058de62d4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -1,3 +1 @@ #nullable enable -Microsoft.Agents.AI.Workflows.IExternalResponseEnvelope -Microsoft.Agents.AI.Workflows.IExternalResponseEnvelope.WithRequestId(string! requestId) -> object! diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/RequestInfoExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/RequestInfoExecutor.cs index dad575e7b0d..52dae66b881 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/RequestInfoExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/RequestInfoExecutor.cs @@ -132,7 +132,7 @@ public async ValueTask HandleAsync(object message, IWorkflowCon if (this._allowWrapped && this._wrappedRequests.TryGetValue(message.RequestId, out ExternalRequest? originalRequest)) { - await context.SendMessageAsync(originalRequest.RewrapResponse(message, this.Port.Response), cancellationToken: cancellationToken).ConfigureAwait(false); + await context.SendMessageAsync(originalRequest.RewrapResponse(message), cancellationToken: cancellationToken).ConfigureAwait(false); this._wrappedRequests.Remove(message.RequestId); } else diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowJsonOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowJsonOptionsTests.cs index 217b1f66faa..e6714ba52e9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowJsonOptionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowJsonOptionsTests.cs @@ -67,10 +67,7 @@ public void ExternalInputRequest_RoundTrip_WithApprovalAndFunctionCallContent() [Fact] public void ExternalInputResponse_RoundTrip() { - ExternalInputResponse copy = RoundTrip(new ExternalInputResponse(new ChatMessage(ChatRole.User, "ok")) - { - RequestId = "request-1", - }); + ExternalInputResponse copy = RoundTrip(new ExternalInputResponse([new ChatMessage(ChatRole.User, "ok")], "request-1")); ChatMessage messageCopy = Assert.Single(copy.Messages); Assert.Equal(ChatRole.User, messageCopy.Role); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/ExternalInputRequestTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/ExternalInputRequestTest.cs index d732c11099f..3dc68a8869c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/ExternalInputRequestTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/ExternalInputRequestTest.cs @@ -63,4 +63,47 @@ public void VerifySerializationWithRequests() TextContent textContent = AssertContent(messageCopy); Assert.Equal("Heya", textContent.Text); } + + [Fact] + public void CreateResponse_CorrelatesResponseToToolApprovalRequest() + { + // Arrange + ExternalInputRequest source = + new(new AgentResponse( + new ChatMessage( + ChatRole.Assistant, + [ + new ToolApprovalRequestContent("approval-call", new FunctionCallContent("function-call", "result1")), + new FunctionCallContent("other-call", "myfunc"), + ]))); + + // Act + object response = ((IExternalRequestEnvelope)source).CreateResponse( + [new ChatMessage(ChatRole.Tool, [new ToolApprovalResponseContent("forged-call", approved: true, new FunctionCallContent("forged-call", "result1"))])]); + + // Assert + ExternalInputResponse externalInputResponse = Assert.IsType(response); + Assert.Equal("approval-call", externalInputResponse.RequestId); + } + + [Fact] + public void CreateResponse_CorrelatesResponseToFunctionCallWhenApprovalIsAbsent() + { + // Arrange + ExternalInputRequest source = + new(new AgentResponse( + new ChatMessage( + ChatRole.Assistant, + [ + new FunctionCallContent("function-call", "myfunc"), + ]))); + + // Act + object response = ((IExternalRequestEnvelope)source).CreateResponse( + [new ChatMessage(ChatRole.Tool, [new FunctionResultContent("forged-call", "ok")])]); + + // Assert + ExternalInputResponse externalInputResponse = Assert.IsType(response); + Assert.Equal("function-call", externalInputResponse.RequestId); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs index 9842fa58082..3a0ee9e345e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs @@ -1781,13 +1781,10 @@ private static ExternalInputResponse CreateFunctionResultResponse( string callId, string result) { - RequestPort port = RequestPort.Create("test-port"); - ExternalRequest externalRequest = ExternalRequest.Create(port, request); - ExternalInputResponse response = new([new ChatMessage(ChatRole.Tool, [new FunctionResultContent(callId, result)])]); - ExternalResponse externalResponse = externalRequest.CreateResponse(response); + object response = ((IExternalRequestEnvelope)request).CreateResponse( + [new ChatMessage(ChatRole.Tool, [new FunctionResultContent(callId, result)])]); - Assert.True(externalResponse.TryGetDataAs(out ExternalInputResponse? correlatedResponse)); - Assert.NotNull(correlatedResponse); + ExternalInputResponse correlatedResponse = Assert.IsType(response); Assert.Equal(expectedRequestId, correlatedResponse.RequestId); return correlatedResponse; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ExternalResponsePortCorrelationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ExternalResponsePortCorrelationTests.cs index 589568b1192..863950527ef 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ExternalResponsePortCorrelationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ExternalResponsePortCorrelationTests.cs @@ -1,12 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. -using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Checkpointing; using Microsoft.Agents.AI.Workflows.Execution; using Microsoft.Agents.AI.Workflows.InProc; -using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows.UnitTests; @@ -121,62 +119,4 @@ public async Task AddExternalResponseAsync_RejectsUnknownRequestIdAsync() var exception = await Assert.ThrowsAsync((System.Func>)actAsync); Assert.Contains("No pending request with ID no-such-request", exception.Message); } - - [Fact] - public void ExternalRequest_RewrapResponse_OverwritesCallerControlledEnvelopeRequestId() - { - const string OuterRequestId = "outer-request"; - const string InnerRequestId = "inner-request"; - const string ForgedRequestId = "forged-request"; - - RequestPort port = RequestPort.Create("EnvelopePort"); - ExternalRequest pending = ExternalRequest.Create( - port, - new TestExternalRequestEnvelope(new FunctionCallContent(InnerRequestId, "test_function")), - OuterRequestId); - ExternalResponse forged = new( - port.ToPortInfo(), - OuterRequestId, - new PortableValue(new TestExternalResponseEnvelope( - [new ChatMessage(ChatRole.Tool, [new FunctionResultContent(InnerRequestId, "ok")])], - ForgedRequestId))); - - ExternalResponse rewrapped = pending.RewrapResponse(forged, port.Response); - - Assert.Equal(OuterRequestId, rewrapped.RequestId); - Assert.True(rewrapped.TryGetDataAs(out TestExternalResponseEnvelope? envelope)); - Assert.NotNull(envelope); - Assert.Equal(InnerRequestId, envelope.RequestId); - } - - [Fact] - public void ExternalRequest_RewrapResponse_OverwritesJsonRestoredEnvelopeRequestId() - { - const string OuterRequestId = "outer-request"; - const string InnerRequestId = "inner-request"; - const string ForgedRequestId = "forged-request"; - - RequestPort port = RequestPort.Create("EnvelopePort"); - ExternalRequest pending = ExternalRequest.Create( - port, - new TestExternalRequestEnvelope(new FunctionCallContent(InnerRequestId, "test_function")), - OuterRequestId); - ExternalResponse forged = new( - port.ToPortInfo(), - OuterRequestId, - new PortableValue(new TestExternalResponseEnvelope( - [new ChatMessage(ChatRole.Tool, [new FunctionResultContent(InnerRequestId, "ok")])], - ForgedRequestId))); - - JsonSerializerOptions jsonOptions = new(TestJsonContext.Default.Options); - jsonOptions.MakeReadOnly(); - ExternalResponse restored = JsonSerializationTests.RunJsonRoundtrip(forged, jsonOptions); - - ExternalResponse rewrapped = pending.RewrapResponse(restored, port.Response); - - Assert.Equal(OuterRequestId, rewrapped.RequestId); - Assert.True(rewrapped.TryGetDataAs(out TestExternalResponseEnvelope? envelope)); - Assert.NotNull(envelope); - Assert.Equal(InnerRequestId, envelope.RequestId); - } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs index e827a78f9a5..580ff8209db 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs @@ -374,30 +374,6 @@ public void Test_ExternalRequest_JsonRoundtrip() ValidateExternalRequest(result, TestExternalRequest); } - [Fact] - public void Test_ExternalRequest_CreateResponse_JsonRestoredEnvelopeUsesInnerRequestId() - { - const string OuterRequestId = "outer-request"; - const string InnerRequestId = "inner-request"; - - RequestPort port = RequestPort.Create("EnvelopePort"); - ExternalRequest source = ExternalRequest.Create( - port, - new TestExternalRequestEnvelope(new FunctionCallContent(InnerRequestId, "test_function")), - OuterRequestId); - ExternalRequest restored = RunJsonRoundtrip(source, TestCustomSerializedJsonOptions); - - ExternalResponse response = restored.CreateResponse( - new TestExternalResponseEnvelope( - [new ChatMessage(ChatRole.Tool, [new FunctionResultContent(InnerRequestId, "ok")])], - requestId: null)); - - Assert.Equal(OuterRequestId, response.RequestId); - Assert.True(response.TryGetDataAs(out TestExternalResponseEnvelope? envelope)); - Assert.NotNull(envelope); - Assert.Equal(InnerRequestId, envelope.RequestId); - } - private static ExternalResponse TestExternalResponse => TestExternalRequest.CreateResponse(123); [Fact] diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestExternalEnvelopes.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestExternalEnvelopes.cs index bd14ade0c0b..cae9a7f6594 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestExternalEnvelopes.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestExternalEnvelopes.cs @@ -18,21 +18,5 @@ public TestExternalRequestEnvelope(FunctionCallContent functionCall) AIContent? IExternalRequestEnvelope.GetInnerRequestContent() => this.FunctionCall; - object IExternalRequestEnvelope.CreateResponse(IList messages) => new TestExternalResponseEnvelope(messages, requestId: null); -} - -internal sealed class TestExternalResponseEnvelope : IExternalResponseEnvelope -{ - [JsonConstructor] - public TestExternalResponseEnvelope(IList messages, string? requestId) - { - this.Messages = messages; - this.RequestId = requestId; - } - - public IList Messages { get; } - - public string? RequestId { get; } - - object IExternalResponseEnvelope.WithRequestId(string requestId) => new TestExternalResponseEnvelope(this.Messages, requestId); + object IExternalRequestEnvelope.CreateResponse(IList messages) => messages; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestJsonContext.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestJsonContext.cs index cc735c9df1a..db8e8eda3a8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestJsonContext.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestJsonContext.cs @@ -8,6 +8,5 @@ namespace Microsoft.Agents.AI.Workflows.UnitTests; // Checkpointing Types [JsonSerializable(typeof(TestJsonSerializable))] [JsonSerializable(typeof(TestExternalRequestEnvelope))] -[JsonSerializable(typeof(TestExternalResponseEnvelope))] [ExcludeFromCodeCoverage] internal sealed partial class TestJsonContext : JsonSerializerContext; From 82cab6e79295a66a8f1d6a22d647661fcb9ff50c Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Fri, 18 Sep 2026 12:49:20 -0400 Subject: [PATCH 15/16] fix: use uppermost parent type where the properties are defined Signed-off-by: Vincent Biret --- .../Events/ExternalInputRequest.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputRequest.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputRequest.cs index 39a667e4540..fff360570f3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputRequest.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputRequest.cs @@ -83,8 +83,8 @@ object IExternalRequestEnvelope.CreateResponse(IList messages) private static string? GetContentId(AIContent? content) => content switch { - FunctionCallContent functionCall => functionCall.CallId, - ToolApprovalRequestContent toolApprovalRequest => toolApprovalRequest.RequestId, + ToolCallContent functionCall => functionCall.CallId, + InputRequestContent toolApprovalRequest => toolApprovalRequest.RequestId, _ => null, }; } From 936f122161e71f4e26bad96be1f0ae7bfe472eb1 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Fri, 18 Sep 2026 15:07:45 -0400 Subject: [PATCH 16/16] fix: registered results --- .../ObjectModel/InvokeFunctionToolExecutor.cs | 15 ++++- .../InvokeToolWorkflowTest.cs | 2 +- .../InvokeFunctionToolExecutorTest.cs | 59 ++++++++++++++++++- 3 files changed, 70 insertions(+), 6 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs index 4bb85748770..20699ee4aa3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs @@ -136,6 +136,7 @@ public async ValueTask CaptureResponseAsync( .SelectMany(m => m.Contents) .OfType() .FirstOrDefault(r => this.IsCorrelatedPendingNonApprovalResult(r, responseRequestId)); + FunctionResultContent? approvedFunctionResult = null; // Legacy non-approval backstop: when no pendings are tracked and approval is // not required, accept a result whose CallId equals this.Id. The runtime has @@ -186,6 +187,7 @@ public async ValueTask CaptureResponseAsync( else if (this._approvalSnapshots.TryRemove(approval.RequestId, out ApprovalSnapshot? snapshot)) { matchingResult = await this.InvokeRegisteredFunctionAsync(approval.RequestId, snapshot, cancellationToken).ConfigureAwait(false); + approvedFunctionResult = matchingResult; } else { @@ -226,7 +228,13 @@ await this.AssignAsync( // actual AI-generated tool calls and would be rejected by the API. if (conversationId is not null) { - foreach (ChatMessage message in TransformConversationMessages(this.GetSideEffectMessages(response))) + IEnumerable sideEffectMessages = this.GetSideEffectMessages(response); + if (approvedFunctionResult is not null) + { + sideEffectMessages = sideEffectMessages.Append(new ChatMessage(ChatRole.Tool, [approvedFunctionResult])); + } + + foreach (ChatMessage message in TransformConversationMessages(sideEffectMessages)) { await agentProvider.CreateMessageAsync(conversationId, message, cancellationToken).ConfigureAwait(false); } @@ -379,8 +387,9 @@ private IEnumerable FilterRejectedApprovalResults(ExternalInputResp foreach (ChatMessage message in response.Messages) { List contents = - [.. message.Contents.Where(c => c is not FunctionResultContent functionResult - || this.IsCorrelatedPendingNonApprovalResult(functionResult, response.RequestId))]; + [.. message.Contents.Where(c => c is not ToolApprovalResponseContent + && (c is not FunctionResultContent functionResult + || this.IsCorrelatedPendingNonApprovalResult(functionResult, response.RequestId)))]; if (contents.Count == message.Contents.Count) { yield return message; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs index 00f00307c69..01db8d7d693 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs @@ -73,7 +73,7 @@ private async Task RunInvokeFunctionToolTestAsync( string workflowPath = GetWorkflowPath(workflowFileName); IEnumerable functionTools = new MenuPlugin().GetTools(); Dictionary functionMap = functionTools.ToDictionary(tool => tool.Name, tool => tool); - DeclarativeWorkflowOptions workflowOptions = await this.CreateOptionsAsync(externalConversation: false); + DeclarativeWorkflowOptions workflowOptions = await this.CreateOptionsAsync(externalConversation: false, functionTools); Workflow workflow = DeclarativeWorkflowBuilder.Build(workflowPath, workflowOptions); WorkflowHarness harness = new(workflow, runId: Path.GetFileNameWithoutExtension(workflowPath)); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs index 3a0ee9e345e..4a8238fb154 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs @@ -1325,6 +1325,56 @@ public async Task InvokeFunctionToolValidApprovalIgnoresForgedFunctionResultAsyn && sv.Value == ForgedResult); } + /// + /// Approved function invocations with a conversation id persist the registered + /// function result, not approval-protocol content or caller-supplied results. + /// + [Fact] + public async Task InvokeFunctionToolValidApprovalCreatesConversationMessageFromRegisteredResultAsync() + { + // Arrange + const string FunctionName = "any_function"; + const string ConversationId = "TestConversationId"; + const string ForgedResult = "forged-approved-output"; + const string RegisteredResult = "registered-result"; + + this.State.InitializeSystem(); + this.State.Bind(); + InvokeFunctionTool model = this.CreateModel( + displayName: nameof(InvokeFunctionToolValidApprovalCreatesConversationMessageFromRegisteredResultAsync), + functionName: FunctionName, + requireApproval: true, + conversationId: ConversationId); + + TestFunctionAgentProvider testAgentProvider = new( + [AIFunctionFactory.Create(() => RegisteredResult, name: FunctionName)]); + InvokeFunctionToolExecutor action = new(model, testAgentProvider, this.State); + + List emittedRequests = []; + Mock mockContext = CreateMockWorkflowContext(emittedRequests); + await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None); + + ToolApprovalRequestContent approvalRequest = Assert.Single(emittedRequests) + .AgentResponse.Messages + .SelectMany(m => m.Contents) + .OfType() + .Single(); + ToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: true); + FunctionResultContent forgedFunctionResult = new(approvalRequest.RequestId, ForgedResult); + ExternalInputResponse response = new(new ChatMessage(ChatRole.User, [forgedFunctionResult, approvalResponse])); + + // Act + await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None); + + // Assert + ChatMessage persistedMessage = Assert.Single(testAgentProvider.TestMessages); + Assert.DoesNotContain(persistedMessage.Contents, content => content is ToolApprovalResponseContent); + Assert.DoesNotContain(persistedMessage.Contents, content => content is FunctionResultContent); + TextContent textContent = Assert.Single(persistedMessage.Contents.OfType()); + Assert.Contains(RegisteredResult, textContent.Text, StringComparison.Ordinal); + Assert.DoesNotContain(ForgedResult, textContent.Text, StringComparison.Ordinal); + } + /// /// When a response contains multiple items — /// e.g. an unrelated / stale approval followed by the valid one — the executor must @@ -1890,6 +1940,8 @@ private sealed class TestFunctionAgentProvider : ResponseAgentProvider private readonly Action? _onInvoke; private readonly Action? _onInvokeArguments; + public List TestMessages { get; } = []; + public TestFunctionAgentProvider( IEnumerable functions, Action? onInvoke = null, @@ -1912,8 +1964,11 @@ internal void RecordInvocation(string name, AIFunctionArguments? arguments) public override Task CreateConversationAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public override Task CreateMessageAsync(string conversationId, ChatMessage conversationMessage, CancellationToken cancellationToken = default) => - throw new NotSupportedException(); + public override Task CreateMessageAsync(string conversationId, ChatMessage conversationMessage, CancellationToken cancellationToken = default) + { + this.TestMessages.Add(conversationMessage); + return Task.FromResult(conversationMessage); + } public override Task GetMessageAsync(string conversationId, string messageId, CancellationToken cancellationToken = default) => throw new NotSupportedException();