@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
@@ -7,15 +7,27 @@ import {
7
7
  START,
8
8
  StateGraph,
9
9
  MemorySaver,
10
+ GraphInterrupt,
10
11
  isInterrupted,
11
12
  MessagesAnnotation,
12
13
  Command,
13
14
  } from '@langchain/langgraph';
14
15
  import type { Runnable, RunnableConfig } from '@langchain/core/runnables';
15
16
  import type { StructuredToolInterface } from '@langchain/core/tools';
17
+ import type { ToolCall } from '@langchain/core/messages/tool';
16
18
  import type { BaseMessage } from '@langchain/core/messages';
19
+ import type {
20
+ ReplayableSubagentTool,
21
+ SettledSubagentToolOutput,
22
+ } from '@/tools/subagent/SubagentReplay';
17
23
  import type { PreToolUseHookOutput } from '@/hooks';
18
24
  import type * as t from '@/types';
25
+ import {
26
+ getSubagentResumeManifest,
27
+ stripSubagentResumeManifest,
28
+ SUBAGENT_PARENT_BATCH_CONFIG_KEY,
29
+ SUBAGENT_REPLAY_CONTROLLER,
30
+ } from '@/tools/subagent/SubagentReplay';
19
31
  import { HookRegistry } from '@/hooks';
20
32
  import { ToolNode } from '../ToolNode';
21
33
 
@@ -36,7 +48,7 @@ import { ToolNode } from '../ToolNode';
36
48
  */
37
49
 
