Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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)
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.

Suggested change
public SearchableAIFunctionDeclaration(AIFunctionDeclaration innerFunction, string? namespaceName = null)
public SearchableAIFunctionDeclaration(AIFunctionDeclaration innerFunction, string? namespace = null)

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 — renamed namespaceName to @namespace in both the constructor and CreateToolSet.

: 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,
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.

Suggested change
string? namespaceName = null,
string? namespace = null,

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.

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
@@ -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,20 @@ 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:
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
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
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,73 @@ public void AsOpenAIResponseTool_WithUnknownToolType_ReturnsNull()
Assert.Null(result);
}

[Fact]
public void AsOpenAIResponseTool_WithHostedToolSearchTool_ProducesValidToolSearchTool()
{
var toolSearchTool = new HostedToolSearchTool();

var result = toolSearchTool.AsOpenAIResponseTool();

Assert.NotNull(result);
var json = ModelReaderWriter.Write(result, ModelReaderWriterOptions.Json).ToString();
Assert.Contains("\"type\"", json);
Assert.Contains("tool_search", json);
}

[Fact]
public void AsOpenAIResponseTool_WithHostedToolSearchTool_ProducesNewInstanceEachTime()
{
var result1 = new HostedToolSearchTool().AsOpenAIResponseTool();
var result2 = new HostedToolSearchTool().AsOpenAIResponseTool();

Assert.NotNull(result1);
Assert.NotNull(result2);
Assert.NotSame(result1, result2);
}

[Fact]
public void AsOpenAIResponseTool_WithSearchableAIFunctionDeclaration_PatchesDeferLoading()
{
var inner = AIFunctionFactory.Create(() => 42, "MyFunc", "My description");
var searchable = new SearchableAIFunctionDeclaration(inner);

var result = ((AITool)searchable).AsOpenAIResponseTool();

Assert.NotNull(result);
var functionTool = Assert.IsType<FunctionTool>(result);
var json = ModelReaderWriter.Write(functionTool, ModelReaderWriterOptions.Json).ToString();
Assert.Contains("defer_loading", json);
Assert.Contains("true", json);
}

[Fact]
public void AsOpenAIResponseTool_WithSearchableAIFunctionDeclarationWithNamespace_PatchesNamespace()
{
var inner = AIFunctionFactory.Create(() => 42, "MyFunc", "My description");
var searchable = new SearchableAIFunctionDeclaration(inner, namespaceName: "myNamespace");

var result = ((AITool)searchable).AsOpenAIResponseTool();

Assert.NotNull(result);
var functionTool = Assert.IsType<FunctionTool>(result);
var json = ModelReaderWriter.Write(functionTool, ModelReaderWriterOptions.Json).ToString();
Assert.Contains("namespace", json);
Assert.Contains("myNamespace", json);
}

[Fact]
public void AsOpenAIResponseTool_WithPlainAIFunction_NoDeferLoading()
{
var func = AIFunctionFactory.Create(() => 42, "MyFunc", "My description");

var result = ((AITool)func).AsOpenAIResponseTool();

Assert.NotNull(result);
var functionTool = Assert.IsType<FunctionTool>(result);
var json = ModelReaderWriter.Write(functionTool, ModelReaderWriterOptions.Json).ToString();
Assert.DoesNotContain("defer_loading", json);
}

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 please add one or more OpenAI unit tests that confirm the right JSON is sent when using HostedToolSearchTool in various ways, and an integration test as well.

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.

Added in ead292d. Six new VerbatimHttpHandler unit tests in OpenAIResponseClientTests.cs that validate the exact JSON request body for various HostedToolSearchTool configurations:

  • ToolSearchTool_OnlyToolSearch_NonStreaming — just tool_search in tools array
  • ToolSearchTool_AllToolsDeferred_NonStreaming — both lists null → all function tools get defer_loading: true
  • ToolSearchTool_SpecificDeferredTools_NonStreamingDeferredTools = ["GetWeather"] → only that tool deferred
  • ToolSearchTool_NonDeferredExclusion_NonStreamingNonDeferredTools = ["ImportantTool"] → all except that tool deferred
  • ToolSearchTool_BothLists_DisableTakesPrecedence_NonStreaming — both lists set, NonDeferred wins
  • ToolSearchTool_NoFunctionTools_NonStreaming — tool_search + web_search, no function tools

Plus an integration test UseToolSearch_WithDeferredFunctions in OpenAIResponseClientIntegrationTests.cs.

[Fact]
public void AsOpenAIResponseTool_WithNullTool_ThrowsArgumentNullException()
{
Expand Down
Loading
Loading