diff --git a/src/core/transform.ts b/src/core/transform.ts index 0351259..8ce9c8f 100644 --- a/src/core/transform.ts +++ b/src/core/transform.ts @@ -1823,6 +1823,9 @@ export async function transformRequest( // server-side; there is nothing to compress). Mirrors the OpenAI-path // guard (openai.ts isFunctionTool). if (t.type !== undefined && t.type !== 'custom') return t; + // Anthropic excludes deferred tool definitions from model context until + // selected by tool search; imaging their docs would defeat that behavior. + if ((t as ToolDef & { defer_loading?: boolean }).defer_loading === true) return t; docs.push(renderToolDoc(t)); // tools[] keeps the annotation-STRIPPED schema: structure (type/properties/ // required/enum/items) stays for Anthropic's tool-use validator — a bare diff --git a/tests/defer-loading.test.ts b/tests/defer-loading.test.ts new file mode 100644 index 0000000..90d4755 --- /dev/null +++ b/tests/defer-loading.test.ts @@ -0,0 +1,156 @@ +/** + * Tools managed by Anthropic's tool-search beta must pass through the tool + * rewrite untouched. + * + * A tool marked `defer_loading: true` is not injected into context until the + * model searches for it — the server bills it at ~zero until then. Rewriting + * it (stub description + annotation-stripped schema) and rendering its full + * docs into the imaged Tool Reference would materialize documentation the API + * was deliberately keeping out of context: with a large MCP surface, Claude + * Code ships hundreds of deferred tools (~477k chars observed), all of which + * were being imaged into every request. The search tool itself + * (`tool_search_tool_regex_20251119` / `_bm25_`) is schema-less and + * server-defined and must also survive byte-identical. + * + * Contract being verified: + * - `defer_loading: true` tools keep their original description and + * input_schema and gain no stub. + * - `tool_search_tool_*` server tools pass through unchanged. + * - Deferred tools' docs do NOT count toward info.toolDocsChars. + * - Non-deferred tools in the same request are still rewritten (stubbed). + * - A request with no deferred tools behaves exactly as before. + */ + +import { describe, expect, it } from 'vitest'; +import { transformRequest } from '../src/core/transform.js'; + +const enc = new TextEncoder(); +const dec = new TextDecoder(); + +const DEFERRED_DESC = + 'Query the observability backend for traces. '.repeat(40); +const PLAIN_DESC = 'Run a shell command in the workspace. '.repeat(40); + +function makeReq(tools: unknown[]) { + return enc.encode( + JSON.stringify({ + model: 'claude-3-5-sonnet', + // Large static slab so the compression path definitely runs. + system: 'x'.repeat(80_000), + tools, + messages: [{ role: 'user', content: 'hello' }], + }), + ); +} + +function parse(body: Uint8Array): any { + return JSON.parse(dec.decode(body)); +} + +const deferredTool = { + name: 'mcp__datadog__get_traces', + description: DEFERRED_DESC, + input_schema: { + type: 'object', + properties: { + query: { type: 'string', description: 'Trace search query' }, + }, + required: ['query'], + }, + defer_loading: true, +}; + +const searchTool = { + type: 'tool_search_tool_regex_20251119', + name: 'tool_search_tool_regex', +}; + +const plainTool = { + name: 'bash', + description: PLAIN_DESC, + input_schema: { + type: 'object', + properties: { + command: { type: 'string', description: 'The command to execute' }, + }, + required: ['command'], + }, +}; + +describe('defer_loading tools pass through the tool rewrite', () => { + it('leaves deferred tools byte-identical and still rewrites the rest', async () => { + const { body, info } = await transformRequest( + makeReq([searchTool, deferredTool, plainTool]), + {}, + ); + const out = parse(body); + expect(out.tools).toHaveLength(3); + + const [outSearch, outDeferred, outPlain] = out.tools; + + // Search tool: byte-identical. + expect(outSearch).toEqual(searchTool); + + // Deferred tool: original description + schema + flag survive; no stub. + expect(outDeferred).toEqual(deferredTool); + expect(outDeferred.description).toBe(DEFERRED_DESC); + expect(outDeferred.input_schema.properties.query.description).toBe( + 'Trace search query', + ); + + // Plain tool: still rewritten — stub description pointing at the + // Tool Reference, schema annotations stripped. + expect(outPlain.description).toContain('## Tool: bash'); + expect(outPlain.description).not.toBe(PLAIN_DESC); + expect(outPlain.input_schema.properties.command.description).toBeUndefined(); + + // Deferred docs excluded from the imaged reference accounting. + expect(info.toolDocsChars).toBeGreaterThan(0); + expect(info.toolDocsChars!).toBeLessThan( + PLAIN_DESC.length + DEFERRED_DESC.length, + ); + }); + + it('keeps deferred docs out of the imaged Tool Reference text', async () => { + const { body } = await transformRequest( + makeReq([deferredTool, plainTool]), + {}, + ); + const out = parse(body); + // The system field carries the imaged slab plus any text tail; no + // rendered remnant of the deferred tool's doc heading should exist + // anywhere in the outgoing body except the tool's own definition. + const raw = dec.decode(body); + expect(raw).not.toContain('## Tool: mcp__datadog__get_traces'); + expect(raw).toContain('## Tool: bash'); + // And the deferred tool's full description exists exactly once — in + // its own tools[] entry, not duplicated into any reference text. + expect(raw.split(DEFERRED_DESC).length - 1).toBe(1); + expect(out.tools[0]).toEqual(deferredTool); + }); + + it('is a no-op for requests without deferred tools', async () => { + const { body } = await transformRequest(makeReq([plainTool]), {}); + const out = parse(body); + expect(out.tools[0].description).toContain('## Tool: bash'); + }); + + it('stays byte-identical when every tool is deferred and the request falls below the compression gate', async () => { + // Tiny system prompt + all-deferred tools -> toolDocsText stays empty and + // combinedRaw lands under minCompressChars, taking the below_min_chars + // early return (before req.tools = toolsRewritten runs). The passthrough + // this suite verifies elsewhere must still hold on that path. + const req = { + model: 'claude-3-5-sonnet', + system: 'hi', + tools: [searchTool, deferredTool], + messages: [{ role: 'user', content: 'hello' }], + }; + const reqBytes = enc.encode(JSON.stringify(req)); + const { body, info } = await transformRequest(reqBytes, {}); + expect(info.compressed).toBe(false); + expect(dec.decode(body)).toBe(dec.decode(reqBytes)); + const out = parse(body); + expect(out.tools).toEqual([searchTool, deferredTool]); + }); +});