Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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 @@ -2716,6 +2716,10 @@
{
"Member": "string? Microsoft.Extensions.AI.HostedToolSearchTool.Namespace { get; set; }",
"Stage": "Experimental"
},
{
"Member": "string? Microsoft.Extensions.AI.HostedToolSearchTool.NamespaceDescription { get; set; }",
"Stage": "Experimental"
}
]
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,23 @@ public HostedToolSearchTool(IReadOnlyDictionary<string, object?>? additionalProp
/// When <see langword="null"/> (the default), deferred tools are sent as top-level tools
/// with <c>defer_loading</c> set individually.
/// </para>
/// <para>
/// Use <see cref="NamespaceDescription"/> to supply a description for the namespace.
/// </para>
/// </remarks>
public string? Namespace { get; set; }

/// <summary>
/// Gets or sets the description for the namespace produced when <see cref="Namespace"/> is specified.
/// </summary>
/// <remarks>
/// <para>
/// Setting this property alone does not create a namespace.
/// </para>
/// <para>
/// When <see langword="null"/>, no description is emitted on the namespace. The underlying provider
/// may require a description when a namespace is supplied.
/// </para>
/// </remarks>
public string? NamespaceDescription { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -869,7 +869,7 @@ internal static FunctionTool ToResponseTool(AIFunctionDeclaration aiFunction, Ch
/// 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)
internal static ResponseTool ToNamespaceResponseTool(string name, string? description, IEnumerable<ResponseTool> namespacedTools)
{
using var stream = new System.IO.MemoryStream();
using (var writer = new Utf8JsonWriter(stream))
Expand All @@ -878,6 +878,11 @@ internal static ResponseTool ToNamespaceResponseTool(string name, IEnumerable<Re
writer.WriteString("type"u8, "namespace"u8);
writer.WriteString("name"u8, name);

if (!string.IsNullOrEmpty(description))
{
writer.WriteString("description"u8, description);
}

Comment thread
jozkee marked this conversation as resolved.
writer.WriteStartArray("tools"u8);
foreach (var namespacedTool in namespacedTools)
{
Expand Down Expand Up @@ -977,12 +982,25 @@ private CreateResponseOptions AsCreateResponseOptions(ChatOptions? options, out
if (options.Tools is { Count: > 0 } tools)
{
ToolSearchLookup toolSearchLookup = ToolSearchLookup.Create(tools);
Dictionary<string, List<ResponseTool>>? namespaceGroups = null;
Dictionary<ToolSearchLookup.Namespace, List<ResponseTool>>? namespaceGroups = null;
bool toolSearchAdded = false;

foreach (AITool tool in tools)
{
if (ToResponseTool(tool, options, toolSearchLookup) is { } responseTool)
{
// Avoid sending multiple tool_search entries when callers supply more than one
// HostedToolSearchTool; the OpenAI Responses API only accepts one.
if (tool is HostedToolSearchTool)
{
if (toolSearchAdded)
{
continue;
}

toolSearchAdded = true;
}

// 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
Expand All @@ -992,7 +1010,7 @@ private CreateResponseOptions AsCreateResponseOptions(ChatOptions? options, out
if (responseToolName is not null
&& toolSearchLookup.GetNamespace(responseToolName) is { } ns)
{
namespaceGroups ??= new(StringComparer.Ordinal);
namespaceGroups ??= new();
if (!namespaceGroups.TryGetValue(ns, out var group))
{
group = new();
Expand All @@ -1009,9 +1027,9 @@ private CreateResponseOptions AsCreateResponseOptions(ChatOptions? options, out

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

Expand Down Expand Up @@ -1055,9 +1073,9 @@ 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 readonly Dictionary<string, Namespace> _namespacedToolNames;

private ToolSearchLookup(bool deferAll, HashSet<string> deferredToolNames, Dictionary<string, string> namespacedToolNames)
private ToolSearchLookup(bool deferAll, HashSet<string> deferredToolNames, Dictionary<string, Namespace> namespacedToolNames)
{
_deferAll = deferAll;
_deferredToolNames = deferredToolNames;
Expand Down Expand Up @@ -1089,7 +1107,8 @@ public static ToolSearchLookup Create(IList<AITool>? tools)

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

foreach (AITool tool in tools)
Expand All @@ -1104,8 +1123,9 @@ public static ToolSearchLookup Create(IList<AITool>? tools)
deferAll = true;
deferredToolNames.UnionWith(functionAndMcpToolNames);

if (toolSearch.Namespace is { } ns && unclaimedToolNames.Count > 0)
if (toolSearch.Namespace is { } nsName && unclaimedToolNames.Count > 0)
{
Namespace ns = GetOrCreateNamespace(namespacesByName, nsName, toolSearch.NamespaceDescription);
foreach (string toolName in unclaimedToolNames)
{
namespacedToolNames[toolName] = ns;
Expand All @@ -1125,9 +1145,9 @@ public static ToolSearchLookup Create(IList<AITool>? tools)
}

_ = deferredToolNames.Add(deferredTool);
if (toolSearch.Namespace is { } ns && unclaimedToolNames.Remove(deferredTool))
if (toolSearch.Namespace is { } nsName && unclaimedToolNames.Remove(deferredTool))
{
namespacedToolNames[deferredTool] = ns;
namespacedToolNames[deferredTool] = GetOrCreateNamespace(namespacesByName, nsName, toolSearch.NamespaceDescription);
}
Comment thread
jozkee marked this conversation as resolved.
}
}
Expand All @@ -1138,8 +1158,23 @@ public static ToolSearchLookup Create(IList<AITool>? tools)
public bool IsDeferred(string toolName) =>
_deferAll || _deferredToolNames.Contains(toolName);

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

// First-writer-wins per namespace name: the first HostedToolSearchTool to claim a given
// namespace name supplies the description used for that namespace's wrapper.
private static Namespace GetOrCreateNamespace(Dictionary<string, Namespace> namespacesByName, string name, string? description)
{
if (!namespacesByName.TryGetValue(name, out Namespace? existing))
{
existing = new Namespace(name, description);
namespacesByName[name] = existing;
}
Comment thread
jeffhandley marked this conversation as resolved.
Outdated

return existing;
}

internal sealed record Namespace(string Name, string? Description);
}

internal static ResponseTextFormat? ToOpenAIResponseTextFormat(ChatResponseFormat? format, ChatOptions? options = null) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public void Constructor_Roundtrips()
Assert.Equal(tool.Name, tool.ToString());
Assert.Null(tool.DeferredTools);
Assert.Null(tool.Namespace);
Assert.Null(tool.NamespaceDescription);
}

[Fact]
Expand Down Expand Up @@ -69,4 +70,22 @@ public void Namespace_DefaultsToNull()
var tool = new HostedToolSearchTool();
Assert.Null(tool.Namespace);
}

[Fact]
public void NamespaceDescription_Roundtrips()
{
var tool = new HostedToolSearchTool
{
NamespaceDescription = "Tools for managing my data."
};

Assert.Equal("Tools for managing my data.", tool.NamespaceDescription);
}

[Fact]
public void NamespaceDescription_DefaultsToNull()
{
var tool = new HostedToolSearchTool();
Assert.Null(tool.NamespaceDescription);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -969,4 +969,102 @@ await Assert.ThrowsAsync<ClientResultException>(() =>
],
}));
}

[ConditionalFact]
public async Task UseToolSearch_NamespaceWithDescription_RoundTrips()
{
SkipIfNotEnabled();

if (TestRunnerConfiguration.Instance["OpenAI:ChatModel"]?.StartsWith("gpt-5.4", StringComparison.OrdinalIgnoreCase) is not true)
{
throw new SkipTestException("Tool search requires gpt-5.4 or later.");
Comment thread
jozkee marked this conversation as resolved.
}

AIFunction getWeather = AIFunctionFactory.Create(() => "Sunny, 72°F", "GetWeather", "Gets the current weather.");
AIFunction getTime = AIFunctionFactory.Create(() => "3:00 PM", "GetTime", "Gets the current time.");

using var client = new FunctionInvokingChatClient(ChatClient);
var response = await client.GetResponseAsync(
"What's the weather like? Just respond with the weather info, nothing else.",
new()
{
Tools =
[
new HostedToolSearchTool
{
Namespace = "weather_and_time",
NamespaceDescription = "Tools for getting current weather and time.",
DeferredTools = ["GetWeather", "GetTime"],
},
getWeather,
getTime,
],
});

Assert.NotNull(response);
Assert.NotEmpty(response.Text);

// Verify tool_search response items occurred (the namespace wrapper must have been
// accepted by the service for tool search to fire).
var rawJsons = response.Messages
.SelectMany(m => m.Contents)
.Where(c => c.RawRepresentation is ResponseItem)
.Select(c => ModelReaderWriter.Write((ResponseItem)c.RawRepresentation!, ModelReaderWriterOptions.Json).ToString())
.ToList();
Assert.Contains(rawJsons, json => json.Contains("\"type\":\"tool_search_call\"") || json.Contains("\"type\": \"tool_search_call\""));
Assert.Contains(rawJsons, json => json.Contains("\"type\":\"tool_search_output\"") || json.Contains("\"type\": \"tool_search_output\""));
}

[ConditionalFact]
public async Task UseToolSearch_TwoNamespacesWithDescriptions_RoundTrips()
{
SkipIfNotEnabled();

if (TestRunnerConfiguration.Instance["OpenAI:ChatModel"]?.StartsWith("gpt-5.4", StringComparison.OrdinalIgnoreCase) is not true)
{
throw new SkipTestException("Tool search requires gpt-5.4 or later.");
Comment thread
jozkee marked this conversation as resolved.
}

AIFunction getWeather = AIFunctionFactory.Create(() => "Sunny, 72°F", "GetWeather", "Gets the current weather.");
AIFunction getTime = AIFunctionFactory.Create(() => "3:00 PM", "GetTime", "Gets the current time.");
AIFunction getCustomer = AIFunctionFactory.Create((string id) => $"Customer {id}", "GetCustomer", "Gets a customer by id.");

using var client = new FunctionInvokingChatClient(ChatClient);
var response = await client.GetResponseAsync(
"What's the weather like? Just respond with the weather info, nothing else.",
new()
{
Tools =
[
new HostedToolSearchTool
{
Namespace = "weather_and_time",
NamespaceDescription = "Tools for getting current weather and time.",
DeferredTools = ["GetWeather", "GetTime"],
},
new HostedToolSearchTool
{
Namespace = "crm",
NamespaceDescription = "Customer relationship management tools.",
DeferredTools = ["GetCustomer"],
},
getWeather,
getTime,
getCustomer,
],
});

Assert.NotNull(response);
Assert.NotEmpty(response.Text);

// Verify tool_search response items occurred (both namespace wrappers must have been
// accepted by the service for tool search to fire).
var rawJsons = response.Messages
.SelectMany(m => m.Contents)
.Where(c => c.RawRepresentation is ResponseItem)
.Select(c => ModelReaderWriter.Write((ResponseItem)c.RawRepresentation!, ModelReaderWriterOptions.Json).ToString())
.ToList();
Assert.Contains(rawJsons, json => json.Contains("\"type\":\"tool_search_call\"") || json.Contains("\"type\": \"tool_search_call\""));
Assert.Contains(rawJsons, json => json.Contains("\"type\":\"tool_search_output\"") || json.Contains("\"type\": \"tool_search_output\""));
}
}
Loading
Loading