From 9858a75e678bd3fcebc502a07c4e4d4a9334a6ca Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Sat, 19 Apr 2025 17:28:49 +0100 Subject: [PATCH 01/24] Created potential extensible FunctionInvokingChatClientV2 --- .../FunctionInvokingChatClientV2.Extra.cs | 123 ++ .../FunctionInvokingChatClientV2.cs | 886 ++++++++++++++ .../KernelFunctionInvokingChatClient.cs | 754 +----------- .../KernelFunctionInvokingChatClientOld.cs | 1023 +++++++++++++++++ 4 files changed, 2077 insertions(+), 709 deletions(-) create mode 100644 dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.Extra.cs create mode 100644 dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.cs create mode 100644 dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClientOld.cs diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.Extra.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.Extra.cs new file mode 100644 index 000000000000..12edbbb4d15e --- /dev/null +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.Extra.cs @@ -0,0 +1,123 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Intended for deletion + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; + +namespace Microsoft.Extensions.AI; + +public partial class FunctionInvokingChatClientV2 : DelegatingChatClient +{ + private static class Throw + { + /// + /// Throws an if the specified argument is . + /// + /// Argument type to be checked for . + /// Object to be checked for . + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [return: NotNull] + public static T IfNull([NotNull] T argument, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument is null) + { + throw new ArgumentNullException(paramName); + } + + return argument; + } + + /// + /// Throws an . + /// + /// A message that describes the error. +#if !NET6_0_OR_GREATER + [MethodImpl(MethodImplOptions.NoInlining)] +#endif + [DoesNotReturn] + public static void InvalidOperationException(string message) + => throw new InvalidOperationException(message); + + /// + /// Throws an . + /// + /// The name of the parameter that caused the exception. +#if !NET6_0_OR_GREATER + [MethodImpl(MethodImplOptions.NoInlining)] +#endif + [DoesNotReturn] + public static void ArgumentOutOfRangeException(string paramName) + => throw new ArgumentOutOfRangeException(paramName); + + /// + /// Throws an . + /// + /// The name of the parameter that caused the exception. + /// A message that describes the error. +#if !NET6_0_OR_GREATER + [MethodImpl(MethodImplOptions.NoInlining)] +#endif + [DoesNotReturn] + public static void ArgumentOutOfRangeException(string paramName, string? message) + => throw new ArgumentOutOfRangeException(paramName, message); + + /// + /// Throws an . + /// + /// The name of the parameter that caused the exception. + /// The value of the argument that caused this exception. + /// A message that describes the error. +#if !NET6_0_OR_GREATER + [MethodImpl(MethodImplOptions.NoInlining)] +#endif + [DoesNotReturn] + public static void ArgumentOutOfRangeException(string paramName, object? actualValue, string? message) + => throw new ArgumentOutOfRangeException(paramName, actualValue, message); + + /// + /// Throws an if the specified number is less than min. + /// + /// Number to be expected being less than min. + /// The number that must be less than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int IfLessThan(int argument, int min, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument < min) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument less than minimum value {min}"); + } + + return argument; + } + } + + private static class LoggingHelpers + { + /// Serializes as JSON for logging purposes. + public static string AsJson(T value, System.Text.Json.JsonSerializerOptions? options) + { + if (options?.TryGetTypeInfo(typeof(T), out var typeInfo) is true || + AIJsonUtilities.DefaultOptions.TryGetTypeInfo(typeof(T), out typeInfo)) + { + try + { + return System.Text.Json.JsonSerializer.Serialize(value, typeInfo); + } +#pragma warning disable CA1031 // Do not catch general exception types + catch +#pragma warning restore CA1031 // Do not catch general exception types + { + } + } + + return "{}"; + } + } +} diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.cs new file mode 100644 index 000000000000..f2f3d9f12a62 --- /dev/null +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.cs @@ -0,0 +1,886 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Runtime.ExceptionServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +#pragma warning disable CA2213 // Disposable fields should be disposed +#pragma warning disable EA0002 // Use 'System.TimeProvider' to make the code easier to test +#pragma warning disable SA1202 // 'protected' members should come before 'private' members +#pragma warning disable VSTHRD111 // Use ConfigureAwait(bool) +#pragma warning disable CA2007 // Use ConfigureAwait + +namespace Microsoft.Extensions.AI; + +/// +/// A delegating chat client that invokes functions defined on . +/// Include this in a chat pipeline to resolve function calls automatically. +/// +/// +/// +/// When this client receives a in a chat response, it responds +/// by calling the corresponding defined in , +/// producing a that it sends back to the inner client. This loop +/// is repeated until there are no more function calls to make, or until another stop condition is met, +/// such as hitting . +/// +/// +/// The provided implementation of is thread-safe for concurrent use so long as the +/// instances employed as part of the supplied are also safe. +/// The property can be used to control whether multiple function invocation +/// requests as part of the same request are invocable concurrently, but even with that set to +/// (the default), multiple concurrent requests to this same instance and using the same tools could result in those +/// tools being used concurrently (one per request). For example, a function that accesses the HttpContext of a specific +/// ASP.NET web request should only be used as part of a single at a time, and only with +/// set to , in case the inner client decided to issue multiple +/// invocation requests to that same function. +/// +/// +public partial class FunctionInvokingChatClientV2 : DelegatingChatClient +{ + /// The for the current function invocation. + private static readonly AsyncLocal _currentContext = new(); + + /// Optional services used for function invocation. + private readonly IServiceProvider? _functionInvocationServices; + + /// The logger to use for logging information about function invocation. + protected readonly ILogger _logger; + + /// The to use for telemetry. + /// This component does not own the instance and should not dispose it. + protected readonly ActivitySource? _activitySource; + + /// Maximum number of roundtrips allowed to the inner client. + private int _maximumIterationsPerRequest = 10; + + /// Maximum number of consecutive iterations that are allowed contain at least one exception result. If the limit is exceeded, we rethrow the exception instead of continuing. + private int _maximumConsecutiveErrorsPerRequest = 3; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying , or the next instance in a chain of clients. + /// An to use for logging information about function invocation. + /// An optional to use for resolving services required by the instances being invoked. + public FunctionInvokingChatClientV2(IChatClient innerClient, ILoggerFactory? loggerFactory = null, IServiceProvider? functionInvocationServices = null) + : base(innerClient) + { + _logger = (ILogger?)loggerFactory?.CreateLogger() ?? NullLogger.Instance; + _activitySource = innerClient.GetService(); + _functionInvocationServices = functionInvocationServices; + } + + /// + /// Gets or sets the for the current function invocation. + /// + /// + /// This value flows across async calls. + /// + public static FunctionInvocationContext? CurrentContext + { + get => _currentContext.Value; + protected set => _currentContext.Value = value; + } + + /// + /// Gets or sets a value indicating whether detailed exception information should be included + /// in the chat history when calling the underlying . + /// + /// + /// if the full exception message is added to the chat history + /// when calling the underlying . + /// if a generic error message is included in the chat history. + /// The default value is . + /// + /// + /// + /// Setting the value to prevents the underlying language model from disclosing + /// raw exception details to the end user, since it doesn't receive that information. Even in this + /// case, the raw object is available to application code by inspecting + /// the property. + /// + /// + /// Setting the value to can help the underlying bypass problems on + /// its own, for example by retrying the function call with different arguments. However it might + /// result in disclosing the raw exception information to external users, which can be a security + /// concern depending on the application scenario. + /// + /// + /// Changing the value of this property while the client is in use might result in inconsistencies + /// as to whether detailed errors are provided during an in-flight request. + /// + /// + public bool IncludeDetailedErrors { get; set; } + + /// + /// Gets or sets a value indicating whether to allow concurrent invocation of functions. + /// + /// + /// if multiple function calls can execute in parallel. + /// if function calls are processed serially. + /// The default value is . + /// + /// + /// An individual response from the inner client might contain multiple function call requests. + /// By default, such function calls are processed serially. Set to + /// to enable concurrent invocation such that multiple function calls can execute in parallel. + /// + public bool AllowConcurrentInvocation { get; set; } + + /// + /// Gets or sets the maximum number of iterations per request. + /// + /// + /// The maximum number of iterations per request. + /// The default value is 10. + /// + /// + /// + /// Each request to this might end up making + /// multiple requests to the inner client. Each time the inner client responds with + /// a function call request, this client might perform that invocation and send the results + /// back to the inner client in a new request. This property limits the number of times + /// such a roundtrip is performed. The value must be at least one, as it includes the initial request. + /// + /// + /// Changing the value of this property while the client is in use might result in inconsistencies + /// as to how many iterations are allowed for an in-flight request. + /// + /// + public int MaximumIterationsPerRequest + { + get => _maximumIterationsPerRequest; + set + { + if (value < 1) + { + Throw.ArgumentOutOfRangeException(nameof(value)); + } + + _maximumIterationsPerRequest = value; + } + } + + /// + /// Gets or sets the maximum number of consecutive iterations that are allowed to fail with an error. + /// + /// + /// The maximum number of consecutive iterations that are allowed to fail with an error. + /// The default value is 3. + /// + /// + /// + /// When function invocations fail with an exception, the + /// continues to make requests to the inner client, optionally supplying exception information (as + /// controlled by ). This allows the to + /// recover from errors by trying other function parameters that may succeed. + /// + /// + /// However, in case function invocations continue to produce exceptions, this property can be used to + /// limit the number of consecutive failing attempts. When the limit is reached, the exception will be + /// rethrown to the caller. + /// + /// + /// If the value is set to zero, all function calling exceptions immediately terminate the function + /// invocation loop and the exception will be rethrown to the caller. + /// + /// + /// Changing the value of this property while the client is in use might result in inconsistencies + /// as to how many iterations are allowed for an in-flight request. + /// + /// + public int MaximumConsecutiveErrorsPerRequest + { + get => _maximumConsecutiveErrorsPerRequest; + set => _maximumConsecutiveErrorsPerRequest = Throw.IfLessThan(value, 0); + } + + /// + public override async Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(messages); + + // A single request into this GetResponseAsync may result in multiple requests to the inner client. + // Create an activity to group them together for better observability. + using Activity? activity = _activitySource?.StartActivity(nameof(FunctionInvokingChatClient)); + + // Copy the original messages in order to avoid enumerating the original messages multiple times. + // The IEnumerable can represent an arbitrary amount of work. + List originalMessages = [.. messages]; + messages = originalMessages; + + List? augmentedHistory = null; // the actual history of messages sent on turns other than the first + ChatResponse? response = null; // the response from the inner client, which is possibly modified and then eventually returned + List? responseMessages = null; // tracked list of messages, across multiple turns, to be used for the final response + UsageDetails? totalUsage = null; // tracked usage across all turns, to be used for the final response + List? functionCallContents = null; // function call contents that need responding to in the current turn + bool lastIterationHadThreadId = false; // whether the last iteration's response had a ChatThreadId set + int consecutiveErrorCount = 0; + + for (int iteration = 0; ; iteration++) + { + functionCallContents?.Clear(); + + // Make the call to the inner client. + response = await base.GetResponseAsync(messages, options, cancellationToken); + if (response is null) + { + Throw.InvalidOperationException($"The inner {nameof(IChatClient)} returned a null {nameof(ChatResponse)}."); + } + + // Any function call work to do? If yes, ensure we're tracking that work in functionCallContents. + bool requiresFunctionInvocation = + options?.Tools is { Count: > 0 } && + iteration < MaximumIterationsPerRequest && + CopyFunctionCalls(response.Messages, ref functionCallContents); + + // In a common case where we make a request and there's no function calling work required, + // fast path out by just returning the original response. + if (iteration == 0 && !requiresFunctionInvocation) + { + return response; + } + + // Track aggregatable details from the response, including all of the response messages and usage details. + (responseMessages ??= []).AddRange(response.Messages); + if (response.Usage is not null) + { + if (totalUsage is not null) + { + totalUsage.Add(response.Usage); + } + else + { + totalUsage = response.Usage; + } + } + + // If there are no tools to call, or for any other reason we should stop, we're done. + // Break out of the loop and allow the handling at the end to configure the response + // with aggregated data from previous requests. + if (!requiresFunctionInvocation) + { + break; + } + + // Prepare the history for the next iteration. + FixupHistories(originalMessages, ref messages, ref augmentedHistory, response, responseMessages, ref lastIterationHadThreadId); + + // Add the responses from the function calls into the augmented history and also into the tracked + // list of response messages. + var modeAndMessages = await ProcessFunctionCallsAsync(response, augmentedHistory, options!, functionCallContents!, iteration, consecutiveErrorCount, isStreaming: false, _functionInvocationServices, cancellationToken); + responseMessages.AddRange(modeAndMessages.MessagesAdded); + consecutiveErrorCount = modeAndMessages.NewConsecutiveErrorCount; + + if (modeAndMessages.ShouldTerminate) + { + break; + } + + UpdateOptionsForNextIteration(ref options!, response.ChatThreadId); + } + + Debug.Assert(responseMessages is not null, "Expected to only be here if we have response messages."); + response.Messages = responseMessages!; + response.Usage = totalUsage; + + return response; + } + + /// + public override async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(messages); + + // A single request into this GetStreamingResponseAsync may result in multiple requests to the inner client. + // Create an activity to group them together for better observability. + using Activity? activity = _activitySource?.StartActivity(nameof(FunctionInvokingChatClient)); + + // Copy the original messages in order to avoid enumerating the original messages multiple times. + // The IEnumerable can represent an arbitrary amount of work. + List originalMessages = [.. messages]; + messages = originalMessages; + + List? augmentedHistory = null; // the actual history of messages sent on turns other than the first + List? functionCallContents = null; // function call contents that need responding to in the current turn + List? responseMessages = null; // tracked list of messages, across multiple turns, to be used in fallback cases to reconstitute history + bool lastIterationHadThreadId = false; // whether the last iteration's response had a ChatThreadId set + List updates = []; // updates from the current response + int consecutiveErrorCount = 0; + + for (int iteration = 0; ; iteration++) + { + updates.Clear(); + functionCallContents?.Clear(); + + await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken)) + { + if (update is null) + { + Throw.InvalidOperationException($"The inner {nameof(IChatClient)} streamed a null {nameof(ChatResponseUpdate)}."); + } + + updates.Add(update); + + _ = CopyFunctionCalls(update.Contents, ref functionCallContents); + + yield return update; + 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 } || + options?.Tools is not { Count: > 0 } || + iteration >= _maximumIterationsPerRequest) + { + break; + } + + // Reconsistitue a response from the response updates. + var response = updates.ToChatResponse(); + (responseMessages ??= []).AddRange(response.Messages); + + // Prepare the history for the next iteration. + FixupHistories(originalMessages, ref messages, ref augmentedHistory, response, responseMessages, ref lastIterationHadThreadId); + + // Process all of the functions, adding their results into the history. + var modeAndMessages = await ProcessFunctionCallsAsync(response, augmentedHistory, options, functionCallContents, iteration, consecutiveErrorCount, isStreaming: true, _functionInvocationServices, cancellationToken); + 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 activitys, including generated function results. + foreach (var message in modeAndMessages.MessagesAdded) + { + var toolResultUpdate = new ChatResponseUpdate + { + AdditionalProperties = message.AdditionalProperties, + AuthorName = message.AuthorName, + ChatThreadId = response.ChatThreadId, + 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; + Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802 + } + + if (modeAndMessages.ShouldTerminate) + { + yield break; + } + + UpdateOptionsForNextIteration(ref options, response.ChatThreadId); + } + } + + /// Prepares the various chat message lists after a response from the inner client and before invoking functions. + /// The original messages provided by the caller. + /// The messages reference passed to the inner client. + /// The augmented history containing all the messages to be sent. + /// The most recent response being handled. + /// A list of all response messages received up until this point. + /// Whether the previous iteration's response had a thread id. + private static void FixupHistories( + IEnumerable originalMessages, + ref IEnumerable messages, + [NotNull] ref List? augmentedHistory, + ChatResponse response, + List allTurnsResponseMessages, + ref bool lastIterationHadThreadId) + { + // We're now going to need to augment the history with function result contents. + // That means we need a separate list to store the augmented history. + if (response.ChatThreadId is not null) + { + // The response indicates the inner client is tracking the history, so we don't want to send + // anything we've already sent or received. + if (augmentedHistory is not null) + { + augmentedHistory.Clear(); + } + else + { + augmentedHistory = []; + } + + lastIterationHadThreadId = true; + } + else if (lastIterationHadThreadId) + { + // In the very rare case where the inner client returned a response with a thread ID but then + // returned a subsequent response without one, we want to reconstitue the full history. To do that, + // we can populate the history with the original chat messages and then all of the response + // messages up until this point, which includes the most recent ones. + augmentedHistory ??= []; + augmentedHistory.Clear(); + augmentedHistory.AddRange(originalMessages); + augmentedHistory.AddRange(allTurnsResponseMessages); + + lastIterationHadThreadId = false; + } + else + { + // If augmentedHistory is already non-null, then we've already populated it with everything up + // until this point (except for the most recent response). If it's null, we need to seed it with + // the chat history provided by the caller. + augmentedHistory ??= originalMessages.ToList(); + + // Now add the most recent response messages. + augmentedHistory.AddMessages(response); + + lastIterationHadThreadId = false; + } + + // Use the augmented history as the new set of messages to send. + messages = augmentedHistory; + } + + /// Copies any from to . + private static bool CopyFunctionCalls( + IList messages, [NotNullWhen(true)] ref List? functionCalls) + { + bool any = false; + int count = messages.Count; + for (int i = 0; i < count; i++) + { + any |= CopyFunctionCalls(messages[i].Contents, ref functionCalls); + } + + return any; + } + + /// Copies any from to . + private static bool CopyFunctionCalls( + IList content, [NotNullWhen(true)] ref List? functionCalls) + { + bool any = false; + int count = content.Count; + for (int i = 0; i < count; i++) + { + if (content[i] is FunctionCallContent functionCall) + { + (functionCalls ??= []).Add(functionCall); + any = true; + } + } + + return any; + } + + private static void UpdateOptionsForNextIteration(ref ChatOptions options, string? chatThreadId) + { + if (options.ToolMode is RequiredChatToolMode) + { + // We have to reset the tool mode to be non-required after the first iteration, + // as otherwise we'll be in an infinite loop. + options = options.Clone(); + options.ToolMode = null; + options.ChatThreadId = chatThreadId; + } + else if (options.ChatThreadId != chatThreadId) + { + // As with the other modes, ensure we've propagated the chat thread ID to the options. + // We only need to clone the options if we're actually mutating it. + options = options.Clone(); + options.ChatThreadId = chatThreadId; + } + } + + /// + /// Processes the function calls in the list. + /// + /// The response being processed. + /// The current chat contents, inclusive of the function call contents being processed. + /// The options used for the response being processed. + /// The function call contents representing the functions to be invoked. + /// The iteration number of how many roundtrips have been made to the inner client. + /// The number of consecutive iterations, prior to this one, that were recorded as having function invocation errors. + /// Whether the function calls are being processed in a streaming context. + /// The services to use for function invocation. + /// The to monitor for cancellation requests. + /// A value indicating how the caller should proceed. + protected virtual async Task<(bool ShouldTerminate, int NewConsecutiveErrorCount, IList MessagesAdded)> ProcessFunctionCallsAsync( + ChatResponse response, List messages, ChatOptions options, List functionCallContents, int iteration, int consecutiveErrorCount, bool isStreaming, IServiceProvider functionInvocationServices, CancellationToken cancellationToken) + { + // We must add a response for every tool call, regardless of whether we successfully executed it or not. + // If we successfully execute it, we'll add the result. If we don't, we'll add an error. + + Debug.Assert(functionCallContents.Count > 0, "Expected at least one function call."); + + var captureCurrentIterationExceptions = consecutiveErrorCount < _maximumConsecutiveErrorsPerRequest; + + // Process all functions. If there's more than one and concurrent invocation is enabled, do so in parallel. + if (functionCallContents.Count == 1) + { + FunctionInvocationResult result = await ProcessFunctionCallAsync( + messages, options, functionCallContents, iteration, 0, captureCurrentIterationExceptions, isStreaming, functionInvocationServices, cancellationToken); + + IList added = CreateResponseMessages([result]); + ThrowIfNoFunctionResultsAdded(added); + UpdateConsecutiveErrorCountOrThrow(added, ref consecutiveErrorCount); + + messages.AddRange(added); + return (result.ShouldTerminate, consecutiveErrorCount, added); + } + else + { + FunctionInvocationResult[] results; + + if (AllowConcurrentInvocation) + { + // Rather than await'ing each function before invoking the next, invoke all of them + // and then await all of them. We avoid forcibly introducing parallelism via Task.Run, + // but if a function invocation completes asynchronously, its processing can overlap + // with the processing of other the other invocation invocations. + results = await Task.WhenAll( + from i in Enumerable.Range(0, functionCallContents.Count) + select ProcessFunctionCallAsync( + messages, options, functionCallContents, + iteration, i, captureExceptions: true, isStreaming, functionInvocationServices, cancellationToken)); + } + else + { + // Invoke each function serially. + results = new FunctionInvocationResult[functionCallContents.Count]; + for (int i = 0; i < results.Length; i++) + { + results[i] = await ProcessFunctionCallAsync( + messages, options, functionCallContents, + iteration, i, captureCurrentIterationExceptions, isStreaming, functionInvocationServices, cancellationToken); + } + } + + var shouldTerminate = false; + + IList added = CreateResponseMessages(results); + ThrowIfNoFunctionResultsAdded(added); + UpdateConsecutiveErrorCountOrThrow(added, ref consecutiveErrorCount); + + messages.AddRange(added); + foreach (FunctionInvocationResult fir in results) + { + shouldTerminate = shouldTerminate || fir.ShouldTerminate; + } + + return (shouldTerminate, consecutiveErrorCount, added); + } + } + + protected void UpdateConsecutiveErrorCountOrThrow(IList added, ref int consecutiveErrorCount) + { + var allExceptions = added.SelectMany(m => m.Contents.OfType()) + .Select(frc => frc.Exception!) + .Where(e => e is not null); + + if (allExceptions.Any()) + { + consecutiveErrorCount++; + if (consecutiveErrorCount > _maximumConsecutiveErrorsPerRequest) + { + var allExceptionsArray = allExceptions.ToArray(); + if (allExceptionsArray.Length == 1) + { + ExceptionDispatchInfo.Capture(allExceptionsArray[0]).Throw(); + } + else + { + throw new AggregateException(allExceptionsArray); + } + } + } + else + { + consecutiveErrorCount = 0; + } + } + + /// + /// Throws an exception if doesn't create any messages. + /// + protected void ThrowIfNoFunctionResultsAdded(IList? messages) + { + if (messages is null || messages.Count == 0) + { + Throw.InvalidOperationException($"{GetType().Name}.{nameof(CreateResponseMessages)} returned null or an empty collection of messages."); + } + } + + /// Processes the function call described in []. + /// The current chat contents, inclusive of the function call contents being processed. + /// The options used for the response being processed. + /// The function call contents representing all the functions being invoked. + /// The iteration number of how many roundtrips have been made to the inner client. + /// The 0-based index of the function being called out of . + /// If true, handles function-invocation exceptions by returning a value with . Otherwise, rethrows. + /// Whether the function calls are being processed in a streaming context. + /// The services to use for function invocation. + /// The to monitor for cancellation requests. + /// A value indicating how the caller should proceed. + protected virtual async Task ProcessFunctionCallAsync( + List messages, ChatOptions options, List callContents, + int iteration, int functionCallIndex, bool captureExceptions, bool isStreaming, IServiceProvider functionInvocationServices, CancellationToken cancellationToken) + { + var callContent = callContents[functionCallIndex]; + + // Look up the AIFunction for the function call. If the requested function isn't available, send back an error. + AIFunction? function = options.Tools!.OfType().FirstOrDefault(t => t.Name == callContent.Name); + if (function is null) + { + return new(shouldTerminate: false, FunctionInvocationStatus.NotFound, callContent, result: null, exception: null); + } + + FunctionInvocationContext context = new() + { + Function = function, + Arguments = new(callContent.Arguments) { Services = functionInvocationServices }, + + Messages = messages, + Options = options, + + CallContent = callContent, + Iteration = iteration, + FunctionCallIndex = functionCallIndex, + FunctionCount = callContents.Count, + }; + + object? result; + try + { + result = await InvokeFunctionAsync(context, cancellationToken); + } + catch (Exception e) when (!cancellationToken.IsCancellationRequested) + { + if (!captureExceptions) + { + throw; + } + + return new( + shouldTerminate: false, + FunctionInvocationStatus.Exception, + callContent, + result: null, + exception: e); + } + + return new( + shouldTerminate: context.Terminate, + FunctionInvocationStatus.RanToCompletion, + callContent, + result, + exception: null); + } + + /// Creates one or more response messages for function invocation results. + /// Information about the function call invocations and results. + /// A list of all chat messages created from . + protected virtual IList CreateResponseMessages( + ReadOnlySpan results) + { + var contents = new List(results.Length); + for (int i = 0; i < results.Length; i++) + { + contents.Add(CreateFunctionResultContent(results[i])); + } + + return [new(ChatRole.Tool, contents)]; + + FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult result) + { + _ = Throw.IfNull(result); + + object? functionResult; + if (result.Status == FunctionInvocationStatus.RanToCompletion) + { + functionResult = result.Result ?? "Success: Function completed."; + } + else + { + string message = result.Status switch + { + FunctionInvocationStatus.NotFound => $"Error: Requested function \"{result.CallContent.Name}\" not found.", + FunctionInvocationStatus.Exception => "Error: Function failed.", + _ => "Error: Unknown error.", + }; + + if (IncludeDetailedErrors && result.Exception is not null) + { + message = $"{message} Exception: {result.Exception.Message}"; + } + + functionResult = message; + } + + return new FunctionResultContent(result.CallContent.CallId, functionResult) { Exception = result.Exception }; + } + } + + /// Invokes the function asynchronously. + /// + /// The function invocation context detailing the function to be invoked and its arguments along with additional request information. + /// + /// The to monitor for cancellation requests. The default is . + /// The result of the function invocation, or if the function invocation returned . + /// is . + protected virtual async Task InvokeFunctionAsync(FunctionInvocationContext context, CancellationToken cancellationToken) + { + _ = Throw.IfNull(context); + + using Activity? activity = _activitySource?.StartActivity(context.Function.Name); + + long startingTimestamp = 0; + if (_logger.IsEnabled(LogLevel.Debug)) + { + startingTimestamp = Stopwatch.GetTimestamp(); + if (_logger.IsEnabled(LogLevel.Trace)) + { + LogInvokingSensitive(context.Function.Name, LoggingHelpers.AsJson(context.Arguments, context.Function.JsonSerializerOptions)); + } + else + { + LogInvoking(context.Function.Name); + } + } + + object? result = null; + try + { + CurrentContext = context; // doesn't need to be explicitly reset after, as that's handled automatically at async method exit + result = await TryInvokeFunctionAsync(context, cancellationToken); + } + catch (Exception e) + { + if (activity is not null) + { + _ = activity.SetTag("error.type", e.GetType().FullName) + .SetStatus(ActivityStatusCode.Error, e.Message); + } + + if (e is OperationCanceledException) + { + LogInvocationCanceled(context.Function.Name); + } + else + { + LogInvocationFailed(context.Function.Name, e); + } + + throw; + } + finally + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + TimeSpan elapsed = GetElapsedTime(startingTimestamp); + + if (result is not null && _logger.IsEnabled(LogLevel.Trace)) + { + LogInvocationCompletedSensitive(context.Function.Name, elapsed, LoggingHelpers.AsJson(result, context.Function.JsonSerializerOptions)); + } + else + { + LogInvocationCompleted(context.Function.Name, elapsed); + } + } + } + + return result; + } + + protected virtual async Task<(FunctionInvocationContext Context, object? Result)> TryInvokeFunctionAsync(FunctionInvocationContext context, CancellationToken cancellationToken) + { + var result = await context.Function.InvokeAsync(context.Arguments, cancellationToken); + + return (context, result); + } + + private static TimeSpan GetElapsedTime(long startingTimestamp) => +#if NET + Stopwatch.GetElapsedTime(startingTimestamp); +#else + new((long)((Stopwatch.GetTimestamp() - startingTimestamp) * ((double)TimeSpan.TicksPerSecond / Stopwatch.Frequency))); +#endif + + [LoggerMessage(LogLevel.Debug, "Invoking {MethodName}.", SkipEnabledCheck = true)] + private partial void LogInvoking(string methodName); + + [LoggerMessage(LogLevel.Trace, "Invoking {MethodName}({Arguments}).", SkipEnabledCheck = true)] + private partial void LogInvokingSensitive(string methodName, string arguments); + + [LoggerMessage(LogLevel.Debug, "{MethodName} invocation completed. Duration: {Duration}", SkipEnabledCheck = true)] + private partial void LogInvocationCompleted(string methodName, TimeSpan duration); + + [LoggerMessage(LogLevel.Trace, "{MethodName} invocation completed. Duration: {Duration}. Result: {Result}", SkipEnabledCheck = true)] + private partial void LogInvocationCompletedSensitive(string methodName, TimeSpan duration, string result); + + [LoggerMessage(LogLevel.Debug, "{MethodName} invocation canceled.")] + private partial void LogInvocationCanceled(string methodName); + + [LoggerMessage(LogLevel.Error, "{MethodName} invocation failed.")] + private partial void LogInvocationFailed(string methodName, Exception error); + + /// Provides information about the invocation of a function call. + public sealed class FunctionInvocationResult + { + internal FunctionInvocationResult(bool shouldTerminate, FunctionInvocationStatus status, FunctionCallContent callContent, object? result, Exception? exception) + { + ShouldTerminate = shouldTerminate; + Status = status; + CallContent = callContent; + Result = result; + Exception = exception; + } + + /// Gets status about how the function invocation completed. + public FunctionInvocationStatus Status { get; } + + /// Gets the function call content information associated with this invocation. + public FunctionCallContent CallContent { get; } + + /// Gets the result of the function call. + public object? Result { get; } + + /// Gets any exception the function call threw. + public Exception? Exception { get; } + + /// Gets a value indicating whether the caller should terminate the processing loop. + internal bool ShouldTerminate { get; } + } + + /// Provides error codes for when errors occur as part of the function calling loop. + public enum FunctionInvocationStatus + { + /// The operation completed successfully. + RanToCompletion, + + /// The requested function could not be found. + NotFound, + + /// The function call failed with an exception. + Exception, + } +} diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs index ea2dce48fc62..fc4968a390ed 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs @@ -4,15 +4,10 @@ #pragma warning restore IDE0073 // The file header does not match the required text using System.Collections.Generic; using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; using System.Linq; -using System.Runtime.CompilerServices; -using System.Runtime.ExceptionServices; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.ChatCompletion; @@ -51,469 +46,12 @@ namespace Microsoft.Extensions.AI; /// invocation requests to that same function. /// /// -public partial class KernelFunctionInvokingChatClient : DelegatingChatClient +public partial class KernelFunctionInvokingChatClient : FunctionInvokingChatClientV2 { - /// The for the current function invocation. - private static readonly AsyncLocal _currentContext = new(); - - /// Optional services used for function invocation. - private readonly IServiceProvider? _functionInvocationServices; - - /// The logger to use for logging information about function invocation. - private readonly ILogger _logger; - - /// The to use for telemetry. - /// This component does not own the instance and should not dispose it. - private readonly ActivitySource? _activitySource; - - /// Maximum number of roundtrips allowed to the inner client. - private int _maximumIterationsPerRequest = 10; - - /// Maximum number of consecutive iterations that are allowed contain at least one exception result. If the limit is exceeded, we rethrow the exception instead of continuing. - private int _maximumConsecutiveErrorsPerRequest = 3; - - /// - /// Initializes a new instance of the class. - /// - /// The underlying , or the next instance in a chain of clients. - /// An to use for logging information about function invocation. - /// An optional to use for resolving services required by the instances being invoked. - public KernelFunctionInvokingChatClient(IChatClient innerClient, ILoggerFactory? loggerFactory = null, IServiceProvider? functionInvocationServices = null) - : base(innerClient) - { - _logger = (ILogger?)loggerFactory?.CreateLogger() ?? NullLogger.Instance; - _activitySource = innerClient.GetService(); - _functionInvocationServices = functionInvocationServices; - } - - /// - /// Gets or sets the for the current function invocation. - /// - /// - /// This value flows across async calls. - /// - public static AutoFunctionInvocationContext? CurrentContext - { - get => _currentContext.Value; - protected set => _currentContext.Value = value; - } - - /// - /// Gets or sets a value indicating whether detailed exception information should be included - /// in the chat history when calling the underlying . - /// - /// - /// if the full exception message is added to the chat history - /// when calling the underlying . - /// if a generic error message is included in the chat history. - /// The default value is . - /// - /// - /// - /// Setting the value to prevents the underlying language model from disclosing - /// raw exception details to the end user, since it doesn't receive that information. Even in this - /// case, the raw object is available to application code by inspecting - /// the property. - /// - /// - /// Setting the value to can help the underlying bypass problems on - /// its own, for example by retrying the function call with different arguments. However, it might - /// result in disclosing the raw exception information to external users, which can be a security - /// concern depending on the application scenario. - /// - /// - /// Changing the value of this property while the client is in use might result in inconsistencies - /// whether detailed errors are provided during an in-flight request. - /// - /// - public bool IncludeDetailedErrors { get; set; } - - /// - /// Gets or sets a value indicating whether to allow concurrent invocation of functions. - /// - /// - /// if multiple function calls can execute in parallel. - /// if function calls are processed serially. - /// The default value is . - /// - /// - /// An individual response from the inner client might contain multiple function call requests. - /// By default, such function calls are processed serially. Set to - /// to enable concurrent invocation such that multiple function calls can execute in parallel. - /// - public bool AllowConcurrentInvocation { get; set; } - - /// - /// Gets or sets the maximum number of iterations per request. - /// - /// - /// The maximum number of iterations per request. - /// The default value is 10. - /// - /// - /// - /// Each request to this might end up making - /// multiple requests to the inner client. Each time the inner client responds with - /// a function call request, this client might perform that invocation and send the results - /// back to the inner client in a new request. This property limits the number of times - /// such a roundtrip is performed. The value must be at least one, as it includes the initial request. - /// - /// - /// Changing the value of this property while the client is in use might result in inconsistencies - /// as to how many iterations are allowed for an in-flight request. - /// - /// - public int MaximumIterationsPerRequest - { - get => _maximumIterationsPerRequest; - set - { - if (value < 1) - { - throw new ArgumentOutOfRangeException(nameof(value)); - } - - _maximumIterationsPerRequest = value; - } - } - - /// - /// Gets or sets the maximum number of consecutive iterations that are allowed to fail with an error. - /// - /// - /// The maximum number of consecutive iterations that are allowed to fail with an error. - /// The default value is 3. - /// - /// - /// - /// When function invocations fail with an exception, the - /// continues to make requests to the inner client, optionally supplying exception information (as - /// controlled by ). This allows the to - /// recover from errors by trying other function parameters that may succeed. - /// - /// - /// However, in case function invocations continue to produce exceptions, this property can be used to - /// limit the number of consecutive failing attempts. When the limit is reached, the exception will be - /// rethrown to the caller. - /// - /// - /// If the value is set to zero, all function calling exceptions immediately terminate the function - /// invocation loop and the exception will be rethrown to the caller. - /// - /// - /// Changing the value of this property while the client is in use might result in inconsistencies - /// as to how many iterations are allowed for an in-flight request. - /// - /// - public int MaximumConsecutiveErrorsPerRequest - { - get => _maximumConsecutiveErrorsPerRequest; - set - { - if (value < 0) - { - throw new ArgumentOutOfRangeException(nameof(value), "Argument less than minimum value 0"); - } - _maximumConsecutiveErrorsPerRequest = value; - } - } - /// - public override async Task GetResponseAsync( - IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - { - Verify.NotNull(messages); - - // A single request into this GetResponseAsync may result in multiple requests to the inner client. - // Create an activity to group them together for better observability. - using Activity? activity = _activitySource?.StartActivity(nameof(KernelFunctionInvokingChatClient)); - - // Copy the original messages in order to avoid enumerating the original messages multiple times. - // The IEnumerable can represent an arbitrary amount of work. - List originalMessages = [.. messages]; - messages = originalMessages; - - List? augmentedHistory = null; // the actual history of messages sent on turns other than the first - ChatResponse? response = null; // the response from the inner client, which is possibly modified and then eventually returned - List? responseMessages = null; // tracked list of messages, across multiple turns, to be used for the final response - UsageDetails? totalUsage = null; // tracked usage across all turns, to be used for the final response - List? functionCallContents = null; // function call contents that need responding to in the current turn - bool lastIterationHadThreadId = false; // whether the last iteration's response had a ChatThreadId set - int consecutiveErrorCount = 0; - - for (int iteration = 0; ; iteration++) - { - functionCallContents?.Clear(); - - // Make the call to the inner client. - response = await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); - if (response is null) - { - throw new InvalidOperationException($"The inner {nameof(IChatClient)} returned a null {nameof(ChatResponse)}."); - } - - // Any function call work to do? If yes, ensure we're tracking that work in functionCallContents. - bool requiresFunctionInvocation = - options?.Tools is { Count: > 0 } && - iteration < MaximumIterationsPerRequest && - CopyFunctionCalls(response.Messages, ref functionCallContents); - - // In a common case where we make a request and there's no function calling work required, - // fast path out by just returning the original response. - if (iteration == 0 && !requiresFunctionInvocation) - { - return response; - } - - // Track aggregate details from the response, including all the response messages and usage details. - (responseMessages ??= []).AddRange(response.Messages); - if (response.Usage is not null) - { - if (totalUsage is not null) - { - totalUsage.Add(response.Usage); - } - else - { - totalUsage = response.Usage; - } - } - - // If there are no tools to call, or for any other reason we should stop, we're done. - // Break out of the loop and allow the handling at the end to configure the response - // with aggregated data from previous requests. - if (!requiresFunctionInvocation) - { - break; - } - - // Prepare the history for the next iteration. - FixupHistories(originalMessages, ref messages, ref augmentedHistory, response, responseMessages, ref lastIterationHadThreadId); - - // Prepare the options for the next auto function invocation iteration. - UpdateOptionsForAutoFunctionInvocation(ref options!, response.Messages.Last().ToChatMessageContent(), isStreaming: false); - - // Add the responses from the function calls into the augmented history and also into the tracked - // list of response messages. - var modeAndMessages = await ProcessFunctionCallsAsync(augmentedHistory, options!, functionCallContents!, iteration, consecutiveErrorCount, isStreaming: false, cancellationToken).ConfigureAwait(false); - responseMessages.AddRange(modeAndMessages.MessagesAdded); - consecutiveErrorCount = modeAndMessages.NewConsecutiveErrorCount; - - if (modeAndMessages.ShouldTerminate) - { - break; - } - - // Clear the auto function invocation options. - ClearOptionsForAutoFunctionInvocation(ref options); - - UpdateOptionsForNextIteration(ref options!, response.ChatThreadId); - } - - Debug.Assert(responseMessages is not null, "Expected to only be here if we have response messages."); - response.Messages = responseMessages!; - response.Usage = totalUsage; - - return response; - } - - /// - public override async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - Verify.NotNull(messages); - - // A single request into this GetStreamingResponseAsync may result in multiple requests to the inner client. - // Create an activity to group them together for better observability. - using Activity? activity = _activitySource?.StartActivity(nameof(FunctionInvokingChatClient)); - - // Copy the original messages in order to avoid enumerating the original messages multiple times. - // The IEnumerable can represent an arbitrary amount of work. - List originalMessages = [.. messages]; - messages = originalMessages; - - List? augmentedHistory = null; // the actual history of messages sent on turns other than the first - List? functionCallContents = null; // function call contents that need responding to in the current turn - List? responseMessages = null; // tracked list of messages, across multiple turns, to be used in fallback cases to reconstitute history - bool lastIterationHadThreadId = false; // whether the last iteration's response had a ChatThreadId set - List updates = []; // updates from the current response - int consecutiveErrorCount = 0; - - for (int iteration = 0; ; iteration++) - { - updates.Clear(); - functionCallContents?.Clear(); - - await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) - { - if (update is null) - { - throw new InvalidOperationException($"The inner {nameof(IChatClient)} streamed a null {nameof(ChatResponseUpdate)}."); - } - - updates.Add(update); - - _ = CopyFunctionCalls(update.Contents, ref functionCallContents); - - yield return update; - 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 } || - options?.Tools is not { Count: > 0 } || - iteration >= _maximumIterationsPerRequest) - { - break; - } - - // Reconstitute a response from the response updates. - var response = updates.ToChatResponse(); - (responseMessages ??= []).AddRange(response.Messages); - - // Prepare the history for the next iteration. - FixupHistories(originalMessages, ref messages, ref augmentedHistory, response, responseMessages, ref lastIterationHadThreadId); - - // Prepare the options for the next auto function invocation iteration. - UpdateOptionsForAutoFunctionInvocation(ref options, response.Messages.Last().ToChatMessageContent(), isStreaming: true); - - // Process all the functions, adding their results into the history. - var modeAndMessages = await ProcessFunctionCallsAsync(augmentedHistory, options, functionCallContents, iteration, consecutiveErrorCount, isStreaming: true, cancellationToken).ConfigureAwait(false); - responseMessages.AddRange(modeAndMessages.MessagesAdded); - consecutiveErrorCount = modeAndMessages.NewConsecutiveErrorCount; - - // Clear the auto function invocation options. - ClearOptionsForAutoFunctionInvocation(ref options); - - // 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 - // include all activity, including generated function results. - foreach (var message in modeAndMessages.MessagesAdded) - { - var toolResultUpdate = new ChatResponseUpdate - { - AdditionalProperties = message.AdditionalProperties, - AuthorName = message.AuthorName, - ChatThreadId = response.ChatThreadId, - 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; - Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802 - } - - if (modeAndMessages.ShouldTerminate) - { - yield break; - } - - UpdateOptionsForNextIteration(ref options, response.ChatThreadId); - } - } - - /// Prepares the various chat message lists after a response from the inner client and before invoking functions. - /// The original messages provided by the caller. - /// The messages reference passed to the inner client. - /// The augmented history containing all the messages to be sent. - /// The most recent response being handled. - /// A list of all response messages received up until this point. - /// Whether the previous iteration's response had a thread id. - private static void FixupHistories( - IEnumerable originalMessages, - ref IEnumerable messages, - [NotNull] ref List? augmentedHistory, - ChatResponse response, - List allTurnsResponseMessages, - ref bool lastIterationHadThreadId) - { - // We're now going to need to augment the history with function result contents. - // That means we need a separate list to store the augmented history. - if (response.ChatThreadId is not null) - { - // The response indicates the inner client is tracking the history, so we don't want to send - // anything we've already sent or received. - if (augmentedHistory is not null) - { - augmentedHistory.Clear(); - } - else - { - augmentedHistory = []; - } - - lastIterationHadThreadId = true; - } - else if (lastIterationHadThreadId) - { - // In the very rare case where the inner client returned a response with a thread ID but then - // returned a subsequent response without one, we want to reconstitute the full history. To do that, - // we can populate the history with the original chat messages and then all the response - // messages up until this point, which includes the most recent ones. - augmentedHistory ??= []; - augmentedHistory.Clear(); - augmentedHistory.AddRange(originalMessages); - augmentedHistory.AddRange(allTurnsResponseMessages); - - lastIterationHadThreadId = false; - } - else - { - // If augmentedHistory is already non-null, then we've already populated it with everything up - // until this point (except for the most recent response). If it's null, we need to seed it with - // the chat history provided by the caller. - augmentedHistory ??= originalMessages.ToList(); - - // Now add the most recent response messages. - augmentedHistory.AddMessages(response); - - lastIterationHadThreadId = false; - } - - // Use the augmented history as the new set of messages to send. - messages = augmentedHistory; - } - - /// Copies any from to . - private static bool CopyFunctionCalls( - IList messages, [NotNullWhen(true)] ref List? functionCalls) - { - bool any = false; - int count = messages.Count; - for (int i = 0; i < count; i++) - { - any |= CopyFunctionCalls(messages[i].Contents, ref functionCalls); - } - - return any; - } - - /// Copies any from to . - private static bool CopyFunctionCalls( - IList content, [NotNullWhen(true)] ref List? functionCalls) + public KernelFunctionInvokingChatClient(IChatClient innerClient, ILoggerFactory? loggerFactory = null, IServiceProvider? functionInvocationServices = null) + : base(innerClient, loggerFactory, functionInvocationServices) { - bool any = false; - int count = content.Count; - for (int i = 0; i < count; i++) - { - if (content[i] is FunctionCallContent functionCall) - { - (functionCalls ??= []).Add(functionCall); - any = true; - } - } - - return any; } private static void UpdateOptionsForAutoFunctionInvocation(ref ChatOptions options, ChatMessageContent content, bool isStreaming) @@ -547,67 +85,40 @@ private static void ClearOptionsForAutoFunctionInvocation(ref ChatOptions option } } - private static void UpdateOptionsForNextIteration(ref ChatOptions options, string? chatThreadId) + /// + protected override async Task<(bool ShouldTerminate, int NewConsecutiveErrorCount, IList MessagesAdded)> ProcessFunctionCallsAsync( + ChatResponse response, List messages, ChatOptions options, List functionCallContents, int iteration, int consecutiveErrorCount, bool isStreaming, IServiceProvider functionInvocationServices, CancellationToken cancellationToken) { - if (options.ToolMode is RequiredChatToolMode) - { - // We have to reset the tool mode to be non-required after the first iteration, - // as otherwise we'll be in an infinite loop. - options = options.Clone(); - options.ToolMode = null; - options.ChatThreadId = chatThreadId; - } - else if (options.ChatThreadId != chatThreadId) - { - // As with the other modes, ensure we've propagated the chat thread ID to the options. - // We only need to clone the options if we're actually mutating it. - options = options.Clone(); - options.ChatThreadId = chatThreadId; - } - } + // Prepare the options for the next auto function invocation iteration. + UpdateOptionsForAutoFunctionInvocation(ref options!, response.Messages.Last().ToChatMessageContent(), isStreaming: false); - /// - /// Processes the function calls in the list. - /// - /// The current chat contents, inclusive of the function call contents being processed. - /// The options used for the response being processed. - /// The function call contents representing the functions to be invoked. - /// The iteration number of how many roundtrips have been made to the inner client. - /// The number of consecutive iterations, prior to this one, that were recorded as having function invocation errors. - /// Whether the function calls are being processed in a streaming context. - /// The to monitor for cancellation requests. - /// A value indicating how the caller should proceed. - private async Task<(bool ShouldTerminate, int NewConsecutiveErrorCount, IList MessagesAdded)> ProcessFunctionCallsAsync( - List messages, ChatOptions options, List functionCallContents, - int iteration, int consecutiveErrorCount, bool isStreaming, CancellationToken cancellationToken) - { // We must add a response for every tool call, regardless of whether we successfully executed it or not. // If we successfully execute it, we'll add the result. If we don't, we'll add an error. - + (bool ShouldTerminate, int NewConsecutiveErrorCount, IList MessagesAdded) result; Debug.Assert(functionCallContents.Count > 0, "Expected at least one function call."); var shouldTerminate = false; - var captureCurrentIterationExceptions = consecutiveErrorCount < _maximumConsecutiveErrorsPerRequest; + var captureCurrentIterationExceptions = consecutiveErrorCount < this.MaximumConsecutiveErrorsPerRequest; // Process all functions. If there's more than one and concurrent invocation is enabled, do so in parallel. if (functionCallContents.Count == 1) { - FunctionInvocationResult result = await ProcessFunctionCallAsync( - messages, options, functionCallContents, iteration, 0, captureCurrentIterationExceptions, isStreaming, cancellationToken).ConfigureAwait(false); + FunctionInvocationResult functionResult = await this.ProcessFunctionCallAsync( + messages, options, functionCallContents, iteration, 0, captureCurrentIterationExceptions, isStreaming, functionInvocationServices, cancellationToken).ConfigureAwait(false); - IList added = CreateResponseMessages([result]); - ThrowIfNoFunctionResultsAdded(added); - UpdateConsecutiveErrorCountOrThrow(added, ref consecutiveErrorCount); + IList added = this.CreateResponseMessages([functionResult]); + this.ThrowIfNoFunctionResultsAdded(added); + this.UpdateConsecutiveErrorCountOrThrow(added, ref consecutiveErrorCount); messages.AddRange(added); - return (result.ShouldTerminate, consecutiveErrorCount, added); + result = (functionResult.ShouldTerminate, consecutiveErrorCount, added); } else { List results = []; var terminationRequested = false; - if (AllowConcurrentInvocation) + if (this.AllowConcurrentInvocation) { // Rather than awaiting each function before invoking the next, invoke all of them // and then await all of them. We avoid forcibly introducing parallelism via Task.Run, @@ -615,9 +126,9 @@ private static void UpdateOptionsForNextIteration(ref ChatOptions options, strin // with the processing of other the other invocation invocations. results.AddRange(await Task.WhenAll( from i in Enumerable.Range(0, functionCallContents.Count) - select ProcessFunctionCallAsync( + select this.ProcessFunctionCallAsync( messages, options, functionCallContents, - iteration, i, captureExceptions: true, isStreaming, cancellationToken)).ConfigureAwait(false)); + iteration, i, captureExceptions: true, isStreaming, functionInvocationServices, cancellationToken)).ConfigureAwait(false)); terminationRequested = results.Any(r => r.ShouldTerminate); } @@ -626,13 +137,15 @@ select ProcessFunctionCallAsync( // Invoke each function serially. for (int i = 0; i < functionCallContents.Count; i++) { - var result = await ProcessFunctionCallAsync( + var functionResult = await this.ProcessFunctionCallAsync( messages, options, functionCallContents, - iteration, i, captureCurrentIterationExceptions, isStreaming, cancellationToken).ConfigureAwait(false); + iteration, i, captureCurrentIterationExceptions, isStreaming, functionInvocationServices, cancellationToken).ConfigureAwait(false); - results.Add(result); + results.Add(functionResult); - if (result.ShouldTerminate) + // Differently from the original FunctionInvokingChatClient, as soon the termination is requested, + // we stop and don't continue + if (functionResult.ShouldTerminate) { shouldTerminate = true; terminationRequested = true; @@ -641,9 +154,9 @@ select ProcessFunctionCallAsync( } } - IList added = CreateResponseMessages(results); - ThrowIfNoFunctionResultsAdded(added); - UpdateConsecutiveErrorCountOrThrow(added, ref consecutiveErrorCount); + IList added = this.CreateResponseMessages(results); + this.ThrowIfNoFunctionResultsAdded(added); + this.UpdateConsecutiveErrorCountOrThrow(added, ref consecutiveErrorCount); messages.AddRange(added); @@ -657,64 +170,19 @@ select ProcessFunctionCallAsync( } } - return (shouldTerminate, consecutiveErrorCount, added); + result = (shouldTerminate, consecutiveErrorCount, added); } - } - private void UpdateConsecutiveErrorCountOrThrow(IList added, ref int consecutiveErrorCount) - { - var allExceptions = added.SelectMany(m => m.Contents.OfType()) - .Select(frc => frc.Exception!) - .Where(e => e is not null); + // Clear the auto function invocation options. + ClearOptionsForAutoFunctionInvocation(ref options); -#pragma warning disable CA1851 // Possible multiple enumerations of 'IEnumerable' collection - if (allExceptions.Any()) - { - consecutiveErrorCount++; - if (consecutiveErrorCount > _maximumConsecutiveErrorsPerRequest) - { - var allExceptionsArray = allExceptions.ToArray(); - if (allExceptionsArray.Length == 1) - { - ExceptionDispatchInfo.Capture(allExceptionsArray[0]).Throw(); - } - else - { - throw new AggregateException(allExceptionsArray); - } - } - } - else - { - consecutiveErrorCount = 0; - } -#pragma warning restore CA1851 // Possible multiple enumerations of 'IEnumerable' collection - } - - /// - /// Throws an exception if doesn't create any messages. - /// - private void ThrowIfNoFunctionResultsAdded(IList? messages) - { - if (messages is null || messages.Count == 0) - { - throw new InvalidOperationException($"{this.GetType().Name}.{nameof(this.CreateResponseMessages)} returned null or an empty collection of messages."); - } + return result; } - /// Processes the function call described in []. - /// The current chat contents, inclusive of the function call contents being processed. - /// The options used for the response being processed. - /// The function call contents representing all the functions being invoked. - /// The iteration number of how many roundtrips have been made to the inner client. - /// The 0-based index of the function being called out of . - /// If true, handles function-invocation exceptions by returning a value with . Otherwise, rethrows. - /// Whether the function calls are being processed in a streaming context. - /// The to monitor for cancellation requests. - /// A value indicating how the caller should proceed. - private async Task ProcessFunctionCallAsync( + /// + protected override async Task ProcessFunctionCallAsync( List messages, ChatOptions options, List callContents, - int iteration, int functionCallIndex, bool captureExceptions, bool isStreaming, CancellationToken cancellationToken) + int iteration, int functionCallIndex, bool captureExceptions, bool isStreaming, IServiceProvider functionInvocationServices, CancellationToken cancellationToken) { var callContent = callContents[functionCallIndex]; @@ -722,7 +190,7 @@ private async Task ProcessFunctionCallAsync( AIFunction? function = options.Tools!.OfType().FirstOrDefault(t => t.Name == callContent.Name); if (function is null) { - return new(shouldTerminate: false, FunctionInvokingChatClient.FunctionInvocationStatus.NotFound, callContent, result: null, exception: null); + return new(shouldTerminate: false, FunctionInvocationStatus.NotFound, callContent, result: null, exception: null); } if (callContent.Arguments is not null) @@ -733,7 +201,7 @@ private async Task ProcessFunctionCallAsync( var context = new AutoFunctionInvocationContext(new() { Function = function, - Arguments = new(callContent.Arguments) { Services = _functionInvocationServices }, + Arguments = new(callContent.Arguments) { Services = functionInvocationServices }, Messages = messages, Options = options, @@ -858,39 +326,13 @@ await autoFunctionInvocationFilters[index].OnAutoFunctionInvocationAsync( } } - /// Invokes the function asynchronously. - /// - /// The function invocation context detailing the function to be invoked and its arguments along with additional request information. - /// - /// The to monitor for cancellation requests. The default is . - /// The result of the function invocation, or if the function invocation returned . - /// is . - private async Task InvokeFunctionAsync(AutoFunctionInvocationContext context, CancellationToken cancellationToken) + protected override async Task<(FunctionInvocationContext context, object? result)> TryInvokeFunctionAsync(FunctionInvocationContext context, CancellationToken cancellationToken) { - Verify.NotNull(context); - - using Activity? activity = _activitySource?.StartActivity(context.Function.Name); - - long startingTimestamp = 0; - if (_logger.IsEnabled(LogLevel.Debug)) - { - startingTimestamp = Stopwatch.GetTimestamp(); - if (_logger.IsEnabled(LogLevel.Trace)) - { - LogInvokingSensitive(context.Function.Name, LoggingAsJson(context.CallContent.Arguments, context.AIFunction.JsonSerializerOptions)); - } - else - { - LogInvoking(context.Function.Name); - } - } - object? result = null; - try + if (context is AutoFunctionInvocationContext autoContext) { - CurrentContext = context; // doesn't need to be explicitly reset after, as that's handled automatically at async method exit context = await this.OnAutoFunctionInvocationAsync( - context, + autoContext, async (ctx) => { // Check if filter requested termination @@ -902,122 +344,16 @@ await autoFunctionInvocationFilters[index].OnAutoFunctionInvocationAsync( // Note that we explicitly do not use executionSettings here; those pertain to the all-up operation and not necessarily to any // further calls made as part of this function invocation. In particular, we must not use function calling settings naively here, // as the called function could in turn telling the model about itself as a possible candidate for invocation. - result = await context.AIFunction.InvokeAsync(new(context.Arguments), cancellationToken).ConfigureAwait(false); + result = await autoContext.AIFunction.InvokeAsync(new(context.Arguments), cancellationToken).ConfigureAwait(false); ctx.Result = new FunctionResult(ctx.Function, result); }).ConfigureAwait(false); - result = context.Result.GetValue(); - } - catch (Exception e) - { - if (activity is not null) - { - _ = activity.SetTag("error.type", e.GetType().FullName) - .SetStatus(ActivityStatusCode.Error, e.Message); - } - - if (e is OperationCanceledException) - { - LogInvocationCanceled(context.Function.Name); - } - else - { - LogInvocationFailed(context.Function.Name, e); - } - - throw; - } - finally - { - if (_logger.IsEnabled(LogLevel.Debug)) - { - TimeSpan elapsed = GetElapsedTime(startingTimestamp); - - if (result is not null && _logger.IsEnabled(LogLevel.Trace)) - { - LogInvocationCompletedSensitive(context.Function.Name, elapsed, LoggingAsJson(result, context.AIFunction.JsonSerializerOptions)); - } - else - { - LogInvocationCompleted(context.Function.Name, elapsed); - } - } + result = autoContext.Result.GetValue(); } - - return result; - } - - /// Serializes as JSON for logging purposes. - private static string LoggingAsJson(T value, JsonSerializerOptions? options) - { - if (options?.TryGetTypeInfo(typeof(T), out var typeInfo) is true || - AIJsonUtilities.DefaultOptions.TryGetTypeInfo(typeof(T), out typeInfo)) - { -#pragma warning disable CA1031 // Do not catch general exception types - try - { - return JsonSerializer.Serialize(value, typeInfo); - } - catch - { - } -#pragma warning restore CA1031 // Do not catch general exception types - } - - // If we're unable to get a type info for the value, or if we fail to serialize, - // return an empty JSON object. We do not want lack of type info to disrupt application behavior with exceptions. - return "{}"; - } - - private static TimeSpan GetElapsedTime(long startingTimestamp) => -#if NET - Stopwatch.GetElapsedTime(startingTimestamp); -#else - new((long)((Stopwatch.GetTimestamp() - startingTimestamp) * ((double)TimeSpan.TicksPerSecond / Stopwatch.Frequency))); -#endif - - [LoggerMessage(LogLevel.Debug, "Invoking {MethodName}.", SkipEnabledCheck = true)] - private partial void LogInvoking(string methodName); - - [LoggerMessage(LogLevel.Trace, "Invoking {MethodName}({Arguments}).", SkipEnabledCheck = true)] - private partial void LogInvokingSensitive(string methodName, string arguments); - - [LoggerMessage(LogLevel.Debug, "{MethodName} invocation completed. Duration: {Duration}", SkipEnabledCheck = true)] - private partial void LogInvocationCompleted(string methodName, TimeSpan duration); - - [LoggerMessage(LogLevel.Trace, "{MethodName} invocation completed. Duration: {Duration}. Result: {Result}", SkipEnabledCheck = true)] - private partial void LogInvocationCompletedSensitive(string methodName, TimeSpan duration, string result); - - [LoggerMessage(LogLevel.Debug, "{MethodName} invocation canceled.")] - private partial void LogInvocationCanceled(string methodName); - - [LoggerMessage(LogLevel.Error, "{MethodName} invocation failed.")] - private partial void LogInvocationFailed(string methodName, Exception error); - - /// Provides information about the invocation of a function call. - public sealed class FunctionInvocationResult - { - internal FunctionInvocationResult(bool shouldTerminate, FunctionInvokingChatClient.FunctionInvocationStatus status, FunctionCallContent callContent, object? result, Exception? exception) + else { - ShouldTerminate = shouldTerminate; - Status = status; - CallContent = callContent; - Result = result; - Exception = exception; + result = await context.Function.InvokeAsync(context.Arguments, cancellationToken).ConfigureAwait(false); } - /// Gets status about how the function invocation completed. - public FunctionInvokingChatClient.FunctionInvocationStatus Status { get; } - - /// Gets the function call content information associated with this invocation. - public FunctionCallContent CallContent { get; } - - /// Gets the result of the function call. - public object? Result { get; } - - /// Gets any exception the function call threw. - public Exception? Exception { get; } - - /// Gets a value indicating whether the caller should terminate the processing loop. - internal bool ShouldTerminate { get; } + return (context, result); } } diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClientOld.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClientOld.cs new file mode 100644 index 000000000000..1afb2689983b --- /dev/null +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClientOld.cs @@ -0,0 +1,1023 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +#pragma warning restore IDE0073 // The file header does not match the required text +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Runtime.ExceptionServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; + +#pragma warning disable IDE1006 // Naming Styles +#pragma warning disable IDE0009 // This +#pragma warning disable CA2213 // Disposable fields should be disposed +#pragma warning disable EA0002 // Use 'System.TimeProvider' to make the code easier to test +#pragma warning disable SA1202 // 'protected' members should come before 'private' members + +// Modified source from 2025-04-07 +// https://raw.githubusercontent.com/dotnet/extensions/84d09b794d994435568adcbb85a981143d4f15cb/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs + +namespace Microsoft.Extensions.AI; + +/// +/// A delegating chat client that invokes functions defined on . +/// Include this in a chat pipeline to resolve function calls automatically. +/// +/// +/// +/// When this client receives a in a chat response, it responds +/// by calling the corresponding defined in , +/// producing a that it sends back to the inner client. This loop +/// is repeated until there are no more function calls to make, or until another stop condition is met, +/// such as hitting . +/// +/// +/// The provided implementation of is thread-safe for concurrent use so long as the +/// instances employed as part of the supplied are also safe. +/// The property can be used to control whether multiple function invocation +/// requests as part of the same request are invocable concurrently, but even with that set to +/// (the default), multiple concurrent requests to this same instance and using the same tools could result in those +/// tools being used concurrently (one per request). For example, a function that accesses the HttpContext of a specific +/// ASP.NET web request should only be used as part of a single at a time, and only with +/// set to , in case the inner client decided to issue multiple +/// invocation requests to that same function. +/// +/// +public partial class KernelFunctionInvokingChatClientOld : DelegatingChatClient +{ + /// The for the current function invocation. + private static readonly AsyncLocal _currentContext = new(); + + /// Optional services used for function invocation. + private readonly IServiceProvider? _functionInvocationServices; + + /// The logger to use for logging information about function invocation. + private readonly ILogger _logger; + + /// The to use for telemetry. + /// This component does not own the instance and should not dispose it. + private readonly ActivitySource? _activitySource; + + /// Maximum number of roundtrips allowed to the inner client. + private int _maximumIterationsPerRequest = 10; + + /// Maximum number of consecutive iterations that are allowed contain at least one exception result. If the limit is exceeded, we rethrow the exception instead of continuing. + private int _maximumConsecutiveErrorsPerRequest = 3; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying , or the next instance in a chain of clients. + /// An to use for logging information about function invocation. + /// An optional to use for resolving services required by the instances being invoked. + public KernelFunctionInvokingChatClientOld(IChatClient innerClient, ILoggerFactory? loggerFactory = null, IServiceProvider? functionInvocationServices = null) + : base(innerClient) + { + _logger = (ILogger?)loggerFactory?.CreateLogger() ?? NullLogger.Instance; + _activitySource = innerClient.GetService(); + _functionInvocationServices = functionInvocationServices; + } + + /// + /// Gets or sets the for the current function invocation. + /// + /// + /// This value flows across async calls. + /// + public static AutoFunctionInvocationContext? CurrentContext + { + get => _currentContext.Value; + protected set => _currentContext.Value = value; + } + + /// + /// Gets or sets a value indicating whether detailed exception information should be included + /// in the chat history when calling the underlying . + /// + /// + /// if the full exception message is added to the chat history + /// when calling the underlying . + /// if a generic error message is included in the chat history. + /// The default value is . + /// + /// + /// + /// Setting the value to prevents the underlying language model from disclosing + /// raw exception details to the end user, since it doesn't receive that information. Even in this + /// case, the raw object is available to application code by inspecting + /// the property. + /// + /// + /// Setting the value to can help the underlying bypass problems on + /// its own, for example by retrying the function call with different arguments. However, it might + /// result in disclosing the raw exception information to external users, which can be a security + /// concern depending on the application scenario. + /// + /// + /// Changing the value of this property while the client is in use might result in inconsistencies + /// whether detailed errors are provided during an in-flight request. + /// + /// + public bool IncludeDetailedErrors { get; set; } + + /// + /// Gets or sets a value indicating whether to allow concurrent invocation of functions. + /// + /// + /// if multiple function calls can execute in parallel. + /// if function calls are processed serially. + /// The default value is . + /// + /// + /// An individual response from the inner client might contain multiple function call requests. + /// By default, such function calls are processed serially. Set to + /// to enable concurrent invocation such that multiple function calls can execute in parallel. + /// + public bool AllowConcurrentInvocation { get; set; } + + /// + /// Gets or sets the maximum number of iterations per request. + /// + /// + /// The maximum number of iterations per request. + /// The default value is 10. + /// + /// + /// + /// Each request to this might end up making + /// multiple requests to the inner client. Each time the inner client responds with + /// a function call request, this client might perform that invocation and send the results + /// back to the inner client in a new request. This property limits the number of times + /// such a roundtrip is performed. The value must be at least one, as it includes the initial request. + /// + /// + /// Changing the value of this property while the client is in use might result in inconsistencies + /// as to how many iterations are allowed for an in-flight request. + /// + /// + public int MaximumIterationsPerRequest + { + get => _maximumIterationsPerRequest; + set + { + if (value < 1) + { + throw new ArgumentOutOfRangeException(nameof(value)); + } + + _maximumIterationsPerRequest = value; + } + } + + /// + /// Gets or sets the maximum number of consecutive iterations that are allowed to fail with an error. + /// + /// + /// The maximum number of consecutive iterations that are allowed to fail with an error. + /// The default value is 3. + /// + /// + /// + /// When function invocations fail with an exception, the + /// continues to make requests to the inner client, optionally supplying exception information (as + /// controlled by ). This allows the to + /// recover from errors by trying other function parameters that may succeed. + /// + /// + /// However, in case function invocations continue to produce exceptions, this property can be used to + /// limit the number of consecutive failing attempts. When the limit is reached, the exception will be + /// rethrown to the caller. + /// + /// + /// If the value is set to zero, all function calling exceptions immediately terminate the function + /// invocation loop and the exception will be rethrown to the caller. + /// + /// + /// Changing the value of this property while the client is in use might result in inconsistencies + /// as to how many iterations are allowed for an in-flight request. + /// + /// + public int MaximumConsecutiveErrorsPerRequest + { + get => _maximumConsecutiveErrorsPerRequest; + set + { + if (value < 0) + { + throw new ArgumentOutOfRangeException(nameof(value), "Argument less than minimum value 0"); + } + _maximumConsecutiveErrorsPerRequest = value; + } + } + + /// + public override async Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + Verify.NotNull(messages); + + // A single request into this GetResponseAsync may result in multiple requests to the inner client. + // Create an activity to group them together for better observability. + using Activity? activity = _activitySource?.StartActivity(nameof(KernelFunctionInvokingChatClient)); + + // Copy the original messages in order to avoid enumerating the original messages multiple times. + // The IEnumerable can represent an arbitrary amount of work. + List originalMessages = [.. messages]; + messages = originalMessages; + + List? augmentedHistory = null; // the actual history of messages sent on turns other than the first + ChatResponse? response = null; // the response from the inner client, which is possibly modified and then eventually returned + List? responseMessages = null; // tracked list of messages, across multiple turns, to be used for the final response + UsageDetails? totalUsage = null; // tracked usage across all turns, to be used for the final response + List? functionCallContents = null; // function call contents that need responding to in the current turn + bool lastIterationHadThreadId = false; // whether the last iteration's response had a ChatThreadId set + int consecutiveErrorCount = 0; + + for (int iteration = 0; ; iteration++) + { + functionCallContents?.Clear(); + + // Make the call to the inner client. + response = await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); + if (response is null) + { + throw new InvalidOperationException($"The inner {nameof(IChatClient)} returned a null {nameof(ChatResponse)}."); + } + + // Any function call work to do? If yes, ensure we're tracking that work in functionCallContents. + bool requiresFunctionInvocation = + options?.Tools is { Count: > 0 } && + iteration < MaximumIterationsPerRequest && + CopyFunctionCalls(response.Messages, ref functionCallContents); + + // In a common case where we make a request and there's no function calling work required, + // fast path out by just returning the original response. + if (iteration == 0 && !requiresFunctionInvocation) + { + return response; + } + + // Track aggregate details from the response, including all the response messages and usage details. + (responseMessages ??= []).AddRange(response.Messages); + if (response.Usage is not null) + { + if (totalUsage is not null) + { + totalUsage.Add(response.Usage); + } + else + { + totalUsage = response.Usage; + } + } + + // If there are no tools to call, or for any other reason we should stop, we're done. + // Break out of the loop and allow the handling at the end to configure the response + // with aggregated data from previous requests. + if (!requiresFunctionInvocation) + { + break; + } + + // Prepare the history for the next iteration. + FixupHistories(originalMessages, ref messages, ref augmentedHistory, response, responseMessages, ref lastIterationHadThreadId); + + // Prepare the options for the next auto function invocation iteration. + UpdateOptionsForAutoFunctionInvocation(ref options!, response.Messages.Last().ToChatMessageContent(), isStreaming: false); + + // Add the responses from the function calls into the augmented history and also into the tracked + // list of response messages. + var modeAndMessages = await ProcessFunctionCallsAsync(augmentedHistory, options!, functionCallContents!, iteration, consecutiveErrorCount, isStreaming: false, cancellationToken).ConfigureAwait(false); + responseMessages.AddRange(modeAndMessages.MessagesAdded); + consecutiveErrorCount = modeAndMessages.NewConsecutiveErrorCount; + + if (modeAndMessages.ShouldTerminate) + { + break; + } + + // Clear the auto function invocation options. + ClearOptionsForAutoFunctionInvocation(ref options); + + UpdateOptionsForNextIteration(ref options!, response.ChatThreadId); + } + + Debug.Assert(responseMessages is not null, "Expected to only be here if we have response messages."); + response.Messages = responseMessages!; + response.Usage = totalUsage; + + return response; + } + + /// + public override async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + Verify.NotNull(messages); + + // A single request into this GetStreamingResponseAsync may result in multiple requests to the inner client. + // Create an activity to group them together for better observability. + using Activity? activity = _activitySource?.StartActivity(nameof(FunctionInvokingChatClient)); + + // Copy the original messages in order to avoid enumerating the original messages multiple times. + // The IEnumerable can represent an arbitrary amount of work. + List originalMessages = [.. messages]; + messages = originalMessages; + + List? augmentedHistory = null; // the actual history of messages sent on turns other than the first + List? functionCallContents = null; // function call contents that need responding to in the current turn + List? responseMessages = null; // tracked list of messages, across multiple turns, to be used in fallback cases to reconstitute history + bool lastIterationHadThreadId = false; // whether the last iteration's response had a ChatThreadId set + List updates = []; // updates from the current response + int consecutiveErrorCount = 0; + + for (int iteration = 0; ; iteration++) + { + updates.Clear(); + functionCallContents?.Clear(); + + await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) + { + if (update is null) + { + throw new InvalidOperationException($"The inner {nameof(IChatClient)} streamed a null {nameof(ChatResponseUpdate)}."); + } + + updates.Add(update); + + _ = CopyFunctionCalls(update.Contents, ref functionCallContents); + + yield return update; + 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 } || + options?.Tools is not { Count: > 0 } || + iteration >= _maximumIterationsPerRequest) + { + break; + } + + // Reconstitute a response from the response updates. + var response = updates.ToChatResponse(); + (responseMessages ??= []).AddRange(response.Messages); + + // Prepare the history for the next iteration. + FixupHistories(originalMessages, ref messages, ref augmentedHistory, response, responseMessages, ref lastIterationHadThreadId); + + // Prepare the options for the next auto function invocation iteration. + UpdateOptionsForAutoFunctionInvocation(ref options, response.Messages.Last().ToChatMessageContent(), isStreaming: true); + + // Process all the functions, adding their results into the history. + var modeAndMessages = await ProcessFunctionCallsAsync(augmentedHistory, options, functionCallContents, iteration, consecutiveErrorCount, isStreaming: true, cancellationToken).ConfigureAwait(false); + responseMessages.AddRange(modeAndMessages.MessagesAdded); + consecutiveErrorCount = modeAndMessages.NewConsecutiveErrorCount; + + // Clear the auto function invocation options. + ClearOptionsForAutoFunctionInvocation(ref options); + + // 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 + // include all activity, including generated function results. + foreach (var message in modeAndMessages.MessagesAdded) + { + var toolResultUpdate = new ChatResponseUpdate + { + AdditionalProperties = message.AdditionalProperties, + AuthorName = message.AuthorName, + ChatThreadId = response.ChatThreadId, + 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; + Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802 + } + + if (modeAndMessages.ShouldTerminate) + { + yield break; + } + + UpdateOptionsForNextIteration(ref options, response.ChatThreadId); + } + } + + /// Prepares the various chat message lists after a response from the inner client and before invoking functions. + /// The original messages provided by the caller. + /// The messages reference passed to the inner client. + /// The augmented history containing all the messages to be sent. + /// The most recent response being handled. + /// A list of all response messages received up until this point. + /// Whether the previous iteration's response had a thread id. + private static void FixupHistories( + IEnumerable originalMessages, + ref IEnumerable messages, + [NotNull] ref List? augmentedHistory, + ChatResponse response, + List allTurnsResponseMessages, + ref bool lastIterationHadThreadId) + { + // We're now going to need to augment the history with function result contents. + // That means we need a separate list to store the augmented history. + if (response.ChatThreadId is not null) + { + // The response indicates the inner client is tracking the history, so we don't want to send + // anything we've already sent or received. + if (augmentedHistory is not null) + { + augmentedHistory.Clear(); + } + else + { + augmentedHistory = []; + } + + lastIterationHadThreadId = true; + } + else if (lastIterationHadThreadId) + { + // In the very rare case where the inner client returned a response with a thread ID but then + // returned a subsequent response without one, we want to reconstitute the full history. To do that, + // we can populate the history with the original chat messages and then all the response + // messages up until this point, which includes the most recent ones. + augmentedHistory ??= []; + augmentedHistory.Clear(); + augmentedHistory.AddRange(originalMessages); + augmentedHistory.AddRange(allTurnsResponseMessages); + + lastIterationHadThreadId = false; + } + else + { + // If augmentedHistory is already non-null, then we've already populated it with everything up + // until this point (except for the most recent response). If it's null, we need to seed it with + // the chat history provided by the caller. + augmentedHistory ??= originalMessages.ToList(); + + // Now add the most recent response messages. + augmentedHistory.AddMessages(response); + + lastIterationHadThreadId = false; + } + + // Use the augmented history as the new set of messages to send. + messages = augmentedHistory; + } + + /// Copies any from to . + private static bool CopyFunctionCalls( + IList messages, [NotNullWhen(true)] ref List? functionCalls) + { + bool any = false; + int count = messages.Count; + for (int i = 0; i < count; i++) + { + any |= CopyFunctionCalls(messages[i].Contents, ref functionCalls); + } + + return any; + } + + /// Copies any from to . + private static bool CopyFunctionCalls( + IList content, [NotNullWhen(true)] ref List? functionCalls) + { + bool any = false; + int count = content.Count; + for (int i = 0; i < count; i++) + { + if (content[i] is FunctionCallContent functionCall) + { + (functionCalls ??= []).Add(functionCall); + any = true; + } + } + + return any; + } + + private static void UpdateOptionsForAutoFunctionInvocation(ref ChatOptions options, ChatMessageContent content, bool isStreaming) + { + if (options.AdditionalProperties?.ContainsKey(ChatOptionsExtensions.IsStreamingKey) ?? false) + { + throw new KernelException($"The reserved key name '{ChatOptionsExtensions.IsStreamingKey}' is already specified in the options. Avoid using this key name."); + } + + if (options.AdditionalProperties?.ContainsKey(ChatOptionsExtensions.ChatMessageContentKey) ?? false) + { + throw new KernelException($"The reserved key name '{ChatOptionsExtensions.ChatMessageContentKey}' is already specified in the options. Avoid using this key name."); + } + + options.AdditionalProperties ??= []; + + options.AdditionalProperties[ChatOptionsExtensions.IsStreamingKey] = isStreaming; + options.AdditionalProperties[ChatOptionsExtensions.ChatMessageContentKey] = content; + } + + private static void ClearOptionsForAutoFunctionInvocation(ref ChatOptions options) + { + if (options.AdditionalProperties?.ContainsKey(ChatOptionsExtensions.IsStreamingKey) ?? false) + { + options.AdditionalProperties.Remove(ChatOptionsExtensions.IsStreamingKey); + } + + if (options.AdditionalProperties?.ContainsKey(ChatOptionsExtensions.ChatMessageContentKey) ?? false) + { + options.AdditionalProperties.Remove(ChatOptionsExtensions.ChatMessageContentKey); + } + } + + private static void UpdateOptionsForNextIteration(ref ChatOptions options, string? chatThreadId) + { + if (options.ToolMode is RequiredChatToolMode) + { + // We have to reset the tool mode to be non-required after the first iteration, + // as otherwise we'll be in an infinite loop. + options = options.Clone(); + options.ToolMode = null; + options.ChatThreadId = chatThreadId; + } + else if (options.ChatThreadId != chatThreadId) + { + // As with the other modes, ensure we've propagated the chat thread ID to the options. + // We only need to clone the options if we're actually mutating it. + options = options.Clone(); + options.ChatThreadId = chatThreadId; + } + } + + /// + /// Processes the function calls in the list. + /// + /// The current chat contents, inclusive of the function call contents being processed. + /// The options used for the response being processed. + /// The function call contents representing the functions to be invoked. + /// The iteration number of how many roundtrips have been made to the inner client. + /// The number of consecutive iterations, prior to this one, that were recorded as having function invocation errors. + /// Whether the function calls are being processed in a streaming context. + /// The to monitor for cancellation requests. + /// A value indicating how the caller should proceed. + private async Task<(bool ShouldTerminate, int NewConsecutiveErrorCount, IList MessagesAdded)> ProcessFunctionCallsAsync( + List messages, ChatOptions options, List functionCallContents, + int iteration, int consecutiveErrorCount, bool isStreaming, CancellationToken cancellationToken) + { + // We must add a response for every tool call, regardless of whether we successfully executed it or not. + // If we successfully execute it, we'll add the result. If we don't, we'll add an error. + + Debug.Assert(functionCallContents.Count > 0, "Expected at least one function call."); + var shouldTerminate = false; + + var captureCurrentIterationExceptions = consecutiveErrorCount < _maximumConsecutiveErrorsPerRequest; + + // Process all functions. If there's more than one and concurrent invocation is enabled, do so in parallel. + if (functionCallContents.Count == 1) + { + FunctionInvocationResult result = await ProcessFunctionCallAsync( + messages, options, functionCallContents, iteration, 0, captureCurrentIterationExceptions, isStreaming, cancellationToken).ConfigureAwait(false); + + IList added = CreateResponseMessages([result]); + ThrowIfNoFunctionResultsAdded(added); + UpdateConsecutiveErrorCountOrThrow(added, ref consecutiveErrorCount); + + messages.AddRange(added); + return (result.ShouldTerminate, consecutiveErrorCount, added); + } + else + { + List results = []; + + var terminationRequested = false; + if (AllowConcurrentInvocation) + { + // Rather than awaiting each function before invoking the next, invoke all of them + // and then await all of them. We avoid forcibly introducing parallelism via Task.Run, + // but if a function invocation completes asynchronously, its processing can overlap + // with the processing of other the other invocation invocations. + results.AddRange(await Task.WhenAll( + from i in Enumerable.Range(0, functionCallContents.Count) + select ProcessFunctionCallAsync( + messages, options, functionCallContents, + iteration, i, captureExceptions: true, isStreaming, cancellationToken)).ConfigureAwait(false)); + + terminationRequested = results.Any(r => r.ShouldTerminate); + } + else + { + // Invoke each function serially. + for (int i = 0; i < functionCallContents.Count; i++) + { + var result = await ProcessFunctionCallAsync( + messages, options, functionCallContents, + iteration, i, captureCurrentIterationExceptions, isStreaming, cancellationToken).ConfigureAwait(false); + + results.Add(result); + + if (result.ShouldTerminate) + { + shouldTerminate = true; + terminationRequested = true; + break; + } + } + } + + IList added = CreateResponseMessages(results); + ThrowIfNoFunctionResultsAdded(added); + UpdateConsecutiveErrorCountOrThrow(added, ref consecutiveErrorCount); + + messages.AddRange(added); + + if (!terminationRequested) + { + // If any function requested termination, we'll terminate. + shouldTerminate = false; + foreach (FunctionInvocationResult fir in results) + { + shouldTerminate = shouldTerminate || fir.ShouldTerminate; + } + } + + return (shouldTerminate, consecutiveErrorCount, added); + } + } + + private void UpdateConsecutiveErrorCountOrThrow(IList added, ref int consecutiveErrorCount) + { + var allExceptions = added.SelectMany(m => m.Contents.OfType()) + .Select(frc => frc.Exception!) + .Where(e => e is not null); + +#pragma warning disable CA1851 // Possible multiple enumerations of 'IEnumerable' collection + if (allExceptions.Any()) + { + consecutiveErrorCount++; + if (consecutiveErrorCount > _maximumConsecutiveErrorsPerRequest) + { + var allExceptionsArray = allExceptions.ToArray(); + if (allExceptionsArray.Length == 1) + { + ExceptionDispatchInfo.Capture(allExceptionsArray[0]).Throw(); + } + else + { + throw new AggregateException(allExceptionsArray); + } + } + } + else + { + consecutiveErrorCount = 0; + } +#pragma warning restore CA1851 // Possible multiple enumerations of 'IEnumerable' collection + } + + /// + /// Throws an exception if doesn't create any messages. + /// + private void ThrowIfNoFunctionResultsAdded(IList? messages) + { + if (messages is null || messages.Count == 0) + { + throw new InvalidOperationException($"{this.GetType().Name}.{nameof(this.CreateResponseMessages)} returned null or an empty collection of messages."); + } + } + + /// Processes the function call described in []. + /// The current chat contents, inclusive of the function call contents being processed. + /// The options used for the response being processed. + /// The function call contents representing all the functions being invoked. + /// The iteration number of how many roundtrips have been made to the inner client. + /// The 0-based index of the function being called out of . + /// If true, handles function-invocation exceptions by returning a value with . Otherwise, rethrows. + /// Whether the function calls are being processed in a streaming context. + /// The to monitor for cancellation requests. + /// A value indicating how the caller should proceed. + private async Task ProcessFunctionCallAsync( + List messages, ChatOptions options, List callContents, + int iteration, int functionCallIndex, bool captureExceptions, bool isStreaming, CancellationToken cancellationToken) + { + var callContent = callContents[functionCallIndex]; + + // Look up the AIFunction for the function call. If the requested function isn't available, send back an error. + AIFunction? function = options.Tools!.OfType().FirstOrDefault(t => t.Name == callContent.Name); + if (function is null) + { + return new(shouldTerminate: false, FunctionInvokingChatClient.FunctionInvocationStatus.NotFound, callContent, result: null, exception: null); + } + + if (callContent.Arguments is not null) + { + callContent.Arguments = new KernelArguments(callContent.Arguments); + } + + var context = new AutoFunctionInvocationContext(new() + { + Function = function, + Arguments = new(callContent.Arguments) { Services = _functionInvocationServices }, + + Messages = messages, + Options = options, + + CallContent = callContent, + Iteration = iteration, + FunctionCallIndex = functionCallIndex, + FunctionCount = callContents.Count, + }) + { IsStreaming = isStreaming }; + + object? result; + try + { + result = await InvokeFunctionAsync(context, cancellationToken).ConfigureAwait(false); + } + catch (Exception e) when (!cancellationToken.IsCancellationRequested) + { + if (!captureExceptions) + { + throw; + } + + return new( + shouldTerminate: false, + FunctionInvokingChatClient.FunctionInvocationStatus.Exception, + callContent, + result: null, + exception: e); + } + + return new( + shouldTerminate: context.Terminate, + FunctionInvokingChatClient.FunctionInvocationStatus.RanToCompletion, + callContent, + result, + exception: null); + } + + /// Creates one or more response messages for function invocation results. + /// Information about the function call invocations and results. + /// A list of all chat messages created from . + private IList CreateResponseMessages(List results) + { + var contents = new List(results.Count); + for (int i = 0; i < results.Count; i++) + { + contents.Add(CreateFunctionResultContent(results[i])); + } + + return [new(ChatRole.Tool, contents)]; + + FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult result) + { + Verify.NotNull(result); + + object? functionResult; + if (result.Status == FunctionInvokingChatClient.FunctionInvocationStatus.RanToCompletion) + { + functionResult = result.Result ?? "Success: Function completed."; + } + else + { + string message = result.Status switch + { + FunctionInvokingChatClient.FunctionInvocationStatus.NotFound => $"Error: Requested function \"{result.CallContent.Name}\" not found.", + FunctionInvokingChatClient.FunctionInvocationStatus.Exception => "Error: Function failed.", + _ => "Error: Unknown error.", + }; + + if (IncludeDetailedErrors && result.Exception is not null) + { + message = $"{message} Exception: {result.Exception.Message}"; + } + + functionResult = message; + } + + return new FunctionResultContent(result.CallContent.CallId, functionResult) { Exception = result.Exception }; + } + } + + /// + /// Invokes the auto function invocation filters. + /// + /// The auto function invocation context. + /// The function to call after the filters. + /// The auto function invocation context. + private async Task OnAutoFunctionInvocationAsync( + AutoFunctionInvocationContext context, + Func functionCallCallback) + { + await this.InvokeFilterOrFunctionAsync(functionCallCallback, context).ConfigureAwait(false); + + return context; + } + + /// + /// This method will execute auto function invocation filters and function recursively. + /// If there are no registered filters, just function will be executed. + /// If there are registered filters, filter on position will be executed. + /// Second parameter of filter is callback. It can be either filter on + 1 position or function if there are no remaining filters to execute. + /// Function will always be executed as last step after all filters. + /// + private async Task InvokeFilterOrFunctionAsync( + Func functionCallCallback, + AutoFunctionInvocationContext context, + int index = 0) + { + IList autoFunctionInvocationFilters = context.Kernel.AutoFunctionInvocationFilters; + + if (autoFunctionInvocationFilters is { Count: > 0 } && index < autoFunctionInvocationFilters.Count) + { + await autoFunctionInvocationFilters[index].OnAutoFunctionInvocationAsync( + context, + (ctx) => this.InvokeFilterOrFunctionAsync(functionCallCallback, ctx, index + 1) + ).ConfigureAwait(false); + } + else + { + await functionCallCallback(context).ConfigureAwait(false); + } + } + + /// Invokes the function asynchronously. + /// + /// The function invocation context detailing the function to be invoked and its arguments along with additional request information. + /// + /// The to monitor for cancellation requests. The default is . + /// The result of the function invocation, or if the function invocation returned . + /// is . + private async Task InvokeFunctionAsync(AutoFunctionInvocationContext context, CancellationToken cancellationToken) + { + Verify.NotNull(context); + + using Activity? activity = _activitySource?.StartActivity(context.Function.Name); + + long startingTimestamp = 0; + if (_logger.IsEnabled(LogLevel.Debug)) + { + startingTimestamp = Stopwatch.GetTimestamp(); + if (_logger.IsEnabled(LogLevel.Trace)) + { + LogInvokingSensitive(context.Function.Name, LoggingAsJson(context.CallContent.Arguments, context.AIFunction.JsonSerializerOptions)); + } + else + { + LogInvoking(context.Function.Name); + } + } + + object? result = null; + try + { + CurrentContext = context; // doesn't need to be explicitly reset after, as that's handled automatically at async method exit + context = await this.OnAutoFunctionInvocationAsync( + context, + async (ctx) => + { + // Check if filter requested termination + if (ctx.Terminate) + { + return; + } + + // Note that we explicitly do not use executionSettings here; those pertain to the all-up operation and not necessarily to any + // further calls made as part of this function invocation. In particular, we must not use function calling settings naively here, + // as the called function could in turn telling the model about itself as a possible candidate for invocation. + result = await context.AIFunction.InvokeAsync(new(context.Arguments), cancellationToken).ConfigureAwait(false); + ctx.Result = new FunctionResult(ctx.Function, result); + }).ConfigureAwait(false); + result = context.Result.GetValue(); + } + catch (Exception e) + { + if (activity is not null) + { + _ = activity.SetTag("error.type", e.GetType().FullName) + .SetStatus(ActivityStatusCode.Error, e.Message); + } + + if (e is OperationCanceledException) + { + LogInvocationCanceled(context.Function.Name); + } + else + { + LogInvocationFailed(context.Function.Name, e); + } + + throw; + } + finally + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + TimeSpan elapsed = GetElapsedTime(startingTimestamp); + + if (result is not null && _logger.IsEnabled(LogLevel.Trace)) + { + LogInvocationCompletedSensitive(context.Function.Name, elapsed, LoggingAsJson(result, context.AIFunction.JsonSerializerOptions)); + } + else + { + LogInvocationCompleted(context.Function.Name, elapsed); + } + } + } + + return result; + } + + /// Serializes as JSON for logging purposes. + private static string LoggingAsJson(T value, JsonSerializerOptions? options) + { + if (options?.TryGetTypeInfo(typeof(T), out var typeInfo) is true || + AIJsonUtilities.DefaultOptions.TryGetTypeInfo(typeof(T), out typeInfo)) + { +#pragma warning disable CA1031 // Do not catch general exception types + try + { + return JsonSerializer.Serialize(value, typeInfo); + } + catch + { + } +#pragma warning restore CA1031 // Do not catch general exception types + } + + // If we're unable to get a type info for the value, or if we fail to serialize, + // return an empty JSON object. We do not want lack of type info to disrupt application behavior with exceptions. + return "{}"; + } + + private static TimeSpan GetElapsedTime(long startingTimestamp) => +#if NET + Stopwatch.GetElapsedTime(startingTimestamp); +#else + new((long)((Stopwatch.GetTimestamp() - startingTimestamp) * ((double)TimeSpan.TicksPerSecond / Stopwatch.Frequency))); +#endif + + [LoggerMessage(LogLevel.Debug, "Invoking {MethodName}.", SkipEnabledCheck = true)] + private partial void LogInvoking(string methodName); + + [LoggerMessage(LogLevel.Trace, "Invoking {MethodName}({Arguments}).", SkipEnabledCheck = true)] + private partial void LogInvokingSensitive(string methodName, string arguments); + + [LoggerMessage(LogLevel.Debug, "{MethodName} invocation completed. Duration: {Duration}", SkipEnabledCheck = true)] + private partial void LogInvocationCompleted(string methodName, TimeSpan duration); + + [LoggerMessage(LogLevel.Trace, "{MethodName} invocation completed. Duration: {Duration}. Result: {Result}", SkipEnabledCheck = true)] + private partial void LogInvocationCompletedSensitive(string methodName, TimeSpan duration, string result); + + [LoggerMessage(LogLevel.Debug, "{MethodName} invocation canceled.")] + private partial void LogInvocationCanceled(string methodName); + + [LoggerMessage(LogLevel.Error, "{MethodName} invocation failed.")] + private partial void LogInvocationFailed(string methodName, Exception error); + + /// Provides information about the invocation of a function call. + public sealed class FunctionInvocationResult + { + internal FunctionInvocationResult(bool shouldTerminate, FunctionInvokingChatClient.FunctionInvocationStatus status, FunctionCallContent callContent, object? result, Exception? exception) + { + ShouldTerminate = shouldTerminate; + Status = status; + CallContent = callContent; + Result = result; + Exception = exception; + } + + /// Gets status about how the function invocation completed. + public FunctionInvokingChatClient.FunctionInvocationStatus Status { get; } + + /// Gets the function call content information associated with this invocation. + public FunctionCallContent CallContent { get; } + + /// Gets the result of the function call. + public object? Result { get; } + + /// Gets any exception the function call threw. + public Exception? Exception { get; } + + /// Gets a value indicating whether the caller should terminate the processing loop. + internal bool ShouldTerminate { get; } + } +} From 3df56aaa7ab739987d3f7a7e2154762636026f37 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Sat, 19 Apr 2025 18:18:11 +0100 Subject: [PATCH 02/24] Adding FunctionInvocationContextV2 and making AutoFunctionInvocation a specialization of it --- .../FunctionInvocationContextV2.Extra.cs | 125 ++++++++++++++++++ .../ChatClient/FunctionInvocationContextV2.cs | 101 ++++++++++++++ .../FunctionInvokingChatClientV2.cs | 57 +++++--- .../KernelFunctionInvokingChatClient.cs | 62 +++------ .../KernelFunctionInvokingChatClientOld.cs | 2 +- .../AutoFunctionInvocationContext.cs | 95 ++++--------- 6 files changed, 311 insertions(+), 131 deletions(-) create mode 100644 dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvocationContextV2.Extra.cs create mode 100644 dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvocationContextV2.cs diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvocationContextV2.Extra.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvocationContextV2.Extra.cs new file mode 100644 index 000000000000..d1a20e00c348 --- /dev/null +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvocationContextV2.Extra.cs @@ -0,0 +1,125 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using Microsoft.Shared.Collections; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides context for an in-flight function invocation. +public partial class FunctionInvocationContextV2 +{ + private static class Throw + { + /// + /// Throws an if the specified argument is . + /// + /// Argument type to be checked for . + /// Object to be checked for . + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [return: NotNull] + public static T IfNull([NotNull] T argument, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument is null) + { + throw new ArgumentNullException(paramName); + } + + return argument; + } + + /// + /// Throws an . + /// + /// A message that describes the error. +#if !NET6_0_OR_GREATER + [MethodImpl(MethodImplOptions.NoInlining)] +#endif + [DoesNotReturn] + public static void InvalidOperationException(string message) + => throw new InvalidOperationException(message); + + /// + /// Throws an . + /// + /// The name of the parameter that caused the exception. +#if !NET6_0_OR_GREATER + [MethodImpl(MethodImplOptions.NoInlining)] +#endif + [DoesNotReturn] + public static void ArgumentOutOfRangeException(string paramName) + => throw new ArgumentOutOfRangeException(paramName); + + /// + /// Throws an . + /// + /// The name of the parameter that caused the exception. + /// A message that describes the error. +#if !NET6_0_OR_GREATER + [MethodImpl(MethodImplOptions.NoInlining)] +#endif + [DoesNotReturn] + public static void ArgumentOutOfRangeException(string paramName, string? message) + => throw new ArgumentOutOfRangeException(paramName, message); + + /// + /// Throws an . + /// + /// The name of the parameter that caused the exception. + /// The value of the argument that caused this exception. + /// A message that describes the error. +#if !NET6_0_OR_GREATER + [MethodImpl(MethodImplOptions.NoInlining)] +#endif + [DoesNotReturn] + public static void ArgumentOutOfRangeException(string paramName, object? actualValue, string? message) + => throw new ArgumentOutOfRangeException(paramName, actualValue, message); + + /// + /// Throws an if the specified number is less than min. + /// + /// Number to be expected being less than min. + /// The number that must be less than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int IfLessThan(int argument, int min, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument < min) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument less than minimum value {min}"); + } + + return argument; + } + } + + private static class LoggingHelpers + { + /// Serializes as JSON for logging purposes. + public static string AsJson(T value, System.Text.Json.JsonSerializerOptions? options) + { + if (options?.TryGetTypeInfo(typeof(T), out var typeInfo) is true || + AIJsonUtilities.DefaultOptions.TryGetTypeInfo(typeof(T), out typeInfo)) + { + try + { + return System.Text.Json.JsonSerializer.Serialize(value, typeInfo); + } +#pragma warning disable CA1031 // Do not catch general exception types + catch +#pragma warning restore CA1031 // Do not catch general exception types + { + } + } + + return "{}"; + } + } +} diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvocationContextV2.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvocationContextV2.cs new file mode 100644 index 000000000000..d096656fba56 --- /dev/null +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvocationContextV2.cs @@ -0,0 +1,101 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides context for an in-flight function invocation. +public partial class FunctionInvocationContextV2 +{ + /// + /// A nop function used to allow to be non-nullable. Default instances of + /// start with this as the target function. + /// + private static readonly AIFunction _nopFunction = AIFunctionFactory.Create(() => { }, nameof(FunctionInvocationContext)); + + /// The chat contents associated with the operation that initiated this function call request. + private IList _messages = Array.Empty(); + + /// The AI function to be invoked. + private AIFunction _function = _nopFunction; + + /// The function call content information associated with this invocation. + private FunctionCallContent? _callContent; + + /// The arguments used with the function. + private AIFunctionArguments? _arguments; + + /// Initializes a new instance of the class. + public FunctionInvocationContextV2() + { + } + + /// Gets or sets the AI function to be invoked. + public AIFunction Function + { + get => _function; + set => _function = Throw.IfNull(value); + } + + /// Gets or sets the arguments associated with this invocation. + public AIFunctionArguments Arguments + { + get => _arguments ??= []; + set => _arguments = Throw.IfNull(value); + } + + /// Gets or sets the function call content information associated with this invocation. + public FunctionCallContent CallContent + { + get => _callContent ??= new(string.Empty, _nopFunction.Name, EmptyReadOnlyDictionary.Instance); + set => _callContent = Throw.IfNull(value); + } + + /// Gets or sets the chat contents associated with the operation that initiated this function call request. + public IList Messages + { + get => _messages; + set => _messages = Throw.IfNull(value); + } + + /// Gets or sets the chat options associated with the operation that initiated this function call request. + public ChatOptions? Options { get; set; } + + /// Gets or sets the number of this iteration with the underlying client. + /// + /// The initial request to the client that passes along the chat contents provided to the + /// is iteration 1. If the client responds with a function call request, the next request to the client is iteration 2, and so on. + /// + public int Iteration { get; set; } + + /// Gets or sets the index of the function call within the iteration. + /// + /// The response from the underlying client may include multiple function call requests. + /// This index indicates the position of the function call within the iteration. + /// + public int FunctionCallIndex { get; set; } + + /// Gets or sets the total number of function call requests within the iteration. + /// + /// The response from the underlying client might include multiple function call requests. + /// This count indicates how many there were. + /// + public int FunctionCount { get; set; } + + /// Gets or sets a value indicating whether to terminate the request. + /// + /// In response to a function call request, the function might be invoked, its result added to the chat contents, + /// and a new request issued to the wrapped client. If this property is set to , that subsequent request + /// will not be issued and instead the loop immediately terminated rather than continuing until there are no + /// more function call requests in responses. + /// + public bool Terminate { get; set; } + + /// + /// Gets or sets a value indicating whether the context is happening in a streaming scenario. + /// + public bool IsStreaming { get; set; } +} diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.cs index f2f3d9f12a62..a5ceb8254089 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.cs @@ -47,18 +47,18 @@ namespace Microsoft.Extensions.AI; /// public partial class FunctionInvokingChatClientV2 : DelegatingChatClient { - /// The for the current function invocation. - private static readonly AsyncLocal _currentContext = new(); + /// The for the current function invocation. + private static readonly AsyncLocal _currentContext = new(); /// Optional services used for function invocation. private readonly IServiceProvider? _functionInvocationServices; /// The logger to use for logging information about function invocation. - protected readonly ILogger _logger; + private readonly ILogger _logger; /// The to use for telemetry. /// This component does not own the instance and should not dispose it. - protected readonly ActivitySource? _activitySource; + private readonly ActivitySource? _activitySource; /// Maximum number of roundtrips allowed to the inner client. private int _maximumIterationsPerRequest = 10; @@ -81,12 +81,12 @@ public FunctionInvokingChatClientV2(IChatClient innerClient, ILoggerFactory? log } /// - /// Gets or sets the for the current function invocation. + /// Gets or sets the for the current function invocation. /// /// /// This value flows across async calls. /// - public static FunctionInvocationContext? CurrentContext + public static FunctionInvocationContextV2? CurrentContext { get => _currentContext.Value; protected set => _currentContext.Value = value; @@ -205,6 +205,12 @@ public int MaximumConsecutiveErrorsPerRequest set => _maximumConsecutiveErrorsPerRequest = Throw.IfLessThan(value, 0); } + /// Optional services used for function invocation. + protected IServiceProvider? FunctionInvocationServices + { + get => _functionInvocationServices; + } + /// public override async Task GetResponseAsync( IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) @@ -279,7 +285,7 @@ public override async Task GetResponseAsync( // Add the responses from the function calls into the augmented history and also into the tracked // list of response messages. - var modeAndMessages = await ProcessFunctionCallsAsync(response, augmentedHistory, options!, functionCallContents!, iteration, consecutiveErrorCount, isStreaming: false, _functionInvocationServices, cancellationToken); + var modeAndMessages = await ProcessFunctionCallsAsync(response, augmentedHistory, options!, functionCallContents!, iteration, consecutiveErrorCount, isStreaming: false, cancellationToken); responseMessages.AddRange(modeAndMessages.MessagesAdded); consecutiveErrorCount = modeAndMessages.NewConsecutiveErrorCount; @@ -356,7 +362,7 @@ public override async IAsyncEnumerable GetStreamingResponseA FixupHistories(originalMessages, ref messages, ref augmentedHistory, response, responseMessages, ref lastIterationHadThreadId); // Process all of the functions, adding their results into the history. - var modeAndMessages = await ProcessFunctionCallsAsync(response, augmentedHistory, options, functionCallContents, iteration, consecutiveErrorCount, isStreaming: true, _functionInvocationServices, cancellationToken); + var modeAndMessages = await ProcessFunctionCallsAsync(response, augmentedHistory, options, functionCallContents, iteration, consecutiveErrorCount, isStreaming: true, cancellationToken); responseMessages.AddRange(modeAndMessages.MessagesAdded); consecutiveErrorCount = modeAndMessages.NewConsecutiveErrorCount; @@ -520,11 +526,10 @@ private static void UpdateOptionsForNextIteration(ref ChatOptions options, strin /// The iteration number of how many roundtrips have been made to the inner client. /// The number of consecutive iterations, prior to this one, that were recorded as having function invocation errors. /// Whether the function calls are being processed in a streaming context. - /// The services to use for function invocation. /// The to monitor for cancellation requests. /// A value indicating how the caller should proceed. protected virtual async Task<(bool ShouldTerminate, int NewConsecutiveErrorCount, IList MessagesAdded)> ProcessFunctionCallsAsync( - ChatResponse response, List messages, ChatOptions options, List functionCallContents, int iteration, int consecutiveErrorCount, bool isStreaming, IServiceProvider functionInvocationServices, CancellationToken cancellationToken) + ChatResponse response, List messages, ChatOptions options, List functionCallContents, int iteration, int consecutiveErrorCount, bool isStreaming, CancellationToken cancellationToken) { // We must add a response for every tool call, regardless of whether we successfully executed it or not. // If we successfully execute it, we'll add the result. If we don't, we'll add an error. @@ -537,7 +542,7 @@ private static void UpdateOptionsForNextIteration(ref ChatOptions options, strin if (functionCallContents.Count == 1) { FunctionInvocationResult result = await ProcessFunctionCallAsync( - messages, options, functionCallContents, iteration, 0, captureCurrentIterationExceptions, isStreaming, functionInvocationServices, cancellationToken); + messages, options, functionCallContents, iteration, 0, captureCurrentIterationExceptions, isStreaming, cancellationToken); IList added = CreateResponseMessages([result]); ThrowIfNoFunctionResultsAdded(added); @@ -560,7 +565,7 @@ private static void UpdateOptionsForNextIteration(ref ChatOptions options, strin from i in Enumerable.Range(0, functionCallContents.Count) select ProcessFunctionCallAsync( messages, options, functionCallContents, - iteration, i, captureExceptions: true, isStreaming, functionInvocationServices, cancellationToken)); + iteration, i, captureExceptions: true, isStreaming, cancellationToken)); } else { @@ -570,7 +575,7 @@ select ProcessFunctionCallAsync( { results[i] = await ProcessFunctionCallAsync( messages, options, functionCallContents, - iteration, i, captureCurrentIterationExceptions, isStreaming, functionInvocationServices, cancellationToken); + iteration, i, captureCurrentIterationExceptions, isStreaming, cancellationToken); } } @@ -590,6 +595,13 @@ select ProcessFunctionCallAsync( } } +#pragma warning disable CA1851 // Possible multiple enumerations of 'IEnumerable' collection + /// + /// TODO: Documentation + /// + /// + /// + /// protected void UpdateConsecutiveErrorCountOrThrow(IList added, ref int consecutiveErrorCount) { var allExceptions = added.SelectMany(m => m.Contents.OfType()) @@ -617,6 +629,7 @@ protected void UpdateConsecutiveErrorCountOrThrow(IList added, ref consecutiveErrorCount = 0; } } +#pragma warning restore CA1851 /// /// Throws an exception if doesn't create any messages. @@ -637,12 +650,11 @@ protected void ThrowIfNoFunctionResultsAdded(IList? messages) /// The 0-based index of the function being called out of . /// If true, handles function-invocation exceptions by returning a value with . Otherwise, rethrows. /// Whether the function calls are being processed in a streaming context. - /// The services to use for function invocation. /// The to monitor for cancellation requests. /// A value indicating how the caller should proceed. protected virtual async Task ProcessFunctionCallAsync( List messages, ChatOptions options, List callContents, - int iteration, int functionCallIndex, bool captureExceptions, bool isStreaming, IServiceProvider functionInvocationServices, CancellationToken cancellationToken) + int iteration, int functionCallIndex, bool captureExceptions, bool isStreaming, CancellationToken cancellationToken) { var callContent = callContents[functionCallIndex]; @@ -653,10 +665,10 @@ protected virtual async Task ProcessFunctionCallAsync( return new(shouldTerminate: false, FunctionInvocationStatus.NotFound, callContent, result: null, exception: null); } - FunctionInvocationContext context = new() + FunctionInvocationContextV2 context = new() { Function = function, - Arguments = new(callContent.Arguments) { Services = functionInvocationServices }, + Arguments = new(callContent.Arguments) { Services = _functionInvocationServices }, Messages = messages, Options = options, @@ -665,6 +677,7 @@ protected virtual async Task ProcessFunctionCallAsync( Iteration = iteration, FunctionCallIndex = functionCallIndex, FunctionCount = callContents.Count, + IsStreaming = isStreaming }; object? result; @@ -746,7 +759,7 @@ FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult resul /// The to monitor for cancellation requests. The default is . /// The result of the function invocation, or if the function invocation returned . /// is . - protected virtual async Task InvokeFunctionAsync(FunctionInvocationContext context, CancellationToken cancellationToken) + protected virtual async Task InvokeFunctionAsync(FunctionInvocationContextV2 context, CancellationToken cancellationToken) { _ = Throw.IfNull(context); @@ -811,7 +824,13 @@ FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult resul return result; } - protected virtual async Task<(FunctionInvocationContext Context, object? Result)> TryInvokeFunctionAsync(FunctionInvocationContext context, CancellationToken cancellationToken) + /// + /// This method will execute auto function invocation filters and function recursively within the try block + /// + /// The function invocation context. + /// Cancellation token. + /// The function invocation context and result. + protected virtual async Task<(FunctionInvocationContextV2 context, object? result)> TryInvokeFunctionAsync(FunctionInvocationContextV2 context, CancellationToken cancellationToken) { var result = await context.Function.InvokeAsync(context.Arguments, cancellationToken); diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs index fc4968a390ed..7098315fcd03 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs @@ -11,42 +11,15 @@ using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.ChatCompletion; -#pragma warning disable IDE1006 // Naming Styles -#pragma warning disable IDE0009 // This -#pragma warning disable CA2213 // Disposable fields should be disposed -#pragma warning disable EA0002 // Use 'System.TimeProvider' to make the code easier to test -#pragma warning disable SA1202 // 'protected' members should come before 'private' members - // Modified source from 2025-04-07 // https://raw.githubusercontent.com/dotnet/extensions/84d09b794d994435568adcbb85a981143d4f15cb/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs namespace Microsoft.Extensions.AI; /// -/// A delegating chat client that invokes functions defined on . -/// Include this in a chat pipeline to resolve function calls automatically. +/// Specialization of that uses and supports . /// -/// -/// -/// When this client receives a in a chat response, it responds -/// by calling the corresponding defined in , -/// producing a that it sends back to the inner client. This loop -/// is repeated until there are no more function calls to make, or until another stop condition is met, -/// such as hitting . -/// -/// -/// The provided implementation of is thread-safe for concurrent use so long as the -/// instances employed as part of the supplied are also safe. -/// The property can be used to control whether multiple function invocation -/// requests as part of the same request are invocable concurrently, but even with that set to -/// (the default), multiple concurrent requests to this same instance and using the same tools could result in those -/// tools being used concurrently (one per request). For example, a function that accesses the HttpContext of a specific -/// ASP.NET web request should only be used as part of a single at a time, and only with -/// set to , in case the inner client decided to issue multiple -/// invocation requests to that same function. -/// -/// -public partial class KernelFunctionInvokingChatClient : FunctionInvokingChatClientV2 +internal sealed class KernelFunctionInvokingChatClient : FunctionInvokingChatClientV2 { /// public KernelFunctionInvokingChatClient(IChatClient innerClient, ILoggerFactory? loggerFactory = null, IServiceProvider? functionInvocationServices = null) @@ -87,7 +60,7 @@ private static void ClearOptionsForAutoFunctionInvocation(ref ChatOptions option /// protected override async Task<(bool ShouldTerminate, int NewConsecutiveErrorCount, IList MessagesAdded)> ProcessFunctionCallsAsync( - ChatResponse response, List messages, ChatOptions options, List functionCallContents, int iteration, int consecutiveErrorCount, bool isStreaming, IServiceProvider functionInvocationServices, CancellationToken cancellationToken) + ChatResponse response, List messages, ChatOptions options, List functionCallContents, int iteration, int consecutiveErrorCount, bool isStreaming, CancellationToken cancellationToken) { // Prepare the options for the next auto function invocation iteration. UpdateOptionsForAutoFunctionInvocation(ref options!, response.Messages.Last().ToChatMessageContent(), isStreaming: false); @@ -104,7 +77,7 @@ private static void ClearOptionsForAutoFunctionInvocation(ref ChatOptions option if (functionCallContents.Count == 1) { FunctionInvocationResult functionResult = await this.ProcessFunctionCallAsync( - messages, options, functionCallContents, iteration, 0, captureCurrentIterationExceptions, isStreaming, functionInvocationServices, cancellationToken).ConfigureAwait(false); + messages, options, functionCallContents, iteration, 0, captureCurrentIterationExceptions, isStreaming, cancellationToken).ConfigureAwait(false); IList added = this.CreateResponseMessages([functionResult]); this.ThrowIfNoFunctionResultsAdded(added); @@ -128,7 +101,7 @@ private static void ClearOptionsForAutoFunctionInvocation(ref ChatOptions option from i in Enumerable.Range(0, functionCallContents.Count) select this.ProcessFunctionCallAsync( messages, options, functionCallContents, - iteration, i, captureExceptions: true, isStreaming, functionInvocationServices, cancellationToken)).ConfigureAwait(false)); + iteration, i, captureExceptions: true, isStreaming, cancellationToken)).ConfigureAwait(false)); terminationRequested = results.Any(r => r.ShouldTerminate); } @@ -139,12 +112,12 @@ select this.ProcessFunctionCallAsync( { var functionResult = await this.ProcessFunctionCallAsync( messages, options, functionCallContents, - iteration, i, captureCurrentIterationExceptions, isStreaming, functionInvocationServices, cancellationToken).ConfigureAwait(false); + iteration, i, captureCurrentIterationExceptions, isStreaming, cancellationToken).ConfigureAwait(false); results.Add(functionResult); // Differently from the original FunctionInvokingChatClient, as soon the termination is requested, - // we stop and don't continue + // we stop and don't continue, if that can be parametrized, this override won't be needed if (functionResult.ShouldTerminate) { shouldTerminate = true; @@ -182,7 +155,7 @@ select this.ProcessFunctionCallAsync( /// protected override async Task ProcessFunctionCallAsync( List messages, ChatOptions options, List callContents, - int iteration, int functionCallIndex, bool captureExceptions, bool isStreaming, IServiceProvider functionInvocationServices, CancellationToken cancellationToken) + int iteration, int functionCallIndex, bool captureExceptions, bool isStreaming, CancellationToken cancellationToken) { var callContent = callContents[functionCallIndex]; @@ -201,7 +174,7 @@ protected override async Task ProcessFunctionCallAsync var context = new AutoFunctionInvocationContext(new() { Function = function, - Arguments = new(callContent.Arguments) { Services = functionInvocationServices }, + Arguments = new(callContent.Arguments) { Services = this.FunctionInvocationServices }, Messages = messages, Options = options, @@ -216,7 +189,7 @@ protected override async Task ProcessFunctionCallAsync object? result; try { - result = await InvokeFunctionAsync(context, cancellationToken).ConfigureAwait(false); + result = await this.InvokeFunctionAsync(context, cancellationToken).ConfigureAwait(false); } catch (Exception e) when (!cancellationToken.IsCancellationRequested) { @@ -227,7 +200,7 @@ protected override async Task ProcessFunctionCallAsync return new( shouldTerminate: false, - FunctionInvokingChatClient.FunctionInvocationStatus.Exception, + FunctionInvocationStatus.Exception, callContent, result: null, exception: e); @@ -235,7 +208,7 @@ protected override async Task ProcessFunctionCallAsync return new( shouldTerminate: context.Terminate, - FunctionInvokingChatClient.FunctionInvocationStatus.RanToCompletion, + FunctionInvocationStatus.RanToCompletion, callContent, result, exception: null); @@ -259,7 +232,7 @@ FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult resul Verify.NotNull(result); object? functionResult; - if (result.Status == FunctionInvokingChatClient.FunctionInvocationStatus.RanToCompletion) + if (result.Status == FunctionInvocationStatus.RanToCompletion) { functionResult = result.Result ?? "Success: Function completed."; } @@ -267,12 +240,12 @@ FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult resul { string message = result.Status switch { - FunctionInvokingChatClient.FunctionInvocationStatus.NotFound => $"Error: Requested function \"{result.CallContent.Name}\" not found.", - FunctionInvokingChatClient.FunctionInvocationStatus.Exception => "Error: Function failed.", + FunctionInvocationStatus.NotFound => $"Error: Requested function \"{result.CallContent.Name}\" not found.", + FunctionInvocationStatus.Exception => "Error: Function failed.", _ => "Error: Unknown error.", }; - if (IncludeDetailedErrors && result.Exception is not null) + if (this.IncludeDetailedErrors && result.Exception is not null) { message = $"{message} Exception: {result.Exception.Message}"; } @@ -326,7 +299,8 @@ await autoFunctionInvocationFilters[index].OnAutoFunctionInvocationAsync( } } - protected override async Task<(FunctionInvocationContext context, object? result)> TryInvokeFunctionAsync(FunctionInvocationContext context, CancellationToken cancellationToken) + /// + protected override async Task<(FunctionInvocationContextV2 context, object? result)> TryInvokeFunctionAsync(FunctionInvocationContextV2 context, CancellationToken cancellationToken) { object? result = null; if (context is AutoFunctionInvocationContext autoContext) diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClientOld.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClientOld.cs index 1afb2689983b..20c2b6e39aeb 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClientOld.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClientOld.cs @@ -51,7 +51,7 @@ namespace Microsoft.Extensions.AI; /// invocation requests to that same function. /// /// -public partial class KernelFunctionInvokingChatClientOld : DelegatingChatClient +internal partial class KernelFunctionInvokingChatClientOld : DelegatingChatClient { /// The for the current function invocation. private static readonly AsyncLocal _currentContext = new(); diff --git a/dotnet/src/SemanticKernel.Abstractions/Filters/AutoFunctionInvocation/AutoFunctionInvocationContext.cs b/dotnet/src/SemanticKernel.Abstractions/Filters/AutoFunctionInvocation/AutoFunctionInvocationContext.cs index bc8dd0c3490c..d4e66f48cfc3 100644 --- a/dotnet/src/SemanticKernel.Abstractions/Filters/AutoFunctionInvocation/AutoFunctionInvocationContext.cs +++ b/dotnet/src/SemanticKernel.Abstractions/Filters/AutoFunctionInvocation/AutoFunctionInvocationContext.cs @@ -13,16 +13,15 @@ namespace Microsoft.SemanticKernel; /// /// Class with data related to automatic function invocation. /// -public class AutoFunctionInvocationContext +public class AutoFunctionInvocationContext : FunctionInvocationContextV2 { private ChatHistory? _chatHistory; private KernelFunction? _kernelFunction; - private readonly Microsoft.Extensions.AI.FunctionInvocationContext _invocationContext = new(); /// /// Initializes a new instance of the class from an existing . /// - internal AutoFunctionInvocationContext(Microsoft.Extensions.AI.FunctionInvocationContext invocationContext) + internal AutoFunctionInvocationContext(Microsoft.Extensions.AI.FunctionInvocationContextV2 invocationContext) { Verify.NotNull(invocationContext); Verify.NotNull(invocationContext.Options); @@ -38,7 +37,6 @@ internal AutoFunctionInvocationContext(Microsoft.Extensions.AI.FunctionInvocatio invocationContext.Options.AdditionalProperties.TryGetValue(ChatOptionsExtensions.PromptExecutionSettingsKey, out var executionSettings); this.ExecutionSettings = executionSettings; - this._invocationContext = invocationContext; this.Result = new FunctionResult(this.Function) { Culture = kernel.Culture }; } @@ -64,7 +62,7 @@ public AutoFunctionInvocationContext( Verify.NotNull(chatHistory); Verify.NotNull(chatMessageContent); - this._invocationContext.Options = new() + this.Options = new() { AdditionalProperties = new() { @@ -75,9 +73,9 @@ public AutoFunctionInvocationContext( this._kernelFunction = function; this._chatHistory = chatHistory; - this._invocationContext.Messages = chatHistory.ToChatMessageList(); - chatHistory.SetChatMessageHandlers(this._invocationContext.Messages); - this._invocationContext.Function = function.AsAIFunction(); + this.Messages = chatHistory.ToChatMessageList(); + chatHistory.SetChatMessageHandlers(this.Messages); + base.Function = function.AsAIFunction(); this.Result = result; } @@ -87,18 +85,13 @@ public AutoFunctionInvocationContext( /// public CancellationToken CancellationToken { get; init; } - /// - /// Boolean flag which indicates whether a filter is invoked within streaming or non-streaming mode. - /// - public bool IsStreaming { get; init; } - /// /// Gets the arguments associated with the operation. /// - public KernelArguments? Arguments + public new KernelArguments? Arguments { - get => this._invocationContext.CallContent.Arguments is KernelArguments kernelArguments ? kernelArguments : null; - init => this._invocationContext.CallContent.Arguments = value; + get => this.CallContent.Arguments is KernelArguments kernelArguments ? kernelArguments : null; + init => this.CallContent.Arguments = value; } /// @@ -106,8 +99,8 @@ public KernelArguments? Arguments /// public int RequestSequenceIndex { - get => this._invocationContext.Iteration; - init => this._invocationContext.Iteration = value; + get => this.Iteration; + init => this.Iteration = value; } /// @@ -115,19 +108,8 @@ public int RequestSequenceIndex /// public int FunctionSequenceIndex { - get => this._invocationContext.FunctionCallIndex; - init => this._invocationContext.FunctionCallIndex = value; - } - - /// Gets or sets the total number of function call requests within the iteration. - /// - /// The response from the underlying client might include multiple function call requests. - /// This count indicates how many there were. - /// - public int FunctionCount - { - get => this._invocationContext.FunctionCount; - init => this._invocationContext.FunctionCount = value; + get => this.FunctionCallIndex; + init => this.FunctionCallIndex = value; } /// @@ -135,13 +117,13 @@ public int FunctionCount /// public string? ToolCallId { - get => this._invocationContext.CallContent.CallId; + get => this.CallContent.CallId; init { - this._invocationContext.CallContent = new Microsoft.Extensions.AI.FunctionCallContent( + this.CallContent = new Microsoft.Extensions.AI.FunctionCallContent( callId: value ?? string.Empty, - name: this._invocationContext.CallContent.Name, - arguments: this._invocationContext.CallContent.Arguments); + name: this.CallContent.Name, + arguments: this.CallContent.Arguments); } } @@ -149,40 +131,40 @@ public string? ToolCallId /// The chat message content associated with automatic function invocation. /// public ChatMessageContent ChatMessageContent - => (this._invocationContext.Options?.AdditionalProperties?[ChatOptionsExtensions.ChatMessageContentKey] as ChatMessageContent)!; + => (this.Options?.AdditionalProperties?[ChatOptionsExtensions.ChatMessageContentKey] as ChatMessageContent)!; /// /// The execution settings associated with the operation. /// public PromptExecutionSettings? ExecutionSettings { - get => this._invocationContext.Options?.AdditionalProperties?[ChatOptionsExtensions.PromptExecutionSettingsKey] as PromptExecutionSettings; + get => this.Options?.AdditionalProperties?[ChatOptionsExtensions.PromptExecutionSettingsKey] as PromptExecutionSettings; init { - this._invocationContext.Options ??= new(); - this._invocationContext.Options.AdditionalProperties ??= []; - this._invocationContext.Options.AdditionalProperties[ChatOptionsExtensions.PromptExecutionSettingsKey] = value; + this.Options ??= new(); + this.Options.AdditionalProperties ??= []; + this.Options.AdditionalProperties[ChatOptionsExtensions.PromptExecutionSettingsKey] = value; } } /// /// Gets the associated with automatic function invocation. /// - public ChatHistory ChatHistory => this._chatHistory ??= new ChatMessageHistory(this._invocationContext.Messages); + public ChatHistory ChatHistory => this._chatHistory ??= new ChatMessageHistory(this.Messages); /// /// Gets the with which this filter is associated. /// - public KernelFunction Function + public new KernelFunction Function { get { if (this._kernelFunction is null // If the schemas are different, // AIFunction reference potentially was modified and the kernel function should be regenerated. - || !IsSameSchema(this._kernelFunction, this._invocationContext.Function)) + || !IsSameSchema(this._kernelFunction, base.Function)) { - this._kernelFunction = this._invocationContext.Function.AsKernelFunction(); + this._kernelFunction = base.Function.AsKernelFunction(); } return this._kernelFunction; @@ -197,7 +179,7 @@ public Kernel Kernel get { Kernel? kernel = null; - this._invocationContext.Options?.AdditionalProperties?.TryGetValue(ChatOptionsExtensions.KernelKey, out kernel); + this.Options?.AdditionalProperties?.TryGetValue(ChatOptionsExtensions.KernelKey, out kernel); // To avoid exception from properties, when attempting to retrieve a kernel from a non-ready context, it will give a null. return kernel!; @@ -209,30 +191,9 @@ public Kernel Kernel /// public FunctionResult Result { get; set; } - /// Gets or sets a value indicating whether to terminate the request. - /// - /// In response to a function call request, the function might be invoked, its result added to the chat contents, - /// and a new request issued to the wrapped client. If this property is set to , that subsequent request - /// will not be issued and instead the loop immediately terminated rather than continuing until there are no - /// more function call requests in responses. - /// - public bool Terminate - { - get => this._invocationContext.Terminate; - set => this._invocationContext.Terminate = value; - } - - /// Gets or sets the function call content information associated with this invocation. - internal Microsoft.Extensions.AI.FunctionCallContent CallContent - { - get => this._invocationContext.CallContent; - set => this._invocationContext.CallContent = value; - } - internal AIFunction AIFunction { - get => this._invocationContext.Function; - set => this._invocationContext.Function = value; + get => base.Function; } private static bool IsSameSchema(KernelFunction kernelFunction, AIFunction aiFunction) From 48b50ee3a2b50643a5b1fdc560a6d4e54bd386ac Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Sat, 19 Apr 2025 19:35:41 +0100 Subject: [PATCH 03/24] Most Tests passing, missing identify reference issue --- .../FunctionInvokingChatClientV2.cs | 2 +- .../KernelFunctionInvokingChatClient.cs | 21 +- .../KernelFunctionInvokingChatClientOld.cs | 1023 ----------------- .../AutoFunctionInvocationContext.cs | 36 +- 4 files changed, 36 insertions(+), 1046 deletions(-) delete mode 100644 dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClientOld.cs diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.cs index a5ceb8254089..e155ad7a68d3 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.cs @@ -783,7 +783,7 @@ FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult resul try { CurrentContext = context; // doesn't need to be explicitly reset after, as that's handled automatically at async method exit - result = await TryInvokeFunctionAsync(context, cancellationToken); + (context, result) = await TryInvokeFunctionAsync(context, cancellationToken); } catch (Exception e) { diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs index 7098315fcd03..0415a6dc648b 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs @@ -166,25 +166,22 @@ protected override async Task ProcessFunctionCallAsync return new(shouldTerminate: false, FunctionInvocationStatus.NotFound, callContent, result: null, exception: null); } - if (callContent.Arguments is not null) - { - callContent.Arguments = new KernelArguments(callContent.Arguments); - } + //if (callContent.Arguments is not null) + //{ + // callContent.Arguments = new KernelArguments(callContent.Arguments); + //} - var context = new AutoFunctionInvocationContext(new() + var context = new AutoFunctionInvocationContext(options) { - Function = function, - Arguments = new(callContent.Arguments) { Services = this.FunctionInvocationServices }, - + AIFunction = function, + AIArguments = new AIFunctionArguments(callContent.Arguments) { Services = this.FunctionInvocationServices }, Messages = messages, - Options = options, - CallContent = callContent, Iteration = iteration, FunctionCallIndex = functionCallIndex, FunctionCount = callContents.Count, - }) - { IsStreaming = isStreaming }; + IsStreaming = isStreaming + }; object? result; try diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClientOld.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClientOld.cs deleted file mode 100644 index 20c2b6e39aeb..000000000000 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClientOld.cs +++ /dev/null @@ -1,1023 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -#pragma warning restore IDE0073 // The file header does not match the required text -using System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Runtime.CompilerServices; -using System.Runtime.ExceptionServices; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.ChatCompletion; - -#pragma warning disable IDE1006 // Naming Styles -#pragma warning disable IDE0009 // This -#pragma warning disable CA2213 // Disposable fields should be disposed -#pragma warning disable EA0002 // Use 'System.TimeProvider' to make the code easier to test -#pragma warning disable SA1202 // 'protected' members should come before 'private' members - -// Modified source from 2025-04-07 -// https://raw.githubusercontent.com/dotnet/extensions/84d09b794d994435568adcbb85a981143d4f15cb/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs - -namespace Microsoft.Extensions.AI; - -/// -/// A delegating chat client that invokes functions defined on . -/// Include this in a chat pipeline to resolve function calls automatically. -/// -/// -/// -/// When this client receives a in a chat response, it responds -/// by calling the corresponding defined in , -/// producing a that it sends back to the inner client. This loop -/// is repeated until there are no more function calls to make, or until another stop condition is met, -/// such as hitting . -/// -/// -/// The provided implementation of is thread-safe for concurrent use so long as the -/// instances employed as part of the supplied are also safe. -/// The property can be used to control whether multiple function invocation -/// requests as part of the same request are invocable concurrently, but even with that set to -/// (the default), multiple concurrent requests to this same instance and using the same tools could result in those -/// tools being used concurrently (one per request). For example, a function that accesses the HttpContext of a specific -/// ASP.NET web request should only be used as part of a single at a time, and only with -/// set to , in case the inner client decided to issue multiple -/// invocation requests to that same function. -/// -/// -internal partial class KernelFunctionInvokingChatClientOld : DelegatingChatClient -{ - /// The for the current function invocation. - private static readonly AsyncLocal _currentContext = new(); - - /// Optional services used for function invocation. - private readonly IServiceProvider? _functionInvocationServices; - - /// The logger to use for logging information about function invocation. - private readonly ILogger _logger; - - /// The to use for telemetry. - /// This component does not own the instance and should not dispose it. - private readonly ActivitySource? _activitySource; - - /// Maximum number of roundtrips allowed to the inner client. - private int _maximumIterationsPerRequest = 10; - - /// Maximum number of consecutive iterations that are allowed contain at least one exception result. If the limit is exceeded, we rethrow the exception instead of continuing. - private int _maximumConsecutiveErrorsPerRequest = 3; - - /// - /// Initializes a new instance of the class. - /// - /// The underlying , or the next instance in a chain of clients. - /// An to use for logging information about function invocation. - /// An optional to use for resolving services required by the instances being invoked. - public KernelFunctionInvokingChatClientOld(IChatClient innerClient, ILoggerFactory? loggerFactory = null, IServiceProvider? functionInvocationServices = null) - : base(innerClient) - { - _logger = (ILogger?)loggerFactory?.CreateLogger() ?? NullLogger.Instance; - _activitySource = innerClient.GetService(); - _functionInvocationServices = functionInvocationServices; - } - - /// - /// Gets or sets the for the current function invocation. - /// - /// - /// This value flows across async calls. - /// - public static AutoFunctionInvocationContext? CurrentContext - { - get => _currentContext.Value; - protected set => _currentContext.Value = value; - } - - /// - /// Gets or sets a value indicating whether detailed exception information should be included - /// in the chat history when calling the underlying . - /// - /// - /// if the full exception message is added to the chat history - /// when calling the underlying . - /// if a generic error message is included in the chat history. - /// The default value is . - /// - /// - /// - /// Setting the value to prevents the underlying language model from disclosing - /// raw exception details to the end user, since it doesn't receive that information. Even in this - /// case, the raw object is available to application code by inspecting - /// the property. - /// - /// - /// Setting the value to can help the underlying bypass problems on - /// its own, for example by retrying the function call with different arguments. However, it might - /// result in disclosing the raw exception information to external users, which can be a security - /// concern depending on the application scenario. - /// - /// - /// Changing the value of this property while the client is in use might result in inconsistencies - /// whether detailed errors are provided during an in-flight request. - /// - /// - public bool IncludeDetailedErrors { get; set; } - - /// - /// Gets or sets a value indicating whether to allow concurrent invocation of functions. - /// - /// - /// if multiple function calls can execute in parallel. - /// if function calls are processed serially. - /// The default value is . - /// - /// - /// An individual response from the inner client might contain multiple function call requests. - /// By default, such function calls are processed serially. Set to - /// to enable concurrent invocation such that multiple function calls can execute in parallel. - /// - public bool AllowConcurrentInvocation { get; set; } - - /// - /// Gets or sets the maximum number of iterations per request. - /// - /// - /// The maximum number of iterations per request. - /// The default value is 10. - /// - /// - /// - /// Each request to this might end up making - /// multiple requests to the inner client. Each time the inner client responds with - /// a function call request, this client might perform that invocation and send the results - /// back to the inner client in a new request. This property limits the number of times - /// such a roundtrip is performed. The value must be at least one, as it includes the initial request. - /// - /// - /// Changing the value of this property while the client is in use might result in inconsistencies - /// as to how many iterations are allowed for an in-flight request. - /// - /// - public int MaximumIterationsPerRequest - { - get => _maximumIterationsPerRequest; - set - { - if (value < 1) - { - throw new ArgumentOutOfRangeException(nameof(value)); - } - - _maximumIterationsPerRequest = value; - } - } - - /// - /// Gets or sets the maximum number of consecutive iterations that are allowed to fail with an error. - /// - /// - /// The maximum number of consecutive iterations that are allowed to fail with an error. - /// The default value is 3. - /// - /// - /// - /// When function invocations fail with an exception, the - /// continues to make requests to the inner client, optionally supplying exception information (as - /// controlled by ). This allows the to - /// recover from errors by trying other function parameters that may succeed. - /// - /// - /// However, in case function invocations continue to produce exceptions, this property can be used to - /// limit the number of consecutive failing attempts. When the limit is reached, the exception will be - /// rethrown to the caller. - /// - /// - /// If the value is set to zero, all function calling exceptions immediately terminate the function - /// invocation loop and the exception will be rethrown to the caller. - /// - /// - /// Changing the value of this property while the client is in use might result in inconsistencies - /// as to how many iterations are allowed for an in-flight request. - /// - /// - public int MaximumConsecutiveErrorsPerRequest - { - get => _maximumConsecutiveErrorsPerRequest; - set - { - if (value < 0) - { - throw new ArgumentOutOfRangeException(nameof(value), "Argument less than minimum value 0"); - } - _maximumConsecutiveErrorsPerRequest = value; - } - } - - /// - public override async Task GetResponseAsync( - IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - { - Verify.NotNull(messages); - - // A single request into this GetResponseAsync may result in multiple requests to the inner client. - // Create an activity to group them together for better observability. - using Activity? activity = _activitySource?.StartActivity(nameof(KernelFunctionInvokingChatClient)); - - // Copy the original messages in order to avoid enumerating the original messages multiple times. - // The IEnumerable can represent an arbitrary amount of work. - List originalMessages = [.. messages]; - messages = originalMessages; - - List? augmentedHistory = null; // the actual history of messages sent on turns other than the first - ChatResponse? response = null; // the response from the inner client, which is possibly modified and then eventually returned - List? responseMessages = null; // tracked list of messages, across multiple turns, to be used for the final response - UsageDetails? totalUsage = null; // tracked usage across all turns, to be used for the final response - List? functionCallContents = null; // function call contents that need responding to in the current turn - bool lastIterationHadThreadId = false; // whether the last iteration's response had a ChatThreadId set - int consecutiveErrorCount = 0; - - for (int iteration = 0; ; iteration++) - { - functionCallContents?.Clear(); - - // Make the call to the inner client. - response = await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); - if (response is null) - { - throw new InvalidOperationException($"The inner {nameof(IChatClient)} returned a null {nameof(ChatResponse)}."); - } - - // Any function call work to do? If yes, ensure we're tracking that work in functionCallContents. - bool requiresFunctionInvocation = - options?.Tools is { Count: > 0 } && - iteration < MaximumIterationsPerRequest && - CopyFunctionCalls(response.Messages, ref functionCallContents); - - // In a common case where we make a request and there's no function calling work required, - // fast path out by just returning the original response. - if (iteration == 0 && !requiresFunctionInvocation) - { - return response; - } - - // Track aggregate details from the response, including all the response messages and usage details. - (responseMessages ??= []).AddRange(response.Messages); - if (response.Usage is not null) - { - if (totalUsage is not null) - { - totalUsage.Add(response.Usage); - } - else - { - totalUsage = response.Usage; - } - } - - // If there are no tools to call, or for any other reason we should stop, we're done. - // Break out of the loop and allow the handling at the end to configure the response - // with aggregated data from previous requests. - if (!requiresFunctionInvocation) - { - break; - } - - // Prepare the history for the next iteration. - FixupHistories(originalMessages, ref messages, ref augmentedHistory, response, responseMessages, ref lastIterationHadThreadId); - - // Prepare the options for the next auto function invocation iteration. - UpdateOptionsForAutoFunctionInvocation(ref options!, response.Messages.Last().ToChatMessageContent(), isStreaming: false); - - // Add the responses from the function calls into the augmented history and also into the tracked - // list of response messages. - var modeAndMessages = await ProcessFunctionCallsAsync(augmentedHistory, options!, functionCallContents!, iteration, consecutiveErrorCount, isStreaming: false, cancellationToken).ConfigureAwait(false); - responseMessages.AddRange(modeAndMessages.MessagesAdded); - consecutiveErrorCount = modeAndMessages.NewConsecutiveErrorCount; - - if (modeAndMessages.ShouldTerminate) - { - break; - } - - // Clear the auto function invocation options. - ClearOptionsForAutoFunctionInvocation(ref options); - - UpdateOptionsForNextIteration(ref options!, response.ChatThreadId); - } - - Debug.Assert(responseMessages is not null, "Expected to only be here if we have response messages."); - response.Messages = responseMessages!; - response.Usage = totalUsage; - - return response; - } - - /// - public override async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - Verify.NotNull(messages); - - // A single request into this GetStreamingResponseAsync may result in multiple requests to the inner client. - // Create an activity to group them together for better observability. - using Activity? activity = _activitySource?.StartActivity(nameof(FunctionInvokingChatClient)); - - // Copy the original messages in order to avoid enumerating the original messages multiple times. - // The IEnumerable can represent an arbitrary amount of work. - List originalMessages = [.. messages]; - messages = originalMessages; - - List? augmentedHistory = null; // the actual history of messages sent on turns other than the first - List? functionCallContents = null; // function call contents that need responding to in the current turn - List? responseMessages = null; // tracked list of messages, across multiple turns, to be used in fallback cases to reconstitute history - bool lastIterationHadThreadId = false; // whether the last iteration's response had a ChatThreadId set - List updates = []; // updates from the current response - int consecutiveErrorCount = 0; - - for (int iteration = 0; ; iteration++) - { - updates.Clear(); - functionCallContents?.Clear(); - - await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) - { - if (update is null) - { - throw new InvalidOperationException($"The inner {nameof(IChatClient)} streamed a null {nameof(ChatResponseUpdate)}."); - } - - updates.Add(update); - - _ = CopyFunctionCalls(update.Contents, ref functionCallContents); - - yield return update; - 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 } || - options?.Tools is not { Count: > 0 } || - iteration >= _maximumIterationsPerRequest) - { - break; - } - - // Reconstitute a response from the response updates. - var response = updates.ToChatResponse(); - (responseMessages ??= []).AddRange(response.Messages); - - // Prepare the history for the next iteration. - FixupHistories(originalMessages, ref messages, ref augmentedHistory, response, responseMessages, ref lastIterationHadThreadId); - - // Prepare the options for the next auto function invocation iteration. - UpdateOptionsForAutoFunctionInvocation(ref options, response.Messages.Last().ToChatMessageContent(), isStreaming: true); - - // Process all the functions, adding their results into the history. - var modeAndMessages = await ProcessFunctionCallsAsync(augmentedHistory, options, functionCallContents, iteration, consecutiveErrorCount, isStreaming: true, cancellationToken).ConfigureAwait(false); - responseMessages.AddRange(modeAndMessages.MessagesAdded); - consecutiveErrorCount = modeAndMessages.NewConsecutiveErrorCount; - - // Clear the auto function invocation options. - ClearOptionsForAutoFunctionInvocation(ref options); - - // 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 - // include all activity, including generated function results. - foreach (var message in modeAndMessages.MessagesAdded) - { - var toolResultUpdate = new ChatResponseUpdate - { - AdditionalProperties = message.AdditionalProperties, - AuthorName = message.AuthorName, - ChatThreadId = response.ChatThreadId, - 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; - Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802 - } - - if (modeAndMessages.ShouldTerminate) - { - yield break; - } - - UpdateOptionsForNextIteration(ref options, response.ChatThreadId); - } - } - - /// Prepares the various chat message lists after a response from the inner client and before invoking functions. - /// The original messages provided by the caller. - /// The messages reference passed to the inner client. - /// The augmented history containing all the messages to be sent. - /// The most recent response being handled. - /// A list of all response messages received up until this point. - /// Whether the previous iteration's response had a thread id. - private static void FixupHistories( - IEnumerable originalMessages, - ref IEnumerable messages, - [NotNull] ref List? augmentedHistory, - ChatResponse response, - List allTurnsResponseMessages, - ref bool lastIterationHadThreadId) - { - // We're now going to need to augment the history with function result contents. - // That means we need a separate list to store the augmented history. - if (response.ChatThreadId is not null) - { - // The response indicates the inner client is tracking the history, so we don't want to send - // anything we've already sent or received. - if (augmentedHistory is not null) - { - augmentedHistory.Clear(); - } - else - { - augmentedHistory = []; - } - - lastIterationHadThreadId = true; - } - else if (lastIterationHadThreadId) - { - // In the very rare case where the inner client returned a response with a thread ID but then - // returned a subsequent response without one, we want to reconstitute the full history. To do that, - // we can populate the history with the original chat messages and then all the response - // messages up until this point, which includes the most recent ones. - augmentedHistory ??= []; - augmentedHistory.Clear(); - augmentedHistory.AddRange(originalMessages); - augmentedHistory.AddRange(allTurnsResponseMessages); - - lastIterationHadThreadId = false; - } - else - { - // If augmentedHistory is already non-null, then we've already populated it with everything up - // until this point (except for the most recent response). If it's null, we need to seed it with - // the chat history provided by the caller. - augmentedHistory ??= originalMessages.ToList(); - - // Now add the most recent response messages. - augmentedHistory.AddMessages(response); - - lastIterationHadThreadId = false; - } - - // Use the augmented history as the new set of messages to send. - messages = augmentedHistory; - } - - /// Copies any from to . - private static bool CopyFunctionCalls( - IList messages, [NotNullWhen(true)] ref List? functionCalls) - { - bool any = false; - int count = messages.Count; - for (int i = 0; i < count; i++) - { - any |= CopyFunctionCalls(messages[i].Contents, ref functionCalls); - } - - return any; - } - - /// Copies any from to . - private static bool CopyFunctionCalls( - IList content, [NotNullWhen(true)] ref List? functionCalls) - { - bool any = false; - int count = content.Count; - for (int i = 0; i < count; i++) - { - if (content[i] is FunctionCallContent functionCall) - { - (functionCalls ??= []).Add(functionCall); - any = true; - } - } - - return any; - } - - private static void UpdateOptionsForAutoFunctionInvocation(ref ChatOptions options, ChatMessageContent content, bool isStreaming) - { - if (options.AdditionalProperties?.ContainsKey(ChatOptionsExtensions.IsStreamingKey) ?? false) - { - throw new KernelException($"The reserved key name '{ChatOptionsExtensions.IsStreamingKey}' is already specified in the options. Avoid using this key name."); - } - - if (options.AdditionalProperties?.ContainsKey(ChatOptionsExtensions.ChatMessageContentKey) ?? false) - { - throw new KernelException($"The reserved key name '{ChatOptionsExtensions.ChatMessageContentKey}' is already specified in the options. Avoid using this key name."); - } - - options.AdditionalProperties ??= []; - - options.AdditionalProperties[ChatOptionsExtensions.IsStreamingKey] = isStreaming; - options.AdditionalProperties[ChatOptionsExtensions.ChatMessageContentKey] = content; - } - - private static void ClearOptionsForAutoFunctionInvocation(ref ChatOptions options) - { - if (options.AdditionalProperties?.ContainsKey(ChatOptionsExtensions.IsStreamingKey) ?? false) - { - options.AdditionalProperties.Remove(ChatOptionsExtensions.IsStreamingKey); - } - - if (options.AdditionalProperties?.ContainsKey(ChatOptionsExtensions.ChatMessageContentKey) ?? false) - { - options.AdditionalProperties.Remove(ChatOptionsExtensions.ChatMessageContentKey); - } - } - - private static void UpdateOptionsForNextIteration(ref ChatOptions options, string? chatThreadId) - { - if (options.ToolMode is RequiredChatToolMode) - { - // We have to reset the tool mode to be non-required after the first iteration, - // as otherwise we'll be in an infinite loop. - options = options.Clone(); - options.ToolMode = null; - options.ChatThreadId = chatThreadId; - } - else if (options.ChatThreadId != chatThreadId) - { - // As with the other modes, ensure we've propagated the chat thread ID to the options. - // We only need to clone the options if we're actually mutating it. - options = options.Clone(); - options.ChatThreadId = chatThreadId; - } - } - - /// - /// Processes the function calls in the list. - /// - /// The current chat contents, inclusive of the function call contents being processed. - /// The options used for the response being processed. - /// The function call contents representing the functions to be invoked. - /// The iteration number of how many roundtrips have been made to the inner client. - /// The number of consecutive iterations, prior to this one, that were recorded as having function invocation errors. - /// Whether the function calls are being processed in a streaming context. - /// The to monitor for cancellation requests. - /// A value indicating how the caller should proceed. - private async Task<(bool ShouldTerminate, int NewConsecutiveErrorCount, IList MessagesAdded)> ProcessFunctionCallsAsync( - List messages, ChatOptions options, List functionCallContents, - int iteration, int consecutiveErrorCount, bool isStreaming, CancellationToken cancellationToken) - { - // We must add a response for every tool call, regardless of whether we successfully executed it or not. - // If we successfully execute it, we'll add the result. If we don't, we'll add an error. - - Debug.Assert(functionCallContents.Count > 0, "Expected at least one function call."); - var shouldTerminate = false; - - var captureCurrentIterationExceptions = consecutiveErrorCount < _maximumConsecutiveErrorsPerRequest; - - // Process all functions. If there's more than one and concurrent invocation is enabled, do so in parallel. - if (functionCallContents.Count == 1) - { - FunctionInvocationResult result = await ProcessFunctionCallAsync( - messages, options, functionCallContents, iteration, 0, captureCurrentIterationExceptions, isStreaming, cancellationToken).ConfigureAwait(false); - - IList added = CreateResponseMessages([result]); - ThrowIfNoFunctionResultsAdded(added); - UpdateConsecutiveErrorCountOrThrow(added, ref consecutiveErrorCount); - - messages.AddRange(added); - return (result.ShouldTerminate, consecutiveErrorCount, added); - } - else - { - List results = []; - - var terminationRequested = false; - if (AllowConcurrentInvocation) - { - // Rather than awaiting each function before invoking the next, invoke all of them - // and then await all of them. We avoid forcibly introducing parallelism via Task.Run, - // but if a function invocation completes asynchronously, its processing can overlap - // with the processing of other the other invocation invocations. - results.AddRange(await Task.WhenAll( - from i in Enumerable.Range(0, functionCallContents.Count) - select ProcessFunctionCallAsync( - messages, options, functionCallContents, - iteration, i, captureExceptions: true, isStreaming, cancellationToken)).ConfigureAwait(false)); - - terminationRequested = results.Any(r => r.ShouldTerminate); - } - else - { - // Invoke each function serially. - for (int i = 0; i < functionCallContents.Count; i++) - { - var result = await ProcessFunctionCallAsync( - messages, options, functionCallContents, - iteration, i, captureCurrentIterationExceptions, isStreaming, cancellationToken).ConfigureAwait(false); - - results.Add(result); - - if (result.ShouldTerminate) - { - shouldTerminate = true; - terminationRequested = true; - break; - } - } - } - - IList added = CreateResponseMessages(results); - ThrowIfNoFunctionResultsAdded(added); - UpdateConsecutiveErrorCountOrThrow(added, ref consecutiveErrorCount); - - messages.AddRange(added); - - if (!terminationRequested) - { - // If any function requested termination, we'll terminate. - shouldTerminate = false; - foreach (FunctionInvocationResult fir in results) - { - shouldTerminate = shouldTerminate || fir.ShouldTerminate; - } - } - - return (shouldTerminate, consecutiveErrorCount, added); - } - } - - private void UpdateConsecutiveErrorCountOrThrow(IList added, ref int consecutiveErrorCount) - { - var allExceptions = added.SelectMany(m => m.Contents.OfType()) - .Select(frc => frc.Exception!) - .Where(e => e is not null); - -#pragma warning disable CA1851 // Possible multiple enumerations of 'IEnumerable' collection - if (allExceptions.Any()) - { - consecutiveErrorCount++; - if (consecutiveErrorCount > _maximumConsecutiveErrorsPerRequest) - { - var allExceptionsArray = allExceptions.ToArray(); - if (allExceptionsArray.Length == 1) - { - ExceptionDispatchInfo.Capture(allExceptionsArray[0]).Throw(); - } - else - { - throw new AggregateException(allExceptionsArray); - } - } - } - else - { - consecutiveErrorCount = 0; - } -#pragma warning restore CA1851 // Possible multiple enumerations of 'IEnumerable' collection - } - - /// - /// Throws an exception if doesn't create any messages. - /// - private void ThrowIfNoFunctionResultsAdded(IList? messages) - { - if (messages is null || messages.Count == 0) - { - throw new InvalidOperationException($"{this.GetType().Name}.{nameof(this.CreateResponseMessages)} returned null or an empty collection of messages."); - } - } - - /// Processes the function call described in []. - /// The current chat contents, inclusive of the function call contents being processed. - /// The options used for the response being processed. - /// The function call contents representing all the functions being invoked. - /// The iteration number of how many roundtrips have been made to the inner client. - /// The 0-based index of the function being called out of . - /// If true, handles function-invocation exceptions by returning a value with . Otherwise, rethrows. - /// Whether the function calls are being processed in a streaming context. - /// The to monitor for cancellation requests. - /// A value indicating how the caller should proceed. - private async Task ProcessFunctionCallAsync( - List messages, ChatOptions options, List callContents, - int iteration, int functionCallIndex, bool captureExceptions, bool isStreaming, CancellationToken cancellationToken) - { - var callContent = callContents[functionCallIndex]; - - // Look up the AIFunction for the function call. If the requested function isn't available, send back an error. - AIFunction? function = options.Tools!.OfType().FirstOrDefault(t => t.Name == callContent.Name); - if (function is null) - { - return new(shouldTerminate: false, FunctionInvokingChatClient.FunctionInvocationStatus.NotFound, callContent, result: null, exception: null); - } - - if (callContent.Arguments is not null) - { - callContent.Arguments = new KernelArguments(callContent.Arguments); - } - - var context = new AutoFunctionInvocationContext(new() - { - Function = function, - Arguments = new(callContent.Arguments) { Services = _functionInvocationServices }, - - Messages = messages, - Options = options, - - CallContent = callContent, - Iteration = iteration, - FunctionCallIndex = functionCallIndex, - FunctionCount = callContents.Count, - }) - { IsStreaming = isStreaming }; - - object? result; - try - { - result = await InvokeFunctionAsync(context, cancellationToken).ConfigureAwait(false); - } - catch (Exception e) when (!cancellationToken.IsCancellationRequested) - { - if (!captureExceptions) - { - throw; - } - - return new( - shouldTerminate: false, - FunctionInvokingChatClient.FunctionInvocationStatus.Exception, - callContent, - result: null, - exception: e); - } - - return new( - shouldTerminate: context.Terminate, - FunctionInvokingChatClient.FunctionInvocationStatus.RanToCompletion, - callContent, - result, - exception: null); - } - - /// Creates one or more response messages for function invocation results. - /// Information about the function call invocations and results. - /// A list of all chat messages created from . - private IList CreateResponseMessages(List results) - { - var contents = new List(results.Count); - for (int i = 0; i < results.Count; i++) - { - contents.Add(CreateFunctionResultContent(results[i])); - } - - return [new(ChatRole.Tool, contents)]; - - FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult result) - { - Verify.NotNull(result); - - object? functionResult; - if (result.Status == FunctionInvokingChatClient.FunctionInvocationStatus.RanToCompletion) - { - functionResult = result.Result ?? "Success: Function completed."; - } - else - { - string message = result.Status switch - { - FunctionInvokingChatClient.FunctionInvocationStatus.NotFound => $"Error: Requested function \"{result.CallContent.Name}\" not found.", - FunctionInvokingChatClient.FunctionInvocationStatus.Exception => "Error: Function failed.", - _ => "Error: Unknown error.", - }; - - if (IncludeDetailedErrors && result.Exception is not null) - { - message = $"{message} Exception: {result.Exception.Message}"; - } - - functionResult = message; - } - - return new FunctionResultContent(result.CallContent.CallId, functionResult) { Exception = result.Exception }; - } - } - - /// - /// Invokes the auto function invocation filters. - /// - /// The auto function invocation context. - /// The function to call after the filters. - /// The auto function invocation context. - private async Task OnAutoFunctionInvocationAsync( - AutoFunctionInvocationContext context, - Func functionCallCallback) - { - await this.InvokeFilterOrFunctionAsync(functionCallCallback, context).ConfigureAwait(false); - - return context; - } - - /// - /// This method will execute auto function invocation filters and function recursively. - /// If there are no registered filters, just function will be executed. - /// If there are registered filters, filter on position will be executed. - /// Second parameter of filter is callback. It can be either filter on + 1 position or function if there are no remaining filters to execute. - /// Function will always be executed as last step after all filters. - /// - private async Task InvokeFilterOrFunctionAsync( - Func functionCallCallback, - AutoFunctionInvocationContext context, - int index = 0) - { - IList autoFunctionInvocationFilters = context.Kernel.AutoFunctionInvocationFilters; - - if (autoFunctionInvocationFilters is { Count: > 0 } && index < autoFunctionInvocationFilters.Count) - { - await autoFunctionInvocationFilters[index].OnAutoFunctionInvocationAsync( - context, - (ctx) => this.InvokeFilterOrFunctionAsync(functionCallCallback, ctx, index + 1) - ).ConfigureAwait(false); - } - else - { - await functionCallCallback(context).ConfigureAwait(false); - } - } - - /// Invokes the function asynchronously. - /// - /// The function invocation context detailing the function to be invoked and its arguments along with additional request information. - /// - /// The to monitor for cancellation requests. The default is . - /// The result of the function invocation, or if the function invocation returned . - /// is . - private async Task InvokeFunctionAsync(AutoFunctionInvocationContext context, CancellationToken cancellationToken) - { - Verify.NotNull(context); - - using Activity? activity = _activitySource?.StartActivity(context.Function.Name); - - long startingTimestamp = 0; - if (_logger.IsEnabled(LogLevel.Debug)) - { - startingTimestamp = Stopwatch.GetTimestamp(); - if (_logger.IsEnabled(LogLevel.Trace)) - { - LogInvokingSensitive(context.Function.Name, LoggingAsJson(context.CallContent.Arguments, context.AIFunction.JsonSerializerOptions)); - } - else - { - LogInvoking(context.Function.Name); - } - } - - object? result = null; - try - { - CurrentContext = context; // doesn't need to be explicitly reset after, as that's handled automatically at async method exit - context = await this.OnAutoFunctionInvocationAsync( - context, - async (ctx) => - { - // Check if filter requested termination - if (ctx.Terminate) - { - return; - } - - // Note that we explicitly do not use executionSettings here; those pertain to the all-up operation and not necessarily to any - // further calls made as part of this function invocation. In particular, we must not use function calling settings naively here, - // as the called function could in turn telling the model about itself as a possible candidate for invocation. - result = await context.AIFunction.InvokeAsync(new(context.Arguments), cancellationToken).ConfigureAwait(false); - ctx.Result = new FunctionResult(ctx.Function, result); - }).ConfigureAwait(false); - result = context.Result.GetValue(); - } - catch (Exception e) - { - if (activity is not null) - { - _ = activity.SetTag("error.type", e.GetType().FullName) - .SetStatus(ActivityStatusCode.Error, e.Message); - } - - if (e is OperationCanceledException) - { - LogInvocationCanceled(context.Function.Name); - } - else - { - LogInvocationFailed(context.Function.Name, e); - } - - throw; - } - finally - { - if (_logger.IsEnabled(LogLevel.Debug)) - { - TimeSpan elapsed = GetElapsedTime(startingTimestamp); - - if (result is not null && _logger.IsEnabled(LogLevel.Trace)) - { - LogInvocationCompletedSensitive(context.Function.Name, elapsed, LoggingAsJson(result, context.AIFunction.JsonSerializerOptions)); - } - else - { - LogInvocationCompleted(context.Function.Name, elapsed); - } - } - } - - return result; - } - - /// Serializes as JSON for logging purposes. - private static string LoggingAsJson(T value, JsonSerializerOptions? options) - { - if (options?.TryGetTypeInfo(typeof(T), out var typeInfo) is true || - AIJsonUtilities.DefaultOptions.TryGetTypeInfo(typeof(T), out typeInfo)) - { -#pragma warning disable CA1031 // Do not catch general exception types - try - { - return JsonSerializer.Serialize(value, typeInfo); - } - catch - { - } -#pragma warning restore CA1031 // Do not catch general exception types - } - - // If we're unable to get a type info for the value, or if we fail to serialize, - // return an empty JSON object. We do not want lack of type info to disrupt application behavior with exceptions. - return "{}"; - } - - private static TimeSpan GetElapsedTime(long startingTimestamp) => -#if NET - Stopwatch.GetElapsedTime(startingTimestamp); -#else - new((long)((Stopwatch.GetTimestamp() - startingTimestamp) * ((double)TimeSpan.TicksPerSecond / Stopwatch.Frequency))); -#endif - - [LoggerMessage(LogLevel.Debug, "Invoking {MethodName}.", SkipEnabledCheck = true)] - private partial void LogInvoking(string methodName); - - [LoggerMessage(LogLevel.Trace, "Invoking {MethodName}({Arguments}).", SkipEnabledCheck = true)] - private partial void LogInvokingSensitive(string methodName, string arguments); - - [LoggerMessage(LogLevel.Debug, "{MethodName} invocation completed. Duration: {Duration}", SkipEnabledCheck = true)] - private partial void LogInvocationCompleted(string methodName, TimeSpan duration); - - [LoggerMessage(LogLevel.Trace, "{MethodName} invocation completed. Duration: {Duration}. Result: {Result}", SkipEnabledCheck = true)] - private partial void LogInvocationCompletedSensitive(string methodName, TimeSpan duration, string result); - - [LoggerMessage(LogLevel.Debug, "{MethodName} invocation canceled.")] - private partial void LogInvocationCanceled(string methodName); - - [LoggerMessage(LogLevel.Error, "{MethodName} invocation failed.")] - private partial void LogInvocationFailed(string methodName, Exception error); - - /// Provides information about the invocation of a function call. - public sealed class FunctionInvocationResult - { - internal FunctionInvocationResult(bool shouldTerminate, FunctionInvokingChatClient.FunctionInvocationStatus status, FunctionCallContent callContent, object? result, Exception? exception) - { - ShouldTerminate = shouldTerminate; - Status = status; - CallContent = callContent; - Result = result; - Exception = exception; - } - - /// Gets status about how the function invocation completed. - public FunctionInvokingChatClient.FunctionInvocationStatus Status { get; } - - /// Gets the function call content information associated with this invocation. - public FunctionCallContent CallContent { get; } - - /// Gets the result of the function call. - public object? Result { get; } - - /// Gets any exception the function call threw. - public Exception? Exception { get; } - - /// Gets a value indicating whether the caller should terminate the processing loop. - internal bool ShouldTerminate { get; } - } -} diff --git a/dotnet/src/SemanticKernel.Abstractions/Filters/AutoFunctionInvocation/AutoFunctionInvocationContext.cs b/dotnet/src/SemanticKernel.Abstractions/Filters/AutoFunctionInvocation/AutoFunctionInvocationContext.cs index d4e66f48cfc3..1a31144e26bb 100644 --- a/dotnet/src/SemanticKernel.Abstractions/Filters/AutoFunctionInvocation/AutoFunctionInvocationContext.cs +++ b/dotnet/src/SemanticKernel.Abstractions/Filters/AutoFunctionInvocation/AutoFunctionInvocationContext.cs @@ -21,23 +21,26 @@ public class AutoFunctionInvocationContext : FunctionInvocationContextV2 /// /// Initializes a new instance of the class from an existing . /// - internal AutoFunctionInvocationContext(Microsoft.Extensions.AI.FunctionInvocationContextV2 invocationContext) + internal AutoFunctionInvocationContext(ChatOptions options) { - Verify.NotNull(invocationContext); - Verify.NotNull(invocationContext.Options); + Verify.NotNull(options); // the ChatOptions must be provided with AdditionalProperties. - Verify.NotNull(invocationContext.Options.AdditionalProperties); + Verify.NotNull(options.AdditionalProperties); - invocationContext.Options.AdditionalProperties.TryGetValue(ChatOptionsExtensions.KernelKey, out var kernel); + // The ChatOptions must be provided with the kernel. + options.AdditionalProperties.TryGetValue(ChatOptionsExtensions.KernelKey, out var kernel); Verify.NotNull(kernel); - invocationContext.Options.AdditionalProperties.TryGetValue(ChatOptionsExtensions.ChatMessageContentKey, out var chatMessageContent); + // The ChatOptions must be provided with the chat message content. + options.AdditionalProperties.TryGetValue(ChatOptionsExtensions.ChatMessageContentKey, out var chatMessageContent); Verify.NotNull(chatMessageContent); - invocationContext.Options.AdditionalProperties.TryGetValue(ChatOptionsExtensions.PromptExecutionSettingsKey, out var executionSettings); - this.ExecutionSettings = executionSettings; + // The ChatOptions can be provided with the execution settings. + options.AdditionalProperties.TryGetValue(ChatOptionsExtensions.PromptExecutionSettingsKey, out var executionSettings); + this.ExecutionSettings = executionSettings; + this.Options = options; this.Result = new FunctionResult(this.Function) { Culture = kernel.Culture }; } @@ -90,8 +93,8 @@ public AutoFunctionInvocationContext( /// public new KernelArguments? Arguments { - get => this.CallContent.Arguments is KernelArguments kernelArguments ? kernelArguments : null; - init => this.CallContent.Arguments = value; + get => new(base.Arguments); + init => base.Arguments = new(value); } /// @@ -191,9 +194,22 @@ public Kernel Kernel /// public FunctionResult Result { get; set; } + /// + /// Gets or sets the with which this filter is associated. + /// internal AIFunction AIFunction { get => base.Function; + set => base.Function = value; + } + + /// + /// Gets or sets the arguments associated with this invocation context. + /// + internal AIFunctionArguments AIArguments + { + get => base.Arguments; + set => base.Arguments = value; } private static bool IsSameSchema(KernelFunction kernelFunction, AIFunction aiFunction) From e805c931893ceccc2047f308bd0c5acef2ec1478 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 22 Apr 2025 17:22:35 +0100 Subject: [PATCH 04/24] Using KernelArguments as Specialization of AIFunctionArguments and related update changes needed --- .../RestApiOperationRunner.cs | 2 + .../AI/ChatClient/AIFunctionArgumentsV2.cs | 136 ++++++++++++++++ .../ChatClient/FunctionInvocationContextV2.cs | 4 +- .../FunctionInvokingChatClientV2.cs | 2 +- .../KernelFunctionInvokingChatClient.cs | 9 +- .../AutoFunctionInvocationContext.cs | 30 ++-- .../Functions/KernelArguments.cs | 147 ++---------------- 7 files changed, 172 insertions(+), 158 deletions(-) create mode 100644 dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/AIFunctionArgumentsV2.cs diff --git a/dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs b/dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs index 28305a5ec8a4..068f4713118a 100644 --- a/dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs +++ b/dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs @@ -13,6 +13,8 @@ using System.Threading.Tasks; using Microsoft.SemanticKernel.Http; +#pragma warning disable CA1859 + namespace Microsoft.SemanticKernel.Plugins.OpenApi; /// diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/AIFunctionArgumentsV2.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/AIFunctionArgumentsV2.cs new file mode 100644 index 000000000000..b28bbfdec420 --- /dev/null +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/AIFunctionArgumentsV2.cs @@ -0,0 +1,136 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections; +using System.Collections.Generic; + +#pragma warning disable SA1111 // Closing parenthesis should be on line of last parameter +#pragma warning disable SA1112 // Closing parenthesis should be on line of opening parenthesis +#pragma warning disable SA1114 // Parameter list should follow declaration +#pragma warning disable CA1710 // Identifiers should have correct suffix + +namespace Microsoft.Extensions.AI; + +/// Represents arguments to be used with . +/// +/// is a dictionary of name/value pairs that are used +/// as inputs to an . However, an instance carries additional non-nominal +/// information, such as an optional that can be used by +/// an if it needs to resolve any services from a dependency injection +/// container. +/// +public class AIFunctionArgumentsV2 : IDictionary, IReadOnlyDictionary +{ + /// The nominal arguments. + private readonly Dictionary _arguments; + + /// Initializes a new instance of the class. + /// The optional to use for key comparisons. + public AIFunctionArgumentsV2(IEqualityComparer? comparer = null) + { + _arguments = new Dictionary(comparer); + } + + /// + /// Initializes a new instance of the class containing + /// the specified . + /// + /// The arguments represented by this instance. + /// The to be used. + /// + /// The reference will be stored if the instance is + /// already a and no is specified, + /// in which case all dictionary operations on this instance will be routed directly to that instance. + /// If is not a dictionary or a is specified, + /// a shallow clone of its data will be used to populate this instance. + /// A is treated as an empty dictionary. + /// + public AIFunctionArgumentsV2(IDictionary? arguments, IEqualityComparer? comparer = null) + { + this._arguments = comparer is null + ? arguments is null + ? [] + : arguments as Dictionary ?? + new Dictionary(arguments) + : arguments is null + ? new Dictionary(comparer) + : new Dictionary(arguments, comparer); + } + + /// Gets or sets services optionally associated with these arguments. + public IServiceProvider? Services { get; set; } + + /// Gets or sets additional context associated with these arguments. + /// + /// The context is a dictionary of name/value pairs that can be used to store arbitrary + /// information for use by an implementation. The meaning of this + /// data is left up to the implementer of the . + /// + public IDictionary? Context { get; set; } + + /// + public object? this[string key] + { + get => _arguments[key]; + set => _arguments[key] = value; + } + + /// + public ICollection Keys => _arguments.Keys; + + /// + public ICollection Values => _arguments.Values; + + /// + public int Count => _arguments.Count; + + /// + public bool IsReadOnly => false; + + /// + IEnumerable IReadOnlyDictionary.Keys => Keys; + + /// + IEnumerable IReadOnlyDictionary.Values => Values; + + /// + public void Add(string key, object? value) => _arguments.Add(key, value); + + /// + void ICollection>.Add(KeyValuePair item) => + ((ICollection>)_arguments).Add(item); + + /// + public void Clear() => _arguments.Clear(); + + /// + public bool Contains(KeyValuePair item) => + ((ICollection>)_arguments).Contains(item); + + /// + public bool ContainsKey(string key) => _arguments.ContainsKey(key); + + /// + public void CopyTo(KeyValuePair[] array, int arrayIndex) => + ((ICollection>)_arguments).CopyTo(array, arrayIndex); + + /// + public IEnumerator> GetEnumerator() => _arguments.GetEnumerator(); + + /// + public bool Remove(string key) => _arguments.Remove(key); + + /// + bool ICollection>.Remove(KeyValuePair item) => + ((ICollection>)_arguments).Remove(item); + + /// + public bool TryGetValue(string key, out object? value) => _arguments.TryGetValue(key, out value); + + /// + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + /// Gets the comparer used by the underlying dictionary. + protected IEqualityComparer Comparer => _arguments.Comparer; +} diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvocationContextV2.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvocationContextV2.cs index d096656fba56..457f726b35f3 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvocationContextV2.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvocationContextV2.cs @@ -26,7 +26,7 @@ public partial class FunctionInvocationContextV2 private FunctionCallContent? _callContent; /// The arguments used with the function. - private AIFunctionArguments? _arguments; + private AIFunctionArgumentsV2? _arguments; /// Initializes a new instance of the class. public FunctionInvocationContextV2() @@ -41,7 +41,7 @@ public AIFunction Function } /// Gets or sets the arguments associated with this invocation. - public AIFunctionArguments Arguments + public AIFunctionArgumentsV2 Arguments { get => _arguments ??= []; set => _arguments = Throw.IfNull(value); diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.cs index e155ad7a68d3..2bd2c2ac1e2b 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.cs @@ -832,7 +832,7 @@ FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult resul /// The function invocation context and result. protected virtual async Task<(FunctionInvocationContextV2 context, object? result)> TryInvokeFunctionAsync(FunctionInvocationContextV2 context, CancellationToken cancellationToken) { - var result = await context.Function.InvokeAsync(context.Arguments, cancellationToken); + var result = await context.Function.InvokeAsync(new(context.Arguments), cancellationToken); return (context, result); } diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs index 0415a6dc648b..1deff697cca2 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs @@ -166,15 +166,10 @@ protected override async Task ProcessFunctionCallAsync return new(shouldTerminate: false, FunctionInvocationStatus.NotFound, callContent, result: null, exception: null); } - //if (callContent.Arguments is not null) - //{ - // callContent.Arguments = new KernelArguments(callContent.Arguments); - //} - var context = new AutoFunctionInvocationContext(options) { AIFunction = function, - AIArguments = new AIFunctionArguments(callContent.Arguments) { Services = this.FunctionInvocationServices }, + Arguments = new KernelArguments(callContent.Arguments ?? new Dictionary()) { Services = this.FunctionInvocationServices }, Messages = messages, CallContent = callContent, Iteration = iteration, @@ -322,7 +317,7 @@ await autoFunctionInvocationFilters[index].OnAutoFunctionInvocationAsync( } else { - result = await context.Function.InvokeAsync(context.Arguments, cancellationToken).ConfigureAwait(false); + result = await context.Function.InvokeAsync(new(context.Arguments), cancellationToken).ConfigureAwait(false); } return (context, result); diff --git a/dotnet/src/SemanticKernel.Abstractions/Filters/AutoFunctionInvocation/AutoFunctionInvocationContext.cs b/dotnet/src/SemanticKernel.Abstractions/Filters/AutoFunctionInvocation/AutoFunctionInvocationContext.cs index 1a31144e26bb..cd79f13cca6e 100644 --- a/dotnet/src/SemanticKernel.Abstractions/Filters/AutoFunctionInvocation/AutoFunctionInvocationContext.cs +++ b/dotnet/src/SemanticKernel.Abstractions/Filters/AutoFunctionInvocation/AutoFunctionInvocationContext.cs @@ -93,8 +93,25 @@ public AutoFunctionInvocationContext( /// public new KernelArguments? Arguments { - get => new(base.Arguments); - init => base.Arguments = new(value); + get + { + if (base.Arguments is KernelArguments kernelArguments) + { + return kernelArguments; + } + + throw new InvalidOperationException($"The arguments are not of type {nameof(KernelArguments)}, for those scenarios, use {nameof(this.AIArguments)} instead."); + } + init => base.Arguments = value ?? new(); + } + + /// + /// Get the with which this filter is associated. + /// + public AIFunctionArgumentsV2 AIArguments + { + get => base.Arguments; + init => base.Arguments = value; } /// @@ -203,15 +220,6 @@ internal AIFunction AIFunction set => base.Function = value; } - /// - /// Gets or sets the arguments associated with this invocation context. - /// - internal AIFunctionArguments AIArguments - { - get => base.Arguments; - set => base.Arguments = value; - } - private static bool IsSameSchema(KernelFunction kernelFunction, AIFunction aiFunction) { // Compares the schemas, should be similar. diff --git a/dotnet/src/SemanticKernel.Abstractions/Functions/KernelArguments.cs b/dotnet/src/SemanticKernel.Abstractions/Functions/KernelArguments.cs index eda736b3f583..7e71ee2c05a1 100644 --- a/dotnet/src/SemanticKernel.Abstractions/Functions/KernelArguments.cs +++ b/dotnet/src/SemanticKernel.Abstractions/Functions/KernelArguments.cs @@ -3,7 +3,9 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Runtime.CompilerServices; using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; #pragma warning disable CA1710 // Identifiers should have correct suffix @@ -17,10 +19,8 @@ namespace Microsoft.SemanticKernel; /// A is a dictionary of argument names and values. It also carries a /// , accessible via the property. /// -public sealed class KernelArguments : IDictionary, IReadOnlyDictionary +public sealed class KernelArguments : AIFunctionArgumentsV2 { - /// Dictionary of name/values for all the arguments in the instance. - private readonly Dictionary _arguments; private IReadOnlyDictionary? _executionSettings; /// @@ -28,8 +28,8 @@ public sealed class KernelArguments : IDictionary, IReadOnlyDic /// [JsonConstructor] public KernelArguments() + : base(StringComparer.OrdinalIgnoreCase) { - this._arguments = new(StringComparer.OrdinalIgnoreCase); } /// @@ -37,7 +37,7 @@ public KernelArguments() /// /// The prompt execution settings. public KernelArguments(PromptExecutionSettings? executionSettings) - : this(executionSettings is null ? null : [executionSettings]) + : this(executionSettings: executionSettings is null ? null : [executionSettings]) { } @@ -46,8 +46,8 @@ public KernelArguments(PromptExecutionSettings? executionSettings) /// /// The prompt execution settings. public KernelArguments(IEnumerable? executionSettings) + : base(StringComparer.OrdinalIgnoreCase) { - this._arguments = new(StringComparer.OrdinalIgnoreCase); if (executionSettings is not null) { var newExecutionSettings = new Dictionary(); @@ -80,10 +80,8 @@ public KernelArguments(IEnumerable? executionSettings) /// Otherwise, if the source is a , its are used. /// public KernelArguments(IDictionary source, Dictionary? executionSettings = null) + : base(source, StringComparer.OrdinalIgnoreCase) { - Verify.NotNull(source); - - this._arguments = new(source, StringComparer.OrdinalIgnoreCase); this.ExecutionSettings = executionSettings ?? (source as KernelArguments)?.ExecutionSettings; } @@ -115,37 +113,6 @@ public IReadOnlyDictionary? ExecutionSettings } } - /// - /// Gets the number of arguments contained in the . - /// - public int Count => this._arguments.Count; - - /// Adds the specified argument name and value to the . - /// The name of the argument to add. - /// The value of the argument to add. - /// is null. - /// An argument with the same name already exists in the . - public void Add(string name, object? value) - { - Verify.NotNull(name); - this._arguments.Add(name, value); - } - - /// Removes the argument value with the specified name from the . - /// The name of the argument value to remove. - /// is null. - public bool Remove(string name) - { - Verify.NotNull(name); - return this._arguments.Remove(name); - } - - /// Removes all arguments names and values from the . - /// - /// This does not affect the property. To clear it as well, set it to null. - /// - public void Clear() => this._arguments.Clear(); - /// Determines whether the contains an argument with the specified name. /// The name of the argument to locate. /// true if the arguments contains an argument with the specified named; otherwise, false. @@ -153,103 +120,9 @@ public bool Remove(string name) public bool ContainsName(string name) { Verify.NotNull(name); - return this._arguments.ContainsKey(name); - } - - /// Gets the value associated with the specified argument name. - /// The name of the argument value to get. - /// - /// When this method returns, contains the value associated with the specified name, - /// if the name is found; otherwise, null. - /// - /// true if the arguments contains an argument with the specified name; otherwise, false. - /// is null. - public bool TryGetValue(string name, out object? value) - { - Verify.NotNull(name); - return this._arguments.TryGetValue(name, out value); - } - - /// Gets or sets the value associated with the specified argument name. - /// The name of the argument value to get or set. - /// is null. - public object? this[string name] - { - get - { - Verify.NotNull(name); - return this._arguments[name]; - } - set - { - Verify.NotNull(name); - this._arguments[name] = value; - } - } - - /// Gets an of all of the arguments' names. - public ICollection Names => this._arguments.Keys; - - /// Gets an of all of the arguments' values. - public ICollection Values => this._arguments.Values; - - #region Interface implementations - /// - ICollection IDictionary.Keys => this._arguments.Keys; - - /// - IEnumerable IReadOnlyDictionary.Keys => this._arguments.Keys; - - /// - IEnumerable IReadOnlyDictionary.Values => this._arguments.Values; - - /// - bool ICollection>.IsReadOnly => false; - - /// - object? IReadOnlyDictionary.this[string key] => this._arguments[key]; - - /// - object? IDictionary.this[string key] - { - get => this._arguments[key]; - set => this._arguments[key] = value; + return base.ContainsKey(name); } - /// - void IDictionary.Add(string key, object? value) => this._arguments.Add(key, value); - - /// - bool IDictionary.ContainsKey(string key) => this._arguments.ContainsKey(key); - - /// - bool IDictionary.Remove(string key) => this._arguments.Remove(key); - - /// - bool IDictionary.TryGetValue(string key, out object? value) => this._arguments.TryGetValue(key, out value); - - /// - void ICollection>.Add(KeyValuePair item) => this._arguments.Add(item.Key, item.Value); - - /// - bool ICollection>.Contains(KeyValuePair item) => ((ICollection>)this._arguments).Contains(item); - - /// - void ICollection>.CopyTo(KeyValuePair[] array, int arrayIndex) => ((ICollection>)this._arguments).CopyTo(array, arrayIndex); - - /// - bool ICollection>.Remove(KeyValuePair item) => this._arguments.Remove(item.Key); - - /// - IEnumerator> IEnumerable>.GetEnumerator() => this._arguments.GetEnumerator(); - - /// - IEnumerator IEnumerable.GetEnumerator() => this._arguments.GetEnumerator(); - - /// - bool IReadOnlyDictionary.ContainsKey(string key) => this._arguments.ContainsKey(key); - - /// - bool IReadOnlyDictionary.TryGetValue(string key, out object? value) => this._arguments.TryGetValue(key, out value); - #endregion + /// Gets an of all of the arguments names. + public ICollection Names => this.Keys; } From 968190f1f056ad3161e52165f509d323483a77fd Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 22 Apr 2025 17:32:57 +0100 Subject: [PATCH 05/24] Fix typo --- .../AI/ChatClient/FunctionInvokingChatClientV2.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.cs index 2bd2c2ac1e2b..7c61416872cc 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.cs @@ -438,7 +438,7 @@ private static void FixupHistories( else if (lastIterationHadThreadId) { // In the very rare case where the inner client returned a response with a thread ID but then - // returned a subsequent response without one, we want to reconstitue the full history. To do that, + // returned a subsequent response without one, we want to reconstitute the full history. To do that, // we can populate the history with the original chat messages and then all of the response // messages up until this point, which includes the most recent ones. augmentedHistory ??= []; From 193be17fa0da3f3e30ec2d6a8e68812ebf183a97 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 22 Apr 2025 21:20:37 +0100 Subject: [PATCH 06/24] Fix warnings --- .../AI/ChatClient/AIFunctionArgumentsV2.cs | 4 +++- .../AI/ChatClient/FunctionInvocationContextV2.Extra.cs | 9 +++++---- .../AI/ChatClient/FunctionInvocationContextV2.cs | 9 +++++++-- .../AI/ChatClient/FunctionInvokingChatClientV2.Extra.cs | 6 +++--- .../AI/ChatClient/FunctionInvokingChatClientV2.cs | 6 +++++- .../AI/ChatClient/KernelFunctionInvokingChatClient.cs | 1 - .../Functions/KernelArguments.cs | 2 -- 7 files changed, 23 insertions(+), 14 deletions(-) diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/AIFunctionArgumentsV2.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/AIFunctionArgumentsV2.cs index b28bbfdec420..a9b5077ab540 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/AIFunctionArgumentsV2.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/AIFunctionArgumentsV2.cs @@ -1,4 +1,6 @@ -// Licensed to the .NET Foundation under one or more agreements. +#pragma warning disable IDE0073 // The file header does not match the required text + +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System; diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvocationContextV2.Extra.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvocationContextV2.Extra.cs index d1a20e00c348..03dfcf1d8246 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvocationContextV2.Extra.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvocationContextV2.Extra.cs @@ -1,12 +1,13 @@ -// Licensed to the .NET Foundation under one or more agreements. +#pragma warning disable IDE0073 // The file header does not match the required text + +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; -using Microsoft.Shared.Collections; -using Microsoft.Shared.Diagnostics; + +#pragma warning disable IDE0055 // Fix formatting namespace Microsoft.Extensions.AI; diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvocationContextV2.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvocationContextV2.cs index 457f726b35f3..a1698cea64e6 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvocationContextV2.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvocationContextV2.cs @@ -1,9 +1,13 @@ -// Licensed to the .NET Foundation under one or more agreements. +#pragma warning disable IDE0073 // The file header does not match the required text + +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System; using System.Collections.Generic; -using Microsoft.Shared.Diagnostics; + +#pragma warning disable IDE1006 // Naming Styles +#pragma warning disable IDE0009 // Add this or Me qualification namespace Microsoft.Extensions.AI; @@ -15,6 +19,7 @@ public partial class FunctionInvocationContextV2 /// start with this as the target function. /// private static readonly AIFunction _nopFunction = AIFunctionFactory.Create(() => { }, nameof(FunctionInvocationContext)); +#pragma warning restore IDE1006 // Naming Styles /// The chat contents associated with the operation that initiated this function call request. private IList _messages = Array.Empty(); diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.Extra.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.Extra.cs index 12edbbb4d15e..e2aa2b85db11 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.Extra.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.Extra.cs @@ -1,7 +1,7 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. +#pragma warning disable IDE0073 // The file header does not match the required text -// Intended for deletion +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. using System; using System.Diagnostics.CodeAnalysis; diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.cs index 7c61416872cc..18b0f23c8444 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.cs @@ -1,4 +1,6 @@ -// Licensed to the .NET Foundation under one or more agreements. +#pragma warning disable IDE0073 // The file header does not match the required text + +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System; @@ -18,6 +20,8 @@ #pragma warning disable SA1202 // 'protected' members should come before 'private' members #pragma warning disable VSTHRD111 // Use ConfigureAwait(bool) #pragma warning disable CA2007 // Use ConfigureAwait +#pragma warning disable IDE0009 // Add this or Me qualification +#pragma warning disable IDE1006 // Naming Styles namespace Microsoft.Extensions.AI; diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs index 1deff697cca2..d263e3c156d5 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using System; -#pragma warning restore IDE0073 // The file header does not match the required text using System.Collections.Generic; using System.Diagnostics; using System.Linq; diff --git a/dotnet/src/SemanticKernel.Abstractions/Functions/KernelArguments.cs b/dotnet/src/SemanticKernel.Abstractions/Functions/KernelArguments.cs index 7e71ee2c05a1..2c6d406e497a 100644 --- a/dotnet/src/SemanticKernel.Abstractions/Functions/KernelArguments.cs +++ b/dotnet/src/SemanticKernel.Abstractions/Functions/KernelArguments.cs @@ -1,9 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.Collections; using System.Collections.Generic; -using System.Runtime.CompilerServices; using System.Text.Json.Serialization; using Microsoft.Extensions.AI; From a778718069b4d6f9ff6de3a57e3cbebb8e1d02e2 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 22 Apr 2025 21:24:21 +0100 Subject: [PATCH 07/24] Fix formatting --- .../AI/ChatClient/AIFunctionArgumentsV2.cs | 1 + .../FunctionInvokingChatClientV2.Extra.cs | 16 ++++++++-------- .../KernelFunctionInvokingChatClient.cs | 3 --- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/AIFunctionArgumentsV2.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/AIFunctionArgumentsV2.cs index a9b5077ab540..6008d7077a1b 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/AIFunctionArgumentsV2.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/AIFunctionArgumentsV2.cs @@ -11,6 +11,7 @@ #pragma warning disable SA1112 // Closing parenthesis should be on line of opening parenthesis #pragma warning disable SA1114 // Parameter list should follow declaration #pragma warning disable CA1710 // Identifiers should have correct suffix +#pragma warning disable IDE0009 // Add this or Me qualification namespace Microsoft.Extensions.AI; diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.Extra.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.Extra.cs index e2aa2b85db11..c6fc614ed179 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.Extra.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.Extra.cs @@ -37,22 +37,22 @@ public static T IfNull([NotNull] T argument, [CallerArgumentExpression(nameof /// /// A message that describes the error. #if !NET6_0_OR_GREATER - [MethodImpl(MethodImplOptions.NoInlining)] + [MethodImpl(MethodImplOptions.NoInlining)] #endif [DoesNotReturn] public static void InvalidOperationException(string message) - => throw new InvalidOperationException(message); + => throw new InvalidOperationException(message); /// /// Throws an . /// /// The name of the parameter that caused the exception. #if !NET6_0_OR_GREATER - [MethodImpl(MethodImplOptions.NoInlining)] + [MethodImpl(MethodImplOptions.NoInlining)] #endif [DoesNotReturn] public static void ArgumentOutOfRangeException(string paramName) - => throw new ArgumentOutOfRangeException(paramName); + => throw new ArgumentOutOfRangeException(paramName); /// /// Throws an . @@ -60,11 +60,11 @@ public static void ArgumentOutOfRangeException(string paramName) /// The name of the parameter that caused the exception. /// A message that describes the error. #if !NET6_0_OR_GREATER - [MethodImpl(MethodImplOptions.NoInlining)] + [MethodImpl(MethodImplOptions.NoInlining)] #endif [DoesNotReturn] public static void ArgumentOutOfRangeException(string paramName, string? message) - => throw new ArgumentOutOfRangeException(paramName, message); + => throw new ArgumentOutOfRangeException(paramName, message); /// /// Throws an . @@ -73,11 +73,11 @@ public static void ArgumentOutOfRangeException(string paramName, string? message /// The value of the argument that caused this exception. /// A message that describes the error. #if !NET6_0_OR_GREATER - [MethodImpl(MethodImplOptions.NoInlining)] + [MethodImpl(MethodImplOptions.NoInlining)] #endif [DoesNotReturn] public static void ArgumentOutOfRangeException(string paramName, object? actualValue, string? message) - => throw new ArgumentOutOfRangeException(paramName, actualValue, message); + => throw new ArgumentOutOfRangeException(paramName, actualValue, message); /// /// Throws an if the specified number is less than min. diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs index d263e3c156d5..4bb0edabdd46 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs @@ -10,9 +10,6 @@ using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.ChatCompletion; -// Modified source from 2025-04-07 -// https://raw.githubusercontent.com/dotnet/extensions/84d09b794d994435568adcbb85a981143d4f15cb/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs - namespace Microsoft.Extensions.AI; /// From 6af06b85a4382ebca99ca560f2f80c4d0ef109c3 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Wed, 23 Apr 2025 07:41:11 +0100 Subject: [PATCH 08/24] Removing unused property --- .../AI/ChatClient/AIFunctionArgumentsV2.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/AIFunctionArgumentsV2.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/AIFunctionArgumentsV2.cs index b28bbfdec420..ba57a2a4516f 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/AIFunctionArgumentsV2.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/AIFunctionArgumentsV2.cs @@ -130,7 +130,4 @@ public void CopyTo(KeyValuePair[] array, int arrayIndex) => /// IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - - /// Gets the comparer used by the underlying dictionary. - protected IEqualityComparer Comparer => _arguments.Comparer; } From bb97a217dcc44be5ee54d7e68cc39c46a0336ed6 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 29 Apr 2025 17:04:40 +0100 Subject: [PATCH 09/24] Changes for 9.5 --- .../FunctionInvokingChatClientV2.Extra.cs | 123 --- .../FunctionInvokingChatClientV2.cs | 909 ------------------ .../KernelFunctionInvokingChatClient.cs | 135 +-- .../AutoFunctionInvocationContext.cs | 4 +- .../Functions/KernelArguments.cs | 2 +- 5 files changed, 10 insertions(+), 1163 deletions(-) delete mode 100644 dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.Extra.cs delete mode 100644 dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.cs diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.Extra.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.Extra.cs deleted file mode 100644 index c6fc614ed179..000000000000 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.Extra.cs +++ /dev/null @@ -1,123 +0,0 @@ -#pragma warning disable IDE0073 // The file header does not match the required text - -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; - -namespace Microsoft.Extensions.AI; - -public partial class FunctionInvokingChatClientV2 : DelegatingChatClient -{ - private static class Throw - { - /// - /// Throws an if the specified argument is . - /// - /// Argument type to be checked for . - /// Object to be checked for . - /// The name of the parameter being checked. - /// The original value of . - [MethodImpl(MethodImplOptions.AggressiveInlining)] - [return: NotNull] - public static T IfNull([NotNull] T argument, [CallerArgumentExpression(nameof(argument))] string paramName = "") - { - if (argument is null) - { - throw new ArgumentNullException(paramName); - } - - return argument; - } - - /// - /// Throws an . - /// - /// A message that describes the error. -#if !NET6_0_OR_GREATER - [MethodImpl(MethodImplOptions.NoInlining)] -#endif - [DoesNotReturn] - public static void InvalidOperationException(string message) - => throw new InvalidOperationException(message); - - /// - /// Throws an . - /// - /// The name of the parameter that caused the exception. -#if !NET6_0_OR_GREATER - [MethodImpl(MethodImplOptions.NoInlining)] -#endif - [DoesNotReturn] - public static void ArgumentOutOfRangeException(string paramName) - => throw new ArgumentOutOfRangeException(paramName); - - /// - /// Throws an . - /// - /// The name of the parameter that caused the exception. - /// A message that describes the error. -#if !NET6_0_OR_GREATER - [MethodImpl(MethodImplOptions.NoInlining)] -#endif - [DoesNotReturn] - public static void ArgumentOutOfRangeException(string paramName, string? message) - => throw new ArgumentOutOfRangeException(paramName, message); - - /// - /// Throws an . - /// - /// The name of the parameter that caused the exception. - /// The value of the argument that caused this exception. - /// A message that describes the error. -#if !NET6_0_OR_GREATER - [MethodImpl(MethodImplOptions.NoInlining)] -#endif - [DoesNotReturn] - public static void ArgumentOutOfRangeException(string paramName, object? actualValue, string? message) - => throw new ArgumentOutOfRangeException(paramName, actualValue, message); - - /// - /// Throws an if the specified number is less than min. - /// - /// Number to be expected being less than min. - /// The number that must be less than the argument. - /// The name of the parameter being checked. - /// The original value of . - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int IfLessThan(int argument, int min, [CallerArgumentExpression(nameof(argument))] string paramName = "") - { - if (argument < min) - { - ArgumentOutOfRangeException(paramName, argument, $"Argument less than minimum value {min}"); - } - - return argument; - } - } - - private static class LoggingHelpers - { - /// Serializes as JSON for logging purposes. - public static string AsJson(T value, System.Text.Json.JsonSerializerOptions? options) - { - if (options?.TryGetTypeInfo(typeof(T), out var typeInfo) is true || - AIJsonUtilities.DefaultOptions.TryGetTypeInfo(typeof(T), out typeInfo)) - { - try - { - return System.Text.Json.JsonSerializer.Serialize(value, typeInfo); - } -#pragma warning disable CA1031 // Do not catch general exception types - catch -#pragma warning restore CA1031 // Do not catch general exception types - { - } - } - - return "{}"; - } - } -} diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.cs deleted file mode 100644 index 18b0f23c8444..000000000000 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvokingChatClientV2.cs +++ /dev/null @@ -1,909 +0,0 @@ -#pragma warning disable IDE0073 // The file header does not match the required text - -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Runtime.CompilerServices; -using System.Runtime.ExceptionServices; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; - -#pragma warning disable CA2213 // Disposable fields should be disposed -#pragma warning disable EA0002 // Use 'System.TimeProvider' to make the code easier to test -#pragma warning disable SA1202 // 'protected' members should come before 'private' members -#pragma warning disable VSTHRD111 // Use ConfigureAwait(bool) -#pragma warning disable CA2007 // Use ConfigureAwait -#pragma warning disable IDE0009 // Add this or Me qualification -#pragma warning disable IDE1006 // Naming Styles - -namespace Microsoft.Extensions.AI; - -/// -/// A delegating chat client that invokes functions defined on . -/// Include this in a chat pipeline to resolve function calls automatically. -/// -/// -/// -/// When this client receives a in a chat response, it responds -/// by calling the corresponding defined in , -/// producing a that it sends back to the inner client. This loop -/// is repeated until there are no more function calls to make, or until another stop condition is met, -/// such as hitting . -/// -/// -/// The provided implementation of is thread-safe for concurrent use so long as the -/// instances employed as part of the supplied are also safe. -/// The property can be used to control whether multiple function invocation -/// requests as part of the same request are invocable concurrently, but even with that set to -/// (the default), multiple concurrent requests to this same instance and using the same tools could result in those -/// tools being used concurrently (one per request). For example, a function that accesses the HttpContext of a specific -/// ASP.NET web request should only be used as part of a single at a time, and only with -/// set to , in case the inner client decided to issue multiple -/// invocation requests to that same function. -/// -/// -public partial class FunctionInvokingChatClientV2 : DelegatingChatClient -{ - /// The for the current function invocation. - private static readonly AsyncLocal _currentContext = new(); - - /// Optional services used for function invocation. - private readonly IServiceProvider? _functionInvocationServices; - - /// The logger to use for logging information about function invocation. - private readonly ILogger _logger; - - /// The to use for telemetry. - /// This component does not own the instance and should not dispose it. - private readonly ActivitySource? _activitySource; - - /// Maximum number of roundtrips allowed to the inner client. - private int _maximumIterationsPerRequest = 10; - - /// Maximum number of consecutive iterations that are allowed contain at least one exception result. If the limit is exceeded, we rethrow the exception instead of continuing. - private int _maximumConsecutiveErrorsPerRequest = 3; - - /// - /// Initializes a new instance of the class. - /// - /// The underlying , or the next instance in a chain of clients. - /// An to use for logging information about function invocation. - /// An optional to use for resolving services required by the instances being invoked. - public FunctionInvokingChatClientV2(IChatClient innerClient, ILoggerFactory? loggerFactory = null, IServiceProvider? functionInvocationServices = null) - : base(innerClient) - { - _logger = (ILogger?)loggerFactory?.CreateLogger() ?? NullLogger.Instance; - _activitySource = innerClient.GetService(); - _functionInvocationServices = functionInvocationServices; - } - - /// - /// Gets or sets the for the current function invocation. - /// - /// - /// This value flows across async calls. - /// - public static FunctionInvocationContextV2? CurrentContext - { - get => _currentContext.Value; - protected set => _currentContext.Value = value; - } - - /// - /// Gets or sets a value indicating whether detailed exception information should be included - /// in the chat history when calling the underlying . - /// - /// - /// if the full exception message is added to the chat history - /// when calling the underlying . - /// if a generic error message is included in the chat history. - /// The default value is . - /// - /// - /// - /// Setting the value to prevents the underlying language model from disclosing - /// raw exception details to the end user, since it doesn't receive that information. Even in this - /// case, the raw object is available to application code by inspecting - /// the property. - /// - /// - /// Setting the value to can help the underlying bypass problems on - /// its own, for example by retrying the function call with different arguments. However it might - /// result in disclosing the raw exception information to external users, which can be a security - /// concern depending on the application scenario. - /// - /// - /// Changing the value of this property while the client is in use might result in inconsistencies - /// as to whether detailed errors are provided during an in-flight request. - /// - /// - public bool IncludeDetailedErrors { get; set; } - - /// - /// Gets or sets a value indicating whether to allow concurrent invocation of functions. - /// - /// - /// if multiple function calls can execute in parallel. - /// if function calls are processed serially. - /// The default value is . - /// - /// - /// An individual response from the inner client might contain multiple function call requests. - /// By default, such function calls are processed serially. Set to - /// to enable concurrent invocation such that multiple function calls can execute in parallel. - /// - public bool AllowConcurrentInvocation { get; set; } - - /// - /// Gets or sets the maximum number of iterations per request. - /// - /// - /// The maximum number of iterations per request. - /// The default value is 10. - /// - /// - /// - /// Each request to this might end up making - /// multiple requests to the inner client. Each time the inner client responds with - /// a function call request, this client might perform that invocation and send the results - /// back to the inner client in a new request. This property limits the number of times - /// such a roundtrip is performed. The value must be at least one, as it includes the initial request. - /// - /// - /// Changing the value of this property while the client is in use might result in inconsistencies - /// as to how many iterations are allowed for an in-flight request. - /// - /// - public int MaximumIterationsPerRequest - { - get => _maximumIterationsPerRequest; - set - { - if (value < 1) - { - Throw.ArgumentOutOfRangeException(nameof(value)); - } - - _maximumIterationsPerRequest = value; - } - } - - /// - /// Gets or sets the maximum number of consecutive iterations that are allowed to fail with an error. - /// - /// - /// The maximum number of consecutive iterations that are allowed to fail with an error. - /// The default value is 3. - /// - /// - /// - /// When function invocations fail with an exception, the - /// continues to make requests to the inner client, optionally supplying exception information (as - /// controlled by ). This allows the to - /// recover from errors by trying other function parameters that may succeed. - /// - /// - /// However, in case function invocations continue to produce exceptions, this property can be used to - /// limit the number of consecutive failing attempts. When the limit is reached, the exception will be - /// rethrown to the caller. - /// - /// - /// If the value is set to zero, all function calling exceptions immediately terminate the function - /// invocation loop and the exception will be rethrown to the caller. - /// - /// - /// Changing the value of this property while the client is in use might result in inconsistencies - /// as to how many iterations are allowed for an in-flight request. - /// - /// - public int MaximumConsecutiveErrorsPerRequest - { - get => _maximumConsecutiveErrorsPerRequest; - set => _maximumConsecutiveErrorsPerRequest = Throw.IfLessThan(value, 0); - } - - /// Optional services used for function invocation. - protected IServiceProvider? FunctionInvocationServices - { - get => _functionInvocationServices; - } - - /// - public override async Task GetResponseAsync( - IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - { - _ = Throw.IfNull(messages); - - // A single request into this GetResponseAsync may result in multiple requests to the inner client. - // Create an activity to group them together for better observability. - using Activity? activity = _activitySource?.StartActivity(nameof(FunctionInvokingChatClient)); - - // Copy the original messages in order to avoid enumerating the original messages multiple times. - // The IEnumerable can represent an arbitrary amount of work. - List originalMessages = [.. messages]; - messages = originalMessages; - - List? augmentedHistory = null; // the actual history of messages sent on turns other than the first - ChatResponse? response = null; // the response from the inner client, which is possibly modified and then eventually returned - List? responseMessages = null; // tracked list of messages, across multiple turns, to be used for the final response - UsageDetails? totalUsage = null; // tracked usage across all turns, to be used for the final response - List? functionCallContents = null; // function call contents that need responding to in the current turn - bool lastIterationHadThreadId = false; // whether the last iteration's response had a ChatThreadId set - int consecutiveErrorCount = 0; - - for (int iteration = 0; ; iteration++) - { - functionCallContents?.Clear(); - - // Make the call to the inner client. - response = await base.GetResponseAsync(messages, options, cancellationToken); - if (response is null) - { - Throw.InvalidOperationException($"The inner {nameof(IChatClient)} returned a null {nameof(ChatResponse)}."); - } - - // Any function call work to do? If yes, ensure we're tracking that work in functionCallContents. - bool requiresFunctionInvocation = - options?.Tools is { Count: > 0 } && - iteration < MaximumIterationsPerRequest && - CopyFunctionCalls(response.Messages, ref functionCallContents); - - // In a common case where we make a request and there's no function calling work required, - // fast path out by just returning the original response. - if (iteration == 0 && !requiresFunctionInvocation) - { - return response; - } - - // Track aggregatable details from the response, including all of the response messages and usage details. - (responseMessages ??= []).AddRange(response.Messages); - if (response.Usage is not null) - { - if (totalUsage is not null) - { - totalUsage.Add(response.Usage); - } - else - { - totalUsage = response.Usage; - } - } - - // If there are no tools to call, or for any other reason we should stop, we're done. - // Break out of the loop and allow the handling at the end to configure the response - // with aggregated data from previous requests. - if (!requiresFunctionInvocation) - { - break; - } - - // Prepare the history for the next iteration. - FixupHistories(originalMessages, ref messages, ref augmentedHistory, response, responseMessages, ref lastIterationHadThreadId); - - // Add the responses from the function calls into the augmented history and also into the tracked - // list of response messages. - var modeAndMessages = await ProcessFunctionCallsAsync(response, augmentedHistory, options!, functionCallContents!, iteration, consecutiveErrorCount, isStreaming: false, cancellationToken); - responseMessages.AddRange(modeAndMessages.MessagesAdded); - consecutiveErrorCount = modeAndMessages.NewConsecutiveErrorCount; - - if (modeAndMessages.ShouldTerminate) - { - break; - } - - UpdateOptionsForNextIteration(ref options!, response.ChatThreadId); - } - - Debug.Assert(responseMessages is not null, "Expected to only be here if we have response messages."); - response.Messages = responseMessages!; - response.Usage = totalUsage; - - return response; - } - - /// - public override async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - _ = Throw.IfNull(messages); - - // A single request into this GetStreamingResponseAsync may result in multiple requests to the inner client. - // Create an activity to group them together for better observability. - using Activity? activity = _activitySource?.StartActivity(nameof(FunctionInvokingChatClient)); - - // Copy the original messages in order to avoid enumerating the original messages multiple times. - // The IEnumerable can represent an arbitrary amount of work. - List originalMessages = [.. messages]; - messages = originalMessages; - - List? augmentedHistory = null; // the actual history of messages sent on turns other than the first - List? functionCallContents = null; // function call contents that need responding to in the current turn - List? responseMessages = null; // tracked list of messages, across multiple turns, to be used in fallback cases to reconstitute history - bool lastIterationHadThreadId = false; // whether the last iteration's response had a ChatThreadId set - List updates = []; // updates from the current response - int consecutiveErrorCount = 0; - - for (int iteration = 0; ; iteration++) - { - updates.Clear(); - functionCallContents?.Clear(); - - await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken)) - { - if (update is null) - { - Throw.InvalidOperationException($"The inner {nameof(IChatClient)} streamed a null {nameof(ChatResponseUpdate)}."); - } - - updates.Add(update); - - _ = CopyFunctionCalls(update.Contents, ref functionCallContents); - - yield return update; - 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 } || - options?.Tools is not { Count: > 0 } || - iteration >= _maximumIterationsPerRequest) - { - break; - } - - // Reconsistitue a response from the response updates. - var response = updates.ToChatResponse(); - (responseMessages ??= []).AddRange(response.Messages); - - // Prepare the history for the next iteration. - FixupHistories(originalMessages, ref messages, ref augmentedHistory, response, responseMessages, ref lastIterationHadThreadId); - - // Process all of the functions, adding their results into the history. - var modeAndMessages = await ProcessFunctionCallsAsync(response, augmentedHistory, options, functionCallContents, iteration, consecutiveErrorCount, isStreaming: true, cancellationToken); - 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 activitys, including generated function results. - foreach (var message in modeAndMessages.MessagesAdded) - { - var toolResultUpdate = new ChatResponseUpdate - { - AdditionalProperties = message.AdditionalProperties, - AuthorName = message.AuthorName, - ChatThreadId = response.ChatThreadId, - 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; - Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802 - } - - if (modeAndMessages.ShouldTerminate) - { - yield break; - } - - UpdateOptionsForNextIteration(ref options, response.ChatThreadId); - } - } - - /// Prepares the various chat message lists after a response from the inner client and before invoking functions. - /// The original messages provided by the caller. - /// The messages reference passed to the inner client. - /// The augmented history containing all the messages to be sent. - /// The most recent response being handled. - /// A list of all response messages received up until this point. - /// Whether the previous iteration's response had a thread id. - private static void FixupHistories( - IEnumerable originalMessages, - ref IEnumerable messages, - [NotNull] ref List? augmentedHistory, - ChatResponse response, - List allTurnsResponseMessages, - ref bool lastIterationHadThreadId) - { - // We're now going to need to augment the history with function result contents. - // That means we need a separate list to store the augmented history. - if (response.ChatThreadId is not null) - { - // The response indicates the inner client is tracking the history, so we don't want to send - // anything we've already sent or received. - if (augmentedHistory is not null) - { - augmentedHistory.Clear(); - } - else - { - augmentedHistory = []; - } - - lastIterationHadThreadId = true; - } - else if (lastIterationHadThreadId) - { - // In the very rare case where the inner client returned a response with a thread ID but then - // returned a subsequent response without one, we want to reconstitute the full history. To do that, - // we can populate the history with the original chat messages and then all of the response - // messages up until this point, which includes the most recent ones. - augmentedHistory ??= []; - augmentedHistory.Clear(); - augmentedHistory.AddRange(originalMessages); - augmentedHistory.AddRange(allTurnsResponseMessages); - - lastIterationHadThreadId = false; - } - else - { - // If augmentedHistory is already non-null, then we've already populated it with everything up - // until this point (except for the most recent response). If it's null, we need to seed it with - // the chat history provided by the caller. - augmentedHistory ??= originalMessages.ToList(); - - // Now add the most recent response messages. - augmentedHistory.AddMessages(response); - - lastIterationHadThreadId = false; - } - - // Use the augmented history as the new set of messages to send. - messages = augmentedHistory; - } - - /// Copies any from to . - private static bool CopyFunctionCalls( - IList messages, [NotNullWhen(true)] ref List? functionCalls) - { - bool any = false; - int count = messages.Count; - for (int i = 0; i < count; i++) - { - any |= CopyFunctionCalls(messages[i].Contents, ref functionCalls); - } - - return any; - } - - /// Copies any from to . - private static bool CopyFunctionCalls( - IList content, [NotNullWhen(true)] ref List? functionCalls) - { - bool any = false; - int count = content.Count; - for (int i = 0; i < count; i++) - { - if (content[i] is FunctionCallContent functionCall) - { - (functionCalls ??= []).Add(functionCall); - any = true; - } - } - - return any; - } - - private static void UpdateOptionsForNextIteration(ref ChatOptions options, string? chatThreadId) - { - if (options.ToolMode is RequiredChatToolMode) - { - // We have to reset the tool mode to be non-required after the first iteration, - // as otherwise we'll be in an infinite loop. - options = options.Clone(); - options.ToolMode = null; - options.ChatThreadId = chatThreadId; - } - else if (options.ChatThreadId != chatThreadId) - { - // As with the other modes, ensure we've propagated the chat thread ID to the options. - // We only need to clone the options if we're actually mutating it. - options = options.Clone(); - options.ChatThreadId = chatThreadId; - } - } - - /// - /// Processes the function calls in the list. - /// - /// The response being processed. - /// The current chat contents, inclusive of the function call contents being processed. - /// The options used for the response being processed. - /// The function call contents representing the functions to be invoked. - /// The iteration number of how many roundtrips have been made to the inner client. - /// The number of consecutive iterations, prior to this one, that were recorded as having function invocation errors. - /// Whether the function calls are being processed in a streaming context. - /// The to monitor for cancellation requests. - /// A value indicating how the caller should proceed. - protected virtual async Task<(bool ShouldTerminate, int NewConsecutiveErrorCount, IList MessagesAdded)> ProcessFunctionCallsAsync( - ChatResponse response, List messages, ChatOptions options, List functionCallContents, int iteration, int consecutiveErrorCount, bool isStreaming, CancellationToken cancellationToken) - { - // We must add a response for every tool call, regardless of whether we successfully executed it or not. - // If we successfully execute it, we'll add the result. If we don't, we'll add an error. - - Debug.Assert(functionCallContents.Count > 0, "Expected at least one function call."); - - var captureCurrentIterationExceptions = consecutiveErrorCount < _maximumConsecutiveErrorsPerRequest; - - // Process all functions. If there's more than one and concurrent invocation is enabled, do so in parallel. - if (functionCallContents.Count == 1) - { - FunctionInvocationResult result = await ProcessFunctionCallAsync( - messages, options, functionCallContents, iteration, 0, captureCurrentIterationExceptions, isStreaming, cancellationToken); - - IList added = CreateResponseMessages([result]); - ThrowIfNoFunctionResultsAdded(added); - UpdateConsecutiveErrorCountOrThrow(added, ref consecutiveErrorCount); - - messages.AddRange(added); - return (result.ShouldTerminate, consecutiveErrorCount, added); - } - else - { - FunctionInvocationResult[] results; - - if (AllowConcurrentInvocation) - { - // Rather than await'ing each function before invoking the next, invoke all of them - // and then await all of them. We avoid forcibly introducing parallelism via Task.Run, - // but if a function invocation completes asynchronously, its processing can overlap - // with the processing of other the other invocation invocations. - results = await Task.WhenAll( - from i in Enumerable.Range(0, functionCallContents.Count) - select ProcessFunctionCallAsync( - messages, options, functionCallContents, - iteration, i, captureExceptions: true, isStreaming, cancellationToken)); - } - else - { - // Invoke each function serially. - results = new FunctionInvocationResult[functionCallContents.Count]; - for (int i = 0; i < results.Length; i++) - { - results[i] = await ProcessFunctionCallAsync( - messages, options, functionCallContents, - iteration, i, captureCurrentIterationExceptions, isStreaming, cancellationToken); - } - } - - var shouldTerminate = false; - - IList added = CreateResponseMessages(results); - ThrowIfNoFunctionResultsAdded(added); - UpdateConsecutiveErrorCountOrThrow(added, ref consecutiveErrorCount); - - messages.AddRange(added); - foreach (FunctionInvocationResult fir in results) - { - shouldTerminate = shouldTerminate || fir.ShouldTerminate; - } - - return (shouldTerminate, consecutiveErrorCount, added); - } - } - -#pragma warning disable CA1851 // Possible multiple enumerations of 'IEnumerable' collection - /// - /// TODO: Documentation - /// - /// - /// - /// - protected void UpdateConsecutiveErrorCountOrThrow(IList added, ref int consecutiveErrorCount) - { - var allExceptions = added.SelectMany(m => m.Contents.OfType()) - .Select(frc => frc.Exception!) - .Where(e => e is not null); - - if (allExceptions.Any()) - { - consecutiveErrorCount++; - if (consecutiveErrorCount > _maximumConsecutiveErrorsPerRequest) - { - var allExceptionsArray = allExceptions.ToArray(); - if (allExceptionsArray.Length == 1) - { - ExceptionDispatchInfo.Capture(allExceptionsArray[0]).Throw(); - } - else - { - throw new AggregateException(allExceptionsArray); - } - } - } - else - { - consecutiveErrorCount = 0; - } - } -#pragma warning restore CA1851 - - /// - /// Throws an exception if doesn't create any messages. - /// - protected void ThrowIfNoFunctionResultsAdded(IList? messages) - { - if (messages is null || messages.Count == 0) - { - Throw.InvalidOperationException($"{GetType().Name}.{nameof(CreateResponseMessages)} returned null or an empty collection of messages."); - } - } - - /// Processes the function call described in []. - /// The current chat contents, inclusive of the function call contents being processed. - /// The options used for the response being processed. - /// The function call contents representing all the functions being invoked. - /// The iteration number of how many roundtrips have been made to the inner client. - /// The 0-based index of the function being called out of . - /// If true, handles function-invocation exceptions by returning a value with . Otherwise, rethrows. - /// Whether the function calls are being processed in a streaming context. - /// The to monitor for cancellation requests. - /// A value indicating how the caller should proceed. - protected virtual async Task ProcessFunctionCallAsync( - List messages, ChatOptions options, List callContents, - int iteration, int functionCallIndex, bool captureExceptions, bool isStreaming, CancellationToken cancellationToken) - { - var callContent = callContents[functionCallIndex]; - - // Look up the AIFunction for the function call. If the requested function isn't available, send back an error. - AIFunction? function = options.Tools!.OfType().FirstOrDefault(t => t.Name == callContent.Name); - if (function is null) - { - return new(shouldTerminate: false, FunctionInvocationStatus.NotFound, callContent, result: null, exception: null); - } - - FunctionInvocationContextV2 context = new() - { - Function = function, - Arguments = new(callContent.Arguments) { Services = _functionInvocationServices }, - - Messages = messages, - Options = options, - - CallContent = callContent, - Iteration = iteration, - FunctionCallIndex = functionCallIndex, - FunctionCount = callContents.Count, - IsStreaming = isStreaming - }; - - object? result; - try - { - result = await InvokeFunctionAsync(context, cancellationToken); - } - catch (Exception e) when (!cancellationToken.IsCancellationRequested) - { - if (!captureExceptions) - { - throw; - } - - return new( - shouldTerminate: false, - FunctionInvocationStatus.Exception, - callContent, - result: null, - exception: e); - } - - return new( - shouldTerminate: context.Terminate, - FunctionInvocationStatus.RanToCompletion, - callContent, - result, - exception: null); - } - - /// Creates one or more response messages for function invocation results. - /// Information about the function call invocations and results. - /// A list of all chat messages created from . - protected virtual IList CreateResponseMessages( - ReadOnlySpan results) - { - var contents = new List(results.Length); - for (int i = 0; i < results.Length; i++) - { - contents.Add(CreateFunctionResultContent(results[i])); - } - - return [new(ChatRole.Tool, contents)]; - - FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult result) - { - _ = Throw.IfNull(result); - - object? functionResult; - if (result.Status == FunctionInvocationStatus.RanToCompletion) - { - functionResult = result.Result ?? "Success: Function completed."; - } - else - { - string message = result.Status switch - { - FunctionInvocationStatus.NotFound => $"Error: Requested function \"{result.CallContent.Name}\" not found.", - FunctionInvocationStatus.Exception => "Error: Function failed.", - _ => "Error: Unknown error.", - }; - - if (IncludeDetailedErrors && result.Exception is not null) - { - message = $"{message} Exception: {result.Exception.Message}"; - } - - functionResult = message; - } - - return new FunctionResultContent(result.CallContent.CallId, functionResult) { Exception = result.Exception }; - } - } - - /// Invokes the function asynchronously. - /// - /// The function invocation context detailing the function to be invoked and its arguments along with additional request information. - /// - /// The to monitor for cancellation requests. The default is . - /// The result of the function invocation, or if the function invocation returned . - /// is . - protected virtual async Task InvokeFunctionAsync(FunctionInvocationContextV2 context, CancellationToken cancellationToken) - { - _ = Throw.IfNull(context); - - using Activity? activity = _activitySource?.StartActivity(context.Function.Name); - - long startingTimestamp = 0; - if (_logger.IsEnabled(LogLevel.Debug)) - { - startingTimestamp = Stopwatch.GetTimestamp(); - if (_logger.IsEnabled(LogLevel.Trace)) - { - LogInvokingSensitive(context.Function.Name, LoggingHelpers.AsJson(context.Arguments, context.Function.JsonSerializerOptions)); - } - else - { - LogInvoking(context.Function.Name); - } - } - - object? result = null; - try - { - CurrentContext = context; // doesn't need to be explicitly reset after, as that's handled automatically at async method exit - (context, result) = await TryInvokeFunctionAsync(context, cancellationToken); - } - catch (Exception e) - { - if (activity is not null) - { - _ = activity.SetTag("error.type", e.GetType().FullName) - .SetStatus(ActivityStatusCode.Error, e.Message); - } - - if (e is OperationCanceledException) - { - LogInvocationCanceled(context.Function.Name); - } - else - { - LogInvocationFailed(context.Function.Name, e); - } - - throw; - } - finally - { - if (_logger.IsEnabled(LogLevel.Debug)) - { - TimeSpan elapsed = GetElapsedTime(startingTimestamp); - - if (result is not null && _logger.IsEnabled(LogLevel.Trace)) - { - LogInvocationCompletedSensitive(context.Function.Name, elapsed, LoggingHelpers.AsJson(result, context.Function.JsonSerializerOptions)); - } - else - { - LogInvocationCompleted(context.Function.Name, elapsed); - } - } - } - - return result; - } - - /// - /// This method will execute auto function invocation filters and function recursively within the try block - /// - /// The function invocation context. - /// Cancellation token. - /// The function invocation context and result. - protected virtual async Task<(FunctionInvocationContextV2 context, object? result)> TryInvokeFunctionAsync(FunctionInvocationContextV2 context, CancellationToken cancellationToken) - { - var result = await context.Function.InvokeAsync(new(context.Arguments), cancellationToken); - - return (context, result); - } - - private static TimeSpan GetElapsedTime(long startingTimestamp) => -#if NET - Stopwatch.GetElapsedTime(startingTimestamp); -#else - new((long)((Stopwatch.GetTimestamp() - startingTimestamp) * ((double)TimeSpan.TicksPerSecond / Stopwatch.Frequency))); -#endif - - [LoggerMessage(LogLevel.Debug, "Invoking {MethodName}.", SkipEnabledCheck = true)] - private partial void LogInvoking(string methodName); - - [LoggerMessage(LogLevel.Trace, "Invoking {MethodName}({Arguments}).", SkipEnabledCheck = true)] - private partial void LogInvokingSensitive(string methodName, string arguments); - - [LoggerMessage(LogLevel.Debug, "{MethodName} invocation completed. Duration: {Duration}", SkipEnabledCheck = true)] - private partial void LogInvocationCompleted(string methodName, TimeSpan duration); - - [LoggerMessage(LogLevel.Trace, "{MethodName} invocation completed. Duration: {Duration}. Result: {Result}", SkipEnabledCheck = true)] - private partial void LogInvocationCompletedSensitive(string methodName, TimeSpan duration, string result); - - [LoggerMessage(LogLevel.Debug, "{MethodName} invocation canceled.")] - private partial void LogInvocationCanceled(string methodName); - - [LoggerMessage(LogLevel.Error, "{MethodName} invocation failed.")] - private partial void LogInvocationFailed(string methodName, Exception error); - - /// Provides information about the invocation of a function call. - public sealed class FunctionInvocationResult - { - internal FunctionInvocationResult(bool shouldTerminate, FunctionInvocationStatus status, FunctionCallContent callContent, object? result, Exception? exception) - { - ShouldTerminate = shouldTerminate; - Status = status; - CallContent = callContent; - Result = result; - Exception = exception; - } - - /// Gets status about how the function invocation completed. - public FunctionInvocationStatus Status { get; } - - /// Gets the function call content information associated with this invocation. - public FunctionCallContent CallContent { get; } - - /// Gets the result of the function call. - public object? Result { get; } - - /// Gets any exception the function call threw. - public Exception? Exception { get; } - - /// Gets a value indicating whether the caller should terminate the processing loop. - internal bool ShouldTerminate { get; } - } - - /// Provides error codes for when errors occur as part of the function calling loop. - public enum FunctionInvocationStatus - { - /// The operation completed successfully. - RanToCompletion, - - /// The requested function could not be found. - NotFound, - - /// The function call failed with an exception. - Exception, - } -} diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs index 4bb0edabdd46..7c6efb58baed 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -13,9 +12,9 @@ namespace Microsoft.Extensions.AI; /// -/// Specialization of that uses and supports . +/// Specialization of that uses and supports . /// -internal sealed class KernelFunctionInvokingChatClient : FunctionInvokingChatClientV2 +internal sealed class KernelFunctionInvokingChatClient : FunctionInvokingChatClient { /// public KernelFunctionInvokingChatClient(IChatClient innerClient, ILoggerFactory? loggerFactory = null, IServiceProvider? functionInvocationServices = null) @@ -56,91 +55,14 @@ private static void ClearOptionsForAutoFunctionInvocation(ref ChatOptions option /// protected override async Task<(bool ShouldTerminate, int NewConsecutiveErrorCount, IList MessagesAdded)> ProcessFunctionCallsAsync( - ChatResponse response, List messages, ChatOptions options, List functionCallContents, int iteration, int consecutiveErrorCount, bool isStreaming, CancellationToken cancellationToken) + ChatResponse response, IList messages, ChatOptions options, IList functionCallContents, int iteration, int consecutiveErrorCount, bool isStreaming, CancellationToken cancellationToken) { // Prepare the options for the next auto function invocation iteration. UpdateOptionsForAutoFunctionInvocation(ref options!, response.Messages.Last().ToChatMessageContent(), isStreaming: false); // We must add a response for every tool call, regardless of whether we successfully executed it or not. // If we successfully execute it, we'll add the result. If we don't, we'll add an error. - (bool ShouldTerminate, int NewConsecutiveErrorCount, IList MessagesAdded) result; - Debug.Assert(functionCallContents.Count > 0, "Expected at least one function call."); - var shouldTerminate = false; - - var captureCurrentIterationExceptions = consecutiveErrorCount < this.MaximumConsecutiveErrorsPerRequest; - - // Process all functions. If there's more than one and concurrent invocation is enabled, do so in parallel. - if (functionCallContents.Count == 1) - { - FunctionInvocationResult functionResult = await this.ProcessFunctionCallAsync( - messages, options, functionCallContents, iteration, 0, captureCurrentIterationExceptions, isStreaming, cancellationToken).ConfigureAwait(false); - - IList added = this.CreateResponseMessages([functionResult]); - this.ThrowIfNoFunctionResultsAdded(added); - this.UpdateConsecutiveErrorCountOrThrow(added, ref consecutiveErrorCount); - - messages.AddRange(added); - result = (functionResult.ShouldTerminate, consecutiveErrorCount, added); - } - else - { - List results = []; - - var terminationRequested = false; - if (this.AllowConcurrentInvocation) - { - // Rather than awaiting each function before invoking the next, invoke all of them - // and then await all of them. We avoid forcibly introducing parallelism via Task.Run, - // but if a function invocation completes asynchronously, its processing can overlap - // with the processing of other the other invocation invocations. - results.AddRange(await Task.WhenAll( - from i in Enumerable.Range(0, functionCallContents.Count) - select this.ProcessFunctionCallAsync( - messages, options, functionCallContents, - iteration, i, captureExceptions: true, isStreaming, cancellationToken)).ConfigureAwait(false)); - - terminationRequested = results.Any(r => r.ShouldTerminate); - } - else - { - // Invoke each function serially. - for (int i = 0; i < functionCallContents.Count; i++) - { - var functionResult = await this.ProcessFunctionCallAsync( - messages, options, functionCallContents, - iteration, i, captureCurrentIterationExceptions, isStreaming, cancellationToken).ConfigureAwait(false); - - results.Add(functionResult); - - // Differently from the original FunctionInvokingChatClient, as soon the termination is requested, - // we stop and don't continue, if that can be parametrized, this override won't be needed - if (functionResult.ShouldTerminate) - { - shouldTerminate = true; - terminationRequested = true; - break; - } - } - } - - IList added = this.CreateResponseMessages(results); - this.ThrowIfNoFunctionResultsAdded(added); - this.UpdateConsecutiveErrorCountOrThrow(added, ref consecutiveErrorCount); - - messages.AddRange(added); - - if (!terminationRequested) - { - // If any function requested termination, we'll terminate. - shouldTerminate = false; - foreach (FunctionInvocationResult fir in results) - { - shouldTerminate = shouldTerminate || fir.ShouldTerminate; - } - } - - result = (shouldTerminate, consecutiveErrorCount, added); - } + var result = await base.ProcessFunctionCallsAsync(response, messages, options, functionCallContents, iteration, consecutiveErrorCount, isStreaming, cancellationToken).ConfigureAwait(false); // Clear the auto function invocation options. ClearOptionsForAutoFunctionInvocation(ref options); @@ -150,7 +72,7 @@ select this.ProcessFunctionCallAsync( /// protected override async Task ProcessFunctionCallAsync( - List messages, ChatOptions options, List callContents, + IList messages, ChatOptions options, IList callContents, int iteration, int functionCallIndex, bool captureExceptions, bool isStreaming, CancellationToken cancellationToken) { var callContent = callContents[functionCallIndex]; @@ -177,7 +99,7 @@ protected override async Task ProcessFunctionCallAsync object? result; try { - result = await this.InvokeFunctionAsync(context, cancellationToken).ConfigureAwait(false); + result = await base.InvokeFunctionAsync(context, cancellationToken).ConfigureAwait(false); } catch (Exception e) when (!cancellationToken.IsCancellationRequested) { @@ -202,49 +124,6 @@ protected override async Task ProcessFunctionCallAsync exception: null); } - /// Creates one or more response messages for function invocation results. - /// Information about the function call invocations and results. - /// A list of all chat messages created from . - private IList CreateResponseMessages(List results) - { - var contents = new List(results.Count); - for (int i = 0; i < results.Count; i++) - { - contents.Add(CreateFunctionResultContent(results[i])); - } - - return [new(ChatRole.Tool, contents)]; - - FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult result) - { - Verify.NotNull(result); - - object? functionResult; - if (result.Status == FunctionInvocationStatus.RanToCompletion) - { - functionResult = result.Result ?? "Success: Function completed."; - } - else - { - string message = result.Status switch - { - FunctionInvocationStatus.NotFound => $"Error: Requested function \"{result.CallContent.Name}\" not found.", - FunctionInvocationStatus.Exception => "Error: Function failed.", - _ => "Error: Unknown error.", - }; - - if (this.IncludeDetailedErrors && result.Exception is not null) - { - message = $"{message} Exception: {result.Exception.Message}"; - } - - functionResult = message; - } - - return new FunctionResultContent(result.CallContent.CallId, functionResult) { Exception = result.Exception }; - } - } - /// /// Invokes the auto function invocation filters. /// @@ -288,7 +167,7 @@ await autoFunctionInvocationFilters[index].OnAutoFunctionInvocationAsync( } /// - protected override async Task<(FunctionInvocationContextV2 context, object? result)> TryInvokeFunctionAsync(FunctionInvocationContextV2 context, CancellationToken cancellationToken) + protected override async Task<(FunctionInvocationContext context, object? result)> InvokeFunctionCoreAsync(Microsoft.Extensions.AI.FunctionInvocationContext context, CancellationToken cancellationToken) { object? result = null; if (context is AutoFunctionInvocationContext autoContext) diff --git a/dotnet/src/SemanticKernel.Abstractions/Filters/AutoFunctionInvocation/AutoFunctionInvocationContext.cs b/dotnet/src/SemanticKernel.Abstractions/Filters/AutoFunctionInvocation/AutoFunctionInvocationContext.cs index cd79f13cca6e..478c2dae1d3e 100644 --- a/dotnet/src/SemanticKernel.Abstractions/Filters/AutoFunctionInvocation/AutoFunctionInvocationContext.cs +++ b/dotnet/src/SemanticKernel.Abstractions/Filters/AutoFunctionInvocation/AutoFunctionInvocationContext.cs @@ -13,7 +13,7 @@ namespace Microsoft.SemanticKernel; /// /// Class with data related to automatic function invocation. /// -public class AutoFunctionInvocationContext : FunctionInvocationContextV2 +public class AutoFunctionInvocationContext : Microsoft.Extensions.AI.FunctionInvocationContext { private ChatHistory? _chatHistory; private KernelFunction? _kernelFunction; @@ -108,7 +108,7 @@ public AutoFunctionInvocationContext( /// /// Get the with which this filter is associated. /// - public AIFunctionArgumentsV2 AIArguments + public AIFunctionArguments AIArguments { get => base.Arguments; init => base.Arguments = value; diff --git a/dotnet/src/SemanticKernel.Abstractions/Functions/KernelArguments.cs b/dotnet/src/SemanticKernel.Abstractions/Functions/KernelArguments.cs index 2c6d406e497a..419a12039049 100644 --- a/dotnet/src/SemanticKernel.Abstractions/Functions/KernelArguments.cs +++ b/dotnet/src/SemanticKernel.Abstractions/Functions/KernelArguments.cs @@ -17,7 +17,7 @@ namespace Microsoft.SemanticKernel; /// A is a dictionary of argument names and values. It also carries a /// , accessible via the property. /// -public sealed class KernelArguments : AIFunctionArgumentsV2 +public sealed class KernelArguments : AIFunctionArguments { private IReadOnlyDictionary? _executionSettings; From 7c014a55d5a769418f5ed795f0edf99c6e5754f9 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 29 Apr 2025 21:39:43 +0100 Subject: [PATCH 10/24] SK update to use latest MEAI 9.5 Function invoking chat client --- dotnet/Directory.Packages.props | 8 +-- dotnet/nuget.config | 7 ++- .../InMemoryVectorStoreTests.cs | 3 +- .../OllamaServiceCollectionExtensions.cs | 49 ++++++------------- .../FunctionCalling/FunctionCallsProcessor.cs | 2 +- .../KernelFunctionInvokingChatClient.cs | 1 + .../AI/ServiceConversionExtensionsTests.cs | 2 +- 7 files changed, 29 insertions(+), 43 deletions(-) diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index e08945ac1ae0..fbd0a578f209 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -94,10 +94,10 @@ - - - - + + + + diff --git a/dotnet/nuget.config b/dotnet/nuget.config index 7159fcd04c36..fa87ed4c8a74 100644 --- a/dotnet/nuget.config +++ b/dotnet/nuget.config @@ -1,15 +1,20 @@ - + + + + + + diff --git a/dotnet/src/Connectors/Connectors.InMemory.UnitTests/InMemoryVectorStoreTests.cs b/dotnet/src/Connectors/Connectors.InMemory.UnitTests/InMemoryVectorStoreTests.cs index 14d54969d8c3..ac2e4ddfb770 100644 --- a/dotnet/src/Connectors/Connectors.InMemory.UnitTests/InMemoryVectorStoreTests.cs +++ b/dotnet/src/Connectors/Connectors.InMemory.UnitTests/InMemoryVectorStoreTests.cs @@ -59,7 +59,8 @@ public async Task ListCollectionNamesReadsDictionaryAsync() // Assert. var collectionNamesList = await collectionNames.ToListAsync(); - Assert.Equal(new[] { "collection1", "collection2" }, collectionNamesList); + Assert.Contains("collection1", collectionNamesList); + Assert.Contains("collection2", collectionNamesList); } [Fact] diff --git a/dotnet/src/Connectors/Connectors.Ollama/Extensions/OllamaServiceCollectionExtensions.cs b/dotnet/src/Connectors/Connectors.Ollama/Extensions/OllamaServiceCollectionExtensions.cs index 220737be2749..90c18ad5aada 100644 --- a/dotnet/src/Connectors/Connectors.Ollama/Extensions/OllamaServiceCollectionExtensions.cs +++ b/dotnet/src/Connectors/Connectors.Ollama/Extensions/OllamaServiceCollectionExtensions.cs @@ -155,16 +155,16 @@ public static IServiceCollection AddOllamaChatCompletion( { var loggerFactory = serviceProvider.GetService(); - var builder = ((IChatClient)new OllamaApiClient(endpoint, modelId)) - .AsBuilder() - .UseFunctionInvocation(loggerFactory, config => config.MaximumIterationsPerRequest = MaxInflightAutoInvokes); + var ollamaClient = (IChatClient)new OllamaApiClient(endpoint, modelId); if (loggerFactory is not null) { - builder.UseLogging(loggerFactory); + ollamaClient.AsBuilder().UseLogging(loggerFactory).Build(); } - return builder.Build(serviceProvider).AsChatCompletionService(serviceProvider); + return ollamaClient + .AsKernelFunctionInvokingChatClient(loggerFactory) + .AsChatCompletionService(); }); } @@ -190,16 +190,16 @@ public static IServiceCollection AddOllamaChatCompletion( var loggerFactory = serviceProvider.GetService(); - var builder = ((IChatClient)new OllamaApiClient(httpClient, modelId)) - .AsBuilder() - .UseFunctionInvocation(loggerFactory, config => config.MaximumIterationsPerRequest = MaxInflightAutoInvokes); + var ollamaClient = (IChatClient)new OllamaApiClient(httpClient, modelId); if (loggerFactory is not null) { - builder.UseLogging(loggerFactory); + ollamaClient.AsBuilder().UseLogging(loggerFactory).Build(); } - return builder.Build(serviceProvider).AsChatCompletionService(serviceProvider); + return ollamaClient + .AsKernelFunctionInvokingChatClient(loggerFactory) + .AsChatCompletionService(); }); } @@ -231,15 +231,16 @@ public static IServiceCollection AddOllamaChatCompletion( } var builder = ((IChatClient)ollamaClient) - .AsBuilder() - .UseFunctionInvocation(loggerFactory, config => config.MaximumIterationsPerRequest = MaxInflightAutoInvokes); + .AsKernelFunctionInvokingChatClient(loggerFactory) + .AsBuilder(); if (loggerFactory is not null) { builder.UseLogging(loggerFactory); } - return builder.Build(serviceProvider).AsChatCompletionService(serviceProvider); + return builder.Build(serviceProvider) + .AsChatCompletionService(serviceProvider); }); } @@ -355,26 +356,4 @@ public static IServiceCollection AddOllamaTextEmbeddingGeneration( } #endregion - - #region Private - - /// - /// The maximum number of auto-invokes that can be in-flight at any given time as part of the current - /// asynchronous chain of execution. - /// - /// - /// This is a fail-safe mechanism. If someone accidentally manages to set up execution settings in such a way that - /// auto-invocation is invoked recursively, and in particular where a prompt function is able to auto-invoke itself, - /// we could end up in an infinite loop. This const is a backstop against that happening. We should never come close - /// to this limit, but if we do, auto-invoke will be disabled for the current flow in order to prevent runaway execution. - /// With the current setup, the way this could possibly happen is if a prompt function is configured with built-in - /// execution settings that opt-in to auto-invocation of everything in the kernel, in which case the invocation of that - /// prompt function could advertize itself as a candidate for auto-invocation. We don't want to outright block that, - /// if that's something a developer has asked to do (e.g. it might be invoked with different arguments than its parent - /// was invoked with), but we do want to limit it. This limit is arbitrary and can be tweaked in the future and/or made - /// configurable should need arise. - /// - private const int MaxInflightAutoInvokes = 128; - - #endregion } diff --git a/dotnet/src/InternalUtilities/connectors/AI/FunctionCalling/FunctionCallsProcessor.cs b/dotnet/src/InternalUtilities/connectors/AI/FunctionCalling/FunctionCallsProcessor.cs index 88f3da9d6a53..55a300769812 100644 --- a/dotnet/src/InternalUtilities/connectors/AI/FunctionCalling/FunctionCallsProcessor.cs +++ b/dotnet/src/InternalUtilities/connectors/AI/FunctionCalling/FunctionCallsProcessor.cs @@ -51,7 +51,7 @@ internal sealed class FunctionCallsProcessor /// will be disabled. This is a safeguard against possible runaway execution if the model routinely re-requests /// the same function over and over. /// - private const int MaximumAutoInvokeAttempts = 128; + internal const int MaximumAutoInvokeAttempts = 128; /// Tracking for . /// diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs index 7c6efb58baed..2001435a51b9 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs @@ -20,6 +20,7 @@ internal sealed class KernelFunctionInvokingChatClient : FunctionInvokingChatCli public KernelFunctionInvokingChatClient(IChatClient innerClient, ILoggerFactory? loggerFactory = null, IServiceProvider? functionInvocationServices = null) : base(innerClient, loggerFactory, functionInvocationServices) { + this.MaximumIterationsPerRequest = 128; } private static void UpdateOptionsForAutoFunctionInvocation(ref ChatOptions options, ChatMessageContent content, bool isStreaming) diff --git a/dotnet/src/SemanticKernel.UnitTests/AI/ServiceConversionExtensionsTests.cs b/dotnet/src/SemanticKernel.UnitTests/AI/ServiceConversionExtensionsTests.cs index b8a197e0a1f3..e2e78fd5019b 100644 --- a/dotnet/src/SemanticKernel.UnitTests/AI/ServiceConversionExtensionsTests.cs +++ b/dotnet/src/SemanticKernel.UnitTests/AI/ServiceConversionExtensionsTests.cs @@ -110,7 +110,7 @@ public async Task AsEmbeddingGeneratorConvertedAsExpected() }, }.AsEmbeddingGenerator(); - ReadOnlyMemory embedding = await generator.GenerateEmbeddingVectorAsync("some text"); + ReadOnlyMemory embedding = await generator.GenerateVectorAsync("some text"); Assert.Equal([1f, 2f, 3f], embedding.ToArray()); } From 340fbbeef250f4a88df956bfa45f26cc7431358c Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Wed, 30 Apr 2025 17:39:22 +0100 Subject: [PATCH 11/24] Update to latest changes in 9.5.0-dev --- ...FunctionInvocationFilterChatClientTests.cs | 2 +- .../AI/ChatClient/ChatOptionsExtensions.cs | 1 - .../KernelFunctionInvokingChatClient.cs | 157 +++++------------- 3 files changed, 41 insertions(+), 119 deletions(-) diff --git a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Core/AutoFunctionInvocationFilterChatClientTests.cs b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Core/AutoFunctionInvocationFilterChatClientTests.cs index dd8d94c99824..bb22b67b49a8 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Core/AutoFunctionInvocationFilterChatClientTests.cs +++ b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Core/AutoFunctionInvocationFilterChatClientTests.cs @@ -736,7 +736,7 @@ public void Dispose() private static object? GetLastFunctionResultFromChatResponse(ChatResponse chatResponse) { Assert.NotEmpty(chatResponse.Messages); - var chatMessage = chatResponse.Messages[^1]; + var chatMessage = chatResponse.Messages.Where(m => m.Role == ChatRole.Tool).Last(); Assert.NotEmpty(chatMessage.Contents); Assert.Contains(chatMessage.Contents, c => c is Microsoft.Extensions.AI.FunctionResultContent); diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatOptionsExtensions.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatOptionsExtensions.cs index 68540a1c32d8..1179de5f99b7 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatOptionsExtensions.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatOptionsExtensions.cs @@ -14,7 +14,6 @@ namespace Microsoft.SemanticKernel.ChatCompletion; internal static class ChatOptionsExtensions { internal const string KernelKey = "AutoInvokingKernel"; - internal const string IsStreamingKey = "AutoInvokingIsStreaming"; internal const string ChatMessageContentKey = "AutoInvokingChatCompletionContent"; internal const string PromptExecutionSettingsKey = "AutoInvokingPromptExecutionSettings"; diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs index 2001435a51b9..7b50ef8761e5 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs @@ -23,108 +23,17 @@ public KernelFunctionInvokingChatClient(IChatClient innerClient, ILoggerFactory? this.MaximumIterationsPerRequest = 128; } - private static void UpdateOptionsForAutoFunctionInvocation(ref ChatOptions options, ChatMessageContent content, bool isStreaming) + private static void UpdateOptionsForAutoFunctionInvocation(ChatOptions options, ChatMessageContent content) { - if (options.AdditionalProperties?.ContainsKey(ChatOptionsExtensions.IsStreamingKey) ?? false) - { - throw new KernelException($"The reserved key name '{ChatOptionsExtensions.IsStreamingKey}' is already specified in the options. Avoid using this key name."); - } - if (options.AdditionalProperties?.ContainsKey(ChatOptionsExtensions.ChatMessageContentKey) ?? false) { - throw new KernelException($"The reserved key name '{ChatOptionsExtensions.ChatMessageContentKey}' is already specified in the options. Avoid using this key name."); + return; } options.AdditionalProperties ??= []; - - options.AdditionalProperties[ChatOptionsExtensions.IsStreamingKey] = isStreaming; options.AdditionalProperties[ChatOptionsExtensions.ChatMessageContentKey] = content; } - private static void ClearOptionsForAutoFunctionInvocation(ref ChatOptions options) - { - if (options.AdditionalProperties?.ContainsKey(ChatOptionsExtensions.IsStreamingKey) ?? false) - { - options.AdditionalProperties.Remove(ChatOptionsExtensions.IsStreamingKey); - } - - if (options.AdditionalProperties?.ContainsKey(ChatOptionsExtensions.ChatMessageContentKey) ?? false) - { - options.AdditionalProperties.Remove(ChatOptionsExtensions.ChatMessageContentKey); - } - } - - /// - protected override async Task<(bool ShouldTerminate, int NewConsecutiveErrorCount, IList MessagesAdded)> ProcessFunctionCallsAsync( - ChatResponse response, IList messages, ChatOptions options, IList functionCallContents, int iteration, int consecutiveErrorCount, bool isStreaming, CancellationToken cancellationToken) - { - // Prepare the options for the next auto function invocation iteration. - UpdateOptionsForAutoFunctionInvocation(ref options!, response.Messages.Last().ToChatMessageContent(), isStreaming: false); - - // We must add a response for every tool call, regardless of whether we successfully executed it or not. - // If we successfully execute it, we'll add the result. If we don't, we'll add an error. - var result = await base.ProcessFunctionCallsAsync(response, messages, options, functionCallContents, iteration, consecutiveErrorCount, isStreaming, cancellationToken).ConfigureAwait(false); - - // Clear the auto function invocation options. - ClearOptionsForAutoFunctionInvocation(ref options); - - return result; - } - - /// - protected override async Task ProcessFunctionCallAsync( - IList messages, ChatOptions options, IList callContents, - int iteration, int functionCallIndex, bool captureExceptions, bool isStreaming, CancellationToken cancellationToken) - { - var callContent = callContents[functionCallIndex]; - - // Look up the AIFunction for the function call. If the requested function isn't available, send back an error. - AIFunction? function = options.Tools!.OfType().FirstOrDefault(t => t.Name == callContent.Name); - if (function is null) - { - return new(shouldTerminate: false, FunctionInvocationStatus.NotFound, callContent, result: null, exception: null); - } - - var context = new AutoFunctionInvocationContext(options) - { - AIFunction = function, - Arguments = new KernelArguments(callContent.Arguments ?? new Dictionary()) { Services = this.FunctionInvocationServices }, - Messages = messages, - CallContent = callContent, - Iteration = iteration, - FunctionCallIndex = functionCallIndex, - FunctionCount = callContents.Count, - IsStreaming = isStreaming - }; - - object? result; - try - { - result = await base.InvokeFunctionAsync(context, cancellationToken).ConfigureAwait(false); - } - catch (Exception e) when (!cancellationToken.IsCancellationRequested) - { - if (!captureExceptions) - { - throw; - } - - return new( - shouldTerminate: false, - FunctionInvocationStatus.Exception, - callContent, - result: null, - exception: e); - } - - return new( - shouldTerminate: context.Terminate, - FunctionInvocationStatus.RanToCompletion, - callContent, - result, - exception: null); - } - /// /// Invokes the auto function invocation filters. /// @@ -168,34 +77,48 @@ await autoFunctionInvocationFilters[index].OnAutoFunctionInvocationAsync( } /// - protected override async Task<(FunctionInvocationContext context, object? result)> InvokeFunctionCoreAsync(Microsoft.Extensions.AI.FunctionInvocationContext context, CancellationToken cancellationToken) + protected override async Task InvokeFunctionAsync(Microsoft.Extensions.AI.FunctionInvocationContext context, CancellationToken cancellationToken) { - object? result = null; - if (context is AutoFunctionInvocationContext autoContext) + if (context.Options is null) { - context = await this.OnAutoFunctionInvocationAsync( - autoContext, - async (ctx) => - { - // Check if filter requested termination - if (ctx.Terminate) - { - return; - } - - // Note that we explicitly do not use executionSettings here; those pertain to the all-up operation and not necessarily to any - // further calls made as part of this function invocation. In particular, we must not use function calling settings naively here, - // as the called function could in turn telling the model about itself as a possible candidate for invocation. - result = await autoContext.AIFunction.InvokeAsync(new(context.Arguments), cancellationToken).ConfigureAwait(false); - ctx.Result = new FunctionResult(ctx.Function, result); - }).ConfigureAwait(false); - result = autoContext.Result.GetValue(); + return await context.Function.InvokeAsync(context.Arguments, cancellationToken).ConfigureAwait(false); } - else + + object? result = null; + + UpdateOptionsForAutoFunctionInvocation(context.Options, context.Messages.Last().ToChatMessageContent()); + var autoContext = new AutoFunctionInvocationContext(context.Options) { - result = await context.Function.InvokeAsync(new(context.Arguments), cancellationToken).ConfigureAwait(false); - } + AIFunction = context.Function, + Arguments = new KernelArguments(context.Arguments) { Services = this.FunctionInvocationServices }, + Messages = context.Messages, + CallContent = context.CallContent, + Iteration = context.Iteration, + FunctionCallIndex = context.FunctionCallIndex, + FunctionCount = context.FunctionCount, + IsStreaming = context.IsStreaming + }; - return (context, result); + autoContext = await this.OnAutoFunctionInvocationAsync( + autoContext, + async (ctx) => + { + // Check if filter requested termination + if (ctx.Terminate) + { + return; + } + + // Note that we explicitly do not use executionSettings here; those pertain to the all-up operation and not necessarily to any + // further calls made as part of this function invocation. In particular, we must not use function calling settings naively here, + // as the called function could in turn telling the model about itself as a possible candidate for invocation. + result = await autoContext.AIFunction.InvokeAsync(autoContext.Arguments, cancellationToken).ConfigureAwait(false); + ctx.Result = new FunctionResult(ctx.Function, result); + }).ConfigureAwait(false); + result = autoContext.Result.GetValue(); + + context.Terminate = autoContext.Terminate; + + return result; } } From 166bb292761487d084e493accef42819aed77ea4 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 1 May 2025 09:09:06 +0100 Subject: [PATCH 12/24] Update latest MEAI version with Functin Invocking changes --- dotnet/Directory.Packages.props | 8 ++++---- .../AI/ChatClient/KernelFunctionInvokingChatClient.cs | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index fbd0a578f209..6536761e6603 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -94,10 +94,10 @@ - - - - + + + + diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs index 7b50ef8761e5..babef7736ebd 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs @@ -77,7 +77,7 @@ await autoFunctionInvocationFilters[index].OnAutoFunctionInvocationAsync( } /// - protected override async Task InvokeFunctionAsync(Microsoft.Extensions.AI.FunctionInvocationContext context, CancellationToken cancellationToken) + protected override async ValueTask InvokeFunctionAsync(Microsoft.Extensions.AI.FunctionInvocationContext context, CancellationToken cancellationToken) { if (context.Options is null) { From 054fcf9a9688309184fa1eecca5299a93016b3b6 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 1 May 2025 09:13:09 +0100 Subject: [PATCH 13/24] Remove unused files --- .../AI/ChatClient/AIFunctionArgumentsV2.cs | 136 ------------------ .../FunctionInvocationContextV2.Extra.cs | 126 ---------------- .../ChatClient/FunctionInvocationContextV2.cs | 106 -------------- .../AutoFunctionInvocationContext.cs | 2 +- 4 files changed, 1 insertion(+), 369 deletions(-) delete mode 100644 dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/AIFunctionArgumentsV2.cs delete mode 100644 dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvocationContextV2.Extra.cs delete mode 100644 dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvocationContextV2.cs diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/AIFunctionArgumentsV2.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/AIFunctionArgumentsV2.cs deleted file mode 100644 index 6c0d9080030a..000000000000 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/AIFunctionArgumentsV2.cs +++ /dev/null @@ -1,136 +0,0 @@ -#pragma warning disable IDE0073 // The file header does not match the required text - -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections; -using System.Collections.Generic; - -#pragma warning disable SA1111 // Closing parenthesis should be on line of last parameter -#pragma warning disable SA1112 // Closing parenthesis should be on line of opening parenthesis -#pragma warning disable SA1114 // Parameter list should follow declaration -#pragma warning disable CA1710 // Identifiers should have correct suffix -#pragma warning disable IDE0009 // Add this or Me qualification - -namespace Microsoft.Extensions.AI; - -/// Represents arguments to be used with . -/// -/// is a dictionary of name/value pairs that are used -/// as inputs to an . However, an instance carries additional non-nominal -/// information, such as an optional that can be used by -/// an if it needs to resolve any services from a dependency injection -/// container. -/// -public class AIFunctionArgumentsV2 : IDictionary, IReadOnlyDictionary -{ - /// The nominal arguments. - private readonly Dictionary _arguments; - - /// Initializes a new instance of the class. - /// The optional to use for key comparisons. - public AIFunctionArgumentsV2(IEqualityComparer? comparer = null) - { - _arguments = new Dictionary(comparer); - } - - /// - /// Initializes a new instance of the class containing - /// the specified . - /// - /// The arguments represented by this instance. - /// The to be used. - /// - /// The reference will be stored if the instance is - /// already a and no is specified, - /// in which case all dictionary operations on this instance will be routed directly to that instance. - /// If is not a dictionary or a is specified, - /// a shallow clone of its data will be used to populate this instance. - /// A is treated as an empty dictionary. - /// - public AIFunctionArgumentsV2(IDictionary? arguments, IEqualityComparer? comparer = null) - { - this._arguments = comparer is null - ? arguments is null - ? [] - : arguments as Dictionary ?? - new Dictionary(arguments) - : arguments is null - ? new Dictionary(comparer) - : new Dictionary(arguments, comparer); - } - - /// Gets or sets services optionally associated with these arguments. - public IServiceProvider? Services { get; set; } - - /// Gets or sets additional context associated with these arguments. - /// - /// The context is a dictionary of name/value pairs that can be used to store arbitrary - /// information for use by an implementation. The meaning of this - /// data is left up to the implementer of the . - /// - public IDictionary? Context { get; set; } - - /// - public object? this[string key] - { - get => _arguments[key]; - set => _arguments[key] = value; - } - - /// - public ICollection Keys => _arguments.Keys; - - /// - public ICollection Values => _arguments.Values; - - /// - public int Count => _arguments.Count; - - /// - public bool IsReadOnly => false; - - /// - IEnumerable IReadOnlyDictionary.Keys => Keys; - - /// - IEnumerable IReadOnlyDictionary.Values => Values; - - /// - public void Add(string key, object? value) => _arguments.Add(key, value); - - /// - void ICollection>.Add(KeyValuePair item) => - ((ICollection>)_arguments).Add(item); - - /// - public void Clear() => _arguments.Clear(); - - /// - public bool Contains(KeyValuePair item) => - ((ICollection>)_arguments).Contains(item); - - /// - public bool ContainsKey(string key) => _arguments.ContainsKey(key); - - /// - public void CopyTo(KeyValuePair[] array, int arrayIndex) => - ((ICollection>)_arguments).CopyTo(array, arrayIndex); - - /// - public IEnumerator> GetEnumerator() => _arguments.GetEnumerator(); - - /// - public bool Remove(string key) => _arguments.Remove(key); - - /// - bool ICollection>.Remove(KeyValuePair item) => - ((ICollection>)_arguments).Remove(item); - - /// - public bool TryGetValue(string key, out object? value) => _arguments.TryGetValue(key, out value); - - /// - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); -} diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvocationContextV2.Extra.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvocationContextV2.Extra.cs deleted file mode 100644 index 03dfcf1d8246..000000000000 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvocationContextV2.Extra.cs +++ /dev/null @@ -1,126 +0,0 @@ -#pragma warning disable IDE0073 // The file header does not match the required text - -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; - -#pragma warning disable IDE0055 // Fix formatting - -namespace Microsoft.Extensions.AI; - -/// Provides context for an in-flight function invocation. -public partial class FunctionInvocationContextV2 -{ - private static class Throw - { - /// - /// Throws an if the specified argument is . - /// - /// Argument type to be checked for . - /// Object to be checked for . - /// The name of the parameter being checked. - /// The original value of . - [MethodImpl(MethodImplOptions.AggressiveInlining)] - [return: NotNull] - public static T IfNull([NotNull] T argument, [CallerArgumentExpression(nameof(argument))] string paramName = "") - { - if (argument is null) - { - throw new ArgumentNullException(paramName); - } - - return argument; - } - - /// - /// Throws an . - /// - /// A message that describes the error. -#if !NET6_0_OR_GREATER - [MethodImpl(MethodImplOptions.NoInlining)] -#endif - [DoesNotReturn] - public static void InvalidOperationException(string message) - => throw new InvalidOperationException(message); - - /// - /// Throws an . - /// - /// The name of the parameter that caused the exception. -#if !NET6_0_OR_GREATER - [MethodImpl(MethodImplOptions.NoInlining)] -#endif - [DoesNotReturn] - public static void ArgumentOutOfRangeException(string paramName) - => throw new ArgumentOutOfRangeException(paramName); - - /// - /// Throws an . - /// - /// The name of the parameter that caused the exception. - /// A message that describes the error. -#if !NET6_0_OR_GREATER - [MethodImpl(MethodImplOptions.NoInlining)] -#endif - [DoesNotReturn] - public static void ArgumentOutOfRangeException(string paramName, string? message) - => throw new ArgumentOutOfRangeException(paramName, message); - - /// - /// Throws an . - /// - /// The name of the parameter that caused the exception. - /// The value of the argument that caused this exception. - /// A message that describes the error. -#if !NET6_0_OR_GREATER - [MethodImpl(MethodImplOptions.NoInlining)] -#endif - [DoesNotReturn] - public static void ArgumentOutOfRangeException(string paramName, object? actualValue, string? message) - => throw new ArgumentOutOfRangeException(paramName, actualValue, message); - - /// - /// Throws an if the specified number is less than min. - /// - /// Number to be expected being less than min. - /// The number that must be less than the argument. - /// The name of the parameter being checked. - /// The original value of . - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int IfLessThan(int argument, int min, [CallerArgumentExpression(nameof(argument))] string paramName = "") - { - if (argument < min) - { - ArgumentOutOfRangeException(paramName, argument, $"Argument less than minimum value {min}"); - } - - return argument; - } - } - - private static class LoggingHelpers - { - /// Serializes as JSON for logging purposes. - public static string AsJson(T value, System.Text.Json.JsonSerializerOptions? options) - { - if (options?.TryGetTypeInfo(typeof(T), out var typeInfo) is true || - AIJsonUtilities.DefaultOptions.TryGetTypeInfo(typeof(T), out typeInfo)) - { - try - { - return System.Text.Json.JsonSerializer.Serialize(value, typeInfo); - } -#pragma warning disable CA1031 // Do not catch general exception types - catch -#pragma warning restore CA1031 // Do not catch general exception types - { - } - } - - return "{}"; - } - } -} diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvocationContextV2.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvocationContextV2.cs deleted file mode 100644 index a1698cea64e6..000000000000 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionInvocationContextV2.cs +++ /dev/null @@ -1,106 +0,0 @@ -#pragma warning disable IDE0073 // The file header does not match the required text - -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections.Generic; - -#pragma warning disable IDE1006 // Naming Styles -#pragma warning disable IDE0009 // Add this or Me qualification - -namespace Microsoft.Extensions.AI; - -/// Provides context for an in-flight function invocation. -public partial class FunctionInvocationContextV2 -{ - /// - /// A nop function used to allow to be non-nullable. Default instances of - /// start with this as the target function. - /// - private static readonly AIFunction _nopFunction = AIFunctionFactory.Create(() => { }, nameof(FunctionInvocationContext)); -#pragma warning restore IDE1006 // Naming Styles - - /// The chat contents associated with the operation that initiated this function call request. - private IList _messages = Array.Empty(); - - /// The AI function to be invoked. - private AIFunction _function = _nopFunction; - - /// The function call content information associated with this invocation. - private FunctionCallContent? _callContent; - - /// The arguments used with the function. - private AIFunctionArgumentsV2? _arguments; - - /// Initializes a new instance of the class. - public FunctionInvocationContextV2() - { - } - - /// Gets or sets the AI function to be invoked. - public AIFunction Function - { - get => _function; - set => _function = Throw.IfNull(value); - } - - /// Gets or sets the arguments associated with this invocation. - public AIFunctionArgumentsV2 Arguments - { - get => _arguments ??= []; - set => _arguments = Throw.IfNull(value); - } - - /// Gets or sets the function call content information associated with this invocation. - public FunctionCallContent CallContent - { - get => _callContent ??= new(string.Empty, _nopFunction.Name, EmptyReadOnlyDictionary.Instance); - set => _callContent = Throw.IfNull(value); - } - - /// Gets or sets the chat contents associated with the operation that initiated this function call request. - public IList Messages - { - get => _messages; - set => _messages = Throw.IfNull(value); - } - - /// Gets or sets the chat options associated with the operation that initiated this function call request. - public ChatOptions? Options { get; set; } - - /// Gets or sets the number of this iteration with the underlying client. - /// - /// The initial request to the client that passes along the chat contents provided to the - /// is iteration 1. If the client responds with a function call request, the next request to the client is iteration 2, and so on. - /// - public int Iteration { get; set; } - - /// Gets or sets the index of the function call within the iteration. - /// - /// The response from the underlying client may include multiple function call requests. - /// This index indicates the position of the function call within the iteration. - /// - public int FunctionCallIndex { get; set; } - - /// Gets or sets the total number of function call requests within the iteration. - /// - /// The response from the underlying client might include multiple function call requests. - /// This count indicates how many there were. - /// - public int FunctionCount { get; set; } - - /// Gets or sets a value indicating whether to terminate the request. - /// - /// In response to a function call request, the function might be invoked, its result added to the chat contents, - /// and a new request issued to the wrapped client. If this property is set to , that subsequent request - /// will not be issued and instead the loop immediately terminated rather than continuing until there are no - /// more function call requests in responses. - /// - public bool Terminate { get; set; } - - /// - /// Gets or sets a value indicating whether the context is happening in a streaming scenario. - /// - public bool IsStreaming { get; set; } -} diff --git a/dotnet/src/SemanticKernel.Abstractions/Filters/AutoFunctionInvocation/AutoFunctionInvocationContext.cs b/dotnet/src/SemanticKernel.Abstractions/Filters/AutoFunctionInvocation/AutoFunctionInvocationContext.cs index 478c2dae1d3e..5c5428a39f26 100644 --- a/dotnet/src/SemanticKernel.Abstractions/Filters/AutoFunctionInvocation/AutoFunctionInvocationContext.cs +++ b/dotnet/src/SemanticKernel.Abstractions/Filters/AutoFunctionInvocation/AutoFunctionInvocationContext.cs @@ -106,7 +106,7 @@ public AutoFunctionInvocationContext( } /// - /// Get the with which this filter is associated. + /// Get the with which this filter is associated. /// public AIFunctionArguments AIArguments { From a7b531ef345f6780b70086405ae81f3eb49389ab Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 1 May 2025 10:52:15 +0100 Subject: [PATCH 14/24] Revert nuget.config --- dotnet/nuget.config | 2 -- 1 file changed, 2 deletions(-) diff --git a/dotnet/nuget.config b/dotnet/nuget.config index fa87ed4c8a74..3f0557f4f943 100644 --- a/dotnet/nuget.config +++ b/dotnet/nuget.config @@ -4,8 +4,6 @@ - - From b26887191ab01d6ade8f52613ac1ea6cf8d628c1 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 1 May 2025 10:53:08 +0100 Subject: [PATCH 15/24] Revert nuget.config --- dotnet/nuget.config | 3 --- 1 file changed, 3 deletions(-) diff --git a/dotnet/nuget.config b/dotnet/nuget.config index 3f0557f4f943..de63d676c568 100644 --- a/dotnet/nuget.config +++ b/dotnet/nuget.config @@ -10,9 +10,6 @@ - - - From c899c813f6fb34bc329189c95312ade096de5423 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 1 May 2025 10:55:25 +0100 Subject: [PATCH 16/24] Remove unrelated test --- .../InMemoryVectorStoreTests.cs | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/dotnet/src/Connectors/Connectors.InMemory.UnitTests/InMemoryVectorStoreTests.cs b/dotnet/src/Connectors/Connectors.InMemory.UnitTests/InMemoryVectorStoreTests.cs index 8a6b798d17c2..fe9717c80c70 100644 --- a/dotnet/src/Connectors/Connectors.InMemory.UnitTests/InMemoryVectorStoreTests.cs +++ b/dotnet/src/Connectors/Connectors.InMemory.UnitTests/InMemoryVectorStoreTests.cs @@ -43,24 +43,6 @@ public void GetCollectionReturnsCollectionWithNonStringKey() Assert.IsType>>(actual); } - [Fact] - public async Task ListCollectionNamesReadsDictionaryAsync() - { - // Arrange. - var collectionStore = new ConcurrentDictionary>(); - collectionStore.TryAdd("collection1", new ConcurrentDictionary()); - collectionStore.TryAdd("collection2", new ConcurrentDictionary()); - var sut = new InMemoryVectorStore(collectionStore); - - // Act. - var collectionNames = sut.ListCollectionNamesAsync(); - - // Assert. - var collectionNamesList = await collectionNames.ToListAsync(); - Assert.Contains("collection1", collectionNamesList); - Assert.Contains("collection2", collectionNamesList); - } - [Fact] public async Task GetCollectionDoesNotAllowADifferentDataTypeThanPreviouslyUsedAsync() { From 6b5eea3800bcc258e38704ced650ffb45c3ef57d Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 1 May 2025 13:27:54 +0100 Subject: [PATCH 17/24] Revert undesired changes --- dotnet/nuget.config | 2 +- .../src/Functions/Functions.OpenApi/RestApiOperationRunner.cs | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/dotnet/nuget.config b/dotnet/nuget.config index de63d676c568..7159fcd04c36 100644 --- a/dotnet/nuget.config +++ b/dotnet/nuget.config @@ -1,4 +1,4 @@ - + diff --git a/dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs b/dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs index 068f4713118a..28305a5ec8a4 100644 --- a/dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs +++ b/dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs @@ -13,8 +13,6 @@ using System.Threading.Tasks; using Microsoft.SemanticKernel.Http; -#pragma warning disable CA1859 - namespace Microsoft.SemanticKernel.Plugins.OpenApi; /// From f5847b94227c4a82ec8528109f8ca5b9b7119480 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 1 May 2025 13:40:11 +0100 Subject: [PATCH 18/24] Prevent unrelated warnings --- .../AzureCosmosDBMongoDBVectorStoreRecordCollection.cs | 2 ++ .../InMemoryVectorStoreRecordCollection.cs | 2 ++ .../MongoDBVectorStoreRecordCollection.cs | 2 ++ .../PostgresVectorStoreRecordCollection.cs | 2 ++ .../QdrantVectorStoreRecordCollection.cs | 2 ++ .../WeaviateVectorStoreRecordCollection.cs | 2 ++ .../ConnectorSupport/VectorStoreRecordVectorPropertyModel.cs | 2 ++ .../VectorStoreRecordVectorPropertyModel{TInput}.cs | 2 ++ 8 files changed, 16 insertions(+) diff --git a/dotnet/src/Connectors/Connectors.Memory.AzureCosmosDBMongoDB/AzureCosmosDBMongoDBVectorStoreRecordCollection.cs b/dotnet/src/Connectors/Connectors.Memory.AzureCosmosDBMongoDB/AzureCosmosDBMongoDBVectorStoreRecordCollection.cs index 225d990fee08..a2c6b1a9d88a 100644 --- a/dotnet/src/Connectors/Connectors.Memory.AzureCosmosDBMongoDB/AzureCosmosDBMongoDBVectorStoreRecordCollection.cs +++ b/dotnet/src/Connectors/Connectors.Memory.AzureCosmosDBMongoDB/AzureCosmosDBMongoDBVectorStoreRecordCollection.cs @@ -19,6 +19,8 @@ using MongoDB.Driver; using MEVD = Microsoft.Extensions.VectorData; +#pragma warning disable CS0618 // Type or member is obsolete + namespace Microsoft.SemanticKernel.Connectors.AzureCosmosDBMongoDB; /// diff --git a/dotnet/src/Connectors/Connectors.Memory.InMemory/InMemoryVectorStoreRecordCollection.cs b/dotnet/src/Connectors/Connectors.Memory.InMemory/InMemoryVectorStoreRecordCollection.cs index d4b4646b1671..0f5feb1a3cb7 100644 --- a/dotnet/src/Connectors/Connectors.Memory.InMemory/InMemoryVectorStoreRecordCollection.cs +++ b/dotnet/src/Connectors/Connectors.Memory.InMemory/InMemoryVectorStoreRecordCollection.cs @@ -14,6 +14,8 @@ using Microsoft.Extensions.VectorData.ConnectorSupport; using Microsoft.Extensions.VectorData.Properties; +#pragma warning disable CS0618 // Type or member is obsolete + namespace Microsoft.SemanticKernel.Connectors.InMemory; /// diff --git a/dotnet/src/Connectors/Connectors.Memory.MongoDB/MongoDBVectorStoreRecordCollection.cs b/dotnet/src/Connectors/Connectors.Memory.MongoDB/MongoDBVectorStoreRecordCollection.cs index 062c22d936e8..405c3cc9e322 100644 --- a/dotnet/src/Connectors/Connectors.Memory.MongoDB/MongoDBVectorStoreRecordCollection.cs +++ b/dotnet/src/Connectors/Connectors.Memory.MongoDB/MongoDBVectorStoreRecordCollection.cs @@ -16,6 +16,8 @@ using MongoDB.Driver; using MEVD = Microsoft.Extensions.VectorData; +#pragma warning disable CS0618 // Type or member is obsolete + namespace Microsoft.SemanticKernel.Connectors.MongoDB; /// diff --git a/dotnet/src/Connectors/Connectors.Memory.Postgres/PostgresVectorStoreRecordCollection.cs b/dotnet/src/Connectors/Connectors.Memory.Postgres/PostgresVectorStoreRecordCollection.cs index 2c3bda1627d6..675cc5681b27 100644 --- a/dotnet/src/Connectors/Connectors.Memory.Postgres/PostgresVectorStoreRecordCollection.cs +++ b/dotnet/src/Connectors/Connectors.Memory.Postgres/PostgresVectorStoreRecordCollection.cs @@ -13,6 +13,8 @@ using Microsoft.Extensions.VectorData.Properties; using Npgsql; +#pragma warning disable CS0618 // Type or member is obsolete + namespace Microsoft.SemanticKernel.Connectors.Postgres; /// diff --git a/dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantVectorStoreRecordCollection.cs b/dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantVectorStoreRecordCollection.cs index 7673933d99af..8d77080c84da 100644 --- a/dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantVectorStoreRecordCollection.cs +++ b/dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantVectorStoreRecordCollection.cs @@ -17,6 +17,8 @@ using Qdrant.Client; using Qdrant.Client.Grpc; +#pragma warning disable CS0618 // Type or member is obsolete + namespace Microsoft.SemanticKernel.Connectors.Qdrant; /// diff --git a/dotnet/src/Connectors/Connectors.Memory.Weaviate/WeaviateVectorStoreRecordCollection.cs b/dotnet/src/Connectors/Connectors.Memory.Weaviate/WeaviateVectorStoreRecordCollection.cs index 91d76424dc0c..9aaf7a6cabce 100644 --- a/dotnet/src/Connectors/Connectors.Memory.Weaviate/WeaviateVectorStoreRecordCollection.cs +++ b/dotnet/src/Connectors/Connectors.Memory.Weaviate/WeaviateVectorStoreRecordCollection.cs @@ -18,6 +18,8 @@ using Microsoft.Extensions.VectorData.ConnectorSupport; using Microsoft.Extensions.VectorData.Properties; +#pragma warning disable CS0618 // Type or member is obsolete + namespace Microsoft.SemanticKernel.Connectors.Weaviate; /// diff --git a/dotnet/src/Connectors/VectorData.Abstractions/ConnectorSupport/VectorStoreRecordVectorPropertyModel.cs b/dotnet/src/Connectors/VectorData.Abstractions/ConnectorSupport/VectorStoreRecordVectorPropertyModel.cs index 09a557664753..b25aac34ef06 100644 --- a/dotnet/src/Connectors/VectorData.Abstractions/ConnectorSupport/VectorStoreRecordVectorPropertyModel.cs +++ b/dotnet/src/Connectors/VectorData.Abstractions/ConnectorSupport/VectorStoreRecordVectorPropertyModel.cs @@ -9,6 +9,8 @@ using System.Threading.Tasks; using Microsoft.Extensions.AI; +#pragma warning disable CS0618 // Type or member is obsolete + namespace Microsoft.Extensions.VectorData.ConnectorSupport; /// diff --git a/dotnet/src/Connectors/VectorData.Abstractions/ConnectorSupport/VectorStoreRecordVectorPropertyModel{TInput}.cs b/dotnet/src/Connectors/VectorData.Abstractions/ConnectorSupport/VectorStoreRecordVectorPropertyModel{TInput}.cs index fdcb56c43e14..28acb2587293 100644 --- a/dotnet/src/Connectors/VectorData.Abstractions/ConnectorSupport/VectorStoreRecordVectorPropertyModel{TInput}.cs +++ b/dotnet/src/Connectors/VectorData.Abstractions/ConnectorSupport/VectorStoreRecordVectorPropertyModel{TInput}.cs @@ -9,6 +9,8 @@ using System.Threading.Tasks; using Microsoft.Extensions.AI; +#pragma warning disable CS0618 // Type or member is obsolete + namespace Microsoft.Extensions.VectorData.ConnectorSupport; /// From 2503d58c9a5d87d77f7f904009f12f3422fe7636 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 1 May 2025 14:17:44 +0100 Subject: [PATCH 19/24] Revert "Prevent unrelated warnings" This reverts commit f5847b94227c4a82ec8528109f8ca5b9b7119480. --- .../AzureCosmosDBMongoDBVectorStoreRecordCollection.cs | 2 -- .../InMemoryVectorStoreRecordCollection.cs | 2 -- .../MongoDBVectorStoreRecordCollection.cs | 2 -- .../PostgresVectorStoreRecordCollection.cs | 2 -- .../QdrantVectorStoreRecordCollection.cs | 2 -- .../WeaviateVectorStoreRecordCollection.cs | 2 -- .../ConnectorSupport/VectorStoreRecordVectorPropertyModel.cs | 2 -- .../VectorStoreRecordVectorPropertyModel{TInput}.cs | 2 -- 8 files changed, 16 deletions(-) diff --git a/dotnet/src/Connectors/Connectors.Memory.AzureCosmosDBMongoDB/AzureCosmosDBMongoDBVectorStoreRecordCollection.cs b/dotnet/src/Connectors/Connectors.Memory.AzureCosmosDBMongoDB/AzureCosmosDBMongoDBVectorStoreRecordCollection.cs index a2c6b1a9d88a..225d990fee08 100644 --- a/dotnet/src/Connectors/Connectors.Memory.AzureCosmosDBMongoDB/AzureCosmosDBMongoDBVectorStoreRecordCollection.cs +++ b/dotnet/src/Connectors/Connectors.Memory.AzureCosmosDBMongoDB/AzureCosmosDBMongoDBVectorStoreRecordCollection.cs @@ -19,8 +19,6 @@ using MongoDB.Driver; using MEVD = Microsoft.Extensions.VectorData; -#pragma warning disable CS0618 // Type or member is obsolete - namespace Microsoft.SemanticKernel.Connectors.AzureCosmosDBMongoDB; /// diff --git a/dotnet/src/Connectors/Connectors.Memory.InMemory/InMemoryVectorStoreRecordCollection.cs b/dotnet/src/Connectors/Connectors.Memory.InMemory/InMemoryVectorStoreRecordCollection.cs index 0f5feb1a3cb7..d4b4646b1671 100644 --- a/dotnet/src/Connectors/Connectors.Memory.InMemory/InMemoryVectorStoreRecordCollection.cs +++ b/dotnet/src/Connectors/Connectors.Memory.InMemory/InMemoryVectorStoreRecordCollection.cs @@ -14,8 +14,6 @@ using Microsoft.Extensions.VectorData.ConnectorSupport; using Microsoft.Extensions.VectorData.Properties; -#pragma warning disable CS0618 // Type or member is obsolete - namespace Microsoft.SemanticKernel.Connectors.InMemory; /// diff --git a/dotnet/src/Connectors/Connectors.Memory.MongoDB/MongoDBVectorStoreRecordCollection.cs b/dotnet/src/Connectors/Connectors.Memory.MongoDB/MongoDBVectorStoreRecordCollection.cs index 405c3cc9e322..062c22d936e8 100644 --- a/dotnet/src/Connectors/Connectors.Memory.MongoDB/MongoDBVectorStoreRecordCollection.cs +++ b/dotnet/src/Connectors/Connectors.Memory.MongoDB/MongoDBVectorStoreRecordCollection.cs @@ -16,8 +16,6 @@ using MongoDB.Driver; using MEVD = Microsoft.Extensions.VectorData; -#pragma warning disable CS0618 // Type or member is obsolete - namespace Microsoft.SemanticKernel.Connectors.MongoDB; /// diff --git a/dotnet/src/Connectors/Connectors.Memory.Postgres/PostgresVectorStoreRecordCollection.cs b/dotnet/src/Connectors/Connectors.Memory.Postgres/PostgresVectorStoreRecordCollection.cs index 675cc5681b27..2c3bda1627d6 100644 --- a/dotnet/src/Connectors/Connectors.Memory.Postgres/PostgresVectorStoreRecordCollection.cs +++ b/dotnet/src/Connectors/Connectors.Memory.Postgres/PostgresVectorStoreRecordCollection.cs @@ -13,8 +13,6 @@ using Microsoft.Extensions.VectorData.Properties; using Npgsql; -#pragma warning disable CS0618 // Type or member is obsolete - namespace Microsoft.SemanticKernel.Connectors.Postgres; /// diff --git a/dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantVectorStoreRecordCollection.cs b/dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantVectorStoreRecordCollection.cs index 8d77080c84da..7673933d99af 100644 --- a/dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantVectorStoreRecordCollection.cs +++ b/dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantVectorStoreRecordCollection.cs @@ -17,8 +17,6 @@ using Qdrant.Client; using Qdrant.Client.Grpc; -#pragma warning disable CS0618 // Type or member is obsolete - namespace Microsoft.SemanticKernel.Connectors.Qdrant; /// diff --git a/dotnet/src/Connectors/Connectors.Memory.Weaviate/WeaviateVectorStoreRecordCollection.cs b/dotnet/src/Connectors/Connectors.Memory.Weaviate/WeaviateVectorStoreRecordCollection.cs index 9aaf7a6cabce..91d76424dc0c 100644 --- a/dotnet/src/Connectors/Connectors.Memory.Weaviate/WeaviateVectorStoreRecordCollection.cs +++ b/dotnet/src/Connectors/Connectors.Memory.Weaviate/WeaviateVectorStoreRecordCollection.cs @@ -18,8 +18,6 @@ using Microsoft.Extensions.VectorData.ConnectorSupport; using Microsoft.Extensions.VectorData.Properties; -#pragma warning disable CS0618 // Type or member is obsolete - namespace Microsoft.SemanticKernel.Connectors.Weaviate; /// diff --git a/dotnet/src/Connectors/VectorData.Abstractions/ConnectorSupport/VectorStoreRecordVectorPropertyModel.cs b/dotnet/src/Connectors/VectorData.Abstractions/ConnectorSupport/VectorStoreRecordVectorPropertyModel.cs index b25aac34ef06..09a557664753 100644 --- a/dotnet/src/Connectors/VectorData.Abstractions/ConnectorSupport/VectorStoreRecordVectorPropertyModel.cs +++ b/dotnet/src/Connectors/VectorData.Abstractions/ConnectorSupport/VectorStoreRecordVectorPropertyModel.cs @@ -9,8 +9,6 @@ using System.Threading.Tasks; using Microsoft.Extensions.AI; -#pragma warning disable CS0618 // Type or member is obsolete - namespace Microsoft.Extensions.VectorData.ConnectorSupport; /// diff --git a/dotnet/src/Connectors/VectorData.Abstractions/ConnectorSupport/VectorStoreRecordVectorPropertyModel{TInput}.cs b/dotnet/src/Connectors/VectorData.Abstractions/ConnectorSupport/VectorStoreRecordVectorPropertyModel{TInput}.cs index 28acb2587293..fdcb56c43e14 100644 --- a/dotnet/src/Connectors/VectorData.Abstractions/ConnectorSupport/VectorStoreRecordVectorPropertyModel{TInput}.cs +++ b/dotnet/src/Connectors/VectorData.Abstractions/ConnectorSupport/VectorStoreRecordVectorPropertyModel{TInput}.cs @@ -9,8 +9,6 @@ using System.Threading.Tasks; using Microsoft.Extensions.AI; -#pragma warning disable CS0618 // Type or member is obsolete - namespace Microsoft.Extensions.VectorData.ConnectorSupport; /// From d012c3d7ea718274958110c33f3d82eff06d7a09 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Fri, 2 May 2025 15:18:57 +0100 Subject: [PATCH 20/24] Address latest changes to AutoFunctionInvocationContext --- .../AutoFunctionInvocationContext.cs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/dotnet/src/SemanticKernel.Abstractions/Filters/AutoFunctionInvocation/AutoFunctionInvocationContext.cs b/dotnet/src/SemanticKernel.Abstractions/Filters/AutoFunctionInvocation/AutoFunctionInvocationContext.cs index 5c5428a39f26..02346c7d01f9 100644 --- a/dotnet/src/SemanticKernel.Abstractions/Filters/AutoFunctionInvocation/AutoFunctionInvocationContext.cs +++ b/dotnet/src/SemanticKernel.Abstractions/Filters/AutoFunctionInvocation/AutoFunctionInvocationContext.cs @@ -89,8 +89,15 @@ public AutoFunctionInvocationContext( public CancellationToken CancellationToken { get; init; } /// - /// Gets the arguments associated with the operation. + /// Gets the specialized version of associated with the operation. /// + /// + /// If the arguments are not a class use the property instead. + /// + /// Due to a clash with the as a type, this property hides + /// it to not break existing code that relies on the as a type. + /// + /// public new KernelArguments? Arguments { get @@ -111,7 +118,6 @@ public AutoFunctionInvocationContext( public AIFunctionArguments AIArguments { get => base.Arguments; - init => base.Arguments = value; } /// @@ -175,6 +181,10 @@ public PromptExecutionSettings? ExecutionSettings /// /// Gets the with which this filter is associated. /// + /// + /// Due to a clash with the as a type, this property hides + /// it to not break existing code that relies on the as a type. + /// public new KernelFunction Function { get From c47cde7a535dae1097519a3b832f25600e695dff Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Fri, 2 May 2025 18:10:21 +0100 Subject: [PATCH 21/24] Add missing UT --- .../AutoFunctionInvocationContextTests.cs | 444 ++++++++++++++++++ .../Functions/KernelFunctionCloneTests.cs | 107 +++++ 2 files changed, 551 insertions(+) create mode 100644 dotnet/src/SemanticKernel.UnitTests/Filters/AutoFunctionInvocation/AutoFunctionInvocationContextTests.cs create mode 100644 dotnet/src/SemanticKernel.UnitTests/Functions/KernelFunctionCloneTests.cs diff --git a/dotnet/src/SemanticKernel.UnitTests/Filters/AutoFunctionInvocation/AutoFunctionInvocationContextTests.cs b/dotnet/src/SemanticKernel.UnitTests/Filters/AutoFunctionInvocation/AutoFunctionInvocationContextTests.cs new file mode 100644 index 000000000000..a4e9395b8cca --- /dev/null +++ b/dotnet/src/SemanticKernel.UnitTests/Filters/AutoFunctionInvocation/AutoFunctionInvocationContextTests.cs @@ -0,0 +1,444 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.SemanticKernel.ChatCompletion; +using Xunit; + +namespace Microsoft.SemanticKernel.UnitTests.Filters.AutoFunctionInvocation; + +public class AutoFunctionInvocationContextTests +{ + [Fact] + public void ConstructorWithValidParametersCreatesInstance() + { + // Arrange + var kernel = new Kernel(); + var function = KernelFunctionFactory.CreateFromMethod(() => "Test", "TestFunction"); + var result = new FunctionResult(function); + var chatHistory = new ChatHistory(); + var chatMessageContent = new ChatMessageContent(AuthorRole.Assistant, "Test message"); + + // Act + var context = new AutoFunctionInvocationContext( + kernel, + function, + result, + chatHistory, + chatMessageContent); + + // Assert + Assert.NotNull(context); + Assert.Same(kernel, context.Kernel); + Assert.Same(function, context.Function); + Assert.Same(result, context.Result); + Assert.Same(chatHistory, context.ChatHistory); + Assert.Same(chatMessageContent, context.ChatMessageContent); + } + + [Fact] + public void ConstructorWithNullKernelThrowsException() + { + // Arrange + var function = KernelFunctionFactory.CreateFromMethod(() => "Test", "TestFunction"); + var result = new FunctionResult(function); + var chatHistory = new ChatHistory(); + var chatMessageContent = new ChatMessageContent(AuthorRole.Assistant, "Test message"); + + // Act & Assert + Assert.Throws(() => new AutoFunctionInvocationContext( + null!, + function, + result, + chatHistory, + chatMessageContent)); + } + + [Fact] + public void ConstructorWithNullFunctionThrowsException() + { + // Arrange + var kernel = new Kernel(); + var function = KernelFunctionFactory.CreateFromMethod(() => "Test", "TestFunction"); + var result = new FunctionResult(function); + var chatHistory = new ChatHistory(); + var chatMessageContent = new ChatMessageContent(AuthorRole.Assistant, "Test message"); + + // Act & Assert + Assert.Throws(() => new AutoFunctionInvocationContext( + kernel, + null!, + result, + chatHistory, + chatMessageContent)); + } + + [Fact] + public void ConstructorWithNullResultThrowsException() + { + // Arrange + var kernel = new Kernel(); + var function = KernelFunctionFactory.CreateFromMethod(() => "Test", "TestFunction"); + var chatHistory = new ChatHistory(); + var chatMessageContent = new ChatMessageContent(AuthorRole.Assistant, "Test message"); + + // Act & Assert + Assert.Throws(() => new AutoFunctionInvocationContext( + kernel, + function, + null!, + chatHistory, + chatMessageContent)); + } + + [Fact] + public void ConstructorWithNullChatHistoryThrowsException() + { + // Arrange + var kernel = new Kernel(); + var function = KernelFunctionFactory.CreateFromMethod(() => "Test", "TestFunction"); + var result = new FunctionResult(function); + var chatMessageContent = new ChatMessageContent(AuthorRole.Assistant, "Test message"); + + // Act & Assert + Assert.Throws(() => new AutoFunctionInvocationContext( + kernel, + function, + result, + null!, + chatMessageContent)); + } + + [Fact] + public void ConstructorWithNullChatMessageContentThrowsException() + { + // Arrange + var kernel = new Kernel(); + var function = KernelFunctionFactory.CreateFromMethod(() => "Test", "TestFunction"); + var result = new FunctionResult(function); + var chatHistory = new ChatHistory(); + + // Act & Assert + Assert.Throws(() => new AutoFunctionInvocationContext( + kernel, + function, + result, + chatHistory, + null!)); + } + + [Fact] + public void PropertiesReturnCorrectValues() + { + // Arrange + var kernel = new Kernel(); + var function = KernelFunctionFactory.CreateFromMethod(() => "Test", "TestFunction"); + var result = new FunctionResult(function); + var chatHistory = new ChatHistory(); + var chatMessageContent = new ChatMessageContent(AuthorRole.Assistant, "Test message"); + + // Act + var context = new AutoFunctionInvocationContext( + kernel, + function, + result, + chatHistory, + chatMessageContent); + + // Assert + Assert.Same(kernel, context.Kernel); + Assert.Same(function, context.Function); + Assert.Same(result, context.Result); + Assert.Same(chatHistory, context.ChatHistory); + Assert.Same(chatMessageContent, context.ChatMessageContent); + } + + [Fact] + public async Task AutoFunctionInvocationContextCanBeUsedInFilter() + { + // Arrange + var kernel = new Kernel(); + var function = KernelFunctionFactory.CreateFromMethod(() => "Test", "TestFunction"); + var result = new FunctionResult(function); + var chatHistory = new ChatHistory(); + var chatMessageContent = new ChatMessageContent(AuthorRole.Assistant, "Test message"); + + var context = new AutoFunctionInvocationContext( + kernel, + function, + result, + chatHistory, + chatMessageContent); + + bool filterWasCalled = false; + + // Create a simple filter that just sets a flag + async Task FilterMethod(AutoFunctionInvocationContext ctx, Func next) + { + filterWasCalled = true; + Assert.Same(context, ctx); + await next(ctx); + } + + // Act + await FilterMethod(context, _ => Task.CompletedTask); + + // Assert + Assert.True(filterWasCalled); + } + + [Fact] + public void ExecutionSettingsCanBeSetAndRetrieved() + { + // Arrange + var kernel = new Kernel(); + var function = KernelFunctionFactory.CreateFromMethod(() => "Test", "TestFunction"); + var result = new FunctionResult(function); + var chatHistory = new ChatHistory(); + var chatMessageContent = new ChatMessageContent(AuthorRole.Assistant, "Test message"); + var executionSettings = new PromptExecutionSettings(); + + // Create options with execution settings + var additionalProperties = new AdditionalPropertiesDictionary + { + [ChatOptionsExtensions.PromptExecutionSettingsKey] = executionSettings, + [ChatOptionsExtensions.KernelKey] = kernel, + [ChatOptionsExtensions.ChatMessageContentKey] = chatMessageContent + }; + + var options = new ChatOptions + { + AdditionalProperties = additionalProperties + }; + + // Act + var context = new AutoFunctionInvocationContext(options); + + // Assert + Assert.Same(executionSettings, context.ExecutionSettings); + } + + [Fact] + public async Task KernelFunctionCloneWithKernelUsesProvidedKernel() + { + // Arrange + var originalKernel = new Kernel(); + var newKernel = new Kernel(); + + // Create a function that returns the kernel's hash code + var function = KernelFunctionFactory.CreateFromMethod( + (Kernel k) => k.GetHashCode().ToString(), + "GetKernelHashCode"); + + // Act + // Create AIFunctions with different kernels + var aiFunction1 = function.AsAIFunction(originalKernel); + var aiFunction2 = function.AsAIFunction(newKernel); + + // Invoke both functions + var args = new AIFunctionArguments(); + var result1 = await aiFunction1.InvokeAsync(args, default); + var result2 = await aiFunction2.InvokeAsync(args, default); + + // Assert + // The results should be different because they use different kernels + Assert.NotNull(result1); + Assert.NotNull(result2); + Assert.NotEqual(result1, result2); + Assert.Equal(originalKernel.GetHashCode().ToString(), result1.ToString()); + Assert.Equal(newKernel.GetHashCode().ToString(), result2.ToString()); + } + + // Let's simplify our approach and use a different testing strategy + [Fact] + public void ArgumentsPropertyHandlesKernelArguments() + { + // Arrange + var kernel = new Kernel(); + var function = KernelFunctionFactory.CreateFromMethod(() => "Test", "TestFunction"); + var result = new FunctionResult(function); + var chatHistory = new ChatHistory(); + var chatMessageContent = new ChatMessageContent(AuthorRole.Assistant, "Test message"); + + // Create KernelArguments and set them via the init property + var kernelArgs = new KernelArguments { ["test"] = "value" }; + + // Set the arguments via the init property + var contextWithArgs = new AutoFunctionInvocationContext( + kernel, + function, + result, + chatHistory, + chatMessageContent) + { + Arguments = kernelArgs + }; + + // Act & Assert + Assert.Same(kernelArgs, contextWithArgs.Arguments); + Assert.Same(kernelArgs, contextWithArgs.AIArguments); + } + + [Fact] + public void ArgumentsPropertyInitializesEmptyKernelArgumentsWhenSetToNull() + { + // Arrange + var kernel = new Kernel(); + var function = KernelFunctionFactory.CreateFromMethod(() => "Test", "TestFunction"); + var result = new FunctionResult(function); + var chatHistory = new ChatHistory(); + var chatMessageContent = new ChatMessageContent(AuthorRole.Assistant, "Test message"); + + // Set the arguments to null via the init property + var contextWithNullArgs = new AutoFunctionInvocationContext( + kernel, + function, + result, + chatHistory, + chatMessageContent) + { + Arguments = null + }; + + // Act & Assert + Assert.NotNull(contextWithNullArgs.Arguments); + Assert.IsType(contextWithNullArgs.Arguments); + Assert.Empty(contextWithNullArgs.Arguments); + } + + [Fact] + public void ArgumentsPropertyCanBeSetWithMultipleValues() + { + // Arrange + var kernel = new Kernel(); + var function = KernelFunctionFactory.CreateFromMethod(() => "Test", "TestFunction"); + var result = new FunctionResult(function); + var chatHistory = new ChatHistory(); + var chatMessageContent = new ChatMessageContent(AuthorRole.Assistant, "Test message"); + + // Create KernelArguments with multiple values + var kernelArgs = new KernelArguments + { + ["string"] = "value", + ["int"] = 42, + ["bool"] = true, + ["object"] = new object() + }; + + // Set the arguments via the init property + var context = new AutoFunctionInvocationContext( + kernel, + function, + result, + chatHistory, + chatMessageContent) + { + Arguments = kernelArgs + }; + + // Act & Assert + Assert.Same(kernelArgs, context.Arguments); + Assert.Equal(4, context.Arguments.Count); + Assert.Equal("value", context.Arguments["string"]); + Assert.Equal(42, context.Arguments["int"]); + Assert.Equal(true, context.Arguments["bool"]); + Assert.NotNull(context.Arguments["object"]); + } + + [Fact] + public void ArgumentsPropertyCanBeSetWithExecutionSettings() + { + // Arrange + var kernel = new Kernel(); + var function = KernelFunctionFactory.CreateFromMethod(() => "Test", "TestFunction"); + var result = new FunctionResult(function); + var chatHistory = new ChatHistory(); + var chatMessageContent = new ChatMessageContent(AuthorRole.Assistant, "Test message"); + var executionSettings = new PromptExecutionSettings(); + + // Create KernelArguments with execution settings + var kernelArgs = new KernelArguments(executionSettings) + { + ["test"] = "value" + }; + + // Set the arguments via the init property + var context = new AutoFunctionInvocationContext( + kernel, + function, + result, + chatHistory, + chatMessageContent) + { + Arguments = kernelArgs + }; + + // Act & Assert + Assert.Same(kernelArgs, context.Arguments); + Assert.Equal("value", context.Arguments["test"]); + Assert.Same(executionSettings, context.Arguments.ExecutionSettings?[PromptExecutionSettings.DefaultServiceId]); + } + + [Fact] + public void AIArgumentsPropertyReturnsArguments() + { + // Arrange + var kernel = new Kernel(); + var function = KernelFunctionFactory.CreateFromMethod(() => "Test", "TestFunction"); + var result = new FunctionResult(function); + var chatHistory = new ChatHistory(); + var chatMessageContent = new ChatMessageContent(AuthorRole.Assistant, "Test message"); + + // Create KernelArguments + var kernelArgs = new KernelArguments { ["test"] = "value" }; + + // Create a context with the arguments + var context = new AutoFunctionInvocationContext( + kernel, + function, + result, + chatHistory, + chatMessageContent) + { + Arguments = kernelArgs + }; + + // Act & Assert + Assert.Same(kernelArgs, context.AIArguments); + Assert.IsAssignableFrom(context.AIArguments); + } + + [Fact] + public void ArgumentsPropertyThrowsWhenBaseArgumentsIsNotKernelArguments() + { + // Arrange + var kernel = new Kernel(); + var function = KernelFunctionFactory.CreateFromMethod(() => "Test", "TestFunction"); + var result = new FunctionResult(function); + var chatHistory = new ChatHistory(); + var chatMessageContent = new ChatMessageContent(AuthorRole.Assistant, "Test message"); + + // Create a context + var context = new AutoFunctionInvocationContext( + kernel, + function, + result, + chatHistory, + chatMessageContent); + + // Use reflection to set base.Arguments to a non-KernelArguments instance + var nonKernelArgs = new NonKernelArgumentsClass(); + var baseType = typeof(AutoFunctionInvocationContext).BaseType; + var argumentsProperty = baseType?.GetProperty("Arguments"); + argumentsProperty?.SetValue(context, nonKernelArgs); + + // Act & Assert + var exception = Assert.Throws(() => context.Arguments); + Assert.Contains("The arguments are not of type KernelArguments", exception.Message); + Assert.Contains("use AIArguments instead", exception.Message); + } + + // Helper class for testing non-KernelArguments + private sealed class NonKernelArgumentsClass : AIFunctionArguments { } +} diff --git a/dotnet/src/SemanticKernel.UnitTests/Functions/KernelFunctionCloneTests.cs b/dotnet/src/SemanticKernel.UnitTests/Functions/KernelFunctionCloneTests.cs new file mode 100644 index 000000000000..10268cc8ebf2 --- /dev/null +++ b/dotnet/src/SemanticKernel.UnitTests/Functions/KernelFunctionCloneTests.cs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Xunit; + +namespace Microsoft.SemanticKernel.UnitTests.Functions; + +public class KernelFunctionCloneTests +{ + [Fact] + public async Task ClonedKernelFunctionUsesProvidedKernelWhenInvokingAsAIFunction() + { + // Arrange + var originalKernel = new Kernel(); + var newKernel = new Kernel(); + + // Create a function that returns the kernel's hash code + var function = KernelFunctionFactory.CreateFromMethod( + (Kernel k) => k.GetHashCode().ToString(), + "GetKernelHashCode"); + + // Create an AIFunction from the KernelFunction with the original kernel + var aiFunction = function.AsAIFunction(originalKernel); + + // Act + // Clone the function and create a new AIFunction with the new kernel + var clonedFunction = function.Clone("TestPlugin"); + var clonedAIFunction = clonedFunction.AsAIFunction(newKernel); + + // Invoke both functions + var originalResult = await aiFunction.InvokeAsync(new AIFunctionArguments(), default); + var clonedResult = await clonedAIFunction.InvokeAsync(new AIFunctionArguments(), default); + + // Assert + // The results should be different because they use different kernels + Assert.NotNull(originalResult); + Assert.NotNull(clonedResult); + Assert.NotEqual(originalResult, clonedResult); + Assert.Equal(originalKernel.GetHashCode().ToString(), originalResult.ToString()); + Assert.Equal(newKernel.GetHashCode().ToString(), clonedResult.ToString()); + } + + [Fact] + public async Task KernelAIFunctionUsesProvidedKernelWhenInvoking() + { + // Arrange + var kernel1 = new Kernel(); + var kernel2 = new Kernel(); + + // Create a function that returns the kernel's hash code + var function = KernelFunctionFactory.CreateFromMethod( + (Kernel k) => k.GetHashCode().ToString(), + "GetKernelHashCode"); + + // Act + // Create AIFunctions with different kernels + var aiFunction1 = function.AsAIFunction(kernel1); + var aiFunction2 = function.AsAIFunction(kernel2); + + // Invoke both functions + var result1 = await aiFunction1.InvokeAsync(new AIFunctionArguments(), default); + var result2 = await aiFunction2.InvokeAsync(new AIFunctionArguments(), default); + + // Assert + // The results should be different because they use different kernels + Assert.NotNull(result1); + Assert.NotNull(result2); + Assert.NotEqual(result1, result2); + Assert.Equal(kernel1.GetHashCode().ToString(), result1.ToString()); + Assert.Equal(kernel2.GetHashCode().ToString(), result2.ToString()); + } + + [Fact] + public void AsAIFunctionStoresKernelForLaterUse() + { + // Arrange + var kernel = new Kernel(); + var function = KernelFunctionFactory.CreateFromMethod(() => "Test", "TestFunction"); + + // Act + var aiFunction = function.AsAIFunction(kernel); + + // Assert + // We can't directly access the private _kernel field, but we can verify it's used + // by checking that the AIFunction has the correct name format + Assert.Equal("TestFunction", aiFunction.Name); + } + + [Fact] + public void ClonePreservesMetadataButChangesPluginName() + { + // Arrange + var function = KernelFunctionFactory.CreateFromMethod( + () => "Test", + "TestFunction", + "Test description"); + + // Act + var clonedFunction = function.Clone("NewPlugin"); + + // Assert + Assert.Equal("TestFunction", clonedFunction.Name); + Assert.Equal("NewPlugin", clonedFunction.PluginName); + Assert.Equal("Test description", clonedFunction.Description); + } +} From 786df010f4e3c98e150e60edf066f2e038b57e8f Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Fri, 2 May 2025 18:49:29 +0100 Subject: [PATCH 22/24] Fix warning --- .../src/Functions/Functions.OpenApi/RestApiOperationRunner.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs b/dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs index 28305a5ec8a4..cf07d36be1f5 100644 --- a/dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs +++ b/dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs @@ -13,6 +13,8 @@ using System.Threading.Tasks; using Microsoft.SemanticKernel.Http; +#pragma warning disable CA1859 // Use concrete types when possible for improved performance + namespace Microsoft.SemanticKernel.Plugins.OpenApi; /// From cb40a56372def337743919dfb264090a3f508b8f Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Fri, 2 May 2025 19:52:06 +0100 Subject: [PATCH 23/24] Remove AIArguments + fix UT namespace --- .../AutoFunctionInvocationContext.cs | 14 +----- .../AutoFunctionInvocationContextTests.cs | 47 ++----------------- .../Functions/KernelFunctionCloneTests.cs | 3 +- 3 files changed, 8 insertions(+), 56 deletions(-) diff --git a/dotnet/src/SemanticKernel.Abstractions/Filters/AutoFunctionInvocation/AutoFunctionInvocationContext.cs b/dotnet/src/SemanticKernel.Abstractions/Filters/AutoFunctionInvocation/AutoFunctionInvocationContext.cs index 02346c7d01f9..b58aadb73ddc 100644 --- a/dotnet/src/SemanticKernel.Abstractions/Filters/AutoFunctionInvocation/AutoFunctionInvocationContext.cs +++ b/dotnet/src/SemanticKernel.Abstractions/Filters/AutoFunctionInvocation/AutoFunctionInvocationContext.cs @@ -92,12 +92,10 @@ public AutoFunctionInvocationContext( /// Gets the specialized version of associated with the operation. /// /// - /// If the arguments are not a class use the property instead. - /// /// Due to a clash with the as a type, this property hides /// it to not break existing code that relies on the as a type. - /// /// + /// Attempting to access the property when the arguments is not a class. public new KernelArguments? Arguments { get @@ -107,19 +105,11 @@ public AutoFunctionInvocationContext( return kernelArguments; } - throw new InvalidOperationException($"The arguments are not of type {nameof(KernelArguments)}, for those scenarios, use {nameof(this.AIArguments)} instead."); + throw new InvalidOperationException($"The arguments provided in the initialization must be of type {nameof(KernelArguments)}."); } init => base.Arguments = value ?? new(); } - /// - /// Get the with which this filter is associated. - /// - public AIFunctionArguments AIArguments - { - get => base.Arguments; - } - /// /// Request sequence index of automatic function invocation process. Starts from 0. /// diff --git a/dotnet/src/SemanticKernel.UnitTests/Filters/AutoFunctionInvocation/AutoFunctionInvocationContextTests.cs b/dotnet/src/SemanticKernel.UnitTests/Filters/AutoFunctionInvocation/AutoFunctionInvocationContextTests.cs index a4e9395b8cca..b3971d6472ca 100644 --- a/dotnet/src/SemanticKernel.UnitTests/Filters/AutoFunctionInvocation/AutoFunctionInvocationContextTests.cs +++ b/dotnet/src/SemanticKernel.UnitTests/Filters/AutoFunctionInvocation/AutoFunctionInvocationContextTests.cs @@ -3,10 +3,11 @@ using System; using System.Threading.Tasks; using Microsoft.Extensions.AI; +using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.ChatCompletion; using Xunit; -namespace Microsoft.SemanticKernel.UnitTests.Filters.AutoFunctionInvocation; +namespace SemanticKernel.UnitTests.Filters.AutoFunctionInvocation; public class AutoFunctionInvocationContextTests { @@ -277,7 +278,6 @@ public void ArgumentsPropertyHandlesKernelArguments() // Act & Assert Assert.Same(kernelArgs, contextWithArgs.Arguments); - Assert.Same(kernelArgs, contextWithArgs.AIArguments); } [Fact] @@ -380,35 +380,6 @@ public void ArgumentsPropertyCanBeSetWithExecutionSettings() Assert.Same(executionSettings, context.Arguments.ExecutionSettings?[PromptExecutionSettings.DefaultServiceId]); } - [Fact] - public void AIArgumentsPropertyReturnsArguments() - { - // Arrange - var kernel = new Kernel(); - var function = KernelFunctionFactory.CreateFromMethod(() => "Test", "TestFunction"); - var result = new FunctionResult(function); - var chatHistory = new ChatHistory(); - var chatMessageContent = new ChatMessageContent(AuthorRole.Assistant, "Test message"); - - // Create KernelArguments - var kernelArgs = new KernelArguments { ["test"] = "value" }; - - // Create a context with the arguments - var context = new AutoFunctionInvocationContext( - kernel, - function, - result, - chatHistory, - chatMessageContent) - { - Arguments = kernelArgs - }; - - // Act & Assert - Assert.Same(kernelArgs, context.AIArguments); - Assert.IsAssignableFrom(context.AIArguments); - } - [Fact] public void ArgumentsPropertyThrowsWhenBaseArgumentsIsNotKernelArguments() { @@ -419,7 +390,6 @@ public void ArgumentsPropertyThrowsWhenBaseArgumentsIsNotKernelArguments() var chatHistory = new ChatHistory(); var chatMessageContent = new ChatMessageContent(AuthorRole.Assistant, "Test message"); - // Create a context var context = new AutoFunctionInvocationContext( kernel, function, @@ -427,18 +397,9 @@ public void ArgumentsPropertyThrowsWhenBaseArgumentsIsNotKernelArguments() chatHistory, chatMessageContent); - // Use reflection to set base.Arguments to a non-KernelArguments instance - var nonKernelArgs = new NonKernelArgumentsClass(); - var baseType = typeof(AutoFunctionInvocationContext).BaseType; - var argumentsProperty = baseType?.GetProperty("Arguments"); - argumentsProperty?.SetValue(context, nonKernelArgs); + ((Microsoft.Extensions.AI.FunctionInvocationContext)context).Arguments = new AIFunctionArguments(); // Act & Assert - var exception = Assert.Throws(() => context.Arguments); - Assert.Contains("The arguments are not of type KernelArguments", exception.Message); - Assert.Contains("use AIArguments instead", exception.Message); + Assert.Throws(() => context.Arguments); } - - // Helper class for testing non-KernelArguments - private sealed class NonKernelArgumentsClass : AIFunctionArguments { } } diff --git a/dotnet/src/SemanticKernel.UnitTests/Functions/KernelFunctionCloneTests.cs b/dotnet/src/SemanticKernel.UnitTests/Functions/KernelFunctionCloneTests.cs index 10268cc8ebf2..c6a0bc8dcb97 100644 --- a/dotnet/src/SemanticKernel.UnitTests/Functions/KernelFunctionCloneTests.cs +++ b/dotnet/src/SemanticKernel.UnitTests/Functions/KernelFunctionCloneTests.cs @@ -2,9 +2,10 @@ using System.Threading.Tasks; using Microsoft.Extensions.AI; +using Microsoft.SemanticKernel; using Xunit; -namespace Microsoft.SemanticKernel.UnitTests.Functions; +namespace SemanticKernel.UnitTests.Functions; public class KernelFunctionCloneTests { From 062406f70f5bdf6046ca4adbdfa7e6d278153106 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Fri, 2 May 2025 19:58:33 +0100 Subject: [PATCH 24/24] Fix using order --- .../Plugins/Core/SessionsPythonPluginTests.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/dotnet/src/IntegrationTests/Plugins/Core/SessionsPythonPluginTests.cs b/dotnet/src/IntegrationTests/Plugins/Core/SessionsPythonPluginTests.cs index f76bff8901fd..994f986fb068 100644 --- a/dotnet/src/IntegrationTests/Plugins/Core/SessionsPythonPluginTests.cs +++ b/dotnet/src/IntegrationTests/Plugins/Core/SessionsPythonPluginTests.cs @@ -1,19 +1,19 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.Threading.Tasks; -using Xunit; -using Microsoft.SemanticKernel.Plugins.Core.CodeInterpreter; -using Microsoft.Extensions.Configuration; -using SemanticKernel.IntegrationTests.TestSettings; +using System.Collections.Generic; using System.Net.Http; -using Azure.Identity; +using System.Threading.Tasks; using Azure.Core; -using System.Collections.Generic; -using Microsoft.SemanticKernel; +using Azure.Identity; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.ChatCompletion; using Microsoft.SemanticKernel.Connectors.AzureOpenAI; +using Microsoft.SemanticKernel.Plugins.Core.CodeInterpreter; +using SemanticKernel.IntegrationTests.TestSettings; +using Xunit; namespace SemanticKernel.IntegrationTests.Plugins.Core;