State Tracking Internals
This document explains how Helix Agents tracks state mutations and generates RFC 6902 JSON Patches.
Overview
Agent state is tracked using Immer, which provides:
- Immutable Updates - Mutations on drafts become immutable updates
- Patch Generation - Automatic generation of operation patches
- Efficient Storage - Only changed fields need to be persisted
ImmerStateTracker
The ImmerStateTracker class wraps Immer to provide:
class ImmerStateTracker<T> {
constructor(initialState: T, options?: ImmerStateTrackerOptions);
// Get current state (immutable)
getState(): T;
// Update state via draft mutation
update(fn: (draft: Draft<T>) => void): T;
// Get accumulated step writes since last reset (canonical representation)
getStepWrites(): StepWrites;
// Get RFC 6902 patches derived from step writes (for wire/streaming)
getRFC6902Patches(): JSONPatchOperation[];
// Clear write accumulator
resetTracking(): void;
}How Patches Work
Simple Property Update
const tracker = new ImmerStateTracker({ count: 0, name: 'test' });
tracker.update((draft) => {
draft.count = 5;
});
// Patches:
// [{ op: 'replace', path: '/count', value: 5 }]Object Addition
const tracker = new ImmerStateTracker({ items: [] }, { arrayDeltaMode: true });
tracker.update((draft) => {
draft.items.push({ id: 1, name: 'item' });
});
// Patches:
// [{ op: 'add', path: '/items/-', value: { id: 1, name: 'item' } }]Nested Updates
const tracker = new ImmerStateTracker({
user: { profile: { name: 'Alice' } },
});
tracker.update((draft) => {
draft.user.profile.name = 'Bob';
});
// Patches:
// [{ op: 'replace', path: '/user/profile/name', value: 'Bob' }]Array Handling Modes
Arrays present a challenge for patch-based state tracking. ImmerStateTracker supports two modes controlled by arrayDeltaMode: boolean (default false):
Append Classification Mode (arrayDeltaMode: true)
Set arrayDeltaMode: true when a tracker may run concurrently with other trackers against the same baseline (e.g., parallel server tools). Pure-append mutations emit /- end-of-array ops that compose correctly regardless of arrival order. All production runtimes (runtime-js, Cloudflare DO, CFW Workflows, runtime-dbos, runtime-temporal) use this mode.
const tracker = new ImmerStateTracker({ notes: [] }, { arrayDeltaMode: true });
tracker.update((draft) => {
draft.notes.push({ content: 'Note 1' });
draft.notes.push({ content: 'Note 2' });
});
// Patches include only additions (using RFC 6902 end-of-array path):
// [
// { op: 'add', path: '/notes/-', value: { content: 'Note 1' } },
// { op: 'add', path: '/notes/-', value: { content: 'Note 2' } }
// ]The - path suffix is RFC 6902 syntax for "append to array".
Full Replace Mode (arrayDeltaMode: false, default)
For arrays that may be reordered or have items removed. Any array mutation emits a single replace op for the entire array:
const tracker = new ImmerStateTracker({ items: [1, 2, 3] }); // arrayDeltaMode defaults to false
tracker.update((draft) => {
draft.items.splice(1, 1); // Remove index 1
});
// Patch replaces entire array:
// [{ op: 'replace', path: '/items', value: [1, 3] }]RFC 6902 Patch Format
Helix converts Immer patches to RFC 6902 format:
interface JSONPatchOperation {
op: 'add' | 'remove' | 'replace';
path: string; // JSON Pointer (e.g., '/items/0/name')
value?: unknown; // For add/replace operations
}Conversion Logic
State changes flow through the unified StepWrites pipeline:
- Tool mutations →
tracker.updateState(draft => ...)records Immer patches - At end of tool →
tracker.getStepWrites()classifies changes into a single op-list (append / replace / delete per top-level key) - Persistence:
stateStore.stageChanges(sessionId, stepId, { writes })stages the op-list;promoteStagingapplies viaapplyStepWrites - Wire emission:
stepWritesToRFC6902(writes)derives RFC 6902 patches (using/-end-of-array for parallel-safe appends inarrayDeltaMode)
Both consumers derive from the same getStepWrites() output, so wire and persistence cannot drift.
To derive RFC 6902 patches directly, use stepWritesToRFC6902(tracker.getStepWrites()).
Usage in Tools
Tools receive a ToolContext with state access:
const myTool = defineTool({
execute: async (input, context) => {
// Read current state
const state = context.getState<MyState>();
// Update state (Immer draft)
context.updateState<MyState>((draft) => {
draft.items.push({ id: Date.now(), ...input });
});
return { success: true };
},
});How Tool Context Works
function createToolContext(options: CreateToolContextOptions): ToolContext {
const { stateTracker, streamWriter, agentId, agentType } = options;
return {
getState: () => stateTracker.getState(),
updateState: (fn) => {
stateTracker.update(fn);
// Emit state patches if writer provided
if (streamWriter) {
const patches = stepWritesToRFC6902(stateTracker.getStepWrites());
if (patches.length > 0) {
streamWriter.write({
type: 'state_patch',
patches,
agentId,
timestamp: Date.now(),
});
stateTracker.resetTracking();
}
}
},
// ... other context methods
};
}State Persistence
Merge Strategies
When saving state, atomic operations handle specific updates:
// Update specific fields atomically
await stateStore.updateStatus(sessionId, 'completed');
await stateStore.appendMessages(sessionId, newMessages);
await stateStore.mergeCustomState(sessionId, stateChanges);
await stateStore.incrementStepCount(sessionId);Full State Save
To save complete state:
await stateStore.saveState(sessionId, state);
// Completely overwrites existing stateStreaming State Changes
State patches are streamed in real-time:
{
type: 'state_patch',
patches: [
{ op: 'add', path: '/notes/-', value: { content: 'New note' } },
{ op: 'replace', path: '/searchCount', value: 5 }
],
agentId: 'run-123',
agentType: 'research-assistant',
timestamp: 1702329600000
}Frontend clients can apply these patches to maintain synchronized state:
import { applyPatch } from 'fast-json-patch';
let localState = { notes: [], searchCount: 0 };
stream.on('state_patch', (chunk) => {
localState = applyPatch(localState, chunk.patches).newDocument;
});Concurrency Considerations
Single Tool Execution
Within a single tool, state updates are sequential:
execute: async (input, context) => {
// These execute in order
context.updateState((d) => {
d.count++;
});
context.updateState((d) => {
d.count++;
});
// Final count: +2
};Parallel Tool Execution
When the LLM returns multiple regular server tools in one step, all production runtimes execute them in PARALLEL (runtime-js, Cloudflare DO, CFW Workflows, runtime-dbos, runtime-temporal). Each tool seeds from the committed / pre-step base — not from a sibling's uncommitted writes — and merges its writes via staging. The merge contract is identical across runtimes:
append(in-place.push()inarrayDeltaMode) — composes. Every parallel tool's appended items survive, regardless of arrival order, because the writes emit/-end-of-array ops rather than absolute indices.replace(scalar assignment, or whole-array reassignment likex = [...x, item]) — last-write-wins. Each tool computed its new value from the same pre-step base, so only the final writer's value persists.
// Tool A and Tool B both seed count from the committed base: 0
// Tool A sets count = 1 (replace)
// Tool B sets count = 2 (replace)
// Final count is last-write-wins: depends on which staged write promotes last.
// vs. in-place appends, which compose:
// Tool A: notes.push('a') → add /notes/- 'a'
// Tool B: notes.push('b') → add /notes/- 'b'
// Final notes: ['a', 'b'] (both survive)runtime-temporal note: Temporal previously ran a step's regular tools sequentially inside a single activity, so read-modify-write patterns (scalar
count++, whole-array reassignment) accumulated within one step. As of the parallel-tools change it matches DBOS / Cloudflare: each tool runs as its ownexecuteToolActivity, seeds from the committed base, and stages its writes — so those read-modify-write patterns are now last-write-wins. In-place.push()appends still compose. (A future increment opcode will make counters parallel-safe across all runtimes at once.)
Best practice: Avoid concurrent read-modify-write on the same scalar / array field across parallel tools — use in-place appends, or sequence the work through a single parent tool. See the per-runtime in-step state visibility note in CLAUDE.md for the full cross-runtime contract.
Sub-Agent Isolation
Sub-agents have isolated state:
// Parent state: { notes: ['note1'] }
// Sub-agent state: { texts: [] } // Separate state
// Sub-agent cannot directly modify parent state
// Results are passed back through tool return valuesTesting State Tracking
import { ImmerStateTracker } from '@helix-agents/core';
describe('StateTracker', () => {
it('tracks array additions', () => {
const tracker = new ImmerStateTracker({ items: [] });
tracker.update((d) => {
d.items.push('a');
});
tracker.update((d) => {
d.items.push('b');
});
expect(tracker.getState().items).toEqual(['a', 'b']);
expect(tracker.getRFC6902Patches()).toEqual([
{ op: 'add', path: '/items/-', value: 'a' },
{ op: 'add', path: '/items/-', value: 'b' },
]);
});
it('resets tracking', () => {
const tracker = new ImmerStateTracker({ count: 0 });
tracker.update((d) => {
d.count = 1;
});
tracker.resetTracking();
tracker.update((d) => {
d.count = 2;
});
// Only writes since reset
expect(tracker.getRFC6902Patches()).toEqual([{ op: 'replace', path: '/count', value: 2 }]);
});
});Limitations
Non-Serializable Values
State must be JSON-serializable:
// Bad - functions can't be serialized
context.updateState((d) => {
d.callback = () => console.log('hello'); // Error!
});
// Bad - circular references
const obj = { self: null };
obj.self = obj;
context.updateState((d) => {
d.circular = obj;
}); // Error!Large Arrays
Full-replace mode with large arrays can be expensive:
// With 10,000 items, modifying one generates a 10,000-item patch
context.updateState((d) => {
d.largeArray[5000].modified = true;
});
// Consider using append-only mode or restructuring dataDeep Nesting
Deeply nested updates generate long paths:
// Path: /level1/level2/level3/level4/level5/value
// Consider flattening data structures