mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: Add Approval capabilities to FunctionInvokingChatClient (#457)
* Add Approval capabilities to FunctionInvokingChatClient * Address PR comments. * Address PR comments. * Address PR comments. * Address PR feedback, add sample, and fix bug plus unit test. * Address PR comments
This commit is contained in:
committed by
GitHub
Unverified
parent
b0b3fd151c
commit
a40eda48f1
+205
@@ -0,0 +1,205 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.AI.Agents;
|
||||
|
||||
namespace Steps;
|
||||
|
||||
/// <summary>
|
||||
/// Demonstrates how to indicate that certain function calls require user approval before they can be executed and how to then approve or reject those function calls.
|
||||
/// </summary>
|
||||
public sealed class Step10_ChatClientAgent_UsingFunctionToolsWithApprovals(ITestOutputHelper output) : AgentSample(output)
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(ChatClientProviders.AzureOpenAI)]
|
||||
[InlineData(ChatClientProviders.AzureAIAgentsPersistent)]
|
||||
[InlineData(ChatClientProviders.OpenAIAssistant)]
|
||||
[InlineData(ChatClientProviders.OpenAIChatCompletion)]
|
||||
[InlineData(ChatClientProviders.OpenAIResponses)]
|
||||
public async Task ApprovalsWithTools(ChatClientProviders provider)
|
||||
{
|
||||
// Creating a MenuTools instance to be used by the agent.
|
||||
var menuTools = new MenuTools();
|
||||
|
||||
// Define the options for the chat client agent.
|
||||
// We mark GetMenu and GetSpecial as requiring approval before they can be invoked, while GetItemPrice can be invoked without user approval.
|
||||
// IMPORTANT: A limitation of the approvals flow when using ChatClientAgent is that if more than one function needs to be executed in one run,
|
||||
// and any one of them requires approval, approval will be sought for all function calls produced during that run.
|
||||
var agentOptions = new ChatClientAgentOptions(
|
||||
name: "Host",
|
||||
instructions: "Answer questions about the menu",
|
||||
tools: [
|
||||
new ApprovalRequiredAIFunction(AIFunctionFactory.Create(menuTools.GetMenu)),
|
||||
new ApprovalRequiredAIFunction(AIFunctionFactory.Create(menuTools.GetSpecials)),
|
||||
AIFunctionFactory.Create(menuTools.GetItemPrice)
|
||||
]);
|
||||
|
||||
// Create the server-side agent Id when applicable (depending on the provider).
|
||||
agentOptions.Id = await base.AgentCreateAsync(provider, agentOptions);
|
||||
|
||||
// Get the chat client to use for the agent.
|
||||
using var chatClient = base.GetChatClient(provider, agentOptions);
|
||||
|
||||
// Define the agent
|
||||
var agent = new ChatClientAgent(chatClient, agentOptions);
|
||||
|
||||
// Create the chat history thread to capture the agent interaction.
|
||||
var thread = agent.GetNewThread();
|
||||
|
||||
// Respond to user input, invoking functions where appropriate.
|
||||
await RunAgentAsync("What is the special soup and its price?");
|
||||
await RunAgentAsync("What is the special drink?");
|
||||
|
||||
async Task RunAgentAsync(string input)
|
||||
{
|
||||
this.WriteUserMessage(input);
|
||||
var response = await agent.RunAsync(input, thread);
|
||||
|
||||
// Loop until all user input requests are handled.
|
||||
var userInputRequests = response.UserInputRequests.ToList();
|
||||
while (userInputRequests.Count > 0)
|
||||
{
|
||||
// Approve GetSpecials function calls, reject all others.
|
||||
List<ChatMessage> nextIterationMessages = userInputRequests?.Select((request) => request switch
|
||||
{
|
||||
FunctionApprovalRequestContent functionApprovalRequest when functionApprovalRequest.FunctionCall.Name == "GetSpecials" =>
|
||||
new ChatMessage(ChatRole.User, [functionApprovalRequest.CreateResponse(approved: true)]),
|
||||
|
||||
FunctionApprovalRequestContent functionApprovalRequest =>
|
||||
new ChatMessage(ChatRole.User, [functionApprovalRequest.CreateResponse(approved: false)]),
|
||||
|
||||
_ => throw new NotSupportedException($"Unsupported user input request type: {request.GetType().Name}")
|
||||
})?.ToList() ?? [];
|
||||
|
||||
// Write out what the decision was for each function approval request.
|
||||
nextIterationMessages.ForEach(x => Console.WriteLine($"Approval for the {(x.Contents[0] as FunctionApprovalResponseContent)?.FunctionCall.Name} function call is set to {(x.Contents[0] as FunctionApprovalResponseContent)?.Approved}."));
|
||||
|
||||
// Pass the user input responses back to the agent for further processing.
|
||||
response = await agent.RunAsync(nextIterationMessages, thread);
|
||||
|
||||
userInputRequests = response.UserInputRequests.ToList();
|
||||
}
|
||||
|
||||
this.WriteResponseOutput(response);
|
||||
}
|
||||
|
||||
// Clean up the server-side agent after use when applicable (depending on the provider).
|
||||
await base.AgentCleanUpAsync(provider, agent, thread);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ChatClientProviders.AzureOpenAI)]
|
||||
[InlineData(ChatClientProviders.AzureAIAgentsPersistent)]
|
||||
[InlineData(ChatClientProviders.OpenAIAssistant)]
|
||||
[InlineData(ChatClientProviders.OpenAIChatCompletion)]
|
||||
[InlineData(ChatClientProviders.OpenAIResponses)]
|
||||
public async Task ApprovalsWithToolsStreaming(ChatClientProviders provider)
|
||||
{
|
||||
// Creating a MenuTools instance to be used by the agent.
|
||||
var menuTools = new MenuTools();
|
||||
|
||||
// Creating a MenuTools instance to be used by the agent.
|
||||
// We mark GetMenu and GetSpecial as requiring approval before they can be invoked, while GetItemPrice can be invoked without user approval.
|
||||
// IMPORTANT: A limitation of the approvals flow when using ChatClientAgent is that if more than one function needs to be executed in one run,
|
||||
// and any one of them requires approval, approval will be sought for all function calls produced during that run.
|
||||
var agentOptions = new ChatClientAgentOptions(
|
||||
name: "Host",
|
||||
instructions: "Answer questions about the menu",
|
||||
tools: [
|
||||
new ApprovalRequiredAIFunction(AIFunctionFactory.Create(menuTools.GetMenu)),
|
||||
new ApprovalRequiredAIFunction(AIFunctionFactory.Create(menuTools.GetSpecials)),
|
||||
AIFunctionFactory.Create(menuTools.GetItemPrice),
|
||||
]);
|
||||
|
||||
// Create the server-side agent Id when applicable (depending on the provider).
|
||||
agentOptions.Id = await base.AgentCreateAsync(provider, agentOptions);
|
||||
|
||||
// Get the chat client to use for the agent.
|
||||
using var chatClient = base.GetChatClient(provider, agentOptions);
|
||||
|
||||
// Define the agent
|
||||
var agent = new ChatClientAgent(chatClient, agentOptions);
|
||||
|
||||
// Create the chat history thread to capture the agent interaction.
|
||||
var thread = agent.GetNewThread();
|
||||
|
||||
// Respond to user input, invoking functions where appropriate.
|
||||
await RunAgentAsync("What is the special soup and its price?");
|
||||
await RunAgentAsync("What is the special drink?");
|
||||
|
||||
async Task RunAgentAsync(string input)
|
||||
{
|
||||
this.WriteUserMessage(input);
|
||||
var updates = await agent.RunStreamingAsync(input, thread).ToListAsync();
|
||||
|
||||
// Loop until all user input requests are handled.
|
||||
var userInputRequests = updates.SelectMany(x => x.UserInputRequests).ToList();
|
||||
while (userInputRequests.Count > 0)
|
||||
{
|
||||
// Approve GetSpecials function calls, reject all others.
|
||||
List<ChatMessage> nextIterationMessages = userInputRequests?.Select((request) => request switch
|
||||
{
|
||||
FunctionApprovalRequestContent functionApprovalRequest when functionApprovalRequest.FunctionCall.Name == "GetSpecials" =>
|
||||
new ChatMessage(ChatRole.User, [functionApprovalRequest.CreateResponse(approved: true)]),
|
||||
|
||||
FunctionApprovalRequestContent functionApprovalRequest =>
|
||||
new ChatMessage(ChatRole.User, [functionApprovalRequest.CreateResponse(approved: false)]),
|
||||
|
||||
_ => throw new NotSupportedException($"Unsupported request type: {request.GetType().Name}")
|
||||
})?.ToList() ?? [];
|
||||
|
||||
// Write out what the decision was for each function approval request.
|
||||
nextIterationMessages.ForEach(x => Console.WriteLine($"Approval for the {(x.Contents[0] as FunctionApprovalResponseContent)?.FunctionCall.Name} function call is set to {(x.Contents[0] as FunctionApprovalResponseContent)?.Approved}."));
|
||||
|
||||
// Pass the user input responses back to the agent for further processing.
|
||||
updates = await agent.RunStreamingAsync(nextIterationMessages, thread).ToListAsync();
|
||||
|
||||
userInputRequests = updates.SelectMany(x => x.UserInputRequests).ToList();
|
||||
}
|
||||
|
||||
this.WriteResponseOutput(updates.ToAgentRunResponse());
|
||||
}
|
||||
|
||||
// Clean up the server-side agent after use when applicable (depending on the provider).
|
||||
await base.AgentCleanUpAsync(provider, agent, thread);
|
||||
}
|
||||
|
||||
private sealed class MenuTools
|
||||
{
|
||||
[Description("Get the full menu items.")]
|
||||
public MenuItem[] GetMenu()
|
||||
{
|
||||
return s_menuItems;
|
||||
}
|
||||
|
||||
[Description("Get the specials from the menu.")]
|
||||
public IEnumerable<MenuItem> GetSpecials()
|
||||
{
|
||||
return s_menuItems.Where(i => i.IsSpecial);
|
||||
}
|
||||
|
||||
[Description("Get the price of a menu item.")]
|
||||
public float? GetItemPrice([Description("The name of the menu item.")] string menuItem)
|
||||
{
|
||||
return s_menuItems.FirstOrDefault(i => i.Name.Equals(menuItem, StringComparison.OrdinalIgnoreCase))?.Price;
|
||||
}
|
||||
|
||||
private static readonly MenuItem[] s_menuItems = [
|
||||
new() { Category = "Soup", Name = "Clam Chowder", Price = 4.95f, IsSpecial = true },
|
||||
new() { Category = "Soup", Name = "Tomato Soup", Price = 4.95f, IsSpecial = false },
|
||||
new() { Category = "Salad", Name = "Cobb Salad", Price = 9.99f },
|
||||
new() { Category = "Salad", Name = "House Salad", Price = 4.95f },
|
||||
new() { Category = "Drink", Name = "Chai Tea", Price = 2.95f, IsSpecial = true },
|
||||
new() { Category = "Drink", Name = "Soda", Price = 1.95f },
|
||||
];
|
||||
|
||||
public sealed class MenuItem
|
||||
{
|
||||
public string Category { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public float Price { get; set; }
|
||||
public bool IsSpecial { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
#if NET9_0_OR_GREATER
|
||||
#if NET8_0_OR_GREATER
|
||||
using System.Buffers;
|
||||
#endif
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
#if NET9_0_OR_GREATER
|
||||
using System.Linq;
|
||||
|
||||
#if NET8_0_OR_GREATER
|
||||
using System.Text;
|
||||
#endif
|
||||
using System.Text.Json;
|
||||
@@ -82,6 +84,13 @@ public class AgentRunResponse
|
||||
[JsonIgnore]
|
||||
public string Text => this._messages?.ConcatText() ?? string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the user input requests associated with the response.</summary>
|
||||
/// <remarks>
|
||||
/// This property concatenates all <see cref="UserInputRequestContent"/> instances in the response.
|
||||
/// </remarks>
|
||||
[JsonIgnore]
|
||||
public IEnumerable<UserInputRequestContent> UserInputRequests => this._messages?.SelectMany(x => x.Contents).OfType<UserInputRequestContent>() ?? [];
|
||||
|
||||
/// <summary>Gets or sets the ID of the agent that produced the response.</summary>
|
||||
public string? AgentId { get; set; }
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
@@ -92,6 +93,13 @@ public class AgentRunResponseUpdate
|
||||
[JsonIgnore]
|
||||
public string Text => this._contents is not null ? this._contents.ConcatText() : string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the user input requests associated with the response.</summary>
|
||||
/// <remarks>
|
||||
/// This property concatenates all <see cref="UserInputRequestContent"/> instances in the response.
|
||||
/// </remarks>
|
||||
[JsonIgnore]
|
||||
public IEnumerable<UserInputRequestContent> UserInputRequests => this._contents?.OfType<UserInputRequestContent>() ?? [];
|
||||
|
||||
/// <summary>Gets or sets the agent run response update content items.</summary>
|
||||
[AllowNull]
|
||||
public IList<AIContent> Contents
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Extensions.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a request for user approval of a function call.
|
||||
/// </summary>
|
||||
public sealed class FunctionApprovalRequestContent : UserInputRequestContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FunctionApprovalRequestContent"/> class.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID to uniquely identify the function approval request/response pair.</param>
|
||||
/// <param name="functionCall">The function call that requires user approval.</param>
|
||||
public FunctionApprovalRequestContent(string id, FunctionCallContent functionCall)
|
||||
: base(id)
|
||||
{
|
||||
this.FunctionCall = Throw.IfNull(functionCall);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the function call that pre-invoke approval is required for.
|
||||
/// </summary>
|
||||
public FunctionCallContent FunctionCall { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="FunctionApprovalResponseContent"/> to indicate whether the function call is approved or rejected based on the value of <paramref name="approved"/>.
|
||||
/// </summary>
|
||||
/// <param name="approved"><see langword="true"/> if the function call is approved; otherwise, <see langword="false"/>.</param>
|
||||
/// <returns>The <see cref="FunctionApprovalResponseContent"/> representing the approval response.</returns>
|
||||
public FunctionApprovalResponseContent CreateResponse(bool approved)
|
||||
=> new(this.Id, approved, this.FunctionCall);
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Extensions.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a response to a function approval request.
|
||||
/// </summary>
|
||||
public sealed class FunctionApprovalResponseContent : UserInputResponseContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FunctionApprovalResponseContent"/> class.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID to uniquely identify the function approval request/response pair.</param>
|
||||
/// <param name="approved">Indicates whether the request was approved.</param>
|
||||
/// <param name="functionCall">The function call that requires user approval.</param>
|
||||
public FunctionApprovalResponseContent(string id, bool approved, FunctionCallContent functionCall)
|
||||
: base(id)
|
||||
{
|
||||
this.Approved = approved;
|
||||
this.FunctionCall = Throw.IfNull(functionCall);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the user approved the request.
|
||||
/// </summary>
|
||||
public bool Approved { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the function call that pre-invoke approval is required for.
|
||||
/// </summary>
|
||||
public FunctionCallContent FunctionCall { get; }
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Extensions.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for user input request content.
|
||||
/// </summary>
|
||||
public abstract class UserInputRequestContent : AIContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UserInputRequestContent"/> class.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID to uniquely identify the user input request/response pair.</param>
|
||||
protected UserInputRequestContent(string id)
|
||||
{
|
||||
Id = Throw.IfNullOrWhitespace(id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ID to uniquely identify the user input request/response pair.
|
||||
/// </summary>
|
||||
public string Id { get; }
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Extensions.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for user input response content.
|
||||
/// </summary>
|
||||
public abstract class UserInputResponseContent : AIContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UserInputResponseContent"/> class.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID to uniquely identify the user input request/response pair.</param>
|
||||
protected UserInputResponseContent(string id)
|
||||
{
|
||||
Id = Throw.IfNullOrWhitespace(id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ID to uniquely identify the user input request/response pair.
|
||||
/// </summary>
|
||||
public string Id { get; }
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.Extensions.AI.Agents;
|
||||
|
||||
internal static class ChatClientExtensions
|
||||
@@ -15,7 +19,12 @@ internal static class ChatClientExtensions
|
||||
|
||||
if (chatClient.GetService<NewFunctionInvokingChatClient>() is null)
|
||||
{
|
||||
chatBuilder.UseFunctionInvocation();
|
||||
chatBuilder.Use((IChatClient innerClient, IServiceProvider services) =>
|
||||
{
|
||||
var loggerFactory = services.GetService<ILoggerFactory>();
|
||||
|
||||
return new NewFunctionInvokingChatClient(innerClient, loggerFactory, services);
|
||||
});
|
||||
}
|
||||
|
||||
return chatBuilder.Build();
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Extensions.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Marks an existing <see cref="AIFunction"/> with additional metadata to indicate that it requires approval.
|
||||
/// </summary>
|
||||
/// <param name="function">The <see cref="AIFunction"/> that requires approval.</param>
|
||||
public sealed class ApprovalRequiredAIFunction(AIFunction function) : DelegatingAIFunction(function)
|
||||
{
|
||||
/// <summary>
|
||||
/// An optional callback that can be used to determine if the function call requires approval, instead of the default behavior, which is to always require approval.
|
||||
/// </summary>
|
||||
public Func<ApprovalContext, ValueTask<bool>> RequiresApprovalCallback { get; set; } = _ => new(true);
|
||||
|
||||
/// <summary>
|
||||
/// Context object that provides information about the function call that requires approval.
|
||||
/// </summary>
|
||||
public sealed class ApprovalContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ApprovalContext"/> class.
|
||||
/// </summary>
|
||||
/// <param name="functionCall">The <see cref="FunctionCallContent"/> containing the details of the invocation.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="functionCall"/> is null.</exception>
|
||||
public ApprovalContext(FunctionCallContent functionCall)
|
||||
{
|
||||
this.FunctionCall = Throw.IfNull(functionCall);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="FunctionCallContent"/> containing the details of the invocation that will be made if approval is granted.
|
||||
/// </summary>
|
||||
public FunctionCallContent FunctionCall { get; }
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,6 @@ namespace Microsoft.Extensions.AI;
|
||||
/// invocation requests to that same function.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[ExcludeFromCodeCoverage]
|
||||
public partial class NewFunctionInvokingChatClient : DelegatingChatClient
|
||||
{
|
||||
/// <summary>The <see cref="FunctionInvocationContext"/> for the current function invocation.</summary>
|
||||
@@ -259,6 +258,25 @@ public partial class NewFunctionInvokingChatClient : DelegatingChatClient
|
||||
bool lastIterationHadConversationId = false; // whether the last iteration's response had a ConversationId set
|
||||
int consecutiveErrorCount = 0;
|
||||
|
||||
// Process approval requests (remove from originalMessages) and rejected approval responses (re-create FCC and create failed FRC).
|
||||
var (preDownstreamCallHistory, notInvokedApprovals) = ProcessFunctionApprovalResponses(originalMessages, !string.IsNullOrWhiteSpace(options?.ConversationId), toolResponseId: null, functionCallContentFallbackMessageId: null);
|
||||
|
||||
// Invoke approved approval responses, which generates some additional FRC wrapped in ChatMessage.
|
||||
(IList<ChatMessage>? invokedApprovedFunctionApprovalResponses, bool shouldTerminate, consecutiveErrorCount) =
|
||||
await InvokeApprovedFunctionApprovalResponses(notInvokedApprovals, originalMessages, options, consecutiveErrorCount, isStreaming: false, cancellationToken);
|
||||
|
||||
if (invokedApprovedFunctionApprovalResponses is not null)
|
||||
{
|
||||
// We need to add the generated FRC to the list we'll return to callers as part of the next response.
|
||||
preDownstreamCallHistory ??= [];
|
||||
preDownstreamCallHistory.AddRange(invokedApprovedFunctionApprovalResponses);
|
||||
}
|
||||
|
||||
if (shouldTerminate)
|
||||
{
|
||||
return new ChatResponse(preDownstreamCallHistory);
|
||||
}
|
||||
|
||||
for (int iteration = 0; ; iteration++)
|
||||
{
|
||||
functionCallContents?.Clear();
|
||||
@@ -270,6 +288,10 @@ public partial class NewFunctionInvokingChatClient : DelegatingChatClient
|
||||
Throw.InvalidOperationException($"The inner {nameof(IChatClient)} returned a null {nameof(ChatResponse)}.");
|
||||
}
|
||||
|
||||
// Before we do any function execution, make sure that any functions that require approval, have been turned into approval requests
|
||||
// so that they don't get executed here.
|
||||
response.Messages = await ReplaceFunctionCallsWithApprovalRequests(response.Messages, options?.Tools, AdditionalTools);
|
||||
|
||||
// Any function call work to do? If yes, ensure we're tracking that work in functionCallContents.
|
||||
bool requiresFunctionInvocation =
|
||||
(options?.Tools is { Count: > 0 } || AdditionalTools is { Count: > 0 }) &&
|
||||
@@ -280,6 +302,11 @@ public partial class NewFunctionInvokingChatClient : DelegatingChatClient
|
||||
// fast path out by just returning the original response.
|
||||
if (iteration == 0 && !requiresFunctionInvocation)
|
||||
{
|
||||
// Insert any pre-invocation FCC and FRC that were converted from approval responses into the response here,
|
||||
// so they are returned to the caller.
|
||||
response.Messages = UpdateResponseMessagesWithPreDownstreamCallHistory(response.Messages, preDownstreamCallHistory);
|
||||
preDownstreamCallHistory = null;
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -354,11 +381,59 @@ public partial class NewFunctionInvokingChatClient : DelegatingChatClient
|
||||
List<ChatResponseUpdate> updates = []; // updates from the current response
|
||||
int consecutiveErrorCount = 0;
|
||||
|
||||
// This is a synthetic ID since we're generating the tool messages instead of getting them from
|
||||
// the underlying provider. When emitting the streamed chunks, it's perfectly valid for us to
|
||||
// use the same message ID for all of them within a given iteration, as this is a single logical
|
||||
// message with multiple content items. We could also use different message IDs per tool content,
|
||||
// but there's no benefit to doing so.
|
||||
string toolResponseId = Guid.NewGuid().ToString("N");
|
||||
|
||||
// We also need a synthetic ID for the function call content for approved function calls
|
||||
// where we don't know what the original message id of the function call was.
|
||||
string functionCallContentFallbackMessageId = Guid.NewGuid().ToString("N");
|
||||
|
||||
ApprovalRequiredAIFunction[]? approvalRequiredFunctions = (options?.Tools ?? []).Concat(AdditionalTools ?? []).OfType<ApprovalRequiredAIFunction>().ToArray();
|
||||
bool hasApprovalRequiringFunctions = approvalRequiredFunctions.Length > 0;
|
||||
|
||||
// Process approval requests (remove from original messages) and rejected approval responses (re-create FCC and create failed FRC).
|
||||
var (preDownstreamCallHistory, notInvokedApprovals) = ProcessFunctionApprovalResponses(originalMessages, !string.IsNullOrWhiteSpace(options?.ConversationId), toolResponseId, functionCallContentFallbackMessageId);
|
||||
if (preDownstreamCallHistory is not null)
|
||||
{
|
||||
foreach (var message in preDownstreamCallHistory)
|
||||
{
|
||||
yield return ConvertToolResultMessageToUpdate(message, options?.ConversationId, message.MessageId);
|
||||
Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802
|
||||
}
|
||||
}
|
||||
|
||||
// Invoke approved approval responses, which generates some additional FRC wrapped in ChatMessage.
|
||||
(IList<ChatMessage>? invokedApprovedFunctionApprovalResponses, bool shouldTerminate, consecutiveErrorCount) =
|
||||
await InvokeApprovedFunctionApprovalResponses(notInvokedApprovals, originalMessages, options, consecutiveErrorCount, isStreaming: true, cancellationToken);
|
||||
|
||||
if (invokedApprovedFunctionApprovalResponses is not null)
|
||||
{
|
||||
foreach (var message in invokedApprovedFunctionApprovalResponses)
|
||||
{
|
||||
message.MessageId = toolResponseId;
|
||||
yield return ConvertToolResultMessageToUpdate(message, options?.ConversationId, message.MessageId);
|
||||
Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802
|
||||
}
|
||||
|
||||
if (shouldTerminate)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
|
||||
for (int iteration = 0; ; iteration++)
|
||||
{
|
||||
updates.Clear();
|
||||
functionCallContents?.Clear();
|
||||
|
||||
bool hasApprovalRequiringFcc = false;
|
||||
int lastApprovalCheckedFCCIndex = 0;
|
||||
int lastYieldedUpdateIndex = 0;
|
||||
|
||||
await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken))
|
||||
{
|
||||
if (update is null)
|
||||
@@ -383,12 +458,54 @@ public partial class NewFunctionInvokingChatClient : DelegatingChatClient
|
||||
}
|
||||
}
|
||||
|
||||
yield return update;
|
||||
if (functionCallContents?.Count is not > 0 || !hasApprovalRequiringFunctions)
|
||||
{
|
||||
// If there are no function calls to make yet, or if none of the functions require approval at all,
|
||||
// we can yield the update as-is.
|
||||
lastYieldedUpdateIndex++;
|
||||
yield return update;
|
||||
Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check if any of the function call contents in this update requires approval.
|
||||
// Once we find the first one that requires approval, this method becomes a no-op.
|
||||
(hasApprovalRequiringFcc, lastApprovalCheckedFCCIndex) = await CheckForApprovalRequiringFCCAsync(
|
||||
functionCallContents, approvalRequiredFunctions, hasApprovalRequiringFcc, lastApprovalCheckedFCCIndex);
|
||||
|
||||
// We've encountered a function call content that requires approval (either in this update or ealier)
|
||||
// so we need to ask for approval for all functions, since we cannot mix and match.
|
||||
if (hasApprovalRequiringFcc)
|
||||
{
|
||||
// Convert all function call contents into approval requests from the last yielded update index
|
||||
// and yield all those updates.
|
||||
for (; lastYieldedUpdateIndex < updates.Count; lastYieldedUpdateIndex++)
|
||||
{
|
||||
var updateToYield = updates[lastYieldedUpdateIndex];
|
||||
if (TryReplaceFunctionCallsWithApprovalRequests(updateToYield.Contents, out var updatedContents))
|
||||
{
|
||||
updateToYield.Contents = updatedContents;
|
||||
}
|
||||
yield return updateToYield;
|
||||
Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// We don't have any appoval requiring function calls yet, but we may receive some in future
|
||||
// so we cannot yield the updates yet. We'll just keep them in the updates list
|
||||
// for later.
|
||||
// We will yield the updates as soon as we receive a function call content that requires approval or
|
||||
// when we reach the end of the updates stream.
|
||||
}
|
||||
}
|
||||
|
||||
Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802
|
||||
}
|
||||
|
||||
// If there are no tools to call, or for any other reason we should stop, return the response.
|
||||
if (functionCallContents is not { Count: > 0 } ||
|
||||
hasApprovalRequiringFcc ||
|
||||
(options?.Tools is not { Count: > 0 } && AdditionalTools is not { Count: > 0 }) ||
|
||||
iteration >= _maximumIterationsPerRequest)
|
||||
{
|
||||
@@ -407,31 +524,11 @@ public partial class NewFunctionInvokingChatClient : DelegatingChatClient
|
||||
responseMessages.AddRange(modeAndMessages.MessagesAdded);
|
||||
consecutiveErrorCount = modeAndMessages.NewConsecutiveErrorCount;
|
||||
|
||||
// This is a synthetic ID since we're generating the tool messages instead of getting them from
|
||||
// the underlying provider. When emitting the streamed chunks, it's perfectly valid for us to
|
||||
// use the same message ID for all of them within a given iteration, as this is a single logical
|
||||
// message with multiple content items. We could also use different message IDs per tool content,
|
||||
// but there's no benefit to doing so.
|
||||
string toolResponseId = Guid.NewGuid().ToString("N");
|
||||
|
||||
// Stream any generated function results. This mirrors what's done for GetResponseAsync, where the returned messages
|
||||
// includes all activities, including generated function results.
|
||||
foreach (var message in modeAndMessages.MessagesAdded)
|
||||
{
|
||||
var toolResultUpdate = new ChatResponseUpdate
|
||||
{
|
||||
AdditionalProperties = message.AdditionalProperties,
|
||||
AuthorName = message.AuthorName,
|
||||
ConversationId = response.ConversationId,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Contents = message.Contents,
|
||||
RawRepresentation = message.RawRepresentation,
|
||||
ResponseId = toolResponseId,
|
||||
MessageId = toolResponseId, // See above for why this can be the same as ResponseId
|
||||
Role = message.Role,
|
||||
};
|
||||
|
||||
yield return toolResultUpdate;
|
||||
yield return ConvertToolResultMessageToUpdate(message, response.ConversationId, toolResponseId);
|
||||
Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802
|
||||
}
|
||||
|
||||
@@ -932,6 +1029,448 @@ public partial class NewFunctionInvokingChatClient : DelegatingChatClient
|
||||
context.Function.InvokeAsync(context.Arguments, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 1. Remove all <see cref="FunctionApprovalRequestContent"/> and <see cref="FunctionApprovalResponseContent"/> from the <paramref name="originalMessages"/>.
|
||||
/// 2. Recreate <see cref="FunctionCallContent"/> for any <see cref="FunctionApprovalResponseContent"/> that haven't been executed yet.
|
||||
/// 3. Genreate failed <see cref="FunctionResultContent"/> for any rejected <see cref="FunctionApprovalResponseContent"/>.
|
||||
/// 4. add all the new content items to <paramref name="originalMessages"/> and return them as the pre-invocation history.
|
||||
/// </summary>
|
||||
private static (List<ChatMessage>? preDownstreamCallHistory, List<ApprovalResultWithRequestMessage>? approvals) ProcessFunctionApprovalResponses(
|
||||
List<ChatMessage> originalMessages, bool hasConversationId, string? toolResponseId, string? functionCallContentFallbackMessageId)
|
||||
{
|
||||
// Extract any approval responses where we need to execute or reject the function calls.
|
||||
// The original messages are also modified to remove all approval requests and responses.
|
||||
var notInvokedResponses = ExtractAndRemoveApprovalRequestsAndResponses(originalMessages);
|
||||
|
||||
// Wrap the function call content in message(s).
|
||||
ICollection<ChatMessage>? allPreDownstreamCallMessages = ConvertToFunctionCallContentMessages(
|
||||
[.. notInvokedResponses.rejections ?? [], .. notInvokedResponses.approvals ?? []], functionCallContentFallbackMessageId);
|
||||
|
||||
// Generate failed function result contents for any rejected requests and wrap it in a message.
|
||||
List<AIContent>? rejectedFunctionCallResults = GenerateRejectedFunctionResults(notInvokedResponses.rejections, toolResponseId);
|
||||
ChatMessage? rejectedPreDownstreamCallResultsMessage = rejectedFunctionCallResults != null ?
|
||||
new ChatMessage(ChatRole.Tool, rejectedFunctionCallResults) { MessageId = toolResponseId } :
|
||||
null;
|
||||
|
||||
// Add all the FCC that we generated to the pre-downstream-call history so that they can be returned to the caller as part of the next response.
|
||||
// Also, if we are not dealing with a service thread (i.e. we don't have a conversation ID), add them
|
||||
// into the original messages list so that they are passed to the inner client and can be used to generate a result.
|
||||
List<ChatMessage>? preDownstreamCallHistory = null;
|
||||
if (allPreDownstreamCallMessages is not null)
|
||||
{
|
||||
preDownstreamCallHistory ??= [];
|
||||
foreach (var message in allPreDownstreamCallMessages)
|
||||
{
|
||||
preDownstreamCallHistory.Add(message);
|
||||
if (!hasConversationId)
|
||||
{
|
||||
originalMessages.Add(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add all the FRC that we generated to the pre-downstream-call history so that they can be returned to the caller as part of the next response.
|
||||
// Also, add them into the original messages list so that they are passed to the inner client and can be used to generate a result.
|
||||
if (rejectedPreDownstreamCallResultsMessage is not null)
|
||||
{
|
||||
preDownstreamCallHistory ??= [];
|
||||
originalMessages.Add(rejectedPreDownstreamCallResultsMessage);
|
||||
preDownstreamCallHistory.Add(rejectedPreDownstreamCallResultsMessage);
|
||||
}
|
||||
|
||||
return (preDownstreamCallHistory, notInvokedResponses.approvals);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute the provided <see cref="FunctionApprovalResponseContent"/> and return the resulting <see cref="FunctionCallContent"/>.
|
||||
/// </summary>
|
||||
private async Task<(IList<ChatMessage>? FunctionResultContent, bool ShouldTerminate, int ConsecutiveErrorCount)> InvokeApprovedFunctionApprovalResponses(
|
||||
List<ApprovalResultWithRequestMessage>? notInvokedApprovals, List<ChatMessage> originalMessages, ChatOptions? options, int consecutiveErrorCount, bool isStreaming, CancellationToken cancellationToken)
|
||||
{
|
||||
// Check if there are any function calls to do for any approved functions and execute them.
|
||||
if (notInvokedApprovals is { Count: > 0 })
|
||||
{
|
||||
// The FRC that is generated here is already added to originalMessages by ProcessFunctionCallsAsync.
|
||||
var modeAndMessages = await ProcessFunctionCallsAsync(originalMessages, options, notInvokedApprovals.Select(x => x.Response.FunctionCall).ToList(), 0, consecutiveErrorCount, isStreaming, cancellationToken);
|
||||
consecutiveErrorCount = modeAndMessages.NewConsecutiveErrorCount;
|
||||
|
||||
return (modeAndMessages.MessagesAdded, modeAndMessages.ShouldTerminate, consecutiveErrorCount);
|
||||
}
|
||||
|
||||
return (null, false, consecutiveErrorCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This method extracts the approval requests and responses from the provided list of messages, validates them, filters them to ones that require execution and splits them into approved and rejected.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 1st iteration: over all messages and content
|
||||
/// =====
|
||||
/// Build a list of all function call ids that are already executed.
|
||||
/// Build a list of all function approval requests and responses.
|
||||
/// Build a list of the content we want to keep (everything except approval requests and responses) and create a new list of messages for those.
|
||||
/// Validate that we have an approval response for each approval request.
|
||||
///
|
||||
/// 2nd iteration: over all approval responses
|
||||
/// =====
|
||||
/// Filter out any approval responses that already have a matching function result (i.e. already executed).
|
||||
/// Find the matching function approval request for any response (where available).
|
||||
/// Split the approval responses into two lists: approved and rejected, with their request messages (where available).
|
||||
///
|
||||
/// We return the messages containing the approval requests since these are the same messages that originally contained the FunctionCallContent from the downstream service.
|
||||
/// We can then use the metadata from these messages when we re-create the FunctionCallContent messages/updates to return to the caller. This way, when we finally do return
|
||||
/// the FuncionCallContent to users it's part of a message/update that contains the same metadata as originally returned to the downstream service.
|
||||
/// </remarks>
|
||||
private static (List<ApprovalResultWithRequestMessage>? approvals, List<ApprovalResultWithRequestMessage>? rejections) ExtractAndRemoveApprovalRequestsAndResponses(List<ChatMessage> messages)
|
||||
{
|
||||
Dictionary<string, ChatMessage>? allApprovalRequestsMessages = null;
|
||||
List<FunctionApprovalResponseContent>? allApprovalResponses = null;
|
||||
HashSet<string>? approvalRequestCallIds = null;
|
||||
HashSet<string>? functionResultCallIds = null;
|
||||
|
||||
for (int i = 0; i < messages.Count; i++)
|
||||
{
|
||||
var message = messages[i];
|
||||
|
||||
List<AIContent>? keptContents = null;
|
||||
|
||||
// Find contents we want to keep.
|
||||
for (int j = 0; j < message.Contents.Count; j++)
|
||||
{
|
||||
var content = message.Contents[j];
|
||||
|
||||
// Maintain a list of function calls that have already been executed, so we can avoid executing them a second time.
|
||||
if (content is FunctionResultContent functionResultContent)
|
||||
{
|
||||
functionResultCallIds ??= [];
|
||||
functionResultCallIds.Add(functionResultContent.CallId);
|
||||
}
|
||||
|
||||
// Validation: Capture each call id for each approval request so that we can ensure that we have a matching response later.
|
||||
if (content is FunctionApprovalRequestContent request_)
|
||||
{
|
||||
approvalRequestCallIds ??= [];
|
||||
approvalRequestCallIds.Add(request_.FunctionCall.CallId);
|
||||
}
|
||||
|
||||
// Validation: Remove the call id for each approval response, to check it off the list of requests we need responses for.
|
||||
if (content is FunctionApprovalResponseContent response_ && approvalRequestCallIds is not null)
|
||||
{
|
||||
approvalRequestCallIds.Remove(response_.FunctionCall.CallId);
|
||||
}
|
||||
|
||||
// Build the list of requets and responses and keep them out of the updated message list
|
||||
// since they will be handled in this class, and don't need to be passed further down the stack.
|
||||
if (content is FunctionApprovalRequestContent approvalRequest)
|
||||
{
|
||||
allApprovalRequestsMessages ??= new Dictionary<string, ChatMessage>();
|
||||
allApprovalRequestsMessages.Add(approvalRequest.Id, message);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (content is FunctionApprovalResponseContent approvalResponse)
|
||||
{
|
||||
allApprovalResponses ??= [];
|
||||
allApprovalResponses.Add(approvalResponse);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If we get to here, we should have just the contents that we want to keep.
|
||||
keptContents ??= [];
|
||||
keptContents.Add(content);
|
||||
}
|
||||
|
||||
if (message.Contents.Count > 0 && keptContents?.Count != message.Contents.Count)
|
||||
{
|
||||
if (keptContents is null || keptContents.Count == 0)
|
||||
{
|
||||
// If we have no contents left after filtering, we can remove the message.
|
||||
messages.RemoveAt(i);
|
||||
i--; // Adjust index since we removed an item.
|
||||
continue;
|
||||
}
|
||||
|
||||
// If we have any contents left after filtering, we can keep the message with the new remaining content.
|
||||
var newMessage = message.Clone();
|
||||
newMessage.Contents = keptContents;
|
||||
messages[i] = newMessage;
|
||||
}
|
||||
}
|
||||
|
||||
// Validation: If we got an approval for each request, we should have no call ids left.
|
||||
if (approvalRequestCallIds?.Count is > 0)
|
||||
{
|
||||
Throw.InvalidOperationException($"FunctionApprovalRequestContent found with FunctionCall.CallId(s) '{string.Join(", ", approvalRequestCallIds)}' that have no matching FunctionApprovalResponseContent.");
|
||||
}
|
||||
|
||||
List<ApprovalResultWithRequestMessage>? approvedFunctionCalls = null;
|
||||
List<ApprovalResultWithRequestMessage>? rejectedFunctionCalls = null;
|
||||
|
||||
for (int i = 0; i < (allApprovalResponses?.Count ?? 0); i++)
|
||||
{
|
||||
var approvalResponse = allApprovalResponses![i];
|
||||
|
||||
// Skip any approval responses that have already been executed.
|
||||
if (functionResultCallIds?.Contains(approvalResponse.FunctionCall.CallId) is not true)
|
||||
{
|
||||
ChatMessage? requestMessage = null;
|
||||
allApprovalRequestsMessages?.TryGetValue(approvalResponse.FunctionCall.CallId, out requestMessage);
|
||||
|
||||
// Split the responses into approved and rejected.
|
||||
if (approvalResponse.Approved)
|
||||
{
|
||||
approvedFunctionCalls ??= [];
|
||||
approvedFunctionCalls.Add(new ApprovalResultWithRequestMessage { Response = approvalResponse, RequestMessage = requestMessage });
|
||||
}
|
||||
else
|
||||
{
|
||||
rejectedFunctionCalls ??= [];
|
||||
rejectedFunctionCalls.Add(new ApprovalResultWithRequestMessage { Response = approvalResponse, RequestMessage = requestMessage });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (approvedFunctionCalls, rejectedFunctionCalls);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// If we have any rejected approval responses, we need to generate failed function results for them.
|
||||
/// </summary>
|
||||
/// <param name="rejections">Any rejected approval responses.</param>
|
||||
/// <param name="toolResponseId">The message id to use for the tool response.</param>
|
||||
/// <returns>The <see cref="AIContent"/> for the rejected function calls.</returns>
|
||||
private static List<AIContent>? GenerateRejectedFunctionResults(
|
||||
List<ApprovalResultWithRequestMessage>? rejections,
|
||||
string? toolResponseId)
|
||||
{
|
||||
List<AIContent>? functionResultContent = null;
|
||||
|
||||
if (rejections is { Count: > 0 })
|
||||
{
|
||||
functionResultContent = [];
|
||||
|
||||
foreach (var rejectedCall in rejections)
|
||||
{
|
||||
// Create a FunctionResultContent for the rejected function call.
|
||||
var functionResult = new FunctionResultContent(rejectedCall.Response.FunctionCall.CallId, "Error: Function invocation approval was not granted.");
|
||||
functionResultContent.Add(functionResult);
|
||||
}
|
||||
}
|
||||
|
||||
return functionResultContent;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the <see cref="FunctionCallContent"/> from the provided <see cref="FunctionApprovalResponseContent"/> to recreate the original function call messages.
|
||||
/// The output messages tries to mimic the original messages that contained the <see cref="FunctionCallContent"/>, e.g. if the <see cref="FunctionCallContent"/> had been split into separate messages,
|
||||
/// this method will recreate similarly split messages, each with their own <see cref="FunctionCallContent"/>.
|
||||
/// </summary>
|
||||
#pragma warning disable CA1859 // Use concrete types when possible for improved performance
|
||||
private static ICollection<ChatMessage>? ConvertToFunctionCallContentMessages(IEnumerable<ApprovalResultWithRequestMessage>? resultWithRequestMessages, string? fallbackMessageId)
|
||||
#pragma warning restore CA1859 // Use concrete types when possible for improved performance
|
||||
{
|
||||
if (resultWithRequestMessages is not null)
|
||||
{
|
||||
ChatMessage? currentMessage = null;
|
||||
Dictionary<string, ChatMessage>? messagesById = null;
|
||||
|
||||
foreach (var resultWithRequestMessage in resultWithRequestMessages)
|
||||
{
|
||||
if (currentMessage is not null && messagesById is null // Don't need to create a dictionary on the first iteration or if we alrady have one.
|
||||
&& !(resultWithRequestMessage.RequestMessage is null && currentMessage.MessageId == fallbackMessageId) // Everywhere we have no RequestMessage we use the fallbackMessageId, so in this case there is only one message.
|
||||
&& (resultWithRequestMessage.RequestMessage is not null && currentMessage.MessageId != resultWithRequestMessage.RequestMessage?.MessageId)) // Where we do have a RequestMessage, we can check if its message id differs from the current one.
|
||||
{
|
||||
// The majority of the time, all FCC would be part of a single message, so no need to create a dictionary for this case.
|
||||
// If we are dealing with multiple messages though, we need to keep track of them by their message ID.
|
||||
messagesById ??= new();
|
||||
messagesById[currentMessage.MessageId ?? string.Empty] = currentMessage;
|
||||
}
|
||||
|
||||
if (messagesById is not null)
|
||||
{
|
||||
messagesById.TryGetValue(resultWithRequestMessage.RequestMessage?.MessageId ?? string.Empty, out currentMessage);
|
||||
}
|
||||
|
||||
if (currentMessage is null)
|
||||
{
|
||||
currentMessage = ConvertToFunctionCallContentMessage(resultWithRequestMessage, fallbackMessageId);
|
||||
}
|
||||
else
|
||||
{
|
||||
currentMessage.Contents.Add(resultWithRequestMessage.Response.FunctionCall);
|
||||
}
|
||||
|
||||
if (messagesById is not null)
|
||||
{
|
||||
messagesById[currentMessage.MessageId ?? string.Empty] = currentMessage;
|
||||
}
|
||||
}
|
||||
|
||||
return messagesById?.Values as ICollection<ChatMessage> ?? (currentMessage != null ? [currentMessage!] : null);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes the <see cref="FunctionCallContent"/> from the <paramref name="resultWithRequestMessage"/> and wraps it in a <see cref="ChatMessage"/>
|
||||
/// using the same message id that the <see cref="FunctionCallContent"/> was originally returned with from the downstream <see cref="IChatClient"/>.
|
||||
/// </summary>
|
||||
private static ChatMessage ConvertToFunctionCallContentMessage(ApprovalResultWithRequestMessage resultWithRequestMessage, string? fallbackMessageId)
|
||||
{
|
||||
if (resultWithRequestMessage.RequestMessage is not null)
|
||||
{
|
||||
var functionCallMessage = resultWithRequestMessage.RequestMessage.Clone();
|
||||
functionCallMessage.Contents = [resultWithRequestMessage.Response.FunctionCall];
|
||||
functionCallMessage.MessageId ??= fallbackMessageId;
|
||||
return functionCallMessage;
|
||||
}
|
||||
|
||||
return new ChatMessage(ChatRole.Assistant, [resultWithRequestMessage.Response.FunctionCall]) { MessageId = fallbackMessageId };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if any of the provided <paramref name="functionCallContents"/> require approval.
|
||||
/// Supports checking from a provided index up to the end of the list, to allow efficient incremental checking
|
||||
/// when streaming.
|
||||
/// </summary>
|
||||
private static async Task<(bool hasApprovalRequiringFcc, int lastApprovalCheckedFCCIndex)> CheckForApprovalRequiringFCCAsync(
|
||||
List<FunctionCallContent>? functionCallContents,
|
||||
ApprovalRequiredAIFunction[] approvalRequiredFunctions,
|
||||
bool hasApprovalRequiringFcc,
|
||||
int lastApprovalCheckedFCCIndex)
|
||||
{
|
||||
// If we already found an approval requiring FCC, we can skip checking the rest.
|
||||
if (hasApprovalRequiringFcc)
|
||||
{
|
||||
return (true, functionCallContents?.Count ?? 0);
|
||||
}
|
||||
|
||||
for (; lastApprovalCheckedFCCIndex < (functionCallContents?.Count ?? 0); lastApprovalCheckedFCCIndex++)
|
||||
{
|
||||
var fcc = functionCallContents![lastApprovalCheckedFCCIndex];
|
||||
if (approvalRequiredFunctions.FirstOrDefault(y => y.Name == fcc.Name) is ApprovalRequiredAIFunction approvalFunction &&
|
||||
await approvalFunction.RequiresApprovalCallback(new(fcc)))
|
||||
{
|
||||
hasApprovalRequiringFcc |= true;
|
||||
}
|
||||
}
|
||||
|
||||
return (hasApprovalRequiringFcc, lastApprovalCheckedFCCIndex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces all <see cref="FunctionCallContent"/> with <see cref="FunctionApprovalRequestContent"/> and ouputs a new list if any of them were replaced.
|
||||
/// </summary>
|
||||
/// <returns>true if any <see cref="FunctionCallContent"/> was replaced, false otherwise.</returns>
|
||||
private static bool TryReplaceFunctionCallsWithApprovalRequests(IList<AIContent> content, out IList<AIContent>? updatedContent)
|
||||
{
|
||||
updatedContent = null;
|
||||
|
||||
if (content is { Count: > 0 })
|
||||
{
|
||||
for (int i = 0; i < content.Count; i++)
|
||||
{
|
||||
if (content[i] is FunctionCallContent fcc)
|
||||
{
|
||||
updatedContent ??= [.. content]; // Clone the list if we haven't already
|
||||
var approvalRequest = new FunctionApprovalRequestContent(fcc.CallId, fcc);
|
||||
updatedContent[i] = approvalRequest;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return updatedContent is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces all <see cref="FunctionCallContent"/> from <paramref name="messages"/> with <see cref="FunctionApprovalRequestContent"/>
|
||||
/// if any one of them requires approval.
|
||||
/// </summary>
|
||||
private static async Task<IList<ChatMessage>> ReplaceFunctionCallsWithApprovalRequests(IList<ChatMessage> messages, IList<AITool>? requestOptionsTools, IList<AITool>? additionalTools)
|
||||
{
|
||||
var outputMessages = messages;
|
||||
ApprovalRequiredAIFunction[]? approvalRequiredFunctions = null;
|
||||
|
||||
bool anyApprovalRequired = false;
|
||||
List<(int, int)>? allFunctionCallContentIndices = null;
|
||||
|
||||
// Build a list of the indices of all FunctionCallContent items.
|
||||
// Also check if any of them require approval.
|
||||
for (int i = 0; i < messages.Count; i++)
|
||||
{
|
||||
var content = messages[i].Contents;
|
||||
for (int j = 0; j < content.Count; j++)
|
||||
{
|
||||
if (content[j] is FunctionCallContent functionCall)
|
||||
{
|
||||
allFunctionCallContentIndices ??= [];
|
||||
allFunctionCallContentIndices.Add((i, j));
|
||||
|
||||
approvalRequiredFunctions ??= (requestOptionsTools ?? []).Concat(additionalTools ?? [])
|
||||
.OfType<ApprovalRequiredAIFunction>()
|
||||
.ToArray();
|
||||
|
||||
anyApprovalRequired |= approvalRequiredFunctions.FirstOrDefault(x => x.Name == functionCall.Name) is { } approvalFunction && await approvalFunction.RequiresApprovalCallback(new(functionCall));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If any function calls were found, and any of them required approval, we should replace all of them with approval requests.
|
||||
// This is because we do not have a way to deal with cases where some function calls require approval and others do not, so we just replace all of them.
|
||||
if (allFunctionCallContentIndices is not null && anyApprovalRequired)
|
||||
{
|
||||
// Clone the list so, we don't mutate the input.
|
||||
outputMessages = [.. messages];
|
||||
int lastMessageIndex = -1;
|
||||
|
||||
foreach (var (messageIndex, contentIndex) in allFunctionCallContentIndices)
|
||||
{
|
||||
// Clone the message if we didn't already clone it in a previous iteration.
|
||||
var message = lastMessageIndex != messageIndex ? outputMessages[messageIndex].Clone() : outputMessages[messageIndex];
|
||||
message.Contents = [.. message.Contents];
|
||||
|
||||
var functionCall = (FunctionCallContent)message.Contents[contentIndex];
|
||||
message.Contents[contentIndex] = new FunctionApprovalRequestContent(functionCall.CallId, functionCall);
|
||||
outputMessages[messageIndex] = message;
|
||||
|
||||
lastMessageIndex = messageIndex;
|
||||
}
|
||||
}
|
||||
|
||||
return outputMessages;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Insert the given <paramref name="preDownstreamCallHistory"/> at the start of the <paramref name="responseMessages"/>.
|
||||
/// </summary>
|
||||
private static IList<ChatMessage> UpdateResponseMessagesWithPreDownstreamCallHistory(IList<ChatMessage> responseMessages, List<ChatMessage>? preDownstreamCallHistory)
|
||||
{
|
||||
if (preDownstreamCallHistory?.Count > 0)
|
||||
{
|
||||
// Since these messages are pre-invocation, we want to insert them at the start of the response messages.
|
||||
return [.. preDownstreamCallHistory, .. responseMessages];
|
||||
}
|
||||
|
||||
return responseMessages;
|
||||
}
|
||||
|
||||
private static ChatResponseUpdate ConvertToolResultMessageToUpdate(ChatMessage message, string? conversationId, string? messageId)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
AdditionalProperties = message.AdditionalProperties,
|
||||
AuthorName = message.AuthorName,
|
||||
ConversationId = conversationId,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Contents = message.Contents,
|
||||
RawRepresentation = message.RawRepresentation,
|
||||
ResponseId = messageId,
|
||||
MessageId = messageId,
|
||||
Role = message.Role,
|
||||
};
|
||||
}
|
||||
|
||||
private static TimeSpan GetElapsedTime(long startingTimestamp) =>
|
||||
#if NET
|
||||
Stopwatch.GetElapsedTime(startingTimestamp);
|
||||
@@ -1005,4 +1544,10 @@ public partial class NewFunctionInvokingChatClient : DelegatingChatClient
|
||||
/// <summary>The function call failed with an exception.</summary>
|
||||
Exception,
|
||||
}
|
||||
|
||||
private struct ApprovalResultWithRequestMessage
|
||||
{
|
||||
public FunctionApprovalResponseContent Response { get; set; }
|
||||
public ChatMessage? RequestMessage { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// WARNING:
|
||||
// This class has been temporarily copied here from MEAI, to allow prototyping
|
||||
// functionality that will be moved to MEAI in the future.
|
||||
// This file is not intended to be modified.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace Microsoft.Extensions.AI;
|
||||
|
||||
internal static class AssertExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Asserts that the two function call parameters are equal, up to JSON equivalence.
|
||||
/// </summary>
|
||||
public static void EqualFunctionCallParameters(
|
||||
IDictionary<string, object?>? expected,
|
||||
IDictionary<string, object?>? actual,
|
||||
JsonSerializerOptions? options = null)
|
||||
{
|
||||
if (expected is null || actual is null)
|
||||
{
|
||||
Assert.Equal(expected, actual);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var expectedEntry in expected)
|
||||
{
|
||||
if (!actual.TryGetValue(expectedEntry.Key, out object? actualValue))
|
||||
{
|
||||
throw new XunitException($"Expected parameter '{expectedEntry.Key}' not found in actual value.");
|
||||
}
|
||||
|
||||
AreJsonEquivalentValues(expectedEntry.Value, actualValue, options, propertyName: expectedEntry.Key);
|
||||
}
|
||||
|
||||
if (expected.Count != actual.Count)
|
||||
{
|
||||
var extraParameters = actual
|
||||
.Where(e => !expected.ContainsKey(e.Key))
|
||||
.Select(e => $"'{e.Key}'")
|
||||
.First();
|
||||
|
||||
throw new XunitException($"Actual value contains additional parameters {string.Join(", ", extraParameters)} not found in expected value.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that the two function call results are equal, up to JSON equivalence.
|
||||
/// </summary>
|
||||
public static void EqualFunctionCallResults(object? expected, object? actual, JsonSerializerOptions? options = null)
|
||||
=> AreJsonEquivalentValues(expected, actual, options);
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that the two JSON values are equal.
|
||||
/// </summary>
|
||||
public static void EqualJsonValues(JsonElement expectedJson, JsonElement actualJson, string? propertyName = null)
|
||||
{
|
||||
if (!JsonNode.DeepEquals(
|
||||
JsonSerializer.SerializeToNode(expectedJson, AIJsonUtilities.DefaultOptions),
|
||||
JsonSerializer.SerializeToNode(actualJson, AIJsonUtilities.DefaultOptions)))
|
||||
{
|
||||
string message = propertyName is null
|
||||
? $"JSON result does not match expected JSON.\r\nExpected: {expectedJson.GetRawText()}\r\nActual: {actualJson.GetRawText()}"
|
||||
: $"Parameter '{propertyName}' does not match expected JSON.\r\nExpected: {expectedJson.GetRawText()}\r\nActual: {actualJson.GetRawText()}";
|
||||
|
||||
throw new XunitException(message);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AreJsonEquivalentValues(object? expected, object? actual, JsonSerializerOptions? options, string? propertyName = null)
|
||||
{
|
||||
options ??= AIJsonUtilities.DefaultOptions;
|
||||
JsonElement expectedElement = NormalizeToElement(expected, options);
|
||||
JsonElement actualElement = NormalizeToElement(actual, options);
|
||||
EqualJsonValues(expectedElement, actualElement, propertyName);
|
||||
|
||||
static JsonElement NormalizeToElement(object? value, JsonSerializerOptions options)
|
||||
=> value is JsonElement e ? e : JsonSerializer.SerializeToElement(value, options);
|
||||
}
|
||||
}
|
||||
+931
@@ -0,0 +1,931 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// AF repo suppressions for code copied from MEAI.
|
||||
#pragma warning disable CA5394 // Do not use insecure randomness
|
||||
#pragma warning disable IDE0009 // Member access should be qualified.
|
||||
#pragma warning disable IDE0039 // Use local function
|
||||
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Extensions.AI;
|
||||
|
||||
public class NewFunctionInvokingChatClientTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task AllFunctionCallsReplacedWithApprovalsWhenAllRequireApprovalAsync(bool useAdditionalTools)
|
||||
{
|
||||
AITool[] tools =
|
||||
[
|
||||
new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")),
|
||||
new ApprovalRequiredAIFunction(AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2")) { RequiresApprovalCallback = async (context) => context.FunctionCall.Name == "Func2" },
|
||||
];
|
||||
|
||||
var options = new ChatOptions
|
||||
{
|
||||
Tools = useAdditionalTools ? null : tools
|
||||
};
|
||||
|
||||
List<ChatMessage> input =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "hello"),
|
||||
];
|
||||
|
||||
List<ChatMessage> downstreamClientOutput =
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
|
||||
];
|
||||
|
||||
List<ChatMessage> expectedOutput =
|
||||
[
|
||||
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 } }))
|
||||
])
|
||||
];
|
||||
|
||||
await InvokeAndAssertAsync(options, input, downstreamClientOutput, expectedOutput, additionalTools: useAdditionalTools ? tools : null);
|
||||
|
||||
await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, expectedOutput, additionalTools: useAdditionalTools ? tools : null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AllFunctionCallsReplacedWithApprovalsWhenAnyRequireApprovalAsync()
|
||||
{
|
||||
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"),
|
||||
];
|
||||
|
||||
List<ChatMessage> downstreamClientOutput =
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
|
||||
];
|
||||
|
||||
List<ChatMessage> expectedOutput =
|
||||
[
|
||||
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 } }))
|
||||
])
|
||||
];
|
||||
|
||||
await InvokeAndAssertAsync(options, input, downstreamClientOutput, expectedOutput);
|
||||
|
||||
await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, expectedOutput);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task AllFunctionCallsReplacedWithApprovalsWhenAnyRequestOrAdditionalRequireApprovalAsync(bool additionalToolsRequireApproval)
|
||||
{
|
||||
AIFunction func1 = AIFunctionFactory.Create(() => "Result 1", "Func1");
|
||||
AIFunction func2 = AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2");
|
||||
AITool[] additionalTools =
|
||||
[
|
||||
additionalToolsRequireApproval ? new ApprovalRequiredAIFunction(func1) : func1,
|
||||
];
|
||||
|
||||
var options = new ChatOptions
|
||||
{
|
||||
Tools =
|
||||
[
|
||||
additionalToolsRequireApproval ? func2 : new ApprovalRequiredAIFunction(func2),
|
||||
]
|
||||
};
|
||||
|
||||
List<ChatMessage> input =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "hello"),
|
||||
];
|
||||
|
||||
List<ChatMessage> downstreamClientOutput =
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
|
||||
];
|
||||
|
||||
List<ChatMessage> expectedOutput =
|
||||
[
|
||||
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 } }))
|
||||
])
|
||||
];
|
||||
|
||||
await InvokeAndAssertAsync(options, input, downstreamClientOutput, expectedOutput, additionalTools: additionalTools);
|
||||
|
||||
await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, expectedOutput, additionalTools: additionalTools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApprovedApprovalResponsesAreExecutedAsync()
|
||||
{
|
||||
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", true, new FunctionCallContent("callId1", "Func1")),
|
||||
new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
|
||||
]),
|
||||
];
|
||||
|
||||
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: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]),
|
||||
];
|
||||
|
||||
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: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]),
|
||||
new ChatMessage(ChatRole.Assistant, "world"),
|
||||
];
|
||||
|
||||
await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
|
||||
|
||||
await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApprovedApprovalResponsesFromSeparateFCCMessagesAreExecutedAsync()
|
||||
{
|
||||
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")),
|
||||
]) { MessageId = "resp1" },
|
||||
new ChatMessage(ChatRole.Assistant,
|
||||
[
|
||||
new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
|
||||
]) { MessageId = "resp2" },
|
||||
new ChatMessage(ChatRole.User,
|
||||
[
|
||||
new FunctionApprovalResponseContent("callId1", true, new FunctionCallContent("callId1", "Func1")),
|
||||
]),
|
||||
new ChatMessage(ChatRole.User,
|
||||
[
|
||||
new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
|
||||
]),
|
||||
];
|
||||
|
||||
List<ChatMessage> expectedDownstreamClientInput =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "hello"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]) { MessageId = "resp1" },
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]) { MessageId = "resp2" },
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]),
|
||||
];
|
||||
|
||||
List<ChatMessage> downstreamClientOutput =
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant, "world"),
|
||||
];
|
||||
|
||||
List<ChatMessage> output =
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]) { MessageId = "resp1" },
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]) { MessageId = "resp2" },
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]),
|
||||
new ChatMessage(ChatRole.Assistant, "world"),
|
||||
];
|
||||
|
||||
await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
|
||||
|
||||
await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RejectedApprovalResponsesAreFailedAsync()
|
||||
{
|
||||
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")),
|
||||
new FunctionApprovalResponseContent("callId2", false, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
|
||||
]),
|
||||
];
|
||||
|
||||
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: "Error: Function invocation approval was not granted."), new FunctionResultContent("callId2", result: "Error: Function invocation approval was not granted.")]),
|
||||
];
|
||||
|
||||
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: "Error: Function invocation approval was not granted."), new FunctionResultContent("callId2", result: "Error: Function invocation approval was not granted.")]),
|
||||
new ChatMessage(ChatRole.Assistant, "world"),
|
||||
];
|
||||
|
||||
await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
|
||||
|
||||
await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MixedApprovedAndRejectedApprovalResponsesAreExecutedAndFailedAsync()
|
||||
{
|
||||
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")),
|
||||
new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
|
||||
]),
|
||||
];
|
||||
|
||||
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: "Error: Function invocation approval was not granted.")]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", result: "Result 2: 42")]),
|
||||
];
|
||||
|
||||
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 ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Error: Function invocation approval was not granted.")]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", result: "Result 2: 42")]),
|
||||
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 ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Error: Function invocation approval was not granted."), new FunctionResultContent("callId2", result: "Result 2: 42")]),
|
||||
new ChatMessage(ChatRole.Assistant, "world"),
|
||||
];
|
||||
|
||||
await InvokeAndAssertAsync(options, input, downstreamClientOutput, nonStreamingOutput, expectedDownstreamClientInput);
|
||||
|
||||
await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, streamingOutput, expectedDownstreamClientInput);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApprovedInputsAreExecutedAndFunctionResultsAreConvertedAsync()
|
||||
{
|
||||
var options = new ChatOptions
|
||||
{
|
||||
Tools =
|
||||
[
|
||||
AIFunctionFactory.Create(() => "Result 1", "Func1"),
|
||||
new ApprovalRequiredAIFunction(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", true, new FunctionCallContent("callId1", "Func1")),
|
||||
new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
|
||||
]),
|
||||
];
|
||||
|
||||
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: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]),
|
||||
];
|
||||
|
||||
List<ChatMessage> downstreamClientOutput =
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 3 } })]),
|
||||
];
|
||||
|
||||
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: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 3 } }))]),
|
||||
];
|
||||
|
||||
await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
|
||||
|
||||
await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AlreadyExecutedApprovalsAreIgnoredAsync()
|
||||
{
|
||||
var options = new ChatOptions
|
||||
{
|
||||
Tools =
|
||||
[
|
||||
AIFunctionFactory.Create(() => "Result 1", "Func1"),
|
||||
new ApprovalRequiredAIFunction(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", true, new FunctionCallContent("callId1", "Func1")),
|
||||
new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
|
||||
]),
|
||||
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: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]),
|
||||
new ChatMessage(ChatRole.Assistant,
|
||||
[
|
||||
new FunctionApprovalRequestContent("callId3", new FunctionCallContent("callId3", "Func1")),
|
||||
]) { MessageId = "resp2" },
|
||||
new ChatMessage(ChatRole.User,
|
||||
[
|
||||
new FunctionApprovalResponseContent("callId3", true, new FunctionCallContent("callId3", "Func1")),
|
||||
]),
|
||||
];
|
||||
|
||||
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: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId3", "Func1")]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId3", result: "Result 1")]),
|
||||
];
|
||||
|
||||
List<ChatMessage> downstreamClientOutput =
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant, "World"),
|
||||
];
|
||||
|
||||
List<ChatMessage> output =
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId3", "Func1")]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId3", result: "Result 1")]),
|
||||
new ChatMessage(ChatRole.Assistant, "World"),
|
||||
];
|
||||
|
||||
await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
|
||||
|
||||
await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApprovalRequestWithoutApprovalResponseThrowsAsync()
|
||||
{
|
||||
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")),
|
||||
]) { MessageId = "resp1" },
|
||||
];
|
||||
|
||||
var invokeException = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
async () => await InvokeAndAssertAsync(options, input, [], [], []));
|
||||
Assert.Equal("FunctionApprovalRequestContent found with FunctionCall.CallId(s) 'callId1' that have no matching FunctionApprovalResponseContent.", invokeException.Message);
|
||||
|
||||
var invokeStreamingException = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
async () => await InvokeAndAssertStreamingAsync(options, input, [], [], []));
|
||||
Assert.Equal("FunctionApprovalRequestContent found with FunctionCall.CallId(s) 'callId1' that have no matching FunctionApprovalResponseContent.", invokeStreamingException.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApprovedApprovalResponsesWithoutApprovalRequestAreExecutedAsync()
|
||||
{
|
||||
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.User,
|
||||
[
|
||||
new FunctionApprovalResponseContent("callId1", true, new FunctionCallContent("callId1", "Func1")),
|
||||
new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
|
||||
]),
|
||||
];
|
||||
|
||||
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: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]),
|
||||
];
|
||||
|
||||
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: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]),
|
||||
new ChatMessage(ChatRole.Assistant, "world"),
|
||||
];
|
||||
|
||||
await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
|
||||
|
||||
await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FunctionCallContentIsNotPassedToDownstreamServiceWithServiceThreadsAsync()
|
||||
{
|
||||
var options = new ChatOptions
|
||||
{
|
||||
Tools =
|
||||
[
|
||||
new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")),
|
||||
AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"),
|
||||
],
|
||||
ConversationId = "test-conversation",
|
||||
};
|
||||
|
||||
List<ChatMessage> input =
|
||||
[
|
||||
new ChatMessage(ChatRole.User,
|
||||
[
|
||||
new FunctionApprovalResponseContent("callId1", true, new FunctionCallContent("callId1", "Func1")),
|
||||
new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
|
||||
]),
|
||||
];
|
||||
|
||||
List<ChatMessage> expectedDownstreamClientInput =
|
||||
[
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]),
|
||||
];
|
||||
|
||||
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: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]),
|
||||
new ChatMessage(ChatRole.Assistant, "world"),
|
||||
];
|
||||
|
||||
await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
|
||||
|
||||
await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Since we do not have a way of supporting both functions that require approval and those that do not
|
||||
/// in one invocation, we always require all function calls to be approved if any require approval.
|
||||
/// If we are therefore unsure as to whether we will encounter a function call that requires approval,
|
||||
/// we have to wait until we find one before yielding any function call content.
|
||||
/// If we don't have any function calls that require approval at all though, we can just yield all content normally
|
||||
/// since this issue won't apply.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task FunctionCallContentIsYieldedImmediatelyIfNoApprovalRequiredWhenStreamingAsync()
|
||||
{
|
||||
var options = new ChatOptions
|
||||
{
|
||||
Tools =
|
||||
[
|
||||
AIFunctionFactory.Create(() => "Result 1", "Func1"),
|
||||
AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"),
|
||||
]
|
||||
};
|
||||
|
||||
List<ChatMessage> input = [new ChatMessage(ChatRole.User, "hello")];
|
||||
|
||||
Func<ChatClientBuilder, ChatClientBuilder> configurePipeline = b => b.Use(s => new NewFunctionInvokingChatClient(s));
|
||||
using CancellationTokenSource cts = new();
|
||||
|
||||
var updateYieldCount = 0;
|
||||
|
||||
async IAsyncEnumerable<ChatResponseUpdate> YieldInnerClientUpdates(IEnumerable<ChatMessage> contents, ChatOptions? actualOptions, [EnumeratorCancellation] CancellationToken actualCancellationToken)
|
||||
{
|
||||
Assert.Equal(cts.Token, actualCancellationToken);
|
||||
await Task.Yield();
|
||||
var messageId = Guid.NewGuid().ToString("N");
|
||||
|
||||
updateYieldCount++;
|
||||
yield return new ChatResponseUpdate(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]) { MessageId = messageId };
|
||||
updateYieldCount++;
|
||||
yield return new ChatResponseUpdate(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]) { MessageId = messageId };
|
||||
}
|
||||
|
||||
using var innerClient = new TestChatClient { GetStreamingResponseAsyncCallback = YieldInnerClientUpdates };
|
||||
IChatClient service = configurePipeline(innerClient.AsBuilder()).Build();
|
||||
|
||||
var updates = service.GetStreamingResponseAsync(new EnumeratedOnceEnumerable<ChatMessage>(input), options, cts.Token);
|
||||
|
||||
var updateCount = 0;
|
||||
await foreach (var update in updates)
|
||||
{
|
||||
if (updateCount < 2)
|
||||
{
|
||||
var functionCall = update.Contents.OfType<FunctionCallContent>().First();
|
||||
if (functionCall.CallId == "callId1")
|
||||
{
|
||||
Assert.Equal("Func1", functionCall.Name);
|
||||
Assert.Equal(1, updateYieldCount);
|
||||
}
|
||||
else if (functionCall.CallId == "callId2")
|
||||
{
|
||||
Assert.Equal("Func2", functionCall.Name);
|
||||
Assert.Equal(2, updateYieldCount);
|
||||
}
|
||||
}
|
||||
|
||||
updateCount++;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Since we do not have a way of supporting both functions that require approval and those that do not
|
||||
/// in one invocation, we always require all function calls to be approved if any require approval.
|
||||
/// If we are therefore unsure as to whether we will encounter a function call that requires approval,
|
||||
/// we have to wait until we find one before yielding any function call content.
|
||||
/// We can however, yield any other content until we encounter the first function call.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task FunctionCalsAreBufferedUntilApprovalRequirementEncounteredWhenStreamingAsync()
|
||||
{
|
||||
var options = new ChatOptions
|
||||
{
|
||||
Tools =
|
||||
[
|
||||
AIFunctionFactory.Create(() => "Result 1", "Func1"),
|
||||
new ApprovalRequiredAIFunction(AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2")),
|
||||
AIFunctionFactory.Create(() => "Result 3", "Func3"),
|
||||
]
|
||||
};
|
||||
|
||||
List<ChatMessage> input = [new ChatMessage(ChatRole.User, "hello")];
|
||||
|
||||
Func<ChatClientBuilder, ChatClientBuilder> configurePipeline = b => b.Use(s => new NewFunctionInvokingChatClient(s));
|
||||
using CancellationTokenSource cts = new();
|
||||
|
||||
var updateYieldCount = 0;
|
||||
|
||||
async IAsyncEnumerable<ChatResponseUpdate> YieldInnerClientUpdates(IEnumerable<ChatMessage> contents, ChatOptions? actualOptions, [EnumeratorCancellation] CancellationToken actualCancellationToken)
|
||||
{
|
||||
Assert.Equal(cts.Token, actualCancellationToken);
|
||||
await Task.Yield();
|
||||
var messageId = Guid.NewGuid().ToString("N");
|
||||
|
||||
updateYieldCount++;
|
||||
yield return new ChatResponseUpdate(ChatRole.Assistant, [new TextContent("Text 1")]) { MessageId = messageId };
|
||||
updateYieldCount++;
|
||||
yield return new ChatResponseUpdate(ChatRole.Assistant, [new TextContent("Text 2")]) { MessageId = messageId };
|
||||
updateYieldCount++;
|
||||
yield return new ChatResponseUpdate(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]) { MessageId = messageId };
|
||||
updateYieldCount++;
|
||||
yield return new ChatResponseUpdate(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]) { MessageId = messageId };
|
||||
updateYieldCount++;
|
||||
yield return new ChatResponseUpdate(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func3")]) { MessageId = messageId };
|
||||
}
|
||||
|
||||
using var innerClient = new TestChatClient { GetStreamingResponseAsyncCallback = YieldInnerClientUpdates };
|
||||
IChatClient service = configurePipeline(innerClient.AsBuilder()).Build();
|
||||
|
||||
var updates = service.GetStreamingResponseAsync(new EnumeratedOnceEnumerable<ChatMessage>(input), options, cts.Token);
|
||||
|
||||
var updateCount = 0;
|
||||
await foreach (var update in updates)
|
||||
{
|
||||
switch (updateCount)
|
||||
{
|
||||
case 0:
|
||||
Assert.Equal("Text 1", update.Contents.OfType<TextContent>().First().Text);
|
||||
// First content should be yielded immedately, since we don't have any function calls yet.
|
||||
Assert.Equal(1, updateYieldCount);
|
||||
break;
|
||||
case 1:
|
||||
Assert.Equal("Text 2", update.Contents.OfType<TextContent>().First().Text);
|
||||
// Second content should be yielded immedately, since we don't have any function calls yet.
|
||||
Assert.Equal(2, updateYieldCount);
|
||||
break;
|
||||
case 2:
|
||||
var approvalRequest1 = update.Contents.OfType<FunctionApprovalRequestContent>().First();
|
||||
Assert.Equal("callId1", approvalRequest1.FunctionCall.CallId);
|
||||
Assert.Equal("Func1", approvalRequest1.FunctionCall.Name);
|
||||
// Third content should have been buffered, since we have not yet encountered a function call that requires approval.
|
||||
Assert.Equal(4, updateYieldCount);
|
||||
break;
|
||||
case 3:
|
||||
var approvalRequest2 = update.Contents.OfType<FunctionApprovalRequestContent>().First();
|
||||
Assert.Equal("callId2", approvalRequest2.FunctionCall.CallId);
|
||||
Assert.Equal("Func2", approvalRequest2.FunctionCall.Name);
|
||||
// Fourth content can be yielded immediately, since it is the first function call that requires approval.
|
||||
Assert.Equal(4, updateYieldCount);
|
||||
break;
|
||||
case 4:
|
||||
var approvalRequest3 = update.Contents.OfType<FunctionApprovalRequestContent>().First();
|
||||
Assert.Equal("callId1", approvalRequest3.FunctionCall.CallId);
|
||||
Assert.Equal("Func3", approvalRequest3.FunctionCall.Name);
|
||||
// Fifth content can be yielded immediately, since we previously encountered a function call that requires approval.
|
||||
Assert.Equal(5, updateYieldCount);
|
||||
break;
|
||||
}
|
||||
|
||||
updateCount++;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<List<ChatMessage>> InvokeAndAssertAsync(
|
||||
ChatOptions? options,
|
||||
List<ChatMessage> input,
|
||||
List<ChatMessage> downstreamClientOutput,
|
||||
List<ChatMessage> expectedOutput,
|
||||
List<ChatMessage>? expectedDownstreamClientInput = null,
|
||||
Func<ChatClientBuilder, ChatClientBuilder>? configurePipeline = null,
|
||||
AITool[]? additionalTools = null,
|
||||
IServiceProvider? services = null)
|
||||
{
|
||||
Assert.NotEmpty(input);
|
||||
|
||||
configurePipeline ??= b => b.Use(s => new NewFunctionInvokingChatClient(s) { AdditionalTools = additionalTools });
|
||||
|
||||
using CancellationTokenSource cts = new();
|
||||
long expectedTotalTokenCounts = 0;
|
||||
|
||||
using var innerClient = new TestChatClient
|
||||
{
|
||||
GetResponseAsyncCallback = async (contents, actualOptions, actualCancellationToken) =>
|
||||
{
|
||||
Assert.Equal(cts.Token, actualCancellationToken);
|
||||
if (expectedDownstreamClientInput is not null)
|
||||
{
|
||||
CompareMessageLists(expectedDownstreamClientInput, contents.ToList());
|
||||
}
|
||||
|
||||
await Task.Yield();
|
||||
|
||||
var usage = CreateRandomUsage();
|
||||
expectedTotalTokenCounts += usage.InputTokenCount!.Value;
|
||||
|
||||
downstreamClientOutput.ForEach(m => m.MessageId = Guid.NewGuid().ToString("N"));
|
||||
return new ChatResponse(downstreamClientOutput) { Usage = usage };
|
||||
}
|
||||
};
|
||||
|
||||
IChatClient service = configurePipeline(innerClient.AsBuilder()).Build(services);
|
||||
|
||||
var result = await service.GetResponseAsync(new EnumeratedOnceEnumerable<ChatMessage>(input), options, cts.Token);
|
||||
Assert.NotNull(result);
|
||||
|
||||
var actualOutput = result.Messages as List<ChatMessage> ?? result.Messages.ToList();
|
||||
CompareMessageLists(expectedOutput, actualOutput);
|
||||
|
||||
// Usage should be aggregated over all responses, including AdditionalUsage
|
||||
var actualUsage = result.Usage!;
|
||||
Assert.Equal(expectedTotalTokenCounts, actualUsage.InputTokenCount);
|
||||
Assert.Equal(expectedTotalTokenCounts, actualUsage.OutputTokenCount);
|
||||
Assert.Equal(expectedTotalTokenCounts, actualUsage.TotalTokenCount);
|
||||
Assert.Equal(2, actualUsage.AdditionalCounts!.Count);
|
||||
Assert.Equal(expectedTotalTokenCounts, actualUsage.AdditionalCounts["firstValue"]);
|
||||
Assert.Equal(expectedTotalTokenCounts, actualUsage.AdditionalCounts["secondValue"]);
|
||||
|
||||
return actualOutput;
|
||||
}
|
||||
|
||||
private static UsageDetails CreateRandomUsage()
|
||||
{
|
||||
// We'll set the same random number on all the properties so that, when determining the
|
||||
// correct sum in tests, we only have to total the values once
|
||||
var value = new Random().Next(100);
|
||||
return new UsageDetails
|
||||
{
|
||||
InputTokenCount = value,
|
||||
OutputTokenCount = value,
|
||||
TotalTokenCount = value,
|
||||
AdditionalCounts = new() { ["firstValue"] = value, ["secondValue"] = value },
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<List<ChatMessage>> InvokeAndAssertStreamingAsync(
|
||||
ChatOptions? options,
|
||||
List<ChatMessage> input,
|
||||
List<ChatMessage> downstreamClientOutput,
|
||||
List<ChatMessage> expectedOutput,
|
||||
List<ChatMessage>? expectedDownstreamClientInput = null,
|
||||
Func<ChatClientBuilder, ChatClientBuilder>? configurePipeline = null,
|
||||
AITool[]? additionalTools = null,
|
||||
IServiceProvider? services = null)
|
||||
{
|
||||
Assert.NotEmpty(input);
|
||||
|
||||
configurePipeline ??= b => b.Use(s => new NewFunctionInvokingChatClient(s) { AdditionalTools = additionalTools });
|
||||
|
||||
using CancellationTokenSource cts = new();
|
||||
|
||||
using var innerClient = new TestChatClient
|
||||
{
|
||||
GetStreamingResponseAsyncCallback = (contents, actualOptions, actualCancellationToken) =>
|
||||
{
|
||||
Assert.Equal(cts.Token, actualCancellationToken);
|
||||
if (expectedDownstreamClientInput is not null)
|
||||
{
|
||||
CompareMessageLists(expectedDownstreamClientInput, contents.ToList());
|
||||
}
|
||||
|
||||
downstreamClientOutput.ForEach(m => m.MessageId = Guid.NewGuid().ToString("N"));
|
||||
return YieldAsync(new ChatResponse(downstreamClientOutput).ToChatResponseUpdates());
|
||||
}
|
||||
};
|
||||
|
||||
IChatClient service = configurePipeline(innerClient.AsBuilder()).Build(services);
|
||||
|
||||
var result = await service.GetStreamingResponseAsync(new EnumeratedOnceEnumerable<ChatMessage>(input), options, cts.Token).ToChatResponseAsync();
|
||||
Assert.NotNull(result);
|
||||
|
||||
var actualOutput = result.Messages as List<ChatMessage> ?? result.Messages.ToList();
|
||||
|
||||
expectedOutput ??= input;
|
||||
CompareMessageLists(expectedOutput, actualOutput);
|
||||
|
||||
return actualOutput;
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<T> YieldAsync<T>(params T[] items)
|
||||
{
|
||||
await Task.Yield();
|
||||
foreach (var item in items)
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
}
|
||||
|
||||
private static void CompareMessageLists(List<ChatMessage> expectedMessages, List<ChatMessage> actualMessages)
|
||||
{
|
||||
Assert.Equal(expectedMessages.Count, actualMessages.Count);
|
||||
for (int i = 0; i < expectedMessages.Count; i++)
|
||||
{
|
||||
var expectedMessage = expectedMessages[i];
|
||||
var chatMessage = actualMessages[i];
|
||||
|
||||
Assert.Equal(expectedMessage.Role, chatMessage.Role);
|
||||
Assert.Equal(expectedMessage.Text, chatMessage.Text);
|
||||
Assert.Equal(expectedMessage.GetType(), chatMessage.GetType());
|
||||
|
||||
Assert.Equal(expectedMessage.Contents.Count, chatMessage.Contents.Count);
|
||||
for (int j = 0; j < expectedMessage.Contents.Count; j++)
|
||||
{
|
||||
var expectedItem = expectedMessage.Contents[j];
|
||||
var chatItem = chatMessage.Contents[j];
|
||||
|
||||
Assert.Equal(expectedItem.GetType(), chatItem.GetType());
|
||||
Assert.Equal(expectedItem.ToString(), chatItem.ToString());
|
||||
if (expectedItem is FunctionCallContent expectedFunctionCall)
|
||||
{
|
||||
var chatFunctionCall = (FunctionCallContent)chatItem;
|
||||
Assert.Equal(expectedFunctionCall.Name, chatFunctionCall.Name);
|
||||
AssertExtensions.EqualFunctionCallParameters(expectedFunctionCall.Arguments, chatFunctionCall.Arguments);
|
||||
}
|
||||
else if (expectedItem is FunctionResultContent expectedFunctionResult)
|
||||
{
|
||||
var chatFunctionResult = (FunctionResultContent)chatItem;
|
||||
AssertExtensions.EqualFunctionCallResults(expectedFunctionResult.Result, chatFunctionResult.Result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class EnumeratedOnceEnumerable<T>(IEnumerable<T> items) : IEnumerable<T>
|
||||
{
|
||||
private int _iterated;
|
||||
|
||||
public IEnumerator<T> GetEnumerator()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _iterated, 1) != 0)
|
||||
{
|
||||
throw new InvalidOperationException("This enumerable can only be enumerated once.");
|
||||
}
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
}
|
||||
|
||||
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// WARNING:
|
||||
// This class has been temporarily copied here from MEAI, to allow prototyping
|
||||
// functionality that will be moved to MEAI in the future.
|
||||
// This file is not intended to be modified.
|
||||
|
||||
// AF repo suppressions for code copied from MEAI.
|
||||
#pragma warning disable IDE0009 // Member access should be qualified.
|
||||
#pragma warning disable CA1859 // Use concrete types when possible for improved performance
|
||||
#pragma warning disable CA1063 // Implement IDisposable Correctly
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Extensions.AI;
|
||||
|
||||
public sealed class TestChatClient : IChatClient
|
||||
{
|
||||
public TestChatClient()
|
||||
{
|
||||
GetServiceCallback = DefaultGetServiceCallback;
|
||||
}
|
||||
|
||||
public IServiceProvider? Services { get; set; }
|
||||
|
||||
public Func<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken, Task<ChatResponse>>? GetResponseAsyncCallback { get; set; }
|
||||
|
||||
public Func<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken, IAsyncEnumerable<ChatResponseUpdate>>? GetStreamingResponseAsyncCallback { get; set; }
|
||||
|
||||
public Func<Type, object?, object?> GetServiceCallback { get; set; }
|
||||
|
||||
private object? DefaultGetServiceCallback(Type serviceType, object? serviceKey) =>
|
||||
serviceType is not null && serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null;
|
||||
|
||||
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> GetResponseAsyncCallback!.Invoke(messages, options, cancellationToken);
|
||||
|
||||
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> GetStreamingResponseAsyncCallback!.Invoke(messages, options, cancellationToken);
|
||||
|
||||
public object? GetService(Type serviceType, object? serviceKey = null)
|
||||
=> GetServiceCallback(serviceType, serviceKey);
|
||||
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
// No resources need disposing.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user