tool_search finds nothing unless the path is the exact tool name
Measured on @openai/agents 0.18.0: the built-in client-side tool_search loader matches paths against tool names by exact string equality, and a miss returns zero tools with no error and no warning. The failure arrives one turn later as an error that blames the model.
On this page
Quick answer
As of September 2026, the built-in client-side tool_search loader in the OpenAI Agents SDK for JavaScript matches a search path against the tool name by exact string equality, and a path that matches nothing returns zero tools with no error and no warning.
paths: ["lookup_order"] loads the tool. paths: ["order"] loads nothing. So does paths: ["Lookup_Order"], and so does any word taken from the tool's description. The run carries on as if the search had succeeded. The failure surfaces on the next turn, as an error that blames the model:
Verbatim, from the SDK:
ModelBehaviorError: Model produced deferred function call lookup_order before it was loaded via tool_search.
Three things are worth knowing before you reach for deferred loading at all:
deferLoading: truedoes not make your request smaller. Every tool definition is still uploaded in full, withdefer_loading: trueadded to it. At twenty tools my request grew by 443 bytes.- A namespaced tool is only reachable by
namespaceornamespace.member. Searching the bare member name returns nothing. - The escape hatch is real and it is one function. Pass your own
executetotoolSearchTool()and you decide what matching means.
Measured first-party on @openai/agents 0.18.0, openai 7.23.0, zod 4.1.13 and Node 24.8.0, against a local fake Responses server, with no API key of any kind.
What this is about, in one paragraph
Deferred tool loading is the SDK's answer to having too many tools. You mark tools deferLoading: true, add toolSearchTool(), and the model is supposed to pull in only the definitions it needs. The vendor guide puts it plainly.
Verbatim, from the Tools guide:
Tool search lets the model load only the tool definitions it needs at runtime instead of sending every schema up front.
By default that search runs on OpenAI's servers, and I cannot see inside it. But the SDK also ships a client-executed mode, toolSearchTool({ execution: 'client' }), where the matching happens in your process, on your machine, in code you can read. That is the part this article measures, end to end, with no account.
Reproduce it in about two minutes
You do not need a model provider. A small local HTTP server is enough, because everything interesting here happens before any real model is involved.
mkdir tool-search-demo
cd tool-search-demo
npm init -y
npm pkg set type=module
npm install --save-exact @openai/agents@0.18.0 openai@7.23.0 zod@4.1.13
Then two files. First the stand-in for the Responses API:
// fake-openai.mjs - a local stand-in for the Responses API. No API key, no network.
// Stateless: every reply is a pure function of the request body.
import http from 'node:http';
export function startServer(options) {
const paths = options.paths;
const allowCall = options.allowCall === true;
const seen = [];
function has(body, type) {
const input = Array.isArray(body.input) ? body.input : [];
return input.some(function (item) { return item && item.type === type; });
}
const server = http.createServer(function (req, res) {
let raw = '';
req.on('data', function (chunk) { raw += chunk; });
req.on('end', function () {
let body = {};
try { body = JSON.parse(raw); } catch (err) { body = {}; }
seen.push(body);
let output;
if (paths.length !== 0 && !has(body, 'tool_search_output')) {
output = [{
type: 'tool_search_call', id: 'ts_1', status: 'completed',
call_id: 'call_ts_1', execution: 'client',
arguments: { paths: paths },
}];
} else if (allowCall && !has(body, 'function_call_output')) {
output = [{
type: 'function_call', id: 'fc_1', call_id: 'call_fc_1',
name: 'lookup_order', arguments: JSON.stringify({ id: 'A-1' }),
status: 'completed',
}];
} else {
output = [{
type: 'message', id: 'msg_1', status: 'completed', role: 'assistant',
content: [{ type: 'output_text', text: 'done', annotations: [] }],
}];
}
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({
id: 'resp_1', object: 'response', created_at: 1, model: 'gpt-5.6-sol',
status: 'completed', output: output,
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
}));
});
});
return new Promise(function (resolve) {
server.listen(0, '127.0.0.1', function () {
const port = server.address().port;
resolve({
baseURL: 'http://127.0.0.1:' + port + '/v1',
requests: seen,
close: function () { return new Promise(function (r) { server.close(r); }); },
});
});
});
}
The server is deliberately stateless: every reply is a pure function of the request body, so there is no turn counter to get out of sync.
What deferLoading actually puts on the wire
The first thing I wanted was the request itself. Twenty identical tools, once with deferred loading off and once with it on.
// probe-wire.mjs - what does deferLoading actually put on the wire?
import { Agent, run, tool, toolSearchTool, OpenAIResponsesModel, setTracingDisabled } from '@openai/agents';
import OpenAI from 'openai';
import { z } from 'zod';
import { startServer } from './fake-openai.mjs';
setTracingDisabled(true);
function makeTools(count, defer) {
return Array.from({ length: count }).map(function (_unused, i) {
return tool({
name: 'tool_' + i,
description: 'Tool number ' + i + ' performs a well described business operation',
parameters: z.object({ a: z.string(), b: z.number(), c: z.boolean() }),
deferLoading: defer,
execute: async function () { return 'ok'; },
});
});
}
async function measure(defer, count) {
const srv = await startServer({ paths: [] });
const model = new OpenAIResponsesModel(
new OpenAI({ baseURL: srv.baseURL, apiKey: 'not-a-real-key' }),
'gpt-5.6-sol',
);
const fns = makeTools(count, defer);
const tools = defer ? [toolSearchTool()].concat(fns) : fns;
const agent = new Agent({ name: 'A', instructions: 'hi', model: model, tools: tools });
await run(agent, 'hello');
const body = srv.requests[0];
await srv.close();
const sent = body.tools || [];
const functionDefs = sent.filter(function (t) { return t.type === 'function'; });
return {
entries: sent.length,
functionDefs: functionDefs.length,
bytes: JSON.stringify(body).length,
keys: functionDefs.length ? Object.keys(functionDefs[0]).sort().join(' ') : '',
};
}
const plain = await measure(false, 20);
const deferred = await measure(true, 20);
console.log('deferLoading false | tools entries ' + plain.entries + ' | function defs ' + plain.functionDefs + ' | request bytes ' + plain.bytes);
console.log(' function keys: ' + plain.keys);
console.log('deferLoading true | tools entries ' + deferred.entries + ' | function defs ' + deferred.functionDefs + ' | request bytes ' + deferred.bytes);
console.log(' function keys: ' + deferred.keys);
console.log('request grew by ' + (deferred.bytes - plain.bytes) + ' bytes');
deferLoading false | tools entries 20 | function defs 20 | request bytes 7025
function keys: description name parameters strict type
deferLoading true | tools entries 21 | function defs 20 | request bytes 7468
function keys: defer_loading description name parameters strict type
request grew by 443 bytes
The second run sends the same twenty complete function definitions, each with one extra key. The only thing that changed is a flag and one more entry for the search tool itself.
That is worth sitting with. The phrase "instead of sending every schema up front" is a fair description of what reaches the model's context, which the Responses API manages on OpenAI's side. It is not a description of what leaves your machine. Your upload is the same size, plus a little. If you adopted deferred loading expecting your outbound payload to shrink, it did not, and you cannot see the part that did change from the client.
The path table
Now the part that matters. Two top-level deferred tools, one deferred tool inside a crm namespace, and the built-in client loader. The fake server answers every first turn with a tool_search_call carrying the paths I choose.
// probe-paths.mjs - what does the built-in client tool_search loader actually match?
import { Agent, run, tool, toolNamespace, toolSearchTool, OpenAIResponsesModel, setTracingDisabled } from '@openai/agents';
import OpenAI from 'openai';
import { z } from 'zod';
import { startServer } from './fake-openai.mjs';
setTracingDisabled(true);
const idParam = z.object({ id: z.string() });
function buildTools() {
const lookupOrder = tool({
name: 'lookup_order', description: 'Look up a customer order by its identifier',
parameters: idParam, deferLoading: true, execute: async function () { return 'x'; },
});
const cancelOrder = tool({
name: 'cancel_order', description: 'Cancel a customer order',
parameters: idParam, deferLoading: true, execute: async function () { return 'x'; },
});
const crm = toolNamespace({
name: 'crm', description: 'CRM tools for customer lookups',
tools: [tool({
name: 'get_profile', description: 'Fetch a customer profile',
parameters: idParam, deferLoading: true, execute: async function () { return 'x'; },
})],
});
return [toolSearchTool({ execution: 'client' }), lookupOrder, cancelOrder].concat(Array.from(crm));
}
function namesOf(searchOutput) {
const names = [];
for (const entry of (searchOutput.tools || [])) {
if (entry.type === 'namespace') {
for (const member of (entry.tools || [])) { names.push(entry.name + '.' + member.name); }
} else { names.push(entry.name); }
}
return names;
}
async function search(paths) {
const srv = await startServer({ paths: paths });
const model = new OpenAIResponsesModel(
new OpenAI({ baseURL: srv.baseURL, apiKey: 'not-a-real-key' }), 'gpt-5.6-sol');
const agent = new Agent({ name: 'A', instructions: 'hi', model: model, tools: buildTools() });
const result = await run(agent, 'go');
await srv.close();
for (const item of result.newItems) {
const rawItem = item.rawItem || item;
if (rawItem.type === 'tool_search_output') { return namesOf(rawItem); }
}
return null;
}
const cases = [
['lookup_order'], ['order'], ['Lookup_Order'], ['lookup_orders'],
['customer'], ['crm'], ['crm.get_profile'], ['get_profile'],
['lookup_order', 'crm'], [''],
];
console.log('paths loaded tools');
for (const paths of cases) {
const names = await search(paths);
const label = JSON.stringify(paths);
const count = names === null ? 'n/a' : String(names.length);
const listed = names === null ? '(no search output)' : (names.join(', ') || '(none)');
console.log(label.padEnd(30) + ' ' + count.padStart(6) + ' ' + listed);
}
paths loaded tools
["lookup_order"] 1 lookup_order
["order"] 0 (none)
["Lookup_Order"] 0 (none)
["lookup_orders"] 0 (none)
["customer"] 0 (none)
["crm"] 1 crm.get_profile
["crm.get_profile"] 1 crm.get_profile
["get_profile"] 0 (none)
["lookup_order","crm"] 2 lookup_order, crm.get_profile
[""] 0 (none)
Read that table slowly.
lookup_orderworks.orderdoes not, even though it is a substring of two tool names.Lookup_Orderdoes not. The match is case-sensitive.lookup_ordersdoes not. One character of pluralisation is enough.customerdoes not, even though the word appears in the description oflookup_orderand in the description of thecrmnamespace. Descriptions are not searched at all.crmloads the whole namespace.crm.get_profileloads the member.get_profileon its own loads nothing.
This is not a search. It is a dictionary lookup keyed on the exact tool name, with namespace names and qualified names as additional keys. The parameter being called paths is the clue, and it is a good name once you know; it reads like a query until then.
A miss is completely silent
Nothing above threw. Nothing logged. I re-ran the whole table with DEBUG=openai-agents:* and captured stderr to a file: 10,748 bytes over 146 lines across all ten arms, nine of which found nothing, and not one line mentions the search. Occurrences of warn, error, not found, no match, unmatched, unresolved, skip, ignor, tool_search and deferred: zero, every one. The only two hits for empty are the tracing buffer shutting down.
So the model asked for a tool, got an empty answer that looks exactly like a successful search returning no results, and continued.
The failure arrives one turn later, and blames the model
Here is the whole chain. The model searches, gets nothing, then calls the tool anyway.
// probe-consequence.mjs - a miss is silent; the failure arrives on the next turn.
import { Agent, run, tool, toolSearchTool, OpenAIResponsesModel, setTracingDisabled } from '@openai/agents';
import OpenAI from 'openai';
import { z } from 'zod';
import { startServer } from './fake-openai.mjs';
setTracingDisabled(true);
async function attempt(paths) {
const srv = await startServer({ paths: paths, allowCall: true });
const model = new OpenAIResponsesModel(
new OpenAI({ baseURL: srv.baseURL, apiKey: 'not-a-real-key' }), 'gpt-5.6-sol');
let executions = 0;
const lookupOrder = tool({
name: 'lookup_order', description: 'Look up a customer order by its identifier',
parameters: z.object({ id: z.string() }), deferLoading: true,
execute: async function () { executions += 1; return 'order found'; },
});
const agent = new Agent({
name: 'A', instructions: 'hi', model: model,
tools: [toolSearchTool({ execution: 'client' }), lookupOrder],
});
let verdict;
try {
const result = await run(agent, 'find my order');
verdict = 'completed, finalOutput ' + JSON.stringify(result.finalOutput);
} catch (err) {
verdict = err.constructor.name + ': ' + err.message;
}
await srv.close();
return { executions: executions, verdict: verdict };
}
for (const paths of [['lookup_order'], ['order']]) {
const r = await attempt(paths);
console.log('paths ' + JSON.stringify(paths));
console.log(' execute() ran ' + r.executions + ' time(s)');
console.log(' ' + r.verdict);
}
paths ["lookup_order"]
execute() ran 1 time(s)
completed, finalOutput "done"
paths ["order"]
execute() ran 0 time(s)
ModelBehaviorError: Model produced deferred function call lookup_order before it was loaded via tool_search.
Only the string inside paths differs between those two runs. Same code, same server, same tool, same model id. One works and executes the tool once. The other never runs your function and dies with an error that says the model produced a call before it was loaded.
The model did nothing unreasonable. It searched, it was told nothing matched, and it tried the tool it was clearly meant to use. The resolver is what returned nothing, and the resolver is not mentioned anywhere in the error.
If you set toolNotFoundBehavior: 'return_error_to_model' in your run options the run continues instead of throwing, and the model gets a tool error it can retry from. The running agents guide documents that option. It changes how loudly this fails; it does not change why.
The fix is one function
The SDK gives you the seam. toolSearchTool() accepts an execute, and when you supply one you decide what a path means. Here is an eleven line matcher over names and descriptions.
// probe-fix.mjs - supply your own executor and the same query resolves.
import { Agent, run, tool, toolSearchTool, OpenAIResponsesModel, setTracingDisabled } from '@openai/agents';
import OpenAI from 'openai';
import { z } from 'zod';
import { startServer } from './fake-openai.mjs';
setTracingDisabled(true);
function searchByNameAndDescription(args) {
const raw = args.toolCall.arguments;
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
const paths = Array.isArray(parsed.paths) ? parsed.paths : [];
const words = paths.join(' ').toLowerCase().split(/\s+/).filter(Boolean);
return args.availableTools.filter(function (candidate) {
if (candidate.type !== 'function') { return false; }
const haystack = (candidate.name + ' ' + (candidate.description || '')).toLowerCase();
return words.some(function (word) { return haystack.includes(word); });
});
}
const srv = await startServer({ paths: ['order'], allowCall: true });
const model = new OpenAIResponsesModel(
new OpenAI({ baseURL: srv.baseURL, apiKey: 'not-a-real-key' }), 'gpt-5.6-sol');
let executions = 0;
const idParam = z.object({ id: z.string() });
const lookupOrder = tool({
name: 'lookup_order', description: 'Look up a customer order by its identifier',
parameters: idParam, deferLoading: true,
execute: async function () { executions += 1; return 'order found'; },
});
const cancelOrder = tool({
name: 'cancel_order', description: 'Cancel a customer order',
parameters: idParam, deferLoading: true,
execute: async function () { return 'cancelled'; },
});
const agent = new Agent({
name: 'A', instructions: 'hi', model: model,
tools: [
toolSearchTool({ execution: 'client', execute: searchByNameAndDescription }),
lookupOrder,
cancelOrder,
],
});
const result = await run(agent, 'find my order');
for (const item of result.newItems) {
const rawItem = item.rawItem || item;
if (rawItem.type === 'tool_search_output') {
const names = (rawItem.tools || []).map(function (t) { return t.name; });
console.log('paths ["order"] loaded ' + names.length + ': ' + names.join(', '));
}
}
console.log('execute() ran ' + executions + ' time(s)');
console.log('finalOutput ' + JSON.stringify(result.finalOutput));
await srv.close();
paths ["order"] loaded 2: lookup_order, cancel_order
execute() ran 1 time(s)
finalOutput "done"
Same failing query, same tools. Now order finds both order tools, the deferred tool loads, and execute() runs.
If you keep the built-in loader instead, the rule to write on the wall is the one the vendor guide already hints at: keep a tool top-level when the tool name itself is a good search target, and name your tools the way you would want them searched.
What the SDK does catch, loudly
It is worth being fair about the guard rails that do exist, because they are good ones and they fire before any request leaves the process.
// probe-guards.mjs - the mistakes the SDK does catch loudly, before any request.
import { Agent, run, tool, toolNamespace, toolSearchTool, OpenAIResponsesModel, OpenAIChatCompletionsModel, setTracingDisabled } from '@openai/agents';
import OpenAI from 'openai';
import { z } from 'zod';
import { startServer } from './fake-openai.mjs';
setTracingDisabled(true);
const idParam = z.object({ id: z.string() });
function deferredTool() {
return tool({
name: 'lookup_order', description: 'Look up a customer order by its identifier',
parameters: idParam, deferLoading: true, execute: async function () { return 'x'; },
});
}
async function attempt(label, build) {
const srv = await startServer({ paths: [] });
const client = new OpenAI({ baseURL: srv.baseURL, apiKey: 'not-a-real-key' });
let line;
try {
const cfg = build(client);
await run(cfg.agent, 'go', cfg.options);
line = 'no error';
} catch (err) {
line = err.constructor.name + ': ' + err.message;
}
await srv.close();
console.log(label);
console.log(' ' + line);
}
await attempt('deferLoading without toolSearchTool', function (client) {
return { agent: new Agent({
name: 'A', instructions: 'hi',
model: new OpenAIResponsesModel(client, 'gpt-5.6-sol'),
tools: [deferredTool()] }) };
});
await attempt('toolNamespace on the Chat Completions API', function (client) {
const ns = toolNamespace({ name: 'crm', description: 'CRM tools', tools: [
tool({ name: 'get_profile', description: 'Fetch a profile', parameters: idParam,
execute: async function () { return 'x'; } })] });
return { agent: new Agent({
name: 'A', instructions: 'hi',
model: new OpenAIChatCompletionsModel(client, 'gpt-5.6-sol'),
tools: Array.from(ns) }) };
});
await attempt('toolChoice forcing a deferred tool by name', function (client) {
return { agent: new Agent({
name: 'A', instructions: 'hi',
model: new OpenAIResponsesModel(client, 'gpt-5.6-sol'),
modelSettings: { toolChoice: 'lookup_order' },
tools: [toolSearchTool(), deferredTool()] }) };
});
deferLoading without toolSearchTool
UserError: Deferred function tools and hosted MCP tools with deferLoading: true require toolSearchTool() in the same request.
toolNamespace on the Chat Completions API
UserError: Namespaced function tools created with toolNamespace() are only supported with the Responses API.
toolChoice forcing a deferred tool by name
UserError: modelSettings.toolChoice="lookup_order" cannot force a deferred function tool in Responses. Use "auto" so tool_search can load it.
All three of those are exactly right. The SDK is careful here. What it does not do is tell you when a search found nothing.
What the documentation says, and what it does not
Rather than assert "undocumented", I swept it. Every English guide page plus every tool and tool-search reference page in the sitemap: 74 pages, 0 fetch failures, scanned as rendered text with nav, header, footer and aside stripped, matched on word boundaries.
The stripping matters. Counted on the raw page text, toolSearchTool appears on 74 of 74 pages, because the API reference sidebar lists every exported symbol on every page. That number is navigation, not documentation.
Scroll to see more
| term | pages | occurrences |
|---|---|---|
deferLoading | 4 | 11 |
toolSearchTool | 9 | 27 |
tool_search | 7 | 13 |
paths | 13 | 36 |
ClientToolSearchExecutor | 8 | 16 |
ModelBehaviorError | 2 | 6 |
| exact match | 0 | 0 |
| case-sensitive | 0 | 0 |
| substring / fuzzy / semantic / similarity | 2 | 3 |
| silent / silently | 3 | 4 |
| no match / not found / unmatched / unresolved | 4 | 9 |
| "before it was loaded" | 0 | 0 |
The three hits for the fuzzy family are all unrelated: semantic versioning on the release page, a pointer to OpenAI's own hosted tools guide, and a sentence about strict: false. The four silent and nine no match hits are about approvals, sandboxes, realtime transports and toolNotFoundBehavior. Not one of them is about tool-search path resolution.
So the feature is documented and the matching rule is not. The closest the guide comes is an aside about naming.
Verbatim, from the Tools guide:
Keep a tool top-level when it is a single independent capability and the tool name itself is a good search target.
That sentence is true and, read after the fact, it is the whole rule. Read before the fact it sounds like advice about naming things well.
The reference page for toolSearchTool is the other place to look.
Verbatim, from the toolSearchTool reference:
The standard runner only supports the default built-in client schema (leave parameters unset) and auto-executes { paths: string[] } searches over deferred top-level function tools and deferred namespace members.
"Auto-executes searches over deferred top-level function tools" is accurate. It just does not say that the search is equality on the name.
One trap if you go to reproduce this
If you write a multi-arm script like mine, do not reach for setDefaultOpenAIClient. Measured in this same process: calling it a second time with a new client does not redirect subsequent runs. Arm two still went to arm one's port, which by then was closed, and surfaced as APIConnectionError: Connection error pointing at a port number that appears nowhere in your code.
Constructing the model explicitly instead works on every arm, and that is what every probe above does:
const model = new OpenAIResponsesModel(
new OpenAI({ baseURL: srv.baseURL, apiKey: 'not-a-real-key' }),
'gpt-5.6-sol',
);
const agent = new Agent({ name: 'A', instructions: 'hi', model: model, tools: tools });
Three arms, three servers, three clean runs.
What I did not measure, and will not claim
Being precise about the edges of this:
- Hosted execution. By default tool search runs on OpenAI's servers. I have no visibility into that matching and I am not claiming it is exact-match. Everything here is about
execution: 'client'with the built-in loader. - Token savings. I measured request bytes, which did not shrink. I did not measure billed input tokens or what reaches the model's context, because that needs a real account. The vendor claim about context may well be correct; it is simply not visible from where I was standing.
- Why it is built this way. A conservative exact-match default with a documented
executeseam is a defensible design. My finding is not that the behaviour is wrong. It is that it is called search, it is silent when it fails, and the error you eventually get points at the model. - Other SDKs. The Vercel AI SDK takes a different approach to tool exposure entirely and none of this transfers to it.
The short version
If you are using deferred tool loading with client-side tool search in @openai/agents:
- Your request is not getting smaller. Check that before you adopt it for that reason.
- The built-in loader matches tool names exactly and case-sensitively. Descriptions are invisible to it.
- A namespaced tool needs
namespaceornamespace.member, never the bare member name. - A miss is silent, and the error one turn later blames the model.
- Pass your own
executetotoolSearchTool()if you want anything other than equality.
Everything above is reproducible from the six files in this article, on a machine with no API key. The SDK source is public if you want to read the resolver yourself.
Written by
Sofia NievesFrequently asked questions
Why does my tool_search return no tools?
The built-in client-side loader matches each entry in paths against the tool name by exact string equality. Measured on @openai/agents 0.18.0: paths ['lookup_order'] loads the tool, while paths ['order'], ['Lookup_Order'] and ['lookup_orders'] all load nothing. Descriptions are never searched.
Is tool_search path matching case sensitive?
Yes. Measured: paths ['Lookup_Order'] against a tool named lookup_order returns zero tools, with no error and no warning.
Does deferLoading make my request smaller?
No. Measured at twenty tools on @openai/agents 0.18.0, every function definition is still uploaded in full, with defer_loading: true added to each one. The request grew by 443 bytes because of the extra flags plus the tool_search entry. What deferred loading changes is what reaches the model's context on OpenAI's side, which is not visible from the client.
What does 'Model produced deferred function call before it was loaded via tool_search' mean?
It means the model called a tool marked deferLoading: true that the tool search step never loaded. On the built-in client loader the usual cause is that the search path did not exactly match the tool name, so the search returned zero tools silently and the model tried the tool anyway one turn later.
How do I load a tool that lives inside a toolNamespace?
Use the namespace name or the qualified name. Measured: paths ['crm'] loads every deferred member of that namespace and paths ['crm.get_profile'] loads that member, while paths ['get_profile'] loads nothing.
Can I make tool search match descriptions or substrings?
Yes, by passing your own executor: toolSearchTool({ execution: 'client', execute }). The executor receives the tool call and the available tools and returns the tools to load, so you decide what matching means. An eleven line matcher over names and descriptions makes paths ['order'] resolve to both order tools.
Does the SDK warn when a tool search path matches nothing?
No. Measured with DEBUG=openai-agents:* and stderr captured to a file across ten search arms, nine of which matched nothing: 10,748 bytes over 146 lines, with zero occurrences of warn, error, not found, no match, unmatched, unresolved, skip, ignor, tool_search or deferred.
What mistakes does the Agents SDK catch loudly?
Three, all before any request leaves the process. Using deferLoading: true without toolSearchTool() raises a UserError. Using toolNamespace() on the Chat Completions API raises a UserError. Setting modelSettings.toolChoice to a deferred tool name raises a UserError telling you to use auto so tool_search can load it.
Related tutorials
Your Zod .refine() never reaches the model
Measured on ai 7.0.118 and zod 4.6.5: a .refine() on a tool input schema is stripped before the schema reaches the model, with no warning. z.string().startsWith('acct_') arrives as a pattern; the same rule written as a refinement arrives as nothing.
@openai/agents adds reasoning.effort and text.verbosity by matching your model name
The SDK chooses reasoning.effort and text.verbosity from how your model name is spelled, not from what the model supports. Measured on the wire on 0.18.0, including what it does to a custom gateway deployment name.
generateText defaults to one step, so your agent returns an empty string
On ai 7.0.116, generateText and streamText run exactly one step by default. Pass tools without stopWhen and the model calls a tool, your tool runs, and you get an empty string with no error. The same package defaults ToolLoopAgent to twenty. Measured on the wire, with the bundle lines that cause it.