Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

.Net: Tool filters #4922

Closed
wants to merge 26 commits into from
Closed
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
610e7e7
Tool filter and context classes
gitri-ms Feb 5, 2024
39b125a
save progress
gitri-ms Feb 5, 2024
95156bc
Use enum to control tool stop behavior
gitri-ms Feb 7, 2024
e9b61fd
Helper to update chat history in chat options
gitri-ms Feb 7, 2024
4f9a1cd
Move where filters are applied, update args
gitri-ms Feb 7, 2024
f263823
fix chat history issue
gitri-ms Feb 8, 2024
699b11d
Clean up warnings, add iterations to chatResult metadata
gitri-ms Feb 8, 2024
efbfe78
Merge branch 'main' into tool-filters
gitri-ms Feb 8, 2024
29fdedc
Merge branch 'main' into tool-filters
markwallace-microsoft Feb 8, 2024
865f3e5
bug fixes
gitri-ms Feb 12, 2024
a7e6e45
Add unit tests
gitri-ms Feb 13, 2024
6172aa5
Merge branch 'tool-filters' of https://github.com/gitri-ms/semantic-k…
gitri-ms Feb 13, 2024
1f5752f
Fix bug in test, add comments
gitri-ms Feb 13, 2024
3dee7d6
Merge branch 'main' into tool-filters
gitri-ms Feb 13, 2024
f7cd9f7
Streaming impl, add experimental attribute
gitri-ms Feb 13, 2024
037a996
Merge branch 'main' into tool-filters
gitri-ms Feb 13, 2024
a709123
Add tests for StopTools, StopAutoInvoke
gitri-ms Feb 14, 2024
363a9aa
Additional test
gitri-ms Feb 14, 2024
bddcf05
Revert change to example
gitri-ms Feb 14, 2024
cbdf008
Remove blank line
gitri-ms Feb 14, 2024
09d7c86
Test cases for chat streaming
gitri-ms Feb 15, 2024
46e8af1
Merge branch 'main' into tool-filters
gitri-ms Feb 15, 2024
cb3467e
Address pr comments
gitri-ms Feb 15, 2024
85aeeff
Merge branch 'main' into tool-filters
gitri-ms Feb 15, 2024
087b307
reduce code duplication
gitri-ms Feb 15, 2024
a1012bc
dotnet format
gitri-ms Feb 16, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions dotnet/docs/EXPERIMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ You can use the following diagnostic IDs to ignore warnings or errors for a part
- SKEXP0013: OpenAI parameters
- SKEXP0014: OpenAI chat history extension
- SKEXP0015: OpenAI file service
- SKEXP0016: OpenAI tool call filters

## Memory connectors

Expand Down
131 changes: 123 additions & 8 deletions dotnet/src/Connectors/Connectors.OpenAI/AzureSdk/ClientCore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -175,25 +175,27 @@ internal async IAsyncEnumerable<StreamingTextContent> GetStreamingTextContentsAs
};
}

private static Dictionary<string, object?> GetResponseMetadata(ChatCompletions completions)
private static Dictionary<string, object?> GetResponseMetadata(ChatCompletions completions, int iterations)
{
return new Dictionary<string, object?>(5)
return new Dictionary<string, object?>(6)
{
{ nameof(completions.Id), completions.Id },
{ nameof(completions.Created), completions.Created },
{ nameof(completions.PromptFilterResults), completions.PromptFilterResults },
{ nameof(completions.SystemFingerprint), completions.SystemFingerprint },
{ nameof(completions.Usage), completions.Usage },
{ "Iterations", iterations },
gitri-ms marked this conversation as resolved.
Show resolved Hide resolved
};
}

private static Dictionary<string, object?> GetResponseMetadata(StreamingChatCompletionsUpdate completions)
private static Dictionary<string, object?> GetResponseMetadata(StreamingChatCompletionsUpdate completions, int iterations)
{
return new Dictionary<string, object?>(3)
return new Dictionary<string, object?>(4)
{
{ nameof(completions.Id), completions.Id },
{ nameof(completions.Created), completions.Created },
{ nameof(completions.SystemFingerprint), completions.SystemFingerprint },
{ "Iterations", iterations },
};
}

