@librechat/agents 3.4.0 → 3.4.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.
Files changed (114) hide show
  1. package/dist/cjs/graphs/Graph.cjs +111 -27
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/hooks/HookRegistry.cjs +83 -0
  4. package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
  5. package/dist/cjs/hooks/executeHooks.cjs +60 -14
  6. package/dist/cjs/hooks/executeHooks.cjs.map +1 -1
  7. package/dist/cjs/hooks/index.cjs.map +1 -1
  8. package/dist/cjs/hooks/types.cjs +2 -0
  9. package/dist/cjs/hooks/types.cjs.map +1 -1
  10. package/dist/cjs/llm/init.cjs +3 -3
  11. package/dist/cjs/llm/invoke.cjs +2 -2
  12. package/dist/cjs/main.cjs +14 -13
  13. package/dist/cjs/messages/format.cjs +40 -23
  14. package/dist/cjs/messages/format.cjs.map +1 -1
  15. package/dist/cjs/run.cjs +82 -15
  16. package/dist/cjs/run.cjs.map +1 -1
  17. package/dist/cjs/session/AgentSession.cjs +37 -2
  18. package/dist/cjs/session/AgentSession.cjs.map +1 -1
  19. package/dist/cjs/stream.cjs +1 -1
  20. package/dist/cjs/summarization/node.cjs +6 -3
  21. package/dist/cjs/summarization/node.cjs.map +1 -1
  22. package/dist/cjs/tools/BashExecutor.cjs +1 -1
  23. package/dist/cjs/tools/CodeExecutor.cjs +1 -1
  24. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +1 -1
  25. package/dist/cjs/tools/ToolNode.cjs +316 -94
  26. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  27. package/dist/cjs/tools/ToolSearch.cjs +1 -1
  28. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +630 -78
  29. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  30. package/dist/cjs/tools/subagent/SubagentReplay.cjs +175 -0
  31. package/dist/cjs/tools/subagent/SubagentReplay.cjs.map +1 -0
  32. package/dist/cjs/tools/toolOutputReferences.cjs +20 -0
  33. package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
  34. package/dist/cjs/utils/index.cjs +2 -2
  35. package/dist/esm/graphs/Graph.mjs +111 -27
  36. package/dist/esm/graphs/Graph.mjs.map +1 -1
  37. package/dist/esm/hooks/HookRegistry.mjs +83 -0
  38. package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
  39. package/dist/esm/hooks/executeHooks.mjs +60 -14
  40. package/dist/esm/hooks/executeHooks.mjs.map +1 -1
  41. package/dist/esm/hooks/index.mjs.map +1 -1
  42. package/dist/esm/hooks/types.mjs +2 -1
  43. package/dist/esm/hooks/types.mjs.map +1 -1
  44. package/dist/esm/llm/init.mjs +1 -1
  45. package/dist/esm/llm/invoke.mjs +2 -2
  46. package/dist/esm/main.mjs +11 -11
  47. package/dist/esm/messages/format.mjs +40 -23
  48. package/dist/esm/messages/format.mjs.map +1 -1
  49. package/dist/esm/run.mjs +83 -16
  50. package/dist/esm/run.mjs.map +1 -1
  51. package/dist/esm/session/AgentSession.mjs +37 -2
  52. package/dist/esm/session/AgentSession.mjs.map +1 -1
  53. package/dist/esm/stream.mjs +1 -1
  54. package/dist/esm/summarization/node.mjs +6 -3
  55. package/dist/esm/summarization/node.mjs.map +1 -1
  56. package/dist/esm/tools/BashExecutor.mjs +1 -1
  57. package/dist/esm/tools/CodeExecutor.mjs +1 -1
  58. package/dist/esm/tools/ProgrammaticToolCalling.mjs +1 -1
  59. package/dist/esm/tools/ToolNode.mjs +317 -95
  60. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  61. package/dist/esm/tools/ToolSearch.mjs +1 -1
  62. package/dist/esm/tools/subagent/SubagentExecutor.mjs +631 -79
  63. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  64. package/dist/esm/tools/subagent/SubagentReplay.mjs +168 -0
  65. package/dist/esm/tools/subagent/SubagentReplay.mjs.map +1 -0
  66. package/dist/esm/tools/toolOutputReferences.mjs +20 -0
  67. package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
  68. package/dist/esm/utils/index.mjs +2 -2
  69. package/dist/types/graphs/Graph.d.ts +16 -1
  70. package/dist/types/hooks/HookRegistry.d.ts +10 -1
  71. package/dist/types/hooks/executeHooks.d.ts +5 -1
  72. package/dist/types/hooks/index.d.ts +2 -2
  73. package/dist/types/hooks/types.d.ts +12 -0
  74. package/dist/types/run.d.ts +4 -1
  75. package/dist/types/session/AgentSession.d.ts +1 -0
  76. package/dist/types/tools/ToolNode.d.ts +23 -15
  77. package/dist/types/tools/subagent/SubagentExecutor.d.ts +46 -7
  78. package/dist/types/tools/subagent/SubagentReplay.d.ts +81 -0
  79. package/dist/types/tools/toolOutputReferences.d.ts +12 -0
  80. package/dist/types/types/graph.d.ts +5 -5
  81. package/dist/types/types/hitl.d.ts +15 -0
  82. package/package.json +6 -2
  83. package/src/graphs/Graph.ts +246 -52
  84. package/src/graphs/__tests__/Graph.breakerLifecycle.test.ts +48 -9
  85. package/src/graphs/__tests__/Graph.contextOverflow.test.ts +1 -1
  86. package/src/graphs/__tests__/Graph.subagentResumeState.test.ts +80 -0
  87. package/src/hooks/HookRegistry.ts +151 -1
  88. package/src/hooks/__tests__/HookRegistry.test.ts +96 -0
  89. package/src/hooks/__tests__/executeHooks.test.ts +160 -0
  90. package/src/hooks/executeHooks.ts +133 -15
  91. package/src/hooks/index.ts +3 -1
  92. package/src/hooks/types.ts +16 -0
  93. package/src/messages/format.ts +54 -26
  94. package/src/messages/formatAgentMessages.reducer.test.ts +162 -0
  95. package/src/messages/formatAgentMessages.steer.test.ts +16 -11
  96. package/src/messages/formatAgentMessages.test.ts +7 -2
  97. package/src/run.ts +164 -18
  98. package/src/session/AgentSession.ts +52 -2
  99. package/src/session/__tests__/JsonlSessionStore.test.ts +53 -0
  100. package/src/summarization/__tests__/node.test.ts +32 -14
  101. package/src/summarization/node.ts +15 -11
  102. package/src/tools/ToolNode.ts +631 -157
  103. package/src/tools/__tests__/SubagentExecutor.test.ts +542 -6
  104. package/src/tools/__tests__/SubagentReplay.test.ts +300 -0
  105. package/src/tools/__tests__/ToolNode.breakerSignal.test.ts +208 -2
  106. package/src/tools/__tests__/directToolHITLResumeScope.test.ts +486 -2
  107. package/src/tools/__tests__/hitl.test.ts +234 -5
  108. package/src/tools/__tests__/subagentHooks.test.ts +1040 -62
  109. package/src/tools/__tests__/toolOutputReferences.test.ts +19 -2
  110. package/src/tools/subagent/SubagentExecutor.ts +1369 -131
  111. package/src/tools/subagent/SubagentReplay.ts +575 -0
  112. package/src/tools/toolOutputReferences.ts +40 -1
  113. package/src/types/graph.ts +5 -5
  114. package/src/types/hitl.ts +16 -0
@@ -1,11 +1,40 @@
1
1
  import { nanoid } from 'nanoid';
2
- import { HumanMessage } from '@langchain/core/messages';
3
2
  import { BaseCallbackHandler } from '@langchain/core/callbacks/base';
4
- import type { BaseMessage, UsageMetadata } from '@langchain/core/messages';
3
+ import { AsyncLocalStorageProviderSingleton } from '@langchain/core/singletons';
4
+ import {
5
+ AIMessage,
6
+ BaseMessage,
7
+ HumanMessage,
8
+ ToolMessage,
9
+ } from '@langchain/core/messages';
10
+ import {
11
+ Command,
12
+ END,
13
+ GraphInterrupt,
14
+ INTERRUPT,
15
+ MessagesAnnotation,
16
+ START,
17
+ StateGraph,
18
+ copyCheckpoint,
19
+ isGraphInterrupt,
20
+ isInterrupted,
21
+ } from '@langchain/langgraph';
22
+ import type {
23
+ Interrupt,
24
+ StateSnapshot,
25
+ CheckpointTuple,
26
+ BaseCheckpointSaver,
27
+ } from '@langchain/langgraph';
5
28
  import type { ChatGeneration, LLMResult } from '@langchain/core/outputs';
6
29
  import type { Callbacks } from '@langchain/core/callbacks/manager';
