mirror of
https://github.com/teamchong/pxpipe.git
synced 2026-07-22 02:02:51 +02:00
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:
@@ -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
@@ -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
@@ -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"');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user