@martian-engineering/lossless-claw 0.6.3 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -6
- package/docs/agent-tools.md +16 -5
- package/docs/configuration.md +223 -214
- package/openclaw.plugin.json +123 -0
- package/package.json +1 -1
- package/skills/lossless-claw/SKILL.md +3 -2
- package/skills/lossless-claw/references/architecture.md +12 -0
- package/skills/lossless-claw/references/config.md +135 -3
- package/skills/lossless-claw/references/diagnostics.md +13 -0
- package/src/assembler.ts +17 -5
- package/src/compaction.ts +161 -53
- package/src/db/config.ts +102 -4
- package/src/db/connection.ts +35 -7
- package/src/db/features.ts +24 -5
- package/src/db/migration.ts +257 -78
- package/src/engine.ts +1007 -110
- package/src/estimate-tokens.ts +80 -0
- package/src/lcm-log.ts +37 -0
- package/src/plugin/index.ts +493 -101
- package/src/plugin/lcm-command.ts +288 -7
- package/src/plugin/lcm-doctor-apply.ts +1 -3
- package/src/plugin/lcm-doctor-cleaners.ts +655 -0
- package/src/plugin/shared-init.ts +59 -0
- package/src/prune.ts +391 -0
- package/src/retrieval.ts +8 -9
- package/src/startup-banner-log.ts +1 -0
- package/src/store/compaction-telemetry-store.ts +156 -0
- package/src/store/conversation-store.ts +6 -1
- package/src/store/fts5-sanitize.ts +25 -4
- package/src/store/full-text-sort.ts +21 -0
- package/src/store/index.ts +8 -0
- package/src/store/summary-store.ts +21 -14
- package/src/summarize.ts +55 -34
- package/src/tools/lcm-describe-tool.ts +9 -4
- package/src/tools/lcm-expand-query-tool.ts +609 -200
- package/src/tools/lcm-expand-tool.ts +9 -4
- package/src/tools/lcm-grep-tool.ts +22 -8
- package/src/types.ts +1 -0
package/src/engine.ts
CHANGED
|
@@ -43,9 +43,16 @@ import {
|
|
|
43
43
|
generateExplorationSummary,
|
|
44
44
|
parseFileBlocks,
|
|
45
45
|
} from "./large-files.js";
|
|
46
|
+
import { describeLogError } from "./lcm-log.js";
|
|
46
47
|
import { RetrievalEngine } from "./retrieval.js";
|
|
47
48
|
import { compileSessionPatterns, matchesSessionPattern } from "./session-patterns.js";
|
|
48
49
|
import { logStartupBannerOnce } from "./startup-banner-log.js";
|
|
50
|
+
import {
|
|
51
|
+
CompactionTelemetryStore,
|
|
52
|
+
type ConversationCompactionTelemetryRecord,
|
|
53
|
+
type CacheState,
|
|
54
|
+
type ActivityBand,
|
|
55
|
+
} from "./store/compaction-telemetry-store.js";
|
|
49
56
|
import {
|
|
50
57
|
ConversationStore,
|
|
51
58
|
type ConversationRecord,
|
|
@@ -56,6 +63,7 @@ import {
|
|
|
56
63
|
import { SummaryStore } from "./store/summary-store.js";
|
|
57
64
|
import { createLcmSummarizeFromLegacyParams, LcmProviderAuthError } from "./summarize.js";
|
|
58
65
|
import type { LcmDependencies } from "./types.js";
|
|
66
|
+
import { estimateTokens } from "./estimate-tokens.js";
|
|
59
67
|
|
|
60
68
|
type AgentMessage = Parameters<ContextEngine["ingest"]>[0]["message"];
|
|
61
69
|
type AssembleResultWithSystemPrompt = AssembleResult & { systemPromptAddition?: string };
|
|
@@ -63,6 +71,30 @@ type CircuitBreakerState = {
|
|
|
63
71
|
failures: number;
|
|
64
72
|
openSince: number | null;
|
|
65
73
|
};
|
|
74
|
+
type PromptCacheSnapshot = {
|
|
75
|
+
lastObservedCacheRead?: number;
|
|
76
|
+
lastObservedCacheWrite?: number;
|
|
77
|
+
cacheState: CacheState;
|
|
78
|
+
retention?: string;
|
|
79
|
+
sawExplicitBreak: boolean;
|
|
80
|
+
};
|
|
81
|
+
type IncrementalCompactionDecision = {
|
|
82
|
+
shouldCompact: boolean;
|
|
83
|
+
cacheState: CacheState;
|
|
84
|
+
maxPasses: number;
|
|
85
|
+
rawTokensOutsideTail: number;
|
|
86
|
+
threshold: number;
|
|
87
|
+
leafChunkTokens: number;
|
|
88
|
+
fallbackLeafChunkTokens: number[];
|
|
89
|
+
activityBand: ActivityBand;
|
|
90
|
+
allowCondensedPasses: boolean;
|
|
91
|
+
};
|
|
92
|
+
type DynamicLeafChunkBounds = {
|
|
93
|
+
floor: number;
|
|
94
|
+
medium: number;
|
|
95
|
+
high: number;
|
|
96
|
+
max: number;
|
|
97
|
+
};
|
|
66
98
|
type TranscriptRewriteReplacement = {
|
|
67
99
|
entryId: string;
|
|
68
100
|
message: AgentMessage;
|
|
@@ -83,14 +115,16 @@ type ContextEngineMaintenanceRuntimeContext = Record<string, unknown> & {
|
|
|
83
115
|
};
|
|
84
116
|
|
|
85
117
|
const TRANSCRIPT_GC_BATCH_SIZE = 12;
|
|
118
|
+
const HOT_CACHE_HYSTERESIS_TURNS = 2;
|
|
119
|
+
const DYNAMIC_LEAF_CHUNK_MEDIUM_MULTIPLIER = 1.5;
|
|
120
|
+
const DYNAMIC_LEAF_CHUNK_HIGH_MULTIPLIER = 2;
|
|
121
|
+
const DYNAMIC_ACTIVITY_MEDIUM_UPSHIFT_FACTOR = 0.5;
|
|
122
|
+
const DYNAMIC_ACTIVITY_MEDIUM_DOWNSHIFT_FACTOR = 0.35;
|
|
123
|
+
const DYNAMIC_ACTIVITY_HIGH_UPSHIFT_FACTOR = 1.0;
|
|
124
|
+
const DYNAMIC_ACTIVITY_HIGH_DOWNSHIFT_FACTOR = 0.75;
|
|
86
125
|
|
|
87
126
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
88
127
|
|
|
89
|
-
/** Rough token estimate: ~4 chars per token. */
|
|
90
|
-
function estimateTokens(text: string): number {
|
|
91
|
-
return Math.ceil(text.length / 4);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
128
|
function toJson(value: unknown): string {
|
|
95
129
|
const encoded = JSON.stringify(value);
|
|
96
130
|
return typeof encoded === "string" ? encoded : "";
|
|
@@ -100,6 +134,10 @@ function safeString(value: unknown): string | undefined {
|
|
|
100
134
|
return typeof value === "string" ? value : undefined;
|
|
101
135
|
}
|
|
102
136
|
|
|
137
|
+
function formatDurationMs(durationMs: number): string {
|
|
138
|
+
return `${durationMs}ms`;
|
|
139
|
+
}
|
|
140
|
+
|
|
103
141
|
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
104
142
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
105
143
|
? (value as Record<string, unknown>)
|
|
@@ -835,12 +873,25 @@ function isBootstrapMessage(value: unknown): value is AgentMessage {
|
|
|
835
873
|
return "content" in msg || ("command" in msg && "output" in msg);
|
|
836
874
|
}
|
|
837
875
|
|
|
876
|
+
function extractCanonicalBootstrapMessage(value: unknown): AgentMessage | null {
|
|
877
|
+
if (isBootstrapMessage(value)) {
|
|
878
|
+
return value;
|
|
879
|
+
}
|
|
880
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
881
|
+
return null;
|
|
882
|
+
}
|
|
883
|
+
const entry = value as { type?: unknown; message?: unknown };
|
|
884
|
+
if ("message" in entry) {
|
|
885
|
+
if (entry.type !== undefined && entry.type !== "message") {
|
|
886
|
+
return null;
|
|
887
|
+
}
|
|
888
|
+
return isBootstrapMessage(entry.message) ? entry.message : null;
|
|
889
|
+
}
|
|
890
|
+
return null;
|
|
891
|
+
}
|
|
892
|
+
|
|
838
893
|
function extractBootstrapMessageCandidate(value: unknown): AgentMessage | null {
|
|
839
|
-
|
|
840
|
-
value && typeof value === "object" && "message" in value
|
|
841
|
-
? (value as { message?: unknown }).message
|
|
842
|
-
: value;
|
|
843
|
-
return isBootstrapMessage(candidate) ? candidate : null;
|
|
894
|
+
return extractCanonicalBootstrapMessage(value);
|
|
844
895
|
}
|
|
845
896
|
|
|
846
897
|
function parseBootstrapJsonl(raw: string, options?: {
|
|
@@ -863,9 +914,6 @@ function parseBootstrapJsonl(raw: string, options?: {
|
|
|
863
914
|
messages.push(candidate);
|
|
864
915
|
continue;
|
|
865
916
|
}
|
|
866
|
-
if (options?.strict) {
|
|
867
|
-
hadMalformedLine = true;
|
|
868
|
-
}
|
|
869
917
|
} catch {
|
|
870
918
|
if (options?.strict) {
|
|
871
919
|
hadMalformedLine = true;
|
|
@@ -1018,7 +1066,12 @@ function readFileSegment(sessionFile: string, offset: number): string | null {
|
|
|
1018
1066
|
}
|
|
1019
1067
|
}
|
|
1020
1068
|
|
|
1021
|
-
function readLastJsonlEntryBeforeOffset(
|
|
1069
|
+
function readLastJsonlEntryBeforeOffset(
|
|
1070
|
+
sessionFile: string,
|
|
1071
|
+
offset: number,
|
|
1072
|
+
messageOnly = false,
|
|
1073
|
+
matcher?: (message: AgentMessage) => boolean,
|
|
1074
|
+
): string | null {
|
|
1022
1075
|
const chunkSize = 16_384;
|
|
1023
1076
|
let fd: number | null = null;
|
|
1024
1077
|
try {
|
|
@@ -1056,11 +1109,11 @@ function readLastJsonlEntryBeforeOffset(sessionFile: string, offset: number, mes
|
|
|
1056
1109
|
const candidate = trimmedEnd.slice(newlineIndex + 1).trim();
|
|
1057
1110
|
if (candidate) {
|
|
1058
1111
|
if (messageOnly) {
|
|
1059
|
-
let
|
|
1112
|
+
let matchedMessage: AgentMessage | null = null;
|
|
1060
1113
|
try {
|
|
1061
|
-
|
|
1114
|
+
matchedMessage = extractBootstrapMessageCandidate(JSON.parse(candidate));
|
|
1062
1115
|
} catch { /* not valid JSON, skip */ }
|
|
1063
|
-
if (!
|
|
1116
|
+
if (!matchedMessage || (matcher && !matcher(matchedMessage))) {
|
|
1064
1117
|
carry = trimmedEnd.slice(0, newlineIndex);
|
|
1065
1118
|
continue;
|
|
1066
1119
|
}
|
|
@@ -1075,11 +1128,11 @@ function readLastJsonlEntryBeforeOffset(sessionFile: string, offset: number, mes
|
|
|
1075
1128
|
if (reachedStart) {
|
|
1076
1129
|
const firstLine = trimmedEnd.trim() || null;
|
|
1077
1130
|
if (firstLine && messageOnly) {
|
|
1078
|
-
let
|
|
1131
|
+
let matchedMessage: AgentMessage | null = null;
|
|
1079
1132
|
try {
|
|
1080
|
-
|
|
1133
|
+
matchedMessage = extractBootstrapMessageCandidate(JSON.parse(firstLine));
|
|
1081
1134
|
} catch { /* not valid JSON */ }
|
|
1082
|
-
if (!
|
|
1135
|
+
if (!matchedMessage || (matcher && !matcher(matchedMessage))) return null;
|
|
1083
1136
|
}
|
|
1084
1137
|
return firstLine;
|
|
1085
1138
|
}
|
|
@@ -1155,6 +1208,7 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1155
1208
|
|
|
1156
1209
|
private conversationStore: ConversationStore;
|
|
1157
1210
|
private summaryStore: SummaryStore;
|
|
1211
|
+
private compactionTelemetryStore: CompactionTelemetryStore;
|
|
1158
1212
|
private assembler: ContextAssembler;
|
|
1159
1213
|
private compaction: CompactionEngine;
|
|
1160
1214
|
private retrieval: RetrievalEngine;
|
|
@@ -1181,13 +1235,14 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1181
1235
|
this.statelessSessionPatterns = compileSessionPatterns(this.config.statelessSessionPatterns);
|
|
1182
1236
|
this.db = database;
|
|
1183
1237
|
|
|
1184
|
-
this.fts5Available = getLcmDbFeatures(this.db).fts5Available;
|
|
1185
|
-
|
|
1186
1238
|
// Run migrations eagerly at construction time so the schema exists
|
|
1187
1239
|
// before any lifecycle hook fires.
|
|
1188
1240
|
let migrationOk = false;
|
|
1241
|
+
const migrationStartedAt = Date.now();
|
|
1189
1242
|
try {
|
|
1190
|
-
runLcmMigrations(this.db, {
|
|
1243
|
+
runLcmMigrations(this.db, {
|
|
1244
|
+
log: this.deps.log,
|
|
1245
|
+
});
|
|
1191
1246
|
this.migrated = true;
|
|
1192
1247
|
|
|
1193
1248
|
// Verify tables were actually created
|
|
@@ -1200,16 +1255,21 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1200
1255
|
);
|
|
1201
1256
|
} else {
|
|
1202
1257
|
migrationOk = true;
|
|
1258
|
+
this.deps.log.info(
|
|
1259
|
+
`[lcm] Migration run completed during engine init: duration=${formatDurationMs(Date.now() - migrationStartedAt)} fts5=${this.fts5Available}`,
|
|
1260
|
+
);
|
|
1203
1261
|
this.deps.log.debug(
|
|
1204
1262
|
`[lcm] Migration successful — ${tables.length} tables: ${tables.map((t) => t.name).join(", ")}`,
|
|
1205
1263
|
);
|
|
1206
1264
|
}
|
|
1207
1265
|
} catch (err) {
|
|
1208
1266
|
this.deps.log.error(
|
|
1209
|
-
`[lcm] Migration failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1267
|
+
`[lcm] Migration failed after ${formatDurationMs(Date.now() - migrationStartedAt)}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1210
1268
|
);
|
|
1211
1269
|
}
|
|
1212
1270
|
|
|
1271
|
+
this.fts5Available = getLcmDbFeatures(this.db).fts5Available;
|
|
1272
|
+
|
|
1213
1273
|
// Only claim ownership of compaction when the DB is operational.
|
|
1214
1274
|
// Without a working schema, ownsCompaction would disable the runtime's
|
|
1215
1275
|
// built-in compaction safeguard and inflate the context budget.
|
|
@@ -1224,6 +1284,7 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1224
1284
|
fts5Available: this.fts5Available,
|
|
1225
1285
|
});
|
|
1226
1286
|
this.summaryStore = new SummaryStore(this.db, { fts5Available: this.fts5Available });
|
|
1287
|
+
this.compactionTelemetryStore = new CompactionTelemetryStore(this.db);
|
|
1227
1288
|
|
|
1228
1289
|
if (!this.fts5Available) {
|
|
1229
1290
|
this.deps.log.warn(
|
|
@@ -1269,6 +1330,7 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1269
1330
|
this.conversationStore,
|
|
1270
1331
|
this.summaryStore,
|
|
1271
1332
|
compactionConfig,
|
|
1333
|
+
this.deps.log,
|
|
1272
1334
|
);
|
|
1273
1335
|
|
|
1274
1336
|
this.retrieval = new RetrievalEngine(this.conversationStore, this.summaryStore);
|
|
@@ -1335,10 +1397,17 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1335
1397
|
private recordCompactionAuthFailure(key: string): void {
|
|
1336
1398
|
const state = this.getCircuitBreakerState(key);
|
|
1337
1399
|
state.failures++;
|
|
1400
|
+
const halfThreshold = Math.ceil(this.config.circuitBreakerThreshold / 2);
|
|
1401
|
+
if (state.failures === halfThreshold && state.failures < this.config.circuitBreakerThreshold) {
|
|
1402
|
+
this.deps.log.warn(
|
|
1403
|
+
`[lcm] WARNING: compaction degraded — ${state.failures}/${this.config.circuitBreakerThreshold} consecutive auth failures for ${key}`,
|
|
1404
|
+
);
|
|
1405
|
+
}
|
|
1338
1406
|
if (state.failures >= this.config.circuitBreakerThreshold) {
|
|
1339
1407
|
state.openSince = Date.now();
|
|
1340
|
-
|
|
1341
|
-
|
|
1408
|
+
const cooldownMin = Math.round(this.config.circuitBreakerCooldownMs / 60000);
|
|
1409
|
+
this.deps.log.warn(
|
|
1410
|
+
`[lcm] CIRCUIT BREAKER OPEN: compaction disabled for ${key}. Auto-retry in ${cooldownMin}m. LCM is operating in degraded mode.`,
|
|
1342
1411
|
);
|
|
1343
1412
|
}
|
|
1344
1413
|
}
|
|
@@ -1349,7 +1418,7 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1349
1418
|
return;
|
|
1350
1419
|
}
|
|
1351
1420
|
if (state.failures > 0 || state.openSince !== null) {
|
|
1352
|
-
|
|
1421
|
+
this.deps.log.info(
|
|
1353
1422
|
`[lcm] compaction circuit breaker CLOSED: successful compaction for ${key} after ${state.failures} prior failures.`,
|
|
1354
1423
|
);
|
|
1355
1424
|
}
|
|
@@ -1365,17 +1434,29 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1365
1434
|
if (this.migrated) {
|
|
1366
1435
|
return;
|
|
1367
1436
|
}
|
|
1368
|
-
|
|
1437
|
+
const migrationStartedAt = Date.now();
|
|
1438
|
+
this.deps.log.info("[lcm] ensureMigrated: running migrations lazily");
|
|
1439
|
+
runLcmMigrations(this.db, {
|
|
1440
|
+
log: this.deps.log,
|
|
1441
|
+
});
|
|
1369
1442
|
this.migrated = true;
|
|
1443
|
+
this.deps.log.info(
|
|
1444
|
+
`[lcm] ensureMigrated: completed in ${formatDurationMs(Date.now() - migrationStartedAt)}`,
|
|
1445
|
+
);
|
|
1370
1446
|
}
|
|
1371
1447
|
|
|
1372
1448
|
/**
|
|
1373
1449
|
* Serialize mutating operations per stable session identity to prevent
|
|
1374
1450
|
* ingest/compaction races across runtime UUID recycling.
|
|
1375
1451
|
*/
|
|
1376
|
-
private async withSessionQueue<T>(
|
|
1452
|
+
private async withSessionQueue<T>(
|
|
1453
|
+
queueKey: string,
|
|
1454
|
+
operation: () => Promise<T>,
|
|
1455
|
+
options?: { operationName?: string; context?: string },
|
|
1456
|
+
): Promise<T> {
|
|
1377
1457
|
const entry = this.sessionOperationQueues.get(queueKey);
|
|
1378
1458
|
const previous = entry?.promise ?? Promise.resolve();
|
|
1459
|
+
const queuedAhead = entry?.refCount ?? 0;
|
|
1379
1460
|
let releaseQueue: () => void = () => {};
|
|
1380
1461
|
const current = new Promise<void>((resolve) => {
|
|
1381
1462
|
releaseQueue = resolve;
|
|
@@ -1389,7 +1470,15 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1389
1470
|
this.sessionOperationQueues.set(queueKey, { promise: next, refCount: 1 });
|
|
1390
1471
|
}
|
|
1391
1472
|
|
|
1473
|
+
const waitStartedAt = Date.now();
|
|
1392
1474
|
await previous.catch(() => {});
|
|
1475
|
+
const waitMs = Date.now() - waitStartedAt;
|
|
1476
|
+
if (options?.operationName) {
|
|
1477
|
+
const detail = options.context ? ` ${options.context}` : "";
|
|
1478
|
+
this.deps.log.info(
|
|
1479
|
+
`[lcm] ${options.operationName}: session queue acquired queueKey=${queueKey} queuedAhead=${queuedAhead} wait=${formatDurationMs(waitMs)}${detail}`,
|
|
1480
|
+
);
|
|
1481
|
+
}
|
|
1393
1482
|
try {
|
|
1394
1483
|
return await operation();
|
|
1395
1484
|
} finally {
|
|
@@ -1446,6 +1535,486 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1446
1535
|
return cap != null && cap > 0 ? Math.min(budget, cap) : budget;
|
|
1447
1536
|
}
|
|
1448
1537
|
|
|
1538
|
+
/** Normalize token counters that may legitimately be zero. */
|
|
1539
|
+
private normalizeOptionalCount(value: unknown): number | undefined {
|
|
1540
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
1541
|
+
return undefined;
|
|
1542
|
+
}
|
|
1543
|
+
return Math.floor(value);
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
/** Treat a recent cache hit as still-hot for a couple of turns unless telemetry observed a later break. */
|
|
1547
|
+
private shouldApplyHotCacheHysteresis(
|
|
1548
|
+
telemetry: ConversationCompactionTelemetryRecord | null,
|
|
1549
|
+
): boolean {
|
|
1550
|
+
if (!telemetry?.lastObservedCacheHitAt) {
|
|
1551
|
+
return false;
|
|
1552
|
+
}
|
|
1553
|
+
if (
|
|
1554
|
+
telemetry.lastObservedCacheBreakAt
|
|
1555
|
+
&& telemetry.lastObservedCacheBreakAt >= telemetry.lastObservedCacheHitAt
|
|
1556
|
+
) {
|
|
1557
|
+
return false;
|
|
1558
|
+
}
|
|
1559
|
+
return telemetry.turnsSinceLeafCompaction <= HOT_CACHE_HYSTERESIS_TURNS;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
/** Resolve the effective cache state the incremental compaction policy should react to. */
|
|
1563
|
+
private resolveCacheAwareState(
|
|
1564
|
+
telemetry: ConversationCompactionTelemetryRecord | null,
|
|
1565
|
+
): CacheState {
|
|
1566
|
+
if (!telemetry) {
|
|
1567
|
+
return "unknown";
|
|
1568
|
+
}
|
|
1569
|
+
if (telemetry.cacheState === "hot") {
|
|
1570
|
+
return "hot";
|
|
1571
|
+
}
|
|
1572
|
+
if (this.shouldApplyHotCacheHysteresis(telemetry)) {
|
|
1573
|
+
return "hot";
|
|
1574
|
+
}
|
|
1575
|
+
return telemetry.cacheState;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
/** Decide whether a hot cache still has enough real token-budget headroom to skip incremental maintenance. */
|
|
1579
|
+
private isComfortablyUnderTokenBudget(params: {
|
|
1580
|
+
currentTokenCount?: number;
|
|
1581
|
+
tokenBudget: number;
|
|
1582
|
+
}): boolean {
|
|
1583
|
+
if (
|
|
1584
|
+
typeof params.currentTokenCount !== "number"
|
|
1585
|
+
|| !Number.isFinite(params.currentTokenCount)
|
|
1586
|
+
|| params.currentTokenCount < 0
|
|
1587
|
+
) {
|
|
1588
|
+
return false;
|
|
1589
|
+
}
|
|
1590
|
+
const budget = Math.max(1, Math.floor(params.tokenBudget));
|
|
1591
|
+
const safeBudget = Math.floor(
|
|
1592
|
+
budget * (1 - this.config.cacheAwareCompaction.hotCacheBudgetHeadroomRatio),
|
|
1593
|
+
);
|
|
1594
|
+
return params.currentTokenCount <= safeBudget;
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
/** Resolve bounded dynamic leaf chunk sizes from config and the active token budget. */
|
|
1598
|
+
private resolveDynamicLeafChunkBounds(tokenBudget?: number): DynamicLeafChunkBounds {
|
|
1599
|
+
const floor = Math.max(1, Math.floor(this.config.leafChunkTokens));
|
|
1600
|
+
const configuredMax = this.config.dynamicLeafChunkTokens.enabled
|
|
1601
|
+
? Math.max(floor, Math.floor(this.config.dynamicLeafChunkTokens.max))
|
|
1602
|
+
: floor;
|
|
1603
|
+
const budgetCap =
|
|
1604
|
+
typeof tokenBudget === "number" &&
|
|
1605
|
+
Number.isFinite(tokenBudget) &&
|
|
1606
|
+
tokenBudget > 0
|
|
1607
|
+
? Math.max(floor, Math.floor(tokenBudget * this.config.contextThreshold))
|
|
1608
|
+
: configuredMax;
|
|
1609
|
+
const max = Math.max(floor, Math.min(configuredMax, budgetCap));
|
|
1610
|
+
const medium = Math.max(
|
|
1611
|
+
floor,
|
|
1612
|
+
Math.min(max, Math.floor(floor * DYNAMIC_LEAF_CHUNK_MEDIUM_MULTIPLIER)),
|
|
1613
|
+
);
|
|
1614
|
+
const high = Math.max(
|
|
1615
|
+
floor,
|
|
1616
|
+
Math.min(max, Math.floor(floor * DYNAMIC_LEAF_CHUNK_HIGH_MULTIPLIER)),
|
|
1617
|
+
);
|
|
1618
|
+
return { floor, medium, high, max };
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
/** Classify the current refill rate into a simple step band with downshift hysteresis. */
|
|
1622
|
+
private classifyDynamicLeafActivityBand(params: {
|
|
1623
|
+
lastActivityBand?: ActivityBand;
|
|
1624
|
+
tokensAccumulatedSinceLeafCompaction: number;
|
|
1625
|
+
turnsSinceLeafCompaction: number;
|
|
1626
|
+
floor: number;
|
|
1627
|
+
}): ActivityBand {
|
|
1628
|
+
const turns = Math.max(1, params.turnsSinceLeafCompaction);
|
|
1629
|
+
const tokensPerTurn = params.tokensAccumulatedSinceLeafCompaction / turns;
|
|
1630
|
+
const mediumUpshift = params.floor * DYNAMIC_ACTIVITY_MEDIUM_UPSHIFT_FACTOR;
|
|
1631
|
+
const mediumDownshift = params.floor * DYNAMIC_ACTIVITY_MEDIUM_DOWNSHIFT_FACTOR;
|
|
1632
|
+
const highUpshift = params.floor * DYNAMIC_ACTIVITY_HIGH_UPSHIFT_FACTOR;
|
|
1633
|
+
const highDownshift = params.floor * DYNAMIC_ACTIVITY_HIGH_DOWNSHIFT_FACTOR;
|
|
1634
|
+
const lastBand = params.lastActivityBand ?? "low";
|
|
1635
|
+
|
|
1636
|
+
if (lastBand === "high") {
|
|
1637
|
+
if (tokensPerTurn >= highDownshift) {
|
|
1638
|
+
return "high";
|
|
1639
|
+
}
|
|
1640
|
+
return tokensPerTurn >= mediumDownshift ? "medium" : "low";
|
|
1641
|
+
}
|
|
1642
|
+
if (lastBand === "medium") {
|
|
1643
|
+
if (tokensPerTurn >= highUpshift) {
|
|
1644
|
+
return "high";
|
|
1645
|
+
}
|
|
1646
|
+
if (tokensPerTurn < mediumDownshift) {
|
|
1647
|
+
return "low";
|
|
1648
|
+
}
|
|
1649
|
+
return "medium";
|
|
1650
|
+
}
|
|
1651
|
+
if (tokensPerTurn >= highUpshift) {
|
|
1652
|
+
return "high";
|
|
1653
|
+
}
|
|
1654
|
+
if (tokensPerTurn >= mediumUpshift) {
|
|
1655
|
+
return "medium";
|
|
1656
|
+
}
|
|
1657
|
+
return "low";
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
/** Map an activity band to the corresponding working leaf chunk size. */
|
|
1661
|
+
private resolveLeafChunkTokensForBand(
|
|
1662
|
+
band: ActivityBand,
|
|
1663
|
+
bounds: DynamicLeafChunkBounds,
|
|
1664
|
+
): number {
|
|
1665
|
+
switch (band) {
|
|
1666
|
+
case "high":
|
|
1667
|
+
return bounds.high;
|
|
1668
|
+
case "medium":
|
|
1669
|
+
return bounds.medium;
|
|
1670
|
+
default:
|
|
1671
|
+
return bounds.floor;
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
/** Build descending fallback chunk sizes used when a provider rejects a larger chunk. */
|
|
1676
|
+
private buildLeafChunkFallbacks(params: {
|
|
1677
|
+
preferred: number;
|
|
1678
|
+
bounds: DynamicLeafChunkBounds;
|
|
1679
|
+
}): number[] {
|
|
1680
|
+
const ordered = [params.preferred, params.bounds.max, params.bounds.high, params.bounds.medium, params.bounds.floor];
|
|
1681
|
+
const seen = new Set<number>();
|
|
1682
|
+
const fallbacks: number[] = [];
|
|
1683
|
+
for (const value of ordered) {
|
|
1684
|
+
const normalized = Math.max(params.bounds.floor, Math.floor(value));
|
|
1685
|
+
if (seen.has(normalized)) {
|
|
1686
|
+
continue;
|
|
1687
|
+
}
|
|
1688
|
+
seen.add(normalized);
|
|
1689
|
+
fallbacks.push(normalized);
|
|
1690
|
+
}
|
|
1691
|
+
return fallbacks.sort((a, b) => b - a);
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
/** Detect provider/model token-limit failures that should trigger a lower chunk retry. */
|
|
1695
|
+
private isRecoverableLeafChunkOverflowError(error: unknown): boolean {
|
|
1696
|
+
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
1697
|
+
if (!message) {
|
|
1698
|
+
return false;
|
|
1699
|
+
}
|
|
1700
|
+
return [
|
|
1701
|
+
"context length",
|
|
1702
|
+
"context window",
|
|
1703
|
+
"maximum context",
|
|
1704
|
+
"max context",
|
|
1705
|
+
"too many tokens",
|
|
1706
|
+
"too many input tokens",
|
|
1707
|
+
"input tokens",
|
|
1708
|
+
"token limit",
|
|
1709
|
+
"context limit",
|
|
1710
|
+
"input is too large",
|
|
1711
|
+
"input too large",
|
|
1712
|
+
"prompt is too long",
|
|
1713
|
+
"request too large",
|
|
1714
|
+
"exceeds the model",
|
|
1715
|
+
"exceeds context",
|
|
1716
|
+
].some((fragment) => message.includes(fragment));
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
/** Extract the current prompt-cache snapshot from runtime context, if present. */
|
|
1720
|
+
private readPromptCacheSnapshot(runtimeContext?: Record<string, unknown>): PromptCacheSnapshot | null {
|
|
1721
|
+
const promptCache = asRecord(runtimeContext?.promptCache);
|
|
1722
|
+
if (!promptCache) {
|
|
1723
|
+
return null;
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
const lastCallUsage = asRecord(promptCache.lastCallUsage);
|
|
1727
|
+
const observation = asRecord(promptCache.observation);
|
|
1728
|
+
const cacheRead = this.normalizeOptionalCount(lastCallUsage?.cacheRead);
|
|
1729
|
+
const cacheWrite = this.normalizeOptionalCount(lastCallUsage?.cacheWrite);
|
|
1730
|
+
const sawExplicitBreak = safeBoolean(observation?.broke) === true;
|
|
1731
|
+
const retention = safeString(promptCache.retention)?.trim();
|
|
1732
|
+
const hasUsageSignal = cacheRead !== undefined || cacheWrite !== undefined;
|
|
1733
|
+
const hasObservationSignal =
|
|
1734
|
+
typeof observation?.cacheRead === "number"
|
|
1735
|
+
|| typeof observation?.previousCacheRead === "number"
|
|
1736
|
+
|| sawExplicitBreak;
|
|
1737
|
+
|
|
1738
|
+
let cacheState: CacheState = "unknown";
|
|
1739
|
+
if (sawExplicitBreak) {
|
|
1740
|
+
cacheState = "cold";
|
|
1741
|
+
} else if (typeof cacheRead === "number" && cacheRead > 0) {
|
|
1742
|
+
cacheState = "hot";
|
|
1743
|
+
} else if (hasUsageSignal || hasObservationSignal) {
|
|
1744
|
+
cacheState = "cold";
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
return {
|
|
1748
|
+
...(cacheRead !== undefined ? { lastObservedCacheRead: cacheRead } : {}),
|
|
1749
|
+
...(cacheWrite !== undefined ? { lastObservedCacheWrite: cacheWrite } : {}),
|
|
1750
|
+
cacheState,
|
|
1751
|
+
...(retention ? { retention } : {}),
|
|
1752
|
+
sawExplicitBreak,
|
|
1753
|
+
};
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
/** Persist the current turn's compaction telemetry for later policy decisions. */
|
|
1757
|
+
private async updateCompactionTelemetry(params: {
|
|
1758
|
+
conversationId: number;
|
|
1759
|
+
runtimeContext?: Record<string, unknown>;
|
|
1760
|
+
tokenBudget?: number;
|
|
1761
|
+
rawTokensOutsideTail?: number;
|
|
1762
|
+
}): Promise<ConversationCompactionTelemetryRecord | null> {
|
|
1763
|
+
const snapshot = this.readPromptCacheSnapshot(params.runtimeContext);
|
|
1764
|
+
const existing = await this.compactionTelemetryStore.getConversationCompactionTelemetry(
|
|
1765
|
+
params.conversationId,
|
|
1766
|
+
);
|
|
1767
|
+
if (!snapshot && params.rawTokensOutsideTail === undefined) {
|
|
1768
|
+
return existing;
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
const now = new Date();
|
|
1772
|
+
const bounds = this.resolveDynamicLeafChunkBounds(params.tokenBudget);
|
|
1773
|
+
const turnsSinceLeafCompaction =
|
|
1774
|
+
(existing?.turnsSinceLeafCompaction ?? 0) + 1;
|
|
1775
|
+
const tokensAccumulatedSinceLeafCompaction =
|
|
1776
|
+
params.rawTokensOutsideTail ?? existing?.tokensAccumulatedSinceLeafCompaction ?? 0;
|
|
1777
|
+
const lastActivityBand = this.classifyDynamicLeafActivityBand({
|
|
1778
|
+
lastActivityBand: existing?.lastActivityBand,
|
|
1779
|
+
tokensAccumulatedSinceLeafCompaction,
|
|
1780
|
+
turnsSinceLeafCompaction,
|
|
1781
|
+
floor: bounds.floor,
|
|
1782
|
+
});
|
|
1783
|
+
await this.compactionTelemetryStore.upsertConversationCompactionTelemetry({
|
|
1784
|
+
conversationId: params.conversationId,
|
|
1785
|
+
lastObservedCacheRead: snapshot?.lastObservedCacheRead ?? existing?.lastObservedCacheRead ?? null,
|
|
1786
|
+
lastObservedCacheWrite:
|
|
1787
|
+
snapshot?.lastObservedCacheWrite ?? existing?.lastObservedCacheWrite ?? null,
|
|
1788
|
+
lastObservedCacheHitAt:
|
|
1789
|
+
snapshot?.cacheState === "hot"
|
|
1790
|
+
? now
|
|
1791
|
+
: existing?.lastObservedCacheHitAt ?? null,
|
|
1792
|
+
lastObservedCacheBreakAt:
|
|
1793
|
+
snapshot?.sawExplicitBreak
|
|
1794
|
+
? now
|
|
1795
|
+
: existing?.lastObservedCacheBreakAt ?? null,
|
|
1796
|
+
cacheState: snapshot?.cacheState ?? existing?.cacheState ?? "unknown",
|
|
1797
|
+
retention: snapshot?.retention ?? existing?.retention ?? null,
|
|
1798
|
+
lastLeafCompactionAt: existing?.lastLeafCompactionAt ?? null,
|
|
1799
|
+
turnsSinceLeafCompaction,
|
|
1800
|
+
tokensAccumulatedSinceLeafCompaction,
|
|
1801
|
+
lastActivityBand,
|
|
1802
|
+
});
|
|
1803
|
+
const updated = await this.compactionTelemetryStore.getConversationCompactionTelemetry(
|
|
1804
|
+
params.conversationId,
|
|
1805
|
+
);
|
|
1806
|
+
if (updated) {
|
|
1807
|
+
this.deps.log.debug(
|
|
1808
|
+
`[lcm] compaction telemetry updated: conversation=${params.conversationId} cacheState=${updated.cacheState} cacheRead=${updated.lastObservedCacheRead ?? "null"} cacheWrite=${updated.lastObservedCacheWrite ?? "null"} retention=${updated.retention ?? "null"} turnsSinceLeafCompaction=${updated.turnsSinceLeafCompaction} tokensSinceLeafCompaction=${updated.tokensAccumulatedSinceLeafCompaction} activityBand=${updated.lastActivityBand} rawTokensOutsideTail=${params.rawTokensOutsideTail ?? "null"} tokenBudget=${params.tokenBudget ?? "null"}`,
|
|
1809
|
+
);
|
|
1810
|
+
}
|
|
1811
|
+
return updated;
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
/** Reset refill counters after any successful leaf-producing compaction. */
|
|
1815
|
+
private async markLeafCompactionTelemetrySuccess(params: {
|
|
1816
|
+
conversationId: number;
|
|
1817
|
+
activityBand?: ActivityBand;
|
|
1818
|
+
}): Promise<void> {
|
|
1819
|
+
const existing = await this.compactionTelemetryStore.getConversationCompactionTelemetry(
|
|
1820
|
+
params.conversationId,
|
|
1821
|
+
);
|
|
1822
|
+
await this.compactionTelemetryStore.upsertConversationCompactionTelemetry({
|
|
1823
|
+
conversationId: params.conversationId,
|
|
1824
|
+
lastObservedCacheRead: existing?.lastObservedCacheRead ?? null,
|
|
1825
|
+
lastObservedCacheWrite: existing?.lastObservedCacheWrite ?? null,
|
|
1826
|
+
lastObservedCacheHitAt: existing?.lastObservedCacheHitAt ?? null,
|
|
1827
|
+
lastObservedCacheBreakAt: existing?.lastObservedCacheBreakAt ?? null,
|
|
1828
|
+
cacheState: existing?.cacheState ?? "unknown",
|
|
1829
|
+
retention: existing?.retention ?? null,
|
|
1830
|
+
lastLeafCompactionAt: new Date(),
|
|
1831
|
+
turnsSinceLeafCompaction: 0,
|
|
1832
|
+
tokensAccumulatedSinceLeafCompaction: 0,
|
|
1833
|
+
lastActivityBand: params.activityBand ?? existing?.lastActivityBand ?? "low",
|
|
1834
|
+
});
|
|
1835
|
+
this.deps.log.debug(
|
|
1836
|
+
`[lcm] compaction telemetry reset after leaf compaction: conversation=${params.conversationId} cacheState=${existing?.cacheState ?? "unknown"} activityBand=${params.activityBand ?? existing?.lastActivityBand ?? "low"}`,
|
|
1837
|
+
);
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
/** Emit an operational trace for the incremental compaction policy decision. */
|
|
1841
|
+
private logIncrementalCompactionDecision(params: {
|
|
1842
|
+
conversationId: number;
|
|
1843
|
+
cacheState: CacheState;
|
|
1844
|
+
activityBand: ActivityBand;
|
|
1845
|
+
triggerLeafChunkTokens: number;
|
|
1846
|
+
preferredLeafChunkTokens: number;
|
|
1847
|
+
fallbackLeafChunkTokens: number[];
|
|
1848
|
+
rawTokensOutsideTail: number;
|
|
1849
|
+
threshold: number;
|
|
1850
|
+
shouldCompact: boolean;
|
|
1851
|
+
maxPasses: number;
|
|
1852
|
+
allowCondensedPasses: boolean;
|
|
1853
|
+
reason: string;
|
|
1854
|
+
}): IncrementalCompactionDecision {
|
|
1855
|
+
this.deps.log.info(
|
|
1856
|
+
`[lcm] incremental compaction decision: conversation=${params.conversationId} cacheState=${params.cacheState} activityBand=${params.activityBand} triggerLeafChunkTokens=${params.triggerLeafChunkTokens} preferredLeafChunkTokens=${params.preferredLeafChunkTokens} fallbackLeafChunkTokens=${params.fallbackLeafChunkTokens.join(",")} rawTokensOutsideTail=${params.rawTokensOutsideTail} threshold=${params.threshold} shouldCompact=${params.shouldCompact} maxPasses=${params.maxPasses} allowCondensedPasses=${params.allowCondensedPasses} reason=${params.reason}`,
|
|
1857
|
+
);
|
|
1858
|
+
return {
|
|
1859
|
+
shouldCompact: params.shouldCompact,
|
|
1860
|
+
cacheState: params.cacheState,
|
|
1861
|
+
maxPasses: params.maxPasses,
|
|
1862
|
+
rawTokensOutsideTail: params.rawTokensOutsideTail,
|
|
1863
|
+
threshold: params.threshold,
|
|
1864
|
+
leafChunkTokens: params.preferredLeafChunkTokens,
|
|
1865
|
+
fallbackLeafChunkTokens: params.fallbackLeafChunkTokens,
|
|
1866
|
+
activityBand: params.activityBand,
|
|
1867
|
+
allowCondensedPasses: params.allowCondensedPasses,
|
|
1868
|
+
};
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
/** Resolve the cache-aware incremental-compaction policy for the current session. */
|
|
1872
|
+
private async evaluateIncrementalCompaction(params: {
|
|
1873
|
+
conversationId: number;
|
|
1874
|
+
tokenBudget: number;
|
|
1875
|
+
currentTokenCount?: number;
|
|
1876
|
+
}): Promise<IncrementalCompactionDecision> {
|
|
1877
|
+
const telemetry = await this.compactionTelemetryStore.getConversationCompactionTelemetry(
|
|
1878
|
+
params.conversationId,
|
|
1879
|
+
);
|
|
1880
|
+
const cacheState =
|
|
1881
|
+
this.config.cacheAwareCompaction.enabled
|
|
1882
|
+
? this.resolveCacheAwareState(telemetry)
|
|
1883
|
+
: "unknown";
|
|
1884
|
+
const bounds = this.resolveDynamicLeafChunkBounds(params.tokenBudget);
|
|
1885
|
+
const activityBand =
|
|
1886
|
+
this.config.dynamicLeafChunkTokens.enabled
|
|
1887
|
+
? this.classifyDynamicLeafActivityBand({
|
|
1888
|
+
lastActivityBand: telemetry?.lastActivityBand,
|
|
1889
|
+
tokensAccumulatedSinceLeafCompaction:
|
|
1890
|
+
telemetry?.tokensAccumulatedSinceLeafCompaction ?? 0,
|
|
1891
|
+
turnsSinceLeafCompaction: telemetry?.turnsSinceLeafCompaction ?? 0,
|
|
1892
|
+
floor: bounds.floor,
|
|
1893
|
+
})
|
|
1894
|
+
: "low";
|
|
1895
|
+
const triggerLeafChunkTokens =
|
|
1896
|
+
this.config.dynamicLeafChunkTokens.enabled && cacheState === "hot"
|
|
1897
|
+
? bounds.max
|
|
1898
|
+
: this.config.dynamicLeafChunkTokens.enabled
|
|
1899
|
+
? this.resolveLeafChunkTokensForBand(activityBand, bounds)
|
|
1900
|
+
: bounds.floor;
|
|
1901
|
+
const preferredLeafChunkTokens =
|
|
1902
|
+
this.config.cacheAwareCompaction.enabled && (cacheState === "cold" || cacheState === "hot")
|
|
1903
|
+
? bounds.max
|
|
1904
|
+
: triggerLeafChunkTokens;
|
|
1905
|
+
const fallbackLeafChunkTokens = this.buildLeafChunkFallbacks({
|
|
1906
|
+
preferred: preferredLeafChunkTokens,
|
|
1907
|
+
bounds,
|
|
1908
|
+
});
|
|
1909
|
+
const leafTrigger = await this.compaction.evaluateLeafTrigger(
|
|
1910
|
+
params.conversationId,
|
|
1911
|
+
triggerLeafChunkTokens,
|
|
1912
|
+
);
|
|
1913
|
+
if (!leafTrigger.shouldCompact) {
|
|
1914
|
+
return this.logIncrementalCompactionDecision({
|
|
1915
|
+
conversationId: params.conversationId,
|
|
1916
|
+
cacheState,
|
|
1917
|
+
activityBand,
|
|
1918
|
+
triggerLeafChunkTokens,
|
|
1919
|
+
preferredLeafChunkTokens,
|
|
1920
|
+
fallbackLeafChunkTokens,
|
|
1921
|
+
rawTokensOutsideTail: leafTrigger.rawTokensOutsideTail,
|
|
1922
|
+
threshold: leafTrigger.threshold,
|
|
1923
|
+
shouldCompact: false,
|
|
1924
|
+
maxPasses: 1,
|
|
1925
|
+
allowCondensedPasses: false,
|
|
1926
|
+
reason: "below-leaf-trigger",
|
|
1927
|
+
});
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
const budgetDecision = await this.compaction.evaluate(
|
|
1931
|
+
params.conversationId,
|
|
1932
|
+
params.tokenBudget,
|
|
1933
|
+
params.currentTokenCount,
|
|
1934
|
+
);
|
|
1935
|
+
if (budgetDecision.shouldCompact) {
|
|
1936
|
+
return this.logIncrementalCompactionDecision({
|
|
1937
|
+
conversationId: params.conversationId,
|
|
1938
|
+
cacheState,
|
|
1939
|
+
activityBand,
|
|
1940
|
+
triggerLeafChunkTokens,
|
|
1941
|
+
preferredLeafChunkTokens,
|
|
1942
|
+
fallbackLeafChunkTokens,
|
|
1943
|
+
rawTokensOutsideTail: leafTrigger.rawTokensOutsideTail,
|
|
1944
|
+
threshold: leafTrigger.threshold,
|
|
1945
|
+
shouldCompact: true,
|
|
1946
|
+
maxPasses: 1,
|
|
1947
|
+
allowCondensedPasses: true,
|
|
1948
|
+
reason: "budget-trigger",
|
|
1949
|
+
});
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
if (
|
|
1953
|
+
cacheState === "hot"
|
|
1954
|
+
&& this.isComfortablyUnderTokenBudget({
|
|
1955
|
+
currentTokenCount: params.currentTokenCount,
|
|
1956
|
+
tokenBudget: params.tokenBudget,
|
|
1957
|
+
})
|
|
1958
|
+
) {
|
|
1959
|
+
return this.logIncrementalCompactionDecision({
|
|
1960
|
+
conversationId: params.conversationId,
|
|
1961
|
+
cacheState,
|
|
1962
|
+
activityBand,
|
|
1963
|
+
triggerLeafChunkTokens,
|
|
1964
|
+
preferredLeafChunkTokens,
|
|
1965
|
+
fallbackLeafChunkTokens,
|
|
1966
|
+
rawTokensOutsideTail: leafTrigger.rawTokensOutsideTail,
|
|
1967
|
+
threshold: leafTrigger.threshold,
|
|
1968
|
+
shouldCompact: false,
|
|
1969
|
+
maxPasses: 1,
|
|
1970
|
+
allowCondensedPasses: false,
|
|
1971
|
+
reason: "hot-cache-budget-headroom",
|
|
1972
|
+
});
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
if (
|
|
1976
|
+
cacheState === "hot"
|
|
1977
|
+
&& leafTrigger.rawTokensOutsideTail
|
|
1978
|
+
< Math.floor(
|
|
1979
|
+
leafTrigger.threshold * this.config.cacheAwareCompaction.hotCachePressureFactor,
|
|
1980
|
+
)
|
|
1981
|
+
) {
|
|
1982
|
+
return this.logIncrementalCompactionDecision({
|
|
1983
|
+
conversationId: params.conversationId,
|
|
1984
|
+
cacheState,
|
|
1985
|
+
activityBand,
|
|
1986
|
+
triggerLeafChunkTokens,
|
|
1987
|
+
preferredLeafChunkTokens,
|
|
1988
|
+
fallbackLeafChunkTokens,
|
|
1989
|
+
rawTokensOutsideTail: leafTrigger.rawTokensOutsideTail,
|
|
1990
|
+
threshold: leafTrigger.threshold,
|
|
1991
|
+
shouldCompact: false,
|
|
1992
|
+
maxPasses: 1,
|
|
1993
|
+
allowCondensedPasses: false,
|
|
1994
|
+
reason: "hot-cache-defer",
|
|
1995
|
+
});
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
const maxPasses =
|
|
1999
|
+
cacheState === "cold"
|
|
2000
|
+
? Math.max(1, this.config.cacheAwareCompaction.maxColdCacheCatchupPasses)
|
|
2001
|
+
: 1;
|
|
2002
|
+
return this.logIncrementalCompactionDecision({
|
|
2003
|
+
conversationId: params.conversationId,
|
|
2004
|
+
cacheState,
|
|
2005
|
+
activityBand,
|
|
2006
|
+
triggerLeafChunkTokens,
|
|
2007
|
+
preferredLeafChunkTokens,
|
|
2008
|
+
fallbackLeafChunkTokens,
|
|
2009
|
+
rawTokensOutsideTail: leafTrigger.rawTokensOutsideTail,
|
|
2010
|
+
threshold: leafTrigger.threshold,
|
|
2011
|
+
shouldCompact: true,
|
|
2012
|
+
maxPasses,
|
|
2013
|
+
allowCondensedPasses: cacheState !== "hot",
|
|
2014
|
+
reason: cacheState === "cold" ? "cold-cache-catchup" : "leaf-trigger",
|
|
2015
|
+
});
|
|
2016
|
+
}
|
|
2017
|
+
|
|
1449
2018
|
/** Resolve an LCM conversation id from a session key via the session store. */
|
|
1450
2019
|
private async resolveConversationIdForSessionKey(
|
|
1451
2020
|
sessionKey: string,
|
|
@@ -1475,6 +2044,23 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1475
2044
|
}
|
|
1476
2045
|
}
|
|
1477
2046
|
|
|
2047
|
+
/** Format stable session identifiers for LCM diagnostic logs. */
|
|
2048
|
+
private formatSessionLogContext(params: {
|
|
2049
|
+
conversationId: number;
|
|
2050
|
+
sessionId: string;
|
|
2051
|
+
sessionKey?: string;
|
|
2052
|
+
}): string {
|
|
2053
|
+
const parts = [
|
|
2054
|
+
`conversation=${params.conversationId}`,
|
|
2055
|
+
`session=${params.sessionId}`,
|
|
2056
|
+
];
|
|
2057
|
+
const trimmedSessionKey = params.sessionKey?.trim();
|
|
2058
|
+
if (trimmedSessionKey) {
|
|
2059
|
+
parts.push(`sessionKey=${trimmedSessionKey}`);
|
|
2060
|
+
}
|
|
2061
|
+
return parts.join(" ");
|
|
2062
|
+
}
|
|
2063
|
+
|
|
1478
2064
|
/** Build a summarize callback with runtime provider fallback handling. */
|
|
1479
2065
|
private async resolveSummarize(params: {
|
|
1480
2066
|
legacyParams?: Record<string, unknown>;
|
|
@@ -1510,11 +2096,13 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1510
2096
|
breakerKey: runtimeSummarizer.breakerKey,
|
|
1511
2097
|
};
|
|
1512
2098
|
}
|
|
1513
|
-
|
|
2099
|
+
this.deps.log.error(`[lcm] resolveSummarize: createLcmSummarizeFromLegacyParams returned undefined`);
|
|
1514
2100
|
} catch (err) {
|
|
1515
|
-
|
|
2101
|
+
this.deps.log.error(
|
|
2102
|
+
`[lcm] resolveSummarize failed, using emergency fallback: ${describeLogError(err)}`,
|
|
2103
|
+
);
|
|
1516
2104
|
}
|
|
1517
|
-
|
|
2105
|
+
this.deps.log.error(`[lcm] resolveSummarize: FALLING BACK TO EMERGENCY TRUNCATION`);
|
|
1518
2106
|
return { summarize: createEmergencyFallbackSummarize(), summaryModel: "unknown" };
|
|
1519
2107
|
}
|
|
1520
2108
|
|
|
@@ -1858,12 +2446,24 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1858
2446
|
hasOverlap: boolean;
|
|
1859
2447
|
}> {
|
|
1860
2448
|
const { sessionId, conversationId, historicalMessages } = params;
|
|
2449
|
+
const startedAt = Date.now();
|
|
2450
|
+
const sessionContext = this.formatSessionLogContext({
|
|
2451
|
+
conversationId,
|
|
2452
|
+
sessionId,
|
|
2453
|
+
sessionKey: params.sessionKey,
|
|
2454
|
+
});
|
|
1861
2455
|
if (historicalMessages.length === 0) {
|
|
2456
|
+
this.deps.log.info(
|
|
2457
|
+
`[lcm] reconcileSessionTail: skipped for ${sessionContext} duration=${formatDurationMs(Date.now() - startedAt)} historicalMessages=0 reason=empty-history`,
|
|
2458
|
+
);
|
|
1862
2459
|
return { blockedByImportCap: false, importedMessages: 0, hasOverlap: false };
|
|
1863
2460
|
}
|
|
1864
2461
|
|
|
1865
2462
|
const latestDbMessage = await this.conversationStore.getLastMessage(conversationId);
|
|
1866
2463
|
if (!latestDbMessage) {
|
|
2464
|
+
this.deps.log.info(
|
|
2465
|
+
`[lcm] reconcileSessionTail: skipped for ${sessionContext} duration=${formatDurationMs(Date.now() - startedAt)} historicalMessages=${historicalMessages.length} reason=no-db-tail`,
|
|
2466
|
+
);
|
|
1867
2467
|
return { blockedByImportCap: false, importedMessages: 0, hasOverlap: false };
|
|
1868
2468
|
}
|
|
1869
2469
|
|
|
@@ -1885,6 +2485,9 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1885
2485
|
}
|
|
1886
2486
|
}
|
|
1887
2487
|
if (dbOccurrences === historicalOccurrences) {
|
|
2488
|
+
this.deps.log.info(
|
|
2489
|
+
`[lcm] reconcileSessionTail: fast path for ${sessionContext} duration=${formatDurationMs(Date.now() - startedAt)} historicalMessages=${historicalMessages.length} importedMessages=0 overlap=true`,
|
|
2490
|
+
);
|
|
1888
2491
|
return { blockedByImportCap: false, importedMessages: 0, hasOverlap: true };
|
|
1889
2492
|
}
|
|
1890
2493
|
}
|
|
@@ -1937,9 +2540,15 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1937
2540
|
}
|
|
1938
2541
|
|
|
1939
2542
|
if (anchorIndex < 0) {
|
|
2543
|
+
this.deps.log.info(
|
|
2544
|
+
`[lcm] reconcileSessionTail: no anchor for ${sessionContext} duration=${formatDurationMs(Date.now() - startedAt)} historicalMessages=${historicalMessages.length} importedMessages=0 overlap=false`,
|
|
2545
|
+
);
|
|
1940
2546
|
return { blockedByImportCap: false, importedMessages: 0, hasOverlap: false };
|
|
1941
2547
|
}
|
|
1942
2548
|
if (anchorIndex >= historicalMessages.length - 1) {
|
|
2549
|
+
this.deps.log.info(
|
|
2550
|
+
`[lcm] reconcileSessionTail: anchor at tip for ${sessionContext} duration=${formatDurationMs(Date.now() - startedAt)} historicalMessages=${historicalMessages.length} importedMessages=0 overlap=true`,
|
|
2551
|
+
);
|
|
1943
2552
|
return { blockedByImportCap: false, importedMessages: 0, hasOverlap: true };
|
|
1944
2553
|
}
|
|
1945
2554
|
|
|
@@ -1947,7 +2556,12 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1947
2556
|
|
|
1948
2557
|
const existingDbCount = await this.conversationStore.getMessageCount(conversationId);
|
|
1949
2558
|
if (existingDbCount > 0 && missingTail.length > Math.max(existingDbCount * 0.2, 50)) {
|
|
1950
|
-
|
|
2559
|
+
this.deps.log.warn(
|
|
2560
|
+
`[lcm] reconcileSessionTail: import cap exceeded for ${sessionContext} — would import ${missingTail.length} messages (existing: ${existingDbCount}). Aborting to prevent flood.`,
|
|
2561
|
+
);
|
|
2562
|
+
this.deps.log.info(
|
|
2563
|
+
`[lcm] reconcileSessionTail: blocked for ${sessionContext} duration=${formatDurationMs(Date.now() - startedAt)} historicalMessages=${historicalMessages.length} missingTail=${missingTail.length} existingDbCount=${existingDbCount}`,
|
|
2564
|
+
);
|
|
1951
2565
|
return { blockedByImportCap: true, importedMessages: 0, hasOverlap: true };
|
|
1952
2566
|
}
|
|
1953
2567
|
|
|
@@ -1959,9 +2573,43 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1959
2573
|
}
|
|
1960
2574
|
}
|
|
1961
2575
|
|
|
2576
|
+
this.deps.log.info(
|
|
2577
|
+
`[lcm] reconcileSessionTail: slow path for ${sessionContext} duration=${formatDurationMs(Date.now() - startedAt)} historicalMessages=${historicalMessages.length} anchorIndex=${anchorIndex} missingTail=${missingTail.length} importedMessages=${importedMessages}`,
|
|
2578
|
+
);
|
|
1962
2579
|
return { blockedByImportCap: false, importedMessages, hasOverlap: true };
|
|
1963
2580
|
}
|
|
1964
2581
|
|
|
2582
|
+
/**
|
|
2583
|
+
* Persist bootstrap checkpoint metadata anchored to the current DB frontier.
|
|
2584
|
+
*
|
|
2585
|
+
* We intentionally checkpoint the session file's current EOF while hashing the
|
|
2586
|
+
* latest persisted DB message. This keeps append-only recovery aligned with the
|
|
2587
|
+
* canonical LCM frontier even when trailing transcript entries are pruned or
|
|
2588
|
+
* otherwise noncanonical.
|
|
2589
|
+
*/
|
|
2590
|
+
private async refreshBootstrapState(params: {
|
|
2591
|
+
conversationId: number;
|
|
2592
|
+
sessionFile: string;
|
|
2593
|
+
fileStats?: { size: number; mtimeMs: number };
|
|
2594
|
+
}): Promise<void> {
|
|
2595
|
+
const latestDbMessage = await this.conversationStore.getLastMessage(params.conversationId);
|
|
2596
|
+
const fileStats = params.fileStats ?? statSync(params.sessionFile);
|
|
2597
|
+
await this.summaryStore.upsertConversationBootstrapState({
|
|
2598
|
+
conversationId: params.conversationId,
|
|
2599
|
+
sessionFilePath: params.sessionFile,
|
|
2600
|
+
lastSeenSize: fileStats.size,
|
|
2601
|
+
lastSeenMtimeMs: Math.trunc(fileStats.mtimeMs),
|
|
2602
|
+
lastProcessedOffset: fileStats.size,
|
|
2603
|
+
lastProcessedEntryHash: latestDbMessage
|
|
2604
|
+
? createBootstrapEntryHash({
|
|
2605
|
+
role: latestDbMessage.role,
|
|
2606
|
+
content: latestDbMessage.content,
|
|
2607
|
+
tokenCount: latestDbMessage.tokenCount,
|
|
2608
|
+
})
|
|
2609
|
+
: null,
|
|
2610
|
+
});
|
|
2611
|
+
}
|
|
2612
|
+
|
|
1965
2613
|
async bootstrap(params: {
|
|
1966
2614
|
sessionId: string;
|
|
1967
2615
|
sessionFile: string;
|
|
@@ -1982,6 +2630,11 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1982
2630
|
};
|
|
1983
2631
|
}
|
|
1984
2632
|
this.ensureMigrated();
|
|
2633
|
+
const startedAt = Date.now();
|
|
2634
|
+
const sessionLabel = [
|
|
2635
|
+
`session=${params.sessionId}`,
|
|
2636
|
+
...(params.sessionKey?.trim() ? [`sessionKey=${params.sessionKey.trim()}`] : []),
|
|
2637
|
+
].join(" ");
|
|
1985
2638
|
const sessionFileStats = statSync(params.sessionFile);
|
|
1986
2639
|
const sessionFileSize = sessionFileStats.size;
|
|
1987
2640
|
const sessionFileMtimeMs = Math.trunc(sessionFileStats.mtimeMs);
|
|
@@ -1992,19 +2645,14 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1992
2645
|
this.conversationStore.withTransaction(async () => {
|
|
1993
2646
|
const persistBootstrapState = async (
|
|
1994
2647
|
conversationId: number,
|
|
1995
|
-
historicalMessages: AgentMessage[],
|
|
1996
2648
|
): Promise<void> => {
|
|
1997
|
-
|
|
1998
|
-
historicalMessages.length > 0
|
|
1999
|
-
? toStoredMessage(historicalMessages[historicalMessages.length - 1]!)
|
|
2000
|
-
: null;
|
|
2001
|
-
await this.summaryStore.upsertConversationBootstrapState({
|
|
2649
|
+
await this.refreshBootstrapState({
|
|
2002
2650
|
conversationId,
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2651
|
+
sessionFile: params.sessionFile,
|
|
2652
|
+
fileStats: {
|
|
2653
|
+
size: sessionFileSize,
|
|
2654
|
+
mtimeMs: sessionFileMtimeMs,
|
|
2655
|
+
},
|
|
2008
2656
|
});
|
|
2009
2657
|
};
|
|
2010
2658
|
|
|
@@ -2029,6 +2677,9 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2029
2677
|
if (!conversation.bootstrappedAt) {
|
|
2030
2678
|
await this.conversationStore.markConversationBootstrapped(conversationId);
|
|
2031
2679
|
}
|
|
2680
|
+
this.deps.log.info(
|
|
2681
|
+
`[lcm] bootstrap: checkpoint hit conversation=${conversationId} ${sessionLabel} existingCount=${existingCount} duration=${formatDurationMs(Date.now() - startedAt)}`,
|
|
2682
|
+
);
|
|
2032
2683
|
return {
|
|
2033
2684
|
bootstrapped: false,
|
|
2034
2685
|
importedMessages: 0,
|
|
@@ -2055,6 +2706,7 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2055
2706
|
params.sessionFile,
|
|
2056
2707
|
bootstrapState.lastProcessedOffset,
|
|
2057
2708
|
true,
|
|
2709
|
+
(message) => createBootstrapEntryHash(toStoredMessage(message)) === latestDbHash,
|
|
2058
2710
|
);
|
|
2059
2711
|
const tailEntryMessage = readBootstrapMessageFromJsonLine(tailEntryRaw);
|
|
2060
2712
|
const tailEntryHash = tailEntryMessage
|
|
@@ -2088,13 +2740,9 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2088
2740
|
}
|
|
2089
2741
|
}
|
|
2090
2742
|
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
: tailEntryMessage;
|
|
2095
|
-
await persistBootstrapState(
|
|
2096
|
-
conversationId,
|
|
2097
|
-
lastAppendedMessage ? [lastAppendedMessage] : [],
|
|
2743
|
+
await persistBootstrapState(conversationId);
|
|
2744
|
+
this.deps.log.info(
|
|
2745
|
+
`[lcm] bootstrap: append-only conversation=${conversationId} ${sessionLabel} existingCount=${existingCount} appendedMessages=${appended.messages.length} importedMessages=${importedMessages} duration=${formatDurationMs(Date.now() - startedAt)}`,
|
|
2098
2746
|
);
|
|
2099
2747
|
|
|
2100
2748
|
if (importedMessages > 0) {
|
|
@@ -2115,6 +2763,9 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2115
2763
|
}
|
|
2116
2764
|
|
|
2117
2765
|
const historicalMessages = await readLeafPathMessages(params.sessionFile);
|
|
2766
|
+
this.deps.log.info(
|
|
2767
|
+
`[lcm] bootstrap: full transcript read conversation=${conversationId} ${sessionLabel} existingCount=${existingCount} historicalMessages=${historicalMessages.length} duration=${formatDurationMs(Date.now() - startedAt)}`,
|
|
2768
|
+
);
|
|
2118
2769
|
|
|
2119
2770
|
// First-time import path: no LCM rows yet, so seed directly from the
|
|
2120
2771
|
// active leaf context snapshot.
|
|
@@ -2126,7 +2777,7 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2126
2777
|
|
|
2127
2778
|
if (bootstrapMessages.length === 0) {
|
|
2128
2779
|
await this.conversationStore.markConversationBootstrapped(conversationId);
|
|
2129
|
-
await persistBootstrapState(conversationId
|
|
2780
|
+
await persistBootstrapState(conversationId);
|
|
2130
2781
|
return {
|
|
2131
2782
|
bootstrapped: false,
|
|
2132
2783
|
importedMessages: 0,
|
|
@@ -2152,18 +2803,22 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2152
2803
|
inserted.map((record) => record.messageId),
|
|
2153
2804
|
);
|
|
2154
2805
|
await this.conversationStore.markConversationBootstrapped(conversationId);
|
|
2155
|
-
await persistBootstrapState(conversationId, historicalMessages);
|
|
2156
2806
|
|
|
2157
2807
|
// Prune HEARTBEAT_OK turns from the freshly imported data
|
|
2158
2808
|
if (this.config.pruneHeartbeatOk) {
|
|
2159
2809
|
const pruned = await this.pruneHeartbeatOkTurns(conversationId);
|
|
2160
2810
|
if (pruned > 0) {
|
|
2161
|
-
|
|
2811
|
+
this.deps.log.info(
|
|
2162
2812
|
`[lcm] bootstrap: pruned ${pruned} HEARTBEAT_OK messages from conversation ${conversationId}`,
|
|
2163
2813
|
);
|
|
2164
2814
|
}
|
|
2165
2815
|
}
|
|
2166
2816
|
|
|
2817
|
+
await persistBootstrapState(conversationId);
|
|
2818
|
+
this.deps.log.info(
|
|
2819
|
+
`[lcm] bootstrap: initial import conversation=${conversationId} ${sessionLabel} importedMessages=${inserted.length} sourceMessages=${historicalMessages.length} duration=${formatDurationMs(Date.now() - startedAt)}`,
|
|
2820
|
+
);
|
|
2821
|
+
|
|
2167
2822
|
return {
|
|
2168
2823
|
bootstrapped: true,
|
|
2169
2824
|
importedMessages: inserted.length,
|
|
@@ -2178,6 +2833,9 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2178
2833
|
conversationId,
|
|
2179
2834
|
historicalMessages,
|
|
2180
2835
|
});
|
|
2836
|
+
this.deps.log.info(
|
|
2837
|
+
`[lcm] bootstrap: reconcile finished conversation=${conversationId} ${sessionLabel} importedMessages=${reconcile.importedMessages} overlap=${reconcile.hasOverlap} blockedByImportCap=${reconcile.blockedByImportCap} duration=${formatDurationMs(Date.now() - startedAt)}`,
|
|
2838
|
+
);
|
|
2181
2839
|
|
|
2182
2840
|
if (reconcile.blockedByImportCap) {
|
|
2183
2841
|
return {
|
|
@@ -2192,7 +2850,7 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2192
2850
|
}
|
|
2193
2851
|
|
|
2194
2852
|
if (reconcile.importedMessages > 0) {
|
|
2195
|
-
await persistBootstrapState(conversationId
|
|
2853
|
+
await persistBootstrapState(conversationId);
|
|
2196
2854
|
return {
|
|
2197
2855
|
bootstrapped: true,
|
|
2198
2856
|
importedMessages: reconcile.importedMessages,
|
|
@@ -2201,7 +2859,7 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2201
2859
|
}
|
|
2202
2860
|
|
|
2203
2861
|
if (reconcile.hasOverlap) {
|
|
2204
|
-
await persistBootstrapState(conversationId
|
|
2862
|
+
await persistBootstrapState(conversationId);
|
|
2205
2863
|
}
|
|
2206
2864
|
|
|
2207
2865
|
if (conversation.bootstrappedAt) {
|
|
@@ -2220,6 +2878,7 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2220
2878
|
: "conversation already has messages",
|
|
2221
2879
|
};
|
|
2222
2880
|
}),
|
|
2881
|
+
{ operationName: "bootstrap", context: sessionLabel },
|
|
2223
2882
|
);
|
|
2224
2883
|
|
|
2225
2884
|
// Post-bootstrap pruning: clean HEARTBEAT_OK turns that were already
|
|
@@ -2233,19 +2892,25 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2233
2892
|
if (conversation) {
|
|
2234
2893
|
const pruned = await this.pruneHeartbeatOkTurns(conversation.conversationId);
|
|
2235
2894
|
if (pruned > 0) {
|
|
2236
|
-
|
|
2895
|
+
await this.refreshBootstrapState({
|
|
2896
|
+
conversationId: conversation.conversationId,
|
|
2897
|
+
sessionFile: params.sessionFile,
|
|
2898
|
+
});
|
|
2899
|
+
this.deps.log.info(
|
|
2237
2900
|
`[lcm] bootstrap: retroactively pruned ${pruned} HEARTBEAT_OK messages from conversation ${conversation.conversationId}`,
|
|
2238
2901
|
);
|
|
2239
2902
|
}
|
|
2240
2903
|
}
|
|
2241
2904
|
} catch (err) {
|
|
2242
|
-
|
|
2243
|
-
`[lcm] bootstrap: heartbeat pruning failed
|
|
2244
|
-
err instanceof Error ? err.message : err,
|
|
2905
|
+
this.deps.log.warn(
|
|
2906
|
+
`[lcm] bootstrap: heartbeat pruning failed: ${describeLogError(err)}`,
|
|
2245
2907
|
);
|
|
2246
2908
|
}
|
|
2247
2909
|
}
|
|
2248
2910
|
|
|
2911
|
+
this.deps.log.info(
|
|
2912
|
+
`[lcm] bootstrap: done ${sessionLabel} bootstrapped=${result.bootstrapped} importedMessages=${result.importedMessages} reason=${result.reason ?? "none"} duration=${formatDurationMs(Date.now() - startedAt)}`,
|
|
2913
|
+
);
|
|
2249
2914
|
return result;
|
|
2250
2915
|
}
|
|
2251
2916
|
|
|
@@ -2385,6 +3050,12 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2385
3050
|
};
|
|
2386
3051
|
}
|
|
2387
3052
|
|
|
3053
|
+
const rewriteTranscriptEntries = params.runtimeContext.rewriteTranscriptEntries;
|
|
3054
|
+
const startedAt = Date.now();
|
|
3055
|
+
const sessionLabel = [
|
|
3056
|
+
`session=${params.sessionId}`,
|
|
3057
|
+
...(params.sessionKey?.trim() ? [`sessionKey=${params.sessionKey.trim()}`] : []),
|
|
3058
|
+
].join(" ");
|
|
2388
3059
|
return this.withSessionQueue(
|
|
2389
3060
|
this.resolveSessionQueueKey(params.sessionId, params.sessionKey),
|
|
2390
3061
|
async () => {
|
|
@@ -2406,6 +3077,9 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2406
3077
|
{ limit: TRANSCRIPT_GC_BATCH_SIZE },
|
|
2407
3078
|
);
|
|
2408
3079
|
if (candidates.length === 0) {
|
|
3080
|
+
this.deps.log.info(
|
|
3081
|
+
`[lcm] maintain: no transcript GC candidates conversation=${conversation.conversationId} ${sessionLabel} duration=${formatDurationMs(Date.now() - startedAt)}`,
|
|
3082
|
+
);
|
|
2409
3083
|
return {
|
|
2410
3084
|
changed: false,
|
|
2411
3085
|
bytesFreed: 0,
|
|
@@ -2441,6 +3115,9 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2441
3115
|
}
|
|
2442
3116
|
|
|
2443
3117
|
if (replacements.length === 0) {
|
|
3118
|
+
this.deps.log.info(
|
|
3119
|
+
`[lcm] maintain: no matching transcript entries conversation=${conversation.conversationId} ${sessionLabel} candidates=${candidates.length} duration=${formatDurationMs(Date.now() - startedAt)}`,
|
|
3120
|
+
);
|
|
2444
3121
|
return {
|
|
2445
3122
|
changed: false,
|
|
2446
3123
|
bytesFreed: 0,
|
|
@@ -2449,35 +3126,29 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2449
3126
|
};
|
|
2450
3127
|
}
|
|
2451
3128
|
|
|
2452
|
-
const result = await
|
|
3129
|
+
const result = await rewriteTranscriptEntries({
|
|
2453
3130
|
replacements,
|
|
2454
3131
|
});
|
|
2455
3132
|
|
|
2456
3133
|
if (result.changed) {
|
|
2457
3134
|
try {
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
const lastEntryMsg = readBootstrapMessageFromJsonLine(lastEntryRaw);
|
|
2463
|
-
const lastEntryHash = lastEntryMsg ? createBootstrapEntryHash(toStoredMessage(lastEntryMsg)) : null;
|
|
2464
|
-
if (lastEntryHash) {
|
|
2465
|
-
await this.summaryStore.upsertConversationBootstrapState({
|
|
2466
|
-
conversationId: conversation.conversationId,
|
|
2467
|
-
sessionFilePath: params.sessionFile,
|
|
2468
|
-
lastSeenSize: newSize,
|
|
2469
|
-
lastSeenMtimeMs: newMtimeMs,
|
|
2470
|
-
lastProcessedOffset: newSize,
|
|
2471
|
-
lastProcessedEntryHash: lastEntryHash,
|
|
2472
|
-
});
|
|
2473
|
-
}
|
|
3135
|
+
await this.refreshBootstrapState({
|
|
3136
|
+
conversationId: conversation.conversationId,
|
|
3137
|
+
sessionFile: params.sessionFile,
|
|
3138
|
+
});
|
|
2474
3139
|
} catch (e) {
|
|
2475
|
-
|
|
3140
|
+
this.deps.log.warn(
|
|
3141
|
+
`[lcm] Failed to update bootstrap checkpoint after maintain: ${describeLogError(e)}`,
|
|
3142
|
+
);
|
|
2476
3143
|
}
|
|
2477
3144
|
}
|
|
2478
3145
|
|
|
3146
|
+
this.deps.log.info(
|
|
3147
|
+
`[lcm] maintain: done conversation=${conversation.conversationId} ${sessionLabel} candidates=${candidates.length} replacements=${replacements.length} changed=${result.changed} rewrittenEntries=${result.rewrittenEntries} bytesFreed=${result.bytesFreed} duration=${formatDurationMs(Date.now() - startedAt)}`,
|
|
3148
|
+
);
|
|
2479
3149
|
return result;
|
|
2480
3150
|
},
|
|
3151
|
+
{ operationName: "maintain", context: sessionLabel },
|
|
2481
3152
|
);
|
|
2482
3153
|
}
|
|
2483
3154
|
private async ingestSingle(params: {
|
|
@@ -2490,6 +3161,34 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2490
3161
|
if (isHeartbeat) {
|
|
2491
3162
|
return { ingested: false };
|
|
2492
3163
|
}
|
|
3164
|
+
|
|
3165
|
+
// Skip assistant messages that failed with an error and have no useful content.
|
|
3166
|
+
// These occur when an API call returns a 500 or similar transient error.
|
|
3167
|
+
// Ingesting them pollutes the LCM database: on retry, the error messages
|
|
3168
|
+
// accumulate and get assembled into context, creating a positive feedback
|
|
3169
|
+
// loop where each retry sends an increasingly large (and malformed) payload
|
|
3170
|
+
// that continues to fail.
|
|
3171
|
+
if (message.role === "assistant") {
|
|
3172
|
+
const topLevel = message as unknown as Record<string, unknown>;
|
|
3173
|
+
const stopReason =
|
|
3174
|
+
typeof topLevel.stopReason === "string"
|
|
3175
|
+
? topLevel.stopReason
|
|
3176
|
+
: typeof topLevel.stop_reason === "string"
|
|
3177
|
+
? topLevel.stop_reason
|
|
3178
|
+
: undefined;
|
|
3179
|
+
if (stopReason === "error" || stopReason === "aborted") {
|
|
3180
|
+
const content = topLevel.content;
|
|
3181
|
+
const isEmpty =
|
|
3182
|
+
content === undefined ||
|
|
3183
|
+
content === null ||
|
|
3184
|
+
content === "" ||
|
|
3185
|
+
(Array.isArray(content) && content.length === 0);
|
|
3186
|
+
if (isEmpty) {
|
|
3187
|
+
return { ingested: false };
|
|
3188
|
+
}
|
|
3189
|
+
}
|
|
3190
|
+
}
|
|
3191
|
+
|
|
2493
3192
|
const stored = toStoredMessage(message);
|
|
2494
3193
|
|
|
2495
3194
|
// Get or create conversation for this session
|
|
@@ -2570,6 +3269,13 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2570
3269
|
return this.withSessionQueue(
|
|
2571
3270
|
this.resolveSessionQueueKey(params.sessionId, params.sessionKey),
|
|
2572
3271
|
() => this.ingestSingle(params),
|
|
3272
|
+
{
|
|
3273
|
+
operationName: "ingest",
|
|
3274
|
+
context: [
|
|
3275
|
+
`session=${params.sessionId}`,
|
|
3276
|
+
...(params.sessionKey?.trim() ? [`sessionKey=${params.sessionKey.trim()}`] : []),
|
|
3277
|
+
].join(" "),
|
|
3278
|
+
},
|
|
2573
3279
|
);
|
|
2574
3280
|
}
|
|
2575
3281
|
|
|
@@ -2606,6 +3312,14 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2606
3312
|
}
|
|
2607
3313
|
return { ingestedCount };
|
|
2608
3314
|
},
|
|
3315
|
+
{
|
|
3316
|
+
operationName: "ingestBatch",
|
|
3317
|
+
context: [
|
|
3318
|
+
`session=${params.sessionId}`,
|
|
3319
|
+
...(params.sessionKey?.trim() ? [`sessionKey=${params.sessionKey.trim()}`] : []),
|
|
3320
|
+
`messages=${params.messages.length}`,
|
|
3321
|
+
].join(" "),
|
|
3322
|
+
},
|
|
2609
3323
|
);
|
|
2610
3324
|
}
|
|
2611
3325
|
|
|
@@ -2630,6 +3344,11 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2630
3344
|
return;
|
|
2631
3345
|
}
|
|
2632
3346
|
this.ensureMigrated();
|
|
3347
|
+
const startedAt = Date.now();
|
|
3348
|
+
const sessionLabel = [
|
|
3349
|
+
`session=${params.sessionId}`,
|
|
3350
|
+
...(params.sessionKey?.trim() ? [`sessionKey=${params.sessionKey.trim()}`] : []),
|
|
3351
|
+
].join(" ");
|
|
2633
3352
|
|
|
2634
3353
|
// Dedup guard: prevent duplicate ingestion when gateway restart replays
|
|
2635
3354
|
// full history. Run on newMessages BEFORE prepending autoCompactionSummary
|
|
@@ -2651,6 +3370,9 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2651
3370
|
|
|
2652
3371
|
ingestBatch.push(...dedupedNewMessages);
|
|
2653
3372
|
if (ingestBatch.length === 0) {
|
|
3373
|
+
this.deps.log.info(
|
|
3374
|
+
`[lcm] afterTurn: nothing to ingest ${sessionLabel} newMessages=${newMessages.length} duration=${formatDurationMs(Date.now() - startedAt)}`,
|
|
3375
|
+
);
|
|
2654
3376
|
return;
|
|
2655
3377
|
}
|
|
2656
3378
|
|
|
@@ -2663,9 +3385,8 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2663
3385
|
});
|
|
2664
3386
|
} catch (err) {
|
|
2665
3387
|
// Never compact a stale or partially ingested frontier.
|
|
2666
|
-
|
|
2667
|
-
`[lcm] afterTurn: ingest failed, skipping compaction
|
|
2668
|
-
err instanceof Error ? err.message : err,
|
|
3388
|
+
this.deps.log.error(
|
|
3389
|
+
`[lcm] afterTurn: ingest failed, skipping compaction: ${describeLogError(err)}`,
|
|
2669
3390
|
);
|
|
2670
3391
|
return;
|
|
2671
3392
|
}
|
|
@@ -2679,16 +3400,30 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2679
3400
|
if (conversation) {
|
|
2680
3401
|
const pruned = await this.pruneHeartbeatOkTurns(conversation.conversationId);
|
|
2681
3402
|
if (pruned > 0) {
|
|
2682
|
-
|
|
2683
|
-
|
|
3403
|
+
const sessionContext = this.formatSessionLogContext({
|
|
3404
|
+
conversationId: conversation.conversationId,
|
|
3405
|
+
sessionId: params.sessionId,
|
|
3406
|
+
sessionKey: params.sessionKey,
|
|
3407
|
+
});
|
|
3408
|
+
try {
|
|
3409
|
+
await this.refreshBootstrapState({
|
|
3410
|
+
conversationId: conversation.conversationId,
|
|
3411
|
+
sessionFile: params.sessionFile,
|
|
3412
|
+
});
|
|
3413
|
+
} catch (err) {
|
|
3414
|
+
this.deps.log.warn(
|
|
3415
|
+
`[lcm] afterTurn: heartbeat pruning checkpoint refresh failed for ${sessionContext}: ${describeLogError(err)}`,
|
|
3416
|
+
);
|
|
3417
|
+
}
|
|
3418
|
+
this.deps.log.info(
|
|
3419
|
+
`[lcm] afterTurn: pruned ${pruned} heartbeat ack messages for ${sessionContext}`,
|
|
2684
3420
|
);
|
|
2685
3421
|
return;
|
|
2686
3422
|
}
|
|
2687
3423
|
}
|
|
2688
3424
|
} catch (err) {
|
|
2689
|
-
|
|
2690
|
-
`[lcm] afterTurn: heartbeat pruning failed
|
|
2691
|
-
err instanceof Error ? err.message : err,
|
|
3425
|
+
this.deps.log.warn(
|
|
3426
|
+
`[lcm] afterTurn: heartbeat pruning failed: ${describeLogError(err)}`,
|
|
2692
3427
|
);
|
|
2693
3428
|
}
|
|
2694
3429
|
}
|
|
@@ -2702,16 +3437,44 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2702
3437
|
});
|
|
2703
3438
|
const tokenBudget = this.applyAssemblyBudgetCap(resolvedTokenBudget ?? DEFAULT_AFTER_TURN_TOKEN_BUDGET);
|
|
2704
3439
|
if (resolvedTokenBudget === undefined) {
|
|
2705
|
-
|
|
3440
|
+
this.deps.log.warn(
|
|
2706
3441
|
`[lcm] afterTurn: tokenBudget not provided; using default ${DEFAULT_AFTER_TURN_TOKEN_BUDGET}`,
|
|
2707
3442
|
);
|
|
2708
3443
|
}
|
|
2709
3444
|
|
|
2710
3445
|
const liveContextTokens = estimateSessionTokenCountForAfterTurn(params.messages);
|
|
3446
|
+
const conversation = await this.conversationStore.getConversationForSession({
|
|
3447
|
+
sessionId: params.sessionId,
|
|
3448
|
+
sessionKey: params.sessionKey,
|
|
3449
|
+
});
|
|
3450
|
+
if (!conversation) {
|
|
3451
|
+
this.deps.log.info(
|
|
3452
|
+
`[lcm] afterTurn: conversation lookup missed ${sessionLabel} ingestBatch=${ingestBatch.length} duration=${formatDurationMs(Date.now() - startedAt)}`,
|
|
3453
|
+
);
|
|
3454
|
+
return;
|
|
3455
|
+
}
|
|
3456
|
+
|
|
3457
|
+
try {
|
|
3458
|
+
const rawLeafTrigger = await this.compaction.evaluateLeafTrigger(conversation.conversationId);
|
|
3459
|
+
await this.updateCompactionTelemetry({
|
|
3460
|
+
conversationId: conversation.conversationId,
|
|
3461
|
+
runtimeContext: asRecord(params.runtimeContext),
|
|
3462
|
+
tokenBudget,
|
|
3463
|
+
rawTokensOutsideTail: rawLeafTrigger.rawTokensOutsideTail,
|
|
3464
|
+
});
|
|
3465
|
+
} catch (err) {
|
|
3466
|
+
this.deps.log.warn(
|
|
3467
|
+
`[lcm] afterTurn: compaction telemetry update failed: ${describeLogError(err)}`,
|
|
3468
|
+
);
|
|
3469
|
+
}
|
|
2711
3470
|
|
|
2712
3471
|
try {
|
|
2713
|
-
const
|
|
2714
|
-
|
|
3472
|
+
const leafDecision = await this.evaluateIncrementalCompaction({
|
|
3473
|
+
conversationId: conversation.conversationId,
|
|
3474
|
+
tokenBudget,
|
|
3475
|
+
currentTokenCount: liveContextTokens,
|
|
3476
|
+
});
|
|
3477
|
+
if (leafDecision.shouldCompact) {
|
|
2715
3478
|
this.compactLeafAsync({
|
|
2716
3479
|
sessionId: params.sessionId,
|
|
2717
3480
|
sessionKey: params.sessionKey,
|
|
@@ -2719,6 +3482,11 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2719
3482
|
tokenBudget,
|
|
2720
3483
|
currentTokenCount: liveContextTokens,
|
|
2721
3484
|
legacyParams,
|
|
3485
|
+
maxPasses: leafDecision.maxPasses,
|
|
3486
|
+
leafChunkTokens: leafDecision.leafChunkTokens,
|
|
3487
|
+
fallbackLeafChunkTokens: leafDecision.fallbackLeafChunkTokens,
|
|
3488
|
+
activityBand: leafDecision.activityBand,
|
|
3489
|
+
allowCondensedPasses: leafDecision.allowCondensedPasses,
|
|
2722
3490
|
}).catch(() => {
|
|
2723
3491
|
// Leaf compaction is best-effort and should not fail the caller.
|
|
2724
3492
|
});
|
|
@@ -2740,6 +3508,10 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2740
3508
|
} catch {
|
|
2741
3509
|
// Proactive compaction is best-effort in the post-turn lifecycle.
|
|
2742
3510
|
}
|
|
3511
|
+
|
|
3512
|
+
this.deps.log.info(
|
|
3513
|
+
`[lcm] afterTurn: done conversation=${conversation.conversationId} ${sessionLabel} newMessages=${newMessages.length} dedupedMessages=${dedupedNewMessages.length} ingestedMessages=${ingestBatch.length} duration=${formatDurationMs(Date.now() - startedAt)}`,
|
|
3514
|
+
);
|
|
2743
3515
|
}
|
|
2744
3516
|
|
|
2745
3517
|
async assemble(params: {
|
|
@@ -2758,12 +3530,20 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2758
3530
|
}
|
|
2759
3531
|
try {
|
|
2760
3532
|
this.ensureMigrated();
|
|
3533
|
+
const startedAt = Date.now();
|
|
3534
|
+
const sessionLabel = [
|
|
3535
|
+
`session=${params.sessionId}`,
|
|
3536
|
+
...(params.sessionKey?.trim() ? [`sessionKey=${params.sessionKey.trim()}`] : []),
|
|
3537
|
+
].join(" ");
|
|
2761
3538
|
|
|
2762
3539
|
const conversation = await this.conversationStore.getConversationForSession({
|
|
2763
3540
|
sessionId: params.sessionId,
|
|
2764
3541
|
sessionKey: params.sessionKey,
|
|
2765
3542
|
});
|
|
2766
3543
|
if (!conversation) {
|
|
3544
|
+
this.deps.log.info(
|
|
3545
|
+
`[lcm] assemble: conversation lookup missed ${sessionLabel} duration=${formatDurationMs(Date.now() - startedAt)}`,
|
|
3546
|
+
);
|
|
2767
3547
|
return {
|
|
2768
3548
|
messages: params.messages,
|
|
2769
3549
|
estimatedTokens: 0,
|
|
@@ -2772,6 +3552,9 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2772
3552
|
|
|
2773
3553
|
const contextItems = await this.summaryStore.getContextItems(conversation.conversationId);
|
|
2774
3554
|
if (contextItems.length === 0) {
|
|
3555
|
+
this.deps.log.info(
|
|
3556
|
+
`[lcm] assemble: no context items conversation=${conversation.conversationId} ${sessionLabel} duration=${formatDurationMs(Date.now() - startedAt)}`,
|
|
3557
|
+
);
|
|
2775
3558
|
return {
|
|
2776
3559
|
messages: params.messages,
|
|
2777
3560
|
estimatedTokens: 0,
|
|
@@ -2783,6 +3566,9 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2783
3566
|
// the live path to avoid dropping prompt context.
|
|
2784
3567
|
const hasSummaryItems = contextItems.some((item) => item.itemType === "summary");
|
|
2785
3568
|
if (!hasSummaryItems && contextItems.length < params.messages.length) {
|
|
3569
|
+
this.deps.log.info(
|
|
3570
|
+
`[lcm] assemble: falling back to live context conversation=${conversation.conversationId} ${sessionLabel} contextItems=${contextItems.length} liveMessages=${params.messages.length} duration=${formatDurationMs(Date.now() - startedAt)}`,
|
|
3571
|
+
);
|
|
2786
3572
|
return {
|
|
2787
3573
|
messages: params.messages,
|
|
2788
3574
|
estimatedTokens: 0,
|
|
@@ -2807,12 +3593,19 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2807
3593
|
// If assembly produced no messages for a non-empty live session,
|
|
2808
3594
|
// fail safe to the live context.
|
|
2809
3595
|
if (assembled.messages.length === 0 && params.messages.length > 0) {
|
|
3596
|
+
this.deps.log.info(
|
|
3597
|
+
`[lcm] assemble: empty assembled output, using live context conversation=${conversation.conversationId} ${sessionLabel} contextItems=${contextItems.length} tokenBudget=${tokenBudget} duration=${formatDurationMs(Date.now() - startedAt)}`,
|
|
3598
|
+
);
|
|
2810
3599
|
return {
|
|
2811
3600
|
messages: params.messages,
|
|
2812
3601
|
estimatedTokens: 0,
|
|
2813
3602
|
};
|
|
2814
3603
|
}
|
|
2815
3604
|
|
|
3605
|
+
this.deps.log.info(
|
|
3606
|
+
`[lcm] assemble: done conversation=${conversation.conversationId} ${sessionLabel} contextItems=${contextItems.length} hasSummaryItems=${hasSummaryItems} inputMessages=${params.messages.length} outputMessages=${assembled.messages.length} tokenBudget=${tokenBudget} estimatedTokens=${assembled.estimatedTokens} duration=${formatDurationMs(Date.now() - startedAt)}`,
|
|
3607
|
+
);
|
|
3608
|
+
|
|
2816
3609
|
const result: AssembleResultWithSystemPrompt = {
|
|
2817
3610
|
messages: assembled.messages,
|
|
2818
3611
|
estimatedTokens: assembled.estimatedTokens,
|
|
@@ -2821,7 +3614,10 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2821
3614
|
: {}),
|
|
2822
3615
|
};
|
|
2823
3616
|
return result;
|
|
2824
|
-
} catch {
|
|
3617
|
+
} catch (err) {
|
|
3618
|
+
this.deps.log.info(
|
|
3619
|
+
`[lcm] assemble: failed for session=${params.sessionId}${params.sessionKey?.trim() ? ` sessionKey=${params.sessionKey.trim()}` : ""} error=${describeLogError(err)}`,
|
|
3620
|
+
);
|
|
2825
3621
|
return {
|
|
2826
3622
|
messages: params.messages,
|
|
2827
3623
|
estimatedTokens: 0,
|
|
@@ -2856,7 +3652,7 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2856
3652
|
return this.compaction.evaluateLeafTrigger(conversation.conversationId);
|
|
2857
3653
|
}
|
|
2858
3654
|
|
|
2859
|
-
/** Run one incremental leaf compaction
|
|
3655
|
+
/** Run one or more incremental leaf compaction passes in the per-session queue. */
|
|
2860
3656
|
async compactLeafAsync(params: {
|
|
2861
3657
|
sessionId: string;
|
|
2862
3658
|
sessionKey?: string;
|
|
@@ -2870,6 +3666,11 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2870
3666
|
legacyParams?: Record<string, unknown>;
|
|
2871
3667
|
force?: boolean;
|
|
2872
3668
|
previousSummaryContent?: string;
|
|
3669
|
+
maxPasses?: number;
|
|
3670
|
+
leafChunkTokens?: number;
|
|
3671
|
+
fallbackLeafChunkTokens?: number[];
|
|
3672
|
+
activityBand?: ActivityBand;
|
|
3673
|
+
allowCondensedPasses?: boolean;
|
|
2873
3674
|
}): Promise<CompactResult> {
|
|
2874
3675
|
if (this.isStatelessSession(params.sessionKey)) {
|
|
2875
3676
|
return {
|
|
@@ -2933,38 +3734,114 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2933
3734
|
};
|
|
2934
3735
|
}
|
|
2935
3736
|
|
|
2936
|
-
const
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
3737
|
+
const storedTokensBefore = await this.summaryStore.getContextTokenCount(
|
|
3738
|
+
conversation.conversationId,
|
|
3739
|
+
);
|
|
3740
|
+
const maxPasses =
|
|
3741
|
+
typeof params.maxPasses === "number" &&
|
|
3742
|
+
Number.isFinite(params.maxPasses) &&
|
|
3743
|
+
params.maxPasses > 0
|
|
3744
|
+
? Math.floor(params.maxPasses)
|
|
3745
|
+
: 1;
|
|
3746
|
+
const fallbackLeafChunkTokens = Array.isArray(params.fallbackLeafChunkTokens)
|
|
3747
|
+
? [...new Set(params.fallbackLeafChunkTokens
|
|
3748
|
+
.filter((value): value is number => typeof value === "number" && Number.isFinite(value) && value > 0)
|
|
3749
|
+
.map((value) => Math.floor(value)))]
|
|
3750
|
+
.sort((a, b) => b - a)
|
|
3751
|
+
: [];
|
|
3752
|
+
let activeLeafChunkTokens =
|
|
3753
|
+
typeof params.leafChunkTokens === "number" &&
|
|
3754
|
+
Number.isFinite(params.leafChunkTokens) &&
|
|
3755
|
+
params.leafChunkTokens > 0
|
|
3756
|
+
? Math.floor(params.leafChunkTokens)
|
|
3757
|
+
: fallbackLeafChunkTokens[0];
|
|
3758
|
+
this.deps.log.info(
|
|
3759
|
+
`[lcm] compactLeafAsync start: conversation=${conversation.conversationId} session=${params.sessionId} leafChunkTokens=${activeLeafChunkTokens ?? "null"} fallbackLeafChunkTokens=${fallbackLeafChunkTokens.join(",")} maxPasses=${maxPasses} activityBand=${params.activityBand ?? "unknown"} allowCondensedPasses=${params.allowCondensedPasses !== false}`,
|
|
3760
|
+
);
|
|
3761
|
+
|
|
3762
|
+
let rounds = 0;
|
|
3763
|
+
let finalTokens = observedTokens ?? storedTokensBefore;
|
|
3764
|
+
let authFailure = false;
|
|
3765
|
+
|
|
3766
|
+
for (let pass = 0; pass < maxPasses; pass += 1) {
|
|
3767
|
+
let leafResult: Awaited<ReturnType<typeof this.compaction.compactLeaf>> | undefined;
|
|
3768
|
+
while (true) {
|
|
3769
|
+
try {
|
|
3770
|
+
leafResult = await this.compaction.compactLeaf({
|
|
3771
|
+
conversationId: conversation.conversationId,
|
|
3772
|
+
tokenBudget,
|
|
3773
|
+
summarize,
|
|
3774
|
+
...(activeLeafChunkTokens !== undefined ? { leafChunkTokens: activeLeafChunkTokens } : {}),
|
|
3775
|
+
force: params.force,
|
|
3776
|
+
previousSummaryContent: pass === 0 ? params.previousSummaryContent : undefined,
|
|
3777
|
+
summaryModel,
|
|
3778
|
+
allowCondensedPasses: params.allowCondensedPasses,
|
|
3779
|
+
});
|
|
3780
|
+
break;
|
|
3781
|
+
} catch (err) {
|
|
3782
|
+
const nextLeafChunkTokens = fallbackLeafChunkTokens.find(
|
|
3783
|
+
(value) => activeLeafChunkTokens !== undefined && value < activeLeafChunkTokens,
|
|
3784
|
+
);
|
|
3785
|
+
if (!this.isRecoverableLeafChunkOverflowError(err) || nextLeafChunkTokens === undefined) {
|
|
3786
|
+
throw err;
|
|
3787
|
+
}
|
|
3788
|
+
this.deps.log.warn(
|
|
3789
|
+
`[lcm] compactLeafAsync: retrying with smaller leafChunkTokens=${nextLeafChunkTokens} after provider token-limit error: ${err instanceof Error ? err.message : String(err)}`,
|
|
3790
|
+
);
|
|
3791
|
+
activeLeafChunkTokens = nextLeafChunkTokens;
|
|
3792
|
+
}
|
|
3793
|
+
}
|
|
3794
|
+
if (!leafResult) {
|
|
3795
|
+
break;
|
|
3796
|
+
}
|
|
3797
|
+
finalTokens = leafResult.tokensAfter;
|
|
3798
|
+
|
|
3799
|
+
if (leafResult.authFailure) {
|
|
3800
|
+
authFailure = true;
|
|
3801
|
+
break;
|
|
3802
|
+
}
|
|
3803
|
+
if (!leafResult.actionTaken) {
|
|
3804
|
+
break;
|
|
3805
|
+
}
|
|
3806
|
+
rounds += 1;
|
|
3807
|
+
if (leafResult.tokensAfter >= leafResult.tokensBefore) {
|
|
3808
|
+
break;
|
|
3809
|
+
}
|
|
3810
|
+
}
|
|
2944
3811
|
|
|
2945
|
-
if (
|
|
3812
|
+
if (authFailure && breakerKey) {
|
|
2946
3813
|
this.recordCompactionAuthFailure(breakerKey);
|
|
2947
|
-
} else if (
|
|
3814
|
+
} else if (rounds > 0 && breakerKey) {
|
|
2948
3815
|
this.recordCompactionSuccess(breakerKey);
|
|
2949
3816
|
}
|
|
3817
|
+
if (rounds > 0) {
|
|
3818
|
+
await this.markLeafCompactionTelemetrySuccess({
|
|
3819
|
+
conversationId: conversation.conversationId,
|
|
3820
|
+
activityBand: params.activityBand,
|
|
3821
|
+
});
|
|
3822
|
+
}
|
|
2950
3823
|
|
|
2951
|
-
const tokensBefore = observedTokens ??
|
|
3824
|
+
const tokensBefore = observedTokens ?? storedTokensBefore;
|
|
3825
|
+
this.deps.log.debug(
|
|
3826
|
+
`[lcm] compactLeafAsync result: conversation=${conversation.conversationId} session=${params.sessionId} rounds=${rounds} compacted=${rounds > 0} authFailure=${authFailure} finalLeafChunkTokens=${activeLeafChunkTokens ?? "null"} finalTokens=${finalTokens}`,
|
|
3827
|
+
);
|
|
2952
3828
|
|
|
2953
3829
|
return {
|
|
2954
3830
|
ok: true,
|
|
2955
|
-
compacted:
|
|
2956
|
-
reason:
|
|
3831
|
+
compacted: rounds > 0,
|
|
3832
|
+
reason: authFailure
|
|
2957
3833
|
? "provider auth failure"
|
|
2958
|
-
:
|
|
3834
|
+
: rounds > 0
|
|
2959
3835
|
? "compacted"
|
|
2960
3836
|
: "below threshold",
|
|
2961
3837
|
result: {
|
|
2962
3838
|
tokensBefore,
|
|
2963
|
-
tokensAfter:
|
|
3839
|
+
tokensAfter: finalTokens,
|
|
2964
3840
|
details: {
|
|
2965
|
-
rounds
|
|
3841
|
+
rounds,
|
|
2966
3842
|
targetTokens: tokenBudget,
|
|
2967
3843
|
mode: "leaf",
|
|
3844
|
+
maxPasses,
|
|
2968
3845
|
},
|
|
2969
3846
|
},
|
|
2970
3847
|
};
|
|
@@ -3093,7 +3970,7 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
3093
3970
|
// overflow counts can drive recovery even when persisted context is already small.
|
|
3094
3971
|
const useSweep = manualCompactionRequested || params.compactionTarget === "threshold";
|
|
3095
3972
|
if (useSweep) {
|
|
3096
|
-
const sweepResult = await this.compaction.
|
|
3973
|
+
const sweepResult = await this.compaction.compact({
|
|
3097
3974
|
conversationId,
|
|
3098
3975
|
tokenBudget,
|
|
3099
3976
|
summarize,
|
|
@@ -3107,6 +3984,9 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
3107
3984
|
} else if (sweepResult.actionTaken && breakerKey) {
|
|
3108
3985
|
this.recordCompactionSuccess(breakerKey);
|
|
3109
3986
|
}
|
|
3987
|
+
if (sweepResult.actionTaken) {
|
|
3988
|
+
await this.markLeafCompactionTelemetrySuccess({ conversationId });
|
|
3989
|
+
}
|
|
3110
3990
|
|
|
3111
3991
|
return {
|
|
3112
3992
|
ok: !sweepResult.authFailure && (sweepResult.actionTaken || !liveContextStillExceedsTarget),
|
|
@@ -3140,11 +4020,21 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
3140
4020
|
? decision.threshold
|
|
3141
4021
|
: tokenBudget;
|
|
3142
4022
|
|
|
4023
|
+
// When forced (overflow recovery) and the caller did not supply an
|
|
4024
|
+
// observed token count, assume we are at least at the token budget so
|
|
4025
|
+
// compactUntilUnder does not bail with "already under target" while the
|
|
4026
|
+
// live context is actually overflowing.
|
|
4027
|
+
const effectiveCurrentTokens =
|
|
4028
|
+
observedTokens !== undefined
|
|
4029
|
+
? observedTokens
|
|
4030
|
+
: forceCompaction
|
|
4031
|
+
? tokenBudget
|
|
4032
|
+
: undefined;
|
|
3143
4033
|
const compactResult = await this.compaction.compactUntilUnder({
|
|
3144
4034
|
conversationId,
|
|
3145
4035
|
tokenBudget,
|
|
3146
4036
|
targetTokens: convergenceTargetTokens,
|
|
3147
|
-
...(
|
|
4037
|
+
...(effectiveCurrentTokens !== undefined ? { currentTokens: effectiveCurrentTokens } : {}),
|
|
3148
4038
|
summarize,
|
|
3149
4039
|
summaryModel,
|
|
3150
4040
|
});
|
|
@@ -3156,6 +4046,9 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
3156
4046
|
}
|
|
3157
4047
|
|
|
3158
4048
|
const didCompact = compactResult.rounds > 0;
|
|
4049
|
+
if (didCompact) {
|
|
4050
|
+
await this.markLeafCompactionTelemetrySuccess({ conversationId });
|
|
4051
|
+
}
|
|
3159
4052
|
|
|
3160
4053
|
return {
|
|
3161
4054
|
ok: compactResult.success,
|
|
@@ -3344,7 +4237,7 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
3344
4237
|
const nextSessionKey = params.nextSessionKey?.trim() || params.sessionKey?.trim() || current?.sessionKey;
|
|
3345
4238
|
const freshConversation = await this.conversationStore.createConversation({
|
|
3346
4239
|
sessionId: nextSessionId,
|
|
3347
|
-
sessionKey: nextSessionKey,
|
|
4240
|
+
...(nextSessionKey ? { sessionKey: nextSessionKey } : {}),
|
|
3348
4241
|
});
|
|
3349
4242
|
this.deps.log.info(
|
|
3350
4243
|
`[lcm] ${params.reason} lifecycle archived prior conversation and created ${freshConversation.conversationId}`,
|
|
@@ -3455,6 +4348,10 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
3455
4348
|
return this.summaryStore;
|
|
3456
4349
|
}
|
|
3457
4350
|
|
|
4351
|
+
getCompactionTelemetryStore(): CompactionTelemetryStore {
|
|
4352
|
+
return this.compactionTelemetryStore;
|
|
4353
|
+
}
|
|
4354
|
+
|
|
3458
4355
|
// ── Heartbeat pruning ──────────────────────────────────────────────────
|
|
3459
4356
|
|
|
3460
4357
|
/**
|