From a464214860fcc250df71b652bb17cbeb993f1f6c Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Thu, 10 Sep 2026 00:17:51 +0800 Subject: [PATCH 1/3] fix(#651): drop oversized reasoning from closed compress turns Models that keep reasoning/thinking on the wire build a permanent uncompressible floor: the fold anchor is a compress call, and any reasoning sitting before that anchor survives every fold as protected prefix. Storm sessions hit ~50% visible context lost this way. Proxy-side twin of billion-context-pi #339 / opencode-acp #377: - new src/reasoning-drop.ts: dropCompressReasoning() strips the contiguous reasoning run immediately preceding a CLOSED compress call (result present + genuine user message after) when the summed run exceeds a char threshold (default 2048). Active rounds, other tools' reasoning, and non-contiguous runs are never touched. - resolveCompress() now merges a third nested-object field (reasoning) sub-field-wise like absorb/prompts. - all three wire prepares (anthropic/openai/responses) apply the drop after stripKernelSummaries, before wire rebuild. - new config: compress.reasoning = { drop = true, threshold = 2048 }. - docs: CONFIGURATION.md + zh-CN; 17 new tests (1263 total green). --- CONFIGURATION.md | 9 +++ CONFIGURATION.zh-CN.md | 9 +++ src/compress-settings.ts | 5 ++ src/config.ts | 14 +++++ src/reasoning-drop.ts | 81 ++++++++++++++++++++++++ src/server.ts | 35 +++++++++-- tests/reasoning-drop.test.ts | 116 +++++++++++++++++++++++++++++++++++ 7 files changed, 263 insertions(+), 6 deletions(-) create mode 100644 src/reasoning-drop.ts create mode 100644 tests/reasoning-drop.test.ts diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 8236a36f..37702ef0 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -291,6 +291,15 @@ For each request, the proxy resolves the settings by longest-URL-prefix match (t - `toolName: string` — rename the injected tool (default `"absorb"`); the schema, system-prompt section and per-session adjudication all follow the name. Injection follows the wire's native-tool surface: proxy mode injects the tool + a static system-prompt section on the anthropic/openai/responses native-tools wires, plugin mode advertises it in the plugin manifest (the MCP shell picks it up for free). Responses **marker/text-protocol** routes are not supported (no native tool surface — the REQUIRED absorb instruction would be unsatisfiable), and title-generation requests (`max_tokens ≤ 200`) skip injection like the compress prompt does. Absorbed pairs stay hidden across restarts (persisted in the session state). +#### `reasoning` + +- **Type:** `object` (`{ drop?, threshold? }`) +- **Default:** `drop: true`, `threshold: 2048` — on +- **Status:** ACTIVE +- **Description:** **Compress-reasoning hygiene** (issue #651, the proxy-side twin of `billion-context-pi` #339 / `opencode-acp` #377). Models that keep their `reasoning`/`thinking` traces on the wire accumulate a permanent uncompressible floor: the anchor of a fold is a `compress` call, and any reasoning messages sitting *before* that call survive every fold as part of the protected prefix — they can never be re-summarized, only stripped. In the storm sessions this floor reached ~50% of the visible context. When on, the proxy removes the reasoning run that immediately precedes a **closed** `compress` call — i.e. one that already has its tool result and is followed by a genuine user message — when that run exceeds `threshold` characters. Safety gates: the *active* round (compress still in flight, no user message after it yet) is never touched; runs of ordinary tool calls (`read`, `bash`, …) keep their reasoning; a run is judged by its summed length so a 2×1200-char run still trips a 2048 gate; non-contiguous reasoning (text between the fragments) is left alone. Sub-fields (merged deepest-wins like every other CompressSettings field): + - `drop: boolean` — kill-switch; `false` restores the old wire verbatim. + - `threshold: number` — character gate; runs **strictly greater** than this are dropped (`0` = drop any non-empty run). Invalid values fall back to the default instead of throwing. + #### `stripImages` - **Type:** `boolean` diff --git a/CONFIGURATION.zh-CN.md b/CONFIGURATION.zh-CN.md index 884c8ec8..c0d40ae3 100644 --- a/CONFIGURATION.zh-CN.md +++ b/CONFIGURATION.zh-CN.md @@ -289,6 +289,15 @@ - `toolName: string` — 重命名注入工具(默认 `"absorb"`);模式、系统提示段与按会话裁决都跟随名称。 注入跟随线上原生工具面:代理模式在 anthropic/openai/responses 原生工具线上注入工具 + 静态系统提示段,插件模式在插件清单中广告它(MCP shell 自动拾取)。Responses **marker/文本协议**路由不支持(无原生工具面 — 强制的 absorb 指令不可满足),标题生成请求(`max_tokens ≤ 200`)跳过注入如压缩提示一样。吸收配对在重启后保持隐藏(在会话状态持久化)。 +#### `reasoning` + +- **类型:** `object`(`{ drop?, threshold? }`) +- **默认值:** `drop: true`、`threshold: 2048` — 默认开启 +- **状态:** ACTIVE +- **说明:** **压缩回执 reasoning 卫生**(issue #651,对应 `billion-context-pi` #339 / `opencode-acp` #377 的代理侧孪生)。把 `reasoning`/`thinking` 轨迹留在 wire 上的模型会积累一块永久不可压缩的地板:折叠的锚点是一条 `compress` 调用,而它**前方**的 reasoning 消息会作为受保护前缀活过每一次折叠——它们永远无法被重新摘要,只能被剥离。在风暴会话里这块地板曾占到可见上下文的 ~50%。开启后,代理会剥离紧邻**已闭合** `compress` 调用(即已拿到工具结果、且其后存在真实用户消息)之前的 reasoning 连续段,条件是该段总长超过 `threshold` 字符。安全门:**活跃回合**(compress 仍在飞行中、其后还没有用户消息)绝不动;普通工具调用(`read`、`bash` …)的 reasoning 保留;连续段按求和后的总长判定(2×1200 字符的段仍会命中 2048 门槛);不连续的 reasoning(片段之间夹着正文)不动。子字段与其他 CompressSettings 字段一样按“深层覆盖”合并: + - `drop: boolean` — 总开关;`false` 完整还原旧行为。 + - `threshold: number` — 字符门槛;**严格大于**该值的段才被剥离(`0` = 只要非空就剥)。非法值回退默认而不是报错。 + #### `stripImages` - **类型:** `boolean` diff --git a/src/compress-settings.ts b/src/compress-settings.ts index 910cf2b5..12d6075d 100644 --- a/src/compress-settings.ts +++ b/src/compress-settings.ts @@ -51,6 +51,7 @@ export function mergeCompress( // like `prompts`: a model-level minToolTokens must not discard a // provider-level excludeTools. const absorbLevels = [global?.absorb, provider?.absorb, model?.absorb].filter(Boolean) as NonNullable[]; + const reasoningLevels = [global?.reasoning, provider?.reasoning, model?.reasoning].filter(Boolean) as NonNullable[]; return { modelContextLimit: pick("modelContextLimit"), maxContextLimit: pick("maxContextLimit"), @@ -66,6 +67,10 @@ export function mergeCompress( stripImages: pick("stripImages"), stripImagesKeepRecent: pick("stripImagesKeepRecent"), + // `reasoning` is a third nested-object field merged sub-field-wise + // exactly like `absorb`/`prompts`: a model-level `threshold` must not + // discard a provider-level `drop: false`. + reasoning: reasoningLevels.length > 0 ? Object.assign({}, ...reasoningLevels) : undefined, }; } diff --git a/src/config.ts b/src/config.ts index 43288b29..2ba810e7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -160,6 +160,20 @@ export type CompressSettings = { /** With {@link stripImages}, how many trailing messages keep their images * verbatim (default 5). Ignored unless stripImages is true. */ stripImagesKeepRecent?: number; + /** [#651] Drop oversized reasoning (thinking) from closed-turn `compress` + * tool calls at request time (src/reasoning-drop.ts, aligned with + * billion-context-pi #336/#339 and opencode-acp #377). Compress turns + * are hard-exempt from compression, so their reasoning is otherwise an + * unreclaimable context floor. Merged sub-field-wise across the three + * config levels like `absorb`. */ + reasoning?: { + /** Master switch (default true). Set `drop: false` per-provider for + * models whose reasoning must round-trip unmodified. */ + drop?: boolean; + /** A closed turn's reasoning run must exceed this many chars to be + * dropped (default 2048). */ + threshold?: number; + }; }; export type PromptCacheRouting = "auto" | "enabled" | "disabled"; export type UpstreamProxyMode = "auto" | "manual" | "direct"; diff --git a/src/reasoning-drop.ts b/src/reasoning-drop.ts new file mode 100644 index 00000000..541f8d66 --- /dev/null +++ b/src/reasoning-drop.ts @@ -0,0 +1,81 @@ +import type { BiliMessage } from "acp-kernel/wire"; + +/** [#651] Drop oversized reasoning (thinking) from closed-turn `compress` + * tool calls at request time — the billion-context twin of + * billion-context-pi #336/#339, aligned with opencode-acp #377. + * `compress` tool messages are hard-exempt from compression (their tool + * results are the anchors that keep block summaries addressable), so the + * reasoning attached to those turns rides along EVERY forwarded request as + * an unreclaimable context floor — measured at ~83.5% of the never-covered + * residual on real long sessions, growing ~9 KB per compression round. + * This pass removes those reasoning messages from the OUTBOUND view only + * (persisted history and kernel state are never modified) once the turn is + * closed and the reasoning run exceeds the size gate. The active round + * (from the last genuine user message onward) is never touched. */ +export interface CompressReasoningConfig { + /** Master switch. Default: true. `drop: false` disables the pass entirely + * (kill-switch — set it per-provider for models whose reasoning items + * are opaque and MUST round-trip unmodified, e.g. chat models that + * reject requests whose reasoning_content is not echoed back). */ + drop?: boolean; + /** Size gate (chars): the reasoning run attached to a closed-turn + * `compress` call must total STRICTLY more than this to be dropped. + * Default: 2048. `0` drops any non-empty run. */ + threshold?: number; +} + +export const DEFAULT_COMPRESS_REASONING: Required = { drop: true, threshold: 2048 }; + +export function resolveReasoningDrop(cfg?: CompressReasoningConfig): Required { + let threshold = DEFAULT_COMPRESS_REASONING.threshold; + if (cfg?.threshold !== undefined) { + const t = cfg.threshold; + if (typeof t === "number" && Number.isFinite(t) && t >= 0) { + threshold = Math.floor(t); + } + } + return { drop: cfg?.drop !== false, threshold }; +} + +/** Request-time pass: remove reasoning messages attached to a `compress` + * tool call only when ALL gates hold — + * 1. closed turn: the compress call sits strictly before the last genuine + * user message (`role: "user"` + `contentType: "text"`; tool results are + * not genuine users). With no user message at all, nothing is dropped; + * 2. selector: `contentType: "tool-call"` with `toolName === "compress"` + * (other protected tools would need their own explicit config); + * 3. size: the run of reasoning messages immediately preceding the call + * (contiguous, as emitted by anthropicToCore/openaiToCore/responsesToCore) + * totals strictly more than `threshold` chars. + * Pure: never mutates the input; idempotent; fail-safe (any error returns + * the input unchanged). */ +export function dropCompressReasoning(messages: BiliMessage[], cfg?: CompressReasoningConfig): BiliMessage[] { + const { drop, threshold } = resolveReasoningDrop(cfg); + if (!drop || messages.length === 0) return messages; + try { + let lastUser = -1; + for (let i = 0; i < messages.length; i++) { + const m = messages[i]!; + if (m.role === "user" && m.contentType === "text") lastUser = i; + } + if (lastUser < 0) return messages; + const dropIdx = new Set(); + for (let i = 0; i < lastUser; i++) { + const m = messages[i]!; + if (m.contentType !== "tool-call" || m.toolName !== "compress") continue; + let total = 0; + let j = i - 1; + while (j >= 0 && messages[j]!.contentType === "reasoning") { + total += (messages[j]!.text ?? "").length; + j--; + } + if (total > threshold) { + for (let k = j + 1; k < i; k++) dropIdx.add(k); + } + } + if (dropIdx.size === 0) return messages; + return messages.filter((_, idx) => !dropIdx.has(idx)); + } catch { + return messages; + } +} diff --git a/src/server.ts b/src/server.ts index c594eef6..f00f367d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,6 +3,7 @@ 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 { dropCompressReasoning, type CompressReasoningConfig } from "./reasoning-drop.js"; import { DEFAULT_STRIP_IMAGES_KEEP_RECENT, stripHistoricalImages } from "./strip-images.js"; import type { ProxyOptions } from "./config.js"; import { loadOptions, loadRoutes } from "./config.js"; @@ -1558,6 +1559,7 @@ async function handle( await withSessionLock(session, async () => { const runPrepare = (): Prepared => { const cs = resolveCompress(opts.routes, route?.rewrittenUrl, (parsed as { model?: string }).model, opts.compress); + const reasoningCfg = cs.reasoning; const keepRecent = cs.stripImagesKeepRecent ?? DEFAULT_STRIP_IMAGES_KEEP_RECENT; const stripped = cs.stripImages ? stripHistoricalImages(parsed, protocol, keepRecent) @@ -1569,16 +1571,16 @@ async function handle( return countTokens ? prepareCountTokens(work as AnthropicRequestBody, core, reqConfig, log, session) : protocol === "anthropic" - ? prepareAnthropic(work as AnthropicRequestBody, req, opts, core, reqConfig, reqPrompts, log, session, pluginMode) + ? prepareAnthropic(work as AnthropicRequestBody, req, opts, core, reqConfig, reqPrompts, log, session, pluginMode, reasoningCfg) : protocol === "openai" - ? prepareOpenai(work as OpenAIRequestBody, req, opts, core, reqConfig, reqPrompts, log, session, pluginMode, nativeWindow) + ? prepareOpenai(work as OpenAIRequestBody, req, opts, core, reqConfig, reqPrompts, log, session, pluginMode, nativeWindow, reasoningCfg) : responsesCompact // #618 review nit: when no bili compaction item is present, // prepareResponsesCompact falls back to the raw bodyBuffer — forward // the re-serialized post-strip work instead so dropped images don't // ride along. Unchanged bodies keep the original buffer byte-identical. ? prepareResponsesCompact(stripped.removed > 0 ? Buffer.from(JSON.stringify(work)) : bodyBuffer, work as ResponsesRequestBody, session, req, core, reqConfig, log) - : prepareResponses(work as ResponsesRequestBody, req, opts, core, reqConfig, reqPrompts, log, session, responsesIdentity!, pluginMode, upstreamOrigin, nativeWindow); + : prepareResponses(work as ResponsesRequestBody, req, opts, core, reqConfig, reqPrompts, log, session, responsesIdentity!, pluginMode, upstreamOrigin, nativeWindow, reasoningCfg); }; // #332: codex's native remote-compaction request (trigger form) // is dispatched BEFORE prepare/preflight. When it is not @@ -1706,6 +1708,21 @@ const ACP_TAG_MARK = "\x3cacp "; // nonexistent (preflight), so acp_summary survives as the carrier and // systemToUser later re-voices the survivors as USER messages (leaving them at // their anchors) for strict backends (#377). +/** [#651] Strip oversized reasoning from closed compress turns (see + * src/reasoning-drop.ts) with an ops log line when anything was dropped. */ +function withReasoningDrop( + msgs: BiliMessage[], + reasoning: CompressReasoningConfig | undefined, + log: (level: string, msg: string) => void, + sessionId: string, +): BiliMessage[] { + const out = dropCompressReasoning(msgs, reasoning); + if (out.length !== msgs.length) { + log("info", `[${sessionId}] compress-reasoning: dropped ${msgs.length - out.length} reasoning message(s) from closed compress turns (#651)`); + } + return out; +} + export function stripKernelSummaries(messages: BiliMessage[], state: CompressionState): BiliMessage[] { const carried = new Set(); for (const b of state.blocks) { @@ -1859,12 +1876,14 @@ function prepareAnthropic( log: (level: string, msg: string) => void, session: Session, pluginMode: boolean, + reasoning: CompressReasoningConfig | undefined, ): Prepared { const sessionId = session.id; const stream = parsed.stream === true; ++session.stats.requests; session.hostCreditTokens = 0; const injectTools = opts.compress.injectTool && !pluginMode; + const stripReasoning = (msgs: BiliMessage[]): BiliMessage[] => withReasoningDrop(msgs, reasoning, log, sessionId); if (isAutoModeClassifier(parsed)) { log("info", `[${sessionId}] auto-mode classifier passthrough (skipping compress injection)`); @@ -1930,7 +1949,7 @@ function prepareAnthropic( log("info", diagTagSummary(turn.messages, sessionId, "text-only")); const willInjectNudge = opts.compress.injectNudge && !!turn.nudge && (turn.nudge.shouldInject || emergencyNudge(turn.nudge)); log("info", diagNudge(turn, sessionId, tokenCount, config.modelContextLimit, parsed.model, willInjectNudge)); - processedMessages = stripKernelSummaries(turn.messages, turn.state); + processedMessages = stripReasoning(stripKernelSummaries(turn.messages, turn.state)); applyCompactionArchive(session, activeBefore, new Set(msgs.map((m) => m.id)), log); reapOrphanBlocks(session, msgs, deactivateBlock); rebuiltMessages = coreToAnthropic(processedMessages as BiliMessage[], cacheControls); @@ -2094,12 +2113,14 @@ function prepareOpenai( session: Session, pluginMode: boolean, nativeWindow: number, + reasoning: CompressReasoningConfig | undefined, ): Prepared { const sessionId = session.id; const stream = parsed.stream === true; ++session.stats.requests; session.hostCreditTokens = 0; let openaiSystemText = ""; + const stripReasoning = (msgs: BiliMessage[]): BiliMessage[] => withReasoningDrop(msgs, reasoning, log, sessionId); let openaiOutboundSystem: string | undefined; let processedMessages: CoreMessage[] = []; let originalMessages: CoreMessage[] = []; @@ -2161,7 +2182,7 @@ function prepareOpenai( log("info", diagTagSummary(turn.messages, sessionId, "text-only")); const willInjectNudge = opts.compress.injectNudge && !!turn.nudge && shouldInject && (turn.nudge.shouldInject || emergencyNudge(turn.nudge)); log("info", diagNudge(turn, sessionId, tokenCount, config.modelContextLimit, parsed.model, willInjectNudge)); - processedMessages = stripKernelSummaries(turn.messages, turn.state); + processedMessages = stripReasoning(stripKernelSummaries(turn.messages, turn.state)); applyCompactionArchive(session, activeBefore, new Set(msgs.map((m) => m.id)), log); reapOrphanBlocks(session, msgs, deactivateBlock); rebuiltMessages = systemToUser(coreToOpenai(processedMessages as BiliMessage[])); @@ -2248,11 +2269,13 @@ function prepareResponses( pluginMode: boolean, upstreamOrigin: string, nativeWindow: number, + reasoning: CompressReasoningConfig | undefined, ): Prepared { const sessionId = session.id; const stream = parsed.stream === true; ++session.stats.requests; session.hostCreditTokens = 0; + const stripReasoning = (msgs: BiliMessage[]): BiliMessage[] => withReasoningDrop(msgs, reasoning, log, sessionId); if (reconcileNativeCompactionBoundary(session)) { log("info", `[${sessionId}] reconciled ACP state after native Responses compact boundary`); } @@ -2350,7 +2373,7 @@ function prepareResponses( log("info", diagTagSummary(turn.messages, sessionId, "text-only")); const willInjectNudge = opts.compress.injectNudge && !!turn.nudge && shouldInject && !isCompactionTrigger && (turn.nudge.shouldInject || emergencyNudge(turn.nudge)); log("info", diagNudge(turn, sessionId, tokenCount, config.modelContextLimit, parsed.model, willInjectNudge)); - processedMessages = repairResponsesAssistantOrdering(stripKernelSummaries(turn.messages, turn.state), originalMessages); + processedMessages = repairResponsesAssistantOrdering(stripReasoning(stripKernelSummaries(turn.messages, turn.state)), originalMessages); reapOrphanBlocks(session, msgs, deactivateBlock); rebuiltInput = patchResponsesInput(projection, processedMessages); // Fallback path: when the echo did NOT come back this turn (client diff --git a/tests/reasoning-drop.test.ts b/tests/reasoning-drop.test.ts new file mode 100644 index 00000000..efef1862 --- /dev/null +++ b/tests/reasoning-drop.test.ts @@ -0,0 +1,116 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { dropCompressReasoning, resolveReasoningDrop, DEFAULT_COMPRESS_REASONING, type CompressReasoningConfig } from "../src/reasoning-drop.ts"; +import { mergeCompress } from "../src/compress-settings.ts"; +import type { BiliMessage } from "acp-kernel/wire"; + +const R = (text: string, id = "r"): BiliMessage => ({ id, role: "assistant", contentType: "reasoning", text }); +const CALL = (toolName = "compress", id = "c"): BiliMessage => ({ id, role: "assistant", contentType: "tool-call", toolName, toolCallId: "t1", text: "{}" }); +const RESULT = (toolName = "compress"): BiliMessage => ({ id: "res", role: "user", contentType: "tool-result", toolName, toolCallId: "t1", text: "ok" }); +const USER = (text = "hi"): BiliMessage => ({ id: "u", role: "user", contentType: "text", text }); + +test("default: drops oversized reasoning run before a closed compress call", () => { + const msgs = [R("x".repeat(3000)), CALL(), RESULT(), USER("next")]; + const out = dropCompressReasoning(msgs); + assert.equal(out.length, 3); + assert.ok(!out.some((m) => m.contentType === "reasoning")); + assert.ok(out.some((m) => m.contentType === "tool-call" && m.toolName === "compress")); +}); + +test("small reasoning survives the default threshold", () => { + const msgs = [R("x".repeat(1000)), CALL(), RESULT(), USER("next")]; + assert.equal(dropCompressReasoning(msgs).length, 4); +}); + +test("exactly-threshold reasoning is kept (strictly-greater gate)", () => { + const msgs = [R("x".repeat(DEFAULT_COMPRESS_REASONING.threshold)), CALL(), RESULT(), USER("next")]; + assert.equal(dropCompressReasoning(msgs).length, 4); +}); + +test("active round is never touched (compress call after the last user message)", () => { + const msgs = [USER("q"), R("x".repeat(3000)), CALL(), RESULT()]; + assert.equal(dropCompressReasoning(msgs).length, 4); +}); + +test("no genuine user message means no drop", () => { + const msgs = [R("x".repeat(3000)), CALL(), RESULT()]; + assert.equal(dropCompressReasoning(msgs).length, 3); +}); + +test("tool-result messages are not genuine users", () => { + const msgs = [R("x".repeat(3000)), CALL("read"), RESULT("read"), USER("next")]; + assert.equal(dropCompressReasoning(msgs).length, 4); +}); + +test("only compress calls select the drop — other tool calls keep their reasoning", () => { + const msgs = [R("x".repeat(3000)), CALL("read"), RESULT("read"), USER("next")]; + assert.ok(dropCompressReasoning(msgs).some((m) => m.contentType === "reasoning")); +}); + +test("multi-message reasoning run is summed before the gate", () => { + const msgs = [R("x".repeat(1200), "a"), R("y".repeat(1200), "b"), CALL(), RESULT(), USER("next")]; + const out = dropCompressReasoning(msgs); + assert.equal(out.length, 3); + assert.ok(!out.some((m) => m.contentType === "reasoning")); +}); + +test("non-contiguous reasoning is not attributed to the compress call", () => { + const interlude: BiliMessage = { id: "txt", role: "assistant", contentType: "text", text: "hm" }; + const msgs = [R("x".repeat(1200)), interlude, R("y".repeat(1200)), CALL(), RESULT(), USER("next")]; + const out = dropCompressReasoning(msgs); + assert.equal(out.length, 6); +}); + +test("threshold 0 drops any non-empty run", () => { + const msgs = [R("tiny"), CALL(), RESULT(), USER("next")]; + assert.equal(dropCompressReasoning(msgs, { threshold: 0 }).length, 3); +}); + +test("drop:false is a kill-switch", () => { + const msgs = [R("x".repeat(3000)), CALL(), RESULT(), USER("next")]; + assert.equal(dropCompressReasoning(msgs, { drop: false }).length, 4); +}); + +test("purity: the input array is never mutated", () => { + const msgs = [R("x".repeat(3000)), CALL(), RESULT(), USER("next")]; + const snapshot = JSON.stringify(msgs); + dropCompressReasoning(msgs); + assert.equal(JSON.stringify(msgs), snapshot); +}); + +test("idempotence: a second pass changes nothing", () => { + const msgs = [R("x".repeat(3000)), CALL(), RESULT(), USER("next")]; + const once = dropCompressReasoning(msgs); + assert.equal(dropCompressReasoning(once).length, once.length); +}); + +test("resolveReasoningDrop: defaults, validation, and passthrough", () => { + assert.deepEqual(resolveReasoningDrop(undefined), { drop: true, threshold: 2048 }); + assert.deepEqual(resolveReasoningDrop({}), { drop: true, threshold: 2048 }); + assert.deepEqual(resolveReasoningDrop({ drop: false, threshold: 0 }), { drop: false, threshold: 0 }); + assert.deepEqual(resolveReasoningDrop({ threshold: -3 }), { drop: true, threshold: 2048 }); + assert.deepEqual(resolveReasoningDrop({ threshold: 512.9 }), { drop: true, threshold: 512 }); +}); + +test("multiple closed compress turns all get stripped", () => { + const msgs = [ + R("a".repeat(3000), "r1"), CALL("compress", "c1"), RESULT(), + R("b".repeat(3000), "r2"), CALL("compress", "c2"), RESULT(), + USER("next"), + ]; + const out = dropCompressReasoning(msgs); + assert.equal(out.length, 5); + assert.ok(!out.some((m) => m.contentType === "reasoning")); +}); + +test("mergeCompress merges reasoning sub-field-wise across levels", () => { + assert.deepEqual(mergeCompress({ reasoning: { threshold: 1024 } }, undefined, { reasoning: { drop: false } }).reasoning, { threshold: 1024, drop: false }); + assert.equal(mergeCompress(undefined, undefined, undefined).reasoning, undefined); + assert.deepEqual(mergeCompress({ reasoning: { threshold: 100 } }, { reasoning: { threshold: 200 } }, undefined).reasoning, { threshold: 200 }); +}); + +test("empty input and empty config short-circuit", () => { + assert.deepEqual(dropCompressReasoning([]), []); + const cfg: CompressReasoningConfig = {}; + assert.equal(dropCompressReasoning([R("x"), CALL(), RESULT(), USER()], cfg).length, 4); +}); From bd8fd8ae375faeb03f3fa9df6577ed236cfc606a Mon Sep 17 00:00:00 2001 From: ework-agent Date: Thu, 10 Sep 2026 00:36:40 +0800 Subject: [PATCH 2/3] =?UTF-8?q?docs:=20name=20the=20kill-switch=20use=20ca?= =?UTF-8?q?se=20=E2=80=94=20round-trip-requiring=20thinking=20models=20(#6?= =?UTF-8?q?51)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONFIGURATION.md | 5 ++++- CONFIGURATION.zh-CN.md | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 37702ef0..078abf1d 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -297,7 +297,10 @@ For each request, the proxy resolves the settings by longest-URL-prefix match (t - **Default:** `drop: true`, `threshold: 2048` — on - **Status:** ACTIVE - **Description:** **Compress-reasoning hygiene** (issue #651, the proxy-side twin of `billion-context-pi` #339 / `opencode-acp` #377). Models that keep their `reasoning`/`thinking` traces on the wire accumulate a permanent uncompressible floor: the anchor of a fold is a `compress` call, and any reasoning messages sitting *before* that call survive every fold as part of the protected prefix — they can never be re-summarized, only stripped. In the storm sessions this floor reached ~50% of the visible context. When on, the proxy removes the reasoning run that immediately precedes a **closed** `compress` call — i.e. one that already has its tool result and is followed by a genuine user message — when that run exceeds `threshold` characters. Safety gates: the *active* round (compress still in flight, no user message after it yet) is never touched; runs of ordinary tool calls (`read`, `bash`, …) keep their reasoning; a run is judged by its summed length so a 2×1200-char run still trips a 2048 gate; non-contiguous reasoning (text between the fragments) is left alone. Sub-fields (merged deepest-wins like every other CompressSettings field): - - `drop: boolean` — kill-switch; `false` restores the old wire verbatim. + - `drop: boolean` — kill-switch; `false` restores the old wire verbatim. Required per-provider for thinking models that mandate `reasoning` round-trip while the request carries `tools` — DeepSeek, GLM thinking and Qwen-QwQ return HTTP 400 when a prior `reasoning_content` is not echoed back: + ```jsonc + "providers": { "https://api.deepseek.com": { "compress": { "reasoning": { "drop": false } } } } + ``` - `threshold: number` — character gate; runs **strictly greater** than this are dropped (`0` = drop any non-empty run). Invalid values fall back to the default instead of throwing. #### `stripImages` diff --git a/CONFIGURATION.zh-CN.md b/CONFIGURATION.zh-CN.md index c0d40ae3..3802e8f1 100644 --- a/CONFIGURATION.zh-CN.md +++ b/CONFIGURATION.zh-CN.md @@ -295,7 +295,10 @@ - **默认值:** `drop: true`、`threshold: 2048` — 默认开启 - **状态:** ACTIVE - **说明:** **压缩回执 reasoning 卫生**(issue #651,对应 `billion-context-pi` #339 / `opencode-acp` #377 的代理侧孪生)。把 `reasoning`/`thinking` 轨迹留在 wire 上的模型会积累一块永久不可压缩的地板:折叠的锚点是一条 `compress` 调用,而它**前方**的 reasoning 消息会作为受保护前缀活过每一次折叠——它们永远无法被重新摘要,只能被剥离。在风暴会话里这块地板曾占到可见上下文的 ~50%。开启后,代理会剥离紧邻**已闭合** `compress` 调用(即已拿到工具结果、且其后存在真实用户消息)之前的 reasoning 连续段,条件是该段总长超过 `threshold` 字符。安全门:**活跃回合**(compress 仍在飞行中、其后还没有用户消息)绝不动;普通工具调用(`read`、`bash` …)的 reasoning 保留;连续段按求和后的总长判定(2×1200 字符的段仍会命中 2048 门槛);不连续的 reasoning(片段之间夹着正文)不动。子字段与其他 CompressSettings 字段一样按“深层覆盖”合并: - - `drop: boolean` — 总开关;`false` 完整还原旧行为。 + - `drop: boolean` — 总开关;`false` 完整还原旧行为。请求携带 `tools` 时要求 `reasoning` 原样往返的 thinking 模型必须按 provider 关闭——DeepSeek、GLM thinking、Qwen-QwQ 在未回传先前 `reasoning_content` 时返回 HTTP 400: + ```jsonc + "providers": { "https://api.deepseek.com": { "compress": { "reasoning": { "drop": false } } } } + ``` - `threshold: number` — 字符门槛;**严格大于**该值的段才被剥离(`0` = 只要非空就剥)。非法值回退默认而不是报错。 #### `stripImages` From 847188234fb1e8798c8a35934705abe4bf3f68fa Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Thu, 10 Sep 2026 01:19:10 +0800 Subject: [PATCH 3/3] fix(#651): close rounds on tool-result evidence, not user messages (#348 twin) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'genuine user message after the compress call' closure gate is unreachable in long agentic sessions — no user messages exist after the opening prompt, so every compress round stayed 'active' forever and its thinking survived as a permanent uncompressible floor (observed on the pi side: 0 drops, 20.6K/8.4K/10.6K chars retained). A round now closes when the compress call's tool-result (contentType 'tool-result', matching toolCallId) exists at a later index AND at least one message follows it. In-flight rounds (result missing, or result still the last message) are never touched; the per-provider compress.providers..reasoning.drop=false escape hatch is preserved for reasoning-replay providers (GLM). Mirrors billion-context-pi #348 / PR #349. Tests: 1266 pass, gate suite rewritten with round-evidence scenarios (no-user-message agentic chains, pending result, result-before-call, mismatched id, distinct ids). --- CHANGELOG.md | 2 ++ CONFIGURATION.md | 2 +- CONFIGURATION.zh-CN.md | 2 +- src/reasoning-drop.ts | 37 ++++++++++++++-------- tests/reasoning-drop.test.ts | 61 +++++++++++++++++++++++++----------- 5 files changed, 70 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd6cd195..a13d1b19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ Versions follow the merge of a `*_release-v*` branch; CI publishes to npm on tag ### Fixes +- **Round-evidence closure for compress-reasoning drop (#651, #348 twin)**: the closure gate shipped with the reasoning drop — "compress call followed by a genuine user message" — is unreachable in long agentic sessions (no user messages after the opening prompt, observed: 0 drops while 30 compress rounds retained 20.6K/8.4K/10.6K-char thinking floors). A round now closes on tool-result evidence: the compress call's `tool-result` (matching `toolCallId`) exists at a later index and at least one message follows it. In-flight rounds (result missing or still the last message) stay untouched; per-provider `compress.providers..reasoning.drop=false` escape hatch preserved for reasoning-replay models (GLM). Mirrors billion-context-pi #348 (PR #349). + - **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. - **Forward-once-then-learn for image-dominated payloads — no false 502 on pixel-tile upstreams (#496)**: the default per-image estimate (`base64 length / 4`, uncapped) matches byte-billing relays but overestimates pixel-tile upstreams (official Anthropic/OpenAI) by up to ~200×, so a session whose *estimated* image floor alone exceeded the window was hard-failed with a 502 `preflight_compress_failed` ("Images alone account for ~N tokens") even though the real cost was a few K tokens — a regression vs master for official-API multimodal users (e.g. `bili claude` pasting screenshots). The fit gate now forwards ONCE instead of hard-failing when the over-window is attributable solely to the image estimate (`textEstimate < limit`), there is no upstream evidence of overflow yet (`lastInputTokens < limit` and no learned limit for the model), and images are present. The upstream then arbitrates billing: a pixel-tile upstream accepts (usage reports small → zero behavior change); a byte-billing relay rejects once, the existing self-heal learns the true window, and every subsequent request fails fast with an accurate message — exactly one rejected forward, strictly better than master's infinite 400 loop, no new knob. Also documents `BILI_IMAGE_TOKEN_CAP` (per-image estimate cap) in CONFIGURATION. diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 078abf1d..6e3ad8dd 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -296,7 +296,7 @@ For each request, the proxy resolves the settings by longest-URL-prefix match (t - **Type:** `object` (`{ drop?, threshold? }`) - **Default:** `drop: true`, `threshold: 2048` — on - **Status:** ACTIVE -- **Description:** **Compress-reasoning hygiene** (issue #651, the proxy-side twin of `billion-context-pi` #339 / `opencode-acp` #377). Models that keep their `reasoning`/`thinking` traces on the wire accumulate a permanent uncompressible floor: the anchor of a fold is a `compress` call, and any reasoning messages sitting *before* that call survive every fold as part of the protected prefix — they can never be re-summarized, only stripped. In the storm sessions this floor reached ~50% of the visible context. When on, the proxy removes the reasoning run that immediately precedes a **closed** `compress` call — i.e. one that already has its tool result and is followed by a genuine user message — when that run exceeds `threshold` characters. Safety gates: the *active* round (compress still in flight, no user message after it yet) is never touched; runs of ordinary tool calls (`read`, `bash`, …) keep their reasoning; a run is judged by its summed length so a 2×1200-char run still trips a 2048 gate; non-contiguous reasoning (text between the fragments) is left alone. Sub-fields (merged deepest-wins like every other CompressSettings field): +- **Description:** **Compress-reasoning hygiene** (issue #651, the proxy-side twin of `billion-context-pi` #339/#348 / `opencode-acp` #377). Models that keep their `reasoning`/`thinking` traces on the wire accumulate a permanent uncompressible floor: the anchor of a fold is a `compress` call, and any reasoning messages sitting *before* that call survive every fold as part of the protected prefix — they can never be re-summarized, only stripped. In the storm sessions this floor reached ~50% of the visible context. When on, the proxy removes the reasoning run that immediately precedes a **closed** `compress` call — closed on **round evidence**: the call's tool result (`contentType: "tool-result"`, matching `toolCallId`) has arrived at a later index and at least one message exists after it. No user message is required, so long agentic sessions close rounds too [#348 twin]. Safety gates: the *in-flight* round (result missing, or result still the last message) is never touched; runs of ordinary tool calls (`read`, `bash`, …) keep their reasoning; a run is judged by its summed length so a 2×1200-char run still trips a 2048 gate; non-contiguous reasoning (text between the fragments) is left alone. Sub-fields (merged deepest-wins like every other CompressSettings field): - `drop: boolean` — kill-switch; `false` restores the old wire verbatim. Required per-provider for thinking models that mandate `reasoning` round-trip while the request carries `tools` — DeepSeek, GLM thinking and Qwen-QwQ return HTTP 400 when a prior `reasoning_content` is not echoed back: ```jsonc "providers": { "https://api.deepseek.com": { "compress": { "reasoning": { "drop": false } } } } diff --git a/CONFIGURATION.zh-CN.md b/CONFIGURATION.zh-CN.md index 3802e8f1..e0091389 100644 --- a/CONFIGURATION.zh-CN.md +++ b/CONFIGURATION.zh-CN.md @@ -294,7 +294,7 @@ - **类型:** `object`(`{ drop?, threshold? }`) - **默认值:** `drop: true`、`threshold: 2048` — 默认开启 - **状态:** ACTIVE -- **说明:** **压缩回执 reasoning 卫生**(issue #651,对应 `billion-context-pi` #339 / `opencode-acp` #377 的代理侧孪生)。把 `reasoning`/`thinking` 轨迹留在 wire 上的模型会积累一块永久不可压缩的地板:折叠的锚点是一条 `compress` 调用,而它**前方**的 reasoning 消息会作为受保护前缀活过每一次折叠——它们永远无法被重新摘要,只能被剥离。在风暴会话里这块地板曾占到可见上下文的 ~50%。开启后,代理会剥离紧邻**已闭合** `compress` 调用(即已拿到工具结果、且其后存在真实用户消息)之前的 reasoning 连续段,条件是该段总长超过 `threshold` 字符。安全门:**活跃回合**(compress 仍在飞行中、其后还没有用户消息)绝不动;普通工具调用(`read`、`bash` …)的 reasoning 保留;连续段按求和后的总长判定(2×1200 字符的段仍会命中 2048 门槛);不连续的 reasoning(片段之间夹着正文)不动。子字段与其他 CompressSettings 字段一样按“深层覆盖”合并: +- **说明:** **压缩回执 reasoning 卫生**(issue #651,对应 `billion-context-pi` #339/#348 / `opencode-acp` #377 的代理侧孪生)。把 `reasoning`/`thinking` 轨迹留在 wire 上的模型会积累一块永久不可压缩的地板:折叠的锚点是一条 `compress` 调用,而它**前方**的 reasoning 消息会作为受保护前缀活过每一次折叠——它们永远无法被重新摘要,只能被剥离。在风暴会话里这块地板曾占到可见上下文的 ~50%。开启后,代理会剥离紧邻**已闭合** `compress` 调用之前的 reasoning 连续段,闭合判定按**回合证据**:该调用的工具结果(`contentType: "tool-result"`、`toolCallId` 匹配)已出现在更晚位置,且其后至少还有一条消息——**不要求用户消息**,长 agent 会话同样能闭合回合(#348 孪生)。安全门:**在飞回合**(结果未返回、或结果仍是最后一条消息)绝不动;普通工具调用(`read`、`bash` …)的 reasoning 保留;连续段按求和后的总长判定(2×1200 字符的段仍会命中 2048 门槛);不连续的 reasoning(片段之间夹着正文)不动。子字段与其他 CompressSettings 字段一样按“深层覆盖”合并: - `drop: boolean` — 总开关;`false` 完整还原旧行为。请求携带 `tools` 时要求 `reasoning` 原样往返的 thinking 模型必须按 provider 关闭——DeepSeek、GLM thinking、Qwen-QwQ 在未回传先前 `reasoning_content` 时返回 HTTP 400: ```jsonc "providers": { "https://api.deepseek.com": { "compress": { "reasoning": { "drop": false } } } } diff --git a/src/reasoning-drop.ts b/src/reasoning-drop.ts index 541f8d66..02490628 100644 --- a/src/reasoning-drop.ts +++ b/src/reasoning-drop.ts @@ -1,17 +1,20 @@ import type { BiliMessage } from "acp-kernel/wire"; -/** [#651] Drop oversized reasoning (thinking) from closed-turn `compress` +/** [#651] Drop oversized reasoning (thinking) from closed-round `compress` * tool calls at request time — the billion-context twin of - * billion-context-pi #336/#339, aligned with opencode-acp #377. + * billion-context-pi #336/#339/#348, aligned with opencode-acp #377. * `compress` tool messages are hard-exempt from compression (their tool * results are the anchors that keep block summaries addressable), so the * reasoning attached to those turns rides along EVERY forwarded request as * an unreclaimable context floor — measured at ~83.5% of the never-covered * residual on real long sessions, growing ~9 KB per compression round. * This pass removes those reasoning messages from the OUTBOUND view only - * (persisted history and kernel state are never modified) once the turn is - * closed and the reasoning run exceeds the size gate. The active round - * (from the last genuine user message onward) is never touched. */ + * (persisted history and kernel state are never modified) once the round is + * closed and the reasoning run exceeds the size gate. A round is closed on + * ROUND EVIDENCE, not on user messages: the compress tool result must have + * arrived and at least one message must exist after it. The in-flight round + * (result still missing or still the last message) is never touched [#348 + * twin]. */ export interface CompressReasoningConfig { /** Master switch. Default: true. `drop: false` disables the pass entirely * (kill-switch — set it per-provider for models whose reasoning items @@ -39,9 +42,13 @@ export function resolveReasoningDrop(cfg?: CompressReasoningConfig): Required(); + for (let i = 0; i <= last; i++) { const m = messages[i]!; - if (m.role === "user" && m.contentType === "text") lastUser = i; + if (m.contentType === "tool-result" && typeof m.toolCallId === "string" && !resultAt.has(m.toolCallId)) { + resultAt.set(m.toolCallId, i); + } } - if (lastUser < 0) return messages; const dropIdx = new Set(); - for (let i = 0; i < lastUser; i++) { + for (let i = 0; i <= last; i++) { const m = messages[i]!; if (m.contentType !== "tool-call" || m.toolName !== "compress") continue; + const ri = typeof m.toolCallId === "string" ? resultAt.get(m.toolCallId) : undefined; + if (ri === undefined || ri <= i || ri >= last) continue; let total = 0; let j = i - 1; while (j >= 0 && messages[j]!.contentType === "reasoning") { diff --git a/tests/reasoning-drop.test.ts b/tests/reasoning-drop.test.ts index efef1862..6b746189 100644 --- a/tests/reasoning-drop.test.ts +++ b/tests/reasoning-drop.test.ts @@ -5,9 +5,10 @@ import { mergeCompress } from "../src/compress-settings.ts"; import type { BiliMessage } from "acp-kernel/wire"; const R = (text: string, id = "r"): BiliMessage => ({ id, role: "assistant", contentType: "reasoning", text }); -const CALL = (toolName = "compress", id = "c"): BiliMessage => ({ id, role: "assistant", contentType: "tool-call", toolName, toolCallId: "t1", text: "{}" }); -const RESULT = (toolName = "compress"): BiliMessage => ({ id: "res", role: "user", contentType: "tool-result", toolName, toolCallId: "t1", text: "ok" }); +const CALL = (toolName = "compress", toolCallId = "t1"): BiliMessage => ({ id: "c", role: "assistant", contentType: "tool-call", toolName, toolCallId, text: "{}" }); +const RESULT = (toolCallId = "t1"): BiliMessage => ({ id: "res", role: "user", contentType: "tool-result", toolName: "compress", toolCallId, text: "ok" }); const USER = (text = "hi"): BiliMessage => ({ id: "u", role: "user", contentType: "text", text }); +const TEXT = (text = "hm"): BiliMessage => ({ id: "txt", role: "assistant", contentType: "text", text }); test("default: drops oversized reasoning run before a closed compress call", () => { const msgs = [R("x".repeat(3000)), CALL(), RESULT(), USER("next")]; @@ -17,33 +18,45 @@ test("default: drops oversized reasoning run before a closed compress call", () assert.ok(out.some((m) => m.contentType === "tool-call" && m.toolName === "compress")); }); -test("small reasoning survives the default threshold", () => { - const msgs = [R("x".repeat(1000)), CALL(), RESULT(), USER("next")]; +test("#348 twin: closes WITHOUT any user message — assistant continuation is round evidence", () => { + const msgs = [R("x".repeat(3000)), CALL(), RESULT(), TEXT("carrying on"), CALL("bash", "t2")]; + const out = dropCompressReasoning(msgs); + assert.equal(out.length, 4); + assert.ok(!out.some((m) => m.contentType === "reasoning")); +}); + +test("in flight: result still the last message → never touched", () => { + const msgs = [USER("q"), R("x".repeat(3000)), CALL(), RESULT()]; assert.equal(dropCompressReasoning(msgs).length, 4); }); -test("exactly-threshold reasoning is kept (strictly-greater gate)", () => { - const msgs = [R("x".repeat(DEFAULT_COMPRESS_REASONING.threshold)), CALL(), RESULT(), USER("next")]; +test("result pending (call without any result) → never touched", () => { + const msgs = [R("x".repeat(3000)), CALL(), TEXT("next round already started")]; + assert.equal(dropCompressReasoning(msgs).length, 3); +}); + +test("result for a different toolCallId does not close the round", () => { + const msgs = [R("x".repeat(3000)), CALL("compress", "a"), RESULT("b"), USER("next")]; assert.equal(dropCompressReasoning(msgs).length, 4); }); -test("active round is never touched (compress call after the last user message)", () => { - const msgs = [USER("q"), R("x".repeat(3000)), CALL(), RESULT()]; +test("result BEFORE the call does not close the round", () => { + const msgs = [RESULT(), R("x".repeat(3000)), CALL("compress", "t1"), USER("next")]; assert.equal(dropCompressReasoning(msgs).length, 4); }); -test("no genuine user message means no drop", () => { - const msgs = [R("x".repeat(3000)), CALL(), RESULT()]; - assert.equal(dropCompressReasoning(msgs).length, 3); +test("small reasoning survives the default threshold", () => { + const msgs = [R("x".repeat(1000)), CALL(), RESULT(), USER("next")]; + assert.equal(dropCompressReasoning(msgs).length, 4); }); -test("tool-result messages are not genuine users", () => { - const msgs = [R("x".repeat(3000)), CALL("read"), RESULT("read"), USER("next")]; +test("exactly-threshold reasoning is kept (strictly-greater gate)", () => { + const msgs = [R("x".repeat(DEFAULT_COMPRESS_REASONING.threshold)), CALL(), RESULT(), USER("next")]; assert.equal(dropCompressReasoning(msgs).length, 4); }); test("only compress calls select the drop — other tool calls keep their reasoning", () => { - const msgs = [R("x".repeat(3000)), CALL("read"), RESULT("read"), USER("next")]; + const msgs = [R("x".repeat(3000)), CALL("read", "t1"), RESULT("t1"), USER("next")]; assert.ok(dropCompressReasoning(msgs).some((m) => m.contentType === "reasoning")); }); @@ -55,8 +68,7 @@ test("multi-message reasoning run is summed before the gate", () => { }); test("non-contiguous reasoning is not attributed to the compress call", () => { - const interlude: BiliMessage = { id: "txt", role: "assistant", contentType: "text", text: "hm" }; - const msgs = [R("x".repeat(1200)), interlude, R("y".repeat(1200)), CALL(), RESULT(), USER("next")]; + const msgs = [R("x".repeat(1200)), TEXT(), R("y".repeat(1200)), CALL(), RESULT(), USER("next")]; const out = dropCompressReasoning(msgs); assert.equal(out.length, 6); }); @@ -92,10 +104,10 @@ test("resolveReasoningDrop: defaults, validation, and passthrough", () => { assert.deepEqual(resolveReasoningDrop({ threshold: 512.9 }), { drop: true, threshold: 512 }); }); -test("multiple closed compress turns all get stripped", () => { +test("multiple closed compress rounds all get stripped (distinct toolCallIds)", () => { const msgs = [ - R("a".repeat(3000), "r1"), CALL("compress", "c1"), RESULT(), - R("b".repeat(3000), "r2"), CALL("compress", "c2"), RESULT(), + R("a".repeat(3000), "r1"), CALL("compress", "c1"), RESULT("c1"), + R("b".repeat(3000), "r2"), CALL("compress", "c2"), RESULT("c2"), USER("next"), ]; const out = dropCompressReasoning(msgs); @@ -103,6 +115,17 @@ test("multiple closed compress turns all get stripped", () => { assert.ok(!out.some((m) => m.contentType === "reasoning")); }); +test("multiple agentic compress rounds close without any user message", () => { + const msgs = [ + R("a".repeat(3000), "r1"), CALL("compress", "c1"), RESULT("c1"), + R("b".repeat(3000), "r2"), CALL("compress", "c2"), RESULT("c2"), + CALL("bash", "t9"), + ]; + const out = dropCompressReasoning(msgs); + assert.equal(out.length, 5); + assert.ok(!out.some((m) => m.contentType === "reasoning")); +}); + test("mergeCompress merges reasoning sub-field-wise across levels", () => { assert.deepEqual(mergeCompress({ reasoning: { threshold: 1024 } }, undefined, { reasoning: { drop: false } }).reasoning, { threshold: 1024, drop: false }); assert.equal(mergeCompress(undefined, undefined, undefined).reasoning, undefined);