38
50
  function aiCall(
39
- callId: string,
51
+ callId: string | undefined,
40
52
  name: string,
41
53
  args: Record<string, unknown>
42
54
  ): AIMessage {
@@ -53,7 +65,11 @@ type CompiledMessagesGraph = Runnable<unknown, { messages: BaseMessage[] }> & {
53
65
 
54
66
  function buildGraph(
55
67
  toolNode: ToolNode,
56
- toolCalls: Array<{ id: string; name: string; args: Record<string, unknown> }>
68
+ toolCalls: Array<{
69
+ id: string | undefined;
70
+ name: string;
71
+ args: Record<string, unknown>;
72
+ }>
57
73
  ): CompiledMessagesGraph {
58
74
  let agentInvocations = 0;
59
75
  const builder = new StateGraph(MessagesAnnotation)
@@ -82,6 +98,422 @@ describe('direct-path HITL: resume scope', () => {
82
98
  jest.restoreAllMocks();
83
99
  });
84
100
 
101
+ it('persists a resume manifest beside a primitive child interrupt', async () => {
102
+ const directTool = tool(
103
+ async () => {
104
+ throw new GraphInterrupt([
105
+ { id: 'primitive-interrupt', value: 'confirm child' },
106
+ ]);
107
+ },
108
+ {
109
+ name: 'subagent',
110
+ description: 'primitive interrupt subagent',
111
+ schema: z.object({ description: z.string() }),
112
+ }
113
+ ) as unknown as StructuredToolInterface;
114
+ const manifest = {
115
+ version: 1 as const,
116
+ executions: [
117
+ {
118
+ parentToolCallId: 'call_primitive',
119
+ childRunId: 'child-run',
120
+ approvalExecutionScope: 'child-approval-attempt',
121
+ checkpoints: [
122
+ {
123
+ threadId: 'child-thread',
124
+ checkpointId: 'child-checkpoint',
125
+ checkpointNs: '',
126
+ },
127
+ ],
128
+ graphState: {
129
+ toolCallSteps: [],
130
+ toolSessions: [],
131
+ toolNodes: [],
132
+ eagerToolUsage: [],
133
+ eagerToolSuppressions: [],
134
+ },
135
+ approvalReplays: [],
136
+ },
137
+ ],
138
+ };
139
+ const replayableTool = directTool as StructuredToolInterface &
140
+ ReplayableSubagentTool;
141
+ const getResumeManifest = jest.fn(
142
+ async (
143
+ _parentToolCallIds?: ReadonlySet<string>,
144
+ _config?: RunnableConfig
145
+ ) => manifest
146
+ );
147
+ const getSettledOutput = jest.fn(
148
+ async (
149
+ _call: ToolCall,
150
+ _config: RunnableConfig
151
+ ): Promise<SettledSubagentToolOutput | undefined> => undefined
152
+ );
153
+ replayableTool[SUBAGENT_REPLAY_CONTROLLER] = {
154
+ getResumeManifest,
155
+ getSettledOutput,
156
+ persistSettledOutput: async () => {},
157
+ };
158
+ const node = new ToolNode({
159
+ tools: [directTool],
160
+ directToolNames: new Set(['subagent']),
161
+ interruptingToolNames: new Set(['subagent']),
162
+ });
163
+ const graph = buildGraph(node, [
164
+ {
165
+ id: 'call_primitive',
166
+ name: 'subagent',
167
+ args: { description: 'pause with primitive data' },
168
+ },
169
+ ]);
170
+
171
+ const interrupted = await graph.invoke(
172
+ { messages: [] },
173
+ { configurable: { thread_id: 'parent-thread' } }
174
+ );
175
+
176
+ expect(isInterrupted(interrupted)).toBe(true);
177
+ if (!isInterrupted(interrupted)) {
178
+ throw new Error('expected primitive child interrupt');
179
+ }
180
+ const internalPayload = interrupted.__interrupt__[0].value;
181
+ const replayBatchKey =
182
+ getSettledOutput.mock.calls[0]?.[1].configurable?.[
183
+ SUBAGENT_PARENT_BATCH_CONFIG_KEY
184
+ ];
185
+ expect(replayBatchKey).toEqual(expect.any(String));
186
+ expect(getResumeManifest).toHaveBeenCalledWith(
187
+ new Set(['call_primitive']),
188
+ expect.objectContaining({
189
+ configurable: expect.objectContaining({
190
+ thread_id: 'parent-thread',
191
+ [SUBAGENT_PARENT_BATCH_CONFIG_KEY]: replayBatchKey,
192
+ }),
193
+ })
194
+ );
195
+ expect(getSubagentResumeManifest(internalPayload)).toEqual(manifest);
196
+ expect(stripSubagentResumeManifest(internalPayload)).toBe('confirm child');
197
+ });
198
+
199
+ it('preserves a one-shot approval until a reject decision is applied', async () => {
200
+ const sideEffect = jest.fn(() => 'must not execute');
201
+ const directTool = tool(async () => sideEffect(), {
202
+ name: 'echo',
203
+ description: 'one-shot approval tool',
204
+ schema: z.object({ command: z.string() }),
205
+ }) as unknown as StructuredToolInterface;
206
+ const registry = new HookRegistry();
207
+ let hookInvocations = 0;
208
+ registry.register('PreToolUse', {
209
+ once: true,
210
+ pattern: '^echo$',
211
+ hooks: [
212
+ async (): Promise<PreToolUseHookOutput> => {
213
+ hookInvocations += 1;
214
+ return { decision: 'ask', reason: 'review once' };
215
+ },
216
+ ],
217
+ });
218
+ const node = new ToolNode({
219
+ tools: [directTool],
220
+ eventDrivenMode: true,
221
+ hookRegistry: registry,
222
+ directToolNames: new Set(['echo']),
223
+ humanInTheLoop: { enabled: true },
224
+ });
225
+ const graph = buildGraph(node, [
226
+ { id: 'call_once', name: 'echo', args: { command: 'go' } },
227
+ ]);
228
+ const config = { configurable: { thread_id: 'thread-once-reject' } };
229
+
230
+ const first = await graph.invoke({ messages: [] }, config);
231
+ expect(isInterrupted<t.HumanInterruptPayload>(first)).toBe(true);
232
+
233
+ const resumed = await graph.invoke(
234
+ new Command({
235
+ resume: [{ type: 'reject', reason: 'rejected once' }],
236
+ }),
237
+ config
238
+ );
239
+
240
+ expect(hookInvocations).toBe(1);
241
+ expect(sideEffect).not.toHaveBeenCalled();
242
+ const messages = (resumed as { messages: ToolMessage[] }).messages;
243
+ const toolMessage = messages.find(
244
+ (message) => message instanceof ToolMessage
245
+ );
246
+ expect(toolMessage?.status).toBe('error');
247
+ expect(String(toolMessage?.content)).toContain('rejected once');
248
+ });
249
+
250
+ it('truncates direct respond decisions before returning them', async () => {
251
+ const executeTool = jest.fn(async () => 'must not execute');
252
+ const directTool = tool(executeTool, {
253
+ name: 'echo',
254
+ description: 'approval response tool',
255
+ schema: z.object({ command: z.string() }),
256
+ }) as unknown as StructuredToolInterface;
257
+ const registry = new HookRegistry();
258
+ registry.register('PreToolUse', {
259
+ hooks: [async (): Promise<PreToolUseHookOutput> => ({ decision: 'ask' })],
260
+ });
261
+ const node = new ToolNode({
262
+ tools: [directTool],
263
+ hookRegistry: registry,
264
+ directToolNames: new Set(['echo']),
265
+ humanInTheLoop: { enabled: true },
266
+ maxToolResultChars: 50,
267
+ });
268
+ const graph = buildGraph(node, [
269
+ { id: 'call_respond', name: 'echo', args: { command: 'go' } },
270
+ ]);
271
+ const config = { configurable: { thread_id: 'thread-respond-truncate' } };
272
+
273
+ const interrupted = await graph.invoke({ messages: [] }, config);
274
+ expect(isInterrupted(interrupted)).toBe(true);
275
+
276
+ const oversized = 'A'.repeat(200);
277
+ const resumed = await graph.invoke(
278
+ new Command({
279
+ resume: [{ type: 'respond', responseText: oversized }],
280
+ }),
281
+ config
282
+ );
283
+
284
+ const messages = (resumed as { messages: ToolMessage[] }).messages;
285
+ const toolMessage = messages.find(
286
+ (message) => message instanceof ToolMessage
287
+ );
288
+ expect(String(toolMessage?.content).length).toBeLessThan(oversized.length);
289
+ expect(executeTool).not.toHaveBeenCalled();
290
+ });
291
+
292
+ it('stamps distinct assistant batches into subagent replay configs', async () => {
293
+ const invokedBatchKeys: unknown[] = [];
294
+ const directTool = tool(
295
+ async (_input, config) => {
296
+ invokedBatchKeys.push(
297
+ config.configurable?.[SUBAGENT_PARENT_BATCH_CONFIG_KEY]
298
+ );
299
+ return 'done';
300
+ },
301
+ {
302
+ name: 'subagent',
303
+ description: 'replayable direct tool',
304
+ schema: z.object({ description: z.string() }),
305
+ }
306
+ ) as unknown as StructuredToolInterface;
307
+ const replayConfigKeys: unknown[] = [];
308
+ const replayableTool = directTool as StructuredToolInterface &
309
+ ReplayableSubagentTool;
310
+ replayableTool[SUBAGENT_REPLAY_CONTROLLER] = {
311
+ getSettledOutput: async (_call, config) => {
312
+ replayConfigKeys.push(
313
+ config.configurable?.[SUBAGENT_PARENT_BATCH_CONFIG_KEY]
314
+ );
315
+ return undefined;
316
+ },
317
+ persistSettledOutput: async () => {},
318
+ };
319
+ const node = new ToolNode({
320
+ tools: [directTool],
321
+ directToolNames: new Set(['subagent']),
322
+ });
323
+ const config = {
324
+ configurable: { run_id: 'run-shared', thread_id: 'thread-shared' },
325
+ };
326
+ const input = (assistantMessageId: string): { messages: AIMessage[] } => ({
327
+ messages: [
328
+ new AIMessage({
329
+ id: assistantMessageId,
330
+ content: '',
331
+ tool_calls: [
332
+ {
333
+ id: 'call_reused',
334
+ name: 'subagent',
335
+ args: { description: assistantMessageId },
336
+ },
337
+ ],
338
+ }),
339
+ ],
340
+ });
341
+
342
+ await node.invoke(input('assistant-1'), config);
343
+ await node.invoke(input('assistant-2'), config);
344
+
345
+ expect(replayConfigKeys).toHaveLength(2);
346
+ expect(replayConfigKeys[0]).not.toBe(replayConfigKeys[1]);
347
+ expect(invokedBatchKeys).toEqual(replayConfigKeys);
348
+ });
349
+
350
+ it('stamps the assistant batch into Send-input subagent configs', async () => {
351
+ const invokedBatchKeys: unknown[] = [];
352
+ const directTool = tool(
353
+ async (_input, config) => {
354
+ invokedBatchKeys.push(
355
+ config.configurable?.[SUBAGENT_PARENT_BATCH_CONFIG_KEY]
356
+ );
357
+ return 'done';
358
+ },
359
+ {
360
+ name: 'subagent',
361
+ description: 'replayable direct tool',
362
+ schema: z.object({ description: z.string() }),
363
+ }
364
+ ) as unknown as StructuredToolInterface;
365
+ const replayConfigKeys: unknown[] = [];
366
+ const replayableTool = directTool as StructuredToolInterface &
367
+ ReplayableSubagentTool;
368
+ replayableTool[SUBAGENT_REPLAY_CONTROLLER] = {
369
+ getSettledOutput: async (_call, config) => {
370
+ replayConfigKeys.push(
371
+ config.configurable?.[SUBAGENT_PARENT_BATCH_CONFIG_KEY]
372
+ );
373
+ return undefined;
374
+ },
375
+ persistSettledOutput: async () => {},
376
+ };
377
+ const node = new ToolNode({
378
+ tools: [directTool],
379
+ directToolNames: new Set(['subagent']),
380
+ });
381
+ const toolCall = {
382
+ id: 'call_send',
383
+ name: 'subagent',
384
+ args: { description: 'send task' },
385
+ };
386
+
387
+ await node.invoke(
388
+ {
389
+ messages: [
390
+ new AIMessage({
391
+ id: 'assistant-send',
392
+ content: '',
393
+ tool_calls: [toolCall],
394
+ }),
395
+ ],
396
+ lg_tool_call: toolCall,
397
+ },
398
+ {
399
+ configurable: {
400
+ run_id: 'run-send',
401
+ thread_id: 'thread-send',
402
+ },
403
+ }
404
+ );
405
+
406
+ expect(replayConfigKeys).toHaveLength(1);
407
+ expect(replayConfigKeys[0]).toBeDefined();
408
+ expect(invokedBatchKeys).toEqual(replayConfigKeys);
409
+ });
410
+
411
+ it('fails closed when an id-less direct call requires approval', async () => {
412
+ const sideEffect = jest.fn(() => 'must not execute');
413
+ const directTool = tool(async () => sideEffect(), {
414
+ name: 'echo',
415
+ description: 'id-less approval tool',
416
+ schema: z.object({ command: z.string() }),
417
+ }) as unknown as StructuredToolInterface;
418
+ const registry = new HookRegistry();
419
+ registry.register('PreToolUse', {
420
+ pattern: '^echo$',
421
+ hooks: [
422
+ async (): Promise<PreToolUseHookOutput> => ({
423
+ decision: 'ask',
424
+ reason: 'review id-less call',
425
+ }),
426
+ ],
427
+ });
428
+ const node = new ToolNode({
429
+ tools: [directTool],
430
+ eventDrivenMode: true,
431
+ hookRegistry: registry,
432
+ directToolNames: new Set(['echo']),
433
+ humanInTheLoop: { enabled: true },
434
+ });
435
+ const graph = buildGraph(node, [
436
+ { id: undefined, name: 'echo', args: { command: 'go' } },
437
+ ]);
438
+
439
+ const result = await graph.invoke(
440
+ { messages: [] },
441
+ { configurable: { thread_id: 'thread-id-less-approval' } }
442
+ );
443
+
444
+ expect(isInterrupted(result)).toBe(false);
445
+ expect(sideEffect).not.toHaveBeenCalled();
446
+ const messages = (result as { messages: ToolMessage[] }).messages;
447
+ const toolMessage = messages.find(
448
+ (message) => message instanceof ToolMessage
449
+ );
450
+ expect(toolMessage?.status).toBe('error');
451
+ expect(String(toolMessage?.content)).toContain(
452
+ 'requires a non-empty tool call ID'
453
+ );
454
+ });
455
+
456
+ it('reruns ordinary hooks while replaying a consumed one-shot approval', async () => {
457
+ const sideEffect = jest.fn(() => 'must not execute');
458
+ const directTool = tool(async () => sideEffect(), {
459
+ name: 'echo',
460
+ description: 'mixed replay hook tool',
461
+ schema: z.object({ command: z.string() }),
462
+ }) as unknown as StructuredToolInterface;
463
+ const registry = new HookRegistry();
464
+ let onceCalls = 0;
465
+ let ordinaryCalls = 0;
466
+ registry.register('PreToolUse', {
467
+ once: true,
468
+ pattern: '^echo$',
469
+ hooks: [
470
+ async (): Promise<PreToolUseHookOutput> => {
471
+ onceCalls += 1;
472
+ return { decision: 'ask', reason: 'review once' };
473
+ },
474
+ ],
475
+ });
476
+ registry.register('PreToolUse', {
477
+ pattern: '^echo$',
478
+ hooks: [
479
+ async (): Promise<PreToolUseHookOutput> => {
480
+ ordinaryCalls += 1;
481
+ return ordinaryCalls === 1
482
+ ? { decision: 'allow' }
483
+ : { decision: 'deny', reason: 'policy changed on resume' };
484
+ },
485
+ ],
486
+ });
487
+ const node = new ToolNode({
488
+ tools: [directTool],
489
+ eventDrivenMode: true,
490
+ hookRegistry: registry,
491
+ directToolNames: new Set(['echo']),
492
+ humanInTheLoop: { enabled: true },
493
+ });
494
+ const graph = buildGraph(node, [
495
+ { id: 'call_mixed_hooks', name: 'echo', args: { command: 'go' } },
496
+ ]);
497
+ const config = { configurable: { thread_id: 'thread-mixed-hooks' } };
498
+
499
+ const first = await graph.invoke({ messages: [] }, config);
500
+ expect(isInterrupted(first)).toBe(true);
501
+ const resumed = await graph.invoke(
502
+ new Command({ resume: [{ type: 'approve' }] }),
503
+ config
504
+ );
505
+
506
+ expect(onceCalls).toBe(1);
507
+ expect(ordinaryCalls).toBe(2);
508
+ expect(sideEffect).not.toHaveBeenCalled();
509
+ const messages = (resumed as { messages: ToolMessage[] }).messages;
510
+ const toolMessage = messages.find(
511
+ (message) => message instanceof ToolMessage
512
+ );
513
+ expect(toolMessage?.status).toBe('error');
514
+ expect(String(toolMessage?.content)).toContain('policy changed on resume');
515
+ });
516
+
85
517
  it('re-executes the direct tool body on resume when interrupt() fires from the direct path', async () => {
86
518
  const sideEffect = jest.fn(() => 'EXECUTED');
87
519
  const directTool = tool(async () => sideEffect(), {
@@ -375,6 +807,84 @@ describe('direct-path HITL: resume scope', () => {
375
807
  });
376
808
  });
377
809
 
810
+ describe('untrusted resume decisions', () => {
811
+ it.each([
812
+ [
813
+ { type: 'respond' },
814
+ 'Approval payload `respond` was missing a string `responseText`',
815
+ ],
816
+ [
817
+ { type: 'aproved' },
818
+ 'Unknown approval decision type "aproved" — failing closed',
819
+ ],
820
+ ])(
821
+ 'blocks and persists malformed direct decision %#',
822
+ async (rawDecision, expectedError) => {
823
+ const executeTool = jest.fn(async () => 'should-not-execute');
824
+ const directTool = tool(executeTool, {
825
+ name: 'subagent',
826
+ description: 'replayable direct tool',
827
+ schema: z.object({ description: z.string() }),
828
+ }) as unknown as StructuredToolInterface;
829
+ const persistSettledOutput = jest.fn(
830
+ async (
831
+ _call: ToolCall,
832
+ _config: RunnableConfig,
833
+ _settled: SettledSubagentToolOutput
834
+ ): Promise<void> => {}
835
+ );
836
+ const replayableTool = directTool as StructuredToolInterface &
837
+ ReplayableSubagentTool;
838
+ replayableTool[SUBAGENT_REPLAY_CONTROLLER] = {
839
+ getSettledOutput: async () => undefined,
840
+ persistSettledOutput,
841
+ };
842
+ const registry = new HookRegistry();
843
+ registry.register('PreToolUse', {
844
+ hooks: [
845
+ async (): Promise<PreToolUseHookOutput> => ({ decision: 'ask' }),
846
+ ],
847
+ });
848
+ const node = new ToolNode({
849
+ tools: [directTool],
850
+ hookRegistry: registry,
851
+ directToolNames: new Set(['subagent']),
852
+ humanInTheLoop: { enabled: true },
853
+ });
854
+ const graph = buildGraph(node, [
855
+ {
856
+ id: 'call_malformed',
857
+ name: 'subagent',
858
+ args: { description: 'inspect' },
859
+ },
860
+ ]);
861
+ const config = {
862
+ configurable: { thread_id: `thread-malformed-${rawDecision.type}` },
863
+ };
864
+
865
+ await graph.invoke({ messages: [] }, config);
866
+ const resumed = await graph.invoke(
867
+ new Command({
868
+ resume: [rawDecision as unknown as t.ToolApprovalDecision],
869
+ }),
870
+ config
871
+ );
872
+
873
+ const messages = (resumed as { messages: ToolMessage[] }).messages;
874
+ const toolMessage = messages.find(
875
+ (message) => message instanceof ToolMessage
876
+ );
877
+ expect(toolMessage?.status).toBe('error');
878
+ expect(String(toolMessage?.content)).toContain(expectedError);
879
+ expect(executeTool).not.toHaveBeenCalled();
880
+ expect(persistSettledOutput).toHaveBeenCalledTimes(1);
881
+ expect(persistSettledOutput.mock.calls[0]?.[2].output.status).toBe(
882
+ 'error'
883
+ );
884
+ }
885
+ );
886
+ });
887
+
378
888
  describe('usage counter stability across resume (Codex P2 #30)', () => {
379
889
  it('turn stays the same across an interrupt + resume — does not double-increment', async () => {
380
890
  // Pre-fix the P2 #27 turn-race fix incremented before the