@martian-engineering/lossless-claw 0.5.2 → 0.6.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 +49 -11
- package/docs/configuration.md +44 -0
- package/openclaw.plugin.json +114 -0
- package/package.json +2 -1
- package/skills/lossless-claw/SKILL.md +33 -0
- package/skills/lossless-claw/references/architecture.md +52 -0
- package/skills/lossless-claw/references/config.md +263 -0
- package/skills/lossless-claw/references/diagnostics.md +79 -0
- package/skills/lossless-claw/references/recall-tools.md +55 -0
- package/skills/lossless-claw/references/session-lifecycle.md +59 -0
- package/src/assembler.ts +321 -34
- package/src/compaction.ts +220 -19
- package/src/db/config.ts +74 -21
- package/src/db/migration.ts +50 -13
- package/src/engine.ts +742 -133
- package/src/plugin/index.ts +156 -73
- package/src/plugin/lcm-command.ts +759 -0
- package/src/plugin/lcm-doctor-apply.ts +546 -0
- package/src/plugin/lcm-doctor-shared.ts +210 -0
- package/src/store/conversation-store.ts +60 -21
- package/src/store/parse-utc-timestamp.ts +25 -0
- package/src/store/summary-store.ts +460 -11
- package/src/summarize.ts +553 -224
- package/src/tools/lcm-expand-query-tool.ts +195 -59
- package/src/tools/lcm-expansion-recursion-guard.ts +87 -0
- package/src/types.ts +1 -0
package/src/engine.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { homedir } from "node:os";
|
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import type { DatabaseSync } from "node:sqlite";
|
|
7
7
|
import { createInterface } from "node:readline";
|
|
8
|
+
import { SessionManager } from "@mariozechner/pi-coding-agent";
|
|
8
9
|
import type {
|
|
9
10
|
ContextEngine,
|
|
10
11
|
ContextEngineInfo,
|
|
@@ -16,7 +17,14 @@ import type {
|
|
|
16
17
|
SubagentEndReason,
|
|
17
18
|
SubagentSpawnPreparation,
|
|
18
19
|
} from "openclaw/plugin-sdk";
|
|
19
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
blockFromPart,
|
|
22
|
+
contentFromParts,
|
|
23
|
+
ContextAssembler,
|
|
24
|
+
pickToolCallId,
|
|
25
|
+
pickToolIsError,
|
|
26
|
+
pickToolName,
|
|
27
|
+
} from "./assembler.js";
|
|
20
28
|
import { CompactionEngine, type CompactionConfig } from "./compaction.js";
|
|
21
29
|
import type { LcmConfig } from "./db/config.js";
|
|
22
30
|
import { getLcmDbFeatures } from "./db/features.js";
|
|
@@ -50,6 +58,30 @@ import type { LcmDependencies } from "./types.js";
|
|
|
50
58
|
|
|
51
59
|
type AgentMessage = Parameters<ContextEngine["ingest"]>[0]["message"];
|
|
52
60
|
type AssembleResultWithSystemPrompt = AssembleResult & { systemPromptAddition?: string };
|
|
61
|
+
type CircuitBreakerState = {
|
|
62
|
+
failures: number;
|
|
63
|
+
openSince: number | null;
|
|
64
|
+
};
|
|
65
|
+
type TranscriptRewriteReplacement = {
|
|
66
|
+
entryId: string;
|
|
67
|
+
message: AgentMessage;
|
|
68
|
+
};
|
|
69
|
+
type TranscriptRewriteRequest = {
|
|
70
|
+
replacements: TranscriptRewriteReplacement[];
|
|
71
|
+
};
|
|
72
|
+
type ContextEngineMaintenanceResult = {
|
|
73
|
+
changed: boolean;
|
|
74
|
+
bytesFreed: number;
|
|
75
|
+
rewrittenEntries: number;
|
|
76
|
+
reason?: string;
|
|
77
|
+
};
|
|
78
|
+
type ContextEngineMaintenanceRuntimeContext = Record<string, unknown> & {
|
|
79
|
+
rewriteTranscriptEntries?: (
|
|
80
|
+
request: TranscriptRewriteRequest,
|
|
81
|
+
) => Promise<ContextEngineMaintenanceResult>;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const TRANSCRIPT_GC_BATCH_SIZE = 12;
|
|
53
85
|
|
|
54
86
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
55
87
|
|
|
@@ -77,6 +109,71 @@ function safeBoolean(value: unknown): boolean | undefined {
|
|
|
77
109
|
return typeof value === "boolean" ? value : undefined;
|
|
78
110
|
}
|
|
79
111
|
|
|
112
|
+
function extractTranscriptToolCallId(message: AgentMessage): string | undefined {
|
|
113
|
+
const topLevel = message as Record<string, unknown>;
|
|
114
|
+
const direct =
|
|
115
|
+
safeString(topLevel.toolCallId) ??
|
|
116
|
+
safeString(topLevel.tool_call_id) ??
|
|
117
|
+
safeString(topLevel.toolUseId) ??
|
|
118
|
+
safeString(topLevel.tool_use_id) ??
|
|
119
|
+
safeString(topLevel.call_id) ??
|
|
120
|
+
safeString(topLevel.id);
|
|
121
|
+
if (direct) {
|
|
122
|
+
return direct;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!Array.isArray(topLevel.content)) {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
for (const item of topLevel.content) {
|
|
130
|
+
const record = asRecord(item);
|
|
131
|
+
if (!record) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const nested =
|
|
135
|
+
safeString(record.toolCallId) ??
|
|
136
|
+
safeString(record.tool_call_id) ??
|
|
137
|
+
safeString(record.toolUseId) ??
|
|
138
|
+
safeString(record.tool_use_id) ??
|
|
139
|
+
safeString(record.call_id) ??
|
|
140
|
+
safeString(record.id);
|
|
141
|
+
if (nested) {
|
|
142
|
+
return nested;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function listTranscriptToolResultEntryIdsByCallId(sessionFile: string): Map<string, string> {
|
|
150
|
+
const sessionManager = SessionManager.open(sessionFile);
|
|
151
|
+
const branch = sessionManager.getBranch();
|
|
152
|
+
const entryIdsByCallId = new Map<string, string>();
|
|
153
|
+
const duplicateCallIds = new Set<string>();
|
|
154
|
+
|
|
155
|
+
for (const entry of branch) {
|
|
156
|
+
if (entry.type !== "message" || entry.message.role !== "toolResult") {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const toolCallId = extractTranscriptToolCallId(entry.message as AgentMessage);
|
|
160
|
+
if (!toolCallId) {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (entryIdsByCallId.has(toolCallId)) {
|
|
164
|
+
duplicateCallIds.add(toolCallId);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
entryIdsByCallId.set(toolCallId, entry.id);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
for (const duplicateCallId of duplicateCallIds) {
|
|
171
|
+
entryIdsByCallId.delete(duplicateCallId);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return entryIdsByCallId;
|
|
175
|
+
}
|
|
176
|
+
|
|
80
177
|
function appendTextValue(value: unknown, out: string[]): void {
|
|
81
178
|
if (typeof value === "string") {
|
|
82
179
|
out.push(value);
|
|
@@ -535,7 +632,15 @@ function buildMessageParts(params: {
|
|
|
535
632
|
for (let ordinal = 0; ordinal < message.content.length; ordinal++) {
|
|
536
633
|
const block = normalizeUnknownBlock(message.content[ordinal]);
|
|
537
634
|
const metadataRecord = block.metadata.raw as Record<string, unknown> | undefined;
|
|
538
|
-
const
|
|
635
|
+
const rawBlockType = safeString(metadataRecord?.rawType) ?? block.type;
|
|
636
|
+
const partType = toPartType(rawBlockType);
|
|
637
|
+
const rawBlock =
|
|
638
|
+
metadataRecord && rawBlockType !== block.type
|
|
639
|
+
? {
|
|
640
|
+
...metadataRecord,
|
|
641
|
+
type: rawBlockType,
|
|
642
|
+
}
|
|
643
|
+
: (metadataRecord ?? message.content[ordinal]);
|
|
539
644
|
const toolCallId =
|
|
540
645
|
safeString(metadataRecord?.toolCallId) ??
|
|
541
646
|
safeString(metadataRecord?.tool_call_id) ??
|
|
@@ -582,8 +687,8 @@ function buildMessageParts(params: {
|
|
|
582
687
|
: undefined,
|
|
583
688
|
toolOutputExternalized: safeBoolean(metadataRecord?.toolOutputExternalized),
|
|
584
689
|
externalizationReason: safeString(metadataRecord?.externalizationReason),
|
|
585
|
-
rawType:
|
|
586
|
-
raw:
|
|
690
|
+
rawType: rawBlockType,
|
|
691
|
+
raw: rawBlock,
|
|
587
692
|
}),
|
|
588
693
|
});
|
|
589
694
|
}
|
|
@@ -826,6 +931,70 @@ async function readLeafPathMessages(sessionFile: string): Promise<AgentMessage[]
|
|
|
826
931
|
}
|
|
827
932
|
}
|
|
828
933
|
|
|
934
|
+
/**
|
|
935
|
+
* Resolve the first-time bootstrap token budget.
|
|
936
|
+
*
|
|
937
|
+
* When unset, bootstrap keeps a modest suffix of the parent session rather than
|
|
938
|
+
* inheriting the full raw history into a brand-new conversation.
|
|
939
|
+
*/
|
|
940
|
+
function resolveBootstrapMaxTokens(config: Pick<LcmConfig, "bootstrapMaxTokens" | "leafChunkTokens">): number {
|
|
941
|
+
if (
|
|
942
|
+
typeof config.bootstrapMaxTokens === "number" &&
|
|
943
|
+
Number.isFinite(config.bootstrapMaxTokens) &&
|
|
944
|
+
config.bootstrapMaxTokens > 0
|
|
945
|
+
) {
|
|
946
|
+
return Math.floor(config.bootstrapMaxTokens);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
const leafChunkTokens =
|
|
950
|
+
typeof config.leafChunkTokens === "number" &&
|
|
951
|
+
Number.isFinite(config.leafChunkTokens) &&
|
|
952
|
+
config.leafChunkTokens > 0
|
|
953
|
+
? Math.floor(config.leafChunkTokens)
|
|
954
|
+
: 20_000;
|
|
955
|
+
return Math.max(6000, Math.floor(leafChunkTokens * 0.3));
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* Keep only the newest bootstrap messages that fit within the token budget.
|
|
960
|
+
*
|
|
961
|
+
* The newest message is always preserved so a fork never starts empty when the
|
|
962
|
+
* parent transcript has any recoverable content at all.
|
|
963
|
+
*/
|
|
964
|
+
function trimBootstrapMessagesToBudget(messages: AgentMessage[], maxTokens: number): AgentMessage[] {
|
|
965
|
+
if (messages.length === 0) {
|
|
966
|
+
return [];
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
const safeMaxTokens = Number.isFinite(maxTokens) ? Math.floor(maxTokens) : 0;
|
|
970
|
+
if (safeMaxTokens <= 0) {
|
|
971
|
+
return [messages[messages.length - 1]!];
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
const kept: AgentMessage[] = [];
|
|
975
|
+
let totalTokens = 0;
|
|
976
|
+
|
|
977
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
978
|
+
const message = messages[index]!;
|
|
979
|
+
const tokenCount = toStoredMessage(message).tokenCount;
|
|
980
|
+
if (kept.length > 0 && totalTokens + tokenCount > safeMaxTokens) {
|
|
981
|
+
break;
|
|
982
|
+
}
|
|
983
|
+
kept.push(message);
|
|
984
|
+
totalTokens += tokenCount;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// If a single oversized tail message exceeds the budget, return empty
|
|
988
|
+
// rather than silently bypassing the budget cap. An empty bootstrap is
|
|
989
|
+
// safer than an exploding one.
|
|
990
|
+
if (kept.length === 1 && totalTokens > safeMaxTokens) {
|
|
991
|
+
return [];
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
kept.reverse();
|
|
995
|
+
return kept;
|
|
996
|
+
}
|
|
997
|
+
|
|
829
998
|
function readFileSegment(sessionFile: string, offset: number): string | null {
|
|
830
999
|
let fd: number | null = null;
|
|
831
1000
|
try {
|
|
@@ -975,6 +1144,9 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
975
1144
|
private largeFileTextSummarizer?: (prompt: string) => Promise<string | null>;
|
|
976
1145
|
private deps: LcmDependencies;
|
|
977
1146
|
|
|
1147
|
+
// ── Circuit breaker for compaction auth failures ──
|
|
1148
|
+
private circuitBreakerStates = new Map<string, CircuitBreakerState>();
|
|
1149
|
+
|
|
978
1150
|
constructor(deps: LcmDependencies, database: DatabaseSync) {
|
|
979
1151
|
this.deps = deps;
|
|
980
1152
|
this.config = deps.config;
|
|
@@ -1064,6 +1236,7 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1064
1236
|
condensedTargetTokens: this.config.condensedTargetTokens,
|
|
1065
1237
|
maxRounds: 10,
|
|
1066
1238
|
timezone: this.config.timezone,
|
|
1239
|
+
summaryMaxOverageFactor: this.config.summaryMaxOverageFactor,
|
|
1067
1240
|
};
|
|
1068
1241
|
this.compaction = new CompactionEngine(
|
|
1069
1242
|
this.conversationStore,
|
|
@@ -1110,6 +1283,56 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1110
1283
|
return matchesSessionPattern(trimmedKey, this.statelessSessionPatterns);
|
|
1111
1284
|
}
|
|
1112
1285
|
|
|
1286
|
+
// ── Circuit breaker helpers ──────────────────────────────────────────────
|
|
1287
|
+
|
|
1288
|
+
private getCircuitBreakerState(key: string): CircuitBreakerState {
|
|
1289
|
+
let state = this.circuitBreakerStates.get(key);
|
|
1290
|
+
if (!state) {
|
|
1291
|
+
state = { failures: 0, openSince: null };
|
|
1292
|
+
this.circuitBreakerStates.set(key, state);
|
|
1293
|
+
}
|
|
1294
|
+
return state;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
private isCircuitBreakerOpen(key: string): boolean {
|
|
1298
|
+
const state = this.circuitBreakerStates.get(key);
|
|
1299
|
+
if (!state || state.openSince === null) return false;
|
|
1300
|
+
const elapsed = Date.now() - state.openSince;
|
|
1301
|
+
if (elapsed >= this.config.circuitBreakerCooldownMs) {
|
|
1302
|
+
this.resetCircuitBreaker(key);
|
|
1303
|
+
return false;
|
|
1304
|
+
}
|
|
1305
|
+
return true;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
private recordCompactionAuthFailure(key: string): void {
|
|
1309
|
+
const state = this.getCircuitBreakerState(key);
|
|
1310
|
+
state.failures++;
|
|
1311
|
+
if (state.failures >= this.config.circuitBreakerThreshold) {
|
|
1312
|
+
state.openSince = Date.now();
|
|
1313
|
+
console.error(
|
|
1314
|
+
`[lcm] compaction circuit breaker OPEN: ${state.failures} consecutive auth failures for ${key}. Compaction halted. Will auto-retry after ${Math.round(this.config.circuitBreakerCooldownMs / 60000)}m or gateway restart.`,
|
|
1315
|
+
);
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
private recordCompactionSuccess(key: string): void {
|
|
1320
|
+
const state = this.circuitBreakerStates.get(key);
|
|
1321
|
+
if (!state) {
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
if (state.failures > 0 || state.openSince !== null) {
|
|
1325
|
+
console.error(
|
|
1326
|
+
`[lcm] compaction circuit breaker CLOSED: successful compaction for ${key} after ${state.failures} prior failures.`,
|
|
1327
|
+
);
|
|
1328
|
+
}
|
|
1329
|
+
this.resetCircuitBreaker(key);
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
private resetCircuitBreaker(key: string): void {
|
|
1333
|
+
this.circuitBreakerStates.delete(key);
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1113
1336
|
/** Ensure DB schema is up-to-date. Called lazily on first bootstrap/ingest/assemble/compact. */
|
|
1114
1337
|
private ensureMigrated(): void {
|
|
1115
1338
|
if (this.migrated) {
|
|
@@ -1152,9 +1375,10 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1152
1375
|
}
|
|
1153
1376
|
|
|
1154
1377
|
/** Prefer stable session keys for queue serialization when available. */
|
|
1155
|
-
private resolveSessionQueueKey(sessionId
|
|
1378
|
+
private resolveSessionQueueKey(sessionId?: string, sessionKey?: string): string {
|
|
1156
1379
|
const normalizedSessionKey = sessionKey?.trim();
|
|
1157
|
-
|
|
1380
|
+
const normalizedSessionId = sessionId?.trim();
|
|
1381
|
+
return normalizedSessionKey || normalizedSessionId || "__lcm__";
|
|
1158
1382
|
}
|
|
1159
1383
|
|
|
1160
1384
|
/** Normalize optional live token estimates supplied by runtime callers. */
|
|
@@ -1189,6 +1413,12 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1189
1413
|
return undefined;
|
|
1190
1414
|
}
|
|
1191
1415
|
|
|
1416
|
+
/** Cap a resolved token budget against the configured maxAssemblyTokenBudget. */
|
|
1417
|
+
private applyAssemblyBudgetCap(budget: number): number {
|
|
1418
|
+
const cap = this.config.maxAssemblyTokenBudget;
|
|
1419
|
+
return cap != null && cap > 0 ? Math.min(budget, cap) : budget;
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1192
1422
|
/** Resolve an LCM conversation id from a session key via the session store. */
|
|
1193
1423
|
private async resolveConversationIdForSessionKey(
|
|
1194
1424
|
sessionKey: string,
|
|
@@ -1222,22 +1452,36 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1222
1452
|
private async resolveSummarize(params: {
|
|
1223
1453
|
legacyParams?: Record<string, unknown>;
|
|
1224
1454
|
customInstructions?: string;
|
|
1225
|
-
|
|
1455
|
+
breakerScope: string;
|
|
1456
|
+
}): Promise<{
|
|
1457
|
+
summarize: (text: string, aggressive?: boolean) => Promise<string>;
|
|
1458
|
+
summaryModel: string;
|
|
1459
|
+
breakerKey?: string;
|
|
1460
|
+
}> {
|
|
1226
1461
|
const lp = params.legacyParams ?? {};
|
|
1227
1462
|
if (typeof lp.summarize === "function") {
|
|
1228
1463
|
return {
|
|
1229
1464
|
summarize: lp.summarize as (text: string, aggressive?: boolean) => Promise<string>,
|
|
1230
1465
|
summaryModel: "unknown",
|
|
1466
|
+
breakerKey: `custom:${params.breakerScope}`,
|
|
1231
1467
|
};
|
|
1232
1468
|
}
|
|
1233
1469
|
try {
|
|
1470
|
+
const customInstructions =
|
|
1471
|
+
params.customInstructions !== undefined
|
|
1472
|
+
? params.customInstructions
|
|
1473
|
+
: (this.config.customInstructions || undefined);
|
|
1234
1474
|
const runtimeSummarizer = await createLcmSummarizeFromLegacyParams({
|
|
1235
1475
|
deps: this.deps,
|
|
1236
1476
|
legacyParams: lp,
|
|
1237
|
-
customInstructions
|
|
1477
|
+
customInstructions,
|
|
1238
1478
|
});
|
|
1239
1479
|
if (runtimeSummarizer) {
|
|
1240
|
-
return {
|
|
1480
|
+
return {
|
|
1481
|
+
summarize: runtimeSummarizer.fn,
|
|
1482
|
+
summaryModel: runtimeSummarizer.model,
|
|
1483
|
+
breakerKey: runtimeSummarizer.breakerKey,
|
|
1484
|
+
};
|
|
1241
1485
|
}
|
|
1242
1486
|
console.error(`[lcm] resolveSummarize: createLcmSummarizeFromLegacyParams returned undefined`);
|
|
1243
1487
|
} catch (err) {
|
|
@@ -1271,6 +1515,7 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1271
1515
|
const result = await createLcmSummarizeFromLegacyParams({
|
|
1272
1516
|
deps: this.deps,
|
|
1273
1517
|
legacyParams: { provider, model },
|
|
1518
|
+
customInstructions: this.config.customInstructions || undefined,
|
|
1274
1519
|
});
|
|
1275
1520
|
if (!result) {
|
|
1276
1521
|
return undefined;
|
|
@@ -1508,14 +1753,24 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1508
1753
|
|
|
1509
1754
|
const normalizedRawType =
|
|
1510
1755
|
rawType === "function_call_output" ? "function_call_output" : "tool_result";
|
|
1511
|
-
const compactBlock: Record<string, unknown> =
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1756
|
+
const compactBlock: Record<string, unknown> = isPlainTextToolResult
|
|
1757
|
+
? {
|
|
1758
|
+
type: "text",
|
|
1759
|
+
text: externalized.reference,
|
|
1760
|
+
rawType: normalizedRawType,
|
|
1761
|
+
externalizedFileId: externalized.fileId,
|
|
1762
|
+
originalByteSize: externalized.byteSize,
|
|
1763
|
+
toolOutputExternalized: true,
|
|
1764
|
+
externalizationReason: "large_tool_result",
|
|
1765
|
+
}
|
|
1766
|
+
: {
|
|
1767
|
+
type: normalizedRawType,
|
|
1768
|
+
output: externalized.reference,
|
|
1769
|
+
externalizedFileId: externalized.fileId,
|
|
1770
|
+
originalByteSize: externalized.byteSize,
|
|
1771
|
+
toolOutputExternalized: true,
|
|
1772
|
+
externalizationReason: "large_tool_result",
|
|
1773
|
+
};
|
|
1519
1774
|
const callId =
|
|
1520
1775
|
safeString(record.tool_use_id) ??
|
|
1521
1776
|
safeString(record.toolUseId) ??
|
|
@@ -1828,7 +2083,12 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1828
2083
|
// First-time import path: no LCM rows yet, so seed directly from the
|
|
1829
2084
|
// active leaf context snapshot.
|
|
1830
2085
|
if (existingCount === 0) {
|
|
1831
|
-
|
|
2086
|
+
const bootstrapMessages = trimBootstrapMessagesToBudget(
|
|
2087
|
+
historicalMessages,
|
|
2088
|
+
resolveBootstrapMaxTokens(this.config),
|
|
2089
|
+
);
|
|
2090
|
+
|
|
2091
|
+
if (bootstrapMessages.length === 0) {
|
|
1832
2092
|
await this.conversationStore.markConversationBootstrapped(conversationId);
|
|
1833
2093
|
await persistBootstrapState(conversationId, historicalMessages);
|
|
1834
2094
|
return {
|
|
@@ -1839,7 +2099,7 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1839
2099
|
}
|
|
1840
2100
|
|
|
1841
2101
|
const nextSeq = (await this.conversationStore.getMaxSeq(conversationId)) + 1;
|
|
1842
|
-
const bulkInput =
|
|
2102
|
+
const bulkInput = bootstrapMessages.map((message, index) => {
|
|
1843
2103
|
const stored = toStoredMessage(message);
|
|
1844
2104
|
return {
|
|
1845
2105
|
conversationId,
|
|
@@ -1945,6 +2205,208 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1945
2205
|
return result;
|
|
1946
2206
|
}
|
|
1947
2207
|
|
|
2208
|
+
/**
|
|
2209
|
+
* Remove messages from the batch that already exist in the DB for this session.
|
|
2210
|
+
* Conservative replay detection: only strip a prefix when the incoming
|
|
2211
|
+
* batch begins with the entire stored transcript for the session.
|
|
2212
|
+
*
|
|
2213
|
+
* Fixes two issues from #246:
|
|
2214
|
+
* 1. Replaced hasMessage() fast-path with aligned-tail check — the old
|
|
2215
|
+
* approach false-positives on legitimate repeated first messages
|
|
2216
|
+
* 2. Dedup now runs on newMessages only, before autoCompactionSummary
|
|
2217
|
+
* is prepended — synthetic summaries can no longer interfere with
|
|
2218
|
+
* replay detection
|
|
2219
|
+
*/
|
|
2220
|
+
private async deduplicateAfterTurnBatch(
|
|
2221
|
+
sessionId: string,
|
|
2222
|
+
batch: AgentMessage[],
|
|
2223
|
+
): Promise<AgentMessage[]> {
|
|
2224
|
+
if (batch.length === 0) return batch;
|
|
2225
|
+
|
|
2226
|
+
const conversation = await this.conversationStore.getConversationBySessionId(sessionId);
|
|
2227
|
+
if (!conversation) return batch;
|
|
2228
|
+
|
|
2229
|
+
const conversationId = conversation.conversationId;
|
|
2230
|
+
const storedMessageCount = await this.conversationStore.getMessageCount(conversationId);
|
|
2231
|
+
if (storedMessageCount === 0 || storedMessageCount > batch.length) {
|
|
2232
|
+
return batch;
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
// Aligned-tail check: DB's last message must match the message at the
|
|
2236
|
+
// exact replay boundary in the incoming batch. This replaces the
|
|
2237
|
+
// hasMessage() check which could false-positive on any repeated content.
|
|
2238
|
+
const lastDbMessage = await this.conversationStore.getLastMessage(conversationId);
|
|
2239
|
+
if (!lastDbMessage) return batch;
|
|
2240
|
+
|
|
2241
|
+
const storedBatch = batch.map((m) => toStoredMessage(m));
|
|
2242
|
+
const batchAtBoundary = storedBatch[storedMessageCount - 1]!;
|
|
2243
|
+
if (
|
|
2244
|
+
messageIdentity(lastDbMessage.role, lastDbMessage.content) !==
|
|
2245
|
+
messageIdentity(batchAtBoundary.role, batchAtBoundary.content)
|
|
2246
|
+
) {
|
|
2247
|
+
return batch;
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
// Full proof: incoming batch must start with the entire stored transcript
|
|
2251
|
+
// in exact order before we trim anything.
|
|
2252
|
+
const storedMessages = await this.conversationStore.getMessages(conversationId, {
|
|
2253
|
+
limit: storedMessageCount,
|
|
2254
|
+
});
|
|
2255
|
+
if (storedMessages.length !== storedMessageCount) {
|
|
2256
|
+
return batch;
|
|
2257
|
+
}
|
|
2258
|
+
for (let i = 0; i < storedMessageCount; i += 1) {
|
|
2259
|
+
const storedConversationMessage = storedMessages[i]!;
|
|
2260
|
+
const incomingMessage = storedBatch[i]!;
|
|
2261
|
+
if (
|
|
2262
|
+
messageIdentity(storedConversationMessage.role, storedConversationMessage.content) !==
|
|
2263
|
+
messageIdentity(incomingMessage.role, incomingMessage.content)
|
|
2264
|
+
) {
|
|
2265
|
+
return batch;
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
return batch.slice(storedMessageCount);
|
|
2270
|
+
}
|
|
2271
|
+
/**
|
|
2272
|
+
* Rebuild a compact tool-result message from stored message parts.
|
|
2273
|
+
*
|
|
2274
|
+
* The first transcript-GC pass only rewrites tool results that were already
|
|
2275
|
+
* externalized into large_files during ingest, so the stored placeholder is
|
|
2276
|
+
* the canonical replacement content.
|
|
2277
|
+
*/
|
|
2278
|
+
private async buildTranscriptGcReplacementMessage(
|
|
2279
|
+
messageId: number,
|
|
2280
|
+
): Promise<AgentMessage | null> {
|
|
2281
|
+
const message = await this.conversationStore.getMessageById(messageId);
|
|
2282
|
+
if (!message) {
|
|
2283
|
+
return null;
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
const parts = await this.conversationStore.getMessageParts(messageId);
|
|
2287
|
+
const toolCallId = pickToolCallId(parts);
|
|
2288
|
+
if (!toolCallId) {
|
|
2289
|
+
return null;
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
const content = contentFromParts(parts, "toolResult", message.content);
|
|
2293
|
+
const toolName = pickToolName(parts) ?? "unknown";
|
|
2294
|
+
const isError = pickToolIsError(parts);
|
|
2295
|
+
|
|
2296
|
+
return {
|
|
2297
|
+
role: "toolResult",
|
|
2298
|
+
toolCallId,
|
|
2299
|
+
toolName,
|
|
2300
|
+
content,
|
|
2301
|
+
...(isError !== undefined ? { isError } : {}),
|
|
2302
|
+
} as AgentMessage;
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
/**
|
|
2306
|
+
* Run transcript GC for summarized tool-result messages that already have a
|
|
2307
|
+
* large_files-backed placeholder stored in LCM.
|
|
2308
|
+
*/
|
|
2309
|
+
async maintain(params: {
|
|
2310
|
+
sessionId: string;
|
|
2311
|
+
sessionFile: string;
|
|
2312
|
+
sessionKey?: string;
|
|
2313
|
+
runtimeContext?: ContextEngineMaintenanceRuntimeContext;
|
|
2314
|
+
}): Promise<ContextEngineMaintenanceResult> {
|
|
2315
|
+
if (this.shouldIgnoreSession({ sessionId: params.sessionId, sessionKey: params.sessionKey })) {
|
|
2316
|
+
return {
|
|
2317
|
+
changed: false,
|
|
2318
|
+
bytesFreed: 0,
|
|
2319
|
+
rewrittenEntries: 0,
|
|
2320
|
+
reason: "session excluded by pattern",
|
|
2321
|
+
};
|
|
2322
|
+
}
|
|
2323
|
+
if (this.isStatelessSession(params.sessionKey)) {
|
|
2324
|
+
return {
|
|
2325
|
+
changed: false,
|
|
2326
|
+
bytesFreed: 0,
|
|
2327
|
+
rewrittenEntries: 0,
|
|
2328
|
+
reason: "stateless session",
|
|
2329
|
+
};
|
|
2330
|
+
}
|
|
2331
|
+
if (typeof params.runtimeContext?.rewriteTranscriptEntries !== "function") {
|
|
2332
|
+
return {
|
|
2333
|
+
changed: false,
|
|
2334
|
+
bytesFreed: 0,
|
|
2335
|
+
rewrittenEntries: 0,
|
|
2336
|
+
reason: "runtime rewrite helper unavailable",
|
|
2337
|
+
};
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
return this.withSessionQueue(
|
|
2341
|
+
this.resolveSessionQueueKey(params.sessionId, params.sessionKey),
|
|
2342
|
+
async () => {
|
|
2343
|
+
const conversation = await this.conversationStore.getConversationForSession({
|
|
2344
|
+
sessionId: params.sessionId,
|
|
2345
|
+
sessionKey: params.sessionKey,
|
|
2346
|
+
});
|
|
2347
|
+
if (!conversation) {
|
|
2348
|
+
return {
|
|
2349
|
+
changed: false,
|
|
2350
|
+
bytesFreed: 0,
|
|
2351
|
+
rewrittenEntries: 0,
|
|
2352
|
+
reason: "conversation not found",
|
|
2353
|
+
};
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
const candidates = await this.summaryStore.listTranscriptGcCandidates(
|
|
2357
|
+
conversation.conversationId,
|
|
2358
|
+
{ limit: TRANSCRIPT_GC_BATCH_SIZE },
|
|
2359
|
+
);
|
|
2360
|
+
if (candidates.length === 0) {
|
|
2361
|
+
return {
|
|
2362
|
+
changed: false,
|
|
2363
|
+
bytesFreed: 0,
|
|
2364
|
+
rewrittenEntries: 0,
|
|
2365
|
+
reason: "no transcript GC candidates",
|
|
2366
|
+
};
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
const transcriptEntryIdsByCallId = listTranscriptToolResultEntryIdsByCallId(
|
|
2370
|
+
params.sessionFile,
|
|
2371
|
+
);
|
|
2372
|
+
const replacements: TranscriptRewriteReplacement[] = [];
|
|
2373
|
+
const seenEntryIds = new Set<string>();
|
|
2374
|
+
|
|
2375
|
+
for (const candidate of candidates) {
|
|
2376
|
+
const entryId = transcriptEntryIdsByCallId.get(candidate.toolCallId);
|
|
2377
|
+
if (!entryId || seenEntryIds.has(entryId)) {
|
|
2378
|
+
continue;
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
const replacementMessage = await this.buildTranscriptGcReplacementMessage(
|
|
2382
|
+
candidate.messageId,
|
|
2383
|
+
);
|
|
2384
|
+
if (!replacementMessage) {
|
|
2385
|
+
continue;
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
seenEntryIds.add(entryId);
|
|
2389
|
+
replacements.push({
|
|
2390
|
+
entryId,
|
|
2391
|
+
message: replacementMessage,
|
|
2392
|
+
});
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
if (replacements.length === 0) {
|
|
2396
|
+
return {
|
|
2397
|
+
changed: false,
|
|
2398
|
+
bytesFreed: 0,
|
|
2399
|
+
rewrittenEntries: 0,
|
|
2400
|
+
reason: "no matching transcript entries",
|
|
2401
|
+
};
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
return params.runtimeContext.rewriteTranscriptEntries({
|
|
2405
|
+
replacements,
|
|
2406
|
+
});
|
|
2407
|
+
},
|
|
2408
|
+
);
|
|
2409
|
+
}
|
|
1948
2410
|
private async ingestSingle(params: {
|
|
1949
2411
|
sessionId: string;
|
|
1950
2412
|
sessionKey?: string;
|
|
@@ -2096,6 +2558,12 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2096
2558
|
}
|
|
2097
2559
|
this.ensureMigrated();
|
|
2098
2560
|
|
|
2561
|
+
// Dedup guard: prevent duplicate ingestion when gateway restart replays
|
|
2562
|
+
// full history. Run on newMessages BEFORE prepending autoCompactionSummary
|
|
2563
|
+
// so synthetic summaries cannot interfere with replay detection.
|
|
2564
|
+
const newMessages = params.messages.slice(params.prePromptMessageCount);
|
|
2565
|
+
const dedupedNewMessages = await this.deduplicateAfterTurnBatch(params.sessionId, newMessages);
|
|
2566
|
+
|
|
2099
2567
|
const ingestBatch: AgentMessage[] = [];
|
|
2100
2568
|
if (params.autoCompactionSummary) {
|
|
2101
2569
|
ingestBatch.push({
|
|
@@ -2104,8 +2572,7 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2104
2572
|
} as AgentMessage);
|
|
2105
2573
|
}
|
|
2106
2574
|
|
|
2107
|
-
|
|
2108
|
-
ingestBatch.push(...newMessages);
|
|
2575
|
+
ingestBatch.push(...dedupedNewMessages);
|
|
2109
2576
|
if (ingestBatch.length === 0) {
|
|
2110
2577
|
return;
|
|
2111
2578
|
}
|
|
@@ -2133,7 +2600,7 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2133
2600
|
runtimeContext: params.runtimeContext,
|
|
2134
2601
|
legacyParams,
|
|
2135
2602
|
});
|
|
2136
|
-
const tokenBudget = resolvedTokenBudget ?? DEFAULT_AFTER_TURN_TOKEN_BUDGET;
|
|
2603
|
+
const tokenBudget = this.applyAssemblyBudgetCap(resolvedTokenBudget ?? DEFAULT_AFTER_TURN_TOKEN_BUDGET);
|
|
2137
2604
|
if (resolvedTokenBudget === undefined) {
|
|
2138
2605
|
console.warn(
|
|
2139
2606
|
`[lcm] afterTurn: tokenBudget not provided; using default ${DEFAULT_AFTER_TURN_TOKEN_BUDGET}`,
|
|
@@ -2180,6 +2647,8 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2180
2647
|
sessionKey?: string;
|
|
2181
2648
|
messages: AgentMessage[];
|
|
2182
2649
|
tokenBudget?: number;
|
|
2650
|
+
/** Optional user query for relevance-based eviction (BM25-lite). When absent or unsearchable, falls back to chronological eviction. */
|
|
2651
|
+
prompt?: string;
|
|
2183
2652
|
}): Promise<AssembleResult> {
|
|
2184
2653
|
if (this.shouldIgnoreSession({ sessionId: params.sessionId, sessionKey: params.sessionKey })) {
|
|
2185
2654
|
return {
|
|
@@ -2220,17 +2689,19 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2220
2689
|
};
|
|
2221
2690
|
}
|
|
2222
2691
|
|
|
2223
|
-
const tokenBudget =
|
|
2692
|
+
const tokenBudget = this.applyAssemblyBudgetCap(
|
|
2224
2693
|
typeof params.tokenBudget === "number" &&
|
|
2225
2694
|
Number.isFinite(params.tokenBudget) &&
|
|
2226
2695
|
params.tokenBudget > 0
|
|
2227
2696
|
? Math.floor(params.tokenBudget)
|
|
2228
|
-
: 128_000
|
|
2697
|
+
: 128_000,
|
|
2698
|
+
);
|
|
2229
2699
|
|
|
2230
2700
|
const assembled = await this.assembler.assemble({
|
|
2231
2701
|
conversationId: conversation.conversationId,
|
|
2232
2702
|
tokenBudget,
|
|
2233
2703
|
freshTailCount: this.config.freshTailCount,
|
|
2704
|
+
prompt: params.prompt,
|
|
2234
2705
|
});
|
|
2235
2706
|
|
|
2236
2707
|
// If assembly produced no messages for a non-empty live session,
|
|
@@ -2324,11 +2795,14 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2324
2795
|
}
|
|
2325
2796
|
|
|
2326
2797
|
const legacyParams = asRecord(params.runtimeContext) ?? params.legacyParams;
|
|
2327
|
-
const
|
|
2798
|
+
const resolvedTokenBudget = this.resolveTokenBudget({
|
|
2328
2799
|
tokenBudget: params.tokenBudget,
|
|
2329
2800
|
runtimeContext: params.runtimeContext,
|
|
2330
2801
|
legacyParams,
|
|
2331
2802
|
});
|
|
2803
|
+
const tokenBudget = resolvedTokenBudget
|
|
2804
|
+
? this.applyAssemblyBudgetCap(resolvedTokenBudget)
|
|
2805
|
+
: resolvedTokenBudget;
|
|
2332
2806
|
if (!tokenBudget) {
|
|
2333
2807
|
return {
|
|
2334
2808
|
ok: false,
|
|
@@ -2346,10 +2820,18 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2346
2820
|
}
|
|
2347
2821
|
).currentTokenCount,
|
|
2348
2822
|
);
|
|
2349
|
-
const { summarize, summaryModel } = await this.resolveSummarize({
|
|
2823
|
+
const { summarize, summaryModel, breakerKey } = await this.resolveSummarize({
|
|
2350
2824
|
legacyParams,
|
|
2351
2825
|
customInstructions: params.customInstructions,
|
|
2826
|
+
breakerScope: this.resolveSessionQueueKey(params.sessionId, params.sessionKey),
|
|
2352
2827
|
});
|
|
2828
|
+
if (breakerKey && this.isCircuitBreakerOpen(breakerKey)) {
|
|
2829
|
+
return {
|
|
2830
|
+
ok: true,
|
|
2831
|
+
compacted: false,
|
|
2832
|
+
reason: "circuit breaker open",
|
|
2833
|
+
};
|
|
2834
|
+
}
|
|
2353
2835
|
|
|
2354
2836
|
const leafResult = await this.compaction.compactLeaf({
|
|
2355
2837
|
conversationId: conversation.conversationId,
|
|
@@ -2359,12 +2841,23 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2359
2841
|
previousSummaryContent: params.previousSummaryContent,
|
|
2360
2842
|
summaryModel,
|
|
2361
2843
|
});
|
|
2844
|
+
|
|
2845
|
+
if (leafResult.authFailure && breakerKey) {
|
|
2846
|
+
this.recordCompactionAuthFailure(breakerKey);
|
|
2847
|
+
} else if (leafResult.actionTaken && breakerKey) {
|
|
2848
|
+
this.recordCompactionSuccess(breakerKey);
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2362
2851
|
const tokensBefore = observedTokens ?? leafResult.tokensBefore;
|
|
2363
2852
|
|
|
2364
2853
|
return {
|
|
2365
2854
|
ok: true,
|
|
2366
2855
|
compacted: leafResult.actionTaken,
|
|
2367
|
-
reason: leafResult.
|
|
2856
|
+
reason: leafResult.authFailure
|
|
2857
|
+
? "provider auth failure"
|
|
2858
|
+
: leafResult.actionTaken
|
|
2859
|
+
? "compacted"
|
|
2860
|
+
: "below threshold",
|
|
2368
2861
|
result: {
|
|
2369
2862
|
tokensBefore,
|
|
2370
2863
|
tokensAfter: leafResult.tokensAfter,
|
|
@@ -2429,129 +2922,161 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2429
2922
|
|
|
2430
2923
|
const conversationId = conversation.conversationId;
|
|
2431
2924
|
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
(
|
|
2436
|
-
lp as {
|
|
2437
|
-
manualCompaction?: unknown;
|
|
2438
|
-
}
|
|
2439
|
-
).manualCompaction === true;
|
|
2440
|
-
const forceCompaction = force || manualCompactionRequested;
|
|
2441
|
-
const tokenBudget = this.resolveTokenBudget({
|
|
2442
|
-
tokenBudget: params.tokenBudget,
|
|
2443
|
-
runtimeContext: params.runtimeContext,
|
|
2444
|
-
legacyParams,
|
|
2445
|
-
});
|
|
2446
|
-
if (!tokenBudget) {
|
|
2447
|
-
return {
|
|
2448
|
-
ok: false,
|
|
2449
|
-
compacted: false,
|
|
2450
|
-
reason: "missing token budget in compact params",
|
|
2451
|
-
};
|
|
2452
|
-
}
|
|
2453
|
-
|
|
2454
|
-
const { summarize, summaryModel } = await this.resolveSummarize({
|
|
2455
|
-
legacyParams,
|
|
2456
|
-
customInstructions: params.customInstructions,
|
|
2457
|
-
});
|
|
2458
|
-
|
|
2459
|
-
// Evaluate whether compaction is needed (unless forced)
|
|
2460
|
-
const observedTokens = this.normalizeObservedTokenCount(
|
|
2461
|
-
params.currentTokenCount ??
|
|
2925
|
+
const legacyParams = asRecord(params.runtimeContext) ?? params.legacyParams;
|
|
2926
|
+
const lp = legacyParams ?? {};
|
|
2927
|
+
const manualCompactionRequested =
|
|
2462
2928
|
(
|
|
2463
2929
|
lp as {
|
|
2464
|
-
|
|
2930
|
+
manualCompaction?: unknown;
|
|
2465
2931
|
}
|
|
2466
|
-
).
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2932
|
+
).manualCompaction === true;
|
|
2933
|
+
const forceCompaction = force || manualCompactionRequested;
|
|
2934
|
+
const resolvedTokenBudget = this.resolveTokenBudget({
|
|
2935
|
+
tokenBudget: params.tokenBudget,
|
|
2936
|
+
runtimeContext: params.runtimeContext,
|
|
2937
|
+
legacyParams,
|
|
2938
|
+
});
|
|
2939
|
+
const tokenBudget = resolvedTokenBudget
|
|
2940
|
+
? this.applyAssemblyBudgetCap(resolvedTokenBudget)
|
|
2941
|
+
: resolvedTokenBudget;
|
|
2942
|
+
if (!tokenBudget) {
|
|
2943
|
+
return {
|
|
2944
|
+
ok: false,
|
|
2945
|
+
compacted: false,
|
|
2946
|
+
reason: "missing token budget in compact params",
|
|
2947
|
+
};
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2950
|
+
const { summarize, summaryModel, breakerKey } = await this.resolveSummarize({
|
|
2951
|
+
legacyParams,
|
|
2952
|
+
customInstructions: params.customInstructions,
|
|
2953
|
+
breakerScope: this.resolveSessionQueueKey(params.sessionId, params.sessionKey),
|
|
2954
|
+
});
|
|
2955
|
+
if (breakerKey && this.isCircuitBreakerOpen(breakerKey)) {
|
|
2956
|
+
return {
|
|
2957
|
+
ok: true,
|
|
2958
|
+
compacted: false,
|
|
2959
|
+
reason: "circuit breaker open",
|
|
2960
|
+
};
|
|
2961
|
+
}
|
|
2962
|
+
|
|
2963
|
+
// Evaluate whether compaction is needed (unless forced)
|
|
2964
|
+
const observedTokens = this.normalizeObservedTokenCount(
|
|
2965
|
+
params.currentTokenCount ??
|
|
2966
|
+
(
|
|
2967
|
+
lp as {
|
|
2968
|
+
currentTokenCount?: unknown;
|
|
2969
|
+
}
|
|
2970
|
+
).currentTokenCount,
|
|
2971
|
+
);
|
|
2972
|
+
const decision =
|
|
2973
|
+
observedTokens !== undefined
|
|
2974
|
+
? await this.compaction.evaluate(conversationId, tokenBudget, observedTokens)
|
|
2975
|
+
: await this.compaction.evaluate(conversationId, tokenBudget);
|
|
2976
|
+
const targetTokens =
|
|
2977
|
+
params.compactionTarget === "threshold" ? decision.threshold : tokenBudget;
|
|
2978
|
+
const liveContextStillExceedsTarget =
|
|
2979
|
+
observedTokens !== undefined && observedTokens >= targetTokens;
|
|
2980
|
+
|
|
2981
|
+
if (!forceCompaction && !decision.shouldCompact) {
|
|
2982
|
+
return {
|
|
2983
|
+
ok: true,
|
|
2984
|
+
compacted: false,
|
|
2985
|
+
reason: "below threshold",
|
|
2986
|
+
result: {
|
|
2987
|
+
tokensBefore: decision.currentTokens,
|
|
2988
|
+
},
|
|
2989
|
+
};
|
|
2990
|
+
}
|
|
2487
2991
|
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2992
|
+
const useSweep =
|
|
2993
|
+
manualCompactionRequested || forceCompaction || params.compactionTarget === "threshold";
|
|
2994
|
+
if (useSweep) {
|
|
2995
|
+
const sweepResult = await this.compaction.compactFullSweep({
|
|
2996
|
+
conversationId,
|
|
2997
|
+
tokenBudget,
|
|
2998
|
+
summarize,
|
|
2999
|
+
force: forceCompaction,
|
|
3000
|
+
hardTrigger: false,
|
|
3001
|
+
summaryModel,
|
|
3002
|
+
});
|
|
3003
|
+
|
|
3004
|
+
if (sweepResult.authFailure && breakerKey) {
|
|
3005
|
+
this.recordCompactionAuthFailure(breakerKey);
|
|
3006
|
+
} else if (sweepResult.actionTaken && breakerKey) {
|
|
3007
|
+
this.recordCompactionSuccess(breakerKey);
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
return {
|
|
3011
|
+
ok: !sweepResult.authFailure && (sweepResult.actionTaken || !liveContextStillExceedsTarget),
|
|
3012
|
+
compacted: sweepResult.actionTaken,
|
|
3013
|
+
reason: sweepResult.authFailure
|
|
3014
|
+
? (sweepResult.actionTaken
|
|
3015
|
+
? "provider auth failure after partial compaction"
|
|
3016
|
+
: "provider auth failure")
|
|
3017
|
+
: sweepResult.actionTaken
|
|
3018
|
+
? "compacted"
|
|
3019
|
+
: manualCompactionRequested
|
|
3020
|
+
? "nothing to compact"
|
|
3021
|
+
: liveContextStillExceedsTarget
|
|
3022
|
+
? "live context still exceeds target"
|
|
3023
|
+
: "already under target",
|
|
3024
|
+
result: {
|
|
3025
|
+
tokensBefore: decision.currentTokens,
|
|
3026
|
+
tokensAfter: sweepResult.tokensAfter,
|
|
3027
|
+
details: {
|
|
3028
|
+
rounds: sweepResult.actionTaken ? 1 : 0,
|
|
3029
|
+
targetTokens,
|
|
3030
|
+
},
|
|
3031
|
+
},
|
|
3032
|
+
};
|
|
3033
|
+
}
|
|
3034
|
+
|
|
3035
|
+
// When forced, use the token budget as target
|
|
3036
|
+
const convergenceTargetTokens = forceCompaction
|
|
3037
|
+
? tokenBudget
|
|
3038
|
+
: params.compactionTarget === "threshold"
|
|
3039
|
+
? decision.threshold
|
|
3040
|
+
: tokenBudget;
|
|
3041
|
+
|
|
3042
|
+
const compactResult = await this.compaction.compactUntilUnder({
|
|
2492
3043
|
conversationId,
|
|
2493
3044
|
tokenBudget,
|
|
3045
|
+
targetTokens: convergenceTargetTokens,
|
|
3046
|
+
...(observedTokens !== undefined ? { currentTokens: observedTokens } : {}),
|
|
2494
3047
|
summarize,
|
|
2495
|
-
force: forceCompaction,
|
|
2496
|
-
hardTrigger: false,
|
|
2497
3048
|
summaryModel,
|
|
2498
3049
|
});
|
|
2499
3050
|
|
|
3051
|
+
if (compactResult.authFailure && breakerKey) {
|
|
3052
|
+
this.recordCompactionAuthFailure(breakerKey);
|
|
3053
|
+
} else if (compactResult.rounds > 0 && breakerKey) {
|
|
3054
|
+
this.recordCompactionSuccess(breakerKey);
|
|
3055
|
+
}
|
|
3056
|
+
|
|
3057
|
+
const didCompact = compactResult.rounds > 0;
|
|
3058
|
+
|
|
2500
3059
|
return {
|
|
2501
|
-
ok:
|
|
2502
|
-
compacted:
|
|
2503
|
-
reason:
|
|
2504
|
-
?
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
3060
|
+
ok: compactResult.success,
|
|
3061
|
+
compacted: didCompact,
|
|
3062
|
+
reason: compactResult.authFailure
|
|
3063
|
+
? (didCompact
|
|
3064
|
+
? "provider auth failure after partial compaction"
|
|
3065
|
+
: "provider auth failure")
|
|
3066
|
+
: compactResult.success
|
|
3067
|
+
? didCompact
|
|
3068
|
+
? "compacted"
|
|
3069
|
+
: "already under target"
|
|
3070
|
+
: "could not reach target",
|
|
2510
3071
|
result: {
|
|
2511
3072
|
tokensBefore: decision.currentTokens,
|
|
2512
|
-
tokensAfter:
|
|
3073
|
+
tokensAfter: compactResult.finalTokens,
|
|
2513
3074
|
details: {
|
|
2514
|
-
rounds:
|
|
2515
|
-
targetTokens,
|
|
3075
|
+
rounds: compactResult.rounds,
|
|
3076
|
+
targetTokens: convergenceTargetTokens,
|
|
2516
3077
|
},
|
|
2517
3078
|
},
|
|
2518
3079
|
};
|
|
2519
|
-
}
|
|
2520
|
-
|
|
2521
|
-
// When forced, use the token budget as target
|
|
2522
|
-
const convergenceTargetTokens = forceCompaction
|
|
2523
|
-
? tokenBudget
|
|
2524
|
-
: params.compactionTarget === "threshold"
|
|
2525
|
-
? decision.threshold
|
|
2526
|
-
: tokenBudget;
|
|
2527
|
-
|
|
2528
|
-
const compactResult = await this.compaction.compactUntilUnder({
|
|
2529
|
-
conversationId,
|
|
2530
|
-
tokenBudget,
|
|
2531
|
-
targetTokens: convergenceTargetTokens,
|
|
2532
|
-
...(observedTokens !== undefined ? { currentTokens: observedTokens } : {}),
|
|
2533
|
-
summarize,
|
|
2534
|
-
summaryModel,
|
|
2535
|
-
});
|
|
2536
|
-
const didCompact = compactResult.rounds > 0;
|
|
2537
|
-
|
|
2538
|
-
return {
|
|
2539
|
-
ok: compactResult.success,
|
|
2540
|
-
compacted: didCompact,
|
|
2541
|
-
reason: compactResult.success
|
|
2542
|
-
? didCompact
|
|
2543
|
-
? "compacted"
|
|
2544
|
-
: "already under target"
|
|
2545
|
-
: "could not reach target",
|
|
2546
|
-
result: {
|
|
2547
|
-
tokensBefore: decision.currentTokens,
|
|
2548
|
-
tokensAfter: compactResult.finalTokens,
|
|
2549
|
-
details: {
|
|
2550
|
-
rounds: compactResult.rounds,
|
|
2551
|
-
targetTokens: convergenceTargetTokens,
|
|
2552
|
-
},
|
|
2553
|
-
},
|
|
2554
|
-
};
|
|
2555
3080
|
},
|
|
2556
3081
|
);
|
|
2557
3082
|
}
|
|
@@ -2662,6 +3187,90 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2662
3187
|
// The shared connection is managed for the lifetime of the plugin process.
|
|
2663
3188
|
}
|
|
2664
3189
|
|
|
3190
|
+
/** Apply LCM lifecycle semantics for OpenClaw's /new and /reset commands. */
|
|
3191
|
+
async handleBeforeReset(params: {
|
|
3192
|
+
reason?: string;
|
|
3193
|
+
sessionId?: string;
|
|
3194
|
+
sessionKey?: string;
|
|
3195
|
+
}): Promise<void> {
|
|
3196
|
+
const reason = params.reason?.trim();
|
|
3197
|
+
if (reason !== "new" && reason !== "reset") {
|
|
3198
|
+
return;
|
|
3199
|
+
}
|
|
3200
|
+
if (this.shouldIgnoreSession({ sessionId: params.sessionId, sessionKey: params.sessionKey })) {
|
|
3201
|
+
return;
|
|
3202
|
+
}
|
|
3203
|
+
if (this.isStatelessSession(params.sessionKey)) {
|
|
3204
|
+
return;
|
|
3205
|
+
}
|
|
3206
|
+
|
|
3207
|
+
this.ensureMigrated();
|
|
3208
|
+
await this.withSessionQueue(
|
|
3209
|
+
this.resolveSessionQueueKey(params.sessionId, params.sessionKey),
|
|
3210
|
+
async () =>
|
|
3211
|
+
this.conversationStore.withTransaction(async () => {
|
|
3212
|
+
if (reason === "new") {
|
|
3213
|
+
const conversation = await this.conversationStore.getConversationForSession({
|
|
3214
|
+
sessionId: params.sessionId,
|
|
3215
|
+
sessionKey: params.sessionKey,
|
|
3216
|
+
});
|
|
3217
|
+
if (!conversation) {
|
|
3218
|
+
return;
|
|
3219
|
+
}
|
|
3220
|
+
|
|
3221
|
+
const retainDepth =
|
|
3222
|
+
typeof this.config.newSessionRetainDepth === "number"
|
|
3223
|
+
&& Number.isFinite(this.config.newSessionRetainDepth)
|
|
3224
|
+
? this.config.newSessionRetainDepth
|
|
3225
|
+
: 2;
|
|
3226
|
+
await this.summaryStore.pruneForNewSession(conversation.conversationId, retainDepth);
|
|
3227
|
+
this.deps.log.info(
|
|
3228
|
+
`[lcm] /new pruned conversation ${conversation.conversationId} to retain depth ${retainDepth}`,
|
|
3229
|
+
);
|
|
3230
|
+
return;
|
|
3231
|
+
}
|
|
3232
|
+
|
|
3233
|
+
const current = await this.conversationStore.getConversationForSession({
|
|
3234
|
+
sessionId: params.sessionId,
|
|
3235
|
+
sessionKey: params.sessionKey,
|
|
3236
|
+
});
|
|
3237
|
+
if (current?.active) {
|
|
3238
|
+
const currentMessageCount = await this.conversationStore.getMessageCount(
|
|
3239
|
+
current.conversationId,
|
|
3240
|
+
);
|
|
3241
|
+
const currentContextItems = await this.summaryStore.getContextItems(
|
|
3242
|
+
current.conversationId,
|
|
3243
|
+
);
|
|
3244
|
+
if (
|
|
3245
|
+
currentMessageCount === 0
|
|
3246
|
+
&& currentContextItems.length === 0
|
|
3247
|
+
&& !current.bootstrappedAt
|
|
3248
|
+
) {
|
|
3249
|
+
this.deps.log.info(
|
|
3250
|
+
`[lcm] /reset no-op for already fresh conversation ${current.conversationId}`,
|
|
3251
|
+
);
|
|
3252
|
+
return;
|
|
3253
|
+
}
|
|
3254
|
+
await this.conversationStore.archiveConversation(current.conversationId);
|
|
3255
|
+
}
|
|
3256
|
+
|
|
3257
|
+
const nextSessionId = params.sessionId?.trim() || current?.sessionId;
|
|
3258
|
+
if (!nextSessionId) {
|
|
3259
|
+
this.deps.log.warn("[lcm] /reset skipped: no session identity available");
|
|
3260
|
+
return;
|
|
3261
|
+
}
|
|
3262
|
+
|
|
3263
|
+
const freshConversation = await this.conversationStore.createConversation({
|
|
3264
|
+
sessionId: nextSessionId,
|
|
3265
|
+
sessionKey: params.sessionKey?.trim(),
|
|
3266
|
+
});
|
|
3267
|
+
this.deps.log.info(
|
|
3268
|
+
`[lcm] /reset archived prior conversation and created ${freshConversation.conversationId}`,
|
|
3269
|
+
);
|
|
3270
|
+
}),
|
|
3271
|
+
);
|
|
3272
|
+
}
|
|
3273
|
+
|
|
2665
3274
|
// ── Public accessors for retrieval (used by subagent expansion) ─────────
|
|
2666
3275
|
|
|
2667
3276
|
getRetrieval(): RetrievalEngine {
|