@librechat/agents 3.1.66 → 3.1.67-dev.0

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 (147) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +23 -3
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/common/enum.cjs +14 -0
  4. package/dist/cjs/common/enum.cjs.map +1 -1
  5. package/dist/cjs/graphs/Graph.cjs +72 -0
  6. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  7. package/dist/cjs/hooks/HookRegistry.cjs +162 -0
  8. package/dist/cjs/hooks/HookRegistry.cjs.map +1 -0
  9. package/dist/cjs/hooks/executeHooks.cjs +276 -0
  10. package/dist/cjs/hooks/executeHooks.cjs.map +1 -0
  11. package/dist/cjs/hooks/matchers.cjs +256 -0
  12. package/dist/cjs/hooks/matchers.cjs.map +1 -0
  13. package/dist/cjs/hooks/types.cjs +27 -0
  14. package/dist/cjs/hooks/types.cjs.map +1 -0
  15. package/dist/cjs/main.cjs +52 -0
  16. package/dist/cjs/main.cjs.map +1 -1
  17. package/dist/cjs/messages/format.cjs +74 -12
  18. package/dist/cjs/messages/format.cjs.map +1 -1
  19. package/dist/cjs/run.cjs +111 -0
  20. package/dist/cjs/run.cjs.map +1 -1
  21. package/dist/cjs/summarization/node.cjs +44 -0
  22. package/dist/cjs/summarization/node.cjs.map +1 -1
  23. package/dist/cjs/tools/BashExecutor.cjs +175 -0
  24. package/dist/cjs/tools/BashExecutor.cjs.map +1 -0
  25. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs +296 -0
  26. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs.map +1 -0
  27. package/dist/cjs/tools/ReadFile.cjs +43 -0
  28. package/dist/cjs/tools/ReadFile.cjs.map +1 -0
  29. package/dist/cjs/tools/SkillTool.cjs +50 -0
  30. package/dist/cjs/tools/SkillTool.cjs.map +1 -0
  31. package/dist/cjs/tools/SubagentTool.cjs +92 -0
  32. package/dist/cjs/tools/SubagentTool.cjs.map +1 -0
  33. package/dist/cjs/tools/ToolNode.cjs +304 -140
  34. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  35. package/dist/cjs/tools/skillCatalog.cjs +84 -0
  36. package/dist/cjs/tools/skillCatalog.cjs.map +1 -0
  37. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +261 -0
  38. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -0
  39. package/dist/esm/agents/AgentContext.mjs +23 -3
  40. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  41. package/dist/esm/common/enum.mjs +13 -1
  42. package/dist/esm/common/enum.mjs.map +1 -1
  43. package/dist/esm/graphs/Graph.mjs +72 -0
  44. package/dist/esm/graphs/Graph.mjs.map +1 -1
  45. package/dist/esm/hooks/HookRegistry.mjs +160 -0
  46. package/dist/esm/hooks/HookRegistry.mjs.map +1 -0
  47. package/dist/esm/hooks/executeHooks.mjs +273 -0
  48. package/dist/esm/hooks/executeHooks.mjs.map +1 -0
  49. package/dist/esm/hooks/matchers.mjs +251 -0
  50. package/dist/esm/hooks/matchers.mjs.map +1 -0
  51. package/dist/esm/hooks/types.mjs +25 -0
  52. package/dist/esm/hooks/types.mjs.map +1 -0
  53. package/dist/esm/main.mjs +12 -1
  54. package/dist/esm/main.mjs.map +1 -1
  55. package/dist/esm/messages/format.mjs +66 -4
  56. package/dist/esm/messages/format.mjs.map +1 -1
  57. package/dist/esm/run.mjs +111 -0
  58. package/dist/esm/run.mjs.map +1 -1
  59. package/dist/esm/summarization/node.mjs +44 -0
  60. package/dist/esm/summarization/node.mjs.map +1 -1
  61. package/dist/esm/tools/BashExecutor.mjs +169 -0
  62. package/dist/esm/tools/BashExecutor.mjs.map +1 -0
  63. package/dist/esm/tools/BashProgrammaticToolCalling.mjs +287 -0
  64. package/dist/esm/tools/BashProgrammaticToolCalling.mjs.map +1 -0
  65. package/dist/esm/tools/ReadFile.mjs +38 -0
  66. package/dist/esm/tools/ReadFile.mjs.map +1 -0
  67. package/dist/esm/tools/SkillTool.mjs +45 -0
  68. package/dist/esm/tools/SkillTool.mjs.map +1 -0
  69. package/dist/esm/tools/SubagentTool.mjs +85 -0
  70. package/dist/esm/tools/SubagentTool.mjs.map +1 -0
  71. package/dist/esm/tools/ToolNode.mjs +306 -142
  72. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  73. package/dist/esm/tools/skillCatalog.mjs +82 -0
  74. package/dist/esm/tools/skillCatalog.mjs.map +1 -0
  75. package/dist/esm/tools/subagent/SubagentExecutor.mjs +256 -0
  76. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -0
  77. package/dist/types/agents/AgentContext.d.ts +6 -0
  78. package/dist/types/common/enum.d.ts +8 -1
  79. package/dist/types/graphs/Graph.d.ts +2 -0
  80. package/dist/types/hooks/HookRegistry.d.ts +56 -0
  81. package/dist/types/hooks/executeHooks.d.ts +79 -0
  82. package/dist/types/hooks/index.d.ts +6 -0
  83. package/dist/types/hooks/matchers.d.ts +95 -0
  84. package/dist/types/hooks/types.d.ts +320 -0
  85. package/dist/types/index.d.ts +8 -0
  86. package/dist/types/messages/format.d.ts +2 -1
  87. package/dist/types/run.d.ts +1 -0
  88. package/dist/types/summarization/node.d.ts +2 -0
  89. package/dist/types/tools/BashExecutor.d.ts +45 -0
  90. package/dist/types/tools/BashProgrammaticToolCalling.d.ts +72 -0
  91. package/dist/types/tools/ReadFile.d.ts +28 -0
  92. package/dist/types/tools/SkillTool.d.ts +40 -0
  93. package/dist/types/tools/SubagentTool.d.ts +36 -0
  94. package/dist/types/tools/ToolNode.d.ts +24 -2
  95. package/dist/types/tools/skillCatalog.d.ts +19 -0
  96. package/dist/types/tools/subagent/SubagentExecutor.d.ts +83 -0
  97. package/dist/types/tools/subagent/index.d.ts +2 -0
  98. package/dist/types/types/graph.d.ts +25 -0
  99. package/dist/types/types/index.d.ts +1 -0
  100. package/dist/types/types/llm.d.ts +14 -2
  101. package/dist/types/types/run.d.ts +20 -0
  102. package/dist/types/types/skill.d.ts +9 -0
  103. package/dist/types/types/tools.d.ts +38 -1
  104. package/package.json +2 -1
  105. package/src/agents/AgentContext.ts +26 -2
  106. package/src/common/enum.ts +13 -0
  107. package/src/graphs/Graph.ts +92 -0
  108. package/src/hooks/HookRegistry.ts +208 -0
  109. package/src/hooks/__tests__/HookRegistry.test.ts +190 -0
  110. package/src/hooks/__tests__/compactHooks.test.ts +214 -0
  111. package/src/hooks/__tests__/executeHooks.test.ts +1013 -0
  112. package/src/hooks/__tests__/integration.test.ts +337 -0
  113. package/src/hooks/__tests__/matchers.test.ts +238 -0
  114. package/src/hooks/__tests__/toolHooks.test.ts +669 -0
  115. package/src/hooks/executeHooks.ts +375 -0
  116. package/src/hooks/index.ts +57 -0
  117. package/src/hooks/matchers.ts +280 -0
  118. package/src/hooks/types.ts +404 -0
  119. package/src/index.ts +10 -0
  120. package/src/messages/format.ts +74 -4
  121. package/src/messages/formatAgentMessages.skills.test.ts +334 -0
  122. package/src/run.ts +126 -0
  123. package/src/scripts/multi-agent-subagent.ts +246 -0
  124. package/src/specs/subagent.test.ts +305 -0
  125. package/src/summarization/node.ts +53 -0
  126. package/src/tools/BashExecutor.ts +205 -0
  127. package/src/tools/BashProgrammaticToolCalling.ts +397 -0
  128. package/src/tools/ReadFile.ts +39 -0
  129. package/src/tools/SkillTool.ts +46 -0
  130. package/src/tools/SubagentTool.ts +100 -0
  131. package/src/tools/ToolNode.ts +391 -169
  132. package/src/tools/__tests__/ReadFile.test.ts +44 -0
  133. package/src/tools/__tests__/SkillTool.test.ts +442 -0
  134. package/src/tools/__tests__/SubagentExecutor.test.ts +615 -0
  135. package/src/tools/__tests__/SubagentTool.test.ts +149 -0
  136. package/src/tools/__tests__/ToolNode.session.test.ts +12 -12
  137. package/src/tools/__tests__/skillCatalog.test.ts +161 -0
  138. package/src/tools/__tests__/subagentHooks.test.ts +215 -0
  139. package/src/tools/skillCatalog.ts +126 -0
  140. package/src/tools/subagent/SubagentExecutor.ts +344 -0
  141. package/src/tools/subagent/index.ts +12 -0
  142. package/src/types/graph.ts +27 -0
  143. package/src/types/index.ts +1 -0
  144. package/src/types/llm.ts +16 -2
  145. package/src/types/run.ts +20 -0
  146. package/src/types/skill.ts +11 -0
  147. package/src/types/tools.ts +41 -1
