@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.
Files changed (58) 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 +87 -1
  4. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  5. package/dist/cjs/llm/bedrock/index.cjs +14 -0
  6. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  7. package/dist/cjs/main.cjs +3 -0
  8. package/dist/cjs/main.cjs.map +1 -1
  9. package/dist/cjs/nodes/ApprovalGateNode.cjs +75 -0
  10. package/dist/cjs/nodes/ApprovalGateNode.cjs.map +1 -0
  11. package/dist/cjs/run.cjs +45 -0
  12. package/dist/cjs/run.cjs.map +1 -1
  13. package/dist/cjs/tools/ToolNode.cjs +21 -18
  14. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  15. package/dist/cjs/types/graph.cjs.map +1 -1
  16. package/dist/cjs/utils/run.cjs +6 -1
  17. package/dist/cjs/utils/run.cjs.map +1 -1
  18. package/dist/esm/common/enum.mjs +2 -0
  19. package/dist/esm/common/enum.mjs.map +1 -1
  20. package/dist/esm/graphs/MultiAgentGraph.mjs +87 -1
  21. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  22. package/dist/esm/llm/bedrock/index.mjs +14 -0
  23. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  24. package/dist/esm/main.mjs +1 -0
  25. package/dist/esm/main.mjs.map +1 -1
  26. package/dist/esm/nodes/ApprovalGateNode.mjs +72 -0
  27. package/dist/esm/nodes/ApprovalGateNode.mjs.map +1 -0
  28. package/dist/esm/run.mjs +45 -0
  29. package/dist/esm/run.mjs.map +1 -1
  30. package/dist/esm/tools/ToolNode.mjs +22 -19
  31. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  32. package/dist/esm/types/graph.mjs.map +1 -1
  33. package/dist/esm/utils/run.mjs +6 -1
  34. package/dist/esm/utils/run.mjs.map +1 -1
  35. package/dist/types/common/enum.d.ts +2 -0
  36. package/dist/types/index.d.ts +1 -0
  37. package/dist/types/nodes/ApprovalGateNode.d.ts +49 -0
  38. package/dist/types/nodes/index.d.ts +2 -0
  39. package/dist/types/run.d.ts +25 -1
  40. package/dist/types/tools/ToolNode.d.ts +7 -5
  41. package/dist/types/types/graph.d.ts +31 -0
  42. package/dist/types/types/tools.d.ts +7 -9
  43. package/package.json +1 -1
  44. package/src/common/enum.ts +2 -0
  45. package/src/graphs/MultiAgentGraph.ts +108 -1
  46. package/src/index.ts +3 -0
  47. package/src/llm/bedrock/index.ts +17 -0
  48. package/src/nodes/ApprovalGateNode.ts +117 -0
  49. package/src/nodes/__tests__/ApprovalGateNode.test.ts +206 -0
  50. package/src/nodes/index.ts +5 -0
  51. package/src/run.ts +55 -1
  52. package/src/specs/agent-handoffs-bedrock.integration.test.ts +2 -2
  53. package/src/specs/agent-handoffs.test.ts +153 -6
  54. package/src/tools/ToolNode.ts +28 -23
  55. package/src/tools/__tests__/ToolApproval.test.ts +162 -325
  56. package/src/types/graph.ts +32 -0
  57. package/src/types/tools.ts +7 -9
  58. package/src/utils/run.ts +9 -1
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';
@@ -250,8 +251,16 @@ export class Run<_T extends t.BaseGraphState> {
250
251
  };
251
252
  }
252
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
+ */
253
262
  async processStream(
254
- inputs: t.IState,
263
+ inputs: t.IState | Command,
255
264
  config: Partial<RunnableConfig> & { version: 'v1' | 'v2'; run_id?: string },
256
265
  streamOptions?: t.EventStreamOptions
257
266
  ): Promise<MessageContentComplex[] | undefined> {
@@ -409,6 +418,51 @@ export class Run<_T extends t.BaseGraphState> {
409
418
  }) as t.SystemCallbacks[K];
410
419
  }
411
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
+
412
466
  getCallbacks(clientCallbacks: t.ClientCallbacks): t.SystemCallbacks {
413
467
  return {
414
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({