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.
This commit is contained in:
can1357
2026-06-30 10:37:42 +02:00
parent dd8b2cfbd8
commit 5b026d304f
3 changed files with 149 additions and 7 deletions
+5 -4
View File
@@ -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
@@ -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<ShakeResult | undefined> {
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",
);
}
@@ -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<void>();
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<void>();
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);
});
});