Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -32,6 +34,21 @@ public ToolApprovalRequestContent(string requestId, ToolCallContent toolCall)
/// </summary>
public ToolCallContent ToolCall { get; }

/// <summary>
/// Gets or sets a value indicating whether the underlying tool call must be confirmed
/// before it is invoked.
/// </summary>
/// <remarks>
/// Defaults to <see langword="true"/>. When <see langword="true"/>, the underlying tool
/// requires a confirmation (such as a user prompt, a policy decision, or any other approver)
/// before it can be invoked. When <see langword="false"/>, the underlying tool does not
/// require a confirmation and the consumer may proceed without prompting; a corresponding
/// <see cref="ToolApprovalResponseContent"/> still has to be supplied so the originating
/// tool call can be invoked.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIApprovalsInvocationRequired, UrlFormat = DiagnosticIds.UrlFormat)]
public bool RequiresConfirmation { get; set; } = true;

/// <summary>
/// Creates a <see cref="ToolApprovalResponseContent"/> indicating whether the tool call is approved or rejected.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<object?>",
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<ForceLatestDotnetVersions>true</ForceLatestDotnetVersions>
<MinCodeCoverage>n/a</MinCodeCoverage>
<MinMutationScore>n/a</MinMutationScore>
<NoWarn>$(NoWarn);MEAI001</NoWarn>
</PropertyGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
<ForceLatestDotnetVersions>true</ForceLatestDotnetVersions>
<MinCodeCoverage>n/a</MinCodeCoverage>
<MinMutationScore>n/a</MinMutationScore>
<NoWarn>$(NoWarn);MEAI001</NoWarn>
</PropertyGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,13 @@ public override async Task<ChatResponse> GetResponseAsync(
anyToolsRequireApproval = AnyToolsRequireApproval(options?.Tools, AdditionalTools);
if (anyToolsRequireApproval)
{
response.Messages = ReplaceFunctionCallsWithApprovalRequests(response.Messages, options?.Tools, AdditionalTools);
var approvalRequiredFunctions =
(options?.Tools ?? Enumerable.Empty<AITool>())
.Concat(AdditionalTools ?? Enumerable.Empty<AITool>())
.Where(t => t.GetService<ApprovalRequiredAIFunction>() 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.
Expand Down Expand Up @@ -613,7 +619,7 @@ public override async IAsyncEnumerable<ChatResponseUpdate> 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;
}
Expand Down Expand Up @@ -1618,7 +1624,10 @@ private static (bool hasApprovalRequiringFcc, int lastApprovalCheckedFCCIndex) C
/// Replaces all <see cref="FunctionCallContent"/> with <see cref="ToolApprovalRequestContent"/> and ouputs a new list if any of them were replaced.
/// </summary>
/// <returns>true if any <see cref="FunctionCallContent"/> was replaced, false otherwise.</returns>
private static bool TryReplaceFunctionCallsWithApprovalRequests(IList<AIContent> content, out List<AIContent>? updatedContent)
private static bool TryReplaceFunctionCallsWithApprovalRequests(
IList<AIContent> content,
AITool[] approvalRequiredFunctions,
out List<AIContent>? updatedContent)
{
updatedContent = null;

Expand All @@ -1629,7 +1638,21 @@ private static bool TryReplaceFunctionCallsWithApprovalRequests(IList<AIContent>
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;
}
}
Comment thread
javiercn marked this conversation as resolved.

updatedContent[i] = new ToolApprovalRequestContent(ComposeApprovalRequestId(fcc.CallId), fcc)
{
RequiresConfirmation = requiresConfirmation,
};
}
}
}
Expand All @@ -1643,25 +1666,35 @@ private static bool TryReplaceFunctionCallsWithApprovalRequests(IList<AIContent>
/// </summary>
private IList<ChatMessage> ReplaceFunctionCallsWithApprovalRequests(
IList<ChatMessage> messages,
params ReadOnlySpan<IList<AITool>?> 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;
for (int j = 0; j < content.Count; j++)
{
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<ApprovalRequiredAIFunction>() is not null;
anyApprovalRequired |= requiresConfirmation;
}
}
}
Expand All @@ -1676,15 +1709,18 @@ private IList<ChatMessage> 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];
message.Contents = [.. message.Contents];

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)
{
Comment thread
javiercn marked this conversation as resolved.
RequiresConfirmation = requiresConfirmation,
};
outputMessages[messageIndex] = message;

