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))
{