Repository navigation
Bug: Nudge injection loop — anchored nudges injected even when recommendation filter says "nothing to compress", and failed compress calls never reset pending-nudge state #216
Description
Bug: Nudge injection loop — anchored nudges injected even when recommendation filter says "nothing to compress", and failed compress calls never reset pending-nudge state
Environment
- opencode-acp: 1.14.3 (from
~/.cache/opencode/packages/opencode-acp@latest) - opencode: 1.14.43
- Model context limit: 260,000 tokens
- Relevant
acp.jsonc:{ "pruneNotification": "detailed", "compress": { "maxContextLimit": "55%", "minContextLimit": "30%", "showCompression": true, "permission": "allow", "mode": "range" }, "debug": "true" }
Summary
In a long tool-heavy session (281 messages, ~135 bash calls), the plugin injected compress nudges on every assistant step (4 injections within ~36 seconds: 14:22:27 / 14:22:37 / 14:22:55 / 14:23:03). The model attempted compress 40 times, of which 28 failed (mostly rejected by the plugin's own protected-zone rule), and each failure made the next nudge come faster. The result is a self-reinforcing loop:
nudge injected → model tries to compress → rejected by protected-zone guard
→ pending-nudge state never cleared → nudge threshold halved
→ next nudge arrives even sooner → repeat
Three separate defects combine to produce this loop.
Defect 1: Anchored nudge is injected BEFORE the "nothing to compress" check
All line numbers refer to the bundled dist/index.js of 1.14.3.
In injectCompressNudges (line 8474), the order of operations is:
// line ~8624: anchored nudge injected as soon as nudgeAllowed is true
if (nudgeAllowed) {
applyAnchoredNudges(state, config, messages, prompts, ...); // ← already pushed into suffix message
}
// line ~8646: recommendation ranges computed AFTERWARDS
const recommendedRanges = filterRecommendedRanges(unprotectedCompressible, ...);
// line ~8674: "nothing to compress" evaluated AFTERWARDS
const nothingToCompress = filterSuppressed || allProtected || allInProtectedZone;
let shouldInject = nudgeAllowed && (!nothingToCompress || emergencyOverride);shouldInject === false only suppresses the breakdown/ranges suffix text — it does not roll back the anchored nudge that applyAnchoredNudges already appended to the suffix message.
Evidence from debug logs
2026-07-27T14:22:27.825Z DEBUG [ACP Debug] Recommendation filter decision:
Input: 1 range(s), 53 tokens
Thresholds: growth=13.0K, lastSegmentFloor=26.0K
Last segment: m00001–m00001 53 tokens → excluded (< floor)
Effective compressible: 0 → SUPPRESSED (< growth threshold)
2026-07-27T14:22:27.825Z DEBUG [ACP Debug] Nudge injected:
<system-reminder>
Context is getting full. If you've finished reading tool outputs ... compress them ...
(+ full HOW TO COMPRESS rules, ~1.5K tokens)
The filter concluded "effective compressible: 0 → SUPPRESSED", yet the nudge (with the full rules text) was injected anyway. Later the same session:
2026-07-27T14:28:57.465Z DEBUG filterRecommendedRanges: decision |
inputRanges=1 inputTokens=31419 growthThreshold=13000 lastSegmentFloor=26000
effectiveCompressible=5419 suppressed=true outputRanges=0
2026-07-27T14:28:57.466Z DEBUG [ACP Debug] Nudge injected: ...
Across the entire session log, grep "Compressible ranges" returns zero hits — every injected nudge in this session carried no recommended ranges at all.
Why this matters
The model receives a self-contradictory instruction: "Context is getting full — compress now", but with no recommended ranges, because the filter already determined there is nothing worth compressing (the only candidate range is the dangerous last segment / protected zone). The model then guesses a range on its own, picks something overlapping the protected tail, and gets rejected — feeding Defect 2.
Defect 2: Failed compress calls never clear pending-nudge state → threshold is permanently halved
messageHasCompress (line 2076) only recognizes successful compress calls:
return parts.some(
(part) => part.type === "tool" && part.tool === "compress" && part.state?.status === "completed"
);Meanwhile in injectCompressNudges:
// line ~8609
const hasPendingNudge = state.nudges.lastNudgeShownTokens !== void 0;
const effectiveThreshold = hasPendingNudge
? Math.floor(nudgeGrowthTokens / 2) // ← halved while a nudge is "pending"
: nudgeGrowthTokens;state.nudges.lastNudgeShownTokens is only reset when currentTurnHasCompress is true (line ~8496), i.e. only when a compress call succeeded in the current turn.
So when the model does respond to the nudge but its compress attempt is rejected (see failure list below), the plugin treats it as "model ignored the nudge" and keeps the halved threshold (13K → 6.5K for a 260K-context model). A couple of ordinary bash outputs (2–3K tokens each) are enough to re-trigger the nudge, indefinitely.
Compress failure distribution in this session (40 calls, 28 errors)
| Count | Rejection reason |
|---|---|
| ~15 | This range includes N protected recent message(s) … Protected zone: last 20 messages + last 20K tokens + … |
| 4 | Range too small (527/1602/2279/2479 chars, min 5000). Not worth compressing — overhead exceeds savings. |
| 3 | All selected messages were filtered out (protected tool outputs and/or the last user message) |
| others | topic missing, acknowledgeRisk without pending gate, encoding retry, … |
Note the irony: the dominant rejection is the plugin's own protected-zone guard rejecting attempts that the plugin's own nudge solicited (with no range recommendations to guide the model away from the protected tail — Defect 1).
The state machine simply has no branch for "model tried to comply but was rejected". Both "ignored the nudge" and "tried and got rejected" are treated identically as pending, which accelerates the nudge cadence precisely when the model cannot comply.
Defect 3: Nudge payload is heavy and self-inflating
Every injected nudge carries the full HOW TO COMPRESS rules text (~1.5K tokens: KEEP VERBATIM list, DROP list, KEEP MARKERS, PRIORITY ordering, etc.), plus philosophy/breakdown text when applicable. When the loop from Defects 1+2 causes injection on every assistant step, the nudges themselves become a significant source of context growth — which pushes currentTokens up and helps cross the (halved) growth threshold again. The mechanism intended to reduce context pressure actively adds to it.
Reproduction sketch
- Config:
minContextLimit: "30%",maxContextLimit: "55%",mode: "range", model with large context (260K). - Run a long agentic session dominated by tool calls (100+ bash/read calls, few user turns) until usage passes the 30% min limit (~78K tokens).
- Arrange context such that most compressible mass sits in the recent/protected tail (typical for a task whose recent tool outputs are large) — the recommendation filter will output
SUPPRESSED / outputRanges=0. - Observe:
- nudges injected with no recommended ranges, every assistant step;
- the model's compress attempts rejected with
protected recent message(s)/Range too small; - nudge cadence accelerating (threshold halved) instead of backing off.
Expected behavior
- Defect 1: If
nothingToCompressis true (filter suppressed, all protected, or all in protected zone) and there is no emergency override, the anchored nudge should not be injected at all. ThenothingToCompressdetermination should happen beforeapplyAnchoredNudges, or the anchored nudge should be rolled back / gated by the sameshouldInjectflag. - Defect 2: A failed/rejected compress attempt in the current turn should count as "model responded to the nudge" for state purposes — e.g. reset
lastNudgeShownTokens(or apply exponential backoff), instead of keeping the halved threshold. At minimum, N consecutive rejections should trigger a cool-down rather than acceleration. - Defect 3: Repeated nudges within the same session shouldn't re-send the full rules text each time (send full text once, short reminder afterwards), so the nudge mechanism doesn't inflate the very context it is trying to shrink.
Actual behavior
- Nudge with full rules text injected on every assistant step (~10s apart) even when the plugin's own filter reports
Effective compressible: 0 → SUPPRESSED. - 28 of 40 compress calls rejected, dominated by the plugin's own protected-zone rule.
- Pending-nudge state never cleared by rejected attempts → threshold permanently halved → injection frequency increases instead of backing off.
Workarounds tried / considered
- Raise
minContextLimit(delays but doesn't fix the loop once past threshold). - Increase
compress.nudgeGrowthTokens(mitigates cadence but pending-halving still applies). manualMode/permission: "ask"silences nudges entirely (loses the feature).
None of these address the root cause: the inject-before-check ordering and the missing "rejected attempt" branch in the nudge state machine.
Activity
ranxianglei commentedon Jul 27, 2026
[bot] Bug confirmed — all 3 defects verified in code.
Defect 1 ✅
applyAnchoredNudgescalled beforenothingToCompresslib/messages/inject/inject.ts:applyAnchoredNudges()fires whennudgeAllowedis trueexcludeProtectedRanges()runs AFTERfilterRecommendedRanges()runs AFTERnothingToCompresscomputed AFTERThe nudge text is already in the suffix message before the filter determines there's nothing worth compressing.
Fix: Move
applyAnchoredNudgesafternothingToCompress/shouldInjectdetermination, or gate it byshouldInject.Defect 2 ✅
messageHasCompressonly recognizes completed callslib/messages/query.ts:37-40:Failed/rejected compress calls (
status !== "completed") don't triggercurrentTurnHasCompress, solastNudgeShownTokensis never cleared → threshold stays halved → nudge fires again sooner → loop.Fix: Change
messageHasCompressto recognize any compress attempt (regardless of status), or add a separatemessageHasCompressAttemptthat resets nudge state on any compress tool call.Defect 3 ✅ Nudge payload self-inflation
Each nudge carries ~1.5K tokens of HOW TO COMPRESS rules. In a loop, this adds to context growth, pushing
currentTokenspast the (halved) threshold again.Fix: Send full rules text once per session; subsequent nudges within the same session use a short reminder.
Will open a PR to fix all three.
fix: nudge injection loop — 3 defects from issue #216 (#218)
Merge pull request #326 from ranxianglei/2026-08-20_emergency-nothing…
fix: emergency + nothing-to-compress emits /compact notice, not compr…