Skip to content

feat(workflow): add execution primitives for step context#1102

Merged
omeraplak merged 4 commits into
mainfrom
feat/workflow-execution-primitives
Feb 22, 2026
Merged

feat(workflow): add execution primitives for step context#1102
omeraplak merged 4 commits into
mainfrom
feat/workflow-execution-primitives

Conversation

@omeraplak

@omeraplak omeraplak commented Feb 22, 2026

Copy link
Copy Markdown
Member

PR Checklist

Please check if your PR fulfills the following requirements:

Bugs / Features

What is the current behavior?

Workflow step context did not expose execution primitives for terminal flow control and stable introspection. There was no bail(), abort(), typed getStepResult(), or getInitData() API in the step runtime/context surface.

What is the new behavior?

Adds execution primitives to workflow step context:

  • bail(result?): complete the workflow early as completed with an optional final result
  • abort(): terminate the workflow as cancelled
  • getStepResult(stepId): read prior step output safely from context
  • getInitData(): read original workflow input across normal runs and resume cycles

Also includes:

  • core runtime support for bail/abort control signals and terminal handling
  • tests for bail/abort/getStepResult/getInitData semantics (including resume flow)
  • docs updates in workflow overview + suspend/resume docs
  • changeset with usage examples

fixes (issue)

N/A

Notes for reviewers

  • Verified with:
    • pnpm --filter @voltagent/core test -- src/workflow/core.spec.ts src/workflow/core.spec-d.ts
    • pnpm --filter voltagent-example-with-workflow exec tsx -e '...execution-primitives smoke...' (prints execution-primitives smoke ok)
  • docs/workflow-parity-plans/ is intentionally not included in this PR.

Summary by cubic

Adds execution primitives to the workflow step context for early finish, cancellation, and safe introspection. Runtime now types bail payloads, handles bail/abort consistently (including nested steps), and preserves the original input across resumes.

  • New Features

    • bail(result?): finish early as completed with an optional, typed final result.
    • abort(): cancel immediately with a clear reason that includes the step name.
    • getStepResult(stepId): read a prior step’s output safely (returns null if missing).
    • getInitData(): access the original workflow input across runs and resume cycles.
  • Bug Fixes

    • Unified bail handling and typing across the runtime; properly closes the bail step span with “bailed” trace attributes.
    • Handle bail() with no result (final output is null).
    • Update bail state before onStepEnd so hooks and events see the final output.

Written for commit f5d1d02. Summary will update on new commits.

Summary by CodeRabbit

  • New Features

    • Added four workflow execution primitives available in all step contexts (including nested flows): bail(result?), abort(), getStepResult(stepId), and getInitData().
  • Documentation

    • Added guides and examples demonstrating bail, abort, getStepResult, and getInitData usage, including suspend/resume scenarios.
  • Tests

    • Added comprehensive tests covering primitives, nested behavior, suspend/resume stability, persistence, and finalization flows.

@changeset-bot

changeset-bot Bot commented Feb 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f5d1d02

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@voltagent/core Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@ghost

This comment has been minimized.

@coderabbitai

coderabbitai Bot commented Feb 22, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

Adds four step-context primitives — bail(result?), abort(), getStepResult(stepId), and getInitData() — available in all step contexts. Implements internal signals/handlers, integrates bail/abort into execution/finalization, and updates types, mocks, tests, and docs.

Changes

Cohort / File(s) Summary
Type Definitions
packages/core/src/workflow/internal/types.ts, packages/core/src/workflow/core.spec-d.ts
Introduce WORKFLOW_RESULT generic threading; add bail(result?), abort(), getStepResult<T>(stepId), and getInitData<T>() to execution context types and spec assertions.
Core Execution Logic
packages/core/src/workflow/core.ts
Add WorkflowBailSignal/WorkflowAbortSignal, signal guards, completeBail and completeSuccessfulExecution helpers; integrate bail/abort into step try/catch, cancellation resolution, finalization, and tracing.
Step Context Creation
packages/core/src/workflow/internal/utils.ts
Extend createStepExecutionContext to accept optional bailFn/abortFn; expose bail, abort, getStepResult, and getInitData on the returned context with safe defaults that throw configuration errors.
Mocks / Test Utilities
packages/core/src/test-utils/mocks/workflows.ts
Add overridable mock implementations for getStepResult, getInitData, bail, and abort in the workflow mock factory (defaults: null / empty object and throwing handlers).
Functional Tests
packages/core/src/workflow/core.spec.ts
Add tests for bail (with/without result), abort, getStepResult, getInitData (including suspend/resume stability), nested bail flows; import/export adjustments (andWhen).
Documentation
website/docs/workflows/overview.md, website/docs/workflows/suspend-resume.md
Document "Execution Primitives in Step Context" with examples for bail(), abort(), getStepResult(), and getInitData(); note: overview.md contains a duplicated section.
Changelog
.changeset/fresh-otters-melt.md
Changelog entry describing the four new primitives with usage examples.
Manifest
package.json
Manifest lines changed (packaging metadata touched).

