From 32df6fb6dd3efc018cf088afdc1522a7e58c6589 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 2 Sep 2025 13:29:09 -0400 Subject: [PATCH] Split AIFunction into a base class Enable a representation that doesn't support invocation separate from one that does. --- .../CHANGELOG.md | 4 + .../ChatCompletion/ChatToolMode.cs | 3 +- .../ChatCompletion/RequiredChatToolMode.cs | 8 +- .../Functions/AIFunction.cs | 51 ++---- .../Functions/AIFunctionDeclaration.cs | 58 ++++++ .../Functions/AIFunctionFactory.cs | 43 ++++- .../Functions/AIFunctionFactoryOptions.cs | 4 +- .../DelegatingAIFunctionDeclaration.cs | 48 +++++ .../Microsoft.Extensions.AI.Abstractions.json | 36 +++- .../Utilities/AIJsonSchemaTransformCache.cs | 16 +- .../AzureAIInferenceChatClient.cs | 4 +- .../CHANGELOG.md | 5 + .../AIToolExtensions.cs | 2 +- .../IntentResolutionEvaluator.cs | 2 +- .../IntentResolutionEvaluatorContext.cs | 8 +- .../TaskAdherenceEvaluator.cs | 2 +- .../TaskAdherenceEvaluatorContext.cs | 8 +- .../ToolCallAccuracyEvaluator.cs | 2 +- .../ToolCallAccuracyEvaluatorContext.cs | 8 +- .../CHANGELOG.md | 5 + ...crosoftExtensionsAIAssistantsExtensions.cs | 4 +- .../MicrosoftExtensionsAIChatExtensions.cs | 4 +- ...MicrosoftExtensionsAIRealtimeExtensions.cs | 4 +- ...icrosoftExtensionsAIResponsesExtensions.cs | 4 +- .../OpenAIAssistantsChatClient.cs | 4 +- .../OpenAIChatClient.cs | 4 +- .../OpenAIClientExtensions.cs | 4 +- .../OpenAIRealtimeConversationClient.cs | 2 +- .../OpenAIResponsesChatClient.cs | 4 +- .../Microsoft.Extensions.AI/CHANGELOG.md | 1 + .../FunctionInvokingChatClient.cs | 170 +++++++++++++----- .../Microsoft.Extensions.AI.json | 4 + .../FunctionInvokingChatClientTests.cs | 115 ++++++++++++ .../Functions/AIFunctionFactoryTest.cs | 20 +++ 34 files changed, 530 insertions(+), 131 deletions(-) create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionDeclaration.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunctionDeclaration.cs diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md index b98fa7f9312..bd2643ec060 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md @@ -1,5 +1,9 @@ # Release History +## NOT YET RELEASED + +- Added non-invocable `AIFunctionDeclaration` (base class for `AIFunction`), `AIFunctionFactory.CreateDeclaration`, and `AIFunction.AsDeclarationOnly`. + ## 9.8.0 - Added `AIAnnotation` and related types to represent citations and other annotations in chat messages. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatToolMode.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatToolMode.cs index 05e1f28f476..73134a5d894 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatToolMode.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatToolMode.cs @@ -55,8 +55,7 @@ private protected ChatToolMode() /// /// Instantiates a indicating that tool usage is required, - /// and that the specified must be selected. The function name - /// must match an entry in . + /// and that the specified function name must be selected. /// /// The name of the required function. /// An instance of for the specified function name. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/RequiredChatToolMode.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/RequiredChatToolMode.cs index 91397e67602..59ce51e7ef3 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/RequiredChatToolMode.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/RequiredChatToolMode.cs @@ -15,17 +15,17 @@ namespace Microsoft.Extensions.AI; public sealed class RequiredChatToolMode : ChatToolMode { /// - /// Gets the name of a specific that must be called. + /// Gets the name of a specific tool that must be called. /// /// - /// If the value is , any available function can be selected (but at least one must be). + /// If the value is , any available tool can be selected (but at least one must be). /// public string? RequiredFunctionName { get; } /// - /// Initializes a new instance of the class that requires a specific function to be called. + /// Initializes a new instance of the class that requires a specific tool to be called. /// - /// The name of the function that must be called. + /// The name of the tool that must be called. /// is empty or composed entirely of whitespace. /// /// can be . However, it's preferable to use diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunction.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunction.cs index 3910040d0a0..88a224ab1c1 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunction.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunction.cs @@ -6,44 +6,17 @@ using System.Threading; using System.Threading.Tasks; +#pragma warning disable SA1202 // Elements should be ordered by access + namespace Microsoft.Extensions.AI; /// Represents a function that can be described to an AI service and invoked. -public abstract class AIFunction : AITool +public abstract class AIFunction : AIFunctionDeclaration { - /// Gets a JSON Schema describing the function and its input parameters. - /// - /// - /// When specified, declares a self-contained JSON schema document that describes the function and its input parameters. - /// A simple example of a JSON schema for a function that adds two numbers together is shown below: - /// - /// - /// { - /// "title" : "addNumbers", - /// "description": "A simple function that adds two numbers together.", - /// "type": "object", - /// "properties": { - /// "a" : { "type": "number" }, - /// "b" : { "type": "number", "default": 1 } - /// }, - /// "required" : ["a"] - /// } - /// - /// - /// The metadata present in the schema document plays an important role in guiding AI function invocation. - /// - /// - /// When no schema is specified, consuming chat clients should assume the "{}" or "true" schema, indicating that any JSON input is admissible. - /// - /// - public virtual JsonElement JsonSchema => AIJsonUtilities.DefaultJsonSchema; - - /// Gets a JSON Schema describing the function's return value. - /// - /// A typically reflects a function that doesn't specify a return schema - /// or a function that returns , , or . - /// - public virtual JsonElement? ReturnJsonSchema => null; + /// Initializes a new instance of the class. + protected AIFunction() + { + } /// /// Gets the underlying that this might be wrapping. @@ -72,4 +45,14 @@ public abstract class AIFunction : AITool protected abstract ValueTask InvokeCoreAsync( AIFunctionArguments arguments, CancellationToken cancellationToken); + + /// Creates a representation of this that can't be invoked. + /// The created instance. + /// + /// derives from , layering on the ability to invoke the function in addition + /// to describing it. creates a new object that describes the function but that can't be invoked. + /// + public AIFunctionDeclaration AsDeclarationOnly() => new NonInvocableAIFunction(this); + + private sealed class NonInvocableAIFunction(AIFunction function) : DelegatingAIFunctionDeclaration(function); } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionDeclaration.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionDeclaration.cs new file mode 100644 index 00000000000..74e1242023a --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionDeclaration.cs @@ -0,0 +1,58 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using System.Threading.Tasks; + +#pragma warning disable S1694 // An abstract class should have both abstract and concrete methods + +namespace Microsoft.Extensions.AI; + +/// Represents a function that can be described to an AI service. +/// +/// is the base class for , which +/// adds the ability to invoke the function. Components may type test instances +/// for to determine whether they can be described as functions, +/// and may type test for to determine whether they can be invoked. +/// +public abstract class AIFunctionDeclaration : AITool +{ + /// Initializes a new instance of the class. + protected AIFunctionDeclaration() + { + } + + /// Gets a JSON Schema describing the function and its input parameters. + /// + /// + /// When specified, declares a self-contained JSON schema document that describes the function and its input parameters. + /// A simple example of a JSON schema for a function that adds two numbers together is shown below: + /// + /// + /// { + /// "title" : "addNumbers", + /// "description": "A simple function that adds two numbers together.", + /// "type": "object", + /// "properties": { + /// "a" : { "type": "number" }, + /// "b" : { "type": "number", "default": 1 } + /// }, + /// "required" : ["a"] + /// } + /// + /// + /// The metadata present in the schema document plays an important role in guiding AI function invocation. + /// + /// + /// When no schema is specified, consuming chat clients should assume the "{}" or "true" schema, indicating that any JSON input is admissible. + /// + /// + public virtual JsonElement JsonSchema => AIJsonUtilities.DefaultJsonSchema; + + /// Gets a JSON Schema describing the function's return value. + /// + /// A typically reflects a function that doesn't specify a return schema + /// or a function that returns , , or . + /// + public virtual JsonElement? ReturnJsonSchema => null; +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs index 0d7de9a341e..a613f6b693d 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs @@ -49,7 +49,7 @@ public static partial class AIFunctionFactory /// /// By default, any parameters to are sourced from the 's dictionary /// of key/value pairs and are represented in the JSON schema for the function, as exposed in the returned 's - /// . There are a few exceptions to this: + /// . There are a few exceptions to this: /// /// /// @@ -131,7 +131,7 @@ public static AIFunction Create(Delegate method, AIFunctionFactoryOptions? optio /// /// Any parameters to are sourced from the 's dictionary /// of key/value pairs and are represented in the JSON schema for the function, as exposed in the returned 's - /// . There are a few exceptions to this: + /// . There are a few exceptions to this: /// /// /// @@ -212,7 +212,7 @@ public static AIFunction Create(Delegate method, string? name = null, string? de /// /// By default, any parameters to are sourced from the 's dictionary /// of key/value pairs and are represented in the JSON schema for the function, as exposed in the returned 's - /// . There are a few exceptions to this: + /// . There are a few exceptions to this: /// /// /// @@ -304,7 +304,7 @@ public static AIFunction Create(MethodInfo method, object? target, AIFunctionFac /// /// Any parameters to are sourced from the 's dictionary /// of key/value pairs and are represented in the JSON schema for the function, as exposed in the returned 's - /// . There are a few exceptions to this: + /// . There are a few exceptions to this: /// /// /// @@ -398,7 +398,7 @@ public static AIFunction Create(MethodInfo method, object? target, string? name /// /// By default, any parameters to are sourced from the 's dictionary /// of key/value pairs and are represented in the JSON schema for the function, as exposed in the returned 's - /// . There are a few exceptions to this: + /// . There are a few exceptions to this: /// /// /// @@ -467,6 +467,39 @@ public static AIFunction Create( AIFunctionFactoryOptions? options = null) => ReflectionAIFunction.Build(method, createInstanceFunc, options ?? _defaultOptions); + /// Creates an using the specified parameters as the implementation of its corresponding properties. + /// The name of the function. + /// A description of the function, suitable for use in describing the purpose to a model. + /// A JSON schema describing the function and its input parameters. + /// A JSON schema describing the function's return value. + /// The created that describes a function. + /// is . + /// + /// creates an that can be used to describe a function + /// but not invoke it. To create an invocable , use Create. A non-invocable + /// may also be created from an invocable using that function's method. + /// + public static AIFunctionDeclaration CreateDeclaration( + string name, + string? description, + JsonElement jsonSchema, + JsonElement? returnJsonSchema = null) => + new DefaultAIFunctionDeclaration( + Throw.IfNullOrEmpty(name), + description ?? string.Empty, + jsonSchema, + returnJsonSchema); + + private sealed class DefaultAIFunctionDeclaration( + string name, string description, JsonElement jsonSchema, JsonElement? returnJsonSchema) : + AIFunctionDeclaration + { + public override string Name => name; + public override string Description => description; + public override JsonElement JsonSchema => jsonSchema; + public override JsonElement? ReturnJsonSchema => returnJsonSchema; + } + private sealed class ReflectionAIFunction : AIFunction { public static ReflectionAIFunction Build(MethodInfo method, object? target, AIFunctionFactoryOptions options) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactoryOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactoryOptions.cs index ebfffc34908..2bb9841a65e 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactoryOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactoryOptions.cs @@ -107,14 +107,14 @@ public AIFunctionFactoryOptions() public Func>? MarshalResult { get; set; } /// - /// Gets or sets a value indicating whether a schema should be created for the function's result type, if possible, and included as . + /// Gets or sets a value indicating whether a schema should be created for the function's result type, if possible, and included as . /// /// /// /// The default value is . /// /// - /// When set to , results in the produced to always be . + /// When set to , results in the produced to always be . /// /// public bool ExcludeResultSchema { get; set; } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunctionDeclaration.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunctionDeclaration.cs new file mode 100644 index 00000000000..874745610c3 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunctionDeclaration.cs @@ -0,0 +1,48 @@ +// 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.Text.Json; +using Microsoft.Shared.Diagnostics; + +#pragma warning disable SA1202 // Elements should be ordered by access + +namespace Microsoft.Extensions.AI; + +/// +/// Provides an optional base class for an that passes through calls to another instance. +/// +internal class DelegatingAIFunctionDeclaration : AIFunctionDeclaration // could be made public in the future if there's demand +{ + /// + /// Initializes a new instance of the class as a wrapper around . + /// + /// The inner AI function to which all calls are delegated by default. + /// is . + protected DelegatingAIFunctionDeclaration(AIFunctionDeclaration innerFunction) + { + InnerFunction = Throw.IfNull(innerFunction); + } + + /// Gets the inner . + protected AIFunctionDeclaration InnerFunction { get; } + + /// + public override string Name => InnerFunction.Name; + + /// + public override string Description => InnerFunction.Description; + + /// + public override JsonElement JsonSchema => InnerFunction.JsonSchema; + + /// + public override JsonElement? ReturnJsonSchema => InnerFunction.ReturnJsonSchema; + + /// + public override IReadOnlyDictionary AdditionalProperties => InnerFunction.AdditionalProperties; + + /// + public override string ToString() => InnerFunction.ToString(); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json index 92a5b51d6f7..ca42fdacd20 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json @@ -172,7 +172,7 @@ ] }, { - "Type": "abstract class Microsoft.Extensions.AI.AIFunction : Microsoft.Extensions.AI.AITool", + "Type": "abstract class Microsoft.Extensions.AI.AIFunction : Microsoft.Extensions.AI.AIFunctionDeclaration", "Stage": "Stable", "Methods": [ { @@ -186,23 +186,39 @@ { "Member": "abstract System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.AIFunction.InvokeCoreAsync(Microsoft.Extensions.AI.AIFunctionArguments arguments, System.Threading.CancellationToken cancellationToken);", "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.AIFunctionDeclaration Microsoft.Extensions.AI.AIFunction.AsDeclarationOnly();", + "Stage": "Stable" } ], "Properties": [ { - "Member": "virtual System.Text.Json.JsonElement Microsoft.Extensions.AI.AIFunction.JsonSchema { get; }", + "Member": "virtual System.Text.Json.JsonSerializerOptions Microsoft.Extensions.AI.AIFunction.JsonSerializerOptions { get; }", "Stage": "Stable" }, { - "Member": "virtual System.Text.Json.JsonSerializerOptions Microsoft.Extensions.AI.AIFunction.JsonSerializerOptions { get; }", + "Member": "virtual System.Reflection.MethodInfo? Microsoft.Extensions.AI.AIFunction.UnderlyingMethod { get; }", "Stage": "Stable" - }, + } + ] + }, + { + "Type": "abstract class Microsoft.Extensions.AI.AIFunctionDeclaration : Microsoft.Extensions.AI.AITool", + "Stage": "Stable", + "Methods": [ { - "Member": "virtual System.Text.Json.JsonElement? Microsoft.Extensions.AI.AIFunction.ReturnJsonSchema { get; }", + "Member": "Microsoft.Extensions.AI.AIFunctionDeclaration.AIFunctionDeclaration();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "virtual System.Text.Json.JsonElement Microsoft.Extensions.AI.AIFunctionDeclaration.JsonSchema { get; }", "Stage": "Stable" }, { - "Member": "virtual System.Reflection.MethodInfo? Microsoft.Extensions.AI.AIFunction.UnderlyingMethod { get; }", + "Member": "virtual System.Text.Json.JsonElement? Microsoft.Extensions.AI.AIFunctionDeclaration.ReturnJsonSchema { get; }", "Stage": "Stable" } ] @@ -306,6 +322,10 @@ { "Member": "static Microsoft.Extensions.AI.AIFunction Microsoft.Extensions.AI.AIFunctionFactory.Create(System.Reflection.MethodInfo method, System.Func createInstanceFunc, Microsoft.Extensions.AI.AIFunctionFactoryOptions? options = null);", "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.AI.AIFunctionDeclaration Microsoft.Extensions.AI.AIFunctionFactory.CreateDeclaration(string name, string? description, System.Text.Json.JsonElement jsonSchema, System.Text.Json.JsonElement? returnJsonSchema = null);", + "Stage": "Stable" } ] }, @@ -513,6 +533,10 @@ "Member": "System.Text.Json.JsonElement Microsoft.Extensions.AI.AIJsonSchemaTransformCache.GetOrCreateTransformedSchema(Microsoft.Extensions.AI.AIFunction function);", "Stage": "Stable" }, + { + "Member": "System.Text.Json.JsonElement Microsoft.Extensions.AI.AIJsonSchemaTransformCache.GetOrCreateTransformedSchema(Microsoft.Extensions.AI.AIFunctionDeclaration function);", + "Stage": "Stable" + }, { "Member": "System.Text.Json.JsonElement? Microsoft.Extensions.AI.AIJsonSchemaTransformCache.GetOrCreateTransformedSchema(Microsoft.Extensions.AI.ChatResponseFormatJson responseFormat);", "Stage": "Stable" diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaTransformCache.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaTransformCache.cs index a1aaeff26ac..198fd33a9d4 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaTransformCache.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaTransformCache.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.ComponentModel; using System.Runtime.CompilerServices; using System.Text.Json; using Microsoft.Shared.Diagnostics; @@ -23,10 +24,10 @@ namespace Microsoft.Extensions.AI; /// public sealed class AIJsonSchemaTransformCache { - private readonly ConditionalWeakTable _functionSchemaCache = new(); + private readonly ConditionalWeakTable _functionSchemaCache = new(); private readonly ConditionalWeakTable _responseFormatCache = new(); - private readonly ConditionalWeakTable.CreateValueCallback _functionSchemaCreateValueCallback; + private readonly ConditionalWeakTable.CreateValueCallback _functionSchemaCreateValueCallback; private readonly ConditionalWeakTable.CreateValueCallback _responseFormatCreateValueCallback; /// @@ -57,7 +58,16 @@ public AIJsonSchemaTransformCache(AIJsonSchemaTransformOptions transformOptions) /// /// The function whose JSON schema we want to transform. /// The transformed JSON schema corresponding to . - public JsonElement GetOrCreateTransformedSchema(AIFunction function) + [EditorBrowsable(EditorBrowsableState.Never)] // maintained for binary compat; functionality for AIFunction is satisfied by AIFunctionDeclaration overload + public JsonElement GetOrCreateTransformedSchema(AIFunction function) => + GetOrCreateTransformedSchema((AIFunctionDeclaration)function); + + /// + /// Gets or creates a transformed JSON schema for the specified instance. + /// + /// The function whose JSON schema we want to transform. + /// The transformed JSON schema corresponding to . + public JsonElement GetOrCreateTransformedSchema(AIFunctionDeclaration function) { _ = Throw.IfNull(function); return (JsonElement)_functionSchemaCache.GetValue(function, _functionSchemaCreateValueCallback); diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs index 0fd8f4506db..b56604c027d 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs @@ -343,7 +343,7 @@ private ChatCompletionsOptions ToAzureAIOptions(IEnumerable chatCon { foreach (AITool tool in tools) { - if (tool is AIFunction af) + if (tool is AIFunctionDeclaration af) { result.Tools.Add(ToAzureAIChatTool(af)); } @@ -410,7 +410,7 @@ private ChatCompletionsOptions ToAzureAIOptions(IEnumerable chatCon private static readonly BinaryData _falseString = BinaryData.FromString("false"); /// Converts an Extensions function to an AzureAI chat tool. - private static ChatCompletionsToolDefinition ToAzureAIChatTool(AIFunction aiFunction) + private static ChatCompletionsToolDefinition ToAzureAIChatTool(AIFunctionDeclaration aiFunction) { // Map to an intermediate model so that redundant properties are skipped. var tool = JsonSerializer.Deserialize(SchemaTransformCache.GetOrCreateTransformedSchema(aiFunction), JsonContext.Default.AzureAIChatToolJson)!; diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/CHANGELOG.md index d39b2d60cc3..4ba77ddf8cc 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/CHANGELOG.md +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/CHANGELOG.md @@ -1,5 +1,10 @@ # Release History +## NOT YET RELEASED + +- Updated tool mapping to recognize any `AIFunctionDeclaration`. +- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. + ## 9.8.0-preview.1.25412.6 - Updated to depend on Azure.AI.Inference 1.0.0-beta.5. diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/AIToolExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/AIToolExtensions.cs index 3dbc8211416..dcba14f92c0 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/AIToolExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/AIToolExtensions.cs @@ -19,7 +19,7 @@ internal static string RenderAsJson( var toolDefinitionsJsonArray = new JsonArray(); - foreach (AIFunction function in toolDefinitions.OfType()) + foreach (AIFunctionDeclaration function in toolDefinitions.OfType()) { JsonNode functionJsonNode = new JsonObject diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/IntentResolutionEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/IntentResolutionEvaluator.cs index 5960eb14aa0..5741adaff66 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/IntentResolutionEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/IntentResolutionEvaluator.cs @@ -25,7 +25,7 @@ namespace Microsoft.Extensions.AI.Evaluation.Quality; /// /// /// Note that at the moment, only supports evaluating calls to tools that are -/// defined as s. Any other definitions that are supplied via +/// defined as s. Any other definitions that are supplied via /// will be ignored. /// /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/IntentResolutionEvaluatorContext.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/IntentResolutionEvaluatorContext.cs index c19cb5dcd71..c8f407a12d8 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/IntentResolutionEvaluatorContext.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/IntentResolutionEvaluatorContext.cs @@ -19,7 +19,7 @@ namespace Microsoft.Extensions.AI.Evaluation.Quality; /// /// /// Note that at the moment, only supports evaluating calls to tools that are -/// defined as s. Any other definitions that are supplied via +/// defined as s. Any other definitions that are supplied via /// will be ignored. /// /// @@ -36,7 +36,7 @@ public sealed class IntentResolutionEvaluatorContext : EvaluationContext /// /// /// Note that at the moment, only supports evaluating calls to tools that - /// are defined as s. Any other definitions will be ignored. + /// are defined as s. Any other definitions will be ignored. /// /// public IntentResolutionEvaluatorContext(params AITool[] toolDefinitions) @@ -55,7 +55,7 @@ public IntentResolutionEvaluatorContext(params AITool[] toolDefinitions) /// /// /// Note that at the moment, only supports evaluating calls to tools that - /// are defined as s. Any other definitions will be ignored. + /// are defined as s. Any other definitions will be ignored. /// /// public IntentResolutionEvaluatorContext(IEnumerable toolDefinitions) @@ -81,7 +81,7 @@ public IntentResolutionEvaluatorContext(IEnumerable toolDefinitions) /// /// /// Note that at the moment, only supports evaluating calls to tools that - /// are defined as s. Any other definitions that are supplied via + /// are defined as s. Any other definitions that are supplied via /// will be ignored. /// /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/TaskAdherenceEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/TaskAdherenceEvaluator.cs index fc97dcc0268..c9e189af365 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/TaskAdherenceEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/TaskAdherenceEvaluator.cs @@ -24,7 +24,7 @@ namespace Microsoft.Extensions.AI.Evaluation.Quality; /// /// /// Note that at the moment, only supports evaluating calls to tools that are -/// defined as s. Any other definitions that are supplied via +/// defined as s. Any other definitions that are supplied via /// will be ignored. /// /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/TaskAdherenceEvaluatorContext.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/TaskAdherenceEvaluatorContext.cs index c8e94d03b26..535306b5d4e 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/TaskAdherenceEvaluatorContext.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/TaskAdherenceEvaluatorContext.cs @@ -20,7 +20,7 @@ namespace Microsoft.Extensions.AI.Evaluation.Quality; /// /// /// Note that at the moment, only supports evaluating calls to tools that are -/// defined as s. Any other definitions that are supplied via +/// defined as s. Any other definitions that are supplied via /// will be ignored. /// /// @@ -37,7 +37,7 @@ public sealed class TaskAdherenceEvaluatorContext : EvaluationContext /// /// /// Note that at the moment, only supports evaluating calls to tools that - /// are defined as s. Any other definitions will be ignored. + /// are defined as s. Any other definitions will be ignored. /// /// public TaskAdherenceEvaluatorContext(params AITool[] toolDefinitions) @@ -56,7 +56,7 @@ public TaskAdherenceEvaluatorContext(params AITool[] toolDefinitions) /// /// /// Note that at the moment, only supports evaluating calls to tools that - /// are defined as s. Any other definitions will be ignored. + /// are defined as s. Any other definitions will be ignored. /// /// public TaskAdherenceEvaluatorContext(IEnumerable toolDefinitions) @@ -83,7 +83,7 @@ public TaskAdherenceEvaluatorContext(IEnumerable toolDefinitions) /// /// /// Note that at the moment, only supports evaluating calls to tools that are - /// defined as s. Any other definitions that are supplied via + /// defined as s. Any other definitions that are supplied via /// will be ignored. /// /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/ToolCallAccuracyEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/ToolCallAccuracyEvaluator.cs index bed95eeb3a2..252b1254354 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/ToolCallAccuracyEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/ToolCallAccuracyEvaluator.cs @@ -25,7 +25,7 @@ namespace Microsoft.Extensions.AI.Evaluation.Quality; /// /// /// Note that at the moment, only supports evaluating calls to tools that are -/// defined as s. Any other definitions that are supplied via +/// defined as s. Any other definitions that are supplied via /// will be ignored. /// /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/ToolCallAccuracyEvaluatorContext.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/ToolCallAccuracyEvaluatorContext.cs index 037d811e0f4..7b01b3f50eb 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/ToolCallAccuracyEvaluatorContext.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/ToolCallAccuracyEvaluatorContext.cs @@ -21,7 +21,7 @@ namespace Microsoft.Extensions.AI.Evaluation.Quality; /// /// /// Note that at the moment, only supports evaluating calls to tools that are -/// defined as s. Any other definitions that are supplied via +/// defined as s. Any other definitions that are supplied via /// will be ignored. /// /// @@ -38,7 +38,7 @@ public sealed class ToolCallAccuracyEvaluatorContext : EvaluationContext /// /// /// Note that at the moment, only supports evaluating calls to tools that - /// are defined as s. Any other definitions will be ignored. + /// are defined as s. Any other definitions will be ignored. /// /// public ToolCallAccuracyEvaluatorContext(params AITool[] toolDefinitions) @@ -57,7 +57,7 @@ public ToolCallAccuracyEvaluatorContext(params AITool[] toolDefinitions) /// /// /// Note that at the moment, only supports evaluating calls to tools that - /// are defined as s. Any other definitions will be ignored. + /// are defined as s. Any other definitions will be ignored. /// /// public ToolCallAccuracyEvaluatorContext(IEnumerable toolDefinitions) @@ -85,7 +85,7 @@ public ToolCallAccuracyEvaluatorContext(IEnumerable toolDefinitions) /// /// /// Note that at the moment, only supports evaluating calls to tools that - /// are defined as s. Any other definitions that are supplied via + /// are defined as s. Any other definitions that are supplied via /// will be ignored. /// /// diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md index 143c439bda7..1c1c175ccd4 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md @@ -1,5 +1,10 @@ # Release History +## NOT YET RELEASED + +- Updated tool mappings to recognize any `AIFunctionDeclaration`. +- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. + ## 9.8.0-preview.1.25412.6 - Updated to depend on OpenAI 2.3.0. diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIAssistantsExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIAssistantsExtensions.cs index 900883c6d43..793c906bd9d 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIAssistantsExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIAssistantsExtensions.cs @@ -10,10 +10,10 @@ namespace OpenAI.Assistants; /// Provides extension methods for working with content associated with OpenAI.Assistants. public static class MicrosoftExtensionsAIAssistantsExtensions { - /// Creates an OpenAI from an . + /// Creates an OpenAI from an . /// The function to convert. /// An OpenAI representing . /// is . - public static FunctionToolDefinition AsOpenAIAssistantsFunctionToolDefinition(this AIFunction function) => + public static FunctionToolDefinition AsOpenAIAssistantsFunctionToolDefinition(this AIFunctionDeclaration function) => OpenAIAssistantsChatClient.ToOpenAIAssistantsFunctionToolDefinition(Throw.IfNull(function)); } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIChatExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIChatExtensions.cs index 0385d318842..113e91c3305 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIChatExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIChatExtensions.cs @@ -19,11 +19,11 @@ namespace OpenAI.Chat; /// Provides extension methods for working with content associated with OpenAI.Chat. public static class MicrosoftExtensionsAIChatExtensions { - /// Creates an OpenAI from an . + /// Creates an OpenAI from an . /// The function to convert. /// An OpenAI representing . /// is . - public static ChatTool AsOpenAIChatTool(this AIFunction function) => + public static ChatTool AsOpenAIChatTool(this AIFunctionDeclaration function) => OpenAIChatClient.ToOpenAIChatTool(Throw.IfNull(function)); /// Creates a sequence of OpenAI instances from the specified input messages. diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIRealtimeExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIRealtimeExtensions.cs index ad180e96b1e..903c6253dde 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIRealtimeExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIRealtimeExtensions.cs @@ -10,10 +10,10 @@ namespace OpenAI.Realtime; /// Provides extension methods for working with content associated with OpenAI.Realtime. public static class MicrosoftExtensionsAIRealtimeExtensions { - /// Creates an OpenAI from an . + /// Creates an OpenAI from an . /// The function to convert. /// An OpenAI representing . /// is . - public static ConversationFunctionTool AsOpenAIConversationFunctionTool(this AIFunction function) => + public static ConversationFunctionTool AsOpenAIConversationFunctionTool(this AIFunctionDeclaration function) => OpenAIRealtimeConversationClient.ToOpenAIConversationFunctionTool(Throw.IfNull(function)); } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs index 188f5df3e52..d6b290f431b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs @@ -14,11 +14,11 @@ namespace OpenAI.Responses; /// Provides extension methods for working with content associated with OpenAI.Responses. public static class MicrosoftExtensionsAIResponsesExtensions { - /// Creates an OpenAI from an . + /// Creates an OpenAI from an . /// The function to convert. /// An OpenAI representing . /// is . - public static ResponseTool AsOpenAIResponseTool(this AIFunction function) => + public static ResponseTool AsOpenAIResponseTool(this AIFunctionDeclaration function) => OpenAIResponsesChatClient.ToResponseTool(Throw.IfNull(function)); /// Creates a sequence of OpenAI instances from the specified input messages. diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantsChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantsChatClient.cs index ed7f8403cb7..01b78994f38 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantsChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantsChatClient.cs @@ -286,7 +286,7 @@ void IDisposable.Dispose() } /// Converts an Extensions function to an OpenAI assistants function tool. - internal static FunctionToolDefinition ToOpenAIAssistantsFunctionToolDefinition(AIFunction aiFunction, ChatOptions? options = null) + internal static FunctionToolDefinition ToOpenAIAssistantsFunctionToolDefinition(AIFunctionDeclaration aiFunction, ChatOptions? options = null) { bool? strict = OpenAIClientExtensions.HasStrict(aiFunction.AdditionalProperties) ?? @@ -348,7 +348,7 @@ internal static FunctionToolDefinition ToOpenAIAssistantsFunctionToolDefinition( { switch (tool) { - case AIFunction aiFunction: + case AIFunctionDeclaration aiFunction: runOptions.ToolsOverride.Add(ToOpenAIAssistantsFunctionToolDefinition(aiFunction, options)); break; diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index 7bffbc79d10..415aef5901e 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -101,7 +101,7 @@ void IDisposable.Dispose() } /// Converts an Extensions function to an OpenAI chat tool. - internal static ChatTool ToOpenAIChatTool(AIFunction aiFunction, ChatOptions? options = null) + internal static ChatTool ToOpenAIChatTool(AIFunctionDeclaration aiFunction, ChatOptions? options = null) { bool? strict = OpenAIClientExtensions.HasStrict(aiFunction.AdditionalProperties) ?? @@ -564,7 +564,7 @@ private ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) { foreach (AITool tool in tools) { - if (tool is AIFunction af) + if (tool is AIFunctionDeclaration af) { result.Tools.Add(ToOpenAIChatTool(af, options)); } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs index a5739fdb4ac..19fa835851f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs @@ -177,8 +177,8 @@ public static IEmbeddingGenerator> AsIEmbeddingGenerato strictObj is bool strictValue ? strictValue : null; - /// Extracts from an the parameters and strictness setting for use with OpenAI's APIs. - internal static BinaryData ToOpenAIFunctionParameters(AIFunction aiFunction, bool? strict) + /// Extracts from an the parameters and strictness setting for use with OpenAI's APIs. + internal static BinaryData ToOpenAIFunctionParameters(AIFunctionDeclaration aiFunction, bool? strict) { // Perform any desirable transformations on the function's JSON schema, if it'll be used in a strict setting. JsonElement jsonSchema = strict is true ? diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeConversationClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeConversationClient.cs index 7c944ac5edb..dbbabea026f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeConversationClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeConversationClient.cs @@ -8,7 +8,7 @@ namespace Microsoft.Extensions.AI; /// Provides helpers for interacting with OpenAI Realtime. internal sealed class OpenAIRealtimeConversationClient { - public static ConversationFunctionTool ToOpenAIConversationFunctionTool(AIFunction aiFunction, ChatOptions? options = null) + public static ConversationFunctionTool ToOpenAIConversationFunctionTool(AIFunctionDeclaration aiFunction, ChatOptions? options = null) { bool? strict = OpenAIClientExtensions.HasStrict(aiFunction.AdditionalProperties) ?? diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs index afb03b518d9..e1cd031f8a8 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -336,7 +336,7 @@ void IDisposable.Dispose() // Nothing to dispose. Implementation required for the IChatClient interface. } - internal static ResponseTool ToResponseTool(AIFunction aiFunction, ChatOptions? options = null) + internal static ResponseTool ToResponseTool(AIFunctionDeclaration aiFunction, ChatOptions? options = null) { bool? strict = OpenAIClientExtensions.HasStrict(aiFunction.AdditionalProperties) ?? @@ -399,7 +399,7 @@ private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? opt { switch (tool) { - case AIFunction aiFunction: + case AIFunctionDeclaration aiFunction: result.Tools.Add(ToResponseTool(aiFunction, options)); break; diff --git a/src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md index 213e3b8a60d..2c1a6179a69 100644 --- a/src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md +++ b/src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md @@ -2,6 +2,7 @@ ## NOT YET RELEASED +- Added `FunctionInvokingChatClient` support for non-invocable tools and `TerminateOnUnknownCalls` property. - Fixed `GetResponseAsync` to only look at the contents of the last message in the response. ## 9.8.0 diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs index 9c0506a2307..c585e007401 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs @@ -18,6 +18,7 @@ #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 S107 // Methods should not have too many parameters +#pragma warning disable IDE0032 // Use auto property, suppressed until repo updates to C# 14 namespace Microsoft.Extensions.AI; @@ -215,6 +216,30 @@ public int MaximumConsecutiveErrorsPerRequest /// public IList? AdditionalTools { get; set; } + /// Gets or sets a value indicating whether a request to call an unknown function should terminate the function calling loop. + /// + /// + /// When , call requests to any tools that aren't available to the + /// will result in a response message automatically being created and returned to the inner client stating that the tool couldn't be + /// found; this can help in cases where a model hallucinates a function, but it's problematic if the model has been made aware + /// of the existence of tools outside of the normal mechanisms, and requests one of those. can be used + /// to help with that, but if instead the consumer wants to know about all function call requests that the client can't handle, + /// can be set to , and upon receiving a request to call a function + /// that the doesn't know about, it will terminate the function calling loop and return + /// the response, leaving the handling of the function call requests to the consumer of the client. + /// + /// + /// Note that s that the is aware of (e.g. because they're in + /// or ) but that aren't are not considered + /// unknown, just not invocable. Any requests to a non-invocable tool will also result in the function calling loop terminating, + /// regardless of . + /// + /// + /// This defaults to . + /// + /// + public bool TerminateOnUnknownCalls { get; set; } + /// Gets or sets a delegate used to invoke instances. /// /// By default, the protected method is called for each to be invoked, @@ -239,6 +264,7 @@ public override async Task GetResponseAsync( List originalMessages = [.. messages]; messages = originalMessages; + Dictionary? toolMap = null; // all available tools, indexed by name 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 @@ -260,14 +286,17 @@ public override async Task GetResponseAsync( // Any function call work to do? If yes, ensure we're tracking that work in functionCallContents. bool requiresFunctionInvocation = - (options?.Tools is { Count: > 0 } || AdditionalTools is { Count: > 0 }) && 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) + if (requiresFunctionInvocation) + { + toolMap ??= CreateToolsDictionary(AdditionalTools, options?.Tools); + } + else if (iteration == 0) { + // 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. return response; } @@ -285,10 +314,10 @@ public override async Task GetResponseAsync( } } - // 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) + // If there's nothing more to do, break out of the loop and allow the handling at the + // end to configure the response with aggregated data from previous requests. + if (!requiresFunctionInvocation || + ShouldTerminateLoopBasedOnHandleableFunctions(functionCallContents, toolMap)) { break; } @@ -298,7 +327,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(augmentedHistory, options, functionCallContents!, iteration, consecutiveErrorCount, isStreaming: false, cancellationToken); + var modeAndMessages = await ProcessFunctionCallsAsync(augmentedHistory, options, toolMap, functionCallContents!, iteration, consecutiveErrorCount, isStreaming: false, cancellationToken); responseMessages.AddRange(modeAndMessages.MessagesAdded); consecutiveErrorCount = modeAndMessages.NewConsecutiveErrorCount; @@ -335,6 +364,7 @@ public override async IAsyncEnumerable GetStreamingResponseA List originalMessages = [.. messages]; messages = originalMessages; + Dictionary? toolMap = null; // all available tools, indexed by name 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 @@ -375,10 +405,10 @@ public override async IAsyncEnumerable GetStreamingResponseA 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 } && AdditionalTools is not { Count: > 0 }) || - iteration >= _maximumIterationsPerRequest) + // If there's nothing more to do, break out of the loop and allow the handling at the + // end to configure the response with aggregated data from previous requests. + if (iteration >= MaximumIterationsPerRequest || + ShouldTerminateLoopBasedOnHandleableFunctions(functionCallContents, toolMap ??= CreateToolsDictionary(AdditionalTools, options?.Tools))) { break; } @@ -391,7 +421,7 @@ public override async IAsyncEnumerable GetStreamingResponseA FixupHistories(originalMessages, ref messages, ref augmentedHistory, response, responseMessages, ref lastIterationHadConversationId); // Process all of the functions, adding their results into the history. - var modeAndMessages = await ProcessFunctionCallsAsync(augmentedHistory, options, functionCallContents, iteration, consecutiveErrorCount, isStreaming: true, cancellationToken); + var modeAndMessages = await ProcessFunctionCallsAsync(augmentedHistory, options, toolMap, functionCallContents!, iteration, consecutiveErrorCount, isStreaming: true, cancellationToken); responseMessages.AddRange(modeAndMessages.MessagesAdded); consecutiveErrorCount = modeAndMessages.NewConsecutiveErrorCount; @@ -513,6 +543,31 @@ private static void FixupHistories( messages = augmentedHistory; } + /// Creates a dictionary mapping tool names to the corresponding tools. + /// + /// The lists of tools to combine into a single dictionary. Tools from later lists are preferred + /// over tools from earlier lists if they have the same name. + /// + private static Dictionary? CreateToolsDictionary(params ReadOnlySpan?> toolLists) + { + Dictionary? tools = null; + + foreach (var toolList in toolLists) + { + if (toolList?.Count is int count && count > 0) + { + tools ??= new(StringComparer.Ordinal); + for (int i = 0; i < count; i++) + { + AITool tool = toolList[i]; + tools[tool.Name] = tool; + } + } + } + + return tools; + } + /// Copies any from to . private static bool CopyFunctionCalls( IList messages, [NotNullWhen(true)] ref List? functionCalls) @@ -571,11 +626,59 @@ private static void UpdateOptionsForNextIteration(ref ChatOptions? options, stri } } + /// Gets whether the function calling loop should exit based on the function call requests. + /// The call requests. + /// The map from tool names to tools. + private bool ShouldTerminateLoopBasedOnHandleableFunctions(List? functionCalls, Dictionary? toolMap) + { + if (functionCalls is not { Count: > 0 }) + { + // There are no functions to call, so there's no reason to keep going. + return true; + } + + if (toolMap is not { Count: > 0 }) + { + // There are functions to call but we have no tools, so we can't handle them. + // If we're configured to terminate on unknown call requests, do so now. + // Otherwise, ProcessFunctionCallsAsync will handle it by creating a NotFound response message. + return TerminateOnUnknownCalls; + } + + // At this point, we have both function call requests and some tools. + // Look up each function. + foreach (var fcc in functionCalls) + { + if (toolMap.TryGetValue(fcc.Name, out var tool)) + { + if (tool is not AIFunction) + { + // The tool was found but it's not invocable. Regardless of TerminateOnUnknownCallRequests, + // we need to break out of the loop so that callers can handle all the call requests. + return true; + } + } + else + { + // The tool couldn't be found. If we're configured to terminate on unknown call requests, + // break out of the loop now. Otherwise, ProcessFunctionCallsAsync will handle it by + // creating a NotFound response message. + if (TerminateOnUnknownCalls) + { + return true; + } + } + } + + return 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. + /// Map from tool name to tool. /// 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. @@ -583,7 +686,8 @@ private static void UpdateOptionsForNextIteration(ref ChatOptions? options, stri /// 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, + List messages, ChatOptions? options, + Dictionary? toolMap, 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. @@ -591,13 +695,13 @@ private static void UpdateOptionsForNextIteration(ref ChatOptions? options, stri Debug.Assert(functionCallContents.Count > 0, "Expected at least one function call."); var shouldTerminate = false; - var captureCurrentIterationExceptions = consecutiveErrorCount < _maximumConsecutiveErrorsPerRequest; + 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, + messages, options, toolMap, functionCallContents, iteration, 0, captureCurrentIterationExceptions, isStreaming, cancellationToken); IList addedMessages = CreateResponseMessages([result]); @@ -620,7 +724,7 @@ private static void UpdateOptionsForNextIteration(ref ChatOptions? options, stri results.AddRange(await Task.WhenAll( from callIndex in Enumerable.Range(0, functionCallContents.Count) select ProcessFunctionCallAsync( - messages, options, functionCallContents, + messages, options, toolMap, functionCallContents, iteration, callIndex, captureExceptions: true, isStreaming, cancellationToken))); shouldTerminate = results.Any(r => r.Terminate); @@ -631,7 +735,7 @@ select ProcessFunctionCallAsync( for (int callIndex = 0; callIndex < functionCallContents.Count; callIndex++) { var functionResult = await ProcessFunctionCallAsync( - messages, options, functionCallContents, + messages, options, toolMap, functionCallContents, iteration, callIndex, captureCurrentIterationExceptions, isStreaming, cancellationToken); results.Add(functionResult); @@ -670,7 +774,7 @@ private void UpdateConsecutiveErrorCountOrThrow(IList added, ref in if (allExceptions.Any()) { consecutiveErrorCount++; - if (consecutiveErrorCount > _maximumConsecutiveErrorsPerRequest) + if (consecutiveErrorCount > MaximumConsecutiveErrorsPerRequest) { var allExceptionsArray = allExceptions.ToArray(); if (allExceptionsArray.Length == 1) @@ -704,6 +808,7 @@ private void ThrowIfNoFunctionResultsAdded(IList? 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. + /// Map from tool name to tool. /// 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 . @@ -712,14 +817,16 @@ private void ThrowIfNoFunctionResultsAdded(IList? messages) /// The to monitor for cancellation requests. /// A value indicating how the caller should proceed. private async Task ProcessFunctionCallAsync( - List messages, ChatOptions? options, List callContents, + List messages, ChatOptions? options, + Dictionary? toolMap, 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? aiFunction = FindAIFunction(options?.Tools, callContent.Name) ?? FindAIFunction(AdditionalTools, callContent.Name); - if (aiFunction is null) + if (toolMap is null || + !toolMap.TryGetValue(callContent.Name, out AITool? tool) || + tool is not AIFunction aiFunction) { return new(terminate: false, FunctionInvocationStatus.NotFound, callContent, result: null, exception: null); } @@ -763,23 +870,6 @@ private async Task ProcessFunctionCallAsync( callContent, result, exception: null); - - static AIFunction? FindAIFunction(IList? tools, string functionName) - { - if (tools is not null) - { - int count = tools.Count; - for (int i = 0; i < count; i++) - { - if (tools[i] is AIFunction function && function.Name == functionName) - { - return function; - } - } - } - - return null; - } } /// Creates one or more response messages for function invocation results. diff --git a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.json b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.json index 3e3f0426dd1..45b72f0aad1 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.json +++ b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.json @@ -546,6 +546,10 @@ { "Member": "int Microsoft.Extensions.AI.FunctionInvokingChatClient.MaximumIterationsPerRequest { get; set; }", "Stage": "Stable" + }, + { + "Member": "bool Microsoft.Extensions.AI.FunctionInvokingChatClient.TerminateOnUnknownCalls { get; set; }", + "Stage": "Stable" } ] }, diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs index 08cb5ee5760..b9a71849034 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs @@ -1062,6 +1062,121 @@ public async Task FunctionInvocations_InvokedOnOriginalSynchronizationContext() await InvokeAndAssertStreamingAsync(options, plan, configurePipeline: configurePipeline); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task TerminateOnUnknownCalls_ControlsBehaviorForUnknownFunctions(bool terminateOnUnknown) + { + ChatOptions options = new() + { + Tools = [AIFunctionFactory.Create((int i) => $"Known: {i}", "KnownFunc")] + }; + + Func configure = b => b.Use( + s => new FunctionInvokingChatClient(s) { TerminateOnUnknownCalls = terminateOnUnknown }); + + if (!terminateOnUnknown) + { + List planForContinue = + [ + new(ChatRole.User, "hello"), + new(ChatRole.Assistant, [ + new FunctionCallContent("callId1", "UnknownFunc", new Dictionary { ["i"] = 1 }), + new FunctionCallContent("callId2", "KnownFunc", new Dictionary { ["i"] = 2 }) + ]), + new(ChatRole.Tool, [ + new FunctionResultContent("callId1", result: "Error: Requested function \"UnknownFunc\" not found."), + new FunctionResultContent("callId2", result: "Known: 2") + ]), + new(ChatRole.Assistant, "done"), + ]; + + await InvokeAndAssertAsync(options, planForContinue, configurePipeline: configure); + await InvokeAndAssertStreamingAsync(options, planForContinue, configurePipeline: configure); + } + else + { + List fullPlanWithUnknown = + [ + new(ChatRole.User, "hello"), + new(ChatRole.Assistant, [ + new FunctionCallContent("callId1", "UnknownFunc", new Dictionary { ["i"] = 1 }), + new FunctionCallContent("callId2", "KnownFunc", new Dictionary { ["i"] = 2 }) + ]), + new(ChatRole.Tool, [ + new FunctionResultContent("callId1", result: "Error: Requested function \"UnknownFunc\" not found."), + new FunctionResultContent("callId2", result: "Known: 2") + ]), + new(ChatRole.Assistant, "done"), + ]; + + var expected = fullPlanWithUnknown.Take(2).ToList(); + await InvokeAndAssertAsync(options, fullPlanWithUnknown, expected, configure); + await InvokeAndAssertStreamingAsync(options, fullPlanWithUnknown, expected, configure); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RequestsWithOnlyFunctionDeclarations_TerminatesRegardlessOfTerminateOnUnknownCalls(bool terminateOnUnknown) + { + var declarationOnly = AIFunctionFactory.Create(() => "unused", "DefOnly").AsDeclarationOnly(); + + ChatOptions options = new() { Tools = [declarationOnly] }; + + List fullPlan = + [ + new(ChatRole.User, "hello"), + new(ChatRole.Assistant, [new FunctionCallContent("callId1", "DefOnly")]), + new(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Should not be produced")]), + new(ChatRole.Assistant, "world"), + ]; + + List expected = fullPlan.Take(2).ToList(); + + Func configure = b => b.Use( + s => new FunctionInvokingChatClient(s) { TerminateOnUnknownCalls = terminateOnUnknown }); + + await InvokeAndAssertAsync(options, fullPlan, expected, configure); + await InvokeAndAssertStreamingAsync(options, fullPlan, expected, configure); + } + + [Fact] + public async Task MixedKnownFunctionAndDeclaration_TerminatesWithoutInvokingKnown() + { + int invoked = 0; + var known = AIFunctionFactory.Create(() => { invoked++; return "OK"; }, "Known"); + var defOnly = AIFunctionFactory.Create(() => "unused", "DefOnly").AsDeclarationOnly(); + + var options = new ChatOptions + { + Tools = [known, defOnly] + }; + + List fullPlan = + [ + new(ChatRole.User, "hi"), + new(ChatRole.Assistant, [ + new FunctionCallContent("callId1", "Known"), + new FunctionCallContent("callId2", "DefOnly") + ]), + new(ChatRole.Tool, [new FunctionResultContent("callId1", result: "OK"), new FunctionResultContent("callId2", result: "nope")]), + new(ChatRole.Assistant, "done"), + ]; + + List expected = fullPlan.Take(2).ToList(); + + Func configure = b => b.Use(s => new FunctionInvokingChatClient(s) { TerminateOnUnknownCalls = false }); + await InvokeAndAssertAsync(options, fullPlan, expected, configure); + Assert.Equal(0, invoked); + + invoked = 0; + configure = b => b.Use(s => new FunctionInvokingChatClient(s) { TerminateOnUnknownCalls = true }); + await InvokeAndAssertStreamingAsync(options, fullPlan, expected, configure); + Assert.Equal(0, invoked); + } + private sealed class CustomSynchronizationContext : SynchronizationContext { public override void Post(SendOrPostCallback d, object? state) diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs index 69787dc868b..7e61ad745c4 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs @@ -931,6 +931,26 @@ public void AIFunctionFactory_ReturnTypeWithDescriptionAttribute() static int Add(int a, int b) => a + b; } + [Fact] + public void CreateDeclaration_Roundtrips() + { + JsonElement schema = AIJsonUtilities.CreateJsonSchema(typeof(int), serializerOptions: AIJsonUtilities.DefaultOptions); + + AIFunctionDeclaration f = AIFunctionFactory.CreateDeclaration("something", "amazing", schema); + Assert.Equal("something", f.Name); + Assert.Equal("amazing", f.Description); + Assert.Equal("""{"type":"integer"}""", f.JsonSchema.ToString()); + Assert.Null(f.ReturnJsonSchema); + + f = AIFunctionFactory.CreateDeclaration("other", null, default, schema); + Assert.Equal("other", f.Name); + Assert.Empty(f.Description); + Assert.Equal(default, f.JsonSchema); + Assert.Equal("""{"type":"integer"}""", f.ReturnJsonSchema.ToString()); + + Assert.Throws("name", () => AIFunctionFactory.CreateDeclaration(null!, "description", default)); + } + private sealed class MyService(int value) { public int Value => value;