Expand Down Expand Up @@ -265,7 +267,7 @@ internal async Task<IReadOnlyList<ChatMessageContent>> GetChatMessageContentsAsy
throw new KernelException("Chat completions not found");
}

IReadOnlyDictionary<string, object?> metadata = GetResponseMetadata(responseData);
IReadOnlyDictionary<string, object?> metadata = GetResponseMetadata(responseData, iteration);

// If we don't want to attempt to invoke any functions, just return the result.
// Or if we are auto-invoking but we somehow end up with other than 1 choice even though only 1 was requested, similarly bail.
Expand Down Expand Up @@ -329,6 +331,27 @@ internal async Task<IReadOnlyList<ChatMessageContent>> GetChatMessageContentsAsy
continue;
}

try
gitri-ms marked this conversation as resolved.
Show resolved Hide resolved
{
// Invoke the pre-invocation filter.
var invokingContext = chatExecutionSettings.ToolCallBehavior?.OnToolInvokingFilter(openAIFunctionToolCall, chat, iteration);
if (invokingContext is not null)
{
// Need to update the chat options in case chat history has changed
gitri-ms marked this conversation as resolved.
Show resolved Hide resolved
this.UpdateChatHistory(chat, chatOptions, chatExecutionSettings);

// Check if filter has requested a stop
this.HandleStopBehavior(invokingContext, chatOptions, ref autoInvoke);
}
}
catch (OperationCanceledException)
{
// Add cancellation message to chat history, turn off tools, and bail out of any remaining tool calls
AddResponseMessage(chatOptions, chat, null, "A tool filter requested cancellation before tool invocation.", toolCall.Id, this.Logger);
gitri-ms marked this conversation as resolved.
Show resolved Hide resolved
chatOptions.ToolChoice = ChatCompletionsToolChoice.None;
break;
}

// Make sure the requested function is one we requested. If we're permitting any kernel function to be invoked,
// then we don't need to check this, as it'll be handled when we look up the function in the kernel to be able
// to invoke it. If we're permitting only a specific list of functions, though, then we need to explicitly check.
Expand Down Expand Up @@ -357,7 +380,7 @@ internal async Task<IReadOnlyList<ChatMessageContent>> GetChatMessageContentsAsy
functionResult = (await function.InvokeAsync(kernel, functionArgs, cancellationToken: cancellationToken).ConfigureAwait(false)).GetValue<object>() ?? string.Empty;
}
#pragma warning disable CA1031 // Do not catch general exception types
catch (Exception e)
catch (Exception e) when (!e.IsCriticalException())
#pragma warning restore CA1031
{
AddResponseMessage(chatOptions, chat, null, $"Error: Exception while invoking function. {e.Message}", toolCall.Id, this.Logger);
Expand All @@ -369,6 +392,26 @@ internal async Task<IReadOnlyList<ChatMessageContent>> GetChatMessageContentsAsy
}
AddResponseMessage(chatOptions, chat, functionResult as string ?? JsonSerializer.Serialize(functionResult), errorMessage: null, toolCall.Id, this.Logger);

try
{
// Invoke the post-invocation filter.
var invokedContext = chatExecutionSettings.ToolCallBehavior?.OnToolInvokedFilter(openAIFunctionToolCall, functionResult, chat, iteration);
if (invokedContext is not null)
{
// Need to update the chat options in case chat history has changed
this.UpdateChatHistory(chat, chatOptions, chatExecutionSettings);

// Check if filter has requested a stop
this.HandleStopBehavior(invokedContext, chatOptions, ref autoInvoke);
}
}
catch (OperationCanceledException)
{
// The tool call already happened so we can't cancel it, but turn off tools and bail out of any remaining tool calls
chatOptions.ToolChoice = ChatCompletionsToolChoice.None;
break;
}

static void AddResponseMessage(ChatCompletionsOptions chatOptions, ChatHistory chat, string? result, string? errorMessage, string toolId, ILogger logger)
{
// Log any error
Expand Down Expand Up @@ -409,6 +452,37 @@ static void AddResponseMessage(ChatCompletionsOptions chatOptions, ChatHistory c
}
}