@@ -0,0 +1,669 @@
1
+ // src/hooks/__tests__/toolHooks.test.ts
2
+ import { ToolCall } from '@langchain/core/messages/tool';
3
+ import { HumanMessage } from '@langchain/core/messages';
4
+ import { HookRegistry } from '../HookRegistry';
5
+ import { Run } from '@/run';
6
+ import {
7
+ GraphEvents,
8
+ Providers,
9
+ ToolEndHandler,
10
+ ModelEndHandler,
11
+ } from '@/index';
12
+ import type * as t from '@/types';
13
+ import type {
14
+ HookCallback,
15
+ PreToolUseHookOutput,
16
+ PostToolUseHookOutput,
17
+ PostToolUseFailureHookOutput,
18
+ PermissionDeniedHookInput,
19
+ PermissionDeniedHookOutput,
20
+ PreToolUseHookInput,
21
+ PostToolUseHookInput,
22
+ PostToolUseFailureHookInput,
23
+ } from '../types';
24
+
25
+ const llmConfig: t.LLMConfig = {
26
+ provider: Providers.OPENAI,
27
+ streaming: true,
28
+ streamUsage: false,
29
+ };
30
+
31
+ const callerConfig = {
32
+ configurable: { thread_id: 'test-thread' },
33
+ streamMode: 'values' as const,
34
+ version: 'v2' as const,
35
+ };
36
+
37
+ const echoToolDef: t.LCTool = {
38
+ name: 'echo',
39
+ description: 'Echoes input',
40
+ parameters: {
41
+ type: 'object' as const,
42
+ properties: { text: { type: 'string' } },
43
+ required: ['text'],
44
+ },
45
+ };
46
+
47
+ let callCounter = 0;
48
+
49
+ function makeToolCall(text = 'hello', name = 'echo'): ToolCall {
50
+ return {
51
+ name,
52
+ args: { text },
53
+ id: `call_${++callCounter}`,
54
+ type: 'tool_call',
55
+ };
56
+ }
57
+
58
+ function createToolExecuteHandler(): t.EventHandler {
59
+ return {
60
+ handle: async (_event: string, rawData: unknown): Promise<void> => {
61
+ const data = rawData as t.ToolExecuteBatchRequest;
62
+ const results: t.ToolExecuteResult[] = data.toolCalls.map(
63
+ (tc: t.ToolCallRequest) => ({
64
+ toolCallId: tc.id,
65
+ content: `echo: ${(tc.args as Record<string, string>).text}`,
66
+ status: 'success' as const,
67
+ })
68
+ );
69
+ data.resolve(results);
70
+ },
71
+ };
72
+ }
73
+
74
+ function createErrorToolExecuteHandler(): t.EventHandler {
75
+ return {
76
+ handle: async (_event: string, rawData: unknown): Promise<void> => {
77
+ const data = rawData as t.ToolExecuteBatchRequest;
78
+ const results: t.ToolExecuteResult[] = data.toolCalls.map(
79
+ (tc: t.ToolCallRequest) => ({
80
+ toolCallId: tc.id,
81
+ content: '',
82
+ status: 'error' as const,
83
+ errorMessage: `tool ${tc.name} failed deliberately`,
84
+ })
85
+ );
86
+ data.resolve(results);
87
+ },
88
+ };
89
+ }
90
+
91
+ async function createEventDrivenRun(
92
+ hooks: HookRegistry,
93
+ toolHandler: t.EventHandler = createToolExecuteHandler(),
94
+ runId = 'tool-hook-run'
95
+ ): Promise<Run<t.IState>> {
96
+ const customHandlers: Record<string, t.EventHandler> = {
97
+ [GraphEvents.ON_TOOL_EXECUTE]: toolHandler,
98
+ [GraphEvents.TOOL_END]: new ToolEndHandler(),
99
+ [GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
100
+ };
101
+
102
+ return Run.create<t.IState>({
103
+ runId,
104
+ graphConfig: {
105
+ type: 'standard',
106
+ llmConfig,
107
+ toolDefinitions: [echoToolDef],
108
+ instructions: 'Use the echo tool when asked.',
109
+ },
110
+ returnContent: true,
111
+ skipCleanup: true,
112
+ customHandlers,
113
+ hooks,
114
+ });
115
+ }
116
+
117
+ describe('Tool-level hook integration (event-driven mode)', () => {
118
+ beforeEach(() => {
119
+ callCounter = 0;
120
+ });
121
+ jest.setTimeout(15000);
122
+
123
+ describe('PreToolUse', () => {
124
+ it('fires with toolName, toolInput, and toolUseId', async () => {
125
+ const registry = new HookRegistry();
126
+ let captured: PreToolUseHookInput | undefined;
127
+ const hook: HookCallback<'PreToolUse'> = async (
128
+ input
129
+ ): Promise<PreToolUseHookOutput> => {
130
+ captured = input;
131
+ return {};
132
+ };
133
+ registry.register('PreToolUse', { hooks: [hook] });
134
+
135
+ const tc = makeToolCall('world');
136
+ const run = await createEventDrivenRun(registry);
137
+ run.Graph!.overrideTestModel(['calling echo'], 5, [tc]);
138
+ await run.processStream(
139
+ { messages: [new HumanMessage('echo world')] },
140
+ callerConfig
141
+ );
142
+
143
+ expect(captured).toBeDefined();
144
+ expect(captured!.hook_event_name).toBe('PreToolUse');
145
+ expect(captured!.toolName).toBe('echo');
146
+ expect(captured!.toolInput).toEqual({ text: 'world' });
147
+ expect(captured!.toolUseId).toBe(tc.id);
148
+ });
149
+
150
+ it('deny blocks tool execution and produces error ToolMessage', async () => {
151
+ const registry = new HookRegistry();
152
+ let toolExecuted = false;
153
+ const denyHook: HookCallback<
154
+ 'PreToolUse'
155
+ > = async (): Promise<PreToolUseHookOutput> => ({
156
+ decision: 'deny',
157
+ reason: 'not allowed',
158
+ });
159
+ registry.register('PreToolUse', {
160
+ pattern: '^echo$',
161
+ hooks: [denyHook],
162
+ });
163
+
164
+ const spyHandler: t.EventHandler = {
165
+ handle: async (_event: string, rawData: unknown): Promise<void> => {
166
+ const data = rawData as t.ToolExecuteBatchRequest;
167
+ toolExecuted = true;
168
+ data.resolve(
169
+ data.toolCalls.map((tc: t.ToolCallRequest) => ({
170
+ toolCallId: tc.id,
171
+ content: 'should not reach',
172
+ status: 'success' as const,
173
+ }))
174
+ );
175
+ },
176
+ };
177
+
178
+ const run = await createEventDrivenRun(registry, spyHandler);
179
+ run.Graph!.overrideTestModel(['calling echo'], 5, [makeToolCall()]);
180
+ await run.processStream(
181
+ { messages: [new HumanMessage('echo hello')] },
182
+ callerConfig
183
+ );
184
+
185
+ expect(toolExecuted).toBe(false);
186
+ });
187
+
188
+ it('deny dispatches ON_RUN_STEP_COMPLETED for the blocked call', async () => {
189
+ const registry = new HookRegistry();
190
+ const denyHook: HookCallback<
191
+ 'PreToolUse'
192
+ > = async (): Promise<PreToolUseHookOutput> => ({
193
+ decision: 'deny',
194
+ reason: 'not allowed',
195
+ });
196
+ registry.register('PreToolUse', {
197
+ pattern: '^echo$',
198
+ hooks: [denyHook],
199
+ });
200
+
201
+ let stepCompletedData: t.ToolCompleteEvent | undefined;
202
+ const stepHandler: t.EventHandler = {
203
+ handle: async (_event: string, rawData: unknown): Promise<void> => {
204
+ const data = rawData as { result: t.ToolCompleteEvent };
205
+ stepCompletedData = data.result;
206
+ },
207
+ };
208
+
209
+ const toolHandler = createToolExecuteHandler();
210
+ const customHandlers: Record<string, t.EventHandler> = {
211
+ [GraphEvents.ON_TOOL_EXECUTE]: toolHandler,
212
+ [GraphEvents.TOOL_END]: new ToolEndHandler(),
213
+ [GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
214
+ [GraphEvents.ON_RUN_STEP_COMPLETED]: stepHandler,
215
+ };
216
+
217
+ const tc = makeToolCall('hello');
218
+ const run = await Run.create<t.IState>({
219
+ runId: 'deny-step-run',
220
+ graphConfig: {
221
+ type: 'standard',
222
+ llmConfig,
223
+ toolDefinitions: [echoToolDef],
224
+ instructions: 'Use the echo tool when asked.',
225
+ },
226
+ returnContent: true,
227
+ skipCleanup: true,
228
+ customHandlers,
229
+ hooks: registry,
230
+ });
231
+
232
+ run.Graph!.overrideTestModel(['calling echo'], 5, [tc]);
233
+ await run.processStream(
234
+ { messages: [new HumanMessage('echo hello')] },
235
+ callerConfig
236
+ );
237
+
238
+ expect(stepCompletedData).toBeDefined();
239
+ expect(stepCompletedData!.type).toBe('tool_call');
240
+ expect(stepCompletedData!.tool_call.name).toBe('echo');
241
+ expect(stepCompletedData!.tool_call.id).toBe(tc.id);
242
+ expect(stepCompletedData!.tool_call.output).toContain('Blocked:');
243
+ });
244
+
245
+ it('ask blocks tool execution in v1 (same as deny)', async () => {
246
+ const registry = new HookRegistry();
247
+ let toolExecuted = false;
248
+ const askHook: HookCallback<
249
+ 'PreToolUse'
250
+ > = async (): Promise<PreToolUseHookOutput> => ({
251
+ decision: 'ask',
252
+ reason: 'needs confirmation',
253
+ });
254
+ registry.register('PreToolUse', { hooks: [askHook] });
255
+
256
+ const spyHandler: t.EventHandler = {
257
+ handle: async (_event: string, rawData: unknown): Promise<void> => {
258
+ const data = rawData as t.ToolExecuteBatchRequest;
259
+ toolExecuted = true;
260
+ data.resolve(
261
+ data.toolCalls.map((tc: t.ToolCallRequest) => ({
262
+ toolCallId: tc.id,
263
+ content: 'x',
264
+ status: 'success' as const,
265
+ }))
266
+ );
267
+ },
268
+ };
269
+
270
+ const run = await createEventDrivenRun(registry, spyHandler);
271
+ run.Graph!.overrideTestModel(['calling echo'], 5, [makeToolCall()]);
272
+ await run.processStream(
273
+ { messages: [new HumanMessage('echo hello')] },
274
+ callerConfig
275
+ );
276
+
277
+ expect(toolExecuted).toBe(false);
278
+ });
279
+
280
+ it('updatedInput rewrites tool args before dispatch', async () => {
281
+ const registry = new HookRegistry();
282
+ let receivedArgs: Record<string, unknown> | undefined;
283
+ const rewriteHook: HookCallback<
284
+ 'PreToolUse'
285
+ > = async (): Promise<PreToolUseHookOutput> => ({
286
+ updatedInput: { text: 'sanitized' },
287
+ });
288
+ registry.register('PreToolUse', { hooks: [rewriteHook] });
289
+
290
+ const captureHandler: t.EventHandler = {
291
+ handle: async (_event: string, rawData: unknown): Promise<void> => {
292
+ const data = rawData as t.ToolExecuteBatchRequest;
293
+ receivedArgs = data.toolCalls[0]?.args;
294
+ data.resolve(
295
+ data.toolCalls.map((tc: t.ToolCallRequest) => ({
296
+ toolCallId: tc.id,
297
+ content: `echo: ${(tc.args as Record<string, string>).text}`,
298
+ status: 'success' as const,
299
+ }))
300
+ );
301
+ },
302
+ };
303
+
304
+ const run = await createEventDrivenRun(registry, captureHandler);
305
+ run.Graph!.overrideTestModel(['calling echo'], 5, [
306
+ makeToolCall('dangerous'),
307
+ ]);
308
+ await run.processStream(
309
+ { messages: [new HumanMessage('echo')] },
310
+ callerConfig
311
+ );
312
+
313
+ expect(receivedArgs).toEqual({ text: 'sanitized' });
314
+ });
315
+
316
+ it('hook errors are non-fatal — tool still executes', async () => {
317
+ const registry = new HookRegistry();
318
+ let toolExecuted = false;
319
+ const throwingHook: HookCallback<
320
+ 'PreToolUse'
321
+ > = async (): Promise<PreToolUseHookOutput> => {
322
+ throw new Error('hook crash');
323
+ };
324
+ registry.register('PreToolUse', { hooks: [throwingHook] });
325
+
326
+ const spyHandler: t.EventHandler = {
327
+ handle: async (_event: string, rawData: unknown): Promise<void> => {
328
+ const data = rawData as t.ToolExecuteBatchRequest;
329
+ toolExecuted = true;
330
+ data.resolve(
331
+ data.toolCalls.map((tc: t.ToolCallRequest) => ({
332
+ toolCallId: tc.id,
333
+ content: 'ok',
334
+ status: 'success' as const,
335
+ }))
336
+ );
337
+ },
338
+ };
339
+
340
+ const run = await createEventDrivenRun(registry, spyHandler);
341
+ run.Graph!.overrideTestModel(['calling echo'], 5, [makeToolCall()]);
342
+ await run.processStream(
343
+ { messages: [new HumanMessage('echo')] },
344
+ callerConfig
345
+ );
346
+
347
+ expect(toolExecuted).toBe(true);
348
+ });
349
+ });
350
+
351
+ describe('PermissionDenied', () => {
352
+ it('fires after PreToolUse deny with the reason', async () => {
353
+ const registry = new HookRegistry();
354
+ let pdResolve: () => void;
355
+ const pdDone = new Promise<void>((r) => {
356
+ pdResolve = r;
357
+ });
358
+ let captured: PermissionDeniedHookInput | undefined;
359
+ const denyHook: HookCallback<
360
+ 'PreToolUse'
361
+ > = async (): Promise<PreToolUseHookOutput> => ({
362
+ decision: 'deny',
363
+ reason: 'security policy',
364
+ });
365
+ const pdHook: HookCallback<'PermissionDenied'> = async (
366
+ input
367
+ ): Promise<PermissionDeniedHookOutput> => {
368
+ captured = input;
369
+ pdResolve();
370
+ return {};
371
+ };
372
+ registry.register('PreToolUse', { hooks: [denyHook] });
373
+ registry.register('PermissionDenied', { hooks: [pdHook] });
374
+
375
+ const run = await createEventDrivenRun(registry);
376
+ run.Graph!.overrideTestModel(['calling echo'], 5, [makeToolCall()]);
377
+ await run.processStream(
378
+ { messages: [new HumanMessage('echo')] },
379
+ callerConfig
380
+ );
381
+
382
+ await pdDone;
383
+ expect(captured).toBeDefined();
384
+ expect(captured!.reason).toBe('security policy');
385
+ expect(captured!.toolName).toBe('echo');
386
+ });
387
+ });
388
+
389
+ describe('PostToolUse', () => {
390
+ it('fires after successful tool execution with output', async () => {
391
+ const registry = new HookRegistry();
392
+ let captured: PostToolUseHookInput | undefined;
393
+ const hook: HookCallback<'PostToolUse'> = async (
394
+ input
395
+ ): Promise<PostToolUseHookOutput> => {
396
+ captured = input;
397
+ return {};
398
+ };
399
+ registry.register('PostToolUse', { hooks: [hook] });
400
+
401
+ const run = await createEventDrivenRun(registry);
402
+ run.Graph!.overrideTestModel(['calling echo'], 5, [makeToolCall('hi')]);
403
+ await run.processStream(
404
+ { messages: [new HumanMessage('echo hi')] },
405
+ callerConfig
406
+ );
407
+
408
+ expect(captured).toBeDefined();
409
+ expect(captured!.hook_event_name).toBe('PostToolUse');
410
+ expect(captured!.toolName).toBe('echo');
411
+ expect(captured!.toolOutput).toBe('echo: hi');
412
+ });
413
+
414
+ it('updatedOutput replaces the ToolMessage content', async () => {
415
+ const registry = new HookRegistry();
416
+ const replaceHook: HookCallback<
417
+ 'PostToolUse'
418
+ > = async (): Promise<PostToolUseHookOutput> => ({
419
+ updatedOutput: 'REDACTED',
420
+ });
421
+ registry.register('PostToolUse', { hooks: [replaceHook] });
422
+
423
+ let resolvedContent: string | undefined;
424
+ const captureHandler: t.EventHandler = {
425
+ handle: async (_event: string, rawData: unknown): Promise<void> => {
426
+ const data = rawData as t.ToolExecuteBatchRequest;
427
+ const results = data.toolCalls.map(
428
+ (tc: t.ToolCallRequest): t.ToolExecuteResult => ({
429
+ toolCallId: tc.id,
430
+ content: 'original secret output',
431
+ status: 'success' as const,
432
+ })
433
+ );
434
+ data.resolve(results);
435
+ },
436
+ };
437
+
438
+ const run = await createEventDrivenRun(registry, captureHandler);
439
+ run.Graph!.overrideTestModel(['calling echo'], 5, [makeToolCall()]);
440
+ await run.processStream(
441
+ { messages: [new HumanMessage('echo')] },
442
+ callerConfig
443
+ );
444
+
445
+ const messages = run.Graph!.getRunMessages() ?? [];
446
+ const toolMsg = messages.find((m) => m.getType() === 'tool');
447
+ expect(toolMsg).toBeDefined();
448
+ if (toolMsg != null) {
449
+ resolvedContent =
450
+ typeof toolMsg.content === 'string'
451
+ ? toolMsg.content
452
+ : JSON.stringify(toolMsg.content);
453
+ }
454
+
455
+ expect(resolvedContent).toBe('REDACTED');
456
+ });
457
+ });
458
+
459
+ describe('PostToolUseFailure', () => {
460
+ it('fires when tool execution returns an error', async () => {
461
+ const registry = new HookRegistry();
462
+ let captured: PostToolUseFailureHookInput | undefined;
463
+ const hook: HookCallback<'PostToolUseFailure'> = async (
464
+ input
465
+ ): Promise<PostToolUseFailureHookOutput> => {
466
+ captured = input;
467
+ return {};
468
+ };
469
+ registry.register('PostToolUseFailure', { hooks: [hook] });
470
+
471
+ const run = await createEventDrivenRun(
472
+ registry,
473
+ createErrorToolExecuteHandler()
474
+ );
475
+ run.Graph!.overrideTestModel(['calling echo'], 5, [makeToolCall()]);
476
+ await run.processStream(
477
+ { messages: [new HumanMessage('echo')] },
478
+ callerConfig
479
+ );
480
+
481
+ expect(captured).toBeDefined();
482
+ expect(captured!.hook_event_name).toBe('PostToolUseFailure');
483
+ expect(captured!.toolName).toBe('echo');
484
+ expect(captured!.error).toContain('failed deliberately');
485
+ });
486
+ });
487
+
488
+ describe('multi-call batch', () => {
489
+ const mathToolDef: t.LCTool = {
490
+ name: 'math',
491
+ description: 'Does math',
492
+ parameters: {
493
+ type: 'object' as const,
494
+ properties: { expr: { type: 'string' } },
495
+ required: ['expr'],
496
+ },
497
+ };
498
+
499
+ function createMultiToolRun(
500
+ hooks: HookRegistry,
501
+ runId = 'multi-run'
502
+ ): Promise<Run<t.IState>> {
503
+ const handler: t.EventHandler = {
504
+ handle: async (_event: string, rawData: unknown): Promise<void> => {
505
+ const data = rawData as t.ToolExecuteBatchRequest;
506
+ data.resolve(
507
+ data.toolCalls.map(
508
+ (tc: t.ToolCallRequest): t.ToolExecuteResult => ({
509
+ toolCallId: tc.id,
510
+ content: `${tc.name}: ok`,
511
+ status: 'success' as const,
512
+ })
513
+ )
514
+ );
515
+ },
516
+ };
517
+ return Run.create<t.IState>({
518
+ runId,
519
+ graphConfig: {
520
+ type: 'standard',
521
+ llmConfig,
522
+ toolDefinitions: [echoToolDef, mathToolDef],
523
+ instructions: 'Use tools.',
524
+ },
525
+ returnContent: true,
526
+ skipCleanup: true,
527
+ customHandlers: {
528
+ [GraphEvents.ON_TOOL_EXECUTE]: handler,
529
+ [GraphEvents.TOOL_END]: new ToolEndHandler(),
530
+ [GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
531
+ },
532
+ hooks,
533
+ });
534
+ }
535
+
536
+ it('partial deny: denied call produces error, approved call executes, order preserved', async () => {
537
+ const registry = new HookRegistry();
538
+ const denyEcho: HookCallback<'PreToolUse'> = async (
539
+ input
540
+ ): Promise<PreToolUseHookOutput> =>
541
+ input.toolName === 'echo'
542
+ ? { decision: 'deny', reason: 'echo blocked' }
543
+ : {};
544
+ registry.register('PreToolUse', { hooks: [denyEcho] });
545
+
546
+ const echoCall = makeToolCall('hi', 'echo');
547
+ const mathCall = makeToolCall('1+1', 'math');
548
+ const run = await createMultiToolRun(registry);
549
+ run.Graph!.overrideTestModel(['calling tools'], 5, [echoCall, mathCall]);
550
+ await run.processStream(
551
+ { messages: [new HumanMessage('do both')] },
552
+ callerConfig
553
+ );
554
+
555
+ const messages = run.Graph!.getRunMessages() ?? [];
556
+ const toolMsgs = messages.filter((m) => m.getType() === 'tool');
557
+
558
+ expect(toolMsgs).toHaveLength(2);
559
+ const first = toolMsgs[0];
560
+ const second = toolMsgs[1];
561
+ expect(first.content).toContain('Blocked');
562
+ expect(second.content).toContain('math: ok');
563
+ });
564
+
565
+ it('all denied: no ON_TOOL_EXECUTE dispatch, all error messages', async () => {
566
+ const registry = new HookRegistry();
567
+ let handlerCalled = false;
568
+ const denyAll: HookCallback<
569
+ 'PreToolUse'
570
+ > = async (): Promise<PreToolUseHookOutput> => ({
571
+ decision: 'deny',
572
+ reason: 'all blocked',
573
+ });
574
+ registry.register('PreToolUse', { hooks: [denyAll] });
575
+
576
+ const handler: t.EventHandler = {
577
+ handle: async (_event: string, rawData: unknown): Promise<void> => {
578
+ handlerCalled = true;
579
+ const data = rawData as t.ToolExecuteBatchRequest;
580
+ data.resolve([]);
581
+ },
582
+ };
583
+
584
+ const run = await Run.create<t.IState>({
585
+ runId: 'all-denied-run',
586
+ graphConfig: {
587
+ type: 'standard',
588
+ llmConfig,
589
+ toolDefinitions: [echoToolDef, mathToolDef],
590
+ instructions: 'Use tools.',
591
+ },
592
+ returnContent: true,
593
+ skipCleanup: true,
594
+ customHandlers: {
595
+ [GraphEvents.ON_TOOL_EXECUTE]: handler,
596
+ [GraphEvents.TOOL_END]: new ToolEndHandler(),
597
+ [GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
598
+ },
599
+ hooks: registry,
600
+ });
601
+ run.Graph!.overrideTestModel(['calling tools'], 5, [
602
+ makeToolCall('a', 'echo'),
603
+ makeToolCall('b', 'math'),
604
+ ]);
605
+ await run.processStream(
606
+ { messages: [new HumanMessage('do both')] },
607
+ callerConfig
608
+ );
609
+
610
+ expect(handlerCalled).toBe(false);
611
+ });
612
+ });
613
+
614
+ describe('PostToolUse error resilience', () => {
615
+ it('PostToolUse hook errors are non-fatal — original output preserved', async () => {
616
+ const registry = new HookRegistry();
617
+ const throwingHook: HookCallback<
618
+ 'PostToolUse'
619
+ > = async (): Promise<PostToolUseHookOutput> => {
620
+ throw new Error('post hook crash');
621
+ };
622
+ registry.register('PostToolUse', { hooks: [throwingHook] });
623
+
624
+ const run = await createEventDrivenRun(registry);
625
+ run.Graph!.overrideTestModel(['calling echo'], 5, [makeToolCall('hi')]);
626
+ await run.processStream(
627
+ { messages: [new HumanMessage('echo hi')] },
628
+ callerConfig
629
+ );
630
+
631
+ const messages = run.Graph!.getRunMessages() ?? [];
632
+ const toolMsg = messages.find((m) => m.getType() === 'tool');
633
+ expect(toolMsg).toBeDefined();
634
+ const content =
635
+ typeof toolMsg!.content === 'string'
636
+ ? toolMsg!.content
637
+ : JSON.stringify(toolMsg!.content);
638
+ expect(content).toContain('echo: hi');
639
+ });
640
+ });
641
+
642
+ describe('no-hooks baseline', () => {
643
+ it('event-driven tool execution works identically without hooks', async () => {
644
+ const run = await Run.create<t.IState>({
645
+ runId: 'no-hooks-tool-run',
646
+ graphConfig: {
647
+ type: 'standard',
648
+ llmConfig,
649
+ toolDefinitions: [echoToolDef],
650
+ instructions: 'Use echo.',
651
+ },
652
+ returnContent: true,
653
+ skipCleanup: true,
654
+ customHandlers: {
655
+ [GraphEvents.ON_TOOL_EXECUTE]: createToolExecuteHandler(),
656
+ [GraphEvents.TOOL_END]: new ToolEndHandler(),
657
+ [GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
658
+ },
659
+ });
660
+ run.Graph!.overrideTestModel(['calling echo'], 5, [makeToolCall('test')]);
661
+ const result = await run.processStream(
662
+ { messages: [new HumanMessage('echo test')] },
663
+ callerConfig
664
+ );
665
+
666
+ expect(result).toBeDefined();
667
+ });
668
+ });
669
+ });