diff --git a/dotnet/samples/Concepts/Filtering/ExternalGovernanceCheckpoint.cs b/dotnet/samples/Concepts/Filtering/ExternalGovernanceCheckpoint.cs new file mode 100644 index 000000000000..594bcfe971c4 --- /dev/null +++ b/dotnet/samples/Concepts/Filtering/ExternalGovernanceCheckpoint.cs @@ -0,0 +1,235 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; + +namespace Filtering; + +/// +/// Shows how to place an external governance checkpoint in front of automatic function invocation. +/// +public class ExternalGovernanceCheckpoint(ITestOutputHelper output) : BaseTest(output) +{ + [Theory] + [InlineData("allow", "Executed wire transfer", "executed")] + [InlineData("require_approval", "Paused for approval", "paused")] + public async Task ExternalCheckpointCanAllowOrPauseFunctionInvocationAsync(string requestedVerdict, string expectedResult, string expectedStatus) + { + var builder = Kernel.CreateBuilder(); + builder.Services.AddSingleton(new ExampleCheckpointClient(requestedVerdict)); + builder.Services.AddSingleton(); + + var kernel = builder.Build(); + var function = KernelFunctionFactory.CreateFromMethod( + (decimal amount, string recipient) => "Executed wire transfer", + "WireTransfer"); + + kernel.ImportPluginFromFunctions("Payments", [function]); + KernelFunction importedFunction = kernel.Plugins.GetFunction("Payments", "WireTransfer"); + + var context = CreateAutoFunctionInvocationContext( + kernel, + importedFunction, + new KernelArguments + { + ["amount"] = 1250m, + ["recipient"] = "Fabrikam" + }); + + var filter = kernel.Services.GetRequiredService(); + await filter.OnAutoFunctionInvocationAsync(context, async invocationContext => + { + invocationContext.Result = await invocationContext.Function.InvokeAsync(kernel, invocationContext.Arguments); + }); + + Console.WriteLine(context.Result); + Assert.Equal(expectedResult, context.Result.GetValue()); + Assert.Equal(expectedStatus, context.Result.Metadata?["governance_status"]); + + // Output for allow: + // Executed wire transfer + // + // Output for require_approval: + // Paused for approval + } + + [Fact] + public async Task ExternalCheckpointCanDenyFunctionInvocationAsync() + { + var builder = Kernel.CreateBuilder(); + builder.Services.AddSingleton(new ExampleCheckpointClient("deny")); + builder.Services.AddSingleton(); + + var kernel = builder.Build(); + var function = KernelFunctionFactory.CreateFromMethod(() => "Deleted customer record", "DeleteCustomerRecord"); + + kernel.ImportPluginFromFunctions("CustomerAdmin", [function]); + KernelFunction importedFunction = kernel.Plugins.GetFunction("CustomerAdmin", "DeleteCustomerRecord"); + + var context = CreateAutoFunctionInvocationContext(kernel, importedFunction, new KernelArguments()); + var filter = kernel.Services.GetRequiredService(); + + var exception = await Assert.ThrowsAsync(() => + filter.OnAutoFunctionInvocationAsync(context, _ => throw new InvalidOperationException("The function should not execute."))); + + Assert.Contains("denied", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + private static AutoFunctionInvocationContext CreateAutoFunctionInvocationContext( + Kernel kernel, + KernelFunction function, + KernelArguments arguments) + { + var chatHistory = new ChatHistory("Transfer $1,250 to Fabrikam."); + var functionCall = new FunctionCallContent( + functionName: function.Name, + pluginName: function.PluginName, + id: "call_123", + arguments: arguments); + + var chatMessageContent = new ChatMessageContent(AuthorRole.Assistant, [functionCall]); + chatHistory.Add(chatMessageContent); + + return new AutoFunctionInvocationContext( + kernel, + function, + new FunctionResult(function), + chatHistory, + chatMessageContent) + { + Arguments = arguments, + RequestSequenceIndex = 0, + FunctionSequenceIndex = 0, + ToolCallId = functionCall.Id + }; + } + + private sealed class ExternalGovernanceFilter(IExternalCheckpointClient checkpointClient) : IAutoFunctionInvocationFilter + { + public async Task OnAutoFunctionInvocationAsync(AutoFunctionInvocationContext context, Func next) + { + ActionEnvelope envelope = ActionEnvelope.FromContext(context); + string checkpointReference = ActionEnvelopeDigest.ComputeReference(envelope); + + CheckpointVerdict verdict = await checkpointClient.EvaluateAsync(envelope, checkpointReference, context.CancellationToken); + + switch (verdict.Decision) + { + case "allow": + await next(context); + context.Result = WithGovernanceMetadata(context.Result, checkpointReference, "executed"); + return; + + case "require_approval": + context.Result = WithGovernanceMetadata( + context.Result, + checkpointReference, + "paused", + "Paused for approval"); + context.Terminate = true; + return; + + case "deny": + throw new UnauthorizedAccessException( + $"Function call '{envelope.PluginName ?? ""}.{envelope.FunctionName}' was denied by checkpoint {checkpointReference}."); + + default: + throw new InvalidOperationException($"Unknown checkpoint verdict '{verdict.Decision}'."); + } + } + + private static FunctionResult WithGovernanceMetadata( + FunctionResult result, + string checkpointReference, + string status, + string? value = null) + { + Dictionary metadata = result.Metadata is not null ? new(result.Metadata) : []; + metadata["governance_checkpoint"] = checkpointReference; + metadata["governance_status"] = status; + + return new FunctionResult(result, value) + { + Metadata = metadata + }; + } + } + + private sealed record ActionEnvelope( + string? PluginName, + string FunctionName, + IReadOnlyDictionary Arguments, + int RequestSequenceIndex, + int FunctionSequenceIndex, + string? ToolCallId) + { + public static ActionEnvelope FromContext(AutoFunctionInvocationContext context) + { + SortedDictionary arguments = new(StringComparer.Ordinal); + + if (context.Arguments is not null) + { + foreach (var argument in context.Arguments.OrderBy(static item => item.Key, StringComparer.Ordinal)) + { + arguments[argument.Key] = argument.Value; + } + } + + return new( + context.Function.PluginName, + context.Function.Name, + arguments, + context.RequestSequenceIndex, + context.FunctionSequenceIndex, + context.ToolCallId); + } + } + + private static class ActionEnvelopeDigest + { + private static readonly JsonSerializerOptions s_serializerOptions = new(JsonSerializerDefaults.Web); + + public static string ComputeReference(ActionEnvelope envelope) + { + StableActionEnvelope stableEnvelope = new( + envelope.PluginName, + envelope.FunctionName, + envelope.Arguments); + + byte[] envelopeBytes = JsonSerializer.SerializeToUtf8Bytes(stableEnvelope, s_serializerOptions); + byte[] digest = SHA256.HashData(envelopeBytes); + + return $"sha256:{Convert.ToHexString(digest).ToLowerInvariant()}"; + } + + private sealed record StableActionEnvelope( + string? PluginName, + string FunctionName, + IReadOnlyDictionary Arguments); + } + + private interface IExternalCheckpointClient + { + Task EvaluateAsync(ActionEnvelope envelope, string checkpointReference, CancellationToken cancellationToken); + } + + private sealed class ExampleCheckpointClient(string decision) : IExternalCheckpointClient + { + public Task EvaluateAsync( + ActionEnvelope envelope, + string checkpointReference, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + Console.WriteLine($"Checkpoint {checkpointReference}: {decision} {envelope.PluginName ?? ""}.{envelope.FunctionName}"); + + return Task.FromResult(new CheckpointVerdict(decision)); + } + } + + private sealed record CheckpointVerdict(string Decision); +} diff --git a/dotnet/samples/Concepts/README.md b/dotnet/samples/Concepts/README.md index 77fe10e7a8ab..103e919f68d4 100644 --- a/dotnet/samples/Concepts/README.md +++ b/dotnet/samples/Concepts/README.md @@ -109,6 +109,7 @@ dotnet test -l "console;verbosity=detailed" --filter "FullyQualifiedName=ChatCom - [AutoFunctionInvocationFiltering](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/Filtering/AutoFunctionInvocationFiltering.cs) - [FunctionInvocationFiltering](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/Filtering/FunctionInvocationFiltering.cs) +- [ExternalGovernanceCheckpoint](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/Filtering/ExternalGovernanceCheckpoint.cs) - [MaxTokensWithFilters](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/Filtering/MaxTokensWithFilters.cs) - [PIIDetection](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/Filtering/PIIDetection.cs) - [PromptRenderFiltering](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/Filtering/PromptRenderFiltering.cs)