@librechat/agents 3.1.88 → 3.1.90

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 (96) hide show
  1. package/dist/cjs/graphs/Graph.cjs +25 -1
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/hooks/executeHooks.cjs +14 -7
  4. package/dist/cjs/hooks/executeHooks.cjs.map +1 -1
  5. package/dist/cjs/llm/anthropic/index.cjs +8 -2
  6. package/dist/cjs/llm/anthropic/index.cjs.map +1 -1
  7. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +34 -0
  8. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
  9. package/dist/cjs/main.cjs +9 -0
  10. package/dist/cjs/main.cjs.map +1 -1
  11. package/dist/cjs/stream.cjs +115 -8
  12. package/dist/cjs/stream.cjs.map +1 -1
  13. package/dist/cjs/tools/BashExecutor.cjs +10 -9
  14. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  15. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs +12 -8
  16. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs.map +1 -1
  17. package/dist/cjs/tools/CodeExecutor.cjs +35 -11
  18. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  19. package/dist/cjs/tools/CodeSessionFileSummary.cjs +63 -0
  20. package/dist/cjs/tools/CodeSessionFileSummary.cjs.map +1 -0
  21. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +16 -12
  22. package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
  23. package/dist/cjs/tools/ToolNode.cjs +32 -12
  24. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  25. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +319 -29
  26. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  27. package/dist/cjs/tools/toolOutputReferences.cjs +8 -0
  28. package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
  29. package/dist/cjs/utils/events.cjs +3 -1
  30. package/dist/cjs/utils/events.cjs.map +1 -1
  31. package/dist/esm/graphs/Graph.mjs +25 -1
  32. package/dist/esm/graphs/Graph.mjs.map +1 -1
  33. package/dist/esm/hooks/executeHooks.mjs +14 -7
  34. package/dist/esm/hooks/executeHooks.mjs.map +1 -1
  35. package/dist/esm/llm/anthropic/index.mjs +9 -3
  36. package/dist/esm/llm/anthropic/index.mjs.map +1 -1
  37. package/dist/esm/llm/anthropic/utils/message_inputs.mjs +33 -1
  38. package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
  39. package/dist/esm/main.mjs +2 -1
  40. package/dist/esm/main.mjs.map +1 -1
  41. package/dist/esm/stream.mjs +115 -8
  42. package/dist/esm/stream.mjs.map +1 -1
  43. package/dist/esm/tools/BashExecutor.mjs +11 -10
  44. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  45. package/dist/esm/tools/BashProgrammaticToolCalling.mjs +13 -9
  46. package/dist/esm/tools/BashProgrammaticToolCalling.mjs.map +1 -1
  47. package/dist/esm/tools/CodeExecutor.mjs +29 -12
  48. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  49. package/dist/esm/tools/CodeSessionFileSummary.mjs +60 -0
  50. package/dist/esm/tools/CodeSessionFileSummary.mjs.map +1 -0
  51. package/dist/esm/tools/ProgrammaticToolCalling.mjs +17 -13
  52. package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
  53. package/dist/esm/tools/ToolNode.mjs +32 -12
  54. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  55. package/dist/esm/tools/subagent/SubagentExecutor.mjs +320 -31
  56. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  57. package/dist/esm/tools/toolOutputReferences.mjs +8 -1
  58. package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
  59. package/dist/esm/utils/events.mjs +3 -1
  60. package/dist/esm/utils/events.mjs.map +1 -1
  61. package/dist/types/graphs/Graph.d.ts +8 -0
  62. package/dist/types/llm/anthropic/index.d.ts +3 -1
  63. package/dist/types/llm/anthropic/utils/message_inputs.d.ts +4 -0
  64. package/dist/types/tools/BashExecutor.d.ts +3 -3
  65. package/dist/types/tools/CodeExecutor.d.ts +10 -3
  66. package/dist/types/tools/CodeSessionFileSummary.d.ts +3 -0
  67. package/dist/types/tools/ProgrammaticToolCalling.d.ts +4 -4
  68. package/dist/types/tools/subagent/SubagentExecutor.d.ts +8 -5
  69. package/dist/types/types/tools.d.ts +11 -3
  70. package/dist/types/utils/events.d.ts +1 -1
  71. package/package.json +1 -1
  72. package/src/__tests__/stream.eagerEventExecution.test.ts +1073 -221
  73. package/src/graphs/Graph.ts +27 -5
  74. package/src/hooks/__tests__/executeHooks.test.ts +38 -0
  75. package/src/hooks/executeHooks.ts +27 -7
  76. package/src/llm/anthropic/index.ts +27 -3
  77. package/src/llm/anthropic/llm.spec.ts +60 -1
  78. package/src/llm/anthropic/utils/message_inputs.ts +46 -0
  79. package/src/specs/subagent.test.ts +87 -1
  80. package/src/stream.ts +163 -12
  81. package/src/tools/BashExecutor.ts +21 -10
  82. package/src/tools/BashProgrammaticToolCalling.ts +21 -9
  83. package/src/tools/CodeExecutor.ts +55 -12
  84. package/src/tools/CodeSessionFileSummary.ts +80 -0
  85. package/src/tools/ProgrammaticToolCalling.ts +25 -12
  86. package/src/tools/ToolNode.ts +142 -116
  87. package/src/tools/__tests__/BashExecutor.test.ts +9 -0
  88. package/src/tools/__tests__/CodeApiAuthHeaders.test.ts +43 -0
  89. package/src/tools/__tests__/ProgrammaticToolCalling.test.ts +100 -16
  90. package/src/tools/__tests__/SubagentExecutor.test.ts +540 -6
  91. package/src/tools/__tests__/ToolNode.eagerEventExecution.test.ts +278 -14
  92. package/src/tools/__tests__/ToolNode.outputReferences.test.ts +52 -0
  93. package/src/tools/__tests__/subagentHooks.test.ts +237 -0
  94. package/src/tools/subagent/SubagentExecutor.ts +514 -36
  95. package/src/types/tools.ts +11 -3
  96. package/src/utils/events.ts +4 -2
