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