Skip to content
Context Windowby Alex Janjic
Menu

When the tool catalog becomes the prompt

A 192-run Microsoft.Extensions.AI benchmark compares sending 8 or 64 tools on every request with a RequestTools discovery function. At 64 tools, billed tokens drop by about 2.5x. At 8 tools, the extra round trip costs more than it saves.

AI systemsReliability
Source links stay with the relevant section

I asked three Azure OpenAI deployments to write a haiku about compilers while 64 AIFunction schemas sat on the request. None of them called a tool. gpt-5.6-luna still used 2,345 and 2,422 total tokens on those two runs. The same prompt with one discovery function, RequestTools, used 407 and 321. The poem did not need a warehouse bin lookup, and the catalog had 56 of those.

That is when the catalog stops being a list you keep locally and starts occupying the prompt. ContextWindow.Extensions.AI keeps the full set in process, advertises RequestTools, and attaches a small deterministic subset to the active FunctionInvokingChatClient loop. I ran 192 requests across gpt-5.6-luna, gpt-5.6-terra, and gpt-5.6-sol to see when the extra round trip is worth paying for.

Eight tools belong on the wire

Microsoft.Extensions.AI already does the hard part of tool calling. ChatClientBuilder.UseFunctionInvocation() wraps an IChatClient. The model returns FunctionCallContent. FunctionInvokingChatClient looks up the matching AIFunction on ChatOptions.Tools, invokes it, and continues the loop. ApprovalRequiredAIFunction is a DelegatingAIFunction wrapper. The invoker is supposed to ask a human before execution. The wrapper does not grant permission by existing.

None of that tells you which tools to send. Eight native functions are cheap. I measured 282 estimated schema tokens for the eight core tools in this sample, and about 830 billed tokens per full-catalog request on luna. At that size, RequestTools is the expensive option. The discovery round trip added about 9,000 tokens across 16 luna runs, 13,291 versus 22,202, and added a couple of seconds of p50 latency.

Register locally, advertise one function

The public API is a builder, an immutable catalog, and a discovery function. AddTool stores the AIFunction instance you pass it, including ApprovalRequiredAIFunction. MCP tools already adapted to AIFunction register the same way. The package does not depend on an MCP SDK.

examples/ProgressiveCatalogUsage.cs
ToolCatalog catalog = new ToolCatalogBuilder()
    .AddTool("github", githubSearch)
    .AddTool("jira", jiraSearch)
    .AddTool("database", queryDatabase, tags: ["sql", "postgres"])
    .Build();


AIFunction requestTools = catalog.CreateRequestToolsFunction();
return new ChatOptions
{
    Tools = [requestTools],
};
The catalog stays in process. ChatOptions.Tools starts with RequestTools, not the hundred functions behind it.

RequestTools has to run inside FunctionInvokingChatClient. Build that with ChatClientBuilder.UseFunctionInvocation() or IChatClient.AsBuilder().UseFunctionInvocation(). The discovery function ranks the catalog, then binds matches onto FunctionInvokingChatClient.CurrentContext.Options.Tools for the rest of the loop.

RequestTools takes a short operation description and optional tags. LexicalToolSelector tokenizes the query, scores name, source, tags, and description, and returns at most MaximumToolsPerRequest matches, default 5. Ties break on function name with ordinal comparison. The selector never invokes a tool. Selection is routing.

src/ContextWindow.Extensions.AI/ToolCatalogFunctionFactory.cs
string RequestTools(
    [Description("Describe the operation, system, or data you need.")]
    string operation,
    [Description("Optional tags that narrow the catalog.")]
    string[]? tags = null)
{
    var selection = catalog.Select(new ToolSelectionContext(operation, tags));
    var tools = selection.Matches.Select(static match => match.Tool).ToArray();
    var bind = FunctionInvocationToolBinder.Bind(tools);
    var payload = DiscoveryPayload.Create(selection, bind);
    return JsonSerializer.Serialize(payload, CatalogJsonContext.Default.DiscoveryPayload);
}
The discovery function ranks the catalog, then binds the same AIFunction instances onto the current request.

The bind has to happen inside the loop

FunctionInvokingChatClient.CurrentContext is an AsyncLocal<FunctionInvocationContext>. During invocation it carries the ChatOptions for that request. Issue 7217 on dotnet/extensions was exactly this: a function adds tools to ChatOptions.Tools, the next model turn asks for the new function, and an older cached name map cannot find it. PR 7218 switched lookup to a linear search of the current lists. ChatOptions.Clone() copies that list into a new collection, so later iterations keep the instances you added.

src/ContextWindow.Extensions.AI/FunctionInvocationToolBinder.cs
var context = FunctionInvokingChatClient.CurrentContext;
if (context is null)
{
    return ToolBindResult.Unavailable(
        "FunctionInvokingChatClient.CurrentContext is null. Call ChatClientBuilder.UseFunctionInvocation().");
}


context.Options ??= new ChatOptions();
var mutable = EnsureMutableToolList(context.Options);
foreach (var tool in tools)
{
    if (!Contains(mutable, tool))
    {
        mutable.Add(tool);
    }
}
Replace a read-only or array tool list before adding. Keep the registered instance so ApprovalRequiredAIFunction stays ApprovalRequiredAIFunction.

Progressive discovery inside one function loop

Catalog

ToolCatalognative and MCP AIFunction instances

Discovery

RequestToolsone advertised function
LexicalToolSelectordeterministic top-k

Request

ChatOptions.Toolsmutated during invocation
FunctionInvokingChatClientlooks up the current list
  1. ToolCatalogregistrationsLexicalToolSelector
  2. RequestToolsoperation textLexicalToolSelector
  3. LexicalToolSelectormatching instancesChatOptions.Tools
  4. ChatOptions.Toolsnext iterationFunctionInvokingChatClient
  • Advertised function
  • Local ranking
  • Invocation loop
