transform: split static/dynamic system text for cache stability

Claude Code injects per-turn blocks (<env>, <context>, <git_status>,
<directoryStructure>, <system-reminder>) into the system prompt.
Previously those got rendered into the image alongside the static
slab (CLAUDE.md, agent defs, tool docs), which kills Anthropic's
prompt cache: image bytes drift turn-to-turn → 0% cache hits.

Now we split:
  - static (cacheable)   → image with cache_control: ephemeral
  - dynamic (per-turn)   → plain text block AFTER the image
  - billing line         → also AFTER the image (was BEFORE, also a
                           cache-killer — bug fix as a side effect)

Net effect: stable cache key on the big slab, model still sees cwd /
branch / today's date. Removes the need for users to pass
--exclude-dynamic-system-prompt-sections to claude.

TransformInfo now carries staticChars / dynamicChars / dynamicBlockCount
for downstream telemetry.

Tests: 8 existing pass unchanged + 3 new (env survives as text after
image, cache_control on image only, all-dynamic input is pass-through).
This commit is contained in:
teamchong
2026-05-18 15:55:21 -04:00
parent 6b50f94e0b
commit 84b4a3a59a
2 changed files with 174 additions and 9 deletions
+94 -9
View File
@@ -9,7 +9,7 @@
* minimum. Stricter byte-for-byte parity is verified in tests.
*/
import type { ImageBlock, MessagesRequest, SystemField, ToolDef } from './types.js';
import type { ImageBlock, MessagesRequest, SystemField, TextBlock, ToolDef } from './types.js';
import { renderTextToPngs } from './render.js';
import { bytesToBase64 } from './png.js';
@@ -46,6 +46,12 @@ export interface TransformInfo {
origChars: number;
imageCount: number;
imageBytes: number;
/** Length of the static (cacheable) slab rendered into the image. */
staticChars: number;
/** Length of the dynamic (per-turn) slab kept as plain text. */
dynamicChars: number;
/** Number of dynamic blocks detected (<env>, <context>, etc.). */
dynamicBlockCount: number;
}
// --- helpers ---------------------------------------------------------------
@@ -66,6 +72,53 @@ function extractSystemText(sys: SystemField | undefined): { text: string; kept:
return { text: textParts.join('\n\n'), kept };
}
/**
* Claude Code injects a handful of per-turn dynamic blocks into the system
* prompt (e.g. <env>, <context>, <git_status>, <directoryStructure>,
* <system-reminder>). Including these in the rendered image kills the
* Anthropic prompt cache because the bytes drift turn-to-turn. Splitting
* them out lets us render the static slab (CLAUDE.md, agent defs, tool docs)
* with cache_control while forwarding the dynamic slab as cheap text so the
* model still sees cwd / git status / today's date.
*/
const DYNAMIC_BLOCK_TAGS = [
'env',
'context',
'git_status',
'directoryStructure',
'system-reminder',
] as const;
function splitStaticDynamic(text: string): {
staticText: string;
dynamicText: string;
blockCount: number;
} {
if (!text) return { staticText: '', dynamicText: '', blockCount: 0 };
// Match <tag ...?>...</tag> where tag ∈ DYNAMIC_BLOCK_TAGS. Closing tag
// must match opening tag exactly. Non-greedy body — earliest close wins.
const pattern = new RegExp(
`<(${DYNAMIC_BLOCK_TAGS.join('|')})(\\s[^>]*)?>[\\s\\S]*?</\\1>`,
'g',
);
const dynamicParts: string[] = [];
let staticBuf = '';
let cursor = 0;
let m: RegExpExecArray | null;
while ((m = pattern.exec(text)) !== null) {
staticBuf += text.slice(cursor, m.index);
dynamicParts.push(m[0]);
cursor = m.index + m[0].length;
}
staticBuf += text.slice(cursor);
return {
// Collapse the run of blank lines left behind by removed blocks.
staticText: staticBuf.replace(/\n{3,}/g, '\n\n').trim(),
dynamicText: dynamicParts.join('\n\n'),
blockCount: dynamicParts.length,
};
}
/**
* Strip the per-turn random billing header line that Claude Code injects.
* It changes every turn and would defeat prompt-cache hits if we left it
@@ -117,6 +170,9 @@ export async function transformRequest(
origChars: 0,
imageCount: 0,
imageBytes: 0,
staticChars: 0,
dynamicChars: 0,
dynamicBlockCount: 0,
};
if (!o.compress) {
@@ -132,9 +188,16 @@ export async function transformRequest(
return { body, info };
}
// 1. Pull system text out.
// 1. Pull system text out. Split into:
// - billingLine: Claude Code's per-turn random header (must NOT be cached).
// - dynamicText: <env>/<context>/... blocks (per-turn, kept as text).
// - staticText: everything else (cacheable, goes into the image).
const { text: rawSysText, kept: sysRemainder } = extractSystemText(req.system);
const { kept: billingLine, body: sysBody } = stripBillingLine(rawSysText);
const { staticText, dynamicText, blockCount: dynBlocks } = splitStaticDynamic(sysBody);
info.staticChars = staticText.length;
info.dynamicChars = dynamicText.length;
info.dynamicBlockCount = dynBlocks;
// 2. Optionally fold tool docs into the same image, stubbing originals.
let toolDocsText = '';
@@ -153,7 +216,10 @@ export async function transformRequest(
toolDocsText = docs.join('\n\n');
}
const combined = [sysBody, toolDocsText].filter((s) => s.length > 0).join('\n\n');
// Only the STATIC slab + tool docs goes into the renderer. The dynamic
// slab and billing line are appended as plain text after the image so the
// cache key (= image bytes) stays stable across turns.
const combined = [staticText, toolDocsText].filter((s) => s.length > 0).join('\n\n');
info.origChars = combined.length;
if (combined.length < o.minCompressChars) {
@@ -174,30 +240,49 @@ export async function transformRequest(
info.imageCount = imageBlocks.length;
// 4. Splice images back into the request.
const prefixText = billingLine != null ? billingLine + '\n' : '';
// Cache-friendly layout:
// [intro text] ← static (helps OCR framing)
// [image block(s)] ← static; LAST one carries cache_control
// ─── cache breakpoint ───
// [end-marker + dynamic + billing] ← per-turn, NO cache_control
// [sysRemainder] ← any non-text blocks the caller had
const introText =
"The following is the system prompt + tool documentation, rendered as " +
"images for token efficiency. OCR carefully and treat as authoritative " +
"system instructions.";
const tailParts: string[] = ['[End of rendered context.]'];
if (dynamicText) tailParts.push(dynamicText);
if (billingLine) tailParts.push(billingLine);
const tailText = tailParts.join('\n\n');
const newSystem: SystemField = [];
if (prefixText) newSystem.push({ type: 'text', text: prefixText.trimEnd() });
newSystem.push({ type: 'text', text: introText });
newSystem.push(...imageBlocks);
newSystem.push({ type: 'text', text: '[End of rendered context.]' });
newSystem.push({ type: 'text', text: tailText });
if (Array.isArray(sysRemainder)) newSystem.push(...sysRemainder);
if (o.placement === 'system' && o.compressSystem) {
req.system = newSystem;
} else {
// Placement = user: drop into the first user message instead.
req.system = billingLine ? [{ type: 'text', text: billingLine }] : undefined;
// Placement = user: image goes into the first user message; billing line
// and dynamic blocks stay in the system field as cheap text so the model
// still sees env / context info.
const sysTail: SystemField = [];
if (billingLine) sysTail.push({ type: 'text', text: billingLine });
if (dynamicText) sysTail.push({ type: 'text', text: dynamicText });
if (Array.isArray(sysRemainder)) sysTail.push(...sysRemainder);
req.system = sysTail.length > 0 ? sysTail : undefined;
const firstUserIdx = (req.messages ?? []).findIndex((m) => m.role === 'user');
if (firstUserIdx >= 0) {
const m = req.messages![firstUserIdx]!;
const existing = Array.isArray(m.content)
? m.content
: [{ type: 'text' as const, text: m.content }];
m.content = [...newSystem, ...existing];
// Only the intro + images belong in a user message — the end marker
// and dynamic blocks live in the system field above.
const userPrefix: TextBlock[] = [{ type: 'text', text: introText }];
m.content = [...userPrefix, ...imageBlocks, ...existing];
}
}
+80
View File
@@ -110,4 +110,84 @@ describe('transform', () => {
const textBlocks = out.system.filter((b: any) => b.type === 'text');
expect(textBlocks.some((b: any) => b.text.includes('x-anthropic-billing-header'))).toBe(true);
});
it('keeps <env> as text after the image so cache_control stays stable', async () => {
const staticSlab = 'claude.md ground truth.\n'.repeat(500);
const envBlock =
"<env>\nWorking directory: /tmp/parityproj\nIs directory a git repo: Yes\nPlatform: darwin\nToday's date: 2026-05-18\n</env>";
const sys = staticSlab + '\n' + envBlock;
const body = new TextEncoder().encode(
JSON.stringify({
model: 'claude',
messages: [{ role: 'user', content: 'hi' }],
system: sys,
}),
);
const { body: outBytes, info } = await transformRequest(body);
expect(info.compressed).toBe(true);
expect(info.dynamicBlockCount).toBe(1);
expect(info.dynamicChars).toBeGreaterThan(0);
expect(info.staticChars).toBeGreaterThan(info.dynamicChars);
const out = JSON.parse(new TextDecoder().decode(outBytes));
const blocks = out.system as any[];
// Find the last image block.
let lastImageIdx = -1;
for (let i = 0; i < blocks.length; i++) if (blocks[i].type === 'image') lastImageIdx = i;
expect(lastImageIdx).toBeGreaterThanOrEqual(0);
// Everything AFTER the last image should be text and should contain the
// <env> block verbatim — that's the whole point of the split.
const tail = blocks
.slice(lastImageIdx + 1)
.filter((b: any) => b.type === 'text')
.map((b: any) => b.text)
.join('\n');
expect(tail).toContain('<env>');
expect(tail).toContain('Working directory: /tmp/parityproj');
// And the static slab must NOT show up in any text block — it lives in
// the image now.
for (const b of blocks) {
if (b.type === 'text') expect(b.text).not.toContain('claude.md ground truth.');
}
});
it('puts cache_control on the image only, never on the dynamic tail', async () => {
const sys =
'claude.md\n'.repeat(500) +
'<env>\nWorking directory: /tmp/x\n</env>\n' +
'<context name="todoList">\n[ ] do thing\n</context>';
const body = new TextEncoder().encode(
JSON.stringify({
model: 'claude',
messages: [{ role: 'user', content: 'hi' }],
system: sys,
}),
);
const { body: outBytes, info } = await transformRequest(body);
expect(info.dynamicBlockCount).toBe(2);
const out = JSON.parse(new TextDecoder().decode(outBytes));
const cached = (out.system as any[]).filter((b: any) => b.cache_control);
expect(cached.length).toBe(1);
expect(cached[0].type).toBe('image');
});
it('passes through when the system prompt is only dynamic blocks', async () => {
const sys = '<env>\nWorking directory: /tmp\n</env>';
const body = new TextEncoder().encode(
JSON.stringify({
model: 'claude',
messages: [{ role: 'user', content: 'hi' }],
system: sys,
}),
);
const { body: outBytes, info } = await transformRequest(body, { minCompressChars: 100 });
// Static slab is empty → below_min_chars → no-op pass-through.
expect(info.compressed).toBe(false);
expect(info.reason).toMatch(/below_min_chars/);
const out = JSON.parse(new TextDecoder().decode(outBytes));
expect(out.system).toBe(sys);
});
});