@martian-engineering/lossless-claw 0.8.0 → 0.8.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 +8 -0
- package/dist/index.js +19240 -0
- package/docs/configuration.md +15 -5
- package/openclaw.plugin.json +27 -3
- package/package.json +7 -6
- package/skills/lossless-claw/references/config.md +37 -0
- package/index.ts +0 -2
- package/src/assembler.ts +0 -1196
- package/src/compaction.ts +0 -1753
- package/src/db/config.ts +0 -345
- package/src/db/connection.ts +0 -151
- package/src/db/features.ts +0 -61
- package/src/db/migration.ts +0 -868
- package/src/engine.ts +0 -4486
- package/src/estimate-tokens.ts +0 -80
- package/src/expansion-auth.ts +0 -365
- package/src/expansion-policy.ts +0 -303
- package/src/expansion.ts +0 -383
- package/src/integrity.ts +0 -600
- package/src/large-files.ts +0 -546
- package/src/lcm-log.ts +0 -37
- package/src/openclaw-bridge.ts +0 -22
- package/src/plugin/index.ts +0 -2037
- package/src/plugin/lcm-command.ts +0 -1040
- package/src/plugin/lcm-doctor-apply.ts +0 -540
- package/src/plugin/lcm-doctor-cleaners.ts +0 -655
- package/src/plugin/lcm-doctor-shared.ts +0 -210
- package/src/plugin/shared-init.ts +0 -59
- package/src/prune.ts +0 -391
- package/src/retrieval.ts +0 -360
- package/src/session-patterns.ts +0 -23
- package/src/startup-banner-log.ts +0 -49
- package/src/store/compaction-telemetry-store.ts +0 -156
- package/src/store/conversation-store.ts +0 -929
- package/src/store/fts5-sanitize.ts +0 -50
- package/src/store/full-text-fallback.ts +0 -83
- package/src/store/full-text-sort.ts +0 -21
- package/src/store/index.ts +0 -39
- package/src/store/parse-utc-timestamp.ts +0 -25
- package/src/store/summary-store.ts +0 -1519
- package/src/summarize.ts +0 -1508
- package/src/tools/common.ts +0 -53
- package/src/tools/lcm-conversation-scope.ts +0 -127
- package/src/tools/lcm-describe-tool.ts +0 -245
- package/src/tools/lcm-expand-query-tool.ts +0 -1235
- package/src/tools/lcm-expand-tool.delegation.ts +0 -580
- package/src/tools/lcm-expand-tool.ts +0 -453
- package/src/tools/lcm-expansion-recursion-guard.ts +0 -373
- package/src/tools/lcm-grep-tool.ts +0 -228
- package/src/transaction-mutex.ts +0 -136
- package/src/transcript-repair.ts +0 -301
- package/src/types.ts +0 -165
|
@@ -1,1235 +0,0 @@
|
|
|
1
|
-
import { Type } from "@sinclair/typebox";
|
|
2
|
-
import crypto from "node:crypto";
|
|
3
|
-
import type { LcmContextEngine } from "../engine.js";
|
|
4
|
-
import {
|
|
5
|
-
createDelegatedExpansionGrant,
|
|
6
|
-
revokeDelegatedExpansionGrantForSession,
|
|
7
|
-
} from "../expansion-auth.js";
|
|
8
|
-
import type { LcmDependencies } from "../types.js";
|
|
9
|
-
import { jsonResult, type AnyAgentTool } from "./common.js";
|
|
10
|
-
import { resolveLcmConversationScope } from "./lcm-conversation-scope.js";
|
|
11
|
-
import {
|
|
12
|
-
normalizeSummaryIds,
|
|
13
|
-
resolveRequesterConversationScopeId,
|
|
14
|
-
} from "./lcm-expand-tool.delegation.js";
|
|
15
|
-
import {
|
|
16
|
-
acquireExpansionConcurrencySlot,
|
|
17
|
-
clearDelegatedExpansionContext,
|
|
18
|
-
evaluateExpansionRecursionGuard,
|
|
19
|
-
recordExpansionDelegationTelemetry,
|
|
20
|
-
releaseExpansionConcurrencySlot,
|
|
21
|
-
resolveExpansionRequestId,
|
|
22
|
-
resolveNextExpansionDepth,
|
|
23
|
-
stampDelegatedExpansionContext,
|
|
24
|
-
} from "./lcm-expansion-recursion-guard.js";
|
|
25
|
-
|
|
26
|
-
const DEFAULT_DELEGATED_WAIT_TIMEOUT_MS = 120_000;
|
|
27
|
-
const GATEWAY_TIMEOUT_MS = 10_000;
|
|
28
|
-
const DEFAULT_MAX_ANSWER_TOKENS = 2_000;
|
|
29
|
-
const DEFAULT_MAX_CONVERSATION_BUCKETS = 3;
|
|
30
|
-
|
|
31
|
-
const LcmExpandQuerySchema = Type.Object({
|
|
32
|
-
summaryIds: Type.Optional(
|
|
33
|
-
Type.Array(Type.String(), {
|
|
34
|
-
description: "Summary IDs to expand (sum_xxx). Required when query is not provided.",
|
|
35
|
-
}),
|
|
36
|
-
),
|
|
37
|
-
query: Type.Optional(
|
|
38
|
-
Type.String({
|
|
39
|
-
description:
|
|
40
|
-
"FTS5 query used to find summaries via the same full-text search path as lcm_grep before expansion. Use 1-3 distinctive terms or a quoted phrase; FTS5 defaults to AND matching, so extra terms make matches stricter. Required when summaryIds is not provided.",
|
|
41
|
-
}),
|
|
42
|
-
),
|
|
43
|
-
prompt: Type.String({
|
|
44
|
-
description:
|
|
45
|
-
"Natural-language question or task to answer using expanded context. Put the answer request here, not in query.",
|
|
46
|
-
}),
|
|
47
|
-
conversationId: Type.Optional(
|
|
48
|
-
Type.Number({
|
|
49
|
-
description:
|
|
50
|
-
"Conversation ID to scope expansion to. If omitted, uses the current session conversation.",
|
|
51
|
-
}),
|
|
52
|
-
),
|
|
53
|
-
allConversations: Type.Optional(
|
|
54
|
-
Type.Boolean({
|
|
55
|
-
description:
|
|
56
|
-
"Set true to explicitly allow cross-conversation lookup. Ignored when conversationId is provided.",
|
|
57
|
-
}),
|
|
58
|
-
),
|
|
59
|
-
maxTokens: Type.Optional(
|
|
60
|
-
Type.Number({
|
|
61
|
-
description: `Maximum answer tokens to target (default: ${DEFAULT_MAX_ANSWER_TOKENS}).`,
|
|
62
|
-
minimum: 1,
|
|
63
|
-
}),
|
|
64
|
-
),
|
|
65
|
-
tokenCap: Type.Optional(
|
|
66
|
-
Type.Number({
|
|
67
|
-
description:
|
|
68
|
-
"Expansion retrieval token budget across all delegated lcm_expand calls for this query.",
|
|
69
|
-
minimum: 1,
|
|
70
|
-
}),
|
|
71
|
-
),
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
type ConversationBreakdown = {
|
|
75
|
-
conversationId: number;
|
|
76
|
-
expandedSummaryCount: number;
|
|
77
|
-
citedIds: string[];
|
|
78
|
-
totalSourceTokens: number;
|
|
79
|
-
truncated: boolean;
|
|
80
|
-
status?: "success" | "failed" | "skipped";
|
|
81
|
-
error?: string;
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
type ExpandQueryReply = {
|
|
85
|
-
answer: string;
|
|
86
|
-
citedIds: string[];
|
|
87
|
-
sourceConversationIds: number[];
|
|
88
|
-
expandedSummaryCount: number;
|
|
89
|
-
totalSourceTokens: number;
|
|
90
|
-
truncated: boolean;
|
|
91
|
-
conversationBreakdown?: ConversationBreakdown[];
|
|
92
|
-
sourceConversationId?: number;
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
type DelegatedExpandQueryReply = {
|
|
96
|
-
answer: string;
|
|
97
|
-
citedIds: string[];
|
|
98
|
-
expandedSummaryCount: number;
|
|
99
|
-
totalSourceTokens: number;
|
|
100
|
-
truncated: boolean;
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
type ParsedExpandQueryReply =
|
|
104
|
-
| {
|
|
105
|
-
ok: true;
|
|
106
|
-
value: DelegatedExpandQueryReply;
|
|
107
|
-
}
|
|
108
|
-
| {
|
|
109
|
-
ok: false;
|
|
110
|
-
error: string;
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
type SummaryCandidate = {
|
|
114
|
-
summaryId: string;
|
|
115
|
-
conversationId: number;
|
|
116
|
-
requiresMessageExpansion: boolean;
|
|
117
|
-
isExplicit: boolean;
|
|
118
|
-
matchedAt?: Date;
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
type ConversationBucket = {
|
|
122
|
-
conversationId: number;
|
|
123
|
-
summaryIds: string[];
|
|
124
|
-
messageBackedSummaryIds: string[];
|
|
125
|
-
candidateCount: number;
|
|
126
|
-
explicitSummaryCount: number;
|
|
127
|
-
messageBackedCount: number;
|
|
128
|
-
newestMatchAt?: Date;
|
|
129
|
-
};
|
|
130
|
-
|
|
131
|
-
type BucketExecutionResult =
|
|
132
|
-
| {
|
|
133
|
-
conversationId: number;
|
|
134
|
-
status: "success";
|
|
135
|
-
candidateCount: number;
|
|
136
|
-
reply: DelegatedExpandQueryReply;
|
|
137
|
-
}
|
|
138
|
-
| {
|
|
139
|
-
conversationId: number;
|
|
140
|
-
status: "failed" | "skipped";
|
|
141
|
-
candidateCount: number;
|
|
142
|
-
error: string;
|
|
143
|
-
};
|
|
144
|
-
|
|
145
|
-
type RunDelegatedExpandQueryParams = {
|
|
146
|
-
deps: LcmDependencies;
|
|
147
|
-
callerSessionKey: string;
|
|
148
|
-
requesterAgentId: string;
|
|
149
|
-
bucket: ConversationBucket;
|
|
150
|
-
query?: string;
|
|
151
|
-
prompt: string;
|
|
152
|
-
maxTokens: number;
|
|
153
|
-
tokenCap: number;
|
|
154
|
-
requestId: string;
|
|
155
|
-
childExpansionDepth: number;
|
|
156
|
-
originSessionKey: string;
|
|
157
|
-
delegatedWaitTimeoutMs: number;
|
|
158
|
-
delegatedWaitTimeoutSeconds: number;
|
|
159
|
-
};
|
|
160
|
-
|
|
161
|
-
function collectExpansionFailureText(value: unknown, parts: string[], depth = 0): void {
|
|
162
|
-
if (depth > 3 || value == null) {
|
|
163
|
-
return;
|
|
164
|
-
}
|
|
165
|
-
if (typeof value === "string") {
|
|
166
|
-
const trimmed = value.trim();
|
|
167
|
-
if (trimmed) {
|
|
168
|
-
parts.push(trimmed);
|
|
169
|
-
}
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
if (typeof value === "number" || typeof value === "boolean") {
|
|
173
|
-
parts.push(String(value));
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
if (value instanceof Error) {
|
|
177
|
-
if (value.message.trim()) {
|
|
178
|
-
parts.push(value.message.trim());
|
|
179
|
-
}
|
|
180
|
-
collectExpansionFailureText(value.cause, parts, depth + 1);
|
|
181
|
-
return;
|
|
182
|
-
}
|
|
183
|
-
if (Array.isArray(value)) {
|
|
184
|
-
for (const entry of value) {
|
|
185
|
-
collectExpansionFailureText(entry, parts, depth + 1);
|
|
186
|
-
}
|
|
187
|
-
return;
|
|
188
|
-
}
|
|
189
|
-
if (typeof value === "object") {
|
|
190
|
-
const record = value as Record<string, unknown>;
|
|
191
|
-
for (const key of ["message", "error", "reason", "details", "response", "cause", "code"]) {
|
|
192
|
-
collectExpansionFailureText(record[key], parts, depth + 1);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function formatExpansionFailure(error: unknown): string {
|
|
198
|
-
const parts: string[] = [];
|
|
199
|
-
collectExpansionFailureText(error, parts);
|
|
200
|
-
const message = parts.join(" ").replace(/\s+/g, " ").trim();
|
|
201
|
-
if (message) {
|
|
202
|
-
return message;
|
|
203
|
-
}
|
|
204
|
-
if (typeof error === "string" && error.trim()) {
|
|
205
|
-
return error.trim();
|
|
206
|
-
}
|
|
207
|
-
return "Delegated expansion query failed.";
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
function shouldRetryWithoutOverride(message: string): boolean {
|
|
211
|
-
const normalized = message.toLowerCase();
|
|
212
|
-
return [
|
|
213
|
-
"model.request",
|
|
214
|
-
"missing scopes",
|
|
215
|
-
"insufficient scope",
|
|
216
|
-
"unauthorized",
|
|
217
|
-
"not authorized",
|
|
218
|
-
"forbidden",
|
|
219
|
-
"provider/model overrides are not authorized",
|
|
220
|
-
"model override is not authorized",
|
|
221
|
-
"unknown model",
|
|
222
|
-
"model not found",
|
|
223
|
-
"invalid model",
|
|
224
|
-
"not available",
|
|
225
|
-
"not supported",
|
|
226
|
-
"401",
|
|
227
|
-
"403",
|
|
228
|
-
].some((signal) => normalized.includes(signal));
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
function maxDate(left?: Date, right?: Date): Date | undefined {
|
|
232
|
-
if (!left) {
|
|
233
|
-
return right;
|
|
234
|
-
}
|
|
235
|
-
if (!right) {
|
|
236
|
-
return left;
|
|
237
|
-
}
|
|
238
|
-
return left.getTime() >= right.getTime() ? left : right;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/**
|
|
242
|
-
* Build the sub-agent task message for delegated expansion and prompt answering.
|
|
243
|
-
*/
|
|
244
|
-
function buildDelegatedExpandQueryTask(params: {
|
|
245
|
-
summaryIds: string[];
|
|
246
|
-
messageBackedSummaryIds: string[];
|
|
247
|
-
conversationId: number;
|
|
248
|
-
query?: string;
|
|
249
|
-
prompt: string;
|
|
250
|
-
maxTokens: number;
|
|
251
|
-
tokenCap: number;
|
|
252
|
-
requestId: string;
|
|
253
|
-
expansionDepth: number;
|
|
254
|
-
originSessionKey: string;
|
|
255
|
-
}) {
|
|
256
|
-
const seedSummaryIds = params.summaryIds.length > 0 ? params.summaryIds.join(", ") : "(none)";
|
|
257
|
-
const messageBackedSummaryIds =
|
|
258
|
-
params.messageBackedSummaryIds.length > 0
|
|
259
|
-
? params.messageBackedSummaryIds.join(", ")
|
|
260
|
-
: "(none)";
|
|
261
|
-
return [
|
|
262
|
-
"You are an autonomous LCM retrieval navigator. Plan and execute retrieval before answering.",
|
|
263
|
-
"",
|
|
264
|
-
"Available tools: lcm_describe, lcm_expand, lcm_grep",
|
|
265
|
-
`Conversation scope: ${params.conversationId}`,
|
|
266
|
-
`Expansion token budget (total across this run): ${params.tokenCap}`,
|
|
267
|
-
`Seed summary IDs: ${seedSummaryIds}`,
|
|
268
|
-
`Seed summaries requiring raw message expansion: ${messageBackedSummaryIds}`,
|
|
269
|
-
params.query ? `Routing query: ${params.query}` : undefined,
|
|
270
|
-
"",
|
|
271
|
-
"Strategy:",
|
|
272
|
-
"1. Start with `lcm_describe` on seed summaries to inspect subtree manifests and branch costs.",
|
|
273
|
-
"2. If additional candidates are needed, use `lcm_grep` scoped to summaries. Prefer `mode: \"full_text\"`, quote exact multi-word phrases, use `sort: \"relevance\"` for older-topic recall, and `sort: \"hybrid\"` when recency should still matter.",
|
|
274
|
-
"3. Select branches that fit remaining budget; prefer high-signal paths first.",
|
|
275
|
-
"4. Call `lcm_expand` selectively (do not expand everything blindly).",
|
|
276
|
-
"5. Keep includeMessages=false by default; use includeMessages=true for the message-backed seed summaries above and any other specific leaf evidence.",
|
|
277
|
-
`6. Stay within ${params.tokenCap} total expansion tokens across all lcm_expand calls.`,
|
|
278
|
-
"",
|
|
279
|
-
"User prompt to answer:",
|
|
280
|
-
params.prompt,
|
|
281
|
-
"",
|
|
282
|
-
"Delegated expansion metadata (for tracing):",
|
|
283
|
-
`- requestId: ${params.requestId}`,
|
|
284
|
-
`- expansionDepth: ${params.expansionDepth}`,
|
|
285
|
-
`- originSessionKey: ${params.originSessionKey}`,
|
|
286
|
-
"",
|
|
287
|
-
"Return ONLY JSON with this shape:",
|
|
288
|
-
"{",
|
|
289
|
-
' "answer": "string",',
|
|
290
|
-
' "citedIds": ["sum_xxx"],',
|
|
291
|
-
' "expandedSummaryCount": 0,',
|
|
292
|
-
' "totalSourceTokens": 0,',
|
|
293
|
-
' "truncated": false',
|
|
294
|
-
"}",
|
|
295
|
-
"",
|
|
296
|
-
"Rules:",
|
|
297
|
-
"- In delegated context, call `lcm_expand` directly for source retrieval.",
|
|
298
|
-
"- DO NOT call `lcm_expand_query` from this delegated session.",
|
|
299
|
-
"- Synthesize the final answer from retrieved evidence, not assumptions.",
|
|
300
|
-
`- Keep answer concise and focused (target <= ${params.maxTokens} tokens).`,
|
|
301
|
-
"- citedIds must be unique summary IDs.",
|
|
302
|
-
"- expandedSummaryCount should reflect how many summaries were expanded/used.",
|
|
303
|
-
"- totalSourceTokens should estimate total tokens consumed from expansion calls.",
|
|
304
|
-
"- truncated should indicate whether source expansion appears truncated.",
|
|
305
|
-
]
|
|
306
|
-
.filter((line): line is string => typeof line === "string")
|
|
307
|
-
.join("\n");
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
function formatInvalidDelegatedReply(reply: string, reason: string): string {
|
|
311
|
-
const compact = reply.replace(/\s+/g, " ").trim();
|
|
312
|
-
const snippet = compact.length <= 240 ? compact : `${compact.slice(0, 240)}...`;
|
|
313
|
-
return `Delegated expansion query returned ${reason}: ${snippet}`;
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
function buildConversationBuckets(candidates: SummaryCandidate[]): ConversationBucket[] {
|
|
317
|
-
const buckets = new Map<
|
|
318
|
-
number,
|
|
319
|
-
{
|
|
320
|
-
conversationId: number;
|
|
321
|
-
summaryIds: string[];
|
|
322
|
-
messageBackedSummaryIds: string[];
|
|
323
|
-
summaryIdSet: Set<string>;
|
|
324
|
-
explicitSummaryIdSet: Set<string>;
|
|
325
|
-
messageBackedSummaryIdSet: Set<string>;
|
|
326
|
-
newestMatchAt?: Date;
|
|
327
|
-
}
|
|
328
|
-
>();
|
|
329
|
-
|
|
330
|
-
for (const candidate of candidates) {
|
|
331
|
-
const bucket =
|
|
332
|
-
buckets.get(candidate.conversationId) ??
|
|
333
|
-
{
|
|
334
|
-
conversationId: candidate.conversationId,
|
|
335
|
-
summaryIds: [],
|
|
336
|
-
messageBackedSummaryIds: [],
|
|
337
|
-
summaryIdSet: new Set<string>(),
|
|
338
|
-
explicitSummaryIdSet: new Set<string>(),
|
|
339
|
-
messageBackedSummaryIdSet: new Set<string>(),
|
|
340
|
-
newestMatchAt: undefined,
|
|
341
|
-
};
|
|
342
|
-
|
|
343
|
-
if (!bucket.summaryIdSet.has(candidate.summaryId)) {
|
|
344
|
-
bucket.summaryIds.push(candidate.summaryId);
|
|
345
|
-
bucket.summaryIdSet.add(candidate.summaryId);
|
|
346
|
-
}
|
|
347
|
-
if (candidate.isExplicit) {
|
|
348
|
-
bucket.explicitSummaryIdSet.add(candidate.summaryId);
|
|
349
|
-
}
|
|
350
|
-
if (
|
|
351
|
-
candidate.requiresMessageExpansion &&
|
|
352
|
-
!bucket.messageBackedSummaryIdSet.has(candidate.summaryId)
|
|
353
|
-
) {
|
|
354
|
-
bucket.messageBackedSummaryIds.push(candidate.summaryId);
|
|
355
|
-
bucket.messageBackedSummaryIdSet.add(candidate.summaryId);
|
|
356
|
-
}
|
|
357
|
-
bucket.newestMatchAt = maxDate(bucket.newestMatchAt, candidate.matchedAt);
|
|
358
|
-
buckets.set(candidate.conversationId, bucket);
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
return Array.from(buckets.values()).map((bucket) => ({
|
|
362
|
-
conversationId: bucket.conversationId,
|
|
363
|
-
summaryIds: normalizeSummaryIds(bucket.summaryIds),
|
|
364
|
-
messageBackedSummaryIds: normalizeSummaryIds(bucket.messageBackedSummaryIds),
|
|
365
|
-
candidateCount: bucket.summaryIds.length,
|
|
366
|
-
explicitSummaryCount: bucket.explicitSummaryIdSet.size,
|
|
367
|
-
messageBackedCount: bucket.messageBackedSummaryIds.length,
|
|
368
|
-
newestMatchAt: bucket.newestMatchAt,
|
|
369
|
-
}));
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
function compareConversationBuckets(left: ConversationBucket, right: ConversationBucket): number {
|
|
373
|
-
const explicitDelta = right.explicitSummaryCount - left.explicitSummaryCount;
|
|
374
|
-
if (explicitDelta !== 0) {
|
|
375
|
-
return explicitDelta;
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
const candidateDelta = right.candidateCount - left.candidateCount;
|
|
379
|
-
if (candidateDelta !== 0) {
|
|
380
|
-
return candidateDelta;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
const recencyDelta =
|
|
384
|
-
(right.newestMatchAt?.getTime() ?? 0) - (left.newestMatchAt?.getTime() ?? 0);
|
|
385
|
-
if (recencyDelta !== 0) {
|
|
386
|
-
return recencyDelta;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
const messageBackedDelta = right.messageBackedCount - left.messageBackedCount;
|
|
390
|
-
if (messageBackedDelta !== 0) {
|
|
391
|
-
return messageBackedDelta;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
return left.conversationId - right.conversationId;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
function buildExpandQueryReply(params: {
|
|
398
|
-
answer: string;
|
|
399
|
-
citedIds: string[];
|
|
400
|
-
sourceConversationIds: number[];
|
|
401
|
-
expandedSummaryCount: number;
|
|
402
|
-
totalSourceTokens: number;
|
|
403
|
-
truncated: boolean;
|
|
404
|
-
conversationBreakdown?: ConversationBreakdown[];
|
|
405
|
-
}): ExpandQueryReply {
|
|
406
|
-
const sourceConversationIds = [...params.sourceConversationIds].sort((left, right) => left - right);
|
|
407
|
-
|
|
408
|
-
return {
|
|
409
|
-
answer: params.answer,
|
|
410
|
-
citedIds: normalizeSummaryIds(params.citedIds),
|
|
411
|
-
sourceConversationIds,
|
|
412
|
-
...(sourceConversationIds.length === 1
|
|
413
|
-
? { sourceConversationId: sourceConversationIds[0] }
|
|
414
|
-
: {}),
|
|
415
|
-
expandedSummaryCount: params.expandedSummaryCount,
|
|
416
|
-
totalSourceTokens: params.totalSourceTokens,
|
|
417
|
-
truncated: params.truncated,
|
|
418
|
-
...(params.conversationBreakdown ? { conversationBreakdown: params.conversationBreakdown } : {}),
|
|
419
|
-
};
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
function synthesizeConversationAnswers(params: {
|
|
423
|
-
prompt: string;
|
|
424
|
-
results: BucketExecutionResult[];
|
|
425
|
-
}): string {
|
|
426
|
-
const successfulResults = params.results.filter(
|
|
427
|
-
(result): result is Extract<BucketExecutionResult, { status: "success" }> =>
|
|
428
|
-
result.status === "success",
|
|
429
|
-
);
|
|
430
|
-
const failedResults = params.results.filter(
|
|
431
|
-
(result): result is Extract<BucketExecutionResult, { status: "failed" }> =>
|
|
432
|
-
result.status === "failed",
|
|
433
|
-
);
|
|
434
|
-
const skippedResults = params.results.filter(
|
|
435
|
-
(result): result is Extract<BucketExecutionResult, { status: "skipped" }> =>
|
|
436
|
-
result.status === "skipped",
|
|
437
|
-
);
|
|
438
|
-
|
|
439
|
-
if (successfulResults.length === 1 && failedResults.length === 0 && skippedResults.length === 0) {
|
|
440
|
-
return successfulResults[0].reply.answer;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
const lines: string[] = [];
|
|
444
|
-
if (successfulResults.length > 1) {
|
|
445
|
-
lines.push(`Merged findings across ${successfulResults.length} conversations:`);
|
|
446
|
-
lines.push("");
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
for (const result of successfulResults) {
|
|
450
|
-
if (successfulResults.length > 1) {
|
|
451
|
-
lines.push(`Conversation ${result.conversationId}:`);
|
|
452
|
-
}
|
|
453
|
-
lines.push(result.reply.answer);
|
|
454
|
-
if (successfulResults.length > 1) {
|
|
455
|
-
lines.push("");
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
const notes: string[] = [];
|
|
460
|
-
if (failedResults.length > 0) {
|
|
461
|
-
notes.push(
|
|
462
|
-
`failed conversations: ${failedResults
|
|
463
|
-
.map((result) => `${result.conversationId} (${result.error})`)
|
|
464
|
-
.join("; ")}`,
|
|
465
|
-
);
|
|
466
|
-
}
|
|
467
|
-
if (skippedResults.length > 0) {
|
|
468
|
-
notes.push(
|
|
469
|
-
`skipped conversations: ${skippedResults
|
|
470
|
-
.map((result) => `${result.conversationId} (${result.error})`)
|
|
471
|
-
.join("; ")}`,
|
|
472
|
-
);
|
|
473
|
-
}
|
|
474
|
-
if (notes.length > 0) {
|
|
475
|
-
if (lines.length > 0 && lines[lines.length - 1] !== "") {
|
|
476
|
-
lines.push("");
|
|
477
|
-
}
|
|
478
|
-
lines.push(`Partial coverage for "${params.prompt}": ${notes.join("; ")}`);
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
return lines.join("\n").trim();
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
/**
|
|
485
|
-
* Parse the child reply; accepts plain JSON or fenced JSON and rejects malformed fallbacks.
|
|
486
|
-
*/
|
|
487
|
-
function parseDelegatedExpandQueryReply(
|
|
488
|
-
rawReply: string | undefined,
|
|
489
|
-
fallbackExpandedSummaryCount: number,
|
|
490
|
-
): ParsedExpandQueryReply {
|
|
491
|
-
const reply = rawReply?.trim();
|
|
492
|
-
if (!reply) {
|
|
493
|
-
return {
|
|
494
|
-
ok: false,
|
|
495
|
-
error: "Delegated expansion query returned an empty reply.",
|
|
496
|
-
};
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
const candidates: string[] = [reply];
|
|
500
|
-
const fenced = reply.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
501
|
-
if (fenced?.[1]) {
|
|
502
|
-
candidates.unshift(fenced[1].trim());
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
for (const candidate of candidates) {
|
|
506
|
-
try {
|
|
507
|
-
const parsed = JSON.parse(candidate) as {
|
|
508
|
-
answer?: unknown;
|
|
509
|
-
citedIds?: unknown;
|
|
510
|
-
expandedSummaryCount?: unknown;
|
|
511
|
-
totalSourceTokens?: unknown;
|
|
512
|
-
truncated?: unknown;
|
|
513
|
-
};
|
|
514
|
-
const answer = typeof parsed.answer === "string" ? parsed.answer.trim() : "";
|
|
515
|
-
if (!answer) {
|
|
516
|
-
return {
|
|
517
|
-
ok: false,
|
|
518
|
-
error: formatInvalidDelegatedReply(reply, 'JSON without a non-empty "answer"'),
|
|
519
|
-
};
|
|
520
|
-
}
|
|
521
|
-
const citedIds = normalizeSummaryIds(
|
|
522
|
-
Array.isArray(parsed.citedIds)
|
|
523
|
-
? parsed.citedIds.filter((value): value is string => typeof value === "string")
|
|
524
|
-
: undefined,
|
|
525
|
-
);
|
|
526
|
-
const expandedSummaryCount =
|
|
527
|
-
typeof parsed.expandedSummaryCount === "number" &&
|
|
528
|
-
Number.isFinite(parsed.expandedSummaryCount)
|
|
529
|
-
? Math.max(0, Math.floor(parsed.expandedSummaryCount))
|
|
530
|
-
: fallbackExpandedSummaryCount;
|
|
531
|
-
const totalSourceTokens =
|
|
532
|
-
typeof parsed.totalSourceTokens === "number" && Number.isFinite(parsed.totalSourceTokens)
|
|
533
|
-
? Math.max(0, Math.floor(parsed.totalSourceTokens))
|
|
534
|
-
: 0;
|
|
535
|
-
const truncated = parsed.truncated === true;
|
|
536
|
-
|
|
537
|
-
return {
|
|
538
|
-
ok: true,
|
|
539
|
-
value: {
|
|
540
|
-
answer,
|
|
541
|
-
citedIds,
|
|
542
|
-
expandedSummaryCount,
|
|
543
|
-
totalSourceTokens,
|
|
544
|
-
truncated,
|
|
545
|
-
},
|
|
546
|
-
};
|
|
547
|
-
} catch {
|
|
548
|
-
// Try next candidate.
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
return {
|
|
553
|
-
ok: false,
|
|
554
|
-
error: formatInvalidDelegatedReply(reply, "non-JSON output"),
|
|
555
|
-
};
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
/**
|
|
559
|
-
* Resolve a single source conversation for delegated expansion.
|
|
560
|
-
*/
|
|
561
|
-
function resolveSourceConversationId(params: {
|
|
562
|
-
scopedConversationId?: number;
|
|
563
|
-
allConversations: boolean;
|
|
564
|
-
candidates: SummaryCandidate[];
|
|
565
|
-
}): number {
|
|
566
|
-
if (typeof params.scopedConversationId === "number") {
|
|
567
|
-
const mismatched = params.candidates
|
|
568
|
-
.filter((candidate) => candidate.conversationId !== params.scopedConversationId)
|
|
569
|
-
.map((candidate) => candidate.summaryId);
|
|
570
|
-
if (mismatched.length > 0) {
|
|
571
|
-
throw new Error(
|
|
572
|
-
`Some summaryIds are outside conversation ${params.scopedConversationId}: ${mismatched.join(", ")}`,
|
|
573
|
-
);
|
|
574
|
-
}
|
|
575
|
-
return params.scopedConversationId;
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
const conversationIds = Array.from(
|
|
579
|
-
new Set(params.candidates.map((candidate) => candidate.conversationId)),
|
|
580
|
-
);
|
|
581
|
-
if (conversationIds.length === 1 && typeof conversationIds[0] === "number") {
|
|
582
|
-
return conversationIds[0];
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
if (params.allConversations && conversationIds.length > 1) {
|
|
586
|
-
throw new Error(
|
|
587
|
-
"Query matched summaries from multiple conversations. Provide conversationId or narrow the query.",
|
|
588
|
-
);
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
throw new Error(
|
|
592
|
-
"Unable to resolve a single conversation scope. Provide conversationId or set a narrower summary scope.",
|
|
593
|
-
);
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
function selectSingleConversationBucket(params: {
|
|
597
|
-
sourceConversationId: number;
|
|
598
|
-
buckets: ConversationBucket[];
|
|
599
|
-
}): ConversationBucket {
|
|
600
|
-
const bucket = params.buckets.find(
|
|
601
|
-
(candidateBucket) => candidateBucket.conversationId === params.sourceConversationId,
|
|
602
|
-
);
|
|
603
|
-
if (!bucket || bucket.summaryIds.length === 0) {
|
|
604
|
-
throw new Error("No summaryIds available after applying conversation scope.");
|
|
605
|
-
}
|
|
606
|
-
return bucket;
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
function upsertSummaryCandidate(
|
|
610
|
-
candidates: Map<string, SummaryCandidate>,
|
|
611
|
-
candidate: SummaryCandidate,
|
|
612
|
-
): void {
|
|
613
|
-
const existing = candidates.get(candidate.summaryId);
|
|
614
|
-
if (!existing) {
|
|
615
|
-
candidates.set(candidate.summaryId, candidate);
|
|
616
|
-
return;
|
|
617
|
-
}
|
|
618
|
-
candidates.set(candidate.summaryId, {
|
|
619
|
-
...existing,
|
|
620
|
-
requiresMessageExpansion:
|
|
621
|
-
existing.requiresMessageExpansion || candidate.requiresMessageExpansion,
|
|
622
|
-
isExplicit: existing.isExplicit || candidate.isExplicit,
|
|
623
|
-
matchedAt: maxDate(existing.matchedAt, candidate.matchedAt),
|
|
624
|
-
});
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
/**
|
|
628
|
-
* Resolve summary candidates from explicit IDs and/or query matches.
|
|
629
|
-
*/
|
|
630
|
-
async function resolveSummaryCandidates(params: {
|
|
631
|
-
lcm: LcmContextEngine;
|
|
632
|
-
explicitSummaryIds: string[];
|
|
633
|
-
query?: string;
|
|
634
|
-
conversationId?: number;
|
|
635
|
-
}): Promise<SummaryCandidate[]> {
|
|
636
|
-
const retrieval = params.lcm.getRetrieval();
|
|
637
|
-
const candidates = new Map<string, SummaryCandidate>();
|
|
638
|
-
|
|
639
|
-
for (const summaryId of params.explicitSummaryIds) {
|
|
640
|
-
const described = await retrieval.describe(summaryId);
|
|
641
|
-
if (!described || described.type !== "summary" || !described.summary) {
|
|
642
|
-
throw new Error(`Summary not found: ${summaryId}`);
|
|
643
|
-
}
|
|
644
|
-
upsertSummaryCandidate(candidates, {
|
|
645
|
-
summaryId,
|
|
646
|
-
conversationId: described.summary.conversationId,
|
|
647
|
-
requiresMessageExpansion: false,
|
|
648
|
-
isExplicit: true,
|
|
649
|
-
matchedAt: described.summary.latestAt ?? described.summary.createdAt,
|
|
650
|
-
});
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
if (params.query) {
|
|
654
|
-
const summaryStore = params.lcm.getSummaryStore();
|
|
655
|
-
const grepResult = await retrieval.grep({
|
|
656
|
-
query: params.query,
|
|
657
|
-
mode: "full_text",
|
|
658
|
-
scope: "summaries",
|
|
659
|
-
conversationId: params.conversationId,
|
|
660
|
-
});
|
|
661
|
-
for (const summary of grepResult.summaries) {
|
|
662
|
-
upsertSummaryCandidate(candidates, {
|
|
663
|
-
summaryId: summary.summaryId,
|
|
664
|
-
conversationId: summary.conversationId,
|
|
665
|
-
requiresMessageExpansion: false,
|
|
666
|
-
isExplicit: false,
|
|
667
|
-
matchedAt: summary.createdAt,
|
|
668
|
-
});
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
if (grepResult.summaries.length === 0 && typeof params.conversationId === "number") {
|
|
672
|
-
const maxDepth = await summaryStore.getConversationMaxSummaryDepth(params.conversationId);
|
|
673
|
-
if (typeof maxDepth === "number" && maxDepth <= 1) {
|
|
674
|
-
const messageResult = await retrieval.grep({
|
|
675
|
-
query: params.query,
|
|
676
|
-
mode: "full_text",
|
|
677
|
-
scope: "messages",
|
|
678
|
-
conversationId: params.conversationId,
|
|
679
|
-
});
|
|
680
|
-
const messageIds = messageResult.messages.map((message) => message.messageId);
|
|
681
|
-
const leafLinks = await summaryStore.getLeafSummaryLinksForMessageIds(
|
|
682
|
-
params.conversationId,
|
|
683
|
-
messageIds,
|
|
684
|
-
);
|
|
685
|
-
const summaryIdsByMessageId = new Map<number, string[]>();
|
|
686
|
-
for (const link of leafLinks) {
|
|
687
|
-
const linkedSummaryIds = summaryIdsByMessageId.get(link.messageId) ?? [];
|
|
688
|
-
if (!linkedSummaryIds.includes(link.summaryId)) {
|
|
689
|
-
linkedSummaryIds.push(link.summaryId);
|
|
690
|
-
summaryIdsByMessageId.set(link.messageId, linkedSummaryIds);
|
|
691
|
-
}
|
|
692
|
-
}
|
|
693
|
-
for (const message of messageResult.messages) {
|
|
694
|
-
for (const summaryId of summaryIdsByMessageId.get(message.messageId) ?? []) {
|
|
695
|
-
upsertSummaryCandidate(candidates, {
|
|
696
|
-
summaryId,
|
|
697
|
-
conversationId: params.conversationId,
|
|
698
|
-
requiresMessageExpansion: true,
|
|
699
|
-
isExplicit: false,
|
|
700
|
-
matchedAt: message.createdAt,
|
|
701
|
-
});
|
|
702
|
-
}
|
|
703
|
-
}
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
return Array.from(candidates.values());
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
/**
|
|
712
|
-
* Run a single delegated lcm_expand_query bucket against one conversation.
|
|
713
|
-
*/
|
|
714
|
-
async function runDelegatedExpandQuery(
|
|
715
|
-
params: RunDelegatedExpandQueryParams,
|
|
716
|
-
): Promise<DelegatedExpandQueryReply> {
|
|
717
|
-
const task = buildDelegatedExpandQueryTask({
|
|
718
|
-
summaryIds: params.bucket.summaryIds,
|
|
719
|
-
messageBackedSummaryIds: params.bucket.messageBackedSummaryIds,
|
|
720
|
-
conversationId: params.bucket.conversationId,
|
|
721
|
-
query: params.query,
|
|
722
|
-
prompt: params.prompt,
|
|
723
|
-
maxTokens: params.maxTokens,
|
|
724
|
-
tokenCap: params.tokenCap,
|
|
725
|
-
requestId: params.requestId,
|
|
726
|
-
expansionDepth: params.childExpansionDepth,
|
|
727
|
-
originSessionKey: params.originSessionKey,
|
|
728
|
-
});
|
|
729
|
-
|
|
730
|
-
const expansionProvider = params.deps.config.expansionProvider || undefined;
|
|
731
|
-
const expansionModel = params.deps.config.expansionModel || undefined;
|
|
732
|
-
const canonicalExpansionModel = expansionModel?.includes("/") ? expansionModel : undefined;
|
|
733
|
-
const delegatedOverrideProvider = canonicalExpansionModel ? undefined : expansionProvider;
|
|
734
|
-
const delegatedOverrideModel = canonicalExpansionModel || expansionModel;
|
|
735
|
-
const configuredOverrideLabel =
|
|
736
|
-
delegatedOverrideProvider && delegatedOverrideModel
|
|
737
|
-
? `${delegatedOverrideProvider}/${delegatedOverrideModel}`
|
|
738
|
-
: delegatedOverrideModel || delegatedOverrideProvider || "configured override";
|
|
739
|
-
|
|
740
|
-
const runDelegatedQuery = async (provider?: string, model?: string) => {
|
|
741
|
-
const childSessionKey = `agent:${params.requesterAgentId}:subagent:${crypto.randomUUID()}`;
|
|
742
|
-
const childIdem = crypto.randomUUID();
|
|
743
|
-
let grantCreated = false;
|
|
744
|
-
|
|
745
|
-
try {
|
|
746
|
-
createDelegatedExpansionGrant({
|
|
747
|
-
delegatedSessionKey: childSessionKey,
|
|
748
|
-
issuerSessionId: params.callerSessionKey || "main",
|
|
749
|
-
allowedConversationIds: [params.bucket.conversationId],
|
|
750
|
-
tokenCap: params.tokenCap,
|
|
751
|
-
ttlMs: params.delegatedWaitTimeoutMs + 30_000,
|
|
752
|
-
});
|
|
753
|
-
stampDelegatedExpansionContext({
|
|
754
|
-
sessionKey: childSessionKey,
|
|
755
|
-
requestId: params.requestId,
|
|
756
|
-
expansionDepth: params.childExpansionDepth,
|
|
757
|
-
originSessionKey: params.originSessionKey,
|
|
758
|
-
stampedBy: "lcm_expand_query",
|
|
759
|
-
});
|
|
760
|
-
grantCreated = true;
|
|
761
|
-
|
|
762
|
-
const response = (await params.deps.callGateway({
|
|
763
|
-
method: "agent",
|
|
764
|
-
params: {
|
|
765
|
-
message: task,
|
|
766
|
-
sessionKey: childSessionKey,
|
|
767
|
-
deliver: false,
|
|
768
|
-
lane: params.deps.agentLaneSubagent,
|
|
769
|
-
idempotencyKey: childIdem,
|
|
770
|
-
...(provider ? { provider } : {}),
|
|
771
|
-
...(model ? { model } : {}),
|
|
772
|
-
extraSystemPrompt: params.deps.buildSubagentSystemPrompt({
|
|
773
|
-
depth: 1,
|
|
774
|
-
maxDepth: 8,
|
|
775
|
-
taskSummary: "Run lcm_expand and return prompt-focused JSON answer",
|
|
776
|
-
}),
|
|
777
|
-
},
|
|
778
|
-
timeoutMs: GATEWAY_TIMEOUT_MS,
|
|
779
|
-
})) as { runId?: unknown; error?: unknown };
|
|
780
|
-
|
|
781
|
-
const runId = typeof response?.runId === "string" ? response.runId.trim() : "";
|
|
782
|
-
if (!runId) {
|
|
783
|
-
throw new Error(
|
|
784
|
-
formatExpansionFailure(response?.error ?? response)
|
|
785
|
-
|| "Delegated expansion did not return a runId.",
|
|
786
|
-
);
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
const wait = (await params.deps.callGateway({
|
|
790
|
-
method: "agent.wait",
|
|
791
|
-
params: {
|
|
792
|
-
runId,
|
|
793
|
-
timeoutMs: params.delegatedWaitTimeoutMs,
|
|
794
|
-
},
|
|
795
|
-
timeoutMs: params.delegatedWaitTimeoutMs,
|
|
796
|
-
})) as { status?: string; error?: unknown };
|
|
797
|
-
const status = typeof wait?.status === "string" ? wait.status : "error";
|
|
798
|
-
if (status === "timeout") {
|
|
799
|
-
recordExpansionDelegationTelemetry({
|
|
800
|
-
deps: params.deps,
|
|
801
|
-
component: "lcm_expand_query",
|
|
802
|
-
event: "timeout",
|
|
803
|
-
requestId: params.requestId,
|
|
804
|
-
sessionKey: params.callerSessionKey,
|
|
805
|
-
expansionDepth: params.childExpansionDepth,
|
|
806
|
-
originSessionKey: params.originSessionKey,
|
|
807
|
-
runId,
|
|
808
|
-
});
|
|
809
|
-
throw new Error(
|
|
810
|
-
`lcm_expand_query timed out waiting for delegated expansion (${params.delegatedWaitTimeoutSeconds}s).`,
|
|
811
|
-
);
|
|
812
|
-
}
|
|
813
|
-
if (status !== "ok") {
|
|
814
|
-
throw new Error(formatExpansionFailure(wait?.error));
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
const replyPayload = (await params.deps.callGateway({
|
|
818
|
-
method: "sessions.get",
|
|
819
|
-
params: { key: childSessionKey, limit: 80 },
|
|
820
|
-
timeoutMs: GATEWAY_TIMEOUT_MS,
|
|
821
|
-
})) as { messages?: unknown[] };
|
|
822
|
-
const reply = params.deps.readLatestAssistantReply(
|
|
823
|
-
Array.isArray(replyPayload.messages) ? replyPayload.messages : [],
|
|
824
|
-
);
|
|
825
|
-
const parsed = parseDelegatedExpandQueryReply(reply, params.bucket.summaryIds.length);
|
|
826
|
-
if (!parsed.ok) {
|
|
827
|
-
throw new Error(parsed.error);
|
|
828
|
-
}
|
|
829
|
-
recordExpansionDelegationTelemetry({
|
|
830
|
-
deps: params.deps,
|
|
831
|
-
component: "lcm_expand_query",
|
|
832
|
-
event: "success",
|
|
833
|
-
requestId: params.requestId,
|
|
834
|
-
sessionKey: params.callerSessionKey,
|
|
835
|
-
expansionDepth: params.childExpansionDepth,
|
|
836
|
-
originSessionKey: params.originSessionKey,
|
|
837
|
-
runId,
|
|
838
|
-
});
|
|
839
|
-
|
|
840
|
-
return parsed.value;
|
|
841
|
-
} finally {
|
|
842
|
-
try {
|
|
843
|
-
await params.deps.callGateway({
|
|
844
|
-
method: "sessions.delete",
|
|
845
|
-
params: { key: childSessionKey, deleteTranscript: true },
|
|
846
|
-
timeoutMs: GATEWAY_TIMEOUT_MS,
|
|
847
|
-
});
|
|
848
|
-
} catch {
|
|
849
|
-
// Cleanup is best-effort.
|
|
850
|
-
}
|
|
851
|
-
if (grantCreated) {
|
|
852
|
-
revokeDelegatedExpansionGrantForSession(childSessionKey, { removeBinding: true });
|
|
853
|
-
}
|
|
854
|
-
clearDelegatedExpansionContext(childSessionKey);
|
|
855
|
-
}
|
|
856
|
-
};
|
|
857
|
-
|
|
858
|
-
if (!expansionProvider && !expansionModel) {
|
|
859
|
-
return await runDelegatedQuery();
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
try {
|
|
863
|
-
return await runDelegatedQuery(delegatedOverrideProvider, delegatedOverrideModel);
|
|
864
|
-
} catch (error) {
|
|
865
|
-
const failure = formatExpansionFailure(error);
|
|
866
|
-
params.deps.log.warn(
|
|
867
|
-
`[lcm] delegated expansion override failed (${configuredOverrideLabel}) for conversation ${params.bucket.conversationId}: ${failure}`,
|
|
868
|
-
);
|
|
869
|
-
if (!shouldRetryWithoutOverride(failure)) {
|
|
870
|
-
throw new Error(failure);
|
|
871
|
-
}
|
|
872
|
-
params.deps.log.warn(
|
|
873
|
-
`[lcm] retrying delegated expansion without provider/model override after: ${failure}`,
|
|
874
|
-
);
|
|
875
|
-
return await runDelegatedQuery();
|
|
876
|
-
}
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
/**
|
|
880
|
-
* Create the top-level lcm_expand_query tool wrapper for main-agent use.
|
|
881
|
-
*/
|
|
882
|
-
export function createLcmExpandQueryTool(input: {
|
|
883
|
-
deps: LcmDependencies;
|
|
884
|
-
lcm?: LcmContextEngine;
|
|
885
|
-
getLcm?: () => Promise<LcmContextEngine>;
|
|
886
|
-
/** Session id used for LCM conversation scoping. */
|
|
887
|
-
sessionId?: string;
|
|
888
|
-
/** Requester agent session key used for delegated child session/auth scoping. */
|
|
889
|
-
requesterSessionKey?: string;
|
|
890
|
-
/** Session key for scope fallback when sessionId is unavailable. */
|
|
891
|
-
sessionKey?: string;
|
|
892
|
-
}): AnyAgentTool {
|
|
893
|
-
const delegatedWaitTimeoutMs =
|
|
894
|
-
input.deps.config.delegationTimeoutMs || DEFAULT_DELEGATED_WAIT_TIMEOUT_MS;
|
|
895
|
-
const delegatedWaitTimeoutSeconds = Math.ceil(delegatedWaitTimeoutMs / 1000);
|
|
896
|
-
|
|
897
|
-
return {
|
|
898
|
-
name: "lcm_expand_query",
|
|
899
|
-
label: "LCM Expand Query",
|
|
900
|
-
description:
|
|
901
|
-
"Answer a focused natural-language question using delegated LCM expansion. " +
|
|
902
|
-
"Find candidate summaries (by IDs or a short FTS5 query that follows the same full-text rules as lcm_grep), expand them in a delegated sub-agent, " +
|
|
903
|
-
"and return a compact prompt-focused answer. Tool output includes cited summary IDs for follow-up.",
|
|
904
|
-
parameters: LcmExpandQuerySchema,
|
|
905
|
-
async execute(_toolCallId, params) {
|
|
906
|
-
const lcm = input.lcm ?? (await input.getLcm?.());
|
|
907
|
-
if (!lcm) {
|
|
908
|
-
throw new Error("LCM engine is unavailable.");
|
|
909
|
-
}
|
|
910
|
-
const p = params as Record<string, unknown>;
|
|
911
|
-
const explicitSummaryIds = normalizeSummaryIds(p.summaryIds as string[] | undefined);
|
|
912
|
-
const query = typeof p.query === "string" ? p.query.trim() : "";
|
|
913
|
-
const prompt = typeof p.prompt === "string" ? p.prompt.trim() : "";
|
|
914
|
-
const requestedMaxTokens =
|
|
915
|
-
typeof p.maxTokens === "number" ? Math.trunc(p.maxTokens) : undefined;
|
|
916
|
-
const maxTokens =
|
|
917
|
-
typeof requestedMaxTokens === "number" && Number.isFinite(requestedMaxTokens)
|
|
918
|
-
? Math.max(1, requestedMaxTokens)
|
|
919
|
-
: DEFAULT_MAX_ANSWER_TOKENS;
|
|
920
|
-
const requestedTokenCap =
|
|
921
|
-
typeof p.tokenCap === "number" ? Math.trunc(p.tokenCap) : undefined;
|
|
922
|
-
const expansionTokenCap =
|
|
923
|
-
typeof requestedTokenCap === "number" && Number.isFinite(requestedTokenCap)
|
|
924
|
-
? Math.max(1, requestedTokenCap)
|
|
925
|
-
: Math.max(1, Math.trunc(input.deps.config.maxExpandTokens));
|
|
926
|
-
|
|
927
|
-
if (!prompt) {
|
|
928
|
-
return jsonResult({
|
|
929
|
-
error: "prompt is required.",
|
|
930
|
-
});
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
if (explicitSummaryIds.length === 0 && !query) {
|
|
934
|
-
return jsonResult({
|
|
935
|
-
error: "Either summaryIds or query must be provided.",
|
|
936
|
-
});
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
const callerSessionKey =
|
|
940
|
-
(typeof input.requesterSessionKey === "string"
|
|
941
|
-
? input.requesterSessionKey
|
|
942
|
-
: input.sessionId
|
|
943
|
-
)?.trim() ?? "";
|
|
944
|
-
const requestId = resolveExpansionRequestId(callerSessionKey);
|
|
945
|
-
const recursionCheck = evaluateExpansionRecursionGuard({
|
|
946
|
-
sessionKey: callerSessionKey,
|
|
947
|
-
requestId,
|
|
948
|
-
});
|
|
949
|
-
recordExpansionDelegationTelemetry({
|
|
950
|
-
deps: input.deps,
|
|
951
|
-
component: "lcm_expand_query",
|
|
952
|
-
event: "start",
|
|
953
|
-
requestId,
|
|
954
|
-
sessionKey: callerSessionKey,
|
|
955
|
-
expansionDepth: recursionCheck.expansionDepth,
|
|
956
|
-
originSessionKey: recursionCheck.originSessionKey,
|
|
957
|
-
});
|
|
958
|
-
if (recursionCheck.blocked) {
|
|
959
|
-
recordExpansionDelegationTelemetry({
|
|
960
|
-
deps: input.deps,
|
|
961
|
-
component: "lcm_expand_query",
|
|
962
|
-
event: "block",
|
|
963
|
-
requestId,
|
|
964
|
-
sessionKey: callerSessionKey,
|
|
965
|
-
expansionDepth: recursionCheck.expansionDepth,
|
|
966
|
-
originSessionKey: recursionCheck.originSessionKey,
|
|
967
|
-
reason: recursionCheck.reason,
|
|
968
|
-
});
|
|
969
|
-
return jsonResult({
|
|
970
|
-
errorCode: recursionCheck.code,
|
|
971
|
-
error: recursionCheck.message,
|
|
972
|
-
requestId: recursionCheck.requestId,
|
|
973
|
-
expansionDepth: recursionCheck.expansionDepth,
|
|
974
|
-
originSessionKey: recursionCheck.originSessionKey,
|
|
975
|
-
reason: recursionCheck.reason,
|
|
976
|
-
});
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
const originSessionKey = recursionCheck.originSessionKey || callerSessionKey || "main";
|
|
980
|
-
|
|
981
|
-
try {
|
|
982
|
-
const conversationScope = await resolveLcmConversationScope({
|
|
983
|
-
lcm,
|
|
984
|
-
deps: input.deps,
|
|
985
|
-
sessionId: input.sessionId,
|
|
986
|
-
sessionKey: input.sessionKey,
|
|
987
|
-
params: p,
|
|
988
|
-
});
|
|
989
|
-
let scopedConversationId = conversationScope.conversationId;
|
|
990
|
-
if (
|
|
991
|
-
!conversationScope.allConversations &&
|
|
992
|
-
scopedConversationId == null &&
|
|
993
|
-
callerSessionKey
|
|
994
|
-
) {
|
|
995
|
-
scopedConversationId = await resolveRequesterConversationScopeId({
|
|
996
|
-
deps: input.deps,
|
|
997
|
-
requesterSessionKey: callerSessionKey,
|
|
998
|
-
lcm,
|
|
999
|
-
});
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
if (!conversationScope.allConversations && scopedConversationId == null) {
|
|
1003
|
-
return jsonResult({
|
|
1004
|
-
error:
|
|
1005
|
-
"No LCM conversation found for this session. Provide conversationId or set allConversations=true.",
|
|
1006
|
-
});
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
|
-
const candidates = await resolveSummaryCandidates({
|
|
1010
|
-
lcm,
|
|
1011
|
-
explicitSummaryIds,
|
|
1012
|
-
query: query || undefined,
|
|
1013
|
-
conversationId: scopedConversationId,
|
|
1014
|
-
});
|
|
1015
|
-
|
|
1016
|
-
if (candidates.length === 0) {
|
|
1017
|
-
if (typeof scopedConversationId !== "number") {
|
|
1018
|
-
return jsonResult({
|
|
1019
|
-
error: "No matching summaries found.",
|
|
1020
|
-
});
|
|
1021
|
-
}
|
|
1022
|
-
return jsonResult(
|
|
1023
|
-
buildExpandQueryReply({
|
|
1024
|
-
answer: "No matching summaries found for this scope.",
|
|
1025
|
-
citedIds: [],
|
|
1026
|
-
sourceConversationIds: [scopedConversationId],
|
|
1027
|
-
expandedSummaryCount: 0,
|
|
1028
|
-
totalSourceTokens: 0,
|
|
1029
|
-
truncated: false,
|
|
1030
|
-
}),
|
|
1031
|
-
);
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
const conversationBuckets = buildConversationBuckets(candidates);
|
|
1035
|
-
|
|
1036
|
-
const concurrencyCheck = acquireExpansionConcurrencySlot({
|
|
1037
|
-
originSessionKey,
|
|
1038
|
-
requestId,
|
|
1039
|
-
});
|
|
1040
|
-
if (concurrencyCheck.blocked) {
|
|
1041
|
-
recordExpansionDelegationTelemetry({
|
|
1042
|
-
deps: input.deps,
|
|
1043
|
-
component: "lcm_expand_query",
|
|
1044
|
-
event: "block",
|
|
1045
|
-
requestId,
|
|
1046
|
-
sessionKey: callerSessionKey,
|
|
1047
|
-
expansionDepth: recursionCheck.expansionDepth,
|
|
1048
|
-
originSessionKey: concurrencyCheck.originSessionKey,
|
|
1049
|
-
reason: concurrencyCheck.reason,
|
|
1050
|
-
});
|
|
1051
|
-
return jsonResult({
|
|
1052
|
-
errorCode: concurrencyCheck.code,
|
|
1053
|
-
error: concurrencyCheck.message,
|
|
1054
|
-
requestId: concurrencyCheck.requestId,
|
|
1055
|
-
expansionDepth: recursionCheck.expansionDepth,
|
|
1056
|
-
originSessionKey: concurrencyCheck.originSessionKey,
|
|
1057
|
-
reason: concurrencyCheck.reason,
|
|
1058
|
-
});
|
|
1059
|
-
}
|
|
1060
|
-
|
|
1061
|
-
const requesterAgentId = input.deps.normalizeAgentId(
|
|
1062
|
-
input.deps.parseAgentSessionKey(callerSessionKey)?.agentId,
|
|
1063
|
-
);
|
|
1064
|
-
const childExpansionDepth = resolveNextExpansionDepth(callerSessionKey);
|
|
1065
|
-
|
|
1066
|
-
if (!conversationScope.allConversations) {
|
|
1067
|
-
const sourceConversationId = resolveSourceConversationId({
|
|
1068
|
-
scopedConversationId,
|
|
1069
|
-
allConversations: conversationScope.allConversations,
|
|
1070
|
-
candidates,
|
|
1071
|
-
});
|
|
1072
|
-
const bucket = selectSingleConversationBucket({
|
|
1073
|
-
sourceConversationId,
|
|
1074
|
-
buckets: conversationBuckets,
|
|
1075
|
-
});
|
|
1076
|
-
const delegatedReply = await runDelegatedExpandQuery({
|
|
1077
|
-
deps: input.deps,
|
|
1078
|
-
callerSessionKey,
|
|
1079
|
-
requesterAgentId,
|
|
1080
|
-
bucket,
|
|
1081
|
-
query: query || undefined,
|
|
1082
|
-
prompt,
|
|
1083
|
-
maxTokens,
|
|
1084
|
-
tokenCap: expansionTokenCap,
|
|
1085
|
-
requestId,
|
|
1086
|
-
childExpansionDepth,
|
|
1087
|
-
originSessionKey,
|
|
1088
|
-
delegatedWaitTimeoutMs,
|
|
1089
|
-
delegatedWaitTimeoutSeconds,
|
|
1090
|
-
});
|
|
1091
|
-
|
|
1092
|
-
return jsonResult(
|
|
1093
|
-
buildExpandQueryReply({
|
|
1094
|
-
answer: delegatedReply.answer,
|
|
1095
|
-
citedIds: delegatedReply.citedIds,
|
|
1096
|
-
sourceConversationIds: [sourceConversationId],
|
|
1097
|
-
expandedSummaryCount: delegatedReply.expandedSummaryCount,
|
|
1098
|
-
totalSourceTokens: delegatedReply.totalSourceTokens,
|
|
1099
|
-
truncated: delegatedReply.truncated,
|
|
1100
|
-
}),
|
|
1101
|
-
);
|
|
1102
|
-
}
|
|
1103
|
-
|
|
1104
|
-
const rankedBuckets = [...conversationBuckets].sort(compareConversationBuckets);
|
|
1105
|
-
const bucketResults: BucketExecutionResult[] = [];
|
|
1106
|
-
const bucketsToExpand = rankedBuckets.slice(0, DEFAULT_MAX_CONVERSATION_BUCKETS);
|
|
1107
|
-
const skippedBuckets = rankedBuckets.slice(DEFAULT_MAX_CONVERSATION_BUCKETS);
|
|
1108
|
-
let remainingTokenCap = expansionTokenCap;
|
|
1109
|
-
let firstFailure: string | undefined;
|
|
1110
|
-
|
|
1111
|
-
for (const bucket of bucketsToExpand) {
|
|
1112
|
-
if (remainingTokenCap <= 0) {
|
|
1113
|
-
bucketResults.push({
|
|
1114
|
-
conversationId: bucket.conversationId,
|
|
1115
|
-
status: "skipped",
|
|
1116
|
-
candidateCount: bucket.candidateCount,
|
|
1117
|
-
error: "global token budget exhausted",
|
|
1118
|
-
});
|
|
1119
|
-
continue;
|
|
1120
|
-
}
|
|
1121
|
-
|
|
1122
|
-
try {
|
|
1123
|
-
const delegatedReply = await runDelegatedExpandQuery({
|
|
1124
|
-
deps: input.deps,
|
|
1125
|
-
callerSessionKey,
|
|
1126
|
-
requesterAgentId,
|
|
1127
|
-
bucket,
|
|
1128
|
-
query: query || undefined,
|
|
1129
|
-
prompt,
|
|
1130
|
-
maxTokens,
|
|
1131
|
-
tokenCap: remainingTokenCap,
|
|
1132
|
-
requestId,
|
|
1133
|
-
childExpansionDepth,
|
|
1134
|
-
originSessionKey,
|
|
1135
|
-
delegatedWaitTimeoutMs,
|
|
1136
|
-
delegatedWaitTimeoutSeconds,
|
|
1137
|
-
});
|
|
1138
|
-
bucketResults.push({
|
|
1139
|
-
conversationId: bucket.conversationId,
|
|
1140
|
-
status: "success",
|
|
1141
|
-
candidateCount: bucket.candidateCount,
|
|
1142
|
-
reply: delegatedReply,
|
|
1143
|
-
});
|
|
1144
|
-
remainingTokenCap = Math.max(
|
|
1145
|
-
0,
|
|
1146
|
-
remainingTokenCap - Math.max(0, delegatedReply.totalSourceTokens),
|
|
1147
|
-
);
|
|
1148
|
-
} catch (error) {
|
|
1149
|
-
const failure = formatExpansionFailure(error);
|
|
1150
|
-
firstFailure ??= failure;
|
|
1151
|
-
bucketResults.push({
|
|
1152
|
-
conversationId: bucket.conversationId,
|
|
1153
|
-
status: "failed",
|
|
1154
|
-
candidateCount: bucket.candidateCount,
|
|
1155
|
-
error: failure,
|
|
1156
|
-
});
|
|
1157
|
-
}
|
|
1158
|
-
}
|
|
1159
|
-
|
|
1160
|
-
for (const bucket of skippedBuckets) {
|
|
1161
|
-
bucketResults.push({
|
|
1162
|
-
conversationId: bucket.conversationId,
|
|
1163
|
-
status: "skipped",
|
|
1164
|
-
candidateCount: bucket.candidateCount,
|
|
1165
|
-
error: `skipped after reaching max conversation bucket limit (${DEFAULT_MAX_CONVERSATION_BUCKETS})`,
|
|
1166
|
-
});
|
|
1167
|
-
}
|
|
1168
|
-
|
|
1169
|
-
const successfulResults = bucketResults.filter(
|
|
1170
|
-
(result): result is Extract<BucketExecutionResult, { status: "success" }> =>
|
|
1171
|
-
result.status === "success",
|
|
1172
|
-
);
|
|
1173
|
-
if (successfulResults.length === 0) {
|
|
1174
|
-
throw new Error(firstFailure ?? "Delegated expansion query failed.");
|
|
1175
|
-
}
|
|
1176
|
-
|
|
1177
|
-
const conversationBreakdown: ConversationBreakdown[] = bucketResults.map((result) => {
|
|
1178
|
-
if (result.status === "success") {
|
|
1179
|
-
return {
|
|
1180
|
-
conversationId: result.conversationId,
|
|
1181
|
-
expandedSummaryCount: result.reply.expandedSummaryCount,
|
|
1182
|
-
citedIds: result.reply.citedIds,
|
|
1183
|
-
totalSourceTokens: result.reply.totalSourceTokens,
|
|
1184
|
-
truncated: result.reply.truncated,
|
|
1185
|
-
status: "success",
|
|
1186
|
-
};
|
|
1187
|
-
}
|
|
1188
|
-
return {
|
|
1189
|
-
conversationId: result.conversationId,
|
|
1190
|
-
expandedSummaryCount: 0,
|
|
1191
|
-
citedIds: [],
|
|
1192
|
-
totalSourceTokens: 0,
|
|
1193
|
-
truncated: true,
|
|
1194
|
-
status: result.status,
|
|
1195
|
-
error: result.error,
|
|
1196
|
-
};
|
|
1197
|
-
});
|
|
1198
|
-
|
|
1199
|
-
return jsonResult(
|
|
1200
|
-
buildExpandQueryReply({
|
|
1201
|
-
answer: synthesizeConversationAnswers({
|
|
1202
|
-
prompt,
|
|
1203
|
-
results: bucketResults,
|
|
1204
|
-
}),
|
|
1205
|
-
citedIds: successfulResults.flatMap((result) => result.reply.citedIds),
|
|
1206
|
-
sourceConversationIds: successfulResults.map((result) => result.conversationId),
|
|
1207
|
-
expandedSummaryCount: successfulResults.reduce(
|
|
1208
|
-
(total, result) => total + result.reply.expandedSummaryCount,
|
|
1209
|
-
0,
|
|
1210
|
-
),
|
|
1211
|
-
totalSourceTokens: successfulResults.reduce(
|
|
1212
|
-
(total, result) => total + result.reply.totalSourceTokens,
|
|
1213
|
-
0,
|
|
1214
|
-
),
|
|
1215
|
-
truncated:
|
|
1216
|
-
successfulResults.some((result) => result.reply.truncated)
|
|
1217
|
-
|| bucketResults.some((result) => result.status !== "success"),
|
|
1218
|
-
conversationBreakdown,
|
|
1219
|
-
}),
|
|
1220
|
-
);
|
|
1221
|
-
} catch (error) {
|
|
1222
|
-
const failure = formatExpansionFailure(error);
|
|
1223
|
-
input.deps.log.error(`[lcm] delegated expansion query failed: ${failure}`);
|
|
1224
|
-
return jsonResult({
|
|
1225
|
-
error: failure,
|
|
1226
|
-
});
|
|
1227
|
-
} finally {
|
|
1228
|
-
releaseExpansionConcurrencySlot({
|
|
1229
|
-
originSessionKey,
|
|
1230
|
-
requestId,
|
|
1231
|
-
});
|
|
1232
|
-
}
|
|
1233
|
-
},
|
|
1234
|
-
};
|
|
1235
|
-
}
|