@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
@@ -1,4 +1,5 @@
1
1
  import './instrumentation';
2
+ import { Command } from '@langchain/langgraph';
2
3
  import type { MessageContentComplex, BaseMessage } from '@langchain/core/messages';
3
4
  import type { RunnableConfig } from '@langchain/core/runnables';
4
5
  import type * as t from '@/types';
@@ -41,11 +42,34 @@ export declare class Run<_T extends t.BaseGraphState> {
41
42
  * and processes them through our handler registry instead of EventStreamCallbackHandler
42
43
  */
43
44
  private createCustomEventCallback;
44
- processStream(inputs: t.IState, config: Partial<RunnableConfig> & {
45
+ /**
46
+ * Processes the graph stream for a given input.
47
+ *
48
+ * @param inputs - Either the initial state (IState) for a new run, or a
49
+ * Command (e.g., `new Command({ resume: ... })`) to resume from an interrupt.
50
+ * @param config - Runnable config with version and optional run_id.
51
+ * @param streamOptions - Optional stream event callbacks and options.
52
+ */
53
+ processStream(inputs: t.IState | Command, config: Partial<RunnableConfig> & {
45
54
  version: 'v1' | 'v2';
46
55
  run_id?: string;
47
56
  }, streamOptions?: t.EventStreamOptions): Promise<MessageContentComplex[] | undefined>;
48
57
  private createSystemCallback;
58
+ /**
59
+ * Checks whether the graph was interrupted (e.g., by HITL tool approval).
60
+ * Call after processStream() returns to determine if the graph is waiting
61
+ * for a resume via Command({ resume }).
62
+ *
63
+ * Requires a checkpointer to be configured — without one, interrupt state
64
+ * is not persisted and this always returns false.
65
+ */
66
+ hasInterrupts(config: Partial<RunnableConfig>): Promise<boolean>;
67
+ /**
68
+ * Returns the interrupt values from the graph state.
69
+ * Each interrupt's `value` contains the data passed to `interrupt()` by the node
70
+ * (e.g., a ToolApprovalRequest for HITL).
71
+ */
72
+ getInterruptValues(config: Partial<RunnableConfig>): Promise<unknown[]>;
49
73
  getCallbacks(clientCallbacks: t.ClientCallbacks): t.SystemCallbacks;
50
74
  generateTitle({ provider, inputText, contentParts, titlePrompt, clientOptions, chainOptions, skipLanguage, titleMethod, titlePromptTemplate, }: t.RunTitleOptions): Promise<{
51
75
  language?: string;
@@ -48,12 +48,14 @@ export declare class ToolNode<T = any> extends RunnableCallable<T, T> {
48
48
  */
49
49
  private requiresApproval;
50
50
  /**
51
- * Requests human approval for a tool call via event dispatch.
52
- * Dispatches an ON_TOOL_APPROVAL_REQUIRED event and waits for the host
53
- * to resolve the promise with an approval response.
51
+ * Requests human approval for a tool call using LangGraph's native interrupt().
54
52
  *
55
- * This uses the same pattern as ON_TOOL_EXECUTE: a promise-based event
56
- * dispatch where the host calls resolve/reject when the human responds.
53
+ * Flow:
54
+ * 1. Dispatches ON_TOOL_APPROVAL_REQUIRED notification (no resolve/reject data only)
55
+ * so the host can persist the request and send UI events.
56
+ * 2. Calls interrupt() which checkpoints graph state and pauses execution.
57
+ * 3. When the host resumes via Command({ resume: ToolApprovalResponse }),
58
+ * interrupt() returns the response synchronously.
57
59
  *
58
60
  * @param call - The tool call requiring approval
59
61
  * @param config - The runnable config for event dispatch
@@ -221,6 +221,31 @@ export type StandardGraphInput = {
221
221
  tokenCounter?: TokenCounter;
222
222
  indexTokenCountMap?: Record<string, number>;
223
223
  };
224
+ /**
225
+ * Configuration for an approval gate placed on a sequence edge.
226
+ * When present, the graph inserts an approval gate node between the source
227
+ * and destination agents. The gate ALWAYS fires (regardless of ExecutionContext)
228
+ * and calls interrupt() to pause the graph for human approval.
229
+ */
230
+ export type ApprovalGateConfig = {
231
+ /** Unique identifier for this gate (used as node ID suffix) */
232
+ gateId: string;
233
+ /**
234
+ * Approval channel — where the approval UI is rendered.
235
+ * - 'chat': SSE-based chat UI (default)
236
+ * - 'outlook': MS Graph Actionable Messages
237
+ * - 'telegram': Telegram Bot inline keyboard
238
+ */
239
+ channel?: 'chat' | 'outlook' | 'telegram';
240
+ /** Optional human-readable prompt shown to the approver */
241
+ prompt?: string;
242
+ /** Optional approver identifier (e.g., email, user ID) */
243
+ approver?: string;
244
+ /** Timeout in ms before the gate auto-expires (default: 5 minutes) */
245
+ timeoutMs?: number;
246
+ /** What to do on denial: 'stop' ends the graph, 'skip' skips the destination agent */
247
+ onDeny?: 'stop' | 'skip';
248
+ };
224
249
  export type GraphEdge = {
225
250
  /** Agent ID, use a list for multiple sources */
226
251
  from: string | string[];
@@ -263,6 +288,12 @@ export type GraphEdge = {
263
288
  * Defaults to DEFAULT_HANDOFF_MAX_RESULT_CHARS (32768 chars, ~8192 tokens).
264
289
  */
265
290
  maxResultChars?: number;
291
+ /**
292
+ * Approval gate configuration for sequence edges.
293
+ * When set, inserts an approval gate node between source and destination.
294
+ * The gate ALWAYS fires regardless of ExecutionContext (unlike tool approval).
295
+ */
296
+ approvalGate?: ApprovalGateConfig;
266
297
  };
267
298
  export type MultiAgentGraphInput = StandardGraphInput & {
268
299
  edges: GraphEdge[];
@@ -271,16 +271,14 @@ export type ToolApprovalResponse = {
271
271
  modifiedArgs?: Record<string, unknown>;
272
272
  };
273
273
  /**
274
- * Event payload dispatched via ON_TOOL_APPROVAL_REQUIRED.
275
- * Extends ToolApprovalRequest with promise resolve/reject for async flow.
276
- * The host handles the event by showing UI and calling resolve/reject.
274
+ * Notification payload dispatched via ON_TOOL_APPROVAL_REQUIRED.
275
+ * Data-only no resolve/reject callbacks. The approval mechanism is handled
276
+ * by LangGraph's native interrupt()/Command({ resume }) pattern.
277
+ *
278
+ * The host persists this data (e.g., in MongoDB) and sends UI events.
279
+ * The actual approval response comes back via Command({ resume: ToolApprovalResponse }).
277
280
  */
278
- export type ToolApprovalEvent = ToolApprovalRequest & {
279
- /** Promise resolver - host calls this with the approval response */
280
- resolve: (response: ToolApprovalResponse) => void;
281
- /** Promise rejector - host calls this on fatal error */
282
- reject: (error: Error) => void;
283
- };
281
+ export type ToolApprovalNotification = ToolApprovalRequest;
284
282
  /** Search mode: code_interpreter uses external sandbox, local uses safe substring matching */
285
283
  export type ToolSearchMode = 'code_interpreter' | 'local';
286
284
  /** Format for MCP tool names in search results */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@illuma-ai/agents",
3
- "version": "1.1.19",
3
+ "version": "1.1.21",
4
4
  "main": "./dist/cjs/main.cjs",
5
5
  "module": "./dist/esm/main.mjs",
6
6
  "types": "./dist/types/index.d.ts",
@@ -31,6 +31,8 @@ export enum GraphEvents {
31
31
  ON_TOOL_APPROVAL_REQUIRED = 'on_tool_approval_required',
32
32
  /** [Custom] Agent transition event — dispatched when control passes between agents */
33
33
  ON_AGENT_TRANSITION = 'on_agent_transition',
34
+ /** [Custom] Approval gate interrupt — dispatched by ApprovalGateNode before interrupt() */
35
+ ON_APPROVAL_GATE = 'on_approval_gate',
34
36
 
35
37
  /* Official Events */
36
38
 
@@ -29,6 +29,10 @@ import {
29
29
  DEFAULT_HANDOFF_MAX_RESULT_CHARS,
30
30
  } from '@/common';
31
31
  import { safeDispatchCustomEvent } from '@/utils/events';
32
+ import {
33
+ createApprovalGateNode,
34
+ getApprovalGateNodeId,
35
+ } from '@/nodes/ApprovalGateNode';
32
36
 
33
37
  /** Pattern to extract instructions from transfer ToolMessage content */
34
38
  const TRANSFER_INSTRUCTIONS_PATTERN = /(?:Instructions?|Context):\s*(.+)/is;
@@ -1182,7 +1186,39 @@ export class MultiAgentGraph extends StandardGraph {
1182
1186
 
1183
1187
  const builder = new StateGraph(StateAnnotation);
1184
1188
 
1185
- // Add all agents as complete subgraphs
1189
+ /**
1190
+ * Identify agents that are ONLY handoff destinations (not transfer/sequence
1191
+ * destinations and not starting nodes). These agents are invoked inline via
1192
+ * subgraph.invoke() inside handoff tools — they must NOT be added as
1193
+ * top-level nodes in the parent graph because LangGraph validates that all
1194
+ * nodes are reachable from START via edges.
1195
+ */
1196
+ const handoffOnlyDestinations = new Set<string>();
1197
+ const transferOrSequenceDestinations = new Set<string>();
1198
+
1199
+ for (const edge of this.handoffEdges) {
1200
+ const dests = Array.isArray(edge.to) ? edge.to : [edge.to];
1201
+ dests.forEach((d) => handoffOnlyDestinations.add(d));
1202
+ }
1203
+ for (const edge of [...this.transferEdges, ...this.sequenceEdges]) {
1204
+ const dests = Array.isArray(edge.to) ? edge.to : [edge.to];
1205
+ dests.forEach((d) => transferOrSequenceDestinations.add(d));
1206
+ }
1207
+ // Remove agents that are also transfer/sequence destinations or starting nodes
1208
+ for (const d of transferOrSequenceDestinations) {
1209
+ handoffOnlyDestinations.delete(d);
1210
+ }
1211
+ for (const startNode of this.startingNodes) {
1212
+ handoffOnlyDestinations.delete(startNode);
1213
+ }
1214
+
1215
+ if (handoffOnlyDestinations.size > 0) {
1216
+ console.debug(
1217
+ `[MultiAgentGraph] Handoff-only children (subgraph only, no top-level node): [${Array.from(handoffOnlyDestinations).join(', ')}]`
1218
+ );
1219
+ }
1220
+
1221
+ // Add agents as nodes — skip handoff-only children (they exist as subgraphs only)
1186
1222
  for (const [agentId] of this.agentContexts) {
1187
1223
  // Get all possible destinations for this agent
1188
1224
  const transferDestinations = new Set<string>();
@@ -1226,6 +1262,15 @@ export class MultiAgentGraph extends StandardGraph {
1226
1262
  /** Register subgraph for handoff tools (lazy reference resolution) */
1227
1263
  this.subgraphRegistry.set(agentId, agentSubgraph);
1228
1264
 
1265
+ /**
1266
+ * Handoff-only children are invoked inline via subgraph.invoke() — they
1267
+ * don't need a top-level node. Adding them would cause LangGraph to reject
1268
+ * the graph because no edge routes to them (UNREACHABLE_NODE).
1269
+ */
1270
+ if (handoffOnlyDestinations.has(agentId)) {
1271
+ continue;
1272
+ }
1273
+
1229
1274
  /** Wrapper function that handles agentMessages channel, handoff reception, and conditional routing */
1230
1275
  const agentWrapper = async (
1231
1276
  state: t.MultiAgentGraphState,
@@ -1645,13 +1690,75 @@ export class MultiAgentGraph extends StandardGraph {
1645
1690
  }
1646
1691
  }
1647
1692
 
1693
+ /**
1694
+ * Add approval gate nodes for sequence edges with approvalGate config.
1695
+ * Gates are inserted between source and destination agents.
1696
+ * They ALWAYS fire regardless of ExecutionContext.
1697
+ */
1698
+ const gatedEdges = new Set<t.GraphEdge>();
1699
+
1700
+ for (const edge of this.sequenceEdges) {
1701
+ if (!edge.approvalGate) {
1702
+ continue;
1703
+ }
1704
+
1705
+ const sources = Array.isArray(edge.from) ? edge.from : [edge.from];
1706
+ const destinations = Array.isArray(edge.to) ? edge.to : [edge.to];
1707
+
1708
+ for (const source of sources) {
1709
+ for (const dest of destinations) {
1710
+ const gateNodeId = getApprovalGateNodeId(edge.approvalGate.gateId);
1711
+ const onDeny = edge.approvalGate.onDeny ?? 'stop';
1712
+
1713
+ // Add the gate node
1714
+ const gateNode = createApprovalGateNode(
1715
+ edge.approvalGate,
1716
+ source,
1717
+ dest,
1718
+ );
1719
+
1720
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
1721
+ /** @ts-ignore */
1722
+ builder.addNode(gateNodeId, gateNode);
1723
+
1724
+ // Wire: source → gate
1725
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
1726
+ /** @ts-ignore */
1727
+ builder.addEdge(source, gateNodeId);
1728
+
1729
+ // Wire: gate → destination (always, since approval is handled
1730
+ // by the interrupt/resume mechanism — if denied, the host
1731
+ // can choose not to resume, or resume with approved=false
1732
+ // and the gate returns empty state)
1733
+ if (onDeny === 'skip') {
1734
+ // Conditional edge: approved → destination, denied → END
1735
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
1736
+ /** @ts-ignore */
1737
+ builder.addEdge(gateNodeId, dest);
1738
+ } else {
1739
+ // Direct edge to destination — denial stops via non-resume or
1740
+ // the host terminates the graph
1741
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
1742
+ /** @ts-ignore */
1743
+ builder.addEdge(gateNodeId, dest);
1744
+ }
1745
+ }
1746
+ }
1747
+
1748
+ gatedEdges.add(edge);
1749
+ }
1750
+
1648
1751
  /**
1649
1752
  * Add sequence edges for automatic transitions
1650
1753
  * Group edges by destination to handle fan-in scenarios
1754
+ * Skip edges that have approval gates (already handled above)
1651
1755
  */
1652
1756
  const edgesByDestination = new Map<string, t.GraphEdge[]>();
1653
1757
 
1654
1758
  for (const edge of this.sequenceEdges) {
1759
+ if (gatedEdges.has(edge)) {
1760
+ continue;
1761
+ }
1655
1762
  const destinations = Array.isArray(edge.to) ? edge.to : [edge.to];
1656
1763
  for (const destination of destinations) {
1657
1764
  if (!edgesByDestination.has(destination)) {
package/src/index.ts CHANGED
@@ -29,6 +29,9 @@ export * from './tools/search';
29
29
  /* Schemas */
30
30
  export * from './schemas';
31
31
 
32
+ /* Nodes */
33
+ export * from './nodes';
34
+
32
35
  /* Misc. */
33
36
  export * from './common';
34
37
  export * from './utils';
@@ -167,6 +167,23 @@ export class IllumaBedrockConverse extends ChatBedrockConverse {
167
167
  const params = super.invocationParams(options);
168
168
 
169
169
  // Add cachePoint to tools array if promptCache is enabled and tools exist
170
+ /**
171
+ * Bedrock requires all toolSpec.description fields to be non-empty strings.
172
+ * Some tools (e.g., MCP-sourced or dynamically created) may have empty or
173
+ * missing descriptions. Patch them here to avoid Bedrock validation errors.
174
+ */
175
+ if (
176
+ params.toolConfig?.tools &&
177
+ Array.isArray(params.toolConfig.tools)
178
+ ) {
179
+ for (const t of params.toolConfig.tools) {
180
+ const spec = (t as { toolSpec?: { description?: string } }).toolSpec;
181
+ if (spec && (!spec.description || spec.description === '')) {
182
+ spec.description = spec.description || `Tool: ${(spec as { name?: string }).name ?? 'unknown'}`;
183
+ }
184
+ }
185
+ }
186
+
170
187
  // Only Claude models support cachePoint - check model name
171
188
  const modelId = this.model.toLowerCase();
172
189
  const isClaudeModel =
@@ -0,0 +1,117 @@
1
+ import { interrupt } from '@langchain/langgraph';
2
+ import type { RunnableConfig } from '@langchain/core/runnables';
3
+ import type { ApprovalGateConfig, BaseGraphState } from '@/types/graph';
4
+ import type { ToolApprovalResponse } from '@/types/tools';
5
+ import { GraphEvents } from '@/common';
6
+ import { safeDispatchCustomEvent } from '@/utils/events';
7
+
8
+ /**
9
+ * Interrupt payload for approval gate nodes.
10
+ * Passed to interrupt() and persisted in the checkpoint.
11
+ */
12
+ export interface ApprovalGateInterrupt {
13
+ /** Discriminator to distinguish from tool approval interrupts */
14
+ type: 'approval_gate';
15
+ /** Unique gate identifier */
16
+ gateId: string;
17
+ /** Approval channel (chat, outlook, telegram) */
18
+ channel: string;
19
+ /** Human-readable prompt for the approver */
20
+ prompt?: string;
21
+ /** Approver identifier */
22
+ approver?: string;
23
+ /** Timeout in ms */
24
+ timeoutMs?: number;
25
+ /** Source agent ID (who just finished) */
26
+ sourceAgentId?: string;
27
+ /** Destination agent ID (who will run next if approved) */
28
+ destinationAgentId?: string;
29
+ }
30
+
31
+ /**
32
+ * Creates a graph node function that acts as an approval gate.
33
+ *
34
+ * Unlike tool approval (which respects ExecutionContext and can be auto-approved
35
+ * in scheduled/handoff modes), approval gates ALWAYS fire. They are placed by
36
+ * the builder between agents in a sequence and represent explicit human
37
+ * checkpoints that cannot be bypassed.
38
+ *
39
+ * Flow:
40
+ * 1. Dispatch ON_APPROVAL_GATE notification (for SSE/persistence)
41
+ * 2. Call interrupt() — graph pauses, state is checkpointed
42
+ * 3. On resume, interrupt() returns the ToolApprovalResponse
43
+ * 4. If approved, pass state through (next agent runs)
44
+ * 5. If denied, return state as-is (routing handled by conditional edge)
45
+ *
46
+ * @param config - The approval gate configuration from the edge definition
47
+ * @param sourceAgentId - The agent that precedes this gate
48
+ * @param destinationAgentId - The agent that follows this gate
49
+ */
50
+ export function createApprovalGateNode(
51
+ config: ApprovalGateConfig,
52
+ sourceAgentId: string,
53
+ destinationAgentId: string,
54
+ ) {
55
+ const {
56
+ gateId,
57
+ channel = 'chat',
58
+ prompt,
59
+ approver,
60
+ timeoutMs,
61
+ } = config;
62
+
63
+ /**
64
+ * The gate node function. Receives the current graph state,
65
+ * dispatches a notification, calls interrupt(), and returns
66
+ * the state with an approval result annotation.
67
+ */
68
+ return async function approvalGateNode(
69
+ state: BaseGraphState,
70
+ runnableConfig?: RunnableConfig,
71
+ ): Promise<Partial<BaseGraphState>> {
72
+ const interruptPayload: ApprovalGateInterrupt = {
73
+ type: 'approval_gate',
74
+ gateId,
75
+ channel,
76
+ prompt,
77
+ approver,
78
+ timeoutMs,
79
+ sourceAgentId,
80
+ destinationAgentId,
81
+ };
82
+
83
+ // Dispatch notification event so the host can:
84
+ // 1. Persist the approval request to MongoDB
85
+ // 2. Route to the appropriate channel adapter
86
+ // 3. Emit SSE event for chat UI
87
+ safeDispatchCustomEvent(
88
+ GraphEvents.ON_APPROVAL_GATE,
89
+ interruptPayload,
90
+ runnableConfig,
91
+ );
92
+
93
+ // Pause the graph — state is checkpointed by the MongoDBSaver.
94
+ // On resume via Command({ resume: ToolApprovalResponse }), interrupt()
95
+ // returns the response value.
96
+ const response = interrupt(interruptPayload) as ToolApprovalResponse;
97
+
98
+ // Return empty state update — the graph structure (conditional edges)
99
+ // handles routing based on the approval result. We store the response
100
+ // in a message so downstream nodes can access it if needed.
101
+ if (response.approved) {
102
+ return {};
103
+ }
104
+
105
+ // On denial, we could add a system message noting the denial.
106
+ // The conditional edge after this node will route to END or skip.
107
+ return {};
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Node ID for an approval gate, derived from the gate configuration.
113
+ * Used by MultiAgentGraph when inserting gate nodes into the graph.
114
+ */
115
+ export function getApprovalGateNodeId(gateId: string): string {
116
+ return `approval_gate_${gateId}`;
117
+ }
@@ -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';