@wundr.io/langgraph-orchestrator 1.0.2-dev.20260530174250.ef0ec927

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.
@@ -0,0 +1,538 @@
1
+ /**
2
+ * Decision Node - Conditional branching node for workflow control flow
3
+ * @module @wundr.io/langgraph-orchestrator
4
+ */
5
+
6
+ import { z } from 'zod';
7
+
8
+ import type {
9
+ AgentState,
10
+ NodeDefinition,
11
+ NodeContext,
12
+ NodeResult,
13
+ EdgeCondition,
14
+ ConditionType,
15
+ } from '../types';
16
+
17
+ /**
18
+ * Configuration for decision node
19
+ */
20
+ export interface DecisionNodeConfig {
21
+ /** Decision branches */
22
+ readonly branches: DecisionBranch[];
23
+ /** Default branch if no conditions match */
24
+ readonly defaultBranch?: string;
25
+ /** Whether to throw error if no branch matches and no default */
26
+ readonly throwOnNoMatch?: boolean;
27
+ /** Custom decision function */
28
+ readonly decide?: (state: AgentState) => string | Promise<string>;
29
+ }
30
+
31
+ /**
32
+ * Decision branch definition
33
+ */
34
+ export interface DecisionBranch {
35
+ /** Name/ID of this branch */
36
+ readonly name: string;
37
+ /** Target node for this branch */
38
+ readonly target: string;
39
+ /** Condition for taking this branch */
40
+ readonly condition: EdgeCondition;
41
+ /** Priority (higher = checked first) */
42
+ readonly priority?: number;
43
+ }
44
+
45
+ /**
46
+ * Schema for decision node configuration validation
47
+ */
48
+ export const DecisionNodeConfigSchema = z.object({
49
+ branches: z.array(
50
+ z.object({
51
+ name: z.string(),
52
+ target: z.string(),
53
+ condition: z.object({
54
+ type: z.enum([
55
+ 'equals',
56
+ 'not_equals',
57
+ 'contains',
58
+ 'greater_than',
59
+ 'less_than',
60
+ 'exists',
61
+ 'not_exists',
62
+ 'custom',
63
+ ]),
64
+ field: z.string().optional(),
65
+ value: z.unknown().optional(),
66
+ }),
67
+ priority: z.number().optional(),
68
+ }),
69
+ ),
70
+ defaultBranch: z.string().optional(),
71
+ throwOnNoMatch: z.boolean().optional(),
72
+ });
73
+
74
+ /**
75
+ * Create a decision node for conditional branching
76
+ *
77
+ * @example
78
+ * ```typescript
79
+ * const decisionNode = createDecisionNode({
80
+ * id: 'router',
81
+ * name: 'Task Router',
82
+ * config: {
83
+ * branches: [
84
+ * {
85
+ * name: 'search',
86
+ * target: 'search-node',
87
+ * condition: { type: 'equals', field: 'data.action', value: 'search' }
88
+ * },
89
+ * {
90
+ * name: 'answer',
91
+ * target: 'answer-node',
92
+ * condition: { type: 'equals', field: 'data.action', value: 'answer' }
93
+ * }
94
+ * ],
95
+ * defaultBranch: 'fallback-node'
96
+ * }
97
+ * });
98
+ *
99
+ * graph.addNode('router', decisionNode);
100
+ * ```
101
+ *
102
+ * @param options - Node creation options
103
+ * @returns NodeDefinition for use in StateGraph
104
+ */
105
+ export function createDecisionNode<
106
+ TState extends AgentState = AgentState,
107
+ >(options: {
108
+ id: string;
109
+ name: string;
110
+ config: DecisionNodeConfig;
111
+ nodeConfig?: NodeDefinition<TState>['config'];
112
+ }): NodeDefinition<TState> {
113
+ const { id, name, config, nodeConfig = {} } = options;
114
+
115
+ return {
116
+ id,
117
+ name,
118
+ type: 'decision',
119
+ config: nodeConfig,
120
+ execute: async (
121
+ state: TState,
122
+ context: NodeContext,
123
+ ): Promise<NodeResult<TState>> => {
124
+ context.services.logger.debug('Evaluating decision branches', {
125
+ branchCount: config.branches.length,
126
+ });
127
+
128
+ // If custom decision function provided, use it
129
+ if (config.decide) {
130
+ const target = await config.decide(state);
131
+ context.services.logger.debug('Custom decision made', { target });
132
+ return {
133
+ state,
134
+ next: target,
135
+ };
136
+ }
137
+
138
+ // Sort branches by priority
139
+ const sortedBranches = [...config.branches].sort(
140
+ (a, b) => (b.priority ?? 0) - (a.priority ?? 0),
141
+ );
142
+
143
+ // Evaluate branches in order
144
+ for (const branch of sortedBranches) {
145
+ const matches = await evaluateCondition(branch.condition, state);
146
+
147
+ if (matches) {
148
+ context.services.logger.debug('Branch matched', {
149
+ branch: branch.name,
150
+ target: branch.target,
151
+ });
152
+ return {
153
+ state,
154
+ next: branch.target,
155
+ };
156
+ }
157
+ }
158
+
159
+ // No branch matched
160
+ if (config.defaultBranch) {
161
+ context.services.logger.debug('Using default branch', {
162
+ target: config.defaultBranch,
163
+ });
164
+ return {
165
+ state,
166
+ next: config.defaultBranch,
167
+ };
168
+ }
169
+
170
+ if (config.throwOnNoMatch) {
171
+ throw new Error('No decision branch matched and no default configured');
172
+ }
173
+
174
+ context.services.logger.warn(
175
+ 'No decision branch matched, workflow may terminate',
176
+ );
177
+ return { state };
178
+ },
179
+ };
180
+ }
181
+
182
+ /**
183
+ * Evaluate a condition against state
184
+ */
185
+ async function evaluateCondition(
186
+ condition: EdgeCondition,
187
+ state: AgentState,
188
+ ): Promise<boolean> {
189
+ const fieldValue = condition.field
190
+ ? getFieldValue(state, condition.field)
191
+ : undefined;
192
+
193
+ switch (condition.type) {
194
+ case 'equals':
195
+ return fieldValue === condition.value;
196
+
197
+ case 'not_equals':
198
+ return fieldValue !== condition.value;
199
+
200
+ case 'contains':
201
+ if (Array.isArray(fieldValue)) {
202
+ return fieldValue.includes(condition.value);
203
+ }
204
+ if (typeof fieldValue === 'string') {
205
+ return fieldValue.includes(String(condition.value));
206
+ }
207
+ return false;
208
+
209
+ case 'greater_than':
210
+ return (
211
+ typeof fieldValue === 'number' &&
212
+ typeof condition.value === 'number' &&
213
+ fieldValue > condition.value
214
+ );
215
+
216
+ case 'less_than':
217
+ return (
218
+ typeof fieldValue === 'number' &&
219
+ typeof condition.value === 'number' &&
220
+ fieldValue < condition.value
221
+ );
222
+
223
+ case 'exists':
224
+ return fieldValue !== undefined && fieldValue !== null;
225
+
226
+ case 'not_exists':
227
+ return fieldValue === undefined || fieldValue === null;
228
+
229
+ case 'custom':
230
+ if (condition.evaluate) {
231
+ return await condition.evaluate(state, {
232
+ edge: { from: '', to: '', type: 'conditional', condition },
233
+ sourceResult: { state },
234
+ graph: {
235
+ id: '',
236
+ name: '',
237
+ entryPoint: '',
238
+ nodes: new Map(),
239
+ edges: new Map(),
240
+ config: {
241
+ maxIterations: 100,
242
+ timeout: 300000,
243
+ checkpointEnabled: false,
244
+ checkpointInterval: 1,
245
+ parallelExecution: false,
246
+ retry: {
247
+ maxRetries: 0,
248
+ initialDelay: 0,
249
+ backoffMultiplier: 1,
250
+ maxDelay: 0,
251
+ retryableErrors: [],
252
+ },
253
+ logLevel: 'silent',
254
+ },
255
+ },
256
+ });
257
+ }
258
+ return false;
259
+
260
+ default:
261
+ return false;
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Get a nested field value from state
267
+ */
268
+ function getFieldValue(state: AgentState, field: string): unknown {
269
+ const parts = field.split('.');
270
+ let current: unknown = state;
271
+
272
+ for (const part of parts) {
273
+ if (current === null || current === undefined) {
274
+ return undefined;
275
+ }
276
+ current = (current as Record<string, unknown>)[part];
277
+ }
278
+
279
+ return current;
280
+ }
281
+
282
+ /**
283
+ * Create a switch-case style decision node
284
+ *
285
+ * @example
286
+ * ```typescript
287
+ * const switchNode = createSwitchNode({
288
+ * id: 'type-switch',
289
+ * name: 'Type Switch',
290
+ * field: 'data.messageType',
291
+ * cases: {
292
+ * 'question': 'question-handler',
293
+ * 'command': 'command-handler',
294
+ * 'feedback': 'feedback-handler'
295
+ * },
296
+ * default: 'unknown-handler'
297
+ * });
298
+ * ```
299
+ *
300
+ * @param options - Switch node options
301
+ * @returns NodeDefinition for use in StateGraph
302
+ */
303
+ export function createSwitchNode<
304
+ TState extends AgentState = AgentState,
305
+ >(options: {
306
+ id: string;
307
+ name: string;
308
+ field: string;
309
+ cases: Record<string, string>;
310
+ default?: string;
311
+ nodeConfig?: NodeDefinition<TState>['config'];
312
+ }): NodeDefinition<TState> {
313
+ const branches: DecisionBranch[] = Object.entries(options.cases).map(
314
+ ([value, target]) => ({
315
+ name: `case-${value}`,
316
+ target,
317
+ condition: {
318
+ type: 'equals' as ConditionType,
319
+ field: options.field,
320
+ value,
321
+ },
322
+ }),
323
+ );
324
+
325
+ return createDecisionNode<TState>({
326
+ id: options.id,
327
+ name: options.name,
328
+ config: {
329
+ branches,
330
+ defaultBranch: options.default,
331
+ throwOnNoMatch: !options.default,
332
+ },
333
+ nodeConfig: options.nodeConfig,
334
+ });
335
+ }
336
+
337
+ /**
338
+ * Create a threshold-based decision node
339
+ *
340
+ * @example
341
+ * ```typescript
342
+ * const confidenceRouter = createThresholdNode({
343
+ * id: 'confidence-router',
344
+ * name: 'Confidence Router',
345
+ * field: 'data.confidence',
346
+ * thresholds: [
347
+ * { value: 0.9, target: 'high-confidence' },
348
+ * { value: 0.7, target: 'medium-confidence' },
349
+ * { value: 0.5, target: 'low-confidence' }
350
+ * ],
351
+ * default: 'very-low-confidence'
352
+ * });
353
+ * ```
354
+ *
355
+ * @param options - Threshold node options
356
+ * @returns NodeDefinition for use in StateGraph
357
+ */
358
+ export function createThresholdNode<
359
+ TState extends AgentState = AgentState,
360
+ >(options: {
361
+ id: string;
362
+ name: string;
363
+ field: string;
364
+ thresholds: Array<{ value: number; target: string }>;
365
+ default?: string;
366
+ nodeConfig?: NodeDefinition<TState>['config'];
367
+ }): NodeDefinition<TState> {
368
+ // Sort thresholds in descending order
369
+ const sortedThresholds = [...options.thresholds].sort(
370
+ (a, b) => b.value - a.value,
371
+ );
372
+
373
+ const branches: DecisionBranch[] = sortedThresholds.map(
374
+ (threshold, index) => ({
375
+ name: `threshold-${threshold.value}`,
376
+ target: threshold.target,
377
+ condition: {
378
+ type: 'custom' as ConditionType,
379
+ evaluate: async (state: AgentState) => {
380
+ const value = getFieldValue(state, options.field);
381
+ if (typeof value !== 'number') {
382
+ return false;
383
+ }
384
+
385
+ // Check if value is >= this threshold but < the next higher threshold
386
+ const isAboveThreshold = value >= threshold.value;
387
+ const nextHigherThreshold = sortedThresholds[index - 1];
388
+ const isBelowNextThreshold =
389
+ !nextHigherThreshold || value < nextHigherThreshold.value;
390
+
391
+ return isAboveThreshold && isBelowNextThreshold;
392
+ },
393
+ },
394
+ priority: sortedThresholds.length - index,
395
+ }),
396
+ );
397
+
398
+ return createDecisionNode<TState>({
399
+ id: options.id,
400
+ name: options.name,
401
+ config: {
402
+ branches,
403
+ defaultBranch: options.default,
404
+ throwOnNoMatch: !options.default,
405
+ },
406
+ nodeConfig: options.nodeConfig,
407
+ });
408
+ }
409
+
410
+ /**
411
+ * Create a boolean decision node (if-else)
412
+ *
413
+ * @example
414
+ * ```typescript
415
+ * const ifElseNode = createIfElseNode({
416
+ * id: 'has-error',
417
+ * name: 'Error Check',
418
+ * condition: {
419
+ * type: 'exists',
420
+ * field: 'error'
421
+ * },
422
+ * ifTrue: 'error-handler',
423
+ * ifFalse: 'success-handler'
424
+ * });
425
+ * ```
426
+ *
427
+ * @param options - If-else node options
428
+ * @returns NodeDefinition for use in StateGraph
429
+ */
430
+ export function createIfElseNode<
431
+ TState extends AgentState = AgentState,
432
+ >(options: {
433
+ id: string;
434
+ name: string;
435
+ condition: EdgeCondition;
436
+ ifTrue: string;
437
+ ifFalse: string;
438
+ nodeConfig?: NodeDefinition<TState>['config'];
439
+ }): NodeDefinition<TState> {
440
+ return createDecisionNode<TState>({
441
+ id: options.id,
442
+ name: options.name,
443
+ config: {
444
+ branches: [
445
+ {
446
+ name: 'if-true',
447
+ target: options.ifTrue,
448
+ condition: options.condition,
449
+ priority: 1,
450
+ },
451
+ ],
452
+ defaultBranch: options.ifFalse,
453
+ },
454
+ nodeConfig: options.nodeConfig,
455
+ });
456
+ }
457
+
458
+ /**
459
+ * Create a multi-condition decision node (AND/OR logic)
460
+ *
461
+ * @example
462
+ * ```typescript
463
+ * const multiConditionNode = createMultiConditionNode({
464
+ * id: 'complex-router',
465
+ * name: 'Complex Router',
466
+ * branches: [
467
+ * {
468
+ * name: 'premium-user',
469
+ * target: 'premium-flow',
470
+ * conditions: [
471
+ * { type: 'equals', field: 'data.userType', value: 'premium' },
472
+ * { type: 'greater_than', field: 'data.credits', value: 0 }
473
+ * ],
474
+ * logic: 'AND'
475
+ * },
476
+ * {
477
+ * name: 'needs-upgrade',
478
+ * target: 'upgrade-flow',
479
+ * conditions: [
480
+ * { type: 'equals', field: 'data.userType', value: 'free' },
481
+ * { type: 'less_than', field: 'data.credits', value: 1 }
482
+ * ],
483
+ * logic: 'OR'
484
+ * }
485
+ * ],
486
+ * default: 'standard-flow'
487
+ * });
488
+ * ```
489
+ *
490
+ * @param options - Multi-condition node options
491
+ * @returns NodeDefinition for use in StateGraph
492
+ */
493
+ export function createMultiConditionNode<
494
+ TState extends AgentState = AgentState,
495
+ >(options: {
496
+ id: string;
497
+ name: string;
498
+ branches: Array<{
499
+ name: string;
500
+ target: string;
501
+ conditions: EdgeCondition[];
502
+ logic: 'AND' | 'OR';
503
+ priority?: number;
504
+ }>;
505
+ default?: string;
506
+ nodeConfig?: NodeDefinition<TState>['config'];
507
+ }): NodeDefinition<TState> {
508
+ const branches: DecisionBranch[] = options.branches.map(branch => ({
509
+ name: branch.name,
510
+ target: branch.target,
511
+ priority: branch.priority,
512
+ condition: {
513
+ type: 'custom' as ConditionType,
514
+ evaluate: async (state: AgentState) => {
515
+ const results = await Promise.all(
516
+ branch.conditions.map(cond => evaluateCondition(cond, state)),
517
+ );
518
+
519
+ if (branch.logic === 'AND') {
520
+ return results.every(r => r);
521
+ } else {
522
+ return results.some(r => r);
523
+ }
524
+ },
525
+ },
526
+ }));
527
+
528
+ return createDecisionNode<TState>({
529
+ id: options.id,
530
+ name: options.name,
531
+ config: {
532
+ branches,
533
+ defaultBranch: options.default,
534
+ throwOnNoMatch: !options.default,
535
+ },
536
+ nodeConfig: options.nodeConfig,
537
+ });
538
+ }