lastMessageIndex = messageIndex;
Expand Down
1 change: 1 addition & 0 deletions src/Shared/DiagnosticIds/DiagnosticIds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ public static void EqualMessageLists(List<ChatMessage> expectedMessages, List<Ch
{
var actualApprovalRequest = (ToolApprovalRequestContent)chatItem;
Assert.Equal(expectedApprovalRequest.RequestId, actualApprovalRequest.RequestId);
Assert.Equal(expectedApprovalRequest.RequiresConfirmation, actualApprovalRequest.RequiresConfirmation);
Assert.Equal(expectedApprovalRequest.ToolCall.CallId, actualApprovalRequest.ToolCall.CallId);
Assert.Equal(expectedApprovalRequest.ToolCall.GetType(), actualApprovalRequest.ToolCall.GetType());
AssertToolCallNameAndArguments(expectedApprovalRequest.ToolCall, actualApprovalRequest.ToolCall);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,28 @@ public void JsonDeserialization_KnownPayload()
Assert.NotNull(approvalRequest.AdditionalProperties);
Assert.Equal("val", approvalRequest.AdditionalProperties["key"]?.ToString());
}

[Fact]
public void RequiresConfirmation_DefaultsToTrue()
{
var content = new ToolApprovalRequestContent("req-1", new FunctionCallContent("call1", "Func"));
Assert.True(content.RequiresConfirmation);
}

[Theory]
[InlineData(false)]
[InlineData(true)]
public void RequiresConfirmation_RoundtripsThroughJson(bool value)
{
var content = new ToolApprovalRequestContent("req-1", new FunctionCallContent("call1", "Func"))
{
RequiresConfirmation = value,
};

string json = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions);
var deserialized = JsonSerializer.Deserialize<ToolApprovalRequestContent>(json, AIJsonUtilities.DefaultOptions);

Assert.NotNull(deserialized);
Assert.Equal(value, deserialized!.RequiresConfirmation);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, object?> { { "i", 42 } }))
{
RequiresConfirmation = false,
}
])
];

Expand Down Expand Up @@ -121,12 +124,20 @@ public async Task AllFunctionCallsReplacedWithApprovalsWhenAnyRequestOrAdditiona
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "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<ChatMessage> 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<string, object?> { { "i", 42 } }))
{
RequiresConfirmation = !additionalToolsRequireApproval,
}
])
];

Expand All @@ -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<ApprovalRequiredAIFunction>().
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<ChatMessage> input =
[
new ChatMessage(ChatRole.User, "hello"),
];

List<ChatMessage> downstreamClientOutput =
[
new ChatMessage(ChatRole.Assistant, [
new FunctionCallContent("callId1", "Func1"),
new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })
]),
];

List<ChatMessage> expectedOutput =
[
new ChatMessage(ChatRole.Assistant,
[
new ToolApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")),
new ToolApprovalRequestContent("ficc_callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "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<ChatMessage> input =
[
new ChatMessage(ChatRole.User, "hello"),
];

List<ChatMessage> downstreamClientOutput =
[
new ChatMessage(ChatRole.Assistant, [
new FunctionCallContent("callId1", "Func1"),
new FunctionCallContent("callId2", "Unknown"),
]),
];

List<ChatMessage> 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()
{
Expand Down Expand Up @@ -1735,7 +1839,10 @@ private static List<ChatMessage> CloneInput(List<ChatMessage> 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))
{
Expand Down