diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionApprovalRequestContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionApprovalRequestContent.cs
index d3ec7ab8f0b..bf5d2c44b4a 100644
--- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionApprovalRequestContent.cs
+++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionApprovalRequestContent.cs
@@ -36,6 +36,7 @@ public FunctionApprovalRequestContent(string id, FunctionCallContent functionCal
/// Creates a to indicate whether the function call is approved or rejected based on the value of .
///
/// if the function call is approved; otherwise, .
+ /// An optional reason for the approval or rejection.
/// The representing the approval response.
- public FunctionApprovalResponseContent CreateResponse(bool approved) => new(Id, approved, FunctionCall);
+ public FunctionApprovalResponseContent CreateResponse(bool approved, string? reason = null) => new(Id, approved, FunctionCall) { Reason = reason };
}
diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionApprovalResponseContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionApprovalResponseContent.cs
index 948dc6a1347..3af623e9549 100644
--- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionApprovalResponseContent.cs
+++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionApprovalResponseContent.cs
@@ -38,4 +38,9 @@ public FunctionApprovalResponseContent(string id, bool approved, FunctionCallCon
/// Gets the function call for which approval was requested.
///
public FunctionCallContent FunctionCall { get; }
+
+ ///
+ /// Gets or sets the optional reason for the approval or rejection.
+ ///
+ public string? Reason { get; set; }
}
diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs
index b3885542de4..cc43c192f89 100644
--- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs
+++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs
@@ -1418,7 +1418,16 @@ private static (List? approvals, ListThe for the rejected function calls.
private static List? GenerateRejectedFunctionResults(List? rejections) =>
rejections is { Count: > 0 } ?
- rejections.ConvertAll(static m => (AIContent)new FunctionResultContent(m.Response.FunctionCall.CallId, "Error: Tool call invocation was rejected by user.")) :
+ rejections.ConvertAll(m =>
+ {
+ string result = "Tool call invocation rejected.";
+ if (!string.IsNullOrWhiteSpace(m.Response.Reason))
+ {
+ result = $"{result} {m.Response.Reason}";
+ }
+
+ return (AIContent)new FunctionResultContent(m.Response.FunctionCall.CallId, result);
+ }) :
null;
///
diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionApprovalRequestContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionApprovalRequestContentTests.cs
index 924243a7d1c..cc5cc1dd8d9 100644
--- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionApprovalRequestContentTests.cs
+++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionApprovalRequestContentTests.cs
@@ -52,6 +52,28 @@ public void CreateResponse_ReturnsExpectedResponse(bool approved)
Assert.Same(id, response.Id);
Assert.Equal(approved, response.Approved);
Assert.Same(functionCall, response.FunctionCall);
+ Assert.Null(response.Reason);
+ }
+
+ [Theory]
+ [InlineData(true, "Approved for testing")]
+ [InlineData(false, "Rejected due to security concerns")]
+ [InlineData(true, null)]
+ [InlineData(false, null)]
+ public void CreateResponse_WithReason_ReturnsExpectedResponse(bool approved, string? reason)
+ {
+ string id = "req-1";
+ FunctionCallContent functionCall = new("FCC1", "TestFunction");
+
+ FunctionApprovalRequestContent content = new(id, functionCall);
+
+ var response = content.CreateResponse(approved, reason);
+
+ Assert.NotNull(response);
+ Assert.Same(id, response.Id);
+ Assert.Equal(approved, response.Approved);
+ Assert.Same(functionCall, response.FunctionCall);
+ Assert.Equal(reason, response.Reason);
}
[Fact]
diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionApprovalResponseContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionApprovalResponseContentTests.cs
index 67d2f13cf49..405955463a1 100644
--- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionApprovalResponseContentTests.cs
+++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionApprovalResponseContentTests.cs
@@ -35,10 +35,15 @@ public void Constructor_Roundtrips(string id, bool approved)
Assert.Same(functionCall, content.FunctionCall);
}
- [Fact]
- public void Serialization_Roundtrips()
+ [Theory]
+ [InlineData(null)]
+ [InlineData("Custom rejection reason")]
+ public void Serialization_Roundtrips(string? reason)
{
- var content = new FunctionApprovalResponseContent("request123", true, new FunctionCallContent("call123", "functionName"));
+ var content = new FunctionApprovalResponseContent("request123", true, new FunctionCallContent("call123", "functionName"))
+ {
+ Reason = reason
+ };
var json = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions);
var deserializedContent = JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions);
@@ -46,6 +51,7 @@ public void Serialization_Roundtrips()
Assert.NotNull(deserializedContent);
Assert.Equal(content.Id, deserializedContent.Id);
Assert.Equal(content.Approved, deserializedContent.Approved);
+ Assert.Equal(content.Reason, deserializedContent.Reason);
Assert.NotNull(deserializedContent.FunctionCall);
Assert.Equal(content.FunctionCall.CallId, deserializedContent.FunctionCall.CallId);
Assert.Equal(content.FunctionCall.Name, deserializedContent.FunctionCall.Name);
diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs
index 93298ba2ac1..01f2e111447 100644
--- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs
+++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs
@@ -331,8 +331,8 @@ public async Task RejectedApprovalResponsesAreFailedAsync()
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]),
new ChatMessage(ChatRole.Tool,
[
- new FunctionResultContent("callId1", result: "Error: Tool call invocation was rejected by user."),
- new FunctionResultContent("callId2", result: "Error: Tool call invocation was rejected by user.")
+ new FunctionResultContent("callId1", result: "Tool call invocation rejected."),
+ new FunctionResultContent("callId2", result: "Tool call invocation rejected.")
]),
];
@@ -346,8 +346,8 @@ public async Task RejectedApprovalResponsesAreFailedAsync()
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]),
new ChatMessage(ChatRole.Tool,
[
- new FunctionResultContent("callId1", result: "Error: Tool call invocation was rejected by user."),
- new FunctionResultContent("callId2", result: "Error: Tool call invocation was rejected by user.")
+ new FunctionResultContent("callId1", result: "Tool call invocation rejected."),
+ new FunctionResultContent("callId2", result: "Tool call invocation rejected.")
]),
new ChatMessage(ChatRole.Assistant, "world"),
];
@@ -388,7 +388,7 @@ public async Task MixedApprovedAndRejectedApprovalResponsesAreExecutedAndFailedA
[
new ChatMessage(ChatRole.User, "hello"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]),
- new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Error: Tool call invocation was rejected by user.")]),
+ new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Tool call invocation rejected.")]),
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", result: "Result 2: 42")]),
];
@@ -400,7 +400,7 @@ public async Task MixedApprovedAndRejectedApprovalResponsesAreExecutedAndFailedA
List nonStreamingOutput =
[
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]),
- new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Error: Tool call invocation was rejected by user.")]),
+ new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Tool call invocation rejected.")]),
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", result: "Result 2: 42")]),
new ChatMessage(ChatRole.Assistant, "world"),
];
@@ -410,7 +410,7 @@ public async Task MixedApprovedAndRejectedApprovalResponsesAreExecutedAndFailedA
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]),
new ChatMessage(ChatRole.Tool,
[
- new FunctionResultContent("callId1", result: "Error: Tool call invocation was rejected by user."),
+ new FunctionResultContent("callId1", result: "Tool call invocation rejected."),
new FunctionResultContent("callId2", result: "Result 2: 42")
]),
new ChatMessage(ChatRole.Assistant, "world"),
@@ -421,6 +421,222 @@ public async Task MixedApprovedAndRejectedApprovalResponsesAreExecutedAndFailedA
await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, streamingOutput, expectedDownstreamClientInput);
}
+ [Fact]
+ public async Task RejectedApprovalResponsesWithCustomReasonAsync()
+ {
+ var options = new ChatOptions
+ {
+ Tools =
+ [
+ new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")),
+ AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"),
+ ]
+ };
+
+ List input =
+ [
+ new ChatMessage(ChatRole.User, "hello"),
+ new ChatMessage(ChatRole.Assistant,
+ [
+ new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")),
+ new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } }))
+ ]) { MessageId = "resp1" },
+ new ChatMessage(ChatRole.User,
+ [
+ new FunctionApprovalResponseContent("callId1", false, new FunctionCallContent("callId1", "Func1"))
+ {
+ Reason = "User denied permission for this operation"
+ },
+ new FunctionApprovalResponseContent("callId2", false, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } }))
+ {
+ Reason = "Function Func2 is not allowed at this time"
+ }
+ ]),
+ ];
+
+ List expectedDownstreamClientInput =
+ [
+ new ChatMessage(ChatRole.User, "hello"),
+ new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]),
+ new ChatMessage(ChatRole.Tool,
+ [
+ new FunctionResultContent("callId1", result: "Tool call invocation rejected. User denied permission for this operation"),
+ new FunctionResultContent("callId2", result: "Tool call invocation rejected. Function Func2 is not allowed at this time")
+ ]),
+ ];
+
+ List downstreamClientOutput =
+ [
+ new ChatMessage(ChatRole.Assistant, "world"),
+ ];
+
+ List output =
+ [
+ new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]),
+ new ChatMessage(ChatRole.Tool,
+ [
+ new FunctionResultContent("callId1", result: "Tool call invocation rejected. User denied permission for this operation"),
+ new FunctionResultContent("callId2", result: "Tool call invocation rejected. Function Func2 is not allowed at this time")
+ ]),
+ new ChatMessage(ChatRole.Assistant, "world"),
+ ];
+
+ await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
+
+ await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
+ }
+
+ [Fact]
+ public async Task MixedApprovalResponsesWithCustomAndDefaultReasonsAsync()
+ {
+ var options = new ChatOptions
+ {
+ Tools =
+ [
+ new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")),
+ AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"),
+ AIFunctionFactory.Create((string s) => $"Result 3: {s}", "Func3"),
+ ]
+ };
+
+ List input =
+ [
+ new ChatMessage(ChatRole.User, "hello"),
+ new ChatMessage(ChatRole.Assistant,
+ [
+ new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")),
+ new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })),
+ new FunctionApprovalRequestContent("callId3", new FunctionCallContent("callId3", "Func3", arguments: new Dictionary { { "s", "test" } }))
+ ]) { MessageId = "resp1" },
+ new ChatMessage(ChatRole.User,
+ [
+ new FunctionApprovalResponseContent("callId1", false, new FunctionCallContent("callId1", "Func1")) { Reason = "Custom rejection for Func1" },
+ new FunctionApprovalResponseContent("callId2", false, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })),
+ new FunctionApprovalResponseContent("callId3", true, new FunctionCallContent("callId3", "Func3", arguments: new Dictionary { { "s", "test" } }))
+ ]),
+ ];
+
+ List expectedDownstreamClientInput =
+ [
+ new ChatMessage(ChatRole.User, "hello"),
+ new ChatMessage(ChatRole.Assistant,
+ [
+ new FunctionCallContent("callId1", "Func1"),
+ new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } }),
+ new FunctionCallContent("callId3", "Func3", arguments: new Dictionary { { "s", "test" } })
+ ]),
+ new ChatMessage(ChatRole.Tool,
+ [
+ new FunctionResultContent("callId1", result: "Tool call invocation rejected. Custom rejection for Func1"),
+ new FunctionResultContent("callId2", result: "Tool call invocation rejected.")
+ ]),
+ new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId3", result: "Result 3: test")]),
+ ];
+
+ List downstreamClientOutput =
+ [
+ new ChatMessage(ChatRole.Assistant, "world"),
+ ];
+
+ List nonStreamingOutput =
+ [
+ new ChatMessage(ChatRole.Assistant,
+ [
+ new FunctionCallContent("callId1", "Func1"),
+ new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } }),
+ new FunctionCallContent("callId3", "Func3", arguments: new Dictionary { { "s", "test" } })
+ ]),
+ new ChatMessage(ChatRole.Tool,
+ [
+ new FunctionResultContent("callId1", result: "Tool call invocation rejected. Custom rejection for Func1"),
+ new FunctionResultContent("callId2", result: "Tool call invocation rejected.")
+ ]),
+ new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId3", result: "Result 3: test")]),
+ new ChatMessage(ChatRole.Assistant, "world"),
+ ];
+
+ List streamingOutput =
+ [
+ new ChatMessage(ChatRole.Assistant,
+ [
+ new FunctionCallContent("callId1", "Func1"),
+ new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } }),
+ new FunctionCallContent("callId3", "Func3", arguments: new Dictionary { { "s", "test" } })
+ ]),
+ new ChatMessage(ChatRole.Tool,
+ [
+ new FunctionResultContent("callId1", result: "Tool call invocation rejected. Custom rejection for Func1"),
+ new FunctionResultContent("callId2", result: "Tool call invocation rejected."),
+ new FunctionResultContent("callId3", result: "Result 3: test")
+ ]),
+ new ChatMessage(ChatRole.Assistant, "world"),
+ ];
+
+ await InvokeAndAssertAsync(options, input, downstreamClientOutput, nonStreamingOutput, expectedDownstreamClientInput);
+
+ await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, streamingOutput, expectedDownstreamClientInput);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public async Task RejectedApprovalResponsesWithEmptyOrWhitespaceReasonUsesDefaultMessageAsync(string? reason)
+ {
+ var options = new ChatOptions
+ {
+ Tools =
+ [
+ new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")),
+ ]
+ };
+
+ List input =
+ [
+ new ChatMessage(ChatRole.User, "hello"),
+ new ChatMessage(ChatRole.Assistant,
+ [
+ new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")),
+ ]) { MessageId = "resp1" },
+ new ChatMessage(ChatRole.User,
+ [
+ new FunctionApprovalResponseContent("callId1", false, new FunctionCallContent("callId1", "Func1"))
+ {
+ Reason = reason
+ },
+ ]),
+ ];
+
+ List expectedDownstreamClientInput =
+ [
+ new ChatMessage(ChatRole.User, "hello"),
+ new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]),
+ new ChatMessage(ChatRole.Tool,
+ [
+ new FunctionResultContent("callId1", result: "Tool call invocation rejected.")
+ ]),
+ ];
+
+ List downstreamClientOutput =
+ [
+ new ChatMessage(ChatRole.Assistant, "world"),
+ ];
+
+ List output =
+ [
+ new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]),
+ new ChatMessage(ChatRole.Tool,
+ [
+ new FunctionResultContent("callId1", result: "Tool call invocation rejected.")
+ ]),
+ new ChatMessage(ChatRole.Assistant, "world"),
+ ];
+
+ await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
+
+ await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
+ }
+
[Fact]
public async Task ApprovedInputsAreExecutedAndFunctionResultsAreConvertedAsync()
{