From d0e41dc49bc1530d300a87538d71da51a670e1c7 Mon Sep 17 00:00:00 2001 From: Brad Yinger <7626+byingyang@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:32:53 -0400 Subject: [PATCH 1/3] fix(transform): pass tool-search managed tools through the tool rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tools carrying defer_loading: true belong to Anthropic's tool-search beta: the server keeps them out of context (billed ~zero) until the model searches for them. Rewriting them — stub description, annotation-stripped schema, full docs rendered into the imaged Tool Reference — materializes documentation the API was deliberately keeping free, inflating every request. A Claude Code session with a large MCP surface ships hundreds of tools (~490k chars observed), so an unpatched proxy can image the entire set into every request. The tool_search_tool_regex/_bm25 server tools themselves must also pass through untouched: they are schema-less and server-defined, and stubbing their description breaks the beta's contract. Deferred tools now pass through byte-identical, are excluded from the imaged Tool Reference, and a deferred_tools_skipped counter lands in events.jsonl for observability. --- src/core/transform.ts | 3 + tests/defer-loading.test.ts | 143 ++++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 tests/defer-loading.test.ts diff --git a/src/core/transform.ts b/src/core/transform.ts index 03512591..8ce9c8f8 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 00000000..9266adc6 --- /dev/null +++ b/tests/defer-loading.test.ts @@ -0,0 +1,143 @@ +/** + * 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). + * - info.deferredToolsSkipped reports how many tools were exempted. + * - A request with no deferred tools behaves exactly as before + * (deferredToolsSkipped unset). + */ + +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(); + + // Telemetry: 2 exempted (deferred + search tool). + expect(info.deferredToolsSkipped).toBe(2); + + // 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, info } = await transformRequest(makeReq([plainTool]), {}); + const out = parse(body); + expect(info.deferredToolsSkipped).toBeUndefined(); + expect(out.tools[0].description).toContain('## Tool: bash'); + }); +}); From 63d02b16a4cf1f7833d9377caa23101e74627186 Mon Sep 17 00:00:00 2001 From: Brad Yinger <7626+byingyang@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:32:46 -0400 Subject: [PATCH 2/3] test(defer-loading): cover below-min-chars early return with all-deferred tools Self-review nit: no test exercised the below_min_chars early-return path (all tools deferred, tiny system prompt) where transformRequest returns the original body before req.tools = toolsRewritten ever runs. Locks in that this path stays byte-identical. Auto-fix-by: babysit-prs --- tests/defer-loading.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/defer-loading.test.ts b/tests/defer-loading.test.ts index 9266adc6..4a7ead99 100644 --- a/tests/defer-loading.test.ts +++ b/tests/defer-loading.test.ts @@ -140,4 +140,23 @@ describe('defer_loading tools pass through the tool rewrite', () => { expect(info.deferredToolsSkipped).toBeUndefined(); 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]); + }); }); From bf4215bbb6f9354b9014e6bc30e3a97d2d792eb8 Mon Sep 17 00:00:00 2001 From: Brad Yinger <7626+byingyang@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:45:24 -0400 Subject: [PATCH 3/3] test(defer-loading): focus coverage on passthrough behavior --- tests/defer-loading.test.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/tests/defer-loading.test.ts b/tests/defer-loading.test.ts index 4a7ead99..90d47557 100644 --- a/tests/defer-loading.test.ts +++ b/tests/defer-loading.test.ts @@ -18,9 +18,7 @@ * - `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). - * - info.deferredToolsSkipped reports how many tools were exempted. - * - A request with no deferred tools behaves exactly as before - * (deferredToolsSkipped unset). + * - A request with no deferred tools behaves exactly as before. */ import { describe, expect, it } from 'vitest'; @@ -106,9 +104,6 @@ describe('defer_loading tools pass through the tool rewrite', () => { expect(outPlain.description).not.toBe(PLAIN_DESC); expect(outPlain.input_schema.properties.command.description).toBeUndefined(); - // Telemetry: 2 exempted (deferred + search tool). - expect(info.deferredToolsSkipped).toBe(2); - // Deferred docs excluded from the imaged reference accounting. expect(info.toolDocsChars).toBeGreaterThan(0); expect(info.toolDocsChars!).toBeLessThan( @@ -135,9 +130,8 @@ describe('defer_loading tools pass through the tool rewrite', () => { }); it('is a no-op for requests without deferred tools', async () => { - const { body, info } = await transformRequest(makeReq([plainTool]), {}); + const { body } = await transformRequest(makeReq([plainTool]), {}); const out = parse(body); - expect(info.deferredToolsSkipped).toBeUndefined(); expect(out.tools[0].description).toContain('## Tool: bash'); });