Skip to content

fix(core): persist workflow context mutations across steps#1078

Merged
omeraplak merged 1 commit into
mainfrom
fix/workflow-context-persistence-1003
Feb 18, 2026
Merged

fix(core): persist workflow context mutations across steps#1078
omeraplak merged 1 commit into
mainfrom
fix/workflow-context-persistence-1003

Conversation

@omeraplak

@omeraplak omeraplak commented Feb 18, 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 mutations are not reliably visible to downstream steps/agents when context is not explicitly initialized via run(..., { context }).

In issue #1003, setting state.context inside an early step (e.g. state.context?.set("new_key", "new_value")) could appear to work in-step but later andAgent/tool calls still saw an empty context.

What is the new behavior?

  • Workflow execution now consistently initializes and propagates the normalized runtime context map through step state and execution context.
  • Step mutations to state.context are preserved and visible in downstream steps and andAgent calls.
  • Workflow stream/event context payloads are aligned with the same normalized runtime context.
  • Added regression tests for:
    • context initialization + persistence across steps
    • context mutation visibility in downstream andAgent calls

fixes #1003

Notes for reviewers

Code changes

  • packages/core/src/workflow/core.ts
    • Use contextMap consistently for state initialization, stream writer context, and emitted events.
  • packages/core/src/workflow/internal/utils.ts
    • Prefer execution context map in convertWorkflowStateToParam (executionContext?.context ?? state.context).
  • packages/core/src/workflow/core.spec.ts
    • Added coverage for context persistence and downstream andAgent visibility.
  • .changeset/neat-context-persist.md
    • Patch release note for @voltagent/core.

Verification

  • pnpm --filter @voltagent/core test -- workflow/core.spec.ts workflow/usage-tracking.spec.ts workflow/steps/and-agent.spec.ts

Summary by cubic

Persist workflow context mutations across steps and downstream agents/tools, fixing missing context in later steps. Addresses #1003.

  • Bug Fixes
    • Initialize and propagate a normalized context map through execution and step state.
    • Preserve state.context mutations and pass context to andAgent/tool calls.
    • Align workflow stream/event context payloads with the runtime context.
    • Add regression tests for context init, cross-step persistence, and andAgent visibility.

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

Summary by CodeRabbit

  • Bug Fixes
    • Context mutations now persist across workflow steps and are accessible in downstream agents and tool calls
    • Execution context is consistently used throughout workflow execution to ensure proper context propagation across all steps
    • Workflow event and stream context payloads are now aligned with runtime context

@changeset-bot

changeset-bot Bot commented Feb 18, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: cd38640

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 Patch

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 18, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

This PR fixes workflow context persistence by converting the context representation to a Map structure throughout the workflow execution pipeline. Context mutations made in workflow steps are now consistently propagated to downstream agent calls and tools, ensuring context modifications persist across the entire execution flow.

Changes

Cohort / File(s) Summary
Release Notes
.changeset/neat-context-persist.md
Patch release note documenting that workflow context mutations now persist across steps and are visible to downstream agents/tools, with alignment of event/stream context payloads with normalized runtime context.
Test Updates
packages/core/src/workflow/core.spec.ts
Added two new test cases: (1) verifying context initialization and persistence across multiple workflow steps, and (2) ensuring step context mutations are visible to downstream andAgent calls with proper context propagation to agent execution.
Core Workflow Engine
packages/core/src/workflow/core.ts
Replaced options.context object usage with contextMap (Map structure) throughout workflow creation, step execution, and event emission paths. Context is now consistently passed to memory state, stream writers, execution context, and agent invocations.
Utility Updates
packages/core/src/workflow/internal/utils.ts
Updated convertWorkflowStateToParam to resolve context from executionContext.context when available, falling back to state.context, ensuring consistent context sourcing for step state parameters.

Sequence Diagram(s)

sequenceDiagram
    participant Workflow as Workflow Runtime
    participant Step1 as Workflow Step 1
    participant Step2 as Workflow Step 2
    participant Agent as Agent/Tool
    
    Workflow->>Workflow: Initialize contextMap from options
    Workflow->>Step1: Execute step with executionContext (contextMap)
    Step1->>Step1: Mutate state.context (backed by contextMap)
    Step1-->>Workflow: Return data + mutated context
    Workflow->>Workflow: Persist context mutations to executionContext
    Workflow->>Step2: Execute step with updated executionContext
    Step2-->>Workflow: Complete
    Workflow->>Agent: Invoke with executionContext.context (Map)
    Agent->>Agent: Access context keys set in Step1
    Agent-->>Workflow: Return result
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related PRs

Poem

🐰 A rabbit's warren grows, as context now flows—
Through steps and downstream calls it goes,
No more lost mutations in forgotten shows,
Context persists wherever workflow goes! 🗺️✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: fixing workflow context persistence across steps.
Description check ✅ Passed The description comprehensively covers current behavior, new behavior, test additions, and code changes with proper section structure matching the template.
Linked Issues check ✅ Passed The PR directly addresses issue #1003 by implementing context initialization, propagation through steps, and persistence to downstream agents via consistent contextMap usage and new regression tests.
Out of Scope Changes check ✅ Passed All changes are scoped to workflow context persistence: core.ts refactors context handling, utils.ts updates context source preference, core.spec.ts adds context-related tests, and changeset documents the fix.

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

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/workflow-context-persistence-1003

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.

No issues found across 4 files

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying voltagent with  Cloudflare Pages  Cloudflare Pages

