From b1b127367540de2bb89a77a0120f59f86f9f893b Mon Sep 17 00:00:00 2001 From: ework-agent Date: Sat, 5 Sep 2026 17:19:27 +0800 Subject: [PATCH] fix(overflow): cap output-headroom reservation at a fraction of the window (closes #207) --- CHANGELOG.md | 1 + CONFIGURATION.md | 9 +++++++++ CONFIGURATION.zh-CN.md | 9 +++++++++ src/config.ts | 6 ++++++ src/index.ts | 30 ++++++++++++++++------------- src/overflow-selfheal.ts | 34 ++++++++++++++++++++++++++++----- src/user-config.ts | 2 ++ tests/overflow-selfheal.test.ts | 24 +++++++++++++++++++++++ tests/user-config.test.ts | 14 ++++++++++++++ 9 files changed, 111 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95a1a91..0a6378b 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` 字段 - **fix(delegate): 并发完成的 delegate 通知合并为单条批量消息(closes #157)** — 多个 subagent 同时(或主窗口正忙时接连)完成时,每条完成各自 `sendUserMessage(deliverAs: followUp)` 注入一条通知,N 个完成的 delegate 吃掉 N 个完整模型轮次,且模型已收尾后通知仍在持续涌入。现在 finalize 不再直接注入,而是进入 2s 尾沿防抖窗口(自首个排队完成起硬上限 10s,防连续错峰完成饿死投递);窗口关闭时 `flushDelegateNotifications` 发**一条**批量消息:头部计数(`[acp_delegate] 3 delegates finished (2 completed, 1 FAILED)`)+ 每个 run 一节(状态/exit code/超时注记/任务/结果文件/失败错误摘录)+ 单一尾部(仍在跑的 delegate 数、session delegate usage、收尾指令)。窗口期内获得 waiter 或被 `acp_delegate_wait`/`acp_delegate_cancel` 消费的 run 自动出批(不重复投递);发送失败不置 delivered,run 留在未送达集由后续 carrier 补投(`findUndeliveredRuns` 将排队中视为已排程而非丢失);单 run flush 保留原单条格式。净效果:N 个同时完成 → 1 个模型轮次 - **feat(delegate): 失败/取消保留日志 + 失败诊断 + `resumeFrom` 续跑中断的 run (closes #235)** — 此前 cancel 与 spawn error 路径直接删除 `.out`/`.activity` 文件,失败通知只有 exit code(信号被丢弃、stderr 可能为空、activity 轨迹不可见)。现在:① 所有终止路径保留文件(spawn error 把错误写入 `.out`;cancel 回填部分回复,cancel/wait 结果明确给出文件路径);② 失败通知带 exit 信号(`exit SIGTERM`)、stderr、activity 日志尾部(400 字符)与 activity 文件路径;③ pi 宿主 delegate 用 `--session /.session.jsonl` + `--session-dir` 持久化自身会话(omp 不变,保持 `--no-session`),新增 `resumeFrom: ""` 参数让新 run 恢复原会话(原任务 + 已执行的 tool calls + 部分结果)从中断处继续——`task` 变为可选(提供时作为本次追加指引);校验:原 run 不得仍在运行、session 文件必须存在、非 pi 宿主拒绝 - **fix(floor): provider-usage floor 跳过压缩后一轮的 stale anchor(#258 评审)** — pi 的 `getContextUsage()` 锚定最后一条有效 assistant usage;成功 compress 落在锚点之后时,下一个 LLM 调用仍以压缩前的大数字 floor,会在刚缩小的上下文上重跑 emergency(nudge 注入 + 工具结果机械截断),面板也继续显示压缩前数字,直到下一条 usage 到达。新增 `src/floor-stale.ts`(`usageAnchorPredatesCompression`):锚点(跳过 aborted/error/全零)早于最后一条成功 compress toolResult(失败/0-block no-op 不算)时跳过 floor,context transform / `acp_status` / `/acp` 三处一致(面板 sessionTokens 同步);对齐 pi 自身 compaction 的 "usage source must be post-compaction" 检查。已知接受:provider 永不报 usage 时(omp #18 tree-sum 回退)floor 仍会永久高位 diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 04fd47e..c43e955 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. | @@ -176,6 +178,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 02d4783..357ef9f 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 限流错误(递进退避)。 | @@ -168,6 +170,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/config.ts b/src/config.ts index 75752aa..a2869cc 100644 --- a/src/config.ts +++ b/src/config.ts @@ -105,6 +105,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; /** Legacy flat alias for `delegate.displayUsage`. Kept for backward * compatibility with existing acp.json files. Prefer `delegate.displayUsage`. */ displayUsage?: "merged" | "separate"; diff --git a/src/index.ts b/src/index.ts index 58e83b7..3b37bad 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,7 +10,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import type { CoreMessage, NudgeDecision, CompressionBlock, Prompts } from "acp-kernel"; import { renderNudgeText, resolvePrompts, defaultPrompts, viableRanges } from "acp-kernel"; -import { type AdapterConfig, resolveDelegate } from "./config.js"; +import { type AdapterConfig, resolveDelegate, parsePercent } from "./config.js"; import { createRuntime, type AcpRuntime } from "./runtime.js"; import { makeCompressTool, isCompressSuccessText, isCompressNoopText } from "./compress-tool.js"; import { makeDecompressTool } from "./decompress-tool.js"; @@ -37,7 +37,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 { DEFAULT_OUTPUT_HEADROOM_MAX_PCT, inspectOverflowMessage, reserveOutputHeadroom, shouldReserveOutputHeadroom } from "./overflow-selfheal.js"; import { isOmpHost, OMP_UNSUPPORTED_MESSAGE } from "./omp.js"; type AgentMessage = SessionMessageEntry["message"]; @@ -211,22 +211,25 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime): void { 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. maxTokens is - // the model's max output capability (ctx.model.maxTokens). Applied to the - // (possibly re-centered) window above; never mutates the shared config. - // Anthropic is exempt — its input limit is enforced independently of - // max_tokens, so reserving would shift every band down by maxTokens with - // no safety gain (see shouldReserveOutputHeadroom). + // 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 — its input limit is enforced + // independently of max_tokens (see shouldReserveOutputHeadroom). const maxOutput = (ctx.model as { maxTokens?: number } | undefined)?.maxTokens ?? 0; + const rawHeadroomCap = runtime.adapter.outputHeadroomMaxPct; + const headroomCap = rawHeadroomCap === undefined ? DEFAULT_OUTPUT_HEADROOM_MAX_PCT : parsePercent(rawHeadroomCap); + let fullWindow = config.modelContextLimit; if (shouldReserveOutputHeadroom((ctx.model as { api?: string } | undefined)?.api)) { - const reservedWindow = reserveOutputHeadroom(config.modelContextLimit, maxOutput); + const reservedWindow = reserveOutputHeadroom(config.modelContextLimit, maxOutput, headroomCap); if (reservedWindow !== config.modelContextLimit) { const before = config.modelContextLimit; config = { ...config, modelContextLimit: reservedWindow }; - logInfo("overflow-selfheal", { sid, event: "output-headroom", before, after: reservedWindow, maxOutput }); + logInfo("overflow-selfheal", { sid, event: "output-headroom", before, after: reservedWindow, maxOutput, cap: headroomCap }); } } const coveredIds = collectCoveredMessageIds(state); @@ -288,6 +291,7 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime): void { tokens: tokenCount, pct: realUsage?.percent ?? (config.modelContextLimit > 0 ? Math.round((tokenCount / config.modelContextLimit) * 100) : 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 72f362b..bd69ddd 100644 --- a/src/overflow-selfheal.ts +++ b/src/overflow-selfheal.ts @@ -73,16 +73,38 @@ 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; + /** * 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 +112,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; } 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/overflow-selfheal.test.ts b/tests/overflow-selfheal.test.ts index 7921f7a..370600f 100644 --- a/tests/overflow-selfheal.test.ts +++ b/tests/overflow-selfheal.test.ts @@ -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")); 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); });