fix(transform): freeze imaged slab at first render — keep volatile content (skill listings, cwd caches) out of the imaged prefix so turn-2 system sha matches turn-1

This commit is contained in:
teamchong
2026-07-02 21:11:18 -04:00
parent 0ad3c68664
commit beacd67b4a
3 changed files with 127 additions and 68 deletions
+23
View File
@@ -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<string, unknown>): boolean {
for (const k of SCHEMA_STRUCTURAL_KEYS) {
if (k in schema) return true;
}
return false;
}
+58 -41
View File
@@ -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<desc>" 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<desc>\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: <name>") 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: <name>") 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<string, unknown> | 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: <name>" 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: <name>" 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: <name>"
// 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: <name>" 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');
+46 -27
View File
@@ -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"');
});