Latest commit: cd38640
Status: ✅  Deploy successful!
Preview URL: https://b8d23727.voltagent.pages.dev
Branch Preview URL: https://fix-workflow-context-persist.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.

Caution

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

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

770-803: ⚠️ Potential issue | 🟠 Major

Map context in stream events serializes as {} (regression) and shared reference corrupts event history.

Two related problems in emitAndCollectEvent:

  1. Serialization regressioncontextMap is a Map. JSON.stringify(new Map([["k","v"]])) produces "{}", so SSE/JSON-serialized workflow events (e.g. workflow-start, step-start, step-complete) will always deliver an empty context object to streaming clients, regardless of what the user put in context. This is a regression from the previous plain-object format.

  2. Stale shared reference — every collectedEvent.context holds a reference to the same contextMap instance. Because Maps are mutable, all collected events reflect the final state of the Map once execution completes — the step-1 step-start event shows context mutations made in step 2, making event history misleading. The as Record<string, unknown> cast is also a type lie that masks the underlying issue.

The fix belongs inside emitAndCollectEvent: convert to a plain-object snapshot at emission time:

🛠️ Proposed fix
 const emitAndCollectEvent = (event: {
   type: string;
   executionId: string;
   from: string;
   input?: any;
   output?: any;
   status: string;
   context?: any;
   timestamp: string;
   stepIndex?: number;
   stepType?: string;
   metadata?: Record<string, any>;
   error?: any;
 }) => {
+  // Snapshot Map → plain object so events are serializable (JSON/SSE) and
+  // reflect context state at emission time rather than the final state.
+  const contextSnapshot: Record<string, unknown> | undefined =
+    event.context instanceof Map
+      ? Object.fromEntries((event.context as Map<string, unknown>).entries())
+      : (event.context as Record<string, unknown> | undefined);
+
   // Emit to stream if available
   if (streamController) {
-    streamController.emit(event as any);
+    streamController.emit({ ...event, context: contextSnapshot } as any);
   }

   // Collect for persistence (convert to storage format)
   const collectedEvent = {
     id: randomUUID(),
     type: event.type,
     name: event.from,
     from: event.from,
     startTime: event.timestamp,
     endTime: event.timestamp,
     status: event.status,
     input: event.input,
     output: event.output,
     metadata: event.metadata,
-    context: event.context as Record<string, unknown> | undefined,
+    context: contextSnapshot,
   };
   collectedEvents.push(collectedEvent);
 };
🤖 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 770 - 803,
emitAndCollectEvent currently attaches the live Map instance to both the
streamed event and collectedEvent.context, causing JSON.stringify of Map to
become "{}" and making all stored events share a mutable reference; fix by
creating a plain-object snapshot of the context at emission time (detect if
event.context is a Map and convert with Object.fromEntries or otherwise
shallow-clone plain objects) and use that snapshot for both the object passed to
streamController.emit and for collectedEvent.context so each emitted/collected
event gets an immutable, serializable copy; update references to emit the
snapshot instead of event.context and set collectedEvent.context = snapshot.

1477-1501: ⚠️ Potential issue | 🟡 Minor

"suspendData" stored in the user-visible shared contextMap risks key collision.

executionContext.context.set("suspendData", suspendData) writes into the same Map the user accesses as state.context. Two issues:

  1. If a step sets state.context.set("suspendData", anything) before calling suspend(), the internal code at line 1477 overwrites it.
  2. After resume, the "suspendData" key remains in the context visible to subsequent steps.

Consider using a separate internal side-channel (e.g., a dedicated field on executionContext) rather than the user-facing context Map.

🤖 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 1477 - 1501, The code writes
suspend data into the user-visible context Map via
executionContext.context.set("suspendData", suspendData), which can collide with
user keys and persist after resume; change this to store suspend data on an
internal side-channel on executionContext (e.g., add methods or a dedicated
property like executionContext._internalSuspendData or
executionContext.setInternalSuspendData/getInternalSuspendData) and update
places that read it (handleStepSuspension and the suspend caller) to use those
internal accessors, ensuring the internal suspend data is cleared on resume so
nothing leaks into the user-facing contextMap.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@packages/core/src/workflow/core.ts`:
- Around line 770-803: emitAndCollectEvent currently attaches the live Map
instance to both the streamed event and collectedEvent.context, causing
JSON.stringify of Map to become "{}" and making all stored events share a
mutable reference; fix by creating a plain-object snapshot of the context at
emission time (detect if event.context is a Map and convert with
Object.fromEntries or otherwise shallow-clone plain objects) and use that
snapshot for both the object passed to streamController.emit and for
collectedEvent.context so each emitted/collected event gets an immutable,
serializable copy; update references to emit the snapshot instead of
event.context and set collectedEvent.context = snapshot.
- Around line 1477-1501: The code writes suspend data into the user-visible
context Map via executionContext.context.set("suspendData", suspendData), which
can collide with user keys and persist after resume; change this to store
suspend data on an internal side-channel on executionContext (e.g., add methods
or a dedicated property like executionContext._internalSuspendData or
executionContext.setInternalSuspendData/getInternalSuspendData) and update
places that read it (handleStepSuspension and the suspend caller) to use those
internal accessors, ensuring the internal suspend data is cleared on resume so
nothing leaks into the user-facing contextMap.

@omeraplak
omeraplak merged commit fbce8aa into main Feb 18, 2026
23 checks passed
@omeraplak
omeraplak deleted the fix/workflow-context-persistence-1003 branch February 18, 2026 18:27
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.

[BUG] Workflow context mutations within steps do not persist and lacks default value

1 participant