Skip to content

Session Model ​

This document is the canonical reference for Helix Agents' session- centric storage model. The framework uses sessionId as the primary key for all state operations.

For framework-level concepts (Run, Agent, Tool, Sub-Agents, etc.) see ./concepts.md. For the actual step-by-step execution loop see ./execution-flow.md.


The framework uses a session-centric storage model where sessionId is the primary key for all state operations.

Session vs Run ​

  • Session: A conversation container. Identified by sessionId. Contains all messages, custom state, and checkpoints.

  • Run: A single execution within a session. When a session is interrupted, suspended (HITL), or resumed, a new run starts but continues the same session. Each run captures startSequence from the stream to enable run-scoped chunk filtering (prevents content duplication when refreshing mid-stream in multi-run sessions).

    Every execute() and resume() turn writes a run record, so listRuns() grows by one per turn on all runtimes (JS, Temporal, DBOS, Cloudflare DO, Cloudflare Workflows). Temporal reached this parity via the continuation run-record fix (FU-TEMPORAL-CONTINUATION-RUN-RECORD): previously a continuation execute() on an existing (e.g. completed) session skipped createRun, so Temporal under-reported turn count (1 vs N). The executor now writes an executor-side run record before returning the handle for every turn.

    As of v7, RunStatus includes three suspension variants — 'suspended_client_tool', 'suspended_awaiting_children', 'suspended_step_partial' — written by runtimes that suspend at HITL boundaries. These mirror RunOutcome.kind discriminators surfaced via AgentResult.status. See ./concepts.md for the full HITL model.

    On Temporal and Cloudflare Workflows, distinct runs within a single session are tagged with the __resume-N workflow-id suffix convention (${prefix}__${agentType}__${sessionId}__resume-${N}, single-dash; spec §5). The counter lives on SessionState.resumeCount and is incremented atomically via incrementResumeCount. See ./concepts.md §Client-Executed Tools for per-runtime resume mechanics.

    Continuing a completed structured-output session now works. Because stepCount is non-monotonic across turns (each turn restarts its own counter — see getLatestCheckpoint below), a continued turn resets stepCount → 0 and gets a fresh per-turn maxSteps budget. A structured-output agent that completed via __finish__ used to leave a dangling tool_use that broke the next LLM call on continuation; the __finish__ history invariant + heal makes that transcript valid, so a follow-up execute(sessionId) continues cleanly (preserving memory, returning a fresh typed result). The same per-turn stepCount → 0 reset applies to persistent-companion re-consult (continuing a completed child) — uniform across all runtimes (JS, Temporal, DBOS, Cloudflare DO, Cloudflare Workflows). See ./concepts.md §Persistent-companion continuation + the __finish__ heal.

Key Benefits ​

  1. Efficient Message Storage: Messages are stored once per session, not duplicated per run. This is O(n) storage vs O(n²) for run-centric models.
  2. Natural Conversation Continuity: Reusing the same sessionId automatically continues the conversation with full history.
  3. Clean Sub-Agent Isolation: Each sub-agent gets its own sessionId, preventing state conflicts.

Usage ​

typescript
// Start a new session (sessionId is required)
const sessionId = `session-${Date.now()}`;
const handle = await executor.execute(agent, { message: 'Hello' }, { sessionId });

// Continue the same session (pass the same sessionId)
const handle2 = await executor.execute(
  agent,
  { message: 'Follow up' },
  {
    sessionId: handle.sessionId,
  }
);

// Branch from a checkpoint (creates a new session from existing state)
const newSessionId = `session-${Date.now()}`;
const handle3 = await executor.execute(
  agent,
  { message: 'What if...' },
  {
    sessionId: newSessionId,
    branch: { fromSessionId: handle.sessionId, checkpointId: 'cp_123' },
  }
);

v7 SessionState Shape ​

The full SessionState<TState, TOutput> interface lives at packages/core/src/types/session.ts:86-295. v7 added a number of suspension- and concurrency-related fields. The canonical shape is:

typescript
interface SessionState<TState, TOutput> {
  // Identity
  sessionId: string;
  agentType: string;
  streamId?: string;

  // Custom application state + status
  customState: TState;
  status: SessionStatus; // 'active' | 'completed' | 'failed' | 'interrupted' | 'paused'
  stepCount: number;
  output?: TOutput;
  error?: string;

  // v7: γ-cascade discriminator. Currently 'parent_suspended' marks a
  // child that was failed because its parent suspended; the cascade in
  // applyResultsAndReload re-spawns these on parent resume.
  failureReason?: string;

  // Interrupt context (set when status === 'interrupted' or 'paused')
  interruptContext?: InterruptContext;

  // v7 HITL suspension state
  pendingClientToolCalls?: Record<string, PendingClientToolCall>;
  suspendedAwaitingChildren?: Record<string, SuspendedChildWait>;
  suspendedStepId?: string;
  completedClientToolCalls?: Record<string, number>; // root-only
  clientToolCallOwnership?: ClientToolCallOwnership; // root-only

  // v7 tracing continuity (sessionId-seeded)
  tracingContext?: { traceId: string; rootSpanId: string };

  // v7 session GC + cross-session links
  expiresAt?: number;
  parentSessionId?: string;
  rootSessionId?: string;

  // v7 DBOS write-once mode binding
  mode?: 'standard' | 'persistent';

  // v7 distributed coordination
  version: number; // monotonic; incremented on every modification
  resumeCount: number; // counter for unique resume workflow IDs

  // Checkpoint tracking
  checkpointId?: string;
  checkpointedAt?: number;
  checkpointSource?: 'staging' | 'save';

  // User context
  userId?: string;
  tags?: string[];
  metadata?: Record<string, string>;

  // v7 persisted workspace ref (so it survives interrupt/resume)
  workspaceRef?: WorkspaceRef;

  // Timestamps
  createdAt: number;
  updatedAt: number;
}

v7-NEW field summary ​

FieldPurpose
failureReasonγ-cascade discriminator (e.g. 'parent_suspended'); used by applyResultsAndReload to decide re-spawn vs. drain.
pendingClientToolCallsMap of toolCallId → pending entry; canonical signal for "awaiting client submission".
suspendedAwaitingChildrenMap of parentToolCallId → child wait info; populated when parent paused awaiting sub-agents.
suspendedStepIdMid-step suspension marker for mixed server+client tool batches.
completedClientToolCallsRoot-only timestamp map; makes 'already_completed' durable across runtime restarts.
clientToolCallOwnershipRoot-only toolCallId → owningSessionId; routes submissions to the owning sub-agent.
tracingContextsessionId-seeded traceId + rootSpanId; one trace per session across runs.
expiresAtOperator GC hint for abandoned sessions.
modeWrite-once 'standard' / 'persistent' binding (DBOS-enforced).
version / resumeCountOptimistic concurrency + unique resume workflow IDs.
workspaceRefPersisted workspace ref (so it survives interrupt/resume cycles).
parentSessionId / rootSessionIdSub-agent cross-session linkage; rootSessionId enables O(1) ownership writes.

State Store Interface ​

All state stores implement SessionStateStore (defined at packages/core/src/store/state-store.ts). v7 introduces several new atomic primitives that runtime code now depends on heavily.

Lifecycle ​

  • createSession(sessionId, options) — Atomically create a session (throws if already exists). All implementations guarantee exactly-one-wins semantics for concurrent calls with the same sessionId, and a concurrent loadState never observes a partially-created session (RM-50).
  • sessionExists(sessionId) / deleteSession(sessionId) / cloneSession(...) — standard lifecycle helpers.

State ​

  • loadState(sessionId) / saveState(sessionId, state) — Load / save session state.
  • mergeCustomState(sessionId, writes) — Atomically merge StepWrites from ImmerStateTracker into custom state.
  • updateStatus(sessionId, status, context?) — Atomic status update (no CAS).

