diff --git a/CHANGELOG.md b/CHANGELOG.md index 6abd6d8..11859d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`,`.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 断言新文案 diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 44ade3a..b77b8ec 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -32,6 +32,7 @@ Create `~/.pi/acp.json` (or `/.pi/acp.json`) and drop in whichever keys "debug": false, "autoUpdate": true, "modelContextLimit": 200000, + "outputHeadroomMaxPct": 0.25, "toolBashDefaultTimeout": 60, "toolOutputMaxBytes": 200000, @@ -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. | @@ -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` diff --git a/CONFIGURATION.zh-CN.md b/CONFIGURATION.zh-CN.md index 412ddbf..057f30e 100644 --- a/CONFIGURATION.zh-CN.md +++ b/CONFIGURATION.zh-CN.md @@ -32,6 +32,7 @@ "debug": false, "autoUpdate": true, "modelContextLimit": 200000, + "outputHeadroomMaxPct": 0.25, "toolBashDefaultTimeout": 60, "toolOutputMaxBytes": 200000, @@ -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 限流错误(递进退避)。 | @@ -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` diff --git a/src/commands.ts b/src/commands.ts index 09edfcd..dfadec4 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -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"; @@ -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?.(); diff --git a/src/config.ts b/src/config.ts index 09aa42f..293e11b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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 diff --git a/src/index.ts b/src/index.ts index 6f6680c..ffdf6ab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; @@ -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 @@ -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, diff --git a/src/overflow-selfheal.ts b/src/overflow-selfheal.ts index bb749b0..baa887e 100644 --- a/src/overflow-selfheal.ts +++ b/src/overflow-selfheal.ts @@ -21,6 +21,8 @@ // OVERFLOW_GUARD in src/throttle-retry.ts (which uses it to AVOID treating an // overflow as a throttle). Keep the two in sync when either changes. +import { parsePercent } from "./config.js"; + // Detect a context-overflow error. Deliberately does NOT match Bedrock's // "too many tokens" throttle (a 429, handled by throttle-retry) — only // genuine context-length errors. The extra phrasings mirror pi-ai's own @@ -73,16 +75,46 @@ function toTokenNumber(raw: string | undefined): number | undefined { return Number.isFinite(n) && n >= 1000 ? n : undefined; } +/** Default cap on the output-headroom reservation, as a fraction of the + * context window (issue #207). Reserving the FULL registered max output + * capability halves the effective input budget on models whose maxTokens is a + * large share of the window (e.g. 131072 on a 262144 window → the 75% force- + * compress band fires at ~37% of the full window), while real per-turn + * replies rarely approach that. Capping at 25% keeps the guarantee where it + * matters — any single-turn reply up to the reserved amount still fits at the + * 95% emergency threshold — while bounding the budget loss. A reply longer + * than the reservation overflows once; the overflow self-heal (learned window + * + armed emergency) recovers it on the next turn. */ +export const DEFAULT_OUTPUT_HEADROOM_MAX_PCT = 0.25; + +/** Resolve the user's `outputHeadroomMaxPct` (ratio or "N%" string) to a + * numeric cap, falling back to DEFAULT_OUTPUT_HEADROOM_MAX_PCT when unset. + * Shared by every headroom call site (context transform, /acp, acp_status) + * so they all measure against the SAME capped limit (issue #207/#267). */ +export function resolveOutputHeadroomCap(value: number | string | undefined): number { + return value === undefined ? DEFAULT_OUTPUT_HEADROOM_MAX_PCT : parsePercent(value); +} + /** * Reserve the model's output budget from the context window, so the kernel's - * nudge/truncate bands sit below (window - maxOutput) and the context always - * leaves room for the model's reply. This prevents the "context + output > - * window" overflow on a small window (agents routinely set a large max output). + * nudge/truncate bands sit below (window - reserved) and the context leaves + * room for the model's reply. This prevents the "context + output > window" + * overflow on a small window (agents routinely set a large max output). + * + * `capPct` bounds the reservation as a fraction of the window: reserved = + * min(maxOutput, capPct * window). The default (1) preserves the original + * full-capability reservation; the adapter passes DEFAULT_OUTPUT_HEADROOM_MAX_PCT + * (or the user's `outputHeadroomMaxPct`) so oversized registered capabilities + * no longer eat most of the input budget (issue #207). capPct semantics: + * - 0 → no reservation (window returned unchanged) + * - (0, 1) → reservation capped at capPct * window + * - >= 1 → legacy behavior (full maxOutput reserved) + * - non-finite → legacy behavior (treated as "not provided") * Returns the window unchanged when maxOutput is not usable (non-positive, * non-finite, or >= window — a maxOutput >= window request is degenerate and is * left to the overflow self-heal). */ -export function reserveOutputHeadroom(window: number, maxOutput: number): number { +export function reserveOutputHeadroom(window: number, maxOutput: number, capPct: number = 1): number { if ( Number.isFinite(window) && window > 0 && @@ -90,7 +122,9 @@ export function reserveOutputHeadroom(window: number, maxOutput: number): number maxOutput > 0 && maxOutput < window ) { - return window - maxOutput; + const cap = Number.isFinite(capPct) ? Math.max(0, Math.min(capPct, 1)) : 1; + const reserved = Math.min(maxOutput, cap * window); + return reserved > 0 ? window - reserved : window; } return window; } @@ -119,14 +153,21 @@ export function shouldReserveOutputHeadroom(api: string | undefined): boolean { * acp_status) so every percentage is measured against the SAME real request * limit — otherwise the panel reports against the full window while the nudge * bands run against (window − maxOutput) (issue #267). + * + * `capPct` bounds the reservation as a fraction of the window (see + * reserveOutputHeadroom); callers pass resolveOutputHeadroomCap( + * adapter.outputHeadroomMaxPct) so the panel and the nudge bands share the + * same capped limit (issue #207). The default (1) preserves the legacy + * full-capability reservation for callers that don't pass a cap. */ export function applyOutputHeadroom( config: T, model: { maxTokens?: number; api?: string } | undefined, + capPct: number = 1, ): T { const maxOutput = model?.maxTokens ?? 0; if (shouldReserveOutputHeadroom(model?.api)) { - const reserved = reserveOutputHeadroom(config.modelContextLimit, maxOutput); + const reserved = reserveOutputHeadroom(config.modelContextLimit, maxOutput, capPct); if (reserved !== config.modelContextLimit) return { ...config, modelContextLimit: reserved }; } return config; diff --git a/src/status-tool.ts b/src/status-tool.ts index 1fc30ad..f56ecc5 100644 --- a/src/status-tool.ts +++ b/src/status-tool.ts @@ -4,7 +4,7 @@ import type { AcpRuntime } from "./runtime.js"; import { buildStatusReport, defaultCountTokens, formatRanges, viableRanges } from "acp-kernel"; import { estimateTokens, collectCoveredMessageIds, 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 { getSystemPromptText } from "./compat.js"; import { logThrow } from "./log.js"; import { getDelegateUsage } from "./delegate-tool.js"; @@ -52,7 +52,7 @@ async function handleStatus(args: StatusArgs, runtime: AcpRuntime, ctx: Extensio const { state, coreMessages, entries } = await runtime.stateFor(ctx); // Same real request limit (window − output headroom) as the live context // transform, so the reported percentages match the nudge bands (issue #267). - const config = applyOutputHeadroom(runtime.configFor(ctx), ctx.model); + const config = applyOutputHeadroom(runtime.configFor(ctx), ctx.model, resolveOutputHeadroomCap(runtime.adapter.outputHeadroomMaxPct)); // Run the same pipeline (assign-refs → prune → hide-compress-calls → ...) that // the context transform runs, so what acp_status reports matches what the // model actually receives. Without this, consumed/hidden compress calls and diff --git a/src/user-config.ts b/src/user-config.ts index 374302d..2c20436 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -19,6 +19,7 @@ export interface UserAcpConfig { toolOutputMaxBytes?: number; delegate?: boolean | DelegateConfig; compress?: CompressConfig; + outputHeadroomMaxPct?: number | string; throttleRetry?: boolean | ThrottleRetryConfig; displayUsage?: "merged" | "separate"; prompts?: Partial; @@ -57,6 +58,7 @@ const KNOWN = new Set([ "enabled", "debug", "autoUpdate", "modelContextLimit", "toolBashDefaultTimeout", "toolOutputMaxBytes", "delegate", "compress", "displayUsage", "throttleRetry", + "outputHeadroomMaxPct", "prompts", "acknowledgePromptsRisk", ]); diff --git a/tests/commands-kit-panel.test.ts b/tests/commands-kit-panel.test.ts index a26c98c..84a122b 100644 --- a/tests/commands-kit-panel.test.ts +++ b/tests/commands-kit-panel.test.ts @@ -10,6 +10,7 @@ const { makeCommands } = await import("../src/commands.js"); function fakeRuntime(): AcpRuntime { return { + adapter: {}, configFor: () => ({ modelContextLimit: 1_000_000 }), stateFor: async () => ({ state: { blocks: [], stats: { tokensCompressed: 0 }, messageRefs: { byRaw: {}, byRef: {} } }, diff --git a/tests/overflow-selfheal.test.ts b/tests/overflow-selfheal.test.ts index 7921f7a..99f6f80 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, reserveOutputHeadroom, shouldReserveOutputHeadroom, applyOutputHeadroom, resolveOutputHeadroomCap, DEFAULT_OUTPUT_HEADROOM_MAX_PCT } from "../src/overflow-selfheal.js"; test("inspectOverflowMessage: detects OpenAI context-overflow + parses window", () => { const info = inspectOverflowMessage( @@ -128,6 +128,30 @@ test("reserveOutputHeadroom: no-op for unusable window", () => { assert.equal(reserveOutputHeadroom(Number.NaN, 10_000), Number.NaN); }); +test("reserveOutputHeadroom: capPct bounds the reservation at capPct * window (issue #207)", () => { + // qwen3.8-27b scenario: 262144 window, 131072 registered maxTokens. Legacy + // full reservation halved the budget; the 25% default cap keeps 196608. + assert.equal(reserveOutputHeadroom(262_144, 131_072, 0.25), 196_608); + // A small maxOutput below the cap is reserved in full (unchanged behavior). + assert.equal(reserveOutputHeadroom(262_144, 32_768, 0.25), 229_376); + assert.equal(reserveOutputHeadroom(100_000, 16_384, 0.25), 83_616); +}); + +test("reserveOutputHeadroom: capPct edge semantics", () => { + assert.equal(reserveOutputHeadroom(100_000, 50_000, 0), 100_000, "0 disables the reservation"); + assert.equal(reserveOutputHeadroom(100_000, 50_000, -1), 100_000, "negative clamps to 0 → disabled"); + assert.equal(reserveOutputHeadroom(100_000, 50_000, 1), 50_000, "1 = legacy full reservation"); + assert.equal(reserveOutputHeadroom(100_000, 50_000, 2), 50_000, ">= 1 clamps to legacy"); + assert.equal(reserveOutputHeadroom(100_000, 50_000, Number.NaN), 50_000, "non-finite = not provided → legacy"); + assert.equal(reserveOutputHeadroom(100_000, 50_000), 50_000, "default arg preserves pre-cap behavior"); +}); + +test("reserveOutputHeadroom: cap does not resurrect no-op cases", () => { + assert.equal(reserveOutputHeadroom(100_000, 0, 0.25), 100_000); + assert.equal(reserveOutputHeadroom(100_000, 100_000, 0.25), 100_000, "maxOutput >= window still degenerate"); + assert.equal(reserveOutputHeadroom(0, 10_000, 0.25), 0); +}); + test("OVERFLOW_MARKER: case-insensitive and matches the shared guard patterns", () => { assert.ok(OVERFLOW_MARKER.test("PROMPT IS TOO LONG")); assert.ok(OVERFLOW_MARKER.test("Context Length Exceeded")); @@ -174,3 +198,23 @@ test("shouldReserveOutputHeadroom: anthropic-messages exempt, other APIs reserve assert.equal(shouldReserveOutputHeadroom("bedrock-converse-stream"), true, "conservative for uncertain APIs"); assert.equal(shouldReserveOutputHeadroom(undefined), true, "unknown api → conservative (reserve)"); }); + +test("applyOutputHeadroom: threads the cap through to reserveOutputHeadroom", () => { + const config = { modelContextLimit: 262_144 }; + const model = { maxTokens: 131_072, api: "openai-completions" }; + assert.equal(applyOutputHeadroom(config, model, 0.25).modelContextLimit, 196_608, "capped reservation (qwen3.8-27b issue #207)"); + assert.equal(applyOutputHeadroom(config, model, 1).modelContextLimit, 131_072, "cap 1 = legacy full reservation"); + assert.equal(applyOutputHeadroom(config, model).modelContextLimit, 131_072, "no cap arg = legacy for old call sites"); + assert.equal(applyOutputHeadroom(config, model, 0).modelContextLimit, 262_144, "cap 0 disables the reservation"); + assert.equal(applyOutputHeadroom(config, { maxTokens: 131_072, api: "anthropic-messages" }, 0.25).modelContextLimit, 262_144, "anthropic exempt even with a cap"); + assert.equal(applyOutputHeadroom(config, undefined, 0.25).modelContextLimit, 262_144, "no model → no reservation"); + assert.equal(config.modelContextLimit, 262_144, "input config never mutated"); +}); + +test("resolveOutputHeadroomCap: unset → default, ratio/percent passthrough", () => { + assert.equal(resolveOutputHeadroomCap(undefined), DEFAULT_OUTPUT_HEADROOM_MAX_PCT, "unset → 0.25 default"); + assert.equal(resolveOutputHeadroomCap(0.5), 0.5, "ratio passthrough"); + assert.equal(resolveOutputHeadroomCap("25%"), 0.25, "percent string"); + assert.equal(resolveOutputHeadroomCap(0), 0, "0 stays 0 (disable)"); + assert.equal(resolveOutputHeadroomCap(1), 1, "1 = legacy full reservation"); +}); diff --git a/tests/user-config.test.ts b/tests/user-config.test.ts index 631f4dd..15f417c 100644 --- a/tests/user-config.test.ts +++ b/tests/user-config.test.ts @@ -106,6 +106,18 @@ test("loadUserConfig project config overrides global config", async () => { } }); +test("loadUserConfig reads outputHeadroomMaxPct (ratio and percent string)", async () => { + const tmpDir = path.join(os.tmpdir(), `acp-test-headroom-${Date.now()}`); + await fs.mkdir(tmpDir, { recursive: true }); + await writeConfig(tmpDir, { outputHeadroomMaxPct: "15%" }); + try { + const config = await loadUserConfig(tmpDir); + assert.equal(config.outputHeadroomMaxPct, "15%", "percent string kept verbatim for parsePercent"); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +}); + test("loadUserConfig ignores unknown keys", async () => { const tmpDir = path.join(os.tmpdir(), `acp-test-unknown-${Date.now()}`); await fs.mkdir(tmpDir, { recursive: true }); @@ -187,6 +199,7 @@ test("applyUserConfig supports all user config keys", () => { delegate: false, toolBashDefaultTimeout: 120, toolOutputMaxBytes: 100_000, + outputHeadroomMaxPct: 0.1, }; const result = applyUserConfig(adapter, user); assert.equal(result.debug, true); @@ -195,4 +208,5 @@ test("applyUserConfig supports all user config keys", () => { assert.equal(result.delegate, false); assert.equal(result.toolBashDefaultTimeout, 120); assert.equal(result.toolOutputMaxBytes, 100_000); + assert.equal(result.outputHeadroomMaxPct, 0.1); });