@librechat/agents 3.3.13 → 3.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/dist/cjs/graphs/Graph.cjs +121 -27
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/hooks/HookRegistry.cjs +83 -0
  4. package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
  5. package/dist/cjs/hooks/executeHooks.cjs +60 -14
  6. package/dist/cjs/hooks/executeHooks.cjs.map +1 -1
  7. package/dist/cjs/hooks/index.cjs.map +1 -1
  8. package/dist/cjs/hooks/types.cjs +2 -0
  9. package/dist/cjs/hooks/types.cjs.map +1 -1
  10. package/dist/cjs/llm/anthropic/index.cjs +35 -206
  11. package/dist/cjs/llm/anthropic/index.cjs.map +1 -1
  12. package/dist/cjs/llm/bedrock/index.cjs +121 -241
  13. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  14. package/dist/cjs/llm/google/index.cjs +19 -12
  15. package/dist/cjs/llm/google/index.cjs.map +1 -1
  16. package/dist/cjs/llm/init.cjs +3 -3
  17. package/dist/cjs/llm/invoke.cjs +2 -2
  18. package/dist/cjs/llm/mistral/index.cjs +26 -0
  19. package/dist/cjs/llm/mistral/index.cjs.map +1 -0
  20. package/dist/cjs/llm/openai/index.cjs +82 -80
  21. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  22. package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
  23. package/dist/cjs/llm/providers.cjs +19 -19
  24. package/dist/cjs/llm/providers.cjs.map +1 -1
  25. package/dist/cjs/llm/stream/chunkAdapters.cjs +198 -0
  26. package/dist/cjs/llm/stream/chunkAdapters.cjs.map +1 -0
  27. package/dist/cjs/llm/stream/smoother.cjs +369 -0
  28. package/dist/cjs/llm/stream/smoother.cjs.map +1 -0
  29. package/dist/cjs/llm/vertexai/index.cjs +13 -1
  30. package/dist/cjs/llm/vertexai/index.cjs.map +1 -1
  31. package/dist/cjs/main.cjs +25 -16
  32. package/dist/cjs/messages/format.cjs +40 -23
  33. package/dist/cjs/messages/format.cjs.map +1 -1
  34. package/dist/cjs/run.cjs +82 -15
  35. package/dist/cjs/run.cjs.map +1 -1
  36. package/dist/cjs/session/AgentSession.cjs +37 -2
  37. package/dist/cjs/session/AgentSession.cjs.map +1 -1
  38. package/dist/cjs/stream.cjs +1 -1
  39. package/dist/cjs/summarization/node.cjs +6 -3
  40. package/dist/cjs/summarization/node.cjs.map +1 -1
  41. package/dist/cjs/tools/BashExecutor.cjs +1 -1
  42. package/dist/cjs/tools/CodeExecutor.cjs +1 -1
  43. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +1 -1
  44. package/dist/cjs/tools/ToolNode.cjs +316 -94
  45. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  46. package/dist/cjs/tools/ToolSearch.cjs +1 -1
  47. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +664 -80
  48. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  49. package/dist/cjs/tools/subagent/SubagentReplay.cjs +175 -0
  50. package/dist/cjs/tools/subagent/SubagentReplay.cjs.map +1 -0
  51. package/dist/cjs/tools/toolOutputReferences.cjs +20 -0
  52. package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
  53. package/dist/cjs/utils/index.cjs +2 -2
  54. package/dist/cjs/utils/tokens.cjs +76 -29
  55. package/dist/cjs/utils/tokens.cjs.map +1 -1
  56. package/dist/esm/graphs/Graph.mjs +121 -27
  57. package/dist/esm/graphs/Graph.mjs.map +1 -1
  58. package/dist/esm/hooks/HookRegistry.mjs +83 -0
  59. package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
  60. package/dist/esm/hooks/executeHooks.mjs +60 -14
  61. package/dist/esm/hooks/executeHooks.mjs.map +1 -1
  62. package/dist/esm/hooks/index.mjs.map +1 -1
  63. package/dist/esm/hooks/types.mjs +2 -1
  64. package/dist/esm/hooks/types.mjs.map +1 -1
  65. package/dist/esm/llm/anthropic/index.mjs +34 -205
  66. package/dist/esm/llm/anthropic/index.mjs.map +1 -1
  67. package/dist/esm/llm/bedrock/index.mjs +120 -240
  68. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  69. package/dist/esm/llm/google/index.mjs +19 -12
  70. package/dist/esm/llm/google/index.mjs.map +1 -1
  71. package/dist/esm/llm/init.mjs +1 -1
  72. package/dist/esm/llm/invoke.mjs +2 -2
  73. package/dist/esm/llm/mistral/index.mjs +26 -0
  74. package/dist/esm/llm/mistral/index.mjs.map +1 -0
  75. package/dist/esm/llm/openai/index.mjs +82 -80
  76. package/dist/esm/llm/openai/index.mjs.map +1 -1
  77. package/dist/esm/llm/openrouter/index.mjs.map +1 -1
  78. package/dist/esm/llm/providers.mjs +3 -3
  79. package/dist/esm/llm/providers.mjs.map +1 -1
  80. package/dist/esm/llm/stream/chunkAdapters.mjs +195 -0
  81. package/dist/esm/llm/stream/chunkAdapters.mjs.map +1 -0
  82. package/dist/esm/llm/stream/smoother.mjs +365 -0
  83. package/dist/esm/llm/stream/smoother.mjs.map +1 -0
  84. package/dist/esm/llm/vertexai/index.mjs +13 -1
  85. package/dist/esm/llm/vertexai/index.mjs.map +1 -1
  86. package/dist/esm/main.mjs +13 -11
  87. package/dist/esm/messages/format.mjs +40 -23
  88. package/dist/esm/messages/format.mjs.map +1 -1
  89. package/dist/esm/run.mjs +83 -16
  90. package/dist/esm/run.mjs.map +1 -1
  91. package/dist/esm/session/AgentSession.mjs +37 -2
  92. package/dist/esm/session/AgentSession.mjs.map +1 -1
  93. package/dist/esm/stream.mjs +1 -1
  94. package/dist/esm/summarization/node.mjs +6 -3
  95. package/dist/esm/summarization/node.mjs.map +1 -1
  96. package/dist/esm/tools/BashExecutor.mjs +1 -1
  97. package/dist/esm/tools/CodeExecutor.mjs +1 -1
  98. package/dist/esm/tools/ProgrammaticToolCalling.mjs +1 -1
  99. package/dist/esm/tools/ToolNode.mjs +317 -95
  100. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  101. package/dist/esm/tools/ToolSearch.mjs +1 -1
  102. package/dist/esm/tools/subagent/SubagentExecutor.mjs +665 -81
  103. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  104. package/dist/esm/tools/subagent/SubagentReplay.mjs +168 -0
  105. package/dist/esm/tools/subagent/SubagentReplay.mjs.map +1 -0
  106. package/dist/esm/tools/toolOutputReferences.mjs +20 -0
  107. package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
  108. package/dist/esm/utils/index.mjs +2 -2
  109. package/dist/esm/utils/tokens.mjs +76 -30
  110. package/dist/esm/utils/tokens.mjs.map +1 -1
  111. package/dist/types/graphs/Graph.d.ts +19 -1
  112. package/dist/types/hooks/HookRegistry.d.ts +10 -1
  113. package/dist/types/hooks/executeHooks.d.ts +5 -1
  114. package/dist/types/hooks/index.d.ts +2 -2
  115. package/dist/types/hooks/types.d.ts +12 -0
  116. package/dist/types/index.d.ts +3 -0
  117. package/dist/types/llm/google/index.d.ts +2 -0
  118. package/dist/types/llm/mistral/index.d.ts +11 -0
  119. package/dist/types/llm/openai/index.d.ts +20 -4
  120. package/dist/types/llm/openrouter/index.d.ts +4 -1
  121. package/dist/types/llm/stream/chunkAdapters.d.ts +48 -0
  122. package/dist/types/llm/stream/smoother.d.ts +95 -0
  123. package/dist/types/llm/vertexai/index.d.ts +2 -0
  124. package/dist/types/run.d.ts +4 -1
  125. package/dist/types/session/AgentSession.d.ts +1 -0
  126. package/dist/types/tools/ToolNode.d.ts +23 -15
  127. package/dist/types/tools/subagent/SubagentExecutor.d.ts +49 -7
  128. package/dist/types/tools/subagent/SubagentReplay.d.ts +81 -0
  129. package/dist/types/tools/toolOutputReferences.d.ts +12 -0
  130. package/dist/types/types/graph.d.ts +5 -5
  131. package/dist/types/types/hitl.d.ts +15 -0
  132. package/dist/types/types/llm.d.ts +21 -14
  133. package/dist/types/utils/tokens.d.ts +10 -0
  134. package/package.json +6 -2
  135. package/src/graphs/Graph.ts +257 -52
  136. package/src/graphs/__tests__/Graph.breakerLifecycle.test.ts +48 -9
  137. package/src/graphs/__tests__/Graph.contextOverflow.test.ts +1 -1
  138. package/src/graphs/__tests__/Graph.subagentResumeState.test.ts +80 -0
  139. package/src/hooks/HookRegistry.ts +151 -1
  140. package/src/hooks/__tests__/HookRegistry.test.ts +96 -0
  141. package/src/hooks/__tests__/executeHooks.test.ts +160 -0
  142. package/src/hooks/executeHooks.ts +133 -15
  143. package/src/hooks/index.ts +3 -1
  144. package/src/hooks/types.ts +16 -0
  145. package/src/index.ts +8 -0
  146. package/src/llm/anthropic/index.ts +85 -354
  147. package/src/llm/bedrock/index.ts +174 -390
  148. package/src/llm/bedrock/llm.spec.ts +2 -0
  149. package/src/llm/bedrock/streamSealDispatch.test.ts +76 -0
  150. package/src/llm/custom-chat-models.smoke.test.ts +16 -1
  151. package/src/llm/google/index.ts +17 -18
  152. package/src/llm/google/streamSmoothing.test.ts +121 -0
  153. package/src/llm/mistral/index.ts +33 -0
  154. package/src/llm/mistral/streamSmoothing.test.ts +97 -0
  155. package/src/llm/openai/deepseek.test.ts +56 -0
  156. package/src/llm/openai/index.ts +119 -126
  157. package/src/llm/openrouter/index.ts +4 -1
  158. package/src/llm/providers.ts +3 -3
  159. package/src/llm/stream/chunkAdapters.test.ts +202 -0
  160. package/src/llm/stream/chunkAdapters.ts +317 -0
  161. package/src/llm/stream/reassembly.test.ts +241 -0
  162. package/src/llm/stream/smoother.bench.test.ts +155 -0
  163. package/src/llm/stream/smoother.test.ts +519 -0
  164. package/src/llm/stream/smoother.ts +574 -0
  165. package/src/llm/vertexai/index.ts +17 -1
  166. package/src/llm/vertexai/streamSmoothing.test.ts +109 -0
  167. package/src/messages/format.ts +54 -26
  168. package/src/messages/formatAgentMessages.reducer.test.ts +162 -0
  169. package/src/messages/formatAgentMessages.steer.test.ts +16 -11
  170. package/src/messages/formatAgentMessages.test.ts +7 -2
  171. package/src/run.ts +164 -18
  172. package/src/session/AgentSession.ts +52 -2
  173. package/src/session/__tests__/JsonlSessionStore.test.ts +53 -0
  174. package/src/specs/subagent.test.ts +44 -0
  175. package/src/specs/tokens.test.ts +159 -14
  176. package/src/summarization/__tests__/node.test.ts +32 -14
  177. package/src/summarization/node.ts +15 -11
  178. package/src/tools/ToolNode.ts +631 -157
  179. package/src/tools/__tests__/SubagentExecutor.test.ts +590 -7
  180. package/src/tools/__tests__/SubagentReplay.test.ts +300 -0
  181. package/src/tools/__tests__/ToolNode.breakerSignal.test.ts +208 -2
  182. package/src/tools/__tests__/directToolHITLResumeScope.test.ts +486 -2
  183. package/src/tools/__tests__/hitl.test.ts +234 -5
  184. package/src/tools/__tests__/subagentHooks.test.ts +1040 -62
  185. package/src/tools/__tests__/toolOutputReferences.test.ts +19 -2
  186. package/src/tools/subagent/SubagentExecutor.ts +1438 -136
  187. package/src/tools/subagent/SubagentReplay.ts +575 -0
  188. package/src/tools/toolOutputReferences.ts +40 -1
  189. package/src/types/graph.ts +5 -5
  190. package/src/types/hitl.ts +16 -0
  191. package/src/types/llm.ts +53 -36
  192. package/src/utils/tokens.ts +115 -30
