Skip to content

Commit 673d314

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 acbb6ee commit 673d314

22 files changed

Lines changed: 513 additions & 385 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: 76 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -768,7 +768,7 @@ describe("createPruningCompactor — summarize receives the workflow context (CL
768768
});
769769
});
770770

771-
describe("createPruningCompactor — prefix-stable summaries (CL-6914)", () => {
771+
describe("createPruningCompactor — consolidated handoff (CL-7521)", () => {
772772
function firstText(turn: ConversationTurn): string {
773773
const block = turn.content.find((b) => b.type === "text");
774774
return block !== undefined && block.type === "text" ? block.text : "";
@@ -795,7 +795,7 @@ describe("createPruningCompactor — prefix-stable summaries (CL-6914)", () => {
795795
return [...base, ...extra];
796796
}
797797

798-
test("second apply leaves output[0] bytes identical and appends a later summary", async () => {
798+
test("second apply replaces the prior summary instead of accumulating", async () => {
799799
const compactor = createPruningCompactor({
800800
keepRecentTurns: 2,
801801
summaryMaxChars: 500,
@@ -808,17 +808,68 @@ describe("createPruningCompactor — prefix-stable summaries (CL-6914)", () => {
808808
await compactor.apply(grow(output1, 16, "round2"), mockStrategyCtx)
809809
).output;
810810

811-
expect(firstText(defined(output2[0]))).toBe(firstText(defined(output1[0])));
812-
expect(output2[0]).toBe(output1[0]);
813-
const summaries = compactedTurns(output2);
814-
expect(summaries.length).toBeGreaterThanOrEqual(2);
815-
expect(output2.indexOf(defined(summaries[1]))).toBeGreaterThan(0);
811+
expect(output2[0]).not.toBe(output1[0]);
812+
expect(compactedTurns(output2)).toHaveLength(1);
813+
expect(firstText(defined(output2[0]))).toContain(COMPACTED_PREFIX);
816814
expect(hasConsecutiveSameRole(output2)).toBe(false);
815+
expect(allText(output2)).toContain("round1 0");
816+
});
817+
818+
test("second apply keeps the initiating task as its own user turn", async () => {
819+
const compactor = createPruningCompactor({
820+
keepRecentTurns: 2,
821+
maxAnchorTurns: 1,
822+
summaryMaxChars: 500,
823+
});
824+
const goal = "GOAL: migrate the auth module to opaque tokens";
825+
const turns: ConversationTurn[] = [
826+
makeTurn({ role: "user", content: [{ type: "text", text: goal }] }),
827+
];
828+
for (let i = 0; i < 8; i++) {
829+
turns.push(
830+
makeTurn({
831+
role: "assistant",
832+
content: [{ type: "text", text: `step ${i}` }],
833+
}),
834+
);
835+
}
836+
turns.push(
837+
makeTurn({
838+
role: "user",
839+
content: [{ type: "text", text: "also handle refresh" }],
840+
}),
841+
);
842+
turns.push(
843+
makeTurn({
844+
role: "assistant",
845+
content: [{ type: "text", text: "recent reply" }],
846+
}),
847+
);
848+
turns.push(
849+
makeTurn({ role: "user", content: [{ type: "text", text: "recent ask" }] }),
850+
);
851+
852+
const output1 = (await compactor.apply(turns, mockStrategyCtx)).output;
853+
expect(
854+
output1.some(
855+
(t) =>
856+
t.role === "user" &&
857+
t.content.some((b) => b.type === "text" && b.text === goal),
858+
),
859+
).toBe(true);
860+
861+
const output2 = (
862+
await compactor.apply(grow(output1, 16, "round2"), mockStrategyCtx)
863+
).output;
864+
expect(compactedTurns(output2)).toHaveLength(1);
817865
expect(
818866
output2.some(
819-
(t) => t.role === "assistant" && firstText(t) === COMPACT_SPACER_TEXT,
867+
(t) =>
868+
t.role === "user" &&
869+
t.content.some((b) => b.type === "text" && b.text === goal),
820870
),
821871
).toBe(true);
872+
expect(hasConsecutiveSameRole(output2)).toBe(false);
822873
});
823874

824875
test("harness spacer is stamped with the reserved producer id and a visible sentinel", async () => {
@@ -840,116 +891,26 @@ describe("createPruningCompactor — prefix-stable summaries (CL-6914)", () => {
840891
expect(COMPACT_SPACER_TEXT).not.toBe(LEGACY_COMPACT_SPACER_TEXT);
841892
});
842893

843-
test("frozen prefix does not absorb a model-emitted spacer", async () => {
844-
const compactor = createPruningCompactor({
845-
keepRecentTurns: 2,
846-
summaryMaxChars: 500,
847-
});
848-
const output1 = (
849-
await compactor.apply(grow([], 16, "round1"), mockStrategyCtx)
850-
).output;
851-
const summary = output1.find((t) =>
852-
firstText(t).startsWith(COMPACTED_PREFIX),
853-
);
854-
expect(summary).toBeDefined();
894+
test("model-emitted spacer is not treated as a harness spacer", async () => {
855895
const echo = makeTurn({
856896
role: "assistant",
857897
model: "omen-alpha",
858898
content: [{ type: "text", text: LEGACY_COMPACT_SPACER_TEXT }],
859899
});
860-
const output2 = (
861-
await compactor.apply(
862-
grow([defined(summary), echo], 16, "round2"),
863-
mockStrategyCtx,
864-
)
865-
).output;
866-
867-
let frozenLen = 0;
868-
while (
869-
frozenLen < output2.length &&
870-
firstText(defined(output2[frozenLen])).startsWith(COMPACTED_PREFIX)
871-
) {
872-
frozenLen++;
873-
if (
874-
frozenLen < output2.length &&
875-
isHarnessCompactSpacer(defined(output2[frozenLen]))
876-
)
877-
frozenLen++;
878-
}
879-
expect(output2.slice(0, frozenLen)).not.toContain(echo);
880-
expect(isHarnessCompactSpacer(echo)).toBe(false);
881-
const harness = output2.find(isHarnessCompactSpacer);
882-
expect(harness).toBeDefined();
883-
expect(defined(harness).model).toBe(HARNESS_COMPACT_SPACER_MODEL);
884-
expect(firstText(defined(harness))).toBe(COMPACT_SPACER_TEXT);
885-
expect(harness).not.toBe(echo);
886-
});
887-
888-
test("frozen prefix does not absorb a model-stamped new-sentinel echo", async () => {
889-
const compactor = createPruningCompactor({
890-
keepRecentTurns: 2,
891-
summaryMaxChars: 500,
892-
});
893-
const output1 = (
894-
await compactor.apply(grow([], 16, "round1"), mockStrategyCtx)
895-
).output;
896-
const summary = output1.find((t) =>
897-
firstText(t).startsWith(COMPACTED_PREFIX),
898-
);
899-
expect(summary).toBeDefined();
900-
const echo = makeTurn({
900+
const stamped = makeTurn({
901901
role: "assistant",
902902
model: "omen-alpha",
903903
content: [{ type: "text", text: COMPACT_SPACER_TEXT }],
904904
});
905-
const output2 = (
906-
await compactor.apply(
907-
grow([defined(summary), echo], 16, "round2"),
908-
mockStrategyCtx,
909-
)
910-
).output;
911-
912-
let frozenLen = 0;
913-
while (
914-
frozenLen < output2.length &&
915-
firstText(defined(output2[frozenLen])).startsWith(COMPACTED_PREFIX)
916-
) {
917-
frozenLen++;
918-
if (
919-
frozenLen < output2.length &&
920-
isHarnessCompactSpacer(defined(output2[frozenLen]))
921-
)
922-
frozenLen++;
923-
}
924-
expect(output2.slice(0, frozenLen)).not.toContain(echo);
925905
expect(isHarnessCompactSpacer(echo)).toBe(false);
926-
const harness = output2.find(isHarnessCompactSpacer);
927-
expect(harness).toBeDefined();
928-
expect(defined(harness).model).toBe(HARNESS_COMPACT_SPACER_MODEL);
929-
expect(firstText(defined(harness))).toBe(COMPACT_SPACER_TEXT);
930-
expect(harness).not.toBe(echo);
906+
expect(isHarnessCompactSpacer(stamped)).toBe(false);
931907
});
932908

933-
test("legacy harness spacer without model still freezes", async () => {
934-
const compactor = createPruningCompactor({
935-
keepRecentTurns: 2,
936-
summaryMaxChars: 500,
937-
});
938-
const output1 = (
939-
await compactor.apply(grow([], 16, "round1"), mockStrategyCtx)
940-
).output;
941-
const summary = output1.find((t) =>
942-
firstText(t).startsWith(COMPACTED_PREFIX),
943-
);
944-
expect(summary).toBeDefined();
909+
test("legacy harness spacer without model is still recognized", () => {
945910
const legacySpacer = makeTurn({
946911
role: "assistant",
947912
content: [{ type: "text", text: LEGACY_COMPACT_SPACER_TEXT }],
948913
});
949-
const grown = grow([defined(summary), legacySpacer], 16, "round2");
950-
const output2 = (await compactor.apply(grown, mockStrategyCtx)).output;
951-
expect(output2[0]).toBe(summary);
952-
expect(output2[1]).toBe(legacySpacer);
953914
expect(isHarnessCompactSpacer(legacySpacer)).toBe(true);
954915
});
955916

@@ -982,7 +943,7 @@ describe("createPruningCompactor — prefix-stable summaries (CL-6914)", () => {
982943
expect(result.record.reason).toBe("no compaction needed");
983944
});
984945

985-
test("failing then succeeding summarizer does not rewrite output[0]", async () => {
946+
test("failing summarizer keeps prior context; a later success writes one handoff", async () => {
986947
const source: InferenceSource = {
987948
id: "test",
988949
provider: "openai",
@@ -1005,22 +966,20 @@ describe("createPruningCompactor — prefix-stable summaries (CL-6914)", () => {
1005966
summarize,
1006967
});
1007968
const turns = grow([], 16, "fail");
1008-
const output1 = (await compactor.apply(turns, mockStrategyCtx)).output;
1009-
expect(firstText(defined(output1[0]))).toContain("Turns compacted:");
1010-
expect(firstText(defined(output1[0]))).not.toContain(
1011-
"UNIQUE_SUCCESS_SUMMARY",
1012-
);
1013-
expect(firstText(defined(output1[0]))).toContain(
1014-
"Model summary unavailable",
969+
const result1 = await compactor.apply(turns, mockStrategyCtx);
970+
expect(result1.output).toBe(turns);
971+
expect(result1.record.reason).toBe("summarize failed");
972+
expect(firstText(defined(result1.output[0]))).not.toContain(COMPACTED_PREFIX);
973+
974+
const result2 = await compactor.apply(
975+
grow(result1.output, 16, "ok"),
976+
mockStrategyCtx,
1015977
);
1016-
1017-
const output2 = (
1018-
await compactor.apply(grow(output1, 16, "ok"), mockStrategyCtx)
1019-
).output;
1020-
expect(firstText(defined(output2[0]))).toBe(firstText(defined(output1[0])));
1021-
expect(allText(output2)).toContain("UNIQUE_SUCCESS_SUMMARY");
1022-
expect(hasConsecutiveSameRole(output2)).toBe(false);
978+
expect(compactedTurns(result2.output)).toHaveLength(1);
979+
expect(allText(result2.output)).toContain("UNIQUE_SUCCESS_SUMMARY");
980+
expect(hasConsecutiveSameRole(result2.output)).toBe(false);
1023981
});
982+
}
1024983
});
1025984

1026985
describe("buildContextEnvelope", () => {

‎src/exec/runner.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -653,8 +653,8 @@ export async function runExec(config: Config): Promise<ExecResult> {
653653
const summarizeForCompaction = createModelSummarizer({
654654
getSource: () => liveSource,
655655
deps: inferenceDeps,
656+
getArchive: () => evidenceArchiveHolder.current,
656657
});
657-
const liveCompactionMode = config.settings?.compactionMode ?? "llm";
658658

659659
const { activated: activatedToolNames, computeAdvertised } =
660660
createAdvertisedToolset({
@@ -701,7 +701,6 @@ export async function runExec(config: Config): Promise<ExecResult> {
701701
liveDefaultSource.length > 0 ? liveDefaultSource : liveSource.id,
702702
getCompactor: () =>
703703
createSessionPruningCompactor({
704-
compactionMode: liveCompactionMode,
705704
summarize: summarizeForCompaction,
706705
telemetry: liveTelemetry,
707706
}),

0 commit comments

Comments
 (0)