Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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` 并校验(错误信息引导模型传数组)
Expand Down
47 changes: 39 additions & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"];

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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());
Expand Down
91 changes: 91 additions & 0 deletions src/overflow-selfheal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
}
100 changes: 99 additions & 1 deletion tests/overflow-selfheal.test.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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)", () => {
Expand Down Expand Up @@ -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
Expand Down
Loading