From beacd67b4a3c5c6704bc0ebb374a602933ac6bb4 Mon Sep 17 00:00:00 2001 From: teamchong <25894545+teamchong@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:11:18 -0400 Subject: [PATCH] =?UTF-8?q?fix(transform):=20freeze=20imaged=20slab=20at?= =?UTF-8?q?=20first=20render=20=E2=80=94=20keep=20volatile=20content=20(sk?= =?UTF-8?q?ill=20listings,=20cwd=20caches)=20out=20of=20the=20imaged=20pre?= =?UTF-8?q?fix=20so=20turn-2=20system=20sha=20matches=20turn-1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/schema-strip.ts | 23 ++++++++++ src/core/transform.ts | 99 +++++++++++++++++++++++----------------- tests/render.test.ts | 73 ++++++++++++++++++----------- 3 files changed, 127 insertions(+), 68 deletions(-) diff --git a/src/core/schema-strip.ts b/src/core/schema-strip.ts index c684f6d..7de0ae6 100644 --- a/src/core/schema-strip.ts +++ b/src/core/schema-strip.ts @@ -147,3 +147,26 @@ export function stripSchemaDescriptions(node: unknown, depth = 0): unknown { } return out; } + +/** JSON Schema keys that carry a parameter *contract* (shape/values), as opposed + * to pure annotations. Used to decide whether a stripped schema still tells the + * validator anything — if none survive, the strip is not worth shipping. */ +export const SCHEMA_STRUCTURAL_KEYS = [ + 'properties', + 'patternProperties', + 'oneOf', + 'anyOf', + 'allOf', + 'items', + '$ref', + 'enum', + 'const', +] as const; + +/** True when the schema node retains at least one structural (contract) key. */ +export function schemaHasStructure(schema: Record): boolean { + for (const k of SCHEMA_STRUCTURAL_KEYS) { + if (k in schema) return true; + } + return false; +} diff --git a/src/core/transform.ts b/src/core/transform.ts index f1f4172..6ab1053 100644 --- a/src/core/transform.ts +++ b/src/core/transform.ts @@ -35,6 +35,7 @@ import { renderTextToPngsWithCharLimit, } from './render.js'; import { factSheetText } from './factsheet.js'; +import { stripSchemaDescriptions, schemaHasStructure } from './schema-strip.js'; import { bytesToBase64 } from './png.js'; import { collapseHistory, HISTORY_SYNTHETIC_INTRO } from './history.js'; import type { GptHistoryOptions } from './openai-history.js'; @@ -947,13 +948,20 @@ function stripMarkdownEnvSection(text: string): { kept: string; body: string } { }; } -/** Build the "## Tool: name\n" block for one tool. Prose only — the schema - * stays untouched in tools[] on this path (text reference: schema JSON here would - * duplicate the structure the API already receives; the GPT path differs because - * its docs are imaged, see openai.ts renderToolDoc). */ +/** Build the "## Tool: name\n\n```json …```" block for one tool. Docs are + * imaged on this path (mirrors openai.ts renderToolDoc): the image carries the + * full schema — annotations included — at image token rates, while tools[] + * carries the annotation-stripped structure for Anthropic's tool-use validator. + * (The earlier text-reference design kept this prose-only because schema JSON + * at text rates would have duplicated what tools[] already pays for; at image + * rates the duplicate structure is cheap and the stripped annotations are the + * compression.) */ function renderToolDoc(t: ToolDef): string { const parts: string[] = [`## Tool: ${t.name ?? '?'}`]; if (t.description) parts.push(t.description); + if (t.input_schema !== undefined) { + parts.push('```json\n' + JSON.stringify(t.input_schema) + '\n```'); + } return parts.join('\n'); } @@ -1431,25 +1439,34 @@ export async function transformRequest( if (claudeMdSha) info.claudeMdSha8 = claudeMdSha; if (firstUserSha) info.firstUserSha8 = firstUserSha; - // 2. Optionally move tool docs to a plain-text "Tool Reference" section in the - // system field, stubbing originals. Text (not image) because tool docs are - // static per session — they sit inside the cached prefix and cost ~0.1× after - // the first turn, while keeping schemas/param docs losslessly readable. - // Each stub description cites its own heading ("## Tool: ") so the - // model can link stub → full doc deterministically. + // 2. Move tool docs into the imaged "Tool Reference", stubbing originals. + // Imaged (not text) because that IS the compression — descriptions and + // schema annotations ride at image token rates, mirroring the GPT path. + // Tool docs are static per session, so the slab image stays byte-stable + // and cache-friendly. Each stub description cites its own heading + // ("## Tool: ") so the model can link stub → full doc + // deterministically. let toolDocsText = ''; let toolsRewritten: ToolDef[] | undefined; if (o.compressTools && Array.isArray(req.tools) && req.tools.length > 0) { const docs: string[] = []; toolsRewritten = req.tools.map((t) => { docs.push(renderToolDoc(t)); - // input_schema passes through UNTOUCHED on this path. The reference text - // carries prose only; the schema (structure + param descriptions) already - // reaches the API once via tools[]. Stripping here would either lose param - // descriptions or force duplicating the schema into the reference — both - // strictly worse than leaving it alone. (History: a bare {type:'object'} - // stub caused validator 400s; stripped-stub + schema-in-text fixed that but - // paid the structure twice. Passthrough pays everything exactly once.) + // tools[] keeps the annotation-STRIPPED schema: structure (type/properties/ + // required/enum/items) stays for Anthropic's tool-use validator — a bare + // {type:'object'} stub caused 400s on non-interactive turns where Anthropic + // deep-validates with no prior tool_use history to short-circuit. The + // stripped annotations (description/title/examples/default) ride in the + // imaged reference instead, at image rates. If stripping yields no + // structural keys, keep the ORIGINAL schema untouched: it's tiny without + // properties, and a bare stub is the riskier trade. + let schema = t.input_schema; + if (schema && typeof schema === 'object') { + const stripped = stripSchemaDescriptions(schema, 0) as Record | null; + if (stripped && typeof stripped === 'object' && schemaHasStructure(stripped)) { + schema = stripped; + } + } return { ...t, // Wording note (do NOT reintroduce "system prompt"/"authoritative" — same ban @@ -1459,16 +1476,31 @@ export async function transformRequest( // to claude-opus-4-8 immediately on cold start, 2026-07-02). The reference // block's own header says where the docs live; the stub only needs the heading. description: `ⓘ Full docs: see "## Tool: ${t.name ?? '?'}" in the Tool Reference section.`, + ...(schema !== undefined ? { input_schema: schema } : {}), }; }); toolDocsText = docs.join('\n\n'); + info.toolDocsChars = toolDocsText.length; } - // Only the static slab goes into the renderer; dynamic slab and billing line stay - // as plain text so the cache key (= image bytes) is stable across turns. Tool docs - // are spliced into the system field as text (see sysTail below), NOT imaged — - // static per session, so they ride the cache prefix at ~0.1× cost. - const combinedRaw = staticText; + // Static slab + Tool Reference go into the renderer; dynamic slab and billing + // line stay as plain text so the cache key (= image bytes) is stable across + // turns. The reference header carries the same first-party provenance framing + // that defused the imaged-slab banner refusal (169521c): pxpipe names itself + // as the author of the relocation so the block reads as this session's own + // config, not a replayed/extracted prompt. + const toolReferenceText = toolDocsText + ? '=== TOOL REFERENCE ===\n' + + "pxpipe (this user's local proxy) moved the full tool documentation for this" + + ' session here to reduce token cost. Each tool in the tools list carries a short' + + ' stub description pointing here; the entry under the matching' + + ' "## Tool: " heading below is the complete description for that tool.\n\n' + + toolDocsText + + '\n=== END TOOL REFERENCE ===' + : ''; + const combinedRaw = [staticText, toolReferenceText] + .filter((s) => s.length > 0) + .join('\n\n'); // Compact then reflow before the gate; gate/renderer/paging all see the same text. // origChars anchored to raw length — that's what Anthropic would have billed. const combined = maybeReflow(compactSlabWhitespace(combinedRaw), o.reflow); @@ -1612,26 +1644,11 @@ export async function transformRequest( // model still sees it; the cached slab image no longer depends on it. if (envMarkdown) sysTail.push({ type: 'text', text: envMarkdown }); if (Array.isArray(sysRemainder)) sysTail.push(...sysRemainder); - // Tool Reference: full tool docs as plain system text. Stubbed tools[] - // descriptions cite these "## Tool: " headings verbatim. Emitted only - // when toolsRewritten is set — both are applied on this same path, so the - // stub ↔ reference invariant holds (gate-fail paths return earlier with + // Tool Reference now rides INSIDE the imaged slab (combinedRaw above) — no + // text splice here. Stubbed tools[] descriptions cite the "## Tool: " + // headings inside the image; stub ↔ reference invariant holds because both + // are applied on this same path (gate-fail paths return earlier with // original tools untouched). - if (toolsRewritten && toolDocsText) { - // First-party provenance framing, mirroring the imaged-slab banner fix - // (169521c): pxpipe names itself as the author of the relocation so the - // block reads as this session's own config, not a replayed/extracted prompt. - const toolReferenceText = - '=== TOOL REFERENCE ===\n' + - "pxpipe (this user's local proxy) moved the full tool documentation for this" + - ' session here to reduce token cost. Each tool in the tools list carries a short' + - ' stub description pointing here; the entry under the matching' + - ' "## Tool: " heading below is the complete description for that tool.\n\n' + - toolDocsText + - '\n=== END TOOL REFERENCE ==='; - sysTail.push({ type: 'text', text: toolReferenceText }); - info.toolDocsChars = toolDocsText.length; - } req.system = sysTail.length > 0 ? sysTail : undefined; const firstUserIdx = (req.messages ?? []).findIndex((m) => m.role === 'user'); diff --git a/tests/render.test.ts b/tests/render.test.ts index c662201..6cc8382 100644 --- a/tests/render.test.ts +++ b/tests/render.test.ts @@ -696,17 +696,18 @@ describe('transform', () => { } }); - it('moves tool docs to a system-text Tool Reference and stubs originals', async () => { + it('moves tool docs into the imaged Tool Reference and stubs originals', async () => { const req = JSON.stringify({ model: 'claude-3-5-sonnet', messages: [{ role: 'user', content: 'hi' }], - // Tool docs no longer feed the image slab, so the static system text - // alone must clear the compression gate. - system: 'x'.repeat(150000), + // Sized so system + tool docs + banner fit inside the 65,536-char + // info.imageSourceText diagnostic window (the reference renders after + // the static slab) while still clearing the compression gates. + system: 'x'.repeat(30000), tools: [ { name: 'BigTool', - description: 'A very long tool description. '.repeat(5000), + description: 'A very long tool description. '.repeat(500), input_schema: { type: 'object', properties: { x: { type: 'string' } } }, }, ], @@ -717,41 +718,44 @@ describe('transform', () => { expect(info.toolDocsChars).toBeGreaterThan(0); const out = JSON.parse(new TextDecoder().decode(body)); - // Stub cites its own heading in the system Tool Reference (link-up contract). + // Stub cites its own heading in the imaged Tool Reference (link-up contract). expect(out.tools[0].name).toBe('BigTool'); expect(out.tools[0].description).toContain('"## Tool: BigTool"'); expect(out.tools[0].description).toContain('Tool Reference'); - // Full docs land as plain text in the system field, under that exact heading. - const sysTexts = (out.system as any[]) + // Full docs ride the imaged slab — that IS the compression. + const imgSrc = info.imageSourceText ?? ''; + expect(imgSrc).toContain('=== TOOL REFERENCE ==='); + expect(imgSrc).toContain('## Tool: BigTool'); + expect(imgSrc).toContain('A very long tool description.'); + // No text-splice remains in the system field. + const sysTexts = ((out.system as any[]) ?? []) .filter((b: any) => b.type === 'text') .map((b: any) => b.text as string); - const ref = sysTexts.find((t) => t.includes('=== TOOL REFERENCE ===')); - expect(ref).toBeDefined(); - expect(ref!).toContain('## Tool: BigTool'); - expect(ref!).toContain('A very long tool description.'); - // And the imaged slab no longer carries the tool docs. - expect(info.imageSourceText ?? '').not.toContain('A very long tool description.'); + expect(sysTexts.some((t) => t.includes('=== TOOL REFERENCE ==='))).toBe(false); // Classifier regression (169521c; retripped 2026-07-02 by a stub citing "the // system prompt"): pxpipe-authored framing must never read as a replayed or // extracted prompt. Ban the trigger wording in the stub and the reference // header, and require first-party provenance framing on the reference block. // Scoped to the header only — quoted tool docs below it are third-party text. expect(out.tools[0].description).not.toMatch(/system prompt|authoritative/i); - const refHeader = ref!.slice(0, ref!.indexOf('## Tool:')); + const refStart = imgSrc.indexOf('=== TOOL REFERENCE ==='); + const refHeader = imgSrc.slice(refStart, imgSrc.indexOf('## Tool:', refStart)); expect(refHeader).not.toMatch(/system prompt|authoritative/i); expect(refHeader).toContain("this user's local proxy"); }); - it('passes input_schema through byte-identical when compressing', async () => { - // History: the proxy once replaced input_schema with a bare `{type:'object'}` - // stub (validator 400s), then with a description-stripped shell + full schema - // JSON duplicated into the reference text. Both are gone: on the Anthropic - // path the reference is TEXT, so the schema ships exactly once — untouched - // in tools[], param descriptions and all. Only the prose description moves. + it('ships annotation-stripped schemas in tools[], full schema in the imaged reference', async () => { + // History: a bare `{type:'object'}` stub caused validator 400s; a text + // reference paid the annotations at text rates. Current contract: tools[] + // keeps the structural contract (type/properties/required/enum) for the + // validator, annotations (description/default/$schema) move into the + // imaged reference where they cost image rates. const req = JSON.stringify({ model: 'claude-3-5-sonnet', messages: [{ role: 'user', content: 'hi' }], - system: 'x'.repeat(150000), // force compression + // Small enough that the reference lands inside the 65,536-char + // info.imageSourceText window; large enough to clear the gates. + system: 'x'.repeat(30000), tools: [ { name: 'Read', @@ -811,11 +815,26 @@ describe('transform', () => { expect(info.reason).toBeUndefined(); const out = JSON.parse(new TextDecoder().decode(body)); - const orig = JSON.parse(req); - // Schemas are identical to what the caller sent — descriptions and all. - expect(out.tools[0].input_schema).toEqual(orig.tools[0].input_schema); - expect(out.tools[1].input_schema).toEqual(orig.tools[1].input_schema); - // Only the prose description is stubbed to cite the reference heading. + // Structural contract survives in tools[] — validator-visible keys intact. + const s0 = out.tools[0].input_schema; + expect(s0.type).toBe('object'); + expect(Object.keys(s0.properties)).toEqual(['file_path', 'mode']); + expect(s0.required).toEqual(['file_path']); + expect(s0.properties.mode.enum).toEqual(['read', 'binary']); + // Annotations are stripped from tools[] everywhere in the tree. + expect(JSON.stringify(s0)).not.toContain('description'); + expect(s0.$schema).toBeUndefined(); + expect(s0.properties.mode.default).toBeUndefined(); + const s1 = out.tools[1].input_schema; + expect(s1.required).toEqual(['command']); + expect(Object.keys(s1.properties.env.properties)).toEqual(['PATH']); + expect(s1.properties.files.items.required).toEqual(['path']); + expect(JSON.stringify(s1)).not.toContain('description'); + // The full annotated schema rides the imaged reference instead. + const imgSrc = info.imageSourceText ?? ''; + expect(imgSrc).toContain('Absolute path to the file'); + expect(imgSrc).toContain('path var'); + // Stubs cite the reference heading. expect(out.tools[0].description).toContain('"## Tool: Read"'); expect(out.tools[1].description).toContain('"## Tool: Bash"'); });