Skip to content

Commit 2207bf7

Browse files
Gustav-SimonssonTheGreatAxios
authored andcommitted
refactor(session): deepen comment compression in the session compactor
1 parent c0e7e1a commit 2207bf7

2 files changed

Lines changed: 70 additions & 75 deletions

File tree

‎src/session/compactor-pairing.test.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import { defined } from "../../testkit/defined.js";
1010

1111
// The runtime puts a tool_call on an assistant turn and its tool_result on the
1212
// following user turn, so a pair can straddle the compaction boundary. The
13-
// compactor must keep pairs together; otherwise the inference layer rejects
14-
// the compacted prompt (dangling call / orphan result).
13+
// compactor must keep pairs together or the inference layer rejects the
14+
// compacted prompt (dangling call / orphan result).
1515
function assistantCall(id: string, name = "read_file"): ConversationTurn {
1616
return {
1717
role: "assistant",
@@ -40,7 +40,7 @@ function userText(text: string): ConversationTurn {
4040
}
4141

4242
// Large enough that a short fixture's whole transcript is the tail, so
43-
// stubSupersededReads still runs when there is no summarized region.
43+
// stubSupersededReads still runs with no summarized region.
4444
const TAIL_SWALLOWS_TRANSCRIPT = { tailBudgetTokens: 50_000 } as const;
4545

4646
describe("pruning compactor preserves tool_call/tool_result pairing", () => {
@@ -217,7 +217,7 @@ describe("pruning compactor preserves tool_call/tool_result pairing", () => {
217217

218218
// When the same path is read more than once and both results survive
219219
// compaction (recent window / anchors), older successful reads become
220-
// one-line stubs; the newest success and error results stay whole.
220+
// one-line stubs; newest success and error results stay whole.
221221
function assistantRead(id: string, path: string): ConversationTurn {
222222
return {
223223
role: "assistant",
@@ -451,7 +451,7 @@ describe("pruning compactor stubs superseded file reads (CL-4374)", () => {
451451

452452
// grep/search_files/list_dir are replayable the same way read_file is: an
453453
// identical later call reflects newer workspace state, so the older result is
454-
// stubbed the same way an older full-file read is.
454+
// stubbed like an older full-file read.
455455
function assistantQuery(
456456
id: string,
457457
name: string,

‎src/session/compactor.ts‎

Lines changed: 65 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
//
33
// Deterministic ConversationTurn[] -> ConversationTurn[] compactor: prunes
44
// completed-task context while preserving the active task, recent turns, plan
5-
// state, file/tool references, and unresolved errors. The context store keeps
6-
// the full run history; only the inference-facing context is curated here.
5+
// state, file/tool references, and unresolved errors. Curates only the
6+
// inference-facing context; the store keeps the full run history.
77

88
import type {
99
ConversationTurn,
@@ -47,28 +47,27 @@ export interface CompactorConfig {
4747
/** Read at compaction time; passed to `summarize` with live workflow state. */
4848
summaryContext?: () => SummaryContext | undefined;
4949
/** Previous fat handoff body: the next fold unions its files/commands and
50-
* full goal text instead of spine-truncated cuts. */
50+
* full goal text rather than spine cuts. */
5151
readPriorHandoff?: () => Promise<string | undefined>;
52-
// Recorded for compatibility; the live fold keeps spine + token-capped tail.
52+
// Kept for compatibility; the live fold keeps spine + token-capped tail.
5353
maxAnchorTurns: number;
54-
/** Budgeted-tail shape (token budget, not turn-count floor). Partial:
55-
* missing fields resolve against DEFAULT_TAIL_COMPACTION_SHAPE. */
54+
/** Budgeted-tail shape (token budget, not turn-count floor). Missing
55+
* fields resolve against DEFAULT_TAIL_COMPACTION_SHAPE. */
5656
compactionShape?: Partial<CompactionShape>;
5757
}
5858

5959
/**
60-
* Shape of the live tail the fold keeps: extract + summary + actions (thin
61-
* spine / fat handoff file) plus a token-capped raw tail (~2–3k tokens by
62-
* default).
60+
* Live-tail shape the fold keeps: extract + summary + actions (thin spine /
61+
* fat handoff file) plus a token-capped raw tail (~2–3k tokens by default).
6362
*/
6463
export interface CompactionShape {
6564
/** Live-tail budget in tokens (chars/4 estimate). Default ~2500. */
6665
tailBudgetTokens: number;
67-
/** Tool outputs in the tail longer than this are head+tail excerpted. */
66+
/** Tail tool outputs longer than this are head+tail excerpted. */
6867
maxTailToolOutputChars: number;
69-
/** Keep the head of a shortened tail tool output. */
68+
/** Keep the head of a shortened tail output. */
7069
excerptHead: boolean;
71-
/** Keep the tail of a shortened tail tool output. */
70+
/** Keep the tail of a shortened tail output. */
7271
excerptTail: boolean;
7372
/** Newest user messages (plus attachments) stay whole up to the budget. */
7473
preserveWholeUserMessages: boolean;
@@ -91,14 +90,13 @@ export function resolveCompactionShape(
9190
return { ...DEFAULT_TAIL_COMPACTION_SHAPE, ...partial };
9291
}
9392

94-
// Fold marker; the handoff format is owned by ./compaction-handoff.js;
95-
// re-exported here so existing importers keep working.
93+
// Fold marker (handoff format owned by ./compaction-handoff.js); re-exported
94+
// so existing importers keep working.
9695
export { COMPACTED_PREFIX };
9796

98-
// Visible sentinel inserted between adjacent user turns so the history stays
99-
// role-alternating and adapters keep a non-empty assistant turn. Identity is
100-
// the reserved producer id on `compactSpacerTurn`, not this text or a missing
101-
// `model` field.
97+
// Sentinel between adjacent user turns so the history stays role-alternating
98+
// and adapters keep a non-empty assistant turn. Identity is the reserved
99+
// producer id on `compactSpacerTurn`, not this text or a missing `model`.
102100
export const COMPACT_SPACER_TEXT = "[compact]";
103101
export const LEGACY_COMPACT_SPACER_TEXT = "[compaction]";
104102
export const HARNESS_COMPACT_SPACER_MODEL = "harness";
@@ -116,16 +114,15 @@ function extraInstructionParameter(
116114
return { extraInstructions: extra };
117115
}
118116

119-
// `apply` no-ops at or below this turn count: a single turn cannot shrink.
117+
// `apply` no-ops at or below this turn count: one turn cannot shrink.
120118
export function compactorNoOpFloor(): number {
121119
return 1;
122120
}
123121

124-
// Query tools deduped by full-argument identity: a later identical
125-
// grep/search_files/list_dir call reflects newer workspace state, so older
126-
// results are stale. run_shell is excluded — the same command is not
127-
// idempotent, so an older result can be the only record of a distinct
128-
// outcome.
122+
// Query tools dedup by full-argument identity: a later identical call
123+
// reflects newer workspace state, so older results are stale. run_shell is
124+
// excluded — the same command is not idempotent, so an older result can be
125+
// the only record of a distinct outcome.
129126
const QUERY_TOOLS = new Set([...SEARCH_QUERY_TOOLS, "list_dir"]);
130127

131128
function isReplayableResultTool(name: string): boolean {
@@ -135,7 +132,7 @@ function isReplayableResultTool(name: string): boolean {
135132
// Call-id index for stub rendering (name + path). Dedup keys live on `readKey`.
136133
interface ToolCallInfo {
137134
name: string;
138-
/** Display path for stubs (always the raw path arg when present). */
135+
/** Display path for stubs (the raw path arg when present). */
139136
pathArg?: string;
140137
/** Dedup identity for re-read stubbing. Full-file reads share the path;
141138
* ranged reads (offset/limit) get a distinct key so chunked reads of one
@@ -145,7 +142,7 @@ interface ToolCallInfo {
145142

146143
interface PathRead {
147144
callId: string;
148-
/** Monotonic order across the turn list; higher = later in the session. */
145+
/** Monotonic order across turns; higher = later in the session. */
149146
order: number;
150147
isError: boolean;
151148
}
@@ -156,9 +153,9 @@ function scalarArg(value: unknown): string {
156153
return "";
157154
}
158155

159-
/** Path + re-read identity from a tool_call's arguments: path alone for
160-
* full-file reads; path+offset+limit when either range arg is present so
161-
* partial reads don't supersede each other. */
156+
/** Path + re-read identity from tool_call arguments: path alone for
157+
* full-file reads; path+offset+limit when a range arg is present so partial
158+
* reads don't supersede each other. */
162159
function readIdentityFromArguments(
163160
raw: unknown,
164161
): { path: string; readKey: string } | undefined {
@@ -184,7 +181,7 @@ function readIdentityFromArguments(
184181
return { path, readKey };
185182
}
186183

187-
// Deterministic key for structurally equal arguments regardless of key order.
184+
// Stable key for structurally equal arguments regardless of key order.
188185
function stableStringify(value: unknown): string {
189186
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
190187
if (value !== null && typeof value === "object") {
@@ -224,8 +221,8 @@ function buildCallIndex(
224221
for (const turn of turns) {
225222
for (const block of turn.content) {
226223
if (block.type !== "tool_call") continue;
227-
// Persisted calls keep the name the model emitted on the wire
228-
// (read/glob/…); the read/query classification sets are engine-keyed.
224+
// Persisted calls keep the wire name (read/glob/…); the read/query
225+
// classification sets are engine-keyed.
229226
const info: ToolCallInfo = { name: canonicalToolName(block.name) };
230227
const identity = readIdentityFromArguments(block.arguments);
231228
if (identity !== undefined) {
@@ -247,9 +244,8 @@ function buildCallIndex(
247244

248245
/**
249246
* Read-identity → matching replayable read/query results in session order.
250-
*
251-
* Callers pass only surviving turns: supersession over the full transcript
252-
* would hollow a kept older read when its newer re-read was summarized away.
247+
* Callers pass only surviving turns: full-transcript supersession would
248+
* hollow a kept older read whose newer re-read was summarized away.
253249
*/
254250
function buildPathToReads(
255251
turns: readonly ConversationTurn[],
@@ -282,7 +278,7 @@ function buildPathToReads(
282278

283279
/**
284280
* Call ids of successful results superseded by a later successful call of the
285-
* same identity. Error results never appear — they stay verbatim so the model
281+
* same identity. Errors stay verbatim — never superseded — so the model
286282
* still sees the failure.
287283
*/
288284
function supersededReadCallIds(
@@ -292,17 +288,17 @@ function supersededReadCallIds(
292288
for (const reads of pathToReads.values()) {
293289
const successes = reads.filter((r) => !r.isError);
294290
if (successes.length < 2) continue;
295-
// Newest success (highest order) stays whole; every earlier success stubs.
291+
// Newest success stays whole; every earlier success stubs.
296292
for (const read of successes.slice(0, -1)) {
297293
superseded.add(read.callId);
298294
}
299295
}
300296
return superseded;
301297
}
302298

303-
// Turn index of each tool_call and its matching tool_result. A call lives on
304-
// one turn and its result on the next, so a pair can straddle a
305-
// keep/summarize boundary.
299+
// Turn index of each tool_call and its matching tool_result; a call lives on
300+
// one turn, its result on the next, so a pair can straddle a keep/summarize
301+
// boundary.
306302
interface PairLocation {
307303
callIdx?: number;
308304
resultIdx?: number;
@@ -325,10 +321,9 @@ function buildPairIndex(turns: ConversationTurn[]): Map<string, PairLocation> {
325321
return pairs;
326322
}
327323

328-
// Whitespace-collapsed error-text prefix length for matching repeated
329-
// failures. Long enough to separate distinct errors, short enough that
330-
// trailing variable detail (line numbers, retry counters) does not defeat
331-
// the collapse.
324+
// Whitespace-collapsed error-text prefix for repeated-failure matching:
325+
// long enough to separate distinct errors, short enough that trailing
326+
// variable detail (line numbers, retry counters) does not defeat the collapse.
332327
const ERROR_SIGNATURE_PREFIX_CHARS = 120;
333328

334329
type ToolResultBlock = Extract<
@@ -352,8 +347,8 @@ function erroredResultSignature(
352347

353348
/**
354349
* Call ids of errored results whose (name, error-text prefix) signature
355-
* recurs on a later turn. Every occurrence but the last is returned,
356-
* collapsing a retry loop to its most recent failure.
350+
* recurs later. Every occurrence but the last is returned, collapsing a
351+
* retry loop to its most recent failure.
357352
*/
358353
function repeatedErroredResultCallIds(
359354
turns: readonly ConversationTurn[],
@@ -427,7 +422,7 @@ function buildResultStub(
427422
return `[${name} — ${size} chars, omitted]`;
428423
}
429424

430-
// Hollow out superseded successful read_file results; errors and the newest
425+
// Hollow superseded successful read_file results; errors and the newest
431426
// success per path stay whole.
432427
function stubSupersededReads(
433428
turn: ConversationTurn,
@@ -460,9 +455,9 @@ function isPlainTextTurn(turn: ConversationTurn): boolean {
460455
}
461456

462457
/**
463-
* Age base64 images in every turn outside the budgeted tail into rehydratable
464-
* attachment:// markers + StrategyBlob spills. Tail turns keep live bytes so a
465-
* just-pasted screenshot still reaches the model.
458+
* Age base64 images outside the budgeted tail into rehydratable
459+
* attachment:// markers + StrategyBlob spills. Tail turns keep live bytes so
460+
* a just-pasted screenshot still reaches the model.
466461
*/
467462
async function ageImagesOutsidePicked(
468463
turns: ConversationTurn[],
@@ -511,11 +506,11 @@ async function ageImagesOutsidePicked(
511506

512507
// Merge adjacent same-role turns whose later half is plain text: compaction
513508
// can leave same-role neighbors, which the Anthropic Messages API rejects.
514-
// A turn carrying a tool_result never fuses — result bodies are bulk the tail
515-
// budgets per turn, and a hybrid turn could not be budgeted independently.
516-
// Call headers stay fusible; a surviving result still lands right after its
517-
// tool_call, so no call/result sequence is disturbed. Result/text neighbors
518-
// that no longer fuse get a [compact] spacer from separateAdjacentUserTurns.
509+
// A tool_result turn never fuses — result bodies are bulk the tail budgets
510+
// per turn, so a hybrid turn could not be budgeted independently. Call
511+
// headers stay fusible; a surviving result still lands right after its
512+
// tool_call, so no call/result sequence is disturbed. Unfused result/text
513+
// neighbors get a [compact] spacer from separateAdjacentUserTurns.
519514
function carriesToolResult(turn: ConversationTurn): boolean {
520515
return turn.content.some((block) => block.type === "tool_result");
521516
}
@@ -599,8 +594,8 @@ function isCompactedSummaryTurn(turn: ConversationTurn): boolean {
599594

600595
// Harness spacers stamp `model: "harness"`. Missing `model` is unattributed
601596
// (replay sanitizer), except persisted `[compaction]` spacers from before
602-
// producer-id stamping. Model-produced copies carry a real model id and must
603-
// not enter the frozen prefix.
597+
// producer-id stamping. Model copies carry a real model id and must not
598+
// enter the frozen prefix.
604599
export function isHarnessCompactSpacer(turn: ConversationTurn): boolean {
605600
if (turn.role !== "assistant") return false;
606601
const text = firstTextBlock(turn);
@@ -662,11 +657,11 @@ function excerptTailText(
662657
}
663658

664659
// Excerpted live copy of a tail turn: thinking/redacted_thinking drop except
665-
// on the last assistant that still has a tool_call (Anthropic continuation
666-
// needs the unmodified thinking+signature on that tool_use). Whole blocks
667-
// drop so signatures never ship with truncated text. Large tool_result text
668-
// parts shrink to head+tail excerpts; user text, attachments, tool calls,
669-
// and error results pass whole (errors are resume state, not bulk).
660+
// on the last assistant with a tool_call (Anthropic continuation needs the
661+
// unmodified thinking+signature on that tool_use). Whole blocks drop so
662+
// signatures never ship truncated. Large tool_result text parts shrink to
663+
// head+tail excerpts; user text, attachments, tool calls, and errors pass
664+
// whole (they are resume state, not bulk).
670665
function excerptTailTurn(
671666
turn: ConversationTurn,
672667
shape: CompactionShape,
@@ -708,9 +703,9 @@ function excerptTailTurn(
708703
}
709704

710705
interface TailSelection {
711-
/** Indices actually selected for the live tail — not a contiguous slice. */
706+
/** Indices selected for the live tail — not a contiguous slice. */
712707
picked: Set<number>;
713-
/** Excerpted live copies for tail turns that needed shortening. */
708+
/** Excerpted live copies of tail turns that needed shortening. */
714709
excerpted: Map<number, ConversationTurn>;
715710
shortenedToolOutputs: number;
716711
/** Token estimate over the emitted (excerpted) tail. */
@@ -893,7 +888,7 @@ export function createPruningCompactor(
893888
turns: ConversationTurn[],
894889
_ctx: StrategyContext,
895890
): Promise<StrategyResult<ConversationTurn[]>> {
896-
// Prior compacted summaries are folded into the next handoff, not frozen.
891+
// Prior compacted summaries fold into the next handoff, not frozen.
897892
const shape = resolveCompactionShape(cfg.compactionShape);
898893

899894
if (turns.length <= compactorNoOpFloor()) {
@@ -1101,10 +1096,10 @@ export function createPruningCompactor(
11011096
// The fold writes a fat structured handoff file (goal, constraints,
11021097
// decisions, evidence markers, files/commands, verification, dead ends,
11031098
// next actions, verbatim exact-facts appendix) under one stable latest
1104-
// key, and keeps only a thin spine plus a pointer to that file in the
1099+
// key, keeping only a thin spine plus a pointer to that file in the
11051100
// live prompt. The spine unions carried facts with new ones; dropped
1106-
// prior spines are adopted into the evidence archive so the completeness
1107-
// gate still certifies the fold.
1101+
// prior spines join the evidence archive so the completeness gate
1102+
// still certifies the fold.
11081103
const priorFileText = await cfg.readPriorHandoff?.();
11091104
const handoff = buildHandoffFold(summarizedTurns, summary, {
11101105
...(priorFileText !== undefined ? { priorFileText } : {}),
@@ -1121,8 +1116,8 @@ export function createPruningCompactor(
11211116

11221117
// Tail turns stay contentful except for path-dedup: older successful
11231118
// re-reads of the same file become one-line stubs, the newest stays
1124-
// whole, and error results are never stubbed. Tail turns keep live
1125-
// base64 so a just-pasted screenshot still reaches the model.
1119+
// whole, errors are never stubbed. Tail turns keep live base64 so a
1120+
// just-pasted screenshot still reaches the model.
11261121
const process = (t: ConversationTurn): ConversationTurn =>
11271122
stubSupersededReads(t, supersededReads, callIndex);
11281123
const liveOutput = separateAdjacentUserTurns(

0 commit comments

Comments
 (0)