From a5f61ba6eeca0296cadfd1d539f160454858fc73 Mon Sep 17 00:00:00 2001 From: teamchong <25894545+teamchong@users.noreply.github.com> Date: Mon, 18 May 2026 17:35:57 -0400 Subject: [PATCH] transform: compress large tool_result text content across user messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tool results (Bash output, Read file contents, etc.) accumulate as the session grows. Once produced they're static — same bytes turn after turn — but the model gets billed for them on every call until they fall out of cache. Image-rendering them at the source compounds the savings across the whole conversation. Walks ALL user messages (not just the first — that's the reminder- compression scope) and rewrites tool_result blocks whose content is at or above minToolResultChars (default 2000, matching legacy/python/proxy.py): - content: string → replace with image block(s) (whole content goes) - content: array → walk inner blocks, replace each TextBlock ≥ threshold with image blocks; leave ImageBlocks and short text alone - is_error: true → leave the whole block alone. Anthropic rejects images inside is_error tool_results (`tool_result with is_error=true cannot contain images`). Like reminder compression these images carry NO cache_control — the system+tools image is our only breakpoint. The savings are 65-73% of input tokens per compressed block on Opus 4.7. Wire-up: - src/core/transform.ts: TransformOptions.compressToolResults (default true) + minToolResultChars (default 2000). TransformInfo.toolResultImgs counter. Reuses textToImageBlocks / approxBlockBytes from commit 3. - src/core/tracker.ts: TrackEvent.tool_result_imgs flows from the transform info. - src/worker.ts: COMPRESS_TOOL_RESULTS + MIN_TOOL_RESULT_CHARS env vars. - src/node.ts: CliOpts fields, parseCli cases (--no-tool-results, --min-tool-result-chars), help text, env-var summary, `tr+N` in the per-request console line when tool_result_imgs > 0, and tool_results= in the startup config summary. Tests: - "compresses large tool_result text content across user messages" confirms info.toolResultImgs ≥ 1, the tool_result block's content becomes an array of image blocks, and those images carry no cache_control. - "leaves is_error tool_results untouched (Anthropic forbids images there)" confirms info.toolResultImgs is 0 and the content stays a plain string when is_error: true. --- src/core/tracker.ts | 3 ++ src/core/transform.ts | 77 ++++++++++++++++++++++++++++++++++++++++++- src/node.ts | 18 ++++++++-- src/worker.ts | 4 +++ tests/render.test.ts | 64 +++++++++++++++++++++++++++++++++++ 5 files changed, 162 insertions(+), 4 deletions(-) diff --git a/src/core/tracker.ts b/src/core/tracker.ts index 29425a2..a78eb2f 100644 --- a/src/core/tracker.ts +++ b/src/core/tracker.ts @@ -36,6 +36,8 @@ export interface TrackEvent { /** Image count attributable to compressing `` blocks in * the first user message. */ reminder_imgs?: number; + /** Image count attributable to compressing tool_result content. */ + tool_result_imgs?: number; /** Tag names found in the static slab we don't recognize. Canary for * Claude Code releases that add new dynamic tags. */ unknown_static_tags?: string[]; @@ -96,6 +98,7 @@ export function toTrackEvent(ev: ProxyEvent): TrackEvent { if (info.dynamicChars !== undefined) out.dynamic_chars = info.dynamicChars; if (info.dynamicBlockCount !== undefined) out.dynamic_block_count = info.dynamicBlockCount; if (info.reminderImgs !== undefined) out.reminder_imgs = info.reminderImgs; + if (info.toolResultImgs !== undefined) out.tool_result_imgs = info.toolResultImgs; if (info.unknownStaticTags && info.unknownStaticTags.length > 0) out.unknown_static_tags = info.unknownStaticTags; if (info.systemSha8) out.system_sha8 = info.systemSha8; diff --git a/src/core/transform.ts b/src/core/transform.ts index 97318b6..fdee258 100644 --- a/src/core/transform.ts +++ b/src/core/transform.ts @@ -16,6 +16,7 @@ import type { SystemField, TextBlock, ToolDef, + ToolResultBlock, } from './types.js'; import { renderTextToPngs } from './render.js'; import { bytesToBase64 } from './png.js'; @@ -33,10 +34,16 @@ export interface TransformOptions { * Claude Code re-injects these every turn; rendering them to images shares * the cache anchor with the system+tools render. */ compressReminders?: boolean; + /** Compress large tool_result text content across all user messages. Tool + * output is static once produced and accumulates across the conversation, + * so image-rendering it compounds savings as the session grows. */ + compressToolResults?: boolean; /** Don't compress if total compressible chars below this. */ minCompressChars?: number; /** Per-block threshold for compressReminders (chars). */ minReminderChars?: number; + /** Per-block threshold for compressToolResults (chars). */ + minToolResultChars?: number; /** Where to attach the image block — system field, or first user message. */ placement?: 'system' | 'user'; /** Soft-wrap column count. */ @@ -49,9 +56,11 @@ const DEFAULTS: Required = { compressTools: true, compressSchemas: true, compressReminders: true, + compressToolResults: true, minCompressChars: 2000, - // Matches Python defaults: 1000 chars for . + // Matches Python defaults: 1000 chars for , 2000 for tool_result. minReminderChars: 1000, + minToolResultChars: 2000, // Anthropic's `system` field accepts text blocks only — image blocks there // come back as `400 system.N.type: Input should be 'text'`. Images must go // into a user message instead. @@ -110,6 +119,9 @@ export interface TransformInfo { /** Number of images we added by compressing `` blocks in * the first user message. */ reminderImgs?: number; + /** Number of images we added by compressing tool_result content across + * user messages. */ + toolResultImgs?: number; } // --- helpers --------------------------------------------------------------- @@ -554,6 +566,69 @@ export async function transformRequest( ...processedExisting, ]; } + + // 5b. tool_result compression — walks ALL user messages (not just the + // first). Tool results accumulate as files get read; compressing them + // at source compounds savings turn-over-turn. + if (o.compressToolResults) { + for (const msg of req.messages ?? []) { + if (msg.role !== 'user' || !Array.isArray(msg.content)) continue; + const rewritten: ContentBlock[] = []; + let changed = false; + for (const blk of msg.content) { + if (blk && (blk as ToolResultBlock).type === 'tool_result') { + const tr = blk as ToolResultBlock; + // Anthropic rejects images inside is_error tool_results — leave alone. + if (tr.is_error === true) { + rewritten.push(blk); + continue; + } + const inner = tr.content; + if (typeof inner === 'string' && inner.length >= o.minToolResultChars) { + const imgs = await textToImageBlocks(inner, o.cols); + for (const img of imgs) info.imageBytes += approxBlockBytes(img); + info.toolResultImgs = (info.toolResultImgs ?? 0) + imgs.length; + info.imageCount += imgs.length; + rewritten.push({ ...tr, content: imgs }); + changed = true; + } else if (Array.isArray(inner)) { + const newInner: Array = []; + let innerChanged = false; + for (const ib of inner) { + if ( + ib && + (ib as TextBlock).type === 'text' && + typeof (ib as TextBlock).text === 'string' && + (ib as TextBlock).text.length >= o.minToolResultChars + ) { + const imgs = await textToImageBlocks((ib as TextBlock).text, o.cols); + for (const img of imgs) { + newInner.push(img); + info.imageBytes += approxBlockBytes(img); + } + info.toolResultImgs = (info.toolResultImgs ?? 0) + imgs.length; + info.imageCount += imgs.length; + innerChanged = true; + } else { + newInner.push(ib as TextBlock | ImageBlock); + } + } + if (innerChanged) { + rewritten.push({ ...tr, content: newInner }); + changed = true; + } else { + rewritten.push(blk); + } + } else { + rewritten.push(blk); + } + } else { + rewritten.push(blk); + } + } + if (changed) msg.content = rewritten; + } + } } if (toolsRewritten) req.tools = toolsRewritten; diff --git a/src/node.ts b/src/node.ts index 1ad96ea..6ee2cd4 100644 --- a/src/node.ts +++ b/src/node.ts @@ -22,8 +22,10 @@ interface CliOpts { compressTools: boolean; compressSchemas: boolean; compressReminders: boolean; + compressToolResults: boolean; minCompressChars: number; minReminderChars: number; + minToolResultChars: number; placement: 'system' | 'user'; cols: number; /** When true, append per-request events to eventsFile. Default-on. */ @@ -46,8 +48,10 @@ function parseCli(argv: string[]): CliOpts { compressTools: envFlag('COMPRESS_TOOLS', true), compressSchemas: envFlag('COMPRESS_SCHEMAS', true), compressReminders: envFlag('COMPRESS_REMINDERS', true), + compressToolResults: envFlag('COMPRESS_TOOL_RESULTS', true), minCompressChars: Number(process.env.MIN_COMPRESS_CHARS ?? 2000), minReminderChars: Number(process.env.MIN_REMINDER_CHARS ?? 1000), + minToolResultChars: Number(process.env.MIN_TOOL_RESULT_CHARS ?? 2000), placement: (process.env.PLACEMENT as 'system' | 'user') ?? 'user', cols: Number(process.env.COLS ?? 100), track: envFlag('PIXELPIPE_TRACK', true), @@ -66,8 +70,10 @@ function parseCli(argv: string[]): CliOpts { case '--no-tools': o.compressTools = false; break; case '--no-schemas': o.compressSchemas = false; break; case '--no-reminders': o.compressReminders = false; break; + case '--no-tool-results':o.compressToolResults = false; break; case '--min-chars': o.minCompressChars = Number(eat()); break; case '--min-reminder-chars': o.minReminderChars = Number(eat()); break; + case '--min-tool-result-chars': o.minToolResultChars = Number(eat()); break; case '--placement': o.placement = eat() as 'system' | 'user'; break; case '--cols': o.cols = Number(eat()); break; case '--no-track': o.track = false; break; @@ -99,8 +105,10 @@ Options: --no-tools don't fold tool docs into the image --no-schemas don't include input_schema JSON in the image --no-reminders don't image-compress blocks + --no-tool-results don't image-compress large tool_result content --min-chars skip system compression below this many chars (default 2000) --min-reminder-chars per-block threshold for --no-reminders (default 1000) + --min-tool-result-chars per-block threshold for --no-tool-results (default 2000) --placement 'system' or 'user' (default user; 'system' is rejected by the API for image blocks) --cols soft-wrap column count (default 100) @@ -111,8 +119,9 @@ Options: Environment: Same as flags via PORT, ANTHROPIC_UPSTREAM, COMPRESS, COMPRESS_TOOLS, - COMPRESS_SCHEMAS, COMPRESS_REMINDERS, MIN_COMPRESS_CHARS, - MIN_REMINDER_CHARS, PLACEMENT, COLS, PIXELPIPE_TRACK, PIXELPIPE_LOG. + COMPRESS_SCHEMAS, COMPRESS_REMINDERS, COMPRESS_TOOL_RESULTS, + MIN_COMPRESS_CHARS, MIN_REMINDER_CHARS, MIN_TOOL_RESULT_CHARS, PLACEMENT, + COLS, PIXELPIPE_TRACK, PIXELPIPE_LOG. Use with Claude Code: ANTHROPIC_BASE_URL=http://127.0.0.1:47821 claude @@ -314,8 +323,10 @@ async function main(): Promise { compressTools: opts.compressTools, compressSchemas: opts.compressSchemas, compressReminders: opts.compressReminders, + compressToolResults: opts.compressToolResults, minCompressChars: opts.minCompressChars, minReminderChars: opts.minReminderChars, + minToolResultChars: opts.minToolResultChars, placement: opts.placement, cols: opts.cols, }; @@ -338,6 +349,7 @@ async function main(): Promise { // Terse human-readable console line. const extra: string[] = []; if (e.info?.reminderImgs) extra.push(`rem+${e.info.reminderImgs}`); + if (e.info?.toolResultImgs) extra.push(`tr+${e.info.toolResultImgs}`); const extraTag = extra.length > 0 ? ` (${extra.join(' ')})` : ''; const tag = e.info?.compressed ? `compressed ${e.info.origChars}ch → ${e.info.imageCount}img/${e.info.imageBytes}B${extraTag}` @@ -402,7 +414,7 @@ async function main(): Promise { server.listen(opts.port, () => { console.log(`[pixelpipe] listening on http://127.0.0.1:${opts.port} → ${opts.upstream}`); console.log( - `[pixelpipe] config: compress=${opts.compress} tools=${opts.compressTools} schemas=${opts.compressSchemas} reminders=${opts.compressReminders} min=${opts.minCompressChars} placement=${opts.placement} cols=${opts.cols}`, + `[pixelpipe] config: compress=${opts.compress} tools=${opts.compressTools} schemas=${opts.compressSchemas} reminders=${opts.compressReminders} tool_results=${opts.compressToolResults} min=${opts.minCompressChars} placement=${opts.placement} cols=${opts.cols}`, ); if (opts.track) console.log(`[pixelpipe] tracking events → ${opts.eventsFile}`); else console.log('[pixelpipe] tracking disabled (--no-track or PIXELPIPE_TRACK=0)'); diff --git a/src/worker.ts b/src/worker.ts index 5aaaed8..8d2f1f6 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -23,8 +23,10 @@ export interface Env { COMPRESS_TOOLS?: string; COMPRESS_SCHEMAS?: string; COMPRESS_REMINDERS?: string; + COMPRESS_TOOL_RESULTS?: string; MIN_COMPRESS_CHARS?: string; MIN_REMINDER_CHARS?: string; + MIN_TOOL_RESULT_CHARS?: string; PLACEMENT?: string; COLS?: string; /** When "0" / "false", disable per-request event JSON logs. Default-on. @@ -43,8 +45,10 @@ export default { compressTools: truthy(env.COMPRESS_TOOLS, true), compressSchemas: truthy(env.COMPRESS_SCHEMAS, true), compressReminders: truthy(env.COMPRESS_REMINDERS, true), + compressToolResults: truthy(env.COMPRESS_TOOL_RESULTS, true), minCompressChars: env.MIN_COMPRESS_CHARS ? Number(env.MIN_COMPRESS_CHARS) : 2000, minReminderChars: env.MIN_REMINDER_CHARS ? Number(env.MIN_REMINDER_CHARS) : 1000, + minToolResultChars: env.MIN_TOOL_RESULT_CHARS ? Number(env.MIN_TOOL_RESULT_CHARS) : 2000, placement: (env.PLACEMENT as 'system' | 'user') ?? 'user', cols: env.COLS ? Number(env.COLS) : 100, }; diff --git a/tests/render.test.ts b/tests/render.test.ts index c901ae5..3259fa2 100644 --- a/tests/render.test.ts +++ b/tests/render.test.ts @@ -435,4 +435,68 @@ describe('transform', () => { .join('\n'); expect(allText).toContain(''); }); + + it('compresses large tool_result text content across user messages', async () => { + const bigResult = 'output line. '.repeat(500); + const body = new TextEncoder().encode( + JSON.stringify({ + model: 'claude', + messages: [ + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'toolu_x', + content: bigResult, + }, + ], + }, + ], + system: 'claude.md\n'.repeat(500), + }), + ); + const { body: outBytes, info } = await transformRequest(body); + expect(info.toolResultImgs).toBeGreaterThanOrEqual(1); + + const out = JSON.parse(new TextDecoder().decode(outBytes)); + // Find the tool_result block and confirm its content is now image blocks. + const tr = (out.messages[0].content as any[]).find((b: any) => b.type === 'tool_result'); + expect(tr).toBeDefined(); + expect(Array.isArray(tr.content)).toBe(true); + const imgInner = (tr.content as any[]).filter((b: any) => b.type === 'image'); + expect(imgInner.length).toBeGreaterThanOrEqual(1); + // No cache_control on tool_result images. + for (const b of imgInner) expect(b.cache_control).toBeUndefined(); + }); + + it('leaves is_error tool_results untouched (Anthropic forbids images there)', async () => { + const bigResult = 'error trace. '.repeat(500); + const body = new TextEncoder().encode( + JSON.stringify({ + model: 'claude', + messages: [ + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'toolu_x', + content: bigResult, + is_error: true, + }, + ], + }, + ], + system: 'claude.md\n'.repeat(500), + }), + ); + const { body: outBytes, info } = await transformRequest(body); + expect(info.toolResultImgs ?? 0).toBe(0); + const out = JSON.parse(new TextDecoder().decode(outBytes)); + const tr = (out.messages[0].content as any[]).find((b: any) => b.type === 'tool_result'); + expect(tr).toBeDefined(); + expect(tr.is_error).toBe(true); + expect(typeof tr.content).toBe('string'); + }); });