@hexabot-ai/agentic 3.1.2-alpha.1 → 3.1.2-alpha.11

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 (35) hide show
  1. package/dist/cjs/index.js +4 -1
  2. package/dist/cjs/step-executors/loop-executor.js +26 -39
  3. package/dist/cjs/step-executors/parallel-executor.js +9 -15
  4. package/dist/cjs/step-executors/suspension-continuation.js +27 -0
  5. package/dist/cjs/suspension-rebuilder.js +61 -74
  6. package/dist/cjs/utils/workflow-definition-resources.js +73 -0
  7. package/dist/cjs/workflow-runner.js +2 -10
  8. package/dist/esm/index.js +1 -0
  9. package/dist/esm/step-executors/loop-executor.js +26 -39
  10. package/dist/esm/step-executors/parallel-executor.js +9 -15
  11. package/dist/esm/step-executors/suspension-continuation.js +24 -0
  12. package/dist/esm/suspension-rebuilder.js +61 -74
  13. package/dist/esm/utils/workflow-definition-resources.js +68 -0
  14. package/dist/esm/workflow-runner.js +2 -10
  15. package/dist/types/index.d.ts +1 -0
  16. package/dist/types/index.d.ts.map +1 -1
  17. package/dist/types/step-executors/loop-executor.d.ts.map +1 -1
  18. package/dist/types/step-executors/parallel-executor.d.ts.map +1 -1
  19. package/dist/types/step-executors/suspension-continuation.d.ts +10 -0
  20. package/dist/types/step-executors/suspension-continuation.d.ts.map +1 -0
  21. package/dist/types/suspension-rebuilder.d.ts.map +1 -1
  22. package/dist/types/utils/workflow-definition-resources.d.ts +16 -0
  23. package/dist/types/utils/workflow-definition-resources.d.ts.map +1 -0
  24. package/dist/types/workflow-runner.d.ts.map +1 -1
  25. package/package.json +2 -2
  26. package/src/__tests__/suspension-rebuilder.test.ts +43 -9
  27. package/src/__tests__/workflow-definition-resources.test.ts +97 -0
  28. package/src/__tests__/workflow-runner.test.ts +119 -1
  29. package/src/index.ts +8 -0
  30. package/src/step-executors/loop-executor.ts +49 -64
  31. package/src/step-executors/parallel-executor.ts +13 -20
  32. package/src/step-executors/suspension-continuation.ts +31 -0
  33. package/src/suspension-rebuilder.ts +94 -100
  34. package/src/utils/workflow-definition-resources.ts +111 -0
  35. package/src/workflow-runner.ts +4 -11
@@ -588,6 +588,103 @@ describe('WorkflowRunner', () => {
588
588
  }
589
589
  });
590
590
 
