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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user