mirror of
https://github.com/teamchong/pxpipe.git
synced 2026-07-22 02:02:51 +02:00
transform: compress large tool_result text content across user messages
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.
This commit is contained in:
@@ -36,6 +36,8 @@ export interface TrackEvent {
|
||||
/** Image count attributable to compressing `<system-reminder>` 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;
|
||||
|
||||
+76
-1
@@ -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<TransformOptions> = {
|
||||
compressTools: true,
|
||||
compressSchemas: true,
|
||||
compressReminders: true,
|
||||
compressToolResults: true,
|
||||
minCompressChars: 2000,
|
||||
// Matches Python defaults: 1000 chars for <system-reminder>.
|
||||
// Matches Python defaults: 1000 chars for <system-reminder>, 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 `<system-reminder>` 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<TextBlock | ImageBlock> = [];
|
||||
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;
|
||||
|
||||
+15
-3
@@ -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 <system-reminder> blocks
|
||||
--no-tool-results don't image-compress large tool_result content
|
||||
--min-chars <N> skip system compression below this many chars (default 2000)
|
||||
--min-reminder-chars <N> per-block threshold for --no-reminders (default 1000)
|
||||
--min-tool-result-chars <N> per-block threshold for --no-tool-results (default 2000)
|
||||
--placement <where> 'system' or 'user' (default user; 'system' is
|
||||
rejected by the API for image blocks)
|
||||
--cols <N> 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<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
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)');
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -435,4 +435,68 @@ describe('transform', () => {
|
||||
.join('\n');
|
||||
expect(allText).toContain('<system-reminder>');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user