diff --git a/CHANGELOG.md b/CHANGELOG.md index 14fec20..a0ab9f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased (master, since v0.1.38) +- **feat(nudge): Pi host 软压缩步长默认 50K→100K + applied 日志折叠几何字段(closes #359)** — 折叠节奏缓存经济学修正后(#359:重付为 `(1−hit)×V'` 而非 ~0.75V、摘要按 output 价 ≈3–5× input 生成),全量折叠在旧节奏下即净赚(回本 ≈4–6 回合 vs 实测周期 ~20 回合)→ 上调阈值的理由从"回本"改为**降低打断频率与每次折叠的摘要输出开销**。`src/config.ts` 新增 `DEFAULT_NUDGE_GROWTH_TOKENS = 100_000`:用户未显式设置 `compress.nudgeGrowthTokens` 时 growthFloor=growthCap=100K(显式值仍优先);内核自身默认 50K 不动(其他 host 保持原节奏)。内核自适应公式 `min(growthCap, max(growthFloor, limit×0.05))` 在 floor=cap 时恒解析到 growthFloor,故有效步长在**所有**窗口尺寸下由恒定 50K 干净翻倍为恒定 100K;连带效应:同轮再注入下限 `max(minGrowthFloor, 0.45×adaptiveGrowth)` 由 22.5K→45K。可观测性:成功折叠时 `event=applied` 新增两个失效几何字段,从日志直读无需实验——`firstFoldStartPct`(最早折叠起点 token 偏移 / 折前视图 ≈ 折后首轮 prompt-cache 命中率预期,与 `beforeTokens` 同一列表同一口径;tier distillation 取被替换块的 `acp_summary_bN` 锚点为真实分歧点)+ `retainedPctUpperBound`(afterTokens/beforeTokens,前缀保留上界——最长公共前缀不可能超过存活 token 占比);不变式 `firstFoldStartPct ≤ retainedPctUpperBound`,无块创建不输出。CONFIGURATION.md(+zh-CN) 同步(默认值 + "为什么是 100K" + 字段说明)。测试:`firstFoldStartTokens` 单测(prefix scale / covered 排除 / null)、applied 几何 e2e 断言、config/e2e-config 默认值断言更新,`tests/nudge-reinject.test.ts` 两用例改 280K 窗口重标定(旧用例按 50K 步长绝对增量构建,新 45K 下限 + 100K drop-reanchor 跨度下小窗口几何不可行) - **fix(degeneration): thinking/text 单字符退化熔断 + 一次性恢复通知(closes #351)** — 长会话末尾模型偶发退化为单字符长连击(实测:thinking 块末尾 4655 个连续「【」,跨轮升级直至 turn abort、会话停死)。根因链已代码级验证:pi 的 openai-completions 转换把历史 assistant thinking 在**每个后续请求**中回传 provider(`reasoning_content`,或 requiresThinkingAsText 时转纯文本),aborted turn 的部分消息又持久化在会话日志里 → 退化尾部随每轮 prompt 重放 → 模型看到自己上一轮以数千个重复字符结尾 → 续写偏置再次触发退化 → 连环 abort。新增 `src/degeneration.ts`:每个 context 事件对出站视图的 assistant text/thinking 块折叠 ≥`minRun`(默认 200,下限 8,codepoint/代理对安全)的单 codepoint 连击为短标记(保留 ≤3 份样本;纯函数、幂等——标记固定文案无相邻重复码点、fail-safe;持久化历史不改,toolCall 参数不动以免与实际执行脱钩);当最近一条 assistant 消息已退化时追加一次性 `[ACP recovery notice]`(位置自限:模型产出新 turn 后自动消失,无持久状态不累积,#223 教训)。检测走持久化 originals 而非出站视图:thinking-only aborted turn 会被 projectMessage 丢弃(空文本在 OpenAI 兼容 provider 400),但它仍是模型的"上一轮",通知必须照发。acp.json 新键 `degenerationGuard`(boolean 或 `{enabled,minRun}`,默认开;`false` 为 kill-switch)。附带修复:`repetitionGuard` 此前不在 user-config KNOWN 白名单内,acp.json 中配置被静默丢弃(dead key),本次补入。测试 `tests/degeneration.test.ts`(31 例:单元 + context transform 端到端 wiring) - **fix(reasoning): 闭合判定改按回合证据——无用户消息的长 agent 会话不再永久保留 compress thinking(closes #348)** — 原门控“compress 调用之后存在真实用户消息才算闭合”在长 agent 会话不可达(整个会话只有开头 1–2 条用户消息,后续 30 个 compress 全部被永久视为活跃回合,观察会话 0 次触发,thinking 地板 20.6K/8.4K/10.6K 字符全部滞留)。现在闭合判定改为:消息内**每个** compress toolCall 的 toolResult(role `toolResult`、`toolCallId` 匹配)已出现在更晚位置,且其后至少还有一条消息(回合已实际推进)。安全门不变:结果未返回或结果仍是最后一条消息(在飞中)绝不动;nudge 在 drop 之后才注入,不可能光当“结果后的消息”闭合在飞回合;per-provider `compress.providers..reasoning.drop=false` 逃生阀保留(GLM 等 reasoning 回显模型)。测试重写 + 新增 #348 场景(无用户消息的助手链闭合、result 悬置、result 在 call 之前、多 toolCall 部分闭合、误 id 不闭合) - **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 统一口径不回归) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 19d18d7..b159e4d 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -49,7 +49,7 @@ Create `~/.pi/acp.json` (or `/.pi/acp.json`) and drop in whichever keys "compress": { "maxContextLimit": "75%", "emergencyThresholdPercent": "95%", - "nudgeGrowthTokens": 50000, + "nudgeGrowthTokens": 100000, "reasoning": { "drop": true, "threshold": 2048 } } } @@ -146,7 +146,7 @@ All keys below are currently **ACTIVE**. |-----|------|---------|--------|-------------| | `compress.maxContextLimit` | number \| string | `"75%"` | 🟢 ACTIVE | Context threshold that triggers forced compression nudges. | | `compress.emergencyThresholdPercent` | number \| string | `"95%"` | 🟢 ACTIVE | Context threshold that triggers emergency truncation. | -| `compress.nudgeGrowthTokens` | number | `50000` | 🟢 ACTIVE | Token growth step for soft compression nudges. | +| `compress.nudgeGrowthTokens` | number | `100000` | 🟢 ACTIVE | Token growth step for soft compression nudges. | | `compress.reasoning` | object | `{ "drop": true, "threshold": 2048 }` | 🟢 ACTIVE | Drop oversized thinking from historical `compress` calls (request-time; persisted history untouched). | **Prompts keys** @@ -512,10 +512,12 @@ The flow is: ### `compress.nudgeGrowthTokens` - **Type:** `number` -- **Default:** `50000` +- **Default:** `100000` (Pi host; the kernel's own default is `50000`) - **Status:** 🟢 ACTIVE - **Description:** The token-growth threshold that controls the cadence of **soft** compression nudges. A soft nudge fires roughly every time this many tokens of new compressible content accumulate. A lower value means the model is nudged to compress more often; a higher value means less frequent nudges. This only governs *growth-driven* nudges — once usage crosses `compress.maxContextLimit`, forced nudges take over regardless of this setting. Maps to the kernel settings `nudge.growthFloor` and `nudge.growthCap`. -- **Same-turn re-inject:** within one user turn a nudge injects at most once, but once the context has since grown by a full growth floor (mirroring the kernel's anti-thrashing cadence: `max(minGrowthFloor, minGrowthRatio × adaptiveGrowth)` — 22.5K tokens with defaults) a fresh reminder re-injects in the same turn (issue #269: a model that ignored a 78% nudge used to stay silent until the 95% emergency truncation). After a successful compress the growth baseline re-anchors to the new (smaller) scale, so post-compress regrowth into the pressure band is not held against the pre-compress peak. +- **Why 100K (fold-cadence economics, #359):** every fold has a one-time cost — the non-hit tail of the post-fold view is re-paid at write price once, and the fold's summary is generated at output price (≈3–5× input) — while the reclaimed tokens pay back in only a few turns (measured break-even ≈ 4–6 turns vs ~20-turn fold intervals, i.e. folds are net-positive even at a slow cadence). Doubling the step from the kernel's 50K halves interruption frequency and per-fold summary output without going net-negative. This is the Pi host's product choice; the kernel's own default stays 50K for other hosts. Set `nudgeGrowthTokens` explicitly to override. +- **Observability (#359):** each successful fold logs its invalidation geometry to `acp.log`: `event=applied … firstFoldStartPct= retainedPctUpperBound=`. `firstFoldStartPct` = earliest fold start / pre-fold view ≈ expected first-round prompt-cache hit fraction after the fold; `retainedPctUpperBound` = afterTokens/beforeTokens, an upper bound on how much of the prefix can remain cache-warm (longest common prefix ≤ surviving token fraction). +- **Same-turn re-inject:** within one user turn a nudge injects at most once, but once the context has since grown by a full growth floor (mirroring the kernel's anti-thrashing cadence: `max(minGrowthFloor, minGrowthRatio × adaptiveGrowth)` — 45K tokens with defaults) a fresh reminder re-injects in the same turn (issue #269: a model that ignored a 78% nudge used to stay silent until the 95% emergency truncation). After a successful compress the growth baseline re-anchors to the new (smaller) scale, so post-compress regrowth into the pressure band is not held against the pre-compress peak. ### `compress.reasoning` @@ -551,7 +553,7 @@ The provider key is the **Pi provider name** (e.g. `"anthropic"`, `"openai"`, `" "compress": { "maxContextLimit": "75%", "emergencyThresholdPercent": "95%", - "nudgeGrowthTokens": 50000, + "nudgeGrowthTokens": 100000, "providers": { "anthropic": { "maxContextLimit": "80%", diff --git a/CONFIGURATION.zh-CN.md b/CONFIGURATION.zh-CN.md index 8db5539..4166a95 100644 --- a/CONFIGURATION.zh-CN.md +++ b/CONFIGURATION.zh-CN.md @@ -49,7 +49,7 @@ "compress": { "maxContextLimit": "75%", "emergencyThresholdPercent": "95%", - "nudgeGrowthTokens": 50000, + "nudgeGrowthTokens": 100000, "reasoning": { "drop": true, "threshold": 2048 } } } @@ -145,7 +145,7 @@ |----|------|--------|------|------| | `compress.maxContextLimit` | number \| string | `"75%"` | 🟢 ACTIVE | 触发强制压缩 nudge 的上下文阈值。 | | `compress.emergencyThresholdPercent` | number \| string | `"95%"` | 🟢 ACTIVE | 触发紧急截断的上下文阈值。 | -| `compress.nudgeGrowthTokens` | number | `50000` | 🟢 ACTIVE | 软压缩 nudge 的 token 增长步长。 | +| `compress.nudgeGrowthTokens` | number | `100000` | 🟢 ACTIVE | 软压缩 nudge 的 token 增长步长。 | | `compress.reasoning` | object | `{ "drop": true, "threshold": 2048 }` | 🟢 ACTIVE | 请求时丢弃历史 `compress` 调用上的超大思考(不修改持久化历史)。 | **prompts 键** @@ -504,10 +504,12 @@ ### `compress.nudgeGrowthTokens` - **类型:** `number` -- **默认值:** `50000` +- **默认值:** `100000`(Pi host;内核自身默认值为 `50000`) - **状态:** 🟢 ACTIVE - **说明:** 控制**软**压缩 nudge 频率的 token 增长阈值。每当积累约这么多新可压缩内容时,触发一次软 nudge。值越低模型被 nudge 压缩的频率越高;值越高频率越低。此设置只控制*基于增长的* nudge——用量越过 `compress.maxContextLimit` 后,强制 nudge 接管,不受此设置影响。映射到内核设置 `nudge.growthFloor` 和 `nudge.growthCap`。 -- **同轮重注入:** 同一用户轮内 nudge 至多注入一次,但上下文自上次注入后又增长满一个增长门槛(镜像内核防抖 cadence:`max(minGrowthFloor, minGrowthRatio × adaptiveGrowth)`,默认 22.5K token)时,会在同轮重新注入新提醒(issue #269:模型忽略 78% nudge 后,原来会一直沉默到 95% emergency 机械截断)。成功 compress 后增长基线重锚到新(更小)刻度,压缩后重新长回压力带不会被压缩前峰值压制。 +- **为什么是 100K(折叠节奏经济学,#359):** 每次折叠有一次性成本——折后视图的未命中尾部按写入价重付一次,且折叠摘要按 output 价(≈3–5× input)生成——而被回收的 token 只需几个回合即回本(实测回本 ≈4–6 回合 vs ~20 回合折叠周期,即慢节奏下折叠仍是净赚)。把步长从内核默认的 50K 翻倍,打断频率与每次折叠的摘要输出开销减半,且不进入净亏区。这是 Pi host 的产品选择;内核自身默认值对其他 host 保持 50K。显式设置 `nudgeGrowthTokens` 可覆盖。 +- **可观测性(#359):** 每次成功折叠都会把失效几何写入 `acp.log`:`event=applied … firstFoldStartPct= retainedPctUpperBound=`。`firstFoldStartPct` = 最早折叠起点 / 折前视图 ≈ 折叠后首轮的 prompt cache 命中率预期;`retainedPctUpperBound` = afterTokens/beforeTokens,是前缀可保持缓存命中的上界(最长公共前缀 ≤ 存活 token 占比)。 +- **同轮重注入:** 同一用户轮内 nudge 至多注入一次,但上下文自上次注入后又增长满一个增长门槛(镜像内核防抖 cadence:`max(minGrowthFloor, minGrowthRatio × adaptiveGrowth)`,默认 45K token)时,会在同轮重新注入新提醒(issue #269:模型忽略 78% nudge 后,原来会一直沉默到 95% emergency 机械截断)。成功 compress 后增长基线重锚到新(更小)刻度,压缩后重新长回压力带不会被压缩前峰值压制。 ### `compress.reasoning` @@ -544,7 +546,7 @@ provider 的 key 是 **Pi provider 名**(如 `"anthropic"`、`"openai"`、`"zhip "compress": { "maxContextLimit": "75%", "emergencyThresholdPercent": "95%", - "nudgeGrowthTokens": 50000, + "nudgeGrowthTokens": 100000, "providers": { "anthropic": { "maxContextLimit": "80%", diff --git a/src/compress-tool.ts b/src/compress-tool.ts index 8ce0b84..5921a89 100644 --- a/src/compress-tool.ts +++ b/src/compress-tool.ts @@ -8,7 +8,7 @@ import type { AcpRuntime } from "./runtime.js"; import { MAX_COMPRESS_ATTEMPTS } from "./runtime.js"; import { debug, logError, logInfo, logThrow, logWarn } from "./log.js"; import { estimateTokens, collectCoveredMessageIds, collectImageTokens, modelSupportsImages, lastUserMessageId, adjustedTokenCount } from "./tokens.js"; -import { defaultCountTokens, parseCompressArgs, viableRanges, formatRanges, type CompressionBlock, type CompressionState, type CompressParseDiagnostics, type NudgeDecision } from "acp-kernel"; +import { defaultCountTokens, parseCompressArgs, viableRanges, formatRanges, type CompressionBlock, type CompressionState, type CompressParseDiagnostics, type CoreMessage, type NudgeDecision } from "acp-kernel"; import { countUnicodeEscapes, findUnverifiableUserQuote, sanitizeSummary } from "./summary-sanitize.js"; import { getSystemPromptText } from "./compat.js"; import { OMP_UNSUPPORTED_MESSAGE } from "./omp.js"; @@ -222,6 +222,23 @@ function refIsDead(ref: string, state: CompressionState, visibleIds: Set return !hasActiveOwner(state, [rawId], visibleIds); } +// #359: token offset of the earliest fold start within the pre-fold sent view — +// where the next render first diverges from the previous one, i.e. the expected +// first-round cache-hit fraction. Measured on the same list/scale as +// beforeTokens (same covered-id exclusion + image tokens). Returns null when no +// folded id is present in the view. +export function firstFoldStartTokens( + messages: CoreMessage[], + coveredIds: Set, + imageTokensById: Map, + foldedIds: Set, +): number | null { + const idx = messages.findIndex((m) => foldedIds.has(m.id)); + if (idx === -1) return null; + if (idx === 0) return 0; + return estimateTokens(messages.slice(0, idx), coveredIds, imageTokensById); +} + function compressibleSnapshotText(nudge: NudgeDecision | undefined): string { const ranges = viableRanges(nudge?.compressibleRanges ?? []); if (ranges.length === 0) { @@ -419,6 +436,19 @@ async function handleCompress(args: CompressArgs, runtime: AcpRuntime, ctx: Exte const reclaimed = Math.max(0, beforeTokens - afterTokens); const newBlocks = applied.state.blocks.slice(-blocksCreated); + // #359: fold geometry for the applied event. Earliest divergence point = + // earliest newly-covered message OR anchor of a block consumed by a new + // block (tier distillation replaces its summary in place). retainedPctUpperBound + // bounds prefix retention because the longest common prefix of pre/post + // renders can never exceed the surviving token fraction. + const foldedIds = new Set(); + for (const b of newBlocks) { + for (const id of b.effectiveMessageIds) foldedIds.add(id); + for (const childId of b.directBlockIds) foldedIds.add(`acp_summary_${childId}`); + } + const foldStartTokens = blocksCreated > 0 && beforeTokens > 0 + ? firstFoldStartTokens(messages, collectCoveredMessageIds(state), imageTokens, foldedIds) + : null; debug.event("compress-out", { sid: ctx.sessionManager.getSessionId(), blocksCreated, @@ -444,6 +474,12 @@ async function handleCompress(args: CompressArgs, runtime: AcpRuntime, ctx: Exte warnings: warnings.length, errors: errors.length, newBlockIds: newBlocks.map((b) => b.blockId), + ...(blocksCreated > 0 && beforeTokens > 0 + ? { + ...(foldStartTokens !== null ? { firstFoldStartPct: Number((foldStartTokens / beforeTokens).toFixed(3)) } : {}), + retainedPctUpperBound: Number((afterTokens / beforeTokens).toFixed(3)), + } + : {}), }); if (errors.length > 0) { logError("compress", { sid: ctx.sessionManager.getSessionId(), event: "errors", count: errors.length, errors: errors.slice(0, 5) }); diff --git a/src/config.ts b/src/config.ts index 4314281..6a6a8cc 100644 --- a/src/config.ts +++ b/src/config.ts @@ -121,8 +121,12 @@ export interface CompressSettings { * Default: 0.95. Must be >= maxContextLimit. Maps to kernel * nudge.emergencyThresholdPct + truncate.threshold. */ emergencyThresholdPercent?: number | string; - /** Token growth threshold for soft compression nudges. Default: 50000. - * Maps to kernel nudge.growthFloor + nudge.growthCap. */ + /** Token growth threshold for soft compression nudges. Default: 100000 + * (Pi host default — 2× the kernel's 50000, raised per #359 fold-cadence + * economics: each fold pays a one-time non-hit re-pay plus its summary at + * output price, while paying back in only a few turns, so a slower cadence + * is net-positive and halves interruption/output overhead). Maps to kernel + * nudge.growthFloor + nudge.growthCap. */ nudgeGrowthTokens?: number; /** Minimum reclaimable tokens for a pressure-band nudge (kernel #198). * Default: max(5000, round(limit×0.01)). Explicit 0 restores the legacy @@ -402,6 +406,12 @@ export function resolveCompress( return mergeCompress(compress, prov, model); } +// #359: Pi host default for the soft-nudge growth step. The kernel's own +// default (50K) is left intact for other hosts; for windows ≤1M tokens the +// kernel's adaptive formula resolves the effective step to growthFloor anyway +// (growthRatio 0.05 × limit < floor), so this cleanly doubles the cadence. +export const DEFAULT_NUDGE_GROWTH_TOKENS = 100_000; + export function resolveConfig(adapter: AdapterConfig, liveContextLimit: number, provider?: string, modelId?: string): Config { const envLimit = process.env.ACP_MODEL_CONTEXT_LIMIT; const envLimitNum = envLimit ? Number(envLimit) : NaN; @@ -429,6 +439,9 @@ export function resolveConfig(adapter: AdapterConfig, liveContextLimit: number, if (c.nudgeGrowthTokens !== undefined) { config.nudge.growthFloor = c.nudgeGrowthTokens; config.nudge.growthCap = c.nudgeGrowthTokens; + } else { + config.nudge.growthFloor = DEFAULT_NUDGE_GROWTH_TOKENS; + config.nudge.growthCap = DEFAULT_NUDGE_GROWTH_TOKENS; } if (c.minPressureBenefitTokens !== undefined) { config.nudge.minPressureBenefitTokens = c.minPressureBenefitTokens; diff --git a/tests/compress-tool.test.ts b/tests/compress-tool.test.ts index ecf9293..745f79f 100644 --- a/tests/compress-tool.test.ts +++ b/tests/compress-tool.test.ts @@ -1,7 +1,10 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { readFile, rm } from "node:fs/promises"; +import type { CoreMessage } from "acp-kernel"; import { createAcpExtension } from "../src/index.js"; +import { firstFoldStartTokens } from "../src/compress-tool.js"; +import { estimateTokens } from "../src/tokens.js"; // ─── helpers (mirror decompress-tool.test.ts) ────────────────────────────── @@ -202,3 +205,66 @@ test("compress in an in-memory session (no session file) survives to the next co assert.match(report, /b1 \(T1\)/, `b1 must survive after the second compress: ${report}`); assert.match(report, /b2 \(T1\)/, `second block must be numbered b2 (nextBlockId retained), not reset to b1: ${report}`); }); + +test("firstFoldStartTokens measures the pre-fold prefix on the beforeTokens scale (#359)", () => { + const msgs = [ + { id: "a", role: "user", contentType: "text", text: "x".repeat(400) }, + { id: "b", role: "assistant", contentType: "text", text: "y".repeat(800) }, + { id: "c", role: "user", contentType: "text", text: "z".repeat(1200) }, + ] as unknown as CoreMessage[]; + const none = new Set(); + const noImages = new Map(); + // fold starts at c → prefix = a+b, exactly what estimateTokens gives for the slice + assert.equal(firstFoldStartTokens(msgs, none, noImages, new Set(["c"])), estimateTokens(msgs.slice(0, 2), none, noImages)); + // fold at the very start → zero prefix + assert.equal(firstFoldStartTokens(msgs, none, noImages, new Set(["a"])), 0); + // middle fold → one-message prefix + assert.equal(firstFoldStartTokens(msgs, none, noImages, new Set(["b"])), estimateTokens(msgs.slice(0, 1), none, noImages)); + // folded id absent from the view → nothing measurable + assert.equal(firstFoldStartTokens(msgs, none, noImages, new Set(["zzz"])), null); + // covered-id exclusion matches beforeTokens semantics + const coveredA = new Set(["a"]); + assert.equal(firstFoldStartTokens(msgs, coveredA, noImages, new Set(["c"])), estimateTokens(msgs.slice(0, 2), coveredA, noImages)); +}); + +test("event=applied logs firstFoldStartPct + retainedPctUpperBound fold geometry (#359)", async () => { + const logFile = "/tmp/pai-acp-compress-applied-geometry.log"; + const stateFile = "/tmp/pai-acp-compress-applied-geometry.session.json"; + await rm(logFile, { force: true }); + await rm(`${stateFile}.acp.json`, { force: true }); + process.env.ACP_LOG_FILE = logFile; + try { + const { api, handlers } = captureApi(); + // preserveRecentMessages:1 keeps only m00003 out of the protected zone so + // the middle fold (m00002) is viable (same pattern as the #309 test). + createAcpExtension({ modelContextLimit: 200_000, preserveRecentMessages: 1 })(api as any); + const BIG = "中".repeat(6000); + const entries = [userMsg("e1", BIG), userMsg("e2", BIG), userMsg("e3", BIG)]; + const ctx = fakeCtx(entries, stateFile); + ctx.__setUsage(100_000); + await runContextRound(handlers, ctx); // prime refs + + const compressTool = api.tools.find((t: any) => t.name === "compress")!; + const out = await compressTool.execute( + "tc1", + { content: [{ startId: "m00002", endId: "m00002", summary: "Middle fold geometry test: second of three identical CJK message blocks compressed to verify the applied-event fields." }] }, + undefined, undefined, ctx, + ); + const text = typeof out === "string" ? out : out.content?.[0]?.text ?? String(out); + assert.ok(text.includes("▣ ACP") && !text.includes("Errors:"), `compress failed: ${text}`); + + const lines = (await readFile(logFile, "utf8")).split("\n").filter((l) => l.includes("event=applied")); + assert.equal(lines.length, 1, `exactly one applied line, got: ${lines.join(" | ")}`); + const applied = lines[0]!; + const start = Number(/firstFoldStartPct=(\d+(?:\.\d+)?)/.exec(applied)?.[1]); + const retained = Number(/retainedPctUpperBound=(\d+(?:\.\d+)?)/.exec(applied)?.[1]); + // m00002 is the middle of three equal messages → divergence ≈ 1/3 into the view + assert.ok(start > 0.2 && start < 0.6, `firstFoldStartPct ≈ 1/3 for a middle fold, got ${start} (${applied})`); + // ~2/3 of the view survives → upper bound in (0.4, 0.9) + assert.ok(retained > 0.4 && retained < 0.9, `retainedPctUpperBound ≈ 2/3, got ${retained} (${applied})`); + // invariant: longest common prefix ≤ surviving token fraction + assert.ok(start <= retained, `prefix retention ≤ surviving fraction: ${start} ≤ ${retained}`); + } finally { + delete process.env.ACP_LOG_FILE; + } +}); diff --git a/tests/config.test.ts b/tests/config.test.ts index c962832..8b2e70b 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -76,10 +76,10 @@ test("resolveConfig maps compress.nudgeGrowthTokens to both growthFloor and grow assert.equal(cfg.nudge.growthCap, 30000); }); -test("resolveConfig leaves growthFloor/growthCap at kernel defaults when compress.nudgeGrowthTokens omitted", () => { +test("resolveConfig applies the Pi host default growth step (100000, #359) when compress.nudgeGrowthTokens omitted", () => { const cfg = resolveConfig(EMPTY, 1_000_000); - assert.equal(cfg.nudge.growthFloor, 50000); - assert.equal(cfg.nudge.growthCap, 50000); + assert.equal(cfg.nudge.growthFloor, 100000); + assert.equal(cfg.nudge.growthCap, 100000); }); test("resolveConfig maps compress.minPressureBenefitTokens to kernel nudge (0 = legacy any-pending)", () => { diff --git a/tests/e2e-compress-config.test.ts b/tests/e2e-compress-config.test.ts index 8887725..e6d947d 100644 --- a/tests/e2e-compress-config.test.ts +++ b/tests/e2e-compress-config.test.ts @@ -95,7 +95,7 @@ test("e2e compress config: without a config file the kernel defaults apply", asy const cfg = runtime.configFor(ctxFor("anthropic", "claude-sonnet-4-5", 200_000)); assert.equal(cfg.nudge.maxContextLimitPct, 0.75, "kernel default maxContextLimitPct"); assert.equal(cfg.nudge.emergencyThresholdPct, 0.95, "kernel default emergencyThresholdPct"); - assert.equal(cfg.nudge.growthFloor, 50000, "kernel default growthFloor"); + assert.equal(cfg.nudge.growthFloor, 100000, "Pi host default growthFloor (#359)"); }); process.env.HOME = savedHome; }); diff --git a/tests/nudge-reinject.test.ts b/tests/nudge-reinject.test.ts index 15a2f5f..ff30982 100644 --- a/tests/nudge-reinject.test.ts +++ b/tests/nudge-reinject.test.ts @@ -8,13 +8,16 @@ import { createAcpExtension } from "../src/index.js"; // the 95% emergency band (per-turn suppression → mechanical truncation) with // no fresh reminder on the growth in between. Fix: once the context has grown // by a full growth floor (mirroring the kernel's decideNudge cadence: -// max(minGrowthFloor, minGrowthRatio × adaptiveGrowth) — for the defaults -// below, 0.45 × 50 000 = 22 500), the nudge re-injects within the same turn. +// max(minGrowthFloor, minGrowthRatio × adaptiveGrowth) — with the Pi host +// defaults below, 0.45 × 100 000 = 45 000 (#359)), the nudge re-injects +// within the same turn. // After a successful compress the baseline re-anchors (mirror of the kernel's // nudgeNode drop re-anchor), so post-compress regrowth into the pressure band // re-injects without needing to exceed the old peak. -const LIMIT = 180_000; +// Test 1 needs a wide window: at the 45K re-inject floor the first pressure +// injection (≥75%) and the re-inject point (+45K, <95%) must both fit inside +// the pressure band — impossible at windows ≤225K (0.75L + 45K < 0.95L ⟹ L > 225K). function captureApi() { const handlers = new Map any)[]>(); const api = { @@ -36,28 +39,28 @@ function msg(id: string, role: string, text: string, over: Record {}, confirm: async () => true, select: async () => undefined, input: async () => "", setStatus: () => {} }, - model: { contextWindow: LIMIT }, + model: { contextWindow: limit }, sessionManager: { getBranch: () => branchEntries as any[], getSessionId: () => "reinject", getSessionFile: () => stateFile, }, - getContextUsage: () => ({ tokens, percent: tokens / LIMIT, contextWindow: LIMIT }), + getContextUsage: () => ({ tokens, percent: tokens / limit, contextWindow: limit }), }; } -const fire = (handlers: Map any)[]>, entries: any[], tokens: number) => - handlers.get("context")![0]!({ type: "context", messages: entries.map((e) => e.message) }, fakeCtx(tokens)); +const fire = (handlers: Map any)[]>, entries: any[], tokens: number, limit: number) => + handlers.get("context")![0]!({ type: "context", messages: entries.map((e) => e.message) }, fakeCtx(tokens, limit)); // Six MID-sized messages (~11K compressible mass, above the 5K // minPressureBenefit floor) ending in the anchor assistant. The LAST USER @@ -85,62 +88,70 @@ const nudgeCount = (rebuilt: any[]) => }).length; test("same-turn pressure nudge re-injects only after a full growth floor (issue #269)", async () => { + const LIMIT_A = 280_000; stateFile = "/tmp/pai-acp-reinject-a.session.json"; await rm(`${stateFile}.acp.json`, { force: true }); const { api, handlers } = captureApi(); - createAcpExtension({ modelContextLimit: LIMIT })(api as any); + createAcpExtension({ modelContextLimit: LIMIT_A })(api as any); - // Event 1 — 140K/180K = 78%: pressure nudge injects (mark at 140K). - branchEntries = [...bulk(), anchor(140_000)]; - const r1 = (await fire(handlers, branchEntries, 140_000)).messages; - assert.equal(nudgeCount(r1), 1, "78% pressure nudge injects"); + // Event 1 — 215K/280K = 76.8%: pressure nudge injects (mark at 215K). + branchEntries = [...bulk(), anchor(215_000)]; + const r1 = (await fire(handlers, branchEntries, 215_000, LIMIT_A)).messages; + assert.equal(nudgeCount(r1), 1, "76.8% pressure nudge injects"); - // Event 2 — same turn, +2K growth (< floor): suppressed. - branchEntries = [...bulk(), anchor(142_000)]; - const r2 = (await fire(handlers, branchEntries, 142_000)).messages; + // Event 2 — same turn, +2K growth (< 45K floor): suppressed. + branchEntries = [...bulk(), anchor(217_000)]; + const r2 = (await fire(handlers, branchEntries, 217_000, LIMIT_A)).messages; assert.equal(nudgeCount(r2), 0, "small same-turn growth stays suppressed"); - // Event 3 — same turn, +23K growth (>= floor): re-injects. - branchEntries = [...bulk(), anchor(163_000)]; - const r3 = (await fire(handlers, branchEntries, 163_000)).messages; + // Event 3 — same turn, +49K growth (>= 45K floor): re-injects. + branchEntries = [...bulk(), anchor(264_000)]; + const r3 = (await fire(handlers, branchEntries, 264_000, LIMIT_A)).messages; assert.equal(nudgeCount(r3), 1, "growth past the re-inject floor re-shows within the turn"); }); test("drop re-anchor: post-compress regrowth into the pressure band re-injects without exceeding the old peak", async () => { + // Also needs a wide window: the collapse must beat the drop-reanchor gate + // (tokenCount < peak − adaptiveGrowth, i.e. 100K at the #359 defaults) while + // the regrown point sits in the pressure band yet UNDER the old peak. + const LIMIT_B = 280_000; stateFile = "/tmp/pai-acp-reinject-b.session.json"; await rm(`${stateFile}.acp.json`, { force: true }); const { api, handlers } = captureApi(); - createAcpExtension({ modelContextLimit: LIMIT })(api as any); - - // Inject at 150K, then a successful compress collapses the anchor scale. - branchEntries = [...bulk(), anchor(150_000)]; - const r1 = (await fire(handlers, branchEntries, 150_000)).messages; - assert.equal(nudgeCount(r1), 1, "83% pressure nudge injects"); - - // Post-compress: the compress toolResult predates the fresh 90K anchor, so - // the meter runs on the provider scale at 90K. 90K < 150K − 50K → the - // baseline re-anchors to 90K (no nudge at 50% usage). - branchEntries = [...bulk(), { type: "message", id: "c1", parentId: null, timestamp: "", message: { role: "toolResult", toolName: "compress", toolCallId: "tc1", content: [{ type: "text", text: COMPRESS_PANEL }], timestamp: Date.now() } }, anchor(90_000)]; - const r2 = (await fire(handlers, branchEntries, 90_000)).messages; + createAcpExtension({ modelContextLimit: LIMIT_B })(api as any); + + // Inject at 250K (89.3%, pressure), then a successful compress collapses the anchor scale. + branchEntries = [...bulk(), anchor(250_000)]; + const r1 = (await fire(handlers, branchEntries, 250_000, LIMIT_B)).messages; + assert.equal(nudgeCount(r1), 1, "89.3% pressure nudge injects"); + + // Post-compress: the compress toolResult predates the fresh 130K anchor, so + // the meter runs on the provider scale at 130K. 130K < 250K − 100K → the + // baseline re-anchors to 130K (no nudge at 46.4% usage). + branchEntries = [...bulk(), { type: "message", id: "c1", parentId: null, timestamp: "", message: { role: "toolResult", toolName: "compress", toolCallId: "tc1", content: [{ type: "text", text: COMPRESS_PANEL }], timestamp: Date.now() } }, anchor(130_000)]; + const r2 = (await fire(handlers, branchEntries, 130_000, LIMIT_B)).messages; assert.equal(nudgeCount(r2), 0, "post-compress regrowth stays quiet below the pressure band"); - // Regrow to 136K (75.6%, pressure). Without the drop re-anchor the baseline - // would still be the 150K peak (growth −14K < floor) and the nudge would be - // suppressed straight into the emergency band — the exact #269 escalation. - branchEntries = [...bulk(), { type: "message", id: "c1", parentId: null, timestamp: "", message: { role: "toolResult", toolName: "compress", toolCallId: "tc1", content: [{ type: "text", text: COMPRESS_PANEL }], timestamp: Date.now() } }, anchor(136_000)]; - const r3 = (await fire(handlers, branchEntries, 136_000)).messages; + // Regrow to 220K (78.6%, pressure, still 30K under the 250K peak). With the + // re-anchored 130K baseline growth is +90K >= 45K floor → passes. Without it + // the baseline would still be the 250K peak (growth −30K < floor) and the + // nudge would be suppressed straight into the emergency band — the exact + // #269 escalation. + branchEntries = [...bulk(), { type: "message", id: "c1", parentId: null, timestamp: "", message: { role: "toolResult", toolName: "compress", toolCallId: "tc1", content: [{ type: "text", text: COMPRESS_PANEL }], timestamp: Date.now() } }, anchor(220_000)]; + const r3 = (await fire(handlers, branchEntries, 220_000, LIMIT_B)).messages; assert.equal(nudgeCount(r3), 1, "re-anchored baseline lets the regrown pressure nudge through"); }); test("emergency bypass is unchanged (95% injects on every event)", async () => { + const LIMIT_C = 180_000; stateFile = "/tmp/pai-acp-reinject-c.session.json"; await rm(`${stateFile}.acp.json`, { force: true }); const { api, handlers } = captureApi(); - createAcpExtension({ modelContextLimit: LIMIT })(api as any); + createAcpExtension({ modelContextLimit: LIMIT_C })(api as any); branchEntries = [...bulk(), anchor(175_000)]; - const r1 = (await fire(handlers, branchEntries, 175_000)).messages; + const r1 = (await fire(handlers, branchEntries, 175_000, LIMIT_C)).messages; assert.equal(nudgeCount(r1), 1, "emergency injects"); - const r2 = (await fire(handlers, branchEntries, 175_000)).messages; + const r2 = (await fire(handlers, branchEntries, 175_000, LIMIT_C)).messages; assert.equal(nudgeCount(r2), 1, "emergency keeps injecting without a growth floor"); });