private void HandleStopBehavior(ToolFilterContext context, ChatCompletionsOptions chatOptions, ref bool autoInvoke)
{
switch (context.StopBehavior)
{
case ToolFilterStopBehavior.StopAutoInvoke:
autoInvoke = false;
break;
case ToolFilterStopBehavior.StopTools:
chatOptions.ToolChoice = ChatCompletionsToolChoice.None;
break;
case ToolFilterStopBehavior.Cancel:
throw new OperationCanceledException();
}
}

private void UpdateChatHistory(ChatHistory chatHistory, ChatCompletionsOptions options, OpenAIPromptExecutionSettings executionSettings)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is being called every time a filter is called right? Is that to handle the case of the filter handler changes the history in some way and we need to sync it? Do we do any updates to the history to clean up if the tool call is canceled?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is being called every time a filter is called right? Is that to handle the case of the filter handler changes the history in some way and we need to sync it?

Yes and yes. If the filter changes the chat history, we would want that updated chat history to be included in the next request to the model. This helper function updates the chat history stored in chatOptions (which is what is sent to the model) to match what is in the chatHistory object.

Do we do any updates to the history to clean up if the tool call is canceled?

If the tool call is cancelled before invocation, instead of a response message, we add an error message to the chat history. If the tool call is cancelled after invocation, I just have it keeping the regular tool response message in the chat history, but bailing out of future tool calls. (I am not necessarily opposed to a different behavior here, but this made the most sense to me.) In both these cases, AddResponseMessage handles updating both the chatHistory and chatOptions objects.

{
// Clear out messages, then copy over from chat history
options.Messages.Clear();

if (!string.IsNullOrWhiteSpace(executionSettings?.ChatSystemPrompt) && !chatHistory.Any(m => m.Role == AuthorRole.System))
{
options.Messages.Add(GetRequestMessage(new ChatMessageContent(AuthorRole.System, executionSettings!.ChatSystemPrompt)));
}

foreach (var message in chatHistory)
{
options.Messages.Add(GetRequestMessage(message));
}
}

