Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 59 additions & 7 deletions src/context-compactor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -801,7 +801,7 @@ describe("createPruningCompactor — consolidated handoff (CL-7521)", () => {
expect(result.record.reason).toBe("no compaction needed");
});

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

const result2 = await compactor.apply(
Expand All @@ -839,8 +843,11 @@ describe("createPruningCompactor — consolidated handoff (CL-7521)", () => {
// CL-8744: the narrative lives in the fat handoff file, not the prompt.
// The live output carries only the thin spine plus its pointer.
expect(allText(result2.output)).not.toContain("UNIQUE_SUCCESS_SUMMARY");
const handoffBlob = defined(defined(result2.blobs)[0]);
expect(handoffBlob.contentType).toBe("text/markdown");
const handoffBlob = defined(
defined(result2.blobs).find(
(blob) => blob.contentType === "text/markdown",
),
);
expect(new TextDecoder().decode(handoffBlob.bytes)).toContain(
"UNIQUE_SUCCESS_SUMMARY",
);
Expand All @@ -849,6 +856,51 @@ describe("createPruningCompactor — consolidated handoff (CL-7521)", () => {
);
expect(hasConsecutiveSameRole(result2.output)).toBe(false);
});

test("empty summarizer output folds with a statistics-only stub", async () => {
const source: InferenceSource = {
id: "test",
provider: "openai",
model: "test-model",
baseURL: "http://localhost:1",
credentialId: "test",
};
const notices: string[] = [];
const summarize = createModelSummarizer({
getSource: () => source,
complete: async () => "",
onFailure: (text) => notices.push(text),
});
const result = await smallCompactor({
keepRecentTurns: 2,
summaryMaxChars: 500,
summarize,
}).apply(grow([], 16, "empty"), mockStrategyCtx);
expect(result.record.decisions.summarizeFailed).toBe(1);
expect(result.record.decisions.summarizeFailureKind).toBe("empty");
expect(result.record.reason).toContain("statistics-only stub: empty");
expect(compactedTurns(result.output)).toHaveLength(1);
expect(notices).toHaveLength(1);
expect(notices[0]).toContain("statistics-only stub");
expect(notices[0]).toContain("empty");
});

test("an aborted summarizer keeps prior context instead of stubbing", async () => {
const err = new Error("aborted by lifecycle");
err.name = "AbortError";
const turns = grow([], 16, "abort");
const result = await smallCompactor({
keepRecentTurns: 2,
summaryMaxChars: 500,
summarize: async () => {
throw err;
},
}).apply(turns, mockStrategyCtx);
expect(result.output).toBe(turns);
expect(result.record.reason).toBe("summarize failed");
expect(result.record.decisions.summarizeFailed).toBe(1);
expect(result.record.decisions.summarizedTurnCount).toBeUndefined();
});
});

describe("buildTurnSummary via createPruningCompactor", () => {
Expand Down
76 changes: 52 additions & 24 deletions src/session/compactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ import type {
} from "@intx/types/runtime";
import { ageImageBlocks } from "./attachment-store.js";
import { buildHandoffFold, COMPACTED_PREFIX } from "./compaction-handoff.js";
import type { SummaryContext } from "./summarizer.js";
import {
classifySummarizerFailure,
type SummarizerFailureClass,
type SummaryContext,
} from "./summarizer.js";
import {
extractContinuationFacts,
verifyOrRepair,
Expand Down Expand Up @@ -1188,33 +1192,47 @@ export function createPruningCompactor(
? operatorCtx
: { ...operatorCtx, priorSummary: priorSummaryForFold };
let summary: string;
let summarizeFallback: SummarizerFailureClass | undefined;
const stubSummary = (): string =>
buildTurnSummary(
summarizedTurns,
cfg.summaryMaxChars,
anchorTurns.length,
);
try {
summary =
cfg.summarize !== undefined
? await cfg.summarize(summarizedTurns, summaryCtx)
: buildTurnSummary(
summarizedTurns,
cfg.summaryMaxChars,
anchorTurns.length,
);
} catch {
return {
output: turns,
record: {
strategy: this.name,
version: this.version,
parameters: {
keepRecentTurns: cfg.keepRecentTurns,
compactionShape: shape,
...extraInstructionParameter(cfg),
: stubSummary();
} catch (error) {
const failureClass = classifySummarizerFailure(error);
// A lifecycle abort is operator intent, not a lossy fallback: keep the
// prior context so the wrapCompactor race cannot land a stub fold.
if (failureClass === "aborted") {
return {
output: turns,
record: {
strategy: this.name,
version: this.version,
parameters: {
keepRecentTurns: cfg.keepRecentTurns,
compactionShape: shape,
...extraInstructionParameter(cfg),
},
reason: "summarize failed",
decisions: {
summarizeFailed: 1,
agedImageCount: aged.agedImageCount,
},
},
reason: "summarize failed",
decisions: {
summarizeFailed: 1,
agedImageCount: aged.agedImageCount,
},
},
};
};
}
summarizeFallback = failureClass;
summary = stubSummary();
}
if (summary.trim().length === 0) {
summarizeFallback = summarizeFallback ?? "empty";
summary = stubSummary();
}
if (summary.trim().length === 0) {
return {
Expand Down Expand Up @@ -1337,7 +1355,11 @@ export function createPruningCompactor(
compactionShape: shape,
...extraInstructionParameter(cfg),
},
reason: `compacted ${summarizedTurns.length} turns, anchored ${anchorTurns.length}, keeping ${tailTurns.length} tail`,
reason: `compacted ${summarizedTurns.length} turns, anchored ${anchorTurns.length}, keeping ${tailTurns.length} tail${
summarizeFallback !== undefined
? ` (statistics-only stub: ${summarizeFallback})`
: ""
}`,
decisions: {
summarizedTurnCount: summarizedTurns.length,
anchorTurnCount: anchorTurns.length,
Expand All @@ -1352,6 +1374,12 @@ export function createPruningCompactor(
agedImageCount: aged.agedImageCount,
supersededReadCount: supersededReads.size,
repeatedErrorCount: repeatedErrors.size,
...(summarizeFallback !== undefined
? {
summarizeFailed: 1,
summarizeFailureKind: summarizeFallback,
}
: {}),
...verifyDecisions,
},
},
Expand Down
56 changes: 56 additions & 0 deletions src/session/runtime-assembly.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
import type { SubAgentSourcesConfig } from "./runtime-assembly.js";
import type { Settings } from "../config/settings.js";
import type { Telemetry } from "../telemetry/index.js";
import { createModelSummarizer } from "./summarizer.js";
import { generateSessionId, initSessionDir, sessionDir } from "./index.js";
import type { PluginModule } from "../plugins/loader.js";

Expand Down Expand Up @@ -563,6 +564,61 @@ describe("createSessionPruningCompactor", () => {
});
});

describe("createSessionPruningCompactor stub fallback", () => {
test("a summarizer stub fallback folds without success telemetry or onFolded", async () => {
const captured: { event: string }[] = [];
const folds: { turnsBefore: number; turnsAfter: number }[] = [];
const telemetry: Telemetry = {
enabled: true,
installationId: "test",
capture: (event) => {
captured.push({ event });
},
captureIntentional: () => false,
flush: async () => undefined,
discard: () => undefined,
};
const notices: string[] = [];
const summarize = createModelSummarizer({
getSource: () =>
({
id: "test",
provider: "openai",
model: "test-model",
baseURL: "http://localhost:1",
credentialId: "test",
}) as never,
complete: async () => {
throw new Error("model unreachable");
},
onFailure: (text) => notices.push(text),
});
const compactor = createSessionPruningCompactor({
summarize,
telemetry,
onFolded: (info) => folds.push(info),
compactionShape: { tailBudgetTokens: 1 },
});
const now = Date.now();
const many = Array.from({ length: 8 }, (_, i) => ({
role: i % 2 === 0 ? "user" : "assistant",
content: [{ type: "text", text: `t${i}` }],
timestamp: now,
}));
const result = await compactor.apply(many as never, {
state: {} as never,
trigger: "test",
});
expect(result.record.decisions.summarizeFailed).toBe(1);
expect(result.record.reason).toContain("statistics-only stub");
expect(folds).toEqual([]);
expect(captured).toEqual([]);
expect(notices).toHaveLength(1);
expect(notices[0]).toContain("statistics-only stub");
expect(notices[0]).toContain("failed");
});
});

describe("buildCompactionContinuationMessage", () => {
test("builds a content-less system inbound that re-enters the reactor", () => {
const message = buildCompactionContinuationMessage();
Expand Down
9 changes: 7 additions & 2 deletions src/session/runtime-assembly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -475,8 +475,13 @@ export function createSessionPruningCompactor(
// summarizedTurnCount is only set on the branch that actually folded
// turns away. The other branch is a no-op (or image aging alone), and
// reporting it as compaction would drag the duration and turn-count
// averages toward the runs where nothing happened.
if (result.record.decisions.summarizedTurnCount !== undefined) {
// averages toward the runs where nothing happened. A statistics-only
// stub fold is not a successful LLM reduction: the operator notice
// owns that path, and emitting the success event would relabel it.
if (
result.record.decisions.summarizedTurnCount !== undefined &&
result.record.decisions.summarizeFailed !== 1
) {
telemetry.capture("compaction", {
mode: "llm",
duration_ms: Date.now() - startedAt,
Expand Down
9 changes: 9 additions & 0 deletions src/session/summarizer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,16 @@ test("model summarizer throws on failure instead of substituting a stats stub",
});

test("model summarizer throws when the model returns empty text", async () => {
const notices: string[] = [];
const summarize = createModelSummarizer({
getSource: () => source,
complete: async () => "",
onFailure: (text) => notices.push(text),
});
await expect(summarize(turns())).rejects.toThrow("empty text");
expect(notices).toHaveLength(1);
expect(notices[0]).toContain("statistics-only stub");
expect(notices[0]).toContain("empty");
});

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

Expand Down Expand Up @@ -356,6 +363,8 @@ test("final failure throws, notices once, and reports telemetry", async () => {
await expect(summarize(turns())).rejects.toThrow("500");
expect(calls).toBe(2);
expect(notices).toHaveLength(1);
expect(notices[0]).toContain("statistics-only stub");
expect(notices[0]).toContain("provider");
expect(notices[0]).toContain("500");
expect(events).toHaveLength(1);
expect(events[0]?.event).toBe("summarizer_failure");
Expand Down
24 changes: 14 additions & 10 deletions src/session/summarizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
// replaces older turns with a summary. A deterministic stats blob ("Turns: N,
// Tools called: ...") loses everything that matters for resuming work, so this
// module produces a structured, workflow-aware narrative via a one-shot
// inference call against the session's own model. Failure is fatal to that
// compact cycle: the caller retains the prior context instead of substituting
// a statistics-only stub.
// inference call against the session's own model. Empty output or a failed
// call throws so the compact cycle can substitute a statistics-only stub and
// tell the operator, instead of logging the fold as a successful reduction.

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

function classifySummarizerFailure(error: unknown): SummarizerFailureClass {
export function classifySummarizerFailure(
error: unknown,
): SummarizerFailureClass {
const cause = inferenceErrorCause(error);
if (cause !== undefined) {
if (cause.category === "aborted") return "aborted";
Expand Down Expand Up @@ -425,13 +427,15 @@ function failureNotice(
error: Error,
): string {
const firstLine = error.message.split("\n", 1)[0]?.trim() ?? "";
const reason =
firstLine.length > 0
const detail =
firstLine.length > 0 && firstLine !== failureClass
? firstLine.length > 140
? `${firstLine.slice(0, 140)}...`
: firstLine
: failureClass;
return `Compaction summary failed — keeping prior context (${reason})`;
: undefined;
const kind =
detail !== undefined ? `${failureClass}: ${detail}` : failureClass;
return `Compaction summary failed — using a statistics-only stub (${kind})`;
}

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