@wundr.io/langgraph-orchestrator 1.0.3

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 (57) hide show
  1. package/README.md +842 -0
  2. package/dist/checkpointing.d.ts +265 -0
  3. package/dist/checkpointing.d.ts.map +1 -0
  4. package/dist/checkpointing.js +577 -0
  5. package/dist/checkpointing.js.map +1 -0
  6. package/dist/edges/conditional-edge.d.ts +230 -0
  7. package/dist/edges/conditional-edge.d.ts.map +1 -0
  8. package/dist/edges/conditional-edge.js +439 -0
  9. package/dist/edges/conditional-edge.js.map +1 -0
  10. package/dist/edges/loop-edge.d.ts +290 -0
  11. package/dist/edges/loop-edge.d.ts.map +1 -0
  12. package/dist/edges/loop-edge.js +503 -0
  13. package/dist/edges/loop-edge.js.map +1 -0
  14. package/dist/index.d.ts +125 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +269 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/nodes/decision-node.d.ts +276 -0
  19. package/dist/nodes/decision-node.d.ts.map +1 -0
  20. package/dist/nodes/decision-node.js +403 -0
  21. package/dist/nodes/decision-node.js.map +1 -0
  22. package/dist/nodes/human-node.d.ts +272 -0
  23. package/dist/nodes/human-node.d.ts.map +1 -0
  24. package/dist/nodes/human-node.js +394 -0
  25. package/dist/nodes/human-node.js.map +1 -0
  26. package/dist/nodes/llm-node.d.ts +173 -0
  27. package/dist/nodes/llm-node.d.ts.map +1 -0
  28. package/dist/nodes/llm-node.js +325 -0
  29. package/dist/nodes/llm-node.js.map +1 -0
  30. package/dist/nodes/tool-node.d.ts +151 -0
  31. package/dist/nodes/tool-node.d.ts.map +1 -0
  32. package/dist/nodes/tool-node.js +373 -0
  33. package/dist/nodes/tool-node.js.map +1 -0
  34. package/dist/prebuilt-graphs/plan-execute-refine.d.ts +149 -0
  35. package/dist/prebuilt-graphs/plan-execute-refine.d.ts.map +1 -0
  36. package/dist/prebuilt-graphs/plan-execute-refine.js +600 -0
  37. package/dist/prebuilt-graphs/plan-execute-refine.js.map +1 -0
  38. package/dist/state-graph.d.ts +158 -0
  39. package/dist/state-graph.d.ts.map +1 -0
  40. package/dist/state-graph.js +756 -0
  41. package/dist/state-graph.js.map +1 -0
  42. package/dist/types.d.ts +762 -0
  43. package/dist/types.d.ts.map +1 -0
  44. package/dist/types.js +73 -0
  45. package/dist/types.js.map +1 -0
  46. package/package.json +57 -0
  47. package/src/checkpointing.ts +702 -0
  48. package/src/edges/conditional-edge.ts +518 -0
  49. package/src/edges/loop-edge.ts +623 -0
  50. package/src/index.ts +416 -0
  51. package/src/nodes/decision-node.ts +538 -0
  52. package/src/nodes/human-node.ts +572 -0
  53. package/src/nodes/llm-node.ts +448 -0
  54. package/src/nodes/tool-node.ts +525 -0
  55. package/src/prebuilt-graphs/plan-execute-refine.ts +769 -0
  56. package/src/state-graph.ts +990 -0
  57. package/src/types.ts +729 -0