internal async IAsyncEnumerable<OpenAIStreamingChatMessageContent> GetStreamingChatMessageContentsAsync(
ChatHistory chat,
PromptExecutionSettings? executionSettings,
Expand Down Expand Up @@ -447,7 +521,7 @@ internal async IAsyncEnumerable<OpenAIStreamingChatMessageContent> GetStreamingC
CompletionsFinishReason finishReason = default;
await foreach (StreamingChatCompletionsUpdate update in response.ConfigureAwait(false))
{
metadata ??= GetResponseMetadata(update);
metadata ??= GetResponseMetadata(update, iteration);
streamedRole ??= update.Role;
finishReason = update.FinishReason ?? default;

Expand Down Expand Up @@ -519,6 +593,27 @@ internal async IAsyncEnumerable<OpenAIStreamingChatMessageContent> GetStreamingC
continue;
}

try
gitri-ms marked this conversation as resolved.
Show resolved Hide resolved
{
// Invoke the pre-invocation filter.
var invokingContext = chatExecutionSettings.ToolCallBehavior?.OnToolInvokingFilter(openAIFunctionToolCall, chat, iteration);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a lot that happens between here and the actual function invocation, including actually getting the corresponding KernelFunction object and creating the KernelArguments to pass to it. I see the value of a callback that's really early in the process, so that a callback can see the raw request from the model, but in that case, should it actually be even earlier and be passed just the raw string name and string arguments? And then have a separate callback that's invoked with the Kernel, KernelFunction, KernelArguments, etc. just before function.InvokeAsync is actually called?

I think it'd be helpful to enumerate all the extensibility scenarios we're trying to enable here, i.e. the different things we expect folks will want to do with this, and then write out the example code for each, showing both that it's possible and what the code would look like. Those can all become samples, too.

For example:

  • Want to limit the number of back and forths with the model, to avoid runaway costs or infinite loops, disabling additional function calling after some number of iterations
  • Want to update what functions are available based on the interactions with the model
  • Want to limit the number of recursive function invocations that can be made (e.g. agents talking back and forth to each other via function calling)
  • Want to screen the arguments being passed to a function and replace the argument with something else
  • Want to screen the results of a function and replace it with something else (it's possible this and the above would already be handled by normal function filters)
  • Want to stop iterating if a particular function is requested, returning that function's result as the result of the operation (basically the eventual invocation of that function was the ultimate goal)
  • ...

if (invokingContext is not null)
{
// Need to update the chat options in case chat history has changed
this.UpdateChatHistory(chat, chatOptions, chatExecutionSettings);

// Check if filter has requested a stop
this.HandleStopBehavior(invokingContext, chatOptions, ref autoInvoke);
}
}
catch (OperationCanceledException)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels a bit icky to me. Does this mean that we're saying the way you early-exit non-exceptionally is to throw an OperationCanceledException?

{
// Add cancellation message to chat history, turn off tools, and bail out of any remaining tool calls
AddResponseMessage(chatOptions, chat, streamedRole, toolCall, metadata, null, "A tool filter requested cancellation before tool invocation.", this.Logger);
chatOptions.ToolChoice = ChatCompletionsToolChoice.None;
break;
}
gitri-ms marked this conversation as resolved.
Show resolved Hide resolved

// Make sure the requested function is one we requested. If we're permitting any kernel function to be invoked,
// then we don't need to check this, as it'll be handled when we look up the function in the kernel to be able
// to invoke it. If we're permitting only a specific list of functions, though, then we need to explicitly check.
Expand Down Expand Up @@ -547,7 +642,7 @@ internal async IAsyncEnumerable<OpenAIStreamingChatMessageContent> GetStreamingC
functionResult = (await function.InvokeAsync(kernel, functionArgs, cancellationToken: cancellationToken).ConfigureAwait(false)).GetValue<object>() ?? string.Empty;
}
#pragma warning disable CA1031 // Do not catch general exception types
catch (Exception e)
catch (Exception e) when (!e.IsCriticalException())
#pragma warning restore CA1031
{
AddResponseMessage(chatOptions, chat, streamedRole, toolCall, metadata, result: null, $"Error: Exception while invoking function. {e.Message}", this.Logger);
Expand All @@ -559,6 +654,26 @@ internal async IAsyncEnumerable<OpenAIStreamingChatMessageContent> GetStreamingC
}
AddResponseMessage(chatOptions, chat, streamedRole, toolCall, metadata, functionResult as string ?? JsonSerializer.Serialize(functionResult), errorMessage: null, this.Logger);

try
{
// Invoke the post-invocation filter.
var invokedContext = chatExecutionSettings.ToolCallBehavior?.OnToolInvokedFilter(openAIFunctionToolCall, functionResult, chat, iteration);
if (invokedContext is not null)
{
// Need to update the chat options in case chat history has changed
this.UpdateChatHistory(chat, chatOptions, chatExecutionSettings);

// Check if filter has requested a stop
this.HandleStopBehavior(invokedContext, chatOptions, ref autoInvoke);
}
}
catch (OperationCanceledException)
{
// This tool call already happened so we can't cancel it, but turn off tools and bail out of any remaining tool calls
chatOptions.ToolChoice = ChatCompletionsToolChoice.None;
break;
}

static void AddResponseMessage(
ChatCompletionsOptions chatOptions, ChatHistory chat, ChatRole? streamedRole, ChatCompletionsToolCall tool, IReadOnlyDictionary<string, object?>? metadata,
string? result, string? errorMessage, ILogger logger)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<RootNamespace>$(AssemblyName)</RootNamespace>
<TargetFramework>netstandard2.0</TargetFramework>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<NoWarn>$(NoWarn);NU5104;SKEXP0013,SKEXP0014</NoWarn>
<NoWarn>$(NoWarn);NU5104;SKEXP0013,SKEXP0014,SKEXP0016</NoWarn>
<EnablePackageValidation>true</EnablePackageValidation>
</PropertyGroup>

Expand Down
24 changes: 24 additions & 0 deletions dotnet/src/Connectors/Connectors.OpenAI/IToolFilter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright (c) Microsoft. All rights reserved.

using System.Diagnostics.CodeAnalysis;

namespace Microsoft.SemanticKernel.Connectors.OpenAI;

/// <summary>
/// Interface for tool filters.
/// </summary>
[Experimental("SKEXP0016")]
public interface IToolFilter
{
/// <summary>
/// Method which is executed before tool invocation.
/// </summary>
/// <param name="context">Data related to tool before invocation.</param>
void OnToolInvoking(ToolInvokingContext context);

/// <summary>
/// Method which is executed after tool invocation.
/// </summary>
/// <param name="context">Data related to tool after invocation.</param>
void OnToolInvoked(ToolInvokedContext context);
}
44 changes: 44 additions & 0 deletions dotnet/src/Connectors/Connectors.OpenAI/ToolCallBehavior.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Azure.AI.OpenAI;
using Microsoft.SemanticKernel.ChatCompletion;

namespace Microsoft.SemanticKernel.Connectors.OpenAI;

Expand Down Expand Up @@ -35,6 +37,12 @@ public abstract class ToolCallBehavior
/// </remarks>
private const int DefaultMaximumAutoInvokeAttempts = 5;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we going to expose this configuration?

Copy link
Contributor Author

@gitri-ms gitri-ms Feb 15, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would argue that should be in a separate PR, since it's unrelated to the tool filters or the planner updates that this PR covers. (I don't mind creating that PR though, should be fairly quick.) Also, if we expose this field, do we want to expose ToolCallBehavior.MaximumUseAttempts as well?


