@martian-engineering/lossless-claw 0.1.4 → 0.1.6

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.
@@ -1,5 +1,9 @@
1
1
  import { Type } from "@sinclair/typebox";
2
2
  import type { LcmContextEngine } from "../engine.js";
3
+ import {
4
+ getRuntimeExpansionAuthManager,
5
+ resolveDelegatedExpansionGrantId,
6
+ } from "../expansion-auth.js";
3
7
  import type { LcmDependencies } from "../types.js";
4
8
  import type { AnyAgentTool } from "./common.js";
5
9
  import { jsonResult } from "./common.js";
@@ -21,8 +25,25 @@ const LcmDescribeSchema = Type.Object({
21
25
  "Set true to explicitly allow lookups across all conversations. Ignored when conversationId is provided.",
22
26
  }),
23
27
  ),
28
+ tokenCap: Type.Optional(
29
+ Type.Number({
30
+ description: "Optional budget cap used for subtree manifest budget-fit annotations.",
31
+ minimum: 1,
32
+ }),
33
+ ),
24
34
  });
25
35
 
36
+ function normalizeRequestedTokenCap(value: unknown): number | undefined {
37
+ if (typeof value !== "number" || !Number.isFinite(value)) {
38
+ return undefined;
39
+ }
40
+ return Math.max(1, Math.trunc(value));
41
+ }
42
+
43
+ function formatIso(value: Date | null | undefined): string {
44
+ return value instanceof Date ? value.toISOString() : "-";
45
+ }
46
+
26
47
  export function createLcmDescribeTool(input: {
27
48
  deps: LcmDependencies;
28
49
  lcm: LcmContextEngine;
@@ -77,33 +98,99 @@ export function createLcmDescribeTool(input: {
77
98
 
78
99
  if (result.type === "summary" && result.summary) {
79
100
  const s = result.summary;
101
+ const requestedTokenCap = normalizeRequestedTokenCap((params as Record<string, unknown>).tokenCap);
102
+ const sessionKey =
103
+ (typeof input.sessionKey === "string" ? input.sessionKey : input.sessionId)?.trim() ?? "";
104
+ const delegatedGrantId = input.deps.isSubagentSessionKey(sessionKey)
105
+ ? (resolveDelegatedExpansionGrantId(sessionKey) ?? "")
106
+ : "";
107
+ const delegatedRemainingBudget =
108
+ delegatedGrantId !== ""
109
+ ? getRuntimeExpansionAuthManager().getRemainingTokenBudget(delegatedGrantId)
110
+ : null;
111
+ const defaultTokenCap = Math.max(1, Math.trunc(input.deps.config.maxExpandTokens));
112
+ const resolvedTokenCap = (() => {
113
+ const base =
114
+ requestedTokenCap ??
115
+ (typeof delegatedRemainingBudget === "number" ? delegatedRemainingBudget : defaultTokenCap);
116
+ if (typeof delegatedRemainingBudget === "number") {
117
+ return Math.max(0, Math.min(base, delegatedRemainingBudget));
118
+ }
119
+ return Math.max(1, base);
120
+ })();
121
+
122
+ const manifestNodes = s.subtree.map((node) => {
123
+ const summariesOnlyCost = Math.max(0, node.tokenCount + node.descendantTokenCount);
124
+ const withMessagesCost = Math.max(0, summariesOnlyCost + node.sourceMessageTokenCount);
125
+ return {
126
+ summaryId: node.summaryId,
127
+ parentSummaryId: node.parentSummaryId,
128
+ depthFromRoot: node.depthFromRoot,
129
+ depth: node.depth,
130
+ kind: node.kind,
131
+ tokenCount: node.tokenCount,
132
+ descendantCount: node.descendantCount,
133
+ descendantTokenCount: node.descendantTokenCount,
134
+ sourceMessageTokenCount: node.sourceMessageTokenCount,
135
+ childCount: node.childCount,
136
+ earliestAt: node.earliestAt,
137
+ latestAt: node.latestAt,
138
+ path: node.path,
139
+ costs: {
140
+ summariesOnly: summariesOnlyCost,
141
+ withMessages: withMessagesCost,
142
+ },
143
+ budgetFit: {
144
+ summariesOnly: summariesOnlyCost <= resolvedTokenCap,
145
+ withMessages: withMessagesCost <= resolvedTokenCap,
146
+ },
147
+ };
148
+ });
149
+
80
150
  const lines: string[] = [];
81
- lines.push(`## LCM Summary: ${id}`);
82
- lines.push("");
83
- lines.push(`**Conversation:** ${s.conversationId}`);
84
- lines.push(`**Kind:** ${s.kind}`);
85
- lines.push(`**Tokens:** ~${s.tokenCount.toLocaleString()}`);
86
- lines.push(`**Created:** ${s.createdAt.toISOString()}`);
151
+ lines.push(`LCM_SUMMARY ${id}`);
152
+ lines.push(
153
+ `meta conv=${s.conversationId} kind=${s.kind} depth=${s.depth} tok=${s.tokenCount} ` +
154
+ `descTok=${s.descendantTokenCount} srcTok=${s.sourceMessageTokenCount} ` +
155
+ `desc=${s.descendantCount} range=${formatIso(s.earliestAt)}..${formatIso(s.latestAt)} ` +
156
+ `budgetCap=${resolvedTokenCap}`,
157
+ );
87
158
  if (s.parentIds.length > 0) {
88
- lines.push(`**Parents:** ${s.parentIds.join(", ")}`);
159
+ lines.push(`parents ${s.parentIds.join(" ")}`);
89
160
  }
90
161
  if (s.childIds.length > 0) {
91
- lines.push(`**Children:** ${s.childIds.join(", ")}`);
92
- }
93
- if (s.messageIds.length > 0) {
94
- lines.push(`**Messages:** ${s.messageIds.length} linked`);
162
+ lines.push(`children ${s.childIds.join(" ")}`);
95
163
  }
96
- if (s.fileIds.length > 0) {
97
- lines.push(`**Files:** ${s.fileIds.join(", ")}`);
164
+ lines.push("manifest");
165
+ for (const node of manifestNodes) {
166
+ lines.push(
167
+ `d${node.depthFromRoot} ${node.summaryId} k=${node.kind} tok=${node.tokenCount} ` +
168
+ `descTok=${node.descendantTokenCount} srcTok=${node.sourceMessageTokenCount} ` +
169
+ `desc=${node.descendantCount} child=${node.childCount} ` +
170
+ `range=${formatIso(node.earliestAt)}..${formatIso(node.latestAt)} ` +
171
+ `cost[s=${node.costs.summariesOnly},m=${node.costs.withMessages}] ` +
172
+ `budget[s=${node.budgetFit.summariesOnly ? "in" : "over"},` +
173
+ `m=${node.budgetFit.withMessages ? "in" : "over"}]`,
174
+ );
98
175
  }
99
- lines.push("");
100
- lines.push("## Content");
101
- lines.push("");
176
+ lines.push("content");
102
177
  lines.push(s.content);
103
178
 
104
179
  return {
105
180
  content: [{ type: "text", text: lines.join("\n") }],
106
- details: result,
181
+ details: {
182
+ ...result,
183
+ manifest: {
184
+ tokenCap: resolvedTokenCap,
185
+ budgetSource:
186
+ requestedTokenCap != null
187
+ ? "request"
188
+ : typeof delegatedRemainingBudget === "number"
189
+ ? "delegated_grant_remaining"
190
+ : "config_default",
191
+ nodes: manifestNodes,
192
+ },
193
+ },
107
194
  };
108
195
  }
109
196
 
@@ -12,6 +12,14 @@ import {
12
12
  normalizeSummaryIds,
13
13
  resolveRequesterConversationScopeId,
14
14
  } from "./lcm-expand-tool.delegation.js";
15
+ import {
16
+ clearDelegatedExpansionContext,
17
+ evaluateExpansionRecursionGuard,
18
+ recordExpansionDelegationTelemetry,
19
+ resolveExpansionRequestId,
20
+ resolveNextExpansionDepth,
21
+ stampDelegatedExpansionContext,
22
+ } from "./lcm-expansion-recursion-guard.js";
15
23
 
16
24
  const DELEGATED_WAIT_TIMEOUT_MS = 120_000;
17
25
  const GATEWAY_TIMEOUT_MS = 10_000;
@@ -50,6 +58,13 @@ const LcmExpandQuerySchema = Type.Object({
50
58
  minimum: 1,
51
59
  }),
52
60
  ),
61
+ tokenCap: Type.Optional(
62
+ Type.Number({
63
+ description:
64
+ "Expansion retrieval token budget across all delegated lcm_expand calls for this query.",
65
+ minimum: 1,
66
+ }),
67
+ ),
53
68
  });
54
69
 
55
70
  type ExpandQueryReply = {
@@ -71,23 +86,40 @@ type SummaryCandidate = {
71
86
  function buildDelegatedExpandQueryTask(params: {
72
87
  summaryIds: string[];
73
88
  conversationId: number;
89
+ query?: string;
74
90
  prompt: string;
75
91
  maxTokens: number;
92
+ tokenCap: number;
93
+ requestId: string;
94
+ expansionDepth: number;
95
+ originSessionKey: string;
76
96
  }) {
77
- const payload = {
78
- summaryIds: params.summaryIds,
79
- conversationId: params.conversationId,
80
- includeMessages: false,
81
- };
97
+ const seedSummaryIds = params.summaryIds.length > 0 ? params.summaryIds.join(", ") : "(none)";
82
98
  return [
83
- "Run LCM expansion, then answer the user's prompt from the expanded context.",
99
+ "You are an autonomous LCM retrieval navigator. Plan and execute retrieval before answering.",
100
+ "",
101
+ "Available tools: lcm_describe, lcm_expand, lcm_grep",
102
+ `Conversation scope: ${params.conversationId}`,
103
+ `Expansion token budget (total across this run): ${params.tokenCap}`,
104
+ `Seed summary IDs: ${seedSummaryIds}`,
105
+ params.query ? `Routing query: ${params.query}` : undefined,
84
106
  "",
85
- "Step 1: Call `lcm_expand` using exactly this JSON payload:",
86
- JSON.stringify(payload, null, 2),
107
+ "Strategy:",
108
+ "1. Start with `lcm_describe` on seed summaries to inspect subtree manifests and branch costs.",
109
+ "2. If additional candidates are needed, use `lcm_grep` scoped to summaries.",
110
+ "3. Select branches that fit remaining budget; prefer high-signal paths first.",
111
+ "4. Call `lcm_expand` selectively (do not expand everything blindly).",
112
+ "5. Keep includeMessages=false by default; use includeMessages=true only for specific leaf evidence.",
113
+ `6. Stay within ${params.tokenCap} total expansion tokens across all lcm_expand calls.`,
87
114
  "",
88
- "Step 2: Use the `lcm_expand` result as source context and answer this prompt:",
115
+ "User prompt to answer:",
89
116
  params.prompt,
90
117
  "",
118
+ "Delegated expansion metadata (for tracing):",
119
+ `- requestId: ${params.requestId}`,
120
+ `- expansionDepth: ${params.expansionDepth}`,
121
+ `- originSessionKey: ${params.originSessionKey}`,
122
+ "",
91
123
  "Return ONLY JSON with this shape:",
92
124
  "{",
93
125
  ' "answer": "string",',
@@ -98,10 +130,13 @@ function buildDelegatedExpandQueryTask(params: {
98
130
  "}",
99
131
  "",
100
132
  "Rules:",
133
+ "- In delegated context, call `lcm_expand` directly for source retrieval.",
134
+ "- DO NOT call `lcm_expand_query` from this delegated session.",
135
+ "- Synthesize the final answer from retrieved evidence, not assumptions.",
101
136
  `- Keep answer concise and focused (target <= ${params.maxTokens} tokens).`,
102
137
  "- citedIds must be unique summary IDs.",
103
138
  "- expandedSummaryCount should reflect how many summaries were expanded/used.",
104
- "- totalSourceTokens should be the estimated source token volume from expansion.",
139
+ "- totalSourceTokens should estimate total tokens consumed from expansion calls.",
105
140
  "- truncated should indicate whether source expansion appears truncated.",
106
141
  ].join("\n");
107
142
  }
@@ -281,6 +316,11 @@ export function createLcmExpandQueryTool(input: {
281
316
  typeof requestedMaxTokens === "number" && Number.isFinite(requestedMaxTokens)
282
317
  ? Math.max(1, requestedMaxTokens)
283
318
  : DEFAULT_MAX_ANSWER_TOKENS;
319
+ const requestedTokenCap = typeof p.tokenCap === "number" ? Math.trunc(p.tokenCap) : undefined;
320
+ const expansionTokenCap =
321
+ typeof requestedTokenCap === "number" && Number.isFinite(requestedTokenCap)
322
+ ? Math.max(1, requestedTokenCap)
323
+ : Math.max(1, Math.trunc(input.deps.config.maxExpandTokens));
284
324
 
285
325
  if (!prompt) {
286
326
  return jsonResult({
@@ -294,11 +334,46 @@ export function createLcmExpandQueryTool(input: {
294
334
  });
295
335
  }
296
336
 
297
- const requesterSessionKey =
337
+ const callerSessionKey =
298
338
  (typeof input.requesterSessionKey === "string"
299
339
  ? input.requesterSessionKey
300
340
  : input.sessionId
301
341
  )?.trim() ?? "";
342
+ const requestId = resolveExpansionRequestId(callerSessionKey);
343
+ const recursionCheck = evaluateExpansionRecursionGuard({
344
+ sessionKey: callerSessionKey,
345
+ requestId,
346
+ });
347
+ recordExpansionDelegationTelemetry({
348
+ deps: input.deps,
349
+ component: "lcm_expand_query",
350
+ event: "start",
351
+ requestId,
352
+ sessionKey: callerSessionKey,
353
+ expansionDepth: recursionCheck.expansionDepth,
354
+ originSessionKey: recursionCheck.originSessionKey,
355
+ });
356
+ if (recursionCheck.blocked) {
357
+ recordExpansionDelegationTelemetry({
358
+ deps: input.deps,
359
+ component: "lcm_expand_query",
360
+ event: "block",
361
+ requestId,
362
+ sessionKey: callerSessionKey,
363
+ expansionDepth: recursionCheck.expansionDepth,
364
+ originSessionKey: recursionCheck.originSessionKey,
365
+ reason: recursionCheck.reason,
366
+ });
367
+ return jsonResult({
368
+ errorCode: recursionCheck.code,
369
+ error: recursionCheck.message,
370
+ requestId: recursionCheck.requestId,
371
+ expansionDepth: recursionCheck.expansionDepth,
372
+ originSessionKey: recursionCheck.originSessionKey,
373
+ reason: recursionCheck.reason,
374
+ });
375
+ }
376
+
302
377
  const conversationScope = await resolveLcmConversationScope({
303
378
  lcm: input.lcm,
304
379
  deps: input.deps,
@@ -310,11 +385,11 @@ export function createLcmExpandQueryTool(input: {
310
385
  if (
311
386
  !conversationScope.allConversations &&
312
387
  scopedConversationId == null &&
313
- requesterSessionKey
388
+ callerSessionKey
314
389
  ) {
315
390
  scopedConversationId = await resolveRequesterConversationScopeId({
316
391
  deps: input.deps,
317
- requesterSessionKey,
392
+ requesterSessionKey: callerSessionKey,
318
393
  lcm: input.lcm,
319
394
  });
320
395
  }
@@ -371,24 +446,38 @@ export function createLcmExpandQueryTool(input: {
371
446
  }
372
447
 
373
448
  const requesterAgentId = input.deps.normalizeAgentId(
374
- input.deps.parseAgentSessionKey(requesterSessionKey)?.agentId,
449
+ input.deps.parseAgentSessionKey(callerSessionKey)?.agentId,
375
450
  );
376
451
  childSessionKey = `agent:${requesterAgentId}:subagent:${crypto.randomUUID()}`;
452
+ const childExpansionDepth = resolveNextExpansionDepth(callerSessionKey);
453
+ const originSessionKey = recursionCheck.originSessionKey || callerSessionKey || "main";
377
454
 
378
455
  createDelegatedExpansionGrant({
379
456
  delegatedSessionKey: childSessionKey,
380
- issuerSessionId: requesterSessionKey || "main",
457
+ issuerSessionId: callerSessionKey || "main",
381
458
  allowedConversationIds: [sourceConversationId],
382
- tokenCap: input.deps.config.maxExpandTokens,
459
+ tokenCap: expansionTokenCap,
383
460
  ttlMs: DELEGATED_WAIT_TIMEOUT_MS + 30_000,
384
461
  });
462
+ stampDelegatedExpansionContext({
463
+ sessionKey: childSessionKey,
464
+ requestId,
465
+ expansionDepth: childExpansionDepth,
466
+ originSessionKey,
467
+ stampedBy: "lcm_expand_query",
468
+ });
385
469
  grantCreated = true;
386
470
 
387
471
  const task = buildDelegatedExpandQueryTask({
388
472
  summaryIds,
389
473
  conversationId: sourceConversationId,
474
+ query: query || undefined,
390
475
  prompt,
391
476
  maxTokens,
477
+ tokenCap: expansionTokenCap,
478
+ requestId,
479
+ expansionDepth: childExpansionDepth,
480
+ originSessionKey,
392
481
  });
393
482
 
394
483
  const childIdem = crypto.randomUUID();
@@ -426,6 +515,16 @@ export function createLcmExpandQueryTool(input: {
426
515
  })) as { status?: string; error?: string };
427
516
  const status = typeof wait?.status === "string" ? wait.status : "error";
428
517
  if (status === "timeout") {
518
+ recordExpansionDelegationTelemetry({
519
+ deps: input.deps,
520
+ component: "lcm_expand_query",
521
+ event: "timeout",
522
+ requestId,
523
+ sessionKey: callerSessionKey,
524
+ expansionDepth: childExpansionDepth,
525
+ originSessionKey,
526
+ runId,
527
+ });
429
528
  return jsonResult({
430
529
  error: "lcm_expand_query timed out waiting for delegated expansion (120s).",
431
530
  });
@@ -448,6 +547,16 @@ export function createLcmExpandQueryTool(input: {
448
547
  Array.isArray(replyPayload.messages) ? replyPayload.messages : [],
449
548
  );
450
549
  const parsed = parseDelegatedExpandQueryReply(reply, summaryIds.length);
550
+ recordExpansionDelegationTelemetry({
551
+ deps: input.deps,
552
+ component: "lcm_expand_query",
553
+ event: "success",
554
+ requestId,
555
+ sessionKey: callerSessionKey,
556
+ expansionDepth: childExpansionDepth,
557
+ originSessionKey,
558
+ runId,
559
+ });
451
560
 
452
561
  return jsonResult({
453
562
  answer: parsed.answer,
@@ -476,6 +585,9 @@ export function createLcmExpandQueryTool(input: {
476
585
  if (grantCreated && childSessionKey) {
477
586
  revokeDelegatedExpansionGrantForSession(childSessionKey, { removeBinding: true });
478
587
  }
588
+ if (childSessionKey) {
589
+ clearDelegatedExpansionContext(childSessionKey);
590
+ }
479
591
  }
480
592
  },
481
593
  };
@@ -5,6 +5,13 @@ import {
5
5
  revokeDelegatedExpansionGrantForSession,
6
6
  } from "../expansion-auth.js";
7
7
  import type { LcmDependencies } from "../types.js";
8
+ import {
9
+ clearDelegatedExpansionContext,
10
+ evaluateExpansionRecursionGuard,
11
+ recordExpansionDelegationTelemetry,
12
+ resolveExpansionRequestId,
13
+ stampDelegatedExpansionContext,
14
+ } from "./lcm-expansion-recursion-guard.js";
8
15
 
9
16
  const MAX_GATEWAY_TIMEOUT_MS = 2_147_483_647;
10
17
 
@@ -156,6 +163,9 @@ function buildDelegatedExpansionTask(params: {
156
163
  includeMessages: boolean;
157
164
  pass: number;
158
165
  query?: string;
166
+ requestId: string;
167
+ expansionDepth: number;
168
+ originSessionKey: string;
159
169
  }) {
160
170
  const payload: {
161
171
  summaryIds: string[];
@@ -180,6 +190,11 @@ function buildDelegatedExpansionTask(params: {
180
190
  "Call `lcm_expand` using exactly this JSON payload:",
181
191
  JSON.stringify(payload, null, 2),
182
192
  "",
193
+ "Delegated expansion metadata (for tracing):",
194
+ `- requestId: ${params.requestId}`,
195
+ `- expansionDepth: ${params.expansionDepth}`,
196
+ `- originSessionKey: ${params.originSessionKey}`,
197
+ "",
183
198
  "Then return ONLY JSON with this shape:",
184
199
  "{",
185
200
  ' "summary": "string concise findings",',
@@ -190,7 +205,10 @@ function buildDelegatedExpansionTask(params: {
190
205
  "}",
191
206
  "",
192
207
  "Rules:",
208
+ "- In delegated context, use `lcm_expand` directly for retrieval.",
209
+ "- DO NOT call `lcm_expand_query` from this delegated session.",
193
210
  "- Keep summary concise and factual.",
211
+ "- Synthesize findings from the `lcm_expand` result before returning.",
194
212
  "- citedIds/followUpSummaryIds must contain unique summary IDs only.",
195
213
  "- If no follow-up is needed, return an empty followUpSummaryIds array.",
196
214
  ]
@@ -240,6 +258,7 @@ async function runDelegatedExpansionPass(params: {
240
258
  | "buildSubagentSystemPrompt"
241
259
  | "readLatestAssistantReply"
242
260
  | "agentLaneSubagent"
261
+ | "log"
243
262
  >;
244
263
  requesterSessionKey: string;
245
264
  conversationId: number;
@@ -249,6 +268,9 @@ async function runDelegatedExpansionPass(params: {
249
268
  includeMessages: boolean;
250
269
  query?: string;
251
270
  pass: number;
271
+ requestId: string;
272
+ parentExpansionDepth: number;
273
+ originSessionKey: string;
252
274
  }): Promise<DelegatedExpansionPassResult> {
253
275
  const requesterAgentId = params.deps.normalizeAgentId(
254
276
  params.deps.parseAgentSessionKey(params.requesterSessionKey)?.agentId,
@@ -260,8 +282,16 @@ async function runDelegatedExpansionPass(params: {
260
282
  delegatedSessionKey: childSessionKey,
261
283
  issuerSessionId: params.requesterSessionKey,
262
284
  allowedConversationIds: [params.conversationId],
285
+ tokenCap: params.tokenCap,
263
286
  ttlMs: MAX_GATEWAY_TIMEOUT_MS,
264
287
  });
288
+ stampDelegatedExpansionContext({
289
+ sessionKey: childSessionKey,
290
+ requestId: params.requestId,
291
+ expansionDepth: params.parentExpansionDepth + 1,
292
+ originSessionKey: params.originSessionKey,
293
+ stampedBy: "runDelegatedExpansionLoop",
294
+ });
265
295
 
266
296
  try {
267
297
  const message = buildDelegatedExpansionTask({
@@ -272,6 +302,9 @@ async function runDelegatedExpansionPass(params: {
272
302
  includeMessages: params.includeMessages,
273
303
  pass: params.pass,
274
304
  query: params.query,
305
+ requestId: params.requestId,
306
+ expansionDepth: params.parentExpansionDepth + 1,
307
+ originSessionKey: params.originSessionKey,
275
308
  });
276
309
  const response = (await params.deps.callGateway({
277
310
  method: "agent",
@@ -374,6 +407,7 @@ async function runDelegatedExpansionPass(params: {
374
407
  // Cleanup is best-effort.
375
408
  }
376
409
  revokeDelegatedExpansionGrantForSession(childSessionKey, { removeBinding: true });
410
+ clearDelegatedExpansionContext(childSessionKey);
377
411
  }
378
412
  }
379
413
 
@@ -386,6 +420,7 @@ export async function runDelegatedExpansionLoop(params: {
386
420
  | "buildSubagentSystemPrompt"
387
421
  | "readLatestAssistantReply"
388
422
  | "agentLaneSubagent"
423
+ | "log"
389
424
  >;
390
425
  requesterSessionKey: string;
391
426
  conversationId: number;
@@ -394,7 +429,44 @@ export async function runDelegatedExpansionLoop(params: {
394
429
  tokenCap?: number;
395
430
  includeMessages: boolean;
396
431
  query?: string;
432
+ requestId?: string;
397
433
  }): Promise<DelegatedExpansionLoopResult> {
434
+ const requestId = params.requestId?.trim() || resolveExpansionRequestId(params.requesterSessionKey);
435
+ const recursionCheck = evaluateExpansionRecursionGuard({
436
+ sessionKey: params.requesterSessionKey,
437
+ requestId,
438
+ });
439
+ recordExpansionDelegationTelemetry({
440
+ deps: params.deps,
441
+ component: "runDelegatedExpansionLoop",
442
+ event: "start",
443
+ requestId,
444
+ sessionKey: params.requesterSessionKey,
445
+ expansionDepth: recursionCheck.expansionDepth,
446
+ originSessionKey: recursionCheck.originSessionKey,
447
+ });
448
+ if (recursionCheck.blocked) {
449
+ recordExpansionDelegationTelemetry({
450
+ deps: params.deps,
451
+ component: "runDelegatedExpansionLoop",
452
+ event: "block",
453
+ requestId,
454
+ sessionKey: params.requesterSessionKey,
455
+ expansionDepth: recursionCheck.expansionDepth,
456
+ originSessionKey: recursionCheck.originSessionKey,
457
+ reason: recursionCheck.reason,
458
+ });
459
+ return {
460
+ status: "error",
461
+ passes: [],
462
+ citedIds: [],
463
+ totalTokens: 0,
464
+ truncated: true,
465
+ text: "Delegated expansion blocked by recursion guard.",
466
+ error: recursionCheck.message,
467
+ };
468
+ }
469
+
398
470
  const passes: DelegatedExpansionPassResult[] = [];
399
471
  const visited = new Set<string>();
400
472
  const cited = new Set<string>();
@@ -415,10 +487,25 @@ export async function runDelegatedExpansionLoop(params: {
415
487
  includeMessages: params.includeMessages,
416
488
  query: params.query,
417
489
  pass,
490
+ requestId,
491
+ parentExpansionDepth: recursionCheck.expansionDepth,
492
+ originSessionKey: recursionCheck.originSessionKey,
418
493
  });
419
494
  passes.push(result);
420
495
 
421
496
  if (result.status !== "ok") {
497
+ if (result.status === "timeout") {
498
+ recordExpansionDelegationTelemetry({
499
+ deps: params.deps,
500
+ component: "runDelegatedExpansionLoop",
501
+ event: "timeout",
502
+ requestId,
503
+ sessionKey: params.requesterSessionKey,
504
+ expansionDepth: recursionCheck.expansionDepth,
505
+ originSessionKey: recursionCheck.originSessionKey,
506
+ runId: result.runId,
507
+ });
508
+ }
422
509
  const okPasses = passes.filter((entry) => entry.status === "ok");
423
510
  for (const okPass of okPasses) {
424
511
  for (const summaryId of okPass.citedIds) {
@@ -449,6 +536,15 @@ export async function runDelegatedExpansionLoop(params: {
449
536
  pass += 1;
450
537
  }
451
538
 
539
+ recordExpansionDelegationTelemetry({
540
+ deps: params.deps,
541
+ component: "runDelegatedExpansionLoop",
542
+ event: "success",
543
+ requestId,
544
+ sessionKey: params.requesterSessionKey,
545
+ expansionDepth: recursionCheck.expansionDepth,
546
+ originSessionKey: recursionCheck.originSessionKey,
547
+ });
452
548
  return {
453
549
  status: "ok",
454
550
  passes,