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 patches since last reset
  getPatches(): JSONPatchOperation[];

  // Clear patch accumulator
  resetPatches(): 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: [] });

tracker.update((draft) => {
  draft.items.push({ id: 1, name: 'item' });
});

// Patches:
// [{ op: 'add', path: '/items/0', 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. Helix supports two modes:

Append-Only Mode (Default)

Best for arrays that only grow (like notes, logs):

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

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

typescript
interface JSONPatchOperation {
  op: 'add' | 'remove' | 'replace';
  path: string; // JSON Pointer (e.g., '/items/0/name')
  value?: unknown; // For add/replace operations
}

Conversion Logic

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

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

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

typescript
await stateStore.save(state); // runId is inside state object
// 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 tools execute in parallel, each gets a snapshot:

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

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

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.