@illuma-ai/agents 1.1.19 → 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.
- package/dist/cjs/common/enum.cjs +2 -0
- package/dist/cjs/common/enum.cjs.map +1 -1
- package/dist/cjs/graphs/MultiAgentGraph.cjs +87 -1
- package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
- package/dist/cjs/main.cjs +3 -0
- package/dist/cjs/main.cjs.map +1 -1
- package/dist/cjs/nodes/ApprovalGateNode.cjs +75 -0
- package/dist/cjs/nodes/ApprovalGateNode.cjs.map +1 -0
- package/dist/cjs/run.cjs +45 -0
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +21 -18
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/types/graph.cjs.map +1 -1
- package/dist/cjs/utils/run.cjs +6 -1
- package/dist/cjs/utils/run.cjs.map +1 -1
- package/dist/esm/common/enum.mjs +2 -0
- package/dist/esm/common/enum.mjs.map +1 -1
- package/dist/esm/graphs/MultiAgentGraph.mjs +87 -1
- package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
- package/dist/esm/main.mjs +1 -0
- package/dist/esm/main.mjs.map +1 -1
- package/dist/esm/nodes/ApprovalGateNode.mjs +72 -0
- package/dist/esm/nodes/ApprovalGateNode.mjs.map +1 -0
- package/dist/esm/run.mjs +45 -0
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +22 -19
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/types/graph.mjs.map +1 -1
- package/dist/esm/utils/run.mjs +6 -1
- package/dist/esm/utils/run.mjs.map +1 -1
- package/dist/types/common/enum.d.ts +2 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/nodes/ApprovalGateNode.d.ts +49 -0
- package/dist/types/nodes/index.d.ts +2 -0
- package/dist/types/run.d.ts +25 -1
- package/dist/types/tools/ToolNode.d.ts +7 -5
- package/dist/types/types/graph.d.ts +31 -0
- package/dist/types/types/tools.d.ts +7 -9
- package/package.json +1 -1
- package/src/common/enum.ts +2 -0
- package/src/graphs/MultiAgentGraph.ts +108 -1
- package/src/index.ts +3 -0
- package/src/nodes/ApprovalGateNode.ts +117 -0
- package/src/nodes/__tests__/ApprovalGateNode.test.ts +206 -0
- package/src/nodes/index.ts +5 -0
- package/src/run.ts +55 -1
- package/src/specs/agent-handoffs-bedrock.integration.test.ts +2 -2
- package/src/specs/agent-handoffs.test.ts +153 -6
- package/src/tools/ToolNode.ts +28 -23
- package/src/tools/__tests__/ToolApproval.test.ts +162 -325
- package/src/types/graph.ts +32 -0
- package/src/types/tools.ts +7 -9
- package/src/utils/run.ts +9 -1
package/dist/types/run.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
*
|
|
56
|
-
*
|
|
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
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
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
|
|
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
package/src/common/enum.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
@@ -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
|
+
});
|