Skip to content

Commit 047ff70

Browse files
authored
feat(core): support multi-step doWhile/doUntil loops (#1064)
1 parent 42be052 commit 047ff70

File tree

8 files changed

+217
-34
lines changed

8 files changed

+217
-34
lines changed

.changeset/giant-coins-twirl.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@voltagent/core": patch
3+
---
4+
5+
Add multi-step loop bodies for `andDoWhile` and `andDoUntil`.
6+
7+
- Loop steps now accept either a single `step` or a sequential `steps` array.
8+
- When `steps` is provided, each iteration runs the steps in order and feeds each output into the next step.
9+
- Workflow step serialization now includes loop `subSteps` when a loop has multiple steps.
10+
- Added runtime and type tests for chained loop steps.

packages/core/src/workflow/core.spec-d.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expectTypeOf, it } from "vitest";
2-
import { andForEach, andThen } from "./";
2+
import { andDoWhile, andForEach, andThen } from "./";
33
import type { WorkflowExecuteContext } from "./internal/types";
44
import type { WorkflowStateStore, WorkflowStateUpdater } from "./types";
55

@@ -55,6 +55,44 @@ describe("non-chaining API type inference", () => {
5555
});
5656
});
5757

58+
describe("andDoWhile", () => {
59+
it("should infer condition data from the last chained loop step", () => {
60+
const step = andDoWhile({
61+
id: "loop",
62+
steps: [
63+
andThen({
64+
id: "step-1",
65+
execute: async (
66+
context: WorkflowExecuteContext<
67+
{ input: string },
68+
{ value: number },
69+
unknown,
70+
unknown
71+
>,
72+
) => ({ value: context.data.value + 1 }),
73+
}),
74+
andThen({
75+
id: "step-2",
76+
execute: async (
77+
context: WorkflowExecuteContext<
78+
{ input: string },
79+
{ value: number },
80+
unknown,
81+
unknown
82+
>,
83+
) => context.data.value,
84+
}),
85+
],
86+
condition: async (context) => {
87+
expectTypeOf(context.data).toEqualTypeOf<number>();
88+
return context.data < 3;
89+
},
90+
});
91+
92+
expectTypeOf(step).not.toBeNever();
93+
});
94+
});
95+
5896
describe("type parity with chaining API", () => {
5997
it("WorkflowExecuteContext should match chaining API context shape", () => {
6098
// Verify WorkflowExecuteContext has all expected properties

packages/core/src/workflow/core.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2642,9 +2642,17 @@ export function serializeWorkflowStep(step: BaseStep, index: number): Serialized
26422642
case "loop": {
26432643
const loopStep = step as WorkflowStep<unknown, unknown, unknown, unknown> & {
26442644
step?: BaseStep;
2645+
steps?: BaseStep[];
26452646
condition?: (...args: any[]) => unknown;
26462647
loopType?: "dowhile" | "dountil";
26472648
};
2649+
const serializedSteps =
2650+
loopStep.steps && Array.isArray(loopStep.steps)
2651+
? loopStep.steps.map((subStep, subIndex) => serializeWorkflowStep(subStep, subIndex))
2652+
: loopStep.step
2653+
? [serializeWorkflowStep(loopStep.step, 0)]
2654+
: [];
2655+
26482656
return {
26492657
...baseStep,
26502658
...(loopStep.condition && {
@@ -2653,8 +2661,12 @@ export function serializeWorkflowStep(step: BaseStep, index: number): Serialized
26532661
...(loopStep.loopType && {
26542662
loopType: loopStep.loopType,
26552663
}),
2656-
...(loopStep.step && {
2657-
nestedStep: serializeWorkflowStep(loopStep.step, 0),
2664+
...(serializedSteps.length === 1 && {
2665+
nestedStep: serializedSteps[0],
2666+
}),
2667+
...(serializedSteps.length > 1 && {
2668+
subSteps: serializedSteps,
2669+
subStepsCount: serializedSteps.length,
26582670
}),
26592671
};
26602672
}

packages/core/src/workflow/steps/and-loop.spec.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,39 @@ describe("andLoop", () => {
4141

4242
expect(result).toBe(2);
4343
});
44+
45+
it("supports multiple chained steps in each loop iteration", async () => {
46+
const step = andDoWhile({
47+
id: "loop",
48+
steps: [
49+
andThen({
50+
id: "increment",
51+
execute: async ({ data }) => data + 1,
52+
}),
53+
andThen({
54+
id: "double",
55+
execute: async ({ data }) => data * 2,
56+
}),
57+
],
58+
condition: async ({ data }) => data < 20,
59+
});
60+
61+
const result = await step.execute(
62+
createMockWorkflowExecuteContext({
63+
data: 1,
64+
}),
65+
);
66+
67+
expect(result).toBe(22);
68+
});
69+
70+
it("throws when loop steps array is empty", () => {
71+
expect(() =>
72+
andDoUntil({
73+
id: "loop",
74+
steps: [] as never,
75+
condition: async () => true,
76+
}),
77+
).toThrow("andDoWhile/andDoUntil requires at least one step");
78+
});
4479
});

packages/core/src/workflow/steps/and-loop.ts

Lines changed: 69 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,60 +2,100 @@ import type { Span } from "@opentelemetry/api";
22
import { defaultStepConfig } from "../internal/utils";
33
import { matchStep } from "./helpers";
44
import { throwIfAborted } from "./signal";
5-
import type { WorkflowStepLoop, WorkflowStepLoopConfig } from "./types";
5+
import type { WorkflowStepLoop, WorkflowStepLoopConfig, WorkflowStepLoopSteps } from "./types";
66

77
type LoopType = "dowhile" | "dountil";
8+
type LoopStepMetadata = { id: string; name?: string; purpose?: string; retries?: number };
9+
10+
function splitLoopConfig<INPUT, DATA, RESULT>(config: WorkflowStepLoopConfig<INPUT, DATA, RESULT>) {
11+
if ("step" in config) {
12+
const { step: _step, condition, ...stepConfig } = config;
13+
return { condition, stepConfig: stepConfig as LoopStepMetadata };
14+
}
15+
16+
const { steps: _steps, condition, ...stepConfig } = config;
17+
return { condition, stepConfig: stepConfig as LoopStepMetadata };
18+
}
19+
20+
function getLoopSteps<INPUT, DATA, RESULT>(
21+
config: WorkflowStepLoopConfig<INPUT, DATA, RESULT>,
22+
): WorkflowStepLoopSteps<INPUT, DATA, RESULT> {
23+
if ("steps" in config && config.steps) {
24+
if (config.steps.length === 0) {
25+
throw new Error("andDoWhile/andDoUntil requires at least one step");
26+
}
27+
return config.steps;
28+
}
29+
30+
return [config.step];
31+
}
832

933
const createLoopStep = <INPUT, DATA, RESULT>(
1034
loopType: LoopType,
11-
{ step, condition, ...config }: WorkflowStepLoopConfig<INPUT, DATA, RESULT>,
35+
config: WorkflowStepLoopConfig<INPUT, DATA, RESULT>,
1236
) => {
13-
const finalStep = matchStep(step);
37+
const { condition, stepConfig } = splitLoopConfig(config);
38+
const loopSteps = getLoopSteps(config);
39+
const resolvedSteps = loopSteps.map((loopStep) => matchStep(loopStep));
40+
const firstStep = loopSteps[0];
41+
42+
if (!firstStep) {
43+
throw new Error("andDoWhile/andDoUntil requires at least one step");
44+
}
1445

1546
return {
16-
...defaultStepConfig(config),
47+
...defaultStepConfig(stepConfig),
1748
type: "loop",
1849
loopType,
19-
step,
50+
step: firstStep,
51+
steps: loopSteps,
2052
condition,
2153
execute: async (context) => {
2254
const { state } = context;
2355
const traceContext = state.workflowContext?.traceContext;
2456
let currentData = context.data as DATA | RESULT;
2557
let iteration = 0;
2658

27-
while (true) {
28-
throwIfAborted(state.signal);
59+
const runResolvedStep = async (stepIndex: number) => {
60+
const resolvedStep = resolvedSteps[stepIndex];
61+
if (!resolvedStep) {
62+
return;
63+
}
2964

3065
let childSpan: Span | undefined;
3166
if (traceContext) {
67+
const isSingleLoopStep = resolvedSteps.length === 1;
3268
childSpan = traceContext.createStepSpan(
33-
iteration,
34-
finalStep.type,
35-
finalStep.name || finalStep.id || `Loop ${iteration + 1}`,
69+
iteration * resolvedSteps.length + stepIndex,
70+
resolvedStep.type,
71+
resolvedStep.name ||
72+
resolvedStep.id ||
73+
(isSingleLoopStep
74+
? `Loop ${iteration + 1}`
75+
: `Loop ${iteration + 1}.${stepIndex + 1}`),
3676
{
37-
parentStepId: config.id,
38-
parallelIndex: iteration,
77+
parentStepId: stepConfig.id,
78+
parallelIndex: isSingleLoopStep ? iteration : stepIndex,
3979
input: currentData,
4080
attributes: {
4181
"workflow.step.loop": true,
4282
"workflow.step.parent_type": "loop",
4383
"workflow.step.loop_type": loopType,
84+
"workflow.step.loop_iteration": iteration,
85+
"workflow.step.loop_step_index": stepIndex,
4486
},
4587
},
4688
);
4789
}
4890

49-
const subState = {
50-
...state,
51-
workflowContext: undefined,
52-
};
53-
5491
const executeStep = () =>
55-
finalStep.execute({
92+
resolvedStep.execute({
5693
...context,
57-
data: currentData as DATA,
58-
state: subState,
94+
data: currentData as never,
95+
state: {
96+
...state,
97+
workflowContext: undefined,
98+
},
5999
});
60100

61101
try {
@@ -73,6 +113,15 @@ const createLoopStep = <INPUT, DATA, RESULT>(
73113
}
74114
throw error;
75115
}
116+
};
117+
118+
while (true) {
119+
throwIfAborted(state.signal);
120+
121+
for (let stepIndex = 0; stepIndex < resolvedSteps.length; stepIndex += 1) {
122+
throwIfAborted(state.signal);
123+
await runResolvedStep(stepIndex);
124+
}
76125

77126
iteration += 1;
78127
throwIfAborted(state.signal);

packages/core/src/workflow/steps/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export type {
3232
WorkflowStepForEachItemsFunc,
3333
WorkflowStepForEachMapFunc,
3434
WorkflowStepLoopConfig,
35+
WorkflowStepLoopSteps,
3536
WorkflowStepBranchConfig,
3637
WorkflowStepMapConfig,
3738
WorkflowStepMapEntry,

packages/core/src/workflow/steps/types.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -195,16 +195,44 @@ export interface WorkflowStepForEach<INPUT, DATA, ITEM, RESULT, MAP_DATA = ITEM>
195195
map?: WorkflowStepForEachMapFunc<INPUT, DATA, ITEM, MAP_DATA>;
196196
}
197197

198-
export type WorkflowStepLoopConfig<INPUT, DATA, RESULT> = InternalWorkflowStepConfig<{
199-
step: InternalAnyWorkflowStep<INPUT, DATA, RESULT>;
198+
export type WorkflowStepLoopSteps<INPUT, DATA, RESULT> =
199+
| readonly [InternalAnyWorkflowStep<INPUT, DATA, RESULT>]
200+
| readonly [
201+
InternalAnyWorkflowStep<INPUT, DATA, DangerouslyAllowAny>,
202+
...InternalAnyWorkflowStep<INPUT, DangerouslyAllowAny, DangerouslyAllowAny>[],
203+
InternalAnyWorkflowStep<INPUT, DangerouslyAllowAny, RESULT>,
204+
];
205+
206+
type WorkflowStepLoopBaseConfig<INPUT, RESULT> = InternalWorkflowStepConfig<{
200207
condition: InternalWorkflowFunc<INPUT, RESULT, boolean, any, any>;
201208
}>;
202209

210+
type WorkflowStepLoopSingleStepConfig<INPUT, DATA, RESULT> = WorkflowStepLoopBaseConfig<
211+
INPUT,
212+
RESULT
213+
> & {
214+
step: InternalAnyWorkflowStep<INPUT, DATA, RESULT>;
215+
steps?: never;
216+
};
217+
218+
type WorkflowStepLoopMultiStepConfig<INPUT, DATA, RESULT> = WorkflowStepLoopBaseConfig<
219+
INPUT,
220+
RESULT
221+
> & {
222+
steps: WorkflowStepLoopSteps<INPUT, DATA, RESULT>;
223+
step?: never;
224+
};
225+
226+
export type WorkflowStepLoopConfig<INPUT, DATA, RESULT> =
227+
| WorkflowStepLoopSingleStepConfig<INPUT, DATA, RESULT>
228+
| WorkflowStepLoopMultiStepConfig<INPUT, DATA, RESULT>;
229+
203230
export interface WorkflowStepLoop<INPUT, DATA, RESULT>
204231
extends InternalBaseWorkflowStep<INPUT, DATA, RESULT, any, any> {
205232
type: "loop";
206233
loopType: "dowhile" | "dountil";
207-
step: InternalAnyWorkflowStep<INPUT, DATA, RESULT>;
234+
step: InternalAnyWorkflowStep<INPUT, DATA, DangerouslyAllowAny>;
235+
steps: WorkflowStepLoopSteps<INPUT, DATA, RESULT>;
208236
condition: InternalWorkflowFunc<INPUT, RESULT, boolean, any, any>;
209237
}
210238

website/docs/workflows/steps/and-loop.md

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# andLoop
22

3-
> Repeat a step with do-while or do-until semantics.
3+
> Repeat one or more steps with do-while or do-until semantics.
44
55
## Quick Start
66

@@ -15,10 +15,16 @@ const workflow = createWorkflowChain({
1515
input: z.number(),
1616
}).andDoWhile({
1717
id: "increment-until-3",
18-
step: andThen({
19-
id: "increment",
20-
execute: async ({ data }) => data + 1,
21-
}),
18+
steps: [
19+
andThen({
20+
id: "increment",
21+
execute: async ({ data }) => data + 1,
22+
}),
23+
andThen({
24+
id: "double",
25+
execute: async ({ data }) => data * 2,
26+
}),
27+
],
2228
condition: ({ data }) => data < 3,
2329
});
2430
```
@@ -47,7 +53,8 @@ const workflow = createWorkflowChain({
4753
```typescript
4854
.andDoWhile({
4955
id: string,
50-
step: Step,
56+
step?: Step,
57+
steps?: Step[],
5158
condition: (ctx) => boolean | Promise<boolean>,
5259
retries?: number,
5360
name?: string,
@@ -56,7 +63,8 @@ const workflow = createWorkflowChain({
5663

5764
.andDoUntil({
5865
id: string,
59-
step: Step,
66+
step?: Step,
67+
steps?: Step[],
6068
condition: (ctx) => boolean | Promise<boolean>,
6169
retries?: number,
6270
name?: string,
@@ -66,5 +74,7 @@ const workflow = createWorkflowChain({
6674

6775
## Notes
6876

69-
- The step runs at least once.
77+
- Provide either `step` (single step) or `steps` (multiple sequential steps).
78+
- The configured step(s) run at least once.
79+
- When `steps` is provided, steps run in order on each iteration.
7080
- The loop continues until the condition fails (do-while) or succeeds (do-until).

0 commit comments

Comments
 (0)