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.