@librechat/agents 3.4.0 → 3.4.2

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 (204) hide show
  1. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  2. package/dist/cjs/graphs/Graph.cjs +165 -57
  3. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  4. package/dist/cjs/graphs/MultiAgentGraph.cjs +81 -33
  5. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  6. package/dist/cjs/graphs/applyGraphRuntimeConfig.cjs +14 -0
  7. package/dist/cjs/graphs/applyGraphRuntimeConfig.cjs.map +1 -0
  8. package/dist/cjs/graphs/createGraph.cjs +15 -0
  9. package/dist/cjs/graphs/createGraph.cjs.map +1 -0
  10. package/dist/cjs/graphs/index.cjs +1 -0
  11. package/dist/cjs/hooks/HookRegistry.cjs +83 -0
  12. package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
  13. package/dist/cjs/hooks/createWorkspacePolicyHook.cjs +1 -1
  14. package/dist/cjs/hooks/executeHooks.cjs +60 -14
  15. package/dist/cjs/hooks/executeHooks.cjs.map +1 -1
  16. package/dist/cjs/hooks/index.cjs.map +1 -1
  17. package/dist/cjs/hooks/types.cjs +2 -0
  18. package/dist/cjs/hooks/types.cjs.map +1 -1
  19. package/dist/cjs/llm/init.cjs +3 -3
  20. package/dist/cjs/llm/invoke.cjs +2 -2
  21. package/dist/cjs/main.cjs +34 -24
  22. package/dist/cjs/messages/format.cjs +40 -23
  23. package/dist/cjs/messages/format.cjs.map +1 -1
  24. package/dist/cjs/run.cjs +130 -53
  25. package/dist/cjs/run.cjs.map +1 -1
  26. package/dist/cjs/session/AgentSession.cjs +37 -2
  27. package/dist/cjs/session/AgentSession.cjs.map +1 -1
  28. package/dist/cjs/session/JsonlSessionStore.cjs +2 -2
  29. package/dist/cjs/stream.cjs +1 -1
  30. package/dist/cjs/summarization/node.cjs +6 -3
  31. package/dist/cjs/summarization/node.cjs.map +1 -1
  32. package/dist/cjs/tools/BashExecutor.cjs +1 -1
  33. package/dist/cjs/tools/CodeExecutor.cjs +1 -1
  34. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +1 -1
  35. package/dist/cjs/tools/SubagentTool.cjs +4 -4
  36. package/dist/cjs/tools/SubagentTool.cjs.map +1 -1
  37. package/dist/cjs/tools/ToolNode.cjs +321 -95
  38. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  39. package/dist/cjs/tools/ToolSearch.cjs +1 -1
  40. package/dist/cjs/tools/eagerEventExecution.cjs +1 -0
  41. package/dist/cjs/tools/local/LocalExecutionEngine.cjs +3 -3
  42. package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs +2 -2
  43. package/dist/cjs/tools/search/rerankers.cjs +154 -1
  44. package/dist/cjs/tools/search/rerankers.cjs.map +1 -1
  45. package/dist/cjs/tools/search/tool.cjs +4 -1
  46. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  47. package/dist/cjs/tools/subagent/SubagentExecutionRegistry.cjs +601 -0
  48. package/dist/cjs/tools/subagent/SubagentExecutionRegistry.cjs.map +1 -0
  49. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1114 -233
  50. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  51. package/dist/cjs/tools/subagent/SubagentReplay.cjs +177 -0
  52. package/dist/cjs/tools/subagent/SubagentReplay.cjs.map +1 -0
  53. package/dist/cjs/tools/subagent/childGraphConfig.cjs +278 -0
  54. package/dist/cjs/tools/subagent/childGraphConfig.cjs.map +1 -0
  55. package/dist/cjs/tools/subagent/index.cjs +1 -0
  56. package/dist/cjs/tools/subagent/runtimeLimits.cjs +5 -0
  57. package/dist/cjs/tools/subagent/runtimeLimits.cjs.map +1 -0
  58. package/dist/cjs/tools/toolOutputReferences.cjs +20 -0
  59. package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
  60. package/dist/cjs/utils/index.cjs +2 -2
  61. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  62. package/dist/esm/graphs/Graph.mjs +165 -57
  63. package/dist/esm/graphs/Graph.mjs.map +1 -1
  64. package/dist/esm/graphs/MultiAgentGraph.mjs +81 -33
  65. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  66. package/dist/esm/graphs/applyGraphRuntimeConfig.mjs +14 -0
  67. package/dist/esm/graphs/applyGraphRuntimeConfig.mjs.map +1 -0
  68. package/dist/esm/graphs/createGraph.mjs +15 -0
  69. package/dist/esm/graphs/createGraph.mjs.map +1 -0
  70. package/dist/esm/graphs/index.mjs +1 -0
  71. package/dist/esm/hooks/HookRegistry.mjs +83 -0
  72. package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
  73. package/dist/esm/hooks/createWorkspacePolicyHook.mjs +1 -1
  74. package/dist/esm/hooks/executeHooks.mjs +60 -14
  75. package/dist/esm/hooks/executeHooks.mjs.map +1 -1
  76. package/dist/esm/hooks/index.mjs.map +1 -1
  77. package/dist/esm/hooks/types.mjs +2 -1
  78. package/dist/esm/hooks/types.mjs.map +1 -1
  79. package/dist/esm/llm/init.mjs +1 -1
  80. package/dist/esm/llm/invoke.mjs +2 -2
  81. package/dist/esm/main.mjs +21 -19
  82. package/dist/esm/messages/format.mjs +40 -23
  83. package/dist/esm/messages/format.mjs.map +1 -1
  84. package/dist/esm/run.mjs +131 -54
  85. package/dist/esm/run.mjs.map +1 -1
  86. package/dist/esm/session/AgentSession.mjs +37 -2
  87. package/dist/esm/session/AgentSession.mjs.map +1 -1
  88. package/dist/esm/session/JsonlSessionStore.mjs +2 -2
  89. package/dist/esm/stream.mjs +1 -1
  90. package/dist/esm/summarization/node.mjs +6 -3
  91. package/dist/esm/summarization/node.mjs.map +1 -1
  92. package/dist/esm/tools/BashExecutor.mjs +1 -1
  93. package/dist/esm/tools/CodeExecutor.mjs +1 -1
  94. package/dist/esm/tools/ProgrammaticToolCalling.mjs +1 -1
  95. package/dist/esm/tools/SubagentTool.mjs +4 -4
  96. package/dist/esm/tools/SubagentTool.mjs.map +1 -1
  97. package/dist/esm/tools/ToolNode.mjs +322 -96
  98. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  99. package/dist/esm/tools/ToolSearch.mjs +1 -1
  100. package/dist/esm/tools/eagerEventExecution.mjs +1 -1
  101. package/dist/esm/tools/local/LocalExecutionEngine.mjs +3 -3
  102. package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs +2 -2
  103. package/dist/esm/tools/search/rerankers.mjs +154 -1
  104. package/dist/esm/tools/search/rerankers.mjs.map +1 -1
  105. package/dist/esm/tools/search/tool.mjs +4 -1
  106. package/dist/esm/tools/search/tool.mjs.map +1 -1
  107. package/dist/esm/tools/subagent/SubagentExecutionRegistry.mjs +597 -0
  108. package/dist/esm/tools/subagent/SubagentExecutionRegistry.mjs.map +1 -0
  109. package/dist/esm/tools/subagent/SubagentExecutor.mjs +1114 -233
  110. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  111. package/dist/esm/tools/subagent/SubagentReplay.mjs +170 -0
  112. package/dist/esm/tools/subagent/SubagentReplay.mjs.map +1 -0
  113. package/dist/esm/tools/subagent/childGraphConfig.mjs +272 -0
  114. package/dist/esm/tools/subagent/childGraphConfig.mjs.map +1 -0
  115. package/dist/esm/tools/subagent/index.mjs +1 -0
  116. package/dist/esm/tools/subagent/runtimeLimits.mjs +5 -0
  117. package/dist/esm/tools/subagent/runtimeLimits.mjs.map +1 -0
  118. package/dist/esm/tools/toolOutputReferences.mjs +20 -0
  119. package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
  120. package/dist/esm/utils/index.mjs +2 -2
  121. package/dist/types/agents/AgentContext.d.ts +1 -1
  122. package/dist/types/graphs/Graph.d.ts +22 -2
  123. package/dist/types/graphs/MultiAgentGraph.d.ts +7 -1
  124. package/dist/types/graphs/applyGraphRuntimeConfig.d.ts +3 -0
  125. package/dist/types/graphs/createGraph.d.ts +13 -0
  126. package/dist/types/graphs/graphFactory.d.ts +13 -0
  127. package/dist/types/graphs/index.d.ts +2 -0
  128. package/dist/types/hooks/HookRegistry.d.ts +10 -1
  129. package/dist/types/hooks/executeHooks.d.ts +5 -1
  130. package/dist/types/hooks/index.d.ts +2 -2
  131. package/dist/types/hooks/types.d.ts +12 -0
  132. package/dist/types/run.d.ts +6 -3
  133. package/dist/types/session/AgentSession.d.ts +1 -0
  134. package/dist/types/tools/SubagentTool.d.ts +1 -1
  135. package/dist/types/tools/ToolNode.d.ts +23 -15
  136. package/dist/types/tools/search/rerankers.d.ts +28 -0
  137. package/dist/types/tools/search/types.d.ts +38 -1
  138. package/dist/types/tools/subagent/SubagentExecutionRegistry.d.ts +171 -0
  139. package/dist/types/tools/subagent/SubagentExecutor.d.ts +70 -36
  140. package/dist/types/tools/subagent/SubagentReplay.d.ts +85 -0
  141. package/dist/types/tools/subagent/childGraphConfig.d.ts +37 -0
  142. package/dist/types/tools/subagent/index.d.ts +1 -1
  143. package/dist/types/tools/subagent/runtimeLimits.d.ts +4 -0
  144. package/dist/types/tools/toolOutputReferences.d.ts +12 -0
  145. package/dist/types/types/graph.d.ts +189 -24
  146. package/dist/types/types/hitl.d.ts +15 -0
  147. package/package.json +8 -2
  148. package/src/agents/AgentContext.ts +1 -1
  149. package/src/graphs/Graph.ts +349 -104
  150. package/src/graphs/MultiAgentGraph.ts +149 -47
  151. package/src/graphs/__tests__/Graph.breakerLifecycle.test.ts +48 -9
  152. package/src/graphs/__tests__/Graph.contextOverflow.test.ts +1 -1
  153. package/src/graphs/__tests__/Graph.subagentResumeState.test.ts +80 -0
  154. package/src/graphs/__tests__/MultiAgentGraph.test.ts +86 -0
  155. package/src/graphs/__tests__/composition.smoke.test.ts +152 -0
  156. package/src/graphs/__tests__/createGraph.test.ts +206 -0
  157. package/src/graphs/applyGraphRuntimeConfig.ts +25 -0
  158. package/src/graphs/createGraph.ts +32 -0
  159. package/src/graphs/graphFactory.ts +12 -0
  160. package/src/graphs/index.ts +2 -0
  161. package/src/hooks/HookRegistry.ts +151 -1
  162. package/src/hooks/__tests__/HookRegistry.test.ts +96 -0
  163. package/src/hooks/__tests__/executeHooks.test.ts +160 -0
  164. package/src/hooks/executeHooks.ts +133 -15
  165. package/src/hooks/index.ts +3 -1
  166. package/src/hooks/types.ts +16 -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 +214 -56
  172. package/src/session/AgentSession.ts +52 -2
  173. package/src/session/__tests__/JsonlSessionStore.test.ts +53 -0
  174. package/src/specs/graph-subagent.live.test.ts +242 -0
  175. package/src/specs/graph-subagent.test.ts +322 -0
  176. package/src/specs/langfuse-routing.integration.test.ts +120 -1
  177. package/src/specs/subagent.test.ts +130 -4
  178. package/src/summarization/__tests__/node.test.ts +32 -14
  179. package/src/summarization/node.ts +15 -11
  180. package/src/tools/SubagentTool.ts +4 -4
  181. package/src/tools/ToolNode.ts +643 -157
  182. package/src/tools/__tests__/SubagentExecutor.test.ts +740 -15
  183. package/src/tools/__tests__/SubagentReplay.test.ts +329 -0
  184. package/src/tools/__tests__/ToolNode.breakerSignal.test.ts +208 -2
  185. package/src/tools/__tests__/directToolHITLResumeScope.test.ts +512 -2
  186. package/src/tools/__tests__/hitl.test.ts +234 -5
  187. package/src/tools/__tests__/subagentHooks.test.ts +1126 -61
  188. package/src/tools/__tests__/toolOutputReferences.test.ts +19 -2
  189. package/src/tools/search/rag-api-reranker.test.ts +891 -0
  190. package/src/tools/search/rerankers.ts +260 -0
  191. package/src/tools/search/tool.ts +6 -0
  192. package/src/tools/search/types.ts +45 -1
  193. package/src/tools/subagent/SubagentExecutionRegistry.ts +1159 -0
  194. package/src/tools/subagent/SubagentExecutor.ts +2232 -312
  195. package/src/tools/subagent/SubagentReplay.ts +584 -0
  196. package/src/tools/subagent/__tests__/SubagentExecutionRegistry.test.ts +637 -0
  197. package/src/tools/subagent/__tests__/SubagentExecutor.lazy.test.ts +3054 -0
  198. package/src/tools/subagent/__tests__/childGraphConfig.test.ts +521 -0
  199. package/src/tools/subagent/childGraphConfig.ts +694 -0
  200. package/src/tools/subagent/index.ts +6 -0
  201. package/src/tools/subagent/runtimeLimits.ts +7 -0
  202. package/src/tools/toolOutputReferences.ts +40 -1
  203. package/src/types/graph.ts +222 -24
  204. 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()}`
@@ -223,6 +496,60 @@ describe('Subagent hook integration (end-to-end via Run)', () => {
223
496
  expect(captured!.messages.length).toBeGreaterThan(0);
224
497
  });
225
498
 
499
+ it('propagates a caller-only Run signal into lazy subagent resolution', async () => {
500
+ const callerAbort = new AbortController();
501
+ let resolverSignal: AbortSignal | undefined;
502
+ let markResolverStarted = (): void => undefined;
503
+ const resolverStarted = new Promise<void>((resolve) => {
504
+ markResolverStarted = resolve;
505
+ });
506
+ let releaseResolver = (): void => undefined;
507
+ const resolverRelease = new Promise<void>((resolve) => {
508
+ releaseResolver = resolve;
509
+ });
510
+ const parent = createParentAgent();
511
+ const eagerChild = parent.subagentConfigs?.[0]?.agentInputs;
512
+ if (eagerChild == null) {
513
+ throw new Error('Expected eager child inputs.');
514
+ }
515
+ parent.subagentConfigs = [
516
+ {
517
+ type: 'researcher',
518
+ name: 'Researcher',
519
+ description: 'Researches topics',
520
+ configId: 'caller-signal-researcher@v1',
521
+ resolveAgentInputs: async (context) => {
522
+ resolverSignal = context.signal;
523
+ markResolverStarted();
524
+ await resolverRelease;
525
+ return eagerChild;
526
+ },
527
+ },
528
+ ];
529
+ const run = await Run.create<t.IState>({
530
+ runId: `subagent-caller-signal-${Date.now()}`,
531
+ graphConfig: { type: 'standard', agents: [parent] },
532
+ returnContent: true,
533
+ skipCleanup: true,
534
+ });
535
+ run.Graph!.overrideTestModel(['Delegating...', 'Caller cancelled.'], 5, [
536
+ makeSubagentToolCall('call_caller_signal'),
537
+ ]);
538
+
539
+ const processing = run.processStream(
540
+ { messages: [new HumanMessage('cancel this delegation')] },
541
+ { ...callerConfig, signal: callerAbort.signal }
542
+ );
543
+ await resolverStarted;
544
+ callerAbort.abort(new Error('caller cancelled'));
545
+ await Promise.resolve();
546
+ const resolverWasAborted = resolverSignal?.aborted === true;
547
+ releaseResolver();
548
+ await processing.catch(() => undefined);
549
+
550
+ expect(resolverWasAborted).toBe(true);
551
+ });
552
+
226
553
  it('SubagentStart deny blocks subagent execution and returns blocked message', async () => {
227
554
  const registry = new HookRegistry();
228
555
  const denyHook: HookCallback<
@@ -489,54 +816,458 @@ describe('Subagent hook integration (end-to-end via Run)', () => {
489
816
  expect(dispatchAgentIds).toEqual(['hook-parent']);
490
817
  });
491
818
 
492
- it('child subagent tool ask hooks fail closed instead of starting unsupported nested HITL', async () => {
819
+ it.each([
820
+ {
821
+ label: 'approve',
822
+ resumeDecision: { type: 'approve' } as const,
823
+ shouldExecute: true,
824
+ deniedReason: undefined,
825
+ },
826
+ {
827
+ label: 'reject',
828
+ resumeDecision: {
829
+ type: 'reject',
830
+ reason: 'host rejected child tool',
831
+ } as const,
832
+ shouldExecute: false,
833
+ deniedReason: 'host rejected child tool',
834
+ },
835
+ {
836
+ label: 'deny',
837
+ resumeDecision: undefined,
838
+ shouldExecute: false,
839
+ deniedReason: 'policy denied child tool',
840
+ },
841
+ ])(
842
+ 'handles a child subagent tool $label through the parent Run',
843
+ async ({ resumeDecision, shouldExecute, deniedReason }) => {
844
+ getChatModelClassSpy.mockImplementation(((provider: Providers) => {
845
+ if (provider === Providers.OPENAI) {
846
+ return HitlChildFakeChatModel;
847
+ }
848
+ return originalGetChatModelClass(provider);
849
+ }) as typeof providers.getChatModelClass);
850
+
851
+ const registry = new HookRegistry();
852
+ const deniedTools: string[] = [];
853
+ const executedTools: string[] = [];
854
+ let calculatorPreToolCalls = 0;
855
+ let calculatorPostToolCalls = 0;
856
+
857
+ const preHook: HookCallback<'PreToolUse'> = async (
858
+ input
859
+ ): Promise<PreToolUseHookOutput> => {
860
+ if (input.toolName === 'calculator') {
861
+ calculatorPreToolCalls += 1;
862
+ if (resumeDecision == null) {
863
+ return {
864
+ decision: 'deny',
865
+ reason: 'policy denied child tool',
866
+ };
867
+ }
868
+ return { decision: 'ask', reason: 'review calculator' };
869
+ }
870
+ return { decision: 'allow' };
871
+ };
872
+ registry.register('PreToolUse', { hooks: [preHook] });
873
+ registry.register('PostToolUse', {
874
+ hooks: [
875
+ async (input): Promise<PostToolUseHookOutput> => {
876
+ if (input.toolName === 'calculator') {
877
+ calculatorPostToolCalls += 1;
878
+ }
879
+ return {};
880
+ },
881
+ ],
882
+ });
883
+
884
+ const deniedHook: HookCallback<'PermissionDenied'> = async (
885
+ input
886
+ ): Promise<PermissionDeniedHookOutput> => {
887
+ deniedTools.push(
888
+ `${input.agentId ?? '-'}:${input.toolName}:${input.reason}`
889
+ );
890
+ return {};
891
+ };
892
+ registry.register('PermissionDenied', { hooks: [deniedHook] });
893
+
894
+ const customHandlers: Record<string, t.EventHandler> = {
895
+ [GraphEvents.TOOL_END]: new ToolEndHandler(),
896
+ [GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
897
+ [GraphEvents.ON_TOOL_EXECUTE]: {
898
+ handle: (_event, rawData): void => {
899
+ const request = rawData as t.ToolExecuteBatchRequest;
900
+ executedTools.push(...request.toolCalls.map((call) => call.name));
901
+ const results: t.ToolExecuteResult[] = request.toolCalls.map(
902
+ (call) => ({
903
+ toolCallId: call.id,
904
+ status: 'success',
905
+ content: '42',
906
+ })
907
+ );
908
+ request.resolve(results);
909
+ },
910
+ },
911
+ };
912
+
913
+ const run = await Run.create<t.IState>({
914
+ runId: `subagent-tool-ask-${Date.now()}`,
915
+ graphConfig: {
916
+ type: 'standard',
917
+ agents: [createParentAgentWithChildTool()],
918
+ },
919
+ returnContent: true,
920
+ skipCleanup: true,
921
+ customHandlers,
922
+ hooks: registry,
923
+ humanInTheLoop: { enabled: true },
924
+ });
925
+
926
+ const tc = makeSubagentToolCall();
927
+ run.Graph!.overrideTestModel(['Delegating...', 'Final answer.'], 5, [tc]);
928
+
929
+ await run.processStream(
930
+ { messages: [new HumanMessage('calculate something')] },
931
+ callerConfig
932
+ );
933
+
934
+ if (resumeDecision == null) {
935
+ expect(run.getInterrupt()).toBeUndefined();
936
+ expect(calculatorPreToolCalls).toBe(1);
937
+ expect(executedTools).not.toContain('calculator');
938
+ expect(deniedTools).toEqual([
939
+ `researcher-child:calculator:${deniedReason}`,
940
+ ]);
941
+ return;
942
+ }
943
+
944
+ const pending = run.getInterrupt();
945
+ expect(pending?.payload).toMatchObject({
946
+ type: 'tool_approval',
947
+ subagent: {
948
+ agent_id: 'researcher-child',
949
+ parent_tool_call_id: tc.id,
950
+ subagent_type: 'researcher',
951
+ },
952
+ });
953
+ expect(executedTools).not.toContain('calculator');
954
+
955
+ await run.resume([resumeDecision], callerConfig);
956
+
957
+ expect(run.getInterrupt()).toBeUndefined();
958
+ expect(calculatorPreToolCalls).toBe(2);
959
+ expect(
960
+ executedTools.filter((name) => name === 'calculator')
961
+ ).toHaveLength(shouldExecute ? 1 : 0);
962
+ expect(calculatorPostToolCalls).toBe(shouldExecute ? 1 : 0);
963
+ expect(deniedTools).toEqual(
964
+ shouldExecute ? [] : [`researcher-child:calculator:${deniedReason}`]
965
+ );
966
+ }
967
+ );
968
+
969
+ it('resumes approvals across multiple subagents and keeps updates sanitized', async () => {
493
970
  getChatModelClassSpy.mockImplementation(((provider: Providers) => {
494
971
  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;
972
+ return HitlChildFakeChatModel;
512
973
  }
513
974
  return originalGetChatModelClass(provider);
514
975
  }) as typeof providers.getChatModelClass);
515
976
 
516
977
  const registry = new HookRegistry();
517
- const deniedTools: string[] = [];
518
- const executedTools: string[] = [];
978
+ const deniedToolIds: string[] = [];
979
+ const executedToolIds: string[] = [];
980
+ const updates: t.SubagentUpdateEvent[] = [];
981
+ const completedSubagentCalls: string[] = [];
519
982
 
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' };
983
+ registry.register('PreToolUse', {
984
+ hooks: [
985
+ async (input): Promise<PreToolUseHookOutput> =>
986
+ input.toolName === 'calculator'
987
+ ? { decision: 'ask', reason: 'review child calculator' }
988
+ : { decision: 'allow' },
989
+ ],
990
+ });
991
+ registry.register('PostToolUse', {
992
+ hooks: [
993
+ async (input): Promise<PostToolUseHookOutput> => {
994
+ if (input.toolName === Constants.SUBAGENT) {
995
+ completedSubagentCalls.push(input.toolUseId);
996
+ return { additionalContext: `context:${input.toolUseId}` };
997
+ }
998
+ return {};
999
+ },
1000
+ ],
1001
+ });
1002
+ registry.register('PermissionDenied', {
1003
+ hooks: [
1004
+ async (input): Promise<PermissionDeniedHookOutput> => {
1005
+ deniedToolIds.push(input.toolUseId);
1006
+ return {};
1007
+ },
1008
+ ],
1009
+ });
1010
+
1011
+ const customHandlers: Record<string, t.EventHandler> = {
1012
+ [GraphEvents.TOOL_END]: new ToolEndHandler(),
1013
+ [GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
1014
+ [GraphEvents.ON_TOOL_EXECUTE]: {
1015
+ handle: (_event, rawData): void => {
1016
+ const request = rawData as t.ToolExecuteBatchRequest;
1017
+ executedToolIds.push(...request.toolCalls.map((call) => call.id));
1018
+ request.resolve(
1019
+ request.toolCalls.map((call) => ({
1020
+ toolCallId: call.id,
1021
+ status: 'success' as const,
1022
+ content: '42',
1023
+ }))
1024
+ );
1025
+ },
1026
+ },
1027
+ [GraphEvents.ON_SUBAGENT_UPDATE]: {
1028
+ handle: (_event, data): void => {
1029
+ updates.push(data as t.SubagentUpdateEvent);
1030
+ },
1031
+ },
527
1032
  };
528
- registry.register('PreToolUse', { hooks: [preHook] });
529
1033
 
530
- const deniedHook: HookCallback<'PermissionDenied'> = async (
531
- input
532
- ): Promise<PermissionDeniedHookOutput> => {
533
- deniedTools.push(
534
- `${input.agentId ?? '-'}:${input.toolName}:${input.reason}`
1034
+ const checkpointer = new MemorySaver();
1035
+ const baseRunId = `subagent-multi-hitl-${Date.now()}`;
1036
+ const createRun = (runId: string): Promise<Run<t.IState>> =>
1037
+ Run.create<t.IState>({
1038
+ runId,
1039
+ graphConfig: {
1040
+ type: 'standard',
1041
+ agents: [createParentAgentWithChildTool()],
1042
+ compileOptions: { checkpointer, interruptBefore: [] },
1043
+ },
1044
+ returnContent: true,
1045
+ skipCleanup: true,
1046
+ customHandlers,
1047
+ hooks: registry,
1048
+ humanInTheLoop: { enabled: true },
1049
+ });
1050
+ const run = await createRun(`${baseRunId}-initial`);
1051
+ const first = makeSubagentToolCall(
1052
+ 'call_sub_first',
1053
+ 'Run the first calculation'
1054
+ );
1055
+ const second = makeSubagentToolCall(
1056
+ 'call_sub_second',
1057
+ 'Run the second calculation'
1058
+ );
1059
+ run.Graph!.overrideTestModel(['Delegating...', 'Final answer.'], 5, [
1060
+ first,
1061
+ second,
1062
+ ]);
1063
+ const multiCallerConfig = {
1064
+ ...callerConfig,
1065
+ configurable: {
1066
+ thread_id: 'multi-subagent-hitl-thread',
1067
+ access_token: 'must-not-leak',
1068
+ requestBody: { currentTaskInput: 'must-not-leak-task' },
1069
+ userMCPAuthMap: { private: { token: 'must-not-leak-mcp' } },
1070
+ },
1071
+ };
1072
+
1073
+ await run.processStream(
1074
+ { messages: [new HumanMessage('calculate twice')] },
1075
+ multiCallerConfig
1076
+ );
1077
+ expect(run.getInterrupt()?.payload).toMatchObject({
1078
+ type: 'tool_approval',
1079
+ subagent: { parent_tool_call_id: first.id },
1080
+ });
1081
+ expect(updates.some((event) => event.parentToolCallId === second.id)).toBe(
1082
+ true
1083
+ );
1084
+
1085
+ await run.resume([{ type: 'approve' }], multiCallerConfig);
1086
+ expect(run.getInterrupt()?.payload).toMatchObject({
1087
+ type: 'tool_approval',
1088
+ subagent: { parent_tool_call_id: second.id },
1089
+ });
1090
+
1091
+ const rebuiltRun = await createRun(`${baseRunId}-rebuilt`);
1092
+ rebuiltRun.Graph!.overrideTestModel(['Final answer.'], 1);
1093
+ await rebuiltRun.resume(
1094
+ [{ type: 'reject', reason: 'reject second child' }],
1095
+ multiCallerConfig
1096
+ );
1097
+
1098
+ expect(rebuiltRun.getInterrupt()).toBeUndefined();
1099
+ expect(executedToolIds).toHaveLength(1);
1100
+ expect(deniedToolIds).toHaveLength(1);
1101
+ expect(completedSubagentCalls).toEqual([first.id, second.id]);
1102
+ const firstPhases = updates
1103
+ .filter((event) => event.parentToolCallId === first.id)
1104
+ .map((event) => event.phase);
1105
+ const secondPhases = updates
1106
+ .filter((event) => event.parentToolCallId === second.id)
1107
+ .map((event) => event.phase);
1108
+ expect(firstPhases[0]).toBe('start');
1109
+ expect(firstPhases[firstPhases.length - 1]).toBe('stop');
1110
+ expect(secondPhases[0]).toBe('start');
1111
+ expect(secondPhases[secondPhases.length - 1]).toBe('stop');
1112
+ const serializedUpdates = JSON.stringify(updates);
1113
+ expect(serializedUpdates).not.toContain('must-not-leak');
1114
+ expect(serializedUpdates).not.toContain('currentTaskInput');
1115
+ expect(serializedUpdates).not.toContain('userMCPAuthMap');
1116
+ expect(serializedUpdates).not.toContain('checkpoint_');
1117
+ });
1118
+
1119
+ it('resumes a primitive child interrupt through a rebuilt Run', async () => {
1120
+ getChatModelClassSpy.mockImplementation(((provider: Providers) => {
1121
+ if (provider === Providers.OPENAI) {
1122
+ return PrimitiveInterruptFakeChatModel;
1123
+ }
1124
+ return originalGetChatModelClass(provider);
1125
+ }) as typeof providers.getChatModelClass);
1126
+
1127
+ const checkpointer = new MemorySaver();
1128
+ const resumedValues: string[] = [];
1129
+ const primitiveInterruptTool = tool(
1130
+ async () => {
1131
+ const resumeValue = interrupt<string, string>('confirm child');
1132
+ resumedValues.push(resumeValue);
1133
+ return `confirmed:${resumeValue}`;
1134
+ },
1135
+ {
1136
+ name: PRIMITIVE_INTERRUPT_TOOL_NAME,
1137
+ description: 'Pause for primitive confirmation.',
1138
+ schema: z.object({}),
1139
+ }
1140
+ );
1141
+ const resolutionContexts: t.SubagentResolveContext[] = [];
1142
+ const createLazyParent = (): t.AgentInputs => {
1143
+ const parent = createParentAgentWithPrimitiveInterruptTool(
1144
+ primitiveInterruptTool
535
1145
  );
536
- return {};
1146
+ const child = parent.subagentConfigs?.[0];
1147
+ const childInputs = child?.agentInputs;
1148
+ if (child == null || childInputs == null) {
1149
+ throw new Error('Expected a child agent configuration.');
1150
+ }
1151
+ return {
1152
+ ...parent,
1153
+ subagentConfigs: [
1154
+ {
1155
+ ...child,
1156
+ agentInputs: undefined,
1157
+ configId: 'primitive-interrupt-child@v1',
1158
+ resolveAgentInputs: async (context) => {
1159
+ resolutionContexts.push(context);
1160
+ return childInputs;
1161
+ },
1162
+ },
1163
+ ],
1164
+ };
537
1165
  };
538
- registry.register('PermissionDenied', { hooks: [deniedHook] });
1166
+ const createRun = (currentRunId: string): Promise<Run<t.IState>> =>
1167
+ Run.create<t.IState>({
1168
+ runId: currentRunId,
1169
+ graphConfig: {
1170
+ type: 'standard',
1171
+ agents: [createLazyParent()],
1172
+ compileOptions: { checkpointer },
1173
+ },
1174
+ returnContent: true,
1175
+ skipCleanup: true,
1176
+ customHandlers: {
1177
+ [GraphEvents.TOOL_END]: new ToolEndHandler(),
1178
+ [GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
1179
+ },
1180
+ humanInTheLoop: { enabled: true },
1181
+ });
1182
+ const runId = `primitive-subagent-rebuild-${Date.now()}`;
1183
+ const parentCall = makeSubagentToolCall('call_primitive_rebuild');
1184
+ const initialRun = await createRun(`${runId}-initial`);
1185
+ initialRun.Graph!.overrideTestModel(['Delegating...', 'Final answer.'], 5, [
1186
+ parentCall,
1187
+ ]);
539
1188
 
1189
+ await initialRun.processStream(
1190
+ { messages: [new HumanMessage('confirm inside the child')] },
1191
+ callerConfig
1192
+ );
1193
+ const paused = initialRun.getInterrupt<string>();
1194
+ expect(paused?.payload).toBe('confirm child');
1195
+ expect(JSON.stringify(paused)).not.toContain(
1196
+ '__librechat_subagent_resume_manifest'
1197
+ );
1198
+ if (paused?.checkpointId == null) {
1199
+ throw new Error('Expected a persisted parent checkpoint.');
1200
+ }
1201
+
1202
+ const rebuiltRun = await createRun(`${runId}-rebuilt`);
1203
+ rebuiltRun.Graph!.overrideTestModel(['Final answer.'], 1);
1204
+ const injected = new HumanMessage('host edit on resume');
1205
+ const rebuiltContent = await rebuiltRun.resume<string>(
1206
+ 'approved after restart',
1207
+ {
1208
+ ...callerConfig,
1209
+ configurable: {
1210
+ ...callerConfig.configurable,
1211
+ checkpoint_id: paused.checkpointId,
1212
+ checkpoint_ns: paused.checkpointNs ?? '',
1213
+ },
1214
+ },
1215
+ undefined,
1216
+ { update: { messages: [injected] } }
1217
+ );
1218
+
1219
+ expect(rebuiltRun.getInterrupt()).toBeUndefined();
1220
+ expect(resolutionContexts).toHaveLength(2);
1221
+ expect(resolutionContexts[0].executionId).toBe(
1222
+ resolutionContexts[1].executionId
1223
+ );
1224
+ expect(resolutionContexts[0].descriptor.configId).toBe(
1225
+ 'primitive-interrupt-child@v1'
1226
+ );
1227
+ expect(resolutionContexts[1].descriptor.configId).toBe(
1228
+ 'primitive-interrupt-child@v1'
1229
+ );
1230
+ expect(resumedValues).toEqual(['approved after restart']);
1231
+ expect(JSON.stringify(rebuiltContent)).toContain('Final answer.');
1232
+ expect(JSON.stringify(rebuiltContent)).not.toContain('host edit on resume');
1233
+ expect(
1234
+ rebuiltRun
1235
+ .getRunMessages()
1236
+ ?.some(
1237
+ (message) =>
1238
+ message._getType() === 'ai' &&
1239
+ JSON.stringify(message.content).includes('Final answer.')
1240
+ )
1241
+ ).toBe(true);
1242
+ expect(
1243
+ rebuiltRun.getRunMessages()?.some((message) => message.id === injected.id)
1244
+ ).toBe(false);
1245
+ });
1246
+
1247
+ it('forks every resume from the paused child snapshot', async () => {
1248
+ getChatModelClassSpy.mockImplementation(((provider: Providers) => {
1249
+ if (provider === Providers.OPENAI) {
1250
+ return HitlChildFakeChatModel;
1251
+ }
1252
+ return originalGetChatModelClass(provider);
1253
+ }) as typeof providers.getChatModelClass);
1254
+
1255
+ const checkpointer = new MemorySaver();
1256
+ const registry = new HookRegistry();
1257
+ const executedTools: string[] = [];
1258
+ const updates: t.SubagentUpdateEvent[] = [];
1259
+ let approvalHookCalls = 0;
1260
+ const runId = `subagent-rebuild-hitl-${Date.now()}`;
1261
+ registry.registerSession(`${runId}-initial`, 'PreToolUse', {
1262
+ once: true,
1263
+ pattern: '^calculator$',
1264
+ hooks: [
1265
+ async (): Promise<PreToolUseHookOutput> => {
1266
+ approvalHookCalls += 1;
1267
+ return { decision: 'ask', reason: 'review calculator' };
1268
+ },
1269
+ ],
1270
+ });
540
1271
  const customHandlers: Record<string, t.EventHandler> = {
541
1272
  [GraphEvents.TOOL_END]: new ToolEndHandler(),
542
1273
  [GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
@@ -544,43 +1275,377 @@ describe('Subagent hook integration (end-to-end via Run)', () => {
544
1275
  handle: (_event, rawData): void => {
545
1276
  const request = rawData as t.ToolExecuteBatchRequest;
546
1277
  executedTools.push(...request.toolCalls.map((call) => call.name));
547
- const results: t.ToolExecuteResult[] = request.toolCalls.map(
548
- (call) => ({
1278
+ request.resolve(
1279
+ request.toolCalls.map((call) => ({
549
1280
  toolCallId: call.id,
550
- status: 'success',
1281
+ status: 'success' as const,
551
1282
  content: '42',
552
- })
1283
+ }))
553
1284
  );
554
- request.resolve(results);
1285
+ },
1286
+ },
1287
+ [GraphEvents.ON_SUBAGENT_UPDATE]: {
1288
+ handle: (_event, data): void => {
1289
+ updates.push(data as t.SubagentUpdateEvent);
555
1290
  },
556
1291
  },
557
1292
  };
1293
+ const createRun = (currentRunId: string): Promise<Run<t.IState>> =>
1294
+ Run.create<t.IState>({
1295
+ runId: currentRunId,
1296
+ graphConfig: {
1297
+ type: 'standard',
1298
+ agents: [createParentAgentWithChildTool()],
1299
+ compileOptions: { checkpointer },
1300
+ },
1301
+ returnContent: true,
1302
+ skipCleanup: true,
1303
+ customHandlers,
1304
+ hooks: registry,
1305
+ humanInTheLoop: { enabled: true },
1306
+ });
1307
+ const tc = makeSubagentToolCall('call_sub_rebuild');
1308
+ const initialRun = await createRun(`${runId}-initial`);
1309
+ initialRun.Graph!.overrideTestModel(['Delegating...', 'Final answer.'], 5, [
1310
+ tc,
1311
+ ]);
558
1312
 
559
- const run = await Run.create<t.IState>({
560
- runId: `subagent-tool-ask-${Date.now()}`,
561
- graphConfig: {
562
- type: 'standard',
563
- agents: [createParentAgentWithChildTool()],
1313
+ await initialRun.processStream(
1314
+ { messages: [new HumanMessage('calculate after restart')] },
1315
+ callerConfig
1316
+ );
1317
+ const persistedInterrupt = initialRun.getInterrupt();
1318
+ expect(persistedInterrupt?.payload).toMatchObject({
1319
+ type: 'tool_approval',
1320
+ subagent: { parent_tool_call_id: tc.id },
1321
+ });
1322
+ expect(JSON.stringify(persistedInterrupt)).not.toContain(
1323
+ '__librechat_subagent_resume_manifest'
1324
+ );
1325
+ expect(approvalHookCalls).toBe(1);
1326
+ const sourceChildRunId =
1327
+ persistedInterrupt?.payload.type === 'tool_approval'
1328
+ ? persistedInterrupt.payload.subagent?.run_id
1329
+ : undefined;
1330
+ expect(sourceChildRunId).toBeDefined();
1331
+ const branchConfig = {
1332
+ ...callerConfig,
1333
+ configurable: {
1334
+ ...callerConfig.configurable,
1335
+ checkpoint_id: persistedInterrupt?.checkpointId,
1336
+ checkpoint_ns: persistedInterrupt?.checkpointNs ?? '',
564
1337
  },
565
- returnContent: true,
566
- skipCleanup: true,
567
- customHandlers,
568
- hooks: registry,
569
- humanInTheLoop: { enabled: true },
1338
+ };
1339
+ const earlyApprovedRun = await createRun(`${runId}-early-approved`);
1340
+ const sharedRebuiltRunId = `${runId}-same-rebuilt-id`;
1341
+ const approvedRun = await createRun(sharedRebuiltRunId);
1342
+ const rejectedRun = await createRun(sharedRebuiltRunId);
1343
+ let armRestoredCheckpointBarrier = false;
1344
+ let blockNextRestoredCheckpointRead = false;
1345
+ const originalRestorePendingToolApprovals =
1346
+ registry.restorePendingToolApprovals.bind(registry);
1347
+ const restoreApprovalSpy = jest
1348
+ .spyOn(registry, 'restorePendingToolApprovals')
1349
+ .mockImplementation((sessionId, targetExecutionScope, snapshots) => {
1350
+ originalRestorePendingToolApprovals(
1351
+ sessionId,
1352
+ targetExecutionScope,
1353
+ snapshots
1354
+ );
1355
+ if (armRestoredCheckpointBarrier) {
1356
+ armRestoredCheckpointBarrier = false;
1357
+ blockNextRestoredCheckpointRead = true;
1358
+ }
1359
+ });
1360
+ let markCheckpointReadBlocked = (): void => undefined;
1361
+ const checkpointReadBlocked = new Promise<void>((resolve) => {
1362
+ markCheckpointReadBlocked = resolve;
570
1363
  });
1364
+ let releaseBlockedCheckpointRead = (): void => undefined;
1365
+ const blockedCheckpointReadRelease = new Promise<void>((resolve) => {
1366
+ releaseBlockedCheckpointRead = resolve;
1367
+ });
1368
+ const originalGetTuple = checkpointer.getTuple.bind(checkpointer);
1369
+ const checkpointReadSpy = jest
1370
+ .spyOn(checkpointer, 'getTuple')
1371
+ .mockImplementation(async (config) => {
1372
+ const tuple = await originalGetTuple(config);
1373
+ const configurable = config.configurable;
1374
+ const threadId = configurable?.thread_id;
1375
+ if (
1376
+ blockNextRestoredCheckpointRead &&
1377
+ typeof threadId === 'string' &&
1378
+ threadId.startsWith('subagent:') &&
1379
+ configurable?.checkpoint_id == null
1380
+ ) {
1381
+ blockNextRestoredCheckpointRead = false;
1382
+ markCheckpointReadBlocked();
1383
+ await blockedCheckpointReadRelease;
1384
+ }
1385
+ return tuple;
1386
+ });
1387
+ earlyApprovedRun.Graph!.overrideTestModel(['Final answer.'], 1);
1388
+ approvedRun.Graph!.overrideTestModel(['Final answer.'], 1);
1389
+ rejectedRun.Graph!.overrideTestModel(['Final answer.'], 1);
1390
+ const warningSpy = jest
1391
+ .spyOn(console, 'warn')
1392
+ .mockImplementation((): void => undefined);
1393
+ let rejectedResume: Promise<t.MessageContentComplex[] | undefined> | null =
1394
+ null;
1395
+ try {
1396
+ await earlyApprovedRun.resume([{ type: 'approve' }], branchConfig);
1397
+ await initialRun.resume([{ type: 'approve' }], callerConfig);
1398
+ armRestoredCheckpointBarrier = true;
1399
+ rejectedResume = rejectedRun.resume(
1400
+ [{ type: 'reject', reason: 'deny after restart' }],
1401
+ branchConfig
1402
+ );
1403
+ await checkpointReadBlocked;
1404
+ await approvedRun.resume([{ type: 'approve' }], branchConfig);
1405
+ releaseBlockedCheckpointRead();
1406
+ await rejectedResume;
1407
+ expect(warningSpy).not.toHaveBeenCalledWith(
1408
+ expect.stringContaining('toolCallStepIds missing entry')
1409
+ );
1410
+ } finally {
1411
+ releaseBlockedCheckpointRead();
1412
+ await rejectedResume?.catch(() => undefined);
1413
+ checkpointReadSpy.mockRestore();
1414
+ warningSpy.mockRestore();
1415
+ }
571
1416
 
572
- const tc = makeSubagentToolCall();
573
- run.Graph!.overrideTestModel(['Delegating...', 'Final answer.'], 5, [tc]);
1417
+ expect(earlyApprovedRun.getInterrupt()).toBeUndefined();
1418
+ expect(initialRun.getInterrupt()).toBeUndefined();
1419
+ expect(approvedRun.getInterrupt()).toBeUndefined();
1420
+ expect(rejectedRun.getInterrupt()).toBeUndefined();
1421
+ expect(executedTools).toEqual(['calculator', 'calculator', 'calculator']);
1422
+ expect(approvalHookCalls).toBe(1);
1423
+ const restoredApprovalScopes = new Set(
1424
+ restoreApprovalSpy.mock.calls.map(([, executionScope]) => executionScope)
1425
+ );
1426
+ expect(restoredApprovalScopes.size).toBeGreaterThanOrEqual(4);
1427
+ const initialChildThreads = initialRun.getChildCheckpointThreadIds();
1428
+ const earlyApprovedChildThreads =
1429
+ earlyApprovedRun.getChildCheckpointThreadIds();
1430
+ const approvedChildThreads = approvedRun.getChildCheckpointThreadIds();
1431
+ const rejectedChildThreads = rejectedRun.getChildCheckpointThreadIds();
1432
+ expect(initialChildThreads).toHaveLength(2);
1433
+ expect(earlyApprovedChildThreads).toHaveLength(1);
1434
+ expect(approvedChildThreads).toHaveLength(1);
1435
+ expect(rejectedChildThreads).toHaveLength(1);
1436
+ expect(approvedChildThreads[0]).not.toBe(rejectedChildThreads[0]);
1437
+ expect(earlyApprovedChildThreads[0]).not.toBe(approvedChildThreads[0]);
1438
+ expect(earlyApprovedChildThreads[0]).not.toBe(rejectedChildThreads[0]);
1439
+ expect(initialChildThreads).not.toContain(approvedChildThreads[0]);
1440
+ expect(initialChildThreads).not.toContain(rejectedChildThreads[0]);
1441
+ expect(
1442
+ updates
1443
+ .filter((event) => event.parentToolCallId === tc.id)
1444
+ .map((event) => event.subagentRunId)
1445
+ ).toEqual(expect.arrayContaining([sourceChildRunId]));
1446
+ expect(
1447
+ updates
1448
+ .filter((event) => event.parentToolCallId === tc.id)
1449
+ .every((event) => event.subagentRunId === sourceChildRunId)
1450
+ ).toBe(true);
1451
+ });
574
1452
 
575
- await run.processStream(
576
- { messages: [new HumanMessage('calculate something')] },
1453
+ it('forks nested resumes from each checkpoint in the manifest chain', async () => {
1454
+ getChatModelClassSpy.mockImplementation(((provider: Providers) => {
1455
+ if (provider === Providers.OPENAI) {
1456
+ return NestedHitlFakeChatModel;
1457
+ }
1458
+ return originalGetChatModelClass(provider);
1459
+ }) as typeof providers.getChatModelClass);
1460
+
1461
+ const checkpointer = new MemorySaver();
1462
+ const registry = new HookRegistry();
1463
+ const executedTools: string[] = [];
1464
+ const baseRunId = `nested-subagent-rebuild-${Date.now()}`;
1465
+ registry.registerSession(`${baseRunId}-initial`, 'PreToolUse', {
1466
+ once: true,
1467
+ pattern: '^calculator$',
1468
+ hooks: [
1469
+ async (): Promise<PreToolUseHookOutput> => ({
1470
+ decision: 'ask',
1471
+ reason: 'review nested calculator',
1472
+ }),
1473
+ ],
1474
+ });
1475
+ const customHandlers: Record<string, t.EventHandler> = {
1476
+ [GraphEvents.TOOL_END]: new ToolEndHandler(),
1477
+ [GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
1478
+ [GraphEvents.ON_TOOL_EXECUTE]: {
1479
+ handle: (_event, rawData): void => {
1480
+ const request = rawData as t.ToolExecuteBatchRequest;
1481
+ executedTools.push(...request.toolCalls.map((call) => call.name));
1482
+ request.resolve(
1483
+ request.toolCalls.map((call) => ({
1484
+ toolCallId: call.id,
1485
+ status: 'success' as const,
1486
+ content: '42',
1487
+ }))
1488
+ );
1489
+ },
1490
+ },
1491
+ };
1492
+ const createRun = (runId: string): Promise<Run<t.IState>> =>
1493
+ Run.create<t.IState>({
1494
+ runId,
1495
+ graphConfig: {
1496
+ type: 'standard',
1497
+ agents: [createParentAgentWithNestedChildTool()],
1498
+ compileOptions: { checkpointer },
1499
+ },
1500
+ returnContent: true,
1501
+ skipCleanup: true,
1502
+ customHandlers,
1503
+ hooks: registry,
1504
+ humanInTheLoop: { enabled: true },
1505
+ });
1506
+ const parentCall = makeSubagentToolCall(
1507
+ 'call_nested_researcher',
1508
+ 'Delegate a nested calculation'
1509
+ );
1510
+ const initialRun = await createRun(`${baseRunId}-initial`);
1511
+ initialRun.Graph!.overrideTestModel(['Delegating...', 'Final answer.'], 5, [
1512
+ parentCall,
1513
+ ]);
1514
+
1515
+ await initialRun.processStream(
1516
+ { messages: [new HumanMessage('calculate through two agents')] },
577
1517
  callerConfig
578
1518
  );
1519
+ const paused = initialRun.getInterrupt();
1520
+ expect(paused?.payload).toMatchObject({
1521
+ type: 'tool_approval',
1522
+ subagent: {
1523
+ agent_id: 'calculator-grandchild',
1524
+ parent_tool_call_id: 'call_nested_calculator_worker',
1525
+ },
1526
+ });
1527
+ const oldParentCheckpoint = {
1528
+ ...callerConfig,
1529
+ configurable: {
1530
+ ...callerConfig.configurable,
1531
+ checkpoint_id: paused?.checkpointId,
1532
+ checkpoint_ns: paused?.checkpointNs ?? '',
1533
+ },
1534
+ };
1535
+ const rebuiltRun = await createRun(`${baseRunId}-rebuilt`);
1536
+ rebuiltRun.Graph!.overrideTestModel(['Final answer.'], 1);
1537
+ const warningSpy = jest
1538
+ .spyOn(console, 'warn')
1539
+ .mockImplementation((): void => undefined);
1540
+ try {
1541
+ await initialRun.resume([{ type: 'approve' }], callerConfig);
1542
+ await rebuiltRun.resume(
1543
+ [{ type: 'reject', reason: 'reject stale nested branch' }],
1544
+ oldParentCheckpoint
1545
+ );
1546
+ expect(warningSpy).not.toHaveBeenCalledWith(
1547
+ expect.stringContaining('toolCallStepIds missing entry')
1548
+ );
1549
+ } finally {
1550
+ warningSpy.mockRestore();
1551
+ }
579
1552
 
580
- expect(run.getInterrupt()).toBeUndefined();
581
- expect(deniedTools).toContain(
582
- 'researcher-child:calculator:review calculator'
1553
+ expect(initialRun.getInterrupt()).toBeUndefined();
1554
+ expect(rebuiltRun.getInterrupt()).toBeUndefined();
1555
+ expect(executedTools).toEqual(['calculator']);
1556
+ expect(initialRun.getChildCheckpointThreadIds().length).toBeGreaterThan(2);
1557
+ expect(rebuiltRun.getChildCheckpointThreadIds().length).toBeGreaterThan(1);
1558
+ });
1559
+
1560
+ it('restores child tool-output references into rebuilt branches', async () => {
1561
+ getChatModelClassSpy.mockImplementation(((provider: Providers) => {
1562
+ if (provider === Providers.OPENAI) {
1563
+ return ReferenceHitlFakeChatModel;
1564
+ }
1565
+ return originalGetChatModelClass(provider);
1566
+ }) as typeof providers.getChatModelClass);
1567
+
1568
+ const referenceValue = 'checkpoint-scoped producer output';
1569
+ const consumedValues: unknown[] = [];
1570
+ const checkpointer = new MemorySaver();
1571
+ const registry = new HookRegistry();
1572
+ const baseRunId = `subagent-reference-rebuild-${Date.now()}`;
1573
+ registry.registerSession(`${baseRunId}-initial`, 'PreToolUse', {
1574
+ once: true,
1575
+ pattern: '^consume_value$',
1576
+ hooks: [
1577
+ async (): Promise<PreToolUseHookOutput> => ({
1578
+ decision: 'ask',
1579
+ reason: 'review referenced input',
1580
+ }),
1581
+ ],
1582
+ });
1583
+ const customHandlers: Record<string, t.EventHandler> = {
1584
+ [GraphEvents.TOOL_END]: new ToolEndHandler(),
1585
+ [GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
1586
+ [GraphEvents.ON_TOOL_EXECUTE]: {
1587
+ handle: (_event, rawData): void => {
1588
+ const request = rawData as t.ToolExecuteBatchRequest;
1589
+ request.resolve(
1590
+ request.toolCalls.map((call) => {
1591
+ if (call.name === 'consume_value') {
1592
+ consumedValues.push(call.args.value);
1593
+ }
1594
+ return {
1595
+ toolCallId: call.id,
1596
+ status: 'success' as const,
1597
+ content:
1598
+ call.name === 'produce_value' ? referenceValue : 'consumed',
1599
+ };
1600
+ })
1601
+ );
1602
+ },
1603
+ },
1604
+ };
1605
+ const createRun = (runId: string): Promise<Run<t.IState>> =>
1606
+ Run.create<t.IState>({
1607
+ runId,
1608
+ graphConfig: {
1609
+ type: 'standard',
1610
+ agents: [createParentAgentWithReferenceTools()],
1611
+ compileOptions: { checkpointer },
1612
+ },
1613
+ returnContent: true,
1614
+ skipCleanup: true,
1615
+ customHandlers,
1616
+ hooks: registry,
1617
+ humanInTheLoop: { enabled: true },
1618
+ toolOutputReferences: { enabled: true },
1619
+ });
1620
+ const parentCall = makeSubagentToolCall('call_reference_researcher');
1621
+ const initialRun = await createRun(`${baseRunId}-initial`);
1622
+ initialRun.Graph!.overrideTestModel(['Delegating...', 'Final answer.'], 5, [
1623
+ parentCall,
1624
+ ]);
1625
+
1626
+ await initialRun.processStream(
1627
+ { messages: [new HumanMessage('produce and consume a value')] },
1628
+ callerConfig
583
1629
  );
584
- expect(executedTools).not.toContain('calculator');
1630
+ const paused = initialRun.getInterrupt();
1631
+ expect(paused?.payload).toMatchObject({
1632
+ type: 'tool_approval',
1633
+ action_requests: [
1634
+ { tool_call_id: 'call_consume_value', name: 'consume_value' },
1635
+ ],
1636
+ });
1637
+ const rebuiltRun = await createRun(`${baseRunId}-rebuilt`);
1638
+ rebuiltRun.Graph!.overrideTestModel(['Final answer.'], 1);
1639
+ await rebuiltRun.resume([{ type: 'approve' }], {
1640
+ ...callerConfig,
1641
+ configurable: {
1642
+ ...callerConfig.configurable,
1643
+ checkpoint_id: paused?.checkpointId,
1644
+ checkpoint_ns: paused?.checkpointNs ?? '',
1645
+ },
1646
+ });
1647
+
1648
+ expect(rebuiltRun.getInterrupt()).toBeUndefined();
1649
+ expect(consumedValues).toEqual([referenceValue]);
585
1650
  });
586
1651
  });