Add HostedToolSearchTool and SearchableAIFunctionDeclaration for tool search / deferred loading support#7377
Add HostedToolSearchTool and SearchableAIFunctionDeclaration for tool search / deferred loading support#7377
Conversation
|
@copilot, I'm wondering if a different design would be more beneficial. What would you think about isolating it to just the HostedToolSearchTool, which would have two collections of either AITool instances or string names, one for Enable and one for Disable. By default, both of these collections would be null, in which case it would treat every tool as Enable and mark every tool as defer-loading. If Disable was non-null but Enable was null, then it would treat every tool as Enable except for those listed in Disable. Conversely if Enable was non-null and Disable was null, it would only treat those tools in Enable as defer-loading, and if both were non-null, it would treat everything in Enable as defer-loading unless it was also listed in Disable. Thoughts? Is the dominant case for tool search with OpenAI and Anthropic one where most tools are in the same boat and only zero to a small number are special-cased the other way? Or is the right way to model this what we currently have? |
Yes, I think the dominant case is indeed "most/all tools are searchable" — the whole point of tool search is to scale to many tools, so users will almost always want blanket defer-loading with at most a few exceptions. Your proposed design is better for that common case. Today, with 50 tools you'd wrap all 50 in A few thoughts on the details: Enable/Disable semantics: The logic makes sense and is consistent with how String names vs AITool references: String names (like Namespace: The current design has per-tool What we'd lose: The Overall I think this is a better design. Want me to implement it? |
@copilot, yes, please |
Implemented in f295d10. Changes:
|
src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json
Outdated
Show resolved
Hide resolved
src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs
Outdated
Show resolved
Hide resolved
src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs
Outdated
Show resolved
Hide resolved
src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs
Outdated
Show resolved
Hide resolved
|
@PederHP any thoughts? |
There was a problem hiding this comment.
Pull request overview
Adds a new experimental hosted tool marker (HostedToolSearchTool) to enable “tool_search” and implements deferred loading (defer_loading) support in the OpenAI Responses provider by patching function tools based on DeferredTools / NonDeferredTools configuration.
Changes:
- Introduces
HostedToolSearchTool(experimental) and a newDiagnosticIds.Experiments.AIToolSearchconstant. - Updates OpenAI Responses tool conversion to emit
tool_searchand optionally patch function tools withdefer_loading: true. - Adds/extends unit tests covering tool_search conversion, caching, and deferred-loading selection logic.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs | Adds OpenAI conversion tests for tool_search and defer_loading behavior. |
| test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedToolSearchToolTests.cs | Adds tests for HostedToolSearchTool construction and list properties. |
| src/Shared/DiagnosticIds/DiagnosticIds.cs | Adds DiagnosticIds.Experiments.AIToolSearch. |
| src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs | Adds tool_search mapping and deferred-loading patching for function tools. |
| src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs | Updates AsOpenAIResponseTool to accept ChatOptions? for deferred-loading context. |
| src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.json | Updates API baseline for the modified extension method signature. |
| src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedToolSearchTool.cs | Adds the new experimental HostedToolSearchTool abstraction with configuration lists. |
src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedToolSearchTool.cs
Show resolved
Hide resolved
src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs
Outdated
Show resolved
Hide resolved
src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs
Outdated
Show resolved
Hide resolved
test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs
Show resolved
Hide resolved
test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedToolSearchToolTests.cs
Show resolved
Hide resolved
|
|
||
| return OpenAIResponsesChatClient.ToResponseTool( | ||
| tool, | ||
| OpenAIResponsesChatClient.FindToolSearchTool(options?.Tools), |
There was a problem hiding this comment.
@copilot why was it valuable to change the signature of ToResponseTool like this? I'm not seeing the benefits rather than just having ToResponseTool call FindToolSearchTool if/when it needs to.
There was a problem hiding this comment.
Oh, nevermind, I see, because we need to call ToResponseTool for each AIFunction
There was a problem hiding this comment.
You're right — there was no benefit to exposing the HostedToolSearchTool? parameter on the internal ToResponseTool. Fixed in b53a6b9: added a simpler ToResponseTool(AITool, ChatOptions?) overload that calls FindToolSearchTool internally. The 3-param version and FindToolSearchTool are now both private. The internal loop still pre-finds the tool for O(N) efficiency.
Addressed in 54d26f8 — reset 220+ unrelated files (eng/, .github/, etc.) back to match main. The original merge commit ( |
@copilot do that please. |
…r tool search support Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
…rredTools/NonDeferredTools - Redesign HostedToolSearchTool with DeferredTools/NonDeferredTools properties - Remove SearchableAIFunctionDeclaration (no longer needed) - Revert DelegatingAIFunctionDeclaration to internal - Update OpenAI provider: use HostedToolSearchTool enable/disable logic for defer_loading - Add ChatOptions parameter to AsOpenAIResponseTool extension method - Use AOT-safe ModelReaderWriter.Read with OpenAIContext.Default - Update API baselines and tests Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
…tor ToResponseTool signature - Fix O(N²) by finding HostedToolSearchTool once before the tools loop instead of scanning the list for each tool - Remove HostedToolSearchTool from json baseline (experimental types don't need entries) - Refactor ToResponseTool(AITool, ...) to take HostedToolSearchTool? directly instead of extracting from ChatOptions each time - Remove FindToolSearchTool helper method (inlined into callers) Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
…SearchTool private Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
…ation test Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
…s/NonDeferredTools from HostedToolSearchTool Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> Agent-Logs-Url: https://github.com/dotnet/extensions/sessions/7a29d49e-c422-4fe7-81f4-366bd781b460
a4a92c3 to
4ad7331
Compare
| /// <param name="innerFunction">The <see cref="AIFunctionDeclaration"/> represented by this instance.</param> | ||
| /// <param name="namespaceName">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? namespaceName = null) |
There was a problem hiding this comment.
| public SearchableAIFunctionDeclaration(AIFunctionDeclaration innerFunction, string? namespaceName = null) | |
| public SearchableAIFunctionDeclaration(AIFunctionDeclaration innerFunction, string? namespace = null) |
There was a problem hiding this comment.
Done in 74f0c51 — renamed namespaceName to @namespace in both the constructor and CreateToolSet.
| /// <exception cref="System.ArgumentNullException"><paramref name="functions"/> is <see langword="null"/>.</exception> | ||
| public static IList<AITool> CreateToolSet( | ||
| IEnumerable<AIFunctionDeclaration> functions, | ||
| string? namespaceName = null, |
There was a problem hiding this comment.
| string? namespaceName = null, | |
| string? namespace = null, |
| return functionTool; | ||
|
|
||
| case HostedToolSearchTool: | ||
| return ModelReaderWriter.Read<ResponseTool>(BinaryData.FromString("""{"type": "tool_search"}"""), ModelReaderWriterOptions.Json, OpenAIContext.Default)!; |
There was a problem hiding this comment.
Add a comment referencing openai/openai-dotnet#1053 pointing out this is a temporary workaround.
There was a problem hiding this comment.
Done in 74f0c51 — added a comment referencing the issue above the ModelReaderWriter.Read call.
| null, | ||
| }; | ||
|
|
||
| case HostedMcpServerTool mcpTool: |
There was a problem hiding this comment.
Add bool DeferLoadingTools annotated as Experimental to HostedMcpServerTool with remarks explaining that this is related to Tool Search.
There was a problem hiding this comment.
Done in 74f0c51 — added [Experimental(AIToolSearch)] bool DeferLoadingTools to HostedMcpServerTool with remarks explaining it signals deferred loading when tool search is enabled. The OpenAI provider applies defer_loading via Patch when this property is true.
…ai-dotnet#1053 comment, add DeferLoadingTools to HostedMcpServerTool Agent-Logs-Url: https://github.com/dotnet/extensions/sessions/08f652ed-169c-43c3-a247-829ebd0b3e4f Co-authored-by: jozkee <16040868+jozkee@users.noreply.github.com>
| 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()); | ||
| } |
There was a problem hiding this comment.
SearchableAIFunctionDeclaration is documented as enabling deferred loading where only name/description are sent initially, but the OpenAI conversion still always includes full function parameters (via ToResponseTool(aiFunction, ...)). This likely defeats the token-savings goal of deferred loading and doesn’t match the new abstractions’ remarks. Consider emitting a minimal FunctionTool payload (omitting/deferring the schema) when SearchableAIFunctionDeclaration is detected, or adjust the documentation/behavior consistently.
| 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)!; |
There was a problem hiding this comment.
Creating the tool_search ResponseTool allocates a new JSON string/BinaryData each time via BinaryData.FromString(...). Even if you intentionally avoid caching the deserialized ResponseTool, consider caching the serialized payload (e.g., static BinaryData/UTF8 bytes) to reduce per-call allocations in tool conversion.
| 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()); |
There was a problem hiding this comment.
This is not how namespaces work in OpenAI, they are a tool array as a tool itself. Anthropic doesn't have such concept.
Implements tool search and deferred loading support (issue #7371): a
HostedToolSearchToolmarker andSearchableAIFunctionDeclarationdecorator that signals per-tool deferred loading to supporting providers. OpenAI Responses API support included; Anthropic follows separately.New abstractions (
Microsoft.Extensions.AI.Abstractions)HostedToolSearchTool— markerAITool(same pattern asHostedWebSearchTool/HostedCodeInterpreterTool); maps to thetool_searchhosted tool.SearchableAIFunctionDeclaration— decorator wrappingAIFunctionDeclarationthat signals deferred loading to supporting providers. Includes optionalNamespaceproperty for grouping related tools and aCreateToolSetconvenience method for creating a complete tool list.DelegatingAIFunctionDeclaration— now public (was internal); base class forSearchableAIFunctionDeclarationthat passes through calls to an innerAIFunctionDeclaration.HostedMcpServerTool.DeferLoadingTools— new[Experimental]boolproperty that signals to the provider that tools from this MCP server should use deferred loading when tool search is enabled.DiagnosticIds.Experiments.AIToolSearch— new constant (maps to existingMEAI001)All new types are
[Experimental(AIToolSearch)].OpenAI provider (
Microsoft.Extensions.AI.OpenAI)HostedToolSearchTool→ deserializedResponseToolfrom{"type":"tool_search"}via AOT-safeModelReaderWriter.ReadwithOpenAIContext.Default(created per-call, not cached; temporary workaround for openai/openai-dotnet#1053)SearchableAIFunctionDeclarationviatool.GetService<SearchableAIFunctionDeclaration>()and patchesdefer_loading: trueand optionalnamespaceonto matchingFunctionToolinstancesdefer_loading: trueontoMcpToolwhenHostedMcpServerTool.DeferLoadingToolsistrueAIFunctionDeclarationtools are sent without deferred loadingTests
OpenAIResponseClientTests.cs):VerbatimHttpHandler-based tests validating the exact JSON request body sent for various configurations: tool_search only, searchable functions with deferred loading, mixed searchable and plain functions, and mixed with other hosted tools (web search)OpenAIConversionTests.cs): Tests forAsOpenAIResponseToolextension covering tool_search conversion, defer_loading patching viaSearchableAIFunctionDeclaration, namespace patching, and plain function without deferred loadingHostedToolSearchToolTests.cs,SearchableAIFunctionDeclarationTests.cs): Tests for tool properties, delegation,GetServicediscovery,CreateToolSet, and namespace roundtrippingOpenAIResponseClientIntegrationTests.cs):UseToolSearch_WithDeferredFunctionstest exercisingHostedToolSearchToolwithSearchableAIFunctionDeclaration-wrapped functions against the OpenAI APIUsage
Original prompt
Problem
Implement tool search and deferred loading support as described in #7371. Both OpenAI and Anthropic now support tool search, where tool definitions can be sent with deferred loading (only name/description sent upfront, full schema deferred) and a special
tool_searchhosted tool is included that the model can invoke to search for and load full tool definitions on demand.Design
Follow Option A from the issue discussion — a
HostedToolSearchToolmarker tool + aSearchableAIFunctionDeclarationdecorator, consistent with existing patterns (HostedWebSearchTool,ApprovalRequiredAIFunction, etc.).Requirements
1. New types in
Microsoft.Extensions.AI.AbstractionsHostedToolSearchTool(insrc/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedToolSearchTool.cs)AIToolsubclass, following the exact same pattern asHostedWebSearchToolandHostedCodeInterpreterTool.Namereturns"tool_search".IReadOnlyDictionary<string, object?>? additionalProperties).[Experimental(DiagnosticIds.Experiments.AIToolSearch, UrlFormat = DiagnosticIds.UrlFormat)].SearchableAIFunctionDeclaration(insrc/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/SearchableAIFunctionDeclaration.cs)DelegatingAIFunctionDeclaration(which is currentlyinternal). Important:DelegatingAIFunctionDeclarationis the declaration-only delegating base (notDelegatingAIFunctionwhich requiresAIFunction). This is becauseSearchableAIFunctionDeclarationshould work withAIFunctionDeclarationinstances that may not haveInvokeAsync.AIFunctionDeclaration innerFunctionandstring? namespaceName = null.Namespaceproperty (string?) for grouping related tools.sealed.[Experimental(DiagnosticIds.Experiments.AIToolSearch, UrlFormat = DiagnosticIds.UrlFormat)].public static IList<AITool> CreateToolSet(IEnumerable<AIFunctionDeclaration> functions, string? namespaceName = null, IReadOnlyDictionary<string, object?>? toolSearchProperties = null)that wraps all functions asSearchableAIFunctionDeclarationand prepends aHostedToolSearchTool, returning a complete tool list ready forChatOptions.Tools.2. DiagnosticIds update
In
src/Shared/DiagnosticIds/DiagnosticIds.cs, add a new constant in theExperimentsclass:Place it alongside the other AI experiment constants (near
AIWebSearch,AICodeInterpreter, etc.).3. OpenAI provider implementation
In
src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs:In
ToResponseTool(AITool tool, ChatOptions? options = null)method:Add handling for
HostedToolSearchTool— this maps to the OpenAItool_searchresponse tool. Since the underlying OpenAI .NET SDK likely doesn't have aToolSearchToolclass yet, you need to manually construct aResponseToolfrom JSON. Cache the deserializedResponseToolinstance in a static field so it's only created once. Use theModelReaderWriterpattern or direct JSON deserialization to create aResponseToolfrom the JSON{"type": "tool_search"}. Pattern:Add a
private static ResponseTool? s_toolSearchResponseTool;field to cache it.For
SearchableAIFunctionDeclaration: When anAIFunctionDeclarationis detected as having aSearchableAIFunctionDeclarationviaGetService<SearchableAIFunctionDeclaration>(), the generatedFunctionToolshould havedefer_loadingset totrueand optionally include thenamespacemetadata. Since the OpenAI SDK'sFunctionToolclass may not have these properties yet, use thePatchproperty to set them on the JSON. The check should happen in the existingcase AIFunctionDeclaration aiFunction:branch — after callingToResponseTool(aiFunction, options), check if the originaltool(oraiFunction) hasGetService<SearchableAIFunctionDeclaration>()and if so, patch the resultingFunctionToolwithdefer_loadingandnamespace. This is done in theToResponseTool(AITool, ChatOptions?)method so it doesn't infect the generalToResponseTool(AIFunctionDeclaration, ChatOptions?)helper. Specifically, thecase AIFunctionDeclaration aiFunction:case should become: