diff --git a/CHANGELOG.md b/CHANGELOG.md index dd6cd195..e73ddb23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ Versions follow the merge of a `*_release-v*` branch; CI publishes to npm on tag ## [Unreleased] +### Changes + +- **Historical-image strip now uses the acp-kernel wire primitive (#617)**: the #618 opt-in strip (`compress.stripImages` / `compress.stripImagesKeepRecent`) was implemented locally (`src/strip-images.ts`, a per-protocol traversal mirroring `src/image-tokens.ts`). With the mechanism now sunk into acp-kernel's wire layer (kernel #215, shipped 0.0.58; this repo pins 0.0.59), the proxy calls `stripHistoricalImages` straight from `acp-kernel/wire` — identical semantics (recent-N kept, image-only messages collapse to a `[image]` placeholder, identity no-op) and a single source of truth shared with the pi/omp adapters (billion-context-pi#321). Local module deleted; only the host-side policy (config keys + default-5 constant) remains, moved to `src/compress-settings.ts`. No behavior change. + ### Fixes - **Lenient compress-arg parsing: salvage single-quoted JSON before hard rejection (#603)**: weak local models (reported via omp#121) emit `compress` args with single quotes (`{'content':[{'startId':...}]}`) — a malformation class the kernel's salvage ladder (fences, trailing commas, raw newlines, double-stringification, truncated/prose-wrapped arrays) does not cover, so the whole call was rejected `kind=malformed-json`, the round was wasted, and the model saw a FAILED result that can trigger tag-echoing. `parseCompressInput` now retries once through a quote-normalization pass when the kernel recovers zero ranges or reports invalid items: a state machine converts single-quoted strings to double-quoted ones (apostrophes inside double-quoted values are data and are copied verbatim; control characters inside single-quoted regions become JSON escapes), applied to raw-string args and to object inputs whose `content` value is a stringified array. The retry wins only when it recovers strictly more ranges — valid input is never rewritten — and salvaged ranges pass the same ref-validation gate as any other range, so the worst case is a wasted round, never a wrong compression. A `[acp-compress-input] quote-salvage: recovered N range(s)` warn logs each recovery for attribution. diff --git a/src/compress-settings.ts b/src/compress-settings.ts index 910cf2b5..4d7ec782 100644 --- a/src/compress-settings.ts +++ b/src/compress-settings.ts @@ -2,6 +2,12 @@ import { DEFAULT_ABSORB_CONFIG, defaultPrompts, resolvePrompts, type AbsorbConfi import { findRoute, type CompressSettings, type ProviderRoutes } from "./config.js"; import { log as loggerLog } from "./logger.js"; +/** Host-side policy default for `compress.stripImagesKeepRecent` (#617): how + * many of the most recent messages keep their image payloads when stripping + * is enabled. The strip mechanism itself lives in acp-kernel's wire layer + * (kernel #215) — only the opt-in policy stays host-side. */ +export const DEFAULT_STRIP_IMAGES_KEEP_RECENT = 5; + /** Resolve a raw `contextLimit` value to an absolute token count. * - `number` → used as-is (absolute window). * - `string` ending in `%` (e.g. `"70%"`) → that fraction of `nativeLimit`. diff --git a/src/config.ts b/src/config.ts index 43288b29..e50c5a6d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -151,11 +151,13 @@ export type CompressSettings = { toolName?: string; }; -/** Opt-in removal of historical image payloads (src/strip-images.ts). When - * true, every message except the most recent {@link stripImagesKeepRecent} - * has its image parts dropped before the wire rebuild (image-only content - * collapses to an "[image]" placeholder). Off by default — the #488 image - * floor / overflow 502 stays the opt-in signal until this is enabled. */ + /** Opt-in removal of historical image payloads, executed by the kernel's + * wire-layer primitive `stripHistoricalImages` from "acp-kernel/wire" + * (kernel #215; host-side policy only). When true, every message except + * the most recent {@link stripImagesKeepRecent} has its image parts dropped + * before the wire rebuild (image-only content collapses to an "[image]" + * placeholder). Off by default — the #488 image floor / overflow 502 stays + * the opt-in signal until this is enabled. */ stripImages?: boolean; /** With {@link stripImages}, how many trailing messages keep their images * verbatim (default 5). Ignored unless stripImages is true. */ diff --git a/src/server.ts b/src/server.ts index c594eef6..56caf4d2 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,8 +2,7 @@ import http from "node:http"; import fs from "node:fs"; import { randomUUID } from "node:crypto"; import { createCore, type CompressionCore, type CompressionState, type Config, type CoreMessage, type NudgeDecision, type Prompts, defaultPrompts, defaultCountTokens, estimateTokensFast, renderNudgeText, deactivateBlock, viableRanges } from "acp-kernel"; -import { resolveCompress, resolveCompressPrompts, resolveRequestConfig } from "./compress-settings.js"; -import { DEFAULT_STRIP_IMAGES_KEEP_RECENT, stripHistoricalImages } from "./strip-images.js"; +import { DEFAULT_STRIP_IMAGES_KEEP_RECENT, resolveCompress, resolveCompressPrompts, resolveRequestConfig } from "./compress-settings.js"; import type { ProxyOptions } from "./config.js"; import { loadOptions, loadRoutes } from "./config.js"; import { resetProxyCache } from "./upstream-proxy.js"; @@ -13,14 +12,16 @@ import { codexAlignedWindow } from "./codex-models.js"; import { fetchWithTimeout, MAX_REQUEST_BYTES } from "./fetch-util.js"; import { formatUpstreamError, getUpstreamConnectionStatus, recordUpstreamConnection, resolveProxy, resolveProxyDecision, proxyDispatcher, type UpstreamProxyDecision } from "./upstream-proxy.js"; import { maskHeaderForLog, maskHeadersForLog, maskHostPortForLog, maskUrlForLog, maskUrlsInText } from "./log-mask.js"; -// Protocol codecs live in the kernel now (single source of truth shared with -// the omp/pi adapters): import from "acp-kernel/wire". +// Protocol codecs + the historical-image strip primitive live in the kernel now +// (single source of truth shared with the omp/pi adapters): import from +// "acp-kernel/wire" (kernel #215). import { anthropicToCore, coreToAnthropic, conversationSignalAnthropic, extractSystem, buildSystem, + stripHistoricalImages, type AnthropicRequestBody, } from "acp-kernel/wire"; import { diff --git a/src/strip-images.ts b/src/strip-images.ts deleted file mode 100644 index f50379f8..00000000 --- a/src/strip-images.ts +++ /dev/null @@ -1,93 +0,0 @@ -// #617: opt-in removal of HISTORICAL image payloads. Old base64 screenshots -// otherwise ride along verbatim on every request (the wire codecs move them -// out of CoreMessage.text into sidecars, so compression folds the TEXT but the -// raw image bytes are forwarded anyway — see #488). When enabled, every message -// EXCEPT the most recent `keepRecent` has its image parts dropped before the -// wire rebuild; image-only content collapses to a "[image]" text placeholder so -// message count / role ordering stay stable. Recent-N images survive untouched. -// -// Pure function over the RAW parsed request body (mirrors the per-protocol -// traversal in src/image-tokens.ts, so a stripped body drives -// imageTokensInRawBody → 0 and clears the #488 image floor). Returns the input -// reference unchanged when nothing changed, so an image-free or disabled body -// is byte-identical downstream. Content-hash message ids shift once per message -// when it ages out of the recent-N window (self-healing via orphan-GC). - -export type StripProtocol = "anthropic" | "openai" | "responses" | null; - -export interface StripResult { - body: unknown; - /** Number of image parts removed (0 when the body is returned unchanged). */ - removed: number; -} - -export const DEFAULT_STRIP_IMAGES_KEEP_RECENT = 5; - -const IMAGE_PLACEHOLDER = "[image]"; - -function isObj(v: unknown): v is Record { - return typeof v === "object" && v !== null; -} - -function isImagePart(protocol: Exclude, part: unknown): boolean { - if (!isObj(part)) return false; - if (protocol === "responses") return part.type === "input_image"; - if (protocol === "openai") return part.type === "image_url"; - return part.type === "image"; -} - -/** A single text part standing in for a dropped image-only payload. Responses - * uses `input_text`; OpenAI chat + Anthropic use `text`. */ -function placeholderContent(protocol: Exclude): Record[] { - const type = protocol === "responses" ? "input_text" : "text"; - return [{ type, text: IMAGE_PLACEHOLDER }]; -} - -export function stripHistoricalImages(body: unknown, protocol: StripProtocol, keepRecent: number): StripResult { - if (!protocol || !isObj(body)) return { body, removed: 0 }; - const recentCount = Math.max(0, Math.floor(keepRecent)); - - if (protocol === "responses") { - const input = body.input; - if (!Array.isArray(input)) return { body, removed: 0 }; - const cutoff = input.length - recentCount; - let removed = 0; - let touched = false; - const nextInput = input.map((item, i) => { - if (i < cutoff && isObj(item) && Array.isArray(item.content)) { - const content = item.content as unknown[]; - const imgs = content.filter((p) => isImagePart("responses", p)).length; - if (imgs > 0) { - removed += imgs; - touched = true; - const kept = content.filter((p) => !isImagePart("responses", p)); - return { ...item, content: kept.length > 0 ? kept : placeholderContent("responses") }; - } - } - return item; - }); - if (!touched) return { body, removed: 0 }; - return { body: { ...body, input: nextInput }, removed }; - } - - const messages = body.messages; - if (!Array.isArray(messages)) return { body, removed: 0 }; - const cutoff = messages.length - recentCount; - let removed = 0; - let touched = false; - const nextMessages = messages.map((m, i) => { - if (i < cutoff && isObj(m) && Array.isArray(m.content)) { - const content = m.content as unknown[]; - const imgs = content.filter((p) => isImagePart(protocol, p)).length; - if (imgs > 0) { - removed += imgs; - touched = true; - const kept = content.filter((p) => !isImagePart(protocol, p)); - return { ...m, content: kept.length > 0 ? kept : placeholderContent(protocol) }; - } - } - return m; - }); - if (!touched) return { body, removed: 0 }; - return { body: { ...body, messages: nextMessages }, removed }; -} diff --git a/tests/strip-images.test.ts b/tests/strip-images.test.ts index 6d61f788..e5eef115 100644 --- a/tests/strip-images.test.ts +++ b/tests/strip-images.test.ts @@ -1,6 +1,9 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { DEFAULT_STRIP_IMAGES_KEEP_RECENT, stripHistoricalImages } from "../src/strip-images.ts"; +// The mechanism moved to acp-kernel's wire layer (kernel #215); these tests +// stay as host-level regression coverage over the bundled kernel export. +import { stripHistoricalImages } from "acp-kernel/wire"; +import { DEFAULT_STRIP_IMAGES_KEEP_RECENT } from "../src/compress-settings.ts"; test("default keep-recent constant is 5", () => { assert.equal(DEFAULT_STRIP_IMAGES_KEEP_RECENT, 5);