Sequence Diagram

sequenceDiagram
    participant Step as Step Execution
    participant Ctx as StepExecuteContext
    participant Core as Core Orchestrator
    participant Store as Memory/Store

    Step->>Ctx: call bail(result?)
    Ctx->>Core: throw WorkflowBailSignal
    Core->>Core: completeBail() / finalize workflow
    Core->>Store: persist final output & metadata
    Core->>Store: emit step-complete, workflow-complete
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~30 minutes

Possibly related PRs

Suggested reviewers

  • lzj960515

Poem

🐰 I hopped through steps with ears held high,
A bail to finish, an abort to fly,
Init data kept when resumes restart,
Step results tucked close to the heart,
Small primitives, big workflow sighs ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely summarizes the main feature being added: execution primitives (bail, abort, getStepResult, getInitData) for the step context in workflows.
Description check ✅ Passed The PR description comprehensively covers all required template sections: checklist completion, clear explanation of current vs. new behavior, test/doc/changeset additions, and detailed reviewer notes with verification steps.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/workflow-execution-primitives

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 9 files

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/core/src/workflow/core.ts">

<violation number="1" location="packages/core/src/workflow/core.ts:1851">
P2: Bug: `onStepEnd` hook receives stale state during bail. The state manager is not updated with `finalResult` before the hook call — `completeSuccessfulExecution` updates it afterwards. In the normal execution path, `stateManager.update({ data: result, result })` happens *before* `onStepEnd`, so this is an inconsistency that will surface incorrect data to hook consumers.</violation>

<violation number="2" location="packages/core/src/workflow/core.ts:1859">
P2: Design concern: `bail()` bypasses output guardrails. When a step bails early, `completeSuccessfulExecution` is called directly from `completeBail` without running through the `applyWorkflowOutputGuardrails` check that guards the normal completion path. If this is intentional, consider adding a code comment; otherwise, the bail result should also be validated by output guardrails.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread packages/core/src/workflow/core.ts Outdated
output: finalResult,
});

