diff --git a/CHANGELOG.md b/CHANGELOG.md index cb30e92..6460caf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased (master, since v0.1.38) +- **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 统一口径不回归) - **fix(state): in-memory(无 session 文件)会话压缩后状态不再丢失(closes #322)** — `src/state.ts` 的 cache 更新原本在 no-file early return 之后,导致没有 session 文件的 in-memory 会话压缩后状态不被保存(恢复/重启时丢失)。现在将 cache 更新移到 early return 之前,确保 in-memory 会话也持久化压缩状态。测试:`tests/state.test.ts` + `tests/compress-tool.test.ts` 扩展 - **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` 扩展 diff --git a/src/index.ts b/src/index.ts index 8724e21..875e278 100644 --- a/src/index.ts +++ b/src/index.ts @@ -403,11 +403,12 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime, standDownIf let rebuilt = coreOutToAgentMessages(turn.messages, originalById); // [#336] Request-time reasoning drop, aligned with opencode-acp #377: // compress calls are hard-exempt from compression, so their thinking - // rides along every request as an unreclaimable floor. Applied BEFORE the - // nudge push so a synthetic user-role nudge can never become the "last - // genuine user message" boundary and extend the closed zone over the - // active round. Persisted history is never modified — this only rewrites - // the outgoing view, rebuilt fresh from entries on every event. + // rides along every request as an unreclaimable floor. Round closure is + // judged on tool-result evidence [#348]; applied BEFORE the nudge push + // so a synthetic user-role nudge can never count as the trailing + // "message after the result" and close an in-flight round. Persisted + // history is never modified — this only rewrites the outgoing view, + // rebuilt fresh from entries on every event. const reasoningDrop = runtime.reasoningDropFor(ctx); const droppedThinking = dropCompressReasoning(rebuilt, reasoningDrop); if (droppedThinking !== rebuilt) { diff --git a/src/reasoning-drop.ts b/src/reasoning-drop.ts index 48503ed..26b2595 100644 --- a/src/reasoning-drop.ts +++ b/src/reasoning-drop.ts @@ -9,9 +9,11 @@ type AgentMessage = SessionMessageEntry["message"]; * the anchors that keep block summaries addressable), so their thinking * rides along every request as an unreclaimable context floor. This pass * removes `thinking` parts at request time (persisted history is never - * modified) from closed-turn compress messages whose total reasoning length - * exceeds `threshold`. The active round (from the last genuine user message - * onward) is never touched. */ + * modified) from closed-round compress messages whose total reasoning length + * exceeds `threshold`. A round is closed on ROUND EVIDENCE, not on user + * messages: the compress tool result must have arrived and at least one + * message must exist after it. The in-flight round (result still missing or + * still the last message) is never touched [#348]. */ export interface CompressReasoningConfig { /** Master switch. Default: true. `drop: false` disables the pass entirely * (kill-switch; also the recipe for providers whose thinking items are @@ -54,6 +56,29 @@ function hasCompressCall(content: unknown): boolean { }); } +function compressCallIds(content: unknown): string[] { + if (!Array.isArray(content)) return []; + return content + .filter((p) => { + const b = p as { type?: string; name?: string }; + return b?.type === "toolCall" && b.name === "compress"; + }) + .map((p) => (p as { id?: unknown }).id) + .filter((id): id is string => typeof id === "string"); +} + +/** toolCallId -> index of the message carrying its tool result. Pi gives + * tool results their own `toolResult` role with a top-level `toolCallId`. */ +function resultIndexByCallId(messages: AgentMessage[]): Map { + const map = new Map(); + for (let i = 0; i < messages.length; i++) { + const msg = messages[i] as { role?: string; toolCallId?: unknown }; + if (msg?.role !== "toolResult" || typeof msg.toolCallId !== "string") continue; + if (!map.has(msg.toolCallId)) map.set(msg.toolCallId, i); + } + return map; +} + function reasoningLength(content: unknown): number { if (!Array.isArray(content)) return 0; return content.reduce((n, p) => (isThinking(p) ? n + p.thinking.length : n), 0); @@ -61,9 +86,13 @@ function reasoningLength(content: unknown): number { /** Request-time pass aligned with opencode-acp #377: remove `thinking` parts * from a message only when ALL gates hold — - * 1. closed turn: the message is strictly before the last genuine user - * message (pi gives tool results their own `toolResult` role, so every - * `user` message is genuine); + * 1. closed round [#348]: EVERY `compress` toolCall in the message has its + * tool-result message (role `toolResult`, matching `toolCallId`) at a + * later index, and at least one message exists after that result (the + * round has demonstrably moved on). A compress call without a result, or + * whose result is still the last message, is in flight and never touched + * — no user message is required, so long agentic sessions do close + * rounds; a synthetic nudge pushed later cannot retroactively close one; * 2. selector: the message carries a `toolCall` part with name "compress" * (only compress; other protected tools would need their own explicit * config); @@ -75,17 +104,20 @@ export function dropCompressReasoning(messages: AgentMessage[], cfg?: CompressRe const { drop, threshold } = resolveReasoningDrop(cfg); if (!drop || messages.length === 0) return messages; try { - let lastUser = -1; - for (let i = 0; i < messages.length; i++) { - if ((messages[i] as { role?: string }).role === "user") lastUser = i; - } - if (lastUser < 0) return messages; + const last = messages.length - 1; + const resultAt = resultIndexByCallId(messages); let changed = false; const out = messages.slice(); - for (let i = 0; i < lastUser; i++) { + for (let i = 0; i <= last; i++) { const msg = messages[i] as { role?: string; content?: unknown }; if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue; if (!hasCompressCall(msg.content)) continue; + const ids = compressCallIds(msg.content); + const closed = ids.length > 0 && ids.every((id) => { + const ri = resultAt.get(id); + return ri !== undefined && ri > i && ri < last; + }); + if (!closed) continue; if (reasoningLength(msg.content) <= threshold) continue; out[i] = { ...(msg as object), diff --git a/tests/reasoning-drop.test.ts b/tests/reasoning-drop.test.ts index eb1d523..ac87ab9 100644 --- a/tests/reasoning-drop.test.ts +++ b/tests/reasoning-drop.test.ts @@ -14,6 +14,10 @@ function assistant(parts: unknown[]): AgentMessage { return { role: "assistant", content: parts, timestamp: 0 } as unknown as AgentMessage; } +function toolResult(toolCallId: string): AgentMessage { + return { role: "toolResult", toolCallId, content: [{ type: "text", text: "ok" }], timestamp: 0 } as unknown as AgentMessage; +} + function thinking(len: number, extra: Record = {}): { type: "thinking"; thinking: string } & Record { return { type: "thinking", thinking: "x".repeat(len), ...extra }; } @@ -41,23 +45,53 @@ test("invalid threshold falls back to default, invalid drop is truthy", () => { assert.equal(resolveReasoningDrop({ drop: "yes" as unknown as boolean }).drop, true); }); -test("gate: closed turn — messages from the last genuine user message onward are untouched", () => { +test("gate: closed round — result arrived and a later message exists", () => { const big = thinking(4096); - const msgs = [ - assistant([{ type: "text", text: "old" }, thinking(4096), compressCall()]), - user("go"), - assistant([{ type: "text", text: "active" }, thinking(4096), compressCall()]), - ]; + const closed = assistant([{ type: "text", text: "old" }, thinking(4096), compressCall("c1")]); + const inFlight = assistant([{ type: "text", text: "active" }, thinking(4096), compressCall("c2")]); + const msgs = [closed, toolResult("c1"), user("go"), inFlight, toolResult("c2")]; const out = dropCompressReasoning(msgs, { drop: true, threshold: 0 }); assert.equal((out[0]!.content as unknown[]).includes(big), false); - assert.deepEqual(out[2]!.content, msgs[2]!.content); + assert.deepEqual(out[3]!.content, msgs[3]!.content); // result is last message → in flight +}); + +test("#348: closes WITHOUT any user message — assistant continuation is round evidence", () => { + const closed = assistant([{ type: "text", text: "agentic" }, thinking(4096), compressCall("c1")]); + const continuing = assistant([{ type: "text", text: "carrying on" }]); + const out = dropCompressReasoning([closed, toolResult("c1"), continuing], { drop: true, threshold: 0 }); + assert.deepEqual(out[0]!.content, [{ type: "text", text: "agentic" }, compressCall("c1")]); + assert.deepEqual(out[2]!.content, continuing.content); +}); + +test("gate: result pending (call without any result) is never touched", () => { + const pending = assistant([{ type: "text", text: "hi" }, thinking(4096), compressCall("c1")]); + const msgs = [pending, assistant([{ type: "text", text: "next" }])]; + assert.equal(dropCompressReasoning(msgs, { drop: true, threshold: 0 }), msgs); +}); + +test("gate: result for a different call id does not close the round", () => { + const msg = assistant([{ type: "text", text: "hi" }, thinking(4096), compressCall("c1")]); + const msgs = [msg, toolResult("other"), user("go")]; + assert.equal(dropCompressReasoning(msgs, { drop: true, threshold: 0 }), msgs); +}); + +test("gate: result BEFORE the call message does not close the round", () => { + const msg = assistant([{ type: "text", text: "hi" }, thinking(4096), compressCall("c1")]); + const msgs = [toolResult("c1"), msg, user("go")]; + assert.equal(dropCompressReasoning(msgs, { drop: true, threshold: 0 }), msgs); +}); + +test("gate: mixed message closes on the compress result alone (other calls' results are irrelevant)", () => { + const msg = assistant([{ type: "text", text: "hi" }, thinking(4096), compressCall("c1"), otherCall("read")]); + const msgs = [msg, toolResult("c1"), user("go")]; // read result never arrives + const out = dropCompressReasoning(msgs, { drop: true, threshold: 0 }); + assert.deepEqual(out[0]!.content, [{ type: "text", text: "hi" }, compressCall("c1"), otherCall("read")]); }); test("gate: selector — only messages carrying a compress toolCall part are touched", () => { - const think = thinking(4096); const textOnly = assistant([{ type: "text", text: "hi" }, thinking(4096)]); const otherTool = assistant([{ type: "text", text: "hi" }, thinking(4096), otherCall()]); - const msgs = [textOnly, otherTool, user("go")]; + const msgs = [textOnly, otherTool, toolResult("t1"), user("go")]; const out = dropCompressReasoning(msgs, { drop: true, threshold: 0 }); assert.deepEqual(out[0]!.content, textOnly.content); assert.deepEqual(out[1]!.content, otherTool.content); @@ -67,26 +101,33 @@ test("gate: selector — only messages carrying a compress toolCall part are tou test("gate: size — strictly exceeds threshold, summed across parts of the same message only", () => { const at = assistant([{ type: "text", text: "hi" }, thinking(1024), thinking(1024), compressCall()]); const above = assistant([{ type: "text", text: "hi" }, thinking(1025), thinking(1025), compressCall()]); - const splitKept = [assistant([{ type: "text", text: "hi" }, thinking(1500), compressCall()]), assistant([{ type: "text", text: "hi" }, thinking(1500), compressCall()]), user("go")]; - const out = dropCompressReasoning([at, above, user("go")], { drop: true, threshold: 2048 }); + const splitKept = [ + assistant([{ type: "text", text: "hi" }, thinking(1500), compressCall("a")]), + toolResult("a"), + assistant([{ type: "text", text: "mid" }]), + assistant([{ type: "text", text: "hi" }, thinking(1500), compressCall("b")]), + toolResult("b"), + user("go"), + ]; + const out = dropCompressReasoning([at, above, toolResult("c1"), user("go")], { drop: true, threshold: 2048 }); assert.deepEqual(out[0]!.content, at.content); // 2048 == threshold → kept assert.equal((out[1]!.content as unknown[]).some((p) => p === (above.content as unknown[])[1]), false); // 2050 > 2048 → dropped const out2 = dropCompressReasoning(splitKept, { drop: true, threshold: 2048 }); assert.deepEqual(out2[0]!.content, splitKept[0]!.content); // lengths not accumulated across messages - assert.deepEqual(out2[1]!.content, splitKept[1]!.content); + assert.deepEqual(out2[3]!.content, splitKept[3]!.content); }); test("threshold 0 drops any non-empty reasoning; zero-length survives", () => { const nonEmpty = assistant([{ type: "text", text: "hi" }, thinking(3), compressCall()]); - const empty = assistant([{ type: "text", text: "hi" }, thinking(0), compressCall()]); - const out = dropCompressReasoning([nonEmpty, empty, user("go")], { drop: true, threshold: 0 }); + const empty = assistant([{ type: "text", text: "hi" }, thinking(0), compressCall("c2")]); + const out = dropCompressReasoning([nonEmpty, toolResult("c1"), empty, toolResult("c2"), user("go")], { drop: true, threshold: 0 }); assert.equal((out[0]!.content as unknown[]).length, 2); - assert.equal((out[1]!.content as unknown[]).length, 3); + assert.equal((out[2]!.content as unknown[]).length, 3); }); test("purity and idempotence: input never mutated; second pass is a no-op", () => { const original = assistant([{ type: "text", text: "hi" }, thinking(4096), compressCall()]); - const msgs = [original, user("go")]; + const msgs = [original, toolResult("c1"), user("go")]; const out1 = dropCompressReasoning(msgs, { drop: true, threshold: 0 }); assert.deepEqual(original.content, [{ type: "text", text: "hi" }, thinking(4096), compressCall()]); // input unmutated assert.notEqual(out1[0], msgs[0]); // rewritten message is a new object @@ -105,11 +146,11 @@ test("fail-safe: malformed messages return the input unchanged", () => { }); test("drop:false is a full kill-switch", () => { - const msgs = [assistant([{ type: "text", text: "hi" }, thinking(99999), compressCall()]), user("go")]; + const msgs = [assistant([{ type: "text", text: "hi" }, thinking(99999), compressCall()]), toolResult("c1"), user("go")]; assert.equal(dropCompressReasoning(msgs, { drop: false }), msgs); }); -test("no user message at all → nothing touched (all open round)", () => { +test("no round evidence at all → nothing touched", () => { const msgs = [assistant([{ type: "text", text: "hi" }, thinking(4096), compressCall()])]; assert.equal(dropCompressReasoning(msgs, { drop: true, threshold: 0 }), msgs); }); @@ -117,7 +158,7 @@ test("no user message at all → nothing touched (all open round)", () => { test("text and toolCall parts (incl. thoughtSignature) survive the drop", () => { const call = { ...compressCall(), thoughtSignature: "sig" }; const out = dropCompressReasoning( - [assistant([{ type: "text", text: "keep" }, thinking(4096), call]), user("go")], + [assistant([{ type: "text", text: "keep" }, thinking(4096), call]), toolResult("c1"), user("go")], { drop: true, threshold: 0 }, ); const content = out[0]!.content as unknown[];