Skip to content

Commit 9428eea

Browse files
committed
Replace accumulating compaction with one evidence-backed handoff
Failed summaries used to write a stats stub into history, and later folds stacked frozen prefixes. The primary session now keeps the prior context on summary failure and replaces the dropped prefix with a single archive-backed handoff.
1 parent 4eb579e commit 9428eea

22 files changed

Lines changed: 484 additions & 268 deletions

docs/IMPLEMENTATION.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ src/
7979
index.ts Session lifecycle
8080
state.ts RunState JSON save/load
8181
compactor.ts Context compactor
82-
summarizer.ts Model-backed structured compaction summary (+ deterministic fallback)
82+
summarizer.ts Model-backed structured compaction summary (fails closed)
83+
summary-excerpt.ts Token-budgeted archive excerpt for the summary call
8384
compaction-archive.ts Primary-only authorized evidence archive (post-policy capture)
8485
compaction-archive-schema.ts Archive occurrence / completeness certificate schemas
8586
run-sink.ts Run-level event sink

src/config/settings.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,9 @@ export interface Settings {
117117
// interactive install). Upgrade stamps only after notes are actually shown
118118
// so a missing surface cannot silently swallow them (CL-5475).
119119
lastChangelogVersion?: string;
120-
// Controls the context-compaction strategy used when the context window fills.
121-
// "llm" (default) generates a structured handoff summary via LLM call.
122-
// "pruning" uses fast deterministic pruning with no LLM call.
120+
// Deprecated: summarize vs drop is no longer operator-selectable. Primary
121+
// compaction is always the evidence-backed LLM handoff. Legacy values may
122+
// still appear in on-disk settings and are ignored; new writes omit this field.
123123
compactionMode?: "llm" | "pruning";
124124
// Deprecated (CL-5814): orchestrator is the only product path. Legacy values
125125
// may still appear in on-disk settings and are ignored at resolve time; new

src/context-compactor.test.ts

Lines changed: 50 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
classifyTaskBoundary,
88
buildLLMTurnSummary,
99
COMPACTED_PREFIX,
10-
COMPACT_SPACER_TEXT,
1110
type SessionMetadata,
1211
} from "./session/compactor.js";
1312
import { createModelSummarizer } from "./session/summarizer.js";
@@ -585,7 +584,7 @@ describe("createPruningCompactor — summarize receives the workflow context (CL
585584
});
586585
});
587586