@@ -0,0 +1,990 @@
1
+ /**
2
+ * StateGraph - Core class for defining workflow graphs with nodes and edges
3
+ * @module @wundr.io/langgraph-orchestrator
4
+ */
5
+
6
+ import { EventEmitter } from 'eventemitter3';
7
+ import { v4 as uuidv4 } from 'uuid';
8
+
9
+ import type {
10
+ AgentState,
11
+ GraphConfig,
12
+ GraphGlobalConfig,
13
+ NodeDefinition,
14
+ EdgeDefinition,
15
+ EdgeCondition,
16
+ ExecutionOptions,
17
+ ExecutionResult,
18
+ ExecutionStats,
19
+ NodeContext,
20
+ NodeResult,
21
+ NodeServices,
22
+ Checkpoint,
23
+ WorkflowError,
24
+ StateHistoryEntry,
25
+ StateChange,
26
+ StateMetadata,
27
+ Logger,
28
+ GraphCheckpointer,
29
+ } from './types';
30
+
31
+ /**
32
+ * Default graph configuration
33
+ */
34
+ const DEFAULT_CONFIG: GraphGlobalConfig = {
35
+ maxIterations: 100,
36
+ timeout: 300000, // 5 minutes
37
+ checkpointEnabled: true,
38
+ checkpointInterval: 1,
39
+ parallelExecution: false,
40
+ retry: {
41
+ maxRetries: 3,
42
+ initialDelay: 1000,
43
+ backoffMultiplier: 2,
44
+ maxDelay: 30000,
45
+ retryableErrors: ['TIMEOUT', 'RATE_LIMIT', 'NETWORK_ERROR'],
46
+ },
47
+ logLevel: 'info',
48
+ };
49
+
50
+ /**
51
+ * Events emitted by StateGraph
52
+ */
53
+ export interface StateGraphEvents {
54
+ 'execution:start': (state: AgentState) => void;
55
+ 'execution:complete': (result: ExecutionResult) => void;
56
+ 'execution:error': (error: WorkflowError) => void;
57
+ 'node:enter': (nodeName: string, state: AgentState) => void;
58
+ 'node:exit': (nodeName: string, result: NodeResult) => void;
59
+ 'checkpoint:created': (checkpoint: Checkpoint) => void;
60
+ 'state:updated': (state: AgentState) => void;
61
+ }
62
+
63
+ /**
64
+ * Console-based logger implementation
65
+ */
66
+ class ConsoleLogger implements Logger {
67
+ constructor(private readonly level: string) {}
68
+
69
+ private shouldLog(msgLevel: string): boolean {
70
+ const levels = ['debug', 'info', 'warn', 'error', 'silent'];
71
+ return levels.indexOf(msgLevel) >= levels.indexOf(this.level);
72
+ }
73
+
74
+ debug(message: string, data?: Record<string, unknown>): void {
75
+ if (this.shouldLog('debug')) {
76
+ // Using process.stderr for logging to avoid polluting stdout
77
+ process.stderr.write(
78
+ `[DEBUG] ${message} ${data ? JSON.stringify(data) : ''}\n`,
79
+ );
80
+ }
81
+ }
82
+
83
+ info(message: string, data?: Record<string, unknown>): void {
84
+ if (this.shouldLog('info')) {
85
+ // Using process.stderr for logging to avoid polluting stdout
86
+ process.stderr.write(
87
+ `[INFO] ${message} ${data ? JSON.stringify(data) : ''}\n`,
88
+ );
89
+ }
90
+ }
91
+
92
+ warn(message: string, data?: Record<string, unknown>): void {
93
+ if (this.shouldLog('warn')) {
94
+ console.warn(`[WARN] ${message}`, data ?? '');
95
+ }
96
+ }
97
+
98
+ error(message: string, data?: Record<string, unknown>): void {
99
+ if (this.shouldLog('error')) {
100
+ console.error(`[ERROR] ${message}`, data ?? '');
101
+ }
102
+ }
103
+ }
104
+
105
+ /**
106
+ * StateGraph class for defining and executing workflow graphs
107
+ *
108
+ * @example
109
+ * ```typescript
110
+ * const graph = new StateGraph('my-workflow')
111
+ * .addNode('start', {
112
+ * id: 'start',
113
+ * name: 'Start Node',
114
+ * type: 'start',
115
+ * config: {},
116
+ * execute: async (state) => ({ state, next: 'process' })
117
+ * })
118
+ * .addNode('process', {
119
+ * id: 'process',
120
+ * name: 'Process Node',
121
+ * type: 'transform',
122
+ * config: {},
123
+ * execute: async (state) => ({ state, next: 'end' })
124
+ * })
125
+ * .addEdge('start', 'process')
126
+ * .setEntryPoint('start');
127
+ *
128
+ * const result = await graph.execute();
129
+ * ```
130
+ */
131
+ export class StateGraph<
132
+ TState extends AgentState = AgentState,
133
+ > extends EventEmitter<StateGraphEvents> {
134
+ private readonly id: string;
135
+ private readonly name: string;
136
+ private readonly description?: string;
137
+ private readonly nodes: Map<string, NodeDefinition<TState>> = new Map();
138
+ private readonly edges: Map<string, EdgeDefinition[]> = new Map();
139
+ private entryPoint?: string;
140
+ private config: GraphGlobalConfig;
141
+ private _checkpointer?: GraphCheckpointer;
142
+ private services: Partial<NodeServices> = {};
143
+ private logger: Logger;
144
+
145
+ /** Get the checkpointer */
146
+ get checkpointer(): GraphCheckpointer | undefined {
147
+ return this._checkpointer;
148
+ }
149
+
150
+ /**
151
+ * Create a new StateGraph
152
+ * @param name - Human-readable name for the graph
153
+ * @param config - Optional configuration overrides
154
+ */
155
+ constructor(name: string, config?: Partial<GraphGlobalConfig>) {
156
+ super();
157
+ this.id = uuidv4();
158
+ this.name = name;
159
+ this.config = { ...DEFAULT_CONFIG, ...config };
160
+ this.logger = new ConsoleLogger(this.config.logLevel);
161
+ }
162
+
163
+ /**
164
+ * Add a node to the graph
165
+ * @param name - Unique name for the node
166
+ * @param definition - Node definition
167
+ * @returns this for chaining
168
+ */
169
+ addNode(name: string, definition: NodeDefinition<TState>): this {
170
+ if (this.nodes.has(name)) {
171
+ throw new Error(`Node "${name}" already exists in graph "${this.name}"`);
172
+ }
173
+ this.nodes.set(name, definition);
174
+ this.edges.set(name, []);
175
+ return this;
176
+ }
177
+
178
+ /**
179
+ * Add a direct edge between nodes
180
+ * @param from - Source node name
181
+ * @param to - Target node name
182
+ * @returns this for chaining
183
+ */
184
+ addEdge(from: string, to: string): this {
185
+ this.validateNodeExists(from);
186
+ this.validateNodeExists(to);
187
+
188
+ const edge: EdgeDefinition = {
189
+ from,
190
+ to,
191
+ type: 'direct',
192
+ };
193
+
194
+ const edges = this.edges.get(from) ?? [];
195
+ edges.push(edge);
196
+ this.edges.set(from, edges);
197
+ return this;
198
+ }
199
+
200
+ /**
201
+ * Add a conditional edge
202
+ * @param from - Source node name
203
+ * @param to - Target node name
204
+ * @param condition - Condition for traversal
205
+ * @returns this for chaining
206
+ */
207
+ addConditionalEdge(from: string, to: string, condition: EdgeCondition): this {
208
+ this.validateNodeExists(from);
209
+ this.validateNodeExists(to);
210
+
211
+ const edge: EdgeDefinition = {
212
+ from,
213
+ to,
214
+ type: 'conditional',
215
+ condition,
216
+ };
217
+
218
+ const edges = this.edges.get(from) ?? [];
219
+ edges.push(edge);
220
+ this.edges.set(from, edges);
221
+ return this;
222
+ }
223
+
224
+ /**
225
+ * Add a loop edge that can cycle back
226
+ * @param from - Source node name
227
+ * @param to - Target node name (can be same as from)
228
+ * @param condition - Condition to continue looping
229
+ * @returns this for chaining
230
+ */
231
+ addLoopEdge(from: string, to: string, condition: EdgeCondition): this {
232
+ this.validateNodeExists(from);
233
+ this.validateNodeExists(to);
234
+
235
+ const edge: EdgeDefinition = {
236
+ from,
237
+ to,
238
+ type: 'loop',
239
+ condition,
240
+ };
241
+
242
+ const edges = this.edges.get(from) ?? [];
243
+ edges.push(edge);
244
+ this.edges.set(from, edges);
245
+ return this;
246
+ }
247
+
248
+ /**
249
+ * Add parallel edges to multiple targets
250
+ * @param from - Source node name
251
+ * @param targets - Target node names
252
+ * @returns this for chaining
253
+ */
254
+ addParallelEdges(from: string, targets: string[]): this {
255
+ this.validateNodeExists(from);
256
+ targets.forEach(t => this.validateNodeExists(t));
257
+
258
+ for (const to of targets) {
259
+ const edge: EdgeDefinition = {
260
+ from,
261
+ to,
262
+ type: 'parallel',
263
+ };
264
+ const edges = this.edges.get(from) ?? [];
265
+ edges.push(edge);
266
+ this.edges.set(from, edges);
267
+ }
268
+ return this;
269
+ }
270
+
271
+ /**
272
+ * Set the entry point node
273
+ * @param nodeName - Name of the entry point node
274
+ * @returns this for chaining
275
+ */
276
+ setEntryPoint(nodeName: string): this {
277
+ this.validateNodeExists(nodeName);
278
+ this.entryPoint = nodeName;
279
+ return this;
280
+ }
281
+
282
+ /**
283
+ * Set the checkpointer for state persistence
284
+ * @param checkpointer - Checkpointer implementation
285
+ * @returns this for chaining
286
+ */
287
+ setCheckpointer(checkpointer: GraphCheckpointer): this {
288
+ this._checkpointer = checkpointer;
289
+ this.services = { ...this.services, checkpointer };
290
+ return this;
291
+ }
292
+
293
+ /**
294
+ * Set services available to nodes
295
+ * @param services - Services to provide
296
+ * @returns this for chaining
297
+ */
298
+ setServices(services: Partial<NodeServices>): this {
299
+ this.services = { ...this.services, ...services };
300
+ return this;
301
+ }
302
+
303
+ /**
304
+ * Get the graph configuration
305
+ * @returns GraphConfig object
306
+ */
307
+ getConfig(): GraphConfig {
308
+ if (!this.entryPoint) {
309
+ throw new Error(
310
+ 'Entry point not set. Call setEntryPoint() before getConfig()',
311
+ );
312
+ }
313
+
314
+ // The nodes Map uses TState-specific NodeDefinition, but GraphConfig uses the base type.
315
+ // This cast is safe because GraphConfig is used for inspection/serialization,
316
+ // while actual execution uses the strongly-typed internal this.nodes Map.
317
+ const typedNodes: Map<string, NodeDefinition> = new Map();
318
+ for (const [key, value] of this.nodes) {
319
+ // Cast through unknown is necessary here because NodeDefinition<TState>
320
+ // has contravariant parameter in execute function signature
321
+ typedNodes.set(key, value as unknown as NodeDefinition);
322
+ }
323
+
324
+ return {
325
+ id: this.id,
326
+ name: this.name,
327
+ description: this.description,
328
+ entryPoint: this.entryPoint,
329
+ nodes: typedNodes,
330
+ edges: new Map(this.edges),
331
+ config: this.config,
332
+ };
333
+ }
334
+
335
+ /**
336
+ * Execute the graph
337
+ * @param options - Execution options
338
+ * @returns Execution result
339
+ */
340
+ async execute(options?: ExecutionOptions): Promise<ExecutionResult> {
341
+ if (!this.entryPoint) {
342
+ throw new Error(
343
+ 'Entry point not set. Call setEntryPoint() before execute()',
344
+ );
345
+ }
346
+
347
+ const executionId = uuidv4();
348
+ const startTime = Date.now();
349
+ const path: string[] = [];
350
+ let nodesExecuted = 0;
351
+ let iterations = 0;
352
+ let tokensUsed = 0;
353
+ let checkpointsCreated = 0;
354
+ let retries = 0;
355
+
356
+ // Initialize or restore state
357
+ let state = await this.initializeState(executionId, options);
358
+
359
+ this.emit('execution:start', state);
360
+ options?.handlers?.onStart?.(state);
361
+
362
+ let currentNode = options?.resumeFrom
363
+ ? await this.getNodeFromCheckpoint(options.resumeFrom)
364
+ : this.entryPoint;
365
+
366
+ try {
367
+ while (currentNode && iterations < this.config.maxIterations) {
368
+ iterations++;
369
+ const node = this.nodes.get(currentNode);
370
+
371
+ if (!node) {
372
+ throw this.createError(
373
+ 'NODE_NOT_FOUND',
374
+ `Node "${currentNode}" not found`,
375
+ );
376
+ }
377
+
378
+ path.push(currentNode);
379
+ this.logger.debug(`Entering node: ${currentNode}`, {
380
+ iteration: iterations,
381
+ });
382
+ this.emit('node:enter', currentNode, state);
383
+ options?.handlers?.onNodeEnter?.(currentNode, state);
384
+
385
+ // Execute node with retry logic
386
+ const result = await this.executeNodeWithRetry(
387
+ node,
388
+ state,
389
+ executionId,
390
+ iterations,
391
+ );
392
+ nodesExecuted++;
393
+
394
+ if (result.metadata?.tokensUsed) {
395
+ tokensUsed += result.metadata.tokensUsed;
396
+ }
397
+ if (result.metadata?.retryCount) {
398
+ retries += result.metadata.retryCount;
399
+ }
400
+
401
+ // Update state with history
402
+ state = this.updateStateWithHistory(result.state, currentNode, state);
403
+
404
+ this.emit('node:exit', currentNode, result);
405
+ this.emit('state:updated', state);
406
+ options?.handlers?.onNodeExit?.(currentNode, result);
407
+
408
+ // Create checkpoint if enabled
409
+ if (
410
+ this.config.checkpointEnabled &&
411
+ iterations % this.config.checkpointInterval === 0 &&
412
+ this.checkpointer
413
+ ) {
414
+ const checkpoint = await this.createCheckpoint(
415
+ state,
416
+ currentNode,
417
+ executionId,
418
+ iterations,
419
+ );
420
+ checkpointsCreated++;
421
+ this.emit('checkpoint:created', checkpoint);
422
+ options?.handlers?.onCheckpoint?.(checkpoint);
423
+ }
424
+
425
+ // Check for termination
426
+ if (result.terminate) {
427
+ this.logger.info('Workflow terminated by node', {
428
+ node: currentNode,
429
+ });
430
+ break;
431
+ }
432
+
433
+ // Determine next node
434
+ const nextNode = await this.determineNextNode(
435
+ currentNode,
436
+ result,
437
+ state,
438
+ );
439
+ currentNode = nextNode ?? '';
440
+
441
+ // Check abort signal
442
+ if (options?.signal?.aborted) {
443
+ throw this.createError('ABORTED', 'Execution was aborted');
444
+ }
445
+ }
446
+
447
+ if (iterations >= this.config.maxIterations) {
448
+ throw this.createError(
449
+ 'MAX_ITERATIONS',
450
+ `Exceeded maximum iterations (${this.config.maxIterations})`,
451
+ );
452
+ }
453
+
454
+ const duration = Date.now() - startTime;
455
+ const stats: ExecutionStats = {
456
+ duration,
457
+ nodesExecuted,
458
+ iterations,
459
+ tokensUsed,
460
+ checkpointsCreated,
461
+ retries,
462
+ };
463
+
464
+ const result: ExecutionResult = {
465
+ state,
466
+ success: true,
467
+ stats,
468
+ path,
469
+ };
470
+
471
+ this.emit('execution:complete', result);
472
+ options?.handlers?.onComplete?.(state);
473
+
474
+ return result;
475
+ } catch (error) {
476
+ const workflowError = this.normalizeError(
477
+ error,
478
+ currentNode ?? 'unknown',
479
+ );
480
+ state = {
481
+ ...state,
482
+ error: workflowError,
483
+ updatedAt: new Date(),
484
+ } as TState;
485
+
486
+ this.emit('execution:error', workflowError);
487
+ options?.handlers?.onError?.(workflowError);
488
+
489
+ const duration = Date.now() - startTime;
490
+ return {
491
+ state,
492
+ success: false,
493
+ error: workflowError,
494
+ stats: {
495
+ duration,
496
+ nodesExecuted,
497
+ iterations,
498
+ tokensUsed,
499
+ checkpointsCreated,
500
+ retries,
501
+ },
502
+ path,
503
+ };
504
+ }
505
+ }
506
+
507
+ /**
508
+ * Compile the graph into an executable form
509
+ * @returns Compiled graph configuration
510
+ */
511
+ compile(): GraphConfig {
512
+ // Validate graph structure
513
+ this.validateGraph();
514
+ return this.getConfig();
515
+ }
516
+
517
+ /**
518
+ * Create a subgraph from a subset of nodes
519
+ * @param nodeNames - Names of nodes to include
520
+ * @returns New StateGraph containing the subgraph
521
+ */
522
+ subgraph(nodeNames: string[]): StateGraph<TState> {
523
+ const sub = new StateGraph<TState>(`${this.name}-subgraph`, this.config);
524
+
525
+ for (const name of nodeNames) {
526
+ const node = this.nodes.get(name);
527
+ if (node) {
528
+ sub.addNode(name, node);
529
+
530
+ // Add edges that connect nodes within the subgraph
531
+ const edges = this.edges.get(name) ?? [];
532
+ for (const edge of edges) {
533
+ if (nodeNames.includes(edge.to)) {
534
+ const existing = sub.edges.get(name) ?? [];
535
+ existing.push(edge);
536
+ sub.edges.set(name, existing);
537
+ }
538
+ }
539
+ }
540
+ }
541
+
542
+ return sub;
543
+ }
544
+
545
+ // Private helper methods
546
+
547
+ private validateNodeExists(nodeName: string): void {
548
+ if (!this.nodes.has(nodeName)) {
549
+ throw new Error(
550
+ `Node "${nodeName}" does not exist in graph "${this.name}"`,
551
+ );
552
+ }
553
+ }
554
+
555
+ private validateGraph(): void {
556
+ if (!this.entryPoint) {
557
+ throw new Error('Graph validation failed: No entry point defined');
558
+ }
559
+
560
+ if (this.nodes.size === 0) {
561
+ throw new Error('Graph validation failed: No nodes defined');
562
+ }
563
+
564
+ // Check all edge targets exist
565
+ for (const [source, edges] of this.edges) {
566
+ for (const edge of edges) {
567
+ if (!this.nodes.has(edge.to)) {
568
+ throw new Error(
569
+ `Graph validation failed: Edge from "${source}" references non-existent node "${edge.to}"`,
570
+ );
571
+ }
572
+ }
573
+ }
574
+
575
+ // Check entry point is reachable
576
+ if (!this.nodes.has(this.entryPoint)) {
577
+ throw new Error(
578
+ `Graph validation failed: Entry point "${this.entryPoint}" does not exist`,
579
+ );
580
+ }
581
+ }
582
+
583
+ private async initializeState(
584
+ executionId: string,
585
+ options?: ExecutionOptions,
586
+ ): Promise<TState> {
587
+ const now = new Date();
588
+ const metadata: StateMetadata = {
589
+ sessionId: uuidv4(),
590
+ executionId,
591
+ stepCount: 0,
592
+ tags: [],
593
+ };
594
+
595
+ const baseState: AgentState = {
596
+ id: uuidv4(),
597
+ messages: [],
598
+ data: {},
599
+ currentStep: this.entryPoint ?? '',
600
+ history: [],
601
+ createdAt: now,
602
+ updatedAt: now,
603
+ metadata,
604
+ ...options?.initialState,
605
+ };
606
+
607
+ return baseState as TState;
608
+ }
609
+
610
+ private async getNodeFromCheckpoint(checkpointId: string): Promise<string> {
611
+ if (!this.checkpointer) {
612
+ throw new Error('Checkpointer not configured');
613
+ }
614
+
615
+ const checkpoint = await this.checkpointer.load(checkpointId);
616
+ if (!checkpoint) {
617
+ throw new Error(`Checkpoint "${checkpointId}" not found`);
618
+ }
619
+
620
+ return checkpoint.nodeName;
621
+ }
622
+
623
+ private async executeNodeWithRetry(
624
+ node: NodeDefinition<TState>,
625
+ state: TState,
626
+ executionId: string,
627
+ iterationCount: number,
628
+ ): Promise<NodeResult<TState>> {
629
+ const retryConfig = { ...this.config.retry, ...node.config.retry };
630
+ let lastError: Error | null = null;
631
+ let retryCount = 0;
632
+
633
+ const context: NodeContext = {
634
+ node: node as unknown as NodeDefinition,
635
+ graph: this.getConfig(),
636
+ executionId,
637
+ iterationCount,
638
+ services: {
639
+ logger: this.logger,
640
+ checkpointer: this._checkpointer,
641
+ toolRegistry: this.services.toolRegistry,
642
+ llmProvider: this.services.llmProvider,
643
+ },
644
+ };
645
+
646
+ while (retryCount <= retryConfig.maxRetries) {
647
+ try {
648
+ // Execute pre-hooks
649
+ let currentState = state;
650
+ if (node.preHooks) {
651
+ for (const hook of node.preHooks) {
652
+ currentState = await hook(currentState, context);
653
+ }
654
+ }
655
+
656
+ // Execute node
657
+ const startTime = Date.now();
658
+ let result = await node.execute(currentState, context);
659
+ const duration = Date.now() - startTime;
660
+
661
+ // Execute post-hooks
662
+ if (node.postHooks) {
663
+ for (const hook of node.postHooks) {
664
+ result = {
665
+ ...result,
666
+ state: await hook(result.state, context),
667
+ };
668
+ }
669
+ }
670
+
671
+ // Validate output if schema provided
672
+ if (node.outputSchema) {
673
+ node.outputSchema.parse(result.state);
674
+ }
675
+
676
+ return {
677
+ ...result,
678
+ metadata: {
679
+ ...result.metadata,
680
+ duration,
681
+ retryCount,
682
+ },
683
+ };
684
+ } catch (error) {
685
+ lastError = error instanceof Error ? error : new Error(String(error));
686
+ const errorCode = (error as { code?: string }).code ?? 'UNKNOWN';
687
+
688
+ if (
689
+ !retryConfig.retryableErrors.includes(errorCode) ||
690
+ retryCount >= retryConfig.maxRetries
691
+ ) {
692
+ throw error;
693
+ }
694
+
695
+ const delay = Math.min(
696
+ retryConfig.initialDelay *
697
+ Math.pow(retryConfig.backoffMultiplier, retryCount),
698
+ retryConfig.maxDelay,
699
+ );
700
+
701
+ this.logger.warn(`Retrying node ${node.name} after ${delay}ms`, {
702
+ attempt: retryCount + 1,
703
+ error: lastError.message,
704
+ });
705
+
706
+ await this.sleep(delay);
707
+ retryCount++;
708
+ }
709
+ }
710
+
711
+ throw lastError ?? new Error('Unknown error during node execution');
712
+ }
713
+
714
+ private updateStateWithHistory(
715
+ newState: TState,
716
+ nodeName: string,
717
+ previousState: TState,
718
+ ): TState {
719
+ const changes = this.computeStateChanges(previousState, newState);
720
+
721
+ const historyEntry: StateHistoryEntry = {
722
+ step: nodeName,
723
+ timestamp: new Date(),
724
+ snapshot: {
725
+ currentStep: previousState.currentStep,
726
+ data: { ...previousState.data },
727
+ },
728
+ changes,
729
+ };
730
+
731
+ return {
732
+ ...newState,
733
+ currentStep: nodeName,
734
+ history: [...previousState.history, historyEntry],
735
+ updatedAt: new Date(),
736
+ metadata: {
737
+ ...newState.metadata,
738
+ stepCount: previousState.metadata.stepCount + 1,
739
+ },
740
+ } as TState;
741
+ }
742
+
743
+ private computeStateChanges(
744
+ previous: TState,
745
+ current: TState,
746
+ ): StateChange[] {
747
+ const changes: StateChange[] = [];
748
+
749
+ // Compare data fields
750
+ const allKeys = new Set([
751
+ ...Object.keys(previous.data),
752
+ ...Object.keys(current.data),
753
+ ]);
754
+
755
+ for (const key of allKeys) {
756
+ const prevValue = previous.data[key];
757
+ const currValue = current.data[key];
758
+
759
+ if (JSON.stringify(prevValue) !== JSON.stringify(currValue)) {
760
+ changes.push({
761
+ path: `data.${key}`,
762
+ previousValue: prevValue,
763
+ newValue: currValue,
764
+ });
765
+ }
766
+ }
767
+
768
+ // Compare messages
769
+ if (current.messages.length !== previous.messages.length) {
770
+ changes.push({
771
+ path: 'messages',
772
+ previousValue: previous.messages.length,
773
+ newValue: current.messages.length,
774
+ });
775
+ }
776
+
777
+ return changes;
778
+ }
779
+
780
+ private async determineNextNode(
781
+ currentNode: string,
782
+ result: NodeResult<TState>,
783
+ state: TState,
784
+ ): Promise<string | undefined> {
785
+ // If node explicitly specifies next
786
+ if (result.next) {
787
+ if (Array.isArray(result.next)) {
788
+ // For parallel execution, return the first one
789
+ // (proper parallel execution would need more complex handling)
790
+ return result.next[0];
791
+ }
792
+ return result.next;
793
+ }
794
+
795
+ // Evaluate edges
796
+ const edges = this.edges.get(currentNode) ?? [];
797
+
798
+ for (const edge of edges) {
799
+ const shouldFollow = await this.evaluateEdgeCondition(
800
+ edge,
801
+ result,
802
+ state,
803
+ );
804
+ if (shouldFollow) {
805
+ return edge.to;
806
+ }
807
+ }
808
+
809
+ // No matching edge - end of workflow
810
+ return undefined;
811
+ }
812
+
813
+ private async evaluateEdgeCondition(
814
+ edge: EdgeDefinition,
815
+ result: NodeResult<TState>,
816
+ state: TState,
817
+ ): Promise<boolean> {
818
+ // Direct edges always follow
819
+ if (edge.type === 'direct' || edge.type === 'parallel') {
820
+ return true;
821
+ }
822
+
823
+ // Conditional and loop edges need condition evaluation
824
+ if (!edge.condition) {
825
+ return true;
826
+ }
827
+
828
+ const { condition } = edge;
829
+ const context = {
830
+ edge,
831
+ sourceResult: result,
832
+ graph: this.getConfig(),
833
+ };
834
+
835
+ switch (condition.type) {
836
+ case 'equals':
837
+ return this.getFieldValue(state, condition.field) === condition.value;
838
+
839
+ case 'not_equals':
840
+ return this.getFieldValue(state, condition.field) !== condition.value;
841
+
842
+ case 'contains': {
843
+ const fieldValue = this.getFieldValue(state, condition.field);
844
+ return (
845
+ Array.isArray(fieldValue) && fieldValue.includes(condition.value)
846
+ );
847
+ }
848
+
849
+ case 'greater_than':
850
+ return (
851
+ (this.getFieldValue(state, condition.field) as number) >
852
+ (condition.value as number)
853
+ );
854
+
855
+ case 'less_than':
856
+ return (
857
+ (this.getFieldValue(state, condition.field) as number) <
858
+ (condition.value as number)
859
+ );
860
+
861
+ case 'exists':
862
+ return this.getFieldValue(state, condition.field) !== undefined;
863
+
864
+ case 'not_exists':
865
+ return this.getFieldValue(state, condition.field) === undefined;
866
+
867
+ case 'custom':
868
+ if (condition.evaluate) {
869
+ return await condition.evaluate(state, context);
870
+ }
871
+ return false;
872
+
873
+ default:
874
+ return false;
875
+ }
876
+ }
877
+
878
+ private getFieldValue(state: TState, field?: string): unknown {
879
+ if (!field) {
880
+ return undefined;
881
+ }
882
+
883
+ const parts = field.split('.');
884
+ let current: unknown = state;
885
+
886
+ for (const part of parts) {
887
+ if (current === null || current === undefined) {
888
+ return undefined;
889
+ }
890
+ current = (current as Record<string, unknown>)[part];
891
+ }
892
+
893
+ return current;
894
+ }
895
+
896
+ private async createCheckpoint(
897
+ state: TState,
898
+ nodeName: string,
899
+ executionId: string,
900
+ stepNumber: number,
901
+ ): Promise<Checkpoint> {
902
+ const checkpoint: Checkpoint = {
903
+ id: uuidv4(),
904
+ executionId,
905
+ stepNumber,
906
+ nodeName,
907
+ state,
908
+ timestamp: new Date(),
909
+ };
910
+
911
+ if (this.checkpointer) {
912
+ await this.checkpointer.save(checkpoint);
913
+ }
914
+
915
+ return checkpoint;
916
+ }
917
+
918
+ private createError(
919
+ code: string,
920
+ message: string,
921
+ node?: string,
922
+ ): WorkflowError {
923
+ return {
924
+ code,
925
+ message,
926
+ node,
927
+ recoverable: ['TIMEOUT', 'RATE_LIMIT', 'NETWORK_ERROR'].includes(code),
928
+ recoveryHints: this.getRecoveryHints(code),
929
+ };
930
+ }
931
+
932
+ private normalizeError(error: unknown, node: string): WorkflowError {
933
+ if (this.isWorkflowError(error)) {
934
+ return error;
935
+ }
936
+
937
+ const err = error instanceof Error ? error : new Error(String(error));
938
+ return {
939
+ code: (error as { code?: string }).code ?? 'EXECUTION_ERROR',
940
+ message: err.message,
941
+ stack: err.stack,
942
+ node,
943
+ recoverable: false,
944
+ };
945
+ }
946
+
947
+ private isWorkflowError(error: unknown): error is WorkflowError {
948
+ return (
949
+ typeof error === 'object' &&
950
+ error !== null &&
951
+ 'code' in error &&
952
+ 'message' in error &&
953
+ 'recoverable' in error
954
+ );
955
+ }
956
+
957
+ private getRecoveryHints(code: string): string[] {
958
+ const hints: Record<string, string[]> = {
959
+ TIMEOUT: [
960
+ 'Increase timeout configuration',
961
+ 'Check network connectivity',
962
+ 'Reduce payload size',
963
+ ],
964
+ RATE_LIMIT: [
965
+ 'Add delay between requests',
966
+ 'Reduce concurrency',
967
+ 'Contact API provider',
968
+ ],
969
+ NETWORK_ERROR: [
970
+ 'Check network connectivity',
971
+ 'Verify endpoint availability',
972
+ 'Check firewall settings',
973
+ ],
974
+ MAX_ITERATIONS: [
975
+ 'Review loop conditions',
976
+ 'Increase maxIterations config',
977
+ 'Check for infinite loops',
978
+ ],
979
+ ABORTED: [
980
+ 'Execution was cancelled by user',
981
+ 'Resume from checkpoint if needed',
982
+ ],
983
+ };
984
+ return hints[code] ?? [];
985
+ }
986
+
987
+ private sleep(ms: number): Promise<void> {
988
+ return new Promise(resolve => setTimeout(resolve, ms));
989
+ }
990
+ }