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 patches since last reset
getPatches(): JSONPatchOperation[];
// Clear patch accumulator
resetPatches(): 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: [] });
tracker.update((draft) => {
draft.items.push({ id: 1, name: 'item' });
});
// Patches:
// [{ op: 'add', path: '/items/0', 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. Helix supports two modes:
Append-Only Mode (Default)
Best for arrays that only grow (like notes, logs):
const tracker = new ImmerStateTracker({ notes: [] }, { arrayDeltaMode: 'append_only' });
tracker.update((draft) => {
draft.notes.push({ content: 'Note 1' });
draft.notes.push({ content: 'Note 2' });
});
// Patches include only additions:
// [
// { 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
For arrays that may be reordered or have items removed:
const tracker = new ImmerStateTracker({ items: [1, 2, 3] }, { arrayDeltaMode: 'full_replace' });
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
function convertImmerPatchToRFC6902(immerPatch: Patch): JSONPatchOperation {
const path = '/' + immerPatch.path.join('/');
switch (immerPatch.op) {
case 'add':
return { op: 'add', path, value: immerPatch.value };
case 'remove':
return { op: 'remove', path };
case 'replace':
return { op: 'replace', path, value: immerPatch.value };
}
}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 = stateTracker.getPatches();
if (patches.length > 0) {
streamWriter.write({
type: 'state_patch',
patches,
agentId,
timestamp: Date.now(),
});
stateTracker.resetPatches();
}
}
},
// ... other context methods
};
}State Persistence
Merge Strategies
When saving state, atomic operations handle specific updates:
// Update specific fields atomically
await stateStore.updateStatus(runId, 'completed');
await stateStore.appendMessages(runId, newMessages);
await stateStore.mergeCustomState(runId, stateChanges);
await stateStore.incrementStepCount(runId);Full State Save
To save complete state:
await stateStore.save(state); // runId is inside state object
// 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 tools execute in parallel, each gets a snapshot:
// Tool A and Tool B both read count: 0
// Tool A sets count: 1
// Tool B sets count: 2
// Final count depends on which completes last!Best practice: Avoid concurrent modifications to the same fields.
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.getPatches()).toEqual([
{ op: 'add', path: '/items/-', value: 'a' },
{ op: 'add', path: '/items/-', value: 'b' },
]);
});
it('resets patches', () => {
const tracker = new ImmerStateTracker({ count: 0 });
tracker.update((d) => {
d.count = 1;
});
tracker.resetPatches();
tracker.update((d) => {
d.count = 2;
});
// Only patches since reset
expect(tracker.getPatches()).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