From c355c4edbd56e5b269442e7779a97414559c29ac Mon Sep 17 00:00:00 2001 From: awork Date: Sun, 23 Aug 2026 22:23:41 +0800 Subject: [PATCH] fix(overflow): arm self-heal on ambiguous no-body 4xx errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pi surfaces a bodyless provider 4xx verbatim as '400/413 status code (no body)' — pi-ai's own classifier treats it as overflow (anchored regex /^4(?:00|13)\s*(?:status code)?\s*\(no body\)/i), but OVERFLOW_MARKER never matched it, so the extension-side self-heal never armed. Incident 2026-08-23: a 50,358-char bash toolResult (~31.5k tokens of a 131,072 effective window) pushed every request past sglang's input+max_tokens cap; each retry returned the no-body 400 forever, the model never got a successful turn to compress, and user 'continue' just resent the same oversized context (dead loop). Treat the no-body text as a POSSIBLE overflow, armed only with corroboration (the same text serves non-overflow 4xx — invalid model, malformed request; see messages.ts): - sent-view estimate >= 50% of the effective limit (same pct basis as the turn log), OR - >= 2nd consecutive no-body 4xx since the last successful assistant turn (count resets on success/session start; runtime in-memory like the armed flag — not persisted). A bodyless error parses no window, so none is learned: the armed emergency uses the already-resolved effective limit. The classic text-marker arm path is unchanged. Relates #204; complements acp-kernel #133. --- CHANGELOG.md | 1 + src/index.ts | 47 ++++++++++++--- src/overflow-selfheal.ts | 91 +++++++++++++++++++++++++++++ tests/overflow-selfheal.test.ts | 100 +++++++++++++++++++++++++++++++- 4 files changed, 230 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f829e4..8a65e45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased (master, since v0.1.38) +- **fix(overflow)**: 无 body 4xx 也触发溢出自愈 — pi 把 provider 空 body 的 4xx 原样透出为 `400/413 status code (no body)`(pi-ai 自身按 `/^4(?:00|13)\s*(?:status code)?\s*\(no body\)/i` 归类为 overflow),但 OVERFLOW_MARKER 不含该形态,扩展侧自愈永不武装 → 死循环无法恢复(2026-08-23 事故:50k 字符 bash toolResult 撞穿 sglang input+max_tokens 硬上限,此后每轮 400 无 body,模型永远等不到成功回合去 compress,用户「继续」只是原样重发超限上下文)。现将其作为**疑似溢出**信号:仅当发送视图估算 ≥ 有效上限 50%(与 turn 日志 pct 同口径)或自上次成功 assistant 回合起连续 ≥2 次无 body 4xx 时才武装紧急压缩;成功回合/新会话清零计数(runtime 内存态,与 armed 同生命周期,不入 acp.json)。无 body 解析不出窗口数 → 不学习窗口,紧急压缩直接用已解析的有效上限。经典文本标记路径不变 (relates #204) - **fix(guardrail): `toolOutputMaxBytes` 未配置时文档默认值 200000 现在实际生效** — 原接线 `if (max !== undefined && max > 0)` 把「未配置」当成「禁用」,内置 200KB 天花板永远不可达(`capToolOutput` 内部的回退到不了);pi 只内置 cap bash/read/grep,其余工具可无限注入 context,与 CONFIGURATION.md 承诺的 ACTIVE 默认不符。改为接线层 `?? DEFAULT_TOOL_OUTPUT_MAX_BYTES` 回退,`0`/负数禁用语义不变 (#210) - **fix(compress): 接受 JSON 字符串形式的 `content` 参数** — 非严格工具 provider(vLLM openai-completions,`supportsStrictTools:false`)会把嵌套数组参数字符串化,pi 的 typebox 校验直接拒掉(`content.0: must be object`)。实测会话 01a00a38 全部唯一一次 compress 调用即死于此,3 小时会话零压缩。schema 改为 `Type.Union([Array, String])`,字符串自动 `JSON.parse` 并校验(错误信息引导模型传数组) diff --git a/src/index.ts b/src/index.ts index a041e4e..50d43c8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,7 +32,7 @@ import { } from "./throttle-retry.js"; import { defaultCountTokens } from "acp-kernel"; import { formatSystemPromptForEvent, getSystemPromptText } from "./compat.js"; -import { inspectOverflowMessage, reserveOutputHeadroom, shouldReserveOutputHeadroom } from "./overflow-selfheal.js"; +import { inspectOverflowMessage, isNoBody4xxError, reserveOutputHeadroom, shouldReserveOutputHeadroom } from "./overflow-selfheal.js"; type AgentMessage = SessionMessageEntry["message"]; @@ -195,6 +195,12 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime): void { // below is fed the RAW sentTokens — its samples must stay on the // raw basis or density would chase its own calibration. let tokenCount = calibrateTokens(sentTokens, runtime.density.densityFor(modelId)); + // Basis for the no-body-4xx overflow guard (wireOverflowSelfHeal): the + // sent-view estimate + effective limit of the request about to be sent — + // the same pct basis as the turn log below. Recorded BEFORE the armed + // boost so a guard consult after an error sees the real estimate, not + // the forced >=95% floor. + ov.noteSentView(tokenCount, config.modelContextLimit); // Self-heal (armed): after an overflow, force this turn's usage to >=95% // so the kernel's emergency nudge + tool-result truncate fire immediately, // even if the density-calibrated estimate under-reports the sent view. @@ -398,21 +404,46 @@ function wireOverflowSelfHeal(pi: ExtensionAPI, runtime: AcpRuntime): void { pi.on("message_end", (event, ctx) => { const msg = event.message; if (msg.role !== "assistant") return; - if (msg.stopReason !== "error") return; + const sid = ctx.sessionManager.getSessionId(); + const ov = runtime.overflowFor(sid); + if (msg.stopReason !== "error") { + // Successful assistant turn: the request loop is unwedged, so the + // consecutive no-body-4xx count (possible-overflow path below) + // restarts from zero. + ov.noteSuccess(); + return; + } // Haystack = errorMessage + error content: some relays put the upstream // error body in the streamed content and leave errorMessage generic // ("Provider finish_reason: error_finish") — errorMessage alone would miss // them. (Same haystack approach as isThrottleError.) const haystack = `${msg.errorMessage ?? ""}\n${extractText(msg.content)}`; const info = inspectOverflowMessage(haystack); - if (!info.isOverflow) return; - const sid = ctx.sessionManager.getSessionId(); const modelId = (ctx.model as { id?: string } | undefined)?.id ?? "default"; - const ov = runtime.overflowFor(sid); - if (info.window) ov.setLearnedWindow(modelId, info.window); + if (info.isOverflow) { + if (info.window) ov.setLearnedWindow(modelId, info.window); + ov.armed = true; + logWarn("overflow-selfheal", { sid, modelId, event: "detected", window: info.window ?? null, message: info.message.slice(0, 200) }); + if (ctx.hasUI) ctx.ui.notify(`[ACP] context overflow detected${info.window ? ` (window ${info.window})` : ""} — forcing emergency compression next turn`); + return; + } + // Possible overflow: pi's bodyless "4xx ... (no body)" (incident + // 2026-08-23: a huge bash tool result pushed every request past sglang's + // input+max_tokens cap; each retry returned "400 status code (no body)" + // forever and the text-marker path above never matched, so the emergency + // never fired and the session dead-looped). The text is ambiguous — the + // same 4xx comes back for invalid models / malformed requests (see + // messages.ts) — so arm only with corroboration: sent-view >= 50% of the + // effective limit, or the >=2nd consecutive no-body since the last + // successful turn. Unlike the path above no window can be parsed from a + // bodyless error, so none is learned: the armed emergency uses the + // already-resolved effective limit (wireContextTransform). + if (!isNoBody4xxError(haystack)) return; + const decision = ov.onNoBody4xx(); + if (!decision.arm) return; ov.armed = true; - logWarn("overflow-selfheal", { sid, modelId, event: "detected", window: info.window ?? null, message: info.message.slice(0, 200) }); - if (ctx.hasUI) ctx.ui.notify(`[ACP] context overflow detected${info.window ? ` (window ${info.window})` : ""} — forcing emergency compression next turn`); + logWarn("overflow-selfheal", { sid, modelId, event: "no-body-arm", consecutive: decision.consecutive, ratio: decision.ratio, message: haystack.slice(0, 200) }); + if (ctx.hasUI) ctx.ui.notify(`[ACP] possible context overflow (4xx no-body error, ${decision.consecutive} consecutive) — forcing emergency compression next turn`); }); pi.on("session_shutdown", (_event, ctx) => { runtime.overflowDrop(ctx.sessionManager.getSessionId()); diff --git a/src/overflow-selfheal.ts b/src/overflow-selfheal.ts index 00275c9..6803a7a 100644 --- a/src/overflow-selfheal.ts +++ b/src/overflow-selfheal.ts @@ -49,6 +49,40 @@ export function inspectOverflowMessage(haystack: string | undefined | null): Ove return { isOverflow: true, window: parseOverflowWindow(body), message: body }; } +// Pi surfaces a provider 4xx whose response body is empty verbatim as the +// errorMessage: "400 status code (no body)" / "413 (no body)". sglang and +// other OpenAI-compatible backends return exactly this when the request busts +// a hard input+max_tokens cap (incident 2026-08-23: a 50k-char bash tool +// result pushed every subsequent request past the cap; each retry came back +// "400 status code (no body)" forever, the model never got a successful turn +// to compress, and the text-marker path above never matched). pi's own +// classifier DOES treat the text as overflow (pi-stable-ai OVERFLOW_PATTERNS +// ends with the same anchored regex), but OVERFLOW_MARKER deliberately does +// not: the SAME no-body text is returned for NON-overflow 4xx (invalid model, +// malformed request — see the note in messages.ts), so it is a POSSIBLE +// overflow only, armed by the wiring when OverflowEpisode.onNoBody4xx() +// corroborates it (usage ratio or consecutive count). +export const NO_BODY_4XX_MARKER = /^4(?:00|13)\s*(?:status code)?\s*\(no body\)/i; + +export function isNoBody4xxError(haystack: string | undefined | null): boolean { + const body = (haystack ?? "").trim(); + return body.length > 0 && NO_BODY_4XX_MARKER.test(body); +} + +/** Corroboration threshold for arming on an ambiguous no-body 4xx: the + * calibrated sent-view estimate must be at least this fraction of the + * effective limit (same basis as the turn log's `pct`). */ +export const NO_BODY_ARM_RATIO = 0.5; + +/** Decision returned by OverflowEpisode.onNoBody4xx(). */ +export interface NoBodyDecision { + arm: boolean; + /** Consecutive no-body 4xx errors since the last successful assistant turn. */ + consecutive: number; + /** Recorded sent-view estimate / effective limit, when both were seen. */ + ratio: number | null; +} + function parseOverflowWindow(text: string): number | undefined { // Anthropic: "prompt is too long: 130000 tokens > 128000 maximum" -> 128000 let m = />\s*(\d[\d,]*)\s*(?:tokens?)?\s*maximum/i.exec(text); @@ -130,8 +164,65 @@ export class OverflowEpisode { * session-scoped (not per-model): the context did not shrink, so the next * turn needs the emergency regardless of which model answers it. */ armed = false; + + // --- Ambiguous no-body 4xx corroboration (incident 2026-08-23) --- + // Runtime/in-memory like `armed` above, NOT persisted to acp.json: the + // count and the sent-view basis describe the live request loop of the + // current process. A resumed session re-establishes both within one turn + // (the context event re-records the sent view; the next error re-counts), + // while a persisted count would arm an emergency against a fresh session + // whose first request may succeed. + /** Sent-view estimate + effective limit recorded by the context event — + * the numbers of the request that just ended. */ + private sentTokens: number | null = null; + private sentLimit: number | null = null; + /** Consecutive no-body 4xx errors since the last successful assistant + * turn. 0 at session start (the episode is created per session). */ + private noBody4xx = 0; + + noteSentView(tokens: number, limit: number): void { + this.sentTokens = tokens > 0 ? tokens : null; + this.sentLimit = limit > 0 ? limit : null; + } + + /** A successful assistant turn unwedged the request loop: the consecutive + * no-body count restarts from zero. */ + noteSuccess(): void { + this.noBody4xx = 0; + } + + /** + * Record one ambiguous no-body 4xx and decide whether it corroborates a + * probable context overflow. Guards against the false positive (the same + * text serves non-overflow 4xx): arm only when the current sent-view + * estimate is >= NO_BODY_ARM_RATIO of the effective limit, OR this is the + * >=2nd consecutive no-body since the last success. The consecutive guard + * recovers the dead-loop even at a low ratio (the incident ran at ~24%: + * the estimate under-reported vs the input+max_tokens cap) — a genuine + * overflow fails identically while the context is unchanged, so repeated + * no-body errors with no successful turn in between are size-dependent by + * construction. No window can be parsed from a bodyless error, so when + * this arms, the emergency uses the already-resolved effective limit (no + * window is learned). + */ + onNoBody4xx(): NoBodyDecision { + this.noBody4xx += 1; + const ratio = + this.sentTokens !== null && this.sentLimit !== null && this.sentLimit > 0 + ? this.sentTokens / this.sentLimit + : null; + return { + arm: (ratio !== null && ratio >= NO_BODY_ARM_RATIO) || this.noBody4xx >= 2, + consecutive: this.noBody4xx, + ratio, + }; + } + reset(): void { this.learned.clear(); this.armed = false; + this.sentTokens = null; + this.sentLimit = null; + this.noBody4xx = 0; } } diff --git a/tests/overflow-selfheal.test.ts b/tests/overflow-selfheal.test.ts index 7921f7a..c5562cb 100644 --- a/tests/overflow-selfheal.test.ts +++ b/tests/overflow-selfheal.test.ts @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { inspectOverflowMessage, OverflowEpisode, OVERFLOW_MARKER, reserveOutputHeadroom, shouldReserveOutputHeadroom } from "../src/overflow-selfheal.js"; +import { inspectOverflowMessage, OverflowEpisode, OVERFLOW_MARKER, isNoBody4xxError, NO_BODY_ARM_RATIO, reserveOutputHeadroom, shouldReserveOutputHeadroom } from "../src/overflow-selfheal.js"; test("inspectOverflowMessage: detects OpenAI context-overflow + parses window", () => { const info = inspectOverflowMessage( @@ -83,11 +83,18 @@ test("OverflowEpisode: initial state + reset", () => { assert.equal(ep.armed, false); ep.setLearnedWindow("m1", 100000); ep.armed = true; + ep.noteSentView(60_000, 100_000); + assert.equal(ep.onNoBody4xx().arm, true); + ep.noteSuccess(); + ep.noteSentView(10_000, 100_000); + assert.equal(ep.onNoBody4xx().arm, false, "low ratio + first occurrence after reset"); assert.equal(ep.learnedWindowFor("m1"), 100000); assert.equal(ep.armed, true); ep.reset(); assert.equal(ep.learnedWindowFor("m1"), null); assert.equal(ep.armed, false); + assert.equal(ep.onNoBody4xx().ratio, null, "reset clears the recorded sent view"); + assert.equal(ep.onNoBody4xx().consecutive, 2, "reset clears the consecutive count"); }); test("OverflowEpisode: learned windows are per-model (no cross-model crosstalk)", () => { @@ -135,6 +142,97 @@ test("OVERFLOW_MARKER: case-insensitive and matches the shared guard patterns", assert.ok(!OVERFLOW_MARKER.test("too many tokens, please wait before trying again")); }); +// --- no-body 4xx (incident 2026-08-23): pi's ambiguous "4xx ... (no body)" --- +// pi surfaces a bodyless provider 4xx verbatim; pi-ai's own classifier +// treats the text as overflow (same anchored regex), but our marker set must +// NOT — the text also carries non-overflow 4xx. It is a possible-overflow +// signal armed only with corroboration (OverflowEpisode.onNoBody4xx). +test("isNoBody4xxError: matches pi's surfaced forms of the bodyless 4xx", () => { + assert.equal(isNoBody4xxError("400 status code (no body)"), true, "the incident form"); + assert.equal(isNoBody4xxError("413 status code (no body)"), true); + assert.equal(isNoBody4xxError("400 (no body)"), true); + assert.equal(isNoBody4xxError("413(no body)"), true, "zero spaces allowed"); +}); + +test("isNoBody4xxError: other statuses / texts / empty are not no-body 4xx", () => { + assert.equal(isNoBody4xxError("429 status code (no body)"), false, "throttle stays on the throttle path"); + assert.equal(isNoBody4xxError("500 status code (no body)"), false); + assert.equal(isNoBody4xxError("404 status code (no body)"), false); + assert.equal(isNoBody4xxError("insufficient_quota"), false); + assert.equal(isNoBody4xxError(""), false); + assert.equal(isNoBody4xxError(undefined), false); + assert.equal(isNoBody4xxError("Error: 400 status code (no body)"), false, "anchored like pi-ai's classifier — a prefix is not pi's surface form"); +}); + +test("no-body 4xx stays OUT of the unconditional text-marker path", () => { + assert.equal(inspectOverflowMessage("400 status code (no body)").isOverflow, false); + assert.equal(inspectOverflowMessage("413 (no body)").isOverflow, false); +}); + +test("inspectOverflowMessage: classic 'maximum context length is 262144' still arms via the text-marker path (regression)", () => { + const info = inspectOverflowMessage( + "This model's maximum context length is 262144 tokens. However, you requested about 262200 tokens. Please reduce the length of the messages.", + ); + assert.equal(info.isOverflow, true); + assert.equal(info.window, 262144); + assert.equal(isNoBody4xxError(info.message), false); +}); + +test("no-body guard: low estimate + first occurrence → NO arm (false-positive guard)", () => { + // Incident scale: ~31.5k estimated on a 131,072 effective limit (~24%) — + // the estimate under-reports vs sglang's input+max_tokens cap, so the + // ratio guard alone cannot fire; a single hit must not arm either. + const ep = new OverflowEpisode(); + ep.noteSentView(31_475, 131_072); + const d = ep.onNoBody4xx(); + assert.equal(d.arm, false); + assert.equal(d.consecutive, 1); + assert.equal(d.ratio, 31_475 / 131_072); +}); + +test("no-body guard: estimate >= 50% of effective limit → arm on first occurrence", () => { + const ep = new OverflowEpisode(); + ep.noteSentView(70_000, 131_072); + assert.equal(ep.onNoBody4xx().arm, true); +}); + +test("no-body guard: exactly the threshold ratio arms (>= semantics)", () => { + const ep = new OverflowEpisode(); + ep.noteSentView(65_536, 131_072); + const d = ep.onNoBody4xx(); + assert.equal(d.ratio, NO_BODY_ARM_RATIO); + assert.equal(d.arm, true, "ratio == threshold arms on the first occurrence"); +}); + +test("no-body guard: second consecutive no-body at low estimate → arm (incident dead-loop)", () => { + const ep = new OverflowEpisode(); + ep.noteSentView(31_475, 131_072); + assert.equal(ep.onNoBody4xx().arm, false, "first: low ratio, first occurrence"); + const d = ep.onNoBody4xx(); + assert.equal(d.arm, true, "second consecutive since last success arms"); + assert.equal(d.consecutive, 2); +}); + +test("no-body guard: successful turn resets the consecutive count", () => { + const ep = new OverflowEpisode(); + ep.noteSentView(31_475, 131_072); + assert.equal(ep.onNoBody4xx().arm, false); + ep.noteSuccess(); + assert.equal(ep.onNoBody4xx().arm, false, "after a success the count restarts — a lone no-body does not arm"); + assert.equal(ep.onNoBody4xx().arm, true, "the next consecutive pair still arms"); +}); + +test("no-body guard: no recorded sent view (fresh episode) → first no-body does not arm", () => { + // message_end can only see a sent view recorded by a context event; on a + // mid-session extension reload the first error has no basis — only the + // consecutive guard can fire (on the second hit). + const ep = new OverflowEpisode(); + const d = ep.onNoBody4xx(); + assert.equal(d.arm, false); + assert.equal(d.ratio, null); + assert.equal(ep.onNoBody4xx().arm, true); +}); + // Provider phrasings the shorter marker set missed — mirrored from pi-ai's // own OVERFLOW_PATTERNS (pi-stable-ai/dist/utils/overflow.js). Without them // the self-heal never fires for a DIRECT connection to these providers (no