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
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;