return completeSuccessfulExecution(finalResult, {

@cubic-dev-ai cubic-dev-ai Bot Feb 22, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Design concern: bail() bypasses output guardrails. When a step bails early, completeSuccessfulExecution is called directly from completeBail without running through the applyWorkflowOutputGuardrails check that guards the normal completion path. If this is intentional, consider adding a code comment; otherwise, the bail result should also be validated by output guardrails.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/src/workflow/core.ts, line 1859:

<comment>Design concern: `bail()` bypasses output guardrails. When a step bails early, `completeSuccessfulExecution` is called directly from `completeBail` without running through the `applyWorkflowOutputGuardrails` check that guards the normal completion path. If this is intentional, consider adding a code comment; otherwise, the bail result should also be validated by output guardrails.</comment>

<file context>
@@ -1688,11 +1809,70 @@ export function createWorkflow<
+              output: finalResult,
+            });
+
+            return completeSuccessfulExecution(finalResult, {
+              stepId: step.id,
+              stepName,
</file context>
Fix with Cubic

Comment thread packages/core/src/workflow/core.ts Outdated
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Feb 22, 2026

Copy link
Copy Markdown

Deploying voltagent with  Cloudflare Pages  Cloudflare Pages

Latest commit: f5d1d02
Status: ✅  Deploy successful!
Preview URL: https://2cb58fad.voltagent.pages.dev
Branch Preview URL: https://feat-workflow-execution-prim.voltagent.pages.dev

View logs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
packages/core/src/workflow/core.ts (1)

2488-2507: Outer catch bail handling is a reasonable safety net, but step span may be orphaned.

If a WorkflowBailSignal somehow escapes the step-level catch and reaches this outer catch, completeSuccessfulExecution is called directly without ending the current step span (unlike completeBail which calls traceContext.endStepSpan). This could leave an orphaned trace span. In practice this path should be rare, but it's worth noting.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/workflow/core.ts` around lines 2488 - 2507, The outer catch
handling for isWorkflowBailSignal returns via completeSuccessfulExecution but
does not end the current step trace span, risking an orphaned span; update this
branch (inside the isWorkflowBailSignal block) to mirror completeBail by calling
traceContext.endStepSpan(...) for the current step (use
executionContext.currentStepIndex and the bailStep info/ids) before returning
completeSuccessfulExecution so the step span is properly closed; ensure you use
the same traceContext methods and arguments pattern as completeBail and still
pass the finalResult and bail step metadata into completeSuccessfulExecution.
packages/core/src/workflow/core.spec.ts (1)

337-381: Add a test for bail() with no arguments

The PR description documents bail(result?)result is optional — but all bail tests invoke it with an argument. An uncovered bail() call could hide a bug in the no-result finalisation path (e.g., result.result should be null/undefined and status should still be "completed").

💡 Suggested additional test case
+  it("should support bail() with no result to complete early with null output", async () => {
+    const memory = new Memory({ storage: new InMemoryStorageAdapter() });
+
+    const workflow = createWorkflow(
+      {
+        id: "execution-primitives-bail-no-result",
+        name: "Execution Primitives Bail No Result",
+        input: z.object({ value: z.number() }),
+        result: z.object({ final: z.number() }),
+        memory,
+      },
+      andThen({
+        id: "bail-no-result",
+        execute: async ({ bail }) => {
+          bail();
+        },
+      }),
+    );
+
+    const registry = WorkflowRegistry.getInstance();
+    registry.registerWorkflow(workflow);
+
+    const result = await workflow.run({ value: 1 });
+
+    expect(result.status).toBe("completed");
+    expect(result.result).toBeNull();
+  });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/workflow/core.spec.ts` around lines 337 - 381, Add a new
spec that mirrors the existing "should support bail(result)..." test but calls
bail() with no argument to verify the workflow completes with no result value;
locate the test setup using createWorkflow, andThen blocks (use a prepare step,
a "bail-step" whose execute calls bail() with no args, and a subsequent step
that must not run), run the workflow and assert result.status === "completed",
result.result is null or undefined (adjust expectation to the project's
convention), final step flag remains false, and the persisted memory
(Memory.getWorkflowState) shows status "completed" and no output value.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/core/src/workflow/core.spec.ts`:
- Around line 337-381: Add a new spec that mirrors the existing "should support
bail(result)..." test but calls bail() with no argument to verify the workflow
completes with no result value; locate the test setup using createWorkflow,
andThen blocks (use a prepare step, a "bail-step" whose execute calls bail()
with no args, and a subsequent step that must not run), run the workflow and
assert result.status === "completed", result.result is null or undefined (adjust
expectation to the project's convention), final step flag remains false, and the
persisted memory (Memory.getWorkflowState) shows status "completed" and no
output value.

In `@packages/core/src/workflow/core.ts`:
- Around line 2488-2507: The outer catch handling for isWorkflowBailSignal
returns via completeSuccessfulExecution but does not end the current step trace
span, risking an orphaned span; update this branch (inside the
isWorkflowBailSignal block) to mirror completeBail by calling
traceContext.endStepSpan(...) for the current step (use
executionContext.currentStepIndex and the bailStep info/ids) before returning
completeSuccessfulExecution so the step span is properly closed; ensure you use
the same traceContext methods and arguments pattern as completeBail and still
pass the finalResult and bail step metadata into completeSuccessfulExecution.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/core/src/workflow/core.ts">

<violation number="1" location="packages/core/src/workflow/core.ts:2499">
P2: This bail handling logic is unreachable dead code. Bail signals are fully caught and handled by the inner step execution loop.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread packages/core/src/workflow/core.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/core/src/workflow/core.ts (1)

2261-2279: ⚠️ Potential issue | 🟠 Major

Add RESULT_SCHEMA generic parameter to createStepExecutionContext and constrain bail type

The bail function in the step execution context currently accepts result?: unknown, allowing steps to pass incompatible results without compile-time validation. Since RESULT_SCHEMA is available in scope (the execute() method operates within the Workflow<INPUT_SCHEMA, RESULT_SCHEMA> context), both bailFn and createStepExecutionContext should use it to ensure type safety. The framework currently works around this with a runtime cast (bailSignal.result as z.infer<RESULT_SCHEMA> at lines 1824 and 2498), but steps should have compile-time guarantees. Update createStepExecutionContext to accept a RESULT_SCHEMA generic and type bail: (result?: z.infer<RESULT_SCHEMA>) => never.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/workflow/core.ts` around lines 2261 - 2279, The step
execution context currently allows bail to accept unknown; update
createStepExecutionContext to take an extra RESULT_SCHEMA generic and ensure the
bail function is typed as (result?: z.infer<RESULT_SCHEMA>) => never so steps
get compile-time guarantees; change the call site in execute() to pass the
Workflow's RESULT_SCHEMA generic into createStepExecutionContext and update
bailFn (and any related bailSignal usage) to use z.infer<RESULT_SCHEMA> instead
of relying on runtime casts (e.g., remove casts like bailSignal.result as
z.infer<RESULT_SCHEMA> and surface the proper typed bail through
createStepExecutionContext and bailFn).
🧹 Nitpick comments (1)
packages/core/src/workflow/core.ts (1)

75-83: WorkflowAbortSignal silently relies on the "WORKFLOW_CANCELLED" string contract

WorkflowAbortSignal passes "WORKFLOW_CANCELLED" to super(...) so that the existing stepError.message === "WORKFLOW_CANCELLED" checks at Lines 2378 and 2521 treat it as a cancellation. This is intentional but invisible — if the string literal or the detection predicate ever diverge, abort signals will be misrouted. A brief comment here explaining the dependency would help future maintainers.

💡 Suggested clarification comment
 class WorkflowAbortSignal extends Error {
   readonly reason?: string;

   constructor(reason?: string) {
+    // Message must remain "WORKFLOW_CANCELLED" so the existing WORKFLOW_CANCELLED
+    // detection branches also handle abort signals; isWorkflowAbortSignal then
+    // promotes the step-supplied reason over controller/registry defaults.
     super("WORKFLOW_CANCELLED");
     this.name = "WorkflowAbortSignal";
     this.reason = reason;
   }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/workflow/core.ts` around lines 75 - 83, The
WorkflowAbortSignal class relies on passing the literal "WORKFLOW_CANCELLED" to
Error so external checks that compare stepError.message === "WORKFLOW_CANCELLED"
treat it as a cancellation; add a short clarifying comment above class
WorkflowAbortSignal that documents this contract and its dependency on those
message equality checks, and to make it robust, extract the literal into a
shared constant (e.g., WORKFLOW_CANCELLED) and use that constant inside
WorkflowAbortSignal and wherever the stepError.message is compared so the
contract is explicit and less error-prone.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/core/src/workflow/core.ts`:
- Around line 2493-2518: The outer catch currently handles a WorkflowBailSignal
by calling completeSuccessfulExecution directly and thus skips the step-data
update, emitting the "step-complete" event, and firing hooks.onStepEnd; change
the outer-catch branch that checks isWorkflowBailSignal(error) to delegate to
completeBail(...) instead of calling completeSuccessfulExecution so that
executionContext.stepData is updated, the step-complete event is emitted, and
hooks?.onStepEnd?.(stateManager.state) runs; use the same parameters/step info
you already compute (bailStepIndex, bailStep, bailStepName, finalResult) when
invoking completeBail to match the inner-catch behavior (refer to completeBail,
executionContext, completeSuccessfulExecution, and isWorkflowBailSignal).

---

Outside diff comments:
In `@packages/core/src/workflow/core.ts`:
- Around line 2261-2279: The step execution context currently allows bail to
accept unknown; update createStepExecutionContext to take an extra RESULT_SCHEMA
generic and ensure the bail function is typed as (result?:
z.infer<RESULT_SCHEMA>) => never so steps get compile-time guarantees; change
the call site in execute() to pass the Workflow's RESULT_SCHEMA generic into
createStepExecutionContext and update bailFn (and any related bailSignal usage)
to use z.infer<RESULT_SCHEMA> instead of relying on runtime casts (e.g., remove
casts like bailSignal.result as z.infer<RESULT_SCHEMA> and surface the proper
typed bail through createStepExecutionContext and bailFn).

---

Nitpick comments:
In `@packages/core/src/workflow/core.ts`:
- Around line 75-83: The WorkflowAbortSignal class relies on passing the literal
"WORKFLOW_CANCELLED" to Error so external checks that compare stepError.message
=== "WORKFLOW_CANCELLED" treat it as a cancellation; add a short clarifying
comment above class WorkflowAbortSignal that documents this contract and its
dependency on those message equality checks, and to make it robust, extract the
literal into a shared constant (e.g., WORKFLOW_CANCELLED) and use that constant
inside WorkflowAbortSignal and wherever the stepError.message is compared so the
contract is explicit and less error-prone.

Comment thread packages/core/src/workflow/core.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
packages/core/src/workflow/core.ts (1)

2532-2542: reasonFromAbortError logic duplicated from resolveCancellationReason — consider extracting.

resolveCancellationReason (line 1889) is defined inside the for-loop closure and therefore unavailable in the outer catch. The inline check here re-implements the same isWorkflowAbortSignal(error) && error.reason extraction. If more control-flow paths are added in the future, this duplication will become a maintenance burden.

♻️ Suggested optional refactor — lift a small helper to outer scope

Define a module-level (or executeInternal-scoped) helper:

const extractAbortReason = (err: unknown): string | undefined =>
  isWorkflowAbortSignal(err) && err.reason ? err.reason : undefined;

Then both resolveCancellationReason and the outer catch can delegate to it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/workflow/core.ts` around lines 2532 - 2542, The code
duplicates the abort-reason extraction logic (isWorkflowAbortSignal && .reason)
in both resolveCancellationReason (inside the for-loop) and the outer catch
where reasonFromAbortError is computed; extract that logic into a small shared
helper (e.g., extractAbortReason) at executeInternal/module scope and replace
both resolveCancellationReason and the outer catch's inline check to call
extractAbortReason(error) so both locations delegate to the same function and
avoid duplication; ensure the helper returns string | undefined and keep
existing callers (resolveCancellationReason, the outer catch) unchanged in
behavior.
packages/core/src/workflow/internal/utils.ts (1)

93-99: Unnecessary optional chaining on non-optional executionContext.

executionContext: WorkflowExecutionContext is typed as non-optional, so executionContext?.stepData.get(stepId) silently returns undefined if executionContext is somehow absent at runtime, masking a programming error instead of surfacing it as a TypeError. Remove the ?.:

🔧 Proposed fix
-    getStepResult: <T = unknown>(stepId: string) => {
-      const stepData = executionContext?.stepData.get(stepId);
+    getStepResult: <T = unknown>(stepId: string) => {
+      const stepData = executionContext.stepData.get(stepId);
       if (!stepData || stepData.output === undefined) {
         return null;
       }
       return stepData.output as T;
     },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/workflow/internal/utils.ts` around lines 93 - 99, The
getStepResult function is using optional chaining on a non-optional
executionContext which can mask programming errors; change
executionContext?.stepData.get(stepId) to executionContext.stepData.get(stepId)
in getStepResult so a missing executionContext throws immediately (or add an
explicit guard if you want a clearer error), referencing the getStepResult
method and the executionContext variable to locate the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/core/src/workflow/core.ts`:
- Around line 1683-1748: completeBail currently calls hooks?.onStepEnd before
the stateManager is updated with the bail result, so hooks see stale state; fix
by updating stateManager with the bail result (e.g. call stateManager.update({
data: finalResult, result: finalResult }) or the project-equivalent update
method) immediately after setting stepData/output/status and before invoking
hooks?.onStepEnd, then call completeSuccessfulExecution as before; reference
symbols: completeBail, hooks?.onStepEnd, stateManager.update,
completeSuccessfulExecution, stateManager.state.

---

Nitpick comments:
In `@packages/core/src/workflow/core.ts`:
- Around line 2532-2542: The code duplicates the abort-reason extraction logic
(isWorkflowAbortSignal && .reason) in both resolveCancellationReason (inside the
for-loop) and the outer catch where reasonFromAbortError is computed; extract
that logic into a small shared helper (e.g., extractAbortReason) at
executeInternal/module scope and replace both resolveCancellationReason and the
outer catch's inline check to call extractAbortReason(error) so both locations
delegate to the same function and avoid duplication; ensure the helper returns
string | undefined and keep existing callers (resolveCancellationReason, the
outer catch) unchanged in behavior.

In `@packages/core/src/workflow/internal/utils.ts`:
- Around line 93-99: The getStepResult function is using optional chaining on a
non-optional executionContext which can mask programming errors; change
executionContext?.stepData.get(stepId) to executionContext.stepData.get(stepId)
in getStepResult so a missing executionContext throws immediately (or add an
explicit guard if you want a clearer error), referencing the getStepResult
method and the executionContext variable to locate the change.

Comment thread packages/core/src/workflow/core.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/core/src/workflow/core.ts (1)

1719-1754: Redundant double stateManager.update — optional cleanup

completeBail calls stateManager.update({ data: finalResult, result: finalResult }) at lines 1719–1728 (correctly, before onStepEnd) and then immediately delegates to completeSuccessfulExecution(finalResult, ...) at line 1754, which performs the identical stateManager.update again (lines 1592–1601). The second write is a no-op but can mislead readers into thinking completeSuccessfulExecution is the authoritative owner of the state update.

♻️ Suggested refactor — skip re-update in `completeSuccessfulExecution` when already set

Pass a flag or use a separate internal variant that skips the state update since it was already applied in completeBail:

-  const completeSuccessfulExecution = async (
-    result: z.infer<RESULT_SCHEMA> | null,
-    bailInfo?: {
-      stepId: string;
-      stepName: string;
-      stepIndex: number;
-    },
-  ): Promise<WorkflowExecutionResult<RESULT_SCHEMA, RESUME_SCHEMA>> => {
-    if (result === null) {
-      stateManager.update({ result: null });
-    } else {
-      stateManager.update({ data: result, result });
-    }
+  const completeSuccessfulExecution = async (
+    result: z.infer<RESULT_SCHEMA> | null,
+    bailInfo?: { stepId: string; stepName: string; stepIndex: number },
+    skipStateUpdate = false,
+  ): Promise<WorkflowExecutionResult<RESULT_SCHEMA, RESUME_SCHEMA>> => {
+    if (!skipStateUpdate) {
+      if (result === null) {
+        stateManager.update({ result: null });
+      } else {
+        stateManager.update({ data: result, result });
+      }
+    }

Then in completeBail line 1754:

-   return completeSuccessfulExecution(finalResult, { stepId: step.id, stepName, stepIndex });
+   return completeSuccessfulExecution(finalResult, { stepId: step.id, stepName, stepIndex }, true);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/workflow/core.ts` around lines 1719 - 1754, completeBail
currently updates stateManager with data/result and then calls
completeSuccessfulExecution which performs the same stateManager.update again;
change completeSuccessfulExecution to accept an optional flag (e.g.,
skipStateUpdate or internal variant like completeSuccessfulExecutionNoUpdate)
and in completeBail call that variant or pass the flag to avoid the redundant
stateManager.update call, ensuring you still call
hooks?.onStepEnd?.(stateManager.state) and emit the same event before returning.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/core/src/workflow/core.ts`:
- Around line 2266-2271: The andTap implementation swallows WorkflowBailSignal
and WorkflowAbortSignal because it catches all errors and returns context.data;
update the andTap execute wrapper to detect and re-throw signals using the core
helpers (isWorkflowBailSignal and isWorkflowAbortSignal) before logging and
returning, so that bailFn/abortFn propagate; also audit other nested step
handlers that compare error.message and replace those checks with
isWorkflowBailSignal/isWorkflowAbortSignal to consistently re-throw signals
(refer to symbols: andTap, bailFn, abortFn, WorkflowBailSignal,
WorkflowAbortSignal, isWorkflowBailSignal, isWorkflowAbortSignal).

---

Nitpick comments:
In `@packages/core/src/workflow/core.ts`:
- Around line 1719-1754: completeBail currently updates stateManager with
data/result and then calls completeSuccessfulExecution which performs the same
stateManager.update again; change completeSuccessfulExecution to accept an
optional flag (e.g., skipStateUpdate or internal variant like
completeSuccessfulExecutionNoUpdate) and in completeBail call that variant or
pass the flag to avoid the redundant stateManager.update call, ensuring you
still call hooks?.onStepEnd?.(stateManager.state) and emit the same event before
returning.

Comment on lines +2266 to +2271
const bailFn = (result?: z.infer<RESULT_SCHEMA>): never => {
throw new WorkflowBailSignal<z.infer<RESULT_SCHEMA>>(result);
};
const abortFn = (): never => {
throw new WorkflowAbortSignal(`${WORKFLOW_ABORT_REASON_DEFAULT}: ${stepName}`);
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Look for try/catch blocks in step implementations that might swallow bail/abort signals
find packages/core/src/workflow/steps -name "*.ts" -type f | while read file; do
  echo "=== File: $file ==="
  rg -n -A 10 'catch\s*\(' "$file"
done

Repository: VoltAgent/voltagent

Length of output: 6167


🏁 Script executed:

#!/bin/bash
# Check if nested step files reference WorkflowBailSignal or abort/bail handling
rg -n --type=ts 'WorkflowBailSignal|WorkflowAbortSignal|isWorkflowBailSignal|isWorkflowAbortSignal' packages/core/src/workflow/steps/

Repository: VoltAgent/voltagent

Length of output: 45


🏁 Script executed:

#!/bin/bash
# Also check the core workflow file for how signals are defined and used
rg -n --type=ts 'class WorkflowBailSignal|class WorkflowAbortSignal|isWorkflowBailSignal|isWorkflowAbortSignal' packages/core/src/workflow/

Repository: VoltAgent/voltagent

Length of output: 902


🏁 Script executed:

#!/bin/bash
# Check the full context of and-tap.ts to see if it should be re-throwing
cat -n packages/core/src/workflow/steps/and-tap.ts

Repository: VoltAgent/voltagent

Length of output: 2269


🏁 Script executed:

#!/bin/bash
# Check the full context of and-race.ts to see the catch handling
cat -n packages/core/src/workflow/steps/and-race.ts

Repository: VoltAgent/voltagent

Length of output: 10037


🏁 Script executed:

#!/bin/bash
# Search for actual usage of WorkflowBailSignal/WorkflowAbortSignal in core.ts to understand when they're thrown
rg -n -B 5 -A 5 'throw new WorkflowBailSignal|throw new WorkflowAbortSignal' packages/core/src/workflow/core.ts

Repository: VoltAgent/voltagent

Length of output: 891


Fix signal propagation in nested steps, especially andTap

The concern is partially valid. Most nested step implementations do re-throw errors, allowing WorkflowBailSignal and WorkflowAbortSignal to propagate. However, andTap (lines 54-59 of and-tap.ts) catches all errors without re-throwing:

} catch (error) {
  getGlobalLogger()...error("Error executing tap step", { error: error });
}
return context.data as DATA;  // ❌ Swallows bail/abort signals

Since bail() and abort() are provided in the context, users can (intentionally or accidentally) call them within a tap's execute function. These calls would be silently swallowed, breaking the abort/bail mechanism.

Additionally, other nested steps handle errors through fragile message checks (error.message === "WORKFLOW_SUSPENDED") rather than using the proper signal checkers (isWorkflowBailSignal, isWorkflowAbortSignal) defined in core.ts. This works for the common path but creates maintenance risk.

Fix: In andTap, explicitly check and re-throw signals before logging. Throughout nested step implementations, use isWorkflowBailSignal() and isWorkflowAbortSignal() for explicit signal handling.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/workflow/core.ts` around lines 2266 - 2271, The andTap
implementation swallows WorkflowBailSignal and WorkflowAbortSignal because it
catches all errors and returns context.data; update the andTap execute wrapper
to detect and re-throw signals using the core helpers (isWorkflowBailSignal and
isWorkflowAbortSignal) before logging and returning, so that bailFn/abortFn
propagate; also audit other nested step handlers that compare error.message and
replace those checks with isWorkflowBailSignal/isWorkflowAbortSignal to
consistently re-throw signals (refer to symbols: andTap, bailFn, abortFn,
WorkflowBailSignal, WorkflowAbortSignal, isWorkflowBailSignal,
isWorkflowAbortSignal).

@omeraplak
omeraplak merged commit 7f923d2 into main Feb 22, 2026
23 checks passed
@omeraplak
omeraplak deleted the feat/workflow-execution-primitives branch February 22, 2026 22:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant