@illuma-ai/agents 1.1.18 → 1.1.20

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 (56) hide show
  1. package/dist/cjs/common/enum.cjs +2 -0
  2. package/dist/cjs/common/enum.cjs.map +1 -1
  3. package/dist/cjs/graphs/MultiAgentGraph.cjs +133 -6
  4. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  5. package/dist/cjs/main.cjs +3 -0
  6. package/dist/cjs/main.cjs.map +1 -1
  7. package/dist/cjs/nodes/ApprovalGateNode.cjs +75 -0
  8. package/dist/cjs/nodes/ApprovalGateNode.cjs.map +1 -0
  9. package/dist/cjs/run.cjs +47 -1
  10. package/dist/cjs/run.cjs.map +1 -1
  11. package/dist/cjs/tools/ToolNode.cjs +21 -18
  12. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  13. package/dist/cjs/types/graph.cjs.map +1 -1
  14. package/dist/cjs/utils/run.cjs +6 -1
  15. package/dist/cjs/utils/run.cjs.map +1 -1
  16. package/dist/esm/common/enum.mjs +2 -0
  17. package/dist/esm/common/enum.mjs.map +1 -1
  18. package/dist/esm/graphs/MultiAgentGraph.mjs +133 -6
  19. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  20. package/dist/esm/main.mjs +1 -0
  21. package/dist/esm/main.mjs.map +1 -1
  22. package/dist/esm/nodes/ApprovalGateNode.mjs +72 -0
  23. package/dist/esm/nodes/ApprovalGateNode.mjs.map +1 -0
  24. package/dist/esm/run.mjs +47 -1
  25. package/dist/esm/run.mjs.map +1 -1
  26. package/dist/esm/tools/ToolNode.mjs +22 -19
  27. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  28. package/dist/esm/types/graph.mjs.map +1 -1
  29. package/dist/esm/utils/run.mjs +6 -1
  30. package/dist/esm/utils/run.mjs.map +1 -1
  31. package/dist/types/common/enum.d.ts +2 -0
  32. package/dist/types/graphs/MultiAgentGraph.d.ts +6 -0
  33. package/dist/types/index.d.ts +1 -0
  34. package/dist/types/nodes/ApprovalGateNode.d.ts +49 -0
  35. package/dist/types/nodes/index.d.ts +2 -0
  36. package/dist/types/run.d.ts +25 -1
  37. package/dist/types/tools/ToolNode.d.ts +7 -5
  38. package/dist/types/types/graph.d.ts +40 -0
  39. package/dist/types/types/run.d.ts +6 -0
  40. package/dist/types/types/tools.d.ts +7 -9
  41. package/package.json +1 -1
  42. package/src/common/enum.ts +2 -0
  43. package/src/graphs/MultiAgentGraph.ts +166 -6
  44. package/src/index.ts +3 -0
  45. package/src/nodes/ApprovalGateNode.ts +117 -0
  46. package/src/nodes/__tests__/ApprovalGateNode.test.ts +206 -0
  47. package/src/nodes/index.ts +5 -0
  48. package/src/run.ts +57 -2
  49. package/src/specs/agent-handoffs-bedrock.integration.test.ts +2 -2
  50. package/src/specs/agent-handoffs.test.ts +153 -6
  51. package/src/tools/ToolNode.ts +28 -23
  52. package/src/tools/__tests__/ToolApproval.test.ts +162 -325
  53. package/src/types/graph.ts +41 -0
  54. package/src/types/run.ts +6 -0
  55. package/src/types/tools.ts +7 -9
  56. package/src/utils/run.ts +9 -1
@@ -0,0 +1,206 @@
1
+ import { MemorySaver } from '@langchain/langgraph';
2
+ import {
3
+ StateGraph,
4
+ Annotation,
5
+ messagesStateReducer,
6
+ Command,
7
+ } from '@langchain/langgraph';
8
+ import { AIMessage, HumanMessage } from '@langchain/core/messages';
9
+ import type { BaseMessage } from '@langchain/core/messages';
10
+ import type { RunnableConfig } from '@langchain/core/runnables';
11
+ import {
12
+ createApprovalGateNode,
13
+ getApprovalGateNodeId,
14
+ } from '../ApprovalGateNode';
15
+ import type { ApprovalGateConfig } from '@/types/graph';
16
+
17
+ // Suppress safeDispatchCustomEvent since we don't have a full graph context
18
+ jest.mock('@/utils/events', () => ({
19
+ safeDispatchCustomEvent: jest.fn(),
20
+ }));
21
+
22
+ const { safeDispatchCustomEvent } = require('@/utils/events');
23
+
24
+ /**
25
+ * Creates a simple 2-agent graph with an approval gate between them.
26
+ * Agent A → Gate → Agent B
27
+ */
28
+ function createGatedGraph(gateConfig: ApprovalGateConfig) {
29
+ const GraphState = Annotation.Root({
30
+ messages: Annotation<BaseMessage[]>({
31
+ reducer: messagesStateReducer,
32
+ }),
33
+ });
34
+
35
+ const checkpointer = new MemorySaver();
36
+ const gateNodeId = getApprovalGateNodeId(gateConfig.gateId);
37
+
38
+ const builder = new StateGraph(GraphState);
39
+
40
+ // Agent A: echoes input
41
+ builder.addNode('agent_a', async (state) => ({
42
+ messages: [new AIMessage('Agent A done')],
43
+ }));
44
+
45
+ // Approval gate
46
+ const gateNode = createApprovalGateNode(gateConfig, 'agent_a', 'agent_b');
47
+ builder.addNode(gateNodeId, gateNode as any);
48
+
49
+ // Agent B: runs after approval
50
+ builder.addNode('agent_b', async (state) => ({
51
+ messages: [new AIMessage('Agent B done')],
52
+ }));
53
+
54
+ // Wire: START → A → gate → B → END
55
+ builder.addEdge('__start__' as any, 'agent_a' as any);
56
+ builder.addEdge('agent_a' as any, gateNodeId as any);
57
+ builder.addEdge(gateNodeId as any, 'agent_b' as any);
58
+ builder.addEdge('agent_b' as any, '__end__' as any);
59
+
60
+ const compiled = builder.compile({ checkpointer } as any);
61
+
62
+ return { compiled, checkpointer, gateNodeId };
63
+ }
64
+
65
+ describe('ApprovalGateNode', () => {
66
+ beforeEach(() => {
67
+ jest.clearAllMocks();
68
+ });
69
+
70
+ it('interrupts the graph at the gate node', async () => {
71
+ const { compiled } = createGatedGraph({
72
+ gateId: 'review-step',
73
+ channel: 'chat',
74
+ prompt: 'Please review before Agent B runs',
75
+ });
76
+
77
+ const config: Partial<RunnableConfig> = {
78
+ configurable: { thread_id: 'test-thread-1' },
79
+ };
80
+
81
+ // First invoke: Agent A runs, then gate interrupts
82
+ const result = await compiled.invoke(
83
+ { messages: [new HumanMessage('start')] },
84
+ config,
85
+ );
86
+
87
+ // Check that the graph was interrupted (Agent B should NOT have run)
88
+ const state = await compiled.getState(config);
89
+ const interrupted = state.tasks?.some((t: any) => t.interrupts?.length > 0);
90
+ expect(interrupted).toBe(true);
91
+
92
+ // Agent A should have run
93
+ const messages = result.messages as BaseMessage[];
94
+ const hasAgentA = messages.some(
95
+ (m: any) => m._getType?.() === 'ai' && m.content === 'Agent A done',
96
+ );
97
+ expect(hasAgentA).toBe(true);
98
+
99
+ // Agent B should NOT have run
100
+ const hasAgentB = messages.some(
101
+ (m: any) => m._getType?.() === 'ai' && m.content === 'Agent B done',
102
+ );
103
+ expect(hasAgentB).toBe(false);
104
+ });
105
+
106
+ it('resumes and runs Agent B after approval', async () => {
107
+ const { compiled } = createGatedGraph({
108
+ gateId: 'review-step',
109
+ channel: 'chat',
110
+ });
111
+
112
+ const config: Partial<RunnableConfig> = {
113
+ configurable: { thread_id: 'test-thread-2' },
114
+ };
115
+
116
+ // First invoke: hits gate interrupt
117
+ await compiled.invoke(
118
+ { messages: [new HumanMessage('start')] },
119
+ config,
120
+ );
121
+
122
+ // Resume with approval
123
+ const result = await compiled.invoke(
124
+ new Command({ resume: { approved: true } }),
125
+ config,
126
+ );
127
+
128
+ // Agent B should have run after approval
129
+ const messages = result.messages as BaseMessage[];
130
+ const hasAgentB = messages.some(
131
+ (m: any) => m._getType?.() === 'ai' && m.content === 'Agent B done',
132
+ );
133
+ expect(hasAgentB).toBe(true);
134
+ });
135
+
136
+ it('dispatches ON_APPROVAL_GATE event before interrupting', async () => {
137
+ const { compiled } = createGatedGraph({
138
+ gateId: 'notify-gate',
139
+ channel: 'outlook',
140
+ prompt: 'Review needed',
141
+ approver: 'manager@example.com',
142
+ });
143
+
144
+ const config: Partial<RunnableConfig> = {
145
+ configurable: { thread_id: 'test-thread-3' },
146
+ };
147
+
148
+ await compiled.invoke(
149
+ { messages: [new HumanMessage('start')] },
150
+ config,
151
+ );
152
+
153
+ // safeDispatchCustomEvent should have been called with gate data
154
+ expect(safeDispatchCustomEvent).toHaveBeenCalledWith(
155
+ 'on_approval_gate',
156
+ expect.objectContaining({
157
+ type: 'approval_gate',
158
+ gateId: 'notify-gate',
159
+ channel: 'outlook',
160
+ prompt: 'Review needed',
161
+ approver: 'manager@example.com',
162
+ sourceAgentId: 'agent_a',
163
+ destinationAgentId: 'agent_b',
164
+ }),
165
+ expect.anything(),
166
+ );
167
+ });
168
+
169
+ it('resumes with denial — gate returns empty state', async () => {
170
+ const { compiled } = createGatedGraph({
171
+ gateId: 'deny-gate',
172
+ });
173
+
174
+ const config: Partial<RunnableConfig> = {
175
+ configurable: { thread_id: 'test-thread-4' },
176
+ };
177
+
178
+ // First invoke: hits gate interrupt
179
+ await compiled.invoke(
180
+ { messages: [new HumanMessage('start')] },
181
+ config,
182
+ );
183
+
184
+ // Resume with denial
185
+ const result = await compiled.invoke(
186
+ new Command({ resume: { approved: false, feedback: 'Not ready' } }),
187
+ config,
188
+ );
189
+
190
+ // Agent B still runs (the gate doesn't block — it's the host's
191
+ // responsibility to not resume on denial, or use onDeny routing).
192
+ // In the current implementation, the gate node returns {} regardless.
193
+ const messages = result.messages as BaseMessage[];
194
+ const hasAgentB = messages.some(
195
+ (m: any) => m._getType?.() === 'ai' && m.content === 'Agent B done',
196
+ );
197
+ expect(hasAgentB).toBe(true);
198
+ });
199
+
200
+ describe('getApprovalGateNodeId', () => {
201
+ it('generates consistent node IDs', () => {
202
+ expect(getApprovalGateNodeId('step-1')).toBe('approval_gate_step-1');
203
+ expect(getApprovalGateNodeId('review')).toBe('approval_gate_review');
204
+ });
205
+ });
206
+ });
@@ -0,0 +1,5 @@
1
+ export {
2
+ createApprovalGateNode,
3
+ getApprovalGateNodeId,
4
+ } from './ApprovalGateNode';
5
+ export type { ApprovalGateInterrupt } from './ApprovalGateNode';
package/src/run.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // src/run.ts
2
2
  import './instrumentation';
3
3
  import { ObservabilityCallbackHandler } from '@illuma-ai/observability-langchain';
4
+ import { Command } from '@langchain/langgraph';
4
5
  import { PromptTemplate } from '@langchain/core/prompts';
5
6
  import { RunnableLambda } from '@langchain/core/runnables';
6
7
  import { AzureChatOpenAI, ChatOpenAI } from '@langchain/openai';
@@ -143,12 +144,13 @@ export class Run<_T extends t.BaseGraphState> {
143
144
  private createMultiAgentGraph(
144
145
  config: t.MultiAgentGraphConfig
145
146
  ): t.CompiledStateWorkflow {
146
- const { agents, edges, compileOptions } = config;
147
+ const { agents, edges, compileOptions, resumeFromAgentId } = config;
147
148
 
148
149
  const multiAgentGraph = new MultiAgentGraph({
149
150
  runId: this.id,
150
151
  agents,
151
152
  edges,
153
+ resumeFromAgentId,
152
154
  tokenCounter: this.tokenCounter,
153
155
  indexTokenCountMap: this.indexTokenCountMap,
154
156
  });
@@ -249,8 +251,16 @@ export class Run<_T extends t.BaseGraphState> {
249
251
  };
250
252
  }
251
253
 
254
+ /**
255
+ * Processes the graph stream for a given input.
256
+ *
257
+ * @param inputs - Either the initial state (IState) for a new run, or a
258
+ * Command (e.g., `new Command({ resume: ... })`) to resume from an interrupt.
259
+ * @param config - Runnable config with version and optional run_id.
260
+ * @param streamOptions - Optional stream event callbacks and options.
261
+ */
252
262
  async processStream(
253
- inputs: t.IState,
263
+ inputs: t.IState | Command,
254
264
  config: Partial<RunnableConfig> & { version: 'v1' | 'v2'; run_id?: string },
255
265
  streamOptions?: t.EventStreamOptions
256
266
  ): Promise<MessageContentComplex[] | undefined> {
@@ -408,6 +418,51 @@ export class Run<_T extends t.BaseGraphState> {
408
418
  }) as t.SystemCallbacks[K];
409
419
  }