@@ -117,6 +117,276 @@ describe('ToolNode eager event tool execution', () => {
117
117
  expect(result.messages[0].content).toBe('eager result');
118
118
  });
119
119
 
120
+ it('uses a prestarted event result when final args are canonically equivalent', async () => {
121
+ const { toolExecuteCalls } = installToolExecuteResponder('redispatched');
122
+ const eagerExecutions = new Map<string, t.EagerEventToolExecution>();
123
+ const request: t.ToolCallRequest = {
124
+ id: 'call_weather',
125
+ name: 'weather',
126
+ args: { city: 'NYC', units: 'metric' },
127
+ stepId: 'step_weather',
128
+ turn: 0,
129
+ };
130
+ eagerExecutions.set('call_weather', {
131
+ toolCallId: 'call_weather',
132
+ toolName: 'weather',
133
+ args: { city: 'NYC', units: 'metric' },
134
+ request,
135
+ promise: Promise.resolve({
136
+ results: [
137
+ {
138
+ toolCallId: 'call_weather',
139
+ status: 'success',
140
+ content: 'eager result',
141
+ },
142
+ ],
143
+ }),
144
+ });
145
+
146
+ const toolNode = new ToolNode({
147
+ tools: [createDummyTool('weather')],
148
+ eventDrivenMode: true,
149
+ eagerEventToolExecution: { enabled: true },
150
+ eagerEventToolExecutions: eagerExecutions,
151
+ toolCallStepIds: new Map([['call_weather', 'step_weather']]),
152
+ });
153
+
154
+ const result = (await toolNode.invoke({
155
+ messages: [
156
+ createAIMessage('call_weather', 'weather', {
157
+ units: 'metric',
158
+ city: 'NYC',
159
+ }),
160
+ ],
161
+ })) as { messages: ToolMessage[] };
162
+
163
+ expect(toolExecuteCalls).toHaveLength(0);
164
+ expect(eagerExecutions.has('call_weather')).toBe(false);
165
+ expect(result.messages).toHaveLength(1);
166
+ expect(result.messages[0].content).toBe('eager result');
167
+ });
168
+
169
+ it('uses a prestarted event result when only final planning turn differs', async () => {
170
+ const { toolExecuteCalls } = installToolExecuteResponder('redispatched');
171
+ const eagerExecutions = new Map<string, t.EagerEventToolExecution>();
172
+ const eagerUsageCount = new Map<string, number>([['weather', 1]]);
173
+ const request: t.ToolCallRequest = {
174
+ id: 'call_weather',
175
+ name: 'weather',
176
+ args: { city: 'NYC' },
177
+ stepId: 'step_weather',
178
+ turn: 7,
179
+ };
180
+ eagerExecutions.set('call_weather', {
181
+ toolCallId: 'call_weather',
182
+ toolName: 'weather',
183
+ args: { city: 'NYC' },
184
+ request,
185
+ promise: Promise.resolve({
186
+ results: [
187
+ {
188
+ toolCallId: 'call_weather',
189
+ status: 'success',
190
+ content: 'eager result',
191
+ },
192
+ ],
193
+ }),
194
+ });
195
+
196
+ const toolNode = new ToolNode({
197
+ tools: [createDummyTool('weather')],
198
+ eventDrivenMode: true,
199
+ eagerEventToolExecution: { enabled: true },
200
+ eagerEventToolExecutions: eagerExecutions,
201
+ eagerEventToolUsageCount: eagerUsageCount,
202
+ toolCallStepIds: new Map([['call_weather', 'step_weather']]),
203
+ });
204
+
205
+ const result = (await toolNode.invoke({
206
+ messages: [createAIMessage('call_weather', 'weather', { city: 'NYC' })],
207
+ })) as { messages: ToolMessage[] };
208
+
209
+ expect(toolExecuteCalls).toHaveLength(0);
210
+ expect(eagerExecutions.has('call_weather')).toBe(false);
211
+ expect(eagerUsageCount.get('weather')).toBe(1);
212
+ expect(result.messages).toHaveLength(1);
213
+ expect(result.messages[0].status).toBe('success');
214
+ expect(result.messages[0].content).toBe('eager result');
215
+ });
216
+
217
+ it('redispatches completion when a prestarted event result used a stale turn', async () => {
218
+ const stepCompletions: Array<{
219
+ result?: { index?: number; tool_call?: { output?: string } };
220
+ }> = [];
221
+ jest
222
+ .spyOn(events, 'safeDispatchCustomEvent')
223
+ .mockImplementation(async (event, data): Promise<boolean | void> => {
224
+ if (event === GraphEvents.ON_RUN_STEP_COMPLETED) {
225
+ stepCompletions.push(data as (typeof stepCompletions)[number]);
226
+ return true;
227
+ }
228
+ if (event === GraphEvents.ON_TOOL_EXECUTE) {
229
+ throw new Error('tool should not redispatch');
230
+ }
231
+ });
232
+
233
+ const eagerExecutions = new Map<string, t.EagerEventToolExecution>();
234
+ const eagerUsageCount = new Map<string, number>([['weather', 1]]);
235
+ const request: t.ToolCallRequest = {
236
+ id: 'call_weather',
237
+ name: 'weather',
238
+ args: { city: 'NYC' },
239
+ stepId: 'step_weather',
240
+ turn: 7,
241
+ };
242
+ eagerExecutions.set('call_weather', {
243
+ toolCallId: 'call_weather',
244
+ toolName: 'weather',
245
+ args: { city: 'NYC' },
246
+ request,
247
+ completionDispatched: true,
248
+ promise: Promise.resolve({
249
+ results: [
250
+ {
251
+ toolCallId: 'call_weather',
252
+ status: 'success',
253
+ content: 'eager result',
254
+ },
255
+ ],
256
+ }),
257
+ });
258
+
259
+ const toolNode = new ToolNode({
260
+ tools: [createDummyTool('weather')],
261
+ eventDrivenMode: true,
262
+ eagerEventToolExecution: { enabled: true },
263
+ eagerEventToolExecutions: eagerExecutions,
264
+ eagerEventToolUsageCount: eagerUsageCount,
265
+ toolCallStepIds: new Map([['call_weather', 'step_weather']]),
266
+ });
267
+
268
+ const result = (await toolNode.invoke({
269
+ messages: [createAIMessage('call_weather', 'weather', { city: 'NYC' })],
270
+ })) as { messages: ToolMessage[] };
271
+
272
+ expect(stepCompletions).toHaveLength(1);
273
+ expect(stepCompletions[0].result?.index).toBe(0);
274
+ expect(stepCompletions[0].result?.tool_call?.output).toBe('eager result');
275
+ expect(result.messages).toHaveLength(1);
276
+ expect(result.messages[0].content).toBe('eager result');
277
+ });
278
+
279
+ it('uses a matching prestarted event result when output references are enabled', async () => {
280
+ const { toolExecuteCalls } = installToolExecuteResponder('redispatched');
281
+ const eagerExecutions = new Map<string, t.EagerEventToolExecution>();
282
+ const eagerUsageCount = new Map<string, number>([['weather', 1]]);
283
+ const request: t.ToolCallRequest = {
284
+ id: 'call_weather',
285
+ name: 'weather',
286
+ args: { city: 'NYC' },
287
+ stepId: 'step_weather',
288
+ turn: 0,
289
+ };
290
+ eagerExecutions.set('call_weather', {
291
+ toolCallId: 'call_weather',
292
+ toolName: 'weather',
293
+ args: { city: 'NYC' },
294
+ request,
295
+ promise: Promise.resolve({
296
+ results: [
297
+ {
298
+ toolCallId: 'call_weather',
299
+ status: 'success',
300
+ content: 'eager result',
301
+ },
302
+ ],
303
+ }),
304
+ });
305
+
306
+ const toolNode = new ToolNode({
307
+ tools: [createDummyTool('weather')],
308
+ eventDrivenMode: true,
309
+ eagerEventToolExecution: { enabled: true },
310
+ eagerEventToolExecutions: eagerExecutions,
311
+ eagerEventToolUsageCount: eagerUsageCount,
312
+ toolCallStepIds: new Map([['call_weather', 'step_weather']]),
313
+ toolOutputReferences: { enabled: true },
314
+ });
315
+
316
+ const result = (await toolNode.invoke(
317
+ {
318
+ messages: [createAIMessage('call_weather', 'weather', { city: 'NYC' })],
319
+ },
320
+ { configurable: { run_id: 'run_1' } }
321
+ )) as { messages: ToolMessage[] };
322
+
323
+ expect(toolExecuteCalls).toHaveLength(0);
324
+ expect(eagerExecutions.has('call_weather')).toBe(false);
325
+ expect(result.messages).toHaveLength(1);
326
+ expect(result.messages[0].content).toBe('eager result');
327
+ expect(result.messages[0].additional_kwargs._refKey).toBe('tool0turn0');
328
+ expect(result.messages[0].additional_kwargs._refScope).toBe('run_1');
329
+ });
330
+
331
+ it('does not redispatch completion when eager path already emitted it', async () => {
332
+ const completedEvents: Array<{ result: t.ToolEndEvent }> = [];
333
+ const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
334
+ jest
335
+ .spyOn(events, 'safeDispatchCustomEvent')
336
+ .mockImplementation(async (event, data): Promise<void> => {
337
+ if (event === GraphEvents.ON_RUN_STEP_COMPLETED) {
338
+ completedEvents.push(data as { result: t.ToolEndEvent });
339
+ return;
340
+ }
341
+ if (event !== GraphEvents.ON_TOOL_EXECUTE) {
342
+ return;
343
+ }
344
+ toolExecuteCalls.push(data as t.ToolExecuteBatchRequest);
345
+ });
346
+
347
+ const eagerExecutions = new Map<string, t.EagerEventToolExecution>();
348
+ const request: t.ToolCallRequest = {
349
+ id: 'call_weather',
350
+ name: 'weather',
351
+ args: { city: 'NYC' },
352
+ stepId: 'step_weather',
353
+ turn: 0,
354
+ };
355
+ eagerExecutions.set('call_weather', {
356
+ toolCallId: 'call_weather',
357
+ toolName: 'weather',
358
+ args: { city: 'NYC' },
359
+ request,
360
+ completionDispatched: true,
361
+ promise: Promise.resolve({
362
+ results: [
363
+ {
364
+ toolCallId: 'call_weather',
365
+ status: 'success',
366
+ content: 'eager result',
367
+ },
368
+ ],
369
+ }),
370
+ });
371
+
372
+ const toolNode = new ToolNode({
373
+ tools: [createDummyTool('weather')],
374
+ eventDrivenMode: true,
375
+ eagerEventToolExecution: { enabled: true },
376
+ eagerEventToolExecutions: eagerExecutions,
377
+ eagerEventToolUsageCount: new Map([['weather', 1]]),
378
+ toolCallStepIds: new Map([['call_weather', 'step_weather']]),
379
+ });
380
+
381
+ const result = (await toolNode.invoke({
382
+ messages: [createAIMessage('call_weather', 'weather', { city: 'NYC' })],
383
+ })) as { messages: ToolMessage[] };
384
+
385
+ expect(toolExecuteCalls).toHaveLength(0);
386
+ expect(completedEvents).toHaveLength(0);
387
+ expect(result.messages[0].content).toBe('eager result');
388
+ });
389
+
120
390
  it('fails closed without redispatching when final args differ from the prestarted call', async () => {
121
391
  const { toolExecuteCalls } = installToolExecuteResponder('fresh result');
122
392
  const eagerExecutions = new Map<string, t.EagerEventToolExecution>();
@@ -196,7 +466,7 @@ describe('ToolNode eager event tool execution', () => {
196
466
  const { toolExecuteCalls } = installToolExecuteResponder('normal result');
197
467
  const eagerExecutions = new Map<string, t.EagerEventToolExecution>();
198
468
  const eagerUsageCount = new Map<string, number>([['weather', 1]]);
199
- const staleRequest: t.ToolCallRequest = {
469
+ const prestartedRequest: t.ToolCallRequest = {
200
470
  id: 'call_weather_2',
201
471
  name: 'weather',
202
472
  args: { city: 'Boston' },
@@ -207,13 +477,13 @@ describe('ToolNode eager event tool execution', () => {
207
477
  toolCallId: 'call_weather_2',
208
478
  toolName: 'weather',
209
479
  args: { city: 'Boston' },
210
- request: staleRequest,
480
+ request: prestartedRequest,
211
481
  promise: Promise.resolve({
212
482
  results: [
213
483
  {
214
484
  toolCallId: 'call_weather_2',
215
485
  status: 'success',
216
- content: 'stale eager result',
486
+ content: 'prestarted eager result',
217
487
  },
218
488
  ],
219
489
  }),
@@ -263,8 +533,8 @@ describe('ToolNode eager event tool execution', () => {
263
533
  'call_weather_2',
264
534
  ]);
265
535
  expect(result.messages[0].status).toBe('success');
266
- expect(result.messages[1].status).toBe('error');
267
- expect(result.messages[1].content).toContain('refusing to re-run');
536
+ expect(result.messages[1].status).toBe('success');
537
+ expect(result.messages[1].content).toBe('prestarted eager result');
268
538
  });
269
539
 
270
540
  it('returns a per-call error for malformed event tool args without aborting the batch', async () => {
@@ -314,9 +584,7 @@ describe('ToolNode eager event tool execution', () => {
314
584
  'call_weather_good',
315
585
  ]);
316
586
  expect(result.messages[0].status).toBe('error');
317
- expect(result.messages[0].content).toContain(
318
- 'Invalid tool call arguments'
319
- );
587
+ expect(result.messages[0].content).toContain('Invalid tool call arguments');
320
588
  expect(result.messages[1].status).toBe('success');
321
589
  expect(result.messages[1].content).toBe('normal result');
322
590
  });
@@ -328,9 +596,7 @@ describe('ToolNode eager event tool execution', () => {
328
596
  const hookRegistry = new HookRegistry();
329
597
  hookRegistry.register('PreToolUse', {
330
598
  hooks: [
331
- async (
332
- input: PreToolUseHookInput
333
- ): Promise<PreToolUseHookOutput> => {
599
+ async (input: PreToolUseHookInput): Promise<PreToolUseHookOutput> => {
334
600
  preToolTurns.push(input.turn);
335
601
  return { decision: 'allow' };
336
602
  },
@@ -351,9 +617,7 @@ describe('ToolNode eager event tool execution', () => {
351
617
  });
352
618
 
353
619
  await toolNode.invoke({
354
- messages: [
355
- createAIMessage('call_weather_1', 'weather', { city: 'NYC' }),
356
- ],
620
+ messages: [createAIMessage('call_weather_1', 'weather', { city: 'NYC' })],
357
621
  });
358
622
 
359
623
  eagerUsageCount.clear();
@@ -204,6 +204,58 @@ describe('ToolNode tool output references', () => {
204
204
  expect(capturedArgs).toEqual(['first', 'echo raw-payload']);
205
205
  });
206
206
 
207
+ it('keeps generated-file summaries out of registered outputs', async () => {
208
+ const rawOutput = [
209
+ 'stdout:',
210
+ '{"ok":true}',
211
+ '',
212
+ 'Generated files:',
213
+ 'Session files: 1 persisted file(s) are available in /mnt/data, including 0 image(s). Use known /mnt/data paths directly in later code-tool calls. The app displays files/images automatically; do not invent download links or wrap generated images in Markdown.',
214
+ ].join('\n');
215
+ const cleanOutput = 'stdout:\n{"ok":true}';
216
+ const capturedArgs: string[] = [];
217
+ const filesTool = tool(
218
+ async () =>
219
+ new ToolMessage({
220
+ status: 'success',
221
+ content: rawOutput,
222
+ name: 'files',
223
+ tool_call_id: 'c1',
224
+ }),
225
+ {
226
+ name: 'files',
227
+ description: 'returns generated-file summary output',
228
+ schema: z.object({ command: z.string() }),
229
+ }
230
+ ) as unknown as StructuredToolInterface;
231
+ const echo = createEchoTool({
232
+ capturedArgs,
233
+ outputs: ['resolved'],
234
+ });
235
+ const node = new ToolNode({
236
+ tools: [filesTool, echo],
237
+ toolOutputReferences: { enabled: true },
238
+ });
239
+
240
+ const [msg] = await invokeBatch(node, [
241
+ { id: 'c1', name: 'files', command: 'first' },
242
+ ]);
243
+ await invokeBatch(node, [
244
+ {
245
+ id: 'c2',
246
+ name: 'echo',
247
+ command: 'echo {{tool0turn0}}',
248
+ },
249
+ ]);
250
+
251
+ expect(msg.content).toBe(rawOutput);
252
+ expect(getRefKey(msg)).toBe('tool0turn0');
253
+ expect(
254
+ node._unsafeGetToolOutputRegistry()!.get('test-run', 'tool0turn0')
255
+ ).toBe(cleanOutput);
256
+ expect(capturedArgs).toEqual([`echo ${cleanOutput}`]);
257
+ });
258
+
207
259
  it('increments the turn counter per ToolNode batch', async () => {
208
260
  const capturedArgs: string[] = [];
209
261
  const t1 = createEchoTool({
@@ -4,6 +4,9 @@ import type { ToolCall } from '@langchain/core/messages/tool';
4
4
  import type * as t from '@/types';
5
5
  import type {
6
6
  HookCallback,
7
+ PermissionDeniedHookOutput,
8
+ PostToolUseHookOutput,
9
+ PreToolUseHookOutput,
7
10
  SubagentStartHookInput,
8
11
  SubagentStartHookOutput,
9
12
  SubagentStopHookInput,
@@ -11,6 +14,7 @@ import type {
11
14
  } from '@/hooks/types';
12
15
  import { HookRegistry } from '@/hooks/HookRegistry';
13
16
  import { Run } from '@/run';
17
+ import { FakeChatModel } from '@/llm/fake';
14
18
  import {
15
19
  Constants,
16
20
  GraphEvents,
@@ -22,6 +26,18 @@ import * as providers from '@/llm/providers';
22
26
 
23
27
  const CHILD_RESPONSE = 'Hook test child response.';
24
28
 
29
+ const calculatorDef: t.LCTool = {
30
+ name: 'calculator',
31
+ description: 'Evaluate a math expression.',
32
+ parameters: {
33
+ type: 'object',
34
+ properties: {
35
+ expression: { type: 'string' },
36
+ },
37
+ required: ['expression'],
38
+ },
39
+ };
40
+
25
41
  const callerConfig = {
26
42
  configurable: { thread_id: 'hook-test-thread' },
27
43
  streamMode: 'values' as const,
@@ -66,6 +82,40 @@ function createParentAgent(): t.AgentInputs {
66
82
  };
67
83
  }
68
84
 
85
+ function createParentAgentWithChildTool(): t.AgentInputs {
86
+ return {
87
+ agentId: 'hook-parent',
88
+ provider: Providers.OPENAI,
89
+ clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
90
+ instructions: 'Delegate research tasks to subagents.',
91
+ maxContextTokens: 8000,
92
+ subagentConfigs: [
93
+ {
94
+ type: 'researcher',
95
+ name: 'Researcher',
96
+ description: 'Researches topics',
97
+ agentInputs: {
98
+ agentId: 'researcher-child',
99
+ provider: Providers.OPENAI,
100
+ clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
101
+ instructions: 'Use calculator for arithmetic, then answer concisely.',
102
+ maxContextTokens: 8000,
103
+ toolDefinitions: [calculatorDef],
104
+ },
105
+ },
106
+ ],
107
+ };
108
+ }
109
+
110
+ function createCalculatorToolCall(): ToolCall {
111
+ return {
112
+ name: 'calculator',
113
+ args: { expression: '21 * 2' },
114
+ id: 'call_child_calculator',
115
+ type: 'tool_call',
116
+ };
117
+ }
118
+
69
119
  async function createSubagentRun(
70
120
  hooks: HookRegistry,
71
121
  runId = `subagent-hook-${Date.now()}`
@@ -212,4 +262,191 @@ describe('Subagent hook integration (end-to-end via Run)', () => {
212
262
  'Blocked: policy violation'
213
263
  );
214
264
  });
265
+
266
+ it('PreToolUse and PostToolUse fire for event-driven tools inside subagents', async () => {
267
+ getChatModelClassSpy.mockImplementation(((provider: Providers) => {
268
+ if (provider === Providers.OPENAI) {
269
+ return class extends FakeChatModel {
270
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
271
+ constructor(_options: any) {
272
+ super({
273
+ responses: ['Using calculator.', CHILD_RESPONSE],
274
+ sleep: 1,
275
+ toolCalls: [createCalculatorToolCall()],
276
+ });
277
+ }
278
+ bindTools(
279
+ tools: unknown
280
+ ): ReturnType<FakeChatModel['withConfig']> {
281
+ const config = {
282
+ tools,
283
+ } as Parameters<FakeChatModel['withConfig']>[0];
284
+ return this.withConfig(config);
285
+ }
286
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
287
+ } as any;
288
+ }
289
+ return originalGetChatModelClass(provider);
290
+ }) as typeof providers.getChatModelClass);
291
+
292
+ const registry = new HookRegistry();
293
+ const preToolEvents: string[] = [];
294
+ const postToolEvents: string[] = [];
295
+
296
+ const preHook: HookCallback<'PreToolUse'> = async (
297
+ input
298
+ ): Promise<PreToolUseHookOutput> => {
299
+ preToolEvents.push(`${input.agentId ?? '-'}:${input.toolName}`);
300
+ return { decision: 'allow' };
301
+ };
302
+ registry.register('PreToolUse', { hooks: [preHook] });
303
+
304
+ const postHook: HookCallback<'PostToolUse'> = async (
305
+ input
306
+ ): Promise<PostToolUseHookOutput> => {
307
+ postToolEvents.push(`${input.agentId ?? '-'}:${input.toolName}`);
308
+ return {};
309
+ };
310
+ registry.register('PostToolUse', { hooks: [postHook] });
311
+
312
+ const customHandlers: Record<string, t.EventHandler> = {
313
+ [GraphEvents.TOOL_END]: new ToolEndHandler(),
314
+ [GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
315
+ [GraphEvents.ON_TOOL_EXECUTE]: {
316
+ handle: (_event, rawData): void => {
317
+ const request = rawData as t.ToolExecuteBatchRequest;
318
+ const results: t.ToolExecuteResult[] = request.toolCalls.map(
319
+ (call) => ({
320
+ toolCallId: call.id,
321
+ status: 'success',
322
+ content: '42',
323
+ })
324
+ );
325
+ request.resolve(results);
326
+ },
327
+ },
328
+ };
329
+
330
+ const run = await Run.create<t.IState>({
331
+ runId: `subagent-tool-hook-${Date.now()}`,
332
+ graphConfig: {
333
+ type: 'standard',
334
+ agents: [createParentAgentWithChildTool()],
335
+ },
336
+ returnContent: true,
337
+ skipCleanup: true,
338
+ customHandlers,
339
+ hooks: registry,
340
+ });
341
+
342
+ const tc = makeSubagentToolCall();
343
+ run.Graph!.overrideTestModel(['Delegating...', 'Final answer.'], 5, [tc]);
344
+
345
+ await run.processStream(
346
+ { messages: [new HumanMessage('calculate something')] },
347
+ callerConfig
348
+ );
349
+
350
+ expect(preToolEvents).toContain('-:subagent');
351
+ expect(preToolEvents).toContain('researcher-child:calculator');
352
+ expect(postToolEvents).toContain('-:subagent');
353
+ expect(postToolEvents).toContain('researcher-child:calculator');
354
+ });
355
+
356
+ it('child subagent tool ask hooks fail closed instead of starting unsupported nested HITL', async () => {
357
+ getChatModelClassSpy.mockImplementation(((provider: Providers) => {
358
+ if (provider === Providers.OPENAI) {
359
+ return class extends FakeChatModel {
360
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
361
+ constructor(_options: any) {
362
+ super({
363
+ responses: ['Using calculator.', CHILD_RESPONSE],
364
+ sleep: 1,
365
+ toolCalls: [createCalculatorToolCall()],
366
+ });
367
+ }
368
+ bindTools(
369
+ tools: unknown
370
+ ): ReturnType<FakeChatModel['withConfig']> {
371
+ const config = {
372
+ tools,
373
+ } as Parameters<FakeChatModel['withConfig']>[0];
374
+ return this.withConfig(config);
375
+ }
376
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
377
+ } as any;
378
+ }
379
+ return originalGetChatModelClass(provider);
380
+ }) as typeof providers.getChatModelClass);
381
+
382
+ const registry = new HookRegistry();
383
+ const deniedTools: string[] = [];
384
+ const executedTools: string[] = [];
385
+
386
+ const preHook: HookCallback<'PreToolUse'> = async (
387
+ input
388
+ ): Promise<PreToolUseHookOutput> => {
389
+ if (input.toolName === 'calculator') {
390
+ return { decision: 'ask', reason: 'review calculator' };
391
+ }
392
+ return { decision: 'allow' };
393
+ };
394
+ registry.register('PreToolUse', { hooks: [preHook] });
395
+
396
+ const deniedHook: HookCallback<'PermissionDenied'> = async (
397
+ input
398
+ ): Promise<PermissionDeniedHookOutput> => {
399
+ deniedTools.push(
400
+ `${input.agentId ?? '-'}:${input.toolName}:${input.reason}`
401
+ );
402
+ return {};
403
+ };
404
+ registry.register('PermissionDenied', { hooks: [deniedHook] });
405
+
406
+ const customHandlers: Record<string, t.EventHandler> = {
407
+ [GraphEvents.TOOL_END]: new ToolEndHandler(),
408
+ [GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
409
+ [GraphEvents.ON_TOOL_EXECUTE]: {
410
+ handle: (_event, rawData): void => {
411
+ const request = rawData as t.ToolExecuteBatchRequest;
412
+ executedTools.push(...request.toolCalls.map((call) => call.name));
413
+ const results: t.ToolExecuteResult[] = request.toolCalls.map(
414
+ (call) => ({
415
+ toolCallId: call.id,
416
+ status: 'success',
417
+ content: '42',
418
+ })
419
+ );
420
+ request.resolve(results);
421
+ },
422
+ },
423
+ };
424
+
425
+ const run = await Run.create<t.IState>({
426
+ runId: `subagent-tool-ask-${Date.now()}`,
427
+ graphConfig: {
428
+ type: 'standard',
429
+ agents: [createParentAgentWithChildTool()],
430
+ },
431
+ returnContent: true,
432
+ skipCleanup: true,
433
+ customHandlers,
434
+ hooks: registry,
435
+ humanInTheLoop: { enabled: true },
436
+ });
437
+
438
+ const tc = makeSubagentToolCall();
439
+ run.Graph!.overrideTestModel(['Delegating...', 'Final answer.'], 5, [tc]);
440
+
441
+ await run.processStream(
442
+ { messages: [new HumanMessage('calculate something')] },
443
+ callerConfig
444
+ );
445
+
446
+ expect(run.getInterrupt()).toBeUndefined();
447
+ expect(deniedTools).toContain(
448
+ 'researcher-child:calculator:review calculator'
449
+ );
450
+ expect(executedTools).not.toContain('calculator');
451
+ });
215
452
  });