Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 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
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 @@ -11,7 +11,7 @@ namespace Microsoft.Extensions.AI;
/// <summary>
/// Provides an optional base class for an <see cref="AIFunctionDeclaration"/> that passes through calls to another instance.
/// </summary>
internal class DelegatingAIFunctionDeclaration : AIFunctionDeclaration // could be made public in the future if there's demand
public class DelegatingAIFunctionDeclaration : AIFunctionDeclaration
{
/// <summary>
/// Initializes a new instance of the <see cref="DelegatingAIFunctionDeclaration"/> class as a wrapper around <paramref name="innerFunction"/>.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// 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 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>
public sealed class SearchableAIFunctionDeclaration : DelegatingAIFunctionDeclaration
{
/// <summary>
Comment on lines +19 to +21
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SearchableAIFunctionDeclaration is described as an experimental API, but it is currently missing an [Experimental(...)] attribute. Without it, the API is treated as Stable (and the generated Abstractions.json reflects that), which is hard to undo later. Mark it [Experimental(DiagnosticIds.Experiments.AIToolSearch, UrlFormat = DiagnosticIds.UrlFormat)] and ensure DiagnosticIds.Experiments.AIToolSearch is defined.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot revise the PR description. This shouldn't be experimental.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the PR description in 89d8df4 — removed all references to [Experimental] annotations. The types are stable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description was updated previously to state all types are stable (not experimental). The current description reflects this — no [Experimental] attributes on the new types.

/// 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 to group this function under for tool search.</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 namespace this function belongs to, or <see langword="null"/> if it is a standalone deferred function.</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 to group the functions under.</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
Expand Up @@ -1687,6 +1687,50 @@
}
]
},
{
"Type": "class Microsoft.Extensions.AI.DelegatingAIFunctionDeclaration : Microsoft.Extensions.AI.AIFunctionDeclaration",
"Stage": "Stable",
"Methods": [
{
"Member": "Microsoft.Extensions.AI.DelegatingAIFunctionDeclaration.DelegatingAIFunctionDeclaration(Microsoft.Extensions.AI.AIFunctionDeclaration innerFunction);",
"Stage": "Stable"
},
{
"Member": "override object? Microsoft.Extensions.AI.DelegatingAIFunctionDeclaration.GetService(System.Type serviceType, object? serviceKey = null);",
"Stage": "Stable"
},
{
"Member": "override string Microsoft.Extensions.AI.DelegatingAIFunctionDeclaration.ToString();",
"Stage": "Stable"
}
],
"Properties": [
{
"Member": "override System.Collections.Generic.IReadOnlyDictionary<string, object?> Microsoft.Extensions.AI.DelegatingAIFunctionDeclaration.AdditionalProperties { get; }",
"Stage": "Stable"
},
{
"Member": "override string Microsoft.Extensions.AI.DelegatingAIFunctionDeclaration.Description { get; }",
"Stage": "Stable"
},
{
"Member": "Microsoft.Extensions.AI.AIFunctionDeclaration Microsoft.Extensions.AI.DelegatingAIFunctionDeclaration.InnerFunction { get; }",
"Stage": "Stable"
},
{
"Member": "override System.Text.Json.JsonElement Microsoft.Extensions.AI.DelegatingAIFunctionDeclaration.JsonSchema { get; }",
"Stage": "Stable"
},
{
"Member": "override string Microsoft.Extensions.AI.DelegatingAIFunctionDeclaration.Name { get; }",
"Stage": "Stable"
},
{
"Member": "override System.Text.Json.JsonElement? Microsoft.Extensions.AI.DelegatingAIFunctionDeclaration.ReturnJsonSchema { get; }",
"Stage": "Stable"
}
]
},
{
"Type": "class Microsoft.Extensions.AI.DelegatingChatClient : Microsoft.Extensions.AI.IChatClient, System.IDisposable",
"Stage": "Stable",
Expand Down Expand Up @@ -2687,6 +2731,30 @@
}
]
},
{
"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": "override string Microsoft.Extensions.AI.HostedToolSearchTool.Name { get; }",
"Stage": "Stable"
}
]
},
{
"Type": "sealed class Microsoft.Extensions.AI.HostedVectorStoreContent : Microsoft.Extensions.AI.AIContent",
"Stage": "Stable",
Expand Down Expand Up @@ -3898,6 +3966,26 @@
}
]
},
{
"Type": "sealed class Microsoft.Extensions.AI.SearchableAIFunctionDeclaration : Microsoft.Extensions.AI.DelegatingAIFunctionDeclaration",
"Stage": "Stable",
"Methods": [
{
"Member": "Microsoft.Extensions.AI.SearchableAIFunctionDeclaration.SearchableAIFunctionDeclaration(Microsoft.Extensions.AI.AIFunctionDeclaration innerFunction, string? @namespace = null);",
"Stage": "Stable"
},
{
"Member": "static System.Collections.Generic.IList<Microsoft.Extensions.AI.AITool> Microsoft.Extensions.AI.SearchableAIFunctionDeclaration.CreateToolSet(System.Collections.Generic.IEnumerable<Microsoft.Extensions.AI.AIFunctionDeclaration> functions, string? @namespace = null, System.Collections.Generic.IReadOnlyDictionary<string, object?>? toolSearchProperties = null);",
"Stage": "Stable"
}
],
"Properties": [
{
"Member": "string? Microsoft.Extensions.AI.SearchableAIFunctionDeclaration.Namespace { get; }",
"Stage": "Stable"
}
]
},
{
"Type": "class Microsoft.Extensions.AI.SessionUpdateRealtimeClientMessage : Microsoft.Extensions.AI.RealtimeClientMessage",
"Stage": "Experimental",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// 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;

Comment thread
stephentoub marked this conversation as resolved.
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>
public class HostedToolSearchTool : AITool
{
/// <summary>Any additional properties associated with the tool.</summary>
Comment on lines +13 to +15
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HostedToolSearchTool is intended to be experimental per the PR description, but the type currently has no [Experimental(...)] attribute. This makes the API appear Stable (see the generated Abstractions.json) and could unintentionally lock the API surface. Add [Experimental(DiagnosticIds.Experiments.AIToolSearch, UrlFormat = DiagnosticIds.UrlFormat)] and ensure the corresponding DiagnosticIds constant exists.

Copilot uses AI. Check for mistakes.
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
Expand Up @@ -7,6 +7,7 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
Expand All @@ -17,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
Expand Down Expand Up @@ -709,7 +711,18 @@ 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 not null)
{
functionTool.Patch.Set("$.defer_loading"u8, "true"u8);
}
Comment on lines +715 to +718
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW, we can't do anything with SearchableAIFunctionDeclaration.Namespace here, meaning is useless for AsOpenAIResponseTool.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed Namespace entirely from SearchableAIFunctionDeclaration in 89d8df4 — it was indeed unusable in the public AsOpenAIResponseTool API and the grouping approach was wrong for OpenAI's array-based namespace model.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot revert 89d8df4, we want to keep namespaces.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reverted in 0579b05Namespace property, ToNamespaceResponseTool, namespace grouping logic, and all associated tests are restored.


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)!;
Comment thread
jozkee marked this conversation as resolved.
Comment thread
jozkee marked this conversation as resolved.

