@martian-engineering/lossless-claw 0.6.2 → 0.7.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 +16 -4
- package/docs/agent-tools.md +7 -1
- package/docs/configuration.md +200 -200
- package/openclaw.plugin.json +123 -0
- package/package.json +1 -1
- package/skills/lossless-claw/references/config.md +135 -3
- package/src/assembler.ts +5 -1
- package/src/compaction.ts +149 -38
- package/src/db/config.ts +102 -4
- package/src/db/connection.ts +20 -2
- package/src/db/migration.ts +57 -0
- package/src/engine.ts +980 -122
- package/src/lcm-log.ts +37 -0
- package/src/plugin/index.ts +407 -74
- package/src/plugin/lcm-command.ts +10 -4
- package/src/plugin/shared-init.ts +59 -0
- package/src/prune.ts +391 -0
- package/src/retrieval.ts +7 -5
- 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 +54 -30
- package/src/tools/lcm-describe-tool.ts +9 -4
- package/src/tools/lcm-expand-query-tool.ts +11 -6
- 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,11 +43,19 @@ 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,
|
|
58
|
+
type ConversationRecord,
|
|
51
59
|
type CreateMessagePartInput,
|
|
52
60
|
type MessagePartRecord,
|
|
53
61
|
type MessagePartType,
|
|
@@ -62,6 +70,30 @@ type CircuitBreakerState = {
|
|
|
62
70
|
failures: number;
|
|
63
71
|
openSince: number | null;
|
|
64
72
|
};
|
|
73
|
+
type PromptCacheSnapshot = {
|
|
74
|
+
lastObservedCacheRead?: number;
|
|
75
|
+
lastObservedCacheWrite?: number;
|
|
76
|
+
cacheState: CacheState;
|
|
77
|
+
retention?: string;
|
|
78
|
+
sawExplicitBreak: boolean;
|
|
79
|
+
};
|
|
80
|
+
type IncrementalCompactionDecision = {
|
|
81
|
+
shouldCompact: boolean;
|
|
82
|
+
cacheState: CacheState;
|
|
83
|
+
maxPasses: number;
|
|
84
|
+
rawTokensOutsideTail: number;
|
|
85
|
+
threshold: number;
|
|
86
|
+
leafChunkTokens: number;
|
|
87
|
+
fallbackLeafChunkTokens: number[];
|
|
88
|
+
activityBand: ActivityBand;
|
|
89
|
+
allowCondensedPasses: boolean;
|
|
90
|
+
};
|
|
91
|
+
type DynamicLeafChunkBounds = {
|
|
92
|
+
floor: number;
|
|
93
|
+
medium: number;
|
|
94
|
+
high: number;
|
|
95
|
+
max: number;
|
|
96
|
+
};
|
|
65
97
|
type TranscriptRewriteReplacement = {
|
|
66
98
|
entryId: string;
|
|
67
99
|
message: AgentMessage;
|
|
@@ -82,6 +114,13 @@ type ContextEngineMaintenanceRuntimeContext = Record<string, unknown> & {
|
|
|
82
114
|
};
|
|
83
115
|
|
|
84
116
|
const TRANSCRIPT_GC_BATCH_SIZE = 12;
|
|
117
|
+
const HOT_CACHE_HYSTERESIS_TURNS = 2;
|
|
118
|
+
const DYNAMIC_LEAF_CHUNK_MEDIUM_MULTIPLIER = 1.5;
|
|
119
|
+
const DYNAMIC_LEAF_CHUNK_HIGH_MULTIPLIER = 2;
|
|
120
|
+
const DYNAMIC_ACTIVITY_MEDIUM_UPSHIFT_FACTOR = 0.5;
|
|
121
|
+
const DYNAMIC_ACTIVITY_MEDIUM_DOWNSHIFT_FACTOR = 0.35;
|
|
122
|
+
const DYNAMIC_ACTIVITY_HIGH_UPSHIFT_FACTOR = 1.0;
|
|
123
|
+
const DYNAMIC_ACTIVITY_HIGH_DOWNSHIFT_FACTOR = 0.75;
|
|
85
124
|
|
|
86
125
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
87
126
|
|
|
@@ -834,12 +873,25 @@ function isBootstrapMessage(value: unknown): value is AgentMessage {
|
|
|
834
873
|
return "content" in msg || ("command" in msg && "output" in msg);
|
|
835
874
|
}
|
|
836
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
|
+
|
|
837
893
|
function extractBootstrapMessageCandidate(value: unknown): AgentMessage | null {
|
|
838
|
-
|
|
839
|
-
value && typeof value === "object" && "message" in value
|
|
840
|
-
? (value as { message?: unknown }).message
|
|
841
|
-
: value;
|
|
842
|
-
return isBootstrapMessage(candidate) ? candidate : null;
|
|
894
|
+
return extractCanonicalBootstrapMessage(value);
|
|
843
895
|
}
|
|
844
896
|
|
|
845
897
|
function parseBootstrapJsonl(raw: string, options?: {
|
|
@@ -862,9 +914,6 @@ function parseBootstrapJsonl(raw: string, options?: {
|
|
|
862
914
|
messages.push(candidate);
|
|
863
915
|
continue;
|
|
864
916
|
}
|
|
865
|
-
if (options?.strict) {
|
|
866
|
-
hadMalformedLine = true;
|
|
867
|
-
}
|
|
868
917
|
} catch {
|
|
869
918
|
if (options?.strict) {
|
|
870
919
|
hadMalformedLine = true;
|
|
@@ -1017,7 +1066,12 @@ function readFileSegment(sessionFile: string, offset: number): string | null {
|
|
|
1017
1066
|
}
|
|
1018
1067
|
}
|
|
1019
1068
|
|
|
1020
|
-
function readLastJsonlEntryBeforeOffset(
|
|
1069
|
+
function readLastJsonlEntryBeforeOffset(
|
|
1070
|
+
sessionFile: string,
|
|
1071
|
+
offset: number,
|
|
1072
|
+
messageOnly = false,
|
|
1073
|
+
matcher?: (message: AgentMessage) => boolean,
|
|
1074
|
+
): string | null {
|
|
1021
1075
|
const chunkSize = 16_384;
|
|
1022
1076
|
let fd: number | null = null;
|
|
1023
1077
|
try {
|
|
@@ -1029,16 +1083,23 @@ function readLastJsonlEntryBeforeOffset(sessionFile: string, offset: number): st
|
|
|
1029
1083
|
fd = openSync(sessionFile, "r");
|
|
1030
1084
|
let cursor = safeOffset;
|
|
1031
1085
|
let carry = "";
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1086
|
+
let reachedStart = false;
|
|
1087
|
+
while (cursor > 0 || (reachedStart && carry.length > 0)) {
|
|
1088
|
+
if (!reachedStart) {
|
|
1089
|
+
const start = Math.max(0, cursor - chunkSize);
|
|
1090
|
+
const length = cursor - start;
|
|
1091
|
+
const buffer = Buffer.alloc(length);
|
|
1092
|
+
readSync(fd, buffer, 0, length, start);
|
|
1093
|
+
carry = buffer.toString("utf8") + carry;
|
|
1094
|
+
cursor = start;
|
|
1095
|
+
if (start === 0) {
|
|
1096
|
+
reachedStart = true;
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1038
1099
|
|
|
1039
1100
|
const trimmedEnd = carry.replace(/\s+$/u, "");
|
|
1040
1101
|
if (!trimmedEnd) {
|
|
1041
|
-
|
|
1102
|
+
if (reachedStart) break;
|
|
1042
1103
|
carry = "";
|
|
1043
1104
|
continue;
|
|
1044
1105
|
}
|
|
@@ -1047,17 +1108,36 @@ function readLastJsonlEntryBeforeOffset(sessionFile: string, offset: number): st
|
|
|
1047
1108
|
if (newlineIndex >= 0) {
|
|
1048
1109
|
const candidate = trimmedEnd.slice(newlineIndex + 1).trim();
|
|
1049
1110
|
if (candidate) {
|
|
1111
|
+
if (messageOnly) {
|
|
1112
|
+
let matchedMessage: AgentMessage | null = null;
|
|
1113
|
+
try {
|
|
1114
|
+
matchedMessage = extractBootstrapMessageCandidate(JSON.parse(candidate));
|
|
1115
|
+
} catch { /* not valid JSON, skip */ }
|
|
1116
|
+
if (!matchedMessage || (matcher && !matcher(matchedMessage))) {
|
|
1117
|
+
carry = trimmedEnd.slice(0, newlineIndex);
|
|
1118
|
+
continue;
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1050
1121
|
return candidate;
|
|
1051
1122
|
}
|
|
1052
1123
|
carry = trimmedEnd.slice(0, newlineIndex);
|
|
1053
|
-
cursor = start;
|
|
1054
1124
|
continue;
|
|
1055
1125
|
}
|
|
1056
1126
|
|
|
1057
|
-
|
|
1058
|
-
|
|
1127
|
+
// No newline found — entire trimmedEnd is one line
|
|
1128
|
+
if (reachedStart) {
|
|
1129
|
+
const firstLine = trimmedEnd.trim() || null;
|
|
1130
|
+
if (firstLine && messageOnly) {
|
|
1131
|
+
let matchedMessage: AgentMessage | null = null;
|
|
1132
|
+
try {
|
|
1133
|
+
matchedMessage = extractBootstrapMessageCandidate(JSON.parse(firstLine));
|
|
1134
|
+
} catch { /* not valid JSON */ }
|
|
1135
|
+
if (!matchedMessage || (matcher && !matcher(matchedMessage))) return null;
|
|
1136
|
+
}
|
|
1137
|
+
return firstLine;
|
|
1059
1138
|
}
|
|
1060
|
-
|
|
1139
|
+
// Need more data from earlier in the file
|
|
1140
|
+
continue;
|
|
1061
1141
|
}
|
|
1062
1142
|
return null;
|
|
1063
1143
|
} catch {
|
|
@@ -1128,6 +1208,7 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1128
1208
|
|
|
1129
1209
|
private conversationStore: ConversationStore;
|
|
1130
1210
|
private summaryStore: SummaryStore;
|
|
1211
|
+
private compactionTelemetryStore: CompactionTelemetryStore;
|
|
1131
1212
|
private assembler: ContextAssembler;
|
|
1132
1213
|
private compaction: CompactionEngine;
|
|
1133
1214
|
private retrieval: RetrievalEngine;
|
|
@@ -1197,6 +1278,7 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1197
1278
|
fts5Available: this.fts5Available,
|
|
1198
1279
|
});
|
|
1199
1280
|
this.summaryStore = new SummaryStore(this.db, { fts5Available: this.fts5Available });
|
|
1281
|
+
this.compactionTelemetryStore = new CompactionTelemetryStore(this.db);
|
|
1200
1282
|
|
|
1201
1283
|
if (!this.fts5Available) {
|
|
1202
1284
|
this.deps.log.warn(
|
|
@@ -1242,6 +1324,7 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1242
1324
|
this.conversationStore,
|
|
1243
1325
|
this.summaryStore,
|
|
1244
1326
|
compactionConfig,
|
|
1327
|
+
this.deps.log,
|
|
1245
1328
|
);
|
|
1246
1329
|
|
|
1247
1330
|
this.retrieval = new RetrievalEngine(this.conversationStore, this.summaryStore);
|
|
@@ -1308,10 +1391,17 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1308
1391
|
private recordCompactionAuthFailure(key: string): void {
|
|
1309
1392
|
const state = this.getCircuitBreakerState(key);
|
|
1310
1393
|
state.failures++;
|
|
1394
|
+
const halfThreshold = Math.ceil(this.config.circuitBreakerThreshold / 2);
|
|
1395
|
+
if (state.failures === halfThreshold && state.failures < this.config.circuitBreakerThreshold) {
|
|
1396
|
+
this.deps.log.warn(
|
|
1397
|
+
`[lcm] WARNING: compaction degraded — ${state.failures}/${this.config.circuitBreakerThreshold} consecutive auth failures for ${key}`,
|
|
1398
|
+
);
|
|
1399
|
+
}
|
|
1311
1400
|
if (state.failures >= this.config.circuitBreakerThreshold) {
|
|
1312
1401
|
state.openSince = Date.now();
|
|
1313
|
-
|
|
1314
|
-
|
|
1402
|
+
const cooldownMin = Math.round(this.config.circuitBreakerCooldownMs / 60000);
|
|
1403
|
+
this.deps.log.warn(
|
|
1404
|
+
`[lcm] CIRCUIT BREAKER OPEN: compaction disabled for ${key}. Auto-retry in ${cooldownMin}m. LCM is operating in degraded mode.`,
|
|
1315
1405
|
);
|
|
1316
1406
|
}
|
|
1317
1407
|
}
|
|
@@ -1322,7 +1412,7 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1322
1412
|
return;
|
|
1323
1413
|
}
|
|
1324
1414
|
if (state.failures > 0 || state.openSince !== null) {
|
|
1325
|
-
|
|
1415
|
+
this.deps.log.info(
|
|
1326
1416
|
`[lcm] compaction circuit breaker CLOSED: successful compaction for ${key} after ${state.failures} prior failures.`,
|
|
1327
1417
|
);
|
|
1328
1418
|
}
|
|
@@ -1419,6 +1509,486 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1419
1509
|
return cap != null && cap > 0 ? Math.min(budget, cap) : budget;
|
|
1420
1510
|
}
|
|
1421
1511
|
|
|
1512
|
+
/** Normalize token counters that may legitimately be zero. */
|
|
1513
|
+
private normalizeOptionalCount(value: unknown): number | undefined {
|
|
1514
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
1515
|
+
return undefined;
|
|
1516
|
+
}
|
|
1517
|
+
return Math.floor(value);
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
/** Treat a recent cache hit as still-hot for a couple of turns unless telemetry observed a later break. */
|
|
1521
|
+
private shouldApplyHotCacheHysteresis(
|
|
1522
|
+
telemetry: ConversationCompactionTelemetryRecord | null,
|
|
1523
|
+
): boolean {
|
|
1524
|
+
if (!telemetry?.lastObservedCacheHitAt) {
|
|
1525
|
+
return false;
|
|
1526
|
+
}
|
|
1527
|
+
if (
|
|
1528
|
+
telemetry.lastObservedCacheBreakAt
|
|
1529
|
+
&& telemetry.lastObservedCacheBreakAt >= telemetry.lastObservedCacheHitAt
|
|
1530
|
+
) {
|
|
1531
|
+
return false;
|
|
1532
|
+
}
|
|
1533
|
+
return telemetry.turnsSinceLeafCompaction <= HOT_CACHE_HYSTERESIS_TURNS;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
/** Resolve the effective cache state the incremental compaction policy should react to. */
|
|
1537
|
+
private resolveCacheAwareState(
|
|
1538
|
+
telemetry: ConversationCompactionTelemetryRecord | null,
|
|
1539
|
+
): CacheState {
|
|
1540
|
+
if (!telemetry) {
|
|
1541
|
+
return "unknown";
|
|
1542
|
+
}
|
|
1543
|
+
if (telemetry.cacheState === "hot") {
|
|
1544
|
+
return "hot";
|
|
1545
|
+
}
|
|
1546
|
+
if (this.shouldApplyHotCacheHysteresis(telemetry)) {
|
|
1547
|
+
return "hot";
|
|
1548
|
+
}
|
|
1549
|
+
return telemetry.cacheState;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
/** Decide whether a hot cache still has enough real token-budget headroom to skip incremental maintenance. */
|
|
1553
|
+
private isComfortablyUnderTokenBudget(params: {
|
|
1554
|
+
currentTokenCount?: number;
|
|
1555
|
+
tokenBudget: number;
|
|
1556
|
+
}): boolean {
|
|
1557
|
+
if (
|
|
1558
|
+
typeof params.currentTokenCount !== "number"
|
|
1559
|
+
|| !Number.isFinite(params.currentTokenCount)
|
|
1560
|
+
|| params.currentTokenCount < 0
|
|
1561
|
+
) {
|
|
1562
|
+
return false;
|
|
1563
|
+
}
|
|
1564
|
+
const budget = Math.max(1, Math.floor(params.tokenBudget));
|
|
1565
|
+
const safeBudget = Math.floor(
|
|
1566
|
+
budget * (1 - this.config.cacheAwareCompaction.hotCacheBudgetHeadroomRatio),
|
|
1567
|
+
);
|
|
1568
|
+
return params.currentTokenCount <= safeBudget;
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
/** Resolve bounded dynamic leaf chunk sizes from config and the active token budget. */
|
|
1572
|
+
private resolveDynamicLeafChunkBounds(tokenBudget?: number): DynamicLeafChunkBounds {
|
|
1573
|
+
const floor = Math.max(1, Math.floor(this.config.leafChunkTokens));
|
|
1574
|
+
const configuredMax = this.config.dynamicLeafChunkTokens.enabled
|
|
1575
|
+
? Math.max(floor, Math.floor(this.config.dynamicLeafChunkTokens.max))
|
|
1576
|
+
: floor;
|
|
1577
|
+
const budgetCap =
|
|
1578
|
+
typeof tokenBudget === "number" &&
|
|
1579
|
+
Number.isFinite(tokenBudget) &&
|
|
1580
|
+
tokenBudget > 0
|
|
1581
|
+
? Math.max(floor, Math.floor(tokenBudget * this.config.contextThreshold))
|
|
1582
|
+
: configuredMax;
|
|
1583
|
+
const max = Math.max(floor, Math.min(configuredMax, budgetCap));
|
|
1584
|
+
const medium = Math.max(
|
|
1585
|
+
floor,
|
|
1586
|
+
Math.min(max, Math.floor(floor * DYNAMIC_LEAF_CHUNK_MEDIUM_MULTIPLIER)),
|
|
1587
|
+
);
|
|
1588
|
+
const high = Math.max(
|
|
1589
|
+
floor,
|
|
1590
|
+
Math.min(max, Math.floor(floor * DYNAMIC_LEAF_CHUNK_HIGH_MULTIPLIER)),
|
|
1591
|
+
);
|
|
1592
|
+
return { floor, medium, high, max };
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
/** Classify the current refill rate into a simple step band with downshift hysteresis. */
|
|
1596
|
+
private classifyDynamicLeafActivityBand(params: {
|
|
1597
|
+
lastActivityBand?: ActivityBand;
|
|
1598
|
+
tokensAccumulatedSinceLeafCompaction: number;
|
|
1599
|
+
turnsSinceLeafCompaction: number;
|
|
1600
|
+
floor: number;
|
|
1601
|
+
}): ActivityBand {
|
|
1602
|
+
const turns = Math.max(1, params.turnsSinceLeafCompaction);
|
|
1603
|
+
const tokensPerTurn = params.tokensAccumulatedSinceLeafCompaction / turns;
|
|
1604
|
+
const mediumUpshift = params.floor * DYNAMIC_ACTIVITY_MEDIUM_UPSHIFT_FACTOR;
|
|
1605
|
+
const mediumDownshift = params.floor * DYNAMIC_ACTIVITY_MEDIUM_DOWNSHIFT_FACTOR;
|
|
1606
|
+
const highUpshift = params.floor * DYNAMIC_ACTIVITY_HIGH_UPSHIFT_FACTOR;
|
|
1607
|
+
const highDownshift = params.floor * DYNAMIC_ACTIVITY_HIGH_DOWNSHIFT_FACTOR;
|
|
1608
|
+
const lastBand = params.lastActivityBand ?? "low";
|
|
1609
|
+
|
|
1610
|
+
if (lastBand === "high") {
|
|
1611
|
+
if (tokensPerTurn >= highDownshift) {
|
|
1612
|
+
return "high";
|
|
1613
|
+
}
|
|
1614
|
+
return tokensPerTurn >= mediumDownshift ? "medium" : "low";
|
|
1615
|
+
}
|
|
1616
|
+
if (lastBand === "medium") {
|
|
1617
|
+
if (tokensPerTurn >= highUpshift) {
|
|
1618
|
+
return "high";
|
|
1619
|
+
}
|
|
1620
|
+
if (tokensPerTurn < mediumDownshift) {
|
|
1621
|
+
return "low";
|
|
1622
|
+
}
|
|
1623
|
+
return "medium";
|
|
1624
|
+
}
|
|
1625
|
+
if (tokensPerTurn >= highUpshift) {
|
|
1626
|
+
return "high";
|
|
1627
|
+
}
|
|
1628
|
+
if (tokensPerTurn >= mediumUpshift) {
|
|
1629
|
+
return "medium";
|
|
1630
|
+
}
|
|
1631
|
+
return "low";
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
/** Map an activity band to the corresponding working leaf chunk size. */
|
|
1635
|
+
private resolveLeafChunkTokensForBand(
|
|
1636
|
+
band: ActivityBand,
|
|
1637
|
+
bounds: DynamicLeafChunkBounds,
|
|
1638
|
+
): number {
|
|
1639
|
+
switch (band) {
|
|
1640
|
+
case "high":
|
|
1641
|
+
return bounds.high;
|
|
1642
|
+
case "medium":
|
|
1643
|
+
return bounds.medium;
|
|
1644
|
+
default:
|
|
1645
|
+
return bounds.floor;
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
/** Build descending fallback chunk sizes used when a provider rejects a larger chunk. */
|
|
1650
|
+
private buildLeafChunkFallbacks(params: {
|
|
1651
|
+
preferred: number;
|
|
1652
|
+
bounds: DynamicLeafChunkBounds;
|
|
1653
|
+
}): number[] {
|
|
1654
|
+
const ordered = [params.preferred, params.bounds.max, params.bounds.high, params.bounds.medium, params.bounds.floor];
|
|
1655
|
+
const seen = new Set<number>();
|
|
1656
|
+
const fallbacks: number[] = [];
|
|
1657
|
+
for (const value of ordered) {
|
|
1658
|
+
const normalized = Math.max(params.bounds.floor, Math.floor(value));
|
|
1659
|
+
if (seen.has(normalized)) {
|
|
1660
|
+
continue;
|
|
1661
|
+
}
|
|
1662
|
+
seen.add(normalized);
|
|
1663
|
+
fallbacks.push(normalized);
|
|
1664
|
+
}
|
|
1665
|
+
return fallbacks.sort((a, b) => b - a);
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
/** Detect provider/model token-limit failures that should trigger a lower chunk retry. */
|
|
1669
|
+
private isRecoverableLeafChunkOverflowError(error: unknown): boolean {
|
|
1670
|
+
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
1671
|
+
if (!message) {
|
|
1672
|
+
return false;
|
|
1673
|
+
}
|
|
1674
|
+
return [
|
|
1675
|
+
"context length",
|
|
1676
|
+
"context window",
|
|
1677
|
+
"maximum context",
|
|
1678
|
+
"max context",
|
|
1679
|
+
"too many tokens",
|
|
1680
|
+
"too many input tokens",
|
|
1681
|
+
"input tokens",
|
|
1682
|
+
"token limit",
|
|
1683
|
+
"context limit",
|
|
1684
|
+
"input is too large",
|
|
1685
|
+
"input too large",
|
|
1686
|
+
"prompt is too long",
|
|
1687
|
+
"request too large",
|
|
1688
|
+
"exceeds the model",
|
|
1689
|
+
"exceeds context",
|
|
1690
|
+
].some((fragment) => message.includes(fragment));
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
/** Extract the current prompt-cache snapshot from runtime context, if present. */
|
|
1694
|
+
private readPromptCacheSnapshot(runtimeContext?: Record<string, unknown>): PromptCacheSnapshot | null {
|
|
1695
|
+
const promptCache = asRecord(runtimeContext?.promptCache);
|
|
1696
|
+
if (!promptCache) {
|
|
1697
|
+
return null;
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
const lastCallUsage = asRecord(promptCache.lastCallUsage);
|
|
1701
|
+
const observation = asRecord(promptCache.observation);
|
|
1702
|
+
const cacheRead = this.normalizeOptionalCount(lastCallUsage?.cacheRead);
|
|
1703
|
+
const cacheWrite = this.normalizeOptionalCount(lastCallUsage?.cacheWrite);
|
|
1704
|
+
const sawExplicitBreak = safeBoolean(observation?.broke) === true;
|
|
1705
|
+
const retention = safeString(promptCache.retention)?.trim();
|
|
1706
|
+
const hasUsageSignal = cacheRead !== undefined || cacheWrite !== undefined;
|
|
1707
|
+
const hasObservationSignal =
|
|
1708
|
+
typeof observation?.cacheRead === "number"
|
|
1709
|
+
|| typeof observation?.previousCacheRead === "number"
|
|
1710
|
+
|| sawExplicitBreak;
|
|
1711
|
+
|
|
1712
|
+
let cacheState: CacheState = "unknown";
|
|
1713
|
+
if (sawExplicitBreak) {
|
|
1714
|
+
cacheState = "cold";
|
|
1715
|
+
} else if (typeof cacheRead === "number" && cacheRead > 0) {
|
|
1716
|
+
cacheState = "hot";
|
|
1717
|
+
} else if (hasUsageSignal || hasObservationSignal) {
|
|
1718
|
+
cacheState = "cold";
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
return {
|
|
1722
|
+
...(cacheRead !== undefined ? { lastObservedCacheRead: cacheRead } : {}),
|
|
1723
|
+
...(cacheWrite !== undefined ? { lastObservedCacheWrite: cacheWrite } : {}),
|
|
1724
|
+
cacheState,
|
|
1725
|
+
...(retention ? { retention } : {}),
|
|
1726
|
+
sawExplicitBreak,
|
|
1727
|
+
};
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
/** Persist the current turn's compaction telemetry for later policy decisions. */
|
|
1731
|
+
private async updateCompactionTelemetry(params: {
|
|
1732
|
+
conversationId: number;
|
|
1733
|
+
runtimeContext?: Record<string, unknown>;
|
|
1734
|
+
tokenBudget?: number;
|
|
1735
|
+
rawTokensOutsideTail?: number;
|
|
1736
|
+
}): Promise<ConversationCompactionTelemetryRecord | null> {
|
|
1737
|
+
const snapshot = this.readPromptCacheSnapshot(params.runtimeContext);
|
|
1738
|
+
const existing = await this.compactionTelemetryStore.getConversationCompactionTelemetry(
|
|
1739
|
+
params.conversationId,
|
|
1740
|
+
);
|
|
1741
|
+
if (!snapshot && params.rawTokensOutsideTail === undefined) {
|
|
1742
|
+
return existing;
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
const now = new Date();
|
|
1746
|
+
const bounds = this.resolveDynamicLeafChunkBounds(params.tokenBudget);
|
|
1747
|
+
const turnsSinceLeafCompaction =
|
|
1748
|
+
(existing?.turnsSinceLeafCompaction ?? 0) + 1;
|
|
1749
|
+
const tokensAccumulatedSinceLeafCompaction =
|
|
1750
|
+
params.rawTokensOutsideTail ?? existing?.tokensAccumulatedSinceLeafCompaction ?? 0;
|
|
1751
|
+
const lastActivityBand = this.classifyDynamicLeafActivityBand({
|
|
1752
|
+
lastActivityBand: existing?.lastActivityBand,
|
|
1753
|
+
tokensAccumulatedSinceLeafCompaction,
|
|
1754
|
+
turnsSinceLeafCompaction,
|
|
1755
|
+
floor: bounds.floor,
|
|
1756
|
+
});
|
|
1757
|
+
await this.compactionTelemetryStore.upsertConversationCompactionTelemetry({
|
|
1758
|
+
conversationId: params.conversationId,
|
|
1759
|
+
lastObservedCacheRead: snapshot?.lastObservedCacheRead ?? existing?.lastObservedCacheRead ?? null,
|
|
1760
|
+
lastObservedCacheWrite:
|
|
1761
|
+
snapshot?.lastObservedCacheWrite ?? existing?.lastObservedCacheWrite ?? null,
|
|
1762
|
+
lastObservedCacheHitAt:
|
|
1763
|
+
snapshot?.cacheState === "hot"
|
|
1764
|
+
? now
|
|
1765
|
+
: existing?.lastObservedCacheHitAt ?? null,
|
|
1766
|
+
lastObservedCacheBreakAt:
|
|
1767
|
+
snapshot?.sawExplicitBreak
|
|
1768
|
+
? now
|
|
1769
|
+
: existing?.lastObservedCacheBreakAt ?? null,
|
|
1770
|
+
cacheState: snapshot?.cacheState ?? existing?.cacheState ?? "unknown",
|
|
1771
|
+
retention: snapshot?.retention ?? existing?.retention ?? null,
|
|
1772
|
+
lastLeafCompactionAt: existing?.lastLeafCompactionAt ?? null,
|
|
1773
|
+
turnsSinceLeafCompaction,
|
|
1774
|
+
tokensAccumulatedSinceLeafCompaction,
|
|
1775
|
+
lastActivityBand,
|
|
1776
|
+
});
|
|
1777
|
+
const updated = await this.compactionTelemetryStore.getConversationCompactionTelemetry(
|
|
1778
|
+
params.conversationId,
|
|
1779
|
+
);
|
|
1780
|
+
if (updated) {
|
|
1781
|
+
this.deps.log.debug(
|
|
1782
|
+
`[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"}`,
|
|
1783
|
+
);
|
|
1784
|
+
}
|
|
1785
|
+
return updated;
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
/** Reset refill counters after any successful leaf-producing compaction. */
|
|
1789
|
+
private async markLeafCompactionTelemetrySuccess(params: {
|
|
1790
|
+
conversationId: number;
|
|
1791
|
+
activityBand?: ActivityBand;
|
|
1792
|
+
}): Promise<void> {
|
|
1793
|
+
const existing = await this.compactionTelemetryStore.getConversationCompactionTelemetry(
|
|
1794
|
+
params.conversationId,
|
|
1795
|
+
);
|
|
1796
|
+
await this.compactionTelemetryStore.upsertConversationCompactionTelemetry({
|
|
1797
|
+
conversationId: params.conversationId,
|
|
1798
|
+
lastObservedCacheRead: existing?.lastObservedCacheRead ?? null,
|
|
1799
|
+
lastObservedCacheWrite: existing?.lastObservedCacheWrite ?? null,
|
|
1800
|
+
lastObservedCacheHitAt: existing?.lastObservedCacheHitAt ?? null,
|
|
1801
|
+
lastObservedCacheBreakAt: existing?.lastObservedCacheBreakAt ?? null,
|
|
1802
|
+
cacheState: existing?.cacheState ?? "unknown",
|
|
1803
|
+
retention: existing?.retention ?? null,
|
|
1804
|
+
lastLeafCompactionAt: new Date(),
|
|
1805
|
+
turnsSinceLeafCompaction: 0,
|
|
1806
|
+
tokensAccumulatedSinceLeafCompaction: 0,
|
|
1807
|
+
lastActivityBand: params.activityBand ?? existing?.lastActivityBand ?? "low",
|
|
1808
|
+
});
|
|
1809
|
+
this.deps.log.debug(
|
|
1810
|
+
`[lcm] compaction telemetry reset after leaf compaction: conversation=${params.conversationId} cacheState=${existing?.cacheState ?? "unknown"} activityBand=${params.activityBand ?? existing?.lastActivityBand ?? "low"}`,
|
|
1811
|
+
);
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
/** Emit an operational trace for the incremental compaction policy decision. */
|
|
1815
|
+
private logIncrementalCompactionDecision(params: {
|
|
1816
|
+
conversationId: number;
|
|
1817
|
+
cacheState: CacheState;
|
|
1818
|
+
activityBand: ActivityBand;
|
|
1819
|
+
triggerLeafChunkTokens: number;
|
|
1820
|
+
preferredLeafChunkTokens: number;
|
|
1821
|
+
fallbackLeafChunkTokens: number[];
|
|
1822
|
+
rawTokensOutsideTail: number;
|
|
1823
|
+
threshold: number;
|
|
1824
|
+
shouldCompact: boolean;
|
|
1825
|
+
maxPasses: number;
|
|
1826
|
+
allowCondensedPasses: boolean;
|
|
1827
|
+
reason: string;
|
|
1828
|
+
}): IncrementalCompactionDecision {
|
|
1829
|
+
this.deps.log.info(
|
|
1830
|
+
`[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}`,
|
|
1831
|
+
);
|
|
1832
|
+
return {
|
|
1833
|
+
shouldCompact: params.shouldCompact,
|
|
1834
|
+
cacheState: params.cacheState,
|
|
1835
|
+
maxPasses: params.maxPasses,
|
|
1836
|
+
rawTokensOutsideTail: params.rawTokensOutsideTail,
|
|
1837
|
+
threshold: params.threshold,
|
|
1838
|
+
leafChunkTokens: params.preferredLeafChunkTokens,
|
|
1839
|
+
fallbackLeafChunkTokens: params.fallbackLeafChunkTokens,
|
|
1840
|
+
activityBand: params.activityBand,
|
|
1841
|
+
allowCondensedPasses: params.allowCondensedPasses,
|
|
1842
|
+
};
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
/** Resolve the cache-aware incremental-compaction policy for the current session. */
|
|
1846
|
+
private async evaluateIncrementalCompaction(params: {
|
|
1847
|
+
conversationId: number;
|
|
1848
|
+
tokenBudget: number;
|
|
1849
|
+
currentTokenCount?: number;
|
|
1850
|
+
}): Promise<IncrementalCompactionDecision> {
|
|
1851
|
+
const telemetry = await this.compactionTelemetryStore.getConversationCompactionTelemetry(
|
|
1852
|
+
params.conversationId,
|
|
1853
|
+
);
|
|
1854
|
+
const cacheState =
|
|
1855
|
+
this.config.cacheAwareCompaction.enabled
|
|
1856
|
+
? this.resolveCacheAwareState(telemetry)
|
|
1857
|
+
: "unknown";
|
|
1858
|
+
const bounds = this.resolveDynamicLeafChunkBounds(params.tokenBudget);
|
|
1859
|
+
const activityBand =
|
|
1860
|
+
this.config.dynamicLeafChunkTokens.enabled
|
|
1861
|
+
? this.classifyDynamicLeafActivityBand({
|
|
1862
|
+
lastActivityBand: telemetry?.lastActivityBand,
|
|
1863
|
+
tokensAccumulatedSinceLeafCompaction:
|
|
1864
|
+
telemetry?.tokensAccumulatedSinceLeafCompaction ?? 0,
|
|
1865
|
+
turnsSinceLeafCompaction: telemetry?.turnsSinceLeafCompaction ?? 0,
|
|
1866
|
+
floor: bounds.floor,
|
|
1867
|
+
})
|
|
1868
|
+
: "low";
|
|
1869
|
+
const triggerLeafChunkTokens =
|
|
1870
|
+
this.config.dynamicLeafChunkTokens.enabled && cacheState === "hot"
|
|
1871
|
+
? bounds.max
|
|
1872
|
+
: this.config.dynamicLeafChunkTokens.enabled
|
|
1873
|
+
? this.resolveLeafChunkTokensForBand(activityBand, bounds)
|
|
1874
|
+
: bounds.floor;
|
|
1875
|
+
const preferredLeafChunkTokens =
|
|
1876
|
+
this.config.cacheAwareCompaction.enabled && (cacheState === "cold" || cacheState === "hot")
|
|
1877
|
+
? bounds.max
|
|
1878
|
+
: triggerLeafChunkTokens;
|
|
1879
|
+
const fallbackLeafChunkTokens = this.buildLeafChunkFallbacks({
|
|
1880
|
+
preferred: preferredLeafChunkTokens,
|
|
1881
|
+
bounds,
|
|
1882
|
+
});
|
|
1883
|
+
const leafTrigger = await this.compaction.evaluateLeafTrigger(
|
|
1884
|
+
params.conversationId,
|
|
1885
|
+
triggerLeafChunkTokens,
|
|
1886
|
+
);
|
|
1887
|
+
if (!leafTrigger.shouldCompact) {
|
|
1888
|
+
return this.logIncrementalCompactionDecision({
|
|
1889
|
+
conversationId: params.conversationId,
|
|
1890
|
+
cacheState,
|
|
1891
|
+
activityBand,
|
|
1892
|
+
triggerLeafChunkTokens,
|
|
1893
|
+
preferredLeafChunkTokens,
|
|
1894
|
+
fallbackLeafChunkTokens,
|
|
1895
|
+
rawTokensOutsideTail: leafTrigger.rawTokensOutsideTail,
|
|
1896
|
+
threshold: leafTrigger.threshold,
|
|
1897
|
+
shouldCompact: false,
|
|
1898
|
+
maxPasses: 1,
|
|
1899
|
+
allowCondensedPasses: false,
|
|
1900
|
+
reason: "below-leaf-trigger",
|
|
1901
|
+
});
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
const budgetDecision = await this.compaction.evaluate(
|
|
1905
|
+
params.conversationId,
|
|
1906
|
+
params.tokenBudget,
|
|
1907
|
+
params.currentTokenCount,
|
|
1908
|
+
);
|
|
1909
|
+
if (budgetDecision.shouldCompact) {
|
|
1910
|
+
return this.logIncrementalCompactionDecision({
|
|
1911
|
+
conversationId: params.conversationId,
|
|
1912
|
+
cacheState,
|
|
1913
|
+
activityBand,
|
|
1914
|
+
triggerLeafChunkTokens,
|
|
1915
|
+
preferredLeafChunkTokens,
|
|
1916
|
+
fallbackLeafChunkTokens,
|
|
1917
|
+
rawTokensOutsideTail: leafTrigger.rawTokensOutsideTail,
|
|
1918
|
+
threshold: leafTrigger.threshold,
|
|
1919
|
+
shouldCompact: true,
|
|
1920
|
+
maxPasses: 1,
|
|
1921
|
+
allowCondensedPasses: true,
|
|
1922
|
+
reason: "budget-trigger",
|
|
1923
|
+
});
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
if (
|
|
1927
|
+
cacheState === "hot"
|
|
1928
|
+
&& this.isComfortablyUnderTokenBudget({
|
|
1929
|
+
currentTokenCount: params.currentTokenCount,
|
|
1930
|
+
tokenBudget: params.tokenBudget,
|
|
1931
|
+
})
|
|
1932
|
+
) {
|
|
1933
|
+
return this.logIncrementalCompactionDecision({
|
|
1934
|
+
conversationId: params.conversationId,
|
|
1935
|
+
cacheState,
|
|
1936
|
+
activityBand,
|
|
1937
|
+
triggerLeafChunkTokens,
|
|
1938
|
+
preferredLeafChunkTokens,
|
|
1939
|
+
fallbackLeafChunkTokens,
|
|
1940
|
+
rawTokensOutsideTail: leafTrigger.rawTokensOutsideTail,
|
|
1941
|
+
threshold: leafTrigger.threshold,
|
|
1942
|
+
shouldCompact: false,
|
|
1943
|
+
maxPasses: 1,
|
|
1944
|
+
allowCondensedPasses: false,
|
|
1945
|
+
reason: "hot-cache-budget-headroom",
|
|
1946
|
+
});
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
if (
|
|
1950
|
+
cacheState === "hot"
|
|
1951
|
+
&& leafTrigger.rawTokensOutsideTail
|
|
1952
|
+
< Math.floor(
|
|
1953
|
+
leafTrigger.threshold * this.config.cacheAwareCompaction.hotCachePressureFactor,
|
|
1954
|
+
)
|
|
1955
|
+
) {
|
|
1956
|
+
return this.logIncrementalCompactionDecision({
|
|
1957
|
+
conversationId: params.conversationId,
|
|
1958
|
+
cacheState,
|
|
1959
|
+
activityBand,
|
|
1960
|
+
triggerLeafChunkTokens,
|
|
1961
|
+
preferredLeafChunkTokens,
|
|
1962
|
+
fallbackLeafChunkTokens,
|
|
1963
|
+
rawTokensOutsideTail: leafTrigger.rawTokensOutsideTail,
|
|
1964
|
+
threshold: leafTrigger.threshold,
|
|
1965
|
+
shouldCompact: false,
|
|
1966
|
+
maxPasses: 1,
|
|
1967
|
+
allowCondensedPasses: false,
|
|
1968
|
+
reason: "hot-cache-defer",
|
|
1969
|
+
});
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
const maxPasses =
|
|
1973
|
+
cacheState === "cold"
|
|
1974
|
+
? Math.max(1, this.config.cacheAwareCompaction.maxColdCacheCatchupPasses)
|
|
1975
|
+
: 1;
|
|
1976
|
+
return this.logIncrementalCompactionDecision({
|
|
1977
|
+
conversationId: params.conversationId,
|
|
1978
|
+
cacheState,
|
|
1979
|
+
activityBand,
|
|
1980
|
+
triggerLeafChunkTokens,
|
|
1981
|
+
preferredLeafChunkTokens,
|
|
1982
|
+
fallbackLeafChunkTokens,
|
|
1983
|
+
rawTokensOutsideTail: leafTrigger.rawTokensOutsideTail,
|
|
1984
|
+
threshold: leafTrigger.threshold,
|
|
1985
|
+
shouldCompact: true,
|
|
1986
|
+
maxPasses,
|
|
1987
|
+
allowCondensedPasses: cacheState !== "hot",
|
|
1988
|
+
reason: cacheState === "cold" ? "cold-cache-catchup" : "leaf-trigger",
|
|
1989
|
+
});
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1422
1992
|
/** Resolve an LCM conversation id from a session key via the session store. */
|
|
1423
1993
|
private async resolveConversationIdForSessionKey(
|
|
1424
1994
|
sessionKey: string,
|
|
@@ -1448,6 +2018,23 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1448
2018
|
}
|
|
1449
2019
|
}
|
|
1450
2020
|
|
|
2021
|
+
/** Format stable session identifiers for LCM diagnostic logs. */
|
|
2022
|
+
private formatSessionLogContext(params: {
|
|
2023
|
+
conversationId: number;
|
|
2024
|
+
sessionId: string;
|
|
2025
|
+
sessionKey?: string;
|
|
2026
|
+
}): string {
|
|
2027
|
+
const parts = [
|
|
2028
|
+
`conversation=${params.conversationId}`,
|
|
2029
|
+
`session=${params.sessionId}`,
|
|
2030
|
+
];
|
|
2031
|
+
const trimmedSessionKey = params.sessionKey?.trim();
|
|
2032
|
+
if (trimmedSessionKey) {
|
|
2033
|
+
parts.push(`sessionKey=${trimmedSessionKey}`);
|
|
2034
|
+
}
|
|
2035
|
+
return parts.join(" ");
|
|
2036
|
+
}
|
|
2037
|
+
|
|
1451
2038
|
/** Build a summarize callback with runtime provider fallback handling. */
|
|
1452
2039
|
private async resolveSummarize(params: {
|
|
1453
2040
|
legacyParams?: Record<string, unknown>;
|
|
@@ -1483,11 +2070,13 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1483
2070
|
breakerKey: runtimeSummarizer.breakerKey,
|
|
1484
2071
|
};
|
|
1485
2072
|
}
|
|
1486
|
-
|
|
2073
|
+
this.deps.log.error(`[lcm] resolveSummarize: createLcmSummarizeFromLegacyParams returned undefined`);
|
|
1487
2074
|
} catch (err) {
|
|
1488
|
-
|
|
2075
|
+
this.deps.log.error(
|
|
2076
|
+
`[lcm] resolveSummarize failed, using emergency fallback: ${describeLogError(err)}`,
|
|
2077
|
+
);
|
|
1489
2078
|
}
|
|
1490
|
-
|
|
2079
|
+
this.deps.log.error(`[lcm] resolveSummarize: FALLING BACK TO EMERGENCY TRUNCATION`);
|
|
1491
2080
|
return { summarize: createEmergencyFallbackSummarize(), summaryModel: "unknown" };
|
|
1492
2081
|
}
|
|
1493
2082
|
|
|
@@ -1826,17 +2415,18 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1826
2415
|
conversationId: number;
|
|
1827
2416
|
historicalMessages: AgentMessage[];
|
|
1828
2417
|
}): Promise<{
|
|
2418
|
+
blockedByImportCap: boolean;
|
|
1829
2419
|
importedMessages: number;
|
|
1830
2420
|
hasOverlap: boolean;
|
|
1831
2421
|
}> {
|
|
1832
2422
|
const { sessionId, conversationId, historicalMessages } = params;
|
|
1833
2423
|
if (historicalMessages.length === 0) {
|
|
1834
|
-
return { importedMessages: 0, hasOverlap: false };
|
|
2424
|
+
return { blockedByImportCap: false, importedMessages: 0, hasOverlap: false };
|
|
1835
2425
|
}
|
|
1836
2426
|
|
|
1837
2427
|
const latestDbMessage = await this.conversationStore.getLastMessage(conversationId);
|
|
1838
2428
|
if (!latestDbMessage) {
|
|
1839
|
-
return { importedMessages: 0, hasOverlap: false };
|
|
2429
|
+
return { blockedByImportCap: false, importedMessages: 0, hasOverlap: false };
|
|
1840
2430
|
}
|
|
1841
2431
|
|
|
1842
2432
|
const storedHistoricalMessages = historicalMessages.map((message) => toStoredMessage(message));
|
|
@@ -1857,7 +2447,7 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1857
2447
|
}
|
|
1858
2448
|
}
|
|
1859
2449
|
if (dbOccurrences === historicalOccurrences) {
|
|
1860
|
-
return { importedMessages: 0, hasOverlap: true };
|
|
2450
|
+
return { blockedByImportCap: false, importedMessages: 0, hasOverlap: true };
|
|
1861
2451
|
}
|
|
1862
2452
|
}
|
|
1863
2453
|
|
|
@@ -1909,13 +2499,27 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1909
2499
|
}
|
|
1910
2500
|
|
|
1911
2501
|
if (anchorIndex < 0) {
|
|
1912
|
-
return { importedMessages: 0, hasOverlap: false };
|
|
2502
|
+
return { blockedByImportCap: false, importedMessages: 0, hasOverlap: false };
|
|
1913
2503
|
}
|
|
1914
2504
|
if (anchorIndex >= historicalMessages.length - 1) {
|
|
1915
|
-
return { importedMessages: 0, hasOverlap: true };
|
|
2505
|
+
return { blockedByImportCap: false, importedMessages: 0, hasOverlap: true };
|
|
1916
2506
|
}
|
|
1917
2507
|
|
|
1918
2508
|
const missingTail = historicalMessages.slice(anchorIndex + 1);
|
|
2509
|
+
|
|
2510
|
+
const existingDbCount = await this.conversationStore.getMessageCount(conversationId);
|
|
2511
|
+
if (existingDbCount > 0 && missingTail.length > Math.max(existingDbCount * 0.2, 50)) {
|
|
2512
|
+
const sessionContext = this.formatSessionLogContext({
|
|
2513
|
+
conversationId,
|
|
2514
|
+
sessionId,
|
|
2515
|
+
sessionKey: params.sessionKey,
|
|
2516
|
+
});
|
|
2517
|
+
this.deps.log.warn(
|
|
2518
|
+
`[lcm] reconcileSessionTail: import cap exceeded for ${sessionContext} — would import ${missingTail.length} messages (existing: ${existingDbCount}). Aborting to prevent flood.`,
|
|
2519
|
+
);
|
|
2520
|
+
return { blockedByImportCap: true, importedMessages: 0, hasOverlap: true };
|
|
2521
|
+
}
|
|
2522
|
+
|
|
1919
2523
|
let importedMessages = 0;
|
|
1920
2524
|
for (const message of missingTail) {
|
|
1921
2525
|
const result = await this.ingestSingle({ sessionId, sessionKey: params.sessionKey, message });
|
|
@@ -1924,7 +2528,38 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1924
2528
|
}
|
|
1925
2529
|
}
|
|
1926
2530
|
|
|
1927
|
-
return { importedMessages, hasOverlap: true };
|
|
2531
|
+
return { blockedByImportCap: false, importedMessages, hasOverlap: true };
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
/**
|
|
2535
|
+
* Persist bootstrap checkpoint metadata anchored to the current DB frontier.
|
|
2536
|
+
*
|
|
2537
|
+
* We intentionally checkpoint the session file's current EOF while hashing the
|
|
2538
|
+
* latest persisted DB message. This keeps append-only recovery aligned with the
|
|
2539
|
+
* canonical LCM frontier even when trailing transcript entries are pruned or
|
|
2540
|
+
* otherwise noncanonical.
|
|
2541
|
+
*/
|
|
2542
|
+
private async refreshBootstrapState(params: {
|
|
2543
|
+
conversationId: number;
|
|
2544
|
+
sessionFile: string;
|
|
2545
|
+
fileStats?: { size: number; mtimeMs: number };
|
|
2546
|
+
}): Promise<void> {
|
|
2547
|
+
const latestDbMessage = await this.conversationStore.getLastMessage(params.conversationId);
|
|
2548
|
+
const fileStats = params.fileStats ?? statSync(params.sessionFile);
|
|
2549
|
+
await this.summaryStore.upsertConversationBootstrapState({
|
|
2550
|
+
conversationId: params.conversationId,
|
|
2551
|
+
sessionFilePath: params.sessionFile,
|
|
2552
|
+
lastSeenSize: fileStats.size,
|
|
2553
|
+
lastSeenMtimeMs: Math.trunc(fileStats.mtimeMs),
|
|
2554
|
+
lastProcessedOffset: fileStats.size,
|
|
2555
|
+
lastProcessedEntryHash: latestDbMessage
|
|
2556
|
+
? createBootstrapEntryHash({
|
|
2557
|
+
role: latestDbMessage.role,
|
|
2558
|
+
content: latestDbMessage.content,
|
|
2559
|
+
tokenCount: latestDbMessage.tokenCount,
|
|
2560
|
+
})
|
|
2561
|
+
: null,
|
|
2562
|
+
});
|
|
1928
2563
|
}
|
|
1929
2564
|
|
|
1930
2565
|
async bootstrap(params: {
|
|
@@ -1957,19 +2592,14 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1957
2592
|
this.conversationStore.withTransaction(async () => {
|
|
1958
2593
|
const persistBootstrapState = async (
|
|
1959
2594
|
conversationId: number,
|
|
1960
|
-
historicalMessages: AgentMessage[],
|
|
1961
2595
|
): Promise<void> => {
|
|
1962
|
-
|
|
1963
|
-
historicalMessages.length > 0
|
|
1964
|
-
? toStoredMessage(historicalMessages[historicalMessages.length - 1]!)
|
|
1965
|
-
: null;
|
|
1966
|
-
await this.summaryStore.upsertConversationBootstrapState({
|
|
2596
|
+
await this.refreshBootstrapState({
|
|
1967
2597
|
conversationId,
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
2598
|
+
sessionFile: params.sessionFile,
|
|
2599
|
+
fileStats: {
|
|
2600
|
+
size: sessionFileSize,
|
|
2601
|
+
mtimeMs: sessionFileMtimeMs,
|
|
2602
|
+
},
|
|
1973
2603
|
});
|
|
1974
2604
|
};
|
|
1975
2605
|
|
|
@@ -2019,6 +2649,8 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2019
2649
|
const tailEntryRaw = readLastJsonlEntryBeforeOffset(
|
|
2020
2650
|
params.sessionFile,
|
|
2021
2651
|
bootstrapState.lastProcessedOffset,
|
|
2652
|
+
true,
|
|
2653
|
+
(message) => createBootstrapEntryHash(toStoredMessage(message)) === latestDbHash,
|
|
2022
2654
|
);
|
|
2023
2655
|
const tailEntryMessage = readBootstrapMessageFromJsonLine(tailEntryRaw);
|
|
2024
2656
|
const tailEntryHash = tailEntryMessage
|
|
@@ -2052,14 +2684,7 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2052
2684
|
}
|
|
2053
2685
|
}
|
|
2054
2686
|
|
|
2055
|
-
|
|
2056
|
-
appended.messages.length > 0
|
|
2057
|
-
? appended.messages[appended.messages.length - 1]!
|
|
2058
|
-
: tailEntryMessage;
|
|
2059
|
-
await persistBootstrapState(
|
|
2060
|
-
conversationId,
|
|
2061
|
-
lastAppendedMessage ? [lastAppendedMessage] : [],
|
|
2062
|
-
);
|
|
2687
|
+
await persistBootstrapState(conversationId);
|
|
2063
2688
|
|
|
2064
2689
|
if (importedMessages > 0) {
|
|
2065
2690
|
return {
|
|
@@ -2090,7 +2715,7 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2090
2715
|
|
|
2091
2716
|
if (bootstrapMessages.length === 0) {
|
|
2092
2717
|
await this.conversationStore.markConversationBootstrapped(conversationId);
|
|
2093
|
-
await persistBootstrapState(conversationId
|
|
2718
|
+
await persistBootstrapState(conversationId);
|
|
2094
2719
|
return {
|
|
2095
2720
|
bootstrapped: false,
|
|
2096
2721
|
importedMessages: 0,
|
|
@@ -2116,18 +2741,19 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2116
2741
|
inserted.map((record) => record.messageId),
|
|
2117
2742
|
);
|
|
2118
2743
|
await this.conversationStore.markConversationBootstrapped(conversationId);
|
|
2119
|
-
await persistBootstrapState(conversationId, historicalMessages);
|
|
2120
2744
|
|
|
2121
2745
|
// Prune HEARTBEAT_OK turns from the freshly imported data
|
|
2122
2746
|
if (this.config.pruneHeartbeatOk) {
|
|
2123
2747
|
const pruned = await this.pruneHeartbeatOkTurns(conversationId);
|
|
2124
2748
|
if (pruned > 0) {
|
|
2125
|
-
|
|
2749
|
+
this.deps.log.info(
|
|
2126
2750
|
`[lcm] bootstrap: pruned ${pruned} HEARTBEAT_OK messages from conversation ${conversationId}`,
|
|
2127
2751
|
);
|
|
2128
2752
|
}
|
|
2129
2753
|
}
|
|
2130
2754
|
|
|
2755
|
+
await persistBootstrapState(conversationId);
|
|
2756
|
+
|
|
2131
2757
|
return {
|
|
2132
2758
|
bootstrapped: true,
|
|
2133
2759
|
importedMessages: inserted.length,
|
|
@@ -2143,12 +2769,20 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2143
2769
|
historicalMessages,
|
|
2144
2770
|
});
|
|
2145
2771
|
|
|
2772
|
+
if (reconcile.blockedByImportCap) {
|
|
2773
|
+
return {
|
|
2774
|
+
bootstrapped: false,
|
|
2775
|
+
importedMessages: 0,
|
|
2776
|
+
reason: "reconcile import capped",
|
|
2777
|
+
};
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2146
2780
|
if (!conversation.bootstrappedAt) {
|
|
2147
2781
|
await this.conversationStore.markConversationBootstrapped(conversationId);
|
|
2148
2782
|
}
|
|
2149
2783
|
|
|
2150
2784
|
if (reconcile.importedMessages > 0) {
|
|
2151
|
-
await persistBootstrapState(conversationId
|
|
2785
|
+
await persistBootstrapState(conversationId);
|
|
2152
2786
|
return {
|
|
2153
2787
|
bootstrapped: true,
|
|
2154
2788
|
importedMessages: reconcile.importedMessages,
|
|
@@ -2157,7 +2791,7 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2157
2791
|
}
|
|
2158
2792
|
|
|
2159
2793
|
if (reconcile.hasOverlap) {
|
|
2160
|
-
await persistBootstrapState(conversationId
|
|
2794
|
+
await persistBootstrapState(conversationId);
|
|
2161
2795
|
}
|
|
2162
2796
|
|
|
2163
2797
|
if (conversation.bootstrappedAt) {
|
|
@@ -2189,15 +2823,18 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2189
2823
|
if (conversation) {
|
|
2190
2824
|
const pruned = await this.pruneHeartbeatOkTurns(conversation.conversationId);
|
|
2191
2825
|
if (pruned > 0) {
|
|
2192
|
-
|
|
2826
|
+
await this.refreshBootstrapState({
|
|
2827
|
+
conversationId: conversation.conversationId,
|
|
2828
|
+
sessionFile: params.sessionFile,
|
|
2829
|
+
});
|
|
2830
|
+
this.deps.log.info(
|
|
2193
2831
|
`[lcm] bootstrap: retroactively pruned ${pruned} HEARTBEAT_OK messages from conversation ${conversation.conversationId}`,
|
|
2194
2832
|
);
|
|
2195
2833
|
}
|
|
2196
2834
|
}
|
|
2197
2835
|
} catch (err) {
|
|
2198
|
-
|
|
2199
|
-
`[lcm] bootstrap: heartbeat pruning failed
|
|
2200
|
-
err instanceof Error ? err.message : err,
|
|
2836
|
+
this.deps.log.warn(
|
|
2837
|
+
`[lcm] bootstrap: heartbeat pruning failed: ${describeLogError(err)}`,
|
|
2201
2838
|
);
|
|
2202
2839
|
}
|
|
2203
2840
|
}
|
|
@@ -2405,9 +3042,24 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2405
3042
|
};
|
|
2406
3043
|
}
|
|
2407
3044
|
|
|
2408
|
-
|
|
3045
|
+
const result = await params.runtimeContext.rewriteTranscriptEntries({
|
|
2409
3046
|
replacements,
|
|
2410
3047
|
});
|
|
3048
|
+
|
|
3049
|
+
if (result.changed) {
|
|
3050
|
+
try {
|
|
3051
|
+
await this.refreshBootstrapState({
|
|
3052
|
+
conversationId: conversation.conversationId,
|
|
3053
|
+
sessionFile: params.sessionFile,
|
|
3054
|
+
});
|
|
3055
|
+
} catch (e) {
|
|
3056
|
+
this.deps.log.warn(
|
|
3057
|
+
`[lcm] Failed to update bootstrap checkpoint after maintain: ${describeLogError(e)}`,
|
|
3058
|
+
);
|
|
3059
|
+
}
|
|
3060
|
+
}
|
|
3061
|
+
|
|
3062
|
+
return result;
|
|
2411
3063
|
},
|
|
2412
3064
|
);
|
|
2413
3065
|
}
|
|
@@ -2594,9 +3246,8 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2594
3246
|
});
|
|
2595
3247
|
} catch (err) {
|
|
2596
3248
|
// Never compact a stale or partially ingested frontier.
|
|
2597
|
-
|
|
2598
|
-
`[lcm] afterTurn: ingest failed, skipping compaction
|
|
2599
|
-
err instanceof Error ? err.message : err,
|
|
3249
|
+
this.deps.log.error(
|
|
3250
|
+
`[lcm] afterTurn: ingest failed, skipping compaction: ${describeLogError(err)}`,
|
|
2600
3251
|
);
|
|
2601
3252
|
return;
|
|
2602
3253
|
}
|
|
@@ -2610,16 +3261,30 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2610
3261
|
if (conversation) {
|
|
2611
3262
|
const pruned = await this.pruneHeartbeatOkTurns(conversation.conversationId);
|
|
2612
3263
|
if (pruned > 0) {
|
|
2613
|
-
|
|
2614
|
-
|
|
3264
|
+
const sessionContext = this.formatSessionLogContext({
|
|
3265
|
+
conversationId: conversation.conversationId,
|
|
3266
|
+
sessionId: params.sessionId,
|
|
3267
|
+
sessionKey: params.sessionKey,
|
|
3268
|
+
});
|
|
3269
|
+
try {
|
|
3270
|
+
await this.refreshBootstrapState({
|
|
3271
|
+
conversationId: conversation.conversationId,
|
|
3272
|
+
sessionFile: params.sessionFile,
|
|
3273
|
+
});
|
|
3274
|
+
} catch (err) {
|
|
3275
|
+
this.deps.log.warn(
|
|
3276
|
+
`[lcm] afterTurn: heartbeat pruning checkpoint refresh failed for ${sessionContext}: ${describeLogError(err)}`,
|
|
3277
|
+
);
|
|
3278
|
+
}
|
|
3279
|
+
this.deps.log.info(
|
|
3280
|
+
`[lcm] afterTurn: pruned ${pruned} heartbeat ack messages for ${sessionContext}`,
|
|
2615
3281
|
);
|
|
2616
3282
|
return;
|
|
2617
3283
|
}
|
|
2618
3284
|
}
|
|
2619
3285
|
} catch (err) {
|
|
2620
|
-
|
|
2621
|
-
`[lcm] afterTurn: heartbeat pruning failed
|
|
2622
|
-
err instanceof Error ? err.message : err,
|
|
3286
|
+
this.deps.log.warn(
|
|
3287
|
+
`[lcm] afterTurn: heartbeat pruning failed: ${describeLogError(err)}`,
|
|
2623
3288
|
);
|
|
2624
3289
|
}
|
|
2625
3290
|
}
|
|
@@ -2633,16 +3298,41 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2633
3298
|
});
|
|
2634
3299
|
const tokenBudget = this.applyAssemblyBudgetCap(resolvedTokenBudget ?? DEFAULT_AFTER_TURN_TOKEN_BUDGET);
|
|
2635
3300
|
if (resolvedTokenBudget === undefined) {
|
|
2636
|
-
|
|
3301
|
+
this.deps.log.warn(
|
|
2637
3302
|
`[lcm] afterTurn: tokenBudget not provided; using default ${DEFAULT_AFTER_TURN_TOKEN_BUDGET}`,
|
|
2638
3303
|
);
|
|
2639
3304
|
}
|
|
2640
3305
|
|
|
2641
3306
|
const liveContextTokens = estimateSessionTokenCountForAfterTurn(params.messages);
|
|
3307
|
+
const conversation = await this.conversationStore.getConversationForSession({
|
|
3308
|
+
sessionId: params.sessionId,
|
|
3309
|
+
sessionKey: params.sessionKey,
|
|
3310
|
+
});
|
|
3311
|
+
if (!conversation) {
|
|
3312
|
+
return;
|
|
3313
|
+
}
|
|
3314
|
+
|
|
3315
|
+
try {
|
|
3316
|
+
const rawLeafTrigger = await this.compaction.evaluateLeafTrigger(conversation.conversationId);
|
|
3317
|
+
await this.updateCompactionTelemetry({
|
|
3318
|
+
conversationId: conversation.conversationId,
|
|
3319
|
+
runtimeContext: asRecord(params.runtimeContext),
|
|
3320
|
+
tokenBudget,
|
|
3321
|
+
rawTokensOutsideTail: rawLeafTrigger.rawTokensOutsideTail,
|
|
3322
|
+
});
|
|
3323
|
+
} catch (err) {
|
|
3324
|
+
this.deps.log.warn(
|
|
3325
|
+
`[lcm] afterTurn: compaction telemetry update failed: ${describeLogError(err)}`,
|
|
3326
|
+
);
|
|
3327
|
+
}
|
|
2642
3328
|
|
|
2643
3329
|
try {
|
|
2644
|
-
const
|
|
2645
|
-
|
|
3330
|
+
const leafDecision = await this.evaluateIncrementalCompaction({
|
|
3331
|
+
conversationId: conversation.conversationId,
|
|
3332
|
+
tokenBudget,
|
|
3333
|
+
currentTokenCount: liveContextTokens,
|
|
3334
|
+
});
|
|
3335
|
+
if (leafDecision.shouldCompact) {
|
|
2646
3336
|
this.compactLeafAsync({
|
|
2647
3337
|
sessionId: params.sessionId,
|
|
2648
3338
|
sessionKey: params.sessionKey,
|
|
@@ -2650,6 +3340,11 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2650
3340
|
tokenBudget,
|
|
2651
3341
|
currentTokenCount: liveContextTokens,
|
|
2652
3342
|
legacyParams,
|
|
3343
|
+
maxPasses: leafDecision.maxPasses,
|
|
3344
|
+
leafChunkTokens: leafDecision.leafChunkTokens,
|
|
3345
|
+
fallbackLeafChunkTokens: leafDecision.fallbackLeafChunkTokens,
|
|
3346
|
+
activityBand: leafDecision.activityBand,
|
|
3347
|
+
allowCondensedPasses: leafDecision.allowCondensedPasses,
|
|
2653
3348
|
}).catch(() => {
|
|
2654
3349
|
// Leaf compaction is best-effort and should not fail the caller.
|
|
2655
3350
|
});
|
|
@@ -2787,7 +3482,7 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2787
3482
|
return this.compaction.evaluateLeafTrigger(conversation.conversationId);
|
|
2788
3483
|
}
|
|
2789
3484
|
|
|
2790
|
-
/** Run one incremental leaf compaction
|
|
3485
|
+
/** Run one or more incremental leaf compaction passes in the per-session queue. */
|
|
2791
3486
|
async compactLeafAsync(params: {
|
|
2792
3487
|
sessionId: string;
|
|
2793
3488
|
sessionKey?: string;
|
|
@@ -2801,6 +3496,11 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2801
3496
|
legacyParams?: Record<string, unknown>;
|
|
2802
3497
|
force?: boolean;
|
|
2803
3498
|
previousSummaryContent?: string;
|
|
3499
|
+
maxPasses?: number;
|
|
3500
|
+
leafChunkTokens?: number;
|
|
3501
|
+
fallbackLeafChunkTokens?: number[];
|
|
3502
|
+
activityBand?: ActivityBand;
|
|
3503
|
+
allowCondensedPasses?: boolean;
|
|
2804
3504
|
}): Promise<CompactResult> {
|
|
2805
3505
|
if (this.isStatelessSession(params.sessionKey)) {
|
|
2806
3506
|
return {
|
|
@@ -2864,38 +3564,114 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2864
3564
|
};
|
|
2865
3565
|
}
|
|
2866
3566
|
|
|
2867
|
-
const
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
3567
|
+
const storedTokensBefore = await this.summaryStore.getContextTokenCount(
|
|
3568
|
+
conversation.conversationId,
|
|
3569
|
+
);
|
|
3570
|
+
const maxPasses =
|
|
3571
|
+
typeof params.maxPasses === "number" &&
|
|
3572
|
+
Number.isFinite(params.maxPasses) &&
|
|
3573
|
+
params.maxPasses > 0
|
|
3574
|
+
? Math.floor(params.maxPasses)
|
|
3575
|
+
: 1;
|
|
3576
|
+
const fallbackLeafChunkTokens = Array.isArray(params.fallbackLeafChunkTokens)
|
|
3577
|
+
? [...new Set(params.fallbackLeafChunkTokens
|
|
3578
|
+
.filter((value): value is number => typeof value === "number" && Number.isFinite(value) && value > 0)
|
|
3579
|
+
.map((value) => Math.floor(value)))]
|
|
3580
|
+
.sort((a, b) => b - a)
|
|
3581
|
+
: [];
|
|
3582
|
+
let activeLeafChunkTokens =
|
|
3583
|
+
typeof params.leafChunkTokens === "number" &&
|
|
3584
|
+
Number.isFinite(params.leafChunkTokens) &&
|
|
3585
|
+
params.leafChunkTokens > 0
|
|
3586
|
+
? Math.floor(params.leafChunkTokens)
|
|
3587
|
+
: fallbackLeafChunkTokens[0];
|
|
3588
|
+
this.deps.log.info(
|
|
3589
|
+
`[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}`,
|
|
3590
|
+
);
|
|
3591
|
+
|
|
3592
|
+
let rounds = 0;
|
|
3593
|
+
let finalTokens = observedTokens ?? storedTokensBefore;
|
|
3594
|
+
let authFailure = false;
|
|
3595
|
+
|
|
3596
|
+
for (let pass = 0; pass < maxPasses; pass += 1) {
|
|
3597
|
+
let leafResult: Awaited<ReturnType<typeof this.compaction.compactLeaf>> | undefined;
|
|
3598
|
+
while (true) {
|
|
3599
|
+
try {
|
|
3600
|
+
leafResult = await this.compaction.compactLeaf({
|
|
3601
|
+
conversationId: conversation.conversationId,
|
|
3602
|
+
tokenBudget,
|
|
3603
|
+
summarize,
|
|
3604
|
+
...(activeLeafChunkTokens !== undefined ? { leafChunkTokens: activeLeafChunkTokens } : {}),
|
|
3605
|
+
force: params.force,
|
|
3606
|
+
previousSummaryContent: pass === 0 ? params.previousSummaryContent : undefined,
|
|
3607
|
+
summaryModel,
|
|
3608
|
+
allowCondensedPasses: params.allowCondensedPasses,
|
|
3609
|
+
});
|
|
3610
|
+
break;
|
|
3611
|
+
} catch (err) {
|
|
3612
|
+
const nextLeafChunkTokens = fallbackLeafChunkTokens.find(
|
|
3613
|
+
(value) => activeLeafChunkTokens !== undefined && value < activeLeafChunkTokens,
|
|
3614
|
+
);
|
|
3615
|
+
if (!this.isRecoverableLeafChunkOverflowError(err) || nextLeafChunkTokens === undefined) {
|
|
3616
|
+
throw err;
|
|
3617
|
+
}
|
|
3618
|
+
this.deps.log.warn(
|
|
3619
|
+
`[lcm] compactLeafAsync: retrying with smaller leafChunkTokens=${nextLeafChunkTokens} after provider token-limit error: ${err instanceof Error ? err.message : String(err)}`,
|
|
3620
|
+
);
|
|
3621
|
+
activeLeafChunkTokens = nextLeafChunkTokens;
|
|
3622
|
+
}
|
|
3623
|
+
}
|
|
3624
|
+
if (!leafResult) {
|
|
3625
|
+
break;
|
|
3626
|
+
}
|
|
3627
|
+
finalTokens = leafResult.tokensAfter;
|
|
3628
|
+
|
|
3629
|
+
if (leafResult.authFailure) {
|
|
3630
|
+
authFailure = true;
|
|
3631
|
+
break;
|
|
3632
|
+
}
|
|
3633
|
+
if (!leafResult.actionTaken) {
|
|
3634
|
+
break;
|
|
3635
|
+
}
|
|
3636
|
+
rounds += 1;
|
|
3637
|
+
if (leafResult.tokensAfter >= leafResult.tokensBefore) {
|
|
3638
|
+
break;
|
|
3639
|
+
}
|
|
3640
|
+
}
|
|
2875
3641
|
|
|
2876
|
-
if (
|
|
3642
|
+
if (authFailure && breakerKey) {
|
|
2877
3643
|
this.recordCompactionAuthFailure(breakerKey);
|
|
2878
|
-
} else if (
|
|
3644
|
+
} else if (rounds > 0 && breakerKey) {
|
|
2879
3645
|
this.recordCompactionSuccess(breakerKey);
|
|
2880
3646
|
}
|
|
3647
|
+
if (rounds > 0) {
|
|
3648
|
+
await this.markLeafCompactionTelemetrySuccess({
|
|
3649
|
+
conversationId: conversation.conversationId,
|
|
3650
|
+
activityBand: params.activityBand,
|
|
3651
|
+
});
|
|
3652
|
+
}
|
|
2881
3653
|
|
|
2882
|
-
const tokensBefore = observedTokens ??
|
|
3654
|
+
const tokensBefore = observedTokens ?? storedTokensBefore;
|
|
3655
|
+
this.deps.log.debug(
|
|
3656
|
+
`[lcm] compactLeafAsync result: conversation=${conversation.conversationId} session=${params.sessionId} rounds=${rounds} compacted=${rounds > 0} authFailure=${authFailure} finalLeafChunkTokens=${activeLeafChunkTokens ?? "null"} finalTokens=${finalTokens}`,
|
|
3657
|
+
);
|
|
2883
3658
|
|
|
2884
3659
|
return {
|
|
2885
3660
|
ok: true,
|
|
2886
|
-
compacted:
|
|
2887
|
-
reason:
|
|
3661
|
+
compacted: rounds > 0,
|
|
3662
|
+
reason: authFailure
|
|
2888
3663
|
? "provider auth failure"
|
|
2889
|
-
:
|
|
3664
|
+
: rounds > 0
|
|
2890
3665
|
? "compacted"
|
|
2891
3666
|
: "below threshold",
|
|
2892
3667
|
result: {
|
|
2893
3668
|
tokensBefore,
|
|
2894
|
-
tokensAfter:
|
|
3669
|
+
tokensAfter: finalTokens,
|
|
2895
3670
|
details: {
|
|
2896
|
-
rounds
|
|
3671
|
+
rounds,
|
|
2897
3672
|
targetTokens: tokenBudget,
|
|
2898
3673
|
mode: "leaf",
|
|
3674
|
+
maxPasses,
|
|
2899
3675
|
},
|
|
2900
3676
|
},
|
|
2901
3677
|
};
|
|
@@ -3024,7 +3800,7 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
3024
3800
|
// overflow counts can drive recovery even when persisted context is already small.
|
|
3025
3801
|
const useSweep = manualCompactionRequested || params.compactionTarget === "threshold";
|
|
3026
3802
|
if (useSweep) {
|
|
3027
|
-
const sweepResult = await this.compaction.
|
|
3803
|
+
const sweepResult = await this.compaction.compact({
|
|
3028
3804
|
conversationId,
|
|
3029
3805
|
tokenBudget,
|
|
3030
3806
|
summarize,
|
|
@@ -3038,6 +3814,9 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
3038
3814
|
} else if (sweepResult.actionTaken && breakerKey) {
|
|
3039
3815
|
this.recordCompactionSuccess(breakerKey);
|
|
3040
3816
|
}
|
|
3817
|
+
if (sweepResult.actionTaken) {
|
|
3818
|
+
await this.markLeafCompactionTelemetrySuccess({ conversationId });
|
|
3819
|
+
}
|
|
3041
3820
|
|
|
3042
3821
|
return {
|
|
3043
3822
|
ok: !sweepResult.authFailure && (sweepResult.actionTaken || !liveContextStillExceedsTarget),
|
|
@@ -3087,6 +3866,9 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
3087
3866
|
}
|
|
3088
3867
|
|
|
3089
3868
|
const didCompact = compactResult.rounds > 0;
|
|
3869
|
+
if (didCompact) {
|
|
3870
|
+
await this.markLeafCompactionTelemetrySuccess({ conversationId });
|
|
3871
|
+
}
|
|
3090
3872
|
|
|
3091
3873
|
return {
|
|
3092
3874
|
ok: compactResult.success,
|
|
@@ -3219,6 +4001,69 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
3219
4001
|
// The shared connection is managed for the lifetime of the plugin process.
|
|
3220
4002
|
}
|
|
3221
4003
|
|
|
4004
|
+
/** Detect the empty replacement row created during a prior lifecycle rollover. */
|
|
4005
|
+
private async isFreshLifecycleConversation(conversation: ConversationRecord): Promise<boolean> {
|
|
4006
|
+
const currentMessageCount = await this.conversationStore.getMessageCount(conversation.conversationId);
|
|
4007
|
+
if (currentMessageCount !== 0) {
|
|
4008
|
+
return false;
|
|
4009
|
+
}
|
|
4010
|
+
const currentContextItems = await this.summaryStore.getContextItems(conversation.conversationId);
|
|
4011
|
+
return currentContextItems.length === 0 && !conversation.bootstrappedAt;
|
|
4012
|
+
}
|
|
4013
|
+
|
|
4014
|
+
/**
|
|
4015
|
+
* Archive the current active conversation and optionally create the replacement
|
|
4016
|
+
* row that bootstrap should attach to for the next session transcript.
|
|
4017
|
+
*/
|
|
4018
|
+
private async applySessionReplacement(params: {
|
|
4019
|
+
reason: string;
|
|
4020
|
+
sessionId?: string;
|
|
4021
|
+
sessionKey?: string;
|
|
4022
|
+
nextSessionId?: string;
|
|
4023
|
+
nextSessionKey?: string;
|
|
4024
|
+
createReplacement: boolean;
|
|
4025
|
+
createReplacementWhenMissing?: boolean;
|
|
4026
|
+
}): Promise<void> {
|
|
4027
|
+
const current = await this.conversationStore.getConversationForSession({
|
|
4028
|
+
sessionId: params.sessionId,
|
|
4029
|
+
sessionKey: params.sessionKey,
|
|
4030
|
+
});
|
|
4031
|
+
if (!current && !params.createReplacementWhenMissing) {
|
|
4032
|
+
return;
|
|
4033
|
+
}
|
|
4034
|
+
|
|
4035
|
+
if (current?.active) {
|
|
4036
|
+
if (params.createReplacement && await this.isFreshLifecycleConversation(current)) {
|
|
4037
|
+
this.deps.log.info(
|
|
4038
|
+
`[lcm] ${params.reason} lifecycle no-op for already fresh conversation ${current.conversationId}`,
|
|
4039
|
+
);
|
|
4040
|
+
return;
|
|
4041
|
+
}
|
|
4042
|
+
await this.conversationStore.archiveConversation(current.conversationId);
|
|
4043
|
+
}
|
|
4044
|
+
|
|
4045
|
+
if (!params.createReplacement) {
|
|
4046
|
+
this.deps.log.info(
|
|
4047
|
+
`[lcm] ${params.reason} lifecycle archived conversation ${current?.conversationId ?? "(none)"}`,
|
|
4048
|
+
);
|
|
4049
|
+
return;
|
|
4050
|
+
}
|
|
4051
|
+
|
|
4052
|
+
const nextSessionId = params.nextSessionId?.trim() || params.sessionId?.trim() || current?.sessionId;
|
|
4053
|
+
if (!nextSessionId) {
|
|
4054
|
+
this.deps.log.warn(`[lcm] ${params.reason} lifecycle skipped: no session identity available`);
|
|
4055
|
+
return;
|
|
4056
|
+
}
|
|
4057
|
+
const nextSessionKey = params.nextSessionKey?.trim() || params.sessionKey?.trim() || current?.sessionKey;
|
|
4058
|
+
const freshConversation = await this.conversationStore.createConversation({
|
|
4059
|
+
sessionId: nextSessionId,
|
|
4060
|
+
...(nextSessionKey ? { sessionKey: nextSessionKey } : {}),
|
|
4061
|
+
});
|
|
4062
|
+
this.deps.log.info(
|
|
4063
|
+
`[lcm] ${params.reason} lifecycle archived prior conversation and created ${freshConversation.conversationId}`,
|
|
4064
|
+
);
|
|
4065
|
+
}
|
|
4066
|
+
|
|
3222
4067
|
/** Apply LCM lifecycle semantics for OpenClaw's /new and /reset commands. */
|
|
3223
4068
|
async handleBeforeReset(params: {
|
|
3224
4069
|
reason?: string;
|
|
@@ -3261,44 +4106,50 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
3261
4106
|
);
|
|
3262
4107
|
return;
|
|
3263
4108
|
}
|
|
3264
|
-
|
|
3265
|
-
|
|
4109
|
+
await this.applySessionReplacement({
|
|
4110
|
+
reason: "/reset",
|
|
3266
4111
|
sessionId: params.sessionId,
|
|
3267
4112
|
sessionKey: params.sessionKey,
|
|
4113
|
+
createReplacement: true,
|
|
4114
|
+
createReplacementWhenMissing: true,
|
|
3268
4115
|
});
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
);
|
|
3273
|
-
const currentContextItems = await this.summaryStore.getContextItems(
|
|
3274
|
-
current.conversationId,
|
|
3275
|
-
);
|
|
3276
|
-
if (
|
|
3277
|
-
currentMessageCount === 0
|
|
3278
|
-
&& currentContextItems.length === 0
|
|
3279
|
-
&& !current.bootstrappedAt
|
|
3280
|
-
) {
|
|
3281
|
-
this.deps.log.info(
|
|
3282
|
-
`[lcm] /reset no-op for already fresh conversation ${current.conversationId}`,
|
|
3283
|
-
);
|
|
3284
|
-
return;
|
|
3285
|
-
}
|
|
3286
|
-
await this.conversationStore.archiveConversation(current.conversationId);
|
|
3287
|
-
}
|
|
4116
|
+
}),
|
|
4117
|
+
);
|
|
4118
|
+
}
|
|
3288
4119
|
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
4120
|
+
/** Apply generic lifecycle semantics for session rollover and deletion hooks. */
|
|
4121
|
+
async handleSessionEnd(params: {
|
|
4122
|
+
reason?: string;
|
|
4123
|
+
sessionId?: string;
|
|
4124
|
+
sessionKey?: string;
|
|
4125
|
+
nextSessionId?: string;
|
|
4126
|
+
nextSessionKey?: string;
|
|
4127
|
+
}): Promise<void> {
|
|
4128
|
+
const reason = params.reason?.trim();
|
|
4129
|
+
if (!reason || reason === "new" || reason === "unknown") {
|
|
4130
|
+
return;
|
|
4131
|
+
}
|
|
4132
|
+
if (this.shouldIgnoreSession({ sessionId: params.sessionId, sessionKey: params.sessionKey })) {
|
|
4133
|
+
return;
|
|
4134
|
+
}
|
|
4135
|
+
if (this.isStatelessSession(params.sessionKey ?? params.nextSessionKey)) {
|
|
4136
|
+
return;
|
|
4137
|
+
}
|
|
3294
4138
|
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
4139
|
+
const createReplacement = reason !== "deleted";
|
|
4140
|
+
this.ensureMigrated();
|
|
4141
|
+
await this.withSessionQueue(
|
|
4142
|
+
this.resolveSessionQueueKey(params.nextSessionId ?? params.sessionId, params.sessionKey ?? params.nextSessionKey),
|
|
4143
|
+
async () =>
|
|
4144
|
+
this.conversationStore.withTransaction(async () => {
|
|
4145
|
+
await this.applySessionReplacement({
|
|
4146
|
+
reason: `session_end:${reason}`,
|
|
4147
|
+
sessionId: params.sessionId,
|
|
4148
|
+
sessionKey: params.sessionKey ?? params.nextSessionKey,
|
|
4149
|
+
nextSessionId: params.nextSessionId,
|
|
4150
|
+
nextSessionKey: params.nextSessionKey,
|
|
4151
|
+
createReplacement,
|
|
3298
4152
|
});
|
|
3299
|
-
this.deps.log.info(
|
|
3300
|
-
`[lcm] /reset archived prior conversation and created ${freshConversation.conversationId}`,
|
|
3301
|
-
);
|
|
3302
4153
|
}),
|
|
3303
4154
|
);
|
|
3304
4155
|
}
|
|
@@ -3317,6 +4168,10 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
3317
4168
|
return this.summaryStore;
|
|
3318
4169
|
}
|
|
3319
4170
|
|
|
4171
|
+
getCompactionTelemetryStore(): CompactionTelemetryStore {
|
|
4172
|
+
return this.compactionTelemetryStore;
|
|
4173
|
+
}
|
|
4174
|
+
|
|
3320
4175
|
// ── Heartbeat pruning ──────────────────────────────────────────────────
|
|
3321
4176
|
|
|
3322
4177
|
/**
|
|
@@ -3446,3 +4301,6 @@ function createEmergencyFallbackSummarize(): (
|
|
|
3446
4301
|
return text.slice(0, maxChars) + "\n[Truncated for context management]";
|
|
3447
4302
|
};
|
|
3448
4303
|
}
|
|
4304
|
+
|
|
4305
|
+
/** @internal Exposed for unit tests only. */
|
|
4306
|
+
export const __testing = { readLastJsonlEntryBeforeOffset };
|