-
Notifications
You must be signed in to change notification settings - Fork 860
Add HostedToolSearchTool and SearchableAIFunctionDeclaration for tool search / deferred loading support #7377
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 8 commits
475d067
8e7c59b
bf6107a
40f3432
5997a6a
7f3893f
4ad7331
74f0c51
2b39eb9
78f7d57
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| // Licensed to the .NET Foundation under one or more agreements. | ||
| // The .NET Foundation licenses this file to you under the MIT license. | ||
|
|
||
| using System.Collections.Generic; | ||
| using System.Diagnostics.CodeAnalysis; | ||
| using Microsoft.Shared.DiagnosticIds; | ||
| using Microsoft.Shared.Diagnostics; | ||
|
|
||
| namespace Microsoft.Extensions.AI; | ||
|
|
||
| /// <summary> | ||
| /// Represents an <see cref="AIFunctionDeclaration"/> that signals to supporting AI services that deferred | ||
| /// loading should be used when tool search is enabled. Only the function's name and description are sent initially; | ||
| /// the full JSON schema is loaded on demand by the service when the model selects this tool. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// This class is a marker/decorator that signals to a supporting provider that the function should be | ||
| /// sent with deferred loading (only name and description upfront). Use <see cref="CreateToolSet"/> to create | ||
| /// a complete tool list including a <see cref="HostedToolSearchTool"/> and wrapped functions. | ||
| /// </remarks> | ||
| [Experimental(DiagnosticIds.Experiments.AIToolSearch, UrlFormat = DiagnosticIds.UrlFormat)] | ||
| public sealed class SearchableAIFunctionDeclaration : DelegatingAIFunctionDeclaration | ||
| { | ||
| /// <summary> | ||
| /// Initializes a new instance of the <see cref="SearchableAIFunctionDeclaration"/> class. | ||
| /// </summary> | ||
| /// <param name="innerFunction">The <see cref="AIFunctionDeclaration"/> represented by this instance.</param> | ||
| /// <param name="namespace">An optional namespace for grouping related tools in the tool search index.</param> | ||
| /// <exception cref="System.ArgumentNullException"><paramref name="innerFunction"/> is <see langword="null"/>.</exception> | ||
| public SearchableAIFunctionDeclaration(AIFunctionDeclaration innerFunction, string? @namespace = null) | ||
| : base(innerFunction) | ||
| { | ||
| Namespace = @namespace; | ||
| } | ||
|
|
||
| /// <summary>Gets the optional namespace this function belongs to, for grouping related tools in the tool search index.</summary> | ||
| public string? Namespace { get; } | ||
|
|
||
| /// <summary> | ||
| /// Creates a complete tool list with a <see cref="HostedToolSearchTool"/> and the given functions wrapped as <see cref="SearchableAIFunctionDeclaration"/>. | ||
| /// </summary> | ||
| /// <param name="functions">The functions to include as searchable tools.</param> | ||
| /// <param name="namespace">An optional namespace for grouping related tools.</param> | ||
| /// <param name="toolSearchProperties">Any additional properties to pass to the <see cref="HostedToolSearchTool"/>.</param> | ||
| /// <returns>A list of <see cref="AITool"/> instances ready for use in <see cref="ChatOptions.Tools"/>.</returns> | ||
| /// <exception cref="System.ArgumentNullException"><paramref name="functions"/> is <see langword="null"/>.</exception> | ||
| public static IList<AITool> CreateToolSet( | ||
| IEnumerable<AIFunctionDeclaration> functions, | ||
| string? @namespace = null, | ||
| IReadOnlyDictionary<string, object?>? toolSearchProperties = null) | ||
| { | ||
| _ = Throw.IfNull(functions); | ||
|
|
||
| var tools = new List<AITool> { new HostedToolSearchTool(toolSearchProperties) }; | ||
| foreach (var fn in functions) | ||
| { | ||
| tools.Add(new SearchableAIFunctionDeclaration(fn, @namespace)); | ||
| } | ||
|
|
||
| return tools; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| // Licensed to the .NET Foundation under one or more agreements. | ||
| // The .NET Foundation licenses this file to you under the MIT license. | ||
|
|
||
| using System.Collections.Generic; | ||
| using System.Diagnostics.CodeAnalysis; | ||
| using Microsoft.Shared.DiagnosticIds; | ||
|
|
||
| namespace Microsoft.Extensions.AI; | ||
|
|
||
| /// <summary>Represents a hosted tool that can be specified to an AI service to enable it to search for and selectively load tool definitions on demand.</summary> | ||
| /// <remarks> | ||
| /// This tool does not itself implement tool search. It is a marker that can be used to inform a service | ||
| /// that tool search should be enabled, reducing token usage by deferring full tool schema loading until the model requests it. | ||
| /// </remarks> | ||
| [Experimental(DiagnosticIds.Experiments.AIToolSearch, UrlFormat = DiagnosticIds.UrlFormat)] | ||
| public class HostedToolSearchTool : AITool | ||
| { | ||
| /// <summary>Any additional properties associated with the tool.</summary> | ||
| private IReadOnlyDictionary<string, object?>? _additionalProperties; | ||
|
|
||
| /// <summary>Initializes a new instance of the <see cref="HostedToolSearchTool"/> class.</summary> | ||
| public HostedToolSearchTool() | ||
| { | ||
| } | ||
|
|
||
| /// <summary>Initializes a new instance of the <see cref="HostedToolSearchTool"/> class.</summary> | ||
| /// <param name="additionalProperties">Any additional properties associated with the tool.</param> | ||
| public HostedToolSearchTool(IReadOnlyDictionary<string, object?>? additionalProperties) | ||
| { | ||
| _additionalProperties = additionalProperties; | ||
| } | ||
|
|
||
| /// <inheritdoc /> | ||
| public override string Name => "tool_search"; | ||
|
|
||
| /// <inheritdoc /> | ||
| public override IReadOnlyDictionary<string, object?> AdditionalProperties => _additionalProperties ?? base.AdditionalProperties; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,6 +18,7 @@ | |
| using System.Threading.Tasks; | ||
| using Microsoft.Shared.DiagnosticIds; | ||
| using Microsoft.Shared.Diagnostics; | ||
| using OpenAI; | ||
| using OpenAI.Responses; | ||
|
|
||
| #pragma warning disable S1226 // Method parameters, caught exceptions and foreach variables' initial values should not be ignored | ||
|
|
@@ -702,7 +703,22 @@ private static bool IsStoredOutputDisabled(CreateResponseOptions? options, Respo | |
| return rtat.Tool; | ||
|
|
||
| case AIFunctionDeclaration aiFunction: | ||
| return ToResponseTool(aiFunction, options); | ||
| var functionTool = ToResponseTool(aiFunction, options); | ||
| if (tool.GetService<SearchableAIFunctionDeclaration>() is { } searchable) | ||
| { | ||
| functionTool.Patch.Set("$.defer_loading"u8, "true"u8); | ||
| if (searchable.Namespace is { } ns) | ||
| { | ||
| functionTool.Patch.Set("$.namespace"u8, JsonSerializer.SerializeToUtf8Bytes(ns, OpenAIJsonContext.Default.String).AsSpan()); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is not how namespaces work in OpenAI, they are a tool array as a tool itself. Anthropic doesn't have such concept. |
||
| } | ||
|
Comment on lines
705
to
+713
|
||
| } | ||
|
|
||
| return functionTool; | ||
|
|
||
| case HostedToolSearchTool: | ||
| // Workaround: The OpenAI .NET SDK doesn't yet expose a ToolSearchTool type. | ||
| // See https://github.com/openai/openai-dotnet/issues/1053 | ||
| return ModelReaderWriter.Read<ResponseTool>(BinaryData.FromString("""{"type": "tool_search"}"""), ModelReaderWriterOptions.Json, OpenAIContext.Default)!; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add a comment referencing openai/openai-dotnet#1053 pointing out this is a temporary workaround.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done in 74f0c51 — added a comment referencing the issue above the
Comment on lines
+718
to
+721
|
||
|
|
||
| case HostedWebSearchTool webSearchTool: | ||
| return new WebSearchTool | ||
|
|
@@ -814,6 +830,11 @@ private static bool IsStoredOutputDisabled(CreateResponseOptions? options, Respo | |
| break; | ||
| } | ||
|
|
||
| if (mcpTool.DeferLoadingTools) | ||
| { | ||
| responsesMcpTool.Patch.Set("$.defer_loading"u8, "true"u8); | ||
| } | ||
|
|
||
| return responsesMcpTool; | ||
|
|
||
| default: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| // 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 Xunit; | ||
|
|
||
| namespace Microsoft.Extensions.AI.Functions; | ||
|
|
||
| public class SearchableAIFunctionDeclarationTests | ||
| { | ||
| [Fact] | ||
| public void Constructor_NullFunction_ThrowsArgumentNullException() | ||
| { | ||
| Assert.Throws<ArgumentNullException>("innerFunction", () => new SearchableAIFunctionDeclaration(null!)); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void Constructor_DelegatesToInnerFunction_Properties() | ||
| { | ||
| var inner = AIFunctionFactory.Create(() => 42, "MyFunc", "My description"); | ||
| var wrapper = new SearchableAIFunctionDeclaration(inner); | ||
|
|
||
| Assert.Equal(inner.Name, wrapper.Name); | ||
| Assert.Equal(inner.Description, wrapper.Description); | ||
| Assert.Equal(inner.JsonSchema, wrapper.JsonSchema); | ||
| Assert.Equal(inner.ReturnJsonSchema, wrapper.ReturnJsonSchema); | ||
| Assert.Same(inner.AdditionalProperties, wrapper.AdditionalProperties); | ||
| Assert.Equal(inner.ToString(), wrapper.ToString()); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void Namespace_DefaultIsNull() | ||
| { | ||
| var inner = AIFunctionFactory.Create(() => 42); | ||
| var wrapper = new SearchableAIFunctionDeclaration(inner); | ||
|
|
||
| Assert.Null(wrapper.Namespace); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void Namespace_Roundtrips() | ||
| { | ||
| var inner = AIFunctionFactory.Create(() => 42); | ||
| var wrapper = new SearchableAIFunctionDeclaration(inner, @namespace: "myNamespace"); | ||
|
|
||
| Assert.Equal("myNamespace", wrapper.Namespace); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void GetService_ReturnsSelf() | ||
| { | ||
| var inner = AIFunctionFactory.Create(() => 42); | ||
| var wrapper = new SearchableAIFunctionDeclaration(inner); | ||
|
|
||
| Assert.Same(wrapper, wrapper.GetService<SearchableAIFunctionDeclaration>()); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void CreateToolSet_NullFunctions_Throws() | ||
| { | ||
| Assert.Throws<ArgumentNullException>("functions", () => SearchableAIFunctionDeclaration.CreateToolSet(null!)); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void CreateToolSet_ReturnsHostedToolSearchToolFirst_ThenWrappedFunctions() | ||
| { | ||
| var f1 = AIFunctionFactory.Create(() => 1, "F1"); | ||
| var f2 = AIFunctionFactory.Create(() => 2, "F2"); | ||
|
|
||
| var tools = SearchableAIFunctionDeclaration.CreateToolSet([f1, f2]); | ||
|
|
||
| Assert.Equal(3, tools.Count); | ||
| Assert.IsType<HostedToolSearchTool>(tools[0]); | ||
| Assert.Empty(tools[0].AdditionalProperties); | ||
|
|
||
| var s1 = Assert.IsType<SearchableAIFunctionDeclaration>(tools[1]); | ||
| Assert.Equal("F1", s1.Name); | ||
| Assert.Null(s1.Namespace); | ||
|
|
||
| var s2 = Assert.IsType<SearchableAIFunctionDeclaration>(tools[2]); | ||
| Assert.Equal("F2", s2.Name); | ||
| Assert.Null(s2.Namespace); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void CreateToolSet_WithNamespace_AppliesNamespaceToAll() | ||
| { | ||
| var f1 = AIFunctionFactory.Create(() => 1, "F1"); | ||
|
|
||
| var tools = SearchableAIFunctionDeclaration.CreateToolSet([f1], @namespace: "ns"); | ||
|
|
||
| var s1 = Assert.IsType<SearchableAIFunctionDeclaration>(tools[1]); | ||
| Assert.Equal("ns", s1.Namespace); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void CreateToolSet_WithAdditionalProperties_PassesToHostedToolSearchTool() | ||
| { | ||
| var props = new Dictionary<string, object?> { ["key"] = "value" }; | ||
| var f1 = AIFunctionFactory.Create(() => 1, "F1"); | ||
|
|
||
| var tools = SearchableAIFunctionDeclaration.CreateToolSet([f1], toolSearchProperties: props); | ||
|
|
||
| var toolSearch = Assert.IsType<HostedToolSearchTool>(tools[0]); | ||
| Assert.Same(props, toolSearch.AdditionalProperties); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| // Licensed to the .NET Foundation under one or more agreements. | ||
| // The .NET Foundation licenses this file to you under the MIT license. | ||
|
|
||
| using System.Collections.Generic; | ||
| using Xunit; | ||
|
|
||
| namespace Microsoft.Extensions.AI; | ||
|
|
||
| public class HostedToolSearchToolTests | ||
| { | ||
| [Fact] | ||
| public void Constructor_Roundtrips() | ||
| { | ||
| var tool = new HostedToolSearchTool(); | ||
| Assert.Equal("tool_search", tool.Name); | ||
| Assert.Empty(tool.Description); | ||
| Assert.Empty(tool.AdditionalProperties); | ||
| Assert.Equal(tool.Name, tool.ToString()); | ||
stephentoub marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| [Fact] | ||
| public void Constructor_AdditionalProperties_Roundtrips() | ||
| { | ||
| var props = new Dictionary<string, object?> { ["key"] = "value" }; | ||
| var tool = new HostedToolSearchTool(props); | ||
|
|
||
| Assert.Equal("tool_search", tool.Name); | ||
| Assert.Same(props, tool.AdditionalProperties); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void Constructor_NullAdditionalProperties_UsesEmpty() | ||
| { | ||
| var tool = new HostedToolSearchTool(null); | ||
|
|
||
| Assert.Empty(tool.AdditionalProperties); | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.