588-
describe("createPruningCompactor — prefix-stable summaries (CL-6914)", () => {
587+
describe("createPruningCompactor — consolidated handoff (CL-7521)", () => {
589588
function firstText(turn: ConversationTurn): string {
590589
const block = turn.content.find((b) => b.type === "text");
591590
return block !== undefined && block.type === "text" ? block.text : "";
@@ -608,23 +607,55 @@ describe("createPruningCompactor — prefix-stable summaries (CL-6914)", () => {
608607
return [...base, ...extra];
609608
}
610609

611-
test("second apply leaves output[0] bytes identical and appends a later summary", async () => {
610+
test("second apply replaces the prior summary instead of accumulating", async () => {
612611
const compactor = createPruningCompactor({ keepRecentTurns: 2, summaryMaxChars: 500 });
613612
const turns = grow([], 16, "round1");
614613
const output1 = (await compactor.apply(turns, mockStrategyCtx)).output;
615614
expect(firstText(output1[0]!)).toContain(COMPACTED_PREFIX);
616615

617616
const output2 = (await compactor.apply(grow(output1, 16, "round2"), mockStrategyCtx)).output;
618617

619-
expect(firstText(output2[0]!)).toBe(firstText(output1[0]!));
620-
expect(output2[0]).toBe(output1[0]);
621-
const summaries = compactedTurns(output2);
622-
expect(summaries.length).toBeGreaterThanOrEqual(2);
623-
expect(output2.indexOf(summaries[1]!)).toBeGreaterThan(0);
618+
expect(output2[0]).not.toBe(output1[0]);
619+
expect(compactedTurns(output2)).toHaveLength(1);
620+
expect(firstText(output2[0]!)).toContain(COMPACTED_PREFIX);
624621
expect(hasConsecutiveSameRole(output2)).toBe(false);
622+
expect(allText(output2)).toContain("round1 0");
623+
});
624+
625+
test("second apply keeps the initiating task as its own user turn", async () => {
626+
const compactor = createPruningCompactor({
627+
keepRecentTurns: 2,
628+
maxAnchorTurns: 1,
629+
summaryMaxChars: 500,
630+
});
631+
const goal = "GOAL: migrate the auth module to opaque tokens";
632+
const turns: ConversationTurn[] = [
633+
makeTurn({ role: "user", content: [{ type: "text", text: goal }] }),
634+
];
635+
for (let i = 0; i < 8; i++) {
636+
turns.push(makeTurn({ role: "assistant", content: [{ type: "text", text: `step ${i}` }] }));
637+
}
638+
turns.push(
639+
makeTurn({ role: "user", content: [{ type: "text", text: "also handle refresh" }] }),
640+
);
641+
turns.push(makeTurn({ role: "assistant", content: [{ type: "text", text: "recent reply" }] }));
642+
turns.push(makeTurn({ role: "user", content: [{ type: "text", text: "recent ask" }] }));
643+
644+
const output1 = (await compactor.apply(turns, mockStrategyCtx)).output;
625645
expect(
626-
output2.some((t) => t.role === "assistant" && firstText(t) === COMPACT_SPACER_TEXT),
646+
output1.some(
647+
(t) => t.role === "user" && t.content.some((b) => b.type === "text" && b.text === goal),
648+
),
627649
).toBe(true);
650+
651+
const output2 = (await compactor.apply(grow(output1, 16, "round2"), mockStrategyCtx)).output;
652+
expect(compactedTurns(output2)).toHaveLength(1);
653+
expect(
654+
output2.some(
655+
(t) => t.role === "user" && t.content.some((b) => b.type === "text" && b.text === goal),
656+
),
657+
).toBe(true);
658+
expect(hasConsecutiveSameRole(output2)).toBe(false);
628659
});
629660

630661
test("empty-fold keep-set returns the input unchanged", async () => {
@@ -648,7 +679,7 @@ describe("createPruningCompactor — prefix-stable summaries (CL-6914)", () => {
648679
expect(result.record.reason).toBe("no compaction needed");
649680
});
650681

651-
test("failing then succeeding summarizer does not rewrite output[0]", async () => {
682+
test("failing summarizer keeps prior context; a later success writes one handoff", async () => {
652683
const source: InferenceSource = {
653684
id: "test",
654685
provider: "openai",
@@ -671,15 +702,15 @@ describe("createPruningCompactor — prefix-stable summaries (CL-6914)", () => {
671702
summarize,
672703
});
673704
const turns = grow([], 16, "fail");
674-
const output1 = (await compactor.apply(turns, mockStrategyCtx)).output;
675-
expect(firstText(output1[0]!)).toContain("Turns compacted:");
676-
expect(firstText(output1[0]!)).not.toContain("UNIQUE_SUCCESS_SUMMARY");
677-
expect(firstText(output1[0]!)).toContain("Model summary unavailable");
678-
679-
const output2 = (await compactor.apply(grow(output1, 16, "ok"), mockStrategyCtx)).output;
680-
expect(firstText(output2[0]!)).toBe(firstText(output1[0]!));
681-
expect(allText(output2)).toContain("UNIQUE_SUCCESS_SUMMARY");
682-
expect(hasConsecutiveSameRole(output2)).toBe(false);
705+
const result1 = await compactor.apply(turns, mockStrategyCtx);
706+
expect(result1.output).toBe(turns);
707+
expect(result1.record.reason).toBe("summarize failed");
708+
expect(firstText(result1.output[0]!)).not.toContain(COMPACTED_PREFIX);
709+
710+
const result2 = await compactor.apply(grow(result1.output, 16, "ok"), mockStrategyCtx);
711+
expect(compactedTurns(result2.output)).toHaveLength(1);
712+
expect(allText(result2.output)).toContain("UNIQUE_SUCCESS_SUMMARY");
713+
expect(hasConsecutiveSameRole(result2.output)).toBe(false);
683714
});
684715
});
685716

src/exec/runner.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -537,8 +537,8 @@ export async function runExec(config: Config): Promise<ExecResult> {
537537
const summarizeForCompaction = createModelSummarizer({
538538
getSource: () => liveSource,
539539
deps: inferenceDeps,
540+
getArchive: () => evidenceArchiveHolder.current,
540541
});
541-
const liveCompactionMode = config.settings?.compactionMode ?? "llm";
542542

543543
const { activated: activatedToolNames, computeAdvertised } = createAdvertisedToolset({
544544
sessionMode,
@@ -582,7 +582,6 @@ export async function runExec(config: Config): Promise<ExecResult> {
582582
getDefaultSource: () => (liveDefaultSource.length > 0 ? liveDefaultSource : liveSource.id),
583583
getCompactor: () =>
584584
createSessionPruningCompactor({
585-
compactionMode: liveCompactionMode,
586585
summarize: summarizeForCompaction,
587586
telemetry: liveTelemetry,
588587
}),

0 commit comments

Comments
 (0)