Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
475d067
Implement HostedToolSearchTool and SearchableAIFunctionDeclaration fo…
Copilot Mar 9, 2026
8e7c59b
Redesign: consolidate tool search into HostedToolSearchTool with Defe…
Copilot Mar 9, 2026
bf6107a
Address review feedback: fix O(N²), remove json baseline entry, refac…
Copilot Mar 9, 2026
40f3432
Extract shared FindToolSearchTool helper to deduplicate lookup code
Copilot Mar 9, 2026
5997a6a
Simplify ToResponseTool: add ChatOptions-only overload, make FindTool…
Copilot Mar 9, 2026
7f3893f
Add unit tests for HostedToolSearchTool JSON serialization and integr…
Copilot Mar 9, 2026
4ad7331
Revert to SearchableAIFunctionDeclaration design, remove DeferredTool…
Copilot Mar 24, 2026
74f0c51
Address review feedback: rename namespaceName to @namespace, add open…
Copilot Apr 3, 2026
2b39eb9
Update tests
jozkee Apr 6, 2026
78f7d57
revert namespace param rename
jozkee Apr 6, 2026
8e24e6f
Remove Namespace from SearchableAIFunctionDeclaration and DeferLoadin…
jozkee Apr 14, 2026
8ddecb8
Remove [Experimental] from tool search types and add API baselines
jozkee Apr 15, 2026
f53ca13
Add integration tests for tool search edge cases
jozkee Apr 15, 2026
1822466
Merge remote-tracking branch 'upstream/main' into copilot/add-tool-se…
jozkee Apr 15, 2026
87f81ff
Add namespace support to SearchableAIFunctionDeclaration and namespac…
jozkee Apr 15, 2026
ba60cd3
Feedback
jozkee Apr 15, 2026
89d8df4
Remove Namespace from SearchableAIFunctionDeclaration and namespace g…
Copilot Apr 15, 2026
0579b05
Revert "Remove Namespace from SearchableAIFunctionDeclaration and nam…
Copilot Apr 15, 2026
3a28bd0
Fix CI: replace KeyValuePair deconstruction for net462/netstandard2.0…
Copilot Apr 16, 2026
9da8171
Add DeferredTools/Namespace on HostedToolSearchTool, support MCP tools
jozkee Apr 16, 2026
44631fb
Address tool-search reviewer feedback for docs and lookup performance
Copilot Apr 16, 2026
9a3e39d
Refine HostedToolSearchTool docs to use deferrable tool wording
Copilot Apr 16, 2026
cc2e021
Optimize tool-search lookup precomputation in OpenAI responses client
Copilot Apr 16, 2026
f764760
Add tests for non-deferrable tools with HostedToolSearchTool
jozkee Apr 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2687,6 +2687,38 @@
}
]
},
{
"Type": "class Microsoft.Extensions.AI.HostedToolSearchTool : Microsoft.Extensions.AI.AITool",
"Stage": "Stable",
"Methods": [
{
"Member": "Microsoft.Extensions.AI.HostedToolSearchTool.HostedToolSearchTool();",
"Stage": "Stable"
},
{
"Member": "Microsoft.Extensions.AI.HostedToolSearchTool.HostedToolSearchTool(System.Collections.Generic.IReadOnlyDictionary<string, object?>? additionalProperties);",
"Stage": "Stable"
}
],
"Properties": [
{
"Member": "override System.Collections.Generic.IReadOnlyDictionary<string, object?> Microsoft.Extensions.AI.HostedToolSearchTool.AdditionalProperties { get; }",
"Stage": "Stable"
},
{
"Member": "System.Collections.Generic.IList<string>? Microsoft.Extensions.AI.HostedToolSearchTool.DeferredTools { get; set; }",
"Stage": "Stable"
},
{
"Member": "override string Microsoft.Extensions.AI.HostedToolSearchTool.Name { get; }",
"Stage": "Stable"
},
{
"Member": "string? Microsoft.Extensions.AI.HostedToolSearchTool.Namespace { get; set; }",
"Stage": "Stable"
}
]
},
{
"Type": "sealed class Microsoft.Extensions.AI.HostedVectorStoreContent : Microsoft.Extensions.AI.AIContent",
"Stage": "Stable",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// 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;

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>
/// <para>
/// 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.
/// </para>
/// <para>
/// By default, when a <see cref="HostedToolSearchTool"/> is present in the tools list, all other deferrable tools
/// are treated as having deferred loading enabled. Use <see cref="DeferredTools"/> to control which tools have deferred loading
/// on a per-tool basis.
/// </para>
/// </remarks>
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;

/// <summary>
/// Gets or sets the list of tool names for which deferred loading should be enabled.
/// </summary>
/// <remarks>
/// <para>
/// The default value is <see langword="null"/>, which enables deferred loading for all deferrable tools in the tools list.
/// </para>
/// <para>
/// When non-null, only deferrable tools whose names appear in this list will have deferred loading enabled.
/// </para>
/// </remarks>
public IList<string>? DeferredTools { get; set; }

/// <summary>
/// Gets or sets the namespace name under which deferred tools should be grouped.
/// </summary>
/// <remarks>
/// <para>
/// When non-null, all deferred tools are wrapped inside a <c>{"type":"namespace","name":"..."}</c>
/// container. Non-deferred tools remain as top-level tools.
/// </para>
/// <para>
/// When <see langword="null"/> (the default), deferred tools are sent as top-level tools
/// with <c>defer_loading</c> set individually.
/// </para>
/// </remarks>
public string? Namespace { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@
"Stage": "Experimental"
},
{
"Member": "static OpenAI.Responses.ResponseTool? OpenAI.Responses.MicrosoftExtensionsAIResponsesExtensions.AsOpenAIResponseTool(this Microsoft.Extensions.AI.AITool tool);",
"Member": "static OpenAI.Responses.ResponseTool? OpenAI.Responses.MicrosoftExtensionsAIResponsesExtensions.AsOpenAIResponseTool(this Microsoft.Extensions.AI.AITool tool, Microsoft.Extensions.AI.ChatOptions? options = null);",
"Stage": "Experimental"
}
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,19 @@ public static FunctionTool AsOpenAIResponseTool(this AIFunctionDeclaration funct

/// <summary>Creates an OpenAI <see cref="ResponseTool"/> from an <see cref="AITool"/>.</summary>
/// <param name="tool">The tool to convert.</param>
/// <param name="options">
/// The <see cref="ChatOptions"/> that will be sent alongside this tool. When a <see cref="HostedToolSearchTool"/>
/// is present in <see cref="ChatOptions.Tools"/>, function tools may have <c>defer_loading</c> patched based on the
/// <see cref="HostedToolSearchTool.DeferredTools"/> configuration.
/// </param>
/// <returns>An OpenAI <see cref="ResponseTool"/> representing <paramref name="tool"/> or <see langword="null"/> if there is no mapping.</returns>
/// <exception cref="ArgumentNullException"><paramref name="tool"/> is <see langword="null"/>.</exception>
/// <remarks>
/// This method is only able to create <see cref="ResponseTool"/>s for <see cref="AITool"/> types
/// it's aware of, namely all of those available from the Microsoft.Extensions.AI.Abstractions library.
/// </remarks>
public static ResponseTool? AsOpenAIResponseTool(this AITool tool) =>
OpenAIResponsesChatClient.ToResponseTool(Throw.IfNull(tool));
public static ResponseTool? AsOpenAIResponseTool(this AITool tool, ChatOptions? options = null) =>
OpenAIResponsesChatClient.ToResponseTool(Throw.IfNull(tool), options);

/// <summary>
/// Creates an OpenAI <see cref="ResponseTextFormat"/> from a <see cref="ChatResponseFormat"/>.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,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
Expand Down Expand Up @@ -701,15 +702,26 @@ private static bool IsStoredOutputDisabled(CreateResponseOptions? options, Respo
(response is not null && response.Patch.TryGetValue("$.store"u8, out bool store) && !store);
#pragma warning restore SCME0001

internal static ResponseTool? ToResponseTool(AITool tool, ChatOptions? options = null)
internal static ResponseTool? ToResponseTool(AITool tool, ChatOptions? options = null, ToolSearchLookup? toolSearchLookup = null)
{
switch (tool)
{
case ResponseToolAITool rtat:
return rtat.Tool;

case AIFunctionDeclaration aiFunction:
return ToResponseTool(aiFunction, options);
var functionTool = ToResponseTool(aiFunction, options);
if ((toolSearchLookup ??= ToolSearchLookup.Create(options?.Tools)).IsDeferred(aiFunction.Name))
{
functionTool.Patch.Set("$.defer_loading"u8, "true"u8);
}

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

case HostedWebSearchTool webSearchTool:
return new WebSearchTool
Expand Down Expand Up @@ -821,6 +833,11 @@ private static bool IsStoredOutputDisabled(CreateResponseOptions? options, Respo
break;
}

if ((toolSearchLookup ??= ToolSearchLookup.Create(options?.Tools)).IsDeferred(mcpTool.ServerName))
{
responsesMcpTool.Patch.Set("$.defer_loading"u8, "true"u8);
}

return responsesMcpTool;

default:
Expand All @@ -843,6 +860,34 @@ internal static FunctionTool ToResponseTool(AIFunctionDeclaration aiFunction, Ch
};
}

/// <summary>
/// Builds a <c>{"type":"namespace"}</c> <see cref="ResponseTool"/> from a name and set of tools.
/// The OpenAI .NET SDK doesn't expose a NamespaceTool type, so we construct the JSON manually.
/// </summary>
internal static ResponseTool ToNamespaceResponseTool(string name, IEnumerable<ResponseTool> namespacedTools)
{
using var stream = new System.IO.MemoryStream();
using (var writer = new Utf8JsonWriter(stream))
{
writer.WriteStartObject();
writer.WriteString("type"u8, "namespace"u8);
writer.WriteString("name"u8, name);

writer.WriteStartArray("tools"u8);
foreach (var namespacedTool in namespacedTools)
{
var toolData = ModelReaderWriter.Write(namespacedTool, ModelReaderWriterOptions.Json, OpenAIContext.Default);
using var doc = JsonDocument.Parse(toolData);
doc.RootElement.WriteTo(writer);
}

writer.WriteEndArray();
writer.WriteEndObject();
}

return ModelReaderWriter.Read<ResponseTool>(BinaryData.FromBytes(stream.ToArray()), ModelReaderWriterOptions.Json, OpenAIContext.Default)!;
}

/// <summary>Creates a <see cref="ChatRole"/> from a <see cref="MessageRole"/>.</summary>
private static ChatRole AsChatRole(MessageRole? role) =>
role switch
Expand Down Expand Up @@ -926,14 +971,45 @@ private CreateResponseOptions AsCreateResponseOptions(ChatOptions? options, out
// Populate tools if there are any.
if (options.Tools is { Count: > 0 } tools)
{
ToolSearchLookup toolSearchLookup = ToolSearchLookup.Create(tools);
Dictionary<string, List<ResponseTool>>? namespaceGroups = null;

foreach (AITool tool in tools)
{
if (ToResponseTool(tool, options) is { } responseTool)
if (ToResponseTool(tool, options, toolSearchLookup) is { } responseTool)
{
// When a namespaced HostedToolSearchTool claims this deferred tool,
// collect it for later wrapping in a namespace container.
string? responseToolName = responseTool is FunctionTool ft ? ft.FunctionName
: responseTool is McpTool mcp ? mcp.ServerLabel
: null;

if (responseToolName is not null
&& toolSearchLookup.GetNamespace(responseToolName) is { } ns)
{
namespaceGroups ??= new(StringComparer.Ordinal);
if (!namespaceGroups.TryGetValue(ns, out var group))
{
group = new();
namespaceGroups[ns] = group;
}

group.Add(responseTool);
continue;
}

result.Tools.Add(responseTool);
}
}

if (namespaceGroups is not null)
{
foreach (KeyValuePair<string, List<ResponseTool>> kvp in namespaceGroups)
{
result.Tools.Add(ToNamespaceResponseTool(kvp.Key, kvp.Value));
}
}

if (result.Tools.Count > 0)
{
result.ParallelToolCallsEnabled ??= options.AllowMultipleToolCalls;
Expand Down Expand Up @@ -969,6 +1045,98 @@ private CreateResponseOptions AsCreateResponseOptions(ChatOptions? options, out
return result;
}

internal sealed class ToolSearchLookup
{
private static readonly ToolSearchLookup _empty = new(deferAll: false, deferredToolNames: [], namespacedToolNames: []);
private readonly bool _deferAll;
private readonly HashSet<string> _deferredToolNames;
private readonly Dictionary<string, string> _namespacedToolNames;

private ToolSearchLookup(bool deferAll, HashSet<string> deferredToolNames, Dictionary<string, string> namespacedToolNames)
{
_deferAll = deferAll;
_deferredToolNames = deferredToolNames;
_namespacedToolNames = namespacedToolNames;
}

public static ToolSearchLookup Create(IList<AITool>? tools)
{
if (tools is not { Count: > 0 })
{
return _empty;
}

HashSet<string> functionAndMcpToolNames = new(
tools.Select(
static tool => tool switch
{
AIFunctionDeclaration aiFunction => aiFunction.Name,
HostedMcpServerTool mcpTool => mcpTool.ServerName,
_ => null,
})
.OfType<string>(),
StringComparer.Ordinal);

if (functionAndMcpToolNames.Count == 0)
{
return _empty;
}

bool deferAll = false;
HashSet<string> deferredToolNames = new(StringComparer.Ordinal);
Dictionary<string, string> namespacedToolNames = new(StringComparer.Ordinal);
HashSet<string> unclaimedToolNames = new(functionAndMcpToolNames, StringComparer.Ordinal);

foreach (AITool tool in tools)
{
if (tool is not HostedToolSearchTool toolSearch)
{
continue;
}

if (toolSearch.DeferredTools is not { } deferredTools)
{
deferAll = true;
deferredToolNames.UnionWith(functionAndMcpToolNames);

if (toolSearch.Namespace is { } ns && unclaimedToolNames.Count > 0)
{
foreach (string toolName in unclaimedToolNames)
{
namespacedToolNames[toolName] = ns;
}

unclaimedToolNames.Clear();
}

continue;
}

foreach (string deferredTool in deferredTools)
{
if (!functionAndMcpToolNames.Contains(deferredTool))
{
continue;
}

_ = deferredToolNames.Add(deferredTool);
if (toolSearch.Namespace is { } ns && unclaimedToolNames.Remove(deferredTool))
{
namespacedToolNames[deferredTool] = ns;
}
}
}

return new(deferAll, deferredToolNames, namespacedToolNames);
}

public bool IsDeferred(string toolName) =>
_deferAll || _deferredToolNames.Contains(toolName);

public string? GetNamespace(string toolName) =>
_namespacedToolNames.TryGetValue(toolName, out string? ns) ? ns : null;
}

internal static ResponseTextFormat? ToOpenAIResponseTextFormat(ChatResponseFormat? format, ChatOptions? options = null) =>
format switch
{
Expand Down
Loading
Loading