Read this diagram as text

ToolCatalog holds native and MCP-backed AIFunction instances in process. The model calls RequestTools with an operation description. LexicalToolSelector ranks the catalog and returns a small set. Those exact instances are added to ChatOptions.Tools. FunctionInvokingChatClient searches the current tool list on the next iteration and can invoke them, including ApprovalRequiredAIFunction wrappers.

RequestTools is the only function on the first model call. Matching catalog entries join ChatOptions.Tools for the rest of the loop.

The live suite

The host builds a catalog of eight named tools covering GitHub, Jira, PostgreSQL, and Slack, then pads with inventory_lookup_NNN fillers up to 8 or 64. Six prompts should call a specific domain tool. Two should not: a hash-table explanation and a compiler haiku. Each deployment ran every prompt twice in Full mode and twice in Progressive mode, 16 runs per cell, 192 completed requests. The benchmark host uses Microsoft.Extensions.AI.OpenAI with a preview Azure.AI.OpenAI package isolated there so the catalog library can stay on stable Microsoft.Extensions.AI.

Provider-backed runsamples/toolscout-extensions-dotnet
> dotnet test ContextWindow.Extensions.AI.slnx --configuration Release
Passed! - Failed: 0, Passed: 19, Skipped: 0, Total: 19
> dotnet run --project benchmarks/ToolScout.Benchmarks --configuration Release -- live-benchmark --sizes 8,64 --repetitions 2
Measured 3 deployments x 2 sizes x 2 modes x 8 cases x 2 repetitions
gpt-5.6-luna size=64 Full 16/16 choice, 65001 tokens, p50 4621 ms
gpt-5.6-luna size=64 Progressive 15/16 choice, 25657 tokens, p50 5072 ms
Release tests, then the provider-backed comparison. Token counts come from ChatResponse.Usage.

Sixty-four tools is where the catalog takes over

Catalog size 64. Measured August 31, 2026 with .NET 10.0.11 on Windows 10.0.26200. Each cell is 16 requests.
Measuregpt-5.6-lunagpt-5.6-terragpt-5.6-sol
Full completed16 / 1616 / 1616 / 16
Full tool choice116 / 1616 / 1616 / 16
Full total tokens365,00164,67964,593
Progressive completed16 / 1616 / 1616 / 16
Progressive tool choice115 / 1616 / 1616 / 16
Progressive exact tool211 / 1212 / 1212 / 12
Progressive total tokens325,65726,05526,290
  1. Tool choice is correct when the expected domain tool ran, or when explanation prompts invoked no domain tool. RequestTools is not a domain tool.
  2. Exact tool requires exactly one domain tool matching the expected name. Six prompts per repetition expect a tool, twelve per cell.
  3. Token counts are provider-reported input plus output across the 16 runs in that cell.

On these deployments, 64 schemas did not wreck tool choice. Full mode was 48 / 48. Progressive was 47 / 48. What 64 schemas wrecked was the bill. Luna spent 63,362 input tokens sending the whole list and 23,465 with RequestTools. Terra and sol were within a few hundred tokens of that. Per request that is about 4,060 tokens for Full and about 1,600 for Progressive. The extra round trip shows up in p50, 4.6 s versus 5.1 s on luna, which is real and smaller than a 2.5x token jump.

The miss is worth keeping. On luna, Progressive, catalog size 64, "Read the file src/Program.cs from the GitHub repository" called RequestTools and then asked which owner/name to use instead of calling get_file_contents. The stub tool does not need a repo. Full mode called it. Discovery added a place for the model to get polite and stall. That is a product problem for the operation description and the tool description, not a reason to put 56 inventory functions back on the wire.

Eight tools still prefer the full list

Catalog size 8. Same 16-request cells. RequestTools costs an extra round trip before the schema list is large enough to matter.
MeasureFull catalogRequestTools
Total tokens166,345
luna p505.25 s
terra p504.13 s
sol p504.77 s
  1. Values are totals across gpt-5.6-luna, gpt-5.6-terra, and gpt-5.6-sol, 48 requests per column.
  2. Tool choice was 48 / 48 in both columns. Exact tool was 36 / 36.

I would not turn RequestTools on for a handful of functions. The crossover in this sample sits between 8 and 64. Local schema estimates put a 96-tool list at 3,428 tokens against 144 for RequestTools, and a 256-tool list at 9,148 against the same 144. Those estimates are (name + description + JSON schema characters) / 4, not billed tokens, and they already show why the billed input line jumps once fillers exist.

Reproduce the local schema estimate and the live suite

The local command writes artifacts/benchmark-results.json and artifacts/benchmark-summary.md. LexicalToolSelector stayed at 9 / 9 labeled hits from 8 through 256 tools, with a mean of 1.171 ms per selection at 256. The provider-backed command writes artifacts/live-benchmark-results.json and artifacts/live-benchmark-summary.md. Provider values come from AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_KEY, and AZURE_OPENAI_DEPLOYMENTS.

dotnet run --project benchmarks/ToolScout.Benchmarks --configuration Release -- benchmark --repetitions 2000

Source

The complete sample, benchmarks, and compatibility tests are available at github.com/alex-janjic/ContextWindow.Extensions.AI.

What I would ship

Keep every AIFunction in a catalog the process owns. Send RequestTools once the schema list is the expensive part of the request, and cap how many matches one discovery call can attach. Leave ApprovalRequiredAIFunction wrapped. A match is not permission to run. I would also keep a regression test on FunctionInvocationContext.Options.Tools, because the bind is the whole trick and Microsoft.Extensions.AI can still change how that list is copied.