From 5b026d304fae4c5aeef6eded7d9e342cca88c88a Mon Sep 17 00:00:00 2001 From: can1357 Date: Tue, 30 Jun 2026 10:37:42 +0200 Subject: [PATCH] fix(coding-agent): recovered from auto-compaction dead-ends using shake elisions - Added a last-resort recovery step to run `shake("elide")` on oversized message tails when auto-compaction cannot otherwise free enough context. - Re-tests the context headroom and auto-continue predicates after a successful rescue before falling back to pausing maintenance. - Updated the dead-end warning message to suggest running `/shake images` for irreducible, image-only tails. --- packages/coding-agent/CHANGELOG.md | 9 ++- .../coding-agent/src/session/agent-session.ts | 70 ++++++++++++++++- ...ion-auto-compaction-progress-guard.test.ts | 77 +++++++++++++++++++ 3 files changed, 149 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 45155b541..583e4ec6d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,14 +5,15 @@ ### Added - Added built-in Go coding rules including `go-add-cleanup`, `go-bench-loop`, `go-exp-promoted`, `go-ioutil`, `go-join-hostport`, `go-new-expr`, `go-rand-v2`, and `go-range-int` + ### Fixed +- Fixed auto-compaction dead-ends by automatically triggering a shake rescue to elide oversized tails +- Improved compaction warning message to suggest running `/shake images` for irreducible image tails + - Fixed `grep`/`search` direct execution to accept JSON-array string `paths` for string-or-array inputs. ([#3873](https://github.com/can1357/oh-my-pi/issues/3873)) -### Fixed - +- Fixed auto-compaction dead-ending with "Compaction freed too little context to make progress" when a single recent turn (large tool output, heavy fenced/XML block) is itself bigger than the recovery band — `findCutPoint` can't cut inside one message, so the summarizer had no lever left. The guard now runs an artifact-backed `shake` elide pass over the oversized tail and re-tests headroom before pausing, and the remaining warning points at `/shake images` for image-only tails it can't elide. ([#3786](https://github.com/can1357/oh-my-pi/issues/3786)) - Fixed reviewer/`task` subagents whose incremental `yield` (`type: ["overall_correctness"]`, `type: ["findings"]`, …) carried a value that mismatched the matching property's sub-schema being silently accepted and then post-mortem rejected with `schema_violation` — opaquely swapping the agent's accepted output for an error blob. The yield tool now validates each incremental section's `data` against its top-level property's sub-schema (items schema for array-typed labels) and surfaces the same retry feedback as terminal yields, so models like `deepseek-v4-pro` that emit `"Correct"`/`"correct."`/`"approved"` for an enum field get up to three corrective retries; the existing `MAX_SCHEMA_RETRIES` override then accepts the value with `SUBAGENT_WARNING_SCHEMA_OVERRIDDEN` instead of losing the entire result. Unknown labels stay unconstrained ([#3870](https://github.com/can1357/oh-my-pi/issues/3870)). -### Fixed - - Fixed streaming tool-call previews (notably `write`) showing an empty body for the entire streaming phase by surfacing the partial JSON already in hand on the first reveal, then pacing only subsequent growth ([#3881](https://github.com/can1357/oh-my-pi/issues/3881)). ## [16.2.7] - 2026-06-30 diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 9e23972c5..cf694a150 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -11100,6 +11100,52 @@ export class AgentSession { return residualTokens <= fitBudget; } + /** + * Last-resort reducer when {@link #runAutoCompaction} would otherwise dead-end. + * The summarizer cut at the only available turn boundary, but the kept tail is + * still over the recovery band because a single recent turn (a large + * tool-result, a heavy fenced/XML block) is itself bigger than the band and + * `findCutPoint` cannot cut inside one message. `shake("elide")` reaches INSIDE + * that tail — it offloads heavy tool-result / block content to one + * `artifact://` blob and leaves a recoverable placeholder — so residual context + * genuinely drops instead of the guard pausing maintenance and looping the + * warning. Without it the guard would pause/warn here; with it the caller + * re-tests its progress predicate after the elide pass and only falls through + * to the warning when residual stays over. + * + * Image-only tails are out of scope: `collectShakeRegions` skips image-only + * tool results and user-message images aren't counted by the local estimate + * that gates the dead-end, so those still surface the warning (remedy: + * `/shake images`). + * + * Returns the elide {@link ShakeResult} when something was offloaded (so the + * caller can re-test and report), or `undefined` when nothing was eligible or + * the pass aborted/failed. + */ + async #tryShakeRescueForDeadEnd(signal: AbortSignal): Promise { + if (signal.aborted) return undefined; + try { + const result = await this.shake("elide", { signal }); + return result.toolResultsDropped + result.blocksDropped > 0 ? result : undefined; + } catch (error) { + logger.warn("Dead-end shake rescue failed", { + error: error instanceof Error ? error.message : String(error), + }); + return undefined; + } + } + + /** Notice describing a successful dead-end elide rescue. */ + #emitShakeRescueNotice(result: ShakeResult): void { + const elided = result.toolResultsDropped + result.blocksDropped; + const sink = result.artifactId ? "an artifact" : "placeholders"; + this.emitNotice( + "info", + `Compaction dead-end recovery: elided ${elided} heavy block${elided === 1 ? "" : "s"} (~${result.tokensFreed.toLocaleString()} tokens) to ${sink} so maintenance could make progress.`, + "compaction", + ); + } + /** * Internal: Run auto-compaction with events. * @@ -11133,6 +11179,7 @@ export class AgentSession { const shouldAutoContinue = !suppressContinuation && options.autoContinue !== false && compactionSettings.autoContinue !== false; const suppressHandoff = options.suppressHandoff === true; + let fallbackFromShake = false; // Shake runs inline (cheap, no remote LLM). On overflow recovery, if shake // reclaims nothing we fall through to the summary-compaction body below so // the oversized input still gets resolved. @@ -11146,6 +11193,7 @@ export class AgentSession { suppressContinuation, ); if (outcome !== "fallback") return outcome; + fallbackFromShake = true; } // "overflow" and "incomplete" force inline execution because they are recovery // paths the caller wants resolved before scheduling the next turn. "idle" is @@ -11644,7 +11692,15 @@ export class AgentSession { // won't include) is excluded. Reusing the auto-continue recovery band // here turned recoverable overflows into manual dead-ends (#3412 review), // so use the looser fit budget. - if (this.#compactionCreatedRetryFit()) { + let retryFits = this.#compactionCreatedRetryFit(); + if (!retryFits && !fallbackFromShake) { + const rescue = await this.#tryShakeRescueForDeadEnd(autoCompactionSignal); + if (rescue && this.#compactionCreatedRetryFit()) { + retryFits = true; + this.#emitShakeRescueNotice(rescue); + } + } + if (retryFits) { this.#scheduleAgentContinue({ delayMs: 100, generation }); continuationScheduled = true; } else { @@ -11658,7 +11714,15 @@ export class AgentSession { // when auto-continue is disabled, a no-headroom threshold pass must still // block later automatic continuations (todo reminders/session_stop hooks) // from re-entering the same oversized context. - if (this.#compactionCreatedHeadroom()) { + let hasHeadroom = this.#compactionCreatedHeadroom(); + if (!hasHeadroom && !fallbackFromShake) { + const rescue = await this.#tryShakeRescueForDeadEnd(autoCompactionSignal); + if (rescue && this.#compactionCreatedHeadroom()) { + hasHeadroom = true; + this.#emitShakeRescueNotice(rescue); + } + } + if (hasHeadroom) { if (shouldAutoContinue) { this.#scheduleAutoContinuePrompt(generation); continuationScheduled = true; @@ -11682,7 +11746,7 @@ export class AgentSession { if (noProgressDeadEnd) { this.emitNotice( "warning", - "Compaction freed too little context to make progress — pausing automatic maintenance to avoid a compaction loop. The most recent turn alone is too large to reduce further; shrink it (e.g. clear large tool output) or switch to a larger-context model.", + "Compaction freed too little context to make progress — pausing automatic maintenance to avoid a compaction loop. The most recent turn alone is too large to reduce further; clear large tool output, run `/shake images` to drop attached images, or switch to a larger-context model.", "compaction", ); } diff --git a/packages/coding-agent/test/agent-session-auto-compaction-progress-guard.test.ts b/packages/coding-agent/test/agent-session-auto-compaction-progress-guard.test.ts index 25ac72ece..ee8a39074 100644 --- a/packages/coding-agent/test/agent-session-auto-compaction-progress-guard.test.ts +++ b/packages/coding-agent/test/agent-session-auto-compaction-progress-guard.test.ts @@ -694,4 +694,81 @@ describe("AgentSession auto-compaction progress guard", () => { expect(noProgress.length).toBe(1); expect(noProgress[0].level).toBe("warning"); }); + + it("auto-continues (no warning) when a shake rescue frees the oversized tail", async () => { + // The escalation contract: compaction cut at the only turn boundary but the + // kept tail (e.g. a huge tool result) still sits over the recovery band. The + // guard now runs an elide shake INSIDE that tail; once it frees enough, the + // auto-continue proceeds instead of pausing with the no-progress warning. + const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never); + vi.spyOn(session.agent, "continue").mockResolvedValue(); + // Residual is over the band until the rescue elides the tail, then drops. + let shaken = false; + vi.spyOn(session, "getContextUsage").mockImplementation(() => + shaken + ? { tokens: 1000, contextWindow: 200000, percent: 0.5 } + : { tokens: 190000, contextWindow: 200000, percent: 95 }, + ); + const shakeSpy = vi.spyOn(session, "shake").mockImplementation(async () => { + shaken = true; + return { mode: "elide", toolResultsDropped: 1, blocksDropped: 0, tokensFreed: 160000, artifactId: "art-1" }; + }); + + const notices = collectNotices(); + + const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers(); + session.subscribe(event => { + if (event.type === "auto_compaction_end") onCompactionDone(); + }); + + const assistantMsg = highUsageAssistant(); + session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg }); + session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] }); + + await compactionDone; + await session.waitForIdle(); + + expect(shakeSpy).toHaveBeenCalledWith("elide", expect.anything()); + expect(promptSpy).toHaveBeenCalledTimes(1); + const noProgress = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes(NO_PROGRESS_FRAGMENT)); + expect(noProgress.length).toBe(0); + const recovery = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes("dead-end recovery")); + expect(recovery.length).toBe(1); + expect(recovery[0].level).toBe("info"); + }); + + it("still warns when a shake rescue cannot free the irreducible tail", async () => { + // When the oversized tail has nothing elide-eligible (image-only or plain + // prose), the rescue frees nothing, the residual stays over the band, and + // the guard MUST still pause with the single no-progress warning. + const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never); + vi.spyOn(session.agent, "continue").mockResolvedValue(); + vi.spyOn(session, "getContextUsage").mockReturnValue({ tokens: 190000, contextWindow: 200000, percent: 95 }); + // Nothing eligible: shake reports zero dropped, so residual is unchanged. + const shakeSpy = vi + .spyOn(session, "shake") + .mockResolvedValue({ mode: "elide", toolResultsDropped: 0, blocksDropped: 0, tokensFreed: 0 }); + + const notices = collectNotices(); + + const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers(); + session.subscribe(event => { + if (event.type === "auto_compaction_end") onCompactionDone(); + }); + + const assistantMsg = highUsageAssistant(); + session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg }); + session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] }); + + await compactionDone; + await session.waitForIdle(); + + expect(shakeSpy).toHaveBeenCalledWith("elide", expect.anything()); + expect(promptSpy).not.toHaveBeenCalled(); + const noProgress = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes(NO_PROGRESS_FRAGMENT)); + expect(noProgress.length).toBe(1); + expect(noProgress[0].level).toBe("warning"); + const recovery = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes("dead-end recovery")); + expect(recovery.length).toBe(0); + }); });