Skip to content

Commit 1e6f717

Browse files
feat(session): fold compaction into a fat handoff file with a thin live spine (#1155)
* feat(session): fold compaction into a fat handoff file with a thin live spine A fold writes the full structured record to one stable handoff file and keeps only a short spine plus an explicit pointer in the prompt. The completeness gate rejects folds that drop novel text, so the spine re-renders carried facts byte-identical and every fold overwrites the same latest key instead of chaining per-fold files. * fix(session): union fat-file facts and adopt dropped handoff spines Iterative folds dropped fold-1 paths when the next region was only the prior spine plus later turns, and a growing Evidence/Constraints line fail-closed the completeness gate. Union the previous fat file, scan tool_result evidence, and archive synthetic spines as user_message. * fix(session): keep distinct handoff paths and ignore summary headings Prefix-collapse treated src/auth as a truncation of src/auth.ts. Scanning every ## heading let a model Summary overwrite schema, and empty constraint arrays won over carried spine facts via ??. * fix(session): keep prefix-sharing handoff facts distinct Production iterative folds merge full prior-file text, so prefix unique collapsed distinct constraints, decisions, and verification that only shared a string start. * fix(session): mark truncated handoff spine cuts with ellipsis An 80-char complete fact that prefixes a sibling was collapsing on the next fold because the cut detector used length alone. * fix(session): keep complete handoff facts over spine cuts Cuts are 83 chars; longer-string-wins replaced 81- and 82-char complete facts with the truncated spine fragment and dropped the last characters.
1 parent b4fab39 commit 1e6f717

12 files changed

Lines changed: 2165 additions & 36 deletions

‎src/context-compactor.test.ts‎

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -982,7 +982,17 @@ describe("createPruningCompactor — consolidated handoff (CL-7521)", () => {
982982
mockStrategyCtx,
983983
);
984984
expect(compactedTurns(result2.output)).toHaveLength(1);
985-
expect(allText(result2.output)).toContain("UNIQUE_SUCCESS_SUMMARY");
985+
// CL-8744: the narrative lives in the fat handoff file, not the prompt.
986+
// The live output carries only the thin spine plus its pointer.
987+
expect(allText(result2.output)).not.toContain("UNIQUE_SUCCESS_SUMMARY");
988+
const handoffBlob = defined(defined(result2.blobs)[0]);
989+
expect(handoffBlob.contentType).toBe("text/markdown");
990+
expect(new TextDecoder().decode(handoffBlob.bytes)).toContain(
991+
"UNIQUE_SUCCESS_SUMMARY",
992+
);
993+
expect(allText(result2.output)).toContain(
994+
`Handoff: tool-output:///${handoffBlob.key}`,
995+
);
986996
expect(hasConsecutiveSameRole(result2.output)).toBe(false);
987997
});
988998
});
@@ -1204,11 +1214,24 @@ describe("buildTurnSummary via createPruningCompactor", () => {
12041214
];
12051215

12061216
const result = await compactor.apply(turns, mockStrategyCtx);
1207-
const summaryText = (
1217+
const spineText = (
12081218
defined(defined(result.output[0]).content[0]) as { text: string }
12091219
).text;
1210-
expect(summaryText).toContain("read_file");
1211-
expect(summaryText).toContain("Total tool calls: 1");
1220+
// CL-8744: the live output carries only the thin spine (goal one-liner,
1221+
// evidence echo, explicit pointer) — file lists and counts stay in the
1222+
// fat handoff file, where they cannot make the next spine novel.
1223+
expect(spineText).toContain("[Compacted prior context]");
1224+
expect(spineText).toContain("Handoff: tool-output:///");
1225+
expect(spineText).not.toContain("src/foo.ts");
1226+
// The structured tool memory lives in the fat handoff file.
1227+
const file = new TextDecoder().decode(
1228+
defined(defined(result.blobs)[0]).bytes,
1229+
);
1230+
expect(file).toContain("src/foo.ts");
1231+
expect(file).toContain("paths: src/foo.ts");
1232+
expect(file).toContain("turns: 2, tool calls: 1");
1233+
expect(file).toContain("read_file");
1234+
expect(file).toContain("Total tool calls: 1");
12121235
});
12131236

