@librechat/agents 3.2.58 → 3.2.60

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 (67) hide show
  1. package/dist/cjs/graphs/Graph.cjs +31 -7
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/main.cjs +7 -0
  4. package/dist/cjs/run.cjs +4 -0
  5. package/dist/cjs/run.cjs.map +1 -1
  6. package/dist/cjs/stream.cjs +2 -1
  7. package/dist/cjs/stream.cjs.map +1 -1
  8. package/dist/cjs/tools/BashExecutor.cjs +58 -9
  9. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  10. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs +4 -2
  11. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs.map +1 -1
  12. package/dist/cjs/tools/CodeExecutor.cjs +57 -7
  13. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  14. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +9 -3
  15. package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
  16. package/dist/cjs/tools/ToolNode.cjs +114 -11
  17. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  18. package/dist/cjs/tools/eagerEventExecution.cjs +18 -1
  19. package/dist/cjs/tools/eagerEventExecution.cjs.map +1 -1
  20. package/dist/esm/graphs/Graph.mjs +31 -7
  21. package/dist/esm/graphs/Graph.mjs.map +1 -1
  22. package/dist/esm/main.mjs +3 -3
  23. package/dist/esm/run.mjs +4 -0
  24. package/dist/esm/run.mjs.map +1 -1
  25. package/dist/esm/stream.mjs +2 -1
  26. package/dist/esm/stream.mjs.map +1 -1
  27. package/dist/esm/tools/BashExecutor.mjs +56 -10
  28. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  29. package/dist/esm/tools/BashProgrammaticToolCalling.mjs +4 -2
  30. package/dist/esm/tools/BashProgrammaticToolCalling.mjs.map +1 -1
  31. package/dist/esm/tools/CodeExecutor.mjs +54 -8
  32. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  33. package/dist/esm/tools/ProgrammaticToolCalling.mjs +9 -3
  34. package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
  35. package/dist/esm/tools/ToolNode.mjs +115 -12
  36. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  37. package/dist/esm/tools/eagerEventExecution.mjs +18 -2
  38. package/dist/esm/tools/eagerEventExecution.mjs.map +1 -1
  39. package/dist/types/graphs/Graph.d.ts +21 -3
  40. package/dist/types/run.d.ts +1 -0
  41. package/dist/types/tools/BashExecutor.d.ts +13 -0
  42. package/dist/types/tools/CodeExecutor.d.ts +14 -0
  43. package/dist/types/tools/ToolNode.d.ts +60 -1
  44. package/dist/types/tools/eagerEventExecution.d.ts +8 -0
  45. package/dist/types/types/hitl.d.ts +49 -3
  46. package/dist/types/types/run.d.ts +21 -0
  47. package/dist/types/types/tools.d.ts +95 -1
  48. package/package.json +1 -1
  49. package/src/graphs/Graph.ts +74 -28
  50. package/src/run.ts +4 -0
  51. package/src/specs/ask-user-question-batch.test.ts +289 -0
  52. package/src/specs/tool-error-resume.test.ts +194 -0
  53. package/src/stream.ts +17 -1
  54. package/src/tools/BashExecutor.ts +107 -14
  55. package/src/tools/BashProgrammaticToolCalling.ts +20 -1
  56. package/src/tools/CodeExecutor.ts +113 -9
  57. package/src/tools/ProgrammaticToolCalling.ts +27 -1
  58. package/src/tools/ToolNode.ts +208 -31
  59. package/src/tools/__tests__/BashExecutor.test.ts +39 -0
  60. package/src/tools/__tests__/CodeExecutor.stateful.test.ts +113 -0
  61. package/src/tools/__tests__/ToolNode.session.test.ts +86 -0
  62. package/src/tools/__tests__/eagerEventExecution.session.test.ts +92 -0
  63. package/src/tools/__tests__/hitl.test.ts +48 -0
  64. package/src/tools/eagerEventExecution.ts +32 -5
  65. package/src/types/hitl.ts +49 -3
  66. package/src/types/run.ts +21 -0
  67. package/src/types/tools.ts +102 -1
@@ -609,6 +609,14 @@ export abstract class Graph<
609
609
  */
610
610
  eagerEventToolExecution: t.EagerEventToolExecutionConfig | undefined;