30
+ import type { RunnableConfig } from '@langchain/core/runnables';
31
+ import type { UsageMetadata } from '@langchain/core/messages';
32
+ import type { ToolCall } from '@langchain/core/messages/tool';
7
33
  import type {
8
34
  AgentInputs,
35
+ BaseGraphState,
36
+ CompiledStateWorkflow,
37
+ HumanInTheLoopConfig,
9
38
  InjectedMessage,
10
39
  MessageDeltaEvent,
11
40
  ProcessedToolCall,
@@ -19,14 +48,30 @@ import type {
19
48
  SubagentUpdateEvent,
20
49
  SubagentUpdatePhase,
21
50
  SubagentUsageSink,
51
+ ToolApprovalInterruptPayload,
22
52
  ToolExecuteBatchRequest,
23
53
  ToolCallDelta,
24
54
  TokenCounter,
55
+ ToolApprovalDecision,
56
+ ToolApprovalDecisionMap,
25
57
  } from '@/types';
58
+ import type {
59
+ SubagentResumeExecution,
60
+ SubagentResumeManifest,
61
+ SubagentCheckpointReference,
62
+ SettledSubagentToolOutput,
63
+ } from './SubagentReplay';
26
64
  import type { AggregatedHookResult, HookRegistry } from '@/hooks';
27
65
  import type { AgentContext } from '@/agents/AgentContext';
28
66
  import type { StandardGraph } from '@/graphs/Graph';
29
67
  import type { HandlerRegistry } from '@/events';
68
+ import {
69
+ getSubagentResumeManifest,
70
+ attachSubagentResumeManifest,
71
+ SUBAGENT_PARENT_BATCH_CONFIG_KEY,
72
+ SUBAGENT_RESUME_ATTEMPT_CONFIG_KEY,
73
+ SUBAGENT_RESUME_MANIFEST_CONFIG_KEY,
74
+ } from './SubagentReplay';
30
75
  import {
31
76
  StreamLimitExceededError,
32
77
  RUN_BREAKER_SCOPE_CONFIG_KEY,
@@ -38,7 +83,10 @@ import {
38
83
  Callback,
39
84
  StepTypes,
40
85
  } from '@/common';
41
- import { executeHooks } from '@/hooks';
86
+ import {
87
+ executeHooks,
88
+ TOOL_APPROVAL_EXECUTION_SCOPE_CONFIG_KEY,
89
+ } from '@/hooks';
42
90
 
43
91
  const DEFAULT_MAX_TURNS = 25;
44
92
  const RECURSION_MULTIPLIER = 3;
@@ -160,12 +208,449 @@ type ForwarderCallback = {
160
208
  drain: () => Promise<void>;
161
209
  };
162
210
 
211
+ type StatefulCompiledWorkflow = Omit<CompiledStateWorkflow, 'invoke'> & {
212
+ invoke(
213
+ input: BaseGraphState | Command | null,
214
+ config?: RunnableConfig
215
+ ): Promise<BaseGraphState>;
216
+ getState(config: RunnableConfig): Promise<StateSnapshot>;
217
+ updateState?(
218
+ config: RunnableConfig,
219
+ values: Record<string, unknown>,
220
+ asNode?: string
221
+ ): Promise<RunnableConfig>;
222
+ };
223
+
224
+ type ReplayCheckpointWorkflow = {
225
+ updateState(
226
+ config: RunnableConfig,
227
+ values: { messages: BaseMessage[] },
228
+ asNode: string
229
+ ): Promise<RunnableConfig>;
230
+ };
231
+
232
+ type ActiveChildRun = {
233
+ graph: StandardGraph;
234
+ workflow: StatefulCompiledWorkflow;
235
+ pendingInterrupts: Interrupt[];
236
+ invokeConfig?: RunnableConfig;
237
+ childAgentId: string;
238
+ childRunId: string;
239
+ };
240
+
241
+ type PersistedToolOutput = {
242
+ content: ToolMessage['content'];
243
+ toolCallId: string;
244
+ id?: string;
245
+ name?: string;
246
+ status?: 'success' | 'error';
247
+ additionalKwargs: ToolMessage['additional_kwargs'];
248
+ responseMetadata: ToolMessage['response_metadata'];
249
+ metadata?: Record<string, unknown>;
250
+ additionalContexts: string[];
251
+ resolvedArgs?: Record<string, unknown>;
252
+ referenceContent?: string;
253
+ };
254
+
255
+ type SubagentCheckpointMarker = {
256
+ version: 1;
257
+ parentToolCallId: string;
258
+ lifecycleComplete: true;
259
+ hookSessionId?: string;
260
+ childRunId?: string;
261
+ settledOutput?: PersistedToolOutput;
262
+ };
263
+
264
+ type ChildExecutionIdentity = {
265
+ childRunId: string;
266
+ childThreadId: string;
267
+ approvalExecutionScope: string;
268
+ resumeExecution?: SubagentResumeExecution;
269
+ };
270
+
271
+ type ChildExecutionIdentityParams = {
272
+ threadId?: string;
273
+ parentToolCallId: string;
274
+ parentConfigurable?: Record<string, unknown>;
275
+ };
276
+
163
277
  const LANGGRAPH_RUNTIME_CONFIG_PREFIX = '__pregel_';
278
+ const LANGGRAPH_RESUME_MAP_CONFIG_KEY = '__pregel_resume_map';
164
279
  const LANGGRAPH_CHECKPOINT_CONFIG_KEYS = new Set([
165
280
  'checkpoint_id',
166
281
  'checkpoint_map',
167
282
  'checkpoint_ns',
168
283
  ]);
284
+ const SUBAGENT_CHECKPOINT_MARKER_KEY = '__librechat_subagent_checkpoint';
285
+ const SUBAGENT_HOOK_SESSION_KEY = '__librechat_subagent_hook_session';
286
+ const SUBAGENT_RUN_ID_KEY = '__librechat_subagent_run_id';
287
+ const SUBAGENT_REPLAY_NODE = 'subagent-replay';
288
+
289
+ function isCheckpointSaver(value: unknown): value is BaseCheckpointSaver {
290
+ if (value == null || typeof value !== 'object') {
291
+ return false;
292
+ }
293
+ const candidate = value as Partial<BaseCheckpointSaver>;
294
+ return (
295
+ typeof candidate.getTuple === 'function' &&
296
+ typeof candidate.list === 'function' &&
297
+ typeof candidate.put === 'function' &&
298
+ typeof candidate.putWrites === 'function' &&
299
+ typeof candidate.deleteThread === 'function'
300
+ );
301
+ }
302
+
303
+ function isSubagentCheckpointMarker(
304
+ value: unknown
305
+ ): value is SubagentCheckpointMarker {
306
+ if (value == null || typeof value !== 'object') {
307
+ return false;
308
+ }
309
+ const marker = value as Partial<SubagentCheckpointMarker>;
310
+ return (
311
+ marker.version === 1 &&
312
+ marker.lifecycleComplete === true &&
313
+ typeof marker.parentToolCallId === 'string' &&
314
+ (marker.hookSessionId == null ||
315
+ typeof marker.hookSessionId === 'string') &&
316
+ (marker.childRunId == null || typeof marker.childRunId === 'string') &&
317
+ (marker.settledOutput == null ||
318
+ isPersistedToolOutput(marker.settledOutput))
319
+ );
320
+ }
321
+
322
+ function isPersistedToolOutput(value: unknown): value is PersistedToolOutput {
323
+ if (value == null || typeof value !== 'object') {
324
+ return false;
325
+ }
326
+ const output = value as {
327
+ content?: unknown;
328
+ toolCallId?: unknown;
329
+ status?: unknown;
330
+ additionalKwargs?: unknown;
331
+ responseMetadata?: unknown;
332
+ additionalContexts?: unknown;
333
+ resolvedArgs?: unknown;
334
+ referenceContent?: unknown;
335
+ };
336
+ const contentIsValid =
337
+ typeof output.content === 'string' || Array.isArray(output.content);
338
+ const statusIsValid =
339
+ output.status == null ||
340
+ output.status === 'success' ||
341
+ output.status === 'error';
342
+ const contextsAreValid =
343
+ Array.isArray(output.additionalContexts) &&
344
+ output.additionalContexts.every((context) => typeof context === 'string');
345
+ const resolvedArgsAreValid =
346
+ output.resolvedArgs == null ||
347
+ (typeof output.resolvedArgs === 'object' &&
348
+ !Array.isArray(output.resolvedArgs));
349
+ return (
350
+ contentIsValid &&
351
+ typeof output.toolCallId === 'string' &&
352
+ output.additionalKwargs != null &&
353
+ typeof output.additionalKwargs === 'object' &&
354
+ output.responseMetadata != null &&
355
+ typeof output.responseMetadata === 'object' &&
356
+ statusIsValid &&
357
+ contextsAreValid &&
358
+ resolvedArgsAreValid &&
359
+ (output.referenceContent == null ||
360
+ typeof output.referenceContent === 'string')
361
+ );
362
+ }
363
+
364
+ function getSubagentCheckpointMarker(
365
+ messages: BaseMessage[],
366
+ parentToolCallId: string
367
+ ): SubagentCheckpointMarker | undefined {
368
+ for (let i = messages.length - 1; i >= 0; i--) {
369
+ const marker =
370
+ messages[i].additional_kwargs[SUBAGENT_CHECKPOINT_MARKER_KEY];
371
+ if (
372
+ isSubagentCheckpointMarker(marker) &&
373
+ marker.parentToolCallId === parentToolCallId
374
+ ) {
375
+ return marker;
376
+ }
377
+ }
378
+ return undefined;
379
+ }
380
+
381
+ function getSubagentHookSessionId(messages: BaseMessage[]): string | undefined {
382
+ for (let i = messages.length - 1; i >= 0; i--) {
383
+ const sessionId = messages[i].additional_kwargs[SUBAGENT_HOOK_SESSION_KEY];
384
+ if (typeof sessionId === 'string' && sessionId.length > 0) {
385
+ return sessionId;
386
+ }
387
+ }
388
+ return undefined;
389
+ }
390
+
391
+ function getSubagentRunId(messages: BaseMessage[]): string | undefined {
392
+ for (let i = messages.length - 1; i >= 0; i--) {
393
+ const runId = messages[i].additional_kwargs[SUBAGENT_RUN_ID_KEY];
394
+ if (typeof runId === 'string' && runId.length > 0) {
395
+ return runId;
396
+ }
397
+ const marker =
398
+ messages[i].additional_kwargs[SUBAGENT_CHECKPOINT_MARKER_KEY];
399
+ if (
400
+ isSubagentCheckpointMarker(marker) &&
401
+ marker.childRunId != null &&
402
+ marker.childRunId.length > 0
403
+ ) {
404
+ return marker.childRunId;
405
+ }
406
+ }
407
+ return undefined;
408
+ }
409
+
410
+ function isSubagentCheckpointMarkerMessage(message: BaseMessage): boolean {
411
+ return isSubagentCheckpointMarker(
412
+ message.additional_kwargs[SUBAGENT_CHECKPOINT_MARKER_KEY]
413
+ );
414
+ }
415
+
416
+ function createSubagentCheckpointMarkerMessage(
417
+ marker: SubagentCheckpointMarker
418
+ ): AIMessage {
419
+ return new AIMessage({
420
+ content: '',
421
+ additional_kwargs: { [SUBAGENT_CHECKPOINT_MARKER_KEY]: marker },
422
+ });
423
+ }
424
+
425
+ function getCheckpointMessages(value: unknown): BaseMessage[] {
426
+ return Array.isArray(value) ? value.filter(BaseMessage.isInstance) : [];
427
+ }
428
+
429
+ function getConfigurableString(
430
+ config: RunnableConfig | undefined,
431
+ key: string
432
+ ): string | undefined {
433
+ const value = config?.configurable?.[key];
434
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
435
+ }
436
+
437
+ function getTupleMessages(tuple: CheckpointTuple | undefined): BaseMessage[] {
438
+ return getCheckpointMessages(tuple?.checkpoint.channel_values.messages);
439
+ }
440
+
441
+ function getCheckpointReference(
442
+ tuple: CheckpointTuple | undefined
443
+ ): SubagentCheckpointReference | undefined {
444
+ const configurable = tuple?.config.configurable;
445
+ const threadId = configurable?.thread_id;
446
+ const checkpointId = configurable?.checkpoint_id;
447
+ const checkpointNs = configurable?.checkpoint_ns ?? '';
448
+ if (
449
+ typeof threadId !== 'string' ||
450
+ threadId.length === 0 ||
451
+ typeof checkpointId !== 'string' ||
452
+ checkpointId.length === 0 ||
453
+ typeof checkpointNs !== 'string'
454
+ ) {
455
+ return undefined;
456
+ }
457
+ return { threadId, checkpointId, checkpointNs };
458
+ }
459
+
460
+ function serializeToolOutput(
461
+ settled: SettledSubagentToolOutput
462
+ ): PersistedToolOutput {
463
+ const { output } = settled;
464
+ return {
465
+ content: output.content,
466
+ toolCallId: output.tool_call_id,
467
+ ...(output.id == null ? {} : { id: output.id }),
468
+ ...(output.name == null ? {} : { name: output.name }),
469
+ ...(output.status == null ? {} : { status: output.status }),
470
+ additionalKwargs: output.additional_kwargs,
471
+ responseMetadata: output.response_metadata,
472
+ ...(output.metadata == null ? {} : { metadata: output.metadata }),
473
+ additionalContexts: settled.additionalContexts,
474
+ ...(settled.resolvedArgs == null
475
+ ? {}
476
+ : { resolvedArgs: settled.resolvedArgs }),
477
+ ...(settled.referenceContent == null
478
+ ? {}
479
+ : { referenceContent: settled.referenceContent }),
480
+ };
481
+ }
482
+
483
+ function deserializeToolOutput(
484
+ output: PersistedToolOutput
485
+ ): SettledSubagentToolOutput {
486
+ return {
487
+ output: new ToolMessage({
488
+ content: output.content,
489
+ tool_call_id: output.toolCallId,
490
+ ...(output.id == null ? {} : { id: output.id }),
491
+ ...(output.name == null ? {} : { name: output.name }),
492
+ ...(output.status == null ? {} : { status: output.status }),
493
+ additional_kwargs: output.additionalKwargs,
494
+ response_metadata: output.responseMetadata,
495
+ ...(output.metadata == null ? {} : { metadata: output.metadata }),
496
+ }),
497
+ additionalContexts: output.additionalContexts,
498
+ ...(output.resolvedArgs == null
499
+ ? {}
500
+ : { resolvedArgs: output.resolvedArgs }),
501
+ ...(output.referenceContent == null
502
+ ? {}
503
+ : { referenceContent: output.referenceContent }),
504
+ };
505
+ }
506
+
507
+ function getParentCheckpointFork(
508
+ configurable: Record<string, unknown> | undefined
509
+ ): string {
510
+ const checkpointId = configurable?.checkpoint_id;
511
+ return typeof checkpointId === 'string' && checkpointId.length > 0
512
+ ? checkpointId
513
+ : 'root';
514
+ }
515
+
516
+ function getResumeAttemptId(
517
+ configurable: Record<string, unknown> | undefined,
518
+ fallback: string
519
+ ): string {
520
+ const attemptId = configurable?.[SUBAGENT_RESUME_ATTEMPT_CONFIG_KEY];
521
+ return typeof attemptId === 'string' && attemptId.length > 0
522
+ ? attemptId
523
+ : fallback;
524
+ }
525
+
526
+ function getApprovalExecutionScope(
527
+ childRunId: string,
528
+ resumeAttemptId: string
529
+ ): string {
530
+ return `subagent-approval:${Buffer.from(
531
+ JSON.stringify([childRunId, resumeAttemptId])
532
+ ).toString('base64url')}`;
533
+ }
534
+
535
+ function getChildThreadId(args: {
536
+ parentRunId: string;
537
+ parentAgentId?: string;
538
+ threadId?: string;
539
+ parentToolCallId: string;
540
+ parentConfigurable?: Record<string, unknown>;
541
+ branchId?: string;
542
+ }): string {
543
+ const durableParentId = args.threadId ?? args.parentRunId;
544
+ const parentFork = getParentCheckpointFork(args.parentConfigurable);
545
+ const parentBatch =
546
+ args.parentConfigurable?.[SUBAGENT_PARENT_BATCH_CONFIG_KEY];
547
+ const identity = [
548
+ durableParentId,
549
+ parentFork,
550
+ args.parentAgentId ?? 'agent',
551
+ args.parentToolCallId,
552
+ typeof parentBatch === 'string' && parentBatch.length > 0
553
+ ? parentBatch
554
+ : 'batch',
555
+ ];
556
+ if (args.branchId != null) {
557
+ identity.push(args.branchId);
558
+ }
559
+ return `subagent:${Buffer.from(JSON.stringify(identity)).toString('base64url')}`;
560
+ }
561
+
562
+ function isToolApprovalPayload(
563
+ value: unknown
564
+ ): value is ToolApprovalInterruptPayload {
565
+ return (
566
+ value != null &&
567
+ typeof value === 'object' &&
568
+ (value as { type?: unknown }).type === 'tool_approval'
569
+ );
570
+ }
571
+
572
+ function addSubagentScope(
573
+ interrupts: Interrupt[],
574
+ scope: NonNullable<ToolApprovalInterruptPayload['subagent']>,
575
+ resumeManifest?: SubagentResumeManifest
576
+ ): Interrupt[] {
577
+ return interrupts.map((childInterrupt) => {
578
+ const payload = isToolApprovalPayload(childInterrupt.value)
579
+ ? {
580
+ ...childInterrupt.value,
581
+ subagent: childInterrupt.value.subagent ?? scope,
582
+ }
583
+ : childInterrupt.value;
584
+ return {
585
+ ...childInterrupt,
586
+ value:
587
+ resumeManifest == null
588
+ ? payload
589
+ : attachSubagentResumeManifest(payload, resumeManifest),
590
+ };
591
+ });
592
+ }
593
+
594
+ type ToolApprovalResumeValue = ToolApprovalDecision[] | ToolApprovalDecisionMap;
595
+
596
+ function getChildResumeMap(
597
+ pendingInterrupts: Interrupt[],
598
+ parentConfigurable: Record<string, unknown> | undefined
599
+ ): Record<string, ToolApprovalResumeValue> | undefined {
600
+ const resumeMap = parentConfigurable?.[LANGGRAPH_RESUME_MAP_CONFIG_KEY];
601
+ if (resumeMap == null || typeof resumeMap !== 'object') {
602
+ return undefined;
603
+ }
604
+
605
+ const parentResumeMap = resumeMap as Record<string, ToolApprovalResumeValue>;
606
+ const childResumeMap: Record<string, ToolApprovalResumeValue> = {};
607
+ for (const childInterrupt of pendingInterrupts) {
608
+ const interruptId = childInterrupt.id;
609
+ if (
610
+ typeof interruptId === 'string' &&
611
+ Object.prototype.hasOwnProperty.call(parentResumeMap, interruptId)
612
+ ) {
613
+ childResumeMap[interruptId] = parentResumeMap[interruptId];
614
+ }
615
+ }
616
+ return Object.keys(childResumeMap).length > 0 ? childResumeMap : undefined;
617
+ }
618
+
619
+ function getPersistedInterrupts(snapshot: StateSnapshot): Interrupt[] {
620
+ const interrupts: Interrupt[] = [];
621
+ for (const task of snapshot.tasks) {
622
+ for (const pendingInterrupt of task.interrupts) {
623
+ interrupts.push(pendingInterrupt);
624
+ }
625
+ }
626
+ return interrupts;
627
+ }
628
+
629
+ function getPersistedMessages(
630
+ snapshot: StateSnapshot
631
+ ): BaseMessage[] | undefined {
632
+ if (snapshot.values == null || typeof snapshot.values !== 'object') {
633
+ return undefined;
634
+ }
635
+ const values = snapshot.values as { messages?: BaseMessage[] };
636
+ if (!Array.isArray(values.messages) || values.messages.length === 0) {
637
+ return undefined;
638
+ }
639
+ const messages = values.messages.filter(
640
+ (message) => !isSubagentCheckpointMarkerMessage(message)
641
+ );
642
+ return messages.length > 0 ? messages : undefined;
643
+ }
644
+
645
+ function createReplayCheckpointWorkflow(
646
+ checkpointer: BaseCheckpointSaver
647
+ ): ReplayCheckpointWorkflow {
648
+ return new StateGraph(MessagesAnnotation)
649
+ .addNode(SUBAGENT_REPLAY_NODE, (state) => state)
650
+ .addEdge(START, SUBAGENT_REPLAY_NODE)
651
+ .addEdge(SUBAGENT_REPLAY_NODE, END)
652
+ .compile({ checkpointer }) as ReplayCheckpointWorkflow;
653
+ }
169
654
 
170
655
  export type SubagentExecuteParams = {
171
656
  description: string;
@@ -197,11 +682,12 @@ export type SubagentExecuteParams = {
197
682
  *
198
683
  * Inheritance details (verified empirically against LangGraph):
199
684
  * - host-set keys propagate as-is into the child's tool dispatches;
200
- * - `thread_id` propagates (with `childRunId` as a fallback when
201
- * parent did not supply one) — matches the "subagent is part of
202
- * the same conversation" mental model and aligns with the
203
- * `sessionId: this.parentRunId` convention this executor already
204
- * uses for `SubagentStart` / `SubagentStop` hooks;
685
+ * - with nested HITL enabled, `thread_id` is replaced with a stable
686
+ * child checkpoint id derived from the parent's durable thread id,
687
+ * checkpoint fork, parent agent id, and spawning tool call id so parent
688
+ * and child checkpoints cannot collide, sibling parent forks stay
689
+ * isolated, and reconstruction returns to the same child checkpoint;
690
+ * parent-scoped hook lookup remains keyed by the inherited `run_id`;
205
691
  * - `parent_run_id` propagates when the host put it on parent's
206
692
  * configurable;
207
693
  * - `run_id` is *overwritten by the LangGraph runtime* at child
@@ -261,6 +747,11 @@ export type SubagentExecutorOptions = {
261
747
  * revert to the defaults.
262
748
  */
263
749
  streamLimits?: StandardGraphInput['streamLimits'];
750
+ humanInTheLoop?: HumanInTheLoopConfig;
751
+ /** Shared durable saver used to recover outer tool lifecycle results before
752
+ * parent hooks re-enter after a process rebuild. Narrowed structurally at
753
+ * construction because graph compile options also permit framework flags. */
754
+ checkpointer?: unknown;
264
755
  /** Remaining nesting budget. 0 or negative blocks execution. */
265
756
  maxDepth?: number;
266
757
  /**
@@ -310,9 +801,27 @@ export class SubagentExecutor {
310
801
  private readonly langfuse?: StandardGraphInput['langfuse'];
311
802
  private readonly tokenCounter?: TokenCounter;
312
803
  private readonly streamLimits?: StandardGraphInput['streamLimits'];
804
+ private readonly humanInTheLoop?: HumanInTheLoopConfig;
805
+ private readonly checkpointer?: BaseCheckpointSaver;
313
806
  private readonly maxDepth: number;
314
807
  private readonly createChildGraph: ChildGraphFactory;
315
808
  private readonly usageSink?: SubagentUsageSink;
809
+ private readonly checkpointThreadIds = new Set<string>();
810
+ private readonly startedChildRuns = new Set<string>();
811
+ private readonly completedChildRuns = new Set<string>();
812
+ private readonly completedChildResults = new Map<
813
+ string,
814
+ SubagentExecuteResult
815
+ >();
816
+ private readonly childExecutionIdentities = new Map<
817
+ string,
818
+ Pick<
819
+ ChildExecutionIdentity,
820
+ 'childRunId' | 'childThreadId' | 'approvalExecutionScope'
821
+ >
822
+ >();
823
+ private readonly activeChildRuns = new Map<string, ActiveChildRun>();
824
+ private replayCheckpointWorkflow?: ReplayCheckpointWorkflow;
316
825
  private readonly resolveParentHandlerRegistry?: () =>
317
826
  | HandlerRegistry
318
827
  | undefined;
@@ -327,6 +836,10 @@ export class SubagentExecutor {
327
836
  this.langfuse = options.langfuse;
328
837
  this.tokenCounter = options.tokenCounter;
329
838
  this.streamLimits = options.streamLimits;
839
+ this.humanInTheLoop = options.humanInTheLoop;
840
+ this.checkpointer = isCheckpointSaver(options.checkpointer)
841
+ ? options.checkpointer
842
+ : undefined;
330
843
  this.maxDepth = options.maxDepth ?? 1;
331
844
  this.createChildGraph = options.createChildGraph;
332
845
  this.usageSink = options.usageSink;
@@ -360,6 +873,533 @@ export class SubagentExecutor {
360
873
  return this.resolveParentHandlerRegistry?.();
361
874
  }
362
875
 
876
+ /**
877
+ * Keeps the original child thread as an immutable resume source once a
878
+ * different parent Run reconstructs it. Each rebuilt parent gets a private
879
+ * checkpoint fork, while the persisted child run ID remains stable for
880
+ * activity and usage correlation across the interrupt boundary.
881
+ */
882
+ private async resolveChildExecutionIdentity(
883
+ params: ChildExecutionIdentityParams
884
+ ): Promise<ChildExecutionIdentity> {
885
+ const currentChildRunId = `${this.parentRunId}_sub_${params.parentToolCallId}`;
886
+ const resumeAttemptId = getResumeAttemptId(
887
+ params.parentConfigurable,
888
+ this.parentRunId
889
+ );
890
+ const baseChildThreadId = getChildThreadId({
891
+ parentRunId: this.parentRunId,
892
+ parentAgentId: this.parentAgentId,
893
+ threadId: params.threadId,
894
+ parentToolCallId: params.parentToolCallId,
895
+ parentConfigurable: params.parentConfigurable,
896
+ });
897
+ if (this.humanInTheLoop?.enabled !== true || this.checkpointer == null) {
898
+ return {
899
+ childRunId: currentChildRunId,
900
+ childThreadId: baseChildThreadId,
901
+ approvalExecutionScope: currentChildRunId,
902
+ };
903
+ }
904
+
905
+ const resumeManifest = getSubagentResumeManifest(params.parentConfigurable);
906
+ const resumeExecution = resumeManifest?.executions.find(
907
+ (execution) => execution.parentToolCallId === params.parentToolCallId
908
+ );
909
+ const branchChildThreadId = getChildThreadId({
910
+ parentRunId: this.parentRunId,
911
+ parentAgentId: this.parentAgentId,
912
+ threadId: params.threadId,
913
+ parentToolCallId: params.parentToolCallId,
914
+ parentConfigurable: params.parentConfigurable,
915
+ branchId: resumeAttemptId,
916
+ });
917
+ if (resumeExecution != null) {
918
+ const approvalExecutionScope = getApprovalExecutionScope(
919
+ resumeExecution.childRunId,
920
+ resumeAttemptId
921
+ );
922
+ await this.forkCheckpointSnapshot(
923
+ resumeExecution.checkpoints,
924
+ branchChildThreadId
925
+ );
926
+ this.hookRegistry?.restorePendingToolApprovals(
927
+ approvalExecutionScope,
928
+ approvalExecutionScope,
929
+ resumeExecution.approvalReplays
930
+ );
931
+ this.checkpointThreadIds.add(branchChildThreadId);
932
+ return {
933
+ childRunId: resumeExecution.childRunId,
934
+ childThreadId: branchChildThreadId,
935
+ approvalExecutionScope,
936
+ resumeExecution,
937
+ };
938
+ }
939
+
940
+ const branchTuple = await this.checkpointer.getTuple({
941
+ configurable: { thread_id: branchChildThreadId },
942
+ });
943
+ if (branchTuple != null) {
944
+ const childRunId =
945
+ getSubagentRunId(getTupleMessages(branchTuple)) ?? currentChildRunId;
946
+ this.checkpointThreadIds.add(branchChildThreadId);
947
+ return {
948
+ childRunId,
949
+ childThreadId: branchChildThreadId,
950
+ approvalExecutionScope: getApprovalExecutionScope(
951
+ childRunId,
952
+ resumeAttemptId
953
+ ),
954
+ };
955
+ }
956
+
957
+ const baseTuple = await this.checkpointer.getTuple({
958
+ configurable: { thread_id: baseChildThreadId },
959
+ });
960
+ const persistedChildRunId = getSubagentRunId(getTupleMessages(baseTuple));
961
+ if (
962
+ baseTuple == null ||
963
+ persistedChildRunId == null ||
964
+ persistedChildRunId === currentChildRunId
965
+ ) {
966
+ this.checkpointThreadIds.add(baseChildThreadId);
967
+ return {
968
+ childRunId: persistedChildRunId ?? currentChildRunId,
969
+ childThreadId: baseChildThreadId,
970
+ approvalExecutionScope: getApprovalExecutionScope(
971
+ persistedChildRunId ?? currentChildRunId,
972
+ resumeAttemptId
973
+ ),
974
+ };
975
+ }
976
+
977
+ const sourceCheckpoints =
978
+ await this.getLatestCheckpointSnapshot(baseChildThreadId);
979
+ if (sourceCheckpoints.length === 0) {
980
+ throw new Error(
981
+ `Cannot fork subagent checkpoint thread "${baseChildThreadId}" without a checkpoint ID.`
982
+ );
983
+ }
984
+ await this.forkCheckpointSnapshot(sourceCheckpoints, branchChildThreadId);
985
+ this.checkpointThreadIds.add(branchChildThreadId);
986
+ return {
987
+ childRunId: persistedChildRunId,
988
+ childThreadId: branchChildThreadId,
989
+ approvalExecutionScope: getApprovalExecutionScope(
990
+ persistedChildRunId,
991
+ resumeAttemptId
992
+ ),
993
+ };
994
+ }
995
+
996
+ /** Captures one exact checkpoint head per namespace for a child thread. */
997
+ private async getLatestCheckpointSnapshot(
998
+ threadId: string
999
+ ): Promise<SubagentCheckpointReference[]> {
1000
+ if (this.checkpointer == null) {
1001
+ return [];
1002
+ }
1003
+ const checkpointsByNamespace = new Map<
1004
+ string,
1005
+ SubagentCheckpointReference
1006
+ >();
1007
+ for await (const tuple of this.checkpointer.list({
1008
+ configurable: { thread_id: threadId },
1009
+ })) {
1010
+ const checkpoint = getCheckpointReference(tuple);
1011
+ const current =
1012
+ checkpoint == null
1013
+ ? undefined
1014
+ : checkpointsByNamespace.get(checkpoint.checkpointNs);
1015
+ if (
1016
+ checkpoint != null &&
1017
+ (current == null ||
1018
+ checkpoint.checkpointId.localeCompare(current.checkpointId) > 0)
1019
+ ) {
1020
+ checkpointsByNamespace.set(checkpoint.checkpointNs, checkpoint);
1021
+ }
1022
+ }
1023
+ return [...checkpointsByNamespace.values()].sort((left, right) =>
1024
+ left.checkpointNs.localeCompare(right.checkpointNs)
1025
+ );
1026
+ }
1027
+
1028
+ /** Copies exact checkpoint lineages, including pending task writes. */
1029
+ private async forkCheckpointSnapshot(
1030
+ sources: ReadonlyArray<SubagentCheckpointReference>,
1031
+ targetThreadId: string
1032
+ ): Promise<void> {
1033
+ if (
1034
+ this.checkpointer == null ||
1035
+ sources.length === 0 ||
1036
+ sources.every((source) => source.threadId === targetThreadId)
1037
+ ) {
1038
+ return;
1039
+ }
1040
+ for (const source of sources) {
1041
+ const tuples: CheckpointTuple[] = [];
1042
+ const visited = new Set<string>();
1043
+ let tuple = await this.checkpointer.getTuple({
1044
+ configurable: {
1045
+ thread_id: source.threadId,
1046
+ checkpoint_ns: source.checkpointNs,
1047
+ checkpoint_id: source.checkpointId,
1048
+ },
1049
+ });
1050
+ if (tuple == null) {
1051
+ throw new Error(
1052
+ `Subagent checkpoint "${source.checkpointId}" is unavailable.`
1053
+ );
1054
+ }
1055
+ for (;;) {
1056
+ const reference = getCheckpointReference(tuple);
1057
+ if (reference == null) {
1058
+ throw new Error(
1059
+ 'Subagent checkpoint lineage contains an invalid tuple.'
1060
+ );
1061
+ }
1062
+ if (
1063
+ reference.threadId !== source.threadId ||
1064
+ reference.checkpointNs !== source.checkpointNs
1065
+ ) {
1066
+ throw new Error(
1067
+ 'Subagent checkpoint lineage escapes its source namespace.'
1068
+ );
1069
+ }
1070
+ const lineageKey = JSON.stringify([
1071
+ reference.threadId,
1072
+ reference.checkpointNs,
1073
+ reference.checkpointId,
1074
+ ]);
1075
+ if (visited.has(lineageKey)) {
1076
+ throw new Error('Subagent checkpoint lineage contains a cycle.');
1077
+ }
1078
+ visited.add(lineageKey);
1079
+ tuples.push(tuple);
1080
+ if (tuple.parentConfig == null) {
1081
+ break;
1082
+ }
1083
+ tuple = await this.checkpointer.getTuple(tuple.parentConfig);
1084
+ if (tuple == null) {
1085
+ throw new Error('Subagent checkpoint lineage is incomplete.');
1086
+ }
1087
+ }
1088
+ tuples.reverse();
1089
+
1090
+ for (const lineageTuple of tuples) {
1091
+ const checkpointNs =
1092
+ getConfigurableString(lineageTuple.config, 'checkpoint_ns') ?? '';
1093
+ const parentCheckpointId = getConfigurableString(
1094
+ lineageTuple.parentConfig,
1095
+ 'checkpoint_id'
1096
+ );
1097
+ const storedConfig = await this.checkpointer.put(
1098
+ {
1099
+ configurable: {
1100
+ thread_id: targetThreadId,
1101
+ checkpoint_ns: checkpointNs,
1102
+ ...(parentCheckpointId == null
1103
+ ? {}
1104
+ : { checkpoint_id: parentCheckpointId }),
1105
+ },
1106
+ },
1107
+ copyCheckpoint(lineageTuple.checkpoint),
1108
+ lineageTuple.metadata ?? {
1109
+ source: 'fork',
1110
+ step: -1,
1111
+ parents: {},
1112
+ },
1113
+ lineageTuple.checkpoint.channel_versions
1114
+ );
1115
+ const writesByTask = new Map<string, Array<[string, unknown]>>();
1116
+ for (const [taskId, channel, value] of lineageTuple.pendingWrites ??
1117
+ []) {
1118
+ let writes = writesByTask.get(taskId);
1119
+ if (writes == null) {
1120
+ writes = [];
1121
+ writesByTask.set(taskId, writes);
1122
+ }
1123
+ writes.push([channel, value]);
1124
+ }
1125
+ for (const [taskId, writes] of writesByTask) {
1126
+ await this.checkpointer.putWrites(storedConfig, writes, taskId);
1127
+ }
1128
+ }
1129
+ }
1130
+ }
1131
+
1132
+ private async createResumeManifest(
1133
+ parentToolCallIds?: ReadonlySet<string>
1134
+ ): Promise<SubagentResumeManifest | undefined> {
1135
+ if (this.checkpointer == null) {
1136
+ return undefined;
1137
+ }
1138
+ const executions: SubagentResumeExecution[] = [];
1139
+ for (const [parentToolCallId, identity] of this.childExecutionIdentities) {
1140
+ if (
1141
+ parentToolCallIds != null &&
1142
+ !parentToolCallIds.has(parentToolCallId)
1143
+ ) {
1144
+ continue;
1145
+ }
1146
+ const checkpoints = await this.getLatestCheckpointSnapshot(
1147
+ identity.childThreadId
1148
+ );
1149
+ if (checkpoints.length === 0) {
1150
+ continue;
1151
+ }
1152
+ const activeChildRun = this.activeChildRuns.get(identity.childThreadId);
1153
+ const configuredHookSessionId =
1154
+ activeChildRun?.invokeConfig?.configurable?.run_id;
1155
+ const hookSessionId =
1156
+ typeof configuredHookSessionId === 'string' &&
1157
+ configuredHookSessionId.length > 0
1158
+ ? configuredHookSessionId
1159
+ : this.parentRunId;
1160
+ const approvalReplays =
1161
+ this.hookRegistry?.snapshotPendingToolApprovals(
1162
+ identity.approvalExecutionScope,
1163
+ identity.approvalExecutionScope
1164
+ ) ?? [];
1165
+ const descendant = activeChildRun?.pendingInterrupts
1166
+ .map((pendingInterrupt) =>
1167
+ getSubagentResumeManifest(pendingInterrupt.value)
1168
+ )
1169
+ .find((manifest) => manifest != null);
1170
+ const graphState = activeChildRun?.graph.createSubagentResumeState(
1171
+ hookSessionId
1172
+ ) ?? {
1173
+ toolCallSteps: [],
1174
+ toolSessions: [],
1175
+ toolNodes: [],
1176
+ eagerToolUsage: [],
1177
+ eagerToolSuppressions: [],
1178
+ };
1179
+ executions.push({
1180
+ parentToolCallId,
1181
+ childRunId: identity.childRunId,
1182
+ approvalExecutionScope: identity.approvalExecutionScope,
1183
+ checkpoints,
1184
+ graphState,
1185
+ approvalReplays,
1186
+ ...(descendant == null ? {} : { descendant }),
1187
+ });
1188
+ }
1189
+ if (executions.length === 0) {
1190
+ return undefined;
1191
+ }
1192
+ return {
1193
+ version: 1,
1194
+ executions,
1195
+ };
1196
+ }
1197
+
1198
+ getResumeManifest(
1199
+ parentToolCallIds?: ReadonlySet<string>
1200
+ ): Promise<SubagentResumeManifest | undefined> {
1201
+ return this.createResumeManifest(parentToolCallIds);
1202
+ }
1203
+
1204
+ getChildCheckpointThreadIds(): string[] {
1205
+ const threadIds = new Set(this.checkpointThreadIds);
1206
+ for (const activeChildRun of this.activeChildRuns.values()) {
1207
+ for (const threadId of this.getGraphChildCheckpointThreadIds(
1208
+ activeChildRun.graph
1209
+ )) {
1210
+ threadIds.add(threadId);
1211
+ }
1212
+ }
1213
+ return [...threadIds];
1214
+ }
1215
+
1216
+ resetCheckpointThreadIds(): void {
1217
+ this.checkpointThreadIds.clear();
1218
+ }
1219
+
1220
+ private getGraphChildCheckpointThreadIds(graph: StandardGraph): string[] {
1221
+ const checkpointGraph = graph as {
1222
+ getChildCheckpointThreadIds?: () => string[];
1223
+ };
1224
+ return checkpointGraph.getChildCheckpointThreadIds?.() ?? [];
1225
+ }
1226
+
1227
+ private clearChildGraph(graph: StandardGraph): void {
1228
+ for (const threadId of this.getGraphChildCheckpointThreadIds(graph)) {
1229
+ this.checkpointThreadIds.add(threadId);
1230
+ }
1231
+ graph.clearHeavyState();
1232
+ }
1233
+
1234
+ clearHeavyState(): void {
1235
+ for (const activeChildRun of this.activeChildRuns.values()) {
1236
+ this.clearChildGraph(activeChildRun.graph);
1237
+ }
1238
+ this.activeChildRuns.clear();
1239
+ this.completedChildResults.clear();
1240
+ for (const identity of this.childExecutionIdentities.values()) {
1241
+ this.hookRegistry?.clearSession(identity.approvalExecutionScope);
1242
+ }
1243
+ this.childExecutionIdentities.clear();
1244
+ this.startedChildRuns.clear();
1245
+ this.completedChildRuns.clear();
1246
+ this.replayCheckpointWorkflow = undefined;
1247
+ }
1248
+
1249
+ async getSettledToolOutput(
1250
+ call: ToolCall,
1251
+ config: RunnableConfig
1252
+ ): Promise<SettledSubagentToolOutput | undefined> {
1253
+ const parentToolCallId = call.id;
1254
+ if (
1255
+ this.humanInTheLoop?.enabled !== true ||
1256
+ this.checkpointer == null ||
1257
+ parentToolCallId == null ||
1258
+ parentToolCallId === ''
1259
+ ) {
1260
+ return undefined;
1261
+ }
1262
+ const parentConfigurable = config.configurable as
1263
+ | Record<string, unknown>
1264
+ | undefined;
1265
+ const threadId = parentConfigurable?.thread_id;
1266
+ const { childRunId, childThreadId, approvalExecutionScope } =
1267
+ await this.resolveChildExecutionIdentity({
1268
+ threadId: typeof threadId === 'string' ? threadId : undefined,
1269
+ parentToolCallId,
1270
+ parentConfigurable,
1271
+ });
1272
+ this.childExecutionIdentities.set(parentToolCallId, {
1273
+ childRunId,
1274
+ childThreadId,
1275
+ approvalExecutionScope,
1276
+ });
1277
+ this.checkpointThreadIds.add(childThreadId);
1278
+ const checkpoint = await this.checkpointer.getTuple({
1279
+ configurable: { thread_id: childThreadId },
1280
+ });
1281
+ const messages = getCheckpointMessages(
1282
+ checkpoint?.checkpoint.channel_values.messages
1283
+ );
1284
+ const marker = getSubagentCheckpointMarker(messages, parentToolCallId);
1285
+ const persistedHookSessionId =
1286
+ marker?.hookSessionId ?? getSubagentHookSessionId(messages);
1287
+ const currentHookSessionId = parentConfigurable?.run_id;
1288
+ if (
1289
+ persistedHookSessionId != null &&
1290
+ typeof currentHookSessionId === 'string' &&
1291
+ currentHookSessionId.length > 0
1292
+ ) {
1293
+ this.hookRegistry?.copySession(
1294
+ persistedHookSessionId,
1295
+ currentHookSessionId
1296
+ );
1297
+ }
1298
+ return marker?.settledOutput == null
1299
+ ? undefined
1300
+ : deserializeToolOutput(marker.settledOutput);
1301
+ }
1302
+
1303
+ async persistSettledToolOutput(
1304
+ call: ToolCall,
1305
+ config: RunnableConfig,
1306
+ settled: SettledSubagentToolOutput
1307
+ ): Promise<void> {
1308
+ const parentToolCallId = call.id;
1309
+ if (
1310
+ this.humanInTheLoop?.enabled !== true ||
1311
+ this.checkpointer == null ||
1312
+ parentToolCallId == null ||
1313
+ parentToolCallId === ''
1314
+ ) {
1315
+ return;
1316
+ }
1317
+ const parentConfigurable = config.configurable as
1318
+ | Record<string, unknown>
1319
+ | undefined;
1320
+ const threadId = parentConfigurable?.thread_id;
1321
+ const { childRunId, childThreadId, approvalExecutionScope } =
1322
+ await this.resolveChildExecutionIdentity({
1323
+ threadId: typeof threadId === 'string' ? threadId : undefined,
1324
+ parentToolCallId,
1325
+ parentConfigurable,
1326
+ });
1327
+ this.childExecutionIdentities.set(parentToolCallId, {
1328
+ childRunId,
1329
+ childThreadId,
1330
+ approvalExecutionScope,
1331
+ });
1332
+ this.checkpointThreadIds.add(childThreadId);
1333
+ const activeChildRun = this.activeChildRuns.get(childThreadId);
1334
+ const persistedOutput = serializeToolOutput(settled);
1335
+ if (activeChildRun != null) {
1336
+ await this.persistChildCheckpointMarker(
1337
+ activeChildRun,
1338
+ parentToolCallId,
1339
+ persistedOutput
1340
+ );
1341
+ this.clearChildGraph(activeChildRun.graph);
1342
+ this.activeChildRuns.delete(childThreadId);
1343
+ return;
1344
+ }
1345
+ this.replayCheckpointWorkflow ??= createReplayCheckpointWorkflow(
1346
+ this.checkpointer
1347
+ );
1348
+ await this.replayCheckpointWorkflow.updateState(
1349
+ { configurable: { thread_id: childThreadId } },
1350
+ {
1351
+ messages: [
1352
+ createSubagentCheckpointMarkerMessage({
1353
+ version: 1,
1354
+ parentToolCallId,
1355
+ lifecycleComplete: true,
1356
+ hookSessionId:
1357
+ typeof parentConfigurable?.run_id === 'string'
1358
+ ? parentConfigurable.run_id
1359
+ : this.parentRunId,
1360
+ childRunId,
1361
+ settledOutput: persistedOutput,
1362
+ }),
1363
+ ],
1364
+ },
1365
+ SUBAGENT_REPLAY_NODE
1366
+ );
1367
+ }
1368
+
1369
+ private async persistChildCheckpointMarker(
1370
+ activeChildRun: ActiveChildRun,
1371
+ parentToolCallId: string,
1372
+ settledOutput?: PersistedToolOutput
1373
+ ): Promise<void> {
1374
+ if (
1375
+ this.humanInTheLoop?.enabled !== true ||
1376
+ activeChildRun.workflow.updateState == null ||
1377
+ activeChildRun.invokeConfig == null
1378
+ ) {
1379
+ return;
1380
+ }
1381
+ await activeChildRun.workflow.updateState(
1382
+ activeChildRun.invokeConfig,
1383
+ {
1384
+ messages: [
1385
+ createSubagentCheckpointMarkerMessage({
1386
+ version: 1,
1387
+ parentToolCallId,
1388
+ lifecycleComplete: true,
1389
+ hookSessionId:
1390
+ typeof activeChildRun.invokeConfig.configurable?.run_id ===
1391
+ 'string'
1392
+ ? activeChildRun.invokeConfig.configurable.run_id
1393
+ : this.parentRunId,
1394
+ childRunId: activeChildRun.childRunId,
1395
+ ...(settledOutput == null ? {} : { settledOutput }),
1396
+ }),
1397
+ ],
1398
+ },
1399
+ activeChildRun.childAgentId
1400
+ );
1401
+ }
1402
+
363
1403
  async execute(params: SubagentExecuteParams): Promise<SubagentExecuteResult> {
364
1404
  const { description, subagentType, threadId, parentToolCallId } = params;
365
1405
  /** Captured ONCE per execution, preferring the controller the parent
@@ -388,39 +1428,41 @@ export class SubagentExecutor {
388
1428
  };
389
1429
  }
390
1430
 
391
- const childAgentId =
392
- config.agentInputs.agentId ||
393
- `${this.parentAgentId ?? 'agent'}_sub_${nanoid(8)}`;
394
-
395
1431
  if (
396
- this.hookRegistry?.hasHookFor('SubagentStart', this.parentRunId) === true
1432
+ this.humanInTheLoop?.enabled === true &&
1433
+ (parentToolCallId == null || parentToolCallId === '')
397
1434
  ) {
398
- const hookResult = await executeHooks({
399
- registry: this.hookRegistry,
400
- input: {
401
- hook_event_name: 'SubagentStart',
402
- runId: this.parentRunId,
403
- threadId,
404
- parentAgentId: this.parentAgentId,
405
- agentId: childAgentId,
406
- agentType: subagentType,
407
- inputs: [new HumanMessage(description)],
408
- },
409
- sessionId: this.parentRunId,
410
- matchQuery: subagentType,
411
- }).catch((): AggregatedHookResult => HOOK_FALLBACK);
1435
+ return {
1436
+ content:
1437
+ 'Error: Resumable subagent execution requires a parent tool call ID.',
1438
+ messages: [],
1439
+ };
1440
+ }
412
1441
 
413
- /**
414
- * `ask` is treated identically to `deny` in the subagent context:
415
- * subagents are non-interactive, so there is no prompt path for `ask`.
416
- * Both decisions block execution and return a "Blocked" tool result.
417
- */
418
- if (hookResult.decision === 'deny' || hookResult.decision === 'ask') {
419
- return {
420
- content: `Blocked: ${hookResult.reason ?? 'Blocked by hook'}`,
421
- messages: [],
422
- };
423
- }
1442
+ const executionSuffix = parentToolCallId ?? nanoid(8);
1443
+ const {
1444
+ childRunId,
1445
+ childThreadId,
1446
+ approvalExecutionScope,
1447
+ resumeExecution,
1448
+ } = await this.resolveChildExecutionIdentity({
1449
+ threadId,
1450
+ parentToolCallId: executionSuffix,
1451
+ parentConfigurable: params.parentConfigurable,
1452
+ });
1453
+ this.childExecutionIdentities.set(executionSuffix, {
1454
+ childRunId,
1455
+ childThreadId,
1456
+ approvalExecutionScope,
1457
+ });
1458
+ const childExecutionKey = childThreadId;
1459
+ const childAgentId =
1460
+ config.agentInputs.agentId ||
1461
+ `${this.parentAgentId ?? 'agent'}_sub_${executionSuffix}`;
1462
+ const completedChildResult =
1463
+ this.completedChildResults.get(childExecutionKey);
1464
+ if (completedChildResult != null) {
1465
+ return completedChildResult;
424
1466
  }
425
1467
 
426
1468
  const parentRegistry = this.getParentHandlerRegistry();
@@ -442,42 +1484,43 @@ export class SubagentExecutor {
442
1484
  this.maxDepth,
443
1485
  /* keepToolDefinitions */ hasToolExecuteHandler
444
1486
  );
445
- const childRunId = `${this.parentRunId}_sub_${nanoid(8)}`;
446
1487
  const maxTurns = config.maxTurns ?? DEFAULT_MAX_TURNS;
447
1488
 
448
1489
  const hostUsageSink = this.usageSink;
449
- const childGraph = this.createChildGraph({
450
- runId: childRunId,
451
- signal: childSignal,
452
- agents: [childInputs],
453
- langfuse: this.langfuse,
454
- tokenCounter: this.tokenCounter,
455
- streamLimits: this.streamLimits,
456
- subagentScope: true,
457
- /**
458
- * Forwarded so the child graph's own `SubagentExecutor` (created in
459
- * its `createAgentNode` when `allowNested` keeps subagentConfigs)
460
- * reports nested-child usage through the same host sink. Each nesting
461
- * level attaches its own capture callback `workflow.invoke` replaces
462
- * the inherited callback chain, so a single top-level handler would
463
- * never see grandchild model calls.
464
- *
465
- * The wrapper rewrites `runId` to THIS executor's parent run: nested
466
- * executors emit with their own `parentRunId` (a `*_sub_*` child id),
467
- * and each wrapper layer rewrites upward, so by the time an event
468
- * reaches the host sink its `runId` is the ROOT run — hosts keying
469
- * billing by run id never see intermediate child run ids there
470
- * (`subagentRunId` still identifies the emitting child).
471
- */
472
- subagentUsageSink:
473
- hostUsageSink == null
474
- ? undefined
475
- : /** Returns the host sink's result so async sinks stay awaited
476
- * through every wrapper layer. */
477
- (event): void | Promise<void> =>
478
- hostUsageSink({ ...event, runId: this.parentRunId }),
479
- });
480
-
1490
+ const cachedChildRun = this.activeChildRuns.get(childExecutionKey);
1491
+ const childGraph =
1492
+ cachedChildRun?.graph ??
1493
+ this.createChildGraph({
1494
+ runId: childRunId,
1495
+ signal: childSignal,
1496
+ agents: [childInputs],
1497
+ langfuse: this.langfuse,
1498
+ tokenCounter: this.tokenCounter,
1499
+ streamLimits: this.streamLimits,
1500
+ subagentScope: true,
1501
+ /**
1502
+ * Forwarded so the child graph's own `SubagentExecutor` (created in
1503
+ * its `createAgentNode` when `allowNested` keeps subagentConfigs)
1504
+ * reports nested-child usage through the same host sink. Each nesting
1505
+ * level attaches its own capture callback — `workflow.invoke` replaces
1506
+ * the inherited callback chain, so a single top-level handler would
1507
+ * never see grandchild model calls.
1508
+ *
1509
+ * The wrapper rewrites `runId` to THIS executor's parent run: nested
1510
+ * executors emit with their own `parentRunId` (a `*_sub_*` child id),
1511
+ * and each wrapper layer rewrites upward, so by the time an event
1512
+ * reaches the host sink its `runId` is the ROOT run — hosts keying
1513
+ * billing by run id never see intermediate child run ids there
1514
+ * (`subagentRunId` still identifies the emitting child).
1515
+ */
1516
+ subagentUsageSink:
1517
+ hostUsageSink == null
1518
+ ? undefined
1519
+ : /** Returns the host sink's result so async sinks stay awaited
1520
+ * through every wrapper layer. */
1521
+ (event): void | Promise<void> =>
1522
+ hostUsageSink({ ...event, runId: this.parentRunId }),
1523
+ });
481
1524
  let forwarding: ForwarderCallback | undefined;
482
1525
  if (forwardingEnabled) {
483
1526
  forwarding = this.createForwarderCallback({
@@ -489,21 +1532,25 @@ export class SubagentExecutor {
489
1532
  });
490
1533
  }
491
1534
  const forwarder = forwarding?.handler;
1535
+ let childAlreadyStarted = this.startedChildRuns.has(childExecutionKey);
1536
+ let childAlreadyCompleted = this.completedChildRuns.has(childExecutionKey);
492
1537
 
493
- if (forwarder) {
494
- await this.emitSubagentUpdate(parentRegistry!, {
495
- childRunId,
496
- subagentType,
497
- subagentAgentId: childAgentId,
498
- parentToolCallId,
499
- phase: 'start',
500
- label: `Subagent "${subagentType}" started`,
501
- });
502
- }
503
-
504
- let result: { messages: BaseMessage[] };
1538
+ let result: { messages: BaseMessage[] } | undefined;
1539
+ let recoveredComplete = false;
1540
+ let recoveredInProgress = false;
505
1541
  try {
506
- const workflow = childGraph.createWorkflow();
1542
+ const workflow = (cachedChildRun?.workflow ??
1543
+ childGraph.createWorkflow()) as StatefulCompiledWorkflow;
1544
+ const activeChildRun = cachedChildRun ?? {
1545
+ graph: childGraph,
1546
+ workflow,
1547
+ pendingInterrupts: [],
1548
+ childAgentId,
1549
+ childRunId,
1550
+ };
1551
+ if (cachedChildRun == null) {
1552
+ this.activeChildRuns.set(childExecutionKey, activeChildRun);
1553
+ }
507
1554
  /**
508
1555
  * When `parentHandlerRegistry` is provided (forwarding mode), attach a
509
1556
  * lightweight callback that intercepts the child's `on_custom_event`
@@ -547,50 +1594,219 @@ export class SubagentExecutor {
547
1594
  }
548
1595
  const callbacks: Callbacks = callbackHandlers;
549
1596
  /**
550
- * Inherit the parent's host `configurable` host-set fields
551
- * (`requestBody`, `user`, `userMCPAuthMap`, etc.) AND the run-
552
- * identity fields (`run_id`, `parent_run_id`, `thread_id`) all
553
- * propagate. LangGraph's own runtime keys are excluded because the
554
- * child graph creates its own scratchpad/checkpoint/abort plumbing.
555
- *
556
- * Run-identity propagation is intentional and matches the
557
- * convention this executor itself already uses for `SubagentStart`
558
- * / `SubagentStop` hooks (`sessionId: this.parentRunId`): the
559
- * subagent runs under the parent's session scope, not its own.
560
- * Forwarding `run_id` / `parent_run_id` / `thread_id` makes
561
- * `ToolNode`'s hook lookups (`hasHookFor(eventName, runId)`),
562
- * `ToolOutputReferenceRegistry` keying, and trace lineage all
563
- * resolve to the parent's session for tools dispatched from the
564
- * subagent — so `PreToolUse` / `PostToolUse` hooks the host
565
- * registered against the parent's run fire for subagent tool
566
- * calls too. "Same run" matches the user-perceptual mental model.
1597
+ * Inherit the parent's host `configurable` while binding LangGraph's
1598
+ * checkpoint identity to a stable child id derived from the durable
1599
+ * parent thread and checkpoint fork. The parent thread id cannot be
1600
+ * reused here: parent and child share one checkpointer when nested HITL
1601
+ * is enabled, and root checkpoint namespaces are normalized by
1602
+ * LangGraph, so a shared `thread_id` would collide with the parent.
567
1603
  *
568
- * `thread_id` falls back to `childRunId` only when the parent
569
- * didn't supply one (legacy behavior preserved for hosts that
570
- * never set thread_id).
571
- *
572
- * NOTE: a future revision will likely make this configurable per
573
- * spawn type — e.g. a background / async subagent that runs after
574
- * the parent's run completes wants isolation, not inheritance.
575
- * For now the inheritance path matches LibreChat's primary use
576
- * case (synchronous subagents within a single user turn).
1604
+ * `run_id` still propagates as the parent run id, which is the key used
1605
+ * for session-scoped hook lookup. Child hook inputs therefore retain
1606
+ * the parent policy scope while their `threadId` truthfully identifies
1607
+ * the independently checkpointed child execution.
577
1608
  */
578
1609
  const inheritedConfigurable: Record<string, unknown> =
579
1610
  sanitizeChildConfigurable(params.parentConfigurable);
580
- result = await workflow.invoke(
581
- { messages: [new HumanMessage(description)] },
582
- {
583
- recursionLimit: maxTurns * RECURSION_MULTIPLIER,
584
- signal: childSignal,
585
- callbacks,
586
- runName: `subagent:${subagentType}`,
587
- configurable: {
588
- thread_id: childRunId,
589
- ...inheritedConfigurable,
590
- },
591
- }
1611
+ const resumeAttemptId = getResumeAttemptId(
1612
+ params.parentConfigurable,
1613
+ this.parentRunId
592
1614
  );
1615
+ const currentHookSessionId =
1616
+ typeof inheritedConfigurable.run_id === 'string' &&
1617
+ inheritedConfigurable.run_id.length > 0
1618
+ ? inheritedConfigurable.run_id
1619
+ : this.parentRunId;
1620
+ if (cachedChildRun == null && resumeExecution != null) {
1621
+ childGraph.restoreSubagentResumeState(
1622
+ resumeExecution.graphState,
1623
+ currentHookSessionId
1624
+ );
1625
+ }
1626
+ const childInvokeConfig = {
1627
+ recursionLimit: maxTurns * RECURSION_MULTIPLIER,
1628
+ signal: childSignal,
1629
+ callbacks,
1630
+ runName: `subagent:${subagentType}`,
1631
+ configurable: {
1632
+ ...inheritedConfigurable,
1633
+ ...(this.humanInTheLoop?.enabled === true
1634
+ ? {
1635
+ [TOOL_APPROVAL_EXECUTION_SCOPE_CONFIG_KEY]:
1636
+ approvalExecutionScope,
1637
+ }
1638
+ : {}),
1639
+ ...(resumeExecution?.descendant == null
1640
+ ? {}
1641
+ : {
1642
+ [SUBAGENT_RESUME_MANIFEST_CONFIG_KEY]:
1643
+ resumeExecution.descendant,
1644
+ [SUBAGENT_RESUME_ATTEMPT_CONFIG_KEY]: resumeAttemptId,
1645
+ }),
1646
+ thread_id:
1647
+ this.humanInTheLoop?.enabled === true
1648
+ ? childThreadId
1649
+ : (inheritedConfigurable.thread_id ?? childRunId),
1650
+ },
1651
+ };
1652
+ activeChildRun.invokeConfig = childInvokeConfig;
1653
+ if (cachedChildRun == null && this.humanInTheLoop?.enabled === true) {
1654
+ /** Rehydrate child-owned interrupt state when a host rebuilds Run
1655
+ * around the same durable checkpointer after a process boundary. */
1656
+ const persistedState = await workflow.getState(childInvokeConfig);
1657
+ const checkpointMessages = getCheckpointMessages(
1658
+ (persistedState.values as { messages?: unknown } | undefined)
1659
+ ?.messages
1660
+ );
1661
+ const persistedHookSessionId =
1662
+ getSubagentHookSessionId(checkpointMessages);
1663
+ if (persistedHookSessionId != null) {
1664
+ this.hookRegistry?.copySession(
1665
+ persistedHookSessionId,
1666
+ currentHookSessionId
1667
+ );
1668
+ }
1669
+ const persistedInterrupts = getPersistedInterrupts(persistedState);
1670
+ if (persistedInterrupts.length > 0) {
1671
+ activeChildRun.pendingInterrupts = persistedInterrupts;
1672
+ this.startedChildRuns.add(childExecutionKey);
1673
+ childAlreadyStarted = true;
1674
+ } else if (persistedState.next.length > 0) {
1675
+ recoveredInProgress = true;
1676
+ childAlreadyStarted = true;
1677
+ this.startedChildRuns.add(childExecutionKey);
1678
+ } else if (persistedState.next.length === 0) {
1679
+ const persistedMessages = getPersistedMessages(persistedState);
1680
+ if (persistedMessages != null) {
1681
+ const marker = getSubagentCheckpointMarker(
1682
+ checkpointMessages,
1683
+ executionSuffix
1684
+ );
1685
+ result = { messages: persistedMessages };
1686
+ recoveredComplete = true;
1687
+ childAlreadyStarted = true;
1688
+ childAlreadyCompleted = marker?.lifecycleComplete === true;
1689
+ this.startedChildRuns.add(childExecutionKey);
1690
+ }
1691
+ }
1692
+ }
1693
+ if (!recoveredComplete) {
1694
+ const childResumeMap = getChildResumeMap(
1695
+ activeChildRun.pendingInterrupts,
1696
+ params.parentConfigurable
1697
+ );
1698
+ if (
1699
+ activeChildRun.pendingInterrupts.length > 0 &&
1700
+ childResumeMap == null
1701
+ ) {
1702
+ throw new GraphInterrupt(activeChildRun.pendingInterrupts);
1703
+ }
1704
+ let childInput: BaseGraphState | Command | null;
1705
+ if (childResumeMap != null) {
1706
+ childInput = new Command({ resume: childResumeMap });
1707
+ } else if (recoveredInProgress) {
1708
+ childInput = null;
1709
+ } else {
1710
+ childInput = {
1711
+ messages: [
1712
+ new HumanMessage({
1713
+ content: description,
1714
+ additional_kwargs: {
1715
+ [SUBAGENT_HOOK_SESSION_KEY]: currentHookSessionId,
1716
+ [SUBAGENT_RUN_ID_KEY]: childRunId,
1717
+ },
1718
+ }),
1719
+ ],
1720
+ };
1721
+ }
1722
+
1723
+ if (
1724
+ !childAlreadyStarted &&
1725
+ this.hookRegistry?.hasHookFor('SubagentStart', this.parentRunId) ===
1726
+ true
1727
+ ) {
1728
+ const hookResult = await executeHooks({
1729
+ registry: this.hookRegistry,
1730
+ input: {
1731
+ hook_event_name: 'SubagentStart',
1732
+ runId: this.parentRunId,
1733
+ threadId,
1734
+ parentAgentId: this.parentAgentId,
1735
+ agentId: childAgentId,
1736
+ agentType: subagentType,
1737
+ inputs: [new HumanMessage(description)],
1738
+ },
1739
+ sessionId: this.parentRunId,
1740
+ matchQuery: subagentType,
1741
+ }).catch((): AggregatedHookResult => HOOK_FALLBACK);
1742
+
1743
+ if (hookResult.decision === 'deny' || hookResult.decision === 'ask') {
1744
+ this.clearChildGraph(childGraph);
1745
+ this.activeChildRuns.delete(childExecutionKey);
1746
+ return {
1747
+ content: `Blocked: ${hookResult.reason ?? 'Blocked by hook'}`,
1748
+ messages: [],
1749
+ };
1750
+ }
1751
+ }
1752
+ this.startedChildRuns.add(childExecutionKey);
1753
+
1754
+ if (forwarder && !childAlreadyStarted) {
1755
+ await this.emitSubagentUpdate(parentRegistry!, {
1756
+ childRunId,
1757
+ subagentType,
1758
+ subagentAgentId: childAgentId,
1759
+ parentToolCallId,
1760
+ phase: 'start',
1761
+ label: `Subagent "${subagentType}" started`,
1762
+ });
1763
+ }
1764
+
1765
+ let childResult: BaseGraphState;
1766
+ if (this.humanInTheLoop?.enabled === true) {
1767
+ /** Execute as an independently checkpointed root instead of inheriting
1768
+ * the parent's Pregel namespace. Parent decisions are routed explicitly
1769
+ * by interrupt id, so concurrent children keep isolated resume state. */
1770
+ childResult = await AsyncLocalStorageProviderSingleton.runWithConfig(
1771
+ childInvokeConfig,
1772
+ (): Promise<BaseGraphState> =>
1773
+ workflow.invoke(childInput, childInvokeConfig)
1774
+ );
1775
+ } else {
1776
+ childResult = await workflow.invoke(childInput, childInvokeConfig);
1777
+ }
1778
+ const childInterrupts = isInterrupted(childResult)
1779
+ ? childResult[INTERRUPT]
1780
+ : undefined;
1781
+ if (childInterrupts != null && childInterrupts.length > 0) {
1782
+ throw new GraphInterrupt(childInterrupts);
1783
+ }
1784
+ result = { messages: childResult.messages };
1785
+ }
593
1786
  } catch (error) {
1787
+ if (isGraphInterrupt(error)) {
1788
+ const activeChildRun = this.activeChildRuns.get(childExecutionKey);
1789
+ if (activeChildRun != null) {
1790
+ activeChildRun.pendingInterrupts = error.interrupts;
1791
+ }
1792
+ const resumeManifest =
1793
+ activeChildRun == null || parentToolCallId == null
1794
+ ? undefined
1795
+ : await this.createResumeManifest();
1796
+ await forwarding?.drain();
1797
+ throw new GraphInterrupt(
1798
+ addSubagentScope(
1799
+ error.interrupts,
1800
+ {
1801
+ run_id: childRunId,
1802
+ agent_id: childAgentId,
1803
+ subagent_type: subagentType,
1804
+ parent_tool_call_id: parentToolCallId,
1805
+ },
1806
+ resumeManifest
1807
+ )
1808
+ );
1809
+ }
594
1810
  /** Aborted before any observational work below: parallel siblings — in
595
1811
  * this executor and, via the graph-scoped breaker, under other
596
1812
  * parallel agent nodes — stream on the composed child signal, and
@@ -614,7 +1830,8 @@ export class SubagentExecutor {
614
1830
  data: { message: errorMessage },
615
1831
  });
616
1832
  }
617
- childGraph.clearHeavyState();
1833
+ this.clearChildGraph(childGraph);
1834
+ this.activeChildRuns.delete(childExecutionKey);
618
1835
  /**
619
1836
  * A tripped stream circuit breaker is a safety abort, not a recoverable
620
1837
  * subagent failure: converting it into a tool result would let the
@@ -631,9 +1848,13 @@ export class SubagentExecutor {
631
1848
  };
632
1849
  }
633
1850
 
1851
+ if (result == null) {
1852
+ throw new Error('Subagent completed without producing graph state.');
1853
+ }
634
1854
  const filteredContent = filterSubagentResult(result.messages);
635
1855
 
636
1856
  if (
1857
+ !childAlreadyCompleted &&
637
1858
  this.hookRegistry?.hasHookFor('SubagentStop', this.parentRunId) === true
638
1859
  ) {
639
1860
  /**
@@ -659,7 +1880,7 @@ export class SubagentExecutor {
659
1880
  });
660
1881
  }
661
1882
 
662
- if (forwarding) {
1883
+ if (forwarding && !childAlreadyCompleted) {
663
1884
  await forwarding.drain();
664
1885
  await this.emitSubagentUpdate(parentRegistry!, {
665
1886
  childRunId,
@@ -670,10 +1891,25 @@ export class SubagentExecutor {
670
1891
  label: `Subagent "${subagentType}" finished`,
671
1892
  });
672
1893
  }
1894
+ if (!childAlreadyCompleted) {
1895
+ const activeChildRun = this.activeChildRuns.get(childExecutionKey);
1896
+ if (activeChildRun != null && parentToolCallId != null) {
1897
+ await this.persistChildCheckpointMarker(
1898
+ activeChildRun,
1899
+ parentToolCallId
1900
+ );
1901
+ }
1902
+ }
1903
+ this.completedChildRuns.add(childExecutionKey);
673
1904
 
674
- childGraph.clearHeavyState();
1905
+ this.clearChildGraph(childGraph);
675
1906
 
676
- return { content: filteredContent, messages: result.messages };
1907
+ const completedResult = {
1908
+ content: filteredContent,
1909
+ messages: result.messages,
1910
+ };
1911
+ this.completedChildResults.set(childExecutionKey, completedResult);
1912
+ return completedResult;
677
1913
  }
678
1914
 
679
1915
  /**
@@ -1057,6 +2293,9 @@ function isLangGraphRuntimeConfigKey(key: string): boolean {
1057
2293
  return (
1058
2294
  key.startsWith(LANGGRAPH_RUNTIME_CONFIG_PREFIX) ||
1059
2295
  LANGGRAPH_CHECKPOINT_CONFIG_KEYS.has(key) ||
2296
+ key === SUBAGENT_RESUME_ATTEMPT_CONFIG_KEY ||
2297
+ key === SUBAGENT_RESUME_MANIFEST_CONFIG_KEY ||
2298
+ key === SUBAGENT_PARENT_BATCH_CONFIG_KEY ||
1060
2299
  /** The parent batch's breaker scope must not leak into the child
1061
2300
  * workflow's configurable — children own separate controllers. */
1062
2301
  key === RUN_BREAKER_SCOPE_CONFIG_KEY
@@ -1587,11 +2826,10 @@ export function buildChildInputs(
1587
2826
  * Host-supplied direct tools are scrubbed from INHERITED configs only.
1588
2827
  * A self-spawn config's `agentInputs` is a shallow spread of the parent's
1589
2828
  * `_sourceInputs`, so without this a parent-scoped graph tool (e.g. an
1590
- * interrupt-raising ask_user_question, which needs the parent's
1591
- * checkpointer child graphs compile without one) would silently leak
1592
- * into the child and deterministically throw `No checkpointer set`. An
1593
- * EXPLICIT child config that lists its own `graphTools` is a deliberate
1594
- * host choice and keeps them (Codex #289 P2).
2829
+ * interrupt-raising ask_user_question) would silently become available to
2830
+ * the child. An EXPLICIT child config that lists its own `graphTools` is a
2831
+ * deliberate host choice and keeps them (Codex #289 P2); with HITL enabled,
2832
+ * those tools use the shared checkpointer and can pause and resume safely.
1595
2833
  */
1596
2834
  graphTools: config.self === true ? undefined : agentInputs.graphTools,
1597
2835
  };