@martian-engineering/lossless-claw 0.5.3 → 0.6.1
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 +22 -46
- package/src/db/config.ts +52 -20
- package/src/db/migration.ts +50 -13
- package/src/engine.ts +781 -172
- 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,212 @@ 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
|
+
sessionKey: string | undefined,
|
|
2223
|
+
batch: AgentMessage[],
|
|
2224
|
+
): Promise<AgentMessage[]> {
|
|
2225
|
+
if (batch.length === 0) return batch;
|
|
2226
|
+
|
|
2227
|
+
const conversation = await this.conversationStore.getConversationForSession({
|
|
2228
|
+
sessionId,
|
|
2229
|
+
sessionKey,
|
|
2230
|
+
});
|
|
2231
|
+
if (!conversation) return batch;
|
|
2232
|
+
|
|
2233
|
+
const conversationId = conversation.conversationId;
|
|
2234
|
+
const storedMessageCount = await this.conversationStore.getMessageCount(conversationId);
|
|
2235
|
+
if (storedMessageCount === 0 || storedMessageCount > batch.length) {
|
|
2236
|
+
return batch;
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
// Aligned-tail check: DB's last message must match the message at the
|
|
2240
|
+
// exact replay boundary in the incoming batch. This replaces the
|
|
2241
|
+
// hasMessage() check which could false-positive on any repeated content.
|
|
2242
|
+
const lastDbMessage = await this.conversationStore.getLastMessage(conversationId);
|
|
2243
|
+
if (!lastDbMessage) return batch;
|
|
2244
|
+
|
|
2245
|
+
const storedBatch = batch.map((m) => toStoredMessage(m));
|
|
2246
|
+
const batchAtBoundary = storedBatch[storedMessageCount - 1]!;
|
|
2247
|
+
if (
|
|
2248
|
+
messageIdentity(lastDbMessage.role, lastDbMessage.content) !==
|
|
2249
|
+
messageIdentity(batchAtBoundary.role, batchAtBoundary.content)
|
|
2250
|
+
) {
|
|
2251
|
+
return batch;
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
// Full proof: incoming batch must start with the entire stored transcript
|
|
2255
|
+
// in exact order before we trim anything.
|
|
2256
|
+
const storedMessages = await this.conversationStore.getMessages(conversationId, {
|
|
2257
|
+
limit: storedMessageCount,
|
|
2258
|
+
});
|
|
2259
|
+
if (storedMessages.length !== storedMessageCount) {
|
|
2260
|
+
return batch;
|
|
2261
|
+
}
|
|
2262
|
+
for (let i = 0; i < storedMessageCount; i += 1) {
|
|
2263
|
+
const storedConversationMessage = storedMessages[i]!;
|
|
2264
|
+
const incomingMessage = storedBatch[i]!;
|
|
2265
|
+
if (
|
|
2266
|
+
messageIdentity(storedConversationMessage.role, storedConversationMessage.content) !==
|
|
2267
|
+
messageIdentity(incomingMessage.role, incomingMessage.content)
|
|
2268
|
+
) {
|
|
2269
|
+
return batch;
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
return batch.slice(storedMessageCount);
|
|
2274
|
+
}
|
|
2275
|
+
/**
|
|
2276
|
+
* Rebuild a compact tool-result message from stored message parts.
|
|
2277
|
+
*
|
|
2278
|
+
* The first transcript-GC pass only rewrites tool results that were already
|
|
2279
|
+
* externalized into large_files during ingest, so the stored placeholder is
|
|
2280
|
+
* the canonical replacement content.
|
|
2281
|
+
*/
|
|
2282
|
+
private async buildTranscriptGcReplacementMessage(
|
|
2283
|
+
messageId: number,
|
|
2284
|
+
): Promise<AgentMessage | null> {
|
|
2285
|
+
const message = await this.conversationStore.getMessageById(messageId);
|
|
2286
|
+
if (!message) {
|
|
2287
|
+
return null;
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
const parts = await this.conversationStore.getMessageParts(messageId);
|
|
2291
|
+
const toolCallId = pickToolCallId(parts);
|
|
2292
|
+
if (!toolCallId) {
|
|
2293
|
+
return null;
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
const content = contentFromParts(parts, "toolResult", message.content);
|
|
2297
|
+
const toolName = pickToolName(parts) ?? "unknown";
|
|
2298
|
+
const isError = pickToolIsError(parts);
|
|
2299
|
+
|
|
2300
|
+
return {
|
|
2301
|
+
role: "toolResult",
|
|
2302
|
+
toolCallId,
|
|
2303
|
+
toolName,
|
|
2304
|
+
content,
|
|
2305
|
+
...(isError !== undefined ? { isError } : {}),
|
|
2306
|
+
} as AgentMessage;
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
/**
|
|
2310
|
+
* Run transcript GC for summarized tool-result messages that already have a
|
|
2311
|
+
* large_files-backed placeholder stored in LCM.
|
|
2312
|
+
*/
|
|
2313
|
+
async maintain(params: {
|
|
2314
|
+
sessionId: string;
|
|
2315
|
+
sessionFile: string;
|
|
2316
|
+
sessionKey?: string;
|
|
2317
|
+
runtimeContext?: ContextEngineMaintenanceRuntimeContext;
|
|
2318
|
+
}): Promise<ContextEngineMaintenanceResult> {
|
|
2319
|
+
if (this.shouldIgnoreSession({ sessionId: params.sessionId, sessionKey: params.sessionKey })) {
|
|
2320
|
+
return {
|
|
2321
|
+
changed: false,
|
|
2322
|
+
bytesFreed: 0,
|
|
2323
|
+
rewrittenEntries: 0,
|
|
2324
|
+
reason: "session excluded by pattern",
|
|
2325
|
+
};
|
|
2326
|
+
}
|
|
2327
|
+
if (this.isStatelessSession(params.sessionKey)) {
|
|
2328
|
+
return {
|
|
2329
|
+
changed: false,
|
|
2330
|
+
bytesFreed: 0,
|
|
2331
|
+
rewrittenEntries: 0,
|
|
2332
|
+
reason: "stateless session",
|
|
2333
|
+
};
|
|
2334
|
+
}
|
|
2335
|
+
if (typeof params.runtimeContext?.rewriteTranscriptEntries !== "function") {
|
|
2336
|
+
return {
|
|
2337
|
+
changed: false,
|
|
2338
|
+
bytesFreed: 0,
|
|
2339
|
+
rewrittenEntries: 0,
|
|
2340
|
+
reason: "runtime rewrite helper unavailable",
|
|
2341
|
+
};
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
return this.withSessionQueue(
|
|
2345
|
+
this.resolveSessionQueueKey(params.sessionId, params.sessionKey),
|
|
2346
|
+
async () => {
|
|
2347
|
+
const conversation = await this.conversationStore.getConversationForSession({
|
|
2348
|
+
sessionId: params.sessionId,
|
|
2349
|
+
sessionKey: params.sessionKey,
|
|
2350
|
+
});
|
|
2351
|
+
if (!conversation) {
|
|
2352
|
+
return {
|
|
2353
|
+
changed: false,
|
|
2354
|
+
bytesFreed: 0,
|
|
2355
|
+
rewrittenEntries: 0,
|
|
2356
|
+
reason: "conversation not found",
|
|
2357
|
+
};
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
const candidates = await this.summaryStore.listTranscriptGcCandidates(
|
|
2361
|
+
conversation.conversationId,
|
|
2362
|
+
{ limit: TRANSCRIPT_GC_BATCH_SIZE },
|
|
2363
|
+
);
|
|
2364
|
+
if (candidates.length === 0) {
|
|
2365
|
+
return {
|
|
2366
|
+
changed: false,
|
|
2367
|
+
bytesFreed: 0,
|
|
2368
|
+
rewrittenEntries: 0,
|
|
2369
|
+
reason: "no transcript GC candidates",
|
|
2370
|
+
};
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
const transcriptEntryIdsByCallId = listTranscriptToolResultEntryIdsByCallId(
|
|
2374
|
+
params.sessionFile,
|
|
2375
|
+
);
|
|
2376
|
+
const replacements: TranscriptRewriteReplacement[] = [];
|
|
2377
|
+
const seenEntryIds = new Set<string>();
|
|
2378
|
+
|
|
2379
|
+
for (const candidate of candidates) {
|
|
2380
|
+
const entryId = transcriptEntryIdsByCallId.get(candidate.toolCallId);
|
|
2381
|
+
if (!entryId || seenEntryIds.has(entryId)) {
|
|
2382
|
+
continue;
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
const replacementMessage = await this.buildTranscriptGcReplacementMessage(
|
|
2386
|
+
candidate.messageId,
|
|
2387
|
+
);
|
|
2388
|
+
if (!replacementMessage) {
|
|
2389
|
+
continue;
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
seenEntryIds.add(entryId);
|
|
2393
|
+
replacements.push({
|
|
2394
|
+
entryId,
|
|
2395
|
+
message: replacementMessage,
|
|
2396
|
+
});
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
if (replacements.length === 0) {
|
|
2400
|
+
return {
|
|
2401
|
+
changed: false,
|
|
2402
|
+
bytesFreed: 0,
|
|
2403
|
+
rewrittenEntries: 0,
|
|
2404
|
+
reason: "no matching transcript entries",
|
|
2405
|
+
};
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
return params.runtimeContext.rewriteTranscriptEntries({
|
|
2409
|
+
replacements,
|
|
2410
|
+
});
|
|
2411
|
+
},
|
|
2412
|
+
);
|
|
2413
|
+
}
|
|
1960
2414
|
private async ingestSingle(params: {
|
|
1961
2415
|
sessionId: string;
|
|
1962
2416
|
sessionKey?: string;
|
|
@@ -2108,6 +2562,16 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2108
2562
|
}
|
|
2109
2563
|
this.ensureMigrated();
|
|
2110
2564
|
|
|
2565
|
+
// Dedup guard: prevent duplicate ingestion when gateway restart replays
|
|
2566
|
+
// full history. Run on newMessages BEFORE prepending autoCompactionSummary
|
|
2567
|
+
// so synthetic summaries cannot interfere with replay detection.
|
|
2568
|
+
const newMessages = params.messages.slice(params.prePromptMessageCount);
|
|
2569
|
+
const dedupedNewMessages = await this.deduplicateAfterTurnBatch(
|
|
2570
|
+
params.sessionId,
|
|
2571
|
+
params.sessionKey,
|
|
2572
|
+
newMessages,
|
|
2573
|
+
);
|
|
2574
|
+
|
|
2111
2575
|
const ingestBatch: AgentMessage[] = [];
|
|
2112
2576
|
if (params.autoCompactionSummary) {
|
|
2113
2577
|
ingestBatch.push({
|
|
@@ -2116,8 +2580,7 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2116
2580
|
} as AgentMessage);
|
|
2117
2581
|
}
|
|
2118
2582
|
|
|
2119
|
-
|
|
2120
|
-
ingestBatch.push(...newMessages);
|
|
2583
|
+
ingestBatch.push(...dedupedNewMessages);
|
|
2121
2584
|
if (ingestBatch.length === 0) {
|
|
2122
2585
|
return;
|
|
2123
2586
|
}
|
|
@@ -2138,6 +2601,29 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2138
2601
|
return;
|
|
2139
2602
|
}
|
|
2140
2603
|
|
|
2604
|
+
if (batchLooksLikeHeartbeatAckTurn(ingestBatch)) {
|
|
2605
|
+
try {
|
|
2606
|
+
const conversation = await this.conversationStore.getConversationForSession({
|
|
2607
|
+
sessionId: params.sessionId,
|
|
2608
|
+
sessionKey: params.sessionKey,
|
|
2609
|
+
});
|
|
2610
|
+
if (conversation) {
|
|
2611
|
+
const pruned = await this.pruneHeartbeatOkTurns(conversation.conversationId);
|
|
2612
|
+
if (pruned > 0) {
|
|
2613
|
+
console.error(
|
|
2614
|
+
`[lcm] afterTurn: pruned ${pruned} heartbeat ack messages from conversation ${conversation.conversationId}`,
|
|
2615
|
+
);
|
|
2616
|
+
return;
|
|
2617
|
+
}
|
|
2618
|
+
}
|
|
2619
|
+
} catch (err) {
|
|
2620
|
+
console.error(
|
|
2621
|
+
`[lcm] afterTurn: heartbeat pruning failed:`,
|
|
2622
|
+
err instanceof Error ? err.message : err,
|
|
2623
|
+
);
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2141
2627
|
const legacyParams = asRecord(params.runtimeContext) ?? asRecord(params.legacyCompactionParams);
|
|
2142
2628
|
const DEFAULT_AFTER_TURN_TOKEN_BUDGET = 128_000;
|
|
2143
2629
|
const resolvedTokenBudget = this.resolveTokenBudget({
|
|
@@ -2192,6 +2678,8 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2192
2678
|
sessionKey?: string;
|
|
2193
2679
|
messages: AgentMessage[];
|
|
2194
2680
|
tokenBudget?: number;
|
|
2681
|
+
/** Optional user query for relevance-based eviction (BM25-lite). When absent or unsearchable, falls back to chronological eviction. */
|
|
2682
|
+
prompt?: string;
|
|
2195
2683
|
}): Promise<AssembleResult> {
|
|
2196
2684
|
if (this.shouldIgnoreSession({ sessionId: params.sessionId, sessionKey: params.sessionKey })) {
|
|
2197
2685
|
return {
|
|
@@ -2244,6 +2732,7 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2244
2732
|
conversationId: conversation.conversationId,
|
|
2245
2733
|
tokenBudget,
|
|
2246
2734
|
freshTailCount: this.config.freshTailCount,
|
|
2735
|
+
prompt: params.prompt,
|
|
2247
2736
|
});
|
|
2248
2737
|
|
|
2249
2738
|
// If assembly produced no messages for a non-empty live session,
|
|
@@ -2362,10 +2851,18 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2362
2851
|
}
|
|
2363
2852
|
).currentTokenCount,
|
|
2364
2853
|
);
|
|
2365
|
-
const { summarize, summaryModel } = await this.resolveSummarize({
|
|
2854
|
+
const { summarize, summaryModel, breakerKey } = await this.resolveSummarize({
|
|
2366
2855
|
legacyParams,
|
|
2367
2856
|
customInstructions: params.customInstructions,
|
|
2857
|
+
breakerScope: this.resolveSessionQueueKey(params.sessionId, params.sessionKey),
|
|
2368
2858
|
});
|
|
2859
|
+
if (breakerKey && this.isCircuitBreakerOpen(breakerKey)) {
|
|
2860
|
+
return {
|
|
2861
|
+
ok: true,
|
|
2862
|
+
compacted: false,
|
|
2863
|
+
reason: "circuit breaker open",
|
|
2864
|
+
};
|
|
2865
|
+
}
|
|
2369
2866
|
|
|
2370
2867
|
const leafResult = await this.compaction.compactLeaf({
|
|
2371
2868
|
conversationId: conversation.conversationId,
|
|
@@ -2375,12 +2872,23 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2375
2872
|
previousSummaryContent: params.previousSummaryContent,
|
|
2376
2873
|
summaryModel,
|
|
2377
2874
|
});
|
|
2875
|
+
|
|
2876
|
+
if (leafResult.authFailure && breakerKey) {
|
|
2877
|
+
this.recordCompactionAuthFailure(breakerKey);
|
|
2878
|
+
} else if (leafResult.actionTaken && breakerKey) {
|
|
2879
|
+
this.recordCompactionSuccess(breakerKey);
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2378
2882
|
const tokensBefore = observedTokens ?? leafResult.tokensBefore;
|
|
2379
2883
|
|
|
2380
2884
|
return {
|
|
2381
2885
|
ok: true,
|
|
2382
2886
|
compacted: leafResult.actionTaken,
|
|
2383
|
-
reason: leafResult.
|
|
2887
|
+
reason: leafResult.authFailure
|
|
2888
|
+
? "provider auth failure"
|
|
2889
|
+
: leafResult.actionTaken
|
|
2890
|
+
? "compacted"
|
|
2891
|
+
: "below threshold",
|
|
2384
2892
|
result: {
|
|
2385
2893
|
tokensBefore,
|
|
2386
2894
|
tokensAfter: leafResult.tokensAfter,
|
|
@@ -2445,132 +2953,161 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2445
2953
|
|
|
2446
2954
|
const conversationId = conversation.conversationId;
|
|
2447
2955
|
|
|
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 ??
|
|
2956
|
+
const legacyParams = asRecord(params.runtimeContext) ?? params.legacyParams;
|
|
2957
|
+
const lp = legacyParams ?? {};
|
|
2958
|
+
const manualCompactionRequested =
|
|
2481
2959
|
(
|
|
2482
2960
|
lp as {
|
|
2483
|
-
|
|
2961
|
+
manualCompaction?: unknown;
|
|
2484
2962
|
}
|
|
2485
|
-
).
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2963
|
+
).manualCompaction === true;
|
|
2964
|
+
const forceCompaction = force || manualCompactionRequested;
|
|
2965
|
+
const resolvedTokenBudget = this.resolveTokenBudget({
|
|
2966
|
+
tokenBudget: params.tokenBudget,
|
|
2967
|
+
runtimeContext: params.runtimeContext,
|
|
2968
|
+
legacyParams,
|
|
2969
|
+
});
|
|
2970
|
+
const tokenBudget = resolvedTokenBudget
|
|
2971
|
+
? this.applyAssemblyBudgetCap(resolvedTokenBudget)
|
|
2972
|
+
: resolvedTokenBudget;
|
|
2973
|
+
if (!tokenBudget) {
|
|
2974
|
+
return {
|
|
2975
|
+
ok: false,
|
|
2976
|
+
compacted: false,
|
|
2977
|
+
reason: "missing token budget in compact params",
|
|
2978
|
+
};
|
|
2979
|
+
}
|
|
2980
|
+
|
|
2981
|
+
const { summarize, summaryModel, breakerKey } = await this.resolveSummarize({
|
|
2982
|
+
legacyParams,
|
|
2983
|
+
customInstructions: params.customInstructions,
|
|
2984
|
+
breakerScope: this.resolveSessionQueueKey(params.sessionId, params.sessionKey),
|
|
2985
|
+
});
|
|
2986
|
+
if (breakerKey && this.isCircuitBreakerOpen(breakerKey)) {
|
|
2987
|
+
return {
|
|
2988
|
+
ok: true,
|
|
2989
|
+
compacted: false,
|
|
2990
|
+
reason: "circuit breaker open",
|
|
2991
|
+
};
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2994
|
+
// Evaluate whether compaction is needed (unless forced)
|
|
2995
|
+
const observedTokens = this.normalizeObservedTokenCount(
|
|
2996
|
+
params.currentTokenCount ??
|
|
2997
|
+
(
|
|
2998
|
+
lp as {
|
|
2999
|
+
currentTokenCount?: unknown;
|
|
3000
|
+
}
|
|
3001
|
+
).currentTokenCount,
|
|
3002
|
+
);
|
|
3003
|
+
const decision =
|
|
3004
|
+
observedTokens !== undefined
|
|
3005
|
+
? await this.compaction.evaluate(conversationId, tokenBudget, observedTokens)
|
|
3006
|
+
: await this.compaction.evaluate(conversationId, tokenBudget);
|
|
3007
|
+
const targetTokens =
|
|
3008
|
+
params.compactionTarget === "threshold" ? decision.threshold : tokenBudget;
|
|
3009
|
+
const liveContextStillExceedsTarget =
|
|
3010
|
+
observedTokens !== undefined && observedTokens >= targetTokens;
|
|
3011
|
+
|
|
3012
|
+
if (!forceCompaction && !decision.shouldCompact) {
|
|
3013
|
+
return {
|
|
3014
|
+
ok: true,
|
|
3015
|
+
compacted: false,
|
|
3016
|
+
reason: "below threshold",
|
|
3017
|
+
result: {
|
|
3018
|
+
tokensBefore: decision.currentTokens,
|
|
3019
|
+
},
|
|
3020
|
+
};
|
|
3021
|
+
}
|
|
3022
|
+
|
|
3023
|
+
const useSweep =
|
|
3024
|
+
manualCompactionRequested || forceCompaction || params.compactionTarget === "threshold";
|
|
3025
|
+
if (useSweep) {
|
|
3026
|
+
const sweepResult = await this.compaction.compactFullSweep({
|
|
3027
|
+
conversationId,
|
|
3028
|
+
tokenBudget,
|
|
3029
|
+
summarize,
|
|
3030
|
+
force: forceCompaction,
|
|
3031
|
+
hardTrigger: false,
|
|
3032
|
+
summaryModel,
|
|
3033
|
+
});
|
|
3034
|
+
|
|
3035
|
+
if (sweepResult.authFailure && breakerKey) {
|
|
3036
|
+
this.recordCompactionAuthFailure(breakerKey);
|
|
3037
|
+
} else if (sweepResult.actionTaken && breakerKey) {
|
|
3038
|
+
this.recordCompactionSuccess(breakerKey);
|
|
3039
|
+
}
|
|
3040
|
+
|
|
3041
|
+
return {
|
|
3042
|
+
ok: !sweepResult.authFailure && (sweepResult.actionTaken || !liveContextStillExceedsTarget),
|
|
3043
|
+
compacted: sweepResult.actionTaken,
|
|
3044
|
+
reason: sweepResult.authFailure
|
|
3045
|
+
? (sweepResult.actionTaken
|
|
3046
|
+
? "provider auth failure after partial compaction"
|
|
3047
|
+
: "provider auth failure")
|
|
3048
|
+
: sweepResult.actionTaken
|
|
3049
|
+
? "compacted"
|
|
3050
|
+
: manualCompactionRequested
|
|
3051
|
+
? "nothing to compact"
|
|
3052
|
+
: liveContextStillExceedsTarget
|
|
3053
|
+
? "live context still exceeds target"
|
|
3054
|
+
: "already under target",
|
|
3055
|
+
result: {
|
|
3056
|
+
tokensBefore: decision.currentTokens,
|
|
3057
|
+
tokensAfter: sweepResult.tokensAfter,
|
|
3058
|
+
details: {
|
|
3059
|
+
rounds: sweepResult.actionTaken ? 1 : 0,
|
|
3060
|
+
targetTokens,
|
|
3061
|
+
},
|
|
3062
|
+
},
|
|
3063
|
+
};
|
|
3064
|
+
}
|
|
2506
3065
|
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
3066
|
+
// When forced, use the token budget as target
|
|
3067
|
+
const convergenceTargetTokens = forceCompaction
|
|
3068
|
+
? tokenBudget
|
|
3069
|
+
: params.compactionTarget === "threshold"
|
|
3070
|
+
? decision.threshold
|
|
3071
|
+
: tokenBudget;
|
|
3072
|
+
|
|
3073
|
+
const compactResult = await this.compaction.compactUntilUnder({
|
|
2511
3074
|
conversationId,
|
|
2512
3075
|
tokenBudget,
|
|
3076
|
+
targetTokens: convergenceTargetTokens,
|
|
3077
|
+
...(observedTokens !== undefined ? { currentTokens: observedTokens } : {}),
|
|
2513
3078
|
summarize,
|
|
2514
|
-
force: forceCompaction,
|
|
2515
|
-
hardTrigger: false,
|
|
2516
3079
|
summaryModel,
|
|
2517
3080
|
});
|
|
2518
3081
|
|
|
3082
|
+
if (compactResult.authFailure && breakerKey) {
|
|
3083
|
+
this.recordCompactionAuthFailure(breakerKey);
|
|
3084
|
+
} else if (compactResult.rounds > 0 && breakerKey) {
|
|
3085
|
+
this.recordCompactionSuccess(breakerKey);
|
|
3086
|
+
}
|
|
3087
|
+
|
|
3088
|
+
const didCompact = compactResult.rounds > 0;
|
|
3089
|
+
|
|
2519
3090
|
return {
|
|
2520
|
-
ok:
|
|
2521
|
-
compacted:
|
|
2522
|
-
reason:
|
|
2523
|
-
?
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
3091
|
+
ok: compactResult.success,
|
|
3092
|
+
compacted: didCompact,
|
|
3093
|
+
reason: compactResult.authFailure
|
|
3094
|
+
? (didCompact
|
|
3095
|
+
? "provider auth failure after partial compaction"
|
|
3096
|
+
: "provider auth failure")
|
|
3097
|
+
: compactResult.success
|
|
3098
|
+
? didCompact
|
|
3099
|
+
? "compacted"
|
|
3100
|
+
: "already under target"
|
|
3101
|
+
: "could not reach target",
|
|
2529
3102
|
result: {
|
|
2530
3103
|
tokensBefore: decision.currentTokens,
|
|
2531
|
-
tokensAfter:
|
|
3104
|
+
tokensAfter: compactResult.finalTokens,
|
|
2532
3105
|
details: {
|
|
2533
|
-
rounds:
|
|
2534
|
-
targetTokens,
|
|
3106
|
+
rounds: compactResult.rounds,
|
|
3107
|
+
targetTokens: convergenceTargetTokens,
|
|
2535
3108
|
},
|
|
2536
3109
|
},
|
|
2537
3110
|
};
|
|
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
3111
|
},
|
|
2575
3112
|
);
|
|
2576
3113
|
}
|
|
@@ -2681,6 +3218,90 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2681
3218
|
// The shared connection is managed for the lifetime of the plugin process.
|
|
2682
3219
|
}
|
|
2683
3220
|
|
|
3221
|
+
/** Apply LCM lifecycle semantics for OpenClaw's /new and /reset commands. */
|
|
3222
|
+
async handleBeforeReset(params: {
|
|
3223
|
+
reason?: string;
|
|
3224
|
+
sessionId?: string;
|
|
3225
|
+
sessionKey?: string;
|
|
3226
|
+
}): Promise<void> {
|
|
3227
|
+
const reason = params.reason?.trim();
|
|
3228
|
+
if (reason !== "new" && reason !== "reset") {
|
|
3229
|
+
return;
|
|
3230
|
+
}
|
|
3231
|
+
if (this.shouldIgnoreSession({ sessionId: params.sessionId, sessionKey: params.sessionKey })) {
|
|
3232
|
+
return;
|
|
3233
|
+
}
|
|
3234
|
+
if (this.isStatelessSession(params.sessionKey)) {
|
|
3235
|
+
return;
|
|
3236
|
+
}
|
|
3237
|
+
|
|
3238
|
+
this.ensureMigrated();
|
|
3239
|
+
await this.withSessionQueue(
|
|
3240
|
+
this.resolveSessionQueueKey(params.sessionId, params.sessionKey),
|
|
3241
|
+
async () =>
|
|
3242
|
+
this.conversationStore.withTransaction(async () => {
|
|
3243
|
+
if (reason === "new") {
|
|
3244
|
+
const conversation = await this.conversationStore.getConversationForSession({
|
|
3245
|
+
sessionId: params.sessionId,
|
|
3246
|
+
sessionKey: params.sessionKey,
|
|
3247
|
+
});
|
|
3248
|
+
if (!conversation) {
|
|
3249
|
+
return;
|
|
3250
|
+
}
|
|
3251
|
+
|
|
3252
|
+
const retainDepth =
|
|
3253
|
+
typeof this.config.newSessionRetainDepth === "number"
|
|
3254
|
+
&& Number.isFinite(this.config.newSessionRetainDepth)
|
|
3255
|
+
? this.config.newSessionRetainDepth
|
|
3256
|
+
: 2;
|
|
3257
|
+
await this.summaryStore.pruneForNewSession(conversation.conversationId, retainDepth);
|
|
3258
|
+
this.deps.log.info(
|
|
3259
|
+
`[lcm] /new pruned conversation ${conversation.conversationId} to retain depth ${retainDepth}`,
|
|
3260
|
+
);
|
|
3261
|
+
return;
|
|
3262
|
+
}
|
|
3263
|
+
|
|
3264
|
+
const current = await this.conversationStore.getConversationForSession({
|
|
3265
|
+
sessionId: params.sessionId,
|
|
3266
|
+
sessionKey: params.sessionKey,
|
|
3267
|
+
});
|
|
3268
|
+
if (current?.active) {
|
|
3269
|
+
const currentMessageCount = await this.conversationStore.getMessageCount(
|
|
3270
|
+
current.conversationId,
|
|
3271
|
+
);
|
|
3272
|
+
const currentContextItems = await this.summaryStore.getContextItems(
|
|
3273
|
+
current.conversationId,
|
|
3274
|
+
);
|
|
3275
|
+
if (
|
|
3276
|
+
currentMessageCount === 0
|
|
3277
|
+
&& currentContextItems.length === 0
|
|
3278
|
+
&& !current.bootstrappedAt
|
|
3279
|
+
) {
|
|
3280
|
+
this.deps.log.info(
|
|
3281
|
+
`[lcm] /reset no-op for already fresh conversation ${current.conversationId}`,
|
|
3282
|
+
);
|
|
3283
|
+
return;
|
|
3284
|
+
}
|
|
3285
|
+
await this.conversationStore.archiveConversation(current.conversationId);
|
|
3286
|
+
}
|
|
3287
|
+
|
|
3288
|
+
const nextSessionId = params.sessionId?.trim() || current?.sessionId;
|
|
3289
|
+
if (!nextSessionId) {
|
|
3290
|
+
this.deps.log.warn("[lcm] /reset skipped: no session identity available");
|
|
3291
|
+
return;
|
|
3292
|
+
}
|
|
3293
|
+
|
|
3294
|
+
const freshConversation = await this.conversationStore.createConversation({
|
|
3295
|
+
sessionId: nextSessionId,
|
|
3296
|
+
sessionKey: params.sessionKey?.trim(),
|
|
3297
|
+
});
|
|
3298
|
+
this.deps.log.info(
|
|
3299
|
+
`[lcm] /reset archived prior conversation and created ${freshConversation.conversationId}`,
|
|
3300
|
+
);
|
|
3301
|
+
}),
|
|
3302
|
+
);
|
|
3303
|
+
}
|
|
3304
|
+
|
|
2684
3305
|
// ── Public accessors for retrieval (used by subagent expansion) ─────────
|
|
2685
3306
|
|
|
2686
3307
|
getRetrieval(): RetrievalEngine {
|
|
@@ -2744,16 +3365,11 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2744
3365
|
if (!turnMessages.some((record) => record.role === "user")) {
|
|
2745
3366
|
continue;
|
|
2746
3367
|
}
|
|
2747
|
-
if (turnMessages
|
|
3368
|
+
if (!turnLooksLikeHeartbeatTurn(turnMessages)) {
|
|
2748
3369
|
continue;
|
|
2749
3370
|
}
|
|
2750
3371
|
|
|
2751
|
-
|
|
2752
|
-
const hasToolParts = await this.turnHasToolInteractions(messageIds);
|
|
2753
|
-
if (hasToolParts) {
|
|
2754
|
-
continue;
|
|
2755
|
-
}
|
|
2756
|
-
toDelete.push(...messageIds);
|
|
3372
|
+
toDelete.push(...turnMessages.map((record) => record.messageId));
|
|
2757
3373
|
}
|
|
2758
3374
|
|
|
2759
3375
|
if (toDelete.length === 0) {
|
|
@@ -2764,45 +3380,12 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2764
3380
|
const uniqueIds = [...new Set(toDelete)];
|
|
2765
3381
|
return this.conversationStore.deleteMessages(uniqueIds);
|
|
2766
3382
|
}
|
|
2767
|
-
|
|
2768
|
-
private async turnHasToolInteractions(messageIds: number[]): Promise<boolean> {
|
|
2769
|
-
for (const messageId of messageIds) {
|
|
2770
|
-
const parts = await this.conversationStore.getMessageParts(messageId);
|
|
2771
|
-
if (parts.some(messagePartIndicatesToolUsage)) {
|
|
2772
|
-
return true;
|
|
2773
|
-
}
|
|
2774
|
-
}
|
|
2775
|
-
return false;
|
|
2776
|
-
}
|
|
2777
|
-
}
|
|
2778
|
-
|
|
2779
|
-
// ── Tool-part detection ──────────────────────────────────────────────────────
|
|
2780
|
-
|
|
2781
|
-
const TOOL_PART_TYPES: ReadonlySet<string> = new Set(["tool"]);
|
|
2782
|
-
|
|
2783
|
-
function messagePartIndicatesToolUsage(part: MessagePartRecord): boolean {
|
|
2784
|
-
if (TOOL_PART_TYPES.has(part.partType)) {
|
|
2785
|
-
return true;
|
|
2786
|
-
}
|
|
2787
|
-
if (part.toolCallId || part.toolName || part.toolInput || part.toolOutput) {
|
|
2788
|
-
return true;
|
|
2789
|
-
}
|
|
2790
|
-
if (typeof part.metadata === "string" && part.metadata.length > 0) {
|
|
2791
|
-
try {
|
|
2792
|
-
const meta = JSON.parse(part.metadata) as Record<string, unknown>;
|
|
2793
|
-
if (typeof meta.rawType === "string" && TOOL_RAW_TYPES.has(meta.rawType)) {
|
|
2794
|
-
return true;
|
|
2795
|
-
}
|
|
2796
|
-
} catch {
|
|
2797
|
-
// ignore
|
|
2798
|
-
}
|
|
2799
|
-
}
|
|
2800
|
-
return false;
|
|
2801
3383
|
}
|
|
2802
3384
|
|
|
2803
3385
|
// ── Heartbeat detection ─────────────────────────────────────────────────────
|
|
2804
3386
|
|
|
2805
3387
|
const HEARTBEAT_OK_TOKEN = "heartbeat_ok";
|
|
3388
|
+
const HEARTBEAT_TURN_MARKER = "heartbeat.md";
|
|
2806
3389
|
|
|
2807
3390
|
/**
|
|
2808
3391
|
* Detect whether an assistant message is a heartbeat ack.
|
|
@@ -2814,6 +3397,32 @@ function isHeartbeatOkContent(content: string): boolean {
|
|
|
2814
3397
|
return content.trim().toLowerCase() === HEARTBEAT_OK_TOKEN;
|
|
2815
3398
|
}
|
|
2816
3399
|
|
|
3400
|
+
function batchLooksLikeHeartbeatAckTurn(messages: AgentMessage[]): boolean {
|
|
3401
|
+
let sawHeartbeatMarker = false;
|
|
3402
|
+
let sawHeartbeatAck = false;
|
|
3403
|
+
|
|
3404
|
+
for (const message of messages) {
|
|
3405
|
+
const stored = toStoredMessage(message);
|
|
3406
|
+
if (!sawHeartbeatMarker && stored.content.toLowerCase().includes(HEARTBEAT_TURN_MARKER)) {
|
|
3407
|
+
sawHeartbeatMarker = true;
|
|
3408
|
+
}
|
|
3409
|
+
if (!sawHeartbeatAck && stored.role === "assistant" && isHeartbeatOkContent(stored.content)) {
|
|
3410
|
+
sawHeartbeatAck = true;
|
|
3411
|
+
}
|
|
3412
|
+
if (sawHeartbeatMarker && sawHeartbeatAck) {
|
|
3413
|
+
return true;
|
|
3414
|
+
}
|
|
3415
|
+
}
|
|
3416
|
+
|
|
3417
|
+
return false;
|
|
3418
|
+
}
|
|
3419
|
+
|
|
3420
|
+
function turnLooksLikeHeartbeatTurn(turnMessages: Array<{ content: string }>): boolean {
|
|
3421
|
+
return turnMessages.some((message) =>
|
|
3422
|
+
message.content.toLowerCase().includes(HEARTBEAT_TURN_MARKER),
|
|
3423
|
+
);
|
|
3424
|
+
}
|
|
3425
|
+
|
|
2817
3426
|
// ── Emergency fallback summarization ────────────────────────────────────────
|
|
2818
3427
|
|
|
2819
3428
|
/**
|