611
611
  codeSessionToolNames: string[] | undefined;
612
+ /**
613
+ * Run-scoped names of tools whose in-process body may raise a LangGraph
614
+ * `interrupt()` (e.g. `ask_user_question`). Threaded from
615
+ * `RunConfig.interruptingToolNames` into every ToolNode this graph
616
+ * compiles so a mid-batch interrupt cannot double-execute non-idempotent
617
+ * siblings on resume. See {@link t.ToolNodeOptions.interruptingToolNames}.
618
+ */
619
+ interruptingToolNames: string[] | undefined;
612
620
  eagerEventToolExecutions: Map<string, t.EagerEventToolExecution> = new Map();
613
621
  eagerEventToolUsageCount: Map<string, number> = new Map();
614
622
  private eagerEventToolUsageCountsByAgentId: Map<string, Map<string, number>> =
@@ -658,6 +666,7 @@ export abstract class Graph<
658
666
  this.toolOutputReferences = undefined;
659
667
  this.eagerEventToolExecution = undefined;
660
668
  this.codeSessionToolNames = undefined;
669
+ this.interruptingToolNames = undefined;
661
670
  this.eagerEventToolExecutions.clear();
662
671
  this.clearEagerEventToolUsageCounts();
663
672
  this.eagerEventToolCallChunks.clear();
@@ -1277,11 +1286,16 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
1277
1286
  ),
1278
1287
  toolExecution: this.toolExecution,
1279
1288
  directToolNames: directToolNames.size > 0 ? directToolNames : undefined,
1289
+ interruptingToolNames:
1290
+ this.interruptingToolNames != null &&
1291
+ this.interruptingToolNames.length > 0
1292
+ ? new Set(this.interruptingToolNames)
1293
+ : undefined,
1280
1294
  maxContextTokens: agentContext?.maxContextTokens,
1281
1295
  maxToolResultChars: agentContext?.maxToolResultChars,
1282
1296
  toolOutputRegistry: this.getOrCreateToolOutputRegistry(),
1283
1297
  fileCheckpointer: this.getOrCreateFileCheckpointer(),
1284
- errorHandler: (data, metadata): Promise<void> =>
1298
+ errorHandler: (data, metadata): Promise<boolean> =>
1285
1299
  StandardGraph.handleToolCallErrorStatic(this, data, metadata),
1286
1300
  });
1287
1301
  this.registerCompiledToolNode(node);
@@ -1330,12 +1344,17 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
1330
1344
  // agent so hooks can attribute the batch even at the top level.
1331
1345
  executingAgentId: agentContext?.agentId,
1332
1346
  toolCallStepIds: this.toolCallStepIds,
1333
- errorHandler: (data, metadata): Promise<void> =>
1347
+ errorHandler: (data, metadata): Promise<boolean> =>
1334
1348
  StandardGraph.handleToolCallErrorStatic(this, data, metadata),
1335
1349
  toolRegistry: agentContext?.toolRegistry,
1336
1350
  sessions: this.sessions,
1337
1351
  toolExecution: this.toolExecution,
1338
1352
  codeSessionToolNames: this.codeSessionToolNames,
1353
+ interruptingToolNames:
1354
+ this.interruptingToolNames != null &&
1355
+ this.interruptingToolNames.length > 0
1356
+ ? new Set(this.interruptingToolNames)
1357
+ : undefined,
1339
1358
  hookRegistry: this.hookRegistry,
1340
1359
  humanInTheLoop: this.humanInTheLoop,
1341
1360
  maxContextTokens: agentContext?.maxContextTokens,
@@ -2361,6 +2380,17 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
2361
2380
  childGraph.toolOutputReferences = this.toolOutputReferences;
2362
2381
  childGraph.eagerEventToolExecution = this.eagerEventToolExecution;
2363
2382
  childGraph.codeSessionToolNames = this.codeSessionToolNames;
