Skip to content
Merged
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)
- **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 统一口径不回归)
- **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` 扩展
Expand Down
11 changes: 6 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
56 changes: 44 additions & 12 deletions src/reasoning-drop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -54,16 +56,43 @@ 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<string, number> {
const map = new Map<string, number>();
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);
}

/** 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);
Expand All @@ -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),
Expand Down
79 changes: 60 additions & 19 deletions tests/reasoning-drop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {}): { type: "thinking"; thinking: string } & Record<string, unknown> {
return { type: "thinking", thinking: "x".repeat(len), ...extra };
}
Expand Down Expand Up @@ -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 turnmessages from the last genuine user message onward are untouched", () => {
test("gate: closed roundresult 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);
Expand All @@ -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
Expand All @@ -105,19 +146,19 @@ 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);
});

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[];
Expand Down
Loading