feat(ai/core): lazy tool discovery for token-efficient large tool sets#14147
feat(ai/core): lazy tool discovery for token-efficient large tool sets#14147fkesheh wants to merge 7 commits intovercel:mainfrom
Conversation
… 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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
very good catch, I will add that
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
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
Alternative approach:
|
| 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.
Summary
Implements lazy tool discovery as requested in #13423. Tools marked with
lazy: truesend only their name and description to the LLM — the fullinputSchemais 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:
{ type: 'object', properties: {} }) instead of their full inputSchema__load_tool_schema__to get the full schema as a tool result (text)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
__load_tool_schema__toolskillfield — optional extended docs loaded on demand for lazy toolsKey implementation details
prepareTools: lazy tools emit minimal empty schema with instruction to load schema firstparseToolCall: 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 bothgenerateTextandstreamTextfilterActiveTools: preserves__load_tool_schema__whenactiveToolsfiltering is usedChanges
@ai-sdk/provider-utils: Addlazy?: booleanandskill?: stringtoTooltypeai: Newload-tool-schema.ts— synthetic tool factory +buildEffectiveToolshelperai:prepareToolsemits minimal empty schema for lazy toolsai:parseToolCallauto-repairs lazy tool inputs (JSON.parse strings that should be objects/arrays)ai:filterActiveToolspreserves__load_tool_schema__through activeTools filteringai: Auto-inject__load_tool_schema__when lazy tools exist (overridable)Test plan
createLoadToolSchemaTool(schema return, skill, inputExamples, error entries)buildEffectiveTools(no-op without lazy, injection, user override)prepareTools(minimal schema for lazy, mixed lazy/eager)Closes #13423
🤖 Generated with Claude Code