@martian-engineering/lossless-claw 0.5.2 → 0.5.3

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.
@@ -21,7 +21,7 @@ import {
21
21
  stampDelegatedExpansionContext,
22
22
  } from "./lcm-expansion-recursion-guard.js";
23
23
 
24
- const DELEGATED_WAIT_TIMEOUT_MS = 120_000;
24
+ const DEFAULT_DELEGATED_WAIT_TIMEOUT_MS = 120_000;
25
25
  const GATEWAY_TIMEOUT_MS = 10_000;
26
26
  const DEFAULT_MAX_ANSWER_TOKENS = 2_000;
27
27
 
@@ -75,9 +75,20 @@ type ExpandQueryReply = {
75
75
  truncated: boolean;
76
76
  };
77
77
 
78
+ type ParsedExpandQueryReply =
79
+ | {
80
+ ok: true;
81
+ value: ExpandQueryReply;
82
+ }
83
+ | {
84
+ ok: false;
85
+ error: string;
86
+ };
87
+
78
88
  type SummaryCandidate = {
79
89
  summaryId: string;
80
90
  conversationId: number;
91
+ requiresMessageExpansion: boolean;
81
92
  };
82
93
 
83
94
  function collectExpansionFailureText(value: unknown, parts: string[], depth = 0): void {
@@ -155,6 +166,7 @@ function shouldRetryWithoutOverride(message: string): boolean {
155
166
  */
156
167
  function buildDelegatedExpandQueryTask(params: {
157
168
  summaryIds: string[];
169
+ messageBackedSummaryIds: string[];
158
170
  conversationId: number;
159
171
  query?: string;
160
172
  prompt: string;
@@ -165,6 +177,10 @@ function buildDelegatedExpandQueryTask(params: {
165
177
  originSessionKey: string;
166
178
  }) {
167
179
  const seedSummaryIds = params.summaryIds.length > 0 ? params.summaryIds.join(", ") : "(none)";
180
+ const messageBackedSummaryIds =
181
+ params.messageBackedSummaryIds.length > 0
182
+ ? params.messageBackedSummaryIds.join(", ")
183
+ : "(none)";
168
184
  return [
169
185
  "You are an autonomous LCM retrieval navigator. Plan and execute retrieval before answering.",
170
186
  "",
@@ -172,6 +188,7 @@ function buildDelegatedExpandQueryTask(params: {
172
188
  `Conversation scope: ${params.conversationId}`,
173
189
  `Expansion token budget (total across this run): ${params.tokenCap}`,
174
190
  `Seed summary IDs: ${seedSummaryIds}`,
191
+ `Seed summaries requiring raw message expansion: ${messageBackedSummaryIds}`,
175
192
  params.query ? `Routing query: ${params.query}` : undefined,
176
193
  "",
177
194
  "Strategy:",
@@ -179,7 +196,7 @@ function buildDelegatedExpandQueryTask(params: {
179
196
  "2. If additional candidates are needed, use `lcm_grep` scoped to summaries.",
180
197
  "3. Select branches that fit remaining budget; prefer high-signal paths first.",
181
198
  "4. Call `lcm_expand` selectively (do not expand everything blindly).",
182
- "5. Keep includeMessages=false by default; use includeMessages=true only for specific leaf evidence.",
199
+ "5. Keep includeMessages=false by default; use includeMessages=true for the message-backed seed summaries above and any other specific leaf evidence.",
183
200
  `6. Stay within ${params.tokenCap} total expansion tokens across all lcm_expand calls.`,
184
201
  "",
185
202
  "User prompt to answer:",
@@ -211,24 +228,25 @@ function buildDelegatedExpandQueryTask(params: {
211
228
  ].join("\n");
212
229
  }
213
230
 
231
+ function formatInvalidDelegatedReply(reply: string, reason: string): string {
232
+ const compact = reply.replace(/\s+/g, " ").trim();
233
+ const snippet = compact.length <= 240 ? compact : `${compact.slice(0, 240)}...`;
234
+ return `Delegated expansion query returned ${reason}: ${snippet}`;
235
+ }
236
+
214
237
  /**
215
- * Parse the child reply; accepts plain JSON or fenced JSON.
238
+ * Parse the child reply; accepts plain JSON or fenced JSON and rejects malformed fallbacks.
216
239
  */
217
240
  function parseDelegatedExpandQueryReply(
218
241
  rawReply: string | undefined,
219
242
  fallbackExpandedSummaryCount: number,
220
- ): ExpandQueryReply {
221
- const fallback: ExpandQueryReply = {
222
- answer: (rawReply ?? "").trim(),
223
- citedIds: [],
224
- expandedSummaryCount: fallbackExpandedSummaryCount,
225
- totalSourceTokens: 0,
226
- truncated: false,
227
- };
228
-
243
+ ): ParsedExpandQueryReply {
229
244
  const reply = rawReply?.trim();
230
245
  if (!reply) {
231
- return fallback;
246
+ return {
247
+ ok: false,
248
+ error: "Delegated expansion query returned an empty reply.",
249
+ };
232
250
  }
233
251
 
234
252
  const candidates: string[] = [reply];
@@ -247,6 +265,12 @@ function parseDelegatedExpandQueryReply(
247
265
  truncated?: unknown;
248
266
  };
249
267
  const answer = typeof parsed.answer === "string" ? parsed.answer.trim() : "";
268
+ if (!answer) {
269
+ return {
270
+ ok: false,
271
+ error: formatInvalidDelegatedReply(reply, 'JSON without a non-empty "answer"'),
272
+ };
273
+ }
250
274
  const citedIds = normalizeSummaryIds(
251
275
  Array.isArray(parsed.citedIds)
252
276
  ? parsed.citedIds.filter((value): value is string => typeof value === "string")
@@ -264,18 +288,24 @@ function parseDelegatedExpandQueryReply(
264
288
  const truncated = parsed.truncated === true;
265
289
 
266
290
  return {
267
- answer: answer || fallback.answer,
268
- citedIds,
269
- expandedSummaryCount,
270
- totalSourceTokens,
271
- truncated,
291
+ ok: true,
292
+ value: {
293
+ answer,
294
+ citedIds,
295
+ expandedSummaryCount,
296
+ totalSourceTokens,
297
+ truncated,
298
+ },
272
299
  };
273
300
  } catch {
274
301
  // Try next candidate.
275
302
  }
276
303
  }
277
304
 
278
- return fallback;
305
+ return {
306
+ ok: false,
307
+ error: formatInvalidDelegatedReply(reply, "non-JSON output"),
308
+ };
279
309
  }
280
310
 
281
311
  /**
@@ -316,6 +346,22 @@ function resolveSourceConversationId(params: {
316
346
  );
317
347
  }
318
348
 
349
+ function upsertSummaryCandidate(
350
+ candidates: Map<string, SummaryCandidate>,
351
+ candidate: SummaryCandidate,
352
+ ): void {
353
+ const existing = candidates.get(candidate.summaryId);
354
+ if (!existing) {
355
+ candidates.set(candidate.summaryId, candidate);
356
+ return;
357
+ }
358
+ candidates.set(candidate.summaryId, {
359
+ ...existing,
360
+ requiresMessageExpansion:
361
+ existing.requiresMessageExpansion || candidate.requiresMessageExpansion,
362
+ });
363
+ }
364
+
319
365
  /**
320
366
  * Resolve summary candidates from explicit IDs and/or query matches.
321
367
  */
@@ -333,13 +379,15 @@ async function resolveSummaryCandidates(params: {
333
379
  if (!described || described.type !== "summary" || !described.summary) {
334
380
  throw new Error(`Summary not found: ${summaryId}`);
335
381
  }
336
- candidates.set(summaryId, {
382
+ upsertSummaryCandidate(candidates, {
337
383
  summaryId,
338
384
  conversationId: described.summary.conversationId,
385
+ requiresMessageExpansion: false,
339
386
  });
340
387
  }
341
388
 
342
389
  if (params.query) {
390
+ const summaryStore = params.lcm.getSummaryStore();
343
391
  const grepResult = await retrieval.grep({
344
392
  query: params.query,
345
393
  mode: "full_text",
@@ -347,11 +395,46 @@ async function resolveSummaryCandidates(params: {
347
395
  conversationId: params.conversationId,
348
396
  });
349
397
  for (const summary of grepResult.summaries) {
350
- candidates.set(summary.summaryId, {
398
+ upsertSummaryCandidate(candidates, {
351
399
  summaryId: summary.summaryId,
352
400
  conversationId: summary.conversationId,
401
+ requiresMessageExpansion: false,
353
402
  });
354
403
  }
404
+
405
+ if (grepResult.summaries.length === 0 && typeof params.conversationId === "number") {
406
+ const maxDepth = await summaryStore.getConversationMaxSummaryDepth(params.conversationId);
407
+ if (typeof maxDepth === "number" && maxDepth <= 1) {
408
+ const messageResult = await retrieval.grep({
409
+ query: params.query,
410
+ mode: "full_text",
411
+ scope: "messages",
412
+ conversationId: params.conversationId,
413
+ });
414
+ const messageIds = messageResult.messages.map((message) => message.messageId);
415
+ const leafLinks = await summaryStore.getLeafSummaryLinksForMessageIds(
416
+ params.conversationId,
417
+ messageIds,
418
+ );
419
+ const summaryIdsByMessageId = new Map<number, string[]>();
420
+ for (const link of leafLinks) {
421
+ const linkedSummaryIds = summaryIdsByMessageId.get(link.messageId) ?? [];
422
+ if (!linkedSummaryIds.includes(link.summaryId)) {
423
+ linkedSummaryIds.push(link.summaryId);
424
+ summaryIdsByMessageId.set(link.messageId, linkedSummaryIds);
425
+ }
426
+ }
427
+ for (const message of messageResult.messages) {
428
+ for (const summaryId of summaryIdsByMessageId.get(message.messageId) ?? []) {
429
+ upsertSummaryCandidate(candidates, {
430
+ summaryId,
431
+ conversationId: params.conversationId,
432
+ requiresMessageExpansion: true,
433
+ });
434
+ }
435
+ }
436
+ }
437
+ }
355
438
  }
356
439
 
357
440
  return Array.from(candidates.values());
@@ -367,6 +450,10 @@ export function createLcmExpandQueryTool(input: {
367
450
  /** Session key for scope fallback when sessionId is unavailable. */
368
451
  sessionKey?: string;
369
452
  }): AnyAgentTool {
453
+ const delegatedWaitTimeoutMs =
454
+ input.deps.config.delegationTimeoutMs || DEFAULT_DELEGATED_WAIT_TIMEOUT_MS;
455
+ const delegatedWaitTimeoutSeconds = Math.ceil(delegatedWaitTimeoutMs / 1000);
456
+
370
457
  return {
371
458
  name: "lcm_expand_query",
372
459
  label: "LCM Expand Query",
@@ -505,6 +592,15 @@ export function createLcmExpandQueryTool(input: {
505
592
  .filter((candidate) => candidate.conversationId === sourceConversationId)
506
593
  .map((candidate) => candidate.summaryId),
507
594
  );
595
+ const messageBackedSummaryIds = normalizeSummaryIds(
596
+ candidates
597
+ .filter(
598
+ (candidate) =>
599
+ candidate.conversationId === sourceConversationId &&
600
+ candidate.requiresMessageExpansion,
601
+ )
602
+ .map((candidate) => candidate.summaryId),
603
+ );
508
604
 
509
605
  if (summaryIds.length === 0) {
510
606
  return jsonResult({
@@ -520,6 +616,7 @@ export function createLcmExpandQueryTool(input: {
520
616
 
521
617
  const task = buildDelegatedExpandQueryTask({
522
618
  summaryIds,
619
+ messageBackedSummaryIds,
523
620
  conversationId: sourceConversationId,
524
621
  query: query || undefined,
525
622
  prompt,
@@ -532,10 +629,13 @@ export function createLcmExpandQueryTool(input: {
532
629
 
533
630
  const expansionProvider = input.deps.config.expansionProvider || undefined;
534
631
  const expansionModel = input.deps.config.expansionModel || undefined;
632
+ const canonicalExpansionModel = expansionModel?.includes("/") ? expansionModel : undefined;
633
+ const delegatedOverrideProvider = canonicalExpansionModel ? undefined : expansionProvider;
634
+ const delegatedOverrideModel = canonicalExpansionModel || expansionModel;
535
635
  const configuredOverrideLabel =
536
- expansionProvider && expansionModel
537
- ? `${expansionProvider}/${expansionModel}`
538
- : expansionModel || expansionProvider || "configured override";
636
+ delegatedOverrideProvider && delegatedOverrideModel
637
+ ? `${delegatedOverrideProvider}/${delegatedOverrideModel}`
638
+ : delegatedOverrideModel || delegatedOverrideProvider || "configured override";
539
639
 
540
640
  const runDelegatedQuery = async (provider?: string, model?: string) => {
541
641
  const childSessionKey = `agent:${requesterAgentId}:subagent:${crypto.randomUUID()}`;
@@ -548,7 +648,7 @@ export function createLcmExpandQueryTool(input: {
548
648
  issuerSessionId: callerSessionKey || "main",
549
649
  allowedConversationIds: [sourceConversationId],
550
650
  tokenCap: expansionTokenCap,
551
- ttlMs: DELEGATED_WAIT_TIMEOUT_MS + 30_000,
651
+ ttlMs: delegatedWaitTimeoutMs + 30_000,
552
652
  });
553
653
  stampDelegatedExpansionContext({
554
654
  sessionKey: childSessionKey,
@@ -590,9 +690,9 @@ export function createLcmExpandQueryTool(input: {
590
690
  method: "agent.wait",
591
691
  params: {
592
692
  runId,
593
- timeoutMs: DELEGATED_WAIT_TIMEOUT_MS,
693
+ timeoutMs: delegatedWaitTimeoutMs,
594
694
  },
595
- timeoutMs: DELEGATED_WAIT_TIMEOUT_MS,
695
+ timeoutMs: delegatedWaitTimeoutMs,
596
696
  })) as { status?: string; error?: unknown };
597
697
  const status = typeof wait?.status === "string" ? wait.status : "error";
598
698
  if (status === "timeout") {
@@ -607,7 +707,7 @@ export function createLcmExpandQueryTool(input: {
607
707
  runId,
608
708
  });
609
709
  throw new Error(
610
- "lcm_expand_query timed out waiting for delegated expansion (120s).",
710
+ `lcm_expand_query timed out waiting for delegated expansion (${delegatedWaitTimeoutSeconds}s).`,
611
711
  );
612
712
  }
613
713
  if (status !== "ok") {
@@ -623,6 +723,9 @@ export function createLcmExpandQueryTool(input: {
623
723
  Array.isArray(replyPayload.messages) ? replyPayload.messages : [],
624
724
  );
625
725
  const parsed = parseDelegatedExpandQueryReply(reply, summaryIds.length);
726
+ if (!parsed.ok) {
727
+ throw new Error(parsed.error);
728
+ }
626
729
  recordExpansionDelegationTelemetry({
627
730
  deps: input.deps,
628
731
  component: "lcm_expand_query",
@@ -635,12 +738,12 @@ export function createLcmExpandQueryTool(input: {
635
738
  });
636
739
 
637
740
  return jsonResult({
638
- answer: parsed.answer,
639
- citedIds: parsed.citedIds,
741
+ answer: parsed.value.answer,
742
+ citedIds: parsed.value.citedIds,
640
743
  sourceConversationId,
641
- expandedSummaryCount: parsed.expandedSummaryCount,
642
- totalSourceTokens: parsed.totalSourceTokens,
643
- truncated: parsed.truncated,
744
+ expandedSummaryCount: parsed.value.expandedSummaryCount,
745
+ totalSourceTokens: parsed.value.totalSourceTokens,
746
+ truncated: parsed.value.truncated,
644
747
  });
645
748
  } finally {
646
749
  try {
@@ -664,7 +767,7 @@ export function createLcmExpandQueryTool(input: {
664
767
  }
665
768
 
666
769
  try {
667
- return await runDelegatedQuery(expansionProvider, expansionModel);
770
+ return await runDelegatedQuery(delegatedOverrideProvider, delegatedOverrideModel);
668
771
  } catch (error) {
669
772
  const failure = formatExpansionFailure(error);
670
773
  input.deps.log.warn(
package/src/types.ts CHANGED
@@ -72,6 +72,7 @@ export type ApiKeyLookupOptions = {
72
72
  preferredProfile?: string;
73
73
  agentDir?: string;
74
74
  runtimeConfig?: unknown;
75
+ skipModelAuth?: boolean;
75
76
  };
76
77
 
77
78
  export type GetApiKeyFn = (