@@ -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,396 @@ 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
+ replayableTool[SUBAGENT_REPLAY_CONTROLLER] = {
142
+ getResumeManifest: async () => manifest,
143
+ getSettledOutput: async () => undefined,
144
+ persistSettledOutput: async () => {},
145
+ };
146
+ const node = new ToolNode({
147
+ tools: [directTool],
148
+ directToolNames: new Set(['subagent']),
149
+ interruptingToolNames: new Set(['subagent']),
150
+ });
151
+ const graph = buildGraph(node, [
152
+ {
153
+ id: 'call_primitive',
154
+ name: 'subagent',
155
+ args: { description: 'pause with primitive data' },
156
+ },
157
+ ]);
158
+
159
+ const interrupted = await graph.invoke(
160
+ { messages: [] },
161
+ { configurable: { thread_id: 'parent-thread' } }
162
+ );
163
+
164
+ expect(isInterrupted(interrupted)).toBe(true);
165
+ if (!isInterrupted(interrupted)) {
166
+ throw new Error('expected primitive child interrupt');
167
+ }
168
+ const internalPayload = interrupted.__interrupt__[0].value;
169
+ expect(getSubagentResumeManifest(internalPayload)).toEqual(manifest);
170
+ expect(stripSubagentResumeManifest(internalPayload)).toBe('confirm child');
171
+ });
172
+
173
+ it('preserves a one-shot approval until a reject decision is applied', async () => {
174
+ const sideEffect = jest.fn(() => 'must not execute');
175
+ const directTool = tool(async () => sideEffect(), {
176
+ name: 'echo',
177
+ description: 'one-shot approval tool',
178
+ schema: z.object({ command: z.string() }),
179
+ }) as unknown as StructuredToolInterface;
180
+ const registry = new HookRegistry();
181
+ let hookInvocations = 0;
182
+ registry.register('PreToolUse', {
183
+ once: true,
184
+ pattern: '^echo$',
185
+ hooks: [
186
+ async (): Promise<PreToolUseHookOutput> => {
187
+ hookInvocations += 1;
188
+ return { decision: 'ask', reason: 'review once' };
189
+ },
190
+ ],
191
+ });
192
+ const node = new ToolNode({
193
+ tools: [directTool],
194
+ eventDrivenMode: true,
195
+ hookRegistry: registry,
196
+ directToolNames: new Set(['echo']),
197
+ humanInTheLoop: { enabled: true },
198
+ });
199
+ const graph = buildGraph(node, [
200
+ { id: 'call_once', name: 'echo', args: { command: 'go' } },
201
+ ]);
202
+ const config = { configurable: { thread_id: 'thread-once-reject' } };
203
+
204
+ const first = await graph.invoke({ messages: [] }, config);
205
+ expect(isInterrupted<t.HumanInterruptPayload>(first)).toBe(true);
206
+
207
+ const resumed = await graph.invoke(
208
+ new Command({
209
+ resume: [{ type: 'reject', reason: 'rejected once' }],
210
+ }),
211
+ config
212
+ );
213
+
214
+ expect(hookInvocations).toBe(1);
215
+ expect(sideEffect).not.toHaveBeenCalled();
216
+ const messages = (resumed as { messages: ToolMessage[] }).messages;
217
+ const toolMessage = messages.find(
218
+ (message) => message instanceof ToolMessage
219
+ );
220
+ expect(toolMessage?.status).toBe('error');
221
+ expect(String(toolMessage?.content)).toContain('rejected once');
222
+ });
223
+
224
+ it('truncates direct respond decisions before returning them', async () => {
225
+ const executeTool = jest.fn(async () => 'must not execute');
226
+ const directTool = tool(executeTool, {
227
+ name: 'echo',
228
+ description: 'approval response tool',
229
+ schema: z.object({ command: z.string() }),
230
+ }) as unknown as StructuredToolInterface;
231
+ const registry = new HookRegistry();
232
+ registry.register('PreToolUse', {
233
+ hooks: [async (): Promise<PreToolUseHookOutput> => ({ decision: 'ask' })],
234
+ });
235
+ const node = new ToolNode({
236
+ tools: [directTool],
237
+ hookRegistry: registry,
238
+ directToolNames: new Set(['echo']),
239
+ humanInTheLoop: { enabled: true },
240
+ maxToolResultChars: 50,
241
+ });
242
+ const graph = buildGraph(node, [
243
+ { id: 'call_respond', name: 'echo', args: { command: 'go' } },
244
+ ]);
245
+ const config = { configurable: { thread_id: 'thread-respond-truncate' } };
246
+
247
+ const interrupted = await graph.invoke({ messages: [] }, config);
248
+ expect(isInterrupted(interrupted)).toBe(true);
249
+
250
+ const oversized = 'A'.repeat(200);
251
+ const resumed = await graph.invoke(
252
+ new Command({
253
+ resume: [{ type: 'respond', responseText: oversized }],
254
+ }),
255
+ config
256
+ );
257
+
258
+ const messages = (resumed as { messages: ToolMessage[] }).messages;
259
+ const toolMessage = messages.find(
260
+ (message) => message instanceof ToolMessage
261
+ );
262
+ expect(String(toolMessage?.content).length).toBeLessThan(oversized.length);
263
+ expect(executeTool).not.toHaveBeenCalled();
264
+ });
265
+
266
+ it('stamps distinct assistant batches into subagent replay configs', async () => {
267
+ const invokedBatchKeys: unknown[] = [];
268
+ const directTool = tool(
269
+ async (_input, config) => {
270
+ invokedBatchKeys.push(
271
+ config.configurable?.[SUBAGENT_PARENT_BATCH_CONFIG_KEY]
272
+ );
273
+ return 'done';
274
+ },
275
+ {
276
+ name: 'subagent',
277
+ description: 'replayable direct tool',
278
+ schema: z.object({ description: z.string() }),
279
+ }
280
+ ) as unknown as StructuredToolInterface;
281
+ const replayConfigKeys: unknown[] = [];
282
+ const replayableTool = directTool as StructuredToolInterface &
283
+ ReplayableSubagentTool;
284
+ replayableTool[SUBAGENT_REPLAY_CONTROLLER] = {
285
+ getSettledOutput: async (_call, config) => {
286
+ replayConfigKeys.push(
287
+ config.configurable?.[SUBAGENT_PARENT_BATCH_CONFIG_KEY]
288
+ );
289
+ return undefined;
290
+ },
291
+ persistSettledOutput: async () => {},
292
+ };
293
+ const node = new ToolNode({
294
+ tools: [directTool],
295
+ directToolNames: new Set(['subagent']),
296
+ });
297
+ const config = {
298
+ configurable: { run_id: 'run-shared', thread_id: 'thread-shared' },
299
+ };
300
+ const input = (assistantMessageId: string): { messages: AIMessage[] } => ({
301
+ messages: [
302
+ new AIMessage({
303
+ id: assistantMessageId,
304
+ content: '',
305
+ tool_calls: [
306
+ {
307
+ id: 'call_reused',
308
+ name: 'subagent',
309
+ args: { description: assistantMessageId },
310
+ },
311
+ ],
312
+ }),
313
+ ],
314
+ });
315
+
316
+ await node.invoke(input('assistant-1'), config);
317
+ await node.invoke(input('assistant-2'), config);
318
+
319
+ expect(replayConfigKeys).toHaveLength(2);
320
+ expect(replayConfigKeys[0]).not.toBe(replayConfigKeys[1]);
321
+ expect(invokedBatchKeys).toEqual(replayConfigKeys);
322
+ });
323
+
324
+ it('stamps the assistant batch into Send-input subagent configs', async () => {
325
+ const invokedBatchKeys: unknown[] = [];
326
+ const directTool = tool(
327
+ async (_input, config) => {
328
+ invokedBatchKeys.push(
329
+ config.configurable?.[SUBAGENT_PARENT_BATCH_CONFIG_KEY]
330
+ );
331
+ return 'done';
332
+ },
333
+ {
334
+ name: 'subagent',
335
+ description: 'replayable direct tool',
336
+ schema: z.object({ description: z.string() }),
337
+ }
338
+ ) as unknown as StructuredToolInterface;
339
+ const replayConfigKeys: unknown[] = [];
340
+ const replayableTool = directTool as StructuredToolInterface &
341
+ ReplayableSubagentTool;
342
+ replayableTool[SUBAGENT_REPLAY_CONTROLLER] = {
343
+ getSettledOutput: async (_call, config) => {
344
+ replayConfigKeys.push(
345
+ config.configurable?.[SUBAGENT_PARENT_BATCH_CONFIG_KEY]
346
+ );
347
+ return undefined;
348
+ },
349
+ persistSettledOutput: async () => {},
350
+ };
351
+ const node = new ToolNode({
352
+ tools: [directTool],
353
+ directToolNames: new Set(['subagent']),
354
+ });
355
+ const toolCall = {
356
+ id: 'call_send',
357
+ name: 'subagent',
358
+ args: { description: 'send task' },
359
+ };
360
+
361
+ await node.invoke(
362
+ {
363
+ messages: [
364
+ new AIMessage({
365
+ id: 'assistant-send',
366
+ content: '',
367
+ tool_calls: [toolCall],
368
+ }),
369
+ ],
370
+ lg_tool_call: toolCall,
371
+ },
372
+ {
373
+ configurable: {
374
+ run_id: 'run-send',
375
+ thread_id: 'thread-send',
376
+ },
377
+ }
378
+ );
379
+
380
+ expect(replayConfigKeys).toHaveLength(1);
381
+ expect(replayConfigKeys[0]).toBeDefined();
382
+ expect(invokedBatchKeys).toEqual(replayConfigKeys);
383
+ });
384
+
385
+ it('fails closed when an id-less direct call requires approval', async () => {
386
+ const sideEffect = jest.fn(() => 'must not execute');
387
+ const directTool = tool(async () => sideEffect(), {
388
+ name: 'echo',
389
+ description: 'id-less approval tool',
390
+ schema: z.object({ command: z.string() }),
391
+ }) as unknown as StructuredToolInterface;
392
+ const registry = new HookRegistry();
393
+ registry.register('PreToolUse', {
394
+ pattern: '^echo$',
395
+ hooks: [
396
+ async (): Promise<PreToolUseHookOutput> => ({
397
+ decision: 'ask',
398
+ reason: 'review id-less call',
399
+ }),
400
+ ],
401
+ });
402
+ const node = new ToolNode({
403
+ tools: [directTool],
404
+ eventDrivenMode: true,
405
+ hookRegistry: registry,
406
+ directToolNames: new Set(['echo']),
407
+ humanInTheLoop: { enabled: true },
408
+ });
409
+ const graph = buildGraph(node, [
410
+ { id: undefined, name: 'echo', args: { command: 'go' } },
411
+ ]);
412
+
413
+ const result = await graph.invoke(
414
+ { messages: [] },
415
+ { configurable: { thread_id: 'thread-id-less-approval' } }
416
+ );
417
+
418
+ expect(isInterrupted(result)).toBe(false);
419
+ expect(sideEffect).not.toHaveBeenCalled();
420
+ const messages = (result as { messages: ToolMessage[] }).messages;
421
+ const toolMessage = messages.find(
422
+ (message) => message instanceof ToolMessage
423
+ );
424
+ expect(toolMessage?.status).toBe('error');
425
+ expect(String(toolMessage?.content)).toContain(
426
+ 'requires a non-empty tool call ID'
427
+ );
428
+ });
429
+
430
+ it('reruns ordinary hooks while replaying a consumed one-shot approval', async () => {
431
+ const sideEffect = jest.fn(() => 'must not execute');
432
+ const directTool = tool(async () => sideEffect(), {
433
+ name: 'echo',
434
+ description: 'mixed replay hook tool',
435
+ schema: z.object({ command: z.string() }),
436
+ }) as unknown as StructuredToolInterface;
437
+ const registry = new HookRegistry();
438
+ let onceCalls = 0;
439
+ let ordinaryCalls = 0;
440
+ registry.register('PreToolUse', {
441
+ once: true,
442
+ pattern: '^echo$',
443
+ hooks: [
444
+ async (): Promise<PreToolUseHookOutput> => {
445
+ onceCalls += 1;
446
+ return { decision: 'ask', reason: 'review once' };
447
+ },
448
+ ],
449
+ });
450
+ registry.register('PreToolUse', {
451
+ pattern: '^echo$',
452
+ hooks: [
453
+ async (): Promise<PreToolUseHookOutput> => {
454
+ ordinaryCalls += 1;
455
+ return ordinaryCalls === 1
456
+ ? { decision: 'allow' }
457
+ : { decision: 'deny', reason: 'policy changed on resume' };
458
+ },
459
+ ],
460
+ });
461
+ const node = new ToolNode({
462
+ tools: [directTool],
463
+ eventDrivenMode: true,
464
+ hookRegistry: registry,
465
+ directToolNames: new Set(['echo']),
466
+ humanInTheLoop: { enabled: true },
467
+ });
468
+ const graph = buildGraph(node, [
469
+ { id: 'call_mixed_hooks', name: 'echo', args: { command: 'go' } },
470
+ ]);
471
+ const config = { configurable: { thread_id: 'thread-mixed-hooks' } };
472
+
473
+ const first = await graph.invoke({ messages: [] }, config);
474
+ expect(isInterrupted(first)).toBe(true);
475
+ const resumed = await graph.invoke(
476
+ new Command({ resume: [{ type: 'approve' }] }),
477
+ config
478
+ );
479
+
480
+ expect(onceCalls).toBe(1);
481
+ expect(ordinaryCalls).toBe(2);
482
+ expect(sideEffect).not.toHaveBeenCalled();
483
+ const messages = (resumed as { messages: ToolMessage[] }).messages;
484
+ const toolMessage = messages.find(
485
+ (message) => message instanceof ToolMessage
486
+ );
487
+ expect(toolMessage?.status).toBe('error');
488
+ expect(String(toolMessage?.content)).toContain('policy changed on resume');
489
+ });
490
+
85
491
  it('re-executes the direct tool body on resume when interrupt() fires from the direct path', async () => {
86
492
  const sideEffect = jest.fn(() => 'EXECUTED');
87
493
  const directTool = tool(async () => sideEffect(), {
@@ -375,6 +781,84 @@ describe('direct-path HITL: resume scope', () => {
375
781
  });
376
782
  });
377
783
 
784
+ describe('untrusted resume decisions', () => {
785
+ it.each([
786
+ [
787
+ { type: 'respond' },
788
+ 'Approval payload `respond` was missing a string `responseText`',
789
+ ],
790
+ [
791
+ { type: 'aproved' },
792
+ 'Unknown approval decision type "aproved" — failing closed',
793
+ ],
794
+ ])(
795
+ 'blocks and persists malformed direct decision %#',
796
+ async (rawDecision, expectedError) => {
797
+ const executeTool = jest.fn(async () => 'should-not-execute');
798
+ const directTool = tool(executeTool, {
799
+ name: 'subagent',
800
+ description: 'replayable direct tool',
801
+ schema: z.object({ description: z.string() }),
802
+ }) as unknown as StructuredToolInterface;
803
+ const persistSettledOutput = jest.fn(
804
+ async (
805
+ _call: ToolCall,
806
+ _config: RunnableConfig,
807
+ _settled: SettledSubagentToolOutput
808
+ ): Promise<void> => {}
809
+ );
810
+ const replayableTool = directTool as StructuredToolInterface &
811
+ ReplayableSubagentTool;
812
+ replayableTool[SUBAGENT_REPLAY_CONTROLLER] = {
813
+ getSettledOutput: async () => undefined,
814
+ persistSettledOutput,
815
+ };
816
+ const registry = new HookRegistry();
817
+ registry.register('PreToolUse', {
818
+ hooks: [
819
+ async (): Promise<PreToolUseHookOutput> => ({ decision: 'ask' }),
820
+ ],
821
+ });
822
+ const node = new ToolNode({
823
+ tools: [directTool],
824
+ hookRegistry: registry,
825
+ directToolNames: new Set(['subagent']),
826
+ humanInTheLoop: { enabled: true },
827
+ });
828
+ const graph = buildGraph(node, [
829
+ {
830
+ id: 'call_malformed',
831
+ name: 'subagent',
832
+ args: { description: 'inspect' },
833
+ },
834
+ ]);
835
+ const config = {
836
+ configurable: { thread_id: `thread-malformed-${rawDecision.type}` },
837
+ };
838
+
839
+ await graph.invoke({ messages: [] }, config);
840
+ const resumed = await graph.invoke(
841
+ new Command({
842
+ resume: [rawDecision as unknown as t.ToolApprovalDecision],
843
+ }),
844
+ config
845
+ );
846
+
847
+ const messages = (resumed as { messages: ToolMessage[] }).messages;
848
+ const toolMessage = messages.find(
849
+ (message) => message instanceof ToolMessage
850
+ );
851
+ expect(toolMessage?.status).toBe('error');
852
+ expect(String(toolMessage?.content)).toContain(expectedError);
853
+ expect(executeTool).not.toHaveBeenCalled();
854
+ expect(persistSettledOutput).toHaveBeenCalledTimes(1);
855
+ expect(persistSettledOutput.mock.calls[0]?.[2].output.status).toBe(
856
+ 'error'
857
+ );
858
+ }
859
+ );
860
+ });
861
+
378
862
  describe('usage counter stability across resume (Codex P2 #30)', () => {
379
863
  it('turn stays the same across an interrupt + resume — does not double-increment', async () => {
380
864
  // Pre-fix the P2 #27 turn-race fix incremented before the