Skip to content

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:

  1. Immutable Updates - Mutations on drafts become immutable updates
  2. Patch Generation - Automatic generation of operation patches
  3. Efficient Storage - Only changed fields need to be persisted

ImmerStateTracker ​

The ImmerStateTracker class wraps Immer to provide:

typescript
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 ​

typescript
const tracker = new ImmerStateTracker({ count: 0, name: 'test' });

tracker.update((draft) => {
  draft.count = 5;
});

// Patches:
// [{ op: 'replace', path: '/count', value: 5 }]

Object Addition ​

typescript
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 ​

typescript
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.

typescript
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:

typescript
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:

typescript
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:

  1. Tool mutations → tracker.updateState(draft => ...) records Immer patches
  2. At end of tool → tracker.getStepWrites() classifies changes into a single op-list (append / replace / delete per top-level key)
  3. Persistence: stateStore.stageChanges(sessionId, stepId, { writes }) stages the op-list; promoteStaging applies via applyStepWrites
  4. Wire emission: stepWritesToRFC6902(writes) derives RFC 6902 patches (using /- end-of-array for parallel-safe appends in arrayDeltaMode)

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:

typescript
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 ​

typescript
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:

typescript
// 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:

typescript
await stateStore.saveState(sessionId, state);
// Completely overwrites existing state

Streaming State Changes ​

State patches are streamed in real-time:

typescript
{
  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:

typescript
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:

typescript
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() in arrayDeltaMode) — 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 like x = [...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.
typescript
// 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 own executeToolActivity, 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:

typescript
// Parent state: { notes: ['note1'] }
// Sub-agent state: { texts: [] }  // Separate state

// Sub-agent cannot directly modify parent state
// Results are passed back through tool return values

Testing State Tracking ​

typescript
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:

typescript
// 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:

typescript
// 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 data

Deep Nesting ​

Deeply nested updates generate long paths:

typescript
// Path: /level1/level2/level3/level4/level5/value
// Consider flattening data structures

See Also ​

Released under the MIT License.