Skip to content

Commit 12e7f91

Browse files
Yigtwxxsteipete
andauthored
fix(compaction): previous summary is duplicated when a later split degrades (#109828)
* fix(compaction): stop duplicating the previous summary when a later split degrades summarizeInStages stamped a merged result as generic-fallback whenever ANY split degraded. compaction-safeguard reads that kind to decide whether to restore the previous summary, on the documented assumption that "a generic fallback means redistillation never happened". That assumption only holds for chunk 0. summarizeViaLLM prepends the previous summary as the first message, so it always lands in the oldest chunk. When that chunk summarizes fine but a later one degrades, the merge already carries the redistilled summary — and the safeguard prepends it a second time. Every subsequent compaction that has any degraded split re-adds it again, so a PR that exists to stop token burn compounds it instead. The kind now reports whether the oldest split degraded, which is exactly the question the only consumer asks. The coarse "any chunk" flag is gone rather than kept alongside the precise one, so there is a single source of truth. Behavior for a degraded oldest split is unchanged: the previous summary is genuinely lost there and restoring it is still correct. * test(compaction): pin previous-summary restoration boundary --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent 2e64628 commit 12e7f91

3 files changed

Lines changed: 30 additions & 4 deletions

File tree

src/agents/agent-hooks/compaction-safeguard.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2021,6 +2021,7 @@ describe("compaction-safeguard recent-turn preservation", () => {
20212021
const messages = requireArray(call.messages);
20222022
expect(JSON.stringify(messages[0])).toContain("<previous-compaction-summary>");
20232023
expect(JSON.stringify(messages[0])).toContain("Old duplicated section");
2024+
expect(result.compaction?.summary).not.toContain("Old duplicated section");
20242025
});
20252026

20262027
it("preserves the prior summary when staged summarization returns a generic fallback", async () => {

src/agents/compaction.circuit-breaker.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,25 @@ describe("compaction staged fallback circuit breaker", () => {
7979
});
8080
expect(agentSessionMocks.generateSummary).toHaveBeenCalledTimes(4);
8181
});
82+
83+
it("reports a summary when only a later split degraded and the oldest one survived", async () => {
84+
agentSessionMocks.generateSummary
85+
.mockResolvedValueOnce("oldest summary")
86+
.mockRejectedValueOnce(new Error("fetch failed"))
87+
.mockResolvedValueOnce("newest summary")
88+
.mockResolvedValueOnce("merged: oldest summary + newest summary");
89+
90+
// The oldest split carries whatever context the caller needs redistilled, and it
91+
// made it into the merge. Reporting a fallback here makes callers re-add it.
92+
await expect(summarize()).resolves.toEqual({
93+
kind: "summary",
94+
text: "merged: oldest summary + newest summary",
95+
});
96+
expect(agentSessionMocks.generateSummary).toHaveBeenCalledTimes(4);
97+
expect(agentSessionMocks.generateSummary.mock.calls[3]?.[0]).toEqual(
98+
expect.arrayContaining([
99+
expect.objectContaining({ content: expect.stringContaining("oldest summary") }),
100+
]),
101+
);
102+
});
82103
});

src/agents/compaction.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -413,7 +413,9 @@ export async function summarizeInStages(params: {
413413

414414
const partialSummaries: string[] = [];
415415
let consecutiveGenericFallbacks = 0;
416-
let usedGenericFallback = false;
416+
// Caller-owned leading context lives in the oldest split. Only losing that
417+
// split requires restoration; later fallback placeholders remain in the merge.
418+
let oldestChunkDegraded = false;
417419
for (const [index, chunk] of plan.chunks.entries()) {
418420
const result = await summarizeWithFallbackResult({
419421
...params,
@@ -422,7 +424,9 @@ export async function summarizeInStages(params: {
422424
});
423425
consecutiveGenericFallbacks =
424426
result.kind === "generic-fallback" ? consecutiveGenericFallbacks + 1 : 0;
425-
usedGenericFallback ||= result.kind === "generic-fallback";
427+
if (index === 0) {
428+
oldestChunkDegraded = result.kind === "generic-fallback";
429+
}
426430

427431
// Keep one placeholder to mark the missing split, but stop before repeated
428432
// placeholders trigger more split requests or a doomed merge request.
@@ -445,7 +449,7 @@ export async function summarizeInStages(params: {
445449
throw new Error("Compaction summary plan produced no summary");
446450
}
447451
return {
448-
kind: usedGenericFallback ? "generic-fallback" : "summary",
452+
kind: oldestChunkDegraded ? "generic-fallback" : "summary",
449453
text: summary,
450454
};
451455
}
@@ -486,7 +490,7 @@ export async function summarizeInStages(params: {
486490
messages: summaryMessages,
487491
customInstructions: mergeInstructions,
488492
});
489-
return usedGenericFallback && mergedResult.kind === "summary"
493+
return oldestChunkDegraded && mergedResult.kind === "summary"
490494
? { kind: "generic-fallback", text: mergedResult.text }
491495
: mergedResult;
492496
}

0 commit comments

Comments
 (0)