Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>.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.

Expand Down
12 changes: 12 additions & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,18 @@ 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/#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 } } } }
```
- `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`
Expand Down
12 changes: 12 additions & 0 deletions CONFIGURATION.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,18 @@
- `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/#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 } } } }
```
- `threshold: number` — 字符门槛;**严格大于**该值的段才被剥离(`0` = 只要非空就剥)。非法值回退默认而不是报错。

#### `stripImages`

- **类型:** `boolean`
Expand Down
5 changes: 5 additions & 0 deletions src/compress-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CompressSettings["absorb"]>[];
const reasoningLevels = [global?.reasoning, provider?.reasoning, model?.reasoning].filter(Boolean) as NonNullable<CompressSettings["reasoning"]>[];
return {
modelContextLimit: pick("modelContextLimit"),
maxContextLimit: pick("maxContextLimit"),
Expand All @@ -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,
};
}

Expand Down
14 changes: 14 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
92 changes: 92 additions & 0 deletions src/reasoning-drop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { BiliMessage } from "acp-kernel/wire";

/** [#651] Drop oversized reasoning (thinking) from closed-round `compress`
* tool calls at request time — the billion-context twin of
* 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 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
* 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<CompressReasoningConfig> = { drop: true, threshold: 2048 };

export function resolveReasoningDrop(cfg?: CompressReasoningConfig): Required<CompressReasoningConfig> {
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 round [#348 twin]: the compress call has its tool-result
* message (`contentType: "tool-result"`, matching `toolCallId`) at a
* LATER index, and at least one message exists after that result (the
* round has demonstrably moved on). No user message is required, so
* long agentic sessions do close rounds; a call without a result, or
* whose result is still the last message, is in flight and never
* touched;
* 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 {
const last = messages.length - 1;
const resultAt = new Map<string, number>();
for (let i = 0; i <= last; i++) {
const m = messages[i]!;
if (m.contentType === "tool-result" && typeof m.toolCallId === "string" && !resultAt.has(m.toolCallId)) {
resultAt.set(m.toolCallId, i);
}
}
const dropIdx = new Set<number>();
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") {
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;
}
}
Loading
Loading