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)
- **fix(nudge): 瞬态注入统一治理——注入预算账本(统一 #6/#7/#9/#194/#217 一族补丁的根因)** — pi 每次 LLM 调用重建出站数组,任何"瞬态"提示若不预算都会逐次重注入;#223 放大链上每张补丁各记各的账(Set 去重 / 失败调用数 / fire 预算),没有一个在数"注入次数本身"。现在 runtime 提供唯一账本 `noteInjection(turnKey, kind, budget)`:普通 nudge 每真实用户轮 1 次、emergency nudge 每轮 `MAX_EMERGENCY_NUDGES_PER_TURN=3` 次,预算数的是**注入**而非模型行为(忽略、失败、no-op、中性结果都无法延长),仅真实用户轮切换时重置。轮键 `lastUserMessageId` 现在跳过合成 user 消息(`[ACP:provider-throttle]` kick、`[acp_delegate …]` 通知不再重置预算)。删除整套 compress-outcome 记账机器(`noteCompressOutcomes`/`compressOutcomeSeen`/`compressFailTurnKey`+`compressFailCount`/`compressRetryCappedFor`/`collectCompressOutcomes`/`isCompressSuccessText`/`isCompressNoopText`/独立 `nudgeShownTurns`,约 -150 行);emergency nudge 的中性逃生门(模型回 `compress({content: []})` → "No ranges provided." isError:false → 旧失败计数器不增 → 永不封顶、nudge 无限重注入)随之消失。封顶后 UI 一次性提示 nudge 已暂停,kernel ≥95% 紧急截断仍机械兜底;测试加入对抗性回归(20 连发交替 失败/no-op/中性/沉默 → 恰好 3 次注入)
- **fix(nudge): 移除瞬态 compress 重试提示注入(closes #223,取代 #217)** — compress 失败后每次 LLM 调用重注入的 `compressRetryMessage` 瞬态 user 提示整体移除:对从不重试的模型,该提示无限追加(用户日志 ~400 次/小时、emergency pct 95→127%),即 #223 的"永远追加失败标记"。失败信息本身仍以 toolResult 形式持久留在 session 日志中(模型可见、可自我纠正,随正常压缩流程淘汰);issue #6 的 nudge 断路器保留:每用户轮 MAX_COMPRESS_ATTEMPTS=3 次失败/no-op 后 emergency nudge 停止重注入(kernel 紧急截断仍机械兜底),UI 提示改为 "nudge paused until the next user message (emergency truncation still active)"。`noteCompressOutcomes` 返回值去掉 `retryFor`
- **fix(tokens): 图片 token 计入发送视图估算 (closes #200)** — `extractText` 只投影 `type:"text"` 块,图片在 sent-view 估算中计 0 token:含图会话的 nudge/truncation/compress 仲裁系统性偏晚(只等真实 400 后 overflow-selfheal 被动触发),且 density 校准被 phantom gap 污染(provider 真实 usage 含图、估算不含 → 图片轮 dReal/dEst 爆表被 clamp 到 2.5×,纯文本轮又拉回 1.0,density 振荡且仍低估 5-10× → 过早/过晚压缩交替)。现在 `collectImageTokens` 按 `IMAGE_TOKEN_COST=1600`/张计入(仅视觉模型,`model.input` 含 `image`;非视觉模型 pi-ai 静默丢图、计 0),density 校准环自动收敛真实成本(A/B 实测收敛 ~0.98);出站 payload 字节不变(sha256 一致),前缀缓存不受影响 (#201)
- **fix(delegate): 并发多 agent 时失败必达,不再静默 (#16)** — async delegate 此前有三条失败路径完全不通知主模型(spawn error、结果持久化 error、`sendUserMessage` 注入丢失),模型未挂在 `acp_delegate_wait` 上时失败被吞,直到收尾汇总才发现少了结果。现在:所有终止路径 best-effort 注入 `FAILED ⚠️` 通知(带错误摘录,与 sync 路径对齐,明确提示"该任务结果缺失、收尾前决定是否重派");注入失败的 run 进入未送达集,随**下一个** delegate 通知或任何 delegate 工具结果(`acp_delegate`/`wait`/`cancel`)捎带 Recovery notice 补投;Recovery notice 的 delivered 标记改为 carrier 发送成功后才提交(发送抛错不再永久吞掉其他 run 的结果);system prompt 补充 FAILED/Recovery 通知说明
Expand Down
22 changes: 0 additions & 22 deletions src/compress-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,28 +97,6 @@ function describeDiagnostics(diagnostics: CompressParseDiagnostics, content: Com
return `${base}. content must be an ARRAY of {startId, endId, summary} objects.`;
}

/** Panel block count ("… (~N reclaimed, B blocks)"), or -1 for non-panels. */
function compressPanelBlocks(text: string): number {
if (!text.trimStart().startsWith("▣ ACP |")) return -1;
const m = text.match(/, (\d+) blocks?\)/);
return m ? Number(m[1]) : -1;
}

/** Success = completed run that created >= 1 block (partial range errors
* still count: progress was made). A 0-block panel must NOT be success —
* it would reset the retry counter while the emergency nudge re-fires,
* looping no-op compressions (issue #6). */
export function isCompressSuccessText(text: string): boolean {
return compressPanelBlocks(text) > 0;
}

/** No-op = completed run that compressed nothing (0-block panel: every
* range skipped). Counted as a FAILED attempt by noteCompressOutcomes so
* the retry cap applies. Non-panels ("No ranges provided.") stay neutral. */
export function isCompressNoopText(text: string): boolean {
return compressPanelBlocks(text) === 0;
}

function tier3OnlyRewrite(newBlocks: CompressionBlock[], allBlocks: CompressionBlock[]): string[] | null {
if (newBlocks.length === 0) return null;
const byId = new Map(allBlocks.map((b) => [b.blockId, b]));
Expand Down
8 changes: 7 additions & 1 deletion src/delegate-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,12 @@ export function findUndeliveredRuns(all: DelegateRun[], excludeRunId?: string):
);
}

/** Prefix of every injected delegate notification user message
* (`[acp_delegate <status>] ...`). Shared with the injection-ledger turn
* key (SYNTHETIC_USER_PREFIXES in src/tokens.ts) so these synthetic user
* messages cannot reset per-turn injection budgets. */
export const DELEGATE_NOTIFY_PREFIX = "[acp_delegate ";

/** Compute the recovery notice for undelivered runs WITHOUT marking them
* delivered. The caller commits the marking (covered[].injected = true) only
* after the carrier message is actually sent: if the send throws, the runs
Expand Down Expand Up @@ -1141,7 +1147,7 @@ export function injectResult(
const closing = failed
? "This delegate did NOT complete its task — its result is missing from your work. Read the error excerpt (and the result file if present), then decide whether to re-dispatch the task before wrapping up. This is an automated system notification, NOT a user message."
: "This is an automated system notification, NOT a user message. Read the result file if you need the details, then continue your original task; do not treat this as a new user request.";
const header = `[acp_delegate ${status}] **${agent}** (runId \`${runId}\`, exit ${code ?? "?"})${timeoutNote}${remainingLine}${usageNote} ${closing}`;
const header = `${DELEGATE_NOTIFY_PREFIX}${status}] **${agent}** (runId \`${runId}\`, exit ${code ?? "?"})${timeoutNote}${remainingLine}${usageNote} ${closing}`;
const { text: recoveryText, covered } = buildRecoveryNotice(Array.from(runs.values()), runId);
const text = formatPayload(header, file, task, failed ? body : undefined) + (recoveryText ? `\n\n${recoveryText}` : "");
try {
Expand Down
86 changes: 19 additions & 67 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import type {
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 { createRuntime, type AcpRuntime } from "./runtime.js";
import { makeCompressTool, isCompressSuccessText, isCompressNoopText } from "./compress-tool.js";
import { createRuntime, type AcpRuntime, MAX_EMERGENCY_NUDGES_PER_TURN } from "./runtime.js";
import { makeCompressTool } from "./compress-tool.js";
import { makeDecompressTool } from "./decompress-tool.js";
import { makeSearchTool } from "./search-tool.js";
import { makeStatusTool } from "./status-tool.js";
Expand Down Expand Up @@ -77,9 +77,8 @@ function wireCompactionDisable(pi: ExtensionAPI): void {
function wireSessionLifecycle(pi: ExtensionAPI, runtime: AcpRuntime): void {
pi.on("session_start", async (_event, ctx) => {
runtime.store.invalidate();
runtime.clearNudgeTracking();
runtime.clearInjectionLedger();
runtime.throttleFor(ctx.sessionManager.getSessionId()).reset();
runtime.clearCompressRetryTracking();
// 新会话重置该模型的密度校准(文档 §5.3:模型/窗口切换时重新收敛)
const modelId = (ctx.model as { id?: string } | undefined)?.id ?? "default";
runtime.density.resetModel(modelId);
Expand Down Expand Up @@ -280,19 +279,6 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime): void {

const turnKey = lastUserMessageId(entries) ?? sid;

// Compress-outcome tracking feeds ONLY the nudge circuit breaker below:
// failed/no-op attempts are counted (capped at MAX_COMPRESS_ATTEMPTS per
// user turn) to stop re-injecting the nudge at a model that keeps failing
// compress. The failed toolResult itself already persists in the session
// log with the full error text — the model sees it and can self-correct —
// so NO transient retry prompt is injected (transient re-injection per
// LLM call caused the #223 infinite-append loop). Only outcomes from the
// CURRENT user turn are considered; processed BEFORE the nudge block so
// the cap suppression sees the newest outcome (a success on this fire
// must lift the cap on this same fire).
const compressOutcomes = collectCompressOutcomes(entries, turnStartIndex(entries));
const outcome = compressOutcomes.length > 0 ? runtime.noteCompressOutcomes(turnKey, compressOutcomes) : null;

if (turn.nudge?.shouldInject) {
// Two independent channels for the nudge:
// 1. CONTEXT injection (always on): the nudge is appended to the
Expand All @@ -302,50 +288,43 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime): void {
// 2. TERMINAL echo (debug only): when debug is on, also print the exact
// text via ctx.ui.notify so the user can observe what is being
// injected while debugging. The model never sees terminal output.
// Emergency nudges (usage >= 80%) bypass the per-turn dedup so the
// overflow warning always reaches the model. Other nudges inject at most
// once per turn: pi fires the context event multiple times per assistant
// reply (streaming/tool loop), and without this gate the same nudge
// would be appended on every event.
// Budget gate: pi fires the context event multiple times per assistant
// reply (streaming/tool loop), so EVERY transient injection must pass
// the runtime ledger or it would append on every event (#223). Normal
// nudges: once per turn. Emergency nudges: MAX_EMERGENCY_NUDGES_PER_TURN
// per turn — bounded by injections, not by the model's response
// behavior (ignoring, no-op or degenerate neutral compress answers
// cannot keep the loop alive); kernel emergency truncation (>= 95%)
// stays the mechanical backstop.
const emergency = turn.nudge.breakdown?.emergencyOverride === 1;
// Recommend only ranges the model can actually compress: a tiny
// fragmented range in the list makes batched attempts fail atomically
// (kernel validates the whole batch). See viableRanges in acp-kernel.
turn.nudge.compressibleRanges = viableRanges(turn.nudge.compressibleRanges);
// Retry-cap circuit breaker (issue #6): emergency nudges re-inject on
// every LLM call, so a model answering each one with a failed/no-op
// compress call loops forever (each attempt adds protected tokens and
// keeps usage pinned at emergency). Once this turn burned
// MAX_COMPRESS_ATTEMPTS attempts, stop re-injecting the nudge — the
// kernel's emergency truncation still shrinks context mechanically.
const retryCapped = runtime.compressRetryCappedFor(turnKey);
const alreadyShown = retryCapped || (!emergency && runtime.nudgeShownFor(turnKey));
if (!alreadyShown) {
const inj = runtime.noteInjection(turnKey, emergency ? "emergency" : "nudge", emergency ? MAX_EMERGENCY_NUDGES_PER_TURN : 1);
if (inj.allowed) {
rebuilt.push(nudgeMessage(turn.nudge, turn.state.blocks.filter((b) => b.active), runtime.prompts));
const rendered = renderNudgeText(turn.nudge, runtime.prompts);
const top = [...turn.nudge.compressibleRanges].sort((a, b) => b.tokens - a.tokens)[0];
const example = top ? `\n\nExample: compress({ content: [{ startId: "${top.startRef}", endId: "${top.endRef}", summary: "..." }] })` : "";
if (emergency) {
logWarn("nudge", { sid: ctx.sessionManager.getSessionId(), event: "emergency-inject", pct: Math.round(turn.nudge.contextUsage * 100), voice: rendered.voice, compressible: turn.nudge.compressibleRanges.length });
if (inj.exhaustedNow) {
logWarn("nudge", { sid, event: "emergency-nudge-exhausted", count: inj.count });
if (ctx.hasUI) {
ctx.ui.notify(`[ACP] emergency nudge injected ${inj.count}× this turn with no compress response — nudge paused until the next user message (emergency truncation still active).`);
}
}
}
if (debugOn && ctx.hasUI) {
ctx.ui.notify(`[ACP nudge → context]${emergency ? " [EMERGENCY]" : ""}\n${rendered.text}${example}`);
}
if (!emergency) runtime.markNudgeShown(turnKey);
debug.event("nudge-injected", { sid: ctx.sessionManager.getSessionId(), voice: rendered.voice, channels: ["context", debugOn ? "terminal" : null].filter(Boolean), emergency, turnKey, text: rendered.text + example });
} else {
debug.event("nudge-suppressed", { sid: ctx.sessionManager.getSessionId(), turnKey, reason: turn.nudge.reason });
}
}

if (outcome !== null && outcome.cappedNow) {
logWarn("nudge", { sid, event: "compress-retry-capped", failures: outcome.count });
debug.event("compress-retry-capped", { sid, turnKey, failures: outcome.count });
if (ctx.hasUI) {
ctx.ui.notify(`[ACP] compress failed ${outcome.count}× this turn — nudge paused until the next user message (emergency truncation still active).`);
}
}

// Always return the transformed array: every message needs its [mNNNNN] ref
// tag applied, so there is no meaningful "no change" case to short-circuit.
debug.event("context-out", { outMsgs: rebuilt.length, injected: turn.nudge?.shouldInject ?? false, emergency: turn.nudge?.breakdown?.emergencyOverride === 1 });
Expand Down Expand Up @@ -507,33 +486,6 @@ function collectOriginals(entries: Array<{ type: string; id: string; message?: A
return map;
}

// Index of the last user-role entry — the start of the current turn.
// Everything strictly AFTER this index belongs to the current turn; -1 when
// the session has no user message yet.
function turnStartIndex(entries: Array<{ type: string; message?: { role?: string } }>): number {
for (let i = entries.length - 1; i >= 0; i--) {
if (entries[i]!.message?.role === "user") return i;
}
return -1;
}

// Compress toolResults from the CURRENT user turn only — the raw material for
// the nudge circuit breaker above. Scoping matters: feeding the whole session
// would keep an old failure counting against the current turn's budget
// forever (review finding on 7ddd2c6).
function collectCompressOutcomes(entries: Array<{ type: string; id: string; message?: AgentMessage }>, startIndex: number): Array<{ toolCallId: string; isError: boolean; success: boolean; noop: boolean; text: string }> {
const out: Array<{ toolCallId: string; isError: boolean; success: boolean; noop: boolean; text: string }> = [];
for (let i = Math.max(startIndex, -1) + 1; i < entries.length; i++) {
const entry = entries[i]!;
if (entry.type !== "message" || !entry.message) continue;
const m = entry.message as { role?: string; toolName?: string; toolCallId?: string; isError?: boolean; content?: unknown };
if (m.role !== "toolResult" || m.toolName !== "compress" || !m.toolCallId) continue;
const text = extractText(m.content);
out.push({ toolCallId: m.toolCallId, isError: m.isError === true, success: m.isError !== true && isCompressSuccessText(text), noop: m.isError !== true && isCompressNoopText(text), text });
}
return out;
}

function nudgeMessage(nudge: NudgeDecision, blocks: CompressionBlock[], prompts: Prompts): AgentMessage {
const rendered = renderNudgeText(nudge, prompts);
const lines = [rendered.text];
Expand Down
Loading
Loading