@illuma-ai/agents 1.1.19 → 1.1.21
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.
- package/dist/cjs/common/enum.cjs +2 -0
- package/dist/cjs/common/enum.cjs.map +1 -1
- package/dist/cjs/graphs/MultiAgentGraph.cjs +87 -1
- package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/index.cjs +14 -0
- package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
- package/dist/cjs/main.cjs +3 -0
- package/dist/cjs/main.cjs.map +1 -1
- package/dist/cjs/nodes/ApprovalGateNode.cjs +75 -0
- package/dist/cjs/nodes/ApprovalGateNode.cjs.map +1 -0
- package/dist/cjs/run.cjs +45 -0
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +21 -18
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/types/graph.cjs.map +1 -1
- package/dist/cjs/utils/run.cjs +6 -1
- package/dist/cjs/utils/run.cjs.map +1 -1
- package/dist/esm/common/enum.mjs +2 -0
- package/dist/esm/common/enum.mjs.map +1 -1
- package/dist/esm/graphs/MultiAgentGraph.mjs +87 -1
- package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
- package/dist/esm/llm/bedrock/index.mjs +14 -0
- package/dist/esm/llm/bedrock/index.mjs.map +1 -1
- package/dist/esm/main.mjs +1 -0
- package/dist/esm/main.mjs.map +1 -1
- package/dist/esm/nodes/ApprovalGateNode.mjs +72 -0
- package/dist/esm/nodes/ApprovalGateNode.mjs.map +1 -0
- package/dist/esm/run.mjs +45 -0
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +22 -19
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/types/graph.mjs.map +1 -1
- package/dist/esm/utils/run.mjs +6 -1
- package/dist/esm/utils/run.mjs.map +1 -1
- package/dist/types/common/enum.d.ts +2 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/nodes/ApprovalGateNode.d.ts +49 -0
- package/dist/types/nodes/index.d.ts +2 -0
- package/dist/types/run.d.ts +25 -1
- package/dist/types/tools/ToolNode.d.ts +7 -5
- package/dist/types/types/graph.d.ts +31 -0
- package/dist/types/types/tools.d.ts +7 -9
- package/package.json +1 -1
- package/src/common/enum.ts +2 -0
- package/src/graphs/MultiAgentGraph.ts +108 -1
- package/src/index.ts +3 -0
- package/src/llm/bedrock/index.ts +17 -0
- package/src/nodes/ApprovalGateNode.ts +117 -0
- package/src/nodes/__tests__/ApprovalGateNode.test.ts +206 -0
- package/src/nodes/index.ts +5 -0
- package/src/run.ts +55 -1
- package/src/specs/agent-handoffs-bedrock.integration.test.ts +2 -2
- package/src/specs/agent-handoffs.test.ts +153 -6
- package/src/tools/ToolNode.ts +28 -23
- package/src/tools/__tests__/ToolApproval.test.ts +162 -325
- package/src/types/graph.ts +32 -0
- package/src/types/tools.ts +7 -9
- package/src/utils/run.ts +9 -1
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tests for Human-in-the-Loop (HITL) tool approval in ToolNode.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Uses LangGraph's native interrupt()/Command({ resume }) pattern.
|
|
5
|
+
* Tests verify:
|
|
6
|
+
* - Policy evaluation (no config, scheduled, overrides, custom functions)
|
|
7
|
+
* - Interrupt/resume flow with MemorySaver checkpointer
|
|
8
|
+
* - Denial stops tool execution
|
|
9
|
+
* - Modified args are applied on resume
|
|
10
|
+
* - Multiple tool calls each get individual interrupts
|
|
7
11
|
*/
|
|
8
12
|
import { tool } from '@langchain/core/tools';
|
|
9
13
|
import { z } from 'zod';
|
|
@@ -13,6 +17,8 @@ import {
|
|
|
13
17
|
Annotation,
|
|
14
18
|
messagesStateReducer,
|
|
15
19
|
START,
|
|
20
|
+
MemorySaver,
|
|
21
|
+
Command,
|
|
16
22
|
} from '@langchain/langgraph';
|
|
17
23
|
import type { BaseMessage } from '@langchain/core/messages';
|
|
18
24
|
import type { RunnableConfig } from '@langchain/core/runnables';
|
|
@@ -68,22 +74,19 @@ const searchTool = tool(
|
|
|
68
74
|
);
|
|
69
75
|
|
|
70
76
|
/**
|
|
71
|
-
* Helper to build a graph with HITL support
|
|
72
|
-
*
|
|
77
|
+
* Helper to build a graph with HITL support using interrupt/resume pattern.
|
|
78
|
+
* Uses MemorySaver checkpointer for interrupt persistence.
|
|
79
|
+
* Returns the compiled graph and a notification collector.
|
|
73
80
|
*/
|
|
74
81
|
function createTestGraph(
|
|
75
82
|
tools: t.GenericTool[],
|
|
76
83
|
toolApprovalConfig: t.ToolApprovalConfig,
|
|
77
|
-
modelResponses: string[][]
|
|
78
|
-
approvalHandler: (event: t.ToolApprovalEvent) => void
|
|
84
|
+
modelResponses: string[][]
|
|
79
85
|
): {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
) => Promise<{ messages: BaseMessage[] }>;
|
|
85
|
-
};
|
|
86
|
-
config: Partial<RunnableConfig> & { version: 'v1' | 'v2' };
|
|
86
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
87
|
+
compiled: any;
|
|
88
|
+
notifications: t.ToolApprovalNotification[];
|
|
89
|
+
config: Partial<RunnableConfig> & { version: 'v1' | 'v2'; configurable: { thread_id: string } };
|
|
87
90
|
} {
|
|
88
91
|
const StateAnnotation = Annotation.Root({
|
|
89
92
|
messages: Annotation<BaseMessage[]>({
|
|
@@ -135,11 +138,18 @@ function createTestGraph(
|
|
|
135
138
|
.addConditionalEdges('agent', routeMessage)
|
|
136
139
|
.addEdge('tools', 'agent');
|
|
137
140
|
|
|
138
|
-
|
|
141
|
+
// MemorySaver is required for interrupt()/Command({ resume }) to work
|
|
142
|
+
const checkpointer = new MemorySaver();
|
|
143
|
+
const compiled = workflow.compile({ checkpointer });
|
|
144
|
+
|
|
145
|
+
const notifications: t.ToolApprovalNotification[] = [];
|
|
139
146
|
|
|
140
|
-
//
|
|
141
|
-
const config: Partial<RunnableConfig> & { version: 'v1' | 'v2' } = {
|
|
147
|
+
// Collect notification events (data-only, no resolve/reject)
|
|
148
|
+
const config: Partial<RunnableConfig> & { version: 'v1' | 'v2'; configurable: { thread_id: string } } = {
|
|
142
149
|
version: 'v2',
|
|
150
|
+
configurable: {
|
|
151
|
+
thread_id: `test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
152
|
+
},
|
|
143
153
|
callbacks: [
|
|
144
154
|
{
|
|
145
155
|
handleCustomEvent: async (
|
|
@@ -147,14 +157,14 @@ function createTestGraph(
|
|
|
147
157
|
data: unknown
|
|
148
158
|
): Promise<void> => {
|
|
149
159
|
if (eventName === GraphEvents.ON_TOOL_APPROVAL_REQUIRED) {
|
|
150
|
-
|
|
160
|
+
notifications.push(data as t.ToolApprovalNotification);
|
|
151
161
|
}
|
|
152
162
|
},
|
|
153
163
|
},
|
|
154
164
|
],
|
|
155
165
|
};
|
|
156
166
|
|
|
157
|
-
return { compiled, config };
|
|
167
|
+
return { compiled, notifications, config };
|
|
158
168
|
}
|
|
159
169
|
|
|
160
170
|
// ============================================================================
|
|
@@ -265,7 +275,6 @@ describe('HITL Tool Approval - Policy Evaluation', () => {
|
|
|
265
275
|
});
|
|
266
276
|
|
|
267
277
|
test('scheduled context bypasses custom function policy', async () => {
|
|
268
|
-
// Even a custom function that always returns true should be skipped in scheduled context
|
|
269
278
|
const alwaysRequire = (): boolean => true;
|
|
270
279
|
const toolNode = new ToolNode({
|
|
271
280
|
tools: [echoTool],
|
|
@@ -299,7 +308,6 @@ describe('HITL Tool Approval - Policy Evaluation', () => {
|
|
|
299
308
|
});
|
|
300
309
|
|
|
301
310
|
test('scheduled context bypasses per-tool overrides', async () => {
|
|
302
|
-
// Even with an explicit 'always' override for the tool, scheduled context should skip it
|
|
303
311
|
const toolNode = new ToolNode({
|
|
304
312
|
tools: [searchTool],
|
|
305
313
|
toolApprovalConfig: {
|
|
@@ -333,58 +341,17 @@ describe('HITL Tool Approval - Policy Evaluation', () => {
|
|
|
333
341
|
'Result for: scheduled override test'
|
|
334
342
|
);
|
|
335
343
|
});
|
|
336
|
-
|
|
337
|
-
test('interactive context still requires approval (scheduled does not)', async () => {
|
|
338
|
-
// Verify that 'interactive' context DOES require approval (contrasts with scheduled)
|
|
339
|
-
const approvalEvents: t.ToolApprovalEvent[] = [];
|
|
340
|
-
const { compiled, config } = createTestGraph(
|
|
341
|
-
[sendEmailTool],
|
|
342
|
-
{
|
|
343
|
-
defaultPolicy: 'always',
|
|
344
|
-
executionContext: 'interactive',
|
|
345
|
-
},
|
|
346
|
-
[
|
|
347
|
-
// First model call: request tool call
|
|
348
|
-
[
|
|
349
|
-
JSON.stringify({
|
|
350
|
-
name: 'send_email',
|
|
351
|
-
args: { to: 'user@example.com', subject: 'Interactive' },
|
|
352
|
-
id: 'call_int_1',
|
|
353
|
-
type: 'tool_call',
|
|
354
|
-
}),
|
|
355
|
-
],
|
|
356
|
-
// Second model call: finish after tool result
|
|
357
|
-
['Done.'],
|
|
358
|
-
],
|
|
359
|
-
(event) => {
|
|
360
|
-
approvalEvents.push(event);
|
|
361
|
-
// Auto-approve for test to complete
|
|
362
|
-
event.resolve({ approved: true });
|
|
363
|
-
}
|
|
364
|
-
);
|
|
365
|
-
|
|
366
|
-
const _result = await compiled.invoke(
|
|
367
|
-
{ messages: [new HumanMessage('send an email')] },
|
|
368
|
-
config
|
|
369
|
-
);
|
|
370
|
-
|
|
371
|
-
// Approval WAS required for interactive context
|
|
372
|
-
expect(approvalEvents.length).toBe(1);
|
|
373
|
-
expect(approvalEvents[0].toolName).toBe('send_email');
|
|
374
|
-
});
|
|
375
344
|
});
|
|
376
345
|
|
|
377
346
|
// ============================================================================
|
|
378
|
-
// Integration Tests:
|
|
347
|
+
// Integration Tests: Interrupt/Resume approval flow
|
|
379
348
|
// ============================================================================
|
|
380
349
|
|
|
381
|
-
describe('HITL Tool Approval -
|
|
350
|
+
describe('HITL Tool Approval - Interrupt/Resume Flow', () => {
|
|
382
351
|
jest.setTimeout(15000);
|
|
383
352
|
|
|
384
|
-
test('interactive mode with "always" policy
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
const { compiled, config } = createTestGraph(
|
|
353
|
+
test('interactive mode with "always" policy interrupts graph and resumes on approval', async () => {
|
|
354
|
+
const { compiled, notifications, config } = createTestGraph(
|
|
388
355
|
[echoTool as t.GenericTool],
|
|
389
356
|
{
|
|
390
357
|
defaultPolicy: 'always',
|
|
@@ -402,24 +369,26 @@ describe('HITL Tool Approval - Event-Driven Flow', () => {
|
|
|
402
369
|
],
|
|
403
370
|
// Second model response (after tool executes): final text
|
|
404
371
|
['The echo returned the result.'],
|
|
405
|
-
]
|
|
406
|
-
(event) => {
|
|
407
|
-
// Auto-approve after capturing the event
|
|
408
|
-
approvalEvents.push(event);
|
|
409
|
-
event.resolve({ approved: true });
|
|
410
|
-
}
|
|
372
|
+
]
|
|
411
373
|
);
|
|
412
374
|
|
|
413
|
-
|
|
375
|
+
// First invoke: graph runs until interrupt() is hit
|
|
376
|
+
const interruptResult = await compiled.invoke(
|
|
414
377
|
{ messages: [new HumanMessage('Say hello')] },
|
|
415
378
|
config
|
|
416
379
|
);
|
|
417
380
|
|
|
418
|
-
// Verify the
|
|
419
|
-
expect(
|
|
420
|
-
expect(
|
|
421
|
-
expect(
|
|
422
|
-
expect(
|
|
381
|
+
// Verify the notification was dispatched
|
|
382
|
+
expect(notifications).toHaveLength(1);
|
|
383
|
+
expect(notifications[0].type).toBe('tool_approval_required');
|
|
384
|
+
expect(notifications[0].toolName).toBe('echo');
|
|
385
|
+
expect(notifications[0].toolArgs).toEqual({ message: 'needs approval' });
|
|
386
|
+
|
|
387
|
+
// Resume with approval — Command({ resume }) returns value to interrupt()
|
|
388
|
+
const result = await compiled.invoke(
|
|
389
|
+
new Command({ resume: { approved: true } as t.ToolApprovalResponse }),
|
|
390
|
+
config
|
|
391
|
+
);
|
|
423
392
|
|
|
424
393
|
// Verify the tool executed after approval
|
|
425
394
|
const toolMessages = result.messages.filter(
|
|
@@ -454,18 +423,21 @@ describe('HITL Tool Approval - Event-Driven Flow', () => {
|
|
|
454
423
|
],
|
|
455
424
|
// Second model response (after denial): acknowledge
|
|
456
425
|
['I understand, the email was not sent.'],
|
|
457
|
-
]
|
|
458
|
-
(event) => {
|
|
459
|
-
// Deny the tool call
|
|
460
|
-
event.resolve({ approved: false, feedback: 'Not now' });
|
|
461
|
-
}
|
|
426
|
+
]
|
|
462
427
|
);
|
|
463
428
|
|
|
464
|
-
|
|
429
|
+
// First invoke: hits interrupt
|
|
430
|
+
await compiled.invoke(
|
|
465
431
|
{ messages: [new HumanMessage('Send an email')] },
|
|
466
432
|
config
|
|
467
433
|
);
|
|
468
434
|
|
|
435
|
+
// Resume with denial
|
|
436
|
+
const result = await compiled.invoke(
|
|
437
|
+
new Command({ resume: { approved: false, feedback: 'Not now' } as t.ToolApprovalResponse }),
|
|
438
|
+
config
|
|
439
|
+
);
|
|
440
|
+
|
|
469
441
|
// Find the tool message - it should be a denial
|
|
470
442
|
const toolMessages = result.messages.filter(
|
|
471
443
|
(m: BaseMessage) => m._getType() === 'tool'
|
|
@@ -483,11 +455,45 @@ describe('HITL Tool Approval - Event-Driven Flow', () => {
|
|
|
483
455
|
);
|
|
484
456
|
});
|
|
485
457
|
|
|
486
|
-
test('
|
|
487
|
-
|
|
488
|
-
|
|
458
|
+
test('interactive context triggers interrupt (scheduled does not)', async () => {
|
|
459
|
+
const { compiled, notifications, config } = createTestGraph(
|
|
460
|
+
[sendEmailTool as t.GenericTool],
|
|
461
|
+
{
|
|
462
|
+
defaultPolicy: 'always',
|
|
463
|
+
executionContext: 'interactive',
|
|
464
|
+
},
|
|
465
|
+
[
|
|
466
|
+
[
|
|
467
|
+
JSON.stringify({
|
|
468
|
+
name: 'send_email',
|
|
469
|
+
args: { to: 'user@example.com', subject: 'Interactive' },
|
|
470
|
+
id: 'call_int_1',
|
|
471
|
+
type: 'tool_call',
|
|
472
|
+
}),
|
|
473
|
+
],
|
|
474
|
+
['Done.'],
|
|
475
|
+
]
|
|
476
|
+
);
|
|
489
477
|
|
|
490
|
-
|
|
478
|
+
// Graph should interrupt (not complete)
|
|
479
|
+
await compiled.invoke(
|
|
480
|
+
{ messages: [new HumanMessage('send an email')] },
|
|
481
|
+
config
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
// Notification WAS dispatched for interactive context
|
|
485
|
+
expect(notifications.length).toBe(1);
|
|
486
|
+
expect(notifications[0].toolName).toBe('send_email');
|
|
487
|
+
|
|
488
|
+
// Resume to complete the graph
|
|
489
|
+
await compiled.invoke(
|
|
490
|
+
new Command({ resume: { approved: true } as t.ToolApprovalResponse }),
|
|
491
|
+
config
|
|
492
|
+
);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test('custom function override evaluates args to determine approval', async () => {
|
|
496
|
+
const { compiled, notifications, config } = createTestGraph(
|
|
491
497
|
[sendEmailTool as t.GenericTool],
|
|
492
498
|
{
|
|
493
499
|
defaultPolicy: 'never',
|
|
@@ -511,11 +517,7 @@ describe('HITL Tool Approval - Event-Driven Flow', () => {
|
|
|
511
517
|
],
|
|
512
518
|
// Final text
|
|
513
519
|
['Email sent to internal address.'],
|
|
514
|
-
]
|
|
515
|
-
(event) => {
|
|
516
|
-
approvalEvents.push(event);
|
|
517
|
-
event.resolve({ approved: true });
|
|
518
|
-
}
|
|
520
|
+
]
|
|
519
521
|
);
|
|
520
522
|
|
|
521
523
|
const result = await compiled.invoke(
|
|
@@ -523,10 +525,10 @@ describe('HITL Tool Approval - Event-Driven Flow', () => {
|
|
|
523
525
|
config
|
|
524
526
|
);
|
|
525
527
|
|
|
526
|
-
// No
|
|
527
|
-
expect(
|
|
528
|
+
// No notifications should have been dispatched (internal domain bypasses approval)
|
|
529
|
+
expect(notifications).toHaveLength(0);
|
|
528
530
|
|
|
529
|
-
// Email should have been sent directly
|
|
531
|
+
// Email should have been sent directly without interrupt
|
|
530
532
|
const toolMessages = result.messages.filter(
|
|
531
533
|
(m: BaseMessage) => m._getType() === 'tool'
|
|
532
534
|
);
|
|
@@ -536,10 +538,8 @@ describe('HITL Tool Approval - Event-Driven Flow', () => {
|
|
|
536
538
|
);
|
|
537
539
|
});
|
|
538
540
|
|
|
539
|
-
test('custom function override triggers
|
|
540
|
-
const
|
|
541
|
-
|
|
542
|
-
const { compiled, config } = createTestGraph(
|
|
541
|
+
test('custom function override triggers interrupt for external email', async () => {
|
|
542
|
+
const { compiled, notifications, config } = createTestGraph(
|
|
543
543
|
[sendEmailTool as t.GenericTool],
|
|
544
544
|
{
|
|
545
545
|
defaultPolicy: 'never',
|
|
@@ -563,22 +563,25 @@ describe('HITL Tool Approval - Event-Driven Flow', () => {
|
|
|
563
563
|
],
|
|
564
564
|
// After approval
|
|
565
565
|
['Email sent to external address.'],
|
|
566
|
-
]
|
|
567
|
-
(event) => {
|
|
568
|
-
approvalEvents.push(event);
|
|
569
|
-
event.resolve({ approved: true });
|
|
570
|
-
}
|
|
566
|
+
]
|
|
571
567
|
);
|
|
572
568
|
|
|
573
|
-
|
|
569
|
+
// First invoke: hits interrupt for external domain
|
|
570
|
+
await compiled.invoke(
|
|
574
571
|
{ messages: [new HumanMessage('Send external email')] },
|
|
575
572
|
config
|
|
576
573
|
);
|
|
577
574
|
|
|
578
|
-
//
|
|
579
|
-
expect(
|
|
580
|
-
expect(
|
|
581
|
-
expect(
|
|
575
|
+
// Notification should have been dispatched (external domain)
|
|
576
|
+
expect(notifications).toHaveLength(1);
|
|
577
|
+
expect(notifications[0].toolName).toBe('send_email');
|
|
578
|
+
expect(notifications[0].toolArgs.to).toBe('external@gmail.com');
|
|
579
|
+
|
|
580
|
+
// Resume with approval
|
|
581
|
+
const result = await compiled.invoke(
|
|
582
|
+
new Command({ resume: { approved: true } as t.ToolApprovalResponse }),
|
|
583
|
+
config
|
|
584
|
+
);
|
|
582
585
|
|
|
583
586
|
// Email should have been sent after approval
|
|
584
587
|
const lastMessage = result.messages[result.messages.length - 1];
|
|
@@ -604,21 +607,26 @@ describe('HITL Tool Approval - Event-Driven Flow', () => {
|
|
|
604
607
|
],
|
|
605
608
|
// After tool execution with modified args
|
|
606
609
|
['Email sent with corrected details.'],
|
|
607
|
-
]
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
610
|
+
]
|
|
611
|
+
);
|
|
612
|
+
|
|
613
|
+
// First invoke: hits interrupt
|
|
614
|
+
await compiled.invoke(
|
|
615
|
+
{ messages: [new HumanMessage('Send email')] },
|
|
616
|
+
config
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
// Resume with modified args
|
|
620
|
+
const result = await compiled.invoke(
|
|
621
|
+
new Command({
|
|
622
|
+
resume: {
|
|
611
623
|
approved: true,
|
|
612
624
|
modifiedArgs: {
|
|
613
625
|
to: 'correct@example.com',
|
|
614
626
|
subject: 'Corrected Subject',
|
|
615
627
|
},
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
);
|
|
619
|
-
|
|
620
|
-
const result = await compiled.invoke(
|
|
621
|
-
{ messages: [new HumanMessage('Send email')] },
|
|
628
|
+
} as t.ToolApprovalResponse,
|
|
629
|
+
}),
|
|
622
630
|
config
|
|
623
631
|
);
|
|
624
632
|
|
|
@@ -635,14 +643,12 @@ describe('HITL Tool Approval - Event-Driven Flow', () => {
|
|
|
635
643
|
expect(sentMsg!.content.toString()).toContain('Corrected Subject');
|
|
636
644
|
});
|
|
637
645
|
|
|
638
|
-
test('scheduled context auto-approves without dispatching events', async () => {
|
|
639
|
-
const
|
|
640
|
-
|
|
641
|
-
const { compiled, config } = createTestGraph(
|
|
646
|
+
test('scheduled context auto-approves without dispatching events or interrupting', async () => {
|
|
647
|
+
const { compiled, notifications, config } = createTestGraph(
|
|
642
648
|
[sendEmailTool as t.GenericTool],
|
|
643
649
|
{
|
|
644
650
|
defaultPolicy: 'always',
|
|
645
|
-
executionContext: 'scheduled', // Scheduled = auto-approve
|
|
651
|
+
executionContext: 'scheduled', // Scheduled = auto-approve, no interrupt
|
|
646
652
|
},
|
|
647
653
|
[
|
|
648
654
|
[
|
|
@@ -654,20 +660,17 @@ describe('HITL Tool Approval - Event-Driven Flow', () => {
|
|
|
654
660
|
}),
|
|
655
661
|
],
|
|
656
662
|
['Scheduled email sent.'],
|
|
657
|
-
]
|
|
658
|
-
(event) => {
|
|
659
|
-
approvalEvents.push(event);
|
|
660
|
-
event.resolve({ approved: true });
|
|
661
|
-
}
|
|
663
|
+
]
|
|
662
664
|
);
|
|
663
665
|
|
|
666
|
+
// Should complete without interrupting — no resume needed
|
|
664
667
|
const result = await compiled.invoke(
|
|
665
668
|
{ messages: [new HumanMessage('Send scheduled email')] },
|
|
666
669
|
config
|
|
667
670
|
);
|
|
668
671
|
|
|
669
|
-
// No
|
|
670
|
-
expect(
|
|
672
|
+
// No notifications should be dispatched for scheduled execution
|
|
673
|
+
expect(notifications).toHaveLength(0);
|
|
671
674
|
|
|
672
675
|
// Email should have been sent directly
|
|
673
676
|
const toolMessages = result.messages.filter(
|
|
@@ -677,94 +680,8 @@ describe('HITL Tool Approval - Event-Driven Flow', () => {
|
|
|
677
680
|
expect(toolMessages[0].content.toString()).toContain('user@example.com');
|
|
678
681
|
});
|
|
679
682
|
|
|
680
|
-
test('
|
|
681
|
-
const
|
|
682
|
-
tools: [echoTool] as t.GenericTool[],
|
|
683
|
-
toolApprovalConfig: {
|
|
684
|
-
defaultPolicy: 'always',
|
|
685
|
-
executionContext: 'interactive',
|
|
686
|
-
},
|
|
687
|
-
});
|
|
688
|
-
|
|
689
|
-
const StateAnnotation = Annotation.Root({
|
|
690
|
-
messages: Annotation<BaseMessage[]>({
|
|
691
|
-
reducer: messagesStateReducer,
|
|
692
|
-
default: () => [],
|
|
693
|
-
}),
|
|
694
|
-
});
|
|
695
|
-
|
|
696
|
-
let callCount = 0;
|
|
697
|
-
const callModel = async (_state: {
|
|
698
|
-
messages: BaseMessage[];
|
|
699
|
-
}): Promise<{ messages: BaseMessage[] }> => {
|
|
700
|
-
callCount++;
|
|
701
|
-
if (callCount === 1) {
|
|
702
|
-
return {
|
|
703
|
-
messages: [
|
|
704
|
-
new AIMessage({
|
|
705
|
-
content: '',
|
|
706
|
-
tool_calls: [
|
|
707
|
-
{
|
|
708
|
-
name: 'echo',
|
|
709
|
-
args: { message: 'test' },
|
|
710
|
-
id: 'call_reject_1',
|
|
711
|
-
type: 'tool_call' as const,
|
|
712
|
-
},
|
|
713
|
-
],
|
|
714
|
-
}),
|
|
715
|
-
],
|
|
716
|
-
};
|
|
717
|
-
}
|
|
718
|
-
return { messages: [new AIMessage({ content: 'Recovered.' })] };
|
|
719
|
-
};
|
|
720
|
-
|
|
721
|
-
const routeMessage = (state: typeof StateAnnotation.State): string =>
|
|
722
|
-
toolsCondition(state, 'tools');
|
|
723
|
-
|
|
724
|
-
const workflow = new StateGraph(StateAnnotation)
|
|
725
|
-
.addNode('agent', callModel)
|
|
726
|
-
.addNode('tools', toolNode)
|
|
727
|
-
.addEdge(START, 'agent')
|
|
728
|
-
.addConditionalEdges('agent', routeMessage)
|
|
729
|
-
.addEdge('tools', 'agent');
|
|
730
|
-
|
|
731
|
-
const compiled = workflow.compile();
|
|
732
|
-
const config: Partial<RunnableConfig> & { version: 'v1' | 'v2' } = {
|
|
733
|
-
version: 'v2',
|
|
734
|
-
callbacks: [
|
|
735
|
-
{
|
|
736
|
-
handleCustomEvent: async (
|
|
737
|
-
eventName: string,
|
|
738
|
-
data: unknown
|
|
739
|
-
): Promise<void> => {
|
|
740
|
-
if (eventName === GraphEvents.ON_TOOL_APPROVAL_REQUIRED) {
|
|
741
|
-
const event = data as t.ToolApprovalEvent;
|
|
742
|
-
// Simulate host-side error (e.g., stream disconnected)
|
|
743
|
-
event.reject(new Error('Stream closed before approval'));
|
|
744
|
-
}
|
|
745
|
-
},
|
|
746
|
-
},
|
|
747
|
-
],
|
|
748
|
-
};
|
|
749
|
-
|
|
750
|
-
// The graph should handle rejection without crashing
|
|
751
|
-
const result = await compiled.invoke(
|
|
752
|
-
{ messages: [new HumanMessage('Say hello')] },
|
|
753
|
-
config
|
|
754
|
-
);
|
|
755
|
-
|
|
756
|
-
// Should have a tool message with error content
|
|
757
|
-
const toolMessages = result.messages.filter(
|
|
758
|
-
(m: BaseMessage) => m._getType() === 'tool'
|
|
759
|
-
);
|
|
760
|
-
expect(toolMessages.length).toBeGreaterThan(0);
|
|
761
|
-
// Agent should continue after rejection
|
|
762
|
-
const lastMessage = result.messages[result.messages.length - 1];
|
|
763
|
-
expect(lastMessage._getType()).toBe('ai');
|
|
764
|
-
});
|
|
765
|
-
|
|
766
|
-
test('multiple tool calls each get individual approval', async () => {
|
|
767
|
-
const approvalEvents: t.ToolApprovalEvent[] = [];
|
|
683
|
+
test('multiple tool calls each get individual interrupts', async () => {
|
|
684
|
+
const notifications: t.ToolApprovalNotification[] = [];
|
|
768
685
|
|
|
769
686
|
const toolNode = new ToolNode({
|
|
770
687
|
tools: [echoTool, sendEmailTool] as t.GenericTool[],
|
|
@@ -822,9 +739,14 @@ describe('HITL Tool Approval - Event-Driven Flow', () => {
|
|
|
822
739
|
.addConditionalEdges('agent', routeMessage)
|
|
823
740
|
.addEdge('tools', 'agent');
|
|
824
741
|
|
|
825
|
-
const
|
|
826
|
-
const
|
|
742
|
+
const checkpointer = new MemorySaver();
|
|
743
|
+
const compiled = workflow.compile({ checkpointer });
|
|
744
|
+
|
|
745
|
+
const config: Partial<RunnableConfig> & { version: 'v1' | 'v2'; configurable: { thread_id: string } } = {
|
|
827
746
|
version: 'v2',
|
|
747
|
+
configurable: {
|
|
748
|
+
thread_id: `test-multi-${Date.now()}`,
|
|
749
|
+
},
|
|
828
750
|
callbacks: [
|
|
829
751
|
{
|
|
830
752
|
handleCustomEvent: async (
|
|
@@ -832,122 +754,37 @@ describe('HITL Tool Approval - Event-Driven Flow', () => {
|
|
|
832
754
|
data: unknown
|
|
833
755
|
): Promise<void> => {
|
|
834
756
|
if (eventName === GraphEvents.ON_TOOL_APPROVAL_REQUIRED) {
|
|
835
|
-
|
|
836
|
-
approvalEvents.push(event);
|
|
837
|
-
event.resolve({ approved: true });
|
|
757
|
+
notifications.push(data as t.ToolApprovalNotification);
|
|
838
758
|
}
|
|
839
759
|
},
|
|
840
760
|
},
|
|
841
761
|
],
|
|
842
762
|
};
|
|
843
763
|
|
|
844
|
-
|
|
764
|
+
// First invoke: hits first interrupt
|
|
765
|
+
await compiled.invoke(
|
|
845
766
|
{ messages: [new HumanMessage('Do both')] },
|
|
846
767
|
config
|
|
847
768
|
);
|
|
848
769
|
|
|
849
|
-
//
|
|
850
|
-
expect(
|
|
851
|
-
expect(approvalEvents.map((e) => e.toolName).sort()).toEqual([
|
|
852
|
-
'echo',
|
|
853
|
-
'send_email',
|
|
854
|
-
]);
|
|
770
|
+
// First interrupt notification received
|
|
771
|
+
expect(notifications.length).toBeGreaterThanOrEqual(1);
|
|
855
772
|
|
|
856
|
-
//
|
|
857
|
-
|
|
858
|
-
(
|
|
773
|
+
// Resume first tool approval
|
|
774
|
+
await compiled.invoke(
|
|
775
|
+
new Command({ resume: { approved: true } as t.ToolApprovalResponse }),
|
|
776
|
+
config
|
|
859
777
|
);
|
|
860
|
-
expect(toolMessages).toHaveLength(2);
|
|
861
|
-
});
|
|
862
|
-
});
|
|
863
778
|
|
|
864
|
-
//
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
test('ToolApprovalConfig accepts valid configurations', () => {
|
|
870
|
-
const config1: t.ToolApprovalConfig = {
|
|
871
|
-
defaultPolicy: 'always',
|
|
872
|
-
executionContext: 'interactive',
|
|
873
|
-
};
|
|
874
|
-
expect(config1.defaultPolicy).toBe('always');
|
|
875
|
-
|
|
876
|
-
const config2: t.ToolApprovalConfig = {
|
|
877
|
-
defaultPolicy: 'never',
|
|
878
|
-
executionContext: 'scheduled',
|
|
879
|
-
};
|
|
880
|
-
expect(config2.executionContext).toBe('scheduled');
|
|
881
|
-
|
|
882
|
-
const config3: t.ToolApprovalConfig = {
|
|
883
|
-
defaultPolicy: 'always',
|
|
884
|
-
executionContext: 'interactive',
|
|
885
|
-
overrides: {
|
|
886
|
-
search: 'never',
|
|
887
|
-
send_email: 'always',
|
|
888
|
-
custom_tool: (_toolName, args) => (args.dangerous as boolean) === true,
|
|
889
|
-
},
|
|
890
|
-
};
|
|
891
|
-
expect(config3.overrides).toBeDefined();
|
|
892
|
-
expect(config3.overrides!.search).toBe('never');
|
|
893
|
-
});
|
|
894
|
-
|
|
895
|
-
test('ToolApprovalRequest has correct shape', () => {
|
|
896
|
-
const request: t.ToolApprovalRequest = {
|
|
897
|
-
type: 'tool_approval_required',
|
|
898
|
-
toolCallId: 'call_123',
|
|
899
|
-
toolName: 'send_email',
|
|
900
|
-
toolArgs: { to: 'user@example.com' },
|
|
901
|
-
agentId: 'agent-1',
|
|
902
|
-
description: 'Send email to user',
|
|
903
|
-
};
|
|
904
|
-
expect(request.type).toBe('tool_approval_required');
|
|
905
|
-
expect(request.toolName).toBe('send_email');
|
|
906
|
-
});
|
|
907
|
-
|
|
908
|
-
test('ToolApprovalResponse covers all response types', () => {
|
|
909
|
-
const approved: t.ToolApprovalResponse = { approved: true };
|
|
910
|
-
expect(approved.approved).toBe(true);
|
|
911
|
-
|
|
912
|
-
const denied: t.ToolApprovalResponse = {
|
|
913
|
-
approved: false,
|
|
914
|
-
feedback: 'Too risky',
|
|
915
|
-
};
|
|
916
|
-
expect(denied.feedback).toBe('Too risky');
|
|
917
|
-
|
|
918
|
-
const edited: t.ToolApprovalResponse = {
|
|
919
|
-
approved: true,
|
|
920
|
-
modifiedArgs: { to: 'corrected@example.com' },
|
|
921
|
-
};
|
|
922
|
-
expect(edited.modifiedArgs).toBeDefined();
|
|
923
|
-
});
|
|
924
|
-
|
|
925
|
-
test('ToolApprovalRule function receives toolName as first argument', () => {
|
|
926
|
-
// Verify the function-based policy can use toolName for classification
|
|
927
|
-
const actionPrefixes = ['send', 'create', 'delete', 'post'];
|
|
928
|
-
const policy: t.ToolApprovalRule = (
|
|
929
|
-
toolName: string,
|
|
930
|
-
_args: Record<string, unknown>
|
|
931
|
-
) => {
|
|
932
|
-
const lower = toolName.toLowerCase();
|
|
933
|
-
return actionPrefixes.some((prefix) => lower.includes(prefix));
|
|
934
|
-
};
|
|
935
|
-
|
|
936
|
-
// Action tools should require approval
|
|
937
|
-
expect(policy('send_email', { to: 'a@b.com' })).toBe(true);
|
|
938
|
-
expect(policy('create_document', {})).toBe(true);
|
|
939
|
-
expect(policy('delete_file', {})).toBe(true);
|
|
940
|
-
expect(policy('post_message', {})).toBe(true);
|
|
941
|
-
|
|
942
|
-
// Read-only tools should not
|
|
943
|
-
expect(policy('search', { query: 'test' })).toBe(false);
|
|
944
|
-
expect(policy('list_emails', {})).toBe(false);
|
|
945
|
-
expect(policy('get_calendar', {})).toBe(false);
|
|
779
|
+
// Second tool also requires approval — resume again
|
|
780
|
+
await compiled.invoke(
|
|
781
|
+
new Command({ resume: { approved: true } as t.ToolApprovalResponse }),
|
|
782
|
+
config
|
|
783
|
+
);
|
|
946
784
|
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
expect(typeof config.defaultPolicy).toBe('function');
|
|
785
|
+
// Notifications fire on every re-execution (interrupt() replays the node),
|
|
786
|
+
// so we check unique tool names rather than total count.
|
|
787
|
+
const uniqueTools = [...new Set(notifications.map((e) => e.toolName))].sort();
|
|
788
|
+
expect(uniqueTools).toEqual(['echo', 'send_email']);
|
|
952
789
|
});
|
|
953
790
|
});
|