Skip to content

feat(ai/core): lazy tool discovery for token-efficient large tool sets#14147

Open
fkesheh wants to merge 7 commits intovercel:mainfrom
fkesheh:feat/lazy-tool-discovery
Open

feat(ai/core): lazy tool discovery for token-efficient large tool sets#14147
fkesheh wants to merge 7 commits intovercel:mainfrom
fkesheh:feat/lazy-tool-discovery

Conversation

@fkesheh
Copy link
Copy Markdown

@fkesheh fkesheh commented Apr 6, 2026

Summary

Implements lazy tool discovery as requested in #13423. Tools marked with lazy: true send only their name and description to the LLM — the full inputSchema is omitted. A synthetic __load_tool_schema__ tool is auto-injected so the LLM can fetch full schemas on demand before calling a tool.

How it works:

  1. Lazy tools are sent to the LLM with a minimal empty schema ({ type: 'object', properties: {} }) instead of their full inputSchema
  2. The LLM calls __load_tool_schema__ to get the full schema as a tool result (text)
  3. The LLM calls the lazy tool with arguments based on the loaded schema
  4. If the LLM sends strings for nested objects/arrays (common with empty schemas), the SDK auto-repairs by JSON.parsing those values against the real schema, then validates and executes

This reduces token usage and improves tool selection accuracy for apps with large tool sets (20-50+ tools).

Prior art: TanStack AI lazy tool discovery, Cursor dynamic context discovery (46.9% token reduction in A/B test).

Design principles

  • Stateless — no tracking of what's been loaded, no message scanning
  • Cache-friendly — same tool list every step, preserves LLM prompt cache
  • All tools always visible (name + description) and always executable
  • Overridable — users can define their own __load_tool_schema__ tool
  • skill field — optional extended docs loaded on demand for lazy tools

Key implementation details

  • prepareTools: lazy tools emit minimal empty schema with instruction to load schema first
  • parseToolCall: for lazy tools, auto-repairs string→object/array mismatches using the real schema
  • __load_tool_schema__: returns serialized JSON Schema (stripped of Zod internals via JSON.parse/stringify)
  • buildEffectiveTools: shared helper used by both generateText and streamText
  • filterActiveTools: preserves __load_tool_schema__ when activeTools filtering is used

Changes

  • @ai-sdk/provider-utils: Add lazy?: boolean and skill?: string to Tool type
  • ai: New load-tool-schema.ts — synthetic tool factory + buildEffectiveTools helper
  • ai: prepareTools emits minimal empty schema for lazy tools
  • ai: parseToolCall auto-repairs lazy tool inputs (JSON.parse strings that should be objects/arrays)
  • ai: filterActiveTools preserves __load_tool_schema__ through activeTools filtering
  • ai: Auto-inject __load_tool_schema__ when lazy tools exist (overridable)

Test plan

  • Unit tests for createLoadToolSchemaTool (schema return, skill, inputExamples, error entries)
  • Unit tests for buildEffectiveTools (no-op without lazy, injection, user override)
  • Unit tests for prepareTools (minimal schema for lazy, mixed lazy/eager)
  • All 2210 existing tests pass
  • Full monorepo build (69/69 tasks)
  • Lint/format clean
  • Tested in real app (legbi) with 33 tools, 17 lazy

Closes #13423

🤖 Generated with Claude Code

… sets

Tools marked with `lazy: true` send only name + description to the LLM,
omitting their full inputSchema. A synthetic `__load_tool_schema__` tool
is auto-injected so the LLM can fetch full schemas on demand. This reduces
token usage and improves tool selection accuracy for apps with 20-50+ tools.

- Add `lazy` and `skill` properties to Tool type
- Emit minimal schema for lazy tools in prepareTools
- Create buildEffectiveTools helper shared by generateText/streamText
- Auto-inject __load_tool_schema__ (overridable by user)
- Return error entries for unknown tool names in schema loader

Ref: vercel#13423
@tigent tigent bot added ai/core core functions like generateText, streamText, etc. Provider utils, and provider spec. feature New feature or request labels Apr 6, 2026
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the LLM calls __load_tool_schema__({ toolNames: [] }), the execute handler returns {} silently, and the LLM may loop calling it again with no progress. The fix could be simple by just return a warning message for empty input and explicitly tell the LLM in the tool's description to always pass at least one tool name.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

very good catch, I will add that

fkesheh added 5 commits April 6, 2026 10:10
Lazy tools send minimal schema to the LLM (no full inputSchema) to save
tokens. Without proper type info, LLM providers serialize nested objects
as strings, causing validation failures.

Fix: lazy tools now declare { jsonInput: string } as their schema. The
LLM produces a JSON-encoded string of all arguments, and the SDK unwraps
it in parseToolCall, validates against the real schema, then executes.

Also fixes __load_tool_schema__ result serialization (JSON.parse/stringify
to strip non-serializable Zod internals) and adds instruction text to
guide the LLM to call the tool after loading its schema.

Ref: vercel#13423
Add min(1) validation on toolNames array and return an explicit error
message when called with empty input, preventing the LLM from looping
with no progress.

Ref: vercel#13423
min(1) may not be supported by all LLM providers' JSON Schema handling.
Keep only the runtime empty-array check with explicit error message.

Ref: vercel#13423
The _instruction field now tells the LLM to pass arguments as a
JSON-encoded string in the jsonInput parameter, matching the lazy
tool's actual schema contract.

Ref: vercel#13423
Revert the jsonInput string wrapper approach. Instead, lazy tools send
a minimal empty schema and the SDK auto-repairs string values that should
be objects/arrays (common when LLMs only see an empty schema).

Flow: validate against real schema → if fails for lazy tool → parse raw
input → JSON.parse string values where schema expects object/array →
re-validate → execute.

Ref: vercel#13423
@fkesheh fkesheh marked this pull request as ready for review April 6, 2026 14:15
Copy link
Copy Markdown
Contributor

@vercel vercel bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional Suggestion:

filterActiveTools strips the auto-injected __load_tool_schema__ tool when user specifies activeTools, silently breaking lazy tool discovery.

Fix on Vercel

filterActiveTools now always keeps the __load_tool_schema__ tool when
present, preventing lazy tool discovery from being silently broken
when users specify activeTools.

Ref: vercel#13423
@fkesheh
Copy link
Copy Markdown
Author

fkesheh commented Apr 6, 2026

Alternative approach: modelInputSchema for user-land discovery harness

After implementing the baked-in approach above, I explored a lighter alternative that gives users full control without adding SDK-specific discovery logic.

The gap

Users can already:

  • Add synthetic tools to the tools map (their own discovery tool)
  • Use prepareStep to change activeTools, toolChoice, model, messages per step
  • Use toolCallRepair to fix malformed tool calls

The one missing piece: users can't send a different schema to the LLM than the one used for validation. prepareTools always uses inputSchema for both.

Proposed: modelInputSchema

Add an optional modelInputSchema field to the Tool type. When present, prepareTools sends it to the LLM instead of inputSchema. Validation still uses inputSchema.

const myLazyTool = tool({
  description: 'Create entity type',
  inputSchema: z.object({ slug: z.string(), labels: z.object({...}), fields: z.array(...) }),
  modelInputSchema: z.object({}), // minimal schema sent to LLM
  execute: async (input) => { ... },
});

const discoverTools = tool({
  description: 'Get schema for tools',
  inputSchema: z.object({ toolNames: z.array(z.string()) }),
  execute: async ({ toolNames }) => { /* return schemas */ },
});

streamText({
  tools: { create_entity_type: myLazyTool, discover_tools: discoverTools },
  toolCallRepair: async ({ toolCall, tools, error }) => {
    // user-land: repair string→object mismatches for lazy tools
  },
});

Trade-offs

Aspect Current PR (baked-in) modelInputSchema alternative
SDK changes ~8 files, new synthetic tool, auto-repair 2 files: Tool type + prepareTools
User effort Zero — just add lazy: true User builds discovery tool + repair
Flexibility Opinionated flow Users own the entire harness
Maintenance More SDK surface to maintain Minimal SDK surface

Both approaches could coexist — modelInputSchema as the primitive, lazy: true as the batteries-included convenience built on top.

Happy to implement modelInputSchema as a separate PR if the team prefers the composable-primitives approach.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai/core core functions like generateText, streamText, etc. Provider utils, and provider spec. feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: Lazy Tool Discovery

2 participants