Skip to content

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

Closed

Description

@ranxianglei

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

  1. Config: minContextLimit: "30%", maxContextLimit: "55%", mode: "range", model with large context (260K).
  2. 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).
  3. 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.
  4. 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

  1. Defect 1: If nothingToCompress is 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. The nothingToCompress determination should happen before applyAnchoredNudges, or the anchored nudge should be rolled back / gated by the same shouldInject flag.
  2. 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.
  3. 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

  1. ranxianglei commented on Jul 27, 2026

    @ranxianglei
    OwnerAuthor

    [bot] Bug confirmed — all 3 defects verified in code.

    Defect 1 ✅ applyAnchoredNudges called before nothingToCompress

    lib/messages/inject/inject.ts:

    • Line 305-307: applyAnchoredNudges() fires when nudgeAllowed is true
    • Line 336: excludeProtectedRanges() runs AFTER
    • Line 338: filterRecommendedRanges() runs AFTER
    • Line ~390+: nothingToCompress computed AFTER

    The nudge text is already in the suffix message before the filter determines there's nothing worth compressing.

    Fix: Move applyAnchoredNudges after nothingToCompress / shouldInject determination, or gate it by shouldInject.

    Defect 2 ✅ messageHasCompress only recognizes completed calls

    lib/messages/query.ts:37-40:

    part.type === "tool" && part.tool === "compress" && part.state?.status === "completed"

    Failed/rejected compress calls (status !== "completed") don't trigger currentTurnHasCompress, so lastNudgeShownTokens is never cleared → threshold stays halved → nudge fires again sooner → loop.

    Fix: Change messageHasCompress to recognize any compress attempt (regardless of status), or add a separate messageHasCompressAttempt that 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 currentTokens past 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.

  2. added a commit that references this issue on Aug 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions