@librechat/agents 3.3.13 → 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 (192) hide show
  1. package/dist/cjs/graphs/Graph.cjs +121 -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/anthropic/index.cjs +35 -206
  11. package/dist/cjs/llm/anthropic/index.cjs.map +1 -1
  12. package/dist/cjs/llm/bedrock/index.cjs +121 -241
  13. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  14. package/dist/cjs/llm/google/index.cjs +19 -12
  15. package/dist/cjs/llm/google/index.cjs.map +1 -1
  16. package/dist/cjs/llm/init.cjs +3 -3
  17. package/dist/cjs/llm/invoke.cjs +2 -2
  18. package/dist/cjs/llm/mistral/index.cjs +26 -0
  19. package/dist/cjs/llm/mistral/index.cjs.map +1 -0
  20. package/dist/cjs/llm/openai/index.cjs +82 -80
  21. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  22. package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
  23. package/dist/cjs/llm/providers.cjs +19 -19
  24. package/dist/cjs/llm/providers.cjs.map +1 -1
  25. package/dist/cjs/llm/stream/chunkAdapters.cjs +198 -0
  26. package/dist/cjs/llm/stream/chunkAdapters.cjs.map +1 -0
  27. package/dist/cjs/llm/stream/smoother.cjs +369 -0
  28. package/dist/cjs/llm/stream/smoother.cjs.map +1 -0
  29. package/dist/cjs/llm/vertexai/index.cjs +13 -1
  30. package/dist/cjs/llm/vertexai/index.cjs.map +1 -1
  31. package/dist/cjs/main.cjs +25 -16
  32. package/dist/cjs/messages/format.cjs +40 -23
  33. package/dist/cjs/messages/format.cjs.map +1 -1
  34. package/dist/cjs/run.cjs +82 -15
  35. package/dist/cjs/run.cjs.map +1 -1
  36. package/dist/cjs/session/AgentSession.cjs +37 -2
  37. package/dist/cjs/session/AgentSession.cjs.map +1 -1
  38. package/dist/cjs/stream.cjs +1 -1
  39. package/dist/cjs/summarization/node.cjs +6 -3
  40. package/dist/cjs/summarization/node.cjs.map +1 -1
  41. package/dist/cjs/tools/BashExecutor.cjs +1 -1
  42. package/dist/cjs/tools/CodeExecutor.cjs +1 -1
  43. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +1 -1
  44. package/dist/cjs/tools/ToolNode.cjs +316 -94
  45. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  46. package/dist/cjs/tools/ToolSearch.cjs +1 -1
  47. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +664 -80
  48. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  49. package/dist/cjs/tools/subagent/SubagentReplay.cjs +175 -0
  50. package/dist/cjs/tools/subagent/SubagentReplay.cjs.map +1 -0
  51. package/dist/cjs/tools/toolOutputReferences.cjs +20 -0
  52. package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
  53. package/dist/cjs/utils/index.cjs +2 -2
  54. package/dist/cjs/utils/tokens.cjs +76 -29
  55. package/dist/cjs/utils/tokens.cjs.map +1 -1
  56. package/dist/esm/graphs/Graph.mjs +121 -27
  57. package/dist/esm/graphs/Graph.mjs.map +1 -1
  58. package/dist/esm/hooks/HookRegistry.mjs +83 -0
  59. package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
  60. package/dist/esm/hooks/executeHooks.mjs +60 -14
  61. package/dist/esm/hooks/executeHooks.mjs.map +1 -1
  62. package/dist/esm/hooks/index.mjs.map +1 -1
  63. package/dist/esm/hooks/types.mjs +2 -1
  64. package/dist/esm/hooks/types.mjs.map +1 -1
  65. package/dist/esm/llm/anthropic/index.mjs +34 -205
  66. package/dist/esm/llm/anthropic/index.mjs.map +1 -1
  67. package/dist/esm/llm/bedrock/index.mjs +120 -240
  68. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  69. package/dist/esm/llm/google/index.mjs +19 -12
  70. package/dist/esm/llm/google/index.mjs.map +1 -1
  71. package/dist/esm/llm/init.mjs +1 -1
  72. package/dist/esm/llm/invoke.mjs +2 -2
  73. package/dist/esm/llm/mistral/index.mjs +26 -0
  74. package/dist/esm/llm/mistral/index.mjs.map +1 -0
  75. package/dist/esm/llm/openai/index.mjs +82 -80
  76. package/dist/esm/llm/openai/index.mjs.map +1 -1
  77. package/dist/esm/llm/openrouter/index.mjs.map +1 -1
  78. package/dist/esm/llm/providers.mjs +3 -3
  79. package/dist/esm/llm/providers.mjs.map +1 -1
  80. package/dist/esm/llm/stream/chunkAdapters.mjs +195 -0
  81. package/dist/esm/llm/stream/chunkAdapters.mjs.map +1 -0
  82. package/dist/esm/llm/stream/smoother.mjs +365 -0
  83. package/dist/esm/llm/stream/smoother.mjs.map +1 -0
  84. package/dist/esm/llm/vertexai/index.mjs +13 -1
  85. package/dist/esm/llm/vertexai/index.mjs.map +1 -1
  86. package/dist/esm/main.mjs +13 -11
  87. package/dist/esm/messages/format.mjs +40 -23
  88. package/dist/esm/messages/format.mjs.map +1 -1
  89. package/dist/esm/run.mjs +83 -16
  90. package/dist/esm/run.mjs.map +1 -1
  91. package/dist/esm/session/AgentSession.mjs +37 -2
  92. package/dist/esm/session/AgentSession.mjs.map +1 -1
  93. package/dist/esm/stream.mjs +1 -1
  94. package/dist/esm/summarization/node.mjs +6 -3
  95. package/dist/esm/summarization/node.mjs.map +1 -1
  96. package/dist/esm/tools/BashExecutor.mjs +1 -1
  97. package/dist/esm/tools/CodeExecutor.mjs +1 -1
  98. package/dist/esm/tools/ProgrammaticToolCalling.mjs +1 -1
  99. package/dist/esm/tools/ToolNode.mjs +317 -95
  100. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  101. package/dist/esm/tools/ToolSearch.mjs +1 -1
  102. package/dist/esm/tools/subagent/SubagentExecutor.mjs +665 -81
  103. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  104. package/dist/esm/tools/subagent/SubagentReplay.mjs +168 -0
  105. package/dist/esm/tools/subagent/SubagentReplay.mjs.map +1 -0
  106. package/dist/esm/tools/toolOutputReferences.mjs +20 -0
  107. package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
  108. package/dist/esm/utils/index.mjs +2 -2
  109. package/dist/esm/utils/tokens.mjs +76 -30
  110. package/dist/esm/utils/tokens.mjs.map +1 -1
  111. package/dist/types/graphs/Graph.d.ts +19 -1
  112. package/dist/types/hooks/HookRegistry.d.ts +10 -1
  113. package/dist/types/hooks/executeHooks.d.ts +5 -1
  114. package/dist/types/hooks/index.d.ts +2 -2
  115. package/dist/types/hooks/types.d.ts +12 -0
  116. package/dist/types/index.d.ts +3 -0
  117. package/dist/types/llm/google/index.d.ts +2 -0
  118. package/dist/types/llm/mistral/index.d.ts +11 -0
  119. package/dist/types/llm/openai/index.d.ts +20 -4
  120. package/dist/types/llm/openrouter/index.d.ts +4 -1
  121. package/dist/types/llm/stream/chunkAdapters.d.ts +48 -0
  122. package/dist/types/llm/stream/smoother.d.ts +95 -0
  123. package/dist/types/llm/vertexai/index.d.ts +2 -0
  124. package/dist/types/run.d.ts +4 -1
  125. package/dist/types/session/AgentSession.d.ts +1 -0
  126. package/dist/types/tools/ToolNode.d.ts +23 -15
  127. package/dist/types/tools/subagent/SubagentExecutor.d.ts +49 -7
  128. package/dist/types/tools/subagent/SubagentReplay.d.ts +81 -0
  129. package/dist/types/tools/toolOutputReferences.d.ts +12 -0
  130. package/dist/types/types/graph.d.ts +5 -5
  131. package/dist/types/types/hitl.d.ts +15 -0
  132. package/dist/types/types/llm.d.ts +21 -14
  133. package/dist/types/utils/tokens.d.ts +10 -0
  134. package/package.json +6 -2
  135. package/src/graphs/Graph.ts +257 -52
  136. package/src/graphs/__tests__/Graph.breakerLifecycle.test.ts +48 -9
  137. package/src/graphs/__tests__/Graph.contextOverflow.test.ts +1 -1
  138. package/src/graphs/__tests__/Graph.subagentResumeState.test.ts +80 -0
  139. package/src/hooks/HookRegistry.ts +151 -1
  140. package/src/hooks/__tests__/HookRegistry.test.ts +96 -0
  141. package/src/hooks/__tests__/executeHooks.test.ts +160 -0
  142. package/src/hooks/executeHooks.ts +133 -15
  143. package/src/hooks/index.ts +3 -1
  144. package/src/hooks/types.ts +16 -0
  145. package/src/index.ts +8 -0
  146. package/src/llm/anthropic/index.ts +85 -354
  147. package/src/llm/bedrock/index.ts +174 -390
  148. package/src/llm/bedrock/llm.spec.ts +2 -0
  149. package/src/llm/bedrock/streamSealDispatch.test.ts +76 -0
  150. package/src/llm/custom-chat-models.smoke.test.ts +16 -1
  151. package/src/llm/google/index.ts +17 -18
  152. package/src/llm/google/streamSmoothing.test.ts +121 -0
  153. package/src/llm/mistral/index.ts +33 -0
  154. package/src/llm/mistral/streamSmoothing.test.ts +97 -0
  155. package/src/llm/openai/deepseek.test.ts +56 -0
  156. package/src/llm/openai/index.ts +119 -126
  157. package/src/llm/openrouter/index.ts +4 -1
  158. package/src/llm/providers.ts +3 -3
  159. package/src/llm/stream/chunkAdapters.test.ts +202 -0
  160. package/src/llm/stream/chunkAdapters.ts +317 -0
  161. package/src/llm/stream/reassembly.test.ts +241 -0
  162. package/src/llm/stream/smoother.bench.test.ts +155 -0
  163. package/src/llm/stream/smoother.test.ts +519 -0
  164. package/src/llm/stream/smoother.ts +574 -0
  165. package/src/llm/vertexai/index.ts +17 -1
  166. package/src/llm/vertexai/streamSmoothing.test.ts +109 -0
  167. package/src/messages/format.ts +54 -26
  168. package/src/messages/formatAgentMessages.reducer.test.ts +162 -0
  169. package/src/messages/formatAgentMessages.steer.test.ts +16 -11
  170. package/src/messages/formatAgentMessages.test.ts +7 -2
  171. package/src/run.ts +164 -18
  172. package/src/session/AgentSession.ts +52 -2
  173. package/src/session/__tests__/JsonlSessionStore.test.ts +53 -0
  174. package/src/specs/subagent.test.ts +44 -0
  175. package/src/specs/tokens.test.ts +159 -14
  176. package/src/summarization/__tests__/node.test.ts +32 -14
  177. package/src/summarization/node.ts +15 -11
  178. package/src/tools/ToolNode.ts +631 -157
  179. package/src/tools/__tests__/SubagentExecutor.test.ts +590 -7
  180. package/src/tools/__tests__/SubagentReplay.test.ts +300 -0
  181. package/src/tools/__tests__/ToolNode.breakerSignal.test.ts +208 -2
  182. package/src/tools/__tests__/directToolHITLResumeScope.test.ts +486 -2
  183. package/src/tools/__tests__/hitl.test.ts +234 -5
  184. package/src/tools/__tests__/subagentHooks.test.ts +1040 -62
  185. package/src/tools/__tests__/toolOutputReferences.test.ts +19 -2
  186. package/src/tools/subagent/SubagentExecutor.ts +1438 -136
  187. package/src/tools/subagent/SubagentReplay.ts +575 -0
  188. package/src/tools/toolOutputReferences.ts +40 -1
  189. package/src/types/graph.ts +5 -5
  190. package/src/types/hitl.ts +16 -0
  191. package/src/types/llm.ts +53 -36
  192. package/src/utils/tokens.ts +115 -30
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect, beforeEach } from '@jest/globals';
2
+ import { GraphInterrupt, MemorySaver } from '@langchain/langgraph';
2
3
  import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
