fix(coding-agent): rescue snapcompact dead-end when nothing is summarizable
When the single most-recent turn is itself over budget, prepareCompaction returns undefined (findCutPoint never cuts inside a tool result, so the kept tail has nothing on the summarizable side) and summary compaction cannot start. The !preparation short-circuit in #runAutoCompaction emitted the "Compaction freed too little context to make progress" warning and paused, never running the artifact-backed shake elide rescue that #3786 wired into the post-maintenance guard — so snapcompact/context-full maintenance looped the warning with no attempt to shrink the oversized tail. Run the same elide rescue before pausing, re-prepare on the shrunken branch, and fall through to a normal compaction when the tail became summarizable (writing a compaction entry anchors the stale billed usage so the auto-continue re-check cannot re-trip). Only pause with a single warning when nothing is elide-eligible. Flag historyRewritten on a rescue that offloaded content so overflow recovery does not re-restore the just-failed turn onto the elided tail. Fixes #4786
This commit is contained in:
@@ -6,6 +6,10 @@
|
||||
|
||||
- Memoized non-message token totals (system prompt, tool schemas, skills) so the per-turn compaction and context-threshold paths recompute them at most once per input change instead of on every call. `getContextBreakdown` and `#estimateStoredContextTokens` previously re-tokenized the system prompt and every tool's wire schema (per-tool `JSON.stringify`) several times per turn over inputs that change at most once per turn.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed auto-compaction dead-ending in a warning loop ("Compaction freed too little context to make progress") when the single most-recent turn is itself over budget so `prepareCompaction` has nothing to summarize (`findCutPoint` never cuts inside a tool result). This `!preparation` short-circuit never ran the artifact-backed `shake` elide rescue that #3786 added to the post-maintenance guard, so snapcompact/context-full maintenance paused with no attempt to shrink the oversized tail. The dead-end now runs the same elide pass, re-prepares on the shrunken branch, and falls through to a normal compaction when the tail became summarizable — only pausing (single warning) when nothing is elide-eligible. ([#4786](https://github.com/can1357/oh-my-pi/issues/4786))
|
||||
|
||||
## [16.3.11] - 2026-07-06
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -12385,35 +12385,73 @@ export class AgentSession {
|
||||
this.#getCompactionModelCandidates(availableModels),
|
||||
this.sessionId,
|
||||
);
|
||||
const preparation = prepareCompaction(pathEntries, compactionSettings, autoCompactionCandidates);
|
||||
let pathEntriesForCompaction = pathEntries;
|
||||
let preparation = prepareCompaction(pathEntriesForCompaction, compactionSettings, autoCompactionCandidates);
|
||||
if (!preparation) {
|
||||
await this.#emitSessionEvent({
|
||||
type: "auto_compaction_end",
|
||||
action,
|
||||
result: undefined,
|
||||
aborted: false,
|
||||
willRetry: false,
|
||||
skipped: true,
|
||||
});
|
||||
const noProgressDeadEnd = reason !== "idle";
|
||||
let continuationScheduled = false;
|
||||
if (!suppressContinuation && this.agent.hasQueuedMessages()) {
|
||||
this.#scheduleAgentContinue({
|
||||
delayMs: 100,
|
||||
generation,
|
||||
shouldContinue: () => this.agent.hasQueuedMessages(),
|
||||
// prepareCompaction found nothing to summarize because the kept region
|
||||
// is a single oversized recent turn — findCutPoint never cuts inside a
|
||||
// tool result, so a huge tool-result / fenced block tail leaves nothing
|
||||
// on the summarizable side and summary compaction cannot even start.
|
||||
// That is exactly the dead-end the elide shake rescues: it reaches
|
||||
// INSIDE the tail and offloads heavy content to an artifact placeholder,
|
||||
// shrinking the tail so findCutPoint can then move the cut and leave
|
||||
// older turns to summarize. Run the same rescue the post-maintenance
|
||||
// guard uses, then re-prepare on the elided branch and fall through to
|
||||
// the normal compaction body when it now succeeds (writing a compaction
|
||||
// entry anchors the stale billed usage so the auto-continue re-check
|
||||
// cannot re-trip and loop the warning — issue #4786). Skip when we
|
||||
// already fell through from a shake strategy pass (it tried and found
|
||||
// nothing) or on the idle timer (it re-checks usage on its own cadence).
|
||||
let rescued: ShakeResult | undefined;
|
||||
if (reason !== "idle" && !fallbackFromShake) {
|
||||
rescued = await this.#tryShakeRescueForDeadEnd(autoCompactionSignal);
|
||||
if (rescued && !autoCompactionSignal.aborted) {
|
||||
pathEntriesForCompaction = this.sessionManager.getBranch();
|
||||
preparation = prepareCompaction(
|
||||
pathEntriesForCompaction,
|
||||
compactionSettings,
|
||||
autoCompactionCandidates,
|
||||
);
|
||||
if (preparation) this.#emitShakeRescueNotice(rescued);
|
||||
}
|
||||
}
|
||||
if (!preparation) {
|
||||
await this.#emitSessionEvent({
|
||||
type: "auto_compaction_end",
|
||||
action,
|
||||
result: undefined,
|
||||
aborted: false,
|
||||
willRetry: false,
|
||||
skipped: true,
|
||||
});
|
||||
continuationScheduled = true;
|
||||
const noProgressDeadEnd = reason !== "idle";
|
||||
let continuationScheduled = false;
|
||||
if (!suppressContinuation && this.agent.hasQueuedMessages()) {
|
||||
this.#scheduleAgentContinue({
|
||||
delayMs: 100,
|
||||
generation,
|
||||
shouldContinue: () => this.agent.hasQueuedMessages(),
|
||||
});
|
||||
continuationScheduled = true;
|
||||
}
|
||||
if (noProgressDeadEnd) {
|
||||
this.emitNotice(
|
||||
"warning",
|
||||
compactionDeadEndWarning("clear large tool output, run `/shake images` to drop attached images,"),
|
||||
"compaction",
|
||||
);
|
||||
}
|
||||
// A rescue that offloaded content but still could not produce a
|
||||
// preparation rewrote the branch; flag it so the overflow-recovery
|
||||
// rollback does not re-restore the just-failed assistant turn on top
|
||||
// of the elided tail.
|
||||
const base = continuationScheduled
|
||||
? COMPACTION_CHECK_CONTINUATION
|
||||
: noProgressDeadEnd
|
||||
? COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION
|
||||
: COMPACTION_CHECK_NONE;
|
||||
return rescued ? { ...base, historyRewritten: true } : base;
|
||||
}
|
||||
if (noProgressDeadEnd) {
|
||||
this.emitNotice(
|
||||
"warning",
|
||||
compactionDeadEndWarning("shrink it (e.g. clear large tool output)"),
|
||||
"compaction",
|
||||
);
|
||||
}
|
||||
if (continuationScheduled) return COMPACTION_CHECK_CONTINUATION;
|
||||
return noProgressDeadEnd ? COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION : COMPACTION_CHECK_NONE;
|
||||
}
|
||||
|
||||
let hookCompaction: CompactionResult | undefined;
|
||||
@@ -12424,7 +12462,7 @@ export class AgentSession {
|
||||
const hookResult = (await this.#extensionRunner.emit({
|
||||
type: "session_before_compact",
|
||||
preparation,
|
||||
branchEntries: pathEntries,
|
||||
branchEntries: pathEntriesForCompaction,
|
||||
customInstructions: undefined,
|
||||
signal: autoCompactionSignal,
|
||||
})) as SessionBeforeCompactResult | undefined;
|
||||
|
||||
@@ -1162,4 +1162,97 @@ describe("AgentSession auto-compaction progress guard", () => {
|
||||
const recovery = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes("dead-end recovery"));
|
||||
expect(recovery.length).toBe(0);
|
||||
});
|
||||
|
||||
it("re-prepares and compacts after a shake rescue frees the un-summarizable tail", async () => {
|
||||
// Issue #4786: the kept region is a single oversized recent turn, so the
|
||||
// first prepareCompaction returns undefined (nothing on the summarizable
|
||||
// side) and summary compaction cannot start. The dead-end runs the elide
|
||||
// shake rescue INSIDE the tail; once it frees enough, prepareCompaction is
|
||||
// retried on the elided branch, now succeeds, and the pass falls through to
|
||||
// a normal (hook-supplied) compaction that creates headroom and
|
||||
// auto-continues instead of looping the no-progress warning.
|
||||
const branch = sessionManager.getBranch();
|
||||
const firstKeptEntryId = branch[branch.length - 1].id;
|
||||
if (!firstKeptEntryId) throw new Error("seeded entry has no id");
|
||||
let shaken = false;
|
||||
const preparation: compactionModule.CompactionPreparation = {
|
||||
firstKeptEntryId,
|
||||
messagesToSummarize: [{ role: "user", content: "old", timestamp: Date.now() }],
|
||||
turnPrefixMessages: [],
|
||||
recentMessages: [],
|
||||
isSplitTurn: false,
|
||||
tokensBefore: 190000,
|
||||
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
|
||||
settings: session.settings.getGroup("compaction"),
|
||||
};
|
||||
vi.spyOn(compactionModule, "prepareCompaction").mockImplementation(() => (shaken ? preparation : undefined));
|
||||
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.
|
||||
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" && event.result) 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);
|
||||
});
|
||||
|
||||
it("still warns once when a no-preparation dead-end cannot be shaken", async () => {
|
||||
// prepareCompaction returns undefined AND the oversized tail has nothing
|
||||
// elide-eligible: the rescue frees nothing, prepareCompaction still returns
|
||||
// undefined, and the guard MUST pause with a single no-progress warning
|
||||
// (not loop) instead of re-firing on the same oversized tail.
|
||||
vi.spyOn(compactionModule, "prepareCompaction").mockReturnValue(undefined);
|
||||
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never);
|
||||
const continueSpy = vi.spyOn(session.agent, "continue").mockResolvedValue();
|
||||
vi.spyOn(session, "getContextUsage").mockReturnValue({ tokens: 190000, contextWindow: 200000, percent: 95 });
|
||||
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();
|
||||
expect(continueSpy).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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user