@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,623 @@
1
+ /**
2
+ * Loop Edge - Cyclic edges for iterative workflows
3
+ * @module @wundr.io/langgraph-orchestrator
4
+ */
5
+
6
+ import { z } from 'zod';
7
+
8
+ import type {
9
+ AgentState,
10
+ EdgeDefinition,
11
+ EdgeCondition,
12
+ EdgeConditionEvaluator,
13
+ EdgeContext,
14
+ } from '../types';
15
+
16
+ /**
17
+ * Configuration for loop behavior
18
+ */
19
+ export interface LoopConfig {
20
+ /** Maximum number of iterations */
21
+ readonly maxIterations?: number;
22
+ /** Counter field in state.data */
23
+ readonly counterField?: string;
24
+ /** Condition to continue looping */
25
+ readonly condition: EdgeCondition;
26
+ /** What to do when max iterations reached */
27
+ readonly onMaxIterations?: 'error' | 'exit' | 'force-exit';
28
+ /** Exit node when loop completes */
29
+ readonly exitNode?: string;
30
+ }
31
+
32
+ /**
33
+ * Builder for loop edges with fluent API
34
+ */
35
+ export class LoopEdgeBuilder<TState extends AgentState = AgentState> {
36
+ private readonly from: string;
37
+ private readonly to: string;
38
+ private maxIterations: number = 100;
39
+ private counterField: string = '__loopCounter';
40
+ private condition?: EdgeCondition;
41
+ private exitNode?: string;
42
+ private onMaxIterations: 'error' | 'exit' | 'force-exit' = 'exit';
43
+
44
+ /**
45
+ * Create a new loop edge builder
46
+ * @param from - Source node name
47
+ * @param to - Target node name (can be same as from for self-loop)
48
+ */
49
+ constructor(from: string, to?: string) {
50
+ this.from = from;
51
+ this.to = to ?? from; // Default to self-loop
52
+ }
53
+
54
+ /**
55
+ * Set maximum iterations
56
+ * @param max - Maximum number of loop iterations
57
+ * @returns this for chaining
58
+ */
59
+ maxIter(max: number): this {
60
+ this.maxIterations = max;
61
+ return this;
62
+ }
63
+
64
+ /**
65
+ * Set the counter field name in state
66
+ * @param field - Field name for storing iteration count
67
+ * @returns this for chaining
68
+ */
69
+ counter(field: string): this {
70
+ this.counterField = field;
71
+ return this;
72
+ }
73
+
74
+ /**
75
+ * Set condition to continue looping
76
+ * @param condition - Condition that must be true to continue
77
+ * @returns this for chaining
78
+ */
79
+ while(condition: EdgeCondition): this {
80
+ this.condition = condition;
81
+ return this;
82
+ }
83
+
84
+ /**
85
+ * Continue while field equals value
86
+ * @param field - Field to check
87
+ * @param value - Expected value
88
+ * @returns this for chaining
89
+ */
90
+ whileEquals(field: string, value: unknown): this {
91
+ return this.while({ type: 'equals', field, value });
92
+ }
93
+
94
+ /**
95
+ * Continue while field is less than value
96
+ * @param field - Field to check
97
+ * @param value - Maximum value
98
+ * @returns this for chaining
99
+ */
100
+ whileLessThan(field: string, value: number): this {
101
+ return this.while({ type: 'less_than', field, value });
102
+ }
103
+
104
+ /**
105
+ * Continue while custom condition is true
106
+ * @param evaluate - Custom evaluator function
107
+ * @returns this for chaining
108
+ */
109
+ whileCustom(evaluate: EdgeConditionEvaluator<TState>): this {
110
+ return this.while({
111
+ type: 'custom',
112
+ evaluate: evaluate as EdgeConditionEvaluator,
113
+ });
114
+ }
115
+
116
+ /**
117
+ * Set the exit node when loop completes
118
+ * @param node - Node to transition to after loop
119
+ * @returns this for chaining
120
+ */
121
+ exitTo(node: string): this {
122
+ this.exitNode = node;
123
+ return this;
124
+ }
125
+
126
+ /**
127
+ * Set behavior when max iterations reached
128
+ * @param behavior - What to do on max iterations
129
+ * @returns this for chaining
130
+ */
131
+ onMax(behavior: 'error' | 'exit' | 'force-exit'): this {
132
+ this.onMaxIterations = behavior;
133
+ return this;
134
+ }
135
+
136
+ /**
137
+ * Build the loop edge configuration
138
+ * @returns LoopConfig object
139
+ */
140
+ build(): LoopConfig {
141
+ if (!this.condition) {
142
+ throw new Error(
143
+ 'Loop condition is required. Call while() before build()',
144
+ );
145
+ }
146
+
147
+ return {
148
+ maxIterations: this.maxIterations,
149
+ counterField: this.counterField,
150
+ condition: this.condition,
151
+ onMaxIterations: this.onMaxIterations,
152
+ exitNode: this.exitNode,
153
+ };
154
+ }
155
+
156
+ /**
157
+ * Build as EdgeDefinition
158
+ * @returns EdgeDefinition for the loop
159
+ */
160
+ buildEdge(): EdgeDefinition {
161
+ if (!this.condition) {
162
+ throw new Error(
163
+ 'Loop condition is required. Call while() before buildEdge()',
164
+ );
165
+ }
166
+
167
+ return {
168
+ from: this.from,
169
+ to: this.to,
170
+ type: 'loop',
171
+ condition: this.createLoopCondition(),
172
+ metadata: {
173
+ maxIterations: this.maxIterations,
174
+ counterField: this.counterField,
175
+ onMaxIterations: this.onMaxIterations,
176
+ exitNode: this.exitNode,
177
+ },
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Create the composite loop condition
183
+ */
184
+ private createLoopCondition(): EdgeCondition {
185
+ const originalCondition = this.condition!;
186
+ const maxIterations = this.maxIterations;
187
+ const counterField = this.counterField;
188
+ const onMaxIterations = this.onMaxIterations;
189
+
190
+ return {
191
+ type: 'custom',
192
+ evaluate: async (state: AgentState, context: EdgeContext) => {
193
+ // Get current iteration count
194
+ const currentCount = (state.data[counterField] as number) ?? 0;
195
+
196
+ // Check max iterations
197
+ if (currentCount >= maxIterations) {
198
+ if (onMaxIterations === 'error') {
199
+ throw new Error(
200
+ `Loop exceeded maximum iterations (${maxIterations})`,
201
+ );
202
+ }
203
+ return false; // Exit loop
204
+ }
205
+
206
+ // Evaluate the actual condition
207
+ return await evaluateCondition(originalCondition, state, context);
208
+ },
209
+ };
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Create a loop edge builder
215
+ *
216
+ * @example
217
+ * ```typescript
218
+ * const loopEdge = loopEdge('process')
219
+ * .maxIter(10)
220
+ * .whileLessThan('data.retryCount', 3)
221
+ * .exitTo('success')
222
+ * .onMax('exit')
223
+ * .buildEdge();
224
+ *
225
+ * graph.addLoopEdge(loopEdge.from, loopEdge.to, loopEdge.condition!);
226
+ * ```
227
+ *
228
+ * @param from - Source node name
229
+ * @param to - Optional target node (defaults to from for self-loop)
230
+ * @returns LoopEdgeBuilder
231
+ */
232
+ export function loopEdge<TState extends AgentState = AgentState>(
233
+ from: string,
234
+ to?: string,
235
+ ): LoopEdgeBuilder<TState> {
236
+ return new LoopEdgeBuilder<TState>(from, to);
237
+ }
238
+
239
+ /**
240
+ * Create a for-loop style edge
241
+ *
242
+ * @example
243
+ * ```typescript
244
+ * const forLoop = createForLoop({
245
+ * from: 'process-item',
246
+ * iterations: 5,
247
+ * counterField: 'data.itemIndex',
248
+ * exitNode: 'complete'
249
+ * });
250
+ * ```
251
+ *
252
+ * @param options - For loop configuration
253
+ * @returns EdgeDefinition for the loop
254
+ */
255
+ export function createForLoop(options: {
256
+ from: string;
257
+ to?: string;
258
+ iterations: number;
259
+ counterField?: string;
260
+ exitNode?: string;
261
+ }): EdgeDefinition {
262
+ const counterField = options.counterField ?? '__forLoopCounter';
263
+
264
+ return {
265
+ from: options.from,
266
+ to: options.to ?? options.from,
267
+ type: 'loop',
268
+ condition: {
269
+ type: 'custom',
270
+ evaluate: async (state: AgentState) => {
271
+ const current = (state.data[counterField] as number) ?? 0;
272
+ return current < options.iterations;
273
+ },
274
+ },
275
+ metadata: {
276
+ loopType: 'for',
277
+ iterations: options.iterations,
278
+ counterField,
279
+ exitNode: options.exitNode,
280
+ },
281
+ };
282
+ }
283
+
284
+ /**
285
+ * Create a while-loop style edge
286
+ *
287
+ * @example
288
+ * ```typescript
289
+ * const whileLoop = createWhileLoop({
290
+ * from: 'check',
291
+ * to: 'process',
292
+ * condition: conditions.lessThan('data.errorCount', 5),
293
+ * exitNode: 'done',
294
+ * maxIterations: 100
295
+ * });
296
+ * ```
297
+ *
298
+ * @param options - While loop configuration
299
+ * @returns EdgeDefinition for the loop
300
+ */
301
+ export function createWhileLoop(options: {
302
+ from: string;
303
+ to?: string;
304
+ condition: EdgeCondition;
305
+ exitNode?: string;
306
+ maxIterations?: number;
307
+ }): EdgeDefinition {
308
+ const maxIterations = options.maxIterations ?? 1000;
309
+
310
+ return {
311
+ from: options.from,
312
+ to: options.to ?? options.from,
313
+ type: 'loop',
314
+ condition: {
315
+ type: 'custom',
316
+ evaluate: async (state: AgentState, context: EdgeContext) => {
317
+ // Check iteration guard
318
+ const iterations =
319
+ context.graph?.config?.maxIterations ?? maxIterations;
320
+ if (state.metadata.stepCount >= iterations) {
321
+ return false;
322
+ }
323
+
324
+ return await evaluateCondition(options.condition, state, context);
325
+ },
326
+ },
327
+ metadata: {
328
+ loopType: 'while',
329
+ exitNode: options.exitNode,
330
+ maxIterations,
331
+ },
332
+ };
333
+ }
334
+
335
+ /**
336
+ * Create a do-while loop (executes at least once)
337
+ *
338
+ * @example
339
+ * ```typescript
340
+ * const doWhileLoop = createDoWhileLoop({
341
+ * from: 'retry',
342
+ * condition: conditions.equals('data.success', false),
343
+ * exitNode: 'complete',
344
+ * maxIterations: 5
345
+ * });
346
+ * ```
347
+ *
348
+ * @param options - Do-while loop configuration
349
+ * @returns EdgeDefinition for the loop
350
+ */
351
+ export function createDoWhileLoop(options: {
352
+ from: string;
353
+ to?: string;
354
+ condition: EdgeCondition;
355
+ exitNode?: string;
356
+ maxIterations?: number;
357
+ }): EdgeDefinition {
358
+ const maxIterations = options.maxIterations ?? 1000;
359
+
360
+ return {
361
+ from: options.from,
362
+ to: options.to ?? options.from,
363
+ type: 'loop',
364
+ condition: {
365
+ type: 'custom',
366
+ evaluate: async (state: AgentState, context: EdgeContext) => {
367
+ // Check iteration guard
368
+ const iterations =
369
+ context.graph?.config?.maxIterations ?? maxIterations;
370
+ if (state.metadata.stepCount >= iterations) {
371
+ return false;
372
+ }
373
+
374
+ // First iteration always executes (check if this is first time through)
375
+ const loopCount = (state.data['__doWhileCount'] as number) ?? 0;
376
+ if (loopCount === 0) {
377
+ return true;
378
+ }
379
+
380
+ return await evaluateCondition(options.condition, state, context);
381
+ },
382
+ },
383
+ metadata: {
384
+ loopType: 'do-while',
385
+ exitNode: options.exitNode,
386
+ maxIterations,
387
+ },
388
+ };
389
+ }
390
+
391
+ /**
392
+ * Create a retry loop with exponential backoff
393
+ *
394
+ * @example
395
+ * ```typescript
396
+ * const retryLoop = createRetryLoop({
397
+ * from: 'api-call',
398
+ * maxRetries: 3,
399
+ * exitOnSuccess: 'process-result',
400
+ * exitOnFailure: 'handle-error',
401
+ * successCondition: conditions.equals('data.success', true)
402
+ * });
403
+ * ```
404
+ *
405
+ * @param options - Retry loop configuration
406
+ * @returns EdgeDefinition for the loop
407
+ */
408
+ export function createRetryLoop(options: {
409
+ from: string;
410
+ to?: string;
411
+ maxRetries: number;
412
+ retryCountField?: string;
413
+ successCondition: EdgeCondition;
414
+ exitOnSuccess?: string;
415
+ exitOnFailure?: string;
416
+ }): EdgeDefinition {
417
+ const retryCountField = options.retryCountField ?? '__retryCount';
418
+
419
+ return {
420
+ from: options.from,
421
+ to: options.to ?? options.from,
422
+ type: 'loop',
423
+ condition: {
424
+ type: 'custom',
425
+ evaluate: async (state: AgentState, context: EdgeContext) => {
426
+ const retryCount = (state.data[retryCountField] as number) ?? 0;
427
+
428
+ // Check if we've succeeded
429
+ const succeeded = await evaluateCondition(
430
+ options.successCondition,
431
+ state,
432
+ context,
433
+ );
434
+ if (succeeded) {
435
+ return false; // Exit loop to success node
436
+ }
437
+
438
+ // Check if we've exhausted retries
439
+ if (retryCount >= options.maxRetries) {
440
+ return false; // Exit loop to failure node
441
+ }
442
+
443
+ // Continue retrying
444
+ return true;
445
+ },
446
+ },
447
+ metadata: {
448
+ loopType: 'retry',
449
+ maxRetries: options.maxRetries,
450
+ retryCountField,
451
+ exitOnSuccess: options.exitOnSuccess,
452
+ exitOnFailure: options.exitOnFailure,
453
+ },
454
+ };
455
+ }
456
+
457
+ /**
458
+ * Create a pagination loop for iterating through paged data
459
+ *
460
+ * @example
461
+ * ```typescript
462
+ * const paginationLoop = createPaginationLoop({
463
+ * from: 'fetch-page',
464
+ * pageField: 'data.currentPage',
465
+ * hasMoreField: 'data.hasMore',
466
+ * exitNode: 'process-all'
467
+ * });
468
+ * ```
469
+ *
470
+ * @param options - Pagination loop configuration
471
+ * @returns EdgeDefinition for the loop
472
+ */
473
+ export function createPaginationLoop(options: {
474
+ from: string;
475
+ to?: string;
476
+ pageField?: string;
477
+ hasMoreField: string;
478
+ maxPages?: number;
479
+ exitNode?: string;
480
+ }): EdgeDefinition {
481
+ const pageField = options.pageField ?? '__currentPage';
482
+ const maxPages = options.maxPages ?? 1000;
483
+
484
+ return {
485
+ from: options.from,
486
+ to: options.to ?? options.from,
487
+ type: 'loop',
488
+ condition: {
489
+ type: 'custom',
490
+ evaluate: async (state: AgentState) => {
491
+ const currentPage = (state.data[pageField] as number) ?? 0;
492
+
493
+ // Check page limit
494
+ if (currentPage >= maxPages) {
495
+ return false;
496
+ }
497
+
498
+ // Check if there are more pages
499
+ const hasMore = getFieldValue(state, options.hasMoreField);
500
+ return hasMore === true;
501
+ },
502
+ },
503
+ metadata: {
504
+ loopType: 'pagination',
505
+ pageField,
506
+ hasMoreField: options.hasMoreField,
507
+ maxPages,
508
+ exitNode: options.exitNode,
509
+ },
510
+ };
511
+ }
512
+
513
+ /**
514
+ * Evaluate a condition against state
515
+ */
516
+ async function evaluateCondition(
517
+ condition: EdgeCondition,
518
+ state: AgentState,
519
+ context: EdgeContext,
520
+ ): Promise<boolean> {
521
+ const fieldValue = condition.field
522
+ ? getFieldValue(state, condition.field)
523
+ : undefined;
524
+
525
+ switch (condition.type) {
526
+ case 'equals':
527
+ return fieldValue === condition.value;
528
+
529
+ case 'not_equals':
530
+ return fieldValue !== condition.value;
531
+
532
+ case 'contains':
533
+ if (Array.isArray(fieldValue)) {
534
+ return fieldValue.includes(condition.value);
535
+ }
536
+ if (typeof fieldValue === 'string') {
537
+ return fieldValue.includes(String(condition.value));
538
+ }
539
+ return false;
540
+
541
+ case 'greater_than':
542
+ return (
543
+ typeof fieldValue === 'number' &&
544
+ typeof condition.value === 'number' &&
545
+ fieldValue > condition.value
546
+ );
547
+
548
+ case 'less_than':
549
+ return (
550
+ typeof fieldValue === 'number' &&
551
+ typeof condition.value === 'number' &&
552
+ fieldValue < condition.value
553
+ );
554
+
555
+ case 'exists':
556
+ return fieldValue !== undefined && fieldValue !== null;
557
+
558
+ case 'not_exists':
559
+ return fieldValue === undefined || fieldValue === null;
560
+
561
+ case 'custom':
562
+ if (condition.evaluate) {
563
+ return await condition.evaluate(state, context);
564
+ }
565
+ return false;
566
+
567
+ default:
568
+ return false;
569
+ }
570
+ }
571
+
572
+ /**
573
+ * Get a nested field value from state
574
+ */
575
+ function getFieldValue(state: AgentState, field: string): unknown {
576
+ const parts = field.split('.');
577
+ let current: unknown = state;
578
+
579
+ for (const part of parts) {
580
+ if (current === null || current === undefined) {
581
+ return undefined;
582
+ }
583
+ current = (current as Record<string, unknown>)[part];
584
+ }
585
+
586
+ return current;
587
+ }
588
+
589
+ /**
590
+ * Schema for loop configuration validation
591
+ */
592
+ export const LoopConfigSchema = z.object({
593
+ maxIterations: z.number().min(1).optional(),
594
+ counterField: z.string().optional(),
595
+ condition: z.object({
596
+ type: z.enum([
597
+ 'equals',
598
+ 'not_equals',
599
+ 'contains',
600
+ 'greater_than',
601
+ 'less_than',
602
+ 'exists',
603
+ 'not_exists',
604
+ 'custom',
605
+ ]),
606
+ field: z.string().optional(),
607
+ value: z.unknown().optional(),
608
+ }),
609
+ onMaxIterations: z.enum(['error', 'exit', 'force-exit']).optional(),
610
+ exitNode: z.string().optional(),
611
+ });
612
+
613
+ /**
614
+ * Validate a loop configuration
615
+ */
616
+ export function validateLoopConfig(config: LoopConfig): boolean {
617
+ try {
618
+ LoopConfigSchema.parse(config);
619
+ return true;
620
+ } catch {
621
+ return false;
622
+ }
623
+ }