Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,15 @@ public FunctionApprovalResponseContent(string id, bool approved, FunctionCallCon
/// Gets the function call for which approval was requested.
/// </summary>
public FunctionCallContent FunctionCall { get; }

/// <summary>
/// Gets or sets the reason for the approval or rejection.
/// </summary>
/// <remarks>
/// When <see cref="Approved"/> is <see langword="false"/>, this can be used to provide
/// a custom rejection message. If not set, a default rejection message will be used.
/// When <see cref="Approved"/> is <see langword="true"/>, this may be used for logging,
/// tracing, or other informational purposes.
/// </remarks>
public string? Reason { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -1418,7 +1418,11 @@ private static (List<ApprovalResultWithRequestMessage>? approvals, List<Approval
/// <returns>The <see cref="AIContent"/> for the rejected function calls.</returns>
private static List<AIContent>? GenerateRejectedFunctionResults(List<ApprovalResultWithRequestMessage>? 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 = m.Response.Reason ?? "Error: Tool call invocation was rejected by user.";
return (AIContent)new FunctionResultContent(m.Response.FunctionCall.CallId, result);
}) :
null;

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,162 @@ 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<ChatMessage> 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<string, object?> { { "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<string, object?> { { "i", 42 } }))
{
Reason = "Function Func2 is not allowed at this time"
}
]),
];

List<ChatMessage> expectedDownstreamClientInput =
[
new ChatMessage(ChatRole.User, "hello"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
new ChatMessage(ChatRole.Tool,
[
new FunctionResultContent("callId1", result: "User denied permission for this operation"),
new FunctionResultContent("callId2", result: "Function Func2 is not allowed at this time")
]),
];

List<ChatMessage> downstreamClientOutput =
[
new ChatMessage(ChatRole.Assistant, "world"),
];

List<ChatMessage> output =
[
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
new ChatMessage(ChatRole.Tool,
[
new FunctionResultContent("callId1", result: "User denied permission for this operation"),
new FunctionResultContent("callId2", result: "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<ChatMessage> 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<string, object?> { { "i", 42 } })),
new FunctionApprovalRequestContent("callId3", new FunctionCallContent("callId3", "Func3", arguments: new Dictionary<string, object?> { { "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<string, object?> { { "i", 42 } })),
new FunctionApprovalResponseContent("callId3", true, new FunctionCallContent("callId3", "Func3", arguments: new Dictionary<string, object?> { { "s", "test" } }))
]),
];

List<ChatMessage> expectedDownstreamClientInput =
[
new ChatMessage(ChatRole.User, "hello"),
new ChatMessage(ChatRole.Assistant,
[
new FunctionCallContent("callId1", "Func1"),
new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }),
new FunctionCallContent("callId3", "Func3", arguments: new Dictionary<string, object?> { { "s", "test" } })
]),
new ChatMessage(ChatRole.Tool,
[
new FunctionResultContent("callId1", result: "Custom rejection for Func1"),
new FunctionResultContent("callId2", result: "Error: Tool call invocation was rejected by user.")
]),
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId3", result: "Result 3: test")]),
];

List<ChatMessage> downstreamClientOutput =
[
new ChatMessage(ChatRole.Assistant, "world"),
];

List<ChatMessage> nonStreamingOutput =
[
new ChatMessage(ChatRole.Assistant,
[
new FunctionCallContent("callId1", "Func1"),
new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }),
new FunctionCallContent("callId3", "Func3", arguments: new Dictionary<string, object?> { { "s", "test" } })
]),
new ChatMessage(ChatRole.Tool,
[
new FunctionResultContent("callId1", result: "Custom rejection for Func1"),
new FunctionResultContent("callId2", result: "Error: Tool call invocation was rejected by user.")
]),
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId3", result: "Result 3: test")]),
new ChatMessage(ChatRole.Assistant, "world"),
];

List<ChatMessage> streamingOutput =
[
new ChatMessage(ChatRole.Assistant,
[
new FunctionCallContent("callId1", "Func1"),
new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }),
new FunctionCallContent("callId3", "Func3", arguments: new Dictionary<string, object?> { { "s", "test" } })
]),
new ChatMessage(ChatRole.Tool,
[
new FunctionResultContent("callId1", result: "Custom rejection for Func1"),
new FunctionResultContent("callId2", result: "Error: Tool call invocation was rejected by user."),
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);
}

[Fact]
public async Task ApprovedInputsAreExecutedAndFunctionResultsAreConvertedAsync()
{
Expand Down
Loading