591
+ it('continues following steps after a repeatedly suspended while loop exits', async () => {
592
+ const suspendAction = defineAction<
593
+ { prompt: string },
594
+ { reply: string; done: boolean },
595
+ TestContext,
596
+ Settings
597
+ >({
598
+ name: 'loop_suspend_action',
599
+ inputSchema: z.object({ prompt: z.string() }),
600
+ outputSchema: z.object({ reply: z.string(), done: z.boolean() }),
601
+ execute: async ({ input, context }) => {
602
+ const resumeData = (await context.workflow.suspend({
603
+ reason: 'awaiting_reply',
604
+ data: { prompt: input.prompt },
605
+ })) as { reply: string };
606
+
607
+ return {
608
+ reply: resumeData.reply,
609
+ done: resumeData.reply === 'stop',
610
+ };
611
+ },
612
+ });
613
+ const afterExecute = jest.fn(async ({ input }) => ({
614
+ stored: `stored:${input.reply}`,
615
+ }));
616
+ const afterAction = defineAction<
617
+ { reply: string },
618
+ { stored: string },
619
+ TestContext,
620
+ Settings
621
+ >({
622
+ name: 'after_loop_action',
623
+ inputSchema: z.object({ reply: z.string() }),
624
+ outputSchema: z.object({ stored: z.string() }),
625
+ execute: afterExecute,
626
+ });
627
+ const definition: WorkflowDefinition = {
628
+ defs: createTaskDefs({
629
+ wait_step: {
630
+ action: 'loop_suspend_action',
631
+ inputs: { prompt: '="Attempt " & $string($iteration.index)' },
632
+ },
633
+ after_loop_step: {
634
+ action: 'after_loop_action',
635
+ inputs: { reply: '=$output.wait_step.reply' },
636
+ },
637
+ }),
638
+ flow: [
639
+ {
640
+ loop: {
641
+ type: 'while',
642
+ name: 'wait_until_stop',
643
+ while:
644
+ '=$not($exists($output.wait_step.done) and $output.wait_step.done = true)',
645
+ steps: [{ do: 'wait_step' }],
646
+ },
647
+ },
648
+ { do: 'after_loop_step' },
649
+ ],
650
+ outputs: {
651
+ reply: '=$output.wait_step.reply',
652
+ stored: '=$output.after_loop_step.stored',
653
+ },
654
+ };
655
+ const compiled = compileWorkflow(definition, {
656
+ actions: {
657
+ loop_suspend_action: suspendAction,
658
+ after_loop_action: afterAction,
659
+ },
660
+ });
661
+ const runner = new WorkflowRunner(compiled, {
662
+ runId: 'run-while-suspend-after',
663
+ });
664
+ const context = new TestContext({});
665
+ const startResult = await runner.start({ inputData: {}, context });
666
+
667
+ expect(startResult.status).toBe('suspended');
668
+ const firstResume = await runner.resume({
669
+ resumeData: { reply: 'continue' },
670
+ });
671
+
672
+ expect(firstResume.status).toBe('suspended');
673
+ const secondResume = await runner.resume({
674
+ resumeData: { reply: 'stop' },
675
+ });
676
+
677
+ expect(secondResume.status).toBe('finished');
678
+ if (secondResume.status === 'finished') {
679
+ expect(secondResume.output.reply).toBe('stop');
680
+ expect(secondResume.output.stored).toBe('stored:stop');
681
+ }
682
+ expect(afterExecute).toHaveBeenCalledTimes(1);
683
+ expect(afterExecute).toHaveBeenCalledWith(
684
+ expect.objectContaining({ input: { reply: 'stop' } }),
685
+ );
686
+ });
687
+
591
688
  it('handles suspension and resumes with provided data', async () => {
592
689
  const suspendAction = defineAction<
593
690
  { prompt: string },
@@ -994,12 +1091,27 @@ describe('WorkflowRunner', () => {
994
1091
  return { reply: `ack:${resumeData.reply}` };
995
1092
  },
996
1093
  });
1094
+ const afterAction = defineAction<
1095
+ { reply: string },
1096
+ { summary: string },
1097
+ TestContext,
1098
+ Settings
1099
+ >({
1100
+ name: 'after_loop_action',
1101
+ inputSchema: z.object({ reply: z.string() }),
1102
+ outputSchema: z.object({ summary: z.string() }),
1103
+ execute: async ({ input }) => ({ summary: `after:${input.reply}` }),
1104
+ });
997
1105
  const definition: WorkflowDefinition = {
998
1106
  defs: createTaskDefs({
999
1107
  wait_step: {
1000
1108
  action: 'loop_suspend_action',
1001
1109
  inputs: { prompt: '="Ping"' },
1002
1110
  },
1111
+ after_step: {
1112
+ action: 'after_loop_action',
1113
+ inputs: { reply: '=$output.wait_step.reply' },
1114
+ },
1003
1115
  }),
1004
1116
  flow: [
1005
1117
  {
@@ -1010,8 +1122,12 @@ describe('WorkflowRunner', () => {
1010
1122
  steps: [{ do: 'wait_step' }],
1011
1123
  },
1012
1124
  },
1125
+ { do: 'after_step' },
1013
1126
  ],
1014
- outputs: { reply: '=$output.wait_step.reply' },
1127
+ outputs: {
1128
+ reply: '=$output.wait_step.reply',
1129
+ summary: '=$output.after_step.summary',
1130
+ },
1015
1131
  inputs: {
1016
1132
  schema: {
1017
1133
  items: { type: 'array', items: { type: 'string' } },
@@ -1021,6 +1137,7 @@ describe('WorkflowRunner', () => {
1021
1137
  const compiled = compileWorkflow(definition, {
1022
1138
  actions: {
1023
1139
  loop_suspend_action: suspendAction,
1140
+ after_loop_action: afterAction,
1024
1141
  },
1025
1142
  });
1026
1143
  const runner = new WorkflowRunner(compiled);
@@ -1061,6 +1178,7 @@ describe('WorkflowRunner', () => {
1061
1178
  expect(resumeResult.status).toBe('finished');
1062
1179
  if (resumeResult.status === 'finished') {
1063
1180
  expect(resumeResult.output.reply).toBe('ack:Pong');
1181
+ expect(resumeResult.output.summary).toBe('after:ack:Pong');
1064
1182
  }
1065
1183
  });
1066
1184
 
package/src/index.ts CHANGED
@@ -114,3 +114,11 @@ export {
114
114
  } from './utils/naming';
115
115
 
116
116
  export { sleep, withTimeout } from './utils/timeout';
117
+
118
+ export {
119
+ collectWorkflowDefinitionResourceRefs,
120
+ remapWorkflowDefinitionResourceRefs,
121
+ type WorkflowDefinitionResourceDescriptor,
122
+ type WorkflowDefinitionResourceIdMaps,
123
+ type WorkflowDefinitionResourceRefs,
124
+ } from './utils/workflow-definition-resources';
@@ -14,6 +14,7 @@ import type {
14
14
  } from '../workflow-types';
15
15
  import { evaluateValue } from '../workflow-values';
16
16
 
17
+ import { wrapSuspensionContinuation } from './suspension-continuation';
17
18
  import type { StepExecutorEnv } from './types';
18
19
 
19
20
  type LoopScope = EvaluationScope;
@@ -74,45 +75,37 @@ async function executeForEachLoop(
74
75
  ]);
75
76
 
76
77
  if (suspension) {
77
- return {
78
- ...suspension,
79
- continue: async (resumeData: unknown) => {
80
- const next = await suspension.continue(resumeData);
81
- if (next) {
82
- return next;
78
+ return wrapSuspensionContinuation(suspension, async () => {
79
+ const postScope = buildScope(
80
+ env,
81
+ iterationState,
82
+ { item, index },
83
+ accumulator,
84
+ );
85
+ accumulator = await updateAccumulator(step, postScope, accumulator);
86
+
87
+ const shouldStop = await shouldStopLoop(step, postScope);
88
+ if (shouldStop) {
89
+ state.accumulator = accumulator;
90
+ if (step.accumulate && step.name) {
91
+ state.output[step.name] = { [step.accumulate.as]: accumulator };
83
92
  }
84
93
 
85
- const postScope = buildScope(
86
- env,
87
- iterationState,
88
- { item, index },
89
- accumulator,
90
- );
91
- accumulator = await updateAccumulator(step, postScope, accumulator);
92
-
93
- const shouldStop = await shouldStopLoop(step, postScope);
94
- if (shouldStop) {
95
- state.accumulator = accumulator;
96
- if (step.accumulate && step.name) {
97
- state.output[step.name] = { [step.accumulate.as]: accumulator };
98
- }
99
-
100
- return undefined;
101
- }
94
+ return undefined;
95
+ }
102
96
 
103
- return executeForEachLoop(
104
- env,
105
- step,
106
- {
107
- ...iterationState,
108
- accumulator,
109
- iterationStack: state.iterationStack,
110
- },
111
- path,
112
- index + 1,
113
- );
114
- },
115
- };
97
+ return executeForEachLoop(
98
+ env,
99
+ step,
100
+ {
101
+ ...iterationState,
102
+ accumulator,
103
+ iterationStack: state.iterationStack,
104
+ },
105
+ path,
106
+ index + 1,
107
+ );
108
+ });
116
109
  }
117
110
 
118
111
  const postScope = buildScope(
@@ -170,35 +163,27 @@ async function executeWhileLoop(
170
163
  ]);
171
164
 
172
165
  if (suspension) {
173
- return {
174
- ...suspension,
175
- continue: async (resumeData: unknown) => {
176
- const next = await suspension.continue(resumeData);
177
- if (next) {
178
- return next;
179
- }
180
-
181
- const postScope = buildScope(
182
- env,
183
- iterationState,
184
- { item: undefined, index },
166
+ return wrapSuspensionContinuation(suspension, async () => {
167
+ const postScope = buildScope(
168
+ env,
169
+ iterationState,
170
+ { item: undefined, index },
171
+ accumulator,
172
+ );
173
+ accumulator = await updateAccumulator(step, postScope, accumulator);
174
+
175
+ return executeWhileLoop(
176
+ env,
177
+ step,
178
+ {
179
+ ...iterationState,
185
180
  accumulator,
186
- );
187
- accumulator = await updateAccumulator(step, postScope, accumulator);
188
-
189
- return executeWhileLoop(
190
- env,
191
- step,
192
- {
193
- ...iterationState,
194
- accumulator,
195
- iterationStack: state.iterationStack,
196
- },
197
- path,
198
- index + 1,
199
- );
200
- },
201
- };
181
+ iterationStack: state.iterationStack,
182
+ },
183
+ path,
184
+ index + 1,
185
+ );
186
+ });
202
187
  }
203
188
 
204
189
  const postScope = buildScope(
@@ -11,6 +11,7 @@ import type {
11
11
  } from '../workflow-types';
12
12
 
13
13
  import { markStepsSkipped } from './skip-helpers';
14
+ import { wrapSuspensionContinuation } from './suspension-continuation';
14
15
  import type { StepExecutorEnv } from './types';
15
16
 
16
17
  /**
@@ -35,29 +36,21 @@ export async function executeParallel(
35
36
  const childPath = [...path, 'parallel', index];
36
37
  const suspension = await env.executeStep(child, state, childPath);
37
38
  if (suspension) {
38
- return {
39
- ...suspension,
40
- continue: async (resumeData: unknown) => {
41
- const next = await suspension.continue(resumeData);
42
- if (next) {
43
- return next;
39
+ return wrapSuspensionContinuation(suspension, async () => {
40
+ if (step.strategy === 'wait_any') {
41
+ if (index + 1 < step.steps.length) {
42
+ markStepsSkipped(
43
+ env,
44
+ step.steps.slice(index + 1),
45
+ state.iterationStack,
46
+ );
44
47
  }
45
48
 
46
- if (step.strategy === 'wait_any') {
47
- if (index + 1 < step.steps.length) {
48
- markStepsSkipped(
49
- env,
50
- step.steps.slice(index + 1),
51
- state.iterationStack,
52
- );
53
- }
49
+ return undefined;
50
+ }
54
51
 
55
- return undefined;
56
- }
57
-
58
- return executeParallel(env, step, state, path, index + 1);
59
- },
60
- };
52
+ return executeParallel(env, step, state, path, index + 1);
53
+ });
61
54
  }
62
55
 
63
56
  if (step.strategy === 'wait_any') {
@@ -0,0 +1,31 @@
1
+ /*
2
+ * Hexabot — Fair Core License (FCL-1.0-ALv2)
3
+ * Copyright (c) 2025 Hexastack.
4
+ * Full terms: see LICENSE.md.
5
+ */
6
+
7
+ import type { Suspension } from '../workflow-types';
8
+
9
+ /**
10
+ * Compose a suspension with the continuation that should run once it completes.
11
+ *
12
+ * When resuming a suspension yields another suspension from the same logical
13
+ * step, the follow-up suspension still needs to remember how to continue the
14
+ * surrounding flow after that step eventually finishes.
15
+ */
16
+ export function wrapSuspensionContinuation(
17
+ suspension: Suspension,
18
+ onComplete: () => Promise<Suspension | void>,
19
+ ): Suspension {
20
+ return {
21
+ ...suspension,
22
+ continue: async (resumeData: unknown) => {
23
+ const next = await suspension.continue(resumeData);
24
+ if (next) {
25
+ return wrapSuspensionContinuation(next, onComplete);
26
+ }
27
+
28
+ return onComplete();
29
+ },
30
+ };
31
+ }
@@ -12,6 +12,7 @@ import {
12
12
  } from './step-executors/loop-executor';
13
13
  import { executeParallel as runParallelExecutor } from './step-executors/parallel-executor';
14
14
  import { markStepsSkipped } from './step-executors/skip-helpers';
15
+ import { wrapSuspensionContinuation } from './step-executors/suspension-continuation';
15
16
  import type { StepExecutorEnv } from './step-executors/types';
16
17
  import {
17
18
  StepType,
@@ -272,17 +273,9 @@ export function buildSuspensionForPath(
272
273
 
273
274
  const resumed = await executeStep(step, state, currentPath);
274
275
  if (resumed) {
275
- return {
276
- ...resumed,
277
- continue: async (nextResumeData: unknown) => {
278
- const next = await resumed.continue(nextResumeData);
279
- if (next) {
280
- return next;
281
- }
282
-
283
- return deps.executeFlow(steps, state, pathPrefix, current + 1);
284
- },
285
- };
276
+ return wrapSuspensionContinuation(resumed, () =>
277
+ deps.executeFlow(steps, state, pathPrefix, current + 1),
278
+ );
286
279
  }
287
280
 
288
281
  return deps.executeFlow(steps, state, pathPrefix, current + 1);
@@ -324,35 +317,35 @@ export function buildSuspensionForPath(
324
317
  const childIndex =
325
318
  typeof childPath[0] === 'number' ? (childPath[0] as number) : 0;
326
319
 
327
- return {
328
- ...childSuspension,
329
- continue: async (resumeData: unknown) => {
330
- const next = await childSuspension.continue(resumeData);
331
- if (next) {
332
- return next;
333
- }
334
-
335
- if (step.strategy === 'wait_any') {
336
- if (childIndex + 1 < step.steps.length) {
337
- markStepsSkipped(
338
- env,
339
- step.steps.slice(childIndex + 1),
340
- state.iterationStack ?? [],
341
- );
342
- }
343
-
344
- return undefined;
320
+ return wrapSuspensionContinuation(childSuspension, async () => {
321
+ if (step.strategy === 'wait_any') {
322
+ if (childIndex + 1 < step.steps.length) {
323
+ markStepsSkipped(
324
+ env,
325
+ step.steps.slice(childIndex + 1),
326
+ state.iterationStack ?? [],
327
+ );
345
328
  }
346
329
 
347
- return runParallelExecutor(
348
- env,
349
- step,
350
- state,
351
- currentPath,
352
- childIndex + 1,
330
+ return deps.executeFlow(steps, state, pathPrefix, current + 1);
331
+ }
332
+
333
+ const next = await runParallelExecutor(
334
+ env,
335
+ step,
336
+ state,
337
+ currentPath,
338
+ childIndex + 1,
339
+ );
340
+
341
+ if (next) {
342
+ return wrapSuspensionContinuation(next, () =>
343
+ deps.executeFlow(steps, state, pathPrefix, current + 1),
353
344
  );
354
- },
355
- };
345
+ }
346
+
347
+ return deps.executeFlow(steps, state, pathPrefix, current + 1);
348
+ });
356
349
  }
357
350
 
358
351
  if (step.type === StepType.Conditional) {
@@ -382,17 +375,9 @@ export function buildSuspensionForPath(
382
375
  return null;
383
376
  }
384
377
 
385
- return {
386
- ...branchSuspension,
387
- continue: async (resumeData: unknown) => {
388
- const next = await branchSuspension.continue(resumeData);
389
- if (next) {
390
- return next;
391
- }
392
-
393
- return deps.executeFlow(steps, state, pathPrefix, current + 1);
394
- },
395
- };
378
+ return wrapSuspensionContinuation(branchSuspension, () =>
379
+ deps.executeFlow(steps, state, pathPrefix, current + 1),
380
+ );
396
381
  }
397
382
 
398
383
  if (step.type === StepType.Loop) {
@@ -441,63 +426,72 @@ export function buildSuspensionForPath(
441
426
  const accumulator =
442
427
  iterationState.accumulator ?? state.accumulator ?? undefined;
443
428
 
444
- return {
445
- ...childSuspension,
446
- continue: async (resumeData: unknown) => {
447
- const next = await childSuspension.continue(resumeData);
448
- if (next) {
449
- return next;
450
- }
451
-
452
- const scope: EvaluationScope = {
453
- input: iterationState.input,
454
- context: env.context.state,
455
- output: iterationState.output,
456
- iteration: iterationState.iteration ?? {
457
- item: undefined,
458
- index: iterationIndex,
459
- },
460
- accumulator,
429
+ return wrapSuspensionContinuation(childSuspension, async () => {
430
+ const scope: EvaluationScope = {
431
+ input: iterationState.input,
432
+ context: env.context.state,
433
+ output: iterationState.output,
434
+ iteration: iterationState.iteration ?? {
435
+ item: undefined,
436
+ index: iterationIndex,
437
+ },
438
+ accumulator,
439
+ };
440
+ const updatedAccumulator = await updateAccumulator(
441
+ step,
442
+ scope,
443
+ accumulator,
444
+ );
445
+ const shouldStop = await shouldStopLoop(step, scope);
446
+
447
+ state.output = iterationState.output;
448
+
449
+ if (step.accumulate && step.name) {
450
+ state.output[step.name] = {
451
+ [step.accumulate.as]: updatedAccumulator,
461
452
  };
462
- const updatedAccumulator = await updateAccumulator(
463
- step,
464
- scope,
465
- accumulator,
466
- );
467
- const shouldStop = await shouldStopLoop(step, scope);
468
-
469
- state.output = iterationState.output;
470
-
471
- if (step.accumulate && step.name) {
472
- state.output[step.name] = {
473
- [step.accumulate.as]: updatedAccumulator,
474
- };
475
- }
453
+ }
476
454
 
477
- if (step.accumulate) {
478
- state.accumulator = updatedAccumulator;
479
- }
455
+ if (step.accumulate) {
456
+ state.accumulator = updatedAccumulator;
457
+ }
480
458
 
481
- if (shouldStop) {
482
- return undefined;
483
- }
459
+ if (shouldStop) {
460
+ return deps.executeFlow(steps, state, pathPrefix, current + 1);
461
+ }
462
+
463
+ const baseState: ExecutionState = {
464
+ ...iterationState,
465
+ iterationStack: ancestorIterationStack,
466
+ accumulator: updatedAccumulator,
467
+ output: state.output,
468
+ };
469
+ const next = await runLoopExecutor(
470
+ env,
471
+ step,
472
+ baseState,
473
+ currentPath,
474
+ iterationIndex + 1,
475
+ );
476
+
477
+ state.output = baseState.output;
478
+ if (step.accumulate) {
479
+ state.accumulator = baseState.accumulator;
480
+ }
481
+
482
+ if (next) {
483
+ return wrapSuspensionContinuation(next, async () => {
484
+ state.output = baseState.output;
485
+ if (step.accumulate) {
486
+ state.accumulator = baseState.accumulator;
487
+ }
484
488
 
485
- const baseState: ExecutionState = {
486
- ...iterationState,
487
- iterationStack: ancestorIterationStack,
488
- accumulator: updatedAccumulator,
489
- output: state.output,
490
- };
489
+ return deps.executeFlow(steps, state, pathPrefix, current + 1);
490
+ });
491
+ }
491
492
 
492
- return runLoopExecutor(
493
- env,
494
- step,
495
- baseState,
496
- currentPath,
497
- iterationIndex + 1,
498
- );
499
- },
500
- };
493
+ return deps.executeFlow(steps, state, pathPrefix, current + 1);
494
+ });
501
495
  }
502
496
 
503
497
  return null;