@martian-engineering/lossless-claw 0.5.3 → 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 +31 -1
- package/docs/configuration.md +23 -0
- package/openclaw.plugin.json +75 -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 +132 -36
- package/src/compaction.ts +17 -1
- package/src/db/config.ts +52 -20
- package/src/db/migration.ts +50 -13
- package/src/engine.ts +721 -131
- package/src/plugin/index.ts +45 -0
- 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 +380 -11
- package/src/summarize.ts +107 -20
- package/src/tools/lcm-expand-query-tool.ts +58 -25
- package/src/tools/lcm-expansion-recursion-guard.ts +87 -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;
|
|
@@ -1111,6 +1283,56 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1111
1283
|
return matchesSessionPattern(trimmedKey, this.statelessSessionPatterns);
|
|
1112
1284
|
}
|
|
1113
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
|
+
|
|
1114
1336
|
/** Ensure DB schema is up-to-date. Called lazily on first bootstrap/ingest/assemble/compact. */
|
|
1115
1337
|
private ensureMigrated(): void {
|
|
1116
1338
|
if (this.migrated) {
|
|
@@ -1153,9 +1375,10 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1153
1375
|
}
|
|
1154
1376
|
|
|
1155
1377
|
/** Prefer stable session keys for queue serialization when available. */
|
|
1156
|
-
private resolveSessionQueueKey(sessionId
|
|
1378
|
+
private resolveSessionQueueKey(sessionId?: string, sessionKey?: string): string {
|
|
1157
1379
|
const normalizedSessionKey = sessionKey?.trim();
|
|
1158
|
-
|
|
1380
|
+
const normalizedSessionId = sessionId?.trim();
|
|
1381
|
+
return normalizedSessionKey || normalizedSessionId || "__lcm__";
|
|
1159
1382
|
}
|
|
1160
1383
|
|
|
1161
1384
|
/** Normalize optional live token estimates supplied by runtime callers. */
|
|
@@ -1229,12 +1452,18 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1229
1452
|
private async resolveSummarize(params: {
|
|
1230
1453
|
legacyParams?: Record<string, unknown>;
|
|
1231
1454
|
customInstructions?: string;
|
|
1232
|
-
|
|
1455
|
+
breakerScope: string;
|
|
1456
|
+
}): Promise<{
|
|
1457
|
+
summarize: (text: string, aggressive?: boolean) => Promise<string>;
|
|
1458
|
+
summaryModel: string;
|
|
1459
|
+
breakerKey?: string;
|
|
1460
|
+
}> {
|
|
1233
1461
|
const lp = params.legacyParams ?? {};
|
|
1234
1462
|
if (typeof lp.summarize === "function") {
|
|
1235
1463
|
return {
|
|
1236
1464
|
summarize: lp.summarize as (text: string, aggressive?: boolean) => Promise<string>,
|
|
1237
1465
|
summaryModel: "unknown",
|
|
1466
|
+
breakerKey: `custom:${params.breakerScope}`,
|
|
1238
1467
|
};
|
|
1239
1468
|
}
|
|
1240
1469
|
try {
|
|
@@ -1248,7 +1477,11 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1248
1477
|
customInstructions,
|
|
1249
1478
|
});
|
|
1250
1479
|
if (runtimeSummarizer) {
|
|
1251
|
-
return {
|
|
1480
|
+
return {
|
|
1481
|
+
summarize: runtimeSummarizer.fn,
|
|
1482
|
+
summaryModel: runtimeSummarizer.model,
|
|
1483
|
+
breakerKey: runtimeSummarizer.breakerKey,
|
|
1484
|
+
};
|
|
1252
1485
|
}
|
|
1253
1486
|
console.error(`[lcm] resolveSummarize: createLcmSummarizeFromLegacyParams returned undefined`);
|
|
1254
1487
|
} catch (err) {
|
|
@@ -1520,14 +1753,24 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1520
1753
|
|
|
1521
1754
|
const normalizedRawType =
|
|
1522
1755
|
rawType === "function_call_output" ? "function_call_output" : "tool_result";
|
|
1523
|
-
const compactBlock: Record<string, unknown> =
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
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
|
+
};
|
|
1531
1774
|
const callId =
|
|
1532
1775
|
safeString(record.tool_use_id) ??
|
|
1533
1776
|
safeString(record.toolUseId) ??
|
|
@@ -1840,7 +2083,12 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1840
2083
|
// First-time import path: no LCM rows yet, so seed directly from the
|
|
1841
2084
|
// active leaf context snapshot.
|
|
1842
2085
|
if (existingCount === 0) {
|
|
1843
|
-
|
|
2086
|
+
const bootstrapMessages = trimBootstrapMessagesToBudget(
|
|
2087
|
+
historicalMessages,
|
|
2088
|
+
resolveBootstrapMaxTokens(this.config),
|
|
2089
|
+
);
|
|
2090
|
+
|
|
2091
|
+
if (bootstrapMessages.length === 0) {
|
|
1844
2092
|
await this.conversationStore.markConversationBootstrapped(conversationId);
|
|
1845
2093
|
await persistBootstrapState(conversationId, historicalMessages);
|
|
1846
2094
|
return {
|
|
@@ -1851,7 +2099,7 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1851
2099
|
}
|
|
1852
2100
|
|
|
1853
2101
|
const nextSeq = (await this.conversationStore.getMaxSeq(conversationId)) + 1;
|
|
1854
|
-
const bulkInput =
|
|
2102
|
+
const bulkInput = bootstrapMessages.map((message, index) => {
|
|
1855
2103
|
const stored = toStoredMessage(message);
|
|
1856
2104
|
return {
|
|
1857
2105
|
conversationId,
|
|
@@ -1957,6 +2205,208 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1957
2205
|
return result;
|
|
1958
2206
|
}
|
|
1959
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
|
+
}
|
|
1960
2410
|
private async ingestSingle(params: {
|
|
1961
2411
|
sessionId: string;
|
|
1962
2412
|
sessionKey?: string;
|
|
@@ -2108,6 +2558,12 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2108
2558
|
}
|
|
2109
2559
|
this.ensureMigrated();
|
|
2110
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
|
+
|
|
2111
2567
|
const ingestBatch: AgentMessage[] = [];
|
|
2112
2568
|
if (params.autoCompactionSummary) {
|
|
2113
2569
|
ingestBatch.push({
|
|
@@ -2116,8 +2572,7 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2116
2572
|
} as AgentMessage);
|
|
2117
2573
|
}
|
|
2118
2574
|
|
|
2119
|
-
|
|
2120
|
-
ingestBatch.push(...newMessages);
|
|
2575
|
+
ingestBatch.push(...dedupedNewMessages);
|
|
2121
2576
|
if (ingestBatch.length === 0) {
|
|
2122
2577
|
return;
|
|
2123
2578
|
}
|
|
@@ -2192,6 +2647,8 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2192
2647
|
sessionKey?: string;
|
|
2193
2648
|
messages: AgentMessage[];
|
|
2194
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;
|
|
2195
2652
|
}): Promise<AssembleResult> {
|
|
2196
2653
|
if (this.shouldIgnoreSession({ sessionId: params.sessionId, sessionKey: params.sessionKey })) {
|
|
2197
2654
|
return {
|
|
@@ -2244,6 +2701,7 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2244
2701
|
conversationId: conversation.conversationId,
|
|
2245
2702
|
tokenBudget,
|
|
2246
2703
|
freshTailCount: this.config.freshTailCount,
|
|
2704
|
+
prompt: params.prompt,
|
|
2247
2705
|
});
|
|
2248
2706
|
|
|
2249
2707
|
// If assembly produced no messages for a non-empty live session,
|
|
@@ -2362,10 +2820,18 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2362
2820
|
}
|
|
2363
2821
|
).currentTokenCount,
|
|
2364
2822
|
);
|
|
2365
|
-
const { summarize, summaryModel } = await this.resolveSummarize({
|
|
2823
|
+
const { summarize, summaryModel, breakerKey } = await this.resolveSummarize({
|
|
2366
2824
|
legacyParams,
|
|
2367
2825
|
customInstructions: params.customInstructions,
|
|
2826
|
+
breakerScope: this.resolveSessionQueueKey(params.sessionId, params.sessionKey),
|
|
2368
2827
|
});
|
|
2828
|
+
if (breakerKey && this.isCircuitBreakerOpen(breakerKey)) {
|
|
2829
|
+
return {
|
|
2830
|
+
ok: true,
|
|
2831
|
+
compacted: false,
|
|
2832
|
+
reason: "circuit breaker open",
|
|
2833
|
+
};
|
|
2834
|
+
}
|
|
2369
2835
|
|
|
2370
2836
|
const leafResult = await this.compaction.compactLeaf({
|
|
2371
2837
|
conversationId: conversation.conversationId,
|
|
@@ -2375,12 +2841,23 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2375
2841
|
previousSummaryContent: params.previousSummaryContent,
|
|
2376
2842
|
summaryModel,
|
|
2377
2843
|
});
|
|
2844
|
+
|
|
2845
|
+
if (leafResult.authFailure && breakerKey) {
|
|
2846
|
+
this.recordCompactionAuthFailure(breakerKey);
|
|
2847
|
+
} else if (leafResult.actionTaken && breakerKey) {
|
|
2848
|
+
this.recordCompactionSuccess(breakerKey);
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2378
2851
|
const tokensBefore = observedTokens ?? leafResult.tokensBefore;
|
|
2379
2852
|
|
|
2380
2853
|
return {
|
|
2381
2854
|
ok: true,
|
|
2382
2855
|
compacted: leafResult.actionTaken,
|
|
2383
|
-
reason: leafResult.
|
|
2856
|
+
reason: leafResult.authFailure
|
|
2857
|
+
? "provider auth failure"
|
|
2858
|
+
: leafResult.actionTaken
|
|
2859
|
+
? "compacted"
|
|
2860
|
+
: "below threshold",
|
|
2384
2861
|
result: {
|
|
2385
2862
|
tokensBefore,
|
|
2386
2863
|
tokensAfter: leafResult.tokensAfter,
|
|
@@ -2445,132 +2922,161 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2445
2922
|
|
|
2446
2923
|
const conversationId = conversation.conversationId;
|
|
2447
2924
|
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
(
|
|
2452
|
-
lp as {
|
|
2453
|
-
manualCompaction?: unknown;
|
|
2454
|
-
}
|
|
2455
|
-
).manualCompaction === true;
|
|
2456
|
-
const forceCompaction = force || manualCompactionRequested;
|
|
2457
|
-
const resolvedTokenBudget = this.resolveTokenBudget({
|
|
2458
|
-
tokenBudget: params.tokenBudget,
|
|
2459
|
-
runtimeContext: params.runtimeContext,
|
|
2460
|
-
legacyParams,
|
|
2461
|
-
});
|
|
2462
|
-
const tokenBudget = resolvedTokenBudget
|
|
2463
|
-
? this.applyAssemblyBudgetCap(resolvedTokenBudget)
|
|
2464
|
-
: resolvedTokenBudget;
|
|
2465
|
-
if (!tokenBudget) {
|
|
2466
|
-
return {
|
|
2467
|
-
ok: false,
|
|
2468
|
-
compacted: false,
|
|
2469
|
-
reason: "missing token budget in compact params",
|
|
2470
|
-
};
|
|
2471
|
-
}
|
|
2472
|
-
|
|
2473
|
-
const { summarize, summaryModel } = await this.resolveSummarize({
|
|
2474
|
-
legacyParams,
|
|
2475
|
-
customInstructions: params.customInstructions,
|
|
2476
|
-
});
|
|
2477
|
-
|
|
2478
|
-
// Evaluate whether compaction is needed (unless forced)
|
|
2479
|
-
const observedTokens = this.normalizeObservedTokenCount(
|
|
2480
|
-
params.currentTokenCount ??
|
|
2925
|
+
const legacyParams = asRecord(params.runtimeContext) ?? params.legacyParams;
|
|
2926
|
+
const lp = legacyParams ?? {};
|
|
2927
|
+
const manualCompactionRequested =
|
|
2481
2928
|
(
|
|
2482
2929
|
lp as {
|
|
2483
|
-
|
|
2930
|
+
manualCompaction?: unknown;
|
|
2484
2931
|
}
|
|
2485
|
-
).
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
tokensBefore: decision.currentTokens,
|
|
2503
|
-
},
|
|
2504
|
-
};
|
|
2505
|
-
}
|
|
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
|
+
}
|
|
2506
2949
|
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
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
|
+
}
|
|
2991
|
+
|
|
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({
|
|
2511
3043
|
conversationId,
|
|
2512
3044
|
tokenBudget,
|
|
3045
|
+
targetTokens: convergenceTargetTokens,
|
|
3046
|
+
...(observedTokens !== undefined ? { currentTokens: observedTokens } : {}),
|
|
2513
3047
|
summarize,
|
|
2514
|
-
force: forceCompaction,
|
|
2515
|
-
hardTrigger: false,
|
|
2516
3048
|
summaryModel,
|
|
2517
3049
|
});
|
|
2518
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
|
+
|
|
2519
3059
|
return {
|
|
2520
|
-
ok:
|
|
2521
|
-
compacted:
|
|
2522
|
-
reason:
|
|
2523
|
-
?
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
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",
|
|
2529
3071
|
result: {
|
|
2530
3072
|
tokensBefore: decision.currentTokens,
|
|
2531
|
-
tokensAfter:
|
|
3073
|
+
tokensAfter: compactResult.finalTokens,
|
|
2532
3074
|
details: {
|
|
2533
|
-
rounds:
|
|
2534
|
-
targetTokens,
|
|
3075
|
+
rounds: compactResult.rounds,
|
|
3076
|
+
targetTokens: convergenceTargetTokens,
|
|
2535
3077
|
},
|
|
2536
3078
|
},
|
|
2537
3079
|
};
|
|
2538
|
-
}
|
|
2539
|
-
|
|
2540
|
-
// When forced, use the token budget as target
|
|
2541
|
-
const convergenceTargetTokens = forceCompaction
|
|
2542
|
-
? tokenBudget
|
|
2543
|
-
: params.compactionTarget === "threshold"
|
|
2544
|
-
? decision.threshold
|
|
2545
|
-
: tokenBudget;
|
|
2546
|
-
|
|
2547
|
-
const compactResult = await this.compaction.compactUntilUnder({
|
|
2548
|
-
conversationId,
|
|
2549
|
-
tokenBudget,
|
|
2550
|
-
targetTokens: convergenceTargetTokens,
|
|
2551
|
-
...(observedTokens !== undefined ? { currentTokens: observedTokens } : {}),
|
|
2552
|
-
summarize,
|
|
2553
|
-
summaryModel,
|
|
2554
|
-
});
|
|
2555
|
-
const didCompact = compactResult.rounds > 0;
|
|
2556
|
-
|
|
2557
|
-
return {
|
|
2558
|
-
ok: compactResult.success,
|
|
2559
|
-
compacted: didCompact,
|
|
2560
|
-
reason: compactResult.success
|
|
2561
|
-
? didCompact
|
|
2562
|
-
? "compacted"
|
|
2563
|
-
: "already under target"
|
|
2564
|
-
: "could not reach target",
|
|
2565
|
-
result: {
|
|
2566
|
-
tokensBefore: decision.currentTokens,
|
|
2567
|
-
tokensAfter: compactResult.finalTokens,
|
|
2568
|
-
details: {
|
|
2569
|
-
rounds: compactResult.rounds,
|
|
2570
|
-
targetTokens: convergenceTargetTokens,
|
|
2571
|
-
},
|
|
2572
|
-
},
|
|
2573
|
-
};
|
|
2574
3080
|
},
|
|
2575
3081
|
);
|
|
2576
3082
|
}
|
|
@@ -2681,6 +3187,90 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2681
3187
|
// The shared connection is managed for the lifetime of the plugin process.
|
|
2682
3188
|
}
|
|
2683
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
|
+
|
|
2684
3274
|
// ── Public accessors for retrieval (used by subagent expansion) ─────────
|
|
2685
3275
|
|
|
2686
3276
|
getRetrieval(): RetrievalEngine {
|