diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ToolApprovalRequestContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ToolApprovalRequestContent.cs index da5e75d49a8..7295a04b09c 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ToolApprovalRequestContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ToolApprovalRequestContent.cs @@ -2,7 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; +using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI; @@ -32,6 +34,21 @@ public ToolApprovalRequestContent(string requestId, ToolCallContent toolCall) /// public ToolCallContent ToolCall { get; } + /// + /// Gets or sets a value indicating whether the underlying tool call must be confirmed + /// before it is invoked. + /// + /// + /// Defaults to . When , the underlying tool + /// requires a confirmation (such as a user prompt, a policy decision, or any other approver) + /// before it can be invoked. When , the underlying tool does not + /// require a confirmation and the consumer may proceed without prompting; a corresponding + /// still has to be supplied so the originating + /// tool call can be invoked. + /// + [Experimental(DiagnosticIds.Experiments.AIApprovalsInvocationRequired, UrlFormat = DiagnosticIds.UrlFormat)] + public bool RequiresConfirmation { get; set; } = true; + /// /// Creates a indicating whether the tool call is approved or rejected. /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json index f48b3048be7..2d105bc2a32 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json @@ -1,5 +1,5 @@ { - "Name": "Microsoft.Extensions.AI.Abstractions, Version=10.6.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35", + "Name": "Microsoft.Extensions.AI.Abstractions, Version=10.8.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35", "Types": [ { "Type": "sealed class Microsoft.Extensions.AI.AdditionalPropertiesDictionary : Microsoft.Extensions.AI.AdditionalPropertiesDictionary", @@ -4604,6 +4604,10 @@ } ], "Properties": [ + { + "Member": "bool Microsoft.Extensions.AI.ToolApprovalRequestContent.RequiresConfirmation { get; set; }", + "Stage": "Experimental" + }, { "Member": "Microsoft.Extensions.AI.ToolCallContent Microsoft.Extensions.AI.ToolApprovalRequestContent.ToolCall { get; }", "Stage": "Stable" diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Microsoft.Extensions.AI.Evaluation.Reporting.Azure.csproj b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Microsoft.Extensions.AI.Evaluation.Reporting.Azure.csproj index ddc986f187a..c7e88cbb236 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Microsoft.Extensions.AI.Evaluation.Reporting.Azure.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Microsoft.Extensions.AI.Evaluation.Reporting.Azure.csproj @@ -12,6 +12,7 @@ true n/a n/a + $(NoWarn);MEAI001 diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Microsoft.Extensions.AI.Evaluation.Reporting.csproj b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Microsoft.Extensions.AI.Evaluation.Reporting.csproj index 8a960fc4df1..331e7466a15 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Microsoft.Extensions.AI.Evaluation.Reporting.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Microsoft.Extensions.AI.Evaluation.Reporting.csproj @@ -19,6 +19,7 @@ true n/a n/a + $(NoWarn);MEAI001 diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs index e0ff736a68d..da702dcf32a 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs @@ -355,7 +355,13 @@ public override async Task GetResponseAsync( anyToolsRequireApproval = AnyToolsRequireApproval(options?.Tools, AdditionalTools); if (anyToolsRequireApproval) { - response.Messages = ReplaceFunctionCallsWithApprovalRequests(response.Messages, options?.Tools, AdditionalTools); + var approvalRequiredFunctions = + (options?.Tools ?? Enumerable.Empty()) + .Concat(AdditionalTools ?? Enumerable.Empty()) + .Where(t => t.GetService() is not null) + .ToArray(); + + response.Messages = ReplaceFunctionCallsWithApprovalRequests(response.Messages, approvalRequiredFunctions); } // Any function call work to do? If yes, ensure we're tracking that work in functionCallContents. @@ -613,7 +619,7 @@ public override async IAsyncEnumerable GetStreamingResponseA for (; lastYieldedUpdateIndex < updates.Count; lastYieldedUpdateIndex++) { var updateToYield = updates[lastYieldedUpdateIndex]; - if (TryReplaceFunctionCallsWithApprovalRequests(updateToYield.Contents, out var updatedContents)) + if (TryReplaceFunctionCallsWithApprovalRequests(updateToYield.Contents, approvalRequiredFunctions, out var updatedContents)) { updateToYield.Contents = updatedContents; } @@ -1618,7 +1624,10 @@ private static (bool hasApprovalRequiringFcc, int lastApprovalCheckedFCCIndex) C /// Replaces all with and ouputs a new list if any of them were replaced. /// /// true if any was replaced, false otherwise. - private static bool TryReplaceFunctionCallsWithApprovalRequests(IList content, out List? updatedContent) + private static bool TryReplaceFunctionCallsWithApprovalRequests( + IList content, + AITool[] approvalRequiredFunctions, + out List? updatedContent) { updatedContent = null; @@ -1629,7 +1638,21 @@ private static bool TryReplaceFunctionCallsWithApprovalRequests(IList if (content[i] is FunctionCallContent fcc && !fcc.InformationalOnly) { updatedContent ??= [.. content]; // Clone the list if we haven't already - updatedContent[i] = new ToolApprovalRequestContent(ComposeApprovalRequestId(fcc.CallId), fcc); + + bool requiresConfirmation = false; + for (int j = 0; j < approvalRequiredFunctions.Length; j++) + { + if (string.Equals(approvalRequiredFunctions[j].Name, fcc.Name, StringComparison.Ordinal)) + { + requiresConfirmation = true; + break; + } + } + + updatedContent[i] = new ToolApprovalRequestContent(ComposeApprovalRequestId(fcc.CallId), fcc) + { + RequiresConfirmation = requiresConfirmation, + }; } } } @@ -1643,15 +1666,15 @@ private static bool TryReplaceFunctionCallsWithApprovalRequests(IList /// private IList ReplaceFunctionCallsWithApprovalRequests( IList messages, - params ReadOnlySpan?> toolLists) + AITool[] approvalRequiredFunctions) { var outputMessages = messages; bool anyApprovalRequired = false; - List<(int, int)>? allFunctionCallContentIndices = null; + List<(int MessageIndex, int ContentIndex, bool RequiresConfirmation)>? allFunctionCallContentIndices = null; // Build a list of the indices of all FunctionCallContent items. - // Also check if any of them require approval. + // Also check whether each call's target name matches an approval-required function. for (int i = 0; i < messages.Count; i++) { var content = messages[i].Contents; @@ -1659,9 +1682,19 @@ private IList ReplaceFunctionCallsWithApprovalRequests( { if (content[j] is FunctionCallContent functionCall && !functionCall.InformationalOnly) { - (allFunctionCallContentIndices ??= []).Add((i, j)); + bool requiresConfirmation = false; + for (int k = 0; k < approvalRequiredFunctions.Length; k++) + { + if (string.Equals(approvalRequiredFunctions[k].Name, functionCall.Name, StringComparison.Ordinal)) + { + requiresConfirmation = true; + break; + } + } + + (allFunctionCallContentIndices ??= []).Add((i, j, requiresConfirmation)); - anyApprovalRequired |= FindTool(functionCall.Name, toolLists)?.GetService() is not null; + anyApprovalRequired |= requiresConfirmation; } } } @@ -1676,7 +1709,7 @@ private IList ReplaceFunctionCallsWithApprovalRequests( outputMessages = [.. messages]; int lastMessageIndex = -1; - foreach (var (messageIndex, contentIndex) in allFunctionCallContentIndices!) + foreach (var (messageIndex, contentIndex, requiresConfirmation) in allFunctionCallContentIndices!) { // Clone the message if we didn't already clone it in a previous iteration. var message = lastMessageIndex != messageIndex ? outputMessages[messageIndex].Clone() : outputMessages[messageIndex]; @@ -1684,7 +1717,10 @@ private IList ReplaceFunctionCallsWithApprovalRequests( var functionCall = (FunctionCallContent)message.Contents[contentIndex]; LogFunctionRequiresApproval(functionCall.Name); - message.Contents[contentIndex] = new ToolApprovalRequestContent(ComposeApprovalRequestId(functionCall.CallId), functionCall); + message.Contents[contentIndex] = new ToolApprovalRequestContent(ComposeApprovalRequestId(functionCall.CallId), functionCall) + { + RequiresConfirmation = requiresConfirmation, + }; outputMessages[messageIndex] = message; lastMessageIndex = messageIndex; diff --git a/src/Shared/DiagnosticIds/DiagnosticIds.cs b/src/Shared/DiagnosticIds/DiagnosticIds.cs index e2615f0a0bb..4b4925850a4 100644 --- a/src/Shared/DiagnosticIds/DiagnosticIds.cs +++ b/src/Shared/DiagnosticIds/DiagnosticIds.cs @@ -53,6 +53,7 @@ internal static class Experiments internal const string AITextToSpeech = AIExperiments; internal const string AIMcpServers = AIExperiments; internal const string AIFunctionApprovals = AIExperiments; + internal const string AIApprovalsInvocationRequired = AIExperiments; internal const string AIChatReduction = AIExperiments; internal const string AIToolSearch = AIExperiments; diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AssertExtensions.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AssertExtensions.cs index 8111bf80e94..bab1f9f4f68 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AssertExtensions.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AssertExtensions.cs @@ -50,6 +50,7 @@ public static void EqualMessageLists(List expectedMessages, List(json, AIJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.Equal(value, deserialized!.RequiresConfirmation); + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs index 1fcf22fa292..bcdc55d6b7c 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs @@ -83,6 +83,9 @@ public async Task AllFunctionCallsReplacedWithApprovalsWhenAnyRequireApprovalAsy [ new ToolApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")), new ToolApprovalRequestContent("ficc_callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + { + RequiresConfirmation = false, + } ]) ]; @@ -121,12 +124,20 @@ public async Task AllFunctionCallsReplacedWithApprovalsWhenAnyRequestOrAdditiona new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), ]; + // When additionalToolsRequireApproval is true: Func1 (additional tools) requires approval and Func2 (options.Tools) does not. + // When false: Func2 (options.Tools) requires approval and Func1 (additional tools) does not. List expectedOutput = [ new ChatMessage(ChatRole.Assistant, [ - new ToolApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")), + new ToolApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")) + { + RequiresConfirmation = additionalToolsRequireApproval, + }, new ToolApprovalRequestContent("ficc_callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + { + RequiresConfirmation = !additionalToolsRequireApproval, + } ]) ]; @@ -135,6 +146,99 @@ public async Task AllFunctionCallsReplacedWithApprovalsWhenAnyRequestOrAdditiona await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, expectedOutput, additionalTools: additionalTools); } + private sealed class PassThroughDelegatingAIFunction(AIFunction inner) : DelegatingAIFunction(inner); + + [Fact] + public async Task RequiresConfirmation_IsTrueForApprovalRequiredFunctionNestedInDelegatingWrapperAsync() + { + // Wrap the ApprovalRequiredAIFunction in another DelegatingAIFunction (e.g. a telemetry decorator). + // FICC must still classify the call as approval-required (RequiresConfirmation = true, the default) + // by walking the delegation chain via GetService(). + AITool[] tools = + [ + new PassThroughDelegatingAIFunction( + new ApprovalRequiredAIFunction( + AIFunctionFactory.Create(() => "Result 1", "Func1"))), + AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), + ]; + + var options = new ChatOptions { Tools = tools }; + + List input = + [ + new ChatMessage(ChatRole.User, "hello"), + ]; + + List downstreamClientOutput = + [ + new ChatMessage(ChatRole.Assistant, [ + new FunctionCallContent("callId1", "Func1"), + new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } }) + ]), + ]; + + List expectedOutput = + [ + new ChatMessage(ChatRole.Assistant, + [ + new ToolApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")), + new ToolApprovalRequestContent("ficc_callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + { + RequiresConfirmation = false, + } + ]) + ]; + + await InvokeAndAssertAsync(options, input, downstreamClientOutput, expectedOutput); + + await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, expectedOutput); + } + + [Fact] + public async Task RequiresConfirmation_IsFalseForFunctionCallWithNoMatchingToolWhenPeerRequiresApprovalAsync() + { + // The downstream client emits an FCC referencing a tool name that is not in the tools list. + // Because a peer call (Func1) requires approval, FICC still wraps the unknown call. + // Since no matching tool is found (and therefore no ApprovalRequiredAIFunction is detected), + // the resulting approval request must carry RequiresConfirmation = false. + var options = new ChatOptions + { + Tools = + [ + new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")), + ] + }; + + List input = + [ + new ChatMessage(ChatRole.User, "hello"), + ]; + + List downstreamClientOutput = + [ + new ChatMessage(ChatRole.Assistant, [ + new FunctionCallContent("callId1", "Func1"), + new FunctionCallContent("callId2", "Unknown"), + ]), + ]; + + List expectedOutput = + [ + new ChatMessage(ChatRole.Assistant, + [ + new ToolApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")), + new ToolApprovalRequestContent("ficc_callId2", new FunctionCallContent("callId2", "Unknown")) + { + RequiresConfirmation = false, + } + ]) + ]; + + await InvokeAndAssertAsync(options, input, downstreamClientOutput, expectedOutput); + + await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, expectedOutput); + } + [Fact] public async Task ApprovedApprovalResponsesAreExecutedAsync() { @@ -1735,7 +1839,10 @@ private static List CloneInput(List input) => InformationalOnly = fcc.InformationalOnly }, ToolApprovalRequestContent tarc => - new ToolApprovalRequestContent(tarc.RequestId, (ToolCallContent)CloneFcc(tarc.ToolCall)), + new ToolApprovalRequestContent(tarc.RequestId, (ToolCallContent)CloneFcc(tarc.ToolCall)) + { + RequiresConfirmation = tarc.RequiresConfirmation, + }, ToolApprovalResponseContent tarc => new ToolApprovalResponseContent(tarc.RequestId, tarc.Approved, (ToolCallContent)CloneFcc(tarc.ToolCall)) {