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
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): output headroom 预留按窗口比例封顶,默认 25%(closes #207)** — `reserveOutputHeadroom` 原按模型注册表 maxTokens **全额**预留输出预算:maxTokens 占窗口比例大的模型(qwen3.8-27b:262144 窗口 / 131072 maxTokens)输入预算被砍半,kernel 75% 强制压缩带在完整窗口 ~37% 处触发(host pct 仅 ~34%,两个口径不同加剧误导)。现在预留量 = min(maxTokens, `outputHeadroomMaxPct` × window):新增 acp.json 配置键 `outputHeadroomMaxPct`(默认 0.25,接受比例或 `"N%"`;0 完全禁用预留,≥1 恢复旧的全额行为)。小预留不受影响(同窗口 int4 版 32K maxTokens 保持原样),超出预留的超长回复溢出一次后由既有 overflow self-heal(learned window + armed emergency)下一轮恢复。可观测性:`[turn]` 日志新增 `fullWindow` 字段(仅当 limit 被预留削减时出现,= 本轮 recenter 后的完整窗口),消除 pct(完整窗口口径)vs limit(预留后口径)混淆;`output-headroom` 事件日志新增 `cap` 字段;`/acp` 面板与 `acp_status` 分母经 `applyOutputHeadroom` 同步使用同一封顶值(#267 统一口径不回归)
- **fix(nudge): 增长基线刻度混用复位(closes #267)** — token 计量在估算值 ↔ provider 真实 usage 锚点间翻转(hostFloor 激活/失活)时,增长增量跨翻转相减是伪度量:旧刻度基线对新刻度 tokenCount 相减,凭空多出 "+35K 假增长",绕过 cadence 节流乱发 nudge(或反向漏发)。现在翻转时统一重置全部增长基线:kernel 侧 `lastNudgeShownTokens` / `lastPerMessageNudgeTokens` / `lastShownByTier`(0.0.55 per-tier cadence 基线)+ 扩展侧 `clearNudgeTokenStamps()`(#316 引入的同轮 re-inject 刻度戳,旧刻度戳同样会伪造满地板增长触发伪重注入);usage bands 的 floor-stale 行为保留不动。附带:② `/acp` 面板与 `acp_status` 百分比分母统一为与实时仲裁相同的 `window − maxOutput`(新增 `src/overflow-selfheal.ts` `applyOutputHeadroom`,此前面板按全窗口报告而 nudge bands 按 headroom 后窗口仲裁);③ auto-update 限流与只读停止标记按安装位置 sha256 隔离(多副本互不干扰)。实现:`src/runtime.ts` `noteTokenScale`/`dropTokenScale`,`src/index.ts` 翻转检测重置,`src/update.ts` `locationKey`;测试 `tests/growth-scale-flip.test.ts` + `tests/update.test.ts` 扩展
- **fix(state): 导入会话后从会话日志重放重建压缩状态(closes #299)** — pi 的 `importFromJsonl` 只拷贝 `.jsonl`,`<sessionFile>.acp.json` sidecar 不随行,导入的会话压缩状态静默归零(全量重发 → 重压缩 → 全量重缓存)。新增最后一级兑底:sidecar 与父会话继承都未命中且日志含成功 compress 调用时,首个 context 事件中重放日志(assistant toolCall 参数 + 非 error toolResult 配对,逐调用先 `processTurn` 补齐 `messageRefs` 再 `applyCompression`,镜像线上流水顺序),重建 blocks/summaries/effectiveMessageIds/stats 并落盘 sidecar;errored/no-op/无法解析的调用跳过(批量原子,拒绝即状态不变),重放幂等(sidecar 重建后不再触发)。新增 `src/state-rebuild.ts`(`hasCompressHistory` / `rebuildStateFromLog`),挂载 `src/runtime.ts` `stateFor`。上游宿主侧修复另行提案(ranxianglei/pi#1:import 拷贝 `${basename}.*` sidecar + export 保留 `parentSession`)
- **feat(kernel): acp-kernel 0.0.55 → 0.0.56 — 面板文案修正 + decompress 后 block 状态保持 (#182)** — ① 面板 "Context (session accounting)" 行改为 "includes compressed originals; shrinks slower than the sent view"(原 "never shrinks" 不成立:摘要替换被 prune 的原文后会降);"tokens compressed" 标注 cumulative 消除与当前 active blocks 的表面矛盾。② sync.ts 行为修复:宿主标记 `expanded` 的 block(用户显式 decompress 还原过)在 syncBlocks 中保持 inactive,不再被重新激活——否则已还原的消息下一轮又被折叠(双倍成本 + 丢原文);重新压缩同区间会创建新 block。适配 tests/commands-kit-panel.test.ts 断言新文案
Expand Down
9 changes: 9 additions & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Create `~/.pi/acp.json` (or `<project>/.pi/acp.json`) and drop in whichever keys
"debug": false,
"autoUpdate": true,
"modelContextLimit": 200000,
"outputHeadroomMaxPct": 0.25,
"toolBashDefaultTimeout": 60,
"toolOutputMaxBytes": 200000,

Expand Down Expand Up @@ -97,6 +98,7 @@ All keys below are currently **ACTIVE**.
| `debug` | boolean | `false` | 🟢 ACTIVE | Enable verbose debug-level events in the log. |
| `autoUpdate` | boolean | `true` | 🟢 ACTIVE | Check npm for a newer version on startup and auto-install it. |
| `modelContextLimit` | number | *(auto)* | 🟢 ACTIVE | Override the context limit (in tokens). |
| `outputHeadroomMaxPct` | number \| string | `0.25` | 🟢 ACTIVE | Cap on the output-headroom reservation, as a fraction of the context window. |
| `toolBashDefaultTimeout` | number | `60` | 🟢 ACTIVE | Default `bash` tool timeout in seconds when the model omits it. |
| `toolOutputMaxBytes` | number | `200000` | 🟢 ACTIVE | Hard byte cap on tool result text. |
| `throttleRetry` | boolean \| object | `true` | 🟢 ACTIVE | Auto-retry provider token rate-limit errors with progressive backoff. |
Expand Down Expand Up @@ -198,6 +200,13 @@ All keys below are currently **ACTIVE**.
- **Status:** 🟢 ACTIVE
- **Description:** Override the context limit, in tokens. By default the limit is read from the active model's `ctx.model.contextWindow` on every turn, so it stays correct when you switch models. Set an explicit value for deterministic test runs or headless/non-interactive sessions where the model metadata may be unavailable. The `ACP_MODEL_CONTEXT_LIMIT` environment variable takes precedence over this value.

### `outputHeadroomMaxPct`

- **Type:** `number | string` (ratio or percent string)
- **Default:** `0.25`
- **Status:** 🟢 ACTIVE
- **Description:** Caps the output-headroom reservation as a fraction of the context window: reserved = min(model.maxTokens, pct × window). The reservation keeps the kernel's nudge/truncate bands below (window − reserved) so a long reply cannot push input + output past the window on APIs that count output against the window (all except Anthropic Messages, which enforces its input limit independently and is exempt). Without a cap, models whose registered max output is a large share of the window (e.g. 131072 on a 262144 window) lose most of their input budget — the 75% force-compress band then fires at roughly a third of the full window. The 0.25 default bounds that loss while still guaranteeing any single-turn reply up to 25% of the window fits at the 95% emergency threshold; longer replies overflow once and are recovered by the overflow self-heal on the next turn. Accepts a ratio (`0.25`) or percent string (`"25%"`). Set `0` to disable the reservation entirely; `1` (or greater) restores the legacy full-capability reservation.

### `toolBashDefaultTimeout`

- **Type:** `number`
Expand Down
9 changes: 9 additions & 0 deletions CONFIGURATION.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"debug": false,
"autoUpdate": true,
"modelContextLimit": 200000,
"outputHeadroomMaxPct": 0.25,
"toolBashDefaultTimeout": 60,
"toolOutputMaxBytes": 200000,

Expand Down Expand Up @@ -96,6 +97,7 @@
| `debug` | boolean | `false` | 🟢 ACTIVE | 开启日志中的详细调试事件。 |
| `autoUpdate` | boolean | `true` | 🟢 ACTIVE | 启动时检查 npm 并自动安装更新。 |
| `modelContextLimit` | number | *(自动)* | 🟢 ACTIVE | 覆盖上下文窗口大小(token 数)。 |
| `outputHeadroomMaxPct` | number \| string | `0.25` | 🟢 ACTIVE | 输出预留(output headroom)占上下文窗口的比例上限。 |
| `toolBashDefaultTimeout` | number | `60` | 🟢 ACTIVE | 模型省略 `timeout` 时注入 bash 工具的默认超时秒数。 |
| `toolOutputMaxBytes` | number | `200000` | 🟢 ACTIVE | 工具返回文本的硬性字节上限。 |
| `throttleRetry` | boolean \| object | `true` | 🟢 ACTIVE | 自动重试 provider 侧 token 限流错误(递进退避)。 |
Expand Down Expand Up @@ -190,6 +192,13 @@
- **状态:** 🟢 ACTIVE
- **说明:** 覆盖上下文窗口大小(token 数)。默认每轮从活跃模型的 `ctx.model.contextWindow` 读取,切换模型时自动保持正确。在模型元数据可能不可用的测试或无头/非交互会话中,可设置显式值。环境变量 `ACP_MODEL_CONTEXT_LIMIT` 优先于此值。

### `outputHeadroomMaxPct`

- **类型:** `number | string`(比例或百分比字符串)
- **默认值:** `0.25`
- **状态:** 🟢 ACTIVE
- **说明:** 输出预留(output headroom)占上下文窗口的比例上限:预留量 = min(model.maxTokens, pct × window)。该预留让 kernel 的 nudge/truncate 阈值带位于 (window − 预留量) 之下,使长回复不会把「输入 + 输出」推过窗口——适用于把输出计入窗口的 API(Anthropic Messages 除外:其输入限制独立于 max_tokens 强制执行,故豁免)。不设上限时,注册最大输出占窗口比例大的模型(如 262144 窗口配 131072 maxTokens)会失去大部分输入预算,75% 强制压缩阈值会在完整窗口的约三分之一处触发。0.25 的默认值在控制损失的同时,仍保证单轮回复不超过窗口 25% 时在 95% emergency 阈值下不溢出;更长的回复会溢出一次,由下一轮的 overflow self-heal 恢复。接受比例(`0.25`)或百分比字符串(`"25%"`)。设为 `0` 完全禁用预留;`1`(或更大)恢复旧的全额预留行为。

### `toolBashDefaultTimeout`

- **类型:** `number`
Expand Down
4 changes: 2 additions & 2 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { defaultCountTokens, parseBlockIdArg, collectBlockContent } from "acp-ke
import { getSystemPromptText } from "./compat.js";
import { collectCoveredMessageIds, estimateTokens, collectImageTokens, modelSupportsImages, adjustedTokenCount } from "./tokens.js";
import { usageAnchorPredatesCompression } from "./floor-stale.js";
import { applyOutputHeadroom } from "./overflow-selfheal.js";
import { applyOutputHeadroom, resolveOutputHeadroomCap } from "./overflow-selfheal.js";
import { buildStatusPanel } from "acp-kernel/panel";
import { getDelegateUsage } from "./delegate-tool.js";
import { openFleetInspector } from "./fleet-inspector.js";
Expand Down Expand Up @@ -146,7 +146,7 @@ async function statusReport(runtime: AcpRuntime, ctx: ExtensionCommandContext):
// Measure every panel percentage against the SAME real request limit the live
// context transform uses (window − output headroom), not the full window
// (issue #267).
const config = applyOutputHeadroom(runtime.configFor(ctx), ctx.model);
const config = applyOutputHeadroom(runtime.configFor(ctx), ctx.model, resolveOutputHeadroomCap(runtime.adapter.outputHeadroomMaxPct));
// Use pi's real context usage (anchored on provider usage) only for the
// panel's footer-scale display line; see sentTokens below for arbitration.
const realUsage = ctx.getContextUsage?.();
Expand Down
6 changes: 6 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,12 @@ export interface AdapterConfig {
* disables) or a ThrottleRetryConfig object. Default: enabled, 10 retries,
* 60s exponential base capped at 300s per kick. */
throttleRetry?: boolean | ThrottleRetryConfig;
/** Cap on the output-headroom reservation as a fraction of the context
* window: reserved = min(model.maxTokens, pct * window). Accepts a ratio
* (0.25) or percent string ("25%"). Default: 0.25. Set 0 to disable the
* reservation entirely; >= 1 restores the legacy full-capability
* reservation (issue #207). */
outputHeadroomMaxPct?: number | string;
/** Generic tool-call repetition guard (see RepetitionGuardConfig). Accepts a
* boolean shorthand (`false` disables) or an object. Default: enabled,
* warn=3, abort=5. Stops greedy small models looping on byte-identical
Expand Down
26 changes: 15 additions & 11 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import {
} from "./throttle-retry.js";
import { defaultCountTokens } from "acp-kernel";
import { formatSystemPromptForEvent, getSystemPromptText } from "./compat.js";
import { applyOutputHeadroom, inspectOverflowMessage } from "./overflow-selfheal.js";
import { applyOutputHeadroom, inspectOverflowMessage, resolveOutputHeadroomCap } from "./overflow-selfheal.js";
import { isOmpHost, OMP_UNSUPPORTED_MESSAGE } from "./omp.js";
import { isBiliProxyBaseUrl, PROXY_STAND_DOWN_MESSAGE } from "./proxy-detect.js";

Expand Down Expand Up @@ -271,16 +271,19 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime, standDownIf
config = { ...config, modelContextLimit: learnedWindow };
logInfo("overflow-selfheal", { sid, modelId, event: "window-recenter", resolved: configBase.modelContextLimit, learned: learnedWindow });
}
// Output headroom: reserve the model's max output budget from the window
// so the kernel's nudge/truncate bands sit below (window - maxTokens) —
// the context then always leaves room for the model's reply, preventing
// the "context + output > window" overflow on a small window. Applied to
// the (possibly re-centered) window above; never mutates the shared
// config. Anthropic is exempt (see applyOutputHeadroom).
const beforeHeadroom = config.modelContextLimit;
config = applyOutputHeadroom(config, ctx.model);
if (config.modelContextLimit !== beforeHeadroom) {
logInfo("overflow-selfheal", { sid, event: "output-headroom", before: beforeHeadroom, after: config.modelContextLimit, maxOutput: (ctx.model as { maxTokens?: number } | undefined)?.maxTokens ?? 0 });
// Output headroom: reserve the model's output budget from the window so
// the kernel's nudge/truncate bands sit below (window - reserved) and
// the context leaves room for the model's reply. The reservation is
// capped at outputHeadroomMaxPct * window (default 25%, issue #207):
// reserving the FULL registered maxTokens capability halves the input
// budget on models whose maxTokens is a large share of the window.
// Applied to the (possibly re-centered) window above; never mutates the
// shared config. Anthropic is exempt (see applyOutputHeadroom).
const headroomCap = resolveOutputHeadroomCap(runtime.adapter.outputHeadroomMaxPct);
const fullWindow = config.modelContextLimit;
config = applyOutputHeadroom(config, ctx.model, headroomCap);
if (config.modelContextLimit !== fullWindow) {
logInfo("overflow-selfheal", { sid, event: "output-headroom", before: fullWindow, after: config.modelContextLimit, maxOutput: (ctx.model as { maxTokens?: number } | undefined)?.maxTokens ?? 0, cap: headroomCap });
}
const coveredIds = collectCoveredMessageIds(state);
// Nudge arbitration on the SENT-VIEW scale: CJK-aware estimate over the
Expand Down Expand Up @@ -367,6 +370,7 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime, standDownIf
tokens: tokenCount,
pct: config.modelContextLimit > 0 ? Number(((tokenCount / config.modelContextLimit) * 100).toFixed(2)) : null,
limit: config.modelContextLimit,
...(fullWindow !== config.modelContextLimit ? { fullWindow } : {}),
nudge: turn.nudge?.shouldInject ? (turn.nudge.breakdown?.emergencyOverride === 1 ? "emergency" : "active") : "idle",
nudgeReason: turn.nudge?.reason ?? null,
blocks: turn.state.blocks.length,
Expand Down
Loading
Loading