410
420
 
421
+ /**
422
+ * Checks whether the graph was interrupted (e.g., by HITL tool approval).
423
+ * Call after processStream() returns to determine if the graph is waiting
424
+ * for a resume via Command({ resume }).
425
+ *
426
+ * Requires a checkpointer to be configured — without one, interrupt state
427
+ * is not persisted and this always returns false.
428
+ */
429
+ async hasInterrupts(
430
+ config: Partial<RunnableConfig>
431
+ ): Promise<boolean> {
432
+ if (!this.graphRunnable) {
433
+ return false;
434
+ }
435
+ try {
436
+ const state = await this.graphRunnable.getState(config);
437
+ return state.tasks?.some((task) => task.interrupts?.length > 0) ?? false;
438
+ } catch {
439
+ return false;
440
+ }
441
+ }
442
+
443
+ /**
444
+ * Returns the interrupt values from the graph state.
445
+ * Each interrupt's `value` contains the data passed to `interrupt()` by the node
446
+ * (e.g., a ToolApprovalRequest for HITL).
447
+ */
448
+ async getInterruptValues(
449
+ config: Partial<RunnableConfig>
450
+ ): Promise<unknown[]> {
451
+ if (!this.graphRunnable) {
452
+ return [];
453
+ }
454
+ try {
455
+ const state = await this.graphRunnable.getState(config);
456
+ return (
457
+ state.tasks?.flatMap((task) =>
458
+ (task.interrupts ?? []).map((i) => i.value)
459
+ ) ?? []
460
+ );
461
+ } catch {
462
+ return [];
463
+ }
464
+ }
465
+
411
466
  getCallbacks(clientCallbacks: t.ClientCallbacks): t.SystemCallbacks {
412
467
  return {
413
468
  [Callback.TOOL_ERROR]: this.createSystemCallback(
@@ -145,11 +145,11 @@ describeIf('Agent Handoff E2E with Bedrock', () => {
145
145
 
146
146
  // Verify the handoff tools have enriched descriptions
147
147
  const salesTool = findToolByName(
148
- supervisorContext?.tools,
148
+ supervisorContext?.graphTools,
149
149
  `${Constants.LC_TRANSFER_TO_}agent_W47hBnn2RoVZEOy5595GC`
150
150
  );
151
151
  const supportTool = findToolByName(
152
- supervisorContext?.tools,
152
+ supervisorContext?.graphTools,
153
153
  `${Constants.LC_TRANSFER_TO_}agent_X92kLmn4TpQR8vw3221HD`
154
154
  );
155
155
 
@@ -1018,7 +1018,7 @@ describe('Agent Handoffs Tests', () => {
1018
1018
  'supervisor'
1019
1019
  );
1020
1020
  const handoffTool = findToolByName(
1021
- supervisorContext?.tools,
1021
+ supervisorContext?.graphTools,
1022
1022
  `${Constants.LC_TRANSFER_TO_}data_analyst`
1023
1023
  );
1024
1024
 
@@ -1052,7 +1052,7 @@ describe('Agent Handoffs Tests', () => {
1052
1052
  'supervisor'
1053
1053
  );
1054
1054
  const handoffTool = findToolByName(
1055
- supervisorContext?.tools,
1055
+ supervisorContext?.graphTools,
1056
1056
  `${Constants.LC_TRANSFER_TO_}writer`
1057
1057
  );
1058
1058
 
@@ -1086,7 +1086,7 @@ describe('Agent Handoffs Tests', () => {
1086
1086
  'supervisor'
1087
1087
  );
1088
1088
  const handoffTool = findToolByName(
1089
- supervisorContext?.tools,
1089
+ supervisorContext?.graphTools,
1090
1090
  `${Constants.LC_TRANSFER_TO_}agent_b`
1091
1091
  );
1092
1092
 
@@ -1130,15 +1130,15 @@ describe('Agent Handoffs Tests', () => {
1130
1130
  );
1131
1131
 
1132
1132
  const salesTool = findToolByName(
1133
- routerContext?.tools,
1133
+ routerContext?.graphTools,
1134
1134
  `${Constants.LC_TRANSFER_TO_}sales`
1135
1135
  );
1136
1136
  const supportTool = findToolByName(
1137
- routerContext?.tools,
1137
+ routerContext?.graphTools,
1138
1138
  `${Constants.LC_TRANSFER_TO_}support`
1139
1139
  );
1140
1140
  const billingTool = findToolByName(
1141
- routerContext?.tools,
1141
+ routerContext?.graphTools,
1142
1142
  `${Constants.LC_TRANSFER_TO_}billing`
1143
1143
  );
1144
1144
 
@@ -1294,4 +1294,151 @@ describe('Agent Handoffs Tests', () => {
1294
1294
  }
1295
1295
  });
1296
1296
  });
1297
+
1298
+ describe('Multi-Turn Agent Resumption', () => {
1299
+ it('should route START to resumeFromAgentId when valid', async () => {
1300
+ const agents: t.AgentInputs[] = [
1301
+ createBasicAgent('router', 'You are the router agent'),
1302
+ createBasicAgent('writer', 'You are the writer agent'),
1303
+ createBasicAgent('researcher', 'You are the researcher agent'),
1304
+ ];
1305
+
1306
+ const edges: t.GraphEdge[] = [
1307
+ { from: 'router', to: 'writer', edgeType: EdgeType.TRANSFER },
1308
+ { from: 'router', to: 'researcher', edgeType: EdgeType.TRANSFER },
1309
+ ];
1310
+
1311
+ // Create run WITH resumeFromAgentId set to 'writer'
1312
+ const run = await Run.create({
1313
+ runId: `resume-test-${Date.now()}`,
1314
+ graphConfig: {
1315
+ type: 'multi-agent',
1316
+ agents,
1317
+ edges,
1318
+ resumeFromAgentId: 'writer',
1319
+ },
1320
+ returnContent: true,
1321
+ skipCleanup: true,
1322
+ });
1323
+
1324
+ // Graph should compile without error
1325
+ expect(run.graphRunnable).toBeDefined();
1326
+
1327
+ // The writer agent should be reachable from START
1328
+ // (We verify by checking that the graph compiled successfully with conditional routing)
1329
+ expect(run.Graph).toBeDefined();
1330
+ });
1331
+
1332
+ it('should fall back to default starting nodes when resumeFromAgentId is invalid', async () => {
1333
+ const agents: t.AgentInputs[] = [
1334
+ createBasicAgent('router', 'You are the router agent'),
1335
+ createBasicAgent('writer', 'You are the writer agent'),
1336
+ ];
1337
+
1338
+ const edges: t.GraphEdge[] = [
1339
+ { from: 'router', to: 'writer', edgeType: EdgeType.TRANSFER },
1340
+ ];
1341
+
1342
+ // Create run with an invalid resumeFromAgentId
1343
+ const run = await Run.create({
1344
+ runId: `resume-invalid-test-${Date.now()}`,
1345
+ graphConfig: {
1346
+ type: 'multi-agent',
1347
+ agents,
1348
+ edges,
1349
+ resumeFromAgentId: 'nonexistent_agent',
1350
+ },
1351
+ returnContent: true,
1352
+ skipCleanup: true,
1353
+ });
1354
+
1355
+ // Should still compile (falls back to default starting nodes)
1356
+ expect(run.graphRunnable).toBeDefined();
1357
+ expect(run.Graph).toBeDefined();
1358
+ });
1359
+
1360
+ it('should compile normally without resumeFromAgentId (backward compat)', async () => {
1361
+ const agents: t.AgentInputs[] = [
1362
+ createBasicAgent('router', 'You are the router agent'),
1363
+ createBasicAgent('writer', 'You are the writer agent'),
1364
+ ];
1365
+
1366
+ const edges: t.GraphEdge[] = [
1367
+ { from: 'router', to: 'writer', edgeType: EdgeType.TRANSFER },
1368
+ ];
1369
+
1370
+ // Create run WITHOUT resumeFromAgentId — existing behavior
1371
+ const run = await Run.create(createTestConfig(agents, edges));
1372
+
1373
+ expect(run.graphRunnable).toBeDefined();
1374
+ expect(run.Graph).toBeDefined();
1375
+ });
1376
+
1377
+ it('should route to resume agent and track lastActiveAgentId correctly', async () => {
1378
+ const agents: t.AgentInputs[] = [
1379
+ createBasicAgent('router', 'You are the router agent'),
1380
+ createBasicAgent('writer', 'You are the writer agent'),
1381
+ ];
1382
+
1383
+ const edges: t.GraphEdge[] = [
1384
+ { from: 'router', to: 'writer', edgeType: EdgeType.TRANSFER },
1385
+ ];
1386
+
1387
+ const run = await Run.create({
1388
+ runId: `resume-execution-test-${Date.now()}`,
1389
+ graphConfig: {
1390
+ type: 'multi-agent',
1391
+ agents,
1392
+ edges,
1393
+ resumeFromAgentId: 'writer',
1394
+ },
1395
+ returnContent: true,
1396
+ skipCleanup: true,
1397
+ });
1398
+
1399
+ // Override test model to respond directly (no transfer)
1400
+ run.Graph?.overrideTestModel(['I am the writer, continuing your work.'], 10);
1401
+
1402
+ const messages = [new HumanMessage('Make the intro shorter')];
1403
+ const config: Partial<RunnableConfig> & { version: 'v1' | 'v2'; streamMode: string } = {
1404
+ configurable: { thread_id: 'resume-exec-test' },
1405
+ streamMode: 'values',
1406
+ version: 'v2' as const,
1407
+ };
1408
+
1409
+ await run.processStream({ messages }, config);
1410
+
1411
+ // After execution, lastActiveAgentId should be 'writer' (not 'router')
1412
+ expect(run.getLastActiveAgentId()).toBe('writer');
1413
+ });
1414
+
1415
+ it('should handle resumeFromAgentId with parallel starting nodes', async () => {
1416
+ const agents: t.AgentInputs[] = [
1417
+ createBasicAgent('agent_a', 'You are agent A'),
1418
+ createBasicAgent('agent_b', 'You are agent B'),
1419
+ createBasicAgent('agent_c', 'You are agent C'),
1420
+ ];
1421
+
1422
+ // Both A and B are starting nodes (no incoming edges), C has incoming
1423
+ const edges: t.GraphEdge[] = [
1424
+ { from: 'agent_a', to: 'agent_c', edgeType: EdgeType.TRANSFER },
1425
+ { from: 'agent_b', to: 'agent_c', edgeType: EdgeType.TRANSFER },
1426
+ ];
1427
+
1428
+ // Resume from agent_c — should override the parallel start (A + B)
1429
+ const run = await Run.create({
1430
+ runId: `resume-parallel-test-${Date.now()}`,
1431
+ graphConfig: {
1432
+ type: 'multi-agent',
1433
+ agents,
1434
+ edges,
1435
+ resumeFromAgentId: 'agent_c',
1436
+ },
1437
+ returnContent: true,
1438
+ skipCleanup: true,
1439
+ });
1440
+
1441
+ expect(run.graphRunnable).toBeDefined();
1442
+ });
1443
+ });
1297
1444
  });
@@ -9,6 +9,7 @@ import {
9
9
  Send,
10
10
  Command,
11
11
  isCommand,
12
+ interrupt,
12
13
  isGraphInterrupt,
13
14
  MessagesAnnotation,
14
15
  } from '@langchain/langgraph';
@@ -226,21 +227,23 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
226
227
  }
227
228
 
228
229
  /**
229
- * Requests human approval for a tool call via event dispatch.
230
- * Dispatches an ON_TOOL_APPROVAL_REQUIRED event and waits for the host
231
- * to resolve the promise with an approval response.
230
+ * Requests human approval for a tool call using LangGraph's native interrupt().
232
231
  *
233
- * This uses the same pattern as ON_TOOL_EXECUTE: a promise-based event
234
- * dispatch where the host calls resolve/reject when the human responds.
232
+ * Flow:
233
+ * 1. Dispatches ON_TOOL_APPROVAL_REQUIRED notification (no resolve/reject data only)
234
+ * so the host can persist the request and send UI events.
235
+ * 2. Calls interrupt() which checkpoints graph state and pauses execution.
236
+ * 3. When the host resumes via Command({ resume: ToolApprovalResponse }),
237
+ * interrupt() returns the response synchronously.
235
238
  *
236
239
  * @param call - The tool call requiring approval
237
240
  * @param config - The runnable config for event dispatch
238
241
  * @returns The approval response from the human
239
242
  */
240
- private async requestApproval(
243
+ private requestApproval(
241
244
  call: ToolCall,
242
245
  config: RunnableConfig
243
- ): Promise<t.ToolApprovalResponse> {
246
+ ): t.ToolApprovalResponse {
244
247
  const approvalRequest: t.ToolApprovalRequest = {
245
248
  type: 'tool_approval_required',
246
249
  toolCallId: call.id ?? '',
@@ -250,17 +253,18 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
250
253
  description: `Tool "${call.name}" wants to execute with the provided arguments.`,
251
254
  };
252
255
 
253
- return new Promise<t.ToolApprovalResponse>((resolve, reject) => {
254
- safeDispatchCustomEvent(
255
- GraphEvents.ON_TOOL_APPROVAL_REQUIRED,
256
- {
257
- ...approvalRequest,
258
- resolve,
259
- reject,
260
- },
261
- config
262
- );
263
- });
256
+ // Notify host before interrupting — allows SSE event emission and DB persistence.
257
+ // This is a fire-and-forget notification, NOT the approval mechanism.
258
+ safeDispatchCustomEvent(
259
+ GraphEvents.ON_TOOL_APPROVAL_REQUIRED,
260
+ approvalRequest,
261
+ config
262
+ );
263
+
264
+ // interrupt() throws GraphInterrupt on first call (checkpoints state),
265
+ // returns the resume value on re-execution after Command({ resume }).
266
+ const response = interrupt(approvalRequest) as t.ToolApprovalResponse;
267
+ return response;
264
268
  }
265
269
 
266
270
  /**
@@ -363,13 +367,13 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
363
367
 
364
368
  // ========================================================================
365
369
  // HITL: Check if this tool requires human approval before execution.
366
- // Uses event-driven pattern: dispatches ON_TOOL_APPROVAL_REQUIRED event
367
- // and waits for the host to resolve/reject with a ToolApprovalResponse.
370
+ // Uses LangGraph interrupt() checkpoints state and pauses the graph.
371
+ // Resumes when host sends Command({ resume: ToolApprovalResponse }).
368
372
  // ========================================================================
369
373
  if (
370
374
  this.requiresApproval(call.name, call.args as Record<string, unknown>)
371
375
  ) {
372
- const approvalResponse = await this.requestApproval(call, config);
376
+ const approvalResponse = this.requestApproval(call, config);
373
377
  if (!approvalResponse.approved) {
374
378
  // Human denied the tool call - return a denial message
375
379
  return new ToolMessage({
@@ -811,7 +815,8 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
811
815
  ): Promise<ToolMessage[]> {
812
816
  // ========================================================================
813
817
  // HITL: Check approval for event-dispatched tools (browser, MCP, etc.)
814
- // before dispatching. Denied tools return denial messages immediately.
818
+ // before dispatching. Uses LangGraph interrupt() for each tool needing
819
+ // approval — counter-based matching handles sequential interrupts.
815
820
  // ========================================================================
816
821
  const approvedCalls: ToolCall[] = [];
817
822
  const denialMessages: ToolMessage[] = [];
@@ -820,7 +825,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
820
825
  if (
821
826
  this.requiresApproval(call.name, call.args as Record<string, unknown>)
822
827
  ) {
823
- const approvalResponse = await this.requestApproval(call, config);
828
+ const approvalResponse = this.requestApproval(call, config);
824
829
  if (!approvalResponse.approved) {
825
830
  denialMessages.push(
826
831
  new ToolMessage({