v7 atomic primitives ​

  • compareAndSetStatus(sessionId, expectedStatuses, newStatus, options?) — Atomic CAS on session status (and optional expectedVersion). Returns a discriminated result:
    • { ok: true; newVersion: number } on success
    • { ok: false; currentStatus: SessionStatus; currentVersion: number } on mismatch
    • options accepts interruptContext, error, and expectedVersion. Used to prevent double-resume races.
  • saveStateAndPromoteStaging(sessionId, state, appendMessages, checkpointMeta, options?) — Atomic write of state + appended messages + staging promotion + checkpoint creation in one operation. Honors expectedVersion (throws StaleStateError on mismatch). Cross-runtime invariant C-1: when a runtime suspends, this is the single primitive that persists pending tool calls, ownership, completed phase-1 messages, the checkpoint, and suspendedStepId atomically.
  • incrementStepCount(sessionId) / incrementResumeCount(sessionId) — Atomic counters.

Interrupt flag ​

  • setInterruptFlag(sessionId, reason?) — Durable interrupt request (writes durably so other processes can observe it).
  • checkInterruptFlag(sessionId) — Atomic check-and-clear; polled by the runLoop at the top of every step iteration. Foundation for cross-process interrupt parity (JS, CF DO, CFW Workflows all rely on it).
  • clearInterruptFlag(sessionId) — Explicit clear (rarely used directly; checkInterruptFlag clears as part of the read).

Messages, runs, checkpoints, sub-sessions, staging ​

  • appendMessages / getMessages / getMessageCount / truncateMessages
  • createRun / updateRunStatus / getCurrentRun / listRuns / getRun
  • createCheckpoint / getLatestCheckpoint / getCheckpoint / listCheckpoints
  • addSubSessionRefs / updateSubSessionRef / getSubSessionRefs
  • stageChanges / getStagedChanges / promoteStaging / discardStaging / hasStagedChanges / cleanupOrphanedStaging

Optional extensions ​

  • patchMetadata?(sessionId, patch) — Used by runtime-dbos persistent mode to record the active DBOS workflow ID.

Third-party stores: atomic implementation required ​

There is no non-atomic fallback. A previously-exported defaultSaveStateAndPromoteStaging(store, ...) helper (sequential appendMessages → saveState → promoteStaging) was removed in P3.R3-BC-FALLBACK because the crash-between-calls window it created is exactly the corruption the atomic primitive was added to prevent. All five in-tree stores (memory, redis, postgres, D1, DO) implement the atomic version; custom stores must do the same.

getLatestCheckpoint must return the most-recently-WRITTEN checkpoint ​

getLatestCheckpoint(sessionId) must return the checkpoint that was written most recently — i.e. the one the session row's checkpointId pointer references — not the checkpoint with the highest stepCount.

stepCount is not monotonic across turns/runs: each execute()/resume() turn restarts its own step counter, so a later turn's first checkpoint (including a client-tool suspension checkpoint at step 0/1) can have a lower stepCount than the previous turn's final checkpoint. The runtime calls getLatestCheckpoint during resume/retry to find the boundary of committed state and then truncateMessages past it. If the store returns a stale higher-stepCount checkpoint, the truncation deletes the current turn's user message and the suspending assistant tool_use message, leaving an orphaned tool_result that fails the next LLM call (unexpected tool_use_id ...).

Every checkpoint-writing path (createCheckpoint, saveState, promoteStaging, saveStateAndPromoteStaging) updates the session's checkpointId pointer atomically, so resolving via that pointer is correct on all backends. The in-tree SQL/Redis stores resolve the pointer first and fall back to step_count-ordering only when the pointer is absent or dangling (legacy data). The cross-store contract test (packages/core/src/testing/checkpoint-operations.ts) enforces this invariant; custom stores must satisfy it.

Redis saveState breaks the pointer today (RM-38)

The claim above that every path updates the pointer correctly doesn't hold for Redis saveState. It sets checkpointId to an id it generated itself, while createSessionCheckpoint stores the checkpoint under a different id. The pointer dangles, getLatestCheckpoint falls back to stepCount ordering, and it can return an earlier turn's checkpoint. The contract test above exercises only createCheckpoint, never saveState, so it doesn't catch this. Separately, saveState checkpoints record streamSequence 0 on every store that writes one (D1's writes none) (RM-37), and the Durable Object store's also record messageCount 0 (RM-36). See the conformance findings.

Released under the MIT License.