@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,4 +1,7 @@
1
+ import { z } from 'zod';
2
+ import { tool } from '@langchain/core/tools';
1
3
  import { HumanMessage } from '@langchain/core/messages';
4
+ import { MemorySaver, interrupt } from '@langchain/langgraph';
2
5
  import { FakeListChatModel } from '@langchain/core/utils/testing';
3
6
  import type { ToolCall } from '@langchain/core/messages/tool';
4
7
  import type {
@@ -26,6 +29,7 @@ import { FakeChatModel } from '@/llm/fake';
26
29
  import { Run } from '@/run';
27
30
 
28
31
  const CHILD_RESPONSE = 'Hook test child response.';
32
+ const PRIMITIVE_INTERRUPT_TOOL_NAME = 'confirm_child';
29
33
 
30
34
  const calculatorDef: t.LCTool = {
31
35
  name: 'calculator',
@@ -39,6 +43,23 @@ const calculatorDef: t.LCTool = {
39
43
  },
40
44
  };
41
45
 
46
+ const referenceToolDefs: t.LCTool[] = [
47
+ {
48
+ name: 'produce_value',
49
+ description: 'Produce a value for a later tool.',
50
+ parameters: { type: 'object', properties: {} },
51
+ },
52
+ {
53
+ name: 'consume_value',
54
+ description: 'Consume a value from an earlier tool.',
55
+ parameters: {
56
+ type: 'object',
57
+ properties: { value: { type: 'string' } },
58
+ required: ['value'],
59
+ },
60
+ },
61
+ ];
62
+
42
63
  const callerConfig = {
43
64
  configurable: { thread_id: 'hook-test-thread' },
44
65
  streamMode: 'values' as const,
@@ -47,14 +68,17 @@ const callerConfig = {
47
68
 
48
69
  const originalGetChatModelClass = providers.getChatModelClass;
49
70
 
50
- function makeSubagentToolCall(): ToolCall {
71
+ function makeSubagentToolCall(
72
+ id = `call_sub_${Date.now()}`,
73
+ description = 'Test task for hook verification'
74
+ ): ToolCall {
51
75
  return {
52
76
  name: Constants.SUBAGENT,
53
77
  args: {
54
- description: 'Test task for hook verification',
78
+ description,
55
79
  subagent_type: 'researcher',
56
80
  },
57
- id: `call_sub_${Date.now()}`,
81
+ id,
58
82
  type: 'tool_call',
59
83
  };
60
84
  }
@@ -108,6 +132,98 @@ function createParentAgentWithChildTool(): t.AgentInputs {
108
132
  };
109
133
  }
110
134
 
135
+ function createParentAgentWithPrimitiveInterruptTool(
136
+ primitiveInterruptTool: t.GenericTool
137
+ ): t.AgentInputs {
138
+ const parent = createParentAgent();
139
+ const child = parent.subagentConfigs?.[0];
140
+ const childAgent = child?.agentInputs;
141
+ if (child == null || childAgent == null) {
142
+ throw new Error('Expected a child agent configuration.');
143
+ }
144
+ return {
145
+ ...parent,
146
+ subagentConfigs: [
147
+ {
148
+ ...child,
149
+ agentInputs: {
150
+ ...childAgent,
151
+ instructions: 'Request confirmation, then answer concisely.',
152
+ graphTools: [primitiveInterruptTool],
153
+ },
154
+ },
155
+ ],
156
+ };
157
+ }
158
+
159
+ function createParentAgentWithNestedChildTool(): t.AgentInputs {
160
+ return {
161
+ agentId: 'nested-hook-parent',
162
+ provider: Providers.OPENAI,
163
+ clientOptions: { modelName: 'parent-model', apiKey: 'test-key' },
164
+ instructions: 'Delegate nested calculations.',
165
+ maxContextTokens: 8000,
166
+ maxSubagentDepth: 2,
167
+ subagentConfigs: [
168
+ {
169
+ type: 'researcher',
170
+ name: 'Researcher',
171
+ description: 'Delegates calculations',
172
+ allowNested: true,
173
+ agentInputs: {
174
+ agentId: 'nested-researcher-child',
175
+ provider: Providers.OPENAI,
176
+ clientOptions: {
177
+ modelName: 'nested-child-model',
178
+ apiKey: 'test-key',
179
+ },
180
+ instructions: 'Delegate arithmetic to the calculator worker.',
181
+ maxContextTokens: 8000,
182
+ subagentConfigs: [
183
+ {
184
+ type: 'calculator-worker',
185
+ name: 'Calculator Worker',
186
+ description: 'Runs calculator tools',
187
+ agentInputs: {
188
+ agentId: 'calculator-grandchild',
189
+ provider: Providers.OPENAI,
190
+ clientOptions: {
191
+ modelName: 'grandchild-model',
192
+ apiKey: 'test-key',
193
+ },
194
+ instructions: 'Use calculator, then answer.',
195
+ maxContextTokens: 8000,
196
+ toolDefinitions: [calculatorDef],
197
+ },
198
+ },
199
+ ],
200
+ },
201
+ },
202
+ ],
203
+ };
204
+ }
205
+
206
+ function createParentAgentWithReferenceTools(): t.AgentInputs {
207
+ const parent = createParentAgentWithChildTool();
208
+ const child = parent.subagentConfigs?.[0];
209
+ const childAgent = child?.agentInputs;
210
+ if (child == null || childAgent == null) {
211
+ throw new Error('Expected a child agent configuration.');
212
+ }
213
+ return {
214
+ ...parent,
215
+ subagentConfigs: [
216
+ {
217
+ ...child,
218
+ agentInputs: {
219
+ ...childAgent,
220
+ toolDefinitions: referenceToolDefs,
221
+ },
222
+ },
223
+ ],
224
+ };
225
+ }
226
+
111
227
  function createCalculatorToolCall(): ToolCall {
112
228
  return {
113
229
  name: 'calculator',
@@ -117,6 +233,163 @@ function createCalculatorToolCall(): ToolCall {
117
233
  };
118
234
  }
119
235
 
236
+ class HitlChildFakeChatModel extends FakeChatModel {
237
+ constructor(_options: object) {
238
+ super({ responses: [CHILD_RESPONSE], sleep: 1 });
239
+ }
240
+
241
+ _streamResponseChunks(
242
+ messages: Parameters<FakeChatModel['_streamResponseChunks']>[0],
243
+ options: Parameters<FakeChatModel['_streamResponseChunks']>[1],
244
+ runManager?: Parameters<FakeChatModel['_streamResponseChunks']>[2]
245
+ ): ReturnType<FakeChatModel['_streamResponseChunks']> {
246
+ const hasToolResult = messages.some(
247
+ (message) => message._getType() === 'tool'
248
+ );
249
+ return new FakeChatModel({
250
+ responses: [hasToolResult ? CHILD_RESPONSE : 'Using calculator.'],
251
+ sleep: 1,
252
+ toolCalls: hasToolResult ? [] : [createCalculatorToolCall()],
253
+ })._streamResponseChunks(messages, options, runManager);
254
+ }
255
+
256
+ bindTools(tools: unknown): ReturnType<FakeChatModel['withConfig']> {
257
+ const config = {
258
+ tools,
259
+ } as Parameters<FakeChatModel['withConfig']>[0];
260
+ return this.withConfig(config);
261
+ }
262
+ }
263
+
264
+ class PrimitiveInterruptFakeChatModel extends FakeChatModel {
265
+ constructor(_options: object) {
266
+ super({ responses: [CHILD_RESPONSE], sleep: 1 });
267
+ }
268
+
269
+ _streamResponseChunks(
270
+ messages: Parameters<FakeChatModel['_streamResponseChunks']>[0],
271
+ options: Parameters<FakeChatModel['_streamResponseChunks']>[1],
272
+ runManager?: Parameters<FakeChatModel['_streamResponseChunks']>[2]
273
+ ): ReturnType<FakeChatModel['_streamResponseChunks']> {
274
+ const hasToolResult = messages.some(
275
+ (message) => message._getType() === 'tool'
276
+ );
277
+ return new FakeChatModel({
278
+ responses: [hasToolResult ? CHILD_RESPONSE : 'Requesting confirmation.'],
279
+ sleep: 1,
280
+ toolCalls: hasToolResult
281
+ ? []
282
+ : [
283
+ {
284
+ name: PRIMITIVE_INTERRUPT_TOOL_NAME,
285
+ args: {},
286
+ id: 'call_child_primitive_interrupt',
287
+ type: 'tool_call',
288
+ },
289
+ ],
290
+ })._streamResponseChunks(messages, options, runManager);
291
+ }
292
+
293
+ bindTools(tools: unknown): ReturnType<FakeChatModel['withConfig']> {
294
+ return this.withConfig({ tools } as Parameters<
295
+ FakeChatModel['withConfig']
296
+ >[0]);
297
+ }
298
+ }
299
+
300
+ class NestedHitlFakeChatModel extends FakeChatModel {
301
+ private readonly nestedChild: boolean;
302
+
303
+ constructor(options: { modelName?: string }) {
304
+ super({ responses: [CHILD_RESPONSE], sleep: 1 });
305
+ this.nestedChild = options.modelName === 'nested-child-model';
306
+ }
307
+
308
+ _streamResponseChunks(
309
+ messages: Parameters<FakeChatModel['_streamResponseChunks']>[0],
310
+ options: Parameters<FakeChatModel['_streamResponseChunks']>[1],
311
+ runManager?: Parameters<FakeChatModel['_streamResponseChunks']>[2]
312
+ ): ReturnType<FakeChatModel['_streamResponseChunks']> {
313
+ const hasToolResult = messages.some(
314
+ (message) => message._getType() === 'tool'
315
+ );
316
+ let toolCalls: ToolCall[] = [];
317
+ if (!hasToolResult && this.nestedChild) {
318
+ toolCalls = [
319
+ {
320
+ name: Constants.SUBAGENT,
321
+ args: {
322
+ description: 'Calculate the result',
323
+ subagent_type: 'calculator-worker',
324
+ },
325
+ id: 'call_nested_calculator_worker',
326
+ type: 'tool_call',
327
+ },
328
+ ];
329
+ } else if (!hasToolResult) {
330
+ toolCalls = [createCalculatorToolCall()];
331
+ }
332
+ return new FakeChatModel({
333
+ responses: [hasToolResult ? CHILD_RESPONSE : 'Delegating calculation.'],
334
+ sleep: 1,
335
+ toolCalls,
336
+ })._streamResponseChunks(messages, options, runManager);
337
+ }
338
+
339
+ bindTools(tools: unknown): ReturnType<FakeChatModel['withConfig']> {
340
+ return this.withConfig({ tools } as Parameters<
341
+ FakeChatModel['withConfig']
342
+ >[0]);
343
+ }
344
+ }
345
+
346
+ class ReferenceHitlFakeChatModel extends FakeChatModel {
347
+ constructor(_options: object) {
348
+ super({ responses: [CHILD_RESPONSE], sleep: 1 });
349
+ }
350
+
351
+ _streamResponseChunks(
352
+ messages: Parameters<FakeChatModel['_streamResponseChunks']>[0],
353
+ options: Parameters<FakeChatModel['_streamResponseChunks']>[1],
354
+ runManager?: Parameters<FakeChatModel['_streamResponseChunks']>[2]
355
+ ): ReturnType<FakeChatModel['_streamResponseChunks']> {
356
+ const toolResultCount = messages.filter(
357
+ (message) => message._getType() === 'tool'
358
+ ).length;
359
+ let toolCalls: ToolCall[] = [];
360
+ if (toolResultCount === 0) {
361
+ toolCalls = [
362
+ {
363
+ name: 'produce_value',
364
+ args: {},
365
+ id: 'call_produce_value',
366
+ type: 'tool_call',
367
+ },
368
+ ];
369
+ } else if (toolResultCount === 1) {
370
+ toolCalls = [
371
+ {
372
+ name: 'consume_value',
373
+ args: { value: '{{tool0turn0}}' },
374
+ id: 'call_consume_value',
375
+ type: 'tool_call',
376
+ },
377
+ ];
378
+ }
379
+ return new FakeChatModel({
380
+ responses: [toolCalls.length > 0 ? 'Using a tool.' : CHILD_RESPONSE],
381
+ sleep: 1,
382
+ toolCalls,
383
+ })._streamResponseChunks(messages, options, runManager);
384
+ }
385
+
386
+ bindTools(tools: unknown): ReturnType<FakeChatModel['withConfig']> {
387
+ return this.withConfig({ tools } as Parameters<
388
+ FakeChatModel['withConfig']
389
+ >[0]);
390
+ }
391
+ }
392
+
120
393
  async function createSubagentRun(
121
394
  hooks: HookRegistry,
122
395
  runId = `subagent-hook-${Date.now()}`
@@ -489,54 +762,425 @@ describe('Subagent hook integration (end-to-end via Run)', () => {
489
762
  expect(dispatchAgentIds).toEqual(['hook-parent']);
490
763
  });
491
764
 
492
- it('child subagent tool ask hooks fail closed instead of starting unsupported nested HITL', async () => {
765
+ it.each([
766
+ {
767
+ label: 'approve',
768
+ resumeDecision: { type: 'approve' } as const,
769
+ shouldExecute: true,
770
+ deniedReason: undefined,
771
+ },
772
+ {
773
+ label: 'reject',
774
+ resumeDecision: {
775
+ type: 'reject',
776
+ reason: 'host rejected child tool',
777
+ } as const,
778
+ shouldExecute: false,
779
+ deniedReason: 'host rejected child tool',
780
+ },
781
+ {
782
+ label: 'deny',
783
+ resumeDecision: undefined,
784
+ shouldExecute: false,
785
+ deniedReason: 'policy denied child tool',
786
+ },
787
+ ])(
788
+ 'handles a child subagent tool $label through the parent Run',
789
+ async ({ resumeDecision, shouldExecute, deniedReason }) => {
790
+ getChatModelClassSpy.mockImplementation(((provider: Providers) => {
791
+ if (provider === Providers.OPENAI) {
792
+ return HitlChildFakeChatModel;
793
+ }
794
+ return originalGetChatModelClass(provider);
795
+ }) as typeof providers.getChatModelClass);
796
+
797
+ const registry = new HookRegistry();
798
+ const deniedTools: string[] = [];
799
+ const executedTools: string[] = [];
800
+ let calculatorPreToolCalls = 0;
801
+ let calculatorPostToolCalls = 0;
802
+
803
+ const preHook: HookCallback<'PreToolUse'> = async (
804
+ input
805
+ ): Promise<PreToolUseHookOutput> => {
806
+ if (input.toolName === 'calculator') {
807
+ calculatorPreToolCalls += 1;
808
+ if (resumeDecision == null) {
809
+ return {
810
+ decision: 'deny',
811
+ reason: 'policy denied child tool',
812
+ };
813
+ }
814
+ return { decision: 'ask', reason: 'review calculator' };
815
+ }
816
+ return { decision: 'allow' };
817
+ };
818
+ registry.register('PreToolUse', { hooks: [preHook] });
819
+ registry.register('PostToolUse', {
820
+ hooks: [
821
+ async (input): Promise<PostToolUseHookOutput> => {
822
+ if (input.toolName === 'calculator') {
823
+ calculatorPostToolCalls += 1;
824
+ }
825
+ return {};
826
+ },
827
+ ],
828
+ });
829
+
830
+ const deniedHook: HookCallback<'PermissionDenied'> = async (
831
+ input
832
+ ): Promise<PermissionDeniedHookOutput> => {
833
+ deniedTools.push(
834
+ `${input.agentId ?? '-'}:${input.toolName}:${input.reason}`
835
+ );
836
+ return {};
837
+ };
838
+ registry.register('PermissionDenied', { hooks: [deniedHook] });
839
+
840
+ const customHandlers: Record<string, t.EventHandler> = {
841
+ [GraphEvents.TOOL_END]: new ToolEndHandler(),
842
+ [GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
843
+ [GraphEvents.ON_TOOL_EXECUTE]: {
844
+ handle: (_event, rawData): void => {
845
+ const request = rawData as t.ToolExecuteBatchRequest;
846
+ executedTools.push(...request.toolCalls.map((call) => call.name));
847
+ const results: t.ToolExecuteResult[] = request.toolCalls.map(
848
+ (call) => ({
849
+ toolCallId: call.id,
850
+ status: 'success',
851
+ content: '42',
852
+ })
853
+ );
854
+ request.resolve(results);
855
+ },
856
+ },
857
+ };
858
+
859
+ const run = await Run.create<t.IState>({
860
+ runId: `subagent-tool-ask-${Date.now()}`,
861
+ graphConfig: {
862
+ type: 'standard',
863
+ agents: [createParentAgentWithChildTool()],
864
+ },
865
+ returnContent: true,
866
+ skipCleanup: true,
867
+ customHandlers,
868
+ hooks: registry,
869
+ humanInTheLoop: { enabled: true },
870
+ });
871
+
872
+ const tc = makeSubagentToolCall();
873
+ run.Graph!.overrideTestModel(['Delegating...', 'Final answer.'], 5, [tc]);
874
+
875
+ await run.processStream(
876
+ { messages: [new HumanMessage('calculate something')] },
877
+ callerConfig
878
+ );
879
+
880
+ if (resumeDecision == null) {
881
+ expect(run.getInterrupt()).toBeUndefined();
882
+ expect(calculatorPreToolCalls).toBe(1);
883
+ expect(executedTools).not.toContain('calculator');
884
+ expect(deniedTools).toEqual([
885
+ `researcher-child:calculator:${deniedReason}`,
886
+ ]);
887
+ return;
888
+ }
889
+
890
+ const pending = run.getInterrupt();
891
+ expect(pending?.payload).toMatchObject({
892
+ type: 'tool_approval',
893
+ subagent: {
894
+ agent_id: 'researcher-child',
895
+ parent_tool_call_id: tc.id,
896
+ subagent_type: 'researcher',
897
+ },
898
+ });
899
+ expect(executedTools).not.toContain('calculator');
900
+
901
+ await run.resume([resumeDecision], callerConfig);
902
+
903
+ expect(run.getInterrupt()).toBeUndefined();
904
+ expect(calculatorPreToolCalls).toBe(2);
905
+ expect(
906
+ executedTools.filter((name) => name === 'calculator')
907
+ ).toHaveLength(shouldExecute ? 1 : 0);
908
+ expect(calculatorPostToolCalls).toBe(shouldExecute ? 1 : 0);
909
+ expect(deniedTools).toEqual(
910
+ shouldExecute ? [] : [`researcher-child:calculator:${deniedReason}`]
911
+ );
912
+ }
913
+ );
914
+
915
+ it('resumes approvals across multiple subagents and keeps updates sanitized', async () => {
493
916
  getChatModelClassSpy.mockImplementation(((provider: Providers) => {
494
917
  if (provider === Providers.OPENAI) {
495
- return class extends FakeChatModel {
496
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
497
- constructor(_options: any) {
498
- super({
499
- responses: ['Using calculator.', CHILD_RESPONSE],
500
- sleep: 1,
501
- toolCalls: [createCalculatorToolCall()],
502
- });
503
- }
504
- bindTools(tools: unknown): ReturnType<FakeChatModel['withConfig']> {
505
- const config = {
506
- tools,
507
- } as Parameters<FakeChatModel['withConfig']>[0];
508
- return this.withConfig(config);
509
- }
510
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
511
- } as any;
918
+ return HitlChildFakeChatModel;
512
919
  }
513
920
  return originalGetChatModelClass(provider);
514
921
  }) as typeof providers.getChatModelClass);
515
922
 
516
923
  const registry = new HookRegistry();
517
- const deniedTools: string[] = [];
518
- const executedTools: string[] = [];
924
+ const deniedToolIds: string[] = [];
925
+ const executedToolIds: string[] = [];
926
+ const updates: t.SubagentUpdateEvent[] = [];
927
+ const completedSubagentCalls: string[] = [];
519
928
 
520
- const preHook: HookCallback<'PreToolUse'> = async (
521
- input
522
- ): Promise<PreToolUseHookOutput> => {
523
- if (input.toolName === 'calculator') {
524
- return { decision: 'ask', reason: 'review calculator' };
525
- }
526
- return { decision: 'allow' };
929
+ registry.register('PreToolUse', {
930
+ hooks: [
931
+ async (input): Promise<PreToolUseHookOutput> =>
932
+ input.toolName === 'calculator'
933
+ ? { decision: 'ask', reason: 'review child calculator' }
934
+ : { decision: 'allow' },
935
+ ],
936
+ });
937
+ registry.register('PostToolUse', {
938
+ hooks: [
939
+ async (input): Promise<PostToolUseHookOutput> => {
940
+ if (input.toolName === Constants.SUBAGENT) {
941
+ completedSubagentCalls.push(input.toolUseId);
942
+ return { additionalContext: `context:${input.toolUseId}` };
943
+ }
944
+ return {};
945
+ },
946
+ ],
947
+ });
948
+ registry.register('PermissionDenied', {
949
+ hooks: [
950
+ async (input): Promise<PermissionDeniedHookOutput> => {
951
+ deniedToolIds.push(input.toolUseId);
952
+ return {};
953
+ },
954
+ ],
955
+ });
956
+
957
+ const customHandlers: Record<string, t.EventHandler> = {
958
+ [GraphEvents.TOOL_END]: new ToolEndHandler(),
959
+ [GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
960
+ [GraphEvents.ON_TOOL_EXECUTE]: {
961
+ handle: (_event, rawData): void => {
962
+ const request = rawData as t.ToolExecuteBatchRequest;
963
+ executedToolIds.push(...request.toolCalls.map((call) => call.id));
964
+ request.resolve(
965
+ request.toolCalls.map((call) => ({
966
+ toolCallId: call.id,
967
+ status: 'success' as const,
968
+ content: '42',
969
+ }))
970
+ );
971
+ },
972
+ },
973
+ [GraphEvents.ON_SUBAGENT_UPDATE]: {
974
+ handle: (_event, data): void => {
975
+ updates.push(data as t.SubagentUpdateEvent);
976
+ },
977
+ },
527
978
  };
528
- registry.register('PreToolUse', { hooks: [preHook] });
529
979
 
530
- const deniedHook: HookCallback<'PermissionDenied'> = async (
531
- input
532
- ): Promise<PermissionDeniedHookOutput> => {
533
- deniedTools.push(
534
- `${input.agentId ?? '-'}:${input.toolName}:${input.reason}`
535
- );
536
- return {};
980
+ const checkpointer = new MemorySaver();
981
+ const baseRunId = `subagent-multi-hitl-${Date.now()}`;
982
+ const createRun = (runId: string): Promise<Run<t.IState>> =>
983
+ Run.create<t.IState>({
984
+ runId,
985
+ graphConfig: {
986
+ type: 'standard',
987
+ agents: [createParentAgentWithChildTool()],
988
+ compileOptions: { checkpointer, interruptBefore: [] },
989
+ },
990
+ returnContent: true,
991
+ skipCleanup: true,
992
+ customHandlers,
993
+ hooks: registry,
994
+ humanInTheLoop: { enabled: true },
995
+ });
996
+ const run = await createRun(`${baseRunId}-initial`);
997
+ const first = makeSubagentToolCall(
998
+ 'call_sub_first',
999
+ 'Run the first calculation'
1000
+ );
1001
+ const second = makeSubagentToolCall(
1002
+ 'call_sub_second',
1003
+ 'Run the second calculation'
1004
+ );
1005
+ run.Graph!.overrideTestModel(['Delegating...', 'Final answer.'], 5, [
1006
+ first,
1007
+ second,
1008
+ ]);
1009
+ const multiCallerConfig = {
1010
+ ...callerConfig,
1011
+ configurable: {
1012
+ thread_id: 'multi-subagent-hitl-thread',
1013
+ access_token: 'must-not-leak',
1014
+ requestBody: { currentTaskInput: 'must-not-leak-task' },
1015
+ userMCPAuthMap: { private: { token: 'must-not-leak-mcp' } },
1016
+ },
537
1017
  };
538
- registry.register('PermissionDenied', { hooks: [deniedHook] });
539
1018
 
1019
+ await run.processStream(
1020
+ { messages: [new HumanMessage('calculate twice')] },
1021
+ multiCallerConfig
1022
+ );
1023
+ expect(run.getInterrupt()?.payload).toMatchObject({
1024
+ type: 'tool_approval',
1025
+ subagent: { parent_tool_call_id: first.id },
1026
+ });
1027
+ expect(updates.some((event) => event.parentToolCallId === second.id)).toBe(
1028
+ true
1029
+ );
1030
+
1031
+ await run.resume([{ type: 'approve' }], multiCallerConfig);
1032
+ expect(run.getInterrupt()?.payload).toMatchObject({
1033
+ type: 'tool_approval',
1034
+ subagent: { parent_tool_call_id: second.id },
1035
+ });
1036
+
1037
+ const rebuiltRun = await createRun(`${baseRunId}-rebuilt`);
1038
+ rebuiltRun.Graph!.overrideTestModel(['Final answer.'], 1);
1039
+ await rebuiltRun.resume(
1040
+ [{ type: 'reject', reason: 'reject second child' }],
1041
+ multiCallerConfig
1042
+ );
1043
+
1044
+ expect(rebuiltRun.getInterrupt()).toBeUndefined();
1045
+ expect(executedToolIds).toHaveLength(1);
1046
+ expect(deniedToolIds).toHaveLength(1);
1047
+ expect(completedSubagentCalls).toEqual([first.id, second.id]);
1048
+ const firstPhases = updates
1049
+ .filter((event) => event.parentToolCallId === first.id)
1050
+ .map((event) => event.phase);
1051
+ const secondPhases = updates
1052
+ .filter((event) => event.parentToolCallId === second.id)
1053
+ .map((event) => event.phase);
1054
+ expect(firstPhases[0]).toBe('start');
1055
+ expect(firstPhases[firstPhases.length - 1]).toBe('stop');
1056
+ expect(secondPhases[0]).toBe('start');
1057
+ expect(secondPhases[secondPhases.length - 1]).toBe('stop');
1058
+ const serializedUpdates = JSON.stringify(updates);
1059
+ expect(serializedUpdates).not.toContain('must-not-leak');
1060
+ expect(serializedUpdates).not.toContain('currentTaskInput');
1061
+ expect(serializedUpdates).not.toContain('userMCPAuthMap');
1062
+ expect(serializedUpdates).not.toContain('checkpoint_');
1063
+ });
1064
+
1065
+ it('resumes a primitive child interrupt through a rebuilt Run', async () => {
1066
+ getChatModelClassSpy.mockImplementation(((provider: Providers) => {
1067
+ if (provider === Providers.OPENAI) {
1068
+ return PrimitiveInterruptFakeChatModel;
1069
+ }
1070
+ return originalGetChatModelClass(provider);
1071
+ }) as typeof providers.getChatModelClass);
1072
+
1073
+ const checkpointer = new MemorySaver();
1074
+ const resumedValues: string[] = [];
1075
+ const primitiveInterruptTool = tool(
1076
+ async () => {
1077
+ const resumeValue = interrupt<string, string>('confirm child');
1078
+ resumedValues.push(resumeValue);
1079
+ return `confirmed:${resumeValue}`;
1080
+ },
1081
+ {
1082
+ name: PRIMITIVE_INTERRUPT_TOOL_NAME,
1083
+ description: 'Pause for primitive confirmation.',
1084
+ schema: z.object({}),
1085
+ }
1086
+ );
1087
+ const createRun = (currentRunId: string): Promise<Run<t.IState>> =>
1088
+ Run.create<t.IState>({
1089
+ runId: currentRunId,
1090
+ graphConfig: {
1091
+ type: 'standard',
1092
+ agents: [
1093
+ createParentAgentWithPrimitiveInterruptTool(primitiveInterruptTool),
1094
+ ],
1095
+ compileOptions: { checkpointer },
1096
+ },
1097
+ returnContent: true,
1098
+ skipCleanup: true,
1099
+ customHandlers: {
1100
+ [GraphEvents.TOOL_END]: new ToolEndHandler(),
1101
+ [GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
1102
+ },
1103
+ humanInTheLoop: { enabled: true },
1104
+ });
1105
+ const runId = `primitive-subagent-rebuild-${Date.now()}`;
1106
+ const parentCall = makeSubagentToolCall('call_primitive_rebuild');
1107
+ const initialRun = await createRun(`${runId}-initial`);
1108
+ initialRun.Graph!.overrideTestModel(['Delegating...', 'Final answer.'], 5, [
1109
+ parentCall,
1110
+ ]);
1111
+
1112
+ await initialRun.processStream(
1113
+ { messages: [new HumanMessage('confirm inside the child')] },
1114
+ callerConfig
1115
+ );
1116
+ const paused = initialRun.getInterrupt<string>();
1117
+ expect(paused?.payload).toBe('confirm child');
1118
+ expect(JSON.stringify(paused)).not.toContain(
1119
+ '__librechat_subagent_resume_manifest'
1120
+ );
1121
+ if (paused?.checkpointId == null) {
1122
+ throw new Error('Expected a persisted parent checkpoint.');
1123
+ }
1124
+
1125
+ const rebuiltRun = await createRun(`${runId}-rebuilt`);
1126
+ rebuiltRun.Graph!.overrideTestModel(['Final answer.'], 1);
1127
+ const injected = new HumanMessage('host edit on resume');
1128
+ const rebuiltContent = await rebuiltRun.resume<string>(
1129
+ 'approved after restart',
1130
+ {
1131
+ ...callerConfig,
1132
+ configurable: {
1133
+ ...callerConfig.configurable,
1134
+ checkpoint_id: paused.checkpointId,
1135
+ checkpoint_ns: paused.checkpointNs ?? '',
1136
+ },
1137
+ },
1138
+ undefined,
1139
+ { update: { messages: [injected] } }
1140
+ );
1141
+
1142
+ expect(rebuiltRun.getInterrupt()).toBeUndefined();
1143
+ expect(resumedValues).toEqual(['approved after restart']);
1144
+ expect(JSON.stringify(rebuiltContent)).toContain('Final answer.');
1145
+ expect(JSON.stringify(rebuiltContent)).not.toContain('host edit on resume');
1146
+ expect(
1147
+ rebuiltRun
1148
+ .getRunMessages()
1149
+ ?.some(
1150
+ (message) =>
1151
+ message._getType() === 'ai' &&
1152
+ JSON.stringify(message.content).includes('Final answer.')
1153
+ )
1154
+ ).toBe(true);
1155
+ expect(
1156
+ rebuiltRun.getRunMessages()?.some((message) => message.id === injected.id)
1157
+ ).toBe(false);
1158
+ });
1159
+
1160
+ it('forks every resume from the paused child snapshot', async () => {
1161
+ getChatModelClassSpy.mockImplementation(((provider: Providers) => {
1162
+ if (provider === Providers.OPENAI) {
1163
+ return HitlChildFakeChatModel;
1164
+ }
1165
+ return originalGetChatModelClass(provider);
1166
+ }) as typeof providers.getChatModelClass);
1167
+
1168
+ const checkpointer = new MemorySaver();
1169
+ const registry = new HookRegistry();
1170
+ const executedTools: string[] = [];
1171
+ const updates: t.SubagentUpdateEvent[] = [];
1172
+ let approvalHookCalls = 0;
1173
+ const runId = `subagent-rebuild-hitl-${Date.now()}`;
1174
+ registry.registerSession(`${runId}-initial`, 'PreToolUse', {
1175
+ once: true,
1176
+ pattern: '^calculator$',
1177
+ hooks: [
1178
+ async (): Promise<PreToolUseHookOutput> => {
1179
+ approvalHookCalls += 1;
1180
+ return { decision: 'ask', reason: 'review calculator' };
1181
+ },
1182
+ ],
1183
+ });
540
1184
  const customHandlers: Record<string, t.EventHandler> = {
541
1185
  [GraphEvents.TOOL_END]: new ToolEndHandler(),
542
1186
  [GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
@@ -544,43 +1188,377 @@ describe('Subagent hook integration (end-to-end via Run)', () => {
544
1188
  handle: (_event, rawData): void => {
545
1189
  const request = rawData as t.ToolExecuteBatchRequest;
546
1190
  executedTools.push(...request.toolCalls.map((call) => call.name));
547
- const results: t.ToolExecuteResult[] = request.toolCalls.map(
548
- (call) => ({
1191
+ request.resolve(
1192
+ request.toolCalls.map((call) => ({
549
1193
  toolCallId: call.id,
550
- status: 'success',
1194
+ status: 'success' as const,
551
1195
  content: '42',
552
- })
1196
+ }))
553
1197
  );
554
- request.resolve(results);
1198
+ },
1199
+ },
1200
+ [GraphEvents.ON_SUBAGENT_UPDATE]: {
1201
+ handle: (_event, data): void => {
1202
+ updates.push(data as t.SubagentUpdateEvent);
555
1203
  },
556
1204
  },
557
1205
  };
1206
+ const createRun = (currentRunId: string): Promise<Run<t.IState>> =>
1207
+ Run.create<t.IState>({
1208
+ runId: currentRunId,
1209
+ graphConfig: {
1210
+ type: 'standard',
1211
+ agents: [createParentAgentWithChildTool()],
1212
+ compileOptions: { checkpointer },
1213
+ },
1214
+ returnContent: true,
1215
+ skipCleanup: true,
1216
+ customHandlers,
1217
+ hooks: registry,
1218
+ humanInTheLoop: { enabled: true },
1219
+ });
1220
+ const tc = makeSubagentToolCall('call_sub_rebuild');
1221
+ const initialRun = await createRun(`${runId}-initial`);
1222
+ initialRun.Graph!.overrideTestModel(['Delegating...', 'Final answer.'], 5, [
1223
+ tc,
1224
+ ]);
558
1225
 
559
- const run = await Run.create<t.IState>({
560
- runId: `subagent-tool-ask-${Date.now()}`,
561
- graphConfig: {
562
- type: 'standard',
563
- agents: [createParentAgentWithChildTool()],
1226
+ await initialRun.processStream(
1227
+ { messages: [new HumanMessage('calculate after restart')] },
1228
+ callerConfig
1229
+ );
1230
+ const persistedInterrupt = initialRun.getInterrupt();
1231
+ expect(persistedInterrupt?.payload).toMatchObject({
1232
+ type: 'tool_approval',
1233
+ subagent: { parent_tool_call_id: tc.id },
1234
+ });
1235
+ expect(JSON.stringify(persistedInterrupt)).not.toContain(
1236
+ '__librechat_subagent_resume_manifest'
1237
+ );
1238
+ expect(approvalHookCalls).toBe(1);
1239
+ const sourceChildRunId =
1240
+ persistedInterrupt?.payload.type === 'tool_approval'
1241
+ ? persistedInterrupt.payload.subagent?.run_id
1242
+ : undefined;
1243
+ expect(sourceChildRunId).toBeDefined();
1244
+ const branchConfig = {
1245
+ ...callerConfig,
1246
+ configurable: {
1247
+ ...callerConfig.configurable,
1248
+ checkpoint_id: persistedInterrupt?.checkpointId,
1249
+ checkpoint_ns: persistedInterrupt?.checkpointNs ?? '',
564
1250
  },
565
- returnContent: true,
566
- skipCleanup: true,
567
- customHandlers,
568
- hooks: registry,
569
- humanInTheLoop: { enabled: true },
1251
+ };
1252
+ const earlyApprovedRun = await createRun(`${runId}-early-approved`);
1253
+ const sharedRebuiltRunId = `${runId}-same-rebuilt-id`;
1254
+ const approvedRun = await createRun(sharedRebuiltRunId);
1255
+ const rejectedRun = await createRun(sharedRebuiltRunId);
1256
+ let armRestoredCheckpointBarrier = false;
1257
+ let blockNextRestoredCheckpointRead = false;
1258
+ const originalRestorePendingToolApprovals =
1259
+ registry.restorePendingToolApprovals.bind(registry);
1260
+ const restoreApprovalSpy = jest
1261
+ .spyOn(registry, 'restorePendingToolApprovals')
1262
+ .mockImplementation((sessionId, targetExecutionScope, snapshots) => {
1263
+ originalRestorePendingToolApprovals(
1264
+ sessionId,
1265
+ targetExecutionScope,
1266
+ snapshots
1267
+ );
1268
+ if (armRestoredCheckpointBarrier) {
1269
+ armRestoredCheckpointBarrier = false;
1270
+ blockNextRestoredCheckpointRead = true;
1271
+ }
1272
+ });
1273
+ let markCheckpointReadBlocked = (): void => undefined;
1274
+ const checkpointReadBlocked = new Promise<void>((resolve) => {
1275
+ markCheckpointReadBlocked = resolve;
1276
+ });
1277
+ let releaseBlockedCheckpointRead = (): void => undefined;
1278
+ const blockedCheckpointReadRelease = new Promise<void>((resolve) => {
1279
+ releaseBlockedCheckpointRead = resolve;
570
1280
  });
1281
+ const originalGetTuple = checkpointer.getTuple.bind(checkpointer);
1282
+ const checkpointReadSpy = jest
1283
+ .spyOn(checkpointer, 'getTuple')
1284
+ .mockImplementation(async (config) => {
1285
+ const tuple = await originalGetTuple(config);
1286
+ const configurable = config.configurable;
1287
+ const threadId = configurable?.thread_id;
1288
+ if (
1289
+ blockNextRestoredCheckpointRead &&
1290
+ typeof threadId === 'string' &&
1291
+ threadId.startsWith('subagent:') &&
1292
+ configurable?.checkpoint_id == null
1293
+ ) {
1294
+ blockNextRestoredCheckpointRead = false;
1295
+ markCheckpointReadBlocked();
1296
+ await blockedCheckpointReadRelease;
1297
+ }
1298
+ return tuple;
1299
+ });
1300
+ earlyApprovedRun.Graph!.overrideTestModel(['Final answer.'], 1);
1301
+ approvedRun.Graph!.overrideTestModel(['Final answer.'], 1);
1302
+ rejectedRun.Graph!.overrideTestModel(['Final answer.'], 1);
1303
+ const warningSpy = jest
1304
+ .spyOn(console, 'warn')
1305
+ .mockImplementation((): void => undefined);
1306
+ let rejectedResume: Promise<t.MessageContentComplex[] | undefined> | null =
1307
+ null;
1308
+ try {
1309
+ await earlyApprovedRun.resume([{ type: 'approve' }], branchConfig);
1310
+ await initialRun.resume([{ type: 'approve' }], callerConfig);
1311
+ armRestoredCheckpointBarrier = true;
1312
+ rejectedResume = rejectedRun.resume(
1313
+ [{ type: 'reject', reason: 'deny after restart' }],
1314
+ branchConfig
1315
+ );
1316
+ await checkpointReadBlocked;
1317
+ await approvedRun.resume([{ type: 'approve' }], branchConfig);
1318
+ releaseBlockedCheckpointRead();
1319
+ await rejectedResume;
1320
+ expect(warningSpy).not.toHaveBeenCalledWith(
1321
+ expect.stringContaining('toolCallStepIds missing entry')
1322
+ );
1323
+ } finally {
1324
+ releaseBlockedCheckpointRead();
1325
+ await rejectedResume?.catch(() => undefined);
1326
+ checkpointReadSpy.mockRestore();
1327
+ warningSpy.mockRestore();
1328
+ }
571
1329
 
572
- const tc = makeSubagentToolCall();
573
- run.Graph!.overrideTestModel(['Delegating...', 'Final answer.'], 5, [tc]);
1330
+ expect(earlyApprovedRun.getInterrupt()).toBeUndefined();
1331
+ expect(initialRun.getInterrupt()).toBeUndefined();
1332
+ expect(approvedRun.getInterrupt()).toBeUndefined();
1333
+ expect(rejectedRun.getInterrupt()).toBeUndefined();
1334
+ expect(executedTools).toEqual(['calculator', 'calculator', 'calculator']);
1335
+ expect(approvalHookCalls).toBe(1);
1336
+ const restoredApprovalScopes = new Set(
1337
+ restoreApprovalSpy.mock.calls.map(([, executionScope]) => executionScope)
1338
+ );
1339
+ expect(restoredApprovalScopes.size).toBeGreaterThanOrEqual(4);
1340
+ const initialChildThreads = initialRun.getChildCheckpointThreadIds();
1341
+ const earlyApprovedChildThreads =
1342
+ earlyApprovedRun.getChildCheckpointThreadIds();
1343
+ const approvedChildThreads = approvedRun.getChildCheckpointThreadIds();
1344
+ const rejectedChildThreads = rejectedRun.getChildCheckpointThreadIds();
1345
+ expect(initialChildThreads).toHaveLength(2);
1346
+ expect(earlyApprovedChildThreads).toHaveLength(1);
1347
+ expect(approvedChildThreads).toHaveLength(1);
1348
+ expect(rejectedChildThreads).toHaveLength(1);
1349
+ expect(approvedChildThreads[0]).not.toBe(rejectedChildThreads[0]);
1350
+ expect(earlyApprovedChildThreads[0]).not.toBe(approvedChildThreads[0]);
1351
+ expect(earlyApprovedChildThreads[0]).not.toBe(rejectedChildThreads[0]);
1352
+ expect(initialChildThreads).not.toContain(approvedChildThreads[0]);
1353
+ expect(initialChildThreads).not.toContain(rejectedChildThreads[0]);
1354
+ expect(
1355
+ updates
1356
+ .filter((event) => event.parentToolCallId === tc.id)
1357
+ .map((event) => event.subagentRunId)
1358
+ ).toEqual(expect.arrayContaining([sourceChildRunId]));
1359
+ expect(
1360
+ updates
1361
+ .filter((event) => event.parentToolCallId === tc.id)
1362
+ .every((event) => event.subagentRunId === sourceChildRunId)
1363
+ ).toBe(true);
1364
+ });
574
1365
 
575
- await run.processStream(
576
- { messages: [new HumanMessage('calculate something')] },
1366
+ it('forks nested resumes from each checkpoint in the manifest chain', async () => {
1367
+ getChatModelClassSpy.mockImplementation(((provider: Providers) => {
1368
+ if (provider === Providers.OPENAI) {
1369
+ return NestedHitlFakeChatModel;
1370
+ }
1371
+ return originalGetChatModelClass(provider);
1372
+ }) as typeof providers.getChatModelClass);
1373
+
1374
+ const checkpointer = new MemorySaver();
1375
+ const registry = new HookRegistry();
1376
+ const executedTools: string[] = [];
1377
+ const baseRunId = `nested-subagent-rebuild-${Date.now()}`;
1378
+ registry.registerSession(`${baseRunId}-initial`, 'PreToolUse', {
1379
+ once: true,
1380
+ pattern: '^calculator$',
1381
+ hooks: [
1382
+ async (): Promise<PreToolUseHookOutput> => ({
1383
+ decision: 'ask',
1384
+ reason: 'review nested calculator',
1385
+ }),
1386
+ ],
1387
+ });
1388
+ const customHandlers: Record<string, t.EventHandler> = {
1389
+ [GraphEvents.TOOL_END]: new ToolEndHandler(),
1390
+ [GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
1391
+ [GraphEvents.ON_TOOL_EXECUTE]: {
1392
+ handle: (_event, rawData): void => {
1393
+ const request = rawData as t.ToolExecuteBatchRequest;
1394
+ executedTools.push(...request.toolCalls.map((call) => call.name));
1395
+ request.resolve(
1396
+ request.toolCalls.map((call) => ({
1397
+ toolCallId: call.id,
1398
+ status: 'success' as const,
1399
+ content: '42',
1400
+ }))
1401
+ );
1402
+ },
1403
+ },
1404
+ };
1405
+ const createRun = (runId: string): Promise<Run<t.IState>> =>
1406
+ Run.create<t.IState>({
1407
+ runId,
1408
+ graphConfig: {
1409
+ type: 'standard',
1410
+ agents: [createParentAgentWithNestedChildTool()],
1411
+ compileOptions: { checkpointer },
1412
+ },
1413
+ returnContent: true,
1414
+ skipCleanup: true,
1415
+ customHandlers,
1416
+ hooks: registry,
1417
+ humanInTheLoop: { enabled: true },
1418
+ });
1419
+ const parentCall = makeSubagentToolCall(
1420
+ 'call_nested_researcher',
1421
+ 'Delegate a nested calculation'
1422
+ );
1423
+ const initialRun = await createRun(`${baseRunId}-initial`);
1424
+ initialRun.Graph!.overrideTestModel(['Delegating...', 'Final answer.'], 5, [
1425
+ parentCall,
1426
+ ]);
1427
+
1428
+ await initialRun.processStream(
1429
+ { messages: [new HumanMessage('calculate through two agents')] },
577
1430
  callerConfig
578
1431
  );
1432
+ const paused = initialRun.getInterrupt();
1433
+ expect(paused?.payload).toMatchObject({
1434
+ type: 'tool_approval',
1435
+ subagent: {
1436
+ agent_id: 'calculator-grandchild',
1437
+ parent_tool_call_id: 'call_nested_calculator_worker',
1438
+ },
1439
+ });
1440
+ const oldParentCheckpoint = {
1441
+ ...callerConfig,
1442
+ configurable: {
1443
+ ...callerConfig.configurable,
1444
+ checkpoint_id: paused?.checkpointId,
1445
+ checkpoint_ns: paused?.checkpointNs ?? '',
1446
+ },
1447
+ };
1448
+ const rebuiltRun = await createRun(`${baseRunId}-rebuilt`);
1449
+ rebuiltRun.Graph!.overrideTestModel(['Final answer.'], 1);
1450
+ const warningSpy = jest
1451
+ .spyOn(console, 'warn')
1452
+ .mockImplementation((): void => undefined);
1453
+ try {
1454
+ await initialRun.resume([{ type: 'approve' }], callerConfig);
1455
+ await rebuiltRun.resume(
1456
+ [{ type: 'reject', reason: 'reject stale nested branch' }],
1457
+ oldParentCheckpoint
1458
+ );
1459
+ expect(warningSpy).not.toHaveBeenCalledWith(
1460
+ expect.stringContaining('toolCallStepIds missing entry')
1461
+ );
1462
+ } finally {
1463
+ warningSpy.mockRestore();
1464
+ }
1465
+
1466
+ expect(initialRun.getInterrupt()).toBeUndefined();
1467
+ expect(rebuiltRun.getInterrupt()).toBeUndefined();
1468
+ expect(executedTools).toEqual(['calculator']);
1469
+ expect(initialRun.getChildCheckpointThreadIds().length).toBeGreaterThan(2);
1470
+ expect(rebuiltRun.getChildCheckpointThreadIds().length).toBeGreaterThan(1);
1471
+ });
1472
+
1473
+ it('restores child tool-output references into rebuilt branches', async () => {
1474
+ getChatModelClassSpy.mockImplementation(((provider: Providers) => {
1475
+ if (provider === Providers.OPENAI) {
1476
+ return ReferenceHitlFakeChatModel;
1477
+ }
1478
+ return originalGetChatModelClass(provider);
1479
+ }) as typeof providers.getChatModelClass);
1480
+
1481
+ const referenceValue = 'checkpoint-scoped producer output';
1482
+ const consumedValues: unknown[] = [];
1483
+ const checkpointer = new MemorySaver();
1484
+ const registry = new HookRegistry();
1485
+ const baseRunId = `subagent-reference-rebuild-${Date.now()}`;
1486
+ registry.registerSession(`${baseRunId}-initial`, 'PreToolUse', {
1487
+ once: true,
1488
+ pattern: '^consume_value$',
1489
+ hooks: [
1490
+ async (): Promise<PreToolUseHookOutput> => ({
1491
+ decision: 'ask',
1492
+ reason: 'review referenced input',
1493
+ }),
1494
+ ],
1495
+ });
1496
+ const customHandlers: Record<string, t.EventHandler> = {
1497
+ [GraphEvents.TOOL_END]: new ToolEndHandler(),
1498
+ [GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
1499
+ [GraphEvents.ON_TOOL_EXECUTE]: {
1500
+ handle: (_event, rawData): void => {
1501
+ const request = rawData as t.ToolExecuteBatchRequest;
1502
+ request.resolve(
1503
+ request.toolCalls.map((call) => {
1504
+ if (call.name === 'consume_value') {
1505
+ consumedValues.push(call.args.value);
1506
+ }
1507
+ return {
1508
+ toolCallId: call.id,
1509
+ status: 'success' as const,
1510
+ content:
1511
+ call.name === 'produce_value' ? referenceValue : 'consumed',
1512
+ };
1513
+ })
1514
+ );
1515
+ },
1516
+ },
1517
+ };
1518
+ const createRun = (runId: string): Promise<Run<t.IState>> =>
1519
+ Run.create<t.IState>({
1520
+ runId,
1521
+ graphConfig: {
1522
+ type: 'standard',
1523
+ agents: [createParentAgentWithReferenceTools()],
1524
+ compileOptions: { checkpointer },
1525
+ },
1526
+ returnContent: true,
1527
+ skipCleanup: true,
1528
+ customHandlers,
1529
+ hooks: registry,
1530
+ humanInTheLoop: { enabled: true },
1531
+ toolOutputReferences: { enabled: true },
1532
+ });
1533
+ const parentCall = makeSubagentToolCall('call_reference_researcher');
1534
+ const initialRun = await createRun(`${baseRunId}-initial`);
1535
+ initialRun.Graph!.overrideTestModel(['Delegating...', 'Final answer.'], 5, [
1536
+ parentCall,
1537
+ ]);
579
1538
 
580
- expect(run.getInterrupt()).toBeUndefined();
581
- expect(deniedTools).toContain(
582
- 'researcher-child:calculator:review calculator'
1539
+ await initialRun.processStream(
1540
+ { messages: [new HumanMessage('produce and consume a value')] },
1541
+ callerConfig
583
1542
  );
584
- expect(executedTools).not.toContain('calculator');
1543
+ const paused = initialRun.getInterrupt();
1544
+ expect(paused?.payload).toMatchObject({
1545
+ type: 'tool_approval',
1546
+ action_requests: [
1547
+ { tool_call_id: 'call_consume_value', name: 'consume_value' },
1548
+ ],
1549
+ });
1550
+ const rebuiltRun = await createRun(`${baseRunId}-rebuilt`);
1551
+ rebuiltRun.Graph!.overrideTestModel(['Final answer.'], 1);
1552
+ await rebuiltRun.resume([{ type: 'approve' }], {
1553
+ ...callerConfig,
1554
+ configurable: {
1555
+ ...callerConfig.configurable,
1556
+ checkpoint_id: paused?.checkpointId,
1557
+ checkpoint_ns: paused?.checkpointNs ?? '',
1558
+ },
1559
+ });
1560
+
1561
+ expect(rebuiltRun.getInterrupt()).toBeUndefined();
1562
+ expect(consumedValues).toEqual([referenceValue]);
585
1563
  });
586
1564
  });