diff --git a/.changeset/code-mode-lazy-tools.md b/.changeset/code-mode-lazy-tools.md new file mode 100644 index 000000000..91cd82c43 --- /dev/null +++ b/.changeset/code-mode-lazy-tools.md @@ -0,0 +1,6 @@ +--- +'@tanstack/ai': minor +'@tanstack/ai-code-mode': minor +--- + +Add lazy tool support (progressive disclosure) to Code Mode. Tools marked `lazy: true` are kept out of the `execute_typescript` system prompt and listed in a discoverable catalog; the model fetches their TypeScript signatures on demand via a new `discover_tools` tool. A shared optional `lazyToolsConfig` (`includeDescription: 'none' | 'first-sentence' | 'full'`) tunes the catalog detail for both `chat()` and `createCodeMode()`. `createCodeMode` now also returns `discoveryTool` and a `tools` array (backward compatible — `tool` and `systemPrompt` are unchanged). diff --git a/docs/code-mode/lazy-tools.md b/docs/code-mode/lazy-tools.md new file mode 100644 index 000000000..c01ae097f --- /dev/null +++ b/docs/code-mode/lazy-tools.md @@ -0,0 +1,190 @@ +--- +title: Lazy Tools +id: lazy-tools +order: 5 +description: "Keep large tool catalogs out of the Code Mode system prompt with lazy tools — the model fetches TypeScript signatures on demand via a discover_tools call." +keywords: + - tanstack ai + - code mode + - lazy tools + - discover_tools + - progressive disclosure + - prompt size + - tool catalog +--- + +Large tool catalogs bloat the `execute_typescript` system prompt. Every tool you pass to `createCodeMode` becomes a full TypeScript type stub in that prompt — and at 50+ tools, those stubs can push the effective prompt into the tens of thousands of tokens before the model has even seen your user message. + +Lazy tools fix this with **progressive disclosure**: mark rarely-used tools `lazy: true` and they are withheld from the initial system prompt. The model sees only their names in a short "Discoverable APIs" catalog. When it needs one, it calls the `discover_tools` sibling tool to fetch the TypeScript signature on demand, then uses it inside `execute_typescript`. All sandbox bindings are always injected — lazy only defers _documentation_, not callability. + +## Marking a Tool Lazy + +Add `lazy: true` to the `toolDefinition` config for any tool you want to defer: + +```typescript +import { toolDefinition } from "@tanstack/ai"; +import { z } from "zod"; + +// Always eager — documented upfront +const fetchWeather = toolDefinition({ + name: "fetchWeather", + description: "Get current weather for a city", + inputSchema: z.object({ location: z.string() }), + outputSchema: z.object({ temperature: z.number(), condition: z.string() }), +}).server(async ({ location }) => { + const res = await fetch(`https://api.weather.example/v1?city=${location}`); + return res.json(); +}); + +// Lazy — kept out of the system prompt until discovered +const fetchArchive = toolDefinition({ + name: "fetchArchive", + description: "Retrieve historical weather archive data for a date range", + inputSchema: z.object({ + location: z.string(), + from: z.string(), + to: z.string(), + }), + outputSchema: z.array(z.object({ date: z.string(), temperature: z.number() })), + lazy: true, +}).server(async ({ location, from, to }) => { + const res = await fetch( + `https://api.weather.example/v1/archive?city=${location}&from=${from}&to=${to}` + ); + return res.json(); +}); +``` + +Eager tools continue to receive full type stubs in the system prompt. Lazy tools appear only by name. + +## Server Setup + +Pass both eager and lazy tools to `createCodeMode`. When at least one tool is lazy, `createCodeMode` also returns a `discover_tools` sibling tool — include it in the `tools` array you pass to `chat()`: + +```typescript +// server/route.ts +import { chat, maxIterations, toServerSentEventsStream } from "@tanstack/ai"; +import { createCodeMode } from "@tanstack/ai-code-mode"; +import { createNodeIsolateDriver } from "@tanstack/ai-isolate-node"; +import { openaiText } from "@tanstack/ai-openai"; + +const { tools, systemPrompt } = createCodeMode({ + driver: createNodeIsolateDriver(), + tools: [fetchWeather, fetchArchive], // fetchArchive is lazy +}); + +// tools is [execute_typescript, discover_tools] +// — discover_tools is included automatically because fetchArchive is lazy + +export async function POST(req: Request) { + const { messages } = await req.json(); + + const stream = chat({ + adapter: openaiText("gpt-5.5"), + systemPrompts: ["You are a helpful weather assistant.", systemPrompt], + tools: [...tools], + messages, + agentLoopStrategy: maxIterations(10), + }); + + return toServerSentEventsStream(stream); +} +``` + +`createCodeMode` returns `{ tool, discoveryTool, tools, systemPrompt }`: + +| Field | Type | Description | +|-------|------|-------------| +| `tool` | `ServerTool` | The `execute_typescript` tool (backward compatible) | +| `discoveryTool` | `ServerTool \| null` | The `discover_tools` tool, or `null` when there are no lazy tools | +| `tools` | `Array` | `[tool]` or `[tool, discoveryTool]` — spread into `chat({ tools })` | +| `systemPrompt` | `string` | The matching system prompt | + +If no tools are lazy, `discoveryTool` is `null` and `tools` contains only `execute_typescript`. + +## The `discover_tools` Flow + +When the model encounters a task that requires a lazy tool, it: + +1. Calls `discover_tools` with the tool name (bare name, no `external_` prefix). +2. Receives the TypeScript type stub and description for that tool. +3. Writes `execute_typescript` code using the now-documented `external_fetchArchive(...)` call. + +The bindings are always injected into the sandbox — discovering a tool only retrieves documentation, it does not enable the binding. The model could call `external_fetchArchive` without discovering it first, but it would be writing blind without the type signature. + +## Tuning the Discoverable APIs Catalog + +By default, lazy tools appear in the system prompt as bare names with no description: + +```text +### Discoverable APIs + +- external_fetchArchive +- external_runReport +- external_exportData +``` + +If you want the model to have a hint about what each tool does before deciding whether to discover it, use `lazyToolsConfig.includeDescription`: + +```typescript +const { tools, systemPrompt } = createCodeMode({ + driver: createNodeIsolateDriver(), + tools: [fetchWeather, fetchArchive, runReport, exportData], + lazyToolsConfig: { + includeDescription: "first-sentence", // 'none' | 'first-sentence' | 'full' + }, +}); +``` + +With `'first-sentence'` the catalog becomes: + +```text +### Discoverable APIs + +- external_fetchArchive — Retrieve historical weather archive data for a date range. +- external_runReport — Generate a summary report for a given time period. +- external_exportData — Export query results to CSV or JSON format. +``` + +| Value | Effect | +|-------|--------| +| `'none'` (default) | Bare names only — smallest possible prompt addition | +| `'first-sentence'` | Name plus the first sentence of the tool's description | +| `'full'` | Name plus the complete description | + +The full type stub and input/output schema are always returned on discovery — `includeDescription` only affects the pre-discovery catalog. + +## Lazy Tools with Plain `chat()` + +The same `lazyToolsConfig` option works for lazy tools used directly with `chat()`, outside of Code Mode. Tools marked `lazy: true` are withheld from the `__lazy__tool__discovery__` catalog description until the model calls for them. Pass `lazyToolsConfig` directly to `chat()`: + +```typescript +import { chat, maxIterations } from "@tanstack/ai"; +import { openaiText } from "@tanstack/ai-openai"; + +// Non-code-mode: lazy tools in a regular chat agent +const stream = chat({ + adapter: openaiText("gpt-5.5"), + messages, + tools: [fetchWeather, fetchArchive, runReport], + lazyToolsConfig: { + includeDescription: "first-sentence", + }, + agentLoopStrategy: maxIterations(10), +}); +``` + +The `includeDescription` behavior is identical — `'none'` lists bare tool names, `'first-sentence'` appends the first sentence, `'full'` appends the complete description. + +## Tips + +- **Start with `'none'`.** The bare-names catalog is enough for models that reason well about tool names. Add `'first-sentence'` only if the model frequently discovers irrelevant tools. +- **Lazy tools are always callable.** Their `external_*` bindings are injected into the sandbox regardless of whether the model has called `discover_tools`. Discovery only reveals documentation. +- **Use `discoveryTool` for observability.** You can inspect `discoveryTool.name` (`"discover_tools"`) to confirm the tool is wired up, or log its calls for analytics. +- **Partition by frequency, not capability.** Mark tools lazy when they are rarely needed for a typical request. Core tools that most requests use should stay eager. + +## Next Steps + +- [Code Mode](./code-mode) — Core Code Mode setup and API reference +- [Code Mode with Skills](./code-mode-with-skills) — Persistent reusable skill libraries +- [Isolate Drivers](./code-mode-isolates) — Compare Node, QuickJS, and Cloudflare sandbox runtimes diff --git a/docs/config.json b/docs/config.json index f5552703f..528803c24 100644 --- a/docs/config.json +++ b/docs/config.json @@ -102,7 +102,8 @@ { "label": "Lazy Tool Discovery", "to": "tools/lazy-tool-discovery", - "addedAt": "2026-04-15" + "addedAt": "2026-04-15", + "updatedAt": "2026-06-08" } ] }, @@ -213,6 +214,11 @@ "label": "Code Mode Isolate Drivers", "to": "code-mode/code-mode-isolates", "addedAt": "2026-04-15" + }, + { + "label": "Lazy Tools", + "to": "code-mode/lazy-tools", + "addedAt": "2026-06-08" } ] }, diff --git a/docs/tools/lazy-tool-discovery.md b/docs/tools/lazy-tool-discovery.md index 70625dfbc..6fc785110 100644 --- a/docs/tools/lazy-tool-discovery.md +++ b/docs/tools/lazy-tool-discovery.md @@ -82,7 +82,7 @@ import { chat, toServerSentEventsResponse } from "@tanstack/ai"; import { openaiText } from "@tanstack/ai-openai"; const stream = chat({ - adapter: openaiText("gpt-4o"), + adapter: openaiText("gpt-5.5"), messages, tools: [ getProducts, // Normal tool — sent to LLM immediately @@ -94,6 +94,40 @@ const stream = chat({ return toServerSentEventsResponse(stream); ``` +## Controlling the Discovery Catalog + +By default, the `__lazy__tool__discovery__` tool's description lists only the +**names** of available lazy tools. The optional `lazyToolsConfig` on `chat()` +controls how much of each lazy tool's description appears in that pre-discovery +catalog: + +```typescript +const stream = chat({ + adapter: openaiText("gpt-5.5"), + messages, + tools: [getProducts, searchProducts, compareProducts], + lazyToolsConfig: { + // 'none' (default) | 'first-sentence' | 'full' + includeDescription: "first-sentence", + }, +}); +``` + +| `includeDescription` | Catalog entry for `searchProducts` | +| -------------------- | ----------------------------------------------- | +| `'none'` (default) | `searchProducts` | +| `'first-sentence'` | `searchProducts — Search products by keyword.` | +| `'full'` | `searchProducts — ` | + +This only affects the **pre-discovery** catalog. Regardless of the setting, the +discovery tool's result always returns each tool's full description and argument +schema — `includeDescription` just tunes how much the LLM sees before it +decides what to discover. The default `'none'` keeps the catalog as lean as +possible. + +`lazyToolsConfig` is optional and the same option is accepted by Code Mode's +`createCodeMode()` — see [Code Mode Lazy Tools](../code-mode/lazy-tools). + ## When to Use Lazy Tools Lazy tools are useful when: @@ -208,7 +242,7 @@ export async function POST(request: Request) { const { messages } = await request.json(); const stream = chat({ - adapter: openaiText("gpt-4o"), + adapter: openaiText("gpt-5.5"), messages, tools: [getProducts, compareProducts, calculateFinancing], agentLoopStrategy: maxIterations(20), diff --git a/packages/ai-code-mode/skills/ai-code-mode/SKILL.md b/packages/ai-code-mode/skills/ai-code-mode/SKILL.md index c769d5a94..09e1a1945 100644 --- a/packages/ai-code-mode/skills/ai-code-mode/SKILL.md +++ b/packages/ai-code-mode/skills/ai-code-mode/SKILL.md @@ -15,6 +15,7 @@ sources: - 'TanStack/ai:docs/code-mode/code-mode-isolates.md' - 'TanStack/ai:docs/code-mode/code-mode-with-skills.md' - 'TanStack/ai:docs/code-mode/client-integration.md' + - 'TanStack/ai:docs/code-mode/lazy-tools.md' --- > **Note**: This skill requires familiarity with ai-core and ai-core/chat-experience. Code Mode is always used on top of a chat experience. @@ -328,6 +329,84 @@ Skill-specific events (when using `codeModeWithSkills`): | `code_mode:skill_error` | Skill failed | `skill`, `error`, `duration` | | `skill:registered` | New skill saved | `id`, `name`, `description` | +### 4. Lazy Tools + +When a large tool catalog would bloat the `execute_typescript` system prompt, mark low-priority tools `lazy: true`. Lazy tools are kept out of the full type-stub documentation and listed in a compact "Discoverable APIs" catalog instead. All sandbox bindings are always injected — `lazy` defers documentation, not callability. + +**Marking a tool lazy:** + +```typescript +import { toolDefinition } from '@tanstack/ai' +import { z } from 'zod' + +const rarelyUsedTool = toolDefinition({ + name: 'fetchStocks', + description: 'Get stock prices for a ticker. Returns a price quote.', + inputSchema: z.object({ ticker: z.string() }), + outputSchema: z.object({ price: z.number() }), + lazy: true, // <-- opt out of full system-prompt documentation +}).server(async ({ ticker }) => { + // ... + return { price: 0 } +}) +``` + +**`createCodeMode` return shape:** + +`createCodeMode()` returns `{ tool, discoveryTool, tools, systemPrompt }`. When lazy tools are present `discoveryTool` is a `discover_tools` server tool; otherwise it is `null`. Always spread `tools` (not just `tool`) into `chat()` so the discovery tool is registered: + +```typescript +import { chat } from '@tanstack/ai' +import { createCodeMode } from '@tanstack/ai-code-mode' +import { createNodeIsolateDriver } from '@tanstack/ai-isolate-node' +import { openaiText } from '@tanstack/ai-openai' + +const { tools, systemPrompt } = createCodeMode({ + driver: createNodeIsolateDriver(), + tools: [eagerTool, rarelyUsedTool], // rarelyUsedTool has lazy: true +}) + +const stream = chat({ + adapter: openaiText('gpt-5.5'), + systemPrompts: ['You are a helpful assistant.', systemPrompt], + tools: [...tools, ...otherTools], // spread tools, not just tool + messages, +}) +``` + +`tools` equals `[tool]` when there are no lazy tools (backward compatible) and `[tool, discoveryTool]` when lazy tools exist. + +**`discover_tools` flow:** + +When the model encounters a lazy tool it has not seen before, it calls `discover_tools` with the bare name (no `external_` prefix). The tool returns each requested tool's TypeScript type stub and description. The model then writes correctly-typed `external_` calls inside `execute_typescript`. + +```text +Model sees: "Discoverable APIs: external_fetchStocks" +Model calls: discover_tools({ toolNames: ["fetchStocks"] }) +Response: { tools: [{ name: "external_fetchStocks", description: "...", typeStub: "declare function external_fetchStocks(...)" }] } +Model writes inside execute_typescript: const result = await external_fetchStocks({ ticker: "AAPL" }) +``` + +**`lazyToolsConfig.includeDescription`:** + +Control how much of each lazy tool's description appears in the Discoverable APIs catalog (the pre-discovery list): + +| Value | Catalog entry | +| ------------------ | ----------------------------------------------------------------- | +| `'none'` | `external_fetchStocks` (name only — default) | +| `'first-sentence'` | `external_fetchStocks — Get stock prices.` | +| `'full'` | `external_fetchStocks — Get stock prices. Returns a price quote.` | + +```typescript +const { tools, systemPrompt } = createCodeMode({ + driver: createNodeIsolateDriver(), + tools: [eagerTool, rarelyUsedTool], + lazyToolsConfig: { includeDescription: 'first-sentence' }, +}) +``` + +The same `lazyToolsConfig` option is accepted by plain `chat()` for its own lazy-tool discovery catalog (see `ai-core/tool-calling/SKILL.md`). + ## Common Mistakes ### CRITICAL: Passing API keys or secrets to the sandbox environment diff --git a/packages/ai-code-mode/src/create-code-mode-tool.ts b/packages/ai-code-mode/src/create-code-mode-tool.ts index 52180d6a0..5f0584dc8 100644 --- a/packages/ai-code-mode/src/create-code-mode-tool.ts +++ b/packages/ai-code-mode/src/create-code-mode-tool.ts @@ -243,11 +243,17 @@ export function createCodeModeTool( * Build the tool description including available external functions */ function buildToolDescription(tools: Array): string { - const externalFunctions = tools.map((t) => `external_${t.name}`).join(', ') + const eager = tools.filter((t) => !t.lazy) + const hasLazy = tools.some((t) => t.lazy) + const externalFunctions = eager.map((t) => `external_${t.name}`).join(', ') + + const discoverable = hasLazy + ? ` Additional functions can be discovered via the discover_tools tool.` + : '' return ( `Execute TypeScript code in a secure sandbox environment. ` + - `The code can use these external API functions: ${externalFunctions}. ` + + `The code can use these external API functions: ${externalFunctions}.${discoverable} ` + `All external_* calls are async and must be awaited. ` + `Return a value to pass results back. Use console.log() for debugging.` ) diff --git a/packages/ai-code-mode/src/create-code-mode.ts b/packages/ai-code-mode/src/create-code-mode.ts index 0c058ae3f..99c4a1fe8 100644 --- a/packages/ai-code-mode/src/create-code-mode.ts +++ b/packages/ai-code-mode/src/create-code-mode.ts @@ -1,35 +1,45 @@ import { createCodeModeTool } from './create-code-mode-tool' import { createCodeModeSystemPrompt } from './create-system-prompt' -import type { CodeModeToolConfig } from './types' +import { createDiscoveryTool } from './create-discovery-tool' +import type { CodeModeToolConfig, CreateCodeModeResult } from './types' /** - * Create both the `execute_typescript` tool and its matching system prompt - * from a single config object. - * - * This is the recommended way to set up Code Mode — it ensures the tool and - * system prompt always stay in sync. + * Create the `execute_typescript` tool, its matching system prompt, and (when + * any tools are marked `lazy: true`) a `discover_tools` companion tool. * * @example * ```typescript * import { createCodeMode } from '@tanstack/ai-code-mode' * import { createNodeIsolateDriver } from '@tanstack/ai-isolate-node' * - * const { tool, systemPrompt } = createCodeMode({ + * const { tools, systemPrompt } = createCodeMode({ * driver: createNodeIsolateDriver(), - * tools: [weatherTool, dbTool], - * timeout: 30000, + * tools: [weatherTool, rarelyUsedTool], // mark rarelyUsedTool lazy: true * }) * * chat({ * systemPrompts: [myPrompt, systemPrompt], - * tools: [tool, ...otherTools], + * tools: [...tools, ...otherTools], * messages, * }) * ``` */ -export function createCodeMode(config: CodeModeToolConfig) { +export function createCodeMode( + config: CodeModeToolConfig, +): CreateCodeModeResult { + const tool = createCodeModeTool(config) + const systemPrompt = createCodeModeSystemPrompt(config) + + const lazyTools = config.tools.filter((t) => t.lazy) + const discoveryTool = + lazyTools.length > 0 + ? createDiscoveryTool(lazyTools, config.lazyToolsConfig) + : null + return { - tool: createCodeModeTool(config), - systemPrompt: createCodeModeSystemPrompt(config), + tool, + discoveryTool, + tools: discoveryTool ? [tool, discoveryTool] : [tool], + systemPrompt, } } diff --git a/packages/ai-code-mode/src/create-discovery-tool.ts b/packages/ai-code-mode/src/create-discovery-tool.ts new file mode 100644 index 000000000..1b4a0b1ea --- /dev/null +++ b/packages/ai-code-mode/src/create-discovery-tool.ts @@ -0,0 +1,108 @@ +import { z } from 'zod' +import { renderLazyCatalogEntry, toolDefinition } from '@tanstack/ai' +import { toolToBinding } from './bindings/tool-to-binding' +import { generateTypeStubs } from './type-generator/json-schema-to-ts' +import type { LazyToolsConfig, ServerTool } from '@tanstack/ai' +import type { CodeModeTool } from './types' + +const discoverInputSchema = z.object({ + toolNames: z + .array(z.string()) + .describe( + 'Names of tools to discover, exactly as shown in the Discoverable APIs ' + + 'catalog. The external_ prefix is optional — both "external_fetchStocks" ' + + 'and "fetchStocks" resolve.', + ), +}) + +const discoverOutputSchema = z.object({ + tools: z.array( + z.object({ + name: z + .string() + .describe('The sandbox function name, e.g. external_fetchStocks'), + description: z.string(), + typeStub: z.string().describe('TypeScript declaration for the function'), + }), + ), + errors: z.array(z.string()).optional(), +}) + +const EXTERNAL_PREFIX = 'external_' + +/** + * Strip a single leading `external_` prefix so the model can pass either the + * catalog name (`external_fetchStocks`) or the bare name (`fetchStocks`). + */ +function stripExternalPrefix(name: string): string { + return name.startsWith(EXTERNAL_PREFIX) + ? name.slice(EXTERNAL_PREFIX.length) + : name +} + +/** + * Build the `discover_tools` sibling tool for Code Mode lazy tools. The model + * calls it with lazy tool names and receives each one's TypeScript type stub + + * description, which it can then use to write correctly-typed `external_*` + * calls inside `execute_typescript`. The bindings themselves are always present + * in the sandbox — this only reveals documentation. + * + * Tools are catalogued in `external_` form to match the "Discoverable + * APIs" section of the Code Mode system prompt; lookups tolerate either form. + * `lazyToolsConfig.includeDescription` controls how much of each tool's + * description appears in this tool's own catalog (mirroring the system prompt). + */ +export function createDiscoveryTool( + lazyTools: Array, + lazyToolsConfig?: LazyToolsConfig, +): ServerTool< + typeof discoverInputSchema, + typeof discoverOutputSchema, + 'discover_tools' +> { + const lazyMap = new Map(lazyTools.map((t) => [t.name, t])) + const include = lazyToolsConfig?.includeDescription ?? 'none' + const catalog = lazyTools + .map((t) => + renderLazyCatalogEntry( + `${EXTERNAL_PREFIX}${t.name}`, + t.description, + include, + ), + ) + .join(', ') + + return toolDefinition({ + name: 'discover_tools' as const, + description: + `Discover full TypeScript signatures for additional sandbox APIs before ` + + `using them inside execute_typescript. Discoverable tools: [${catalog}]. ` + + `Pass the names exactly as shown (the external_ prefix is optional).`, + inputSchema: discoverInputSchema, + outputSchema: discoverOutputSchema, + }).server(async ({ toolNames }) => { + const tools: Array<{ + name: string + description: string + typeStub: string + }> = [] + const errors: Array = [] + + for (const name of toolNames) { + const tool = lazyMap.get(stripExternalPrefix(name)) + if (!tool) { + errors.push(`Unknown tool: '${name}'. Discoverable tools: [${catalog}]`) + continue + } + const binding = toolToBinding(tool, EXTERNAL_PREFIX) + const typeStub = generateTypeStubs({ [binding.name]: binding }) + tools.push({ + name: binding.name, + description: tool.description, + typeStub, + }) + } + + return errors.length > 0 ? { tools, errors } : { tools } + }) +} diff --git a/packages/ai-code-mode/src/create-system-prompt.ts b/packages/ai-code-mode/src/create-system-prompt.ts index 3ae65263d..9b6a17d83 100644 --- a/packages/ai-code-mode/src/create-system-prompt.ts +++ b/packages/ai-code-mode/src/create-system-prompt.ts @@ -1,3 +1,4 @@ +import { renderLazyCatalogEntry } from '@tanstack/ai' import { toolsToBindings } from './bindings/tool-to-binding' import { generateTypeStubs } from './type-generator/json-schema-to-ts' import type { CodeModeToolConfig } from './types' @@ -26,21 +27,35 @@ import type { CodeModeToolConfig } from './types' */ export function createCodeModeSystemPrompt(config: CodeModeToolConfig): string { const { tools } = config + const include = config.lazyToolsConfig?.includeDescription ?? 'none' - // Transform tools to bindings with external_ prefix to generate correct type stubs - const bindings = toolsToBindings(tools, 'external_') + const eagerTools = tools.filter((t) => !t.lazy) + const lazyTools = tools.filter((t) => t.lazy) - // Generate TypeScript type stubs for the external functions + // Only eager tools get full type stubs + doc lines. + const bindings = toolsToBindings(eagerTools, 'external_') const typeStubs = generateTypeStubs(bindings) - // Build function documentation const functionDocs = Object.entries(bindings) - .map(([name, binding]) => { - const doc = `- \`${name}(input)\`: ${binding.description}` - return doc - }) + .map(([name, binding]) => `- \`${name}(input)\`: ${binding.description}`) .join('\n') + const discoverableSection = + lazyTools.length > 0 + ? ` + +### Discoverable APIs + +These additional functions are available but not yet documented. Before calling \`external_\` for any of them inside \`execute_typescript\`, call the \`discover_tools\` tool with their names to get full TypeScript signatures: + +${lazyTools + .map( + (t) => + `- ${renderLazyCatalogEntry(`external_${t.name}`, t.description, include)}`, + ) + .join('\n')}` + : '' + return `## Code Execution Tool You have access to \`execute_typescript\` which runs TypeScript code in a sandboxed environment. @@ -65,7 +80,7 @@ ${functionDocs} \`\`\`typescript ${typeStubs} -\`\`\` +\`\`\`${discoverableSection} ### Example @@ -77,7 +92,7 @@ const results = await Promise.all( ); // Find the warmest city -const warmest = results.reduce((prev, curr) => +const warmest = results.reduce((prev, curr) => curr.temperature > prev.temperature ? curr : prev ); diff --git a/packages/ai-code-mode/src/index.ts b/packages/ai-code-mode/src/index.ts index c0794a89f..553666bc0 100644 --- a/packages/ai-code-mode/src/index.ts +++ b/packages/ai-code-mode/src/index.ts @@ -7,6 +7,7 @@ export type { export { createCodeModeSystemPrompt } from './create-system-prompt' export { createCodeMode } from './create-code-mode' +export { createDiscoveryTool } from './create-discovery-tool' export { InMemoryAgentStore, @@ -39,6 +40,7 @@ export { wrapCode } from './code-wrapper' export type { // Tool-based API types CodeModeToolConfig, + CreateCodeModeResult, CodeModeToolResult, // Isolate driver interfaces (used by driver packages) IsolateDriver, diff --git a/packages/ai-code-mode/src/types.ts b/packages/ai-code-mode/src/types.ts index a1a0b25cd..2910c7417 100644 --- a/packages/ai-code-mode/src/types.ts +++ b/packages/ai-code-mode/src/types.ts @@ -1,4 +1,5 @@ import type { + LazyToolsConfig, SchemaInput, ServerTool, ToolExecutionContext, @@ -195,6 +196,14 @@ export interface CodeModeToolConfig { * ``` */ getSkillBindings?: () => Promise> + + /** + * Optional lazy-tool discovery config. Tools marked `lazy: true` are kept out + * of the system prompt's full documentation and listed in a Discoverable APIs + * catalog instead; this tunes how much of each lazy tool's description that + * catalog shows. Optional — defaults to `{ includeDescription: 'none' }`. + */ + lazyToolsConfig?: LazyToolsConfig } /** @@ -227,3 +236,19 @@ export interface CodeModeToolResult { } | undefined } + +/** + * Return shape of `createCodeMode`. `tool` (execute_typescript) and + * `systemPrompt` are preserved for backward compatibility; `discoveryTool` and + * `tools` are additive. Spread `tools` into `chat({ tools })`. + */ +export interface CreateCodeModeResult { + /** The execute_typescript tool. */ + tool: ServerTool + /** The discover_tools tool, or null when there are no lazy tools. */ + discoveryTool: ServerTool | null + /** [tool] or [tool, discoveryTool] — the array to spread into chat({ tools }). */ + tools: Array> + /** The matching system prompt. */ + systemPrompt: string +} diff --git a/packages/ai-code-mode/tests/create-code-mode.test.ts b/packages/ai-code-mode/tests/create-code-mode.test.ts index 78e245922..1c8d09627 100644 --- a/packages/ai-code-mode/tests/create-code-mode.test.ts +++ b/packages/ai-code-mode/tests/create-code-mode.test.ts @@ -64,3 +64,49 @@ describe('createCodeMode', () => { ).toThrow('At least one tool must be provided') }) }) + +const driverStub = { + createContext: async () => ({ + execute: async () => ({ success: true }), + dispose: async () => {}, + }), +} + +const eager = toolDefinition({ + name: 'fetchWeather', + description: 'Get weather.', + inputSchema: z.object({ city: z.string() }), +}).server(async () => ({})) + +const lazy = toolDefinition({ + name: 'fetchStocks', + description: 'Get stocks.', + inputSchema: z.object({ ticker: z.string() }), + lazy: true, +}).server(async () => ({})) + +describe('createCodeMode — return shape', () => { + it('returns discoveryTool: null and tools: [tool] when there are no lazy tools', () => { + const r = createCodeMode({ driver: driverStub, tools: [eager] }) + expect(r.tool.name).toBe('execute_typescript') + expect(r.discoveryTool).toBeNull() + expect(r.tools).toHaveLength(1) + expect(r.tools[0]!.name).toBe('execute_typescript') + }) + + it('returns a discover_tools tool and includes it in tools when lazy tools exist', () => { + const r = createCodeMode({ driver: driverStub, tools: [eager, lazy] }) + expect(r.tool.name).toBe('execute_typescript') + expect(r.discoveryTool).not.toBeNull() + expect(r.discoveryTool!.name).toBe('discover_tools') + expect(r.tools.map((t) => t.name)).toEqual([ + 'execute_typescript', + 'discover_tools', + ]) + }) + + it('keeps the backward-compatible systemPrompt field', () => { + const r = createCodeMode({ driver: driverStub, tools: [eager, lazy] }) + expect(r.systemPrompt).toContain('Code Execution Tool') + }) +}) diff --git a/packages/ai-code-mode/tests/create-discovery-tool.test.ts b/packages/ai-code-mode/tests/create-discovery-tool.test.ts new file mode 100644 index 000000000..872edb6da --- /dev/null +++ b/packages/ai-code-mode/tests/create-discovery-tool.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import { toolDefinition } from '@tanstack/ai' +import { createDiscoveryTool } from '../src/create-discovery-tool' + +const lazyA = toolDefinition({ + name: 'fetchStocks', + description: 'Get stock prices.', + inputSchema: z.object({ ticker: z.string() }), + outputSchema: z.object({ price: z.number() }), + lazy: true, +}).server(async () => ({ price: 0 })) + +describe('createDiscoveryTool', () => { + it('names the tool discover_tools and lists discoverable names in its description', () => { + const tool = createDiscoveryTool([lazyA]) + expect(tool.name).toBe('discover_tools') + expect(tool.description).toContain('fetchStocks') + }) + + it('returns a TypeScript type stub + description for a known lazy tool', async () => { + const tool = createDiscoveryTool([lazyA]) + const result = await tool.execute!({ toolNames: ['fetchStocks'] }) + expect(result.tools).toHaveLength(1) + expect(result.tools[0]!.name).toBe('external_fetchStocks') + expect(result.tools[0]!.description).toBe('Get stock prices.') + expect(result.tools[0]!.typeStub).toContain( + 'declare function external_fetchStocks', + ) + expect(result.errors).toBeUndefined() + }) + + it('returns an error entry for an unknown name', async () => { + const tool = createDiscoveryTool([lazyA]) + const result = await tool.execute!({ toolNames: ['nope'] }) + expect(result.tools).toHaveLength(0) + expect(result.errors?.[0]).toContain("Unknown tool: 'nope'") + expect(result.errors?.[0]).toContain('fetchStocks') + }) + + it('resolves names passed with the external_ prefix (the catalog form)', async () => { + const tool = createDiscoveryTool([lazyA]) + const result = await tool.execute!({ toolNames: ['external_fetchStocks'] }) + expect(result.tools).toHaveLength(1) + expect(result.tools[0]!.name).toBe('external_fetchStocks') + expect(result.errors).toBeUndefined() + }) + + it('honors lazyToolsConfig.includeDescription in its own catalog', () => { + const namesOnly = createDiscoveryTool([lazyA]) + expect(namesOnly.description).toContain('external_fetchStocks') + expect(namesOnly.description).not.toContain( + 'external_fetchStocks — Get stock prices.', + ) + + const withDesc = createDiscoveryTool([lazyA], { + includeDescription: 'first-sentence', + }) + expect(withDesc.description).toContain( + 'external_fetchStocks — Get stock prices.', + ) + }) +}) diff --git a/packages/ai-code-mode/tests/create-system-prompt.test.ts b/packages/ai-code-mode/tests/create-system-prompt.test.ts index f6898ac50..2d6c6d9c6 100644 --- a/packages/ai-code-mode/tests/create-system-prompt.test.ts +++ b/packages/ai-code-mode/tests/create-system-prompt.test.ts @@ -72,3 +72,71 @@ describe('createCodeModeSystemPrompt', () => { expect(prompt).toContain('Search the database') }) }) + +const eagerTool = toolDefinition({ + name: 'fetchWeather', + description: 'Get current weather. Returns temperature.', + inputSchema: z.object({ city: z.string() }), + outputSchema: z.object({ temp: z.number() }), +}).server(async () => ({ temp: 0 })) + +const lazyTool = toolDefinition({ + name: 'fetchStocks', + description: 'Get stock prices. Returns quotes.', + inputSchema: z.object({ ticker: z.string() }), + outputSchema: z.object({ price: z.number() }), + lazy: true, +}).server(async () => ({ price: 0 })) + +const driverStub = { + createContext: async () => ({ + execute: async () => ({ success: true }), + dispose: async () => {}, + }), +} + +describe('createCodeModeSystemPrompt — lazy tools', () => { + it('documents eager tools with full type stubs', () => { + const prompt = createCodeModeSystemPrompt({ + driver: driverStub, + tools: [eagerTool, lazyTool], + }) + expect(prompt).toContain('declare function external_fetchWeather') + }) + + it('does NOT emit type stubs for lazy tools', () => { + const prompt = createCodeModeSystemPrompt({ + driver: driverStub, + tools: [eagerTool, lazyTool], + }) + expect(prompt).not.toContain('declare function external_fetchStocks') + }) + + it('lists lazy tools in a Discoverable APIs section (names only by default)', () => { + const prompt = createCodeModeSystemPrompt({ + driver: driverStub, + tools: [eagerTool, lazyTool], + }) + expect(prompt).toContain('Discoverable APIs') + expect(prompt).toContain('discover_tools') + expect(prompt).toContain('external_fetchStocks') + expect(prompt).not.toContain('external_fetchStocks — Get stock prices.') + }) + + it('includes first sentences in the catalog when configured', () => { + const prompt = createCodeModeSystemPrompt({ + driver: driverStub, + tools: [eagerTool, lazyTool], + lazyToolsConfig: { includeDescription: 'first-sentence' }, + }) + expect(prompt).toContain('external_fetchStocks — Get stock prices.') + }) + + it('omits the Discoverable APIs section when there are no lazy tools', () => { + const prompt = createCodeModeSystemPrompt({ + driver: driverStub, + tools: [eagerTool], + }) + expect(prompt).not.toContain('Discoverable APIs') + }) +}) diff --git a/packages/ai/skills/ai-core/tool-calling/SKILL.md b/packages/ai/skills/ai-core/tool-calling/SKILL.md index 98b905e9d..6949896f0 100644 --- a/packages/ai/skills/ai-core/tool-calling/SKILL.md +++ b/packages/ai/skills/ai-core/tool-calling/SKILL.md @@ -360,7 +360,7 @@ const compareProducts = compareProductsDef.server(async ({ productIds }) => { export async function POST(request: Request) { const { messages } = await request.json() const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.5'), messages, tools: [getProducts, compareProducts], agentLoopStrategy: maxIterations(20), @@ -375,6 +375,31 @@ gets the full schema, then calls `compareProducts` directly. Once discovered, a tool stays available for the conversation. When all lazy tools are discovered, the discovery tool is removed automatically. +### Tuning the lazy catalog with `lazyToolsConfig` + +By default the discovery-tool catalog lists only bare names (`'none'`). Pass +`lazyToolsConfig` to `chat()` to include more context: + +```typescript +const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages, + tools: [getProducts, compareProducts], + agentLoopStrategy: maxIterations(20), + lazyToolsConfig: { includeDescription: 'first-sentence' }, +}) +``` + +`includeDescription` values: + +| Value | Catalog entry | When to use | +| ------------------ | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| `'none'` (default) | `compareProducts` | Smallest prompt; model discovers by name | +| `'first-sentence'` | `compareProducts — Compare two or more products side by side.` | Helps the model decide whether to discover without extra tokens | +| `'full'` | `compareProducts — Compare two or more products side by side. Accepts productIds array.` | Use when descriptions are short or the model needs full context to route correctly | + +The post-discovery payload always returns the full description and schema regardless of this setting. + ## MCP Tools `@tanstack/ai-mcp` lets a server-side `chat()` call discover and invoke tools diff --git a/packages/ai/src/activities/chat/index.ts b/packages/ai/src/activities/chat/index.ts index eba41682f..81d6bf7f8 100644 --- a/packages/ai/src/activities/chat/index.ts +++ b/packages/ai/src/activities/chat/index.ts @@ -39,6 +39,7 @@ import type { CustomEvent, InferSchemaType, JSONSchema, + LazyToolsConfig, ModelMessage, RunFinishedEvent, SchemaInput, @@ -224,6 +225,12 @@ export interface TextActivityOptions< abortController?: TextOptions['abortController'] /** Strategy for controlling the agent loop */ agentLoopStrategy?: TextOptions['agentLoopStrategy'] + /** + * Optional configuration for lazy-tool discovery (tools marked `lazy: true`). + * Tunes how much of each lazy tool's description appears in the discovery + * catalog. Optional — defaults to `{ includeDescription: 'none' }`. + */ + lazyToolsConfig?: LazyToolsConfig /** Unique conversation identifier for tracking */ conversationId?: TextOptions['conversationId'] /** Thread/conversation ID for AG-UI protocol. Auto-generated if not provided. */ @@ -583,6 +590,7 @@ class TextEngine< this.lazyToolManager = new LazyToolManager( config.params.tools || [], this.messages, + config.params.lazyToolsConfig, ) this.tools = this.lazyToolManager.getActiveTools() this.toolCallManager = new ToolCallManager< diff --git a/packages/ai/src/activities/chat/tools/lazy-tool-manager.ts b/packages/ai/src/activities/chat/tools/lazy-tool-manager.ts index d4aa50ca0..39c9a9eba 100644 --- a/packages/ai/src/activities/chat/tools/lazy-tool-manager.ts +++ b/packages/ai/src/activities/chat/tools/lazy-tool-manager.ts @@ -1,5 +1,6 @@ import { convertSchemaToJsonSchema } from './schema-converter' -import type { Tool } from '../../../types' +import { renderLazyCatalogEntry } from './lazy-tools' +import type { LazyToolsConfig, Tool } from '../../../types' const DISCOVERY_TOOL_NAME = '__lazy__tool__discovery__' @@ -16,6 +17,7 @@ export class LazyToolManager { private readonly discoveredTools: Set private hasNewDiscoveries: boolean private readonly discoveryTool: Tool | null + private readonly lazyToolsConfig: LazyToolsConfig constructor( tools: ReadonlyArray, @@ -29,7 +31,9 @@ export class LazyToolManager { }> toolCallId?: string }>, + lazyToolsConfig: LazyToolsConfig = {}, ) { + this.lazyToolsConfig = lazyToolsConfig const eager: Array = [] this.lazyToolMap = new Map() this.discoveredTools = new Set() @@ -188,9 +192,13 @@ export class LazyToolManager { const lazyToolMap = this.lazyToolMap - // Build the static description with all lazy tool names - const allLazyNames = Array.from(this.lazyToolMap.keys()) - const description = `You have access to additional tools that can be discovered. Available tools: [${allLazyNames.join(', ')}]. Call this tool with a list of tool names to discover their full descriptions and argument schemas before using them.` + // Build the static description, rendering each entry per includeDescription. + // With the default 'none' this is byte-identical to the legacy output. + const include = this.lazyToolsConfig.includeDescription ?? 'none' + const allLazyEntries = Array.from(this.lazyToolMap.values()).map((t) => + renderLazyCatalogEntry(t.name, t.description, include), + ) + const description = `You have access to additional tools that can be discovered. Available tools: [${allLazyEntries.join(', ')}]. Call this tool with a list of tool names to discover their full descriptions and argument schemas before using them.` // Use the arrow function to capture `this` context const manager = this diff --git a/packages/ai/src/activities/chat/tools/lazy-tools.ts b/packages/ai/src/activities/chat/tools/lazy-tools.ts new file mode 100644 index 000000000..45367b2c7 --- /dev/null +++ b/packages/ai/src/activities/chat/tools/lazy-tools.ts @@ -0,0 +1,33 @@ +import type { LazyToolsConfig } from '../../../types' + +/** + * Extract the first sentence of a description (up to the first ., !, or ? + * followed by whitespace or end-of-string). Falls back to the whole trimmed + * string when there is no sentence terminator. + */ +export function firstSentence(text: string): string { + const trimmed = text.trim() + if (!trimmed) return '' + const match = trimmed.match(/^.*?[.!?](?=\s|$)/) + return (match ? match[0] : trimmed).trim() +} + +/** + * Render one entry in a lazy-tool catalog according to `includeDescription`. + * - 'none' (default) → bare name (preserves legacy chat behavior) + * - 'first-sentence' → `name — ` + * - 'full' → `name — ` + * Falls back to the bare name when there is no description. + */ +export function renderLazyCatalogEntry( + name: string, + description: string, + includeDescription: LazyToolsConfig['includeDescription'] = 'none', +): string { + if (includeDescription === 'none' || !description.trim()) return name + const desc = + includeDescription === 'first-sentence' + ? firstSentence(description) + : description.trim() + return desc ? `${name} — ${desc}` : name +} diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index dbb38722b..47f5bae13 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -121,6 +121,11 @@ export type { // All types export * from './types' +export { + firstSentence, + renderLazyCatalogEntry, +} from './activities/chat/tools/lazy-tools' + // Usage utilities export { buildBaseUsage, type BaseUsageInput } from './utilities/usage' diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index e57f860ba..6cacf8776 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -653,13 +653,29 @@ export interface Tool< /** If true, tool execution requires user approval before running. Works with both server and client tools. */ needsApproval?: boolean - /** If true, this tool is lazy and will only be sent to the LLM after being discovered via the lazy tool discovery mechanism. Only meaningful when used with chat(). */ + /** If true, this tool is lazy and will only be sent to the LLM after being discovered via the lazy tool discovery mechanism. Works with both chat() (the synthetic discovery tool) and Code Mode (kept out of the system prompt and revealed via discover_tools). */ lazy?: boolean /** Additional metadata for adapters or custom extensions */ metadata?: Record | undefined } +/** + * Configuration for the lazy-tool discovery catalog, shared by chat() and + * Code Mode. Optional in both — lazy behavior is triggered purely by tools + * marked `lazy: true`; this only tunes how much of each lazy tool's + * description appears in the pre-discovery catalog. The post-discovery payload + * always returns the full description + schema. + */ +export interface LazyToolsConfig { + /** + * How much of each lazy tool's description appears in the pre-discovery + * catalog (the names list shown before the model discovers the tool). + * @default 'none' + */ + includeDescription?: 'full' | 'first-sentence' | 'none' +} + export type AnyTool = Omit, 'execute'> & { execute?: ((args: any, context?: any) => any) | undefined } @@ -818,6 +834,12 @@ export interface TextOptions< */ systemPrompts?: Array agentLoopStrategy?: AgentLoopStrategy + /** + * Optional configuration for lazy-tool discovery (tools marked `lazy: true`). + * Tunes how much of each lazy tool's description appears in the discovery + * catalog. Optional — defaults to `{ includeDescription: 'none' }`. + */ + lazyToolsConfig?: LazyToolsConfig /** * Additional metadata to attach to the request. * Can be used for tracking, debugging, or passing custom information. diff --git a/packages/ai/tests/lazy-tool-manager.test.ts b/packages/ai/tests/lazy-tool-manager.test.ts index c62830da6..986d9e83d 100644 --- a/packages/ai/tests/lazy-tool-manager.test.ts +++ b/packages/ai/tests/lazy-tool-manager.test.ts @@ -362,3 +362,35 @@ describe('LazyToolManager', () => { }) }) }) + +function lazyTool(name: string, description: string) { + return { + name, + description, + inputSchema: { type: 'object', properties: {} }, + lazy: true, + } +} + +describe('LazyToolManager — catalog includeDescription', () => { + it("lists names only by default ('none'), preserving legacy output", () => { + const mgr = new LazyToolManager([lazyTool('alpha', 'Does A. Extra.')], []) + const discovery = mgr + .getActiveTools() + .find((t) => t.name === '__lazy__tool__discovery__') + expect(discovery?.description).toContain('Available tools: [alpha].') + }) + + it('includes first sentences when configured', () => { + const mgr = new LazyToolManager( + [lazyTool('alpha', 'Does A. Extra.'), lazyTool('beta', 'Does B. Extra.')], + [], + { includeDescription: 'first-sentence' }, + ) + const discovery = mgr + .getActiveTools() + .find((t) => t.name === '__lazy__tool__discovery__') + expect(discovery?.description).toContain('alpha — Does A.') + expect(discovery?.description).toContain('beta — Does B.') + }) +}) diff --git a/packages/ai/tests/lazy-tools.test.ts b/packages/ai/tests/lazy-tools.test.ts new file mode 100644 index 000000000..9f02ad563 --- /dev/null +++ b/packages/ai/tests/lazy-tools.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { firstSentence, renderLazyCatalogEntry } from '../src/index' + +describe('firstSentence', () => { + it('returns the first sentence ending in a period', () => { + expect(firstSentence('Hello world. Second sentence.')).toBe('Hello world.') + }) + it('handles ! and ?', () => { + expect(firstSentence('Is this it? More text.')).toBe('Is this it?') + }) + it('returns the whole string when there is no terminator', () => { + expect(firstSentence('No period here')).toBe('No period here') + }) + it('trims surrounding whitespace', () => { + expect(firstSentence(' Padded. Rest. ')).toBe('Padded.') + }) + it('returns empty string for empty/whitespace input', () => { + expect(firstSentence(' ')).toBe('') + }) +}) + +describe('renderLazyCatalogEntry', () => { + it("returns the bare name when includeDescription is 'none'", () => { + expect( + renderLazyCatalogEntry( + 'fetchWeather', + 'Gets weather. Returns temp.', + 'none', + ), + ).toBe('fetchWeather') + }) + it("defaults to 'none' when includeDescription is omitted", () => { + expect(renderLazyCatalogEntry('fetchWeather', 'Gets weather.')).toBe( + 'fetchWeather', + ) + }) + it("appends the first sentence for 'first-sentence'", () => { + expect( + renderLazyCatalogEntry( + 'fetchWeather', + 'Gets weather. Returns temp.', + 'first-sentence', + ), + ).toBe('fetchWeather — Gets weather.') + }) + it("appends the full description for 'full'", () => { + expect( + renderLazyCatalogEntry( + 'fetchWeather', + 'Gets weather. Returns temp.', + 'full', + ), + ).toBe('fetchWeather — Gets weather. Returns temp.') + }) + it('returns the bare name when description is empty even for full', () => { + expect(renderLazyCatalogEntry('fetchWeather', '', 'full')).toBe( + 'fetchWeather', + ) + }) +}) diff --git a/testing/e2e/fixtures/lazy-tools-wire/basic.json b/testing/e2e/fixtures/lazy-tools-wire/basic.json new file mode 100644 index 000000000..4613dc57a --- /dev/null +++ b/testing/e2e/fixtures/lazy-tools-wire/basic.json @@ -0,0 +1,12 @@ +{ + "fixtures": [ + { + "match": { + "userMessage": "[lazy-wire] discover and describe inventory tools" + }, + "response": { + "content": "Here are the inventory tools." + } + } + ] +} diff --git a/testing/e2e/src/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts index 165f14d4d..46d8a9e7a 100644 --- a/testing/e2e/src/routeTree.gen.ts +++ b/testing/e2e/src/routeTree.gen.ts @@ -36,6 +36,7 @@ import { Route as ApiMcpStatusTestRouteImport } from './routes/api.mcp-status-te import { Route as ApiMcpServerRouteImport } from './routes/api.mcp-server' import { Route as ApiMcpManagedTestRouteImport } from './routes/api.mcp-managed-test' import { Route as ApiMcpLifecycleTestRouteImport } from './routes/api.mcp-lifecycle-test' +import { Route as ApiLazyToolsWireRouteImport } from './routes/api.lazy-tools-wire' import { Route as ApiImageRouteImport } from './routes/api.image' import { Route as ApiChatRouteImport } from './routes/api.chat' import { Route as ApiAudioRouteImport } from './routes/api.audio' @@ -187,6 +188,11 @@ const ApiMcpLifecycleTestRoute = ApiMcpLifecycleTestRouteImport.update({ path: '/api/mcp-lifecycle-test', getParentRoute: () => rootRouteImport, } as any) +const ApiLazyToolsWireRoute = ApiLazyToolsWireRouteImport.update({ + id: '/api/lazy-tools-wire', + path: '/api/lazy-tools-wire', + getParentRoute: () => rootRouteImport, +} as any) const ApiImageRoute = ApiImageRouteImport.update({ id: '/api/image', path: '/api/image', @@ -266,6 +272,7 @@ export interface FileRoutesByFullPath { '/api/audio': typeof ApiAudioRouteWithChildren '/api/chat': typeof ApiChatRoute '/api/image': typeof ApiImageRouteWithChildren + '/api/lazy-tools-wire': typeof ApiLazyToolsWireRoute '/api/mcp-lifecycle-test': typeof ApiMcpLifecycleTestRoute '/api/mcp-managed-test': typeof ApiMcpManagedTestRoute '/api/mcp-server': typeof ApiMcpServerRoute @@ -307,6 +314,7 @@ export interface FileRoutesByTo { '/api/audio': typeof ApiAudioRouteWithChildren '/api/chat': typeof ApiChatRoute '/api/image': typeof ApiImageRouteWithChildren + '/api/lazy-tools-wire': typeof ApiLazyToolsWireRoute '/api/mcp-lifecycle-test': typeof ApiMcpLifecycleTestRoute '/api/mcp-managed-test': typeof ApiMcpManagedTestRoute '/api/mcp-server': typeof ApiMcpServerRoute @@ -349,6 +357,7 @@ export interface FileRoutesById { '/api/audio': typeof ApiAudioRouteWithChildren '/api/chat': typeof ApiChatRoute '/api/image': typeof ApiImageRouteWithChildren + '/api/lazy-tools-wire': typeof ApiLazyToolsWireRoute '/api/mcp-lifecycle-test': typeof ApiMcpLifecycleTestRoute '/api/mcp-managed-test': typeof ApiMcpManagedTestRoute '/api/mcp-server': typeof ApiMcpServerRoute @@ -392,6 +401,7 @@ export interface FileRouteTypes { | '/api/audio' | '/api/chat' | '/api/image' + | '/api/lazy-tools-wire' | '/api/mcp-lifecycle-test' | '/api/mcp-managed-test' | '/api/mcp-server' @@ -433,6 +443,7 @@ export interface FileRouteTypes { | '/api/audio' | '/api/chat' | '/api/image' + | '/api/lazy-tools-wire' | '/api/mcp-lifecycle-test' | '/api/mcp-managed-test' | '/api/mcp-server' @@ -474,6 +485,7 @@ export interface FileRouteTypes { | '/api/audio' | '/api/chat' | '/api/image' + | '/api/lazy-tools-wire' | '/api/mcp-lifecycle-test' | '/api/mcp-managed-test' | '/api/mcp-server' @@ -516,6 +528,7 @@ export interface RootRouteChildren { ApiAudioRoute: typeof ApiAudioRouteWithChildren ApiChatRoute: typeof ApiChatRoute ApiImageRoute: typeof ApiImageRouteWithChildren + ApiLazyToolsWireRoute: typeof ApiLazyToolsWireRoute ApiMcpLifecycleTestRoute: typeof ApiMcpLifecycleTestRoute ApiMcpManagedTestRoute: typeof ApiMcpManagedTestRoute ApiMcpServerRoute: typeof ApiMcpServerRoute @@ -726,6 +739,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiMcpLifecycleTestRouteImport parentRoute: typeof rootRouteImport } + '/api/lazy-tools-wire': { + id: '/api/lazy-tools-wire' + path: '/api/lazy-tools-wire' + fullPath: '/api/lazy-tools-wire' + preLoaderRoute: typeof ApiLazyToolsWireRouteImport + parentRoute: typeof rootRouteImport + } '/api/image': { id: '/api/image' path: '/api/image' @@ -889,6 +909,7 @@ const rootRouteChildren: RootRouteChildren = { ApiAudioRoute: ApiAudioRouteWithChildren, ApiChatRoute: ApiChatRoute, ApiImageRoute: ApiImageRouteWithChildren, + ApiLazyToolsWireRoute: ApiLazyToolsWireRoute, ApiMcpLifecycleTestRoute: ApiMcpLifecycleTestRoute, ApiMcpManagedTestRoute: ApiMcpManagedTestRoute, ApiMcpServerRoute: ApiMcpServerRoute, diff --git a/testing/e2e/src/routes/api.lazy-tools-wire.ts b/testing/e2e/src/routes/api.lazy-tools-wire.ts new file mode 100644 index 000000000..f1e0366d2 --- /dev/null +++ b/testing/e2e/src/routes/api.lazy-tools-wire.ts @@ -0,0 +1,96 @@ +import { createFileRoute } from '@tanstack/react-router' +import { chat, createChatOptions, toolDefinition } from '@tanstack/ai' +import { createOpenaiChat } from '@tanstack/ai-openai' +import { z } from 'zod' +import type { LazyToolsConfig } from '@tanstack/ai' + +const LLMOCK_DEFAULT_BASE = process.env.LLMOCK_URL || 'http://127.0.0.1:4010' +const DUMMY_KEY = 'sk-e2e-test-dummy-key' + +/** + * Wire-format coverage for the lazy-tools `lazyToolsConfig.includeDescription` + * knob. + * + * The synthetic discovery tool (`__lazy__tool__discovery__`) is what carries + * the lazy-tool catalog. Its `description` is rendered per `includeDescription` + * (`'none'` default / `'first-sentence'` / `'full'`) and sent to the provider + * as part of the `tools` array — but with aimock the model never reflects that + * text back to the browser, so the only place to observe it end-to-end is the + * provider request itself. + * + * This route drives the OpenAI chat adapter with two `lazy: true` tools and a + * caller-chosen `includeDescription`, against aimock, so the companion spec can + * inspect aimock's journal (`GET /v1/_requests`) and assert the discovery + * tool's catalog description actually crossed the wire with the configured + * detail level. The model response is irrelevant to the assertion. + */ +const searchInventory = toolDefinition({ + name: 'search_inventory', + description: 'Search the guitar inventory by keyword. Returns matches.', + inputSchema: z.object({ query: z.string() }), + lazy: true, +}).server(async () => JSON.stringify({ ok: true })) + +const checkStock = toolDefinition({ + name: 'check_stock', + description: 'Check stock level for a guitar. Returns quantity on hand.', + inputSchema: z.object({ guitarId: z.number() }), + lazy: true, +}).server(async () => JSON.stringify({ ok: true })) + +const VALID_INCLUDE = new Set< + NonNullable +>(['none', 'first-sentence', 'full']) + +export const Route = createFileRoute('/api/lazy-tools-wire')({ + server: { + handlers: { + POST: async ({ request }) => { + const url = new URL(request.url) + const testId = url.searchParams.get('testId') ?? undefined + const rawInclude = url.searchParams.get('includeDescription') + const includeDescription = + rawInclude != null && + VALID_INCLUDE.has( + rawInclude as NonNullable, + ) + ? (rawInclude as NonNullable) + : 'none' + + const adapter = createOpenaiChat('gpt-5.5', DUMMY_KEY, { + baseURL: `${LLMOCK_DEFAULT_BASE}/v1`, + defaultHeaders: testId ? { 'X-Test-Id': testId } : undefined, + }) + + try { + for await (const _ of chat({ + ...createChatOptions({ adapter }), + messages: [ + { + role: 'user', + content: '[lazy-wire] discover and describe inventory tools', + }, + ], + tools: [searchInventory, checkStock], + lazyToolsConfig: { includeDescription }, + })) { + // Drain the stream. + } + } catch (error) { + return new Response( + JSON.stringify({ + ok: false, + error: error instanceof Error ? error.message : String(error), + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ) + } + + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + }, + }, + }, +}) diff --git a/testing/e2e/tests/lazy-tools-wire.spec.ts b/testing/e2e/tests/lazy-tools-wire.spec.ts new file mode 100644 index 000000000..11f90e127 --- /dev/null +++ b/testing/e2e/tests/lazy-tools-wire.spec.ts @@ -0,0 +1,136 @@ +import { test, expect } from './fixtures' + +/** + * Wire-format coverage for the lazy-tools `lazyToolsConfig.includeDescription` + * knob (shared by `chat()` and Code Mode). + * + * Lazy tools are surfaced to the model through a synthetic discovery tool + * (`__lazy__tool__discovery__`) whose `description` embeds the lazy-tool + * catalog. `includeDescription` tunes how much of each lazy tool's description + * that catalog shows: + * - 'none' (default) → bare names, byte-identical to legacy behavior + * - 'first-sentence' → `name — ` + * - 'full' → `name — ` + * + * With aimock the model never reflects the discovery tool's description back to + * the browser, so the catalog is observable only on the provider request. This + * spec drives `/api/lazy-tools-wire` (OpenAI chat adapter, two `lazy: true` + * tools) and inspects aimock's journal (`GET /v1/_requests`) to assert the + * discovery tool's catalog crossed the wire at the configured detail level. + */ + +type JournalEntry = { + headers?: Record + body: { + tools?: Array<{ + type?: string + function?: { name?: string; description?: string } + }> + } | null +} + +const DISCOVERY_TOOL_NAME = '__lazy__tool__discovery__' + +/** + * Find the discovery tool's wire `description` for this test's request only. + * + * The journal is shared across all parallel tests on the one aimock instance, + * so entries are filtered by the test's `X-Test-Id` (the same header that + * isolates fixture sequencing) before reading the catalog text. + */ +async function discoveryDescription( + request: import('@playwright/test').APIRequestContext, + aimockPort: number, + testId: string, +): Promise { + const journalRes = await request.get( + `http://127.0.0.1:${aimockPort}/v1/_requests`, + ) + const entries = (await journalRes.json()) as Array + for (const entry of entries) { + if (entry.headers?.['x-test-id'] !== testId) continue + const discovery = entry.body?.tools?.find( + (t) => t.function?.name === DISCOVERY_TOOL_NAME, + ) + if (discovery?.function?.description) { + return discovery.function.description + } + } + return undefined +} + +test.describe('lazy tools — discovery catalog wire format', () => { + // No journal reset here: the shared aimock journal is filtered per-test by + // X-Test-Id (see discoveryDescription), so a global DELETE would only race + // with adjacent parallel specs on the same aimock instance. + test("includeDescription: 'none' sends bare lazy tool names (legacy default)", async ({ + request, + aimockPort, + testId, + }) => { + const res = await request.post( + `/api/lazy-tools-wire?includeDescription=none&testId=${encodeURIComponent( + testId, + )}`, + ) + expect(res.ok()).toBe(true) + expect(((await res.json()) as { ok: boolean }).ok).toBe(true) + + const description = await discoveryDescription(request, aimockPort, testId) + expect(description).toBeDefined() + // Bare names present, no description text appended. + expect(description).toContain('search_inventory') + expect(description).toContain('check_stock') + expect(description).not.toContain('search_inventory — ') + expect(description).not.toContain('Search the guitar inventory') + }) + + test("includeDescription: 'first-sentence' appends each lazy tool's first sentence", async ({ + request, + aimockPort, + testId, + }) => { + const res = await request.post( + `/api/lazy-tools-wire?includeDescription=first-sentence&testId=${encodeURIComponent( + testId, + )}`, + ) + expect(res.ok()).toBe(true) + expect(((await res.json()) as { ok: boolean }).ok).toBe(true) + + const description = await discoveryDescription(request, aimockPort, testId) + expect(description).toBeDefined() + // First sentence appended, but not the trailing second sentence. + expect(description).toContain( + 'search_inventory — Search the guitar inventory by keyword.', + ) + expect(description).toContain( + 'check_stock — Check stock level for a guitar.', + ) + expect(description).not.toContain('Returns matches.') + expect(description).not.toContain('Returns quantity on hand.') + }) + + test("includeDescription: 'full' appends each lazy tool's full description", async ({ + request, + aimockPort, + testId, + }) => { + const res = await request.post( + `/api/lazy-tools-wire?includeDescription=full&testId=${encodeURIComponent( + testId, + )}`, + ) + expect(res.ok()).toBe(true) + expect(((await res.json()) as { ok: boolean }).ok).toBe(true) + + const description = await discoveryDescription(request, aimockPort, testId) + expect(description).toBeDefined() + expect(description).toContain( + 'search_inventory — Search the guitar inventory by keyword. Returns matches.', + ) + expect(description).toContain( + 'check_stock — Check stock level for a guitar. Returns quantity on hand.', + ) + }) +})