@martian-engineering/lossless-claw 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -11
- package/docs/configuration.md +44 -0
- package/openclaw.plugin.json +114 -0
- package/package.json +2 -1
- package/skills/lossless-claw/SKILL.md +33 -0
- package/skills/lossless-claw/references/architecture.md +52 -0
- package/skills/lossless-claw/references/config.md +263 -0
- package/skills/lossless-claw/references/diagnostics.md +79 -0
- package/skills/lossless-claw/references/recall-tools.md +55 -0
- package/skills/lossless-claw/references/session-lifecycle.md +59 -0
- package/src/assembler.ts +321 -34
- package/src/compaction.ts +220 -19
- package/src/db/config.ts +74 -21
- package/src/db/migration.ts +50 -13
- package/src/engine.ts +742 -133
- package/src/plugin/index.ts +156 -73
- package/src/plugin/lcm-command.ts +759 -0
- package/src/plugin/lcm-doctor-apply.ts +546 -0
- package/src/plugin/lcm-doctor-shared.ts +210 -0
- package/src/store/conversation-store.ts +60 -21
- package/src/store/parse-utc-timestamp.ts +25 -0
- package/src/store/summary-store.ts +460 -11
- package/src/summarize.ts +553 -224
- package/src/tools/lcm-expand-query-tool.ts +195 -59
- package/src/tools/lcm-expansion-recursion-guard.ts +87 -0
- package/src/types.ts +1 -0
|
@@ -13,15 +13,17 @@ import {
|
|
|
13
13
|
resolveRequesterConversationScopeId,
|
|
14
14
|
} from "./lcm-expand-tool.delegation.js";
|
|
15
15
|
import {
|
|
16
|
+
acquireExpansionConcurrencySlot,
|
|
16
17
|
clearDelegatedExpansionContext,
|
|
17
18
|
evaluateExpansionRecursionGuard,
|
|
18
19
|
recordExpansionDelegationTelemetry,
|
|
20
|
+
releaseExpansionConcurrencySlot,
|
|
19
21
|
resolveExpansionRequestId,
|
|
20
22
|
resolveNextExpansionDepth,
|
|
21
23
|
stampDelegatedExpansionContext,
|
|
22
24
|
} from "./lcm-expansion-recursion-guard.js";
|
|
23
25
|
|
|
24
|
-
const
|
|
26
|
+
const DEFAULT_DELEGATED_WAIT_TIMEOUT_MS = 120_000;
|
|
25
27
|
const GATEWAY_TIMEOUT_MS = 10_000;
|
|
26
28
|
const DEFAULT_MAX_ANSWER_TOKENS = 2_000;
|
|
27
29
|
|
|
@@ -75,9 +77,20 @@ type ExpandQueryReply = {
|
|
|
75
77
|
truncated: boolean;
|
|
76
78
|
};
|
|
77
79
|
|
|
80
|
+
type ParsedExpandQueryReply =
|
|
81
|
+
| {
|
|
82
|
+
ok: true;
|
|
83
|
+
value: ExpandQueryReply;
|
|
84
|
+
}
|
|
85
|
+
| {
|
|
86
|
+
ok: false;
|
|
87
|
+
error: string;
|
|
88
|
+
};
|
|
89
|
+
|
|
78
90
|
type SummaryCandidate = {
|
|
79
91
|
summaryId: string;
|
|
80
92
|
conversationId: number;
|
|
93
|
+
requiresMessageExpansion: boolean;
|
|
81
94
|
};
|
|
82
95
|
|
|
83
96
|
function collectExpansionFailureText(value: unknown, parts: string[], depth = 0): void {
|
|
@@ -155,6 +168,7 @@ function shouldRetryWithoutOverride(message: string): boolean {
|
|
|
155
168
|
*/
|
|
156
169
|
function buildDelegatedExpandQueryTask(params: {
|
|
157
170
|
summaryIds: string[];
|
|
171
|
+
messageBackedSummaryIds: string[];
|
|
158
172
|
conversationId: number;
|
|
159
173
|
query?: string;
|
|
160
174
|
prompt: string;
|
|
@@ -165,6 +179,10 @@ function buildDelegatedExpandQueryTask(params: {
|
|
|
165
179
|
originSessionKey: string;
|
|
166
180
|
}) {
|
|
167
181
|
const seedSummaryIds = params.summaryIds.length > 0 ? params.summaryIds.join(", ") : "(none)";
|
|
182
|
+
const messageBackedSummaryIds =
|
|
183
|
+
params.messageBackedSummaryIds.length > 0
|
|
184
|
+
? params.messageBackedSummaryIds.join(", ")
|
|
185
|
+
: "(none)";
|
|
168
186
|
return [
|
|
169
187
|
"You are an autonomous LCM retrieval navigator. Plan and execute retrieval before answering.",
|
|
170
188
|
"",
|
|
@@ -172,6 +190,7 @@ function buildDelegatedExpandQueryTask(params: {
|
|
|
172
190
|
`Conversation scope: ${params.conversationId}`,
|
|
173
191
|
`Expansion token budget (total across this run): ${params.tokenCap}`,
|
|
174
192
|
`Seed summary IDs: ${seedSummaryIds}`,
|
|
193
|
+
`Seed summaries requiring raw message expansion: ${messageBackedSummaryIds}`,
|
|
175
194
|
params.query ? `Routing query: ${params.query}` : undefined,
|
|
176
195
|
"",
|
|
177
196
|
"Strategy:",
|
|
@@ -179,7 +198,7 @@ function buildDelegatedExpandQueryTask(params: {
|
|
|
179
198
|
"2. If additional candidates are needed, use `lcm_grep` scoped to summaries.",
|
|
180
199
|
"3. Select branches that fit remaining budget; prefer high-signal paths first.",
|
|
181
200
|
"4. Call `lcm_expand` selectively (do not expand everything blindly).",
|
|
182
|
-
"5. Keep includeMessages=false by default; use includeMessages=true
|
|
201
|
+
"5. Keep includeMessages=false by default; use includeMessages=true for the message-backed seed summaries above and any other specific leaf evidence.",
|
|
183
202
|
`6. Stay within ${params.tokenCap} total expansion tokens across all lcm_expand calls.`,
|
|
184
203
|
"",
|
|
185
204
|
"User prompt to answer:",
|
|
@@ -211,24 +230,25 @@ function buildDelegatedExpandQueryTask(params: {
|
|
|
211
230
|
].join("\n");
|
|
212
231
|
}
|
|
213
232
|
|
|
233
|
+
function formatInvalidDelegatedReply(reply: string, reason: string): string {
|
|
234
|
+
const compact = reply.replace(/\s+/g, " ").trim();
|
|
235
|
+
const snippet = compact.length <= 240 ? compact : `${compact.slice(0, 240)}...`;
|
|
236
|
+
return `Delegated expansion query returned ${reason}: ${snippet}`;
|
|
237
|
+
}
|
|
238
|
+
|
|
214
239
|
/**
|
|
215
|
-
* Parse the child reply; accepts plain JSON or fenced JSON.
|
|
240
|
+
* Parse the child reply; accepts plain JSON or fenced JSON and rejects malformed fallbacks.
|
|
216
241
|
*/
|
|
217
242
|
function parseDelegatedExpandQueryReply(
|
|
218
243
|
rawReply: string | undefined,
|
|
219
244
|
fallbackExpandedSummaryCount: number,
|
|
220
|
-
):
|
|
221
|
-
const fallback: ExpandQueryReply = {
|
|
222
|
-
answer: (rawReply ?? "").trim(),
|
|
223
|
-
citedIds: [],
|
|
224
|
-
expandedSummaryCount: fallbackExpandedSummaryCount,
|
|
225
|
-
totalSourceTokens: 0,
|
|
226
|
-
truncated: false,
|
|
227
|
-
};
|
|
228
|
-
|
|
245
|
+
): ParsedExpandQueryReply {
|
|
229
246
|
const reply = rawReply?.trim();
|
|
230
247
|
if (!reply) {
|
|
231
|
-
return
|
|
248
|
+
return {
|
|
249
|
+
ok: false,
|
|
250
|
+
error: "Delegated expansion query returned an empty reply.",
|
|
251
|
+
};
|
|
232
252
|
}
|
|
233
253
|
|
|
234
254
|
const candidates: string[] = [reply];
|
|
@@ -247,6 +267,12 @@ function parseDelegatedExpandQueryReply(
|
|
|
247
267
|
truncated?: unknown;
|
|
248
268
|
};
|
|
249
269
|
const answer = typeof parsed.answer === "string" ? parsed.answer.trim() : "";
|
|
270
|
+
if (!answer) {
|
|
271
|
+
return {
|
|
272
|
+
ok: false,
|
|
273
|
+
error: formatInvalidDelegatedReply(reply, 'JSON without a non-empty "answer"'),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
250
276
|
const citedIds = normalizeSummaryIds(
|
|
251
277
|
Array.isArray(parsed.citedIds)
|
|
252
278
|
? parsed.citedIds.filter((value): value is string => typeof value === "string")
|
|
@@ -264,18 +290,24 @@ function parseDelegatedExpandQueryReply(
|
|
|
264
290
|
const truncated = parsed.truncated === true;
|
|
265
291
|
|
|
266
292
|
return {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
293
|
+
ok: true,
|
|
294
|
+
value: {
|
|
295
|
+
answer,
|
|
296
|
+
citedIds,
|
|
297
|
+
expandedSummaryCount,
|
|
298
|
+
totalSourceTokens,
|
|
299
|
+
truncated,
|
|
300
|
+
},
|
|
272
301
|
};
|
|
273
302
|
} catch {
|
|
274
303
|
// Try next candidate.
|
|
275
304
|
}
|
|
276
305
|
}
|
|
277
306
|
|
|
278
|
-
return
|
|
307
|
+
return {
|
|
308
|
+
ok: false,
|
|
309
|
+
error: formatInvalidDelegatedReply(reply, "non-JSON output"),
|
|
310
|
+
};
|
|
279
311
|
}
|
|
280
312
|
|
|
281
313
|
/**
|
|
@@ -316,6 +348,22 @@ function resolveSourceConversationId(params: {
|
|
|
316
348
|
);
|
|
317
349
|
}
|
|
318
350
|
|
|
351
|
+
function upsertSummaryCandidate(
|
|
352
|
+
candidates: Map<string, SummaryCandidate>,
|
|
353
|
+
candidate: SummaryCandidate,
|
|
354
|
+
): void {
|
|
355
|
+
const existing = candidates.get(candidate.summaryId);
|
|
356
|
+
if (!existing) {
|
|
357
|
+
candidates.set(candidate.summaryId, candidate);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
candidates.set(candidate.summaryId, {
|
|
361
|
+
...existing,
|
|
362
|
+
requiresMessageExpansion:
|
|
363
|
+
existing.requiresMessageExpansion || candidate.requiresMessageExpansion,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
319
367
|
/**
|
|
320
368
|
* Resolve summary candidates from explicit IDs and/or query matches.
|
|
321
369
|
*/
|
|
@@ -333,13 +381,15 @@ async function resolveSummaryCandidates(params: {
|
|
|
333
381
|
if (!described || described.type !== "summary" || !described.summary) {
|
|
334
382
|
throw new Error(`Summary not found: ${summaryId}`);
|
|
335
383
|
}
|
|
336
|
-
candidates
|
|
384
|
+
upsertSummaryCandidate(candidates, {
|
|
337
385
|
summaryId,
|
|
338
386
|
conversationId: described.summary.conversationId,
|
|
387
|
+
requiresMessageExpansion: false,
|
|
339
388
|
});
|
|
340
389
|
}
|
|
341
390
|
|
|
342
391
|
if (params.query) {
|
|
392
|
+
const summaryStore = params.lcm.getSummaryStore();
|
|
343
393
|
const grepResult = await retrieval.grep({
|
|
344
394
|
query: params.query,
|
|
345
395
|
mode: "full_text",
|
|
@@ -347,11 +397,46 @@ async function resolveSummaryCandidates(params: {
|
|
|
347
397
|
conversationId: params.conversationId,
|
|
348
398
|
});
|
|
349
399
|
for (const summary of grepResult.summaries) {
|
|
350
|
-
candidates
|
|
400
|
+
upsertSummaryCandidate(candidates, {
|
|
351
401
|
summaryId: summary.summaryId,
|
|
352
402
|
conversationId: summary.conversationId,
|
|
403
|
+
requiresMessageExpansion: false,
|
|
353
404
|
});
|
|
354
405
|
}
|
|
406
|
+
|
|
407
|
+
if (grepResult.summaries.length === 0 && typeof params.conversationId === "number") {
|
|
408
|
+
const maxDepth = await summaryStore.getConversationMaxSummaryDepth(params.conversationId);
|
|
409
|
+
if (typeof maxDepth === "number" && maxDepth <= 1) {
|
|
410
|
+
const messageResult = await retrieval.grep({
|
|
411
|
+
query: params.query,
|
|
412
|
+
mode: "full_text",
|
|
413
|
+
scope: "messages",
|
|
414
|
+
conversationId: params.conversationId,
|
|
415
|
+
});
|
|
416
|
+
const messageIds = messageResult.messages.map((message) => message.messageId);
|
|
417
|
+
const leafLinks = await summaryStore.getLeafSummaryLinksForMessageIds(
|
|
418
|
+
params.conversationId,
|
|
419
|
+
messageIds,
|
|
420
|
+
);
|
|
421
|
+
const summaryIdsByMessageId = new Map<number, string[]>();
|
|
422
|
+
for (const link of leafLinks) {
|
|
423
|
+
const linkedSummaryIds = summaryIdsByMessageId.get(link.messageId) ?? [];
|
|
424
|
+
if (!linkedSummaryIds.includes(link.summaryId)) {
|
|
425
|
+
linkedSummaryIds.push(link.summaryId);
|
|
426
|
+
summaryIdsByMessageId.set(link.messageId, linkedSummaryIds);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
for (const message of messageResult.messages) {
|
|
430
|
+
for (const summaryId of summaryIdsByMessageId.get(message.messageId) ?? []) {
|
|
431
|
+
upsertSummaryCandidate(candidates, {
|
|
432
|
+
summaryId,
|
|
433
|
+
conversationId: params.conversationId,
|
|
434
|
+
requiresMessageExpansion: true,
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
355
440
|
}
|
|
356
441
|
|
|
357
442
|
return Array.from(candidates.values());
|
|
@@ -367,6 +452,10 @@ export function createLcmExpandQueryTool(input: {
|
|
|
367
452
|
/** Session key for scope fallback when sessionId is unavailable. */
|
|
368
453
|
sessionKey?: string;
|
|
369
454
|
}): AnyAgentTool {
|
|
455
|
+
const delegatedWaitTimeoutMs =
|
|
456
|
+
input.deps.config.delegationTimeoutMs || DEFAULT_DELEGATED_WAIT_TIMEOUT_MS;
|
|
457
|
+
const delegatedWaitTimeoutSeconds = Math.ceil(delegatedWaitTimeoutMs / 1000);
|
|
458
|
+
|
|
370
459
|
return {
|
|
371
460
|
name: "lcm_expand_query",
|
|
372
461
|
label: "LCM Expand Query",
|
|
@@ -444,34 +533,36 @@ export function createLcmExpandQueryTool(input: {
|
|
|
444
533
|
});
|
|
445
534
|
}
|
|
446
535
|
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
sessionKey: input.sessionKey,
|
|
452
|
-
params: p,
|
|
453
|
-
});
|
|
454
|
-
let scopedConversationId = conversationScope.conversationId;
|
|
455
|
-
if (
|
|
456
|
-
!conversationScope.allConversations &&
|
|
457
|
-
scopedConversationId == null &&
|
|
458
|
-
callerSessionKey
|
|
459
|
-
) {
|
|
460
|
-
scopedConversationId = await resolveRequesterConversationScopeId({
|
|
461
|
-
deps: input.deps,
|
|
462
|
-
requesterSessionKey: callerSessionKey,
|
|
536
|
+
const originSessionKey = recursionCheck.originSessionKey || callerSessionKey || "main";
|
|
537
|
+
|
|
538
|
+
try {
|
|
539
|
+
const conversationScope = await resolveLcmConversationScope({
|
|
463
540
|
lcm: input.lcm,
|
|
541
|
+
deps: input.deps,
|
|
542
|
+
sessionId: input.sessionId,
|
|
543
|
+
sessionKey: input.sessionKey,
|
|
544
|
+
params: p,
|
|
464
545
|
});
|
|
465
|
-
|
|
546
|
+
let scopedConversationId = conversationScope.conversationId;
|
|
547
|
+
if (
|
|
548
|
+
!conversationScope.allConversations &&
|
|
549
|
+
scopedConversationId == null &&
|
|
550
|
+
callerSessionKey
|
|
551
|
+
) {
|
|
552
|
+
scopedConversationId = await resolveRequesterConversationScopeId({
|
|
553
|
+
deps: input.deps,
|
|
554
|
+
requesterSessionKey: callerSessionKey,
|
|
555
|
+
lcm: input.lcm,
|
|
556
|
+
});
|
|
557
|
+
}
|
|
466
558
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
559
|
+
if (!conversationScope.allConversations && scopedConversationId == null) {
|
|
560
|
+
return jsonResult({
|
|
561
|
+
error:
|
|
562
|
+
"No LCM conversation found for this session. Provide conversationId or set allConversations=true.",
|
|
563
|
+
});
|
|
564
|
+
}
|
|
473
565
|
|
|
474
|
-
try {
|
|
475
566
|
const candidates = await resolveSummaryCandidates({
|
|
476
567
|
lcm: input.lcm,
|
|
477
568
|
explicitSummaryIds,
|
|
@@ -505,6 +596,15 @@ export function createLcmExpandQueryTool(input: {
|
|
|
505
596
|
.filter((candidate) => candidate.conversationId === sourceConversationId)
|
|
506
597
|
.map((candidate) => candidate.summaryId),
|
|
507
598
|
);
|
|
599
|
+
const messageBackedSummaryIds = normalizeSummaryIds(
|
|
600
|
+
candidates
|
|
601
|
+
.filter(
|
|
602
|
+
(candidate) =>
|
|
603
|
+
candidate.conversationId === sourceConversationId &&
|
|
604
|
+
candidate.requiresMessageExpansion,
|
|
605
|
+
)
|
|
606
|
+
.map((candidate) => candidate.summaryId),
|
|
607
|
+
);
|
|
508
608
|
|
|
509
609
|
if (summaryIds.length === 0) {
|
|
510
610
|
return jsonResult({
|
|
@@ -512,14 +612,39 @@ export function createLcmExpandQueryTool(input: {
|
|
|
512
612
|
});
|
|
513
613
|
}
|
|
514
614
|
|
|
615
|
+
const concurrencyCheck = acquireExpansionConcurrencySlot({
|
|
616
|
+
originSessionKey,
|
|
617
|
+
requestId,
|
|
618
|
+
});
|
|
619
|
+
if (concurrencyCheck.blocked) {
|
|
620
|
+
recordExpansionDelegationTelemetry({
|
|
621
|
+
deps: input.deps,
|
|
622
|
+
component: "lcm_expand_query",
|
|
623
|
+
event: "block",
|
|
624
|
+
requestId,
|
|
625
|
+
sessionKey: callerSessionKey,
|
|
626
|
+
expansionDepth: recursionCheck.expansionDepth,
|
|
627
|
+
originSessionKey: concurrencyCheck.originSessionKey,
|
|
628
|
+
reason: concurrencyCheck.reason,
|
|
629
|
+
});
|
|
630
|
+
return jsonResult({
|
|
631
|
+
errorCode: concurrencyCheck.code,
|
|
632
|
+
error: concurrencyCheck.message,
|
|
633
|
+
requestId: concurrencyCheck.requestId,
|
|
634
|
+
expansionDepth: recursionCheck.expansionDepth,
|
|
635
|
+
originSessionKey: concurrencyCheck.originSessionKey,
|
|
636
|
+
reason: concurrencyCheck.reason,
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
|
|
515
640
|
const requesterAgentId = input.deps.normalizeAgentId(
|
|
516
641
|
input.deps.parseAgentSessionKey(callerSessionKey)?.agentId,
|
|
517
642
|
);
|
|
518
643
|
const childExpansionDepth = resolveNextExpansionDepth(callerSessionKey);
|
|
519
|
-
const originSessionKey = recursionCheck.originSessionKey || callerSessionKey || "main";
|
|
520
644
|
|
|
521
645
|
const task = buildDelegatedExpandQueryTask({
|
|
522
646
|
summaryIds,
|
|
647
|
+
messageBackedSummaryIds,
|
|
523
648
|
conversationId: sourceConversationId,
|
|
524
649
|
query: query || undefined,
|
|
525
650
|
prompt,
|
|
@@ -532,10 +657,13 @@ export function createLcmExpandQueryTool(input: {
|
|
|
532
657
|
|
|
533
658
|
const expansionProvider = input.deps.config.expansionProvider || undefined;
|
|
534
659
|
const expansionModel = input.deps.config.expansionModel || undefined;
|
|
660
|
+
const canonicalExpansionModel = expansionModel?.includes("/") ? expansionModel : undefined;
|
|
661
|
+
const delegatedOverrideProvider = canonicalExpansionModel ? undefined : expansionProvider;
|
|
662
|
+
const delegatedOverrideModel = canonicalExpansionModel || expansionModel;
|
|
535
663
|
const configuredOverrideLabel =
|
|
536
|
-
|
|
537
|
-
? `${
|
|
538
|
-
:
|
|
664
|
+
delegatedOverrideProvider && delegatedOverrideModel
|
|
665
|
+
? `${delegatedOverrideProvider}/${delegatedOverrideModel}`
|
|
666
|
+
: delegatedOverrideModel || delegatedOverrideProvider || "configured override";
|
|
539
667
|
|
|
540
668
|
const runDelegatedQuery = async (provider?: string, model?: string) => {
|
|
541
669
|
const childSessionKey = `agent:${requesterAgentId}:subagent:${crypto.randomUUID()}`;
|
|
@@ -548,7 +676,7 @@ export function createLcmExpandQueryTool(input: {
|
|
|
548
676
|
issuerSessionId: callerSessionKey || "main",
|
|
549
677
|
allowedConversationIds: [sourceConversationId],
|
|
550
678
|
tokenCap: expansionTokenCap,
|
|
551
|
-
ttlMs:
|
|
679
|
+
ttlMs: delegatedWaitTimeoutMs + 30_000,
|
|
552
680
|
});
|
|
553
681
|
stampDelegatedExpansionContext({
|
|
554
682
|
sessionKey: childSessionKey,
|
|
@@ -590,9 +718,9 @@ export function createLcmExpandQueryTool(input: {
|
|
|
590
718
|
method: "agent.wait",
|
|
591
719
|
params: {
|
|
592
720
|
runId,
|
|
593
|
-
timeoutMs:
|
|
721
|
+
timeoutMs: delegatedWaitTimeoutMs,
|
|
594
722
|
},
|
|
595
|
-
timeoutMs:
|
|
723
|
+
timeoutMs: delegatedWaitTimeoutMs,
|
|
596
724
|
})) as { status?: string; error?: unknown };
|
|
597
725
|
const status = typeof wait?.status === "string" ? wait.status : "error";
|
|
598
726
|
if (status === "timeout") {
|
|
@@ -607,7 +735,7 @@ export function createLcmExpandQueryTool(input: {
|
|
|
607
735
|
runId,
|
|
608
736
|
});
|
|
609
737
|
throw new Error(
|
|
610
|
-
|
|
738
|
+
`lcm_expand_query timed out waiting for delegated expansion (${delegatedWaitTimeoutSeconds}s).`,
|
|
611
739
|
);
|
|
612
740
|
}
|
|
613
741
|
if (status !== "ok") {
|
|
@@ -623,6 +751,9 @@ export function createLcmExpandQueryTool(input: {
|
|
|
623
751
|
Array.isArray(replyPayload.messages) ? replyPayload.messages : [],
|
|
624
752
|
);
|
|
625
753
|
const parsed = parseDelegatedExpandQueryReply(reply, summaryIds.length);
|
|
754
|
+
if (!parsed.ok) {
|
|
755
|
+
throw new Error(parsed.error);
|
|
756
|
+
}
|
|
626
757
|
recordExpansionDelegationTelemetry({
|
|
627
758
|
deps: input.deps,
|
|
628
759
|
component: "lcm_expand_query",
|
|
@@ -635,12 +766,12 @@ export function createLcmExpandQueryTool(input: {
|
|
|
635
766
|
});
|
|
636
767
|
|
|
637
768
|
return jsonResult({
|
|
638
|
-
answer: parsed.answer,
|
|
639
|
-
citedIds: parsed.citedIds,
|
|
769
|
+
answer: parsed.value.answer,
|
|
770
|
+
citedIds: parsed.value.citedIds,
|
|
640
771
|
sourceConversationId,
|
|
641
|
-
expandedSummaryCount: parsed.expandedSummaryCount,
|
|
642
|
-
totalSourceTokens: parsed.totalSourceTokens,
|
|
643
|
-
truncated: parsed.truncated,
|
|
772
|
+
expandedSummaryCount: parsed.value.expandedSummaryCount,
|
|
773
|
+
totalSourceTokens: parsed.value.totalSourceTokens,
|
|
774
|
+
truncated: parsed.value.truncated,
|
|
644
775
|
});
|
|
645
776
|
} finally {
|
|
646
777
|
try {
|
|
@@ -664,7 +795,7 @@ export function createLcmExpandQueryTool(input: {
|
|
|
664
795
|
}
|
|
665
796
|
|
|
666
797
|
try {
|
|
667
|
-
return await runDelegatedQuery(
|
|
798
|
+
return await runDelegatedQuery(delegatedOverrideProvider, delegatedOverrideModel);
|
|
668
799
|
} catch (error) {
|
|
669
800
|
const failure = formatExpansionFailure(error);
|
|
670
801
|
input.deps.log.warn(
|
|
@@ -684,6 +815,11 @@ export function createLcmExpandQueryTool(input: {
|
|
|
684
815
|
return jsonResult({
|
|
685
816
|
error: failure,
|
|
686
817
|
});
|
|
818
|
+
} finally {
|
|
819
|
+
releaseExpansionConcurrencySlot({
|
|
820
|
+
originSessionKey,
|
|
821
|
+
requestId,
|
|
822
|
+
});
|
|
687
823
|
}
|
|
688
824
|
},
|
|
689
825
|
};
|
|
@@ -3,6 +3,7 @@ import { resolveDelegatedExpansionGrantId } from "../expansion-auth.js";
|
|
|
3
3
|
import type { LcmDependencies } from "../types.js";
|
|
4
4
|
|
|
5
5
|
export const EXPANSION_RECURSION_ERROR_CODE = "EXPANSION_RECURSION_BLOCKED";
|
|
6
|
+
export const EXPANSION_CONCURRENCY_ERROR_CODE = "EXPANSION_CONCURRENCY_BLOCKED";
|
|
6
7
|
const EXPANSION_DELEGATION_DEPTH_CAP = 1;
|
|
7
8
|
|
|
8
9
|
type TelemetryEvent = "start" | "block" | "timeout" | "success";
|
|
@@ -23,6 +24,7 @@ export type DelegatedExpansionContext = {
|
|
|
23
24
|
};
|
|
24
25
|
|
|
25
26
|
export type ExpansionRecursionBlockReason = "depth_cap" | "idempotent_reentry";
|
|
27
|
+
export type ExpansionConcurrencyBlockReason = "origin_session_in_flight";
|
|
26
28
|
|
|
27
29
|
export type ExpansionRecursionGuardDecision =
|
|
28
30
|
| {
|
|
@@ -41,8 +43,24 @@ export type ExpansionRecursionGuardDecision =
|
|
|
41
43
|
originSessionKey: string;
|
|
42
44
|
};
|
|
43
45
|
|
|
46
|
+
export type ExpansionConcurrencyGuardDecision =
|
|
47
|
+
| {
|
|
48
|
+
blocked: false;
|
|
49
|
+
requestId: string;
|
|
50
|
+
originSessionKey: string;
|
|
51
|
+
}
|
|
52
|
+
| {
|
|
53
|
+
blocked: true;
|
|
54
|
+
code: typeof EXPANSION_CONCURRENCY_ERROR_CODE;
|
|
55
|
+
reason: ExpansionConcurrencyBlockReason;
|
|
56
|
+
message: string;
|
|
57
|
+
requestId: string;
|
|
58
|
+
originSessionKey: string;
|
|
59
|
+
};
|
|
60
|
+
|
|
44
61
|
const delegatedContextBySessionKey = new Map<string, DelegatedExpansionContext>();
|
|
45
62
|
const blockedRequestIdsBySessionKey = new Map<string, Set<string>>();
|
|
63
|
+
const activeRequestIdByOriginSessionKey = new Map<string, string>();
|
|
46
64
|
|
|
47
65
|
function normalizeSessionKey(sessionKey?: string): string {
|
|
48
66
|
return typeof sessionKey === "string" ? sessionKey.trim() : "";
|
|
@@ -90,6 +108,14 @@ function buildExpansionRecursionRecoveryGuidance(originSessionKey: string): stri
|
|
|
90
108
|
);
|
|
91
109
|
}
|
|
92
110
|
|
|
111
|
+
function buildExpansionConcurrencyRecoveryGuidance(originSessionKey: string): string {
|
|
112
|
+
return (
|
|
113
|
+
"Recovery: Wait for the active expansion to finish before retrying. " +
|
|
114
|
+
`If you need an immediate fallback, stay in the origin session (${originSessionKey}) ` +
|
|
115
|
+
"and use `lcm_grep` or `lcm_describe` instead."
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
93
119
|
/**
|
|
94
120
|
* Create a stable request identifier for delegated expansion orchestration.
|
|
95
121
|
*/
|
|
@@ -213,6 +239,66 @@ export function evaluateExpansionRecursionGuard(params: {
|
|
|
213
239
|
};
|
|
214
240
|
}
|
|
215
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Acquire the single active delegated-expansion slot for an origin session.
|
|
244
|
+
* A second concurrent caller from the same origin session is blocked
|
|
245
|
+
* immediately so sub-agents do not deadlock on a shared lane.
|
|
246
|
+
*/
|
|
247
|
+
export function acquireExpansionConcurrencySlot(params: {
|
|
248
|
+
originSessionKey?: string;
|
|
249
|
+
requestId: string;
|
|
250
|
+
}): ExpansionConcurrencyGuardDecision {
|
|
251
|
+
const originSessionKey = normalizeSessionKey(params.originSessionKey) || "main";
|
|
252
|
+
const requestId = params.requestId.trim();
|
|
253
|
+
const activeRequestId = activeRequestIdByOriginSessionKey.get(originSessionKey);
|
|
254
|
+
|
|
255
|
+
if (activeRequestId && activeRequestId !== requestId) {
|
|
256
|
+
return {
|
|
257
|
+
blocked: true,
|
|
258
|
+
code: EXPANSION_CONCURRENCY_ERROR_CODE,
|
|
259
|
+
reason: "origin_session_in_flight",
|
|
260
|
+
message:
|
|
261
|
+
`${EXPANSION_CONCURRENCY_ERROR_CODE}: Another lcm_expand_query delegation is already ` +
|
|
262
|
+
`in flight for origin session (${originSessionKey}; activeRequestId=${activeRequestId}). ` +
|
|
263
|
+
buildExpansionConcurrencyRecoveryGuidance(originSessionKey),
|
|
264
|
+
requestId,
|
|
265
|
+
originSessionKey,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (!activeRequestId) {
|
|
270
|
+
activeRequestIdByOriginSessionKey.set(originSessionKey, requestId);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
blocked: false,
|
|
275
|
+
requestId,
|
|
276
|
+
originSessionKey,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Release the active delegated-expansion slot for an origin session.
|
|
282
|
+
*/
|
|
283
|
+
export function releaseExpansionConcurrencySlot(params: {
|
|
284
|
+
originSessionKey?: string;
|
|
285
|
+
requestId?: string;
|
|
286
|
+
}): void {
|
|
287
|
+
const originSessionKey = normalizeSessionKey(params.originSessionKey);
|
|
288
|
+
if (!originSessionKey) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const activeRequestId = activeRequestIdByOriginSessionKey.get(originSessionKey);
|
|
292
|
+
if (!activeRequestId) {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const requestId = params.requestId?.trim();
|
|
296
|
+
if (requestId && activeRequestId !== requestId) {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
activeRequestIdByOriginSessionKey.delete(originSessionKey);
|
|
300
|
+
}
|
|
301
|
+
|
|
216
302
|
/**
|
|
217
303
|
* Emit structured delegated expansion telemetry with monotonic counters.
|
|
218
304
|
*/
|
|
@@ -279,6 +365,7 @@ export function getExpansionDelegationTelemetrySnapshotForTests(): Record<Teleme
|
|
|
279
365
|
export function resetExpansionDelegationGuardForTests(): void {
|
|
280
366
|
delegatedContextBySessionKey.clear();
|
|
281
367
|
blockedRequestIdsBySessionKey.clear();
|
|
368
|
+
activeRequestIdByOriginSessionKey.clear();
|
|
282
369
|
telemetryCounters.start = 0;
|
|
283
370
|
telemetryCounters.block = 0;
|
|
284
371
|
telemetryCounters.timeout = 0;
|