2383
+ // Pure execution-ordering hint (unlike `humanInTheLoop` above).
2384
+ // It ONLY reorders tools already in the child's direct group;
2385
+ // it does not force a name onto the direct path (that fold-in
2386
+ // was removed — Codex review of #294). So for a self-spawned
2387
+ // child that scrubs inherited `graphTools` (keeping only the
2388
+ // event `toolDefinition` / schema-only stub for a name like
2389
+ // `ask_user_question`), the name isn't in the child's direct
2390
+ // group and this is a no-op — the stub is still dispatched via
2391
+ // ON_TOOL_EXECUTE, never invoked directly. Where the child DOES
2392
+ // have the executable graphTool, the guard correctly applies.
2393
+ childGraph.interruptingToolNames = this.interruptingToolNames;
2364
2394
  childGraph.toolExecution = this.toolExecution;
2365
2395
  childGraph.eventToolExecutionAvailable =
2366
2396
  this.handlerRegistry?.getHandler(GraphEvents.ON_TOOL_EXECUTE) !=
@@ -2715,32 +2745,38 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
2715
2745
 
2716
2746
  /**
2717
2747
  * Static version of handleToolCallError to avoid creating strong references
2718
- * that prevent garbage collection
2748
+ * that prevent garbage collection.
2749
+ *
2750
+ * Returns whether the error completion event was actually dispatched. A
2751
+ * tool can error before this graph instance has a run step for the call —
2752
+ * on a resume pass the interrupted batch re-executes IMMEDIATELY on graph
2753
+ * re-entry, before any step replay has registered `toolCallStepIds` (a
2754
+ * fast-failing tool, e.g. a schema-validation reject, loses that race).
2755
+ * That is a caller-recoverable condition, not an invariant violation: the
2756
+ * ToolNode falls back to its normal completion dispatch for the error
2757
+ * ToolMessage when this returns `false`, so throwing here would only
2758
+ * replace a recoverable miss with a lost completion event and a scary log.
2719
2759
  */
2720
2760
  static async handleToolCallErrorStatic(
2721
2761
  graph: StandardGraph,
2722
2762
  data: t.ToolErrorData,
2723
2763
  metadata?: Record<string, unknown>
2724
- ): Promise<void> {
2725
- if (!graph.config) {
2726
- throw new Error('No config provided');
2727
- }
2728
-
2764
+ ): Promise<boolean> {
2729
2765
  if (!data.id) {
2730
2766
  console.warn('No Tool ID provided for Tool Error');
2731
- return;
2767
+ return false;
2732
2768
  }
2733
2769
 
2734
2770
  const stepId = graph.toolCallStepIds.get(data.id) ?? '';
2735
2771
  if (!stepId) {
2736
- throw new Error(`No stepId found for tool_call_id ${data.id}`);
2772
+ return false;
2737
2773
  }
2738
2774
 
2739
2775
  const { name, input: args, error } = data;
2740
2776
 
2741
2777
  const runStep = graph.getRunStep(stepId);
2742
2778
  if (!runStep) {
2743
- throw new Error(`No run step found for stepId ${stepId}`);
2779
+ return false;
2744
2780
  }
2745
2781
 
2746
2782
  const tool_call: t.ProcessedToolCall = {
@@ -2751,21 +2787,31 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
2751
2787
  progress: 1,
2752
2788
  };
2753
2789
 
2754
- await graph.handlerRegistry
2755
- ?.getHandler(GraphEvents.ON_RUN_STEP_COMPLETED)
2756
- ?.handle(
2757
- GraphEvents.ON_RUN_STEP_COMPLETED,
2758
- {
2759
- result: {
2760
- id: stepId,
2761
- index: runStep.index,
2762
- type: 'tool_call',
2763
- tool_call,
2764
- } as t.ToolCompleteEvent,
2765
- },
2766
- metadata,
2767
- graph
2768
- );
2790
+ // No registered ON_RUN_STEP_COMPLETED handler ⇒ nothing was dispatched.
2791
+ // Report `false` so the ToolNode runs its own fallback dispatch; returning
2792
+ // `true` here would silently drop the error completion for hosts that wire
2793
+ // completions through callback-based custom events instead of a handler.
2794
+ const handler = graph.handlerRegistry?.getHandler(
2795
+ GraphEvents.ON_RUN_STEP_COMPLETED
2796
+ );
2797
+ if (!handler) {
2798
+ return false;
2799
+ }
2800
+
2801
+ await handler.handle(
2802
+ GraphEvents.ON_RUN_STEP_COMPLETED,
2803
+ {
2804
+ result: {
2805
+ id: stepId,
2806
+ index: runStep.index,
2807
+ type: 'tool_call',
2808
+ tool_call,
2809
+ } as t.ToolCompleteEvent,
2810
+ },
2811
+ metadata,
2812
+ graph
2813
+ );
2814
+ return true;
2769
2815
  }
2770
2816
 
2771
2817
  /**
@@ -2775,8 +2821,8 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
2775
2821
  async handleToolCallError(
2776
2822
  data: t.ToolErrorData,
2777
2823
  metadata?: Record<string, unknown>
2778
- ): Promise<void> {
2779
- await StandardGraph.handleToolCallErrorStatic(this, data, metadata);
2824
+ ): Promise<boolean> {
2825
+ return StandardGraph.handleToolCallErrorStatic(this, data, metadata);
2780
2826
  }
2781
2827
 
2782
2828
  async dispatchRunStepDelta(
package/src/run.ts CHANGED
@@ -128,6 +128,7 @@ export class Run<_T extends t.BaseGraphState> {
128
128
  private toolOutputReferences?: t.ToolOutputReferencesConfig;
129
129
  private eagerEventToolExecution?: t.EagerEventToolExecutionConfig;
130
130
  private codeSessionToolNames?: string[];
131
+ private interruptingToolNames?: string[];
131
132
  private toolExecution?: t.ToolExecutionConfig;
132
133
  private subagentUsageSink?: t.SubagentUsageSink;
133
134
  private indexTokenCountMap?: Record<string, number>;
@@ -184,6 +185,7 @@ export class Run<_T extends t.BaseGraphState> {
184
185
  this.toolOutputReferences = config.toolOutputReferences;
185
186
  this.eagerEventToolExecution = config.eagerEventToolExecution;
186
187
  this.codeSessionToolNames = config.codeSessionToolNames;
188
+ this.interruptingToolNames = config.interruptingToolNames;
187
189
  this.toolExecution = config.toolExecution;
188
190
  this.subagentUsageSink = config.subagentUsageSink;
189
191
 
@@ -271,6 +273,7 @@ export class Run<_T extends t.BaseGraphState> {
271
273
  standardGraph.toolOutputReferences = this.toolOutputReferences;
272
274
  standardGraph.eagerEventToolExecution = this.eagerEventToolExecution;
273
275
  standardGraph.codeSessionToolNames = this.codeSessionToolNames;
276
+ standardGraph.interruptingToolNames = this.interruptingToolNames;
274
277
  standardGraph.toolExecution = this.toolExecution;
275
278
  this.Graph = standardGraph;
276
279
  return standardGraph.createWorkflow();
@@ -301,6 +304,7 @@ export class Run<_T extends t.BaseGraphState> {
301
304
  multiAgentGraph.toolOutputReferences = this.toolOutputReferences;
302
305
  multiAgentGraph.eagerEventToolExecution = this.eagerEventToolExecution;
303
306
  multiAgentGraph.codeSessionToolNames = this.codeSessionToolNames;
307
+ multiAgentGraph.interruptingToolNames = this.interruptingToolNames;
304
308
  multiAgentGraph.toolExecution = this.toolExecution;
305
309
  this.Graph = multiAgentGraph;
306
310
  return multiAgentGraph.createWorkflow();
@@ -0,0 +1,289 @@
1
+ import { z } from 'zod';
2
+ import { tool } from '@langchain/core/tools';
3
+ import { AIMessage, ToolMessage } from '@langchain/core/messages';
4
+ import { describe, it, expect, jest, afterEach } from '@jest/globals';
5
+ import {
6
+ END,
7
+ START,
8
+ Command,
9
+ StateGraph,
10
+ MemorySaver,
11
+ isInterrupted,
12
+ MessagesAnnotation,
13
+ } from '@langchain/langgraph';
14
+ import type { Runnable, RunnableConfig } from '@langchain/core/runnables';
15
+ import type { StructuredToolInterface } from '@langchain/core/tools';
16
+ import type { BaseMessage } from '@langchain/core/messages';
17
+ import type * as t from '@/types';
18
+ import * as events from '@/utils/events';
19
+ import { askUserQuestion } from '@/hitl';
20
+ import { ToolNode } from '@/tools/ToolNode';
21
+
22
+ /**
23
+ * Pins the `interruptingToolNames` guard for the `ask_user_question`
24
+ * shape: a normal tool whose BODY raises a LangGraph `interrupt()`
25
+ * mid-execution (via {@link askUserQuestion}) to collect a human answer.
26
+ *
27
+ * When such a tool shares a tool-call batch with a NON-idempotent
28
+ * sibling (send_email, billing), LangGraph's resume contract — re-run
29
+ * the whole interrupted node from the top — would otherwise execute the
30
+ * sibling once on the first pass and AGAIN on resume, duplicating the
31
+ * side effect. Declaring the interrupter in `interruptingToolNames`
32
+ * schedules it ahead of its siblings, so the batch unwinds before any
33
+ * sibling runs and the sibling executes exactly once (on resume).
34
+ *
35
+ * Runs entirely in-memory (StateGraph + MemorySaver) — no LLM, no Run
36
+ * machinery — so it exercises the ToolNode batch-execution change
37
+ * directly. Companion dist probe: `node -e` against dist/cjs/main.cjs.
38
+ */
39
+
40
+ type MessagesUpdate = { messages: BaseMessage[] };
41
+ type CompiledMessagesGraph = Runnable<unknown, { messages: BaseMessage[] }> & {
42
+ invoke(input: unknown, config?: RunnableConfig): Promise<unknown>;
43
+ };
44
+
45
+ /** Tool whose body suspends the run to ask the user (no side effect). */
46
+ function makeAskTool(): StructuredToolInterface {
47
+ return tool(
48
+ async () => {
49
+ const { answer } = askUserQuestion({ question: 'Proceed?' });
50
+ return `answered: ${answer}`;
51
+ },
52
+ {
53
+ name: 'ask_user_question',
54
+ description: 'suspends to collect a human answer',
55
+ schema: z.object({}).passthrough(),
56
+ }
57
+ ) as unknown as StructuredToolInterface;
58
+ }
59
+
60
+ /** Non-idempotent sibling — records every body invocation. */
61
+ function makeSideEffectTool(sideEffect: () => string): StructuredToolInterface {
62
+ return tool(async () => sideEffect(), {
63
+ name: 'side_effect',
64
+ description: 'non-idempotent side effect (e.g. send_email)',
65
+ schema: z.object({}).passthrough(),
66
+ }) as unknown as StructuredToolInterface;
67
+ }
68
+
69
+ function buildGraph(
70
+ node: ToolNode,
71
+ toolCalls: Array<{ id: string; name: string; args: Record<string, unknown> }>
72
+ ): CompiledMessagesGraph {
73
+ const builder = new StateGraph(MessagesAnnotation)
74
+ .addNode(
75
+ 'agent',
76
+ (): MessagesUpdate => ({
77
+ messages: [new AIMessage({ content: '', tool_calls: toolCalls })],
78
+ })
79
+ )
80
+ .addNode('tools', node)
81
+ .addEdge(START, 'agent')
82
+ .addEdge('agent', 'tools')
83
+ .addEdge('tools', END);
84
+ return builder.compile({
85
+ checkpointer: new MemorySaver(),
86
+ }) as unknown as CompiledMessagesGraph;
87
+ }
88
+
89
+ const ASK = { id: 'ask1', name: 'ask_user_question', args: {} };
90
+ const SIDE_EFFECT = { id: 'se1', name: 'side_effect', args: {} };
91
+
92
+ describe('ask_user_question batched with a sibling tool', () => {
93
+ afterEach(() => {
94
+ jest.restoreAllMocks();
95
+ });
96
+
97
+ describe('direct sibling — guarded by interruptingToolNames', () => {
98
+ // The exposed shape: both tools run in-process, so absent the guard
99
+ // they share one Promise.all and the sibling double-executes.
100
+ for (const order of [
101
+ { label: '[ask, side_effect]', calls: [ASK, SIDE_EFFECT] },
102
+ { label: '[side_effect, ask]', calls: [SIDE_EFFECT, ASK] },
103
+ ]) {
104
+ it(`runs the sibling exactly once across pause→resume — order ${order.label}`, async () => {
105
+ const sideEffect = jest.fn(() => 'EXECUTED');
106
+ const node = new ToolNode({
107
+ tools: [makeAskTool(), makeSideEffectTool(sideEffect)],
108
+ eventDrivenMode: true,
109
+ directToolNames: new Set(['ask_user_question', 'side_effect']),
110
+ interruptingToolNames: new Set(['ask_user_question']),
111
+ });
112
+ const graph = buildGraph(node, order.calls);
113
+ const config = {
114
+ configurable: { thread_id: `guarded-${order.label}` },
115
+ };
116
+
117
+ const first = await graph.invoke({ messages: [] }, config);
118
+ expect(isInterrupted<t.HumanInterruptPayload>(first)).toBe(true);
119
+ // Guard: the interrupter is scheduled first, so the sibling has
120
+ // NOT run when the batch unwinds on the first pass.
121
+ expect(sideEffect).not.toHaveBeenCalled();
122
+
123
+ const second = await graph.invoke(
124
+ new Command({ resume: { answer: 'yes' } }),
125
+ config
126
+ );
127
+
128
+ // Sibling ran exactly once, on the resume pass.
129
+ expect(sideEffect).toHaveBeenCalledTimes(1);
130
+
131
+ const messages = (second as { messages: ToolMessage[] }).messages;
132
+ const seMsg = messages.find(
133
+ (m) => m instanceof ToolMessage && m.name === 'side_effect'
134
+ ) as ToolMessage;
135
+ expect(String(seMsg.content)).toBe('EXECUTED');
136
+ const askMsg = messages.find(
137
+ (m) => m instanceof ToolMessage && m.name === 'ask_user_question'
138
+ ) as ToolMessage;
139
+ expect(String(askMsg.content)).toBe('answered: yes');
140
+ });
141
+ }
142
+ });
143
+
144
+ describe('baseline (no guard) — documents the defect the guard closes', () => {
145
+ it('double-executes an unguarded direct sibling on resume', async () => {
146
+ const sideEffect = jest.fn(() => 'EXECUTED');
147
+ const node = new ToolNode({
148
+ tools: [makeAskTool(), makeSideEffectTool(sideEffect)],
149
+ eventDrivenMode: true,
150
+ directToolNames: new Set(['ask_user_question', 'side_effect']),
151
+ // interruptingToolNames intentionally omitted.
152
+ });
153
+ const graph = buildGraph(node, [ASK, SIDE_EFFECT]);
154
+ const config = { configurable: { thread_id: 'baseline-unguarded' } };
155
+
156
+ const first = await graph.invoke({ messages: [] }, config);
157
+ expect(isInterrupted<t.HumanInterruptPayload>(first)).toBe(true);
158
+ // Unguarded: the sibling shared the interrupter's Promise.all and
159
+ // already ran its side effect before the pause.
160
+ expect(sideEffect).toHaveBeenCalledTimes(1);
161
+
162
+ await graph.invoke(new Command({ resume: { answer: 'yes' } }), config);
163
+ // LangGraph re-runs the batch → the side effect fires a SECOND time.
164
+ expect(sideEffect).toHaveBeenCalledTimes(2);
165
+ });
166
+ });
167
+
168
+ describe('event-dispatched sibling — already safe without the guard', () => {
169
+ it('never runs the event sibling on the first pass', async () => {
170
+ const sideEffect = jest.fn(() => 'EXECUTED');
171
+ // Host executes event-dispatched tools; record side_effect there.
172
+ jest
173
+ .spyOn(events, 'safeDispatchCustomEvent')
174
+ .mockImplementation(async (event, data) => {
175
+ if (event !== 'on_tool_execute') {
176
+ return;
177
+ }
178
+ const req = data as {
179
+ toolCalls: Array<{ id: string; name: string }>;
180
+ resolve: (results: t.ToolExecuteResult[]) => void;
181
+ };
182
+ const results = req.toolCalls.map((tc) => {
183
+ if (tc.name === 'side_effect') {
184
+ sideEffect();
185
+ }
186
+ return {
187
+ toolCallId: tc.id,
188
+ content: 'EXECUTED',
189
+ status: 'success' as const,
190
+ };
191
+ });
192
+ req.resolve(results);
193
+ return true;
194
+ });
195
+
196
+ const node = new ToolNode({
197
+ tools: [
198
+ makeAskTool(),
199
+ // schema-only stub; the host "executes" side_effect via event.
200
+ tool(async () => 'unused', {
201
+ name: 'side_effect',
202
+ description: 'event tool',
203
+ schema: z.object({}).passthrough(),
204
+ }) as unknown as StructuredToolInterface,
205
+ ],
206
+ eventDrivenMode: true,
207
+ // side_effect is NOT direct → dispatched as an event.
208
+ directToolNames: new Set(['ask_user_question']),
209
+ interruptingToolNames: new Set(['ask_user_question']),
210
+ });
211
+ const graph = buildGraph(node, [SIDE_EFFECT, ASK]);
212
+ const config = { configurable: { thread_id: 'event-sibling' } };
213
+
214
+ const first = await graph.invoke({ messages: [] }, config);
215
+ expect(isInterrupted<t.HumanInterruptPayload>(first)).toBe(true);
216
+ // The direct interrupter unwinds the batch before event tools are
217
+ // dispatched, so the host never executed side_effect.
218
+ expect(sideEffect).not.toHaveBeenCalled();
219
+
220
+ await graph.invoke(new Command({ resume: { answer: 'yes' } }), config);
221
+ expect(sideEffect).toHaveBeenCalledTimes(1);
222
+ });
223
+ });
224
+
225
+ describe('interruptingToolNames only reorders; never forces direct (Codex #294)', () => {
226
+ // A self-spawned child scrubs inherited `graphTools` but keeps the
227
+ // event `toolDefinition`, so a name like `ask_user_question` exists
228
+ // only as a schema-only stub. Propagating `interruptingToolNames`
229
+ // must NOT force that name onto the direct path — invoking the stub
230
+ // throws "should not be invoked directly". It must stay dispatched.
231
+ it('dispatches an interrupting name that has no in-process implementation', async () => {
232
+ const directlyInvoked = jest.fn();
233
+ // Mirrors createSchemaOnlyTool: throws if invoked in-process.
234
+ const stub = tool(
235
+ async () => {
236
+ directlyInvoked();
237
+ throw new Error(
238
+ 'Tool "ask_user_question" should not be invoked directly in event-driven mode.'
239
+ );
240
+ },
241
+ {
242
+ name: 'ask_user_question',
243
+ description: 'schema-only event stub',
244
+ schema: z.object({}).passthrough(),
245
+ }
246
+ ) as unknown as StructuredToolInterface;
247
+
248
+ jest
249
+ .spyOn(events, 'safeDispatchCustomEvent')
250
+ .mockImplementation(async (event, data) => {
251
+ if (event !== 'on_tool_execute') {
252
+ return;
253
+ }
254
+ const req = data as {
255
+ toolCalls: Array<{ id: string; name: string }>;
256
+ resolve: (results: t.ToolExecuteResult[]) => void;
257
+ };
258
+ req.resolve(
259
+ req.toolCalls.map((tc) => ({
260
+ toolCallId: tc.id,
261
+ content: 'HOST-HANDLED',
262
+ status: 'success' as const,
263
+ }))
264
+ );
265
+ return true;
266
+ });
267
+
268
+ const node = new ToolNode({
269
+ tools: [stub],
270
+ eventDrivenMode: true,
271
+ // NOT in directToolNames → it is an event tool, even though it is
272
+ // named in interruptingToolNames.
273
+ interruptingToolNames: new Set(['ask_user_question']),
274
+ });
275
+ const graph = buildGraph(node, [ASK]);
276
+ const config = { configurable: { thread_id: 'stub-stays-event' } };
277
+
278
+ const result = await graph.invoke({ messages: [] }, config);
279
+
280
+ // Dispatched to the host, NOT invoked in-process.
281
+ expect(directlyInvoked).not.toHaveBeenCalled();
282
+ const msg = (result as { messages: ToolMessage[] }).messages.find(
283
+ (m) => m instanceof ToolMessage
284
+ ) as ToolMessage;
285
+ expect(msg.status).toBe('success');
286
+ expect(String(msg.content)).toBe('HOST-HANDLED');
287
+ });
288
+ });
289
+ });