fix(core): persist workflow context mutations across steps#1078
Conversation
🦋 Changeset detectedLatest commit: cd38640 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
This comment has been minimized.
This comment has been minimized.
📝 WalkthroughWalkthroughThis 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
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
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~22 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
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. Comment |
Deploying voltagent with
|
| Latest commit: |
cd38640
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://b8d23727.voltagent.pages.dev |
| Branch Preview URL: | https://fix-workflow-context-persist.voltagent.pages.dev |
There was a problem hiding this comment.
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 | 🟠 MajorMap context in stream events serializes as
{}(regression) and shared reference corrupts event history.Two related problems in
emitAndCollectEvent:
Serialization regression —
contextMapis aMap.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.Stale shared reference — every
collectedEvent.contextholds a reference to the samecontextMapinstance. Because Maps are mutable, all collected events reflect the final state of the Map once execution completes — the step-1step-startevent shows context mutations made in step 2, making event history misleading. Theas 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 sharedcontextMaprisks key collision.
executionContext.context.set("suspendData", suspendData)writes into the same Map the user accesses asstate.context. Two issues:
- If a step sets
state.context.set("suspendData", anything)before callingsuspend(), the internal code at line 1477 overwrites it.- 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.
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.contextinside an early step (e.g.state.context?.set("new_key", "new_value")) could appear to work in-step but laterandAgent/tool calls still saw an empty context.What is the new behavior?
state.contextare preserved and visible in downstream steps andandAgentcalls.andAgentcallsfixes #1003
Notes for reviewers
Code changes
packages/core/src/workflow/core.tscontextMapconsistently for state initialization, stream writer context, and emitted events.packages/core/src/workflow/internal/utils.tsconvertWorkflowStateToParam(executionContext?.context ?? state.context).packages/core/src/workflow/core.spec.tsandAgentvisibility..changeset/neat-context-persist.md@voltagent/core.Verification
pnpm --filter @voltagent/core test -- workflow/core.spec.ts workflow/usage-tracking.spec.ts workflow/steps/and-agent.spec.tsSummary by cubic
Persist workflow context mutations across steps and downstream agents/tools, fixing missing context in later steps. Addresses #1003.
Written for commit cd38640. Summary will update on new commits.
Summary by CodeRabbit