Tutorials
Sofia Nieves9 min read189 views

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.

Flat printed schematic on pale paper. Five identical narrow outlined rectangles sit in a row above a long horizontal rail. Exactly one passes down through the rail into a small bracket below it. The other four float above the rail, unconnected. Muted slate blue ink, no text, no glow.
Flat printed schematic on pale paper. Five identical narrow outlined rectangles sit in a row above a long horizontal rail. Exactly one passes down through the rail into a small bracket below it. The other four float above the rail, unconnected. Muted slate blue ink, no text, no glow.
On this page

TypeScript Node.js Zod npm

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: true does not make your request smaller. Every tool definition is still uploaded in full, with defer_loading: true added to it. At twenty tools my request grew by 443 bytes.
  • A namespaced tool is only reachable by namespace or namespace.member. Searching the bare member name returns nothing.
  • The escape hatch is real and it is one function. Pass your own execute to toolSearchTool() 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.

bash
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:

js
// 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.

js
// 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');
text
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.

js
// 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);
}
text
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_order works. order does not, even though it is a substring of two tool names.
  • Lookup_Order does not. The match is case-sensitive.
  • lookup_orders does not. One character of pluralisation is enough.
  • customer does not, even though the word appears in the description of lookup_order and in the description of the crm namespace. Descriptions are not searched at all.
  • crm loads the whole namespace. crm.get_profile loads the member. get_profile on 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.

js
// 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);
}
text
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.

js
// 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();
text
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.

js
// 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()] }) };
});
text
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

termpagesoccurrences
deferLoading411
toolSearchTool927
tool_search713
paths1336
ClientToolSearchExecutor816
ModelBehaviorError26
exact match00
case-sensitive00
substring / fuzzy / semantic / similarity23
silent / silently34
no match / not found / unmatched / unresolved49
"before it was loaded"00

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:

js
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 execute seam 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:

  1. Your request is not getting smaller. Check that before you adopt it for that reason.
  2. The built-in loader matches tool names exactly and case-sensitively. Descriptions are invisible to it.
  3. A namespaced tool needs namespace or namespace.member, never the bare member name.
  4. A miss is silent, and the error one turn later blames the model.
  5. Pass your own execute to toolSearchTool() 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.

Sofia Nieves

Written by

Sofia Nieves

Frequently 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.

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.

11 min read162
Tutorials

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.

9 min read159