@illuma-ai/agents 1.0.82 → 1.0.84
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/README.md +485 -485
- package/dist/cjs/common/enum.cjs +2 -0
- package/dist/cjs/common/enum.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +4 -0
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/tools/CodeExecutor.cjs +26 -32
- package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +83 -1
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/types/graph.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/Graph.mjs +4 -0
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/tools/CodeExecutor.mjs +26 -32
- package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +85 -3
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/types/graph.mjs.map +1 -1
- package/dist/types/common/enum.d.ts +2 -0
- package/dist/types/tools/CodeExecutor.d.ts +6 -6
- package/dist/types/tools/ToolNode.d.ts +22 -1
- package/dist/types/types/graph.d.ts +8 -1
- package/dist/types/types/tools.d.ts +85 -0
- package/package.json +6 -2
- package/src/agents/__tests__/resolveStructuredOutputMode.test.ts +137 -137
- package/src/common/enum.ts +2 -0
- package/src/graphs/Graph.ts +5 -0
- package/src/graphs/__tests__/structured-output.integration.test.ts +809 -809
- package/src/graphs/__tests__/structured-output.test.ts +183 -183
- package/src/schemas/schema-preparation.test.ts +500 -500
- package/src/tools/CodeExecutor.ts +27 -32
- package/src/tools/ToolNode.ts +96 -0
- package/src/tools/__tests__/ToolApproval.test.ts +699 -0
- package/src/types/graph.ts +8 -1
- package/src/types/tools.ts +99 -0
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Human-in-the-Loop (HITL) tool approval in ToolNode.
|
|
3
|
+
*
|
|
4
|
+
* Tests the approval policy evaluation and the event-driven approval flow.
|
|
5
|
+
* The HITL system dispatches ON_TOOL_APPROVAL_REQUIRED events that the host
|
|
6
|
+
* (Ranger) handles by showing UI and resolving/rejecting the promise.
|
|
7
|
+
*/
|
|
8
|
+
import { tool } from '@langchain/core/tools';
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
import {
|
|
11
|
+
HumanMessage,
|
|
12
|
+
AIMessage,
|
|
13
|
+
ToolMessage,
|
|
14
|
+
} from '@langchain/core/messages';
|
|
15
|
+
import {
|
|
16
|
+
StateGraph,
|
|
17
|
+
Annotation,
|
|
18
|
+
messagesStateReducer,
|
|
19
|
+
START,
|
|
20
|
+
END,
|
|
21
|
+
} from '@langchain/langgraph';
|
|
22
|
+
import type { BaseMessage } from '@langchain/core/messages';
|
|
23
|
+
import type { RunnableConfig } from '@langchain/core/runnables';
|
|
24
|
+
import type * as t from '@/types';
|
|
25
|
+
import { ToolNode, toolsCondition } from '@/tools/ToolNode';
|
|
26
|
+
import { GraphEvents } from '@/common';
|
|
27
|
+
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// Test Fixtures
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
/** Simple echo tool that returns its input */
|
|
33
|
+
const echoTool = tool(
|
|
34
|
+
async ({ message }: { message: string }) => {
|
|
35
|
+
return `Echo: ${message}`;
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: 'echo',
|
|
39
|
+
description: 'Echoes the input message',
|
|
40
|
+
schema: z.object({
|
|
41
|
+
message: z.string().describe('The message to echo'),
|
|
42
|
+
}),
|
|
43
|
+
}
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
/** Simulated "send email" tool */
|
|
47
|
+
const sendEmailTool = tool(
|
|
48
|
+
async ({ to, subject }: { to: string; subject: string }) => {
|
|
49
|
+
return JSON.stringify({ status: 'sent', to, subject });
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: 'send_email',
|
|
53
|
+
description: 'Sends an email',
|
|
54
|
+
schema: z.object({
|
|
55
|
+
to: z.string().describe('Recipient email'),
|
|
56
|
+
subject: z.string().describe('Email subject'),
|
|
57
|
+
}),
|
|
58
|
+
}
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
/** Simulated "search" tool - typically safe, no approval needed */
|
|
62
|
+
const searchTool = tool(
|
|
63
|
+
async ({ query }: { query: string }) => {
|
|
64
|
+
return JSON.stringify({ results: [`Result for: ${query}`] });
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: 'search',
|
|
68
|
+
description: 'Searches for information',
|
|
69
|
+
schema: z.object({
|
|
70
|
+
query: z.string().describe('Search query'),
|
|
71
|
+
}),
|
|
72
|
+
}
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Helper to build a graph with HITL support for integration testing.
|
|
77
|
+
* Returns the compiled graph and a way to intercept approval events.
|
|
78
|
+
*/
|
|
79
|
+
function createTestGraph(
|
|
80
|
+
tools: t.GenericTool[],
|
|
81
|
+
toolApprovalConfig: t.ToolApprovalConfig,
|
|
82
|
+
modelResponses: string[][],
|
|
83
|
+
approvalHandler: (event: t.ToolApprovalEvent) => void
|
|
84
|
+
) {
|
|
85
|
+
const StateAnnotation = Annotation.Root({
|
|
86
|
+
messages: Annotation<BaseMessage[]>({
|
|
87
|
+
reducer: messagesStateReducer,
|
|
88
|
+
default: () => [],
|
|
89
|
+
}),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
let responseIndex = 0;
|
|
93
|
+
|
|
94
|
+
/** Fake model node that returns pre-defined tool calls or text */
|
|
95
|
+
const callModel = async (state: { messages: BaseMessage[] }) => {
|
|
96
|
+
const responses = modelResponses[responseIndex] ?? ['Done.'];
|
|
97
|
+
responseIndex++;
|
|
98
|
+
|
|
99
|
+
// Check if the response is a tool call instruction (JSON format)
|
|
100
|
+
if (responses.length === 1 && responses[0].startsWith('{')) {
|
|
101
|
+
const parsed = JSON.parse(responses[0]);
|
|
102
|
+
return {
|
|
103
|
+
messages: [
|
|
104
|
+
new AIMessage({
|
|
105
|
+
content: '',
|
|
106
|
+
tool_calls: [parsed],
|
|
107
|
+
}),
|
|
108
|
+
],
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
messages: [new AIMessage({ content: responses.join(' ') })],
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const toolNode = new ToolNode<typeof StateAnnotation.State>({
|
|
118
|
+
tools,
|
|
119
|
+
toolApprovalConfig,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const routeMessage = (state: typeof StateAnnotation.State) => {
|
|
123
|
+
return toolsCondition(state, 'tools');
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const workflow = new StateGraph(StateAnnotation)
|
|
127
|
+
.addNode('agent', callModel)
|
|
128
|
+
.addNode('tools', toolNode)
|
|
129
|
+
.addEdge(START, 'agent')
|
|
130
|
+
.addConditionalEdges('agent', routeMessage)
|
|
131
|
+
.addEdge('tools', 'agent');
|
|
132
|
+
|
|
133
|
+
const compiled = workflow.compile();
|
|
134
|
+
|
|
135
|
+
// Create a config with custom event handler to intercept approval requests
|
|
136
|
+
const config: Partial<RunnableConfig> & { version: 'v1' | 'v2' } = {
|
|
137
|
+
version: 'v2',
|
|
138
|
+
callbacks: [
|
|
139
|
+
{
|
|
140
|
+
handleCustomEvent: async (
|
|
141
|
+
eventName: string,
|
|
142
|
+
data: unknown
|
|
143
|
+
): Promise<void> => {
|
|
144
|
+
if (eventName === GraphEvents.ON_TOOL_APPROVAL_REQUIRED) {
|
|
145
|
+
approvalHandler(data as t.ToolApprovalEvent);
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
return { compiled, config };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ============================================================================
|
|
156
|
+
// Unit Tests: ToolApprovalConfig policy evaluation
|
|
157
|
+
// ============================================================================
|
|
158
|
+
|
|
159
|
+
describe('HITL Tool Approval - Policy Evaluation', () => {
|
|
160
|
+
test('no approval config means no interruption', async () => {
|
|
161
|
+
// ToolNode without toolApprovalConfig should execute tools directly
|
|
162
|
+
const toolNode = new ToolNode({
|
|
163
|
+
tools: [echoTool],
|
|
164
|
+
// No toolApprovalConfig
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const input = {
|
|
168
|
+
messages: [
|
|
169
|
+
new AIMessage({
|
|
170
|
+
content: '',
|
|
171
|
+
tool_calls: [
|
|
172
|
+
{
|
|
173
|
+
name: 'echo',
|
|
174
|
+
args: { message: 'hello' },
|
|
175
|
+
id: 'call_1',
|
|
176
|
+
type: 'tool_call' as const,
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
}),
|
|
180
|
+
],
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// Should execute without interruption
|
|
184
|
+
const result = await toolNode.invoke(input);
|
|
185
|
+
expect(result.messages).toHaveLength(1);
|
|
186
|
+
expect(result.messages[0]).toBeInstanceOf(ToolMessage);
|
|
187
|
+
expect((result.messages[0] as ToolMessage).content).toContain('Echo: hello');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('scheduled execution context bypasses all approvals', async () => {
|
|
191
|
+
// Even with defaultPolicy: 'always', scheduled should not interrupt
|
|
192
|
+
const toolNode = new ToolNode({
|
|
193
|
+
tools: [echoTool],
|
|
194
|
+
toolApprovalConfig: {
|
|
195
|
+
defaultPolicy: 'always',
|
|
196
|
+
executionContext: 'scheduled',
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const input = {
|
|
201
|
+
messages: [
|
|
202
|
+
new AIMessage({
|
|
203
|
+
content: '',
|
|
204
|
+
tool_calls: [
|
|
205
|
+
{
|
|
206
|
+
name: 'echo',
|
|
207
|
+
args: { message: 'scheduled hello' },
|
|
208
|
+
id: 'call_2',
|
|
209
|
+
type: 'tool_call' as const,
|
|
210
|
+
},
|
|
211
|
+
],
|
|
212
|
+
}),
|
|
213
|
+
],
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// Should execute without interruption because context is 'scheduled'
|
|
217
|
+
const result = await toolNode.invoke(input);
|
|
218
|
+
expect(result.messages).toHaveLength(1);
|
|
219
|
+
expect((result.messages[0] as ToolMessage).content).toContain(
|
|
220
|
+
'Echo: scheduled hello'
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('tool with "never" override skips approval even when default is "always"', async () => {
|
|
225
|
+
const toolNode = new ToolNode({
|
|
226
|
+
tools: [searchTool],
|
|
227
|
+
toolApprovalConfig: {
|
|
228
|
+
defaultPolicy: 'always',
|
|
229
|
+
executionContext: 'interactive',
|
|
230
|
+
overrides: {
|
|
231
|
+
search: 'never',
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const input = {
|
|
237
|
+
messages: [
|
|
238
|
+
new AIMessage({
|
|
239
|
+
content: '',
|
|
240
|
+
tool_calls: [
|
|
241
|
+
{
|
|
242
|
+
name: 'search',
|
|
243
|
+
args: { query: 'test query' },
|
|
244
|
+
id: 'call_3',
|
|
245
|
+
type: 'tool_call' as const,
|
|
246
|
+
},
|
|
247
|
+
],
|
|
248
|
+
}),
|
|
249
|
+
],
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
// Should execute without interruption because override is 'never'
|
|
253
|
+
const result = await toolNode.invoke(input);
|
|
254
|
+
expect(result.messages).toHaveLength(1);
|
|
255
|
+
expect((result.messages[0] as ToolMessage).content).toContain(
|
|
256
|
+
'Result for: test query'
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// ============================================================================
|
|
262
|
+
// Integration Tests: Event-driven approval flow
|
|
263
|
+
// ============================================================================
|
|
264
|
+
|
|
265
|
+
describe('HITL Tool Approval - Event-Driven Flow', () => {
|
|
266
|
+
jest.setTimeout(15000);
|
|
267
|
+
|
|
268
|
+
test('interactive mode with "always" policy dispatches approval event and waits', async () => {
|
|
269
|
+
const approvalEvents: t.ToolApprovalEvent[] = [];
|
|
270
|
+
|
|
271
|
+
const { compiled, config } = createTestGraph(
|
|
272
|
+
[echoTool as t.GenericTool],
|
|
273
|
+
{
|
|
274
|
+
defaultPolicy: 'always',
|
|
275
|
+
executionContext: 'interactive',
|
|
276
|
+
},
|
|
277
|
+
[
|
|
278
|
+
// First model response: request tool call
|
|
279
|
+
[
|
|
280
|
+
JSON.stringify({
|
|
281
|
+
name: 'echo',
|
|
282
|
+
args: { message: 'needs approval' },
|
|
283
|
+
id: 'call_int_1',
|
|
284
|
+
type: 'tool_call',
|
|
285
|
+
}),
|
|
286
|
+
],
|
|
287
|
+
// Second model response (after tool executes): final text
|
|
288
|
+
['The echo returned the result.'],
|
|
289
|
+
],
|
|
290
|
+
(event) => {
|
|
291
|
+
// Auto-approve after capturing the event
|
|
292
|
+
approvalEvents.push(event);
|
|
293
|
+
event.resolve({ approved: true });
|
|
294
|
+
}
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
const result = await compiled.invoke(
|
|
298
|
+
{ messages: [new HumanMessage('Say hello')] },
|
|
299
|
+
config
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
// Verify the approval event was dispatched
|
|
303
|
+
expect(approvalEvents).toHaveLength(1);
|
|
304
|
+
expect(approvalEvents[0].type).toBe('tool_approval_required');
|
|
305
|
+
expect(approvalEvents[0].toolName).toBe('echo');
|
|
306
|
+
expect(approvalEvents[0].toolArgs).toEqual({ message: 'needs approval' });
|
|
307
|
+
|
|
308
|
+
// Verify the tool executed after approval
|
|
309
|
+
const toolMessages = result.messages.filter(
|
|
310
|
+
(m: BaseMessage) => m._getType() === 'tool'
|
|
311
|
+
);
|
|
312
|
+
expect(toolMessages.length).toBeGreaterThan(0);
|
|
313
|
+
expect(toolMessages[0].content.toString()).toContain('Echo: needs approval');
|
|
314
|
+
|
|
315
|
+
// Verify final model response
|
|
316
|
+
const lastMessage = result.messages[result.messages.length - 1];
|
|
317
|
+
expect(lastMessage.content).toContain('The echo returned the result');
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test('denial stops tool execution and returns denial message', async () => {
|
|
321
|
+
const { compiled, config } = createTestGraph(
|
|
322
|
+
[sendEmailTool as t.GenericTool],
|
|
323
|
+
{
|
|
324
|
+
defaultPolicy: 'always',
|
|
325
|
+
executionContext: 'interactive',
|
|
326
|
+
},
|
|
327
|
+
[
|
|
328
|
+
// First model response: request send email
|
|
329
|
+
[
|
|
330
|
+
JSON.stringify({
|
|
331
|
+
name: 'send_email',
|
|
332
|
+
args: { to: 'user@example.com', subject: 'Test' },
|
|
333
|
+
id: 'call_deny_1',
|
|
334
|
+
type: 'tool_call',
|
|
335
|
+
}),
|
|
336
|
+
],
|
|
337
|
+
// Second model response (after denial): acknowledge
|
|
338
|
+
['I understand, the email was not sent.'],
|
|
339
|
+
],
|
|
340
|
+
(event) => {
|
|
341
|
+
// Deny the tool call
|
|
342
|
+
event.resolve({ approved: false, feedback: 'Not now' });
|
|
343
|
+
}
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
const result = await compiled.invoke(
|
|
347
|
+
{ messages: [new HumanMessage('Send an email')] },
|
|
348
|
+
config
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
// Find the tool message - it should be a denial
|
|
352
|
+
const toolMessages = result.messages.filter(
|
|
353
|
+
(m: BaseMessage) => m._getType() === 'tool'
|
|
354
|
+
);
|
|
355
|
+
expect(toolMessages.length).toBeGreaterThan(0);
|
|
356
|
+
|
|
357
|
+
const denialMsg = toolMessages.find((m: BaseMessage) =>
|
|
358
|
+
m.content.toString().includes('denied')
|
|
359
|
+
);
|
|
360
|
+
expect(denialMsg).toBeDefined();
|
|
361
|
+
expect(denialMsg!.content.toString()).toContain('Not now');
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test('custom function override evaluates args to determine approval', async () => {
|
|
365
|
+
// Only require approval for emails to external domains
|
|
366
|
+
const approvalEvents: t.ToolApprovalEvent[] = [];
|
|
367
|
+
|
|
368
|
+
const { compiled, config } = createTestGraph(
|
|
369
|
+
[sendEmailTool as t.GenericTool],
|
|
370
|
+
{
|
|
371
|
+
defaultPolicy: 'never',
|
|
372
|
+
executionContext: 'interactive',
|
|
373
|
+
overrides: {
|
|
374
|
+
send_email: (args: Record<string, unknown>) => {
|
|
375
|
+
const to = (args.to as string) ?? '';
|
|
376
|
+
return !to.endsWith('@internal.com');
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
[
|
|
381
|
+
// Tool call to internal domain (should NOT require approval)
|
|
382
|
+
[
|
|
383
|
+
JSON.stringify({
|
|
384
|
+
name: 'send_email',
|
|
385
|
+
args: { to: 'colleague@internal.com', subject: 'Internal' },
|
|
386
|
+
id: 'call_func_1',
|
|
387
|
+
type: 'tool_call',
|
|
388
|
+
}),
|
|
389
|
+
],
|
|
390
|
+
// Final text
|
|
391
|
+
['Email sent to internal address.'],
|
|
392
|
+
],
|
|
393
|
+
(event) => {
|
|
394
|
+
approvalEvents.push(event);
|
|
395
|
+
event.resolve({ approved: true });
|
|
396
|
+
}
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
const result = await compiled.invoke(
|
|
400
|
+
{ messages: [new HumanMessage('Send internal email')] },
|
|
401
|
+
config
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
// No approval events should have been dispatched (internal domain)
|
|
405
|
+
expect(approvalEvents).toHaveLength(0);
|
|
406
|
+
|
|
407
|
+
// Email should have been sent directly
|
|
408
|
+
const toolMessages = result.messages.filter(
|
|
409
|
+
(m: BaseMessage) => m._getType() === 'tool'
|
|
410
|
+
);
|
|
411
|
+
expect(toolMessages.length).toBeGreaterThan(0);
|
|
412
|
+
expect(toolMessages[0].content.toString()).toContain('colleague@internal.com');
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test('custom function override triggers approval for external email', async () => {
|
|
416
|
+
const approvalEvents: t.ToolApprovalEvent[] = [];
|
|
417
|
+
|
|
418
|
+
const { compiled, config } = createTestGraph(
|
|
419
|
+
[sendEmailTool as t.GenericTool],
|
|
420
|
+
{
|
|
421
|
+
defaultPolicy: 'never',
|
|
422
|
+
executionContext: 'interactive',
|
|
423
|
+
overrides: {
|
|
424
|
+
send_email: (args: Record<string, unknown>) => {
|
|
425
|
+
const to = (args.to as string) ?? '';
|
|
426
|
+
return !to.endsWith('@internal.com');
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
[
|
|
431
|
+
// Tool call to external domain (SHOULD require approval)
|
|
432
|
+
[
|
|
433
|
+
JSON.stringify({
|
|
434
|
+
name: 'send_email',
|
|
435
|
+
args: { to: 'external@gmail.com', subject: 'External' },
|
|
436
|
+
id: 'call_func_2',
|
|
437
|
+
type: 'tool_call',
|
|
438
|
+
}),
|
|
439
|
+
],
|
|
440
|
+
// After approval
|
|
441
|
+
['Email sent to external address.'],
|
|
442
|
+
],
|
|
443
|
+
(event) => {
|
|
444
|
+
approvalEvents.push(event);
|
|
445
|
+
event.resolve({ approved: true });
|
|
446
|
+
}
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
const result = await compiled.invoke(
|
|
450
|
+
{ messages: [new HumanMessage('Send external email')] },
|
|
451
|
+
config
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
// Approval event should have been dispatched (external domain)
|
|
455
|
+
expect(approvalEvents).toHaveLength(1);
|
|
456
|
+
expect(approvalEvents[0].toolName).toBe('send_email');
|
|
457
|
+
expect(approvalEvents[0].toolArgs.to).toBe('external@gmail.com');
|
|
458
|
+
|
|
459
|
+
// Email should have been sent after approval
|
|
460
|
+
const lastMessage = result.messages[result.messages.length - 1];
|
|
461
|
+
expect(lastMessage.content).toContain('Email sent to external address');
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test('modified args are used when human edits the tool call', async () => {
|
|
465
|
+
const { compiled, config } = createTestGraph(
|
|
466
|
+
[sendEmailTool as t.GenericTool],
|
|
467
|
+
{
|
|
468
|
+
defaultPolicy: 'always',
|
|
469
|
+
executionContext: 'interactive',
|
|
470
|
+
},
|
|
471
|
+
[
|
|
472
|
+
// Model requests send email with original args
|
|
473
|
+
[
|
|
474
|
+
JSON.stringify({
|
|
475
|
+
name: 'send_email',
|
|
476
|
+
args: { to: 'wrong@example.com', subject: 'Original Subject' },
|
|
477
|
+
id: 'call_edit_1',
|
|
478
|
+
type: 'tool_call',
|
|
479
|
+
}),
|
|
480
|
+
],
|
|
481
|
+
// After tool execution with modified args
|
|
482
|
+
['Email sent with corrected details.'],
|
|
483
|
+
],
|
|
484
|
+
(event) => {
|
|
485
|
+
// Approve with modified args
|
|
486
|
+
event.resolve({
|
|
487
|
+
approved: true,
|
|
488
|
+
modifiedArgs: {
|
|
489
|
+
to: 'correct@example.com',
|
|
490
|
+
subject: 'Corrected Subject',
|
|
491
|
+
},
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
const result = await compiled.invoke(
|
|
497
|
+
{ messages: [new HumanMessage('Send email')] },
|
|
498
|
+
config
|
|
499
|
+
);
|
|
500
|
+
|
|
501
|
+
// Find the tool message to verify modified args were used
|
|
502
|
+
const toolMessages = result.messages.filter(
|
|
503
|
+
(m: BaseMessage) => m._getType() === 'tool'
|
|
504
|
+
);
|
|
505
|
+
const sentMsg = toolMessages.find(
|
|
506
|
+
(m: BaseMessage) =>
|
|
507
|
+
(m as ToolMessage).name === 'send_email' &&
|
|
508
|
+
m.content.toString().includes('correct@example.com')
|
|
509
|
+
);
|
|
510
|
+
expect(sentMsg).toBeDefined();
|
|
511
|
+
expect(sentMsg!.content.toString()).toContain('Corrected Subject');
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test('scheduled context auto-approves without dispatching events', async () => {
|
|
515
|
+
const approvalEvents: t.ToolApprovalEvent[] = [];
|
|
516
|
+
|
|
517
|
+
const { compiled, config } = createTestGraph(
|
|
518
|
+
[sendEmailTool as t.GenericTool],
|
|
519
|
+
{
|
|
520
|
+
defaultPolicy: 'always',
|
|
521
|
+
executionContext: 'scheduled', // Scheduled = auto-approve
|
|
522
|
+
},
|
|
523
|
+
[
|
|
524
|
+
[
|
|
525
|
+
JSON.stringify({
|
|
526
|
+
name: 'send_email',
|
|
527
|
+
args: { to: 'user@example.com', subject: 'Scheduled Email' },
|
|
528
|
+
id: 'call_sched_1',
|
|
529
|
+
type: 'tool_call',
|
|
530
|
+
}),
|
|
531
|
+
],
|
|
532
|
+
['Scheduled email sent.'],
|
|
533
|
+
],
|
|
534
|
+
(event) => {
|
|
535
|
+
approvalEvents.push(event);
|
|
536
|
+
event.resolve({ approved: true });
|
|
537
|
+
}
|
|
538
|
+
);
|
|
539
|
+
|
|
540
|
+
const result = await compiled.invoke(
|
|
541
|
+
{ messages: [new HumanMessage('Send scheduled email')] },
|
|
542
|
+
config
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
// No approval events should be dispatched for scheduled execution
|
|
546
|
+
expect(approvalEvents).toHaveLength(0);
|
|
547
|
+
|
|
548
|
+
// Email should have been sent directly
|
|
549
|
+
const toolMessages = result.messages.filter(
|
|
550
|
+
(m: BaseMessage) => m._getType() === 'tool'
|
|
551
|
+
);
|
|
552
|
+
expect(toolMessages.length).toBeGreaterThan(0);
|
|
553
|
+
expect(toolMessages[0].content.toString()).toContain('user@example.com');
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test('multiple tool calls each get individual approval', async () => {
|
|
557
|
+
const approvalEvents: t.ToolApprovalEvent[] = [];
|
|
558
|
+
|
|
559
|
+
const toolNode = new ToolNode({
|
|
560
|
+
tools: [echoTool, sendEmailTool] as t.GenericTool[],
|
|
561
|
+
toolApprovalConfig: {
|
|
562
|
+
defaultPolicy: 'always',
|
|
563
|
+
executionContext: 'interactive',
|
|
564
|
+
},
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
const StateAnnotation = Annotation.Root({
|
|
568
|
+
messages: Annotation<BaseMessage[]>({
|
|
569
|
+
reducer: messagesStateReducer,
|
|
570
|
+
default: () => [],
|
|
571
|
+
}),
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
let callCount = 0;
|
|
575
|
+
const callModel = async (_state: { messages: BaseMessage[] }) => {
|
|
576
|
+
callCount++;
|
|
577
|
+
if (callCount === 1) {
|
|
578
|
+
return {
|
|
579
|
+
messages: [
|
|
580
|
+
new AIMessage({
|
|
581
|
+
content: '',
|
|
582
|
+
tool_calls: [
|
|
583
|
+
{ name: 'echo', args: { message: 'test' }, id: 'c1', type: 'tool_call' as const },
|
|
584
|
+
{ name: 'send_email', args: { to: 'a@b.com', subject: 'Hi' }, id: 'c2', type: 'tool_call' as const },
|
|
585
|
+
],
|
|
586
|
+
}),
|
|
587
|
+
],
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
return { messages: [new AIMessage({ content: 'Both done.' })] };
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
const routeMessage = (state: typeof StateAnnotation.State) =>
|
|
594
|
+
toolsCondition(state, 'tools');
|
|
595
|
+
|
|
596
|
+
const workflow = new StateGraph(StateAnnotation)
|
|
597
|
+
.addNode('agent', callModel)
|
|
598
|
+
.addNode('tools', toolNode)
|
|
599
|
+
.addEdge(START, 'agent')
|
|
600
|
+
.addConditionalEdges('agent', routeMessage)
|
|
601
|
+
.addEdge('tools', 'agent');
|
|
602
|
+
|
|
603
|
+
const compiled = workflow.compile();
|
|
604
|
+
const config: Partial<RunnableConfig> & { version: 'v1' | 'v2' } = {
|
|
605
|
+
version: 'v2',
|
|
606
|
+
callbacks: [
|
|
607
|
+
{
|
|
608
|
+
handleCustomEvent: async (
|
|
609
|
+
eventName: string,
|
|
610
|
+
data: unknown
|
|
611
|
+
): Promise<void> => {
|
|
612
|
+
if (eventName === GraphEvents.ON_TOOL_APPROVAL_REQUIRED) {
|
|
613
|
+
const event = data as t.ToolApprovalEvent;
|
|
614
|
+
approvalEvents.push(event);
|
|
615
|
+
event.resolve({ approved: true });
|
|
616
|
+
}
|
|
617
|
+
},
|
|
618
|
+
},
|
|
619
|
+
],
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
const result = await compiled.invoke(
|
|
623
|
+
{ messages: [new HumanMessage('Do both')] },
|
|
624
|
+
config
|
|
625
|
+
);
|
|
626
|
+
|
|
627
|
+
// Both tools should have triggered approval events
|
|
628
|
+
expect(approvalEvents).toHaveLength(2);
|
|
629
|
+
expect(approvalEvents.map((e) => e.toolName).sort()).toEqual(['echo', 'send_email']);
|
|
630
|
+
|
|
631
|
+
// Both tools should have executed
|
|
632
|
+
const toolMessages = result.messages.filter(
|
|
633
|
+
(m: BaseMessage) => m._getType() === 'tool'
|
|
634
|
+
);
|
|
635
|
+
expect(toolMessages).toHaveLength(2);
|
|
636
|
+
});
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
// ============================================================================
|
|
640
|
+
// Type Tests: ToolApprovalConfig shape validation
|
|
641
|
+
// ============================================================================
|
|
642
|
+
|
|
643
|
+
describe('HITL Types', () => {
|
|
644
|
+
test('ToolApprovalConfig accepts valid configurations', () => {
|
|
645
|
+
const config1: t.ToolApprovalConfig = {
|
|
646
|
+
defaultPolicy: 'always',
|
|
647
|
+
executionContext: 'interactive',
|
|
648
|
+
};
|
|
649
|
+
expect(config1.defaultPolicy).toBe('always');
|
|
650
|
+
|
|
651
|
+
const config2: t.ToolApprovalConfig = {
|
|
652
|
+
defaultPolicy: 'never',
|
|
653
|
+
executionContext: 'scheduled',
|
|
654
|
+
};
|
|
655
|
+
expect(config2.executionContext).toBe('scheduled');
|
|
656
|
+
|
|
657
|
+
const config3: t.ToolApprovalConfig = {
|
|
658
|
+
defaultPolicy: 'always',
|
|
659
|
+
executionContext: 'interactive',
|
|
660
|
+
overrides: {
|
|
661
|
+
search: 'never',
|
|
662
|
+
send_email: 'always',
|
|
663
|
+
custom_tool: (args) => (args.dangerous as boolean) === true,
|
|
664
|
+
},
|
|
665
|
+
};
|
|
666
|
+
expect(config3.overrides).toBeDefined();
|
|
667
|
+
expect(config3.overrides!.search).toBe('never');
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
test('ToolApprovalRequest has correct shape', () => {
|
|
671
|
+
const request: t.ToolApprovalRequest = {
|
|
672
|
+
type: 'tool_approval_required',
|
|
673
|
+
toolCallId: 'call_123',
|
|
674
|
+
toolName: 'send_email',
|
|
675
|
+
toolArgs: { to: 'user@example.com' },
|
|
676
|
+
agentId: 'agent-1',
|
|
677
|
+
description: 'Send email to user',
|
|
678
|
+
};
|
|
679
|
+
expect(request.type).toBe('tool_approval_required');
|
|
680
|
+
expect(request.toolName).toBe('send_email');
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
test('ToolApprovalResponse covers all response types', () => {
|
|
684
|
+
const approved: t.ToolApprovalResponse = { approved: true };
|
|
685
|
+
expect(approved.approved).toBe(true);
|
|
686
|
+
|
|
687
|
+
const denied: t.ToolApprovalResponse = {
|
|
688
|
+
approved: false,
|
|
689
|
+
feedback: 'Too risky',
|
|
690
|
+
};
|
|
691
|
+
expect(denied.feedback).toBe('Too risky');
|
|
692
|
+
|
|
693
|
+
const edited: t.ToolApprovalResponse = {
|
|
694
|
+
approved: true,
|
|
695
|
+
modifiedArgs: { to: 'corrected@example.com' },
|
|
696
|
+
};
|
|
697
|
+
expect(edited.modifiedArgs).toBeDefined();
|
|
698
|
+
});
|
|
699
|
+
});
|