12141237
test("truncates summary when it exceeds maxChars", async () => {
@@ -1230,17 +1253,17 @@ describe("buildTurnSummary via createPruningCompactor", () => {
12301253
];
12311254

12321255
const result = await compactor.apply(turns, mockStrategyCtx);
1233-
const summaryBlock = defined(defined(result.output[0]).content[0]) as {
1234-
text: string;
1235-
};
1236-
// The summary portion of the block is extracted from after the header line.
1237-
// The header itself is "---..." so we look at the full block text — the
1238-
// embedded buildTurnSummary output must end with "..." when truncated.
1239-
expect(summaryBlock.text).toContain("...");
1240-
// And the truncated summary must not exceed maxChars + 3 (for the "..." suffix)
1241-
const summaryStart = summaryBlock.text.indexOf("[Compacted prior context]");
1242-
const rawSummary = summaryBlock.text.slice(summaryStart);
1243-
// The raw summary lines are bounded by maxChars
1244-
expect(rawSummary.length).toBeLessThan(maxChars + 200); // header text + bounded summary
1256+
// CL-8744: the deterministic narrative lives in the fat handoff file's
1257+
// Summary section; the live output carries only the thin spine.
1258+
const file = new TextDecoder().decode(
1259+
defined(defined(result.blobs)[0]).bytes,
1260+
);
1261+
const narrative = defined(
1262+
file.split("## Summary (this fold — may paraphrase)\n")[1],
1263+
).split("## Exact facts")[0];
1264+
// The embedded buildTurnSummary output ends with "..." when truncated...
1265+
expect(narrative).toContain("...");
1266+
// ...and the truncated narrative stays within maxChars + 3 ("..." suffix).
1267+
expect(defined(narrative?.trim()).length).toBeLessThanOrEqual(maxChars + 3);
12451268
});
12461269
});

‎src/exec/runner.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ import {
137137
resolveLiveSessionSources,
138138
} from "../session/assemble-runtime.js";
139139
import type { CompactionArchive } from "../session/compaction-archive.js";
140+
import { tryReadPriorHandoffFile } from "../session/compaction-handoff.js";
140141
import { emitPluginWarningSummary } from "../plugins/diagnostics.js";
141142
import { createModelSummarizer } from "../session/summarizer.js";
142143
import { ID_PREFIX, LOG_NAMESPACE_ROOT } from "../branding.js";
@@ -848,6 +849,10 @@ export async function runExec(config: Config): Promise<ExecResult> {
848849
const tools = activatedToolNames.list();
849850
return tools.length > 0 ? { activatedTools: tools } : undefined;
850851
},
852+
readPriorHandoff: () =>
853+
tryReadPriorHandoffFile(
854+
currentStorage?.readBlob.bind(currentStorage),
855+
),
851856
telemetry: liveTelemetry,
852857
onFolded: () => {
853858
// Fold restarts the cached prefix, so catch promotions still

‎src/session/compaction-archive.test.ts‎

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -998,4 +998,150 @@ describe("wrapCompactorWithCompletenessGate", () => {
998998
expect(result.output).toHaveLength(1);
999999
expect(result.output[0]).not.toBe(turns[1]);
10001000
});
1001+
1002+
test("second fold that grows the spine still compact-succeeds by adopting the prior handoff", async () => {
1003+
const { wrapCompactorWithCompletenessGate } =
1004+
await import("./compaction-archive.js");
1005+
const { createPruningCompactor } = await import("./compactor.js");
1006+
const { COMPACTED_PREFIX, buildHandoffFold } =
1007+
await import("./compaction-handoff.js");
1008+
const { archive } = memoryArchive();
1009+
1010+
const first = buildHandoffFold(
1011+
[
1012+
{
1013+
role: "user",
1014+
content: [{ type: "text", text: "Ship the widget." }],
1015+
timestamp: 1,
1016+
},
1017+
],
1018+
"first narrative",
1019+
);
1020+
const priorSpine = first.spineText;
1021+
await archive.recordAuthorizedPayload({
1022+
kind: "user_message",
1023+
payload: "Must never write to /tmp. [[evidence:decision|op|no-tmp]]",
1024+
});
1025+
await archive.recordAuthorizedPayload({
1026+
kind: "assistant_text",
1027+
payload: "ok",
1028+
});
1029+
await archive.recordAuthorizedPayload({
1030+
kind: "assistant_text",
1031+
payload: "working",
1032+
});
1033+
1034+
const inner = createPruningCompactor({
1035+
keepRecentTurns: 2,
1036+
maxAnchorTurns: 0,
1037+
summaryMaxChars: 500,
1038+
});
1039+
const wrapped = wrapCompactorWithCompletenessGate(inner, archive);
1040+
const turns: import("@intx/types/runtime").ConversationTurn[] = [
1041+
{
1042+
role: "user",
1043+
content: [{ type: "text", text: priorSpine }],
1044+
timestamp: 1,
1045+
},
1046+
{
1047+
role: "user",
1048+
content: [{ type: "text", text: "Continue the widget." }],
1049+
timestamp: 2,
1050+
},
1051+
{
1052+
role: "assistant",
1053+
content: [{ type: "text", text: "working" }],
1054+
timestamp: 3,
1055+
},
1056+
{
1057+
role: "user",
1058+
content: [
1059+
{
1060+
type: "text",
1061+
text: "Must never write to /tmp. [[evidence:decision|op|no-tmp]]",
1062+
},
1063+
],
1064+
timestamp: 4,
1065+
},
1066+
{
1067+
role: "assistant",
1068+
content: [{ type: "text", text: "ok" }],
1069+
timestamp: 5,
1070+
},
1071+
{
1072+
role: "user",
1073+
content: [{ type: "text", text: "keep one" }],
1074+
timestamp: 6,
1075+
},
1076+
{
1077+
role: "assistant",
1078+
content: [{ type: "text", text: "keep two" }],
1079+
timestamp: 7,
1080+
},
1081+
];
1082+
const result = await wrapped.apply(turns, ctx);
1083+
expect(result.record.reason).not.toBe("incomplete-evidence-archive");
1084+
const spine = result.output[0]?.content.find((b) => b.type === "text");
1085+
expect(spine?.type).toBe("text");
1086+
if (spine?.type !== "text") throw new Error("unreachable");
1087+
expect(spine.text.startsWith(COMPACTED_PREFIX)).toBe(true);
1088+
expect(spine.text).toContain("Must never write to /tmp.");
1089+
expect(spine.text).toContain("[[evidence:decision|op|no-tmp]]");
1090+
});
1091+
1092+
test("pre-format fat Compacted prior context summaries still compact", async () => {
1093+
const { wrapCompactorWithCompletenessGate } =
1094+
await import("./compaction-archive.js");
1095+
const { createPruningCompactor } = await import("./compactor.js");
1096+
const { COMPACTED_PREFIX } = await import("./compaction-handoff.js");
1097+
const { archive } = memoryArchive();
1098+
const fatSummary = `${COMPACTED_PREFIX}\nLong pre-format narrative about the auth migration and every file that was touched.`;
1099+
await archive.recordAuthorizedPayload({
1100+
kind: "user_message",
1101+
payload: "next ask after the old summary",
1102+
});
1103+
await archive.recordAuthorizedPayload({
1104+
kind: "assistant_text",
1105+
payload: "working",
1106+
});
1107+
const inner = createPruningCompactor({
1108+
keepRecentTurns: 2,
1109+
maxAnchorTurns: 0,
1110+
summaryMaxChars: 500,
1111+
});
1112+
const wrapped = wrapCompactorWithCompletenessGate(inner, archive);
1113+
const turns: import("@intx/types/runtime").ConversationTurn[] = [
1114+
{
1115+
role: "user",
1116+
content: [{ type: "text", text: fatSummary }],
1117+
timestamp: 1,
1118+
},
1119+
{
1120+
role: "user",
1121+
content: [{ type: "text", text: "next ask after the old summary" }],
1122+
timestamp: 2,
1123+
},
1124+
{
1125+
role: "assistant",
1126+
content: [{ type: "text", text: "working" }],
1127+
timestamp: 3,
1128+
},
1129+
{
1130+
role: "user",
1131+
content: [{ type: "text", text: "keep one" }],
1132+
timestamp: 4,
1133+
},
1134+
{
1135+
role: "assistant",
1136+
content: [{ type: "text", text: "keep two" }],
1137+
timestamp: 5,
1138+
},
1139+
];
1140+
const result = await wrapped.apply(turns, ctx);
1141+
expect(result.record.reason).not.toBe("incomplete-evidence-archive");
1142+
const spine = result.output[0]?.content.find((b) => b.type === "text");
1143+
expect(spine?.type).toBe("text");
1144+
if (spine?.type !== "text") throw new Error("unreachable");
1145+
expect(spine.text.startsWith(COMPACTED_PREFIX)).toBe(true);
1146+
});
10011147
});

‎src/session/compaction-archive.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
type ToolRecordingLifecycle,
3030
} from "./compaction-archive-schema.js";
3131
import { parseAgedImageMarker } from "./attachment-uri.js";
32+
import { COMPACTED_PREFIX } from "./compaction-handoff.js";
3233

3334
const INDEX_DIR = "evidence-archive";
3435
const INDEX_FILE = "index.jsonl";
@@ -892,9 +893,30 @@ function incompleteIdentity(inner: Compactor, turns: ConversationTurn[]) {
892893
};
893894
}
894895

896+
/**
897+
* Archive a synthetic `[Compacted prior context]` turn as a user_message so
898+
* the completeness gate can certify dropping it when the next spine grows.
899+
*/
900+
export async function recordAdoptedHandoff(
901+
archive: CompactionArchive,
902+
text: string,
903+
): Promise<ArchiveOccurrence> {
904+
return archive.recordAuthorizedPayload({
905+
kind: "user_message",
906+
payload: text,
907+
provenance: "adopted-handoff",
908+
});
909+
}
910+
911+
function isSyntheticHandoffText(text: string): boolean {
912+
return text.startsWith(COMPACTED_PREFIX);
913+
}
914+
895915
/**
896916
* Refuse a destructive compact when the evidence archive cannot certify the
897917
* dropped prefix. Historical gap:true rows are not part of the expected set.
918+
* Synthetic handoff spines (and pre-format fat summaries) are adopted into
919+
* the archive as user_message so a later fold may change the live spine.
898920
*/
899921
export function wrapCompactorWithCompletenessGate(
900922
inner: Compactor,
@@ -908,6 +930,15 @@ export function wrapCompactorWithCompletenessGate(
908930
const proposed = await inner.apply(turns, ctx);
909931
const units = uncoveredContentUnits(turns, proposed.output);
910932
if (units.length === 0) return proposed;
933+
for (const unit of units) {
934+
if (
935+
unit.kind === "text" &&
936+
unit.role === "user" &&
937+
unit.text !== undefined &&
938+
isSyntheticHandoffText(unit.text)
939+
)
940+
await recordAdoptedHandoff(archive, unit.text);
941+
}
911942
const occurrences = await archive.listOccurrences();
912943
const covering = coveringOccurrence(
913944
units,

0 commit comments

Comments
 (0)