/// <summary>
/// Gets the collection of filters that will be applied to tool calls.
/// </summary>
[Experimental("SKEXP0016")]
public IList<IToolFilter> Filters { get; } = new List<IToolFilter>();
Copy link
Member

@stephentoub stephentoub Feb 21, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we want this here, at least not as a public mutable list. It means any code can just do ToolCallBehavior.AutoInvokeKernelFunctions.Filters.Add(myFilter), and it'll be added to the singleton that will apply to everyone, which also means you need to remember to remove filters after you're done with them, even in the case of exception.

I think instead we should add overloads to the existing factories below, e.g.

public static ToolCallBehavior EnableFunctions(
    IEnumerable<OpenAIFunction>? functions, // if null, functions are sourced from the Kernel ala AutoInvokeKernelFunctions,
    EnableFunctionsOptions options);
...
public sealed class EnableFunctionsOptions
{
    public bool AutoInvoke { get; set; }
    public IList<IToolFilter> Filters { get; }
    ... // any other customization desired
}

or something along those lines. That's just a sketch; names and overall shape are debatable.


/// <summary>
/// Gets an instance that will provide all of the <see cref="Kernel"/>'s plugins' function information.
/// Function call requests from the model will be propagated back to the caller.
Expand Down Expand Up @@ -236,4 +244,40 @@ internal override void ConfigureOptions(Kernel? kernel, ChatCompletionsOptions o
/// </remarks>
internal override int MaximumUseAttempts => 1;
}

#region Filters
internal ToolInvokingContext? OnToolInvokingFilter(OpenAIFunctionToolCall toolCall, ChatHistory chatHistory, int iteration)
{
ToolInvokingContext? context = null;

if (this.Filters is { Count: > 0 })
{
context = new(toolCall, chatHistory, iteration);

for (int i = 0; i < this.Filters.Count; i++)
{
this.Filters[i].OnToolInvoking(context);
}
}

return context;
}

internal ToolInvokedContext? OnToolInvokedFilter(OpenAIFunctionToolCall toolCall, object? result, ChatHistory chatHistory, int iteration)
{
ToolInvokedContext? context = null;

if (this.Filters is { Count: > 0 })
{
context = new(toolCall, result, chatHistory, iteration);

for (int i = 0; i < this.Filters.Count; i++)
{
this.Filters[i].OnToolInvoked(context);
}
}

return context;
}
#endregion
}
Loading
Loading