Skip to content

Commit 9cee7e4

Browse files
fix(session): surface compaction summarizer stub fallback (#1235)
A failed or empty summary used to keep prior context while the compact cycle still looked like a successful reduction. The fold now substitutes the statistics-only stub, names the failure kind to the operator, and skips the success compaction event.
1 parent 4d2f915 commit 9cee7e4

8 files changed

Lines changed: 228 additions & 44 deletions

File tree

‎src/context-compactor.test.ts‎

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -801,7 +801,7 @@ describe("createPruningCompactor — consolidated handoff (CL-7521)", () => {
801801
expect(result.record.reason).toBe("no compaction needed");
802802
});
803803

804-
test("failing summarizer keeps prior context; a later success writes one handoff", async () => {
804+
test("failing summarizer folds with a statistics-only stub; a later success writes one handoff", async () => {
805805
const source: InferenceSource = {
806806
id: "test",
807807
provider: "openai",
@@ -825,10 +825,14 @@ describe("createPruningCompactor — consolidated handoff (CL-7521)", () => {
825825
});
826826
const turns = grow([], 16, "fail");
827827
const result1 = await compactor.apply(turns, mockStrategyCtx);
828-
expect(result1.output).toBe(turns);
829-
expect(result1.record.reason).toBe("summarize failed");
830-
expect(firstText(defined(result1.output[0]))).not.toContain(
831-
COMPACTED_PREFIX,
828+
expect(result1.output).not.toBe(turns);
829+
expect(result1.record.reason).toContain("statistics-only stub");
830+
expect(result1.record.decisions.summarizeFailed).toBe(1);
831+
expect(result1.record.decisions.summarizeFailureKind).toBe("failed");
832+
expect(compactedTurns(result1.output)).toHaveLength(1);
833+
const stubHandoff = defined(defined(result1.blobs)[0]);
834+
expect(new TextDecoder().decode(stubHandoff.bytes)).toContain(
835+
"Turns compacted:",
832836
);
833837

834838
const result2 = await compactor.apply(
@@ -839,8 +843,11 @@ describe("createPruningCompactor — consolidated handoff (CL-7521)", () => {
839843
// CL-8744: the narrative lives in the fat handoff file, not the prompt.
840844
// The live output carries only the thin spine plus its pointer.
841845
expect(allText(result2.output)).not.toContain("UNIQUE_SUCCESS_SUMMARY");
842-
const handoffBlob = defined(defined(result2.blobs)[0]);
843-
expect(handoffBlob.contentType).toBe("text/markdown");
846+
const handoffBlob = defined(
847+
defined(result2.blobs).find(
848+
(blob) => blob.contentType === "text/markdown",
849+
),
850+
);
844851
expect(new TextDecoder().decode(handoffBlob.bytes)).toContain(
845852
"UNIQUE_SUCCESS_SUMMARY",
846853
);
@@ -849,6 +856,51 @@ describe("createPruningCompactor — consolidated handoff (CL-7521)", () => {
849856
);
850857
expect(hasConsecutiveSameRole(result2.output)).toBe(false);
851858
});
859+
860+
test("empty summarizer output folds with a statistics-only stub", async () => {
861+
const source: InferenceSource = {
862+
id: "test",
863+
provider: "openai",
864+
model: "test-model",
865+
baseURL: "http://localhost:1",
866+
credentialId: "test",
867+
};
868+
const notices: string[] = [];
869+
const summarize = createModelSummarizer({
870+
getSource: () => source,
871+
complete: async () => "",
872+
onFailure: (text) => notices.push(text),
873+
});
874+
const result = await smallCompactor({
875+
keepRecentTurns: 2,
876+
summaryMaxChars: 500,
877+
summarize,
878+
}).apply(grow([], 16, "empty"), mockStrategyCtx);
879+
expect(result.record.decisions.summarizeFailed).toBe(1);
880+
expect(result.record.decisions.summarizeFailureKind).toBe("empty");
881+
expect(result.record.reason).toContain("statistics-only stub: empty");
882+
expect(compactedTurns(result.output)).toHaveLength(1);
883+
expect(notices).toHaveLength(1);
884+
expect(notices[0]).toContain("statistics-only stub");
885+
expect(notices[0]).toContain("empty");
886+
});
887+
888+
test("an aborted summarizer keeps prior context instead of stubbing", async () => {
889+
const err = new Error("aborted by lifecycle");
890+
err.name = "AbortError";
891+
const turns = grow([], 16, "abort");
892+
const result = await smallCompactor({
893+
keepRecentTurns: 2,
894+
summaryMaxChars: 500,
895+
summarize: async () => {
896+
throw err;
897+
},
898+
}).apply(turns, mockStrategyCtx);
899+
expect(result.output).toBe(turns);
900+
expect(result.record.reason).toBe("summarize failed");
901+
expect(result.record.decisions.summarizeFailed).toBe(1);
902+
expect(result.record.decisions.summarizedTurnCount).toBeUndefined();
903+
});
852904
});
853905

854906
describe("buildTurnSummary via createPruningCompactor", () => {

‎src/session/compactor.ts‎

Lines changed: 52 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ import type {
1616
} from "@intx/types/runtime";
1717
import { ageImageBlocks } from "./attachment-store.js";
1818
import { buildHandoffFold, COMPACTED_PREFIX } from "./compaction-handoff.js";
19-
import type { SummaryContext } from "./summarizer.js";
19+
import {
20+
classifySummarizerFailure,
21+
type SummarizerFailureClass,
22+
type SummaryContext,
23+
} from "./summarizer.js";
2024
import {
2125
extractContinuationFacts,
2226
verifyOrRepair,
@@ -1188,33 +1192,47 @@ export function createPruningCompactor(
11881192
? operatorCtx
11891193
: { ...operatorCtx, priorSummary: priorSummaryForFold };
11901194
let summary: string;
1195+
let summarizeFallback: SummarizerFailureClass | undefined;
1196+
const stubSummary = (): string =>
1197+
buildTurnSummary(
1198+
summarizedTurns,
1199+
cfg.summaryMaxChars,
1200+
anchorTurns.length,
1201+
);
11911202
try {
11921203
summary =
11931204
cfg.summarize !== undefined
11941205
? await cfg.summarize(summarizedTurns, summaryCtx)
1195-
: buildTurnSummary(
1196-
summarizedTurns,
1197-
cfg.summaryMaxChars,
1198-
anchorTurns.length,
1199-
);
1200-
} catch {
1201-
return {
1202-
output: turns,
1203-
record: {
1204-
strategy: this.name,
1205-
version: this.version,
1206-
parameters: {
1207-
keepRecentTurns: cfg.keepRecentTurns,
1208-
compactionShape: shape,
1209-
...extraInstructionParameter(cfg),
1206+
: stubSummary();
1207+
} catch (error) {
1208+
const failureClass = classifySummarizerFailure(error);
1209+
// A lifecycle abort is operator intent, not a lossy fallback: keep the
1210+
// prior context so the wrapCompactor race cannot land a stub fold.
1211+
if (failureClass === "aborted") {
1212+
return {
1213+
output: turns,
1214+
record: {
1215+
strategy: this.name,
1216+
version: this.version,
1217+
parameters: {
1218+
keepRecentTurns: cfg.keepRecentTurns,
1219+
compactionShape: shape,
1220+
...extraInstructionParameter(cfg),
1221+
},
1222+
reason: "summarize failed",
1223+
decisions: {
1224+
summarizeFailed: 1,
1225+
agedImageCount: aged.agedImageCount,
1226+
},
12101227
},
1211-
reason: "summarize failed",
1212-
decisions: {
1213-
summarizeFailed: 1,
1214-
agedImageCount: aged.agedImageCount,
1215-
},
1216-
},
1217-
};
1228+
};
1229+
}
1230+
summarizeFallback = failureClass;
1231+
summary = stubSummary();
1232+
}
1233+
if (summary.trim().length === 0) {
1234+
summarizeFallback = summarizeFallback ?? "empty";
1235+
summary = stubSummary();
12181236
}
12191237
if (summary.trim().length === 0) {
12201238
return {
@@ -1337,7 +1355,11 @@ export function createPruningCompactor(
13371355
compactionShape: shape,
13381356
...extraInstructionParameter(cfg),
13391357
},
1340-
reason: `compacted ${summarizedTurns.length} turns, anchored ${anchorTurns.length}, keeping ${tailTurns.length} tail`,
1358+
reason: `compacted ${summarizedTurns.length} turns, anchored ${anchorTurns.length}, keeping ${tailTurns.length} tail${
1359+
summarizeFallback !== undefined
1360+
? ` (statistics-only stub: ${summarizeFallback})`
1361+
: ""
1362+
}`,
13411363
decisions: {
13421364
summarizedTurnCount: summarizedTurns.length,
13431365
anchorTurnCount: anchorTurns.length,
@@ -1352,6 +1374,12 @@ export function createPruningCompactor(
13521374
agedImageCount: aged.agedImageCount,
13531375
supersededReadCount: supersededReads.size,
13541376
repeatedErrorCount: repeatedErrors.size,
1377+
...(summarizeFallback !== undefined
1378+
? {
1379+
summarizeFailed: 1,
1380+
summarizeFailureKind: summarizeFallback,
1381+
}
1382+
: {}),
13551383
...verifyDecisions,
13561384
},
13571385
},

‎src/session/runtime-assembly.test.ts‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
import type { SubAgentSourcesConfig } from "./runtime-assembly.js";
3333
import type { Settings } from "../config/settings.js";
3434
import type { Telemetry } from "../telemetry/index.js";
35+
import { createModelSummarizer } from "./summarizer.js";
3536
import { generateSessionId, initSessionDir, sessionDir } from "./index.js";
3637
import type { PluginModule } from "../plugins/loader.js";
3738

@@ -563,6 +564,61 @@ describe("createSessionPruningCompactor", () => {
563564
});
564565
});
565566

567+
describe("createSessionPruningCompactor stub fallback", () => {
568+
test("a summarizer stub fallback folds without success telemetry or onFolded", async () => {
569+
const captured: { event: string }[] = [];
570+
const folds: { turnsBefore: number; turnsAfter: number }[] = [];
571+
const telemetry: Telemetry = {
572+
enabled: true,
573+
installationId: "test",
574+
capture: (event) => {
575+
captured.push({ event });
576+
},
577+
captureIntentional: () => false,
578+
flush: async () => undefined,
579+
discard: () => undefined,
580+
};
581+
const notices: string[] = [];
582+
const summarize = createModelSummarizer({
583+
getSource: () =>
584+
({
585+
id: "test",
586+
provider: "openai",
587+
model: "test-model",
588+
baseURL: "http://localhost:1",
589+
credentialId: "test",
590+
}) as never,
591+
complete: async () => {
592+
throw new Error("model unreachable");
593+
},
594+
onFailure: (text) => notices.push(text),
595+
});
596+
const compactor = createSessionPruningCompactor({
597+
summarize,
598+
telemetry,
599+
onFolded: (info) => folds.push(info),
600+
compactionShape: { tailBudgetTokens: 1 },
601+
});
602+
const now = Date.now();
603+
const many = Array.from({ length: 8 }, (_, i) => ({
604+
role: i % 2 === 0 ? "user" : "assistant",
605+
content: [{ type: "text", text: `t${i}` }],
606+
timestamp: now,
607+
}));
608+
const result = await compactor.apply(many as never, {
609+
state: {} as never,
610+
trigger: "test",
611+
});
612+
expect(result.record.decisions.summarizeFailed).toBe(1);
613+
expect(result.record.reason).toContain("statistics-only stub");
614+
expect(folds).toEqual([]);
615+
expect(captured).toEqual([]);
616+
expect(notices).toHaveLength(1);
617+
expect(notices[0]).toContain("statistics-only stub");
618+
expect(notices[0]).toContain("failed");
619+
});
620+
});
621+
566622
describe("buildCompactionContinuationMessage", () => {
567623
test("builds a content-less system inbound that re-enters the reactor", () => {
568624
const message = buildCompactionContinuationMessage();

‎src/session/runtime-assembly.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -475,8 +475,13 @@ export function createSessionPruningCompactor(
475475
// summarizedTurnCount is only set on the branch that actually folded
476476
// turns away. The other branch is a no-op (or image aging alone), and
477477
// reporting it as compaction would drag the duration and turn-count
478-
// averages toward the runs where nothing happened.
479-
if (result.record.decisions.summarizedTurnCount !== undefined) {
478+
// averages toward the runs where nothing happened. A statistics-only
479+
// stub fold is not a successful LLM reduction: the operator notice
480+
// owns that path, and emitting the success event would relabel it.
481+
if (
482+
result.record.decisions.summarizedTurnCount !== undefined &&
483+
result.record.decisions.summarizeFailed !== 1
484+
) {
480485
telemetry.capture("compaction", {
481486
mode: "llm",
482487
duration_ms: Date.now() - startedAt,

‎src/session/summarizer.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,11 +103,16 @@ test("model summarizer throws on failure instead of substituting a stats stub",
103103
});
104104

105105
test("model summarizer throws when the model returns empty text", async () => {
106+
const notices: string[] = [];
106107
const summarize = createModelSummarizer({
107108
getSource: () => source,
108109
complete: async () => "",
110+
onFailure: (text) => notices.push(text),
109111
});
110112
await expect(summarize(turns())).rejects.toThrow("empty text");
113+
expect(notices).toHaveLength(1);
114+
expect(notices[0]).toContain("statistics-only stub");
115+
expect(notices[0]).toContain("empty");
111116
});
112117

113118
test("model summarizer feeds archive payloads into the prompt instead of clipped turns", async () => {
@@ -261,6 +266,8 @@ test("a second 401 fails: retry budget is spent once", async () => {
261266
expect(calls).toBe(2);
262267
expect(refreshes).toBe(1);
263268
expect(notices).toHaveLength(1);
269+
expect(notices[0]).toContain("statistics-only stub");
270+
expect(notices[0]).toContain("auth");
264271
expect(notices[0]).toContain("401");
265272
});
266273

@@ -356,6 +363,8 @@ test("final failure throws, notices once, and reports telemetry", async () => {
356363
await expect(summarize(turns())).rejects.toThrow("500");
357364
expect(calls).toBe(2);
358365
expect(notices).toHaveLength(1);
366+
expect(notices[0]).toContain("statistics-only stub");
367+
expect(notices[0]).toContain("provider");
359368
expect(notices[0]).toContain("500");
360369
expect(events).toHaveLength(1);
361370
expect(events[0]?.event).toBe("summarizer_failure");

‎src/session/summarizer.ts‎

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
// replaces older turns with a summary. A deterministic stats blob ("Turns: N,
55
// Tools called: ...") loses everything that matters for resuming work, so this
66
// module produces a structured, workflow-aware narrative via a one-shot
7-
// inference call against the session's own model. Failure is fatal to that
8-
// compact cycle: the caller retains the prior context instead of substituting
9-
// a statistics-only stub.
7+
// inference call against the session's own model. Empty output or a failed
8+
// call throws so the compact cycle can substitute a statistics-only stub and
9+
// tell the operator, instead of logging the fold as a successful reduction.
1010

1111
import { type } from "arktype";
1212
import { runInference, type Dependencies } from "@intx/inference";
@@ -388,7 +388,9 @@ function inferenceErrorCause(error: unknown): InferenceError | undefined {
388388
return parsed instanceof type.errors ? undefined : parsed;
389389
}
390390

391-
function classifySummarizerFailure(error: unknown): SummarizerFailureClass {
391+
export function classifySummarizerFailure(
392+
error: unknown,
393+
): SummarizerFailureClass {
392394
const cause = inferenceErrorCause(error);
393395
if (cause !== undefined) {
394396
if (cause.category === "aborted") return "aborted";
@@ -425,13 +427,15 @@ function failureNotice(
425427
error: Error,
426428
): string {
427429
const firstLine = error.message.split("\n", 1)[0]?.trim() ?? "";
428-
const reason =
429-
firstLine.length > 0
430+
const detail =
431+
firstLine.length > 0 && firstLine !== failureClass
430432
? firstLine.length > 140
431433
? `${firstLine.slice(0, 140)}...`
432434
: firstLine
433-
: failureClass;
434-
return `Compaction summary failed — keeping prior context (${reason})`;
435+
: undefined;
436+
const kind =
437+
detail !== undefined ? `${failureClass}: ${detail}` : failureClass;
438+
return `Compaction summary failed — using a statistics-only stub (${kind})`;
435439
}
436440

437441
export interface ModelSummarizerOptions {
@@ -467,8 +471,8 @@ export interface ModelSummarizerOptions {
467471
/**
468472
* Build a `summarize(turns, ctx)` function suitable for `CompactorConfig`.
469473
* Produces a structured, workflow-aware summary via the model. Empty output
470-
* or a failed call throws so the compact cycle can keep the prior context
471-
* instead of replacing it with a statistics-only stub.
474+
* or a failed call throws so the compact cycle can substitute a
475+
* statistics-only stub and surface that fallback to the operator.
472476
*/
473477
export function createModelSummarizer(
474478
options: ModelSummarizerOptions,

0 commit comments

Comments
 (0)