Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -3,15 +3,18 @@

using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;

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
[Experimental(DiagnosticIds.Experiments.AIToolSearch, UrlFormat = DiagnosticIds.UrlFormat)]
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,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="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)
: base(innerFunction)
{
Namespace = namespaceName;
}

/// <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="namespaceName">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? namespaceName = 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, namespaceName));
}

return tools;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;

namespace Microsoft.Extensions.AI;
Expand Down Expand Up @@ -143,4 +145,20 @@ private static string ValidateUrl(Uri serverAddress)
/// </para>
/// </remarks>
public IDictionary<string, string>? Headers { get; set; }

/// <summary>
/// Gets or sets a value indicating whether tools from this MCP server should use deferred loading when tool search is enabled.
/// </summary>
/// <remarks>
/// <para>
/// When set to <see langword="true"/> and a <see cref="HostedToolSearchTool"/> is present in <see cref="ChatOptions.Tools"/>,
/// the provider will signal to the AI service that tools from this MCP server should be deferred — only their names and
/// descriptions are sent initially, and full schemas are loaded on demand when the model selects them.
/// </para>
/// <para>
/// The default value is <see langword="false"/>.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIToolSearch, UrlFormat = DiagnosticIds.UrlFormat)]
public bool DeferLoadingTools { get; set; }
}
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
Expand Up @@ -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
Expand Down Expand Up @@ -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());
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.

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
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
}

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

Add a comment referencing openai/openai-dotnet#1053 pointing out this is a temporary workaround.

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.

Done in 74f0c51 — added a comment referencing the issue above the ModelReaderWriter.Read call.

Comment on lines +718 to +721
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.

case HostedWebSearchTool webSearchTool:
return new WebSearchTool
Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions src/Shared/DiagnosticIds/DiagnosticIds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ internal static class Experiments
internal const string AIResponseContinuations = AIExperiments;
internal const string AICodeInterpreter = AIExperiments;
internal const string AIWebSearch = AIExperiments;
internal const string AIToolSearch = AIExperiments;
internal const string AIRealTime = AIExperiments;
internal const string AIFiles = AIExperiments;

Expand Down
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, namespaceName: "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], namespaceName: "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
Expand Up @@ -24,6 +24,7 @@ public void Constructor_PropsDefault()
Assert.Null(tool.AllowedTools);
Assert.Null(tool.ApprovalMode);
Assert.Null(tool.Headers);
Assert.False(tool.DeferLoadingTools);
}

[Fact]
Expand Down Expand Up @@ -96,6 +97,12 @@ public void Constructor_Roundtrips()
Assert.NotNull(tool.Headers);
Assert.Single(tool.Headers);
Assert.Equal("value1", tool.Headers["X-Custom-Header"]);

Assert.False(tool.DeferLoadingTools);
tool.DeferLoadingTools = true;
Assert.True(tool.DeferLoadingTools);
tool.DeferLoadingTools = false;
Assert.False(tool.DeferLoadingTools);
}

[Fact]
Expand Down
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());
}

[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);
}
}
Loading