Skip to content
Closed
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)
- **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.<name>.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 统一口径不回归)
Expand Down
12 changes: 7 additions & 5 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Create `~/.pi/acp.json` (or `<project>/.pi/acp.json`) and drop in whichever keys
"compress": {
"maxContextLimit": "75%",
"emergencyThresholdPercent": "95%",
"nudgeGrowthTokens": 50000,
"nudgeGrowthTokens": 100000,
"reasoning": { "drop": true, "threshold": 2048 }
}
}
Expand Down Expand Up @@ -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**
Expand Down Expand Up @@ -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=<f> retainedPctUpperBound=<g>`. `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`

Expand Down Expand Up @@ -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%",
Expand Down
12 changes: 7 additions & 5 deletions CONFIGURATION.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
"compress": {
"maxContextLimit": "75%",
"emergencyThresholdPercent": "95%",
"nudgeGrowthTokens": 50000,
"nudgeGrowthTokens": 100000,
"reasoning": { "drop": true, "threshold": 2048 }
}
}
Expand Down Expand Up @@ -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 键**
Expand Down Expand Up @@ -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=<f> retainedPctUpperBound=<g>`。`firstFoldStartPct` = 最早折叠起点 / 折前视图 ≈ 折叠后首轮的 prompt cache 命中率预期;`retainedPctUpperBound` = afterTokens/beforeTokens,是前缀可保持缓存命中的上界(最长公共前缀 ≤ 存活 token 占比)。
- **同轮重注入:** 同一用户轮内 nudge 至多注入一次,但上下文自上次注入后又增长满一个增长门槛(镜像内核防抖 cadence:`max(minGrowthFloor, minGrowthRatio × adaptiveGrowth)`,默认 45K token)时,会在同轮重新注入新提醒(issue #269:模型忽略 78% nudge 后,原来会一直沉默到 95% emergency 机械截断)。成功 compress 后增长基线重锚到新(更小)刻度,压缩后重新长回压力带不会被压缩前峰值压制。

### `compress.reasoning`

Expand Down Expand Up @@ -544,7 +546,7 @@ provider 的 key 是 **Pi provider 名**(如 `"anthropic"`、`"openai"`、`"zhip
"compress": {
"maxContextLimit": "75%",
"emergencyThresholdPercent": "95%",
"nudgeGrowthTokens": 50000,
"nudgeGrowthTokens": 100000,
"providers": {
"anthropic": {
"maxContextLimit": "80%",
Expand Down
38 changes: 37 additions & 1 deletion src/compress-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -222,6 +222,23 @@ function refIsDead(ref: string, state: CompressionState, visibleIds: Set<string>
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<string>,
imageTokensById: Map<string, number>,
foldedIds: Set<string>,
): 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) {
Expand Down Expand Up @@ -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<string>();
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,
Expand All @@ -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) });
Expand Down
Loading
Loading