Repository navigation
[Bug]: prePruneTokens is a stale prev-usage estimate, so an inflated upstream usage report (measured 1.76–2.05×) triggers growth nudges with candidateCount=0 #476
Description
opencode-acp 1.18.3, OpenCode dev build 999.0.0, third-party OpenAI-compatible gateway (@ai-sdk/openai-compatible), Linux. Config: compress.maxContextLimit=40%, minContextLimit=35%.
Problem
Two independent defects compound into a compress thrash loop (single steps stuck for 15+ minutes):
-
prePruneTokensis a stale prev-usage estimate, not a count of the outgoing payload.
Pairing everyChat transform completeline with the actual request usage (tokens.input + tokens.cache.read + tokens.output) gives a 3-way match over 269 pairs:best == self : 12 (the request this transform describes) best == self+out : 19 best == prev+out : 238 (previous request's prompt + previous output) <== dominant238/269 match "prev prompt + prev output" within 0.0–0.6%. So the logged number is an
incremental estimate carried over from the previous request's reported usage — new tool
results are not counted, and whatever the upstream reported last is inherited verbatim. -
trigger=growthnudges ignore candidate availability. Same day: 49 nudges injected,
49 withcandidateCount=0 microCandidates=0 estimatedCandidateTokens=0. Other paths do
suppress (Nudge suppressed: nothing to compress), the growth path does not.
Consequence chain observed in one session:
upstream usage report inflated 1.764× 108,499 → reported 191,403
→ next transform inherits it prePruneTokens 108,527 → 191,761 (usagePct 42% → 74%)
→ growthSinceBaseline +83,234 (fake) nudge injected (candidateCount=0)
→ model guesses a compress range picks an already-consumed ID
→ compress tool error "endId m00264 is not available — likely consumed
by an existing block. ... retry with valid IDs."
→ model re-guesses, loop single step 15m02s
Evidence: the upstream report is inflated, not just misread
Fresh-session first requests have an identical payload (system prompt + tool schemas + a fixed
78-char user message), so their reported usage must match across requests. Same model
(gpt-6.1-sol), last 3 days:
| route | n | min | median | max |
|---|---|---|---|---|
gpt-6.1-sol (other gateways) |
33 | 19,843 | 24,223 | 25,439 |
| offending pay-per-use route | 8 | 24,874 | 43,813 | 45,565 |
The offending route's 8 samples, all identical payload, all cacheR=0:
24,874 / 24,961 / 27,375 ← normal, consistent with the 33-sample baseline
43,813 / 43,813 / 43,834 / 44,113 / 45,565 ← 1.76–1.83×
Session-wide scan of "reported / (prev prompt + prev output)":
normal steps (0.85–1.2×): 244
inflated (1.5–2.2×) : 3 factors 2.054 / 1.764 / 1.929, median 1.929
excess tokens : 217,866
108,499 × 1.764 ≈ 191,403 — the +82,876 "growth" that fired the nudge is exactly the
inflation of that single report. The estimator itself is accurate (0.6% agreement on the 238
clean pairs); it just has no defense against a lying report.
Reproduction
# transforms: [(ts, prePruneTokens)] from ACP log "Chat transform complete"
# P: [(ts, prompt, output)] prompt = message.tokens.input + tokens.cache.read
counts = {"self": 0, "self+out": 0, "prev+out": 0}
for ts, pre in transforms:
idx = last assistant message with ts <= transform.ts + 1s
c = {"self": P[idx].prompt,
"self+out": P[idx].prompt + P[idx].output,
"prev+out": P[idx-1].prompt + P[idx-1].output}
if c["self"] == 0 and c["prev+out"] == 0: continue # aborted requests
counts[min(c, key=lambda k: abs(c[k] - pre) / max(pre, 1))] += 1
# observed: {"self": 12, "self+out": 19, "prev+out": 238} (269 pairs)Expected / suggested acceptance
- Recount
prePruneTokensfrom the message list actually being sent, instead of incrementally
deriving it from the previous request's reported usage. - Reject or re-estimate when the reported usage jumps >1.5× without a corresponding payload
change (relay-side usage inflation must not reach the growth heuristic). - Suppress growth nudges when
candidateCount == 0, matching the existing
Nudge suppressed: nothing to compresspaths. - On
compressvalidation failure, return the still-compressible ranges in the error instead of
endId ... not available, call acp_status(), retry— the current message invites the model to
keep guessing consumed IDs.
Related
- [Bug] In-flight prune is a no-op (postTokens == prePruneTokens) at 229k tokens → request exceeds server context → empty-response resume loop #346 —
postTokens == prePruneTokens(in-flight prune is a no-op) observed here too:pre == postin all 45 sampled transforms. - perf: nudge-driven smart compression requires redundant model round trip #436 — nudge-driven compression costs an extra model round trip; with a
candidateCount=0guard most of these round trips disappear.
Not claiming the inflated usage report is an ACP defect — it is gateway-side. But ACP currently
propagates it into usagePct / growthSinceBaseline with no sanity bound, which is what turned
one bad report into a 15-minute compress thrash loop.
Activity
ranxianglei commentedon Oct 6, 2026
[bot] 🏷 Acknowledged — triaging now. Initial code-level confirmation of defect 1:
getCurrentTokenUsage()(lib/token-utils.ts:9) returns the last assistant message'sinput + cacheRead + cacheWrite + output + reasoning— i.e. the previous request's reported prompt + its output, inherited verbatim. That is exactly your 238/269 "prev+out" match, and it flows unbounded intousagePct,overMaxLimit, andgrowthSinceBaselineviaisContextOverLimits()(lib/messages/inject/utils.ts:196) — no sanity check against the actual outgoing payload. A 1.76× inflated relay report therefore propagates 1:1 into the growth heuristic.Defect 2 confirmed structurally: in range mode (candidates off — the default), the
nothingToCompressguard (lib/messages/inject/inject.ts:481-484) has a vacuous hole — whenbuildCompressibleRangesyields zero ranges (fresh/small visible context, everything else already compressed), none ofallProtected/allInProtectedZone/allBelowMinis true, so the guard stays false and a growth nudge fires with no actionable ranges — the model then has to guess IDs, which is your consume-error thrash loop. (ThecandidateCount=0in those log lines is also expected in range mode — that field only tracks opt-in candidate planning.)Not a duplicate of #346 (limit-unknown in spawn+resume) or #436 (round-trip cost) — distinct mechanism, though #346's
post == presymptom shares the same stale-number root. Working on a fix covering all four acceptance items; will follow up with PR link.ranxianglei commentedon Oct 6, 2026
[bot] 🏷 Triage complete — both defects confirmed in code, not duplicates of #346/#436. Working on a fix now; PR to follow in this thread.
What I verified (against current master)
Defect 1 — stale usage channel (confirmed).
prePruneTokensis computed bygetCurrentTokenUsage()(lib/token-utils.ts:9), which returns the last assistant message's reportedinput + cacheRead + cacheWrite + output + reasoning— i.e. exactly "previous request's reported prompt + its output", never counting the message list. Your 238/269post == prev_prompt + prev_outputmatch is therefore expected behavior, not coincidence. The same value feeds:isContextOverLimits()→currentTokens→ overMin/overMax, growth decision, anchor logic (lib/messages/inject/utils.ts:196)lib/hooks.ts:360,417)So one inflated relay report poisons every downstream decision for that turn.
Defect 2 — vacuous suppression hole (confirmed). In default range mode,
nothingToCompress = allProtected || allInProtectedZone || allBelowMin(lib/messages/inject/inject.ts:481). Each disjunct requires some analyzed range to exist:allProtected: compressible=0 and protected>0allInProtectedZone: protectedRefs>0 and unprotectedCompressible=0allBelowMin: compressible>0 and recommended=0When
buildCompressibleRanges()yields zero ranges and zero protected (visible context is entirely compressed blocks + recent zone), all three are false →nothingToCompress=false→ growth nudge fires with no actionable ranges → model guesses IDs →endId ... not availableretry loop. This matches your 49×candidateCount=0injections (candidateCountitself only tracks opt-in candidates, so 0 there is normal — the real signal was zero compressible ranges).The trigger chain you measured lines up with
computeShouldNudge(growth-only cadence:growthSinceLastNudge >= nudgeGrowthTokens || overMaxLimit) — an inflatedcurrentTokensjump of +83K trivially clears any growth floor.Layer judgment (root cause vs symptom)
lib/compress/search.ts:259 buildBoundaryRecoveryHint) with the visible ref span + block count, but no list of actually-free sub-ranges. I'll extend it to enumerate contiguous free spans so a retry doesn't needacp_status().Plan
{reported, payloadEstimate}stored on session state (persisted); ifreportedjumps >1.5× while the fresh payload estimate grows <1.5× (with a small-context floor to avoid noise), downstream decisions use the re-estimate instead. Applied at the two choke points:isContextOverLimits().currentTokensand hooks' pre/post tokens.nothingToCompresshole: zero-ranges-and-zero-protected ⇒ suppress, with a distinct audit reason (no_compressible_ranges), consistent with existing suppression paths.preserveRecentMessages > 0, and each new test verified to fail against the unfixed code), devlog entry, PR referencing this issue.Behavior changes will be explicitly flagged in the PR description (log values on the re-estimate path, new suppression case, richer error text, one new persisted state field).
ranxianglei commentedon Oct 6, 2026
[bot] 🏷 已修复,PR:#477(未合并,等 CI + 人工合并)。
审核结论(Triage)
getCurrentTokenUsage()(lib/token-utils.ts)直接返回上一条 assistant 消息的input+cacheRead+cacheWrite+output+reasoning——即上游报告值,ACP 原样继承,无任何本地校验。你实测的 1.76–2.05× 膨胀会原封不动进入prePruneTokens/currentTokens。nothingToCompress守卫(lib/messages/inject/inject.ts)在「可压缩范围 = 0 且受保护消息 = 0 且保护区为空」时三个标志全为 false——这是当前 master 上唯一残留的空洞(其余零范围组合已被allProtected/allInProtectedZone/allBelowMin覆盖)。此时 growth nudge 照常注入,但没有任何可执行范围 → 模型猜 ID → 撞endId mNNNNN is not available→ 重试循环。你日志里 49 次candidateCount=0注入与 15m02s 单步耗时正是这条路径。结构化七问
nothingToCompress对「零范围且零保护」组合存在逻辑空洞;表现层——compress 报错只给 "first–last 窗口 + 去调 acp_status()",窗口跨过了被消费的间隙,模型无法从中选出合法 ID。lib/usage-sanity.ts的resolveCurrentUsage()——跨轮次跳变检测:当报告值环比 >1.5× 而本地 chars/4 载荷估算环比 ≤1.5×(两侧均 ≥5000 token 地板)时判定为 relay 膨胀,改用本地重估值并打 WARN 日志;诚实报告原样通过。三个调用点(isContextOverLimits、prePruneTokens、postTokens)统一走同一净化通道,基线算术保持一致。inject.ts增加noRangesAtAll(零可压缩 ∧ 零保护消息)并入nothingToCompress,审计日志新增 reasonno_compressible_ranges;emergency 路径自然落到已有 cadence 门控的/compactnotice。buildBoundaryRecoveryHint重写为枚举实际连续空闲区间(Free ranges: m00001–m00002, m00005–m00007.,上限 8 段 +(+N more)),结尾Retry with IDs inside the free ranges.。compresscalls never reset pending-nudge state #216/perf: avoid transform work that scales with compression history #384 惰性分析——均未触碰其代码路径。usageSanity {reported, estimated}为可选追加,旧状态文件照常加载(检测跳过一轮直到种子写入);损坏记录在 init 时丢弃而非投毒比较。重试/去重键不受影响。RELAY_INFLATION_RATIO=1.5、PAYLOAD_FLOOR_TOKENS=5000(按验收标准取值,未开放配置)。行为变更披露(旧 → 新)
prePruneTokens/postTokens/currentTokensno_compressible_ranges(原先这些回合会注入幽灵 nudge)Current visible: first–last (N msgs). … Call acp_status() …Free ranges: <连续空闲段>… Retry with IDs inside the free ranges.usageSanity验证
tests/usage-sanity.test.ts(10)、tests/nudge-no-ranges-suppression.test.ts(3,多轮 + 副作用断言,含端到端 1.76× 场景与"正常场景 nudge 仍触发"对照组)、tests/boundary-recovery-hint.test.ts(6)。判定:可合并(merge-ready),等 CI 绿后由人工点击合并——我不执行 merge。PR:#477