@illuma-ai/agents 1.1.18 → 1.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/cjs/common/enum.cjs +2 -0
  2. package/dist/cjs/common/enum.cjs.map +1 -1
  3. package/dist/cjs/graphs/MultiAgentGraph.cjs +133 -6
  4. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  5. package/dist/cjs/main.cjs +3 -0
  6. package/dist/cjs/main.cjs.map +1 -1
  7. package/dist/cjs/nodes/ApprovalGateNode.cjs +75 -0
  8. package/dist/cjs/nodes/ApprovalGateNode.cjs.map +1 -0
  9. package/dist/cjs/run.cjs +47 -1
  10. package/dist/cjs/run.cjs.map +1 -1
  11. package/dist/cjs/tools/ToolNode.cjs +21 -18
  12. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  13. package/dist/cjs/types/graph.cjs.map +1 -1
  14. package/dist/cjs/utils/run.cjs +6 -1
  15. package/dist/cjs/utils/run.cjs.map +1 -1
  16. package/dist/esm/common/enum.mjs +2 -0
  17. package/dist/esm/common/enum.mjs.map +1 -1
  18. package/dist/esm/graphs/MultiAgentGraph.mjs +133 -6
  19. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  20. package/dist/esm/main.mjs +1 -0
  21. package/dist/esm/main.mjs.map +1 -1
  22. package/dist/esm/nodes/ApprovalGateNode.mjs +72 -0
  23. package/dist/esm/nodes/ApprovalGateNode.mjs.map +1 -0
  24. package/dist/esm/run.mjs +47 -1
  25. package/dist/esm/run.mjs.map +1 -1
  26. package/dist/esm/tools/ToolNode.mjs +22 -19
  27. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  28. package/dist/esm/types/graph.mjs.map +1 -1
  29. package/dist/esm/utils/run.mjs +6 -1
  30. package/dist/esm/utils/run.mjs.map +1 -1
  31. package/dist/types/common/enum.d.ts +2 -0
  32. package/dist/types/graphs/MultiAgentGraph.d.ts +6 -0
  33. package/dist/types/index.d.ts +1 -0
  34. package/dist/types/nodes/ApprovalGateNode.d.ts +49 -0
  35. package/dist/types/nodes/index.d.ts +2 -0
  36. package/dist/types/run.d.ts +25 -1
  37. package/dist/types/tools/ToolNode.d.ts +7 -5
  38. package/dist/types/types/graph.d.ts +40 -0
  39. package/dist/types/types/run.d.ts +6 -0
  40. package/dist/types/types/tools.d.ts +7 -9
  41. package/package.json +1 -1
  42. package/src/common/enum.ts +2 -0
  43. package/src/graphs/MultiAgentGraph.ts +166 -6
  44. package/src/index.ts +3 -0
  45. package/src/nodes/ApprovalGateNode.ts +117 -0
  46. package/src/nodes/__tests__/ApprovalGateNode.test.ts +206 -0
  47. package/src/nodes/index.ts +5 -0
  48. package/src/run.ts +57 -2
  49. package/src/specs/agent-handoffs-bedrock.integration.test.ts +2 -2
  50. package/src/specs/agent-handoffs.test.ts +153 -6
  51. package/src/tools/ToolNode.ts +28 -23
  52. package/src/tools/__tests__/ToolApproval.test.ts +162 -325
  53. package/src/types/graph.ts +41 -0
  54. package/src/types/run.ts +6 -0
  55. package/src/types/tools.ts +7 -9
  56. package/src/utils/run.ts +9 -1
@@ -0,0 +1,2 @@
1
+ export { createApprovalGateNode, getApprovalGateNodeId, } from './ApprovalGateNode';
2
+ export type { ApprovalGateInterrupt } from './ApprovalGateNode';
@@ -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,9 +288,24 @@ 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[];
300
+ /**
301
+ * When set, the graph routes START to this agent instead of the default starting nodes.
302
+ * Used for multi-turn resumption: the caller reads `lastActiveAgentId` from the
303
+ * previous turn's metadata and passes it here so follow-up messages route to the
304
+ * agent that last handled the conversation.
305
+ *
306
+ * If the agent ID is invalid (not in the graph), falls back to default starting nodes.
307
+ */
308
+ resumeFromAgentId?: string;
269
309
  };
270
310
  /**
271
311
  * Structured output mode determines how the agent returns structured data.
@@ -91,6 +91,12 @@ export type MultiAgentGraphConfig = {
91
91
  compileOptions?: g.CompileOptions;
92
92
  agents: g.AgentInputs[];
93
93
  edges: g.GraphEdge[];
94
+ /**
95
+ * Resume from a specific agent on the next turn.
96
+ * When set, START routes directly to this agent instead of default starting nodes.
97
+ * @see MultiAgentGraphInput.resumeFromAgentId
98
+ */
99
+ resumeFromAgentId?: string;
94
100
  };
95
101
  export type StandardGraphConfig = Omit<MultiAgentGraphConfig, 'edges' | 'type'> & {
96
102
  type?: 'standard';
@@ -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.18",
3
+ "version": "1.1.20",
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;
@@ -78,9 +82,17 @@ export class MultiAgentGraph extends StandardGraph {
78
82
  */
79
83
  private lastActiveAgentId: string | undefined;
80
84
 
85
+ /**
86
+ * When set, the graph routes START to this agent instead of the default starting nodes.
87
+ * Enables multi-turn resumption: follow-up messages go to the agent that last handled
88
+ * the conversation rather than restarting from the root/router agent.
89
+ */
90
+ private resumeFromAgentId: string | undefined;
91
+
81
92
  constructor(input: t.MultiAgentGraphInput) {
82
93
  super(input);
83
94
  this.edges = input.edges;
95
+ this.resumeFromAgentId = input.resumeFromAgentId;
84
96
  this.categorizeEdges();
85
97
  this.analyzeGraph();
86
98
  this.createTransferTools();
@@ -1174,7 +1186,39 @@ export class MultiAgentGraph extends StandardGraph {
1174
1186
 
1175
1187
  const builder = new StateGraph(StateAnnotation);
1176
1188
 
1177
- // 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)
1178
1222
  for (const [agentId] of this.agentContexts) {
1179
1223
  // Get all possible destinations for this agent
1180
1224
  const transferDestinations = new Set<string>();
@@ -1218,6 +1262,15 @@ export class MultiAgentGraph extends StandardGraph {
1218
1262
  /** Register subgraph for handoff tools (lazy reference resolution) */
1219
1263
  this.subgraphRegistry.set(agentId, agentSubgraph);
1220
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
+
1221
1274
  /** Wrapper function that handles agentMessages channel, handoff reception, and conditional routing */
1222
1275
  const agentWrapper = async (
1223
1276
  state: t.MultiAgentGraphState,
@@ -1585,20 +1638,127 @@ export class MultiAgentGraph extends StandardGraph {
1585
1638
  });
1586
1639
  }
1587
1640
 
1588
- // Add starting edges for all starting nodes
1589
- for (const startNode of this.startingNodes) {
1590
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
1591
- /** @ts-ignore */
1592
- builder.addEdge(START, startNode);
1641
+ /**
1642
+ * Add starting edges from START to entry agent(s).
1643
+ *
1644
+ * Multi-turn resumption: when `resumeFromAgentId` is set and refers to a
1645
+ * valid agent in this graph, START routes exclusively to that agent so
1646
+ * follow-up messages continue where the previous turn left off.
1647
+ *
1648
+ * Default behavior (no resume): static edges to all starting nodes,
1649
+ * preserving parallel execution for graphs with multiple entry points.
1650
+ */
1651
+ const validResumeAgent =
1652
+ this.resumeFromAgentId != null &&
1653
+ this.agentContexts.has(this.resumeFromAgentId);
1654
+
1655
+ if (validResumeAgent) {
1656
+ const resumeAgentId = this.resumeFromAgentId!;
1657
+ console.debug(
1658
+ `[MultiAgentGraph] Multi-turn resumption: routing START → "${resumeAgentId}" (skipping default starting nodes: [${Array.from(this.startingNodes).join(', ')}])`
1659
+ );
1660
+
1661
+ /**
1662
+ * Build route map containing both the resume agent and default starting
1663
+ * nodes. This is required by LangGraph — all possible destinations must
1664
+ * be declared even if the router always picks one.
1665
+ */
1666
+ const allPossibleStarts = new Set([
1667
+ ...this.startingNodes,
1668
+ resumeAgentId,
1669
+ ]);
1670
+ const routeMap: Record<string, string> = {};
1671
+ for (const nodeId of allPossibleStarts) {
1672
+ routeMap[nodeId] = nodeId;
1673
+ }
1674
+
1675
+ builder.addConditionalEdges(
1676
+ START,
1677
+ () => resumeAgentId,
1678
+ routeMap as unknown as never
1679
+ );
1680
+ } else {
1681
+ if (this.resumeFromAgentId != null) {
1682
+ console.warn(
1683
+ `[MultiAgentGraph] resumeFromAgentId "${this.resumeFromAgentId}" not found in graph — falling back to default starting nodes`
1684
+ );
1685
+ }
1686
+ for (const startNode of this.startingNodes) {
1687
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
1688
+ /** @ts-ignore */
1689
+ builder.addEdge(START, startNode);
1690
+ }
1691
+ }
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);
1593
1749
  }
1594
1750
 
1595
1751
  /**
1596
1752
  * Add sequence edges for automatic transitions
1597
1753
  * Group edges by destination to handle fan-in scenarios
1754
+ * Skip edges that have approval gates (already handled above)
1598
1755
  */
1599
1756
  const edgesByDestination = new Map<string, t.GraphEdge[]>();
1600
1757
 
1601
1758
  for (const edge of this.sequenceEdges) {
1759
+ if (gatedEdges.has(edge)) {
1760
+ continue;
1761
+ }
1602
1762
  const destinations = Array.isArray(edge.to) ? edge.to : [edge.to];
1603
1763
  for (const destination of destinations) {
1604
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';
@@ -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
+ }