Skip to content

Commit e468037

Browse files
author
Quinn 🕊️
committed
fix: prevent fallback truncation summaries in compaction
Replace the three-level summarization escalation (normal → aggressive → deterministic truncation) with a two-level approach that returns null on failure instead of creating garbage summaries. When both normal and aggressive summarization fail to compress below the input token count, the compaction engine now bails and retries on the next turn. This prevents useless '[Truncated from N tokens]' summaries from polluting the DAG — particularly for media-only messages where the stored text content is just a file path (~28 tokens) that no LLM can compress further. Changes: - Remove truncation fallback in summarizeWithEscalation() — return null on compression failure (callers already handle null since upstream) - Wrap summarizer calls in try/catch to handle LLM errors gracefully - Add 'level' column to summaries table (normal/aggressive/fallback) with migration and backfill from compaction event metadata - Add SummaryRecord.level to store types - Add lcm_repair tool to scan/re-summarize existing fallback summaries - Register repair tool in plugin entry point
1 parent 518a1b2 commit e468037

6 files changed

Lines changed: 728 additions & 24 deletions

File tree

src/compaction.ts

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,6 @@ function generateSummaryId(content: string): string {
140140
}
141141

142142
/** Maximum characters for the deterministic fallback truncation (512 tokens * 4 chars). */
143-
const FALLBACK_MAX_CHARS = 512 * 4;
144143
const DEFAULT_LEAF_CHUNK_TOKENS = 20_000;
145144
const CONDENSED_MIN_INPUT_RATIO = 0.1;
146145

@@ -982,8 +981,13 @@ export class CompactionEngine {
982981
}
983982

984983
/**
985-
* Run three-level summarization escalation:
986-
* normal -> aggressive -> deterministic fallback.
984+
* Run two-level summarization escalation with explicit error handling:
985+
* normal -> aggressive -> fail (do NOT truncate to garbage).
986+
*
987+
* If both normal and aggressive summarization fail (return result >= input tokens),
988+
* returns null. The caller MUST NOT persist these failed attempts.
989+
* This forces the compaction engine to bail and retry on the next turn, instead
990+
* of creating useless garbage "fallback" summaries that pollute the DAG.
987991
*/
988992
private async summarizeWithEscalation(params: {
989993
sourceText: string;
@@ -992,17 +996,18 @@ export class CompactionEngine {
992996
}): Promise<{ content: string; level: CompactionLevel } | null> {
993997
const sourceText = params.sourceText.trim();
994998
if (!sourceText) {
995-
return {
996-
content: "[Truncated from 0 tokens]",
997-
level: "fallback",
998-
};
999+
return null;
9991000
}
10001001
const inputTokens = Math.max(1, estimateTokens(sourceText));
10011002

10021003
const runSummarizer = async (aggressiveMode: boolean): Promise<string | null> => {
1003-
const output = await params.summarize(sourceText, aggressiveMode, params.options);
1004-
const trimmed = output.trim();
1005-
return trimmed || null;
1004+
try {
1005+
const output = await params.summarize(sourceText, aggressiveMode, params.options);
1006+
const trimmed = output.trim();
1007+
return trimmed || null;
1008+
} catch {
1009+
return null;
1010+
}
10061011
};
10071012

10081013
const initialSummary = await runSummarizer(false);
@@ -1021,13 +1026,13 @@ export class CompactionEngine {
10211026
level = "aggressive";
10221027

10231028
if (estimateTokens(summaryText) >= inputTokens) {
1024-
const truncated =
1025-
sourceText.length > FALLBACK_MAX_CHARS
1026-
? sourceText.slice(0, FALLBACK_MAX_CHARS)
1027-
: sourceText;
1028-
summaryText = `${truncated}
1029-
[Truncated from ${inputTokens} tokens]`;
1030-
level = "fallback";
1029+
// Both normal and aggressive modes failed to compress.
1030+
// Return null instead of truncating — the caller will skip
1031+
// this compaction and retry on the next turn.
1032+
console.warn(
1033+
`[lcm] summarization failed to compress (input=${inputTokens}, aggressive=${estimateTokens(summaryText)}); skipping`,
1034+
);
1035+
return null;
10311036
}
10321037
}
10331038

src/db/migration.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,16 @@ function ensureSummaryDepthColumn(db: DatabaseSync): void {
3434
}
3535
}
3636

37+
function ensureSummaryLevelColumn(db: DatabaseSync): void {
38+
const summaryColumns = db.prepare(`PRAGMA table_info(summaries)`).all() as SummaryColumnInfo[];
39+
const hasLevel = summaryColumns.some((col) => col.name === "level");
40+
if (!hasLevel) {
41+
db.exec(
42+
`ALTER TABLE summaries ADD COLUMN level TEXT NOT NULL DEFAULT 'normal' CHECK (level IN ('normal', 'aggressive', 'fallback'))`
43+
);
44+
}
45+
}
46+
3747
function ensureSummaryMetadataColumns(db: DatabaseSync): void {
3848
const summaryColumns = db.prepare(`PRAGMA table_info(summaries)`).all() as SummaryColumnInfo[];
3949
const hasEarliestAt = summaryColumns.some((col) => col.name === "earliest_at");
@@ -183,6 +193,50 @@ function backfillSummaryDepths(db: DatabaseSync): void {
183193
}
184194
}
185195

196+
function backfillSummaryLevels(db: DatabaseSync): void {
197+
// Strategy: check for fallback summaries in compaction events (message_parts with part_type='compaction')
198+
// 1. Query all message_parts with part_type='compaction'
199+
// 2. Parse metadata JSON to find summaries with level='fallback'
200+
// 3. Update those summaries to level='fallback'
201+
// 4. Scan remaining summaries for truncation canary in content
202+
203+
try {
204+
// Phase 1: extract fallback events from message_parts metadata
205+
const fallbackSummaryIds = new Set<string>();
206+
const eventRows = db
207+
.prepare(
208+
`SELECT part_id, metadata
209+
FROM message_parts
210+
WHERE part_type = 'compaction' AND metadata IS NOT NULL`
211+
)
212+
.all() as Array<{ part_id: string; metadata: string | null }>;
213+
214+
for (const row of eventRows) {
215+
if (!row.metadata) continue;
216+
try {
217+
const meta = JSON.parse(row.metadata);
218+
if (meta.level === 'fallback' && meta.createdSummaryIds) {
219+
const ids = Array.isArray(meta.createdSummaryIds) ? meta.createdSummaryIds : [];
220+
for (const id of ids) {
221+
if (typeof id === 'string') {
222+
fallbackSummaryIds.add(id);
223+
}
224+
}
225+
}
226+
} catch {
227+
// Skip malformed metadata
228+
}
229+
}
230+
231+
// Phase 2: update extracted fallback summaries
232+
for (const summaryId of fallbackSummaryIds) {
233+
db.prepare(`UPDATE summaries SET level = 'fallback' WHERE summary_id = ?`).run(summaryId);
234+
}
235+
} catch {
236+
// Backfill is best-effort; swallow errors to avoid blocking migration
237+
}
238+
}
239+
186240
function backfillSummaryMetadata(db: DatabaseSync): void {
187241
const conversationRows = db
188242
.prepare(`SELECT DISTINCT conversation_id FROM summaries`)
@@ -386,6 +440,7 @@ export function runLcmMigrations(
386440
conversation_id INTEGER NOT NULL REFERENCES conversations(conversation_id) ON DELETE CASCADE,
387441
kind TEXT NOT NULL CHECK (kind IN ('leaf', 'condensed')),
388442
depth INTEGER NOT NULL DEFAULT 0,
443+
level TEXT NOT NULL DEFAULT 'normal' CHECK (level IN ('normal', 'aggressive', 'fallback')),
389444
content TEXT NOT NULL,
390445
token_count INTEGER NOT NULL,
391446
earliest_at TEXT,
@@ -499,8 +554,10 @@ export function runLcmMigrations(
499554

500555
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS conversations_session_key_idx ON conversations (session_key)`);
501556
ensureSummaryDepthColumn(db);
557+
ensureSummaryLevelColumn(db);
502558
ensureSummaryMetadataColumns(db);
503559
backfillSummaryDepths(db);
560+
backfillSummaryLevels(db);
504561
backfillSummaryMetadata(db);
505562

506563
const fts5Available = options?.fts5Available ?? getLcmDbFeatures(db).fts5Available;

src/plugin/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { createLcmDescribeTool } from "../tools/lcm-describe-tool.js";
1515
import { createLcmExpandQueryTool } from "../tools/lcm-expand-query-tool.js";
1616
import { createLcmExpandTool } from "../tools/lcm-expand-tool.js";
1717
import { createLcmGrepTool } from "../tools/lcm-grep-tool.js";
18+
import { createLcmRepairTool } from "../tools/lcm-repair-command.js";
1819
import type { LcmDependencies } from "../types.js";
1920

2021
/** Parse `agent:<agentId>:<suffix...>` session keys. */
@@ -1354,6 +1355,13 @@ const lcmPlugin = {
13541355
requesterSessionKey: ctx.sessionKey,
13551356
}),
13561357
);
1358+
api.registerTool((ctx) =>
1359+
createLcmRepairTool({
1360+
deps,
1361+
lcm,
1362+
sessionKey: ctx.sessionKey,
1363+
}),
1364+
);
13571365

13581366
logStartupBannerOnce({
13591367
key: "plugin-loaded",

src/store/summary-store.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export type SummaryRecord = {
2525
conversationId: number;
2626
kind: SummaryKind;
2727
depth: number;
28+
level?: "normal" | "aggressive" | "fallback";
2829
content: string;
2930
tokenCount: number;
3031
fileIds: string[];
@@ -98,6 +99,7 @@ interface SummaryRow {
9899
conversation_id: number;
99100
kind: SummaryKind;
100101
depth: number;
102+
level?: string;
101103
content: string;
102104
token_count: number;
103105
file_ids: string;
@@ -176,6 +178,7 @@ function toSummaryRecord(row: SummaryRow): SummaryRecord {
176178
kind: row.kind,
177179
depth: row.depth,
178180
content: row.content,
181+
level: (row.level as SummaryRecord["level"]) ?? "normal",
179182
tokenCount: row.token_count,
180183
fileIds,
181184
earliestAt: row.earliest_at ? new Date(row.earliest_at) : null,

0 commit comments

Comments
 (0)