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:
teamchong
2026-05-18 17:35:57 -04:00
parent 40037568e2
commit a5f61ba6ee
5 changed files with 162 additions and 4 deletions
+3
View File
@@ -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
View File
@@ -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;