case HostedWebSearchTool webSearchTool:
return new WebSearchTool
Expand Down Expand Up @@ -843,6 +856,34 @@ internal static FunctionTool ToResponseTool(AIFunctionDeclaration aiFunction, Ch
};
}

/// <summary>
/// Builds a <c>{"type":"namespace"}</c> <see cref="ResponseTool"/> from a name and set of function 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<FunctionTool> functionTools)
{
using var stream = new 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 functionTool in functionTools)
{
var toolData = ModelReaderWriter.Write(functionTool, 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 +967,39 @@ private CreateResponseOptions AsCreateResponseOptions(ChatOptions? options, out
// Populate tools if there are any.
if (options.Tools is { Count: > 0 } tools)
{
// Group SearchableAIFunctionDeclarations that share a namespace into namespace tools.
Dictionary<string, List<FunctionTool>>? namespaces = null;
foreach (AITool tool in tools)
{
if (tool.GetService<SearchableAIFunctionDeclaration>() is { Namespace: string ns })
{
namespaces ??= new(StringComparer.Ordinal);
if (!namespaces.TryGetValue(ns, out var entry))
{
entry = new List<FunctionTool>();
namespaces[ns] = entry;
}

var ft = ToResponseTool((AIFunctionDeclaration)tool, options);
ft.Patch.Set("$.defer_loading"u8, "true"u8);
entry.Add(ft);
continue;
}

if (ToResponseTool(tool, options) is { } responseTool)
{
result.Tools.Add(responseTool);
}
}

if (namespaces is not null)
{
foreach (var kvp in namespaces)
{
result.Tools.Add(ToNamespaceResponseTool(kvp.Key, kvp.Value));
}
}

if (result.Tools.Count > 0)
{
result.ParallelToolCallsEnabled ??= options.AllowMultipleToolCalls;
Expand Down
Loading
Loading