3
4
  import type { BaseMessage } from '@langchain/core/messages';
4
5
  import type {
@@ -19,6 +20,7 @@ import {
19
20
  summarizeEvent,
20
21
  } from '../subagent';
21
22
  import { sanitizeForwardedSubagentUpdateData } from '../subagent/SubagentExecutor';
23
+ import { SUBAGENT_PARENT_BATCH_CONFIG_KEY } from '../subagent/SubagentReplay';
22
24
  import { Constants, Providers, GraphEvents, StepTypes } from '@/common';
23
25
  import { StreamLimitExceededError } from '@/llm/streamLimits';
24
26
  import { AgentContext } from '@/agents/AgentContext';
@@ -67,6 +69,50 @@ describe('filterSubagentResult', () => {
67
69
  expect(filterSubagentResult(messages)).toBe('First part.\nSecond part.');
68
70
  });
69
71
 
72
+ it('prefers final text_delta blocks over earlier AI text', () => {
73
+ const messages: BaseMessage[] = [
74
+ new AIMessage({
75
+ content: [
76
+ { type: 'text', text: 'Let me search.' },
77
+ { type: 'tool_use', id: 'call_1', name: 'search', input: {} },
78
+ ],
79
+ }),
80
+ new ToolMessage({ content: 'result', tool_call_id: 'call_1' }),
81
+ new AIMessage({
82
+ content: [
83
+ { type: 'text_delta', index: 0, text: 'Streamed ' },
84
+ { type: 'text_delta', index: 0, text: 'result.' },
85
+ ],
86
+ }),
87
+ ];
88
+ expect(filterSubagentResult(messages)).toBe('Streamed result.');
89
+ });
90
+
91
+ it('keeps annotation-only text blocks within a text_delta sequence', () => {
92
+ const messages: BaseMessage[] = [
93
+ new AIMessage({
94
+ content: [
95
+ { type: 'text_delta', index: 0, text: 'Cited ' },
96
+ { type: 'text', index: 0, citations: [{ url: 'source' }] },
97
+ { type: 'text_delta', index: 0, text: 'answer.' },
98
+ ],
99
+ }),
100
+ ];
101
+ expect(filterSubagentResult(messages)).toBe('Cited answer.');
102
+ });
103
+
104
+ it('separates text_delta blocks with different indexes', () => {
105
+ const messages: BaseMessage[] = [
106
+ new AIMessage({
107
+ content: [
108
+ { type: 'text_delta', index: 0, text: 'First.' },
109
+ { type: 'text_delta', index: 1, text: 'Second.' },
110
+ ],
111
+ }),
112
+ ];
113
+ expect(filterSubagentResult(messages)).toBe('First.\nSecond.');
114
+ });
115
+
70
116
  it('strips tool_use blocks from array content', () => {
71
117
  const messages: BaseMessage[] = [
72
118
  new AIMessage({
@@ -305,8 +351,7 @@ describe('buildChildInputs', () => {
305
351
  /**
306
352
  * Self-spawn: `resolveSubagentConfigs` fills `agentInputs` as a shallow
307
353
  * spread of the parent's `_sourceInputs`, so the parent-scoped direct
308
- * tool would leak into a checkpointer-less child graph and fail with
309
- * `No checkpointer set` — must be scrubbed.
354
+ * tool would leak into the child implicitly it must be scrubbed.
310
355
  */
311
356
  const selfConfig: ResolvedSubagentConfig = {
312
357
  type: 'self',
@@ -321,9 +366,8 @@ describe('buildChildInputs', () => {
321
366
 
322
367
  /**
323
368
  * Explicit child config: a host that deliberately attaches its own
324
- * in-process direct tools to a child keeps them (Codex #289 P2
325
- * interrupt-capable tools still fail in children, but non-interrupting
326
- * direct tools are legitimate there).
369
+ * in-process direct tools to a child keeps them (Codex #289 P2). With
370
+ * HITL enabled, interrupt-capable child tools use the shared checkpointer.
327
371
  */
328
372
  const explicitConfig: ResolvedSubagentConfig = {
329
373
  type: 'researcher',
@@ -452,6 +496,232 @@ describe('SubagentExecutor', () => {
452
496
  expect(result.messages).toEqual([]);
453
497
  });
454
498
 
499
+ it('fails closed when resumable execution has no parent tool call ID', async () => {
500
+ const createChildGraph = jest.fn(makeNoopGraphFactory());
501
+ const executor = createExecutor({
502
+ humanInTheLoop: { enabled: true },
503
+ createChildGraph,
504
+ });
505
+
506
+ const result = await executor.execute({
507
+ description: 'Do something',
508
+ subagentType: 'researcher',
509
+ threadId: 'durable-thread',
510
+ });
511
+
512
+ expect(result.content).toContain('requires a parent tool call ID');
513
+ expect(result.messages).toEqual([]);
514
+ expect(createChildGraph).not.toHaveBeenCalled();
515
+ });
516
+
517
+ it('persists terminal replay outcomes without an active child workflow', async () => {
518
+ const checkpointer = new MemorySaver();
519
+ const hookRegistry = new HookRegistry();
520
+ hookRegistry.registerSession('original-run', 'PreToolUse', {
521
+ hooks: [async (): Promise<Record<string, never>> => ({})],
522
+ });
523
+ const initialExecutor = createExecutor({
524
+ checkpointer,
525
+ hookRegistry,
526
+ humanInTheLoop: { enabled: true },
527
+ parentRunId: 'original-run',
528
+ });
529
+ const call = {
530
+ id: 'call_terminal',
531
+ name: Constants.SUBAGENT,
532
+ args: {
533
+ description: 'blocked before execution',
534
+ subagent_type: 'researcher',
535
+ },
536
+ type: 'tool_call' as const,
537
+ };
538
+ const originalConfig = {
539
+ configurable: {
540
+ thread_id: 'durable-parent',
541
+ checkpoint_id: 'parent-fork',
542
+ run_id: 'original-run',
543
+ },
544
+ };
545
+ const settled = {
546
+ output: new ToolMessage({
547
+ content: 'Blocked: policy',
548
+ status: 'error' as const,
549
+ name: Constants.SUBAGENT,
550
+ tool_call_id: call.id,
551
+ }),
552
+ additionalContexts: ['persisted context'],
553
+ resolvedArgs: { description: 'rewritten' },
554
+ };
555
+
556
+ await initialExecutor.persistSettledToolOutput(
557
+ call,
558
+ originalConfig,
559
+ settled
560
+ );
561
+
562
+ const rebuiltExecutor = createExecutor({
563
+ checkpointer,
564
+ hookRegistry,
565
+ humanInTheLoop: { enabled: true },
566
+ parentRunId: 'rebuilt-run',
567
+ });
568
+ const restored = await rebuiltExecutor.getSettledToolOutput(call, {
569
+ configurable: {
570
+ ...originalConfig.configurable,
571
+ run_id: 'rebuilt-run',
572
+ },
573
+ });
574
+
575
+ expect(restored?.output.content).toBe('Blocked: policy');
576
+ expect(restored?.additionalContexts).toEqual(['persisted context']);
577
+ expect(restored?.resolvedArgs).toEqual({ description: 'rewritten' });
578
+ expect(hookRegistry.hasHookFor('PreToolUse', 'original-run')).toBe(true);
579
+ expect(hookRegistry.hasHookFor('PreToolUse', 'rebuilt-run')).toBe(true);
580
+ });
581
+
582
+ it('persists an ordinary child error after the active workflow is released', async () => {
583
+ const checkpointer = new MemorySaver();
584
+ const invoke = jest.fn().mockRejectedValue(new Error('child failed'));
585
+ const executor = createExecutor({
586
+ checkpointer,
587
+ humanInTheLoop: { enabled: true },
588
+ createChildGraph: (): StandardGraph =>
589
+ ({
590
+ createWorkflow: () => ({
591
+ getState: jest.fn().mockResolvedValue({
592
+ values: {},
593
+ next: ['child-agent'],
594
+ tasks: [],
595
+ }),
596
+ invoke,
597
+ }),
598
+ clearHeavyState: jest.fn(),
599
+ }) as unknown as StandardGraph,
600
+ });
601
+ const call = {
602
+ id: 'call_error',
603
+ name: Constants.SUBAGENT,
604
+ args: { description: 'fail', subagent_type: 'researcher' },
605
+ type: 'tool_call' as const,
606
+ };
607
+ const runnableConfig = {
608
+ configurable: {
609
+ thread_id: 'durable-parent',
610
+ checkpoint_id: 'parent-fork',
611
+ run_id: 'original-run',
612
+ },
613
+ };
614
+ const result = await executor.execute({
615
+ description: 'fail',
616
+ subagentType: 'researcher',
617
+ threadId: 'durable-parent',
618
+ parentToolCallId: call.id,
619
+ parentConfigurable: runnableConfig.configurable,
620
+ });
621
+ expect(result.content).toBe('Subagent error: child failed');
622
+
623
+ await executor.persistSettledToolOutput(call, runnableConfig, {
624
+ output: new ToolMessage({
625
+ content: result.content,
626
+ name: Constants.SUBAGENT,
627
+ tool_call_id: call.id,
628
+ }),
629
+ additionalContexts: [],
630
+ });
631
+
632
+ const restored = await createExecutor({
633
+ checkpointer,
634
+ humanInTheLoop: { enabled: true },
635
+ }).getSettledToolOutput(call, runnableConfig);
636
+ expect(invoke).toHaveBeenCalledTimes(1);
637
+ expect(restored?.output.content).toBe('Subagent error: child failed');
638
+ });
639
+
640
+ it('continues a recovered in-progress child without reseeding its task', async () => {
641
+ const invoke = jest.fn().mockResolvedValue({
642
+ messages: [new AIMessage('continued')],
643
+ });
644
+ const executor = createExecutor({
645
+ checkpointer: new MemorySaver(),
646
+ humanInTheLoop: { enabled: true },
647
+ createChildGraph: (): StandardGraph =>
648
+ ({
649
+ createWorkflow: () => ({
650
+ getState: jest.fn().mockResolvedValue({
651
+ values: {},
652
+ next: ['child-agent'],
653
+ tasks: [],
654
+ }),
655
+ invoke,
656
+ }),
657
+ clearHeavyState: jest.fn(),
658
+ }) as unknown as StandardGraph,
659
+ });
660
+
661
+ const result = await executor.execute({
662
+ description: 'Do not duplicate this task',
663
+ subagentType: 'researcher',
664
+ threadId: 'durable-parent',
665
+ parentToolCallId: 'call_in_progress',
666
+ });
667
+
668
+ expect(result.content).toBe('continued');
669
+ expect(invoke).toHaveBeenCalledWith(null, expect.any(Object));
670
+ });
671
+
672
+ it('preserves an existing nested approval scope', async () => {
673
+ const nestedScope = {
674
+ run_id: 'grandchild-run',
675
+ agent_id: 'grandchild-agent',
676
+ subagent_type: 'grandchild',
677
+ parent_tool_call_id: 'call_grandchild',
678
+ };
679
+ const nestedInterrupt = new GraphInterrupt([
680
+ {
681
+ id: 'nested-interrupt',
682
+ value: {
683
+ type: 'tool_approval',
684
+ action_requests: [],
685
+ review_configs: [],
686
+ subagent: nestedScope,
687
+ },
688
+ },
689
+ ]);
690
+ const executor = createExecutor({
691
+ humanInTheLoop: { enabled: true },
692
+ createChildGraph: (): StandardGraph =>
693
+ ({
694
+ createWorkflow: () => ({
695
+ getState: jest.fn().mockResolvedValue({
696
+ values: {},
697
+ next: ['agent'],
698
+ tasks: [],
699
+ }),
700
+ invoke: jest.fn().mockRejectedValue(nestedInterrupt),
701
+ }),
702
+ clearHeavyState: jest.fn(),
703
+ }) as unknown as StandardGraph,
704
+ });
705
+
706
+ let received: GraphInterrupt | undefined;
707
+ try {
708
+ await executor.execute({
709
+ description: 'Delegate again',
710
+ subagentType: 'researcher',
711
+ threadId: 'durable-thread',
712
+ parentToolCallId: 'call_child',
713
+ });
714
+ } catch (error) {
715
+ if (error instanceof GraphInterrupt) {
716
+ received = error;
717
+ }
718
+ }
719
+
720
+ expect(received?.interrupts[0].value).toMatchObject({
721
+ subagent: nestedScope,
722
+ });
723
+ });
724
+
455
725
  it('rethrows a child stream-limit trip instead of converting it to a tool result', async () => {
456
726
  const executor = createExecutor({
457
727
  createChildGraph: (): StandardGraph =>
@@ -470,7 +740,10 @@ describe('SubagentExecutor', () => {
470
740
  }) as unknown as StandardGraph,
471
741
  });
472
742
  await expect(
473
- executor.execute({ description: 'Do something', subagentType: 'researcher' })
743
+ executor.execute({
744
+ description: 'Do something',
745
+ subagentType: 'researcher',
746
+ })
474
747
  ).rejects.toBeInstanceOf(StreamLimitExceededError);
475
748
  });
476
749
 
@@ -1346,6 +1619,7 @@ describe('SubagentExecutor', () => {
1346
1619
  checkpoint_id: 'parent-checkpoint-id',
1347
1620
  checkpoint_map: { parent: 'checkpoint' },
1348
1621
  checkpoint_ns: 'parent-checkpoint-ns',
1622
+ [SUBAGENT_PARENT_BATCH_CONFIG_KEY]: 'assistant-batch',
1349
1623
  requestBody: { messageId: 'msg-1' },
1350
1624
  thread_id: 'parent-thread',
1351
1625
  user: { id: 'user_abc' },
@@ -1362,11 +1636,260 @@ describe('SubagentExecutor', () => {
1362
1636
  expect(configurable.checkpoint_id).toBeUndefined();
1363
1637
  expect(configurable.checkpoint_map).toBeUndefined();
1364
1638
  expect(configurable.checkpoint_ns).toBeUndefined();
1639
+ expect(configurable[SUBAGENT_PARENT_BATCH_CONFIG_KEY]).toBeUndefined();
1365
1640
  expect(configurable.requestBody).toEqual({ messageId: 'msg-1' });
1366
1641
  expect(configurable.thread_id).toBe('parent-thread');
1367
1642
  expect(configurable.user).toEqual({ id: 'user_abc' });
1368
1643
  });
1369
1644
 
1645
+ it('isolates child checkpoints created from different parent forks', async () => {
1646
+ const invokedConfigs: Record<string, unknown>[] = [];
1647
+ const invoke = jest
1648
+ .fn()
1649
+ .mockImplementation(
1650
+ async (
1651
+ _input: unknown,
1652
+ config: Record<string, unknown>
1653
+ ): Promise<{ messages: BaseMessage[] }> => {
1654
+ invokedConfigs.push(config);
1655
+ return { messages: [new AIMessage('done')] };
1656
+ }
1657
+ );
1658
+ const executor = createExecutor({
1659
+ humanInTheLoop: { enabled: true },
1660
+ createChildGraph: (): StandardGraph =>
1661
+ ({
1662
+ createWorkflow: () => ({
1663
+ getState: jest.fn().mockResolvedValue({
1664
+ values: {},
1665
+ next: [],
1666
+ tasks: [],
1667
+ }),
1668
+ invoke,
1669
+ }),
1670
+ clearHeavyState: jest.fn(),
1671
+ }) as unknown as StandardGraph,
1672
+ });
1673
+ const common = {
1674
+ description: 'task',
1675
+ subagentType: 'researcher',
1676
+ threadId: 'parent-thread',
1677
+ parentToolCallId: 'call_shared',
1678
+ };
1679
+
1680
+ await executor.execute({
1681
+ ...common,
1682
+ parentConfigurable: {
1683
+ thread_id: 'parent-thread',
1684
+ checkpoint_id: 'fork-a',
1685
+ },
1686
+ });
1687
+ await executor.execute({
1688
+ ...common,
1689
+ parentConfigurable: {
1690
+ thread_id: 'parent-thread',
1691
+ checkpoint_id: 'fork-b',
1692
+ },
1693
+ });
1694
+
1695
+ expect(invoke).toHaveBeenCalledTimes(2);
1696
+ const childThreadIds = invokedConfigs.map(
1697
+ (config) =>
1698
+ (config.configurable as Record<string, unknown>).thread_id as string
1699
+ );
1700
+ expect(childThreadIds[0]).toMatch(/^subagent:/);
1701
+ expect(childThreadIds[1]).toMatch(/^subagent:/);
1702
+ expect(childThreadIds[0]).not.toBe(childThreadIds[1]);
1703
+ });
1704
+
1705
+ it('isolates reused tool-call IDs across assistant batches', async () => {
1706
+ const invokedThreadIds: string[] = [];
1707
+ const executor = createExecutor({
1708
+ humanInTheLoop: { enabled: true },
1709
+ createChildGraph: (): StandardGraph =>
1710
+ ({
1711
+ createWorkflow: () => ({
1712
+ getState: jest.fn().mockResolvedValue({
1713
+ values: {},
1714
+ next: [],
1715
+ tasks: [],
1716
+ }),
1717
+ invoke: jest
1718
+ .fn()
1719
+ .mockImplementation(
1720
+ async (
1721
+ _input: unknown,
1722
+ invokeConfig: Record<string, unknown>
1723
+ ): Promise<{ messages: BaseMessage[] }> => {
1724
+ invokedThreadIds.push(
1725
+ (invokeConfig.configurable as Record<string, unknown>)
1726
+ .thread_id as string
1727
+ );
1728
+ return { messages: [new AIMessage('done')] };
1729
+ }
1730
+ ),
1731
+ }),
1732
+ clearHeavyState: jest.fn(),
1733
+ }) as unknown as StandardGraph,
1734
+ });
1735
+ const common = {
1736
+ description: 'task',
1737
+ subagentType: 'researcher',
1738
+ threadId: 'parent-thread',
1739
+ parentToolCallId: 'call_reused',
1740
+ };
1741
+
1742
+ await executor.execute({
1743
+ ...common,
1744
+ parentConfigurable: {
1745
+ thread_id: 'parent-thread',
1746
+ [SUBAGENT_PARENT_BATCH_CONFIG_KEY]: 'assistant-batch-1',
1747
+ },
1748
+ });
1749
+ await executor.execute({
1750
+ ...common,
1751
+ parentConfigurable: {
1752
+ thread_id: 'parent-thread',
1753
+ [SUBAGENT_PARENT_BATCH_CONFIG_KEY]: 'assistant-batch-2',
1754
+ },
1755
+ });
1756
+
1757
+ expect(invokedThreadIds).toHaveLength(2);
1758
+ expect(invokedThreadIds[0]).not.toBe(invokedThreadIds[1]);
1759
+ });
1760
+
1761
+ it('keeps ambiguous parent agent and tool-call components collision-free', async () => {
1762
+ const invokedThreadIds: string[] = [];
1763
+ const createChildGraph = (): StandardGraph =>
1764
+ ({
1765
+ createWorkflow: () => ({
1766
+ getState: jest.fn().mockResolvedValue({
1767
+ values: {},
1768
+ next: [],
1769
+ tasks: [],
1770
+ }),
1771
+ invoke: jest
1772
+ .fn()
1773
+ .mockImplementation(
1774
+ async (
1775
+ _input: unknown,
1776
+ invokeConfig: Record<string, unknown>
1777
+ ): Promise<{ messages: BaseMessage[] }> => {
1778
+ invokedThreadIds.push(
1779
+ (invokeConfig.configurable as Record<string, unknown>)
1780
+ .thread_id as string
1781
+ );
1782
+ return { messages: [new AIMessage('done')] };
1783
+ }
1784
+ ),
1785
+ }),
1786
+ clearHeavyState: jest.fn(),
1787
+ }) as unknown as StandardGraph;
1788
+ const common = {
1789
+ description: 'task',
1790
+ subagentType: 'researcher',
1791
+ threadId: 'parent',
1792
+ parentConfigurable: { thread_id: 'parent' },
1793
+ };
1794
+
1795
+ await createExecutor({
1796
+ checkpointer: new MemorySaver(),
1797
+ humanInTheLoop: { enabled: true },
1798
+ parentAgentId: 'a_b',
1799
+ createChildGraph,
1800
+ }).execute({ ...common, parentToolCallId: 'c' });
1801
+ await createExecutor({
1802
+ checkpointer: new MemorySaver(),
1803
+ humanInTheLoop: { enabled: true },
1804
+ parentAgentId: 'a',
1805
+ createChildGraph,
1806
+ }).execute({ ...common, parentToolCallId: 'b_c' });
1807
+
1808
+ expect(invokedThreadIds).toHaveLength(2);
1809
+ expect(invokedThreadIds[0]).not.toBe(invokedThreadIds[1]);
1810
+ });
1811
+
1812
+ it('retains owned child checkpoint threads after heavy-state cleanup', async () => {
1813
+ const executor = createExecutor({
1814
+ checkpointer: new MemorySaver(),
1815
+ humanInTheLoop: { enabled: true },
1816
+ createChildGraph: (): StandardGraph =>
1817
+ ({
1818
+ createWorkflow: () => ({
1819
+ getState: jest.fn().mockResolvedValue({
1820
+ values: {},
1821
+ next: [],
1822
+ tasks: [],
1823
+ }),
1824
+ invoke: jest.fn().mockResolvedValue({
1825
+ messages: [new AIMessage('done')],
1826
+ }),
1827
+ }),
1828
+ clearHeavyState: jest.fn(),
1829
+ }) as unknown as StandardGraph,
1830
+ });
1831
+
1832
+ await executor.execute({
1833
+ description: 'task',
1834
+ subagentType: 'researcher',
1835
+ threadId: 'parent',
1836
+ parentToolCallId: 'call_owned',
1837
+ });
1838
+ const beforeCleanup = executor.getChildCheckpointThreadIds();
1839
+ executor.clearHeavyState();
1840
+
1841
+ expect(beforeCleanup).toHaveLength(1);
1842
+ expect(executor.getChildCheckpointThreadIds()).toEqual(beforeCleanup);
1843
+
1844
+ executor.resetCheckpointThreadIds();
1845
+ expect(executor.getChildCheckpointThreadIds()).toEqual([]);
1846
+ });
1847
+
1848
+ it('includes checkpoint threads from active descendant graphs', async () => {
1849
+ const grandchildThreadId = 'subagent:grandchild';
1850
+ const executor = createExecutor({
1851
+ checkpointer: new MemorySaver(),
1852
+ humanInTheLoop: { enabled: true },
1853
+ createChildGraph: (): StandardGraph =>
1854
+ ({
1855
+ createWorkflow: () => ({
1856
+ getState: jest.fn().mockResolvedValue({
1857
+ values: {},
1858
+ next: ['child-agent'],
1859
+ tasks: [],
1860
+ }),
1861
+ invoke: jest.fn().mockRejectedValue(
1862
+ new GraphInterrupt([
1863
+ {
1864
+ id: 'grandchild-interrupt',
1865
+ value: {
1866
+ type: 'tool_approval',
1867
+ action_requests: [],
1868
+ review_configs: [],
1869
+ },
1870
+ },
1871
+ ])
1872
+ ),
1873
+ }),
1874
+ getChildCheckpointThreadIds: jest.fn(() => [grandchildThreadId]),
1875
+ clearHeavyState: jest.fn(),
1876
+ }) as unknown as StandardGraph,
1877
+ });
1878
+
1879
+ await expect(
1880
+ executor.execute({
1881
+ description: 'nested task',
1882
+ subagentType: 'researcher',
1883
+ threadId: 'parent',
1884
+ parentToolCallId: 'call_child',
1885
+ })
1886
+ ).rejects.toBeInstanceOf(GraphInterrupt);
1887
+
1888
+ expect(executor.getChildCheckpointThreadIds()).toEqual(
1889
+ expect.arrayContaining([grandchildThreadId])
1890
+ );
1891
+ });
1892
+
1370
1893
  it('does not require parentConfigurable (back-compat with hosts that omit it)', async () => {
1371
1894
  const { factory, getInvokeConfig } = makeCapturingGraphFactory();
1372
1895
  const executor = createExecutor({ createChildGraph: factory });
@@ -1380,7 +1903,6 @@ describe('SubagentExecutor', () => {
1380
1903
  string,
1381
1904
  unknown
1382
1905
  >;
1383
- // Only thread_id (childRunId fallback) is set when no parent context is supplied.
1384
1906
  expect(Object.keys(configurable)).toEqual(['thread_id']);
1385
1907
  });
1386
1908
  });
@@ -1455,6 +1977,67 @@ describe('SubagentExecutor', () => {
1455
1977
  expect(input.agentType).toBe('researcher');
1456
1978
  });
1457
1979
 
1980
+ it('finishes missing child lifecycle after recovering a completed checkpoint', async () => {
1981
+ const stopHook = jest.fn().mockResolvedValue({});
1982
+ const hookRegistry = new HookRegistry();
1983
+ hookRegistry.register('SubagentStop', { hooks: [stopHook] });
1984
+ const updates: SubagentUpdateEvent[] = [];
1985
+ const handlerRegistry = new HandlerRegistry();
1986
+ handlerRegistry.register(GraphEvents.ON_SUBAGENT_UPDATE, {
1987
+ handle: (_event, data): void => {
1988
+ updates.push(data as SubagentUpdateEvent);
1989
+ },
1990
+ });
1991
+ const invoke = jest.fn();
1992
+ const updateState = jest.fn().mockResolvedValue({});
1993
+ const executor = createExecutor({
1994
+ humanInTheLoop: { enabled: true },
1995
+ hookRegistry,
1996
+ parentHandlerRegistry: handlerRegistry,
1997
+ createChildGraph: (): StandardGraph =>
1998
+ ({
1999
+ createWorkflow: () => ({
2000
+ getState: jest.fn().mockResolvedValue({
2001
+ values: { messages: [new AIMessage('persisted result')] },
2002
+ next: [],
2003
+ tasks: [],
2004
+ }),
2005
+ invoke,
2006
+ updateState,
2007
+ }),
2008
+ clearHeavyState: jest.fn(),
2009
+ }) as unknown as StandardGraph,
2010
+ });
2011
+
2012
+ const result = await executor.execute({
2013
+ description: 'recover me',
2014
+ subagentType: 'researcher',
2015
+ threadId: 'durable-thread',
2016
+ parentToolCallId: 'call_recovered',
2017
+ });
2018
+
2019
+ expect(result.content).toBe('persisted result');
2020
+ expect(invoke).not.toHaveBeenCalled();
2021
+ expect(stopHook).toHaveBeenCalledTimes(1);
2022
+ expect(updates.filter((update) => update.phase === 'stop')).toHaveLength(
2023
+ 1
2024
+ );
2025
+ expect(updateState).toHaveBeenCalledTimes(1);
2026
+ expect(updateState.mock.calls[0][1]).toMatchObject({
2027
+ messages: [
2028
+ {
2029
+ additional_kwargs: {
2030
+ __librechat_subagent_checkpoint: {
2031
+ version: 1,
2032
+ parentToolCallId: 'call_recovered',
2033
+ lifecycleComplete: true,
2034
+ },
2035
+ },
2036
+ },
2037
+ ],
2038
+ });
2039
+ });
2040
+
1458
2041
  it('SubagentStart deny blocks execution', async () => {
1459
2042
  const registry = new HookRegistry();
1460
2043
  registry.register('SubagentStart', {