Skip to content

@helix-agents/core ​

Core agent framework functionality - types, factories, state management, LLM interface, store interfaces, stream utilities, and orchestration functions.

Installation ​

bash
npm install @helix-agents/core

Entry points ​

ImportWhat it exports
@helix-agents/coreEverything on this page except what the rows below name.
@helix-agents/core/testing/mocksThe mock adapters: MockLLMAdapter, RoutingMockLLMAdapter, MockEmbeddingAdapter, the ScriptedModel family and their types. Imports no vitest: use it in tests, test workers and scripts.
@helix-agents/core/testingThe same mocks, plus the store, stream and remote-consumer contract suites and other test helpers. Imports vitest: only inside a vitest test.
@helix-agents/core/skill-fileparseSkillFile and ParseResult (SKILL.md parsing; the only user of the yaml package).

The main entry loads none of the others. The mocks and parseSkillFile used to be exported from the main entry; see names moved to their own entry points.

Schemas Are Getters ​

Every Zod schema the package exports is a getter, named in camelCase: call it where you use the schema (messageSchema().parse(raw), streamChunkSchema().safeParse(data)). The first call builds the schema; every later call returns the same instance, so identity-keyed caches keep working. Importing the package builds no schemas, which keeps a Cloudflare Worker's startup cheap (Cloudflare Workers: Startup Cost). The old MessageSchema-style constants are gone: see the lazy schemas upgrade guide.

Named types (Message, StreamChunk, ErrorDetail, …) are unchanged. For a schema without a named type, use z.infer<ReturnType<typeof fooSchema>>.

lazySchema / lazySchemaFactory ​

The helpers the SDK builds its own getters with; use them for your schemas too:

typescript
import { z } from 'zod';
import { lazySchema, lazySchemaFactory } from '@helix-agents/core';

// Built on the first call, the same instance after that.
export const noteSchema = lazySchema(() =>
  z.object({ content: z.string(), source: z.string().optional() })
);

// A schema that nests another calls the inner getter inside its own factory.
export const notebookSchema = lazySchema(() => z.object({ notes: z.array(noteSchema()) }));

export type Note = z.infer<ReturnType<typeof noteSchema>>;

// One instance per argument, never evicted. A primitive argument is its own key; an object
// argument needs a `keyOf` (the second argument) that names its key.
export const boundedNameSchema = lazySchemaFactory((max: number) => z.string().max(max));
boundedNameSchema(64) === boundedNameSchema(64); // true
typescript
type LazySchema<T extends z.ZodType> = () => T;

function lazySchema<T extends z.ZodType>(build: () => T): LazySchema<T>;

// Without keyOf the argument is the key, so it must be a primitive.
function lazySchemaFactory<
  A extends string | number | boolean | bigint | undefined | null,
  T extends z.ZodType,
>(build: (arg: A) => T): (arg: A) => T;
function lazySchemaFactory<A, T extends z.ZodType>(
  build: (arg: A) => T,
  keyOf: (arg: A) => string | number | boolean | undefined
): (arg: A) => T;

lazySchema builds on the first call and returns that instance on every later call; a build that throws is not cached, so the next call runs it again. lazySchemaFactory keeps one instance per key (keyOf(arg), else the argument itself) in a cache that never evicts, so use it for a small, fixed set of keys (depths, limits, enum values).

voidOutputSchema ​

voidOutputSchema() is the one shared z.void() instance used as the output schema of the finish tool (createFinishTool), so defining tools and agents builds nothing per call.

Agent Definition ​

defineAgent ​

Create an agent configuration.

typescript
import { defineAgent } from '@helix-agents/core';
import { z } from 'zod';

const MyAgent = defineAgent({
  name: 'my-agent',
  description: 'Does something useful',
  systemPrompt: 'You are a helpful assistant.',
  stateSchema: z.object({ count: z.number().default(0) }),
  outputSchema: z.object({ result: z.string() }),
  tools: [myTool],
  llmConfig: { model: openai('gpt-6-luna') },
  maxSteps: 10,
});

Types:

  • AgentConfig<TStateSchema, TOutputSchema> - Full agent configuration
  • Agent<TState, TOutput> - Shorthand for configured agent
  • LLMConfig - Model configuration (model, temperature, maxOutputTokens, cache, providerOptions, strictOutput). strictOutput?: boolean (default false) opts the auto-injected __finish__ tool into the provider's constrained decoding so a capable model emits schema-conforming structured output. Capability-gated and a safe no-op when the model/provider/adapter doesn't support it. See Finishing Agents → Strict structured output.
  • PersistentAgentConfig - Configuration for persistent sub-agents (agent, mode, description)

Tool Definition ​

defineTool ​

Create a tool that agents can use.

typescript
import { defineTool } from '@helix-agents/core';
import { z } from 'zod';

const myTool = defineTool({
  name: 'my_tool',
  description: 'Does something',
  inputSchema: z.object({ query: z.string() }),
  outputSchema: z.object({ result: z.string() }),
  execute: async (input, context) => {
    return { result: 'done' };
  },
});

Types:

  • Tool<TInput, TOutput> - Tool definition
  • ToolConfig<TInput, TOutput> - Tool configuration
  • ToolContext - Context passed to execute function

Both Tool and ToolConfig accept an optional strict?: boolean. When true, the LLM adapter asks the model provider to constrain the model's tool arguments to the tool's inputSchema (strict / structured tool use). It is forwarded subject to provider/model capability and is a safe no-op when unsupported — useful for tools with rich/nested inputs and for finishWith tools that need schema-exact output. For an agent's auto-injected __finish__ tool, prefer LLMConfig.strictOutput.

Approval-gated tools (v7) ​

Set requireApproval to gate tool execution behind a user approval. The runtime emits a tool_approval_request chunk and durably suspends the run until the frontend submits the user's decision via submitToolResult.

typescript
// Static-true form — every invocation pauses
const deleteTool = defineTool({
  name: 'delete_file',
  inputSchema: z.object({ path: z.string() }),
  requireApproval: true,
  execute: async ({ path }) => deleteFile(path),
});

// Function form — gated on runtime args (fail-closed if it throws)
const sendBulkTool = defineTool({
  name: 'send_bulk_email',
  inputSchema: z.object({ to: z.array(z.string()), body: z.string() }),
  requireApproval: ({ to }) => to.length > 50,
  execute: async ({ to, body }) => sendBulkEmail(to, body),
});

requireApproval cannot be combined with execute: 'client' or finishWith: true — the framework rejects those at defineTool() time.

Helpers:

  • isApprovalGatedTool(tool) - type guard returning true for any tool with requireApproval !== undefined && !== false
  • A denied approval synthesizes the canonical message 'Tool call was not approved by the user' as the tool result so the LLM has a stable anchor for the rejection

createSubAgentTool ​

Create a tool that invokes a sub-agent. The sub-agent must have an outputSchema defined.

typescript
import { createSubAgentTool, defineAgent } from '@helix-agents/core';
import { z } from 'zod';

// First, define the sub-agent with an outputSchema
const WorkerAgent = defineAgent({
  name: 'worker-agent',
  systemPrompt: 'You process tasks.',
  outputSchema: z.object({ result: z.string() }),
  llmConfig: { model: openai('gpt-6-luna') },
});

// Then create a sub-agent tool from it
const delegateTool = createSubAgentTool(
  WorkerAgent, // The agent config (must have outputSchema)
  z.object({ task: z.string() }), // Input schema for the tool
  {
    description: 'Delegate to worker', // Optional description override
    timeoutMs: 60_000, // Optional wall-clock timeout in ms
  }
);

Signature:

typescript
function createSubAgentTool<TInput extends z.ZodType, TOutputSchema extends z.ZodType>(
  agent: AgentConfig<any, TOutputSchema>,
  inputSchema: TInput,
  options?: SubAgentToolOptions // z.infer of subAgentToolOptionsSchema(), the one list of options
): SubAgentTool<TInput, TOutputSchema>;

Options:

OptionTypeDescription
descriptionstringOverride the tool description (defaults to the agent's description)
timeoutMsnumberWall-clock timeout in milliseconds. In the DO runtime, defaults to 10 minutes if not set.
inheritWorkspacebooleanShare the parent's workspace. Refused (framework_not_supported) where the child runs remote, e.g. on the CF DO.

toRemoteSubAgentTool(tool, { transport, remoteAgentType }) is the one sub-agent → remote conversion (the CF DO runs every sub-agent in a sibling DO through it). Every option is carried or refused typed, never dropped: description and timeoutMs are carried; inheritWorkspace: true is a framework_not_supported; a child without outputSchema is a validation_error. A unit test reads the keys off subAgentToolOptionsSchema(), so a new option must be classified.

Types:

  • SubAgentTool - Sub-agent tool definition (extends Tool with _isSubAgent, _agentConfig, _timeoutMs)

Tool Utilities ​

typescript
import {
  SUBAGENT_TOOL_PREFIX, // 'subagent__'
  FINISH_TOOL_NAME, // '__finish__'
  isSubAgentTool, // Check if tool is a sub-agent
  isFinishTool, // Check if tool is __finish__
  createFinishTool, // Create finish tool from schema
} from '@helix-agents/core';

findAgentInTree(agent, agentType) returns the agent config named agentType in agent's sub-agent tree: agent itself when its name matches, else the first nested createSubAgentTool config, depth-first and cycle-safe (undefined when none matches). It is the same walk findToolByNameInAgent uses, for a runtime that holds one root agent and needs a synchronous lookup over its tree (the CF Durable Object's submit-side outputSchema validation).

adoptWorkspaceRefWrites(expectedVersion, writes) (with WorkspaceRefWrite { baseVersion, version, ref }) returns the { version, ref } a step or suspension commit adopts from the workspace-ref writes its run landed, only when they form an unbroken chain from expectedVersion, else null (a foreign writer moved the session, so the commit's version CAS fails). The core step iterator uses it; see Workspaces.

createCallIdRewriter(history) (with CallIdRewriter { wrapCallbacks(callbacks), rewriteResult(result) }) keeps every tool-call id unique within a session (tree client calls T0). Build one per model attempt from the history the attempt sent: wrapCallbacks wraps the attempt's LLMStreamCallbacks so every tool frame carries the rewritten id, and rewriteResult rewrites the StepResult's toolCalls, subAgentCalls (one numbering with toolCalls: an id in both is one call) and parts tool refs. The k-th occurrence of a raw id becomes the raw id when it is free, else <id>_<n> with the smallest free n >= 1; it adds only _ and digits. collectCallIds(messages) is the set of ids a log already holds (assistant toolCalls, parts tool refs, tool results). A runtime with its own LLM call site must wire both; see Concepts: Tool.

createFinishTool(outputSchema, options?) accepts an optional second argument { strict?: boolean }. Passing { strict: true } marks the generated __finish__ tool as strict so the provider constrains its output to outputSchema (capability-gated, safe no-op when unsupported). Runtimes derive this from LLMConfig.strictOutput.

Companion Tools (Persistent Sub-Agents) ​

Auto-injected tools for managing persistent sub-agents. These are added when an agent has persistentAgents configured.

typescript
import {
  COMPANION_TOOL_PREFIX, // 'companion__'
  isCompanionTool, // Check if tool is a companion tool
} from '@helix-agents/core';

import type {
  CompanionTool, // Tool interface with _isCompanionTool marker
  CompanionType, // 'spawnAgent' | 'sendMessage' | 'listChildren' | 'getChildStatus' | 'waitForResult' | 'terminateChild'
} from '@helix-agents/core';

Companion tool creators:

typescript
import {
  createSpawnAgentTool,
  createSendMessageTool,
  createListChildrenTool,
  createGetChildStatusTool,
  createWaitForResultTool,
  createTerminateChildTool,
} from '@helix-agents/core';

These are used internally by buildEffectiveTools() and are not typically called directly. They are auto-injected when an agent's persistentAgents array is populated.

Interrupted companions. A persistent child whose run was interrupted (for example because its parent was stopped) keeps its session interrupted and its SubSessionRef.status 'interrupted' (no completedAt); it is resumable, not failed. companion__sendMessage resumes it on its preserved history. companion__waitForResult returns { name, status: 'interrupted', message } with resume guidance once three consecutive polls agree. companion__spawnAgent with the name of an interrupted child answers { name, status: 'not_spawned', delivered: false, reason: 'child_interrupted', errorDetail: { message: "Agent '<name>' is interrupted. Use companion__sendMessage to resume it." } }, a successful tool result (CP-40 (C2); it threw that message before). Applies to JS and CF Workflows (the CF Durable Object follows the same three-poll debounce for waitForResult and the blocking spawn; its parent stop writes no ref, see the DO stop section). For custom runtimes using executeCompanionToolDispatch, ChildTerminalResult.status (returned by waitForChildTerminal) now also accepts 'interrupted', which records the ref 'interrupted' instead of a terminal status.

Wait answer (FU-WAIT-TIMEOUT-STATUS-TWO-SHAPES). companion__waitForResult takes waitForResultArgsSchema() ({ name, timeout? }, timeout a positive whole number of milliseconds) and answers companionWaitResultSchema(), a Zod union discriminated on status:

typescript
import {
  waitForResultArgsSchema,
  companionWaitResultSchema,
  waitForResultTimeout,
  WAIT_FOR_RESULT_DESCRIPTION,
  type CompanionWaitResult,
  type CompanionWaitTimeout,
  type WaitForResultArgs,
} from '@helix-agents/core';

// { name, status: 'completed', result? }
// { name, status: 'failed', result?, message? }
// { name, status: 'terminated', result? }
// { name, status: 'interrupted', message }
// { name, status: 'suspended', pendingCalls, message } // CP-40 (C2): paused on client / approval calls
// { name, status: 'timeout', currentStatus }   // currentStatus: SubSessionRef['status']
// { name, status: 'unreachable', currentStatus, message } // the CF Durable Object only (D1)
waitForResultTimeout('researcher-1', 'running');
// → { name: 'researcher-1', status: 'timeout', currentStatus: 'running' }

The unreachable answer (CP-125, D1, spec 2026-10-15). A runtime whose parent cannot read its child at all (the CF Durable Object: parent and child are two DOs) answers a typed unreachable once every read failed for its window, instead of recording the child failed. Nothing is written: the child may still be running, and its outcome is recorded when it reaches the parent.

typescript
import { companionUnreachableMessage, waitForResultUnreachable } from '@helix-agents/core';

function companionUnreachableMessage(name: string, windowMs: number): string;
// "Child agent '<name>' could not be reached for <windowMs>ms. Its outcome is unknown: it may still be running,
//  and its result will be delivered when it reaches this agent. Call companion__waitForResult again, or
//  companion__terminateChild to stop it."

function waitForResultUnreachable(
  name: string,
  currentStatus: SubSessionRef['status'],
  windowMs: number
): CompanionWaitResult; // { name, status: 'unreachable', currentStatus, message: companionUnreachableMessage(...) }

ChildTerminalResult (what CompanionToolDispatchDeps.waitForChildTerminal returns) gains the arm { status: 'unreachable'; windowMs: number }, and its failed arm carries the child's typed errorDetail ({ status: 'failed'; errorDetail?: ErrorDetail }, DO companion MR B). handleSpawnAgent writes no ref for unreachable and answers the blocking spawn { name, status: 'unreachable', message: companionUnreachableMessage(name, windowMs) }, a successful tool result.

DO companion MR C1 adds the arm { status: 'parent_stopped'; kind: StopKind } (the CF DO's park, when the parent's own stop ended it) and the input field toolCallId (the spawn call's id) to waitForChildTerminal; a runtime whose park does not need it ignores it (runtime-js does). handleSpawnAgent writes no ref for parent_stopped (I-C8: the child's outcome is unknown) and answers { name, status: 'parent_stopped', kind }, which a runtime never commits (the parent's step ends by its stop). The interrupted answer now carries message, built by the exported interruptedCompanionGuidance(childName) ("Child agent '<name>' was interrupted before completing. It is resumable: use companion__sendMessage to continue it."), the same text companion__waitForResult gives.

The suspended arm (CP-40 (C2), DO companion MR C §2). A child whose run is suspended_client_tool over a paused session has committed a suspension. It waits on client-executed or approval calls that nothing in its parent's conversation can answer. So does a run suspended_step_partial (CP-214): the same pending calls, beside a server tool or a child that already ran in that step; it is answered the same way on CF Workflows, runtime-js and the CF DO (whose Q-B1 ruling, "live", now covers awaiting children only). A run suspended_awaiting_children is not one: it is live (Q-B1), pending CP-214's reachability ruling. ChildTerminalResult, CompanionSpawnResult and CompanionWaitResult each gain a suspended arm. Each awaited call is summarised without its input:

typescript
import {
  pendingCallSummarySchema,
  pendingCallSummariesOf,
  pendingCallsCause,
  suspendedCompanionGuidance,
  companionAwaitingClientDetail,
  waitForResultSuspended,
  type PendingCallSummary,
} from '@helix-agents/core';

// Strict: an extra key is refused (client and server deploy together, so a new kind ships to both at once).
type PendingCallSummary = { toolCallId: string; toolName: string; kind: 'client' | 'approval' };

// The awaited calls of a pending map: unanswered (no submittedAt), never a pre-submit stub; an approvalId → 'approval',
// else 'client'. With the child's log, in the model's call order (pendingEntriesInCallOrder); else in map order.
function pendingCallSummariesOf(
  pending: Readonly<Record<string, PendingClientToolCall>>,
  log?: readonly { role: string; toolCalls?: readonly { id: string }[] }[]
): PendingCallSummary[];

function pendingCallsCause(pendingCalls: readonly PendingCallSummary[]): string;
// "ask_user (call_1, client); pay (call_2, approval)"; '' for no calls

function suspendedCompanionGuidance(
  childName: string,
  pendingCalls: readonly PendingCallSummary[]
): string;
// "Child agent '<name>' is paused on 2 calls only the user can answer; it continues by itself once those calls
//  are answered. Use companion__waitForResult for its result, or companion__terminateChild to stop it.
//  Waiting on: ask_user (client); pay (approval)."
// One call: "… paused on a call … once that call is answered …". CP-250: the child continues by itself (the
// companion self-wake) once the user's submit answers its calls. No calls: says to wait for it (never terminate).

// The errorDetail of a child_awaiting_client answer: { message: suspendedCompanionGuidance(...), cause:
// pendingCallsCause(...) } (no cause for no calls), and no code.
function companionAwaitingClientDetail(
  childName: string,
  pendingCalls: readonly PendingCallSummary[]
): ErrorDetail;

// ChildTerminalResult:   | { status: 'suspended'; pendingCalls: PendingCallSummary[] }
// CompanionSpawnResult:  | { name; status: 'suspended'; pendingCalls; message; priorResult? }
// CompanionWaitResult:   | { name; status: 'suspended'; pendingCalls; message }   // strict, like every wait arm
function waitForResultSuspended(
  name: string,
  pendingCalls: readonly PendingCallSummary[]
): CompanionWaitResult;

companionSpawnResultFromTerminal maps the suspended terminal to { name, status: 'suspended', pendingCalls, message: suspendedCompanionGuidance(name, pendingCalls) }. The guidance is built only from the child's name and the developer's tool names, so the DI-70 sanitizer has nothing to redact.

Bounded by the run-level cap. The entry builders sanitize an answer's errorDetail with the run-level preset (sanitizeErrorDetail), which caps message and a string cause at RUN_ERROR_TEXT_MAX_LENGTH (256). So suspendedCompanionGuidance, pendingCallsCause and companionNotSupportedDetail's message are built to fit it, so the cap never cuts them. Each text puts its guidance first (what happened, what to do), then as many WHOLE entries as fit, joined with ; , then … and K more for the K left out. An entry is never cut midway, and a name longer than 48 characters (in the guidance, or in a path) is shortened at a character boundary, ending in … (core leadName). Redaction still runs first: a tool name or call id that looks like an email, a token or an address is rewritten. The structured pendingCalls list is not subject to this cap. Core's dispatcher (executeCompanionToolDispatch) answers the arm in three places, for a LOCAL companion ref only (a remote ref paused_awaiting_client is paused on calls its root answers, CP-157, and keeps its behaviour):

  • The blocking park. A suspended terminal from waitForChildTerminal writes the parent ref paused_awaiting_client at the entry's generation (atGeneration), with no completedAt and no delivery mark (the child's later outcome is still notified), and answers the suspended spawn result.
  • companion__waitForResult. A paused_awaiting_client ref answers the suspended wait result on the first poll that reads it: no debounce, no sleep. Nothing is delivered and the ref is kept.
  • companion__sendMessage. A child with pending client-tool calls answers { delivered: false, reason: 'child_awaiting_client', errorDetail: companionAwaitingClientDetail(name, pendingCalls) }, a successful tool result (it threw before), and nothing is appended.

Each reads the pending calls from the child's session (pendingClientToolCalls) and orders them by the child's log (pendingCallSummariesOf(pending, log)): the model's call order, never the store's map order.

A parent that cannot read the child's store (the CF DO) takes the calls from the child's /status listing (CP-157 RemoteStatusResponse.pendingCalls) with pendingCallSummariesFromListing(listing): each entry's toolCallId, toolName and kind (its input, inputTruncated and approvalId dropped), in the LISTING's order, which core listRemotePendingCalls makes deterministic (oldest suspendedAt first, then by call id), so a paused child answers the same order on every read and replay. It is not the model's call order (only the child's log holds that).

A child entry that the child's own backstop refused by its definition (the CF DO's consult check: a not-started entry child_entry_failed whose errorDetail.code is framework_companion_not_supported) answers the same not_spawned / child_not_supported as refuseChild, classified by the code, never by text; its ref is released like any other non-start.

A child that awaits NO call any more (every call answered meanwhile, so it may be running again) is treated as live, like a running child, and the model is never told to terminate it: the wait keeps polling (its timeout and orphan rules), sendMessage answers child_busy, and a spawn under that name answers child_busy. Nothing writes the ref; the child's own next outcome corrects it. (Orchestrator ruling, CP-40 C2.) A runtime reports a suspended park terminal only with at least one awaited call.

The spawn-time refusal (companionNotSupportedDetail(agent), CP-40 (C2), D2 (c)). The function returns ErrorDetail | null: why agent cannot run as a companion on a runtime where nothing can answer a companion's client-executed or approval-gated call (the CF Durable Object). It walks agent.tools (isClientTool, isApprovalGatedTool), every sub-agent tool's agent and every persistentAgents[].agent. Depth is unbounded and the walk is cycle-safe (each agent config is visited once). A remote sub-agent tool is opaque. It returns null when nothing offends, else { code: 'framework_companion_not_supported', category: 'framework', retryable: false, message } (its own code, so a runtime classifies the refusal by type, never by its text), whose message says what happened, then names each offending tool by its path below the agent it names (a direct tool is just its name), in walk order, bounded by the run-level cap (above):

typescript
import { companionNotSupportedDetail } from '@helix-agents/core';

companionNotSupportedDetail(critic)?.message;
// "Agent 'critic' cannot be a companion here: nothing can answer its client or approval tools, so spawning it again
//  cannot help. Tools: pay (approval); researcher → deep → sign (approval); researcher → ask_user (client);
//  helper → pick_file (client)."

With more offenders, or longer names, the message ends in … and K more. companionNotSupportedOffenders(agent) returns the COMPLETE list behind it (CompanionOffendingTool[]: { path: string[]; kind: 'client' | 'approval' }, the same walk and order, empty when nothing offends, never capped), so a runtime that refuses a companion logs every offending tool on the server:

typescript
import { companionNotSupportedOffenders } from '@helix-agents/core';

companionNotSupportedOffenders(critic);
// [{ path: ['critic', 'pay'], kind: 'approval' },
//  { path: ['critic', 'researcher', 'deep', 'sign'], kind: 'approval' },
//  { path: ['critic', 'researcher', 'ask_user'], kind: 'client' },
//  { path: ['critic', 'helper', 'pick_file'], kind: 'client' }]

CompanionToolDispatchDeps.refuseChild (CP-40 (C2)). An optional dep:

typescript
interface CompanionToolDispatchDeps {
  // ...
  refuseChild?: (child: PersistentAgentConfig) => ErrorDetail | null;
}

handleSpawnAgent asks it after the C14 replay (a replayed call answers its recorded outcome first) and before anything else: the stranded-claim reclaim, the name resolution, the claim, the prior hold, the reset, and the spawn and continue deps. This holds for every entry kind (a first spawn, a respawn, a continue). A non-null detail answers { name, status: 'not_spawned', delivered: false, reason: 'child_not_supported', errorDetail }, with nothing written or started; name is the call's own, or the agent type when it gave none (no auto-name is resolved or used). Only the CF Durable Object supplies it (nothing there can answer a companion's client or approval call): on companionNotSupportedDetail(child.agent), logging companionNotSupportedOffenders(child.agent) once when it refuses. runtime-js and the other shared-store runtimes leave it unset: their companion pauses, and the park and the wait answer suspended (CP-127). Absent: no refusal.

resolveChildName(providedName, agentType, existingRefs, options?) (CP-40 (C2)). It returns ResolvedChildName, never a thrown error for a name in use:

typescript
import {
  resolveChildName,
  type ResolvedChildName,
  type ChildNameRefusal,
} from '@helix-agents/core';

type ResolvedChildName =
  | { kind: 'resolved'; name: string } // the name to spawn: the provided one, or generateAutoName's
  | { kind: 'refused'; name: string; outcome: ChildNameRefusal };

interface ChildNameRefusal {
  started: false;
  reason: 'child_busy' | 'child_interrupted' | 'child_awaiting_client';
  errorDetail: ErrorDetail;
}

An explicit name held by a running ref is child_busy (the message keeps the old "is already running" text), by an interrupted one child_interrupted (resume guidance), and by a LOCAL paused_awaiting_client one child_awaiting_client (companionAwaitingClientDetail(name, options.pendingCalls); the dispatcher reads the child's calls and passes them). A paused ref whose child awaits NO call any more (answered meanwhile, or pendingCalls not passed) is stale: the child may be running again, so the name is child_busy. outcome is a CompanionSpawnOutcome, so companionSpawnResultFromEntry(name, outcome) is the answer. A name over 128 characters still throws (a programming error). Upgrade: callers read .name from a resolved answer and answer (or throw) the refused one; the DBOS runtime throws the refusal's message for child_busy / child_interrupted, as before.

The spawn contract (CompanionSpawnResult, D4; DO companion MR B). companion__spawnAgent answers one schema, parsed at every runtime boundary:

typescript
import {
  COMPANION_ENTRY_FAILURE_REASONS,
  COMPANION_NOT_SPAWNED_REASONS,
  companionSpawnResultSchema,
  parseCompanionSpawnResult,
  companionSpawnResultFromEntry,
  companionDeliveryFromEntry,
  parentFacingPriorResult,
  type CompanionEntryFailureReason,
  type CompanionEntryOutcome,
  type CompanionSpawnOutcome,
  type CompanionNotSpawnedReason,
  type CompanionSpawnResult,
  type CompanionSpawnHandoff,
  type CompanionSpawnAnswer,
} from '@helix-agents/core';

// The reasons a child's ENTRY reports: each is also a not-delivered reason (by type: `satisfies`).
type CompanionEntryFailureReason =
  | 'child_busy' // the child's run start lost to another entry
  | 'child_start_unconfirmed' // no entry outcome within the runtime's bound, or a non-2xx sent after the child's run-start commit
  | 'child_entry_failed' // the child's entry failed before its run-start commit (errorDetail says how)
  | 'child_terminated'; // the parent terminated the child before the entry's run start

// The entry reasons, then the dispatcher's own refusals, made before any entry (CP-40 (C2)).
type CompanionNotSpawnedReason =
  | CompanionEntryFailureReason
  | 'child_not_supported' // the child's tree has a client / approval tool (errorDetail: companionNotSupportedDetail)
  | 'child_interrupted' // the child is interrupted and resumable: resume it with companion__sendMessage
  | 'child_awaiting_client'; // the child is paused on calls nothing can answer (errorDetail: companionAwaitingClientDetail)

// What spawnPersistentChild / continuePersistentChild report (never a throw for an entry outcome).
type CompanionEntryOutcome =
  | { started: true }
  | { started: false; reason: CompanionEntryFailureReason; errorDetail?: ErrorDetail };

// companionSpawnResultFromEntry's input: an entry outcome, or a refusal with any not-spawned reason.
type CompanionSpawnOutcome =
  | CompanionEntryOutcome
  | { started: false; reason: CompanionNotSpawnedReason; errorDetail?: ErrorDetail };

type CompanionSpawnResult =
  | { name: string; status: 'spawned'; delivered: true; priorResult?: CompanionPriorResult } // non-blocking, started
  | {
      name: string;
      status: 'not_spawned';
      delivered: false;
      reason: CompanionNotSpawnedReason;
      errorDetail?: ErrorDetail;
      priorResult?: CompanionPriorResult;
    }
  | { name: string; status: 'completed'; output?: unknown; priorResult?: CompanionPriorResult } // blocking: the child's committed word
  | {
      name: string;
      status: 'failed';
      errorDetail?: ErrorDetail;
      priorResult?: CompanionPriorResult;
    }
  | { name: string; status: 'terminated'; priorResult?: CompanionPriorResult }
  | { name: string; status: 'interrupted'; message?: string; priorResult?: CompanionPriorResult } // message: resume guidance
  | {
      name: string;
      status: 'suspended'; // CP-40 (C2): paused on client / approval calls; not terminal
      pendingCalls: PendingCallSummary[];
      message: string; // suspendedCompanionGuidance
      priorResult?: CompanionPriorResult;
    }
  | { name: string; status: 'unreachable'; message: string; priorResult?: CompanionPriorResult }
  | { name: string; status: 'parent_stopped'; kind: StopKind; priorResult?: CompanionPriorResult }; // CF DO: never committed, no ref written

A not_spawned result is a successful tool result (the model reads reason and may spawn again). parseCompanionSpawnResult(value) is the one parse at a runtime boundary (a value outside the union — a spawned without delivered, a live status, a priorResult with a live status — throws a non-retryable framework_internal_error): runtime-js's run loop calls it on every spawn answer, the CF Durable Object parses through the spawn tool's outputSchema (the same schema, below), and CF Workflows builds its answers with companionSpawnResultFromEntry. companionSpawnResultFromEntry(name, outcome, priorResult?) builds the non-blocking answer from an entry outcome and companionDeliveryFromEntry(outcome, priorResult?) the sendMessage delivery (the entry reasons, COMPANION_ENTRY_FAILURE_REASONS, are a subset of the not-delivered ones by type). createSpawnAgentTool().outputSchema is companionSpawnResultSchema(); on the CF Durable Object, whose companion tools run runtime-js's regular tool path, that schema is load-bearing (every answer must parse or the call is a tool error). handleSpawnAgent returns CompanionSpawnAnswer = CompanionSpawnResult | CompanionSpawnHandoff: the D4 result on a runtime that supplies waitForChildTerminal (runtime-js, the CF DO), else the handoff { name, childSessionId, agentType, mode, initialMessage } (status?: never, so 'status' in result discriminates) that CF Workflows' and Temporal's workflow bodies consume (FU-COMPANION-SPAWN-HANDOFF-TYPED: Temporal's model still sees the handoff).

A companion's run-start throw → its entry outcome (companionStartOutcome, CP-155 / CP-161). A runtime whose companion entry is opened only by the child's own run-start commit — a continue (runtime-js: StartRun.entryStatuses: ['completed'] plus resumedCompanionRef, nothing written before it but the prior hold), or a first spawn / respawn (runtime-js, CP-161: only the parent's ref and the never-run child session before it) — maps that commit's throw to its D4 answer with (CP-155 named it continueStartOutcome; CP-161 renamed it when the first spawn started sharing it, with no alias):

typescript
import { companionStartOutcome } from '@helix-agents/core';

const outcome: CompanionEntryOutcome = await companionStartOutcome(startError, {
  childSessionId,
  childRunId, // minted by the entry before its commit
  abandonRunStart: (sessionId, runId) => stateStore.abandonRunStart(sessionId, runId),
});
// { started: true } → the commit landed (its acknowledgement was lost): proceed as the winner.
ThrowOutcome
RunStartRejectedError('resumed_companion_ref_terminated')child_terminated
RunEntryFailedError (the commit won, its stream steps failed) / RunSupersededError (a newer run took the child after it)child_start_unconfirmed (+ errorDetail)
entry_status_changed, resumed_companion_ref_missing, entry_abandoned; a state_size_exceeded / validation_error errorchild_entry_failed + resolveErrorDetail(err)
any other lost claim (isLostClaimError: RunStartConflictError, the other rejections)child_busy
anything else (and idempotency_mismatch): abandonRunStart(childSessionId, childRunId) → committed / abandoned / throwsstarted / child_entry_failed / child_start_unconfirmed

abandonRunStart is atomic with the session's run-start commits, so a commit still in flight when the call threw can never land after the answer: an abandoned id is refused entry_abandoned (nothing written), as the durable runtimes' entry fail-safes rely on. The resumed_companion_ref_* rows cannot occur on a first spawn, whose commit carries no resumedCompanionRef; a parent stop before the write (framework_cancelled, CP-21) is decided by the abandon too (child_entry_failed). Every errorDetail it answers is sanitized (sanitizeErrorDetail(resolveErrorDetail(error))): the answer is a tool result the model and the stream read, so a store error's connection string or credentials never reach them; code, category and retryable are kept. This is defence in depth: core's answer builders (companionSpawnResultFromEntry / companionDeliveryFromEntry) sanitize every not-started answer they build.

The run-anchored child outcome read (readChildTerminalOutcome / readChildNotice, CP-155). The one read of a persistent child's terminal outcome, for syncing its parent ref, answering getChildStatus / waitForResult and building its completion notice. A caller takes the word, the text and the notice's run key from one answer, never from a separate read, so a child turn that lands between two reads never gives the old turn's text under the new run.

readChildTerminalOutcome(store, childSessionId, { classifyNonTerminal? }) reads the session first; a session that is not terminal is answered from that one read (unsettled) unless classifyNonTerminal is set. Otherwise it reads the child's current run, its latest checkpoint, the session, the checkpoint again and the run again; nothing may move in between (same run id, same checkpoint id), else the reads are made once more, then unsettled. It answers a ChildTerminalOutcome:

  • { kind: 'settled', status, session, runId }: a completed / failed session whose run's own K5 is the latest commit (live or ended run; isRunStateCommit), or with no run (runId undefined), or a failed session over a run that ended failed whose last commit is that run's;
  • { kind: 'unsettled', liveRunId? }: not settled yet: a live run without its own K5 (liveRunId), a child that moved twice, or a non-terminal session read without classifyNonTerminal;
  • { kind: 'no_outcome', runId, runStatus }: nothing will change it: the run ended without its own K5, or there is no live run and the session is not terminal;
  • { kind: 'missing' }: no child session.

readChildNotice(store, parentSessionId, ref) → { kind: 'notify', status, session, runId } | { kind: 'skip' } is the decision every reader applies (session: null: the word with no session text). settled gives its outcome. A terminal (completed / failed) ref is always delivered: missing / no_outcome give the ref's own word; a live run without its K5 re-reads the ref after the child read, and a ref still terminal gives its word keyed on that run (it was written after that run's run-start commit), else skip. A non-terminal ref is notified failed for a run that ended failed without its K5 over a terminal session, else skip. resolveAndSyncSubSessionRefs and the companion read tools, deliverPendingChildCompletions (Temporal / DBOS), runtime-js's run-loop notifier and CF Workflows' checkCompletedChildren and companion wait read through it. Read errors propagate. ChildOutcomeStore is the store slice the first function reads.

With CP-161's generation scoping: a notice also carries runParent, the consult its run serves (RunMetadata.parent, from the same read), and childNoticeServesRef(notice, ref) (core runServesRef on it) is how the lazy syncs (the companion read tools, deliverPendingChildCompletions, CF Workflows' checkCompletedChildren) refuse to adopt an older generation's run into a ref a respawn's reset opened. The re-read of a terminal ref over a live run skips a ref whose outcomeGeneration is no longer the caller's (a ref reopened and ended since the listing), and every sync and delivered mark the readers write is scoped to the listed generation (writeChildOutcome, atGeneration); a sync the store refuses as stale or over the parent's terminated is not notified.

The entry table (spec 2026-10-15 §3.7, I-C8 / I-C10). A non-start is a typed answer, never a tool error, and its ref write is by the entry's kind: a first spawn's (or its same-id replay's) definite non-start (a reason other than child_start_unconfirmed or child_busy) writes the ref this call created failed + completedAt + completionDelivered: true, with the child's detail in the answer; a first spawn's child_busy writes nothing (CP-161 R1, I-C8: another entry of the child won, and the ref is that entry's — two parallel same-name first spawns share one), nor does a first-spawn non-start whose entry the parent's own stop cancelled (CP-161 rule 6, I-C8); a respawn's (a failed / terminated ref) restores the ref exactly (status, completedAt, completionDelivered, an absent flag as false), except that a held prior delivered by this answer (priorResult) is marked delivered — the respawn's own reset moved the ref's generation, so the slot's release would not mark it, and an exact restore let the notifier present the outcome twice; completionDelivered never suppresses a held prior; the held slot is the only delivery authority while set (R3), so a crash before the commit still delivers it once from the slot; a continue (a completed ref) writes nothing — the completed ref keeps its session. An unconfirmed start writes nothing: the ref stays running, unconfirmed, and the next same-name spawn re-probes it. A dep that throws is a programming or store error: nothing is compensated (the old catch compensation that wrote failed is gone). The usage pointer (recordCompanionSpawnUsage) fires for every spawn entry that started the child session (a first spawn, a respawn, a reset replay; never a continue), and recordCompanionUsagePointer keeps it to one per child session: it records nothing when the parent already has a subagent entry for that session (CP-212). A name's starts are serialized by the parent ref's claim, so that read and write never race for one child session.

The rule is one pure function, companionSpawnNonStartRefUpdate(entryKind, outcome, now?) (CP-161), which returns the updateSubSessionRef fields a definite non-start owes, or null. Its outcome is the dep's not-started answer, SpawnPersistentChildNonStart: the CompanionEntryOutcome arm plus what the dep observed — parentStopped (the parent's own abort signal fired); it never reaches the model (fix round 2 removed round 1's otherRunOwnsChild: the claim makes it redundant). Its sibling companionSpawnNonStartFailsSession(outcome) says whether the non-start fails the never-run child session on the session only (R3: a child_entry_failed of a first spawn or a respawn; a parent stop still fails it).

The parent ref is the claim (CP-161 fix 2, the joint sub-session ref model). A first spawn's addSubSessionRefs answer is its claim: not inserted and held by another call → child_busy before any child IO; held by this call → its replay (isReplay). A respawn resets the ref with updateSubSessionRef({ status: 'running', completionDelivered: false, expect: { op: 'reset', statuses: RESETTABLE_SUB_SESSION_STATUSES, generation: g } }) before deleteSession (a lost reset: SubSessionRefConflictError → child_busy, nothing deleted). A definite non-start writes through releaseSpawnClaim(stateStore, { parentSessionId, childSessionId, refGeneration, update, parentToolCallId, parentRunId? }, logger?), a CAS on the entry's own live generation and its own call (SubSessionRefExpect.parentToolCallId: a ref another call opened at the same generation is a conflict; a conflict is logged; it resolves 'released' | 'conflict'). The dep gets the generation as SpawnPersistentChildInput.refGeneration (required: the claim's reported generation, { inserted: true, generation }, 0 except on the CF DO, whose parent-side floor raises it; g + 1 for a reset; a replay's own; CompanionToolDispatchDeps.claimGeneration, optional, tells the dispatcher that floor, so a same-call replay at it is the claim's, never a reset's), and the outcome writes it drives carry UpdateSubSessionRef.atGeneration. Fix 3: every ref write a child's outcome drives goes through writeChildOutcome(stateStore, parentSessionId, update & { atGeneration }, logger, site): a stale write is a no-op the store answers (UpdateSubSessionRefOutcome), logged at warn companion.ref.stale_outcome_write. The reset stamps its call (UpdateSubSessionRef.parentToolCallId), and so does a continue's / resume's run-start reopen: a commit carrying StartRun.resumedCompanionRef writes StartRun.parent.toolCallId onto the parent's ref in the same atomic step (runtime-js, CF Workflows; the CF DO's reopen is its consult row's, Temporal's and DBOS's pass no parent). So SubSessionRef.parentToolCallId names the call that opened the ref's current generation. A replay of that call resumes at the delete; the lazy syncs adopt only a run of the ref's call (runServesRef). A continue gets ContinuePersistentChildInput.refGeneration (the generation it read) for its reopen CAS (ResumedCompanionRef.expect, resumedCompanionRefGeneration, resumedCompanionRefRefusal: RunStartRejectedError('resumed_companion_ref_moved')) and reports the opened one on SpawnPersistentChildResult.refGeneration; recordedConsultOutcome reports its read generation. A lost claim against ANOTHER call's terminal ref proceeds through the reset (or the continue). A replay's non-start beside another execution of the same call writes nothing (SpawnPersistentChildNonStart.consultRunExists). The dispatcher passes the entry's kind to the spawn dep (SpawnPersistentChildInput.entryKind, required: { kind: 'first_spawn' } or { kind: 'respawn', restore }, the ref as it was before the reset; the impossible no-kind case is a typed framework_internal_error), so a runtime whose workflow body learns the child's entry outcome only after the dispatcher returned (CF Workflows: it creates the __spawn__ instance and polls its run-start commit) applies the same write there; it omits recordCompanionSpawnUsage and records the pointer itself once the child started.

A blocking park's answer (companionSpawnResultFromTerminal(name, terminal, priorResult?), CP-161). The one builder of the park arms of CompanionSpawnResult from a ChildTerminalResult (completed with output, failed with errorDetail, terminated, interrupted with interruptedCompanionGuidance, suspended with its pendingCalls and suspendedCompanionGuidance, unreachable with the D1 message, parent_stopped), each with priorResult. Pure: core's dispatcher (applyParkResult) answers through it and keeps its own ref writes; CF Workflows' blocking spawn and continue parks call it too. ChildTerminalResult and interruptedCompanionGuidance live beside it in types/companion-spawn.ts (still exported from the package root).

A child's failure text as the parent receives it (DI-70). Every builder above builds the PARENT's copy of a child's failure text, so it sanitizes it with core's client preset: the failed arm's errorDetail (sanitizeErrorDetail), and every arm's priorResult through parentFacingPriorResult(priorResult?), which returns a prior that carries an errorDetail (a failed one) with it sanitized and any other prior (or undefined) as is, same reference. CF Workflows' own prior plumbing (its error row with a held prior, a placeholder carrying one) calls it too. The machine fields (code, category, retryable) are kept, and every one is a fixed point, so a detail a runtime already sanitized (runtime-js failureDetailOf, the CF DO's consult /status) is byte-identical. The child session's own error / errorDetail stays raw (operator data).

A superseded parent run mutates no child (CP-161 fix 4, I-A). Two required dispatch deps:

typescript
interface CompanionToolDispatchDeps {
  // ...
  assertParentRunOwned: () => Promise<void>; // the D2 check; throws RunSupersededError
  parentRunId: string | undefined; // the run the reset / reopen CASes carry
}

The dispatcher calls assertParentRunOwned immediately before every child mutation (the claim insert, the reset, each delete, the continue, the spawn dep, sendMessage's delivery, terminateChild's stop). A RunSupersededError propagates out of executeCompanionToolDispatch (never a tool result), so the run ends superseded. The store's fence closes the window between the assert and the CAS: the reset's SubSessionRefExpect.parentRunId and a continue's / resume's ResumedCompanionRef.expect.parentRunId (passed to the continue dep as ContinuePersistentChildInput.parentRunId) are checked in the write's atomic step against the parent session's current live run (companionParentRunHolds); a reset is refused RunSupersededError, a reopen RunStartRejectedError('parent_run_superseded'), nothing written. resumeMetadata (sendMessage to an interrupted child) carries the read ref's refStatus and refGeneration (required), the resume's reopen CAS (I-C).

CP-161 fix 5: only the PARENT run's own supersede escapes the dispatcher (isOwnRunSupersededError(error, sessionId, runId): its session, and its run when parentRunId is known); another session's or run's RunSupersededError is the call's tool failure. A run superseded after the claim it inserted or the reset it landed releases it first (releaseSpawnClaim: I-C10's failed, delivered, or the exact restore). A delivery whose running write did not land answers { delivered: true } with no resumeMetadata.

A stranded claim is reclaimed (CP-161 fix 9; the joint ref model's "reclaim" row). An optional dispatch dep and a pure predicate:

typescript
interface CompanionToolDispatchDeps {
  // ...
  reclaimStrandedSpawnRef?: (
    ref: SubSessionRef,
    ctx: { toolCallId: string }
  ) => Promise<'reclaimed' | 'not_stranded'>;
}

function isStrandedSpawnRef(input: {
  ref: SubSessionRef;
  toolRows: readonly Message[]; // the parent log's tool rows answering ref.parentToolCallId
  runnerCallIds: readonly string[]; // the current run's own companion calls
  toolCallId: string; // the successor's call
  childRun: Pick<RunMetadata, 'status' | 'parent'> | null; // read after the tool rows
}): boolean;

handleSpawnAgent asks the dep where a spawn meets ANOTHER call's running ref of its name: an explicit name (before the "already running" refusal) and a lost claim. 'reclaimed' means the ref is now failed, delivered, at its generation, and the spawn proceeds as a respawn (its reset moves the ref to g + 1). 'not_stranded' keeps today's answer. A ref is stranded only when ALL hold: a local, persistent, running ref; its call has no tool row, or only synthetic not-executed ones (isNotExecutedToolResult); the call is neither this call nor one of the current run's calls; and no child run serves it, decided strictly (no run, or a TERMINAL run serving another call; a run of the call in any status, a run recording no parent, or a LIVE run of another call keeps it). The write is releaseSpawnClaim(store, { …, parentRunId }), whose parentRunId puts expect.parentRunId on the release CAS: the store refuses it RunSupersededError unless that run is the parent's current live run. Its safety rests on two store guards: a late child of the claiming run is refused by its StartRun.parentRunFence, and that run's own ref writes by the parent-run fence (contract: reclaim.release-fenced, reclaim.stale-ref-write, reclaim.stale-child-start). Only CF Workflows supplies the dep (it reads the parent's log and the child's current run inside its companion step).

The runtime preconditions of reclaimStrandedSpawnRef. The predicate is sound only on a runtime that keeps all of these; a new runtime that supplies the dep must:

  • call it only after the run's own run-start commit (so the claiming run is no longer live: the one-live-run invariant);
  • commit each companion call's tool-use before the call runs, and its real result before the next companion call or step starts (so a call with no real result was never answered by the current run);
  • pass, as runnerCallIds, every call the run may still be executing (a call in flight is never read as stranded);
  • create every child that could serve a claim with StartRun.parentRunFence naming the creating run, so a child of a superseded run can never commit after the reclaim;
  • decide the free conditions first (strandedSpawnRefPrecheck: a local persistent running ref, not one of the run's own calls) and read the parent's log and the child's current run only for a possible strand.

A replayed spawn answers from the recorded outcome (ruling C14). An optional dispatch dep:

typescript
interface CompanionToolDispatchDeps {
  // ...
  recordedConsultOutcome?: (input: { parentSessionId: string; toolCallId: string }) => Promise<{
    childName: string;
    childSessionId: string;
    result: ChildTerminalResult;
  } | null>;
}

handleSpawnAgent calls it first, before name resolution. A non-null answer means the spawn is a replay of a tool call whose child outcome is already recorded (the same call re-executed after a parent eviction). It answers from that outcome and starts, continues or parks nothing: a non-blocking spawn answers { name, status: 'spawned' }, a blocking one is answered from result exactly as a park would be (the ref written with the result and marked delivered; nothing for unreachable). null (or no dep) leaves the spawn unchanged. Only the CF Durable Object supplies it: the ref's consult equals toolCallId and its outcome is recorded.

This answer applies only to a replay that reuses the call id (a deterministic or replayed model output). A CF DO recovery re-calls the model, and a real provider mints a new tool-call id, so the re-emitted spawn is not a replay: for a child still live it answers the "already running" tool error, and for a completed child it is a continue, which delivers the undelivered outcome as priorResult and runs the new consult (deliver-then-continue, below; FU-DO-COMPANION-ORPHANED-CONSULT-ADOPTION: re-pointing the orphaned consult to the new call is MR C).

A continue delivers an undelivered outcome, then continues (deliver-then-continue, DO companion MR B). A non-blocking critic can finish while the parent's model call is in flight, before the completion notifier reaches the model. A same-name companion__spawnAgent on a completed / failed child, or a companion__sendMessage to a completed one, issued then HOLDS that outcome and carries it beside its own answer as priorResult (CompanionPriorResult, below) — on every arm of CompanionSpawnResult and CompanionDeliveryOutcome, a not_spawned / delivered: false answer included — and the new consult runs. "Undelivered" means "not marked delivered" (the notifier, a blocking park and waitForResult mark it): a model that read the output through companion__getChildStatus, which does not mark it, gets it once more as priorResult. Before, the continue cleared the outcome and the model never saw it. The dep:

typescript
interface CompanionToolDispatchDeps {
  // ...
  holdPriorResult?: (input: {
    parentSessionId: string;
    childSessionId: string;
    toolCallId: string;
  }) => Promise<CompanionPriorResult | null>;
}

handleSpawnAgent asks it ONLY in its respawn branch (a failed / terminated ref, before deleteSession wipes what the child session holds, and only when waitForChildTerminal is supplied, since the handoff path cannot carry it); a continue dep (continuePersistentChild) holds inside itself, after it won the reopen, and reports the result on its own priorResult. The hold is the store's holdPriorResult CAS (the held prior, below): a held slot on the ref, never a delivered mark. runtime-js and the CF Durable Object supply the dep; CF Workflows holds in its continue dep (steps.ts); Temporal and DBOS neither hold nor deliver a prior (FU-SPAWN-CONTINUE-ENTRY-OUTCOME-PARITY, parked).

The held prior (SessionStateStore.holdPriorResult / releasePriorResult, every store). The exactly-once contract (orchestrator ruling C16): a prior outcome reaches the model in exactly one committed row — the answering call's tool result (priorResult), or the notifier's notice when that call never committed one that carries it.

typescript
import {
  readStoredHeldPrior,
  companionNoticeKey,
  companionNoticeKeySchema,
  findCommittedCompanionNotice,
  isCompanionAnswerCarryingPrior,
  isHeldPriorDelivered,
  heldPriorResultSchema,
  type HeldPriorResult,
  type HoldPriorResultInput,
  type ReleasePriorResultInput,
  type CompanionNoticeKey,
  type HeldPriorDeliveryKey,
} from '@helix-agents/core';

// On SubSessionRef (persistent refs): written only by the two store methods.
interface SubSessionRef {
  // ...
  heldPrior?: HeldPriorResult; // { toolCallId, runId?, status: 'completed' | 'failed', output?, errorDetail? }
  outcomeGeneration?: number; // 0 until the first reopen, +1 per reopen (a non-live ref written live); store-written
}

holdPriorResult(sessionId, { subSessionId, toolCallId, result, expectGeneration, runId? }): Promise<'held' | 'stale'>;
releasePriorResult(sessionId, { subSessionId, toolCallId }): Promise<'released' | 'stale'>;
  • The hold is a CAS inside the store's atomic primitive: the ref is persistent, completed / failed, completionDelivered falsy, its slot empty and its outcomeGeneration equal to expectGeneration — the generation of the SAME getSubSessionRefs read the caller took result from (never a re-read; a 'stale' answer means the child moved on: re-read before deciding). It never changes completionDelivered.
  • The release is the delivered mark, written after the parent's step commit that carried the answer: a CAS on the holder's toolCallId that always clears the slot and writes completionDelivered: true only while the generation is unchanged since the hold (a child reopened meanwhile keeps its new outcome undelivered). Never a timestamp or status comparison.
  • Delivered is proven structurally, never by text (isHeldPriorDelivered, the one release decision): a committed tool row for the holder's call whose decoded content CARRIES a priorResult that companionPriorResultSchema() accepts (isCompanionAnswerCarryingPrior), or a committed notifier notice keyed { subSessionId, runId } (findCommittedCompanionNotice) — and only when the slot names a run: a key without a runId never dedupes. A row for the call that carries no prior (a tool error after the hold, a placeholder) is no delivery: the next held-prior pass injects a notice from the HELD result and releases. There is no "any tool row by call id" check: a committed row counts only through isCompanionAnswerCarryingPrior.
  • The notice key. Every notifier notice carries metadata[COMMON_METADATA_KEYS.COMPANION_NOTICE] ('helixCompanionNotice', CompanionNoticeKey { subSessionId, runId? }); buildCompletionMessage(name, result, notice?) / buildFailureMessage(name, error, notice?) stamp it; companionNoticeKey(message) reads it (schema-checked, null for anything else). A recovery that finds the notice committed marks without re-injecting. buildFailureMessage interpolates sanitizeErrorMessage(error) (DI-70; after the callers' || 'unknown error'), so a failure notice is one line of at most 256 characters of redacted text on every runtime.
  • readStoredHeldPrior(raw, ids, logger, event) is the one reader of a store's held-prior column (a JSON string, a parsed JSONB value, or null): a corrupt value reads as nothing held and is logged at warn, never thrown.
  • Storage: Postgres V19 and D1 V23 (held_prior, outcome_generation, held_generation), DO-SQLite 18, Redis hash fields, memory a store-internal map. Contract: core/src/testing/sub-session-operations.ts ("the held prior") and run-start-operations.ts.

The session's workspace ref (RM-19). SessionState.workspaceRef (the open workspace's WorkspaceRef, saved by runtime-js's persistRef and adopted into the next commit) persists on every durable store, so a woken workspace run reopens its provider's workspace: Postgres V20 and D1 V24 (workspace_ref), DO-SQLite 20, a Redis hash field, memory in its session map. A clone never inherits it. workspaceRefSchema() is its Zod schema; readStoredWorkspaceRef(raw, sessionId, logger, event) is the one reader of the column (a corrupt value reads as no ref and is logged at error, never thrown); workspaceRefColumn(ref) is the value a store writes. Contract: core/src/testing/commit-checkpoint-operations.ts (workspace-ref.*).

A remote child's stop (CP-152, CP-217). The one primitive every runtime uses to stop a REMOTE child, naming the delegation it stops, and the debt a parent keeps on the child's ref until the child confirms it (at-least-once; spec 2026-10-26-cp217-remote-stop-owed-design.md).

typescript
import {
  stopRemoteChild,
  REMOTE_CHILD_STOP_TIMEOUT_MS,
  isRemoteChildStopConfirmed,
  remoteTransportsOf,
  isLiveRemoteChildRef,
  childStopDelegation,
  stopDelegationSchema,
  resolveDelegationRun,
  retryOwedRemoteStops,
  owedRemoteStopsSource,
  nextOwedStopDueAt,
  owedStopAfter,
  remoteStopDebtUpdate,
  stopOwedIdentity,
  stopOwedMatches,
  DEFAULT_OWED_STOP_SCHEDULE,
  resolveOwedStopSchedule,
  readStoredStopOwed,
  subSessionStopOwedSchema,
  type OwedStopSchedule,
  type RemoteChildStopResult,
  type RemoteStopRequest, // { reason: string; target: StopDelegation }
  type StopDelegation, // { dispatchId } | { parentToolCallId }
  type RemoteTransportFor,
  type RemoteStopSenderFor,
  type DueWorkSource,
  type SubSessionStopOwed,
} from '@helix-agents/core';

// Never throws. With `timeoutMs` the wait is bounded (the transport takes no signal).
stopRemoteChild(transport, childSessionId, kind /* 'interrupt' | 'abort' */, { reason, target }, { timeoutMs? }):
  Promise<RemoteChildStopResult>;
// { outcome: 'answered' } | { outcome: 'not_stoppable' | 'target_mismatch'; error } | { outcome: 'gone'; error }
// | { outcome: 'failed'; error } | { outcome: 'timed_out'; timeoutMs }
// confirmed (isRemoteChildStopConfirmed): answered, not_stoppable, target_mismatch, gone.

// CP-217 §12: the delegation a stop names. A persistent companion's ref → { parentToolCallId }; any other child →
// { dispatchId: ref.dispatchId } (a ref without one is a typed framework_internal_error).
childStopDelegation(ref): StopDelegation;
// The child's check (core stopRun / stopSession, runtime-js executor.stop, the CF DO's handleStop): a dispatch
// must be the session's ChildBinding.dispatchId; a consult must be the run's RunMetadata.parent.toolCallId.
// Nothing is inferred from a missing record: { serves: false } → the stop answers target_mismatch.
// `beforeRunRead` (the O2 cancel) runs before the run read: scoped to the consult, or unscoped for a dispatch.
resolveDelegationRun(store, sessionId, target, delegation, { beforeRunRead? }):
  Promise<{ serves: true; target; run: RunMetadata | null } | { serves: false }>;

interface SubSessionRef {
  // ...
  stopOwed?: SubSessionStopOwed;
  // { kind, at, target, reason, owedByRunId, attempts?, nextAttemptAt?, abandonedAt?, refusalLogged? }
}
// UpdateSubSessionRef.stopOwed: a value stamps it, `null` clears it, absent leaves it.
// UpdateSubSessionRef.expectStopOwed (CP-217): a compare-and-set on the stored debt's identity
// ({ kind, at, target, attempts }, absent attempts = 0; `null` = no debt; a schema-invalid debt is no debt), decided with expectStatus in the same atomic step; a mismatch writes
// nothing and throws SubSessionRefConflictError (op 'stamp'). stopOwedMatches(stored, expected) is the rule.
// UpdateSubSessionRef.expectStatus (CP-195): a compare-and-set on the ref's status, in the same atomic step as the
// write; a changed status writes nothing and throws SubSessionRefConflictError (op 'stamp', expected / actual).
// UpdateSubSessionRef.atGeneration (CP-161 rule 4, ported in !458): an outcome write scoped to the ref's outcome
// generation the writer read; another generation writes nothing and the call resolves (never a throw):
//   { written: false, reason: 'stale_generation', actualGeneration } (UpdateSubSessionRefOutcome).
// subSessionRefWriteIsStale(atGeneration, actualGeneration): the rule every store applies inside its atomic step.
  • remoteTransportsOf(tools) maps a ref to the transport of the agent's remote sub-agent tool for its type; isLiveRemoteChildRef(ref) is a ref remote by location (isRemoteSubSessionRef) and still running / paused_awaiting_client. Every runtime bounds each stop at REMOTE_CHILD_STOP_TIMEOUT_MS (10 s).
  • The debt (stopOwed). Every site that stops a remote child and is not confirmed owes it, in the ref write it makes anyway: remoteStopDebtUpdate(prior, result, stop, now, schedule?) gives the stopOwed / expectStopOwed fields (nothing for a confirmed stop with no prior debt, null to clear one, else owedStopAfter: a fresh debt first due baseMs later; a prior live debt keeps its budget and only upgrades its kind). The debt is independent of the ref's status: a debt write re-states the status it read.
  • retryOwedRemoteStops({ sessionId, refs, transportFor, stateStore, timeoutMs?, schedule?, owes?, now?, logger? }) retries every debt of the refs owes selects (default: remote refs, live or settled), in parallel, by the owed kind and naming the debt's target: confirmed clears it; unconfirmed (or no sender) keeps it with attempts + 1 and nextAttemptAt = now + min(baseMs × 2^attempts, maxMs); a typed refusal is logged at error once (refusalLogged); a debt not due is skipped; one owed past windowMs is abandoned once (abandonedAt, one remote_stop_owed_abandoned warn) and skipped afterwards. Every write is a compare-and-set on the status and the debt it read. Never throws. CF Workflows passes owes = settled remote refs until its own pacer (MR 2).
  • The schedule. OwedStopSchedule { baseMs, maxMs, windowMs }, default DEFAULT_OWED_STOP_SCHEDULE (30 s, 1 h, 24 h; the REMOTE_STOP_OWED_* constants), resolved by resolveOwedStopSchedule(partial) (a typed validation_error for a bad member).
  • The pacer's source. owedRemoteStopsSource({ stateStore, transportFor?, timeoutMs?, schedule?, owes?, logger? }) is a DueWorkSource (nextDueAt(sessionId) from the refs alone, runDue({ agent, sessionId, now })), neither run-fenced nor stop-guarded. runtime-js runs it from its owed-stop timer and the CF DO from its stop-cascade alarm; nextOwedStopDueAt(refs, owes?) is its due time.
  • readStoredStopOwed(raw, ids, logger, event) is the one reader of the column (a JSON string, a parsed JSONB value, null or ''): a corrupt value reads as none owed and is logged at warn, never thrown.
  • Storage: Postgres V21 (stop_owed JSONB), D1 V25 and DO-SQLite 21 (stop_owed TEXT), a Redis hash field ('' when cleared), memory in its ref map. Contract: core/src/testing/sub-session-operations.ts ("an owed stop").

waitForResultTimeout(name, currentStatus) is the one builder of the timeout answer; every runtime calls it with the child's ref status read at the deadline. Core's handleWaitForResult (runtime-js, Temporal) decides the deadline once per poll, after the read, lets an unconfirmed interrupted or orphan observation reach that decision, and clamps its sleep to the time left. CompanionToolDispatchDeps.now / .sleep (optional, defaulting to Date.now and a timer) are its clock and sleep seams for tests. createWaitForResultTool() uses both schemas and WAIT_FOR_RESULT_DESCRIPTION.

Remote settlement (Invariant S). A remote SubSessionRef (location: 'remote') leaves live (running / paused_awaiting_client) only in the same atomic store step that commits its call's real result. A commit carries the settlements:

ts
import type { SubSessionSettle } from '@helix-agents/core';

interface SubSessionSettle {
  subSessionId: string;
  parentToolCallId: string;
  status: 'completed' | 'failed';
  completedAt: number;
  /** The ref's `outcomeGeneration` the caller read; absent: any generation. */
  expectGeneration?: number;
  /** CP-152 / D3: the owed stop the settle keeps (an unconfirmed stop); absent: the owed stop is cleared. */
  stopOwed?: SubSessionStopOwed;
}

// An append (or run-start) commit:
await store.appendMessages(sessionId, [resultRow], { streamSequence, runId, settle: [s] });
// A step commit:
await store.saveStateAndPromoteStaging(sessionId, state, rows, checkpointMeta, { settle: [s] });

Every store applies the one admission rule, core settleRefusal(sessionId, settle, actual), against the ref it reads inside the commit: the ref must be live and, when expectGeneration is given, at that generation. Otherwise the WHOLE commit is refused and nothing is written: SubSessionRefConflictError (state_sub_session_ref_conflict, 409, non-retryable, { op, expected: { statuses, generation? }, actual: { status, generation } }), or SubSessionRefMissingError (state_sub_session_ref_missing) for a ref that does not exist. On a run-start commit the conflict is RunStartRejectedError('sub_session_ref_conflict'), which shouldRetryRunStart re-plans once. The contract suite is subSessionRefOperationTests (run by stateStoreContractTests).

Helpers:

  • remoteSettlementsFor(rows, refs, now): one settle per role: 'tool' row whose call is a live remote ref's (failed for a failed or state-write-rejected row, else completed), carrying the read ref's generation (expectGeneration: ref.outcomeGeneration ?? 0); a heal's not_executed row never settles. CF Workflows derives every commit's settles through it, at one choke point.
  • lostStepRemoteRefs(refs, committed) (D3): the live, remote, non-companion refs whose call is a tool_use in no committed assistant row (their parent step was lost), in ref order. Valid only on a log read after the caller's run-start commit won. lostStepRefSettlement(ref, now) returns { settle, detail }: failed at the ref's generation, and the typed framework_subagent_lost detail with cause PARENT_STEP_LOST_CAUSE ('parent_step_lost'). The entry applies the settle in a rows-free append commit of its own run (Invariant S's second door: there is no call to pair); lostStepRefDetail(ref) is the detail alone.
  • remoteChildVerdict(read, ref, watchMs, now, { startConfirmed? }): the transitions table for one transport status read (completed / failed / lost / owed / unreadable; only the transport's answer, never a store error), with remoteChildDeadline(ref, watchMs) = startedAt + watchMs + RECONCILE_GRACE_MS (60 s).
  • RemoteWatchBudget (CP-196): (config) => ms, how long a runtime's in-run dispatch watches one remote child, its retry waits and status reads included; each runtime supplies its own. streamRetryWatchBudget(config) is runtime-js / the CF DO's: (streamRetries + 1) × (timeoutMs + REMOTE_STATUS_READ_TIMEOUT_MS) (each attempt and its recovery read), plus the waits remoteStreamRetryDelayMs(attempt, streamRetryBaseMs) = min(base × 2^attempt, REMOTE_STREAM_RETRY_MAX_DELAY_MS) (5 s; the dispatch waits exactly this), plus the pre-check and final reads (2 × REMOTE_STATUS_READ_TIMEOUT_MS). REMOTE_STATUS_READ_TIMEOUT_MS (5 s) is the one bound every dispatch status read uses.
  • settleRemoteChildrenBeforeInput(...): an entry's plan; its settle rides the entry's run-start commit, its interrupts (lost children) and missingTool refs are for the caller once the commit won.
  • assertToolResultPairing(messages): throws validation_error naming the call on a duplicate tool_result or a result with no earlier tool_use.

recordSubSessionResult (below) settles a live remote ref in its result's own append commit, and answers already-recorded when the store refuses the settle because another writer settled the ref first.

Sub-session results (recordSubSessionResult, reconcileEndedEphemeralSubSessionRefs). recordSubSessionResult(input, deps) records a child's outcome on its parent (the tool result row, the subagent_end / tool_end chunks and the child's SubSessionRef), idempotently by tool call id. Its failed outcome takes an optional typed errorDetail ({ kind: 'failed', error, errorDetail? }), stamped on the row as metadata.helixErrorDetail (COMMON_METADATA_KEYS.ERROR_DETAIL). refUpdate: 'none' records the result but leaves the child's ref as it is (default 'terminal' writes it to the outcome's status): a parent recording its own failure while the child's run may still be live (CF Workflows' ephemeral park when the stop at its wait bound is not confirmed) never writes a terminal ref over it. recordSubSessionResultForCommit(input, deps) is the same record for a caller that commits the row itself (CF Workflows commits a step's rows once, after its last result, RM-69): it writes the frames (and the ref update of a ref the caller's commit does not settle: a live remote ref is settled by that commit, Invariant S) and returns { toolMessage } without appending it (no idempotency scan: the row is not in the history before that commit).

typescript
import { reconcileEndedEphemeralSubSessionRefs } from '@helix-agents/core';

// refs: the parent's refs, as read; reconciled in place and returned.
await reconcileEndedEphemeralSubSessionRefs(stateStore, parentSessionId, refs);

reconcileEndedEphemeralSubSessionRefs writes every local ephemeral running ref whose child has ended (its current run completed / failed, or, with no run, a completed / failed session) to that status, with completedAt and completionDelivered: true. A live run leaves the ref running. It skips persistent refs (their lazy sync and completion notifier own them), remote refs (isRemoteSubSessionRef, by location) and an interrupted run. CF Workflows' readers call it (a handle's state load, every workflow step's state load); read errors propagate to the caller, which treats the reconcile as best effort.

Delivery outcome (CP-105, W1 MR2b). companion__sendMessage answers core CompanionDeliveryOutcome:

typescript
import {
  COMPANION_NOT_DELIVERED_REASONS,
  companionDeliveryOutcomeSchema,
  companionPriorResultSchema,
  parseCompanionSendMessageResult,
  type CompanionDeliveryOutcome,
  type CompanionNotDeliveredReason,
  type CompanionPriorResult,
} from '@helix-agents/core';

type CompanionNotDeliveredReason =
  | 'child_busy' // the child's run start lost to another entry, or its live run has a recorded stop (WA MR2 §7.4)
  | 'child_start_unconfirmed' // no entry outcome within the runtime's bound
  | 'child_entry_failed' // the child's entry failed before its run-start commit (errorDetail says how)
  | 'child_not_running' // a follow-up send to a child that is neither interrupted nor live
  | 'child_exited' // the child's loop ended before it could consume the message
  | 'no_resume_config' // runtime-js: the child's persistent-agent config is not registered on the parent
  | 'child_terminated' // the parent terminated the child before the resume's run start (resumed_companion_ref_terminated)
  | 'child_awaiting_client'; // CP-40 (C2): paused on client / approval calls (errorDetail: companionAwaitingClientDetail)

// The prior consult's undelivered outcome, delivered beside the entry's own answer (deliver-then-continue, MR B).
// Only a committed terminal word: never a live status.
type CompanionPriorResult = {
  status: 'completed' | 'failed';
  output?: unknown;
  errorDetail?: ErrorDetail;
};

type CompanionDeliveryOutcome =
  | { delivered: true; priorResult?: CompanionPriorResult }
  | {
      delivered: false;
      reason: CompanionNotDeliveredReason;
      errorDetail?: ErrorDetail;
      priorResult?: CompanionPriorResult;
    };

A delivered: false outcome is a successful tool result, and it still delivers a priorResult it carries. parseCompanionSendMessageResult(result) is the one parse of core handleSendMessage's result at a runtime boundary: a value outside the union throws a non-retryable framework_internal_error.

A stopping or terminated companion (WA MR2 §7.4). handleSendMessage reads the child's current run first: a LIVE run with a recorded stop (readStop(child, run): a 202 stopping stop, or a record-only stop on a suspended run) is ending, so it answers { delivered: false, reason: 'child_busy' } and writes nothing — before the C-4 "unresolved client-tool calls" guard, since the stop discards those calls. Its append into a live child carries refuseIfStopped: true, so a stop recorded between that read and the append refuses it atomically (CommitStoppedError → child_busy). An interrupted child is still resumed (delivered: true). A resume refused resumed_companion_ref_terminated (the parent terminated the child first) answers child_terminated; one refused expected_run_stopped is a lost claim, child_busy. CF Workflows' entry poll (awaitCompanionEntryOutcome) maps a fire-and-forget resume's lost run start the same way, and so does DBOS's companion continuation (its lost-claim branch). DBOS's own inbox sendMessage (it does not go through handleSendMessage) answers a live child with a recorded stop child_busy too, before its C-4 guard (Ruling 59).

Types:

  • CompanionTool<TInput, TOutput> -- Extends Tool with _isCompanionTool: true and _companionType: CompanionType
  • CompanionType -- 'spawnAgent' | 'sendMessage' | 'listChildren' | 'getChildStatus' | 'waitForResult' | 'terminateChild'
  • COMPANION_TOOL_PREFIX -- 'companion__' (the prefix for all companion tool names)

Detection:

typescript
import { isCompanionTool, COMPANION_TOOL_PREFIX } from '@helix-agents/core';

// Check if a tool is a companion tool
if (isCompanionTool(tool)) {
  console.log(tool._companionType); // e.g., 'spawnAgent'
}

// Check by name prefix
if (toolName.startsWith(COMPANION_TOOL_PREFIX)) {
  const companionType = toolName.slice(COMPANION_TOOL_PREFIX.length);
}

PersistentAgentConfig ​

Configuration for persistent sub-agents on AgentConfig:

typescript
interface PersistentAgentConfig {
  agent: AgentConfig; // The child agent definition
  mode: 'blocking' | 'non-blocking'; // How the parent interacts
  description?: string; // Optional description for companion tool context
}

Used in AgentConfig.persistentAgents:

typescript
const agent = defineAgent({
  name: 'coordinator',
  persistentAgents: [
    { agent: WorkerAgent, mode: 'blocking' },
    { agent: MonitorAgent, mode: 'non-blocking', description: 'Background monitor' },
  ],
  // ...
});

Skills ​

Progressive-disclosure skill library. Configure via AgentConfig.skills (and optionally AgentConfig.preloadSkills); the framework appends a catalog to the system prompt and auto-injects the load_skill / read_skill_file tools. See the Skills guide for the conceptual model.

See also: @helix-agents/skill-cli for baking remote skill packages (git repos / Claude plugin marketplaces) into the in-code provider at build time.

AgentConfig.skills / preloadSkills ​

typescript
import { defineAgent, defineSkill } from '@helix-agents/core';

const agent = defineAgent({
  name: 'assistant',
  systemPrompt: 'You are a helpful assistant.',
  llmConfig: { model },
  // SkillProvider OR an array of in-code SkillDefinitions (sugar for inCodeSkillProvider)
  skills: [
    defineSkill({
      name: 'pdf-processing',
      description: 'Extract text and tables from PDFs. Use when working with PDF files.',
      body: '# PDF processing\n…',
    }),
  ],
  // Names whose full bodies are injected on every step (always in context)
  preloadSkills: ['pdf-processing'],
});
FieldTypeDescription
skillsSkillsConfigA SkillProvider or an array of SkillDefinition (sugar for inCodeSkillProvider). Unset = no-op.
preloadSkillsstring[]Skill names whose full bodies are preloaded into the system prompt every step. Unknown names warn-and-skip. No-op when skills is unset.

Sub-agents do NOT inherit a parent's skills or preloadSkills.

SkillProvider interface ​

typescript
interface SkillProvider {
  listSkills(): Promise<SkillMetadata[]>; // Level 1 — catalog (framework sorts by name)
  getSkill(name: string): Promise<Skill | null>; // Level 2 — body + resource listing (null if unknown)
  readResource(name: string, path: string, range?: ReadResourceRange): Promise<string | null>; // Level 3 — file contents (null if unknown/forbidden)
}

Skill data types ​

typescript
import type {
  Skill,
  SkillMetadata,
  SkillDefinition,
  SkillProvider,
  SkillResourceRef,
  ReadResourceRange,
  SkillsConfig,
} from '@helix-agents/core';

// Level-1 metadata: always resident in the system prompt
interface SkillMetadata {
  name: string; // lowercase a-z/0-9, single hyphens, ≤64 chars, no underscores, no 'anthropic'/'claude'
  description: string; // ≤1024 chars; triggers-only ("what + Use when…")
  license?: string;
  compatibility?: string; // ≤500 chars
  metadata?: Record<string, string>;
  allowedTools?: string[]; // open-standard field; parsed + carried, not enforced in v1
}

// Level-2 payload
interface Skill extends SkillMetadata {
  body: string;
  resources?: SkillResourceRef[];
}

// In-code definition (the "plain data" delivery mode)
interface SkillDefinition extends SkillMetadata {
  body: string;
  // Skill-relative path → content (string) or a lazy loader
  resources?: Record<string, string | (() => string | Promise<string>)>;
}

interface SkillResourceRef {
  path: string; // skill-relative, e.g. "references/forms.md"
  description?: string; // reserved for future use
}

// 1-indexed, inclusive line range for read_skill_file
interface ReadResourceRange {
  startLine?: number;
  endLine?: number;
}

// AgentConfig.skills value
type SkillsConfig = SkillProvider | SkillDefinition[];

Helpers ​

typescript
import {
  defineSkill, // Validate + identity-return an in-code SkillDefinition
  inCodeSkillProvider, // Build a SkillProvider over an in-memory SkillDefinition[]
  resolveSkillsCatalog, // Resolve the system-prompt fragment (catalog + preloaded bodies)
  collectLoadedSkillNames, // Set<string> of skills loaded into a transcript (recovery-safe)
} from '@helix-agents/core';
// Parse + validate a SKILL.md (frontmatter + body); pure/Workers-safe. Its own entry: it loads `yaml`.
import { parseSkillFile, type ParseResult } from '@helix-agents/core/skill-file';
  • defineSkill(def) — validates name/description/body and returns def unchanged. Throws on an invalid name/description or empty body. defineAgent() runs this on each entry of an array-form skills.
  • inCodeSkillProvider(skills: SkillDefinition[]) — returns a dependency-free, Workers-safe SkillProvider. Throws on a duplicate skill name. Passing an array directly to skills calls this for you.
  • resolveSkillsCatalog(skills, preloadSkills?) — async; resolves the Level-1 catalog plus any preloaded bodies into the system-prompt fragment. Runtimes call this once per run where IO is allowed and thread the result into buildMessagesForLLM. Returns '' for no skills. A throwing provider rejects with the retryable framework_skills_unavailable (fail closed: running without the catalog would silently change the system prompt). Every runtime but DBOS fails the step with it; DBOS, which resolves the catalog in its workflow body, logs it and runs without the catalog until DBOS support lands (planned).
  • collectLoadedSkillNames(messages) — pure, recovery-safe scan of durable message history returning the set of skill names successfully loaded via load_skill. The building block for programmatic dedup / round-trip assertions.
  • parseSkillFile(raw: string): ParseResult — parses a SKILL.md (YAML frontmatter + markdown body), validates the metadata against skillMetadataSchema(), and normalizes the open-standard allowed-tools field (string or list) to string[]. Pure (no IO) and Workers-safe; shared by the filesystem provider (@helix-agents/skill-fs) and the build-time baker (@helix-agents/skill-cli). Returns { ok: true; metadata; body } | { ok: false; error } (never throws).

Reserved tool names ​

typescript
import { LOAD_SKILL_TOOL_NAME, READ_SKILL_FILE_TOOL_NAME } from '@helix-agents/core';

LOAD_SKILL_TOOL_NAME; // 'load_skill'
READ_SKILL_FILE_TOOL_NAME; // 'read_skill_file'

Both are in RESERVED_TOOL_NAMES — defineTool() throws if a user tool uses either name. Skill names use [a-z0-9-] (no underscores) so they can never collide with the tool names either.

Schema ​

typescript
import { skillMetadataSchema } from '@helix-agents/core';

// Zod schema validating SkillMetadata (name/description rules, optional fields).
const parsed = skillMetadataSchema().safeParse(frontmatter);

Attachment Types ​

Types and utilities for multimodal tool attachments (images, files).

typescript
import type {
  Attachment,
  ImageUrlAttachment,
  ImageDataAttachment,
  FileUrlAttachment,
  FileDataAttachment,
} from '@helix-agents/core';

Types:

  • Attachment — Union of all attachment types
  • ImageUrlAttachment — { type: 'image-url', url: string, mediaType?: string }
  • ImageDataAttachment — { type: 'image-data', data: string, mediaType: string }
  • FileUrlAttachment — { type: 'file-url', url: string, mediaType?: string }
  • FileDataAttachment — { type: 'file-data', data: string, mediaType: string }

State Types ​

Session vs Run Identifiers ​

The framework uses two identifiers for tracking agent execution:

IdentifierPurposeUsage
sessionIdPrimary key for all state storage operationsUse for loading/saving state, messages, checkpoints
runIdExecution metadata identifying a specific runUse for logging, tracing, debugging

Key distinction:

  • A session is a conversation container. It contains all messages, custom state, and checkpoints.
  • A run is a single execution within a session. When a session is interrupted and resumed, a new run starts but continues the same session.
  • Multiple runs can occur within a single session (e.g., execute → interrupt → resume creates 2 runs in 1 session).

Best practices:

  • Use sessionId for all state store operations
  • Pass the same sessionId to continue a conversation
  • Use runId for tracking/tracing specific executions

AgentState ​

The full state structure for a running agent (used internally for execution and checkpoints).

typescript
interface AgentState<TState, TOutput> {
  sessionId: string; // Primary key for session
  runId: string; // Current run identifier
  agentType: string;
  streamId: string;
  status: AgentStatus;
  stepCount: number;
  customState: TState;
  messages: Message[];
  output?: TOutput;
  error?: string;
  parentSessionId?: string;
  subSessionRefs: SubSessionRef[]; // References to child agent runs
  aborted: boolean;
  abortReason?: string;
}

Message Types ​

typescript
type Message = SystemMessage | UserMessage | AssistantMessage | ToolResultMessage;

interface SystemMessage {
  role: 'system';
  content: string;
  metadata?: Record<string, unknown>;
  providerOptions?: Record<string, Record<string, unknown>>;
}

interface UserMessage {
  role: 'user';
  content: string;
  metadata?: Record<string, unknown>;
  providerOptions?: Record<string, Record<string, unknown>>;
}

interface AssistantMessage {
  role: 'assistant';
  content?: string;
  toolCalls?: ToolCallRequest[];
  thinking?: ThinkingContent;
  parts?: AssistantMessagePart[]; // ordered response parts; replayed verbatim when present
  metadata?: Record<string, unknown>;
  providerOptions?: Record<string, Record<string, unknown>>;
}

// One ordered part of an assistant response, in the AI SDK's representation.
type AssistantMessagePart =
  | { type: 'reasoning'; text: string; providerOptions?: Record<string, Record<string, unknown>> }
  | { type: 'text'; text: string; providerOptions?: Record<string, Record<string, unknown>> }
  | { type: 'tool-call'; toolCallId: string }; // refers into toolCalls

interface ToolResultMessage {
  role: 'tool';
  toolCallId: string;
  toolName: string;
  content: string; // a successful string result verbatim (marked), anything else JSON
  metadata?: Record<string, unknown>;
  providerOptions?: Record<string, Record<string, unknown>>;
}

A ToolResultMessage's content is encoded by the message builders (RM-32): a successful result whose value is a string is stored verbatim and marked metadata.helixContentEncoding: 'text' (COMMON_METADATA_KEYS.CONTENT_ENCODING); anything else (objects, numbers, booleans, null, undefined stored as null, and every error result { "error": "…" }) is JSON.stringify'd with no marker. Read it with decodeToolResultContent, not a bare JSON.parse: a tool returning the string '42' or '{"a":1}' must decode to that string.

An AssistantMessage's parts holds the response's parts in the order the provider produced them: one reasoning part per thinking block with that block's own provider metadata (an Anthropic signature in providerOptions.anthropic.signature, a redacted block in providerOptions.anthropic.redactedData; empty blocks kept), the text parts, and a tool-call reference into toolCalls for each call. It is optional and additive: content, toolCalls and thinking stay populated, every store persists parts through messageSchema() (assistantMessagePartSchema()) with no migration, and an adapter replays a message's parts verbatim when present (models that bind thinking to history need it; see Preserved thinking). stripThinking(message) removes reasoning parts along with thinking.

Message Metadata ​

All message types support an optional metadata field for attaching arbitrary data.

typescript
import {
  COMMON_METADATA_KEYS,
  stripMetadata,
  filterByMetadata,
  getMessagesWithMetadataKey,
} from '@helix-agents/core';

COMMON_METADATA_KEYS ​

Standard metadata key constants:

KeyValueDescription
SOURCE'source'Origin of the message (e.g., 'web-ui', 'api')
HIDDEN'hidden'Whether to hide from UI
TIMESTAMP'timestamp'When the message was created
DURATION'duration'Processing time in ms
MODEL'model'LLM model used
IS_SUB_AGENT'isSubAgent'Whether from a sub-agent
PARENT_SESSION_ID'parentSessionId'Parent session ID for sub-agents
TAGS'tags'Array of tags
MEMORY_INJECTION'memoryInjection'Marks a persisted hidden user message containing auto-injected memories (see collectInjectedMemoryIds)
TOOL_FAILED'helixToolFailed'On a tool result: true when the tool failed (the content is { "error": … })
ERROR_DETAIL'helixErrorDetail'On a failed tool / sub-agent result: the typed ErrorDetail when the runtime classified the failure (e.g. framework_subagent_wait_bound); read with errorDetailSchema()
CONTENT_ENCODING'helixContentEncoding'On a tool result: 'text' when content is the tool's string result verbatim; absent when it is JSON. Decode with decodeToolResultContent
COMPANION_NOTICE'helixCompanionNotice'On a companion completion notice (a hidden user row the notifier injects): its key { subSessionId, runId? } (CompanionNoticeKey), read with companionNoticeKey; a recovery finds a committed notice by it and never re-injects it
PROMPT_PREFIX'helixPrefix'On an assistant message from a model that binds thinking to history: the prompt fingerprint { system, tools, model } of the request that produced it (see checkPrefixStability). Never forwarded to UI messages
NATIVE_COMPLETION'helixNativeCompletion'On an assistant message: true when a native_output JSON answer was normalised into this completion-tool call

stripMetadata ​

Remove metadata from a message (returns a new object):

typescript
const cleanMessage = stripMetadata(message);
// cleanMessage.metadata is undefined

filterByMetadata ​

Filter messages by metadata predicate:

typescript
const webMessages = filterByMetadata(messages, (m) => m.source === 'web-ui');
const hiddenMessages = filterByMetadata(messages, (m) => m.hidden === true);

getMessagesWithMetadataKey ​

Find messages containing a specific metadata key:

typescript
const messagesWithSource = getMessagesWithMetadataKey(messages, 'source');

State Helpers ​

typescript
import {
  isAssistantMessage,
  isToolResultMessage,
  stripThinking, // Remove thinking from messages
  getSubSessionRefsByType, // Filter sub-agent refs
  getToolResultsFromMessages,
  createInitialAgentState,
} from '@helix-agents/core';

Stream Types ​

Stream Chunks ​

All chunk types emitted during agent execution:

typescript
type StreamChunk =
  | TextDeltaChunk // Token-by-token text
  | ThinkingChunk // Reasoning content
  | ToolStartChunk // Tool invocation starting
  | ToolEndChunk // Tool execution complete
  | SubAgentStartChunk // Sub-agent starting
  | SubAgentEndChunk // Sub-agent complete
  | CustomEventChunk // Custom events from tools
  | StatePatchChunk // RFC 6902 state patches
  | ErrorChunk // Error events
  | OutputChunk // Structured output
  | RunStartedChunk // A run won its run start (internal attribution; the UI transformer drops it)
  | RunTerminatedChunk // A run was ended by the framework (owner unknown / admin terminate)
  | RunInterruptedChunk // Agent interrupted
  | RunResumedChunk // Agent resumed
  | RunPausedChunk // Agent paused for confirmation
  | CheckpointCreatedChunk // Reserved: defined, but no runtime emits it today
  | StepCommittedChunk // Step changes committed
  | StepDiscardedChunk // Step changes discarded
  | StreamResyncChunk // Stream recovery notification
  | ForcedCompletionChunk; // Forced-completion phase diagnostics (see Forced Completion)

All stream chunks have a common base with optional step field for cleanup targeting:

typescript
interface BaseChunk {
  type: string;
  agentId: string;
  agentType: string;
  step?: number; // Step number for cleanup targeting (added for crash recovery)
  runId?: string; // The run that wrote the chunk (stamped by `withRun`)
  attemptId?: string; // The LLM-call attempt that produced it (stamped by `withAttempt`)

  /**
   * Provenance stamped on every chunk proxied from a remote child by the
   * shared remote dispatch. Absent on all locally-produced chunks.
   */
  remoteSource?: {
    childSessionId: string;
    childSequence: number;
    streamId: string;
  };
}

remoteSource — when a parent proxies a remote child's chunks into its own stream, the copies get fresh parent-stream sequences. remoteSource preserves the child's identity and position so consumers can:

  • Dedup — drop a chunk whose (remoteSource.childSessionId, remoteSource.childSequence) pair was already seen. Within one parent run the stream itself never holds a duplicate: every stream manager drops a re-forwarded chunk at or below its high-water for that child run (RM-86, core remoteChunkDedup / decideStreamWrite; the write resolves { written: false, reason: 'duplicate_remote_chunk' }). A parent's NEXT run clears that high-water, so a child re-attached across runs is forwarded again, and a consumer that keeps a child document across runs still needs this rule.
  • Align a nested baseline — apply parent-stream child patches whose remoteSource.childSequence > childSnapshot.streamSequence (with matching streamId).

Alignment is exact for the direct child only. Each hop re-stamps remoteSource with its own direct child, while chunk.agentId always stays the emitting agent — so for a grandchild the two live in unrelated sequence spaces. The normative discriminator: a patch is alignable iff agentId === remoteSource.childSessionId. When they differ, seed the descendant from getSnapshot(descendantSessionId), suppress its parent-stream patches, and re-snapshot at boundaries. The shipped consumer-side helper for this is RemotePatchFilter / createRemotePatchFilter in @helix-agents/ai-sdk.

This provenance shape is exported as the type RemoteSource (with its Zod schema remoteSourceSchema()) from @helix-agents/core, so consumers can import it to type or validate the remoteSource field directly.

runId / attemptId — every chunk a run's executor writes carries the run's runId, and every chunk of an LLM attempt carries that attempt's attemptId (see Stream stamping and run markers). Chunk types whose contract needs the run (run_started, run_terminated, run_interrupted, run_resumed, run_paused, checkpoint_created, step_committed, step_discarded) declare runId as required. attemptId is never forwarded to UI consumers; it only lets a reader drop the chunks of a retried attempt that lost to another attempt of the same step. Every runtime writes step_start as an attempt's first chunk.

Run and step marker chunks ​

typescript
interface RunStartedChunk extends BaseChunk {
  type: 'run_started';
  runId: string;
}

interface RunTerminatedChunk extends BaseChunk {
  type: 'run_terminated';
  runId: string; // the KILLED run
  reason: 'owner_unknown_timeout' | 'admin_terminate';
}

interface StepCommittedChunk extends BaseChunk {
  type: 'step_committed';
  runId: string;
  stepId: string;
  checkpointId: string; // the `step` commit's checkpoint
  committedAttemptId: string; // the attempt whose content the commit kept
}
  • run_started is written once per run, only by the run-start winner, after its run-start commit, its stream claim and making the stream live, and before any of the run's content. The order is commit → claimStream → live → run_started → stream_resync (when the entry rewound) → content. A loser writes nothing. The run of any chunk is the runId of the latest run_started before it.
  • run_terminated names a run the framework ended from outside, so a reader sees a typed end instead of text stopping mid-word. Today only CF Workflows writes it: the next entry, before its own run_started, for a run whose instance stayed unknown past cfwUnknownTerminateAfterMs (owner_unknown_timeout), and executor.terminateRun(sessionId) (admin_terminate). It is never run-fenced.
  • step_committed.committedAttemptId names the attempt whose chunks are the step's final content. An attempt that streamed under another attemptId was superseded (a Temporal activity retry, a CF Workflows step.do retry).

StreamResyncChunk ​

Emitted when a stream is resynced after crash recovery, rollback, or branching. Clients should use this to refresh their UI state.

typescript
interface StreamResyncChunk extends BaseChunk {
  type: 'stream_resync';
  runId?: string; // the entry's run (stamped by `withRun`)
  checkpointId: string; // the `truncate` checkpoint when the entry truncated, else the restored one ('' = genesis)
  stepCount: number; // Step count at the restored checkpoint
  messageCount: number; // Message count after the rewind
  fromSequence: number; // Stream sequence of the rewind
  truncateCheckpointId?: string; // the `truncate` checkpoint of the entry's run-start commit, when it truncated
  reason: 'crash_recovery' | 'rollback' | 'branch' | 'retry' | 'respawn';
}

An entry writes it only after it won its run start and wrote its entry state. A client whose snapshot already reports latestTruncateCheckpointId === truncateCheckpointId already shows the rewound log and can skip the replayed resync.

ForcedCompletionChunk ​

Diagnostic progress events for the forced-completion phase. Emitted started → attempt_started → (attempt_failed)* → exactly one terminal succeeded | exhausted.

typescript
interface ForcedCompletionChunk extends BaseChunk {
  type: 'forced_completion';
  phase: 'started' | 'attempt_started' | 'attempt_failed' | 'succeeded' | 'exhausted';
  trigger: 'terminal_text' | 'max_tokens' | 'max_steps';
  expectedToolName: string;
  attempt: number;
  maxAttempts: number;
  stopReason?: string;
  errorCode?: string;
  error?: string;
  strategy?: 'forced_tool' | 'native_output'; // on attempt_started
  capabilityProfile?: string; // on attempt_started, e.g. 'legacy', 'anthropic-5.5'
}

strategy and capabilityProfile record the completion strategy an attempt used (set by runtime-js, the CF DO, CF Workflows and Temporal; not yet by DBOS). Both are optional, so older readers strip them.

ai-sdk's convertToUIMessages ignores this chunk (it's a hidden diagnostic, not chat content) — consumers with an exhaustive switch over StreamChunk/StreamMessage types must add a case for it (even if a no-op).

Chunk Type Guards ​

typescript
import {
  isTextDeltaChunk,
  isThinkingChunk,
  isToolStartChunk,
  isToolEndChunk,
  isSubAgentStartChunk,
  isSubAgentEndChunk,
  isCustomEventChunk,
  isStatePatchChunk,
  isErrorChunk,
  isOutputChunk,
  isStreamEnd,
  // Run markers
  isRunStartedChunk,
  isRunTerminatedChunk,
  // Interrupt/Resume type guards
  isRunInterruptedChunk,
  isRunResumedChunk,
  isRunPausedChunk,
  isCheckpointCreatedChunk,
  isStepCommittedChunk,
  isStepDiscardedChunk,
  // Recovery type guard
  isStreamResyncChunk,
  // Forced-completion diagnostic type guard
  isForcedCompletionChunk,
} from '@helix-agents/core';

Schemas ​

typescript
import {
  streamChunkSchema, // Zod schema for chunks
  streamMessageSchema, // Zod schema for messages
} from '@helix-agents/core';

Stream Event Wire Types ​

The StreamEvent discriminated union is the JSON event format StreamManager implementations use for WebSocket and pub/sub transport (the CF Durable Object's and StreamServer DO's WebSocket messages, Redis pub/sub): chunk | end | fail | status | truncated. It is not the SSE wire format: every Helix SSE surface speaks the stream frames of wire contract v2 (encodeStreamFrame / decodeStreamFrame, see Stream wire).

typescript
import {
  // Types
  type StreamEvent,
  type StreamChunkEvent,
  type StreamEndEvent,
  type StreamFailEvent,
  type StreamStatusEvent, // { type: 'status'; status: 'paused' | 'active' }
  type StreamTruncatedEvent, // G4 truncation signal (see below)
  // Schemas
  streamEventSchema,
  streamChunkEventSchema,
  streamEndEventSchema,
  streamFailEventSchema,
  streamStatusEventSchema,
  streamTruncatedEventSchema,
  // Helpers
  parseStreamEvent,
  serializeStreamEvent,
  // Type guards
  isStreamChunkEvent,
  isStreamEndEvent,
  isStreamFailEvent,
  isStreamTruncatedEvent,
} from '@helix-agents/core';

parseNamedSSEEvent is removed: decode an SSE frame with decodeStreamFrame.

StreamTruncatedEvent is the WebSocket counterpart of G4 (the StreamServer DO broadcasts it) (see StreamManager and the stream-protocol internals doc); on SSE the same cleanup is the non-terminal rewound { atStep, atSequence?, minSequence? } frame. Its shape mirrors StreamTruncatedError:

typescript
interface StreamTruncatedEvent {
  type: 'truncated';
  // The `stepCount` boundary passed to `cleanupToStep`; chunks with
  // `step > truncatedAtStep` (above `minSequence` when present) were removed.
  truncatedAtStep: number;
  // The stream's sequence high-water mark at the moment of cleanup; readers
  // gate the throw on `atSequence > lastYieldedSequence`. Optional for wire
  // compatibility.
  atSequence?: number;
  // The cleanup's run-scoping floor (`cleanupToStep`'s `minSequence`): only
  // chunks above it were eligible, and a reader prunes by the same rule.
  // Absent means no floor.
  minSequence?: number;
}

Note: there is no isStreamStatusEvent guard — narrow on event.type === 'status' directly.

Runtime Types ​

RunOutcome (v7) ​

Discriminated union returned by every runtime's runLoop. The variants 'suspended_*' represent the v7 stateless suspension boundaries — at each, the runLoop EXITS and the next resume creates a fresh execution.

typescript
type RunOutcome<TState, TOutput> =
  | { kind: 'completed'; finalState: AgentState<TState, TOutput>; output?: TOutput }
  | { kind: 'failed'; finalState: AgentState<TState, TOutput>; error: string }
  | { kind: 'interrupted'; finalState: AgentState<TState, TOutput>; reason?: string }
  | { kind: 'aborted'; finalState: AgentState<TState, TOutput>; reason?: string }
  | {
      kind: 'suspended_client_tool';
      finalState: AgentState<TState, TOutput>;
      pendingClientToolCalls: Record<string, PendingClientToolCall>;
    }
  | {
      kind: 'suspended_awaiting_children';
      finalState: AgentState<TState, TOutput>;
      suspendedAwaitingChildren: Record<string, SuspendedChildWait>;
    }
  | {
      kind: 'suspended_step_partial';
      finalState: AgentState<TState, TOutput>;
      suspendedStepId: string;
    };

StepOutcome (v7) ​

Per-step result from runStepIteration(). Used by core orchestration helpers and surfaced into RunOutcome once the loop terminates.

typescript
type StepOutcome<TState, TOutput> =
  | { kind: 'continue' }
  | { kind: 'stop'; reason: 'completed' | 'failed' | 'interrupted' | 'aborted' | 'suspended_*'; ... };

SuspendedChildWait (v7) ​

Entry in SessionState.suspendedAwaitingChildren. Records that a parent session is durably blocked on a sub-agent child completing. Keyed by parentToolCallId so parallel siblings don't collide.

typescript
interface SuspendedChildWait {
  childSessionId: string;
  parentToolCallId: string;
  spawnedAt: number;
}

StepResult ​

Result from an LLM generation step:

typescript
type StepResult = TextStepResult | ToolCallsStepResult | ErrorStepResult;

interface TextStepResult {
  type: 'text';
  content: string;
  thinking?: ThinkingContent;
  parts?: AssistantMessagePart[]; // ordered reasoning / text / tool-call parts
  shouldStop: boolean;
  stopReason?: StopReason;
}

interface ToolCallsStepResult {
  type: 'tool_calls';
  content?: string;
  toolCalls: ParsedToolCall[];
  subAgentCalls: ParsedSubAgentCall[];
  thinking?: ThinkingContent;
  parts?: AssistantMessagePart[];
  stopReason?: StopReason;
}

// A structured-output completion is a ToolCallsStepResult carrying the `__finish__` call (its
// arguments are the output), returned with every co-emitted call and the text. Core keeps the
// assistant message, acknowledges the first `__finish__` and answers every other call
// `not_executed` (RM-35; there is no StructuredOutputStepResult).

interface ErrorStepResult {
  type: 'error';
  /** The LLM failure: always the original, classified error. */
  error: Error;
  shouldStop: boolean;
  stopReason: StopReason;
  usage?: TokenUsage;
  /**
   * Set when the adapter's awaited `callbacks.onError` rejected while
   * reporting `error` (e.g. the run's stream had already ended), so the
   * `error` chunk was not emitted. Never replaces `error`; runtimes log it
   * at warn (`llm.on_error_callback_failed`). See `reportLLMError`.
   */
  onErrorFailure?: Error;
}

StopReason ​

Normalized stop reasons from LLM providers:

typescript
type StopReason =
  | 'end_turn' // Normal completion
  | 'stop_sequence' // Hit stop sequence
  | 'tool_use' // Tool call requested
  | 'max_tokens' // Token limit (error)
  | 'content_filter' // Safety filter (error)
  | 'refusal' // Model refused (error)
  | 'error' // Generation error
  | 'unknown'; // Unrecognized

import { isErrorStopReason, isRecoverableErrorStopReason } from '@helix-agents/core';
  • isErrorStopReason(reason) - Returns true for max_tokens, content_filter, refusal, error, unknown
  • isRecoverableErrorStopReason(reason) - Returns true for max_tokens only. Used by forced-completion entry/result classification (classifyForcedCompletionEntry/classifyForcedCompletionResult) to decide whether a stop reason can be resolved by entering/continuing the forced-completion phase, versus failing the run immediately. Non-recoverable reasons (content_filter, refusal, error, unknown) always fail immediately with their original classification — they are never entered into forced completion.

The error stop reasons do not fail the run on Temporal and DBOS (DI-26)

For an agent without outputSchema, Temporal and DBOS end a run whose final text step stops with an error stop reason completed with no error; JS and both Cloudflare runtimes fail it. Observed live for content_filter on both and for max_tokens on Temporal. See DI-26 in the conformance findings.

Execution Types ​

typescript
interface AgentExecutionHandle<TOutput> {
  readonly sessionId: string;
  readonly runId: string;
  // Set only when the entry failed before its run-start commit (no run; result() is
  // `failed` with this detail): runtime-js's first history read, CF Workflows. An HTTP
  // host answers it as a typed error (see "Typed HTTP errors"), never attaches to the stream.
  // CF Workflows: when the session was deleted during the entry wait (`deleteSession` removes
  // its runs too), whether the entry committed can no longer be read, so this carries the
  // instance's recorded failure (which may be a committed run's) or a typed
  // `state_session_not_found` when the instance recorded no outcome. `result()` reports the same.
  readonly preCommitFailure?: ErrorDetail;
  // D-2: set on the handle a keyed duplicate gets (same requestKey and input as an earlier
  // call). It names the ORIGINAL run (runId, result().runId): this call started and wrote
  // nothing, and result() reports how that run ends. See "Request keys" below.
  readonly attached?: true;
  // D-2: on an attached handle, the attached run's start sequence: read its stream from here.
  readonly startSequence?: number;
  stream(options?: StreamHandleOptions): Promise<AsyncIterable<StreamChunk> | null>;
  result(): Promise<AgentResult<TOutput>>;
  abort(reason?: string): Promise<void>;
  getState(): Promise<AgentState<unknown, TOutput>>;
  canResume(): Promise<CanResumeResult>;
  resume(options?: ResumeOptions): Promise<AgentExecutionHandle<TOutput>>;
  retry(options?: RetryOptions): Promise<AgentExecutionHandle<TOutput>>;
}

interface StreamHandleOptions {
  /** Start at this sequence (inclusive); 0 = full replay. */
  fromSequence?: number;
  /**
   * The client's abort signal. On abort the stream ends and its upstream reader
   * is closed (a signal already aborted opens nothing). The run is not affected.
   * Every runtime implements it with `openHandleStream` / `abortableChunks`.
   */
  signal?: AbortSignal;
}

interface AgentResult<TOutput> {
  sessionId: string;
  runId: string;
  status:
    | 'completed'
    | 'failed'
    | 'interrupted'
    | 'suspended_client_tool'
    | 'suspended_awaiting_children'
    | 'suspended_step_partial'
    | 'superseded';
  output?: TOutput;
  error?: string;
  errorDetail?: ErrorDetail;
  suspended?: { toolCallIds?: string[]; children?: SuspendedChildWait[]; stepId?: string };
}

interface CanResumeResult {
  canResume: boolean;
  reason?: string;
}

status: 'superseded' (breaking for exhaustive switches). A run whose write hit the superseded-run fence — another entry took the session over, or (runtime-js) its run lease was lost — stops with superseded on every runtime, never failed. The run wrote nothing more; the run that superseded it is driving the session. onAgentComplete and onAgentFail do not fire (one exception: a supersede that lands after onAgentComplete fired and before the run's terminal commit leaves onAgentComplete then onAgentSuperseded; the last terminal hook is authoritative); the optional onAgentSuperseded hook does (see Hooks). Add a superseded branch wherever you switch over AgentResult.status.

RunOptions.parentToolCallId / ResumeOptionsWithMessage.parentToolCallId. The parent's tool call this execution serves (a persistent companion's consult). With RunOptions.parentSessionId (or, on a with_message resume, the session's own parentSessionId), the run-start commit records it as RunMetadata.parent ({ sessionId, toolCallId }). It is ignored on a root session (a root serves no consult). runtime-js records it, and also: a continuation of a LIVE run keeps that run's consult, retry() keeps the failed run's, a new execute() turn never inherits one, and its in-process companions record theirs at the spawn, continue and sendMessage resume sites. The CF Durable Object passes the parentToolCallId of a /subagent start / resume through.

RetryOptions ​

Options for the retry() method.

typescript
interface RetryOptions {
  /**
   * Which checkpoint to restore from. Defaults to the failed run's last step
   * boundary (see below). An unknown id throws `state_checkpoint_not_found`;
   * another session's checkpoint throws `state_checkpoint_foreign`. Both are
   * rejected before any state change (no silent fallback to a genesis restart).
   */
  checkpointId?: string;
  /** Input for the retried run (how it combines with the re-run trigger: see below). */
  message?: string | UserInputMessage[];
  /** Abort signal for the retried execution. */
  abortSignal?: AbortSignal;
  /** Optional usage store for tracking token/tool usage on the retried run. */
  usageStore?: UsageStore;
  /** D-2: the request's idempotency key (see Request keys below). */
  requestKey?: string;
}

Every runtime (JS, the Cloudflare DO, Temporal, DBOS, CF Workflows) plans a retry with core planRetry and runs it as a run-start commit that rewinds (startRun.truncateTo), so the rewind and the new run land atomically. Without checkpointId, a retry never targets the failed run's terminal state commit:

  • If the run committed a step, it restores the run's latest step commit (or a later append commit of that run with no assistant row) and keeps the log up to it. The retried run's input is message, else the last user message after that point; with neither, retry() throws validation_error (No user message found to retry with).

  • If the run failed on its first step, it re-runs the run's entry: the log is rewound to just before the run's own triggering user message, and the retried run re-appends that message, followed by message when given.

  • If the session failed before its last entry's run-start commit landed, nothing of that entry reached the log: the retry keeps the whole log and needs message.

  • If the run was cut off by maxSteps (RunMetadata.completionReason: 'max_steps'), the step count restarts at 0 (the full budget) and no message is needed, whether the run committed a step (it continues from that step) or not (a resume that entered at the cap keeps its entry, drained client-tool results included, and continues from it). A given message is appended.

  • If the run was a resume whose entry appended its own input row after its drain (a with_message resume that drains its client-tool results first, W1 MR2b: CF Workflows, runtime-js, the CF DO) and it failed before committing a step, no message is needed: the retry rewinds to just before that row and re-appends it (rule 4). After committed steps such a retry needs message, like any other (the first bullet).

  • While a with_message input is still stashed on a pending client call (runtime-js / the CF DO, rule 5), or answered pending calls trail the kept log (rule 6), the retry drains them first and never commits input between a tool_use and its result; a given message replaces the failed run's own stashed input.

customState, stepCount and forcedCompletion come from that target. An explicit checkpointId is restored as given: its rows are kept, and the input is message, else the last user message after it (a hidden sub-agent completion notification is never that input: with only one after the checkpoint the retry is validation_error). A checkpoint whose rows already end at the turn's trigger, such as the failed run's own run-start (input) checkpoint, has no user message after it, so retry({ checkpointId }) to it needs message (validation_error without one); the default retry of a first-step failure rewinds to just before the trigger and re-sends it itself. Breaking: retry() with no message after a first-step failure now re-runs the trigger on runtime-js too (it used to throw, CP-58), and DBOS now honours checkpointId and message (CP-30).

Request keys ​

A request key dedupes a retried request (D-2). The client sends one key per request: the HTTP header X-Helix-Request-Key, or requestKey on the executor call. The entry's run-start commit records the key and a digest of the entry's own input on its run, and the store keeps the key unique per session. A repeat of the key never runs again: it attaches to the original run, replays it, or is refused typed. Upgrade notes: Request keys.

typescript
import {
  REQUEST_KEY_HEADER, // 'X-Helix-Request-Key'
  REQUEST_KEY_PATTERN, // /^[A-Za-z0-9._:-]{1,128}$/
  parseRequestKey,
  computeRequestDigest,
  executeRequestDigest,
  resumeRequestDigest,
  retryRequestDigest,
  planKeyedEntry,
  planKeyedSend,
  sendRequestDigests,
  ignoredRequestKeyWarner,
  RequestKeyConflictError,
  RequestAlreadyCompletedError,
  type RequestDigestInput,
  type KeyedEntryPlan,
} from '@helix-agents/core';

// The executor calls take the key:
interface ExecuteOptions {
  // ...
  requestKey?: string;
}
// ...and so do every ResumeOptions variant (continue, with_message, with_confirmation,
// from_checkpoint) and RetryOptions: `requestKey?: string`.

interface AgentExecutor {
  /** Records request keys (its run-start commit dedupes a keyed entry). Absent means false. */
  readonly supportsRequestKey?: boolean;
  // ...
}

function parseRequestKey(raw: string | null | undefined): string | undefined;
function computeRequestDigest(input: RequestDigestInput): string;
function executeRequestDigest(input: AgentInput<unknown>): string;
function resumeRequestDigest(options: ResumeOptions): string;
function retryRequestDigest(options: {
  checkpointId?: string;
  message?: string | UserInputMessage[];
}): string;
function planKeyedEntry(
  existing: RunMetadata | null, // getRunByRequestKey(sessionId, key)
  requestDigest: string,
  currentRunId: string | null // the session's current run
): KeyedEntryPlan;
function sendRequestDigests(message: string | UserInputMessage[]): string[];
function planKeyedSend(
  existing: RunMetadata | null,
  message: string | UserInputMessage[], // a user send, whatever route it takes
  currentRunId: string | null
): KeyedEntryPlan;
function ignoredRequestKeyWarner(
  runtime: string
): (logger: Logger | undefined, requestKey: string | undefined) => void;

interface RequestDigestInput {
  entry: 'execute' | 'resume' | 'retry' | 'child_start' | 'child_resume';
  rows: readonly unknown[]; // the entry's own input rows
  resumeMode?: string;
}

type KeyedEntryPlan =
  | { kind: 'new' }
  | { kind: 'attach'; run: RunMetadata }
  | { kind: 'replay'; run: RunMetadata }
  | { kind: 'conflict'; run: RunMetadata }
  | { kind: 'already_completed'; run: RunMetadata };

class RequestKeyConflictError extends HelixError {
  // code 'state_request_key_conflict', non-retryable
  readonly sessionId: string;
  readonly runId: string; // the run that holds the key
}
class RequestAlreadyCompletedError extends HelixError {
  // code 'state_request_already_completed', non-retryable
  readonly sessionId: string;
  readonly runId: string; // the run that answered the request
}
  • parseRequestKey reads a key from untrusted input. undefined / null is a keyless request. Anything else that is not 1–128 characters of [A-Za-z0-9._:-] (an empty string included) throws a non-retryable validation_error (HTTP 400) before any lookup. The message never echoes the raw value.

  • The digest (computeRequestDigest, an FNV-1a hash stable under key order) covers the entry's own input, not its plan. Every runtime and host computes it with the per-entry builders, so two hosts digest one request the same way:

    • executeRequestDigest: the user rows execute() appends (parseAgentInput(input).newMessages, files and row metadata included), so a string and its one-message form are one request. state and history are not part of it.
    • resumeRequestDigest: the mode, plus that mode's input: none for continue; the message rows (and an abandonPending marker) for with_message; data for with_confirmation; checkpointId for from_checkpoint.
    • retryRequestDigest: checkpointId and the message rows.

    The entry kind is part of the digest, so a key reused for another kind of entry is a different request. A user send is the exception: a host routes one message to execute(), a with_message resume() or the abandon-and-send resume() by the session's state, which the first attempt changed. sendRequestDigests(message) lists the three digests one send can be recorded under, and planKeyedSend is planKeyedEntry for a send whatever its route (handleChatStream and agent-server use it for a repeated send).

  • planKeyedEntry is the one decision, pure, and runs before any concurrency policy (the CF DO's concurrentEntry, the chat route's busy refusal):

    The key's run (existing)PlanWhat the caller gets
    nonenewa new entry; its run-start commit records the key and digest
    a run, other digest (any status)conflictRequestKeyConflictError (409 state_request_key_conflict); nothing written
    a live run (running or suspended_*)attacha handle on that run (handle.attached); nothing executes, nothing is stopped
    a finished run that is still the current runreplaya handle on that run; result() reports its outcome
    a finished run, a later run existsalready_completedRequestAlreadyCompletedError (409 state_request_already_completed); nothing written

    After already_completed the client re-anchors through the snapshot, which holds the turn. The old run's stream segment is not replayed.

  • The race guard. Two retries of one request can both find no holder. The store refuses the second run-start commit with RunStartRejectedError('request_key_taken'), details.existingRunId naming the holder, and writes nothing (see Run start). The keyed entry then looks the key up again and re-plans, so the loser attaches. classifyEntryFailure classifies request_key_taken as transient.

  • runtime-js (and the CF DO, which runs it) looks the key up first in execute(), resume() and retry(). A keyed entry that loses (request_key_taken, or any AgentAlreadyRunningError, because the executor's own seen-run check usually fires before the store's key rule) re-plans from the key. When no run holds the key, the loss is the call's own answer, except that a fresh session's createSession lost to a concurrent entry re-enters once into the now-existing session, where the run-start commit picks the single winner. An attached handle's result() is the run's own in-process result when the run executes in this executor. Otherwise, once result() is awaited (nothing is read before), it reads the run back from the store, from every 100 ms backing off to 2 s, until the run is no longer running (a failed read is logged and read again): a terminal run reports its settled result, a superseded one superseded (state_run_superseded), a suspended_client_tool one its pending call ids, the other suspensions an empty suspended, and a run that no longer exists failed (state_session_not_found). A key held by a run still running under an owner that died (its JS lease lapsed, and it does not execute in this process) is not attached to: the executor takes the run over as a resume({ mode: 'continue' }), the non-keyed dead-owner route, without re-appending the committed input, and returns that continuation's handle marked attached. Of two concurrent takers, the run-start commit lets one win; the other re-plans and gets RequestAlreadyCompletedError (the dead run, which keeps the key, is now superseded). A user send (execute(), a with_message resume()) is matched by planKeyedSend, whichever route either attempt took.

  • supportsRequestKey. runtime-js (JSAgentExecutor) and the CF DO client (DOFrontendExecutor) declare true. CF Workflows (CloudflareAgentExecutor), Temporal and DBOS declare false for now: a key passed to them is ignored, and the entry runs keyless (no dedupe). ignoredRequestKeyWarner(runtime) returns the function they call first in execute() / resume() / retry(): it logs request_key.ignored (warn) the first time it sees a key, once per executor instance. handleChatStream forwards a key only to an executor that declares supportsRequestKey: true. An executor wrapper should forward the flag.

A keyless entry keeps the earlier rules: nothing is deduped.

Remote protocol (cross-service) ​

The wire contract between a consumer (a runtime executing a createRemoteSubAgentTool, or a BYO loop calling the dispatch helpers) and a producer (an AgentServer mount, or a Cloudflare DO app exposed with createRemoteAgentWorkerHandler). All types and schemas below are exported from @helix-agents/core.

typescript
import {
  remoteStartResponseSchema,
  remoteSnapshotResponseSchema,
  remoteUsageResponseSchema,
  remoteStatusResponseSchema,
  remoteAgentErrorResponseSchema,
  usageRollupSchema,
} from '@helix-agents/core';
import type {
  RemoteAgentTransport,
  RemoteStartRequest,
  RemoteStatusResponse,
  RemoteRunStatus,
  RemoteSnapshotResponse,
  RemoteUsageResponse,
  RemoteAgentErrorResponse,
  TransportEvent,
} from '@helix-agents/core';

RemoteSnapshotResponse ​

typescript
interface RemoteSnapshotResponse {
  sessionId: string;
  /** The sequence space this snapshot is aligned to. */
  streamId: string;
  /** THIS session's own customState (possibly projected producer-side). */
  state: Record<string, unknown>;
  /** Checkpoint-pinned resume origin. `-1` = no patch continuation. */
  streamSequence: number;
  /** Retention floor. 0 = full log retained. */
  oldestRetainedSequence: number;
  stepCount: number;
  checkpointId?: string;
}

Checkpoint-pinned. For a non-terminal session the producer serves the latest checkpoint row — state and streamSequence come from the same atomically written record, the only pairing that is sound (a live "state then sequence" read skips the in-flight step's patches forever; the inverse double-applies them).

streamSequence: -1 is "no position", not a low number. It is returned for a terminal session (serving the live final state — a post-terminal snapshot pins a moment, not an immutable value), for a session with no checkpoint yet, and for a degraded checkpoint sentinel. A pinned value is always >= 1. Never derive a fromSequence from -1: every real sequence exceeds it, so comparing against it admits patches the snapshot already contains. Against an unpinned baseline, suppress patches and re-snapshot at a boundary instead.

fromSequence is exclusive everywhere (sequence > fromSequence); a pinned streamSequence is passed straight through as fromSequence.

The snapshot covers the target session's own customState only. Nested baselines are per-descendant: subagent_start chunks carry subSessionId; call getSnapshot(childSessionId).

Validate with remoteSnapshotResponseSchema() (both shipped transports do).

Not the frontend snapshot

The protocol snapshot (customState-only, checkpoint-pinned) is a different surface from the ai-sdk chat handler's FrontendSnapshot (getSnapshot() → messages + root state, for UI hydration) and from the Cloudflare DO's native chat-shaped GET /snapshot. See One name, three snapshots.

RemoteUsageResponse ​

typescript
interface RemoteUsageResponse {
  sessionId: string;
  /** getRollup({ includeSubAgents: true }) — the recursive fold. */
  rollup: UsageRollup;
}

The producer always computes the rollup with includeSubAgents: true, so grandchildren arrive pre-folded. On a running session it is the partial rollup as-of-now; once terminal it is at least as complete as the end event's usage. Validate with remoteUsageResponseSchema() (which embeds usageRollupSchema(), the Zod schema for UsageRollup).

TransportEvent ​

typescript
type TransportEvent =
  | { type: 'chunk'; chunk: StreamChunk; sequence: number }
  | {
      type: 'end';
      /** Terminal disposition (required). A status-less end frame is malformed and surfaces as a recoverable `fail`, never a silent 'completed'. */
      status: 'completed' | 'paused' | 'interrupted';
      output?: unknown;
      /** Final committed customState — emitted only after terminal persistence succeeded. */
      state?: Record<string, unknown>;
      /** Recursive usage rollup — same persist-before-emit rule as `state`. */
      usage?: UsageRollup;
    }
  /** The remote stream failed (wire v2 `fail`; the v1 name `error` is gone). */
  | { type: 'fail'; error: string; errorDetail?: ErrorDetail; recoverable: boolean }
  /** The attach cursor is below the remote stream's retention floor (wire v2 `truncated`). */
  | { type: 'truncated'; floor: number; errorDetail: ErrorDetail };

The events are the /sse body's stream frames, decoded by decodeTransportEvents (below). A stream that ends with no terminal event is not an event: the transport's iterator throws the retryable StreamDropError.

Normative consumer rule: only status: 'completed' folds output / state / usage directly. 'paused' / 'interrupted' route through the getStatus recovery branch — a paused child must never surface to the parent LLM as completed-with-null. state and usage may legitimately be absent (eviction / watchdog / replay-synthesized end); consumers fall back to getSnapshot() / getUsage().

decodeTransportEvents ​

typescript
import { decodeTransportEvents } from '@helix-agents/core';

function decodeTransportEvents(
  body: ReadableStream<Uint8Array>,
  options: { sessionId: string; fromSequence?: number; signal?: AbortSignal }
): AsyncGenerator<TransportEvent>;

The one decoder of a remote agent's /sse body, shared by HttpRemoteAgentTransport and the CF DO's DOStubTransport. A custom RemoteAgentTransport should decode with it.

  • chunk, end, fail (with its errorDetail) and truncated frames are their events; a terminal frame ends the iteration.
  • A malformed frame is yielded as a recoverable fail (a re-attach from the cursor re-reads it), and the iteration goes on.
  • caught-up, rewound, evicted (WE C2b: the EOF that follows is the drop below) and unknown events (including the v1 name error) are skipped.
  • A body that ends with no terminal frame throws StreamDropError(sessionId, lastSequence) (retryable), unless options.signal aborted the read, which ends quietly.

RemoteChildPausedError(agentType, remoteSessionId, lastSequence) (CP-45) is the drop a remote dispatch reports when its re-attach retries ran out with the child last reading paused (live, waiting on its own work). It is a StreamDropError, so every drop handler still retries it, and classifyKnownError / resolveErrorDetail classify it transport_stream_drop with cause: 'remote_child_paused', retryable. On runtime-js, the CF DO and CF Workflows it is reported only for a pause the root cannot answer: core planRemotePause(status, transport) (CP-157 L1) parks the parent on any other ({ park: true, calls } when the child is paused, lists its pendingCalls untruncated and the transport implements submitToolResult; else { park: false, reason }). remoteChildWait builds the parent's childKind: 'remote' wait, remotePendingCallEntries the root's entries, and parkedCallConflicts refuses a call id that already names another call on the root. parkRootOf(store, session) is the session the parked calls are recorded on: the top of the run's LOCAL tree, stopping at a remote edge (a parent this store does not hold, or one whose ref for the child is remote). A remote producer is started with its consumer's rootSessionId, which is never a session in its own store, so it records (and forwards) its own children's parked calls itself. An entry's optional reparks counts the no-progress re-parks of that call, and answeredAt is the root router's stamp of a forwarded answer (cleared when the producer did not take it): only an answered call counts, and the second count fails the delegating call typed (the spin bound). The router forwards only an entry whose ROOT session is live (active / paused) and whose parent still holds the remote wait on that child, has a live current run and still has the delegating call trailing unpaired (isParkedEntryCurrent(entry, parent, rootStatus) over facts already read, or parkedEntryIsCurrent(store, entry, root), which reads the parent); any other entry answers unknown_tool_call, the ai-sdk snapshot does not render it, and listRemotePendingCalls(session, loadOwner, isParkedCurrent) (a producer's /status) does not list it. Every terminal write of the runtimes that park on remote children (runtime-js, the CF DO and CF Workflows) clears remotePendingCalls: a stop's (commitStopTerminal), the K5, a K5 that never lands, and the failure helpers (failCommittedRun, failSessionWithoutRun). Every store's compareAndSetStatus also clears it when the new status is not active / paused (as it clears interruptContext), so a status-only terminal writer (agent-server's expiredSessionCleanup, runtime-js's pre-loop failure) keeps none either. Temporal and DBOS never write the field. Every dispatch (core's, runtime-js, CF Workflows, DBOS) never reads a paused or interrupted child's output (an earlier turn's): interrupted is the typed remote-interrupted failure whatever it carries.

remoteTerminalOutcome ​

typescript
import { remoteTerminalOutcome, type RemoteTerminalOutcome } from '@helix-agents/core';

type RemoteTerminalOutcome =
  | { kind: 'completed'; output: unknown; state?: Record<string, unknown>; usage?: UsageRollup }
  | { kind: 'stopped'; status: 'paused' | 'interrupted' }
  | { kind: 'retry'; error: string; errorDetail?: ErrorDetail }
  | { kind: 'failed'; error: string; errorDetail?: ErrorDetail };

function remoteTerminalOutcome(
  event: Exclude<TransportEvent, { type: 'chunk' }>
): RemoteTerminalOutcome;

The one mapping from a terminal TransportEvent to what a remote sub-agent dispatch does next. Every runtime's dispatch (core's, used by Temporal; runtime-js; DBOS; CF Workflows) uses it, so a remote failure keeps its errorDetail everywhere:

EventOutcomeThe dispatch
end { status: 'completed' }completedfolds output (null when absent), and state / usage
end { status: 'paused' | 'interrupted' }stoppedrecovers through the remote /status (not success, D5)
fail { recoverable: true }retrytreats it as a drop: re-attach from its cursor (or its post-drop /status recovery)
fail { recoverable: false }failedfails the call with the event's errorDetail, not retried
truncated { floor, errorDetail }failedfails the call with the TRUNCATED detail (no re-snapshot yet)

RemoteAgentFailedError(remoteSessionId, remoteError, errorDetail?) takes the remote failure's detail as an optional third argument (getErrorDetail / resolveErrorDetail read it), so the failed sub-agent result keeps it on every runtime.

RemoteStartRequest identity fields ​

typescript
interface RemoteStartRequest {
  sessionId: string;
  agentType: string;
  message: string | UserInputMessage[];
  state?: Record<string, unknown>;
  metadata?: Record<string, string>;
  userId?: string; // forwarded from the spawning parent
  tags?: string[]; // forwarded from the spawning parent
  parentSessionId?: string;
  rootSessionId?: string;
  parentToolCallId?: string; // the parent's consult this entry serves (also on RemoteResumeRequest)
  requestKey?: string; // D-2: `X-Helix-Request-Key` on the HTTP route (a delegation's: its dispatch id)
  binding?: ChildBinding; // child identity: the delegation; the producer stores it on the child
}

interface RemoteResumeRequest {
  sessionId: string;
  message?: string | UserInputMessage[];
  parentToolCallId?: string; // the parent's consult this entry serves
  requestKey?: string; // D-2
  binding?: ChildBinding; // child identity: refused when it names another delegation, or none on a bound child
}

interface RemoteStartResponse {
  sessionId: string;
  streamId: string;
  runId: string;
  status?: 'started' | 'resumed' | 'attached'; // when the host says (the CF DO always does)
}

parentToolCallId (also on RemoteResumeRequest) names the parent tool call that sent the entry: a persistent companion's consult. The child records it on the run it starts (RunMetadata.parent) and reports it in /status.

userId / tags / metadata are forwarded by every consumer executor and seeded onto the child session by both servers, so producer-side usage attribution and memory entity-scoping match a local child. metadata is also the escape hatch for threading trace ids across the boundary.

requestKey and status: 'attached' are the request key on the protocol (D-2). attached means the request was a keyed duplicate: runId names the run that holds the key, which this request neither started nor wrote to. The servers read the key from the X-Helix-Request-Key header (agent-server's /start and /resume, the CF DO's /subagent/:type/start|resume). The shipped remote transports do not send keys yet (D-2 MR 2), and remoteStartResponseSchema() does not declare status, so a response parsed with it drops the field.

RemoteStatusResponse ​

Its state field has been removed — getSnapshot() is the single way to obtain state. awaitingClientTool is a required boolean (true when the session is suspended awaiting a client-executed tool). isExecuting is this replica's truth on Node (derived from the server's active handles), not a status === 'active' echo, which is what makes producer-liveness escalation possible.

pendingCalls (CP-157) lists the unanswered client-executed and approval-gated calls of the session's WHOLE tree, oldest first (core listRemotePendingCalls), so a remote parent can park on a paused child and answer them from its own root:

typescript
interface RemotePendingCall {
  toolCallId: string;
  kind: 'client' | 'approval';
  toolName: string;
  input?: unknown; // omitted (with inputTruncated: true) when its UTF-8 JSON is over 8 KiB
  inputTruncated?: true;
  approvalId?: string; // kind 'approval'
}

At most MAX_REMOTE_PENDING_CALLS (100) are listed; pendingCallsTruncated: true says more exist, or that a delegated call's owner could not be read (the CF DO's store holds only its own session, so a delegated call there is always flagged). A consumer parks only on a complete listing. A store error reading an owner fails the status read rather than answering a shorter list. agent-server and the CF DO always send the field (an empty array when nothing is pending); an older producer omits it. awaitingClientTool keeps its meaning (the session's OWN calls).

runId and runStatus describe the session's current run record, when the store reports one:

typescript
type RemoteRunStatus =
  | 'running'
  | 'completed'
  | 'failed'
  | 'interrupted'
  | 'superseded'
  | 'suspended_client_tool'
  | 'suspended_awaiting_children'
  | 'suspended_step_partial'
  | 'unknown'; // a status a newer peer added that this version does not know

runStatus is additive. A superseded run is a typed stop, never a failure, and the session's own status is unaffected by it. remoteStatusResponseSchema() parses a runStatus it does not recognise as 'unknown' instead of failing the response; treat 'unknown' as "not known to be live".

Two more optional fields describe the same run (spec 2026-10-15 §3.2):

typescript
interface RemoteStatusResponse {
  // ...
  /** `terminal`: the current run's terminal is committed (core readCommittedRunOutcome). `suspension`: MR C. */
  committedOutcome?: 'terminal' | 'suspension';
  /** CP-40 (C2): a stop is recorded for the current run and has not ended it yet. */
  stopping?: boolean;
  /** The current run's consult (RunMetadata.parent.toolCallId), when it serves one. */
  parentToolCallId?: string;
}

stopping is additive: a peer that does not report it omits it, and the response still parses. A committed suspension's awaited calls ride the CP-157 pendingCalls listing (above): a companion parent reads their toolCallId, toolName and kind.

With committedOutcome: 'terminal', runStatus is the committed status and output / error / errorDetail come from that committed session. agent-server's /status and the CF Durable Object's /subagent/:type/status report them (the DO counts its R77 announced commit as committed; 'suspension' is not reported yet).

Error codes ​

RemoteAgentErrorResponse.code ({ error, code, streamId?, errorCode?, cause?, inputCommitted? }; inputCommitted: true marks a non-2xx sent AFTER the entry's run-start commit, so a companion parent treats the start as unconfirmed — child_start_unconfirmed, the consult kept — never as a definite non-start; DO companion MR B):

CodeMeaning
NOT_FOUNDUnknown session (both new routes answer with this envelope, never null)
ALREADY_RUNNING/start on a live session. Carries streamId. Every consumer treats it as attach, not failure
ALREADY_COMPLETEDTerminal session
FEATURE_UNAVAILABLERoute/feature not implemented (e.g. /usage with no usageStore wired) — consumers degrade
TRUNCATEDNot an HTTP answer: a cursor below the retention floor is the /sse body's terminal truncated { floor, errorDetail } frame, whose errorDetail.code is TRUNCATED (TRUNCATED_FRAME_CODE)
INVALID_REQUEST, INTERNAL_ERRORMalformed request / producer-side failure
BAD_REQUEST, UNAUTHORIZED, METHOD_NOT_ALLOWED, UPSTREAM_ERRORWorker-layer codes synthesized by createRemoteAgentWorkerHandler before the request reaches a DO

errorCode carries the typed HelixError code behind the failure (for example state_history_incomplete) and cause its typed reason (for example corrupt_row, or a rejected run start's entry_status_changed), when there is one. code stays the protocol envelope code. A CF Durable Object producer sets errorCode on every /subagent/:type/* error answer: ALREADY_RUNNING for a busy session, NOT_FOUND for an unknown agent type (framework_agent_not_found) or session, ALREADY_COMPLETED (completed session) or INVALID_REQUEST with errorCode: 'state_not_resumable' for a refused resume, and INVALID_REQUEST / INTERNAL_ERROR with the status of the core table otherwise. A keyed request (D-2) that a finished run answered while a later run exists is 409 ALREADY_COMPLETED with errorCode: 'state_request_already_completed'; a key reused for other input is 409 INVALID_REQUEST with errorCode: 'state_request_key_conflict'.

Both transports (HttpRemoteAgentTransport and the CF DOStubTransport) map a non-2xx answer through one core function, remoteProtocolError(failure: RemoteProtocolFailure), on every route (the entries start / resume through its wrapper remoteEntryProtocolError, below):

AnswerError
envelope ALREADY_RUNNINGRemoteAgentAlreadyRunningError (its streamId)
envelope NOT_FOUNDRemoteAgentNotFoundError
envelope FEATURE_UNAVAILABLERemoteAgentFeatureUnavailableError
any other envelope with an errorCode, or any envelope answered 503helixErrorFromDetail({ message: error, code: errorCode, cause, retryable: status === 503 })
not an envelope, but a typed { error, code, cause?, retryable? } bodyhelixErrorFromPeerBody(body, status) (agent-server's /start / /resume entry failures answer this body)
any other envelope, or a body that is neithera generic Error (${prefix} (${status}): ${body})

A typed error's detail keeps only the code the server sent (errorCode or the typed body's code); the protocol's uppercase code is never used as an ErrorCode, and no code is inferred from the message. A bare (non-envelope, untyped) 404 stays a generic Error.

Each exposes the public fields a consumer needs to route or log:

typescript
new RemoteAgentAlreadyRunningError(remoteSessionId: string, streamId?: string); // .streamId → the live stream to attach to
new RemoteAgentNotFoundError(remoteSessionId: string, endpoint: string);        // .endpoint → the route that answered 404
new RemoteAgentFeatureUnavailableError(remoteSessionId: string, endpoint: string); // .endpoint → the unsupported route; skip that fold

Entries are sent once (CP-95) ​

Both transports POST an entry (/start, /resume) exactly once: a re-sent entry could start the remote run, or append the resume's message, twice. They map an entry's non-2xx answer through remoteEntryProtocolError(failure & { entry: 'start' | 'resume' }), which is remoteProtocolError except for an unknown outcome:

Entry answerError
the fetch threw (the request may still have arrived)RemoteEntryOutcomeUnknownError (no status)
a 5xx with the host's typed retryable body (a Helix host's 503; Zod-parsed)the host's own typed retryable error, as above
any other 5xx: an untyped body (a load balancer or proxy page) or a typed non-retryable oneRemoteEntryOutcomeUnknownError (status, the mapped error as cause)
a 4xxremoteProtocolError's error (a definite refusal)

A typed retryable answer is safe to surface as is because a Helix host never answers "retryable" once the entry's input is committed (DI-39). An untyped 503 is an unknown outcome, not a retryable one.

typescript
class RemoteEntryOutcomeUnknownError extends HelixError {
  // code 'transport_remote_entry_outcome_unknown'; never retryable
  readonly remoteSessionId: string;
  readonly entry: 'start' | 'resume';
  readonly status: number | undefined; // absent when the fetch threw
  constructor(opts: {
    remoteSessionId: string;
    entry: 'start' | 'resume';
    status?: number;
    cause: unknown;
  });
}

A transport never re-sends an entry itself. HttpTransportConfig.maxRetries (default 3) applies only to the reads (/status, /snapshot, /usage) and the stop requests (/interrupt, /abort). A request's requestKey (RemoteStartRequest / RemoteResumeRequest) is sent as the X-Helix-Request-Key header, never in the body.

Keyed remote entries (D-2 MR 2). Every runtime's remote dispatch keys its entries, so a re-send is answered from the key and never runs the child twice:

typescript
// A delegation's /start keys on the call's minted dispatch id itself (`d_<uuid>`, a valid
// request key). Its re-attach /resume: 'h_' + SHA-256 hex, domain-separated from the start key.
function dispatchResumeRequestKey(dispatchId: string): Promise<string>;

const KEYED_START_RESENDS = 2;
// transport.start(request), re-sent unchanged after a RemoteEntryOutcomeUnknownError
// at most KEYED_START_RESENDS times; any other error is thrown as is.
function startRemoteChild(
  transport: Pick<RemoteAgentTransport, 'start'>,
  request: RemoteStartRequest & { requestKey: string }
): Promise<RemoteStartResponse>;

See Remote agents: entries are sent once.

RemoteAgentTransport ​

getSnapshot and getUsage are required on the interface — both shipped transports (HttpRemoteAgentTransport in core, DOStubTransport in runtime-cloudflare) implement them.

typescript
interface RemoteAgentTransport {
  start(request: RemoteStartRequest): Promise<RemoteStartResponse>;
  resume(request: RemoteResumeRequest): Promise<RemoteStartResponse>;
  stream(
    sessionId: string,
    options?: { fromSequence?: number; signal?: AbortSignal }
  ): AsyncIterable<TransportEvent>;
  getStatus(sessionId: string): Promise<RemoteStatusResponse>;
  getSnapshot(sessionId: string): Promise<RemoteSnapshotResponse>;
  getUsage(sessionId: string): Promise<RemoteUsageResponse>;
  /** A parent's stop of its child, naming the delegation (CP-217): { reason, target }. */
  interrupt(sessionId: string, stop: RemoteStopRequest): Promise<void>;
  abort(sessionId: string, stop: RemoteStopRequest): Promise<void>;
  /** CP-157: answer a pending call of the session's tree (optional). */
  submitToolResult?(sessionId: string, submit: SubmitToolResult): Promise<SubmitToolResultResponse>;
}

submitToolResult is optional (CP-157), so a custom transport keeps compiling. It answers one of the session tree's pending calls (a RemoteStatusResponse.pendingCalls entry) with core SubmitToolResult, sending the transport's own sessionId, and returns core SubmitToolResultResponse (a repeat answers already_completed; a non-2xx answer is the typed protocol error). HttpRemoteAgentTransport posts to the producer's /submit-tool-result (agent-server); DOStubTransport posts to the DO's /subagent/:type/submit-tool-result, which createRemoteAgentWorkerHandler also forwards. A consumer parks a parent on a paused remote child only through a transport that implements it.

Consumers still degrade gracefully at runtime: a RemoteAgentNotFoundError or RemoteAgentFeatureUnavailableError from getSnapshot / getUsage skips that fold with a warning rather than failing the run.

Consumer-side fold types ​

Terminal folds surface through the existing hook and usage types:

typescript
interface AfterSubAgentPayload {
  // ...existing fields...
  /** Remote children only: the child's terminal customState. */
  childCustomState?: Record<string, unknown>;
  /** Remote children only: the child's harvested recursive rollup. */
  remoteRollup?: UsageRollup;
  remoteRollupOrigin?: 'end' | 'recovery';
}

interface SubAgentUsageEntry extends BaseUsageEntry {
  kind: 'subagent';
  subAgentType: string;
  subSessionId: string;
  durationMs: number;
  success: boolean;
  error?: string;
  /** Embedded recursive rollup of a REMOTE child. Absent for local children. */
  remoteRollup?: UsageRollup;
  remoteRollupOrigin?: 'end' | 'recovery';
}
  • childCustomState is terminal-only and gated on end.status === 'completed'; it is sourced from end.state (fast path) or transport.getSnapshot() (recovery), and skipped with a warning when the serialized state exceeds RemoteSubAgentConfig.maxFoldStateBytes (default DEFAULT_MAX_FOLD_STATE_BYTES = 262_144). Output and usage are unaffected by an over-cap state. On at-least-once runtimes, prefer idempotent set/mark folds over appends when consuming it.

  • remoteRollup / remoteRollupOrigin exist because the lazy subSessionId lookup cannot work across a service boundary (the child's entries live in the producer's store). Store getRollup implementations resolve locally first; if that yields nothing they select exactly one embedded rollup per subSessionId by the total order origin ('recovery' > 'end'), then timestamp, then insertion ordinal — entries are deduped by subSessionId and never summed per-entry. Embedded rollups are already producer-side includeSubAgents: true results, so grandchildren fold once, by construction.

    The shipped helpers for this selection are exported from @helix-agents/core:

    typescript
    // Bucket raw usage entries by subSessionId before selection.
    function groupSubAgentEntries(entries: UsageEntry[]): Map<string, SubAgentUsageEntry[]>;
    
    // Apply the origin > timestamp > insertion-ordinal order above and return the
    // single winning embedded rollup for a subSessionId (or undefined if none).
    function selectEmbeddedRemoteRollup(entries: SubAgentUsageEntry[]): UsageRollup | undefined;

State Management ​

ImmerStateTracker ​

Track state mutations with RFC 6902 patches.

typescript
import {
  ImmerStateTracker,
  createImmerStateTracker,
  stepWritesToRFC6902,
} from '@helix-agents/core';

const tracker = new ImmerStateTracker(initialState, {
  arrayDeltaMode: true, // enables parallel-safe append classification (default: false)
});

When arrayDeltaMode is true, getStepWrites() classifies pure-append array mutations as {kind: 'append', key, items: delta} instead of {kind: 'replace', key, value: fullArray}. Use when this tracker may run concurrently with other trackers against the same baseline (e.g., parallel server tools). All four runtimes use arrayDeltaMode: true.

typescript
// Make mutations
tracker.update((draft) => {
  draft.notes.push({ content: 'New note' });
});

// Get step writes (canonical representation for persistence + wire)
const stepWrites = tracker.getStepWrites();

// Get RFC 6902 patches derived from step writes (for streaming)
const patches = stepWritesToRFC6902(stepWrites);

// Get current state
const currentState = tracker.getState();

// Reset tracking between steps
tracker.resetTracking();

Types:

  • ImmerStateTrackerOptions - Configuration options (arrayDeltaMode?: boolean)
  • StepWrites - Unified per-tool write representation (ops + warnings)
  • WriteOp - Discriminated union: {kind: 'append', key, items} | {kind: 'replace', key, value} | {kind: 'delete', key}

stepWritesToRFC6902 ​

Convert StepWrites to RFC 6902 patch operations for state_patch stream chunks.

typescript
import { stepWritesToRFC6902 } from '@helix-agents/core';

// Preferred pattern: derive patches from step writes
const stepWrites = tracker.getStepWrites();
const patches = stepWritesToRFC6902(stepWrites);
// patches: JSONPatchOperation[] — ready to emit in a state_patch chunk

Note: stepWritesToRFC6902(tracker.getStepWrites()) is the only supported path to RFC 6902 patches — it takes the canonical StepWrites representation and produces correct /- end-of-array paths for pure-append mutations. (The old convertImmerPatchToRFC6902 Immer-patch helper was removed.)

LLM Module ​

LLMAdapter Interface ​

Interface for LLM providers:

typescript
interface LLMAdapter {
  generateStep(input: LLMGenerateInput): Promise<StepResult>;
  /** Optional. Absent ⇒ LEGACY_RESOLUTION. Synchronous, pure, no I/O. */
  resolveCapabilities?(config: LLMConfig): CapabilityResolution;
  /** Optional. Absent ⇒ always ok. Called at run entry (early refusal), never during a step. */
  checkOutputFormat?(
    format: LLMOutputFormat,
    config: LLMConfig
  ): { ok: true } | { ok: false; reason: string };
}

interface LLMGenerateInput {
  messages: Message[];
  tools: Tool[];
  config: LLMConfig;
  abortSignal?: AbortSignal;
  callbacks?: LLMStreamCallbacks;
  agentId: string;
  agentType: string;
  // Provider-neutral directive fields — omitted on ordinary calls, set by
  // the framework during forced completion (see Forced Completion below).
  executionPhase?: ExecutionPhase;
  toolChoice?: LLMToolChoice; // forced_tool only
  reasoningMode?: ReasoningMode; // forced_tool only, when thinking is not history-bound
  outputFormat?: LLMOutputFormat; // native_output only
  minOutputTokens?: number; // native_output only: max(config.maxOutputTokens ?? default, this)
}

interface LLMOutputFormat {
  type: 'json_schema';
  name: string; // the completion target: '__finish__' or the finishWith tool
  schema: z.ZodType; // the target's input schema; the adapter converts it
  description?: string;
}

interface LLMStreamCallbacks {
  // Every callback may be async; adapters must await each one.
  onTextDelta?: (delta: string) => void | Promise<void>;
  onThinking?: (content: string, isComplete: boolean) => void | Promise<void>;
  onToolCall?: (toolCall: ParsedToolCall) => void | Promise<void>;
  onError?: (error: Error) => void | Promise<void>;
  // ...plus onToolCallStart / onToolCallArgDelta / onToolCallArgEnd,
  // onStepStart / onStepEnd, onSource and onFile (same shape).
}

Reporting LLM errors (@helix-agents/core, DI-23):

typescript
import { reportLLMError, logOnErrorFailure } from '@helix-agents/core';

// In an adapter: awaits callbacks.onError; returns its rejection (normalized
// to an Error) or undefined; never throws.
const onErrorFailure = await reportLLMError(callbacks, error);
return {
  type: 'error',
  error,
  shouldStop: true,
  stopReason: 'error',
  ...(onErrorFailure ? { onErrorFailure } : {}),
};

// In a runtime / bring-your-own loop, right after generateStep: logs a
// surfaced onErrorFailure at warn ('llm.on_error_callback_failed', exported
// as ON_ERROR_FAILURE_LOG_EVENT) with sessionId, runId, step and the
// original error code. No-op for any other result.
logOnErrorFailure(logger, stepResult, { sessionId, runId, step });

Use reportLLMError instead of calling onError directly. An unawaited call leaks an unhandled rejection when the run's stream has already ended (the runtimes' onError writes an error chunk to it). A rethrown rejection makes generateStep throw, which on Temporal retries the activity (a second model call) and replaces the provider error with the stream-write failure.

Holding the error chunk until the failure is terminal (E5):

typescript
import { holdLLMErrorReport, isTerminalLLMStepError } from '@helix-agents/core';

const held = holdLLMErrorReport(callbacks); // `held.callbacks.onError` only records
let result = await llmAdapter.generateStep({ ...input, callbacks: held.callbacks });
result = await held.settle(result, isTerminalLLMStepError(result));

A runtime writes an error chunk only for a failure that ends the run, never for a model attempt that is retried: a client stops reading at an error event, so an error from a retried attempt would hide the attempt that replaces it. settle(result, terminal) reports the held error to the real onError only when terminal and the result is an error result (a rejecting report lands on result.onErrorFailure), and drops it otherwise. isTerminalLLMStepError(result) is true for an error result with shouldStop. A forced call's error ends the run too (its forced phase is closed by resolveForcedCallTransition), so it no longer takes a forcedCall option; the step iterator also treats a forced call's error with shouldStop: false as terminal. A generateStep that throws is never settled; its failure reaches the client through the runtime's terminal path.

Retrying a retryable LLM error step (MA-26):

typescript
import {
  isRetryableLLMStepError, // (result: StepResult) => boolean
  isRetryableStepErrorDetail, // (detail: ErrorDetail) => boolean: detail.retryable === true
  llmStepRetryDelayMs, // (policy, retry /* 1-based */) => number
  DEFAULT_LLM_STEP_RETRY, // { limit: 3, delayMs: 1000, backoff: 'exponential' }
  type LLMStepRetryPolicy, // { limit, delayMs, backoff?: 'constant' | 'linear' | 'exponential' }
} from '@helix-agents/core';

The one rule every runtime retries an LLM error by: only an error classified retryable: true. An unclassified error (no code) is not retried, because it may be deterministic (DI-35). runStepIteration applies it when its deps carry llmStepRetry: { policy, newAttempt, sleep? }: a failed call under the budget drops its held error report, fires afterLLMCall, waits llmStepRetryDelayMs (an abort cuts the wait short and ends the step framework_cancelled), calls newAttempt() (the runtime mints a fresh attempt: a new attemptId and its step_start) and calls the model again for the SAME step. A thrown retryable error is retried the same way. A custom loop that retries elsewhere (a durable step's own retry) uses isRetryableLLMStepError as its predicate.

Provider-neutral directive types (@helix-agents/core):

typescript
import type { LLMToolChoice, ReasoningMode, ExecutionPhase } from '@helix-agents/core';

type LLMToolChoice = 'auto' | 'none' | 'required' | { type: 'tool'; toolName: string };
type ReasoningMode = 'default' | 'disabled'; // omitted ⇒ 'default'
type ExecutionPhase = 'work' | 'forced_completion'; // omitted ⇒ 'work'

An adapter must honour every directive it receives: forward a named toolChoice to the provider, and send outputFormat as the provider's native structured output. It never falls back silently (auto instead of a named tool, plain text instead of the format); it reports what the model supports through resolveCapabilities, so the framework only sends a directive the model can honour. A non-conforming adapter that ignores the directive anyway does not make the run succeed: the response-side checks in planStepProcessing / classifyForcedCompletionResult turn a missing completion into a recoverable miss and, on exhaustion, framework_completion_protocol_violation.

Model capabilities ​

typescript
import {
  LEGACY_CAPABILITIES,
  LEGACY_RESOLUTION,
  resolveCompletionCapabilities,
  modelIdOf,
  modelLabelOf,
} from '@helix-agents/core';
import type { ModelCapabilities, CapabilityResolution, LLMOutputFormat } from '@helix-agents/core';

interface ModelCapabilities {
  forcedToolChoice: boolean; // a named tool choice can be forced
  nativeStructuredOutput: boolean; // the response can be constrained to a JSON schema
  historyBoundThinking: boolean; // thinking blocks bind the request prefix
}

type CapabilityResolution =
  | {
      status: 'resolved';
      capabilities: ModelCapabilities;
      profile: string;
      source: 'override' | 'table' | 'default';
    }
  | { status: 'unresolved'; reason: string };

// { forcedToolChoice: true, nativeStructuredOutput: false, historyBoundThinking: false }
LEGACY_CAPABILITIES;
// { status: 'resolved', capabilities: LEGACY_CAPABILITIES, profile: 'legacy', source: 'default' }
LEGACY_RESOLUTION;

const resolution = resolveCompletionCapabilities(adapter, effectiveLLMConfig);

resolveCompletionCapabilities(adapter, config) is the one place core asks an adapter about a model: it returns LEGACY_RESOLUTION when the adapter has no resolveCapabilities, and turns a throwing resolver into { status: 'unresolved', reason: 'resolveCapabilities threw: …' } (it never throws). runtime-js, the CF DO and CF Workflows call it on every LLM call with the config the call actually uses, and so does Temporal (in the runLLMStep activity); DBOS does not yet (support is planned). modelIdOf(model) reads a model id from an LLMConfig.model (a string, or a LanguageModel's modelId); modelLabelOf(model) is the same or 'unknown'. Core uses the label only in messages, traces and the prompt fingerprint, never to decide anything. See LLM overview → Model capabilities.

Tool-input coercion helpers ​

The framework guarantees that tool-call arguments and sub-agent input are always objects, and that structured output is repaired schema-aware. Core enforces this in planStepProcessing(), so every runtime and store is protected regardless of adapter. These helpers are exported for custom adapters and bring-your-own-loop code that produces tool calls at the boundary:

typescript
import { coerceToolCallArguments, repairStructuredOutput } from '@helix-agents/core';

// Always returns a non-null, non-array object.
// - object         → returned unchanged
// - '{"a":1}'      → JSON.parse'd to { a: 1 }
// - anything else  → {} (and logs a warning via the optional logger)
const args: Record<string, unknown> = coerceToolCallArguments(raw, logger);

// Schema-aware repair for structured output (the __finish__ value). Only repairs
// a JSON string when it matches the declared outputSchema; never object-coerces,
// so legitimate non-object outputs (z.string()/z.number()/z.array()) are
// preserved. Never throws — returns the value unchanged when it can't repair.
const output = repairStructuredOutput(raw, outputSchema, logger);

A non-coercible tool input becomes {} and logs [helix-agents] non-object tool-call input coerced to {}; a repaired structured output logs [helix-agents] structured output repaired from JSON string.

MockLLMAdapter ​

Mock adapter for testing, exported from @helix-agents/core/testing/mocks (and @helix-agents/core/testing), not from the main entry, like RoutingMockLLMAdapter, MockEmbeddingAdapter and ScriptedModel below:

typescript
import {
  MockLLMAdapter,
  MOCK_NATIVE_OUTPUT_WITHOUT_FORMAT,
} from '@helix-agents/core/testing/mocks';

const mock = new MockLLMAdapter([
  { type: 'text', content: 'Hello!' },
  { type: 'tool_calls', toolCalls: [{ id: 't1', name: 'search', arguments: {} }] },
  // Shorthand for a tool_calls step with one `__finish__` call whose arguments are `output`
  // (MockStructuredOutputResponse): the turn persists its assistant message and acknowledged result.
  { type: 'structured_output', output: { result: 'done' } },
  // A native structured-output answer (MockNativeOutputResponse): `JSON.stringify(output)`
  // (or `rawText`) only when the request carries `outputFormat`, else MOCK_NATIVE_OUTPUT_WITHOUT_FORMAT.
  // Exactly one of `output` / `rawText`: a response with neither throws when it is scripted.
  { type: 'native_output', output: { result: 'done' } },
]);

// Provider-faithful mode: behave like a model with these capabilities.
const faithful = new MockLLMAdapter(responses, {
  capabilities: {
    forcedToolChoice: false,
    nativeStructuredOutput: true,
    historyBoundThinking: true,
  },
  capabilityProfile: 'anthropic-5.5', // default 'mock'
});

A native_output response is returned as a stopping text step. It answers with the JSON only when the request carries outputFormat (as the real API does), so script every native answer this way, never as JSON text: a runtime that drops outputFormat then gets MOCK_NATIVE_OUTPUT_WITHOUT_FORMAT prose, an invalid-output miss, and never completes. It works with or without capabilities.

With capabilities, the mock reports them through resolveCapabilities, rejects a named or required tool choice with the API's provider_invalid_request 400 when forcedToolChoice is false, and, when historyBoundThinking is true:

  • signs scripted reasoning: MockReasoningBlock[] blocks against the request prefix, each chained to the previous block. A block is a string ('' is an empty signed block) or { redacted: true } (a redacted block: no text, opaque providerOptions.mock.redactedData);
  • binds each tool's name, description, schema, strict and non-cache providerOptions (cache markers are exempt);
  • rejects a later request whose system prompt, tools or earlier messages differ, or whose blocks were edited, reordered or removed mid-history (a removed leading run is allowed);
  • skips blocks another model signed, as the API drops them;
  • rejects a tool-result continuation whose last assistant message (the turn in progress) carries fewer of the mock's blocks than it issued: a dropped parts array or a dropped trailing block (provider_invalid_request).

Without capabilities it behaves exactly as before (and a scripted reasoning throws). RoutingMockLLMAdapter accepts the same options; the simulator is exported as FaithfulProviderSimulator from @helix-agents/core/testing. Exported types: MockNativeOutputResponse, MockReasoningBlock (plus the existing MockResponse variants).

ScriptedModel ​

A MockLLMAdapter subclass for outcome-level tests (it backs the runtime conformance suite). It adds per-session scripting, a full record of every call, and four response kinds:

  • hold blocks the call until the test releases it or the call's abortSignal fires.
  • holdAfterDeltas streams a text response and pauses after exactly N text deltas until the test releases it (see "Mid-text holds" below).
  • fail fails the call with a full ErrorDetail.
  • echo answers with a digest of what the call received.

Use it when a test must observe what the model actually received, or must stop a run while a model call is in flight. MockLLMAdapter ignores abortSignal and cannot hold a call open.

typescript
import { ScriptedModel, messageText } from '@helix-agents/core/testing/mocks';

const model = new ScriptedModel();
model.scriptFor(
  sessionId,
  { kind: 'hold', gateId: 'g1', then: { type: 'text', content: 'done', shouldStop: true } },
  { kind: 'fail', detail: { message: 'overloaded', code: 'provider_overloaded' } },
  { kind: 'echo' }
);

// ... start a run whose llmAdapter is `model` ...
const held = await model.whenHeld('g1', 5_000); // resolves once the call is held
model.release('g1'); // or stop the run: the held call then records `abortedAt`
await model.waitForAbort('g1', 5_000); // true once the held call saw its abortSignal

const calls = model.callsFor(sessionId); // ScriptedCallRecord[] for this session only
calls[0].messages.map(messageText); // exactly what the model received
calls[0].outcome; // 'pending' | 'completed' | 'errored' | 'aborted'
  • Options. new ScriptedModel(opts?: ScriptedModelOptions): MockLLMAdapter's options plus control (below) and resolveCapabilities, a per-model LLMAdapter.resolveCapabilities rule called with each call's LLMConfig. Unlike capabilities (one set for every model, plus the provider simulation), the rule changes only what the adapter reports, so returning LEGACY_RESOLUTION leaves a model exactly as an adapter without a resolver. Giving both throws a ScriptedModelError. The conformance suite uses the rule to make one sentinel model id unfinishable.
  • Scripting. script(...) enqueues onto a shared global queue. scriptFor(sessionId, ...) enqueues onto that session's own queue. scriptForAgentType(agentType, ...) enqueues onto that agent type's own queue, consumed by ANY session of that agent type. Plain MockResponses (text, tool_calls, structured_output, native_output, error) work too.
    • Dequeue precedence is session queue → agent-type queue → global queue, so a session-scoped script always wins over an agent-type-scoped one, which in turn always wins over the shared global queue. Two DIFFERENT agent types never swap responses (each has its own queue). But two sessions of the SAME agent type share one FIFO agent-type queue — the response order between them depends on which session's call reaches dequeue first, so scriptForAgentType is only deterministic when a single session of that type consumes the responses, or when the queued responses are interchangeable. Use scriptFor(childSessionId, ...) once a specific session id is known (e.g. from Observation.subSessions) when the assignment must be deterministic.
  • Call records. Each call is recorded as a ScriptedCallRecord with callId, index, agentId (the session id), agentType, step, messages (a deep copy), tools, startedAt, endedAt, abortedAt, outcome, gateId and, for a holdAfterDeltas call that reached its pause, heldAfterDeltas (the number of text deltas out at the pause).
    • A held call that sees an abort returns the same framework_cancelled error step result the Vercel adapter returns.
    • reset() clears the session, agent-type and global queues, plus the records. It resolves every still-held call as aborted and starts a new reset epoch.
  • Gates. A pending whenHeld(gateId, timeoutMs, signal?) wait can be cancelled with an optional AbortSignal. Cancelling clears its timer. Use this when you race the wait against something else, such as the run's outcome.
  • Outstanding holds. outstandingHolds() lists every call blocked in a scripted hold right now, as OutstandingScriptedHold — a discriminated union: { kind: 'hold', gateId, agentId, callId } or { kind: 'holdAfterDeltas', gateId, agentId, callId, deltas } (deltas exists only on the second). outstandingHoldsAfterDeltas() lists only the paused holdAfterDeltas calls, as PausedHoldAfterDeltas (gateId, agentId, callId, deltas). A released, aborted or reset call is never listed. The conformance suite fails any cell that ends with one.
  • ScriptedModelControl / inProcessControl(). The queues and records live behind this storage seam. Every method may return a promise, so a store- or RPC-backed control can replace the in-process default.
    • enqueue(target, responses): target is a ScriptTarget — { sessionId } or { agentType }, exactly one key — or undefined for the shared global queue. dequeue(sessionId, agentType) applies the session → agent-type → global precedence above. A custom ScriptedModelControl (or a wrapper around inProcessControl()) must implement both with these signatures.
    • The sync accessors (calls, callsFor, remainingScripted) require a synchronous control. The async twins (callsAsync, callsForAsync, remainingScriptedAsync) work with either kind.
    • A failing control call is never thrown into the runtime under test. It is kept as a sticky ScriptedModelError whose origin names the call, the session (or agent type, for a scriptForAgentType write) and the reset epoch. await model.ready() rethrows it.
  • ScenarioModel / scenarioApi(). ScenarioModel is the async, scenario-facing subset: scriptFor, scriptForAgentType, callsFor, whenHeld, release and waitForAbort, and every method returns a promise. model.scenarioApi() is its in-process implementation. Test code written against ScenarioModel stays portable to a model that lives in another isolate. The runtime conformance suite adds a cell-id prefix guard on scriptForAgentType — see the scenario authoring guide.

Mid-text holds (holdAfterDeltas) ​

holdAfterDeltas(deltas, gateId, then) builds a validated HoldAfterDeltasResponse ({ kind: 'holdAfterDeltas', gateId, deltas, then }). then is a text response, or a tool_calls response with content. The call streams then exactly as it would unscripted, and pauses after exactly deltas onTextDelta calls. Everything after them (the remaining deltas, tool calls, the step end) and the result wait for release(gateId), then arrive in their original order.

typescript
import {
  ScriptedModel,
  holdAfterDeltas,
  scriptedTextDeltas,
} from '@helix-agents/core/testing/mocks';

const TEXT = 'ALPHA BRAVO CHARLIE DELTA';
model.scriptFor(
  sessionId,
  holdAfterDeltas(2, 'mid', { type: 'text', content: TEXT, shouldStop: true })
);

// ... start a run whose llmAdapter is `model` ...
const rec = await model.whenHeld('mid', 5_000); // paused, 2 deltas out
rec.heldAfterDeltas; // 2
scriptedTextDeltas(TEXT).slice(0, 2).join(''); // 'ALPHA BRAVO' — what has streamed
model.release('mid'); // ' CHARLIE', ' DELTA', the step end, then the result
  • Deltas. scriptedTextDeltas(text) returns the exact deltas the model streams for text: split on single spaces, each chunk after the first keeping its leading space (the same chunks MockLLMAdapter streams).
  • Validation. holdAfterDeltas throws a ScriptedModelError when the pause could never happen: deltas is not a positive integer, is larger than the number of deltas the text streams, the response has no text, or gateId is empty. script, scriptFor and scriptForAgentType refuse such a hand-built literal too.
  • When whenHeld resolves. Only once the call is paused and its deltas are out: each forwarded onTextDelta's own promise (the runtime's stream write) has settled, and every delivery barrier has passed.
  • Delivery barriers. A DeliveryBarrier is (input: LLMGenerateInput) => Promise<void>. addDeliveryBarrier(barrier) registers one, awaited at every pause before the call counts as held, and returns its remover. A cross-process proxy uses it to confirm the deltas reached the far runtime (the conformance cf-do bridge does). A barrier that rejects becomes a sticky ScriptedModelError (await model.ready() rethrows it); it is never thrown into the call, which is then held anyway.
  • Abort and reset. An abort while paused, before the pause was reached, or while the held-back callbacks are being replayed after release, answers the framework_cancelled error step result, and the rest of the stream is never delivered. whenHeld and waitForAbort report such an early-aborted call too. reset() aborts a paused call the same way, and a call reset before its pause never registers a gate in the new epoch.

Stop Reason Mapping ​

typescript
import {
  mapVercelFinishReason,
  mapOpenAIFinishReason,
  mapAnthropicStopReason,
  mapGeminiFinishReason,
} from '@helix-agents/core';

Store Interfaces ​

SessionStateStore ​

Interface for session-centric state persistence. Uses sessionId as the primary key for all operations. Supports atomic operations for safe concurrent modifications from parallel tool execution.

Breaking for custom stores

This release changes the store contract: appendMessages / truncateMessages take a required MessageCommit, promoteStaging takes required { streamSequence, runId } and saveStateAndPromoteStaging a required checkpointMeta.runId, and commitState, renewRunLease, setRunOwnerUnknownSince, abandonRunStart and listRunCheckpoints are new. Every commit writes a checkpoint and moves the session's latest-checkpoint pointer atomically (see Commits and checkpoints), and every run-attributed write is fenced on the run. updateRunStatus throws on a terminal run, getMessages throws CorruptMessageRowError instead of skipping a bad row, Checkpoint gains required (nullable) kind / runId / keepCount, and the session's staging-checkpoint marker is removed (latestTruncateCheckpointId is new). isSessionStateStore now also requires updateRunStatus, listRuns, renewRunLease and commitState. Run the contract suites against your store. Migration detail: Snapshot and checkpoint truth.

typescript
interface SessionStateStore {
  /** The byte limits this store enforces (MA-31); absent or Infinity = none. */
  readonly limits?: StoreLimits;

  // Session lifecycle
  createSession<TState>(sessionId: string, options?: CreateSessionOptions<TState>): Promise<void>;
  sessionExists(sessionId: string): Promise<boolean>;
  /**
   * Delete the session and every descendant: its LOCAL sub-session refs' targets plus the
   * sessions whose `parentSessionId` names it, minus any session a REMOTE ref names (never
   * deleted). Recursive and cycle-safe; starts from `sessionId` even if it is already gone,
   * so a retry finishes an interrupted delete. Undefined with a live run in the tree.
   * DO-SQLite throws HelixError `framework_not_supported`.
   */
  deleteSession(sessionId: string): Promise<void>;
  cloneSession(
    fromSessionId: string,
    toSessionId: string,
    options?: CloneSessionOptions
  ): Promise<void>; // a `clone` commit

  // Messages (commits)
  appendMessages(sessionId: string, messages: Message[], commit: MessageCommit): Promise<void>; // `append`, or `input` with commit.startRun
  truncateMessages(sessionId: string, keepCount: number, commit: MessageCommit): Promise<number>; // `truncate`
  getMessages(sessionId: string, options?: GetMessagesOptions): Promise<PaginatedMessages>;
  getMessageCount(sessionId: string): Promise<number>; // a LIVE row count
  getUIMessageCount(sessionId: string): Promise<number>;

  // State
  saveState<TState, TOutput>(
    sessionId: string,
    state: SessionState<TState, TOutput>,
    opts?: SaveStateOptions // { fenceRunId } | { fenceIdle }
  ): Promise<void>; // never a commit
  commitState<TState, TOutput>(
    sessionId: string,
    state: SessionState<TState, TOutput>,
    commit: MessageCommit,
    opts?: { expectedVersion?: number }
  ): Promise<{ checkpointId: string; newVersion: number }>; // a `state` commit
  loadState<TState, TOutput>(sessionId: string): Promise<SessionState<TState, TOutput> | null>;
  mergeCustomState(sessionId: string, writes: StepWrites): Promise<{ warnings: string[] }>;
  updateStatus(
    sessionId: string,
    status: SessionStatus,
    context?: { interruptContext?: InterruptContext }
  ): Promise<void>;
  compareAndSetStatus(
    sessionId: string,
    expectedStatuses: SessionStatus[],
    newStatus: SessionStatus,
    options?: { interruptContext?: InterruptContext; error?: string; expectedVersion?: number }
  ): Promise<
    | { ok: true; newVersion: number }
    | { ok: false; currentStatus: SessionStatus; currentVersion: number }
  >;
  incrementStepCount(sessionId: string): Promise<number>;
  incrementResumeCount(sessionId: string): Promise<number>;

  // Runs
  createRun(sessionId: string, runId: string, metadata: CreateRunMetadata): Promise<void>;
  updateRunStatus(runId: string, status: RunStatus, metadata?: UpdateRunMetadata): Promise<void>;
  renewRunLease(sessionId: string, runId: string, ttlMs: number): Promise<{ leaseUntil: number }>;
  setRunOwnerUnknownSince(
    sessionId: string,
    runId: string,
    since: number | null
  ): Promise<number | null>;
  abandonRunStart(sessionId: string, runId: string): Promise<'abandoned' | 'committed'>;
  listRunCheckpoints(
    sessionId: string,
    runId: string,
    options?: ListCheckpointsOptions
  ): Promise<PaginatedRunCheckpoints>;
  getCurrentRun(sessionId: string): Promise<RunMetadata | null>;
  getRun(runId: string): Promise<RunMetadata | null>;
  getRunByRequestKey(sessionId: string, requestKey: string): Promise<RunMetadata | null>; // D-2
  listRuns(sessionId: string, options?: ListRunsOptions): Promise<PaginatedRuns>;

  // Checkpoints
  createCheckpoint(
    sessionId: string,
    checkpoint: { stepCount: number; messageCount: number; streamSequence: number; state: unknown }
  ): Promise<string>; // never moves the pointer
  getLatestCheckpoint(sessionId: string): Promise<Checkpoint | null>; // the pointer's checkpoint
  getCheckpoint(checkpointId: string): Promise<Checkpoint | null>;
  listCheckpoints(
    sessionId: string,
    options?: ListCheckpointsOptions
  ): Promise<PaginatedCheckpoints>;

  // Sub-agent refs
  addSubSessionRefs(sessionId: string, refs: SubSessionRef[]): Promise<AddSubSessionRefResult[]>; // validate with assertValidSubSessionRefs; insert-if-absent; per ref { inserted: true, generation } | { inserted: false, ref } (CP-161's claim; generation 0 except the CF DO's floor)
  updateClientToolCallOwnership(
    sessionId: string,
    change: ClientToolCallOwnershipChange,
    opts?: ClientToolCallOwnershipOptions // { fenceRunId }: applies only while that run is the current, live run (else RunSupersededError, nothing written)
  ): Promise<ClientToolCallOwnershipResult>; // CP-128: only the ownership map + completed markers, atomically, no version bump (core applyClientToolCallOwnershipChange; call ids that collide with Object.prototype names are handled as ordinary ids; a size-bounded store refuses an oversized result StateSizeExceededError; every full-state write keeps both fields, so this is their only writer apart from a run-start commit's markCompleted; OWN-*)
  updateSubSessionRef(
    sessionId: string,
    update: UpdateSubSessionRef
  ): Promise<UpdateSubSessionRefOutcome>; // the answer is required (CP-161 m-4). update.expect: a CAS on (statuses, generation), refused with SubSessionRefConflictError; update.expectStatus: CP-195's CAS on the status (op 'stamp'); update.atGeneration: a stale outcome write writes nothing and answers { written: false, reason: 'stale_generation', actualGeneration }, one over the parent's `terminated` at that generation { written: false, reason: 'terminated' }; update.parentToolCallId: written in the same step (the reset's stamp)
  getSubSessionRefs(sessionId: string): Promise<SubSessionRef[]>; // returns `location`, `heldPrior` and `outcomeGeneration`
  holdPriorResult(sessionId: string, input: HoldPriorResultInput): Promise<'held' | 'stale'>; // the held prior's claim CAS (deliver-then-continue) on `expectGeneration`, the `outcomeGeneration` of the read the result came from
  releasePriorResult(
    sessionId: string,
    input: ReleasePriorResultInput
  ): Promise<'released' | 'stale'>; // its delivered mark, a CAS on the holder's call; marks delivered only while the ref was not reopened since the hold

  // Staging (atomic step commits)
  stageChanges(sessionId: string, stepId: string, changes: StagedChanges): Promise<void>;
  getStagedChanges(sessionId: string, stepId: string): Promise<StagedChanges[]>;
  promoteStaging(
    sessionId: string,
    stepId: string,
    options: PromoteStagingOptions // { streamSequence, runId }
  ): Promise<{ checkpointId: string }>; // a `step` commit
  saveStateAndPromoteStaging(
    sessionId: string,
    state: SessionState,
    appendMessages: Message[],
    checkpointMeta: { stepId: string; stepCount: number; streamSequence: number; runId: string },
    options?: SaveAndPromoteOptions // { expectedVersion?, refuseIfStopped? }
  ): Promise<{ checkpointId: string; newVersion: number }>; // a `step` commit
  discardStaging(sessionId: string, stepId: string): Promise<void>;
  hasStagedChanges(sessionId: string, stepId: string): Promise<boolean>;
  cleanupOrphanedStaging(sessionId: string): Promise<void>;

  // Stop records (run-scoped)
  requestStop(sessionId: string, req: RequestStopInput): Promise<RequestStopResult>;
  readStop(sessionId: string, runId: string): Promise<StopRequest | null>;

  listSessions(options?: ListSessionsOptions): Promise<PaginatedSessions>;
  patchMetadata?(sessionId: string, patch: Record<string, unknown>): Promise<void>;
}

interface SaveStateOptions {
  fenceRunId?: string; // the write belongs to this LIVE current run
  fenceIdle?: { runId: string | null }; // the current run is still this one (null = none) and not live
  refuseIfStopped?: boolean; // with fenceRunId: a stop record for that run throws CommitStoppedError, nothing written
}

interface SaveAndPromoteOptions {
  expectedVersion?: number; // a mismatch throws StaleStateError, nothing written
  // A stop record for the run throws CommitStoppedError, nothing written; `{ allowKind }`
  // refuses only a stop of another kind (a step ended by its own stop commits under it, RM-69).
  refuseIfStopped?: RefuseIfStopped; // boolean | { allowKind: StopKind }
}

interface ListCheckpointsOptions {
  offset?: number;
  limit?: number;
}

What changed, method by method:

  • appendMessages(sessionId, messages, commit) — a commit. Without commit.startRun it is an append commit, fenced on commit.runId (the run must be the session's current live run, else RunSupersededError and nothing is written). With commit.startRun it is a run-start commit (input; messages may be empty), see Run start. All rows of one call land in one commit.
  • truncateMessages(sessionId, keepCount, commit) — a fenced truncate commit: the delete and a checkpoint with keepCount land atomically, and the session's latestTruncateCheckpointId is set to it. Returns the number of rows deleted.
  • saveState(sessionId, state, opts?) — never a commit: it writes no checkpoint and never moves the pointer, and keeps the stored checkpointId, checkpointedAt and latestTruncateCheckpointId whatever state carries. Executors always pass a fence: { fenceRunId } (the write belongs to that live run) or { fenceIdle: { runId } } (a write made while no run owns the session, for example an entry recording its own pre-commit failure). A failed fence throws RunSupersededError in the same atomic step as the version check. Unfenced calls are for admin tools and tests. { fenceRunId, refuseIfStopped: true } (CP-173) also refuses the save when that run has a stop record. The check runs after the fence, in the same atomic step, and throws CommitStoppedError (state_commit_stopped, carrying the record); nothing is written. The client-tool deadline expiry and the submit save use it. refuseIfStopped without fenceRunId is a typed validation_error (core saveStateStopGuardRunId).
  • commitState(sessionId, state, commit, opts?) (new) — a state commit: fenced on commit.runId, a version-pinned state write (opts.expectedVersion, else state.version; a mismatch throws StaleStateError), and a checkpoint carrying that state. Runtimes use it for the terminal commit (K5) on every terminal exit — completion, failure and stop — and for a step's forced-completion phase state.
  • promoteStaging / saveStateAndPromoteStaging — step commits, fenced on the run they carry (options.runId / checkpointMeta.runId); options.streamSequence is now required. refuseIfStopped checks the run's stop record in the same atomic step, after the fence (CommitStoppedError, nothing written, staging kept).
  • requestStop(sessionId, { kind, reason, target, deadlineMs }) / readStop(sessionId, runId) (new, WA MR2) — the run-scoped stop record that replaces the interrupt flag; see Stop as one signal.
  • updateRunStatus — terminal statuses (completed, failed, interrupted, superseded) are sticky: changing the status of a run that is not live throws RunSupersededError and writes nothing. Writing a terminal run with the status it already has is a no-op that writes nothing (no status, metadata, lease, completedAt or version change; decideRunStatusTransition returns 'noop'). Any status other than running clears the run's leaseUntil. A live status (running, suspended_*) never sets completedAt, even when the caller passes one; a terminal status sets it (the caller's value, else now). endSequence is only what the caller passes: a store never derives it.
  • renewRunLease(sessionId, runId, ttlMs) (new) — a compare-and-set on the store's own clock: only while runId is the current run and running, sets leaseUntil = storeNow + ttlMs and returns it; otherwise RunSupersededError. See Run lease.
  • setRunOwnerUnknownSince(sessionId, runId, since) (new) — CF Workflows' "owner unknown" timestamp: set-if-null for a number (the first observation wins), clear for null. Returns the stored value.
  • listRunCheckpoints(sessionId, runId, { offset?, limit? }) (new, required) — one run's checkpoints (Checkpoint.runId === runId), full, ordered by messageCount ascending, from an INDEXED read by run (never a scan of the session's checkpoints); an empty page for a run with none; legacy runId: null rows are never returned. Returns PaginatedRunCheckpoints (checkpoints, offset, limit, hasMore). planRetry uses it; Postgres V15, D1 V18 and DO-SQLite 12 add the index. Contract case commit.run-checkpoints.
  • abandonRunStart(sessionId, runId) (new) — atomically with every run-start commit of the session: 'committed' when runId already exists (nothing written), else a tombstone and 'abandoned'. A later run-start commit with that runId throws RunStartRejectedError('entry_abandoned'). Idempotent; the tombstone is invisible to every run read. The durable runtimes' run-start fail-safes use it.
  • getMessages — a persisted row that is not a valid Message throws CorruptMessageRowError (stores used to skip it). Readers surface it as state_history_incomplete with reason: 'corrupt_row'.
  • getRun(runId) — must be read-your-writes once a run-start commit of runId returned, even where getCurrentRun may lag (the stream claim and failCommittedRun decide on the run's own record).
  • getRunByRequestKey(sessionId, requestKey) (new, required, D-2) — the run of sessionId whose run-start commit recorded requestKey (StartRun.requestKey), or null. An indexed read; a key never resolves to a run of another session, and deleting the session frees its keys. At most one run per session holds a key: the run-start commit refuses a second with RunStartRejectedError('request_key_taken'), reading the holder inside its own atomic step (the request-key rule in Run start). The keyed entries plan from it (Request keys). The request-key migration adds request_key / request_digest columns to the runs table and a unique partial index on (session_id, request_key): Postgres V18, D1 V22, DO-SQLite 16. Memory and Redis keep a per-session index (Redis: a hash session:request-keys:{sessionId}, checked in the run-start Lua). Contract cases request-key.*.
  • createCheckpoint — not a commit: it writes a row with kind, runId and keepCount all null and never moves the pointer.
  • getLatestCheckpoint — pointer-based: the checkpoint of the most recent commit, never the highest-stepCount row.
  • cloneSession — a clone commit (streamSequence: -1, runId: null), written atomically; options.fromCheckpointId is resolved with resolveCheckpoint first. The clone's execution state never comes from the source's live row (RM-72): compute it with cloneStateFromCheckpoint(checkpoint) for a checkpoint clone (customState, stepCount, forcedCompletion and status from the checkpoint; output, error and errorDetail too at a completed / failed checkpoint, else cleared; interruptContext and failureReason cleared) or cloneStateWithoutCheckpoint(sourceLive, boundaryForcedCompletion) for a message-index or whole-log clone (the live customState and stepCount, status: 'active', forcedCompletion = the boundary phase the store passes: a whole-log clone the source's latest committed checkpoint's, never the live row; a message-index clone undefined, pending FU-CLONE-INDEX-BOUNDARY-FORCED-PHASE; the rest cleared). Both return a ClonedExecutionState (every key present; a cleared field is undefined); the clone checkpoint carries the same state. A checkpoint with no recorded status clones active; a recorded value that is no status throws InvalidStoredStateError naming the checkpoint, before the store writes anything. Contract cases clone.checkpoint-state, clone.checkpoint-status, clone.checkpoint-corrupt-status and clone.live-state.
  • createRun — no runtime calls it any more: runs are created by run-start commits.

LLM history loading ​

typescript
import {
  loadLLMHistory,
  loadHistoryPrefix,
  loadHistoryRange,
  historyIncompleteError,
  getAllMessages,
  readMessagesToEnd,
  READ_TO_END_ATTEMPTS,
  type MessagePage,
  type HistoryIncompleteReason,
  type SnapshotPassOutcome,
  type SnapshotRead,
} from '@helix-agents/core';

function loadLLMHistory(
  store: Pick<SessionStateStore, 'getMessages' | 'getMessageCount'>,
  sessionId: string
): Promise<{ messages: Message[]; count: number }>;

function loadHistoryPrefix(
  store: Pick<SessionStateStore, 'getMessages'>,
  sessionId: string,
  count: number,
  opts?: { includeThinking?: boolean }
): Promise<Message[]>;

function loadHistoryRange(
  store: Pick<SessionStateStore, 'getMessages'>,
  sessionId: string,
  from: number, // store index, inclusive
  to: number, // exclusive
  opts?: { includeThinking?: boolean }
): Promise<Message[]>;

type HistoryIncompleteReason = 'short_read' | 'corrupt_row' | 'store_error' | 'snapshot_unstable';

// `snapshot_unstable` only: why each fenced read pass failed, on the error as `snapshotPasses`.
type SnapshotRead =
  | 'loadState'
  | 'getStreamInfo'
  | 'getCurrentRun'
  | 'getLatestCheckpoint'
  | 'getMessageCount'
  | 'getMessages';
type SnapshotPassOutcome =
  | { kind: 'deadline'; deadlineMs: number; inFlight: SnapshotRead | null } // always the last record
  | {
      kind: 'fence_changed';
      field: 'status' | 'runId' | 'truncateId' | 'head';
      before: string | number | null;
      after: string | number | null;
    };

function historyIncompleteError(
  sessionId: string,
  expected: number, // -1 when the count could not be read
  read: number,
  cause?: unknown,
  reason?: HistoryIncompleteReason // exposed as `reason` on the thrown error
): HelixError; // code: 'state_history_incomplete'

getMessages with no limit reads every message from offset to the end (ascending, hasMore: false; the result's limit is the number returned; RM-01, before which every store silently returned the oldest 50). A store whose single read is bounded pages internally through readMessagesToEnd(sessionId, offset, pageSize, readPage): the pages must agree on total and reach it, else the read restarts (up to READ_TO_END_ATTEMPTS, 3), else it throws the non-retryable state_history_incomplete (reason: 'snapshot_unstable'), never a short or duplicated log. D1 pages at 500 rows; the CF Durable Object's DOStateStoreClient pages its GET /messages at 1000 (the route's own wire default stays 50: the client always sends a limit).

loadLLMHistory is how every runtime loads history for the model. It pages the whole log with getAllMessages (no cap, no window) and compares the result with getMessageCount, which must be a live row count (never a denormalized column). Any count mismatch is re-read, up to 3 reads in all, to absorb a concurrent append (short read) or truncate (long read, for example a concurrent rewind); if the counts still differ after that it throws a non-retryable state_history_incomplete; if either store call throws (not retried by the loader) it throws state_history_incomplete with retryable: true and the store error as cause, so the runtime's own retry policy applies (a store throw may be a transient connection blip). A store that throws a CorruptMessageRowError for a row that fails validation produces a non-retryable state_history_incomplete with reason: 'corrupt_row' (re-reading fails the same way). The message gives the counts read and expected, then the likely cause: the store error when a store call threw, otherwise a persisted row that failed validation or was skipped (it names the content-less user rows DBOS retry() / resume() wrote before v0.50, RM-28, which have no repair path). Match on code (and reason), not the message text. Runtimes turn that into a failed run with errorDetail.code === 'state_history_incomplete' and make no model call. Use it in a custom loop anywhere you build model input from the store.

loadHistoryPrefix reads exactly the first count rows (a checkpoint's messageCount) in pages of at most 500 and ignores rows appended after them. A short read throws state_history_incomplete with reason: 'short_read' (non-retryable); a store throw has reason: 'corrupt_row' (for a CorruptMessageRowError) or 'store_error'. The snapshot reader uses it.

loadHistoryRange reads exactly the rows [from, to) the same way (pages of at most 500, the same typed failures, counted over the range); loadHistoryPrefix is loadHistoryRange(store, sessionId, 0, count). CF Workflows' realtime memory extraction uses it to read only the messages since the session's watermark.

The realtime memory extraction watermark (MA-40). SessionState.memoryExtractedThrough is the absolute message-log count extracted through (absent: 0). It is store-owned:

typescript
import { createMemoryWatermarkCursor, type MemoryWatermarkCursor } from '@helix-agents/core';
import type { AdvanceMemoryWatermarkInput } from '@helix-agents/core';

// A CAS: the stored value (absent = 0) equals `expected` and `next > expected` → 'advanced'; else 'stale',
// nothing written (also for a missing session).
store.advanceMemoryWatermark(sessionId, { expected, next }): Promise<'advanced' | 'stale'>;

// MemoryManagerLike.extractAndStore now takes the absolute index to extract from.
memoryManager.extractAndStore({ messages, context, fromMessageIndex });
  • Every truncate (truncateMessages, a run-start commit's truncateTo) lowers it to the kept count in the same atomic step. cloneSession sets the clone's to min(source, cloned count). saveState and every commit never write it, whatever the state carries.
  • Core's step iterator extracts from it and advances it (expected = the value it extracted from, next = the post-commit message count) only after the memories are stored. A stale answer drops the run's cursor, so the next extraction reads the stored value again.
  • StepIterationDeps.memoryWatermark is the run's cursor (createMemoryWatermarkCursor(), one per run): the value is read once per run. Without it, every extraction reads it.
  • Storage: Postgres V22 and D1 V26 (memory_extracted_through), DO-SQLite 22, a Redis hash field, a store-internal map in memory. Contract: core/src/testing/memory-watermark-operations.ts.

getAllMessages(store, sessionId, options?) pages the log without the count check; its store parameter now only needs getMessages.

Run Tracking Types ​

typescript
type RunStatus =
  | 'running'
  | 'completed'
  | 'failed'
  | 'interrupted'
  | 'superseded' // another run took over; terminal and sticky
  | 'suspended_client_tool'
  | 'suspended_awaiting_children'
  | 'suspended_step_partial';

interface RunMetadata {
  runId: string;
  sessionId: string;
  turn: number;
  status: RunStatus;
  startSequence: number; // the run-start commit's streamSequence
  startMessageCount?: number; // the message count after the run's input rows
  startUIMessageCount?: number; // first-row UI index of the message holding the run's response
  endSequence?: number;
  stepCount: number;
  entryStepCount?: number; // the step count the run entered with (retry restores it; a max_steps retry restarts at 0)
  inputRowCount?: number; // rows the run-start commit appended
  startedAt: number;
  completedAt?: number;
  error?: string;
  tracingContext?: { traceId: string; resumedFromSpanId?: string };
  completionReason?: CompletionReason; // JS / DO / CFW: only 'stop_when' or 'max_steps' (see below)
  leaseUntil?: number | null; // JS run lease expiry, store clock
  startDigest?: string; // idempotency digest of the run-start commit
  ownerToken?: string; // the one execution that started the run
  ownerUnknownSince?: number | null; // CF Workflows: first `unknown` observation
  parent?: { sessionId: string; toolCallId: string }; // the consult a child run serves
  requestKey?: string; // D-2: the keyed entry's request key; unique per session
  requestDigest?: string; // D-2: computeRequestDigest of that entry's own input
}

interface UpdateRunMetadata {
  endSequence?: number;
  stepCount?: number;
  completedAt?: number;
  error?: string;
  completionReason?: CompletionReason;
}

isLiveRunStatus(status) is true for running and the three suspended_* statuses; every other status is terminal.

completionReason is written only for a run that ends at a stopWhen stop ('stop_when', status completed) or a maxSteps cut-off ('max_steps', status failed); every other exit leaves it unset. Every store round-trips it through getRun, getCurrentRun and listRuns (contract case RS-CR1; D1 migration V19 and DO-SQLite migration 13 add the column). runtime-js, the CF DO and CF Workflows write it; Temporal writes none, and DBOS writes its own reasons on a completed run, 'max_steps' included (DI-27). planRetry reads 'max_steps' from the FAILED run's record to continue it with the full budget (step count 0, no message needed), so this applies only where a cut-off fails (not yet Temporal / DBOS).

Run Tracking Lifecycle:

  1. On every entry (execute(), resume(), retry(), a sub-agent spawn or continuation): the run is created by the entry's run-start commit, which also supersedes any other live run of the session.
  2. On suspension: updateRunStatus() records the suspended_* status.
  3. On completion / failure / interrupt: after the terminal state commit (K5), updateRunStatus() records the final status, then the stream is ended or failed.
  4. On supersede: the superseding run-start commit marks the old run superseded.

Query Methods:

  • getCurrentRun(sessionId): the session's current run (the latest run-start commit's run)
  • getRun(runId): one run, read-your-writes after its run-start commit
  • getRunByRequestKey(sessionId, requestKey): the run that holds a request key (D-2), or null
  • listRuns(sessionId): all runs for a session (for debugging/auditing)
typescript
interface PaginatedCheckpoints {
  checkpoints: CheckpointMeta[];
  total: number;
  offset: number;
  limit: number;
  hasMore: boolean;
}

interface GetMessagesOptions {
  offset?: number; // Starting position (default: 0)
  limit?: number; // Max messages. Omitted: every message from `offset` to the end (RM-01)
  includeThinking?: boolean; // Include thinking content (default: true)
}

interface PaginatedMessages {
  messages: Message[];
  total: number;
  offset: number;
  limit: number;
  hasMore: boolean;
}

listCheckpoints returns total, the page and hasMore from one consistent read, and orders by stepCount with a tiebreaker, so paging a multi-turn session (where stepCount restarts every turn) returns every checkpoint once. The shared contract suite checks both.

StreamManager ​

Interface for real-time streaming (abridged):

typescript
interface StreamManager {
  // Create a writer for emitting chunks (implicitly creates stream)
  createWriter(streamId: string, runId: string, agentType: string): Promise<StreamWriter>;

  // Create a reader to consume chunks
  createReader(streamId: string): Promise<StreamReader | null>;

  // Create a resumable reader (position-based)
  createResumableReader(
    streamId: string,
    options?: ResumableReaderOptions
  ): Promise<ResumableStreamReader | null>; // never null with `waitForLive`

  // Terminal transitions; `fence` = { expectRunId } (CP-85)
  // `terminal.status` is required (CP-87): the ended run's own status, recorded as
  // `metadata.endStatus`. A failed run's stream is failed (`failStream`), never ended.
  endStream(
    streamId: string,
    finalOutput: unknown,
    terminal: StreamEndTerminal, // { status: 'completed' | 'interrupted'; state?; usage? }
    fence?: StreamRunFence
  ): Promise<void>;
  failStream(
    streamId: string,
    error: string,
    errorDetail?: ErrorDetail,
    fence?: StreamRunFence
  ): Promise<void>;
  pauseStream(streamId: string, fence?: StreamRunFence): Promise<void>;

  // Owner compare-and-set (CP-85); optional so older managers type-check
  claimStream?(streamId: string, runId: string, expect?: StreamClaimExpect): Promise<boolean>;

  resumeStream(streamId: string): Promise<void>;
  reactivateStream(streamId: string): Promise<void>;
  initStream(
    streamId: string,
    metadata?: { agentType?: string; agentId?: string }
  ): Promise<boolean>;

  // Stream cleanup (for crash recovery). G4: removing chunks past an
  // ATTACHED reader's cursor surfaces a `StreamTruncatedError` on that
  // reader. Push transports (the Cloudflare DOs) broadcast a `rewound` SSE
  // frame (the StreamServer DO also a `truncated` WebSocket event);
  // marker/poll transports detect it via a stored marker on the next `next()`. Best-effort on push — a reader
  // attaching after cleanup (fresh replay) won't see it.
  cleanupToStep(streamId: string, stepCount: number, minSequence?: number): Promise<number>;
  resetStream(streamId: string): Promise<void>; // also clears the owner

  // Stream info (for snapshot status and commit sequences); null when the stream does not exist.
  // `status` is the STREAM's own status, never a run's liveness (RM-73).
  getStreamInfo(streamId: string): Promise<StreamInfo | null>;
  // ...plus getAllChunks / getChunksFromStep / getChunksFromSequence
}

interface StreamRunFence {
  expectRunId: string;
}

interface StreamClaimExpect {
  owner: string | null; // the owner the caller read (null = none)
}

interface StreamInfo {
  status: 'active' | 'paused' | 'ended' | 'failed';
  latestSequence: number;
  totalChunks: number;
  metadata: StreamMetadata; // includes `currentRunId`, the owner run; a failed stream's `error` and `errorDetail`
  oldestRetainedSequence?: number;
}

interface ResumableReaderOptions {
  fromSequence?: number;
  skipCount?: number;
  waitForLive?: boolean;
}

interface StreamWriter {
  write(chunk: StreamChunk, options?: StreamWriteOptions): Promise<void>;
  close(): Promise<void>; // Closes this writer, NOT the stream
}

interface StreamReader extends AsyncIterable<StreamChunk> {
  [Symbol.asyncIterator](): AsyncIterator<StreamChunk>;
  close(): Promise<void>;
}

interface ResumableStreamReader extends StreamReader {
  readonly currentSequence: number;
  readonly totalChunks: number;
  readonly latestSequence: number;
  readonly catchUpHead: number; // end of the reader's initial backlog
  readonly caughtUp: Promise<void>; // resolves once that backlog was yielded
}

Stream owner and fences (CP-85). A stream is shared by every run of its session. claimStream(streamId, runId, { owner }) makes runId the stream's owner run (StreamMetadata.currentRunId) only while the current owner is owner (null = none), and resolves true when runId owns the stream afterwards, false otherwise (nothing written; a missing stream is never created). Without expect it is unconditional (tests and admin tools only). Runtimes call it only through claimStreamForCommittedRun, right after the run won its run-start commit; a sub-agent writing into its parent's stream never claims. A manager whose claimStream resolves undefined is treated as having claimed (the pre-CAS behaviour); a manager without claimStream has no owner, and fenced calls apply unconditionally. With a fence, endStream / failStream / pauseStream apply only while fence.expectRunId is the owner; otherwise they are a no-op, never a throw. The check and the transition are one atomic step. The built-in managers also drop a chunk of the stream's own session stamped with another run's runId once a newer run owns the stream (chunkFenceRunId).

waitForLive. Until the reader is live, a terminal status (ended / failed / paused) or a stream that does not exist yet means "not live yet": the call returns a reader (never null) that parks on the manager's normal wake-ups instead of ending. The reader is live once the stream is active at attach, it yields a chunk past fromSequence, the stream transitions to active after attach, or a missing stream is created. From then on normal terminal semantics apply, so a run that goes live and ends before its first chunk still ends the reader promptly; a terminal status left from the previous turn never does. close() releases a parked next(). A manager that cannot honour it ignores it. A failed stream ends a reader with one error chunk: the in-band one when the reader already yielded it (same message and code), else one synthesized from failStream's error. YieldedFailures (exported) is the helper the built-in managers use for that dedupe.

catchUpHead / caughtUp. catchUpHead is the highest sequence the reader's initial history read covers (captured atomically with it; fromSequence when nothing past it was retained). caughtUp resolves once every retained chunk up to it was yielded, or the reader ends, throws or is closed; it never rejects. Use it to tell replayed content from live content instead of a quiet period.

Contract cases for these are WL.*, RF.claim.* and RF.run-fence.* in streamManagerContractTests (@helix-agents/core/testing).

Stream Utilities ​

Stream wire frames (SSE) ​

Every Helix SSE surface that carries a run's stream (the CF Durable Object's /sse, the StreamServer DO's /sse, agent-server's /sse, ResumableStreamHandler) speaks one protocol, wire contract v2, encoded and decoded here. The protocol, its terminal state machine and the retention floor are in Stream wire; the v1 → v2 changes are in the upgrade guide.

typescript
type StreamTerminal =
  | {
      type: 'end';
      status: 'completed' | 'paused' | 'interrupted';
      output?: unknown;
      state?: Record<string, unknown>;
      usage?: UsageRollup;
    }
  | { type: 'fail'; error: string; errorDetail?: ErrorDetail; recoverable: boolean }
  | { type: 'truncated'; floor: number; errorDetail: ErrorDetail }; // errorDetail.code 'TRUNCATED'

type StreamFrame =
  | { type: 'chunk'; sequence: number; chunk: StreamChunk } // SSE id: = sequence
  | StreamTerminal
  | { type: 'rewound'; atStep: number; atSequence?: number; minSequence?: number } // non-terminal cleanup marker
  | { type: 'caught-up'; head: number } // the replay reached head; live frames follow
  | EvictedFrame; // WE C2b: this connection ends (EOF follows); the stream goes on

// WE C2b. EVICTION_REASONS = ['slow_consumer', 'stale', 'max_connections', 'server_error'] as const.
type EvictedFrame = { type: 'evicted'; resumeFrom: number; reason: EvictionReason };

type DecodedStreamFrame =
  | StreamFrame
  | { type: 'unknown'; event: string }
  | { type: 'malformed'; event: string; reason: string };

Each connection ends with exactly one terminal frame (end, fail or truncated), then the body closes.

Writing frames ​

typescript
import { encodeStreamFrame, streamFrameToSSE } from '@helix-agents/core';

writer.write(encoder.encode(encodeStreamFrame({ type: 'end', status: 'completed', output })));
const { event, data, id } = streamFrameToSSE(frame); // the unserialized SSE fields
  • encodeStreamFrame(frame) serializes one frame: the SSE event is the frame's type, the data its other fields as JSON (undefined fields omitted), and a chunk frame's SSE id: its sequence.
  • streamFrameToSSE(frame) returns the same mapping as an SSEFrameInput (event, data, id?) for a writer that serializes frames itself (e.g. agent-server's heartbeat-aware SSE writer, through serializeSSEFrame).

Reading frames ​

typescript
import {
  createSSEFrameDecoder,
  isSSEComment,
  decodeStreamFrame,
  isStreamTerminal,
  classifyStreamTerminal,
  streamFailureChunk,
  type DecodedStreamFrame,
} from '@helix-agents/core';

const frames = createSSEFrameDecoder();
const text = new TextDecoder();
for (const item of frames.push(text.decode(bytes, { stream: true }))) {
  if (isSSEComment(item)) continue; // e.g. the DO route's ': waiting' park marker, heartbeats
  const frame: DecodedStreamFrame = decodeStreamFrame(item);
  // 'chunk' | 'end' | 'fail' | 'truncated' | 'rewound' | 'caught-up' | 'evicted' | 'unknown' | 'malformed'
  if (isStreamTerminal(frame)) return classifyStreamTerminal(frame);
}
// The body ended with no terminal frame:
classifyStreamTerminal('eof'); // { action: 'drop' } → throw StreamDropError (unless your caller aborted)
  • createSSEFrameDecoder() is a push decoder: feed it text in any pieces (a frame split across reads, or several frames in one read) and it returns the complete frames and comment lines, in order. It reads LF and CRLF lines, and flush() delivers a last frame that has no closing blank line. It holds no body reader, so the caller can cancel a pending read. parseSSEFrames is built on it with the same grammar (and drops comments).
  • decodeStreamFrame(frame) validates the envelope of each v2 frame. The chunk payload is passed through unvalidated. An event it does not know is unknown (ignore it), whatever its data; this includes the v1 name error (no back-compat). A known event whose JSON or envelope does not validate is malformed (never a skip, never an end: each reader turns it into a retryable typed error). An invalid errorDetail on fail is omitted; on truncated it is replaced with { message: 'Stream truncated', code: 'TRUNCATED' }. TRUNCATED_FRAME_CODE ('TRUNCATED') is the errorDetail.code of a truncated frame; a fail frame is always a failure.
  • isStreamTerminal(frame) is true for end, fail and truncated.
  • classifyStreamTerminal(terminal | 'eof') returns the StreamTerminalAction every reader maps its stream's end through: { action: 'done', end }, { action: 'fail', fail }, { action: 'truncated', floor, errorDetail } (throw StreamTruncatedError; refetch the snapshot), or { action: 'drop' } for 'eof' (throw the retryable StreamDropError, never a clean end). A reader whose own caller aborted the read (CP-102) ends quietly before classifying.
  • createSSEFrameDecoder().push() returns SSEItems: an SSEFrame or an SSEComment (isSSEComment tells them apart). The CF DO's former : connected comment is now the caught-up frame.
  • streamFailureChunk(error, errorDetail) is the one error chunk a reader synthesizes for a stream that FAILED: the message and detail verbatim, code from the detail (never invented), recoverable: false. The in-memory, Redis and in-DO stream managers and the DO /sse readers all use it.

SSE fan-out: SSEConnectionManager ​

typescript
import {
  SSEConnectionManager,
  createSSEStream,
  SSE_READABLE_HIGH_WATER_MARK, // 256 KiB: the default unread-byte budget
} from '@helix-agents/core';

// Options (defaults): maxUnreadBytes (SSE_READABLE_HIGH_WATER_MARK), maxPendingWrites (10_000),
// inactivityTimeoutMs (60_000), logger.
const manager = new SSEConnectionManager({ logger });
const { readable, writable } = createSSEStream();
manager.addConnection(connectionId, writable.getWriter(), snapshotHead, request, {
  readThrough: fromSequence, // WE C2b: the client's cursor while the route replays the backlog itself
});
// For each replayed chunk the route writes itself: writer.write(frame).then(() => manager.markRead(connectionId, seq)).
// return new Response(readable, { headers: { 'Content-Type': 'text/event-stream' } });
manager.broadcast(frameBytes, sequence); // never awaits a write
manager.heartbeat(); // on a timer

Non-blocking fan-out of SSE frames to many connections (the CF agent DO's /sse and store-cloudflare's StreamDurableObject use it). A slow connection never delays the producer or another connection: broadcast, broadcastControl, broadcastTerminal and heartbeat fire their writes and await none.

A connection is measured by what its client has not read (CP-26):

  • createSSEStream() is the connection's stream (a ReadableWritablePair). It buffers nothing, so a write resolves once the client reads it. Build every SSE connection's stream with it: workerd's TransformStream applies no backpressure once its readable has a high-water mark (every write resolves at once), so a buffering stream hides a client that stopped reading; and an identity TransformStream cannot be aborted while a write waits for a read (its abort waits for that write, on Node and workerd), so an evicted connection would keep everything it held. An abort of this pair settles every pending write and errors the body at once; a client that goes away (the body cancelled) errors the writable.
  • The manager counts each connection's pending writes and their bytes. A frame that would put a connection past maxUnreadBytes (default SSE_READABLE_HIGH_WATER_MARK, 256 KiB) evicts it instead (sse.evict_slow_consumer); a frame always reaches a connection with nothing unread, whatever its size. maxPendingWrites (default 10,000) is a secondary cap that never fires first for frames of 27 bytes or more.
  • heartbeat() evicts a connection none of whose writes was read within inactivityTimeoutMs (sse.evict_stale_connection); it no longer reads writer.desiredSize. It writes a heartbeat only to an idle connection (nothing unread): one queued behind an unread write would only grow a stalled client's queue.
  • hold(id, writer, fromSequence, request, frames?) tracks a connection that must not get broadcasts yet (a waitForLive request parked until its stream goes live): it counts toward size, gets the heartbeat and is evicted like any connection, so a client that never reads cannot keep a slot, but no broadcast, control or terminal frame reaches it. promote(id) makes it a normal connection from its fromSequence; unhold(id) takes it out without closing the writer (its owner serves it, then addConnections it). Both answer false for a connection no longer held (gone or evicted).
  • An evicted connection's writer is ABORTED, never closed: a close would wait behind the frames its client has not read, holding them (and every pending write) until it reads or its socket dies, and a client that resumed reading would get a clean end after a gap. The abort discards them, and (WE C2b) carries one last frame, evicted{resumeFrom, reason} (SSEEviction): a createSSEStream body delivers it as the client's next read, then EOF, instead of an error, so the client knows why, reconnects with its last sequence and catches up from the durable log (SSE is a latency path, not the record). resumeFrom is the connection's readThrough: the highest chunk sequence its client read (resolved broadcasts, and the route's replay via markRead(id, sequence)). Reasons: slow_consumer (past the unread budget or the pending-write cap), stale (the heartbeat), and removeConnection(id, cause) with server_error (or any reason); the agent DO's connection cap writes max_connections itself. removeConnection(id) without a cause (a client that left, a normal removal) still closes. See Stream wire › Evictions.

Reader re-open policy (WE C2a, C2b) ​

The shared policy a server-side resumable reader (the Cloudflare DO readers) applies to a drop: a body that ended with no terminal frame, a body read that failed, or an evicted frame. One instance per reader:

typescript
import {
  MAX_STREAM_REOPENS, // 8: counted re-opens per reader
  StreamReopenBudget,
  isReopenableReadError,
  StreamEvictedError,
  StreamRunChangedError,
} from '@helix-agents/core';

const budget = new StreamReopenBudget(streamId, { fromSequence });
budget.onDelivered(sequence, chunk); // every chunk the reader took in (its stored sequence)

// On a drop (`eviction` = the evicted frame's reason, when the writer sent one):
const decision = budget.decide(eviction);
// { reopen: true, attempt, fromSequence } → re-open /sse from fromSequence (exclusive)
// { reopen: false, reason: 'no_progress' | 'cap' } → end the read (StreamDropError / StreamEvictedError)

if (budget.endsRead(chunk)) {
  // after a re-open: a chunk of a newer run of the stream's own session → throw StreamRunChangedError
}
  • decide(eviction?) re-opens from the highest stored sequence delivered so far. The first drop always re-opens; a later one re-opens only if a new sequence arrived since the last re-open (no_progress otherwise; no wall clock). Re-opens after a slow_consumer or stale eviction never count toward MAX_STREAM_REOPENS; every other re-open does (cap). reopenCount is every re-open granted, counted or not.
  • isReopenableReadError(error) is true only for a StreamBodyReadError (transport_error, retryable: a body read that failed mid-read). A malformed frame, a typed refusal or a terminal is never re-opened.
  • StreamEvictedError (transport_stream_evicted, retryable, cause = the eviction reason, also on .reason) and StreamRunChangedError (transport_stream_run_changed, retryable: re-anchor from the snapshot) both extend StreamDropError, so a handler that only knows drops keeps its recovery.
  • ReaderBufferBudget({ maxChunks, maxBytes? }) bounds a reader's unread buffer by count and bytes (maxBytes defaults to READER_BUFFER_HIGH_WATER_BYTES, 8 MiB): add(bytes) / take(bytes) per chunk, full() stops reading, drained() (down to half of both bounds) reads on, reset() empties it. Both bounds must be positive. READER_BACKLOG_MAX_BYTES (32 MiB) caps the backlog a reader reads before it returns; past it the reader ends StreamTruncatedError (re-anchor from the snapshot).

See Stream wire › Evictions and the stream wire v2 upgrade guide.

Client-disconnect helpers ​

typescript
import { abortableChunks, abortableOpened, openHandleStream } from '@helix-agents/core';

// A runtime's handle.stream(options): opens a resumable reader at
// options.fromSequence and ties it to options.signal.
const chunks = await openHandleStream(streamManager, streamId, { fromSequence, signal });

// The underlying helpers: wrap a { chunks, close } source in an abort signal.
// Lazy: the source is opened on the first next().
const lazy = abortableChunks(signal, async () => ({
  chunks: source,
  close: () => reader.close(),
}));
// Already open (stream() created the reader or received the fetch body): the
// abort closes it even before the first next(), or if it is never iterated.
const opened = abortableOpened(signal, { chunks: reader, close: () => reader.close() });

Semantics, shared by every runtime: a signal already aborted when iteration starts yields nothing (abortableChunks never calls open; abortableOpened closes its upstream at once); an abort ends the iteration (also while a read is pending, or while the consumer is between reads) and closes the upstream; return(), the source ending, a rejecting read and a throwing open close it too; return() while a read is pending ends that read at once; close runs exactly once, and a close that throws is rethrown once (by return(), or by the next call after an abort). Each iterable is single-use. Use abortableOpened whenever stream() acquires the upstream before returning (openHandleStream does: it creates the reader to answer null for a missing stream). @helix-agents/core/testing exports streamSignalContractTests to check a runtime's handle.stream({ signal }) against its real stream manager.

requestSignalSupport() ​

typescript
import { requestSignalSupport, REQUEST_SIGNAL_DISABLED_WARNING } from '@helix-agents/core';

requestSignalSupport(); // 'enabled' | 'disabled' | 'unknown'

Whether the runtime reports a client disconnect through the incoming request.signal, read from the Cloudflare global's compatibilityFlags.enable_request_signal (Cloudflare Workers; Zod-checked). 'unknown' when that global is absent or not the expected shape (Node, a runtime that does not expose it); it is never read as 'disabled'. scope (default globalThis) may be passed to test. On Workers the flag is required for disconnects to be seen at all, and handleChatStream logs REQUEST_SIGNAL_DISABLED_WARNING once per isolate when this reads 'disabled' (see Client disconnects).

Stream Filters ​

typescript
import {
  filterByAgentId,
  filterByAgentType,
  filterByType,
  excludeTypes,
  filterWith,
  combineStreams,
  take,
  skip,
  collectText,
  collectAll,
} from '@helix-agents/core';

// Filter by agent
const filtered = filterByAgentId(stream, 'agent-123');

// Filter by chunk type
const textOnly = filterByType(stream, ['text_delta']);

// Exclude types
const noThinking = excludeTypes(stream, ['thinking']);

// Collect all text
const fullText = await collectText(stream);

State Streaming ​

typescript
import { CustomStateStreamer, createStateStreamer } from '@helix-agents/core';

const streamer = createStateStreamer({
  writer, // StreamWriter
  stateSchema: ResearchStateSchema, // z.ZodType<TState>
  agentId: 'deep-research-progress', // MUST be distinct — see below
  agentType: 'deep_research',
});

// Emit a full snapshot, then incremental patches
await streamer.emitSnapshot(initialState);
await streamer.updateStateDraft((draft) => {
  draft.topicsResearched.push('topic1');
});

Use a distinct agentId

A CustomStateStreamer emits state_patch chunks into the same stream as the agent's own customState patches, and consumers reconstruct state by filtering patches on chunk.agentId. The streamer's document is not customState — so it MUST use an agentId that is distinct from the session id (and from any other document's id). Mixing two documents under one agentId corrupts reconstruction for every consumer that follows the documented contract.

State Projection ​

typescript
import { createStateProjection, StreamProjector } from '@helix-agents/core';

// Project subset of state
const projection = createStateProjection<FullState, { count: number }>((state) => ({
  count: state.count,
}));

Not the same as an exposure-mount projection

AgentServerConfig.projection and RemoteAgentWorkerHandlerConfig.projection (the cross-service exposure mounts) are plain pure mappers — (state: Record<string, unknown>) => Record<string, unknown> — applied producer-side to point-in-time full states (getSnapshot().state, end.state). They are unrelated to the stateful StateProjection object documented here.

Resumable Stream Handler ​

typescript
import { createResumableStreamHandler, extractResumePosition } from '@helix-agents/core';

const handler = createResumableStreamHandler({
  streamManager,
});

// Handle request with resume support
const response = await handler.handleRequest({
  method: 'GET',
  streamId: 'run-123',
  resumeAt: extractResumePosition(req.headers, req.query), // Last-Event-ID, X-Resume-At or ?resume=
});

The body speaks the stream wire frames. Chunk frames are the protocol's chunk { sequence, chunk } unless you set eventName / serializeChunk (then your event name and data, with the sequence as the SSE id:). The terminal frame is always the protocol's, taken from the stream's own getStreamInfo when the reader ends: ended → end { status } (completed, or interrupted for a stopped run), paused → end { status: 'paused' }, failed → fail with its error and errorDetail. A reader that ends while the stream is still active (or gone) is a drop: a recoverable fail with errorDetail.code transport_stream_drop. A reader error is a recoverable fail (re-attach from the cursor).

Orchestration ​

Input Types ​

Types for agent execution input:

typescript
import type { UserInputMessage, FileInput, AgentInput, SendInput } from '@helix-agents/core';

UserInputMessage — A structured user message with optional metadata and file attachments:

typescript
interface UserInputMessage {
  role: 'user';
  content: string;
  metadata?: Record<string, unknown>;
  files?: FileInput[];
}

FileInput — A file attachment (base64-encoded data with media type):

typescript
interface FileInput {
  data: string; // base64 or data URI
  mediaType: string; // e.g., 'image/png', 'application/pdf'
  filename?: string;
}

AgentInput — The input type accepted by execute():

typescript
// String shorthand
type AgentInput = string;

// Or structured input with optional state override
type AgentInput = {
  message: string | UserInputMessage[];
  state?: Partial<TState>;
  messages?: Message[]; // External conversation history
};

SendInput — The input type accepted by handle.send() and resume with_message:

typescript
type SendInput = string | UserInputMessage[];

initializeAgentState ​

Create initial state from input:

typescript
import { initializeAgentState } from '@helix-agents/core';

const state = initializeAgentState({
  agent,
  input: 'Hello', // or { message: 'Hello', state: { ... } }
  runId: 'run-123',
  streamId: 'run-123',
  parentSessionId: undefined,
});

// Multi-message input
const state = initializeAgentState({
  agent,
  input: {
    message: [
      { role: 'user', content: 'Context', metadata: { hidden: true } },
      { role: 'user', content: 'Question' },
    ],
  },
  runId: 'run-123',
  streamId: 'run-123',
  parentSessionId: undefined,
});

buildMessagesForLLM ​

Prepare messages with system prompt:

typescript
import { buildMessagesForLLM } from '@helix-agents/core';

const messages = buildMessagesForLLM(state.messages, agent.systemPrompt, state.customState);

buildEffectiveTools ​

Get tools including __finish__:

typescript
import { buildEffectiveTools, memoryToolsFor } from '@helix-agents/core';

const tools = buildEffectiveTools(agent);

// A runtime with memory passes the run's memory tools (appended last) at
// EVERY site that builds the set, so a tool the model is offered is one the
// run can resolve:
const withMemory = buildEffectiveTools(agent, {
  memoryTools: memoryToolsFor(memoryManager, state), // state: the run's identity
});

memoryToolsFor(manager, run) builds search_memory / save_memory for the run's identity (memoryEntityContextOf). A buildTools that throws becomes a retryable framework_internal_error (memoryManager.buildTools threw: …): a run never continues with a smaller tool set.

planStepProcessing ​

Analyze LLM result and plan actions:

typescript
import { planStepProcessing } from '@helix-agents/core';

const plan = planStepProcessing(stepResult, {
  outputSchema: agent.outputSchema,
  // On a forced call only (see Forced Completion below):
  forcedCompletion: { expectedToolName: '__finish__' }, // forced_tool (strategy omitted)
  // or: { expectedToolName, strategy: 'native_output', callIdSalt: `${runId}:${stepCount}:${attempt}` }
});

// plan.assistantMessagePlan - For creating assistant message
// plan.pendingToolCalls - Tools to execute
// plan.pendingSubAgentCalls - Sub-agents to invoke
// plan.statusUpdate - Status change to apply
// plan.isTerminal - Whether execution should stop
// plan.output - Parsed output (if __finish__ called)
// plan.forcedCompletionResult - on a forced call: { kind: 'protocol_violation', … } | { kind: 'invalid_output', error }

With forcedCompletion, any response other than the single expected completion call is a protocol violation: every call is stripped (nothing dispatched, no assistant message) and forcedCompletionResult is set. With strategy: 'native_output', a terminal (end_turn / stop_sequence) text result is parsed as JSON and normalised into a call to expectedToolName with id nativeCompletionCallId(text, callIdSalt) (toolu_helix_<hash>) and metadata.helixNativeCompletion: true, the text part in parts replaced by the call reference; text that does not parse (or, for a finishWith target, is not a JSON object) keeps its text message and sets { kind: 'invalid_output' }. The salt must be replay-stable and unique per run, step and forced attempt (runtimes use ${runId}:${stepCount}:${attempt}): a hash of the text alone would repeat a tool_use id whenever the same answer recurs in a session.

resolveStopDecision ​

The single stop decision every runtime makes after a step (C1). It returns whether to stop and why.

typescript
import { resolveStopDecision, type StopDecision, type StopCause } from '@helix-agents/core';

type StopCause = 'terminal' | 'stop_when' | 'max_steps';
type StopDecision = { stop: false } | { stop: true; cause: StopCause };

const decision = resolveStopDecision(stepResult, stepCount /* after this step, 1-indexed */, {
  maxSteps: agent.maxSteps, // unset → DEFAULT_MAX_STEPS (50)
  stopWhen: agent.stopWhen,
});

The causes are checked in order:

  1. terminal: a step carrying a __finish__ call, or a text / error step with shouldStop: true. A final answer on the last allowed step is a normal completion.
  2. stop_when: config.stopWhen(stepResult) returns true. A predicate that fires on the cap step is a stop_when stop.
  3. max_steps: stepCount >= resolveMaxSteps(config.maxSteps).

The function is pure apart from calling stopWhen, and it does not catch a throwing predicate: the runtime fails the run with it (runtime-js: the run-loop catch; CF Workflows: a hook failure, never retried). How the runtimes act on the cause: stop_when completes the run (completionReason: 'stop_when'); max_steps fails a run without outputSchema with createMaxStepsExhaustedError (completionReason: 'max_steps'); an outputSchema agent with no output enters forced completion for either; a step whose stop reason is an error stop reason fails with that reason first. runtime-js, the CF DO and CF Workflows use it; Temporal and DBOS still call shouldStopExecution with maxSteps alone (DI-29, DI-25).

createMaxStepsExhaustedError ​

typescript
import { createMaxStepsExhaustedError } from '@helix-agents/core';

createMaxStepsExhaustedError(5);
// HelixError { message: 'Max steps (5) reached', code: 'framework_max_steps_exhausted',
//              category: 'framework', retryable: false }

The typed failure of a run without outputSchema that reached its maxSteps budget before the model finished (C3, DI-25). N is the resolved budget. Custom loops can return it to match the built-in runtimes.

shouldStopExecution ​

The boolean form of resolveStopDecision (resolveStopDecision(...).stop), kept for custom loops that only need stop / continue:

typescript
import { shouldStopExecution, determineFinalStatus } from '@helix-agents/core';

const shouldStop = shouldStopExecution(stepResult, stepCount, {
  maxSteps: 10,
  stopWhen: (result) =>
    result.type === 'tool_calls' && result.toolCalls.some((tc) => tc.name === 'final_answer'),
});

const finalStatus = determineFinalStatus(stepResult);

It cannot tell a max_steps cut-off from a successful stop; a custom loop that reports outcomes should use resolveStopDecision. An unset maxSteps stops at DEFAULT_MAX_STEPS (50); pass maxSteps: Infinity for no cap.

DEFAULT_MAX_STEPS / resolveMaxSteps ​

The documented default step budget, and the one place it is applied. Every runtime resolves an agent's maxSteps through resolveMaxSteps(), and so does shouldStopExecution().

typescript
import { DEFAULT_MAX_STEPS, resolveMaxSteps } from '@helix-agents/core';

DEFAULT_MAX_STEPS; // 50
resolveMaxSteps(undefined); // 50
resolveMaxSteps(7); // 7
resolveMaxSteps(Infinity); // Infinity (no cap)

Forced Completion ​

Deterministic forced-completion phase transitions (see Finishing Agents → Forced completion for the conceptual guide, and Step Processing for how these compose with determineFinalStatus/entry classification). These pure functions replace the old prose-only completion-retry mechanism (checkCompletionRetry, buildCompletionRetryMessage, and treating DEFAULT_MAX_COMPLETION_RETRIES as a prompt-nudge count have all been removed); every runtime (JS, Cloudflare DO + Workflows, Temporal, DBOS) composes the same functions, so replay/checkpoint determinism holds everywhere.

typescript
import {
  classifyForcedCompletionEntry,
  beginForcedCompletion,
  prepareForcedCompletionCall,
  forcedDirectiveGenerateInput,
  assertForcedToolsUnchanged,
  classifyForcedCompletionResult,
  resolveForcedCallTransition,
  recordForcedCompletionMiss,
  recordForcedCompletionSuccess,
  failForcedCompletion,
  buildForcedCompletionMessage,
  resolveExpectedToolName,
  selectCompletionStrategy,
  checkCompletionSupport,
  isCompletionSupportRefusal,
  nativeCompletionCallId,
  DEFAULT_MAX_COMPLETION_RETRIES, // 2 — the default forced-completion budget
  FORCED_NATIVE_MIN_OUTPUT_TOKENS, // 16_000 — the native_output token floor
  COMPLETION_STRATEGIES, // ['forced_tool', 'native_output'] — CompletionStrategy's literals
} from '@helix-agents/core';
import type {
  ForcedCompletionTrigger, // 'terminal_text' | 'max_tokens' | 'max_steps'
  ForcedCompletionState,
  ForcedCompletionTransition,
  CompletionStrategy, // 'forced_tool' | 'native_output'
  SelectCompletionStrategyResult,
  CompletionRefusalCode, // the two early-refusal codes
} from '@helix-agents/core';

Every pure function's input/result/directive/decision/miss-reason type is also exported from the package root (not just the durable phase-state types above), so a bring-your-own-loop author can name every argument and return value directly, without reaching into the non-public ./orchestration sub-barrel:

typescript
import type {
  ClassifyForcedCompletionEntryInput,
  ForcedCompletionEntryDecision,
  BeginForcedCompletionInput,
  PrepareForcedCompletionCallInput,
  ForcedCompletionDirective,
  PrepareForcedCompletionCallResult,
  ClassifyForcedCompletionResultInput,
  ForcedCompletionResultClassification,
  ResolveForcedCallTransitionInput,
  ForcedCompletionMissReason,
  RecordForcedCompletionMissInput,
  RecordForcedCompletionSuccessInput,
  FailForcedCompletionInput,
} from '@helix-agents/core';

ForcedCompletionState — durable phase state persisted on both AgentState (in-memory) and SessionState (durable, all runtimes). An absent field means ordinary WORKING mode; status: 'pending' means the next LLM call must be a forced call. It records no strategy: the strategy is resolved again on every forced call.

typescript
interface ForcedCompletionState {
  version: 1;
  status: 'pending' | 'succeeded' | 'failed';
  trigger: ForcedCompletionTrigger;
  expectedToolName: string; // snapshotted at phase entry; validated before each attempt
  attempt: number; // 1-based number of the NEXT forced call
  maxAttempts: number; // snapshotted from maxCompletionRetries at entry
  triggeredAtStep: number;
  lastStopReason?: StopReason;
  lastErrorCode?: string;
  lastError?: string;
}

Completion strategies ​

typescript
selectCompletionStrategy(resolution: CapabilityResolution, modelLabel: string): SelectCompletionStrategyResult;
// { ok: true, strategy, capabilities, profile, source }
// | { ok: false, code: 'framework_completion_strategy_unavailable', message }

checkCompletionSupport(
  agent,
  adapter,
  options?: { appliesLlmConfigOverride?: boolean }
): { ok: true } | { ok: false; error: HelixError };

isCompletionSupportRefusal(code: string | undefined): code is CompletionRefusalCode;

selectCompletionStrategy is the selection rule: unresolved → unavailable; forcedToolChoice → forced_tool; else nativeStructuredOutput → native_output; else unavailable. The failure message names the model and the adapter's capabilities override.

checkCompletionSupport(agent, adapter, options?) is the early refusal every runtime runs before its run-start commit: for an agent with outputSchema it resolves the capabilities of agent.llmConfig (the base config, before any llmConfigOverride), selects the strategy and, for native_output, calls the adapter's checkOutputFormat with the completion target (the first finishWith tool, else __finish__). It returns a non-retryable HelixError (framework_completion_strategy_unavailable or framework_completion_schema_unsupported) instead of throwing. Agents without outputSchema always pass, and so do agents with maxCompletionRetries <= 0: they never make a forced call (forced completion fails them with framework_forced_completion_exhausted as soon as it triggers), so their model's strategy and schema support do not matter. With options.appliesLlmConfigOverride: true (passed by runtimes that run llmConfigOverride before each LLM call: runtime-js and the CF DO) an agent that HAS an llmConfigOverride also passes, because the override may pick the forced call's model; the per-call resolution then fails a forced call typed with the same two codes when the model it actually uses cannot finish. CF Workflows (which never runs the override) omits it, so the base model stays authoritative there, and so does Temporal. runtime-js, the CF DO, CF Workflows and Temporal (in its initializeAgentState / startRunCommit activities) call it at every entry (sub-agents and companions with their own config); DBOS does not yet. isCompletionSupportRefusal(code) matches exactly those two codes; HTTP hosts (the CF DO, @helix-agents/agent-server) use it to answer a refusal as 400 { error, code }.

ForcedCompletionDirective — what one forced call sends, built by prepareForcedCompletionCall:

typescript
type ForcedCompletionDirective =
  | {
      strategy: 'forced_tool';
      tools: AnyTool[]; // [expectedTool], or the full set when thinking is history-bound
      toolChoice: { type: 'tool'; toolName: string };
      reasoningMode?: 'disabled'; // present only when thinking is not history-bound
      trimSystemPrompt: boolean; // drop skills catalog + workspace fragment (not history-bound only)
      executionPhase: 'forced_completion';
      capabilityProfile: string;
    }
  | {
      strategy: 'native_output';
      tools: AnyTool[]; // the full effective set
      outputFormat: LLMOutputFormat; // the expected tool's input schema
      minOutputTokens: number; // FORCED_NATIVE_MIN_OUTPUT_TOKENS
      executionPhase: 'forced_completion';
      capabilityProfile: string;
    };

forcedDirectiveGenerateInput(directive) returns the LLMGenerateInput fields a directive sets (executionPhase, plus toolChoice / reasoningMode or outputFormat / minOutputTokens). assertForcedToolsUnchanged(directive, toolsForLLM) throws when a cache strategy added, removed or reordered the directive's tools (a framework bug).

Transition functions — each is pure (no I/O, no persistence) and returns a ForcedCompletionTransition plan (nextForcedState, messagesToAppend, events: ForcedCompletionChunk[], outcome: 'continue' | 'completed' | 'failed', plus output or error/errorCode/errorDetail) that the caller persists/applies — append messages, save nextForcedState, emit events after the durable commit, then act on outcome:

FunctionWhen it's called
classifyForcedCompletionEntry()After a step result that might need finalizing. Decides whether to enter the phase and with which trigger, or returns { enter: false, nonRecoverable? } for content_filter/refusal/etc. Returns null when not applicable (no outputSchema, or output already produced).
beginForcedCompletion()Snapshots the phase at entry (attempt: 1). maxAttempts <= 0 fails immediately with framework_forced_completion_exhausted — no forced call is ever made.
prepareForcedCompletionCall()Input { state, effectiveTools, resolution, modelLabel }, where resolution describes the model THIS call uses (after llmConfigOverride). Builds the directive for the selected strategy, or fails before any LLM call with framework_completion_tool_missing (the expected tool is absent) or framework_completion_strategy_unavailable.
classifyForcedCompletionResult()Classifies a completed forced call: { kind: 'success', output }, { kind: 'miss', recoverable: true, protocolViolation } (consumes one attempt), or { kind: 'nonRecoverable', code?, message, errorDetail? }. An error step result is non-recoverable with the provider's own detail: its code when it is a known ErrorCode, else no code at all (never an invented one).
resolveForcedCallTransition()Classifies a forced call's result and returns the matching transition (success / miss / fail) in one call; the shared form of the per-runtime "C4" block. Input adds strategy; under forced_tool a provider_invalid_request message gets a hint to declare the model's capabilities.
recordForcedCompletionMiss()Increments attempt and appends one correction message when budget remains; exhausts on the last attempt (framework_forced_completion_exhausted, or framework_completion_protocol_violation when the miss was a protocol violation).
recordForcedCompletionSuccess()Marks the phase 'succeeded' and returns outcome: 'completed' with the produced output.
failForcedCompletion()Fails the phase immediately with a given code/message (and optional provider errorDetail; code is omitted only for an unclassified error, and then the chunk, lastErrorCode, the transition and the detail carry none), e.g. from prepareForcedCompletionCall's failures, a prompt-prefix refusal, or a nonRecoverable classification.

DBOS still passes LEGACY_RESOLUTION to prepareForcedCompletionCall and classifies a forced call's error step as a recoverable miss (a package-local shim) until its migration (planned); see Finishing Agents → DBOS. Temporal decides a forced call's outcome with resolveForcedCallTransition inside its runLLMStep activity.

resolveExpectedToolName(tools) returns the agent's finishWith tool name if one is configured, else FINISH_TOOL_NAME ('__finish__'). buildForcedCompletionMessage() builds the hidden correction message (metadata.hidden: true, metadata.forcedCompletion: { version, attempt, expectedToolName, trigger }), worded for either strategy: [completion] Provide your final answer now (attempt N) in the required structured format. Do not call any other tool and do not reply with plain prose. (plus a "keep this answer short" sentence for a max_tokens trigger). It is explanatory context for the model, never the enforcement mechanism (the directive is).

DEFAULT_MAX_COMPLETION_RETRIES = 2 — the default value for AgentConfig.maxCompletionRetries, the forced-completion budget. 0 means "fail immediately when a trigger fires," not "restore the old prose-retry behavior."

Stream chunk: forced-completion progress is surfaced via a forced_completion chunk (phase: 'started' | 'attempt_started' | 'attempt_failed' | 'succeeded' | 'exhausted'; attempt_started carries strategy and capabilityProfile), added to both streamChunkSchema() and streamMessageSchema(). See Stream Chunks and Chunk Type Guards above for ForcedCompletionChunk and isForcedCompletionChunk.

Error codes: dedicated framework_* codes never collapse into a generic "returned text without structured output" message — framework_completion_tool_missing, framework_completion_strategy_unavailable, framework_completion_schema_unsupported (early refusal, or a forced call whose model, after llmConfigOverride, cannot take the schema), framework_completion_protocol_violation, framework_completion_invalid_output (a forced completion whose output fails schema validation, or native_output text that does not parse, is a recoverable miss), framework_forced_completion_exhausted, and framework_prompt_prefix_changed (below). See Error Classification below. The former framework_completion_tool_choice_unsupported has been removed: it was never raised, and a model that rejects forced tool choice now gets the native_output strategy.

Prompt-prefix stability ​

typescript
import {
  computePromptFingerprint,
  checkPrefixStability,
  stampPromptFingerprint,
  type PromptFingerprint, // { system: string; tools: string; model: string }
} from '@helix-agents/core';

const fp = computePromptFingerprint({
  messages: messagesForLLM,
  tools: toolsForLLM,
  model: modelLabel,
});
const check = checkPrefixStability(state.messages, fp, capabilities); // before the call
if (!check.ok) throw check.error; // HelixError 'framework_prompt_prefix_changed', non-retryable
const stamped = stampPromptFingerprint(assistantMessage, fp, capabilities); // after the call

For models whose thinking is bound to history (historyBoundThinking). computePromptFingerprint hashes the request as sent (after cache strategies and beforeLLMCall hooks): every system message in order (the Vercel adapter hoists them all into the top-level system the API binds), and the name-sorted tools (name, description, input schema, strict, provider options without cache-control keys). stampPromptFingerprint records it as metadata.helixPrefix on the resulting assistant message (a no-op when thinking is not history-bound). checkPrefixStability finds the latest assistant message that has signed reasoning parts and a fingerprint for the same model, and refuses when the system prompt and/or tool set differ, naming which. It passes when there is no such message, and always passes when thinking is not history-bound. runtime-js, the CF DO, CF Workflows and Temporal (inside runLLMStep, before step_start) run it at their LLM call site; DBOS does not yet. See Preserved thinking.

applyCacheStrategies ​

Apply one or more cache strategies to messages and tools. Used internally by all runtimes (JS, Temporal, Cloudflare, and DBOS) when a cache strategy is set on LLMConfig. Strategies are not JSON-serializable, so the DBOS runtime rehydrates the live strategy from a process-local registry (mirroring how it rehydrates the live model) and applies it in the workflow body before each LLM call. Supply one of the built-in helpers (anthropicCache, openaiCache, xaiCache) or a custom CacheStrategy function.

typescript
import {
  applyCacheStrategies,
  anthropicCache,
  openaiCache,
  xaiCache,
  mergeProviderOptions,
} from '@helix-agents/core';
import type {
  CacheStrategy,
  CacheRequest,
  CacheResult,
  AppliedCacheResult,
} from '@helix-agents/core';

const result = applyCacheStrategies(llmConfig.cache, {
  messages,
  tools,
  config: llmConfig,
  context: { sessionId },
});
// result.messages - Messages with cache annotations
// result.tools - Tools with cache annotations
// result.providerOptions - Provider options to merge (e.g., OpenAI promptCacheKey)
// result.headers - HTTP headers to merge (e.g., xAI x-grok-conv-id)

Caching types:

  • CacheStrategy — (request: CacheRequest) => CacheResult — a pure function that annotates a request for prompt caching
  • CacheRequest — { messages, tools, config: LLMConfig, context: { sessionId } } — inputs to a strategy
  • CacheResult — { messages?, tools?, providerOptions?, headers? } — annotations returned by a strategy
  • AppliedCacheResult — result of folding all strategies; messages and tools are always present
  • AnthropicCacheOptions — { ttl?: string } — options for anthropicCache()

Built-in helpers:

HelperProviderEffect
anthropicCache({ ttl? })Anthropic (Claude)Places cache_control markers on the last system message (whose prefix also caches the tool definitions) and a contiguous run covering the latest turn's tool-result batch (≤ 4 breakpoints). Default ttl: '1h'.
openaiCache()OpenAISets providerOptions.openai.promptCacheKey to the session ID for cache affinity.
xaiCache()xAI (Grok)Sets the x-grok-conv-id header to the session ID.

Google / Gemini needs no helper — it uses implicit prefix caching server-side with nothing to annotate.

mergeProviderOptions:

typescript
import { mergeProviderOptions } from '@helix-agents/core';

// Shallow-merge per-provider option objects without mutating the originals.
const merged = mergeProviderOptions(existing, additions);

collectInjectedMemoryIds ​

Collect the memory IDs already injected into the transcript via MEMORY_INJECTION-marked messages. Use this to deduplicate auto-loaded memories across turns so the same memory is never injected twice.

typescript
import { collectInjectedMemoryIds, COMMON_METADATA_KEYS } from '@helix-agents/core';

// Returns a Set<string> of all memory IDs already present in the transcript.
const alreadyInjected = collectInjectedMemoryIds(state.messages);

// Typically used alongside MemoryManager.buildInjectionMessage():
const memoryMessage = await memoryManager.buildInjectionMessage({
  query: userMessage,
  context,
  excludeMemoryIds: collectInjectedMemoryIds(state.messages),
});
if (memoryMessage) {
  await stateStore.appendMessages(sessionId, [memoryMessage]);
}

The function scans messages for those marked with COMMON_METADATA_KEYS.MEMORY_INJECTION === true and returns the union of their memoryIds metadata arrays. Runtimes call this once per turn, before the LLM call, to build the dedup set passed to buildInjectionMessage.

Message Builders ​

typescript
import {
  createAssistantMessage,
  createToolResultMessage,
  createSubAgentResultMessage,
} from '@helix-agents/core';

const assistantMsg = createAssistantMessage(plan.assistantMessagePlan);

const toolResult = createToolResultMessage({
  toolCallId: 'tc1',
  toolName: 'search',
  result: { data: 'found' },
  success: true,
});
// content: '{"data":"found"}', metadata: { helixToolFailed: false }

createToolResultMessage({ toolCallId: 'tc2', toolName: 'say', result: 'hello', success: true });
// content: 'hello', metadata: { helixToolFailed: false, helixContentEncoding: 'text' }

createAssistantMessage writes each call id once (RM-106): a step may list a call in both its toolCalls and its subAgentCalls (an adapter that also reports a sub-agent call as a tool call), and the row keeps it in the toolCalls entry's place with the SUB-AGENT call's input (the input that runs), marked providerExecuted: true unless the resolver says otherwise, so the provider never sees a duplicate tool_use id. planStepProcessing plans such a call the same way: once, in pendingToolCalls (the runtime routes it by tool name, a remote sub-agent tool included) with the sub-agent input, never in pendingSubAgentCalls, so no runtime dispatches it twice; inputs that differ are logged (step.call_listed_twice).

createToolResultMessage and createSubAgentResultMessage store a successful string result verbatim with the helixContentEncoding: 'text' marker, and JSON-stringify everything else (see ToolResultMessage). Caller metadata is spread after the builder's keys.

decodeToolResultContent ​

typescript
import { decodeToolResultContent } from '@helix-agents/core';

decodeToolResultContent(message: Pick<ToolResultMessage, 'content' | 'metadata'>): unknown;

Decodes a tool-result message's content back to the tool's value: a row marked helixContentEncoding: 'text' is the string itself; any other row is JSON.parsed; a row that is not valid JSON (stored before the marker existed, when runtime-js already kept strings verbatim) is returned as its raw text. A pre-upgrade row whose string content happens to look like JSON ('42') decodes as the parsed value; marked rows never do. The ai-sdk converter (convertToAISDKMessages) uses it for a tool part's output.

Typed not-executed results and the trailing pairing ​

typescript
import {
  NOT_EXECUTED_REASONS, // ['superseded_by_terminal_call', 'unpaired_on_continuation', 'run_stopped']
  createNotExecutedToolResult,
  isNotExecutedToolResult,
  findTrailingUnpairedToolCalls,
  pairUnfinishedCalls,
  buildTrailingHealMessages,
  type NotExecutedReason,
} from '@helix-agents/core';

pairUnfinishedCalls(
  messages: readonly Message[],
  opts: { reason: NotExecutedReason; excludeIds?: Iterable<string> }
): ToolResultMessage[];

A call that was never executed is paired with a failed tool result whose metadata.helixSynthetic is { kind: 'not_executed', reason } and whose content is {"error":"Tool call was not executed: <reason text>"} (createNotExecutedToolResult; check it with isNotExecutedToolResult, a Zod parse, never a string match). The reasons:

ReasonWritten whenText after the prefix
superseded_by_terminal_callanother terminal call in the same step completed the agent (C2)another terminal call in the same step completed the agent
unpaired_on_continuationa new turn, retry() or resume heals the history's trailing unpaired calls (C5)the run ended before this call produced a result
run_stoppeda stop's terminal pairs the stopped run's unfinished calls before its K5 (CP-118)the run was stopped before it was recorded, and the tool may have run (prefix Tool call result was not recorded:)

pairUnfinishedCalls is the pure pairing: one result of reason for every trailing unpaired call (findTrailingUnpairedToolCalls: the calls of the last assistant message that only role: 'tool' rows follow, minus excludeIds, each id once), in call order. It returns [] when those calls are all paired, so appending its rows is idempotent. buildTrailingHealMessages(messages, { excludeIds }) is the C5 heal: pairUnfinishedCalls with 'unpaired_on_continuation', except that a __finish__ call gets its { acknowledged: true } ack. A new turn over a terminal session uses planNewTurnPendingSettlement (below) instead, which also settles pending calls. A stop terminal calls it with 'run_stopped' and the pending client-tool call ids as excludeIds (see Stop as one signal); it never acks a __finish__ call (the run did not complete), and it only sees calls with no result, since a stop never refuses the record of completed work (Ruling O-73).

Settling a terminal session's pending calls on a new turn (RM-81) ​

typescript
import {
  planNewTurnPendingSettlement,
  drainStoredAnswer,
  isApprovalResponseResult,
  APPROVAL_DENIED_TOOL_RESULT, // 'Tool call was not approved by the user'
  type NewTurnPendingSettlement, // { rows, settled, restamp }
  type SettledPendingCall,
} from '@helix-agents/core';

planNewTurnPendingSettlement({
  history: readonly Message[],
  pending: Record<string, PendingClientToolCall> | undefined,
  isApprovalTool: (toolName: string) => boolean,
  approvalDenial: 'result' | 'error', // the runtime's denial shape (CP-66)
  markCompleted: boolean,             // the session is the calls' root
  excludeIds?: Iterable<string>,
  placedRows?: readonly ToolResultMessage[], // e.g. remote children's results
}): NewTurnPendingSettlement;

What a NEW turn's run-start commit puts before its input on a completed / failed / interrupted session. A pending entry there belongs to a run that ended, so no submit can answer it. rows pairs every trailing unpaired call of the last assistant message, in call order:

  • an answered call: its stored result or submitted error (drainStoredAnswer);
  • a denied approval: APPROVAL_DENIED_TOOL_RESULT;
  • an unanswered call, or an approved approval that never ran: unpaired_on_continuation;
  • a call with no pending entry: the C5 heal's row.

Then come placedRows, then any undelivered stash. restamp consumes every settled entry (gated on what the plan read, with its already_completed marker when markCompleted) and drops entries whose call is not trailing; pre-submit stubs are untouched. settled lists the pending calls answered, whose tool_end the runtime writes after run_started. A retry() or a resume never uses it: the continued run still awaits its calls. Pure.

Typed state-write-rejected results ​

typescript
import {
  STATE_WRITE_REJECTED_KIND, // 'state_write_rejected'
  stateWriteRejectedToolResultMetaSchema,
  stateWriteRejectedMeta,
  stateWriteRejectedErrorText,
  createStateWriteRejectedToolResult,
  isStateWriteRejectedToolResult,
  type StateWriteRejectedMeta,
} from '@helix-agents/core';

createStateWriteRejectedToolResult(
  call: { id: string; name: string },
  err: StateSizeExceededError
): ToolResultMessage;

A tool that ran but whose state write the store refused as too large (MA-31) is paired with a failed tool result whose metadata.helixSynthetic is { kind: 'state_write_rejected', code: 'state_size_exceeded', operation, sizeBytes, limitBytes, changedKeys } (stateWriteRejectedMeta(err), parsed by stateWriteRejectedToolResultMetaSchema(); check a row with isStateWriteRejectedToolResult, a Zod parse, never a string match). Its content is {"error": stateWriteRejectedErrorText(toolName, err)}, which is also the tool_end chunk's error. It carries keys and byte counts, never values, in one of two texts, by what the store measured:

  • operation: 'stage' (the tool's own staged writes): "Tool 'x' ran but its state write (N bytes) exceeds the store's M-byte limit (keys: …) and was discarded; store large values outside agent state and keep a reference";
  • any other operation (the session state with the write folded in, e.g. the approve drain's own state commit): "Tool 'x' ran but the session state with its write would be N bytes, over the store's M-byte limit (keys: …), so its write was discarded; store large values outside agent state and keep a reference".

RegularToolResult.stateWriteRejected?: StateWriteRejectedMeta carries the marker from a runtime's tool executor to the row it builds. A tool that THREW is never this row (it keeps its own failed row), on every runtime.

Typed result-too-large rows ​

typescript
import {
  createResultTooLargeToolResult,
  fitResultRow,
  resultTooLargeText,
  resultRefusalText,
  resultSubject,
  type ResultRefusal, // { sizeBytes, limitBytes, limitName, measure: 'conservative' | 'row', notStorableReason?, part? }
  type ResultTooLargeInput, // { toolCallId, toolName, subject?, sessionId, refusal }
  type ResultTooLarge, // { row, errorText, error }
  type FittedResultRow, // { kind: 'fits', row } | { kind: 'too_large', row, errorText, error }
} from '@helix-agents/core';

createResultTooLargeToolResult(input: ResultTooLargeInput): ResultTooLarge;
fitResultRow(
  row: ToolResultMessage,
  opts: { maxMessageBytes: number | undefined; sessionId: string; subject?: string }
): FittedResultRow;

MA-31 A11 / A12: a tool or sub-agent call that RAN but whose result is too large to record is paired with ONE core row, on every runtime that has such a limit (O-73: the call is never re-run, and the run continues). createResultTooLargeToolResult builds it: a plain failed row (createToolResultMessage({ success: false }): {"error": errorText}, metadata: { helixToolFailed: true }, no other marker), its errorText (also the tool_end chunk's error), and error, a StateSizeExceededError with operation step, the call's toolCallId, the refusal's byte counts and message: errorText (what afterTool / afterSubAgent get). The text is resultTooLargeText(subject, refusal): "<subject> ran, but its result <resultRefusalText(refusal)>. Return large data by reference (store it outside the result and return an id)", where the subject defaults to resultSubject(toolName) ("Sub-agent 'y'" for a subagent__y call, else "Tool 'x'") and the clause is

  • measure: 'row' (runtime-js and the CF DO, against the store's maxMessageBytes): "was too large to record: the recorded row would be N bytes, over the M-byte message limit";
  • measure: 'conservative' (CF Workflows, against CFW_MAX_STEP_RESULT_BYTES): "was too large to record: the conservative measure passed N bytes, over the 1048576-byte step-result limit";
  • notStorableReason set (CF Workflows, a value the engine cannot store): "cannot be recorded: <reason>", with no limit, byte count or advice.

refusal.part names what was refused: 'result' (default) or 'error', the error text of a failed call ("<subject> ran, but its error was too large to record: …. Keep error messages short (store large details outside the error and return an id)"). fitResultRow sets it from the row: a failed row (helixToolFailed: true) is 'error'.

fitResultRow(row, { maxMessageBytes, sessionId }) is the runtime-side check: it measures the row exactly as the store binds it (messageRowBytes, below) and returns it unchanged when it fits, else the typed replacement (measure row, limit name "message limit"). An absent or infinite limit is a no-op, never measured. A runtime measures the row it will actually store (after every field it stamps), before its tool_end, the run's messages and the hooks; the store's own refusal at the bind stays the backstop. An assistant message is never replaced: an oversized reply fails the run. settleRemoteChildrenBeforeInput({ …, maxMessageBytes }) fits every remote-child row it plans for an entry's run-start commit the same way (the ref keeps the child's own status), so one oversized finished child never blocks the session's next turn.

resultTooLargeText(subject, refusal, verb = 'ran') takes what the subject did; a hidden completion notice says completed / failed (below).

An oversized hidden completion notice (A13; MA-37, MA-38).

typescript
buildPendingCompletionNotifications(
  refs: SubSessionRef[],
  resultsBySubSessionId: Map<string, string>,
  opts?: { maxMessageBytes?: number }
): { messages: UserMessage[]; deliveredSubSessionIds: string[]; notices: PendingCompletionNotice[] };

interface PendingCompletionNotice {
  subSessionId: string;
  childName: string;
  status: 'completed' | 'failed';
  message: UserMessage;
  noticeKey?: CompanionNoticeKey; // the notice's companion key, carried onto the replacement
  tooLarge?: ResultRefusal; // set when `message` is the replacement
}

fitCompletionNotice(notice, { maxMessageBytes }): PendingCompletionNotice;
replaceCompletionNotice(notice, refusal: ResultRefusal): PendingCompletionNotice;
buildCompletionNoticeTooLargeMessage(
  childName,
  status,
  refusal,
  notice?: CompanionNoticeKey
): UserMessage;
completionNoticeTooLargeText(childName, status, refusal): string;

With maxMessageBytes, each notice row is measured with messageRowBytes (the output is escaped twice, so the row is about twice the output) and one over the limit is replaced (fitCompletionNotice, measure row). The replacement is still ONE hidden completion notification: the same hidden / source: 'sub-agent-completion' metadata, plus the notice's companion key (helixCompanionNotice, noticeKey) when the notice it replaces carried one (so findCommittedCompanionNotice still finds it), so isSubAgentCompletionNotification is true and the child is delivered (and marked delivered) exactly as for the notice it replaces. Its text begins with the ordinary prefix: "Sub-agent '<name>' completed, but its result <resultRefusalText(refusal)>. Return large data by reference …", or for a failed child "Sub-agent '<name>' failed, but its error …. Keep error messages short …". deliverPendingChildCompletions passes its store's limits.maxMessageBytes; CF Workflows also fits a step's notices together under its step-result cap and replaces a notice with replaceCompletionNotice and its conservative refusal.

Deferred calls after a resume ​

typescript
import {
  findDeferredFinishWithCalls,
  findDeferredCalls,
  type DeferredCallKind, // 'companion' | 'finishWith'
} from '@helix-agents/core';

findDeferredCalls(
  messages: readonly Message[],
  kindOf: (toolName: string) => DeferredCallKind | undefined
): { companion: ToolCallRequest[]; finishWith: ToolCallRequest[] };

findDeferredFinishWithCalls(
  messages: readonly Message[],
  isFinishWithToolName: (toolName: string) => boolean
): ToolCallRequest[];

A step that suspends on a client-tool call (or a sub-agent) before its other calls run leaves those calls deferred: they are the trailing unpaired calls once the resume's drain has paired everything else. A runtime runs them exactly once on the resume, before its next model call. findDeferredCalls classifies each trailing unpaired call with kindOf (in call order within each kind); when any of them is of another kind (kindOf returns undefined), nothing is deferred and both lists are empty. findDeferredFinishWithCalls is its finishWith view. CF Workflows runs the deferred companion calls first, then the finishWith calls (its step's phase order); the other runtimes defer only finishWith calls.

toolEndChunkForResultRow ​

typescript
import { toolEndChunkForResultRow } from '@helix-agents/core';

toolEndChunkForResultRow(
  row: Pick<ToolResultMessage, 'toolCallId' | 'toolName' | 'content' | 'metadata'>,
  ctx: { agentId: string; agentType: string; step: number }
): ToolEndChunk;

A call's tool_end frame built from its committed result row, so the frame and the row never disagree: result is the row decoded with decodeToolResultContent; a failed row (helixToolFailed) carries its error text in error (and its helixErrorDetail code in errorCode) and no result; providerExecuted is true. A failed companion row that still delivers its held prior ({ error, priorResult }, MR B deliver-then-continue) gives the frame the same priorResult (ToolEndChunk.priorResult, a CompanionPriorResult), Zod-decoded in the same parse as error (a malformed prior is omitted, never thrown); a successful row's prior stays inside result. CF Workflows writes a companion call's tool_end with it before the row's commit (RM-76). @helix-agents/ai-sdk does not forward tool_end.priorResult to the UI stream: a reload renders the same row as output-error with the error text only, so the live UI matches it; the model sees the row verbatim.

Remote sub-agent helpers ​

Three exported helpers implement the consumer half of the remote protocol. Every shipped runtime uses them; BYO-loop users can compose them directly.

executeRemoteSubAgentDispatch ​

Runs one remote child to a terminal outcome: start (or attach), proxy the child's stream into the parent's, track the sub-session ref, and report the result.

typescript
import { executeRemoteSubAgentDispatch } from '@helix-agents/core';

interface RemoteSubAgentDispatchInput {
  transport: RemoteAgentTransport;
  parentSessionId: string;
  childSessionId: string; // deterministic
  agentType: string;
  message: string; // pre-serialized
  timeoutMs: number; // per-attempt
  metadata?: Record<string, unknown>; // forwarded as transport.start.state
  toolCallId?: string; // becomes the sub-session ref's parentToolCallId
  resumeDeadProducer?: boolean; // default false
}

interface RemoteSubAgentDispatchDeps {
  stateStore: SessionStateStore;
  streamManager: StreamManager;
  logger?: Logger;
  recoverFromHeartbeat?: () => Promise<{ lastSequence: number } | null>;
  reportProgress?: (sequence: number) => void;
  isCancelled?: () => boolean;
}

const result: RemoteSubAgentResult = await executeRemoteSubAgentDispatch(input, deps);
typescript
type RemoteSubAgentResult =
  | {
      kind: 'completed';
      output: unknown;
      lastSequence: number;
      streamId?: string;
      origin: 'end' | 'recovery';
      childState?: Record<string, unknown>; // end.state, when the producer attached it
      usage?: UsageRollup; // end.usage, same condition
    }
  | {
      kind: 'failed';
      error: string;
      errorDetail?: ErrorDetail; // the remote failure's detail, verbatim (never inferred)
      lastSequence: number;
      streamId?: string;
      failureReason: RemoteSubAgentFailureReason;
    };

type RemoteSubAgentFailureReason =
  | 'stream-drop'
  | 'remote-failed'
  | 'timeout'
  | 'cancelled'
  | 'transport-error'
  | 'client-tool-unsupported'
  | 'producer-dead'
  | 'remote-interrupted'; // the child was stopped; not retry-worthy

Behaviors worth knowing:

  • Provenance stamping — every proxied child chunk is stamped with remoteSource before it is written to the parent stream.
  • ALREADY_RUNNING → attach — a RemoteAgentAlreadyRunningError from start is not a failure: the dispatch attaches to the running stream.
  • Attach-only once started (D-2 guard) — once the child exists (any getStatus answer, or the sub-session ref's remote.streamId, persisted right after a successful start), the dispatch never calls transport.start again: a retry re-attaches with transport.stream(childSessionId, { fromSequence }). An interrupted child is a typed, non-retried 'remote-interrupted' failure. A start whose outcome is unknown (RemoteEntryOutcomeUnknownError, CP-95) is re-sent with the same request key (startRemoteChild); one still lost after the re-sends fails with the transport_remote_entry_outcome_unknown detail, never a 'stream-drop'. RemoteSubAgentDispatchInput.parentRunId and toolCallId are required (they key the child's entries).
  • Producer-liveness escalation — when the producer keeps reporting status: 'running' with isExecuting: false and no stream progress, the dispatch fails fast with failureReason: 'producer-dead' rather than burning the retry budget. Set resumeDeadProducer: true to opt into a single blind transport.resume(...) first (wrong for multi-replica producers, hence off by default).

readRemoteSubSessionRef ​

typescript
import { readRemoteSubSessionRef } from '@helix-agents/core';

const existingRef = await readRemoteSubSessionRef(stateStore, parentSessionId, remoteSessionId);

Returns the parent's ref for remoteSessionId, or undefined when it has none.

Every remote call site reads it before it writes anything: the core dispatch above, runtime-js and DBOS. The ref decides two things:

  • whether the call still has to add its location: 'remote' ref. That marker keeps deleteSession(parent) from deleting a remote child that shares the store;
  • what a retried call resumes from (its stream cursor and its "already emitted" guard).

So a failed read fails the call before any ref write, stream writer or transport.start. A store's typed HelixError is rethrown as it is. Any other error becomes a retryable transport_error with the store error as its cause.

processRemoteSubAgentCompletion ​

The shared post-terminal fold: end.status gate, output re-validation, and state/usage harvest (with getSnapshot() / getUsage() recovery when the end frame didn't carry them).

typescript
import { processRemoteSubAgentCompletion, DEFAULT_MAX_FOLD_STATE_BYTES } from '@helix-agents/core';

type RemoteCompletionSource =
  | {
      kind: 'end';
      end: {
        status: 'completed' | 'paused' | 'interrupted';
        output?: unknown;
        state?: Record<string, unknown>;
        usage?: UsageRollup;
      };
    }
  | { kind: 'recovery'; status: 'completed' | 'interrupted'; output: unknown };

interface ProcessRemoteSubAgentCompletionInput {
  childSessionId: string;
  agentType: string;
  source: RemoteCompletionSource;
  outputSchema?: z.ZodType;
  maxFoldStateBytes?: number; // default DEFAULT_MAX_FOLD_STATE_BYTES = 262_144
}

const completion: ProcessRemoteSubAgentCompletionResult = await processRemoteSubAgentCompletion(
  input,
  { transport, logger }
);

type ProcessRemoteSubAgentCompletionResult =
  | {
      kind: 'completed';
      output: unknown;
      childCustomState?: Record<string, unknown>;
      remoteRollup?: UsageRollup;
      remoteRollupOrigin?: 'end' | 'recovery';
    }
  | { kind: 'failed'; reason: 'output-invalid' | 'remote-failed' | 'not-terminal'; error: string };

The second argument is a ProcessRemoteSubAgentCompletionDeps — { transport: RemoteAgentTransport; logger?: Logger }. While the child is still streaming (before this terminal fold runs), runtimes persist SubSessionRef.remote.lastSequence every REMOTE_REF_PERSIST_CHUNK_INTERVAL (= 25) proxied chunks, so a crash mid-stream resumes from the last persisted position instead of replaying from zero. Both ProcessRemoteSubAgentCompletionDeps and REMOTE_REF_PERSIST_CHUNK_INTERVAL are exported from @helix-agents/core.

validateRemoteOutput ​

Re-validates a remote child's terminal output against the tool's declared outputSchema (the producer's word is not taken on faith).

typescript
import { validateRemoteOutput } from '@helix-agents/core';

const result: RemoteOutputValidationResult<T> = validateRemoteOutput(outputSchema, output);
// { ok: true; output: T } | { ok: false; error: string; issues: unknown }

No schema declared → passthrough success. Schema declared → safeParse; on success the parsed value is returned (Zod strip/transform semantics apply, so consumers fold the validated value, not the raw wire value).

outputSchema is typed as RemoteOutputSchemaLike<T> | undefined — a structural safeParse stand-in (every Zod schema satisfies it, but it is deliberately not pinned to Zod), also exported from @helix-agents/core:

typescript
interface RemoteOutputSchemaLike<T> {
  safeParse(
    input: unknown
  ): { success: true; data: T } | { success: false; error: { issues?: unknown; message: string } };
}

Recovery ​

recoverConversation ​

Resume a conversation from stored state:

typescript
import { recoverConversation, loadConversationMessages } from '@helix-agents/core';

const { messages, canResume } = await recoverConversation({
  stateStore,
  runId: 'run-123',
});

// Or just load messages
const messages = await loadConversationMessages(stateStore, 'run-123');

Error Types ​

Agent Errors ​

Errors for interrupt/resume and distributed coordination:

typescript
import {
  AgentAlreadyRunningError,
  AgentNotResumableError,
  FencingTokenMismatchError,
  StaleStateError,
  StepCommitConflictError,
  StreamTruncatedError,
} from '@helix-agents/core';

AgentAlreadyRunningError ​

Thrown when attempting to execute/resume an agent that is already running.

typescript
class AgentAlreadyRunningError extends Error {
  constructor(sessionId: string, currentStatus: string, hint?: string);
  readonly sessionId: string;
  /** The live run's (or session's) status, e.g. `running` or `suspended_awaiting_children`. */
  readonly currentStatus: string;
  /** How the live run continues and what to do instead; appended to the message. */
  readonly hint?: string;
}

interface SuspendedRunRefusalHintOptions {
  /** The runtime's caller resumes a suspended run (a submit answers `caller_resumes`). */
  resumedBy?: 'caller';
}

function suspendedRunRefusalHint(status: string, options?: SuspendedRunRefusalHintOptions): string;

The message is Agent session '<id>' is already running (status: <currentStatus>), followed by . <hint> when there is one. An entry refused because the session's run is live but suspended (on its children or a partial step) carries that status and suspendedRunRefusalHint(status, options), which says how the run continues. A runtime that continues the run by itself (the CF Durable Object's /start, CF Workflows execute()) uses the default wording ("continues on its own"). runtime-js and Temporal execute() pass { resumedBy: 'caller' }: a submit there answers caller_resumes, so the hint says to submit the pending results and then resume(). Their execute() refuses such a run whatever a live session reads (running, waiting_tool or paused: a run parked on its children leaves it paused, CP-115), before writing anything. A terminal session over a live run is a run whose K5 landed, which the run-start commit finishes (R66). A paused session over a run parked on its children whose stop is recorded is not refused: the new turn settles it by its stop and proceeds. A paused session with pending client-tool calls is refused first by the pending-calls check, with its own message. Classified state_already_running, 409, non-retryable.

A run-start commit that loses to a live run throws RunStartConflictError, a subclass that keeps the name AgentAlreadyRunningError, so existing instanceof / name checks still treat it as "another run is driving". The other run-start and run-ownership errors (RunStartRejectedError, RunSupersededError, RunEntryFailedError, RunStartAbandonedError, RunStartOutcomeUnknownError, RunStartStalledError, StreamClaimStaleReadError, CommitStoppedError, CorruptMessageRowError, UnknownCommitKindError) are described under Run start and Superseded-run fence and errors.

AgentNotResumableError ​

Thrown when attempting to resume an agent that cannot be resumed (a completed or failed session). A typed, non-retryable state_not_resumable HelixError; hint (optional) is appended to the message.

typescript
class AgentNotResumableError extends HelixError {
  // code 'state_not_resumable'; never retryable
  constructor(sessionId: string, currentStatus: string, hint?: string);
  readonly sessionId: string;
  readonly currentStatus: string;
}

Who throws AgentNotResumableError (DI-31)

CF Workflows' and DBOS's resume() (continue / with_message) refuse a completed or failed session with it; Temporal's refuses a with_message over one with it, while its continue still returns the old terminal result (CP-60). runtime-js (and the CF Durable Object, which runs it) refuses a completed or failed session with a ResumeRefusedError (state_not_resumable). Every one of them carries state_not_resumable. See DI-31 in the conformance findings.

ResumeRefusedError ​

A HelixError (code: 'state_not_resumable') for a resume that the session's own status refuses. runtime-js throws it, so the CF Durable Object answers it too.

typescript
class ResumeRefusedError extends HelixError {
  readonly sessionId: string;
  readonly sessionStatus: SessionStatus; // the status the refusing read saw
  readonly awaitingToolCallIds: readonly string[]; // a partial batch's unsubmitted calls; else []
}
  • A completed or failed session: retryable: false (retry, or start a new turn).
  • A paused session whose client-tool batch is still partial: retryable: true, and the message names the awaited calls first, so they survive a typed body's 256-character scrub: Awaiting client tool call(s) <ids>: submit every result of the batch, then resume session '<id>'.

A host answers it from sessionStatus, never from the message: the CF DO's native routes answer 409 { error, code: 'state_not_resumable' }; its /subagent/:type/resume route answers the protocol envelope ALREADY_COMPLETED for a completed session, else INVALID_REQUEST, both with errorCode: 'state_not_resumable'. The typed body carries only the message and code, so a client that reads it back (DOFrontendExecutor) rebuilds a HelixError with that code, not a ResumeRefusedError.

AgentResolutionError ​

The one typed error for "this agent type cannot be resolved here", thrown by the CF and Temporal AgentRegistry (get() and resolve()), runtime-js's submit-side lookup and the CF Durable Object's agent materialization. It replaces the AgentNotFoundError classes the runtime packages used to export.

typescript
import {
  AgentResolutionError,
  isAgentNotFoundError,
  isAgentResolutionError,
} from '@helix-agents/core';

class AgentResolutionError extends HelixError {
  readonly agentType: string;
  readonly availableTypes?: string[];

  static notFound(
    agentType: string,
    availableTypes: string[],
    options?: { reason?: string }
  ): AgentResolutionError;
  static failed(agentType: string, cause: unknown): AgentResolutionError;
  static fromNotFoundAnswer(agentType: string, message: string): AgentResolutionError;
}

function isAgentNotFoundError(e: unknown): e is AgentResolutionError; // a miss
function isAgentResolutionError(e: unknown): e is AgentResolutionError; // either code
  • notFound — framework_agent_not_found, non-retryable, HTTP 404. The message is Unknown agent type: '<type>'. Available agents: …. With reason (a type that is registered but unavailable to this lookup, e.g. a factory read through the static-only get()), it reads Agent type '<type>' is not available: <reason>. Available agents: …; the code is the same.
  • failed — framework_agent_resolution_failed, with the original error as cause. Retryable unless the cause is classified retryable: false (an unclassified throw counts as transient).
  • fromNotFoundAnswer — a client's rebuild of a peer's 404 framework_agent_not_found answer: the peer's message, no availableTypes (the typed body does not carry them).
  • isAgentNotFoundError / isAgentResolutionError match on name plus the code(s), never instanceof, so an error thrown by a second copy of core is still recognised. Use isAgentNotFoundError to tell a registry miss from a resolver that failed.
typescript
try {
  registry.get(agentType);
} catch (error) {
  if (isAgentNotFoundError(error)) {
    console.log(
      `Unknown agent: ${error.agentType}; available: ${error.availableTypes?.join(', ')}`
    );
  }
  throw error;
}

StaleStateError ​

Thrown when a state save fails due to version mismatch (optimistic concurrency).

typescript
class StaleStateError extends Error {
  readonly sessionId: string;
  readonly expectedVersion: number;
  readonly actualVersion: number;
}

CommitNotAppliedError / CommitOutcomeUnknownError ​

A commit's answer after a transport failure (a dropped connection, a timeout, a failed read before the write). Every built-in store resolves the outcome itself before it answers: applied (the normal value), lost a race (StaleStateError), or one of these two. See A commit's outcome after a transport failure.

typescript
import {
  CommitNotAppliedError,
  CommitOutcomeUnknownError,
  COMMIT_OUTCOME_UNKNOWN_HINT,
  isCommitNotAppliedError,
  isCommitOutcomeUnknownError,
  type GuardedCommitKind, // 'append' | 'run_start' | 'truncate' | 'promote' | 'step' | 'state'
} from '@helix-agents/core';

class CommitNotAppliedError extends HelixError {
  // code 'transport_error', retryable: the store wrote nothing and the pinned version
  // still holds, so the commit can be repeated as-is. `cause` is the original failure.
  constructor(opts: { sessionId: string; commit: GuardedCommitKind; cause: unknown });
  readonly sessionId: string;
  readonly commit: GuardedCommitKind;
}

class CommitOutcomeUnknownError extends HelixError {
  // code 'transport_error', NOT retryable: the read-back failed too, so the commit may have
  // landed. Re-read the session (its version, its latest checkpoint) before repeating it.
  constructor(opts: {
    sessionId: string;
    commit: GuardedCommitKind;
    checkpointId?: string;
    cause: unknown;
    readBackError?: unknown;
  });
  readonly sessionId: string;
  readonly commit: GuardedCommitKind;
  readonly checkpointId: string | undefined; // the checkpoint the commit would have written
  readonly readBackError: unknown;
}

// How every CommitOutcomeUnknownError message starts (first, so an HTTP body cut to 256
// characters keeps it): 'Commit outcome unknown; re-read the session, do not resend'
const COMMIT_OUTCOME_UNKNOWN_HINT: string;

"Unknown" is never permanent: isPermanentStoreError is false for it, and classifyEntryFailure classes it transient (the next attempt re-reads), so a Durable Object wake re-arms instead of ending a run whose commit may have landed. Temporal hands it to the workflow non-retryable (its step commit is not yet landed-aware, FU-TEMPORAL-COMMITSTEP-LANDED-AWARE), and DBOS re-attempts its entry step, whose already-committed path then decides. The is* guards match by name, so they work across copies of core.

StepCommitConflictError ​

A step commit lost a version race while its run was still current and live (runtime-js / the CF DO). The run fails: state_concurrency_conflict, non-retryable, with the wrapped StaleStateError as cause. Not a takeover: a commit lost to a newer run is RunSupersededError.

typescript
class StepCommitConflictError extends Error {
  readonly code: 'state_concurrency_conflict';
  readonly retryable: false;
  readonly sessionId: string;
}

FencingTokenMismatchError ​

Thrown when a fencing token doesn't match expected value (split-brain detection).

typescript
class FencingTokenMismatchError extends Error {
  readonly sessionId: string;
  readonly expectedToken: number;
}

StreamTruncatedError ​

Thrown on an attached reader whose cursor is past the truncation point when cleanupToStep(N) removes chunks the reader has not yet yielded (G4). The reader's next next() throws; subsequent calls return done. Re-attach via createResumableReader({ fromSequence }) to continue.

typescript
class StreamTruncatedError extends Error {
  readonly streamId: string;
  readonly lastValidSequence: number; // highest sequence yielded before truncation
  readonly truncatedAtStep: number; // stepCount passed to cleanupToStep
}

Error Classification ​

Unified error classification system for categorizing and handling errors across the framework.

HelixError ​

Base error class with typed error codes and categories. Uses Symbol.for('helix.agents.error') for cross-package detection.

typescript
import { HelixError } from '@helix-agents/core';
import type { ErrorCode, ErrorCategory, HelixErrorOptions } from '@helix-agents/core';

const error = new HelixError({
  message: 'Provider overloaded',
  code: 'provider_overloaded',
  retryable: true,
  statusCode: 503,
});

error.code; // 'provider_overloaded'
error.category; // 'provider' (auto-derived from code prefix)
error.retryable; // true
error.statusCode; // 503

// Cross-package type check (works across different package versions)
if (HelixError.isInstance(someError)) {
  console.log(someError.code);
}

Types:

  • ErrorCategory — 'provider' | 'tool' | 'state' | 'transport' | 'validation' | 'framework'
  • ErrorCode — specific codes, exhaustively checked at compile time (ERROR_CODE_EXHAUSTIVENESS). Category is derived from the code prefix (e.g., provider_overloaded → provider).

Error Codes:

CategoryCodes
providerprovider_overloaded, provider_rate_limited, provider_auth_error, provider_content_filtered, provider_refused, provider_timeout, provider_unreachable, provider_invalid_request, provider_error
tooltool_input_invalid, tool_execution_failed, tool_not_found, tool_timeout
statestate_concurrency_conflict, state_session_not_found, state_already_running, state_not_resumable, state_history_incomplete, state_checkpoint_not_found, state_checkpoint_foreign, state_checkpoint_sequence_unavailable, state_run_start_conflict, state_run_superseded, state_step_messages_uncommitted, state_commit_stopped, state_not_stoppable, state_agent_type_mismatch, state_request_key_conflict, state_request_already_completed, state_size_exceeded, state_sub_session_ref_conflict, state_sub_session_ref_missing
transporttransport_error, transport_stream_drop, transport_stream_evicted, transport_stream_run_changed, transport_remote_agent_failed, transport_remote_entry_outcome_unknown
validationvalidation_error, validation_payload_too_large
frameworkframework_internal_error, framework_not_supported, framework_companion_not_supported, framework_cancelled, framework_completion_tool_missing, framework_completion_strategy_unavailable, framework_completion_schema_unsupported, framework_completion_protocol_violation, framework_completion_invalid_output, framework_forced_completion_exhausted, framework_prompt_prefix_changed, framework_skills_unavailable, framework_max_steps_exhausted, framework_agent_not_found, framework_agent_resolution_failed, framework_agent_name_conflict, framework_auto_resume_exhausted, framework_usage_store_unavailable, framework_hook_failed, framework_subagent_wait_bound, framework_subagent_lost, framework_subagent_status_unavailable

The framework_completion_* / framework_forced_completion_exhausted codes are dedicated to the forced-completion phase and its early refusal, and are never collapsed into a generic message — see that section for when each fires. framework_prompt_prefix_changed (non-retryable) is the prompt-prefix refusal; framework_skills_unavailable (retryable) is a skills provider that threw while the catalog was resolved (fail closed). framework_max_steps_exhausted (non-retryable) is the maxSteps cut-off of a run without outputSchema; retry() continues such a run with a fresh budget. framework_subagent_wait_bound (non-retryable) is a sub-agent its parent stopped because the parent's wait reached its bound (CF Workflows' ephemeral sub-agent park): the parent's failed sub-agent result carries it as metadata.helixErrorDetail (COMMON_METADATA_KEYS.ERROR_DETAIL). framework_subagent_lost (non-retryable) is a remote sub-agent the parent settled as lost (core remoteChildVerdict: still running past the child's deadline, not known to the producer, refused typed, unreadable past the deadline, or its tool gone), carried the same way, with cause deadline_exceeded, not_found, refused:<code>, status_unreachable or tool_unavailable. framework_subagent_status_unavailable (retryable, RemoteChildStatusUnavailableError) refuses an entry carrying input while a remote child's status cannot be read inside its deadline; nothing is written. It carries the deadline as a retry hint, retryAt (epoch ms): on the error (HelixError.retryAt, any HelixError may set one), in its ErrorDetail (ErrorDetail.retryAt, kept by errorDetailSchema()), in the typed HTTP body and in a peer client's rebuilt error (helixErrorFromPeerBody). state_sub_session_ref_conflict (SubSessionRefConflictError, non-retryable) and state_sub_session_ref_missing (SubSessionRefMissingError) are a commit's remote settlement refused ("Remote settlement (Invariant S)" under Companion Tools).

state_size_exceeded (non-retryable, HTTP 413) is StateSizeExceededError: a state or message write larger than the store binds (MA-31). One tool's staged writes refused this way become that tool's typed failed result (helixSynthetic.kind: 'state_write_rejected') and the run continues; a tool or sub-agent result row over the limit becomes the typed result-too-large row and the run continues (runtime-js and the CF DO measure the row before the store would refuse it; CF Workflows uses its 1 MiB step-result cap); a step or terminal commit (the folded state, the model's reply) refused this way fails the run.

The agent-resolution and DO-drive codes (WA MR1):

CodeRetryableHTTP (httpStatusForHelixError)Raised by
framework_agent_not_foundno404AgentResolutionError.notFound: a registry or resolver has no such agent type
framework_agent_resolution_failedfrom its cause (unclassified: yes)503, else 500AgentResolutionError.failed: a resolver or LLM-adapter factory threw while the CF DO materialized the agent
framework_agent_name_conflictno500DBOS registerAgent: a different config object under a held agent name (one config per name per process)
framework_auto_resume_exhaustedyesnone (a wake failure)the CF DO crash-loop guard: a stranded run ends failed after maxWakeResumeAttempts, with the last re-armed failure as its cause
framework_usage_store_unavailableyes503the CF DO's consumer usageStore factory threw; the entry stops, with no fallback to the internal store
framework_hook_failedfrom its cause (unclassified: yes)503, else 500a CF DO beforeEntry hook or transformRequest threw
state_agent_type_mismatchno409a CF DO /start on an existing session, /resume or /retry named a different agent type than the session was created with
validation_payload_too_largeno413payloadTooLargeError: a client-tool answer (cause: 'tool_result') or submit body ('request_body') over the executor's submit limit; nothing stored (DI-51)

The status column is the row before a commit: once the entry's input committed, a retryable error gets its non-retryable row (typedErrorResponse(…, { inputCommitted: true })).

The three history/checkpoint codes are non-retryable:

  • state_history_incomplete — the store returned fewer (or more) messages than its live row count, or threw while paging. The run fails with this code in errorDetail and no model call is made. Thrown by loadLLMHistory.
  • state_checkpoint_not_found — an explicit checkpointId passed to retry(), resume({ mode: 'from_checkpoint' }) or branch does not exist. Built by checkpointNotFoundError. HTTP 404.
  • state_checkpoint_foreign — that checkpointId belongs to a different session (CheckpointForeignError, carrying checkpointId, sessionId and ownerSessionId). Rejected before any state change, on every runtime. Over HTTP it is answered exactly as state_checkpoint_not_found (404, the same code and message), so a client cannot learn that the id exists in another session.

Both checkpoint codes come from resolveCheckpoint and reach the caller as a thrown HelixError from the executor call itself (execute() / resume() / retry()), before any workflow or instance starts, on every runtime.

state_history_incomplete errors carry a typed reason (HistoryIncompleteReason): short_read, corrupt_row, store_error, or snapshot_unstable (the fenced snapshot reader found no stable pass; retryable). A corrupt_row is non-retryable. Hosts put the reason on the wire as cause (for example a snapshot route's 503 {error, code, cause} for snapshot_unstable).

The codes this release added:

  • state_checkpoint_sequence_unavailable (retryable) — reading the stream head for a commit's streamSequence threw (resolveCommitSequence). Nothing is written; the runtime's native retry applies, then the run fails with this code. There is no silent 0 any more.
  • state_run_start_conflict — the code of RunStartConflictError and RunStartRejectedError. A chat host answers a refused run start that is not "someone else is driving" with a 409 carrying it.
  • state_run_superseded (non-retryable) — the code of RunSupersededError. A late client-tool submit to a superseded run answers 409 with it.
  • state_step_messages_uncommitted (non-retryable) — an internal DBOS guard: a turn tried to end while some of its step messages were never committed.
  • state_commit_stopped (non-retryable) — the code of CommitStoppedError: a commit found a stop record for its run and wrote nothing; the run ends by the stop.
  • state_not_stoppable (non-retryable) — a stop found no live run and wrote nothing (HTTP 409, with currentStatus).
  • transport_remote_entry_outcome_unknown (non-retryable) — the code of RemoteEntryOutcomeUnknownError: a remote /start or /resume got no definite answer (the fetch threw, or a 5xx that is not the host's typed retryable answer), so whether the remote run started is unknown. The transports never re-send an entry (CP-95).

The two request-key codes (D-2) are non-retryable, HTTP 409, and write nothing. The typed body carries the runId they name (typedErrorBody):

  • state_request_key_conflict — the code of RequestKeyConflictError: the request key was already used for a different request (another digest) in this session. runId is the run that holds the key.
  • state_request_already_completed — the code of RequestAlreadyCompletedError: the key's run already ended and a later run of the session exists. runId is the run that answered the request. Re-anchor through the snapshot, which holds that turn.

The run-start / supersede error classes are plain Error subclasses, not HelixError. classifyError matches them by name (so a revived error from a durable-runtime boundary classifies the same): RunSupersededError → state_run_superseded, RunStartRejectedError → state_run_start_conflict, CommitStoppedError → state_commit_stopped, all non-retryable. A RunStartConflictError classifies as state_already_running (it is an AgentAlreadyRunningError).

runStartRejectCauseSchema() (a z.enum of exactly RunStartRejectedError's ten rejectCause values, request_key_taken included) is the one schema for a reject cause read from untrusted input. A client that rebuilds a rejection from a peer's cause (DOFrontendExecutor) and a host that echoes a cause onto the wire (handleChatStream) parse with it, so free text never passes as a cause.

classifyError ​

Convert any error to a typed HelixError:

typescript
import { classifyError } from '@helix-agents/core';

const classified = classifyError(unknownError);
// Returns HelixError with appropriate code, category, retryable

Handles HelixError (pass-through), AbortError → framework_cancelled, internal agent state errors → specific state codes, generic errors → framework_internal_error.

classifyKnownError ​

The same classification without the fallback: a HelixError for a HelixError, an abort or a framework error class, and undefined for anything else.

typescript
import { classifyKnownError } from '@helix-agents/core';

classifyKnownError(new StaleStateError('s', 1, 2))?.code; // 'state_concurrency_conflict'
classifyKnownError(new Error('boom')); // undefined

resolveErrorDetail ​

resolveErrorDetail(error: unknown, detail?: ErrorDetail): ErrorDetail resolves the ErrorDetail a runtime persists, streams and returns for a terminal failure:

  1. an explicit detail;
  2. otherwise the first attached detail (fromErrorDetail) or classifyKnownError classification on the error or its cause chain, outermost first (cycle-safe, depth 8). From a cause it takes code, category and retryable, keeps the outer message and sets cause to the wrapped message;
  3. otherwise toErrorDetail(error): the message, with no code unless the raw value carries one. A boolean retryable property on a raw Error (Cloudflare platform errors carry one) is kept, and so are workerd's own overloaded / remote flags when true (ErrorDetail.flags, DI-76; nothing else is copied).

toErrorDetail of a HelixError records its cause as a nested ErrorDetail when the cause carries structure (a HelixError, or an Error with retryable or flags), else as the cause's message, so ErrorDetail.cause is a string or an ErrorDetail: a reader handles both (DI-76).

When the outermost error is itself classified (not read from an attached detail) and wraps a cause, that cause is recorded as its own resolved ErrorDetail rather than a bare message.

It never infers a code from message text and never returns framework_internal_error for an unrecognised error.

ErrorDetail.cause is string | ErrorDetail, nested at most ERROR_DETAIL_MAX_CAUSE_DEPTH (8) deep; a deeper level, or a repeated error in a cycle, stays a message string. errorDetailSchema() parses the recursive shape (the stream, remote-protocol and every other DTO use it), and sanitizeErrorDetail scrubs every level's message and string cause. A CF DO crash-loop exhaustion, for example, carries the last re-armed failure as a structured cause:

typescript
{
  message: 'Run could not be auto-resumed after 5 wake attempts',
  code: 'framework_auto_resume_exhausted',
  category: 'framework',
  retryable: true,
  cause: { message: "Agent 'chat' could not be materialized: …", code: 'framework_agent_resolution_failed', retryable: true },
}

withoutUnknownCode(detail: ErrorDetail): ErrorDetail returns detail without a code that is not a known ErrorCode, and detail itself (the same object) when its code is absent or known. Every other field (message, category, retryable, cause) is kept, and no code is invented. It is the one rule for a forced-completion call's own LLM error: runtime-js (classifyForcedCompletionResult), Temporal and CF Workflows (their retryable forced-error rethrow) all use it, so the run's errorDetail, the closed forced phase and the stream agree. A work call's error keeps its code.

Session not found ​

sessionNotFoundError(sessionId: string | null, options?: { where?: string; context?: string }): HelixError is the one builder of the refusal of a session that does not exist: code: 'state_session_not_found' (category state), retryable: false. Every runtime and store that refuses a missing session throws it, so a caller classifies the refusal on code. The message is Session not found: <id>; where adds where it was looked for (Session not found on this Durable Object: <id>), context what the caller was doing (Session not found: <id> (cannot branch from it)), and a null id reads No agent session exists <where>. (a Durable Object asked about the session it should hold).

sessionNotFoundDetail(sessionId: string, context?: string): ErrorDetail is the matching ErrorDetail a handle's result() reports when its session was deleted and the handle holds no outcome of its own run.

Checkpoint not found and foreign ​

typescript
import {
  checkpointNotFoundError,
  checkpointForeignError,
  CheckpointForeignError,
  isCheckpointForeignError,
  type CheckpointForeignErrorInput, // { checkpointId; sessionId; ownerSessionId }
} from '@helix-agents/core';

checkpointNotFoundError(checkpointId: string, sessionId: string): HelixError;
checkpointForeignError(input: CheckpointForeignErrorInput): CheckpointForeignError;
isCheckpointForeignError(error: unknown): error is CheckpointForeignError;

checkpointNotFoundError is the one builder of state_checkpoint_not_found for an id that names no checkpoint (message Checkpoint '<id>' not found for session <sessionId>., non-retryable). CheckpointForeignError is state_checkpoint_foreign for an id that names another session's checkpoint, with the three ids as fields. Both are thrown by resolveCheckpoint; in-process the foreign error keeps its own code and fields (a diagnostic for the trusted caller), and over HTTP typedErrorBody answers it exactly as the not-found one. isCheckpointForeignError is structural (HelixError.isInstance, the code and string fields), so a second copy of core still matches.

Typed HTTP errors ​

One contract for every HTTP host that answers a HelixError — the CF Durable Object (/start, /retry, its completion refusals), @helix-agents/agent-server and ai-sdk handleChatStream:

typescript
import {
  httpStatusForHelixError,
  typedErrorBody,
  typedErrorResponse,
  helixErrorFromDetail,
  helixErrorFromPeerBody,
  peerErrorBodySchema,
  type PeerErrorBody,
  type TypedErrorBody,
  type TypedErrorBodyOptions,
  type TypedErrorResponse,
  type TypedErrorResponseOptions,
} from '@helix-agents/core';

httpStatusForHelixError(error: HelixError): number;
typedErrorBody(error: HelixError, options?: TypedErrorBodyOptions): TypedErrorBody; // { error: message, code, cause? }
typedErrorResponse(
  error: HelixError,
  options?: TypedErrorResponseOptions // { inputCommitted?: boolean; cause?: string; logger?: Pick<Logger, 'warn'> }
): TypedErrorResponse; // { status: number; body: TypedErrorBody }, message scrubbed
helixErrorFromDetail(detail: ErrorDetail): HelixError & ErrorWithDetail;
helixErrorFromPeerBody(
  body: PeerErrorBody,
  status: number,
  context?: { sessionId?: string } // names the session in a rebuilt state_size_exceeded
): HelixError & ErrorWithDetail;

interface TypedErrorBody {
  error: string;
  code: string;
  cause?: string; // when the host names one (options.cause); a size refusal's own `what` / `operation`
  retryable?: boolean; // the two size refusals only (always false)
  limitBytes?: number; // the two size refusals only
  sizeBytes?: number; // the two size refusals only
  runId?: string; // D-2: only for RequestKeyConflictError / RequestAlreadyCompletedError
  retryAt?: number; // a retry hint (HelixError.retryAt, epoch ms); RM-100: the remote child's deadline
}
// peerErrorBodySchema(): z.object({ error, code, cause?, runId?, retryable?, limitBytes?, sizeBytes?, retryAt? })
ErrorStatus
framework_agent_not_found404
state_session_not_found, state_checkpoint_not_found, state_checkpoint_foreign (the not-found row group)404
a completion-support refusal (isCompletionSupportRefusal)400
validation_payload_too_large (DI-51), state_size_exceeded (MA-31)413
validation_error400
any other error with retryable: true503
a non-retryable state error (state_history_incomplete, state_already_running, state_run_start_conflict, …)409
anything else500

The not-found and caller-error rows win over retryable, so 503 ⇔ retryable, and a missing session or checkpoint is 404 on every host (it used to be the state category's 409). typedErrorBody keeps the message verbatim. It carries cause only when options.cause names one (a typed reason, such as a rejected run start's rejectCause); the error's own cause chain is never serialised. Without options.cause the body is exactly { error, code }, except for the two request-key errors (RequestKeyConflictError, RequestAlreadyCompletedError), whose body also carries the runId they name, so a client can rebuild the typed error (DOFrontendExecutor does), and the two size refusals (validation_payload_too_large, state_size_exceeded), whose body is { error, code, retryable: false, cause, limitBytes, sizeBytes? } (below).

typedErrorResponse is what a host answers with: the status above and { error, code } (plus options.cause), with the message scrubbed by sanitizeErrorMessage (control characters stripped, known secrets redacted, at most 256 characters; never HTML-escaped, see Error text on outward surfaces). It is the one builder of a typed HTTP error: the CF Durable Object's /start / /retry entry failures and its completion refusals, handleChatStream's typed SEND failures and @helix-agents/agent-server's completion refusal all use it, so the DO and agent-server answer the same refusal with the same bytes. No host answers with a bare typedErrorBody. Pass { inputCommitted: true } when the request's input is already committed (its run exists, and something after the run-start commit failed): a retryable: true error then gets its non-retryable row (409 for a state error, else 500), never 503, because re-sending the request would append the input twice. The body keeps the error's own code.

typescript
const { status, body } = typedErrorResponse(error, { inputCommitted: runExists, logger });
return Response.json(body, { status });

A foreign checkpoint (state_checkpoint_foreign) is serialised as a missing one: typedErrorBody answers checkpointNotFoundError's code and message for its checkpointId and sessionId, never the owning session. When the host passes logger, typedErrorResponse logs the foreign detail at warn (http.checkpoint_foreign_answered_not_found, with checkpointId, sessionId and ownerSessionId), the only place it is kept. The CF DO's native and remote-protocol routes, handleChatStream and agent-server's /start / /resume pass theirs.

Error text on outward surfaces ​

Error free text never leaves the process raw (FU-RAW-ERROR-TEXT-SURFACES). An error's message, an ErrorDetail's message / cause, SessionState.error and a stream's failure text can carry credentials (a database URL, an API key, a password= pair). They stay raw only in the process, in the store's session row and in the server log. Every byte that leaves for an HTTP / SSE / WebSocket client or a remote-agent parent goes through one sanitizer. Machine fields (code, category, retryable, errorCode, recoverable) are never changed.

FunctionUse forBehaviour
sanitizeErrorText(text, { maxLength?, keepLineBreaks? })the one implementationstrips control characters (keeps \n / \t with keepLineBreaks), runs redactKnownPatterns, then truncates to maxLength
sanitizeErrorMessage(text)run-level text: an HTTP error, a run's failure, a failed stream's messageone line, at most RUN_ERROR_TEXT_MAX_LENGTH (256) characters
sanitizeToolErrorText(text)a tool's error text: tool_end.error, a tool input / output error, a reloaded tool result's errorTextline breaks kept, no cap
sanitizeErrorDetail(detail)an ErrorDetailmessage and every level of cause via sanitizeErrorMessage
sanitizeStreamChunk(chunk)a stream chunkan error chunk and a forced_completion error as run-level text, the tool error chunks as tool-level text, a tool_end's typed priorResult.errorDetail (DI-70; never its result); a chunk with no error text is returned as is

Every one is a fixed point: sanitizing sanitized text changes nothing, so a stream's text re-read at another wire stays byte-identical. Still, one layer sanitizes: the DO's /snapshot carries the stream's stored text as is.

Nothing is HTML-escaped: no framework surface emits HTML. Every one is JSON or an SSE / WebSocket JSON line that clients render as text, where &lt; would show literally. A consumer that injects the text as HTML escapes it itself.

Every stream manager sanitizes on write. A stream's readers are all outside the process (a browser, agent-server /sse, a remote parent), so a StreamManager stores and delivers only sanitized text: each written chunk through sanitizeStreamChunk, and failStream's message and detail through sanitizeErrorMessage / sanitizeErrorDetail. So getStreamInfo().metadata.error / errorDetail, the synthesized failure chunk, replays and every live frame carry sanitized text. The contract cases SR.chunk-error-text-sanitized and SR.fail-text-sanitized hold every manager to it. A new outward surface uses these functions, and its test throws SECRET_ERROR_TEXT and checks it with expectNoSecret (both from @helix-agents/core/testing).

The model's view of a failed tool (its tool result message) is not covered yet: it is raw on every runtime (FU-RAW-ERROR-TEXT-MODEL-SINK).

helixErrorFromDetail turns a structured detail (e.g. a handle's preCommitFailure, or resolveErrorDetail(thrown)) into the HelixError to answer: a known code keeps its code, category and retryability; an absent or extension code becomes framework_internal_error on the error itself (the HTTP status and body need a code). Either way the error keeps the detail's own retryable (false when the detail does not say), so a detail with retryable: true answers 503, never an invented 500.

The source detail is never lost: the error carries a copy of it as errorDetail, so resolveErrorDetail(error) and getErrorDetail(error) return it verbatim (its own code or no code, category, cause and retryable). A caller that catches the rebuilt error (e.g. from agent-server's startAgent / resumeAgent) and records it never reads an invented framework_internal_error.

The submit size refusal (DI-51). payloadTooLargeError({ what, limitBytes, sizeBytes?, toolCallId?, message? }) builds PayloadTooLargeError (a HelixError: code validation_payload_too_large, category validation, never retryable, status 413), isPayloadTooLargeError checks it by shape. typedErrorBody / typedErrorResponse answer it { error, code, retryable: false, cause: what, limitBytes, sizeBytes? } (what is tool_result or request_body); helixErrorFromPeerBody rebuilds it from that body, so a forwarded refusal keeps its fields. The one check: checkSubmitResultSize(result, limits?, toolCallId?) (the UTF-8 bytes of JSON.stringify(result), measureSubmitResultBytes; the submit schema refuses at the same byte), assertSubmitWithinLimits(submit, limits?) (what every runtime's submitToolResult and routeSubmitToolResult({ maxResultBytes? }) call first), submitRequestBodyCap(limits?) (the derived transport guard, maxResultBytes × 4). The executor owns the limits: resolveSubmitLimits(schema?) reads an executor's submitToolResultSchema option (SubmitToolResultSchemaOption) into AgentExecutor.submitLimits; a host validates with submitToolResultSchemaFor(limits) (both submit variants, limits stamped; submitToolResultSchema() is submitToolResultSchemaFor()).

StateSizeExceededError ​

The one typed refusal of a state or message write that is too large for the store (MA-31). Every store throws it at the site that binds the bytes (DO-SQLite, D1 and Redis before the bind; Postgres maps SQLSTATE 54000 after it, the statement writing nothing), and a runtime turns it into a typed failed tool result or a typed run failure (see State size and storage limits).

typescript
import {
  StateSizeExceededError, // extends HelixError: code 'state_size_exceeded', category 'state', retryable: false
  stateSizeExceededError, // the one builder
  isStateSizeExceededError, // by shape (code, operation, numeric limitBytes / sizeBytes), not instanceof
  stateSizeExceededMessage,
  STATE_SIZE_OPERATIONS, // ['stage', 'step', 'state', 'append']
  type StateSizeExceededInput,
  type StateSizeOperation,
} from '@helix-agents/core';

stateSizeExceededError({
  operation, // 'stage' (one tool's staged writes) | 'step' (a step commit) | 'state' (a state commit) | 'append' (a message row)
  sessionId,
  limitBytes, // the limit that was exceeded
  sizeBytes, // a finite integer over limitBytes (a lower bound when the measure stopped early)
  changedKeys, // the top-level customState keys the write touched (never values)
  toolCallId, // optional: the call whose write or result row was refused
  message, // optional: built from the fields when absent
  cause, // optional: the driver error a store mapped
});

operation names what was measured, not which runtime path failed: a step commit refuses an oversized output or pending client-tool map as state, so recognise a refusal by its class or code (isStateSizeExceededError), never by operation or the message text. HTTP: 413 on every route, body { error, code: 'state_size_exceeded', retryable: false, cause: <operation>, limitBytes, sizeBytes } (never the keys); helixErrorFromPeerBody(body, status, { sessionId }) rebuilds it as itself, so a forwarded hop (a DO-to-DO submit, DOFrontendExecutor) keeps it typed. createHelixChatTransport in @helix-agents/ai-sdk throws it for such a body.

Store limits. A store declares what it enforces as SessionStateStore.limits?: StoreLimits, in UTF-8 bytes of the JSON it binds (Infinity, or no limits, means none):

typescript
import {
  UNLIMITED_STORE_LIMITS, // memory, Postgres
  uniformStoreLimits, // one limit for every operation (the CF stores' maxStateBytes)
  limitFor, // (limits, operation) => the limit that operation is measured against
  utf8ByteLength,
  messageRowJson, // (message) => the JSON a store binds for one message row
  messageRowBytes, // (message) => utf8ByteLength(messageRowJson(message))
  BoundSizeWitness,
  assertSubmitLimitFitsStore,
  type StoreLimits, // { maxStateBytes, maxStagedWriteBytes, maxMessageBytes }
  type BoundSizeContext, // { changedKeys?, toolCallId? }
} from '@helix-agents/core';
  • BoundSizeWitness(sessionId, limits, platformMaxValueBytes?) measures what one store operation binds: assert refuses a value over its operation's limit, assertMessageRow('step' | 'append', json) measures one message row against maxMessageBytes, assertMessage('step' | 'append', message) serializes the row with messageRowJson, refuses it through assertMessageRow (a tool row names its call) and returns the JSON to bind, record only measures (a checkpoint's copy of state the row already holds), and mapDriverError(operation, error) turns a driver throw into the typed error when, and only when, a value it measured was over the platform's own cap (the measured evidence, never the driver's text); otherwise it returns null. largestBoundBytes / largestBoundOperation report the largest measured bind.
  • assertSubmitLimitFitsStore(submitLimits, storeLimits, owner) throws a typed, non-retryable validation_error when a submit maxResultBytes is over the store's finite maxStateBytes (a submitted answer waits in the stored pending map, so such a configuration can never work). JSAgentExecutor, CloudflareAgentExecutor and the CF Durable Object call it at construction; a store with no limit is unaffected.
  • A runtime may measure the folded customState against maxStateBytes before a step commit (runtime-js does) to fail earlier with the step's keys; the store's own check at the bind is the truth.
  • messageRowBytes is the one measure behind maxMessageBytes (A12): the UTF-8 bytes of the whole Message, JSON.stringify'd, exactly as the DO and D1 stores insert it. Every store whose maxMessageBytes is finite (today the DO and D1 stores) refuses a row through assertMessage: a row of exactly the limit is accepted, one byte over is refused (contract row.threshold-is-messageRowBytes). runtime-js's pre-check (fitResultRow) measures the same bytes, so the two cannot drift. Redis, Postgres and the memory store declare Infinity.

Stream limits (RM-90). A stream manager declares what it enforces as StreamManager.limits?: StreamLimits ({ maxChunkBytes }, UTF-8 bytes of the JSON it binds for one chunk and for each part of an end's terminal payload; Infinity, or no limits, means none). The chunk refusal is the same StateSizeExceededError, with one more operation: STATE_SIZE_OPERATIONS is now stage | step | state | append | stream_chunk, and the state-store helpers above (limitFor, BoundSizeWitness) take the narrower StoreSizeOperation (STORE_SIZE_OPERATIONS, the first four). An end is never refused: a part of its terminal payload over the limit is omitted and named in StreamMetadata.endOmitted and the end frame's omitted (see Stream wire and the remote consumer's recovery rule below).

typescript
import {
  UNLIMITED_STREAM_LIMITS, // memory, Redis (unless configured)
  streamLimits, // (maxChunkBytes, { platformMaxBytes? }) => StreamLimits; typed validation_error if invalid / over the cap
  assertStreamChunkFits, // (streamId, json, limits) => bytes; refuses `stream_chunk`
  assertStorableStreamChunk, // (streamId, chunk): one streamChunkSchema() safeParse; typed validation_error if invalid (T0)
  fitStreamEnd, // (finalOutput, terminal, limits) => { finalOutput, terminal, omitted, omissions }: drops each oversized part
  warnStreamEndOmissions, // (logger, streamId, fitted, limits): warns `core.stream.end_parts_omitted` once per end
  parseStreamEndOmitted, // (storedJson) => StreamEndPart[] | undefined, Zod-checked
  streamEndOmittedFrom, // (value) => StreamEndPart[] | undefined, for wire data
  STREAM_END_PARTS, // ['output', 'state', 'usage']
  withChunkSplit, // (writer, pieceBytes?) => a writer that splits oversized model deltas
  splitPieceBytesFor, // (limits?) => 64 KiB, or half a smaller limit
  DEFAULT_SPLIT_PIECE_BYTES,
  type StreamLimits,
  type StreamEndPart,
} from '@helix-agents/core';
  • Every built-in manager measures the JSON it binds and refuses a chunk over maxChunkBytes BEFORE it allocates a sequence (no gap). It ends a stream whatever its end's size: a part over the limit is not stored, the end names it (omitted) and the manager warns core.stream.end_parts_omitted:

    ManagerDefault limit
    The CF DO (maxStreamChunkBytes)1.8 MB
    store-cloudflare's StreamServer binding (maxChunkBytes)1.8 MB
    Memory, RedisInfinity (configurable)

    The contract cases are CS.* in streamManagerContractTests. Its new required option expectedDefaultMaxChunkBytes pins each store's default, and the optional createManagerWithLimit runs the refusal and omission cases at a configured limit.

  • withChunkSplit is applied to every runtime's model-attempt writer (runtime-js / the CF DO, CF Workflows, Temporal, DBOS). It splits a text_delta, a streaming thinking delta and a tool_arg_stream_delta into pieces of at most splitPieceBytesFor(streamManager.limits) stored bytes, on code-point boundaries, keeping the chunk's run, attempt and step. It never splits a chunk carrying remoteSource (RM-86 dedups on its child sequence) or any other type.

Reading a request body under a byte cap. readRequestText(request, { maxBytes }) is the one reader every Helix route that reads a body uses (Workers-safe: web streams and TextDecoder). A declared Content-Length over maxBytes is refused before anything is read. Otherwise the body is streamed counting BYTES (never UTF-16 units) and cancelled at the first chunk past the cap, so an oversized body, declared or streamed without a length, is never buffered in full. It answers { ok: true, text } or { ok: false, error }, where error is the typed 413 above (payloadTooLargeError({ what: 'request_body', limitBytes, sizeBytes? }); sizeBytes is the declared length, absent when the stream refused it). Pass request.clone() to leave the original unread. DEFAULT_MAX_REQUEST_BYTES (10 MB) is the default cap of an entry body (the chat route's maxRequestBytes, the DO's and the remote-agent worker handler's maxRequestBytes); MAX_STOP_REQUEST_BYTES (4096) caps every stop route's body. A host that reads a chat body itself (the route in front of handleChatStream) should read it with readRequestText too.

peerErrorBodySchema() is the one schema a client reads a typed error body with ({ error, code, cause?, runId?, retryable?, limitBytes?, sizeBytes? }; a body without a code is not one). helixErrorFromPeerBody(body, status) is the one builder a client rebuilds the error with: helixErrorFromDetail of the body's error, code and cause, with retryable from the body, else exactly on a 503. The CF Durable Object client (DOFrontendExecutor) and core remoteProtocolError (both remote-agent transports) use them.

isKnownErrorCategory ​

ErrorDetail.category is widened to ErrorCategory | (string & {}) because it arrives from loose DTO schemas. Use this type guard to narrow such a value to the framework ErrorCategory union. It is exhaustive over ErrorCategory, so never re-declare the category list:

typescript
import { isKnownErrorCategory } from '@helix-agents/core';

if (isKnownErrorCategory(detail.category)) {
  detail.category; // ErrorCategory
}

extractErrorMessage ​

Extract a string message from any error type:

typescript
import { extractErrorMessage } from '@helix-agents/core';

extractErrorMessage(new Error('test')); // 'test'
extractErrorMessage({ message: 'Overloaded' }); // 'Overloaded'
extractErrorMessage('plain string'); // 'plain string'
extractErrorMessage(null); // 'null'

ensureError ​

Convert unknown values to Error instances, preserving the original as cause:

typescript
import { ensureError } from '@helix-agents/core';

const err = ensureError({ message: 'Overloaded' });
err instanceof Error; // true
err.message; // 'Overloaded'
err.cause; // { message: 'Overloaded' }

Checkpoints ​

Types and utilities for checkpoint management.

Checkpoint Types ​

typescript
import type { Checkpoint, CheckpointMeta, ParsedCheckpointId } from '@helix-agents/core';

interface Checkpoint<TState = unknown, TOutput = unknown> {
  id: string; // Unique checkpoint ID
  sessionId: string; // Session this checkpoint belongs to
  stepCount: number; // Step count when created
  timestamp: number; // Creation time (ms since epoch)
  state: AgentState<TState, TOutput>; // Complete agent state
  messageCount: number; // Message count at checkpoint (for recovery coordination)
  streamSequence: number; // Live stream head at the commit (-1 = no stream)
  kind: CommitKind | null; // the commit that wrote it; null = legacy / createCheckpoint row
  runId: string | null; // the run that wrote it; null on legacy and `clone` rows
  keepCount: number | null; // a `truncate` checkpoint's kept count, else null
}

interface CheckpointMeta {
  id: string;
  sessionId: string;
  stepCount: number;
  timestamp: number;
  status: AgentStatus;
}

kind, runId and keepCount are new and required on the type (breaking for code that builds Checkpoint objects). Stores parse a stored kind with parseStoredCommitKind: a missing value reads as null, an unrecognised one throws UnknownCommitKindError. See Commits and checkpoints.

Checkpoint ID Utilities ​

typescript
import {
  generateCheckpointId,
  parseCheckpointId,
  CHECKPOINT_ID_VERSION,
  CHECKPOINT_ID_PREFIX,
} from '@helix-agents/core';

// Generate a new checkpoint ID
const id = generateCheckpointId('session-123', 5);
// Returns: 'cpv1-session-123-s5-t1703123456789-a1b2c3'

// Parse a checkpoint ID
const parsed = parseCheckpointId(id);
// Returns: { version: 1, sessionId: 'session-123', stepCount: 5, timestamp: 1703123456789, random: 'a1b2c3' }

resolveCheckpoint ​

typescript
import { resolveCheckpoint } from '@helix-agents/core';

function resolveCheckpoint(
  store: Pick<SessionStateStore, 'getCheckpoint' | 'getLatestCheckpoint'>,
  sessionId: string,
  checkpointId?: string
): Promise<Checkpoint | null>;

Resolves checkpointId, or the session's latest checkpoint when it is omitted. Returns null only when no id was given and the session has no checkpoint yet (genesis). Throws HelixError state_checkpoint_not_found for an unknown id and state_checkpoint_foreign when the checkpoint belongs to another session. Every runtime calls it before any CAS, version bump, clone or workflow start for retry(), resume({ mode: 'from_checkpoint' }) and branch (with the SOURCE session id for a branch).

assertRewindable ​

typescript
import { assertRewindable } from '@helix-agents/core';

function assertRewindable(
  session: Pick<SessionState, 'sessionId' | 'status' | 'pendingClientToolCalls'>
): void;

The from_checkpoint liveness gate. Throws AgentAlreadyRunningError when the session is active and not suspended: it has no pending client-tool or approval calls (its run is still executing, and rewindToCheckpoint would truncate the log under it), or one of them is rewindHeld (an in-flight rewind owns it until its run takes the calls over). Any other status passes. Temporal and CF Workflows call it for a from_checkpoint entry while the current run is live and its owner is alive, before any write; JS and DBOS rely on the run-start commit's CAS and owner guard. Call it before any write in a custom loop that rewinds with rewindToCheckpoint.

rewindToCheckpoint ​

Not used by the built-in runtimes

Every built-in from_checkpoint / retry rewind is part of the entry's run-start commit (StartRun.truncateTo, planned by planRewindToCheckpoint). rewindToCheckpoint, the claimedVersion / holdPending pinning and the rewindHeld helpers below remain as building blocks for a custom loop.

A rewind changes the ownership map as well as the log: discarded calls lose their routes and restored calls regain theirs. On a self-rooted session the helper makes that change through updateClientToolCallOwnership, fenced on ctx.runId, after the truncate; a child's change goes to its root, unfenced. A failure there is a RewindIncompleteError with outcome savedTruncated.

typescript
import { rewindToCheckpoint, type DiscardedToolCall } from '@helix-agents/core';

function rewindToCheckpoint(
  store: SessionStateStore,
  sessionId: string,
  checkpoint: Checkpoint,
  ctx: CommitContext, // { streamManager, streamId, runId } — new, required
  options?: { claimedVersion?: number; holdPending?: boolean; holdKey?: string }
): Promise<{
  state: SessionState;
  discardedToolCalls: DiscardedToolCall[];
  outcome: 'completed';
  savedVersion: number; // the version the rewind's own save produced
}>;

function releaseRewindHold(entry: PendingClientToolCall): PendingClientToolCall;

interface DiscardedToolCall {
  toolCallId: string;
  toolName: string;
}

The in-run rewind: a fenced saveState plus a truncate commit. The built-in runtimes' resume({ mode: 'from_checkpoint' }) and retry() entries no longer call it: they plan the same rewind with planRewindToCheckpoint and pass plan.keepCount as startRun.truncateTo, so the truncate lands inside their run-start commit, atomically with the new run. Use rewindToCheckpoint in a custom loop that rewinds while it already owns the session. Breaking: it takes a ctx argument. The head of ctx.streamId is read once, before any write (a failed read throws state_checkpoint_sequence_unavailable and leaves the session untouched); the save is fenced on ctx.runId and the truncate is a truncate commit attributed to it, so a superseded run throws RunSupersededError and writes nothing. A supersede that lands between the save and the truncate still leaves the state rewound and the log untruncated (known gap). It:

  • restores the checkpoint's customState, stepCount, forcedCompletion and checkpointId under the CURRENT session's identity (never checkpoint.state.sessionId), and clears output / error / errorDetail;
  • keeps a pending client-tool call only if its tool call survives in the first checkpoint.messageCount messages, and restores a pending entry recorded on the checkpoint when its tool call survives but its result is truncated away;
  • writes the state with a version-guarded, run-fenced saveState, then truncates the log to checkpoint.messageCount with a truncate commit (a lost save leaves nothing half-rewound; StaleStateError retries the whole rewind up to 3 times);
  • returns the discarded calls (pre-submit stubs excluded).

Resolve the checkpoint with resolveCheckpoint and claim the session first, then pass the version your claim CAS returned as claimedVersion. A from_checkpoint claim is active → active plus a version bump, so a second caller that reads the session just after it still sees a suspended session and its own claim succeeds too. With claimedVersion the rewind is pinned to your claim and runs once: if the session moved past it, nothing is written or truncated and it throws AgentAlreadyRunningError. Do not roll the status back from an active source, because a competing claimer may own the session. From a non-active source, call rollbackLostRewindClaim(store, sessionId, sourceStatus): it restores the source status with a pinned CAS, but only when the session has no pending calls, which is when no competing owner can exist. This claim-then-rewind flow is for custom loops: the built-in runtimes no longer claim this way, because their run-start commit is the only gate (a loser writes nothing).

Pass holdPending: true when your run will take over the kept pending calls. A rewind to the suspension checkpoint keeps its pending calls, and without the hold that "suspended" state could be claimed by a second rewind before your run starts. With it, the same save marks those calls rewindHeld, and assertRewindable reads the session as running. When your run takes the calls over, write each kept entry through releaseRewindHold (or resolve and clear it). The built-in runtimes no longer pass holdPending: their run-start commit re-stamps the surviving pending calls to the new run (startRun.pendingRestamp), in the same transaction as the rewind. Temporal and CF Workflows still release any held entry they find in applyResultsAndReload. With holdKey the held calls carry rewindHoldKey, a fencing key (see below).

Pin any follow-up write to the returned savedVersion, not to state.version (the state is re-read after the truncate and may already show another writer's write). When a failure follows the save, the rewind throws RewindIncompleteError instead of the raw error (see below).

Also throws state_session_not_found, state_history_incomplete or, unpinned, StaleStateError; nothing is written in any of these cases. A history-read failure is always state_history_incomplete, never relabelled as a lost claim: loadLLMHistory absorbs a concurrent truncate, and a lost claim is detected by the version check.

planRewindToCheckpoint, applyRewindRootOwnership ​

typescript
import {
  planRewindToCheckpoint,
  applyRewindRootOwnership,
  type RewindPlan,
} from '@helix-agents/core';

function planRewindToCheckpoint(a: {
  sessionId: string;
  checkpoint: Checkpoint;
  history: readonly Message[]; // the durable log, read BEFORE any truncate
  current: SessionState;
}): RewindPlan;

interface RewindPlan {
  keepCount: number; // checkpoint.messageCount
  state: SessionState; // the rewound state, under the current identity
  discardedToolCalls: DiscardedToolCall[]; // emit an aborted tool_end for each (no stubs)
  discardedToolCallIds: string[]; // every discarded pending id, stubs included
  restoredToolCallIds: string[]; // checkpoint-recorded calls restored (R8)
}

function applyRewindRootOwnership(
  store: SessionStateStore,
  sessionId: string,
  current: Pick<SessionState, 'rootSessionId'>,
  plan: Pick<RewindPlan, 'discardedToolCallIds' | 'restoredToolCallIds'>
): Promise<void>;

planRewindToCheckpoint is the pure half of rewindToCheckpoint: the same keep-count, rewound state and pending-call decisions, with no I/O. An entry that rewinds while starting a run passes plan.keepCount as startRun.truncateTo and computes startRun.pendingRestamp from the discarded rows (planPendingRestamp, see Run start). applyRewindRootOwnership updates the ROOT session's clientToolCallOwnership for a rewound child (discarded calls lose their entries, restored calls gain one); it is a no-op for a root session or when nothing changed.

releaseRewindHold ​

typescript
import { releaseRewindHold, type PendingClientToolCall } from '@helix-agents/core';

function releaseRewindHold(entry: PendingClientToolCall): PendingClientToolCall;

Returns entry without its rewindHeld flag and rewindHoldKey, and leaves every other field unchanged. It returns the same object when the entry is not held.

Rewind outcome: RewindIncompleteError, rewindOutcomeOf, failRewoundSession ​

typescript
import {
  RewindIncompleteError,
  rewindOutcomeOf,
  failRewoundSession,
  type RewindOutcome, // 'notSaved' | 'savedNotTruncated' | 'savedTruncated' | 'completed'
} from '@helix-agents/core';

class RewindIncompleteError extends Error {
  readonly outcome: 'savedNotTruncated' | 'savedTruncated';
  readonly savedVersion: number;
  // `cause` is the original error
}
function rewindOutcomeOf(error: unknown): Exclude<RewindOutcome, 'completed'>;
function failRewoundSession(
  store: Pick<SessionStateStore, 'loadState' | 'saveState'>,
  sessionId: string,
  ownedVersion: number,
  error: unknown
): Promise<ErrorDetail | undefined>;

Act on how far a rewind got, never on version arithmetic. notSaved (any error other than RewindIncompleteError, a history-read throw included): nothing was written, so restore the session's previous status (for example with rollbackFailedResumeClaim). savedNotTruncated (the truncate threw) or savedTruncated (a later root-ownership update threw): the session is mutated — call failRewoundSession with savedVersion, which marks it failed with the classified errorDetail, releases its holds and returns the detail (or undefined when another writer moved the session), then fail the stream with it. Do the same when your run cannot start after a completed rewind of a completed / failed session, so the old status is never shown over a rewound log.

Rewind hold fencing: rewindHoldOf, rekeyRewindHold ​

typescript
import { rewindHoldOf, rekeyRewindHold } from '@helix-agents/core';

function rewindHoldOf(
  session: Pick<SessionState, 'pendingClientToolCalls'>
): { key: string | undefined } | undefined;
function rekeyRewindHold(
  pending: SessionState['pendingClientToolCalls'],
  key: string
): SessionState['pendingClientToolCalls'];

A held session means a rewind that has not yet started its run, or one that crashed before it did. Nothing distinguishes the two by time; single-run comes from fencing instead. If your engine has unique run ids, start the run that takes held calls over — the rewind's own run or a continue after a crash — under an id derived from rewindHoldOf(session).key, so at most one runs; if a run under that id already finished without taking the calls over, re-key the hold with rekeyRewindHold in a save pinned to your claim, so of two such callers only one proceeds. Without unique ids, make the takeover a save pinned to the rewind's savedVersion and a continue's claim pinned to the version it read.

rollbackFailedResumeClaim ​

typescript
import { rollbackFailedResumeClaim } from '@helix-agents/core';

function rollbackFailedResumeClaim(
  store: Pick<SessionStateStore, 'loadState' | 'saveState' | 'compareAndSetStatus'>,
  sessionId: string,
  sourceStatus: SessionStatus,
  ownedVersions: readonly number[],
  options?: { releaseHold?: boolean }
): Promise<boolean>;

Undo your own resume claim after a failure that followed it and preceded your run (a rewind error, a message append, a workflow start). Without it the session stays active with no run, which resume() rejects as running and retry() as not failed. It restores sourceStatus in one write pinned to ownedVersions (the versions your own writes produced: the claim, and the rewind save), so a session another writer moved since is left alone. With releaseHold (your own rewind placed the hold) it also releases the rewindHeld calls. Returns whether the session now reads as rolled back.

PendingClientToolCall.rewindHeld?: boolean (also in pendingClientToolCallSchema()) is set by a rewindToCheckpoint(..., { holdPending: true }) save on every pending call the rewind kept or restored. While any entry is held, assertRewindable treats the active session as running, so a second from_checkpoint cannot claim it before your run starts.

The run that takes the calls over must release the hold:

  • Temporal and CF Workflows write every held entry they find through releaseRewindHold in applyResultsAndReload.
  • In a custom loop, mark the calls taken by your run in a save pinned to the rewind's savedVersion, then resolve and clear them. If your entry throws before that, roll back (or mark the session failed) with a pinned write.

Do the same in a custom loop.

rollbackLostRewindClaim ​

typescript
import { rollbackLostRewindClaim } from '@helix-agents/core';

function rollbackLostRewindClaim(
  store: Pick<SessionStateStore, 'loadState' | 'compareAndSetStatus'>,
  sessionId: string,
  sourceStatus: SessionStatus
): Promise<boolean>;

Undoes a from_checkpoint claim whose pinned rewindToCheckpoint threw AgentAlreadyRunningError, when a non-owner write is the only possible cause. That is the case when the claim came from a non-active source and the session now has no pending calls: no competing owner can claim an active session without pending calls.

  • If so, it moves the session back to sourceStatus with a CAS pinned to the version it reads. It retries only when another write lands in between, and returns true.
  • It returns false and changes nothing when the source was active, when the session has pending calls (a competing claimer may own it), or when the session is no longer active.

Call it on a lost claim in a custom loop that claims before rewindToCheckpoint (the built-in runtimes no longer claim this way). Pass the status the claim moved the session from.

discardedToolEndChunk ​

typescript
import { discardedToolEndChunk } from '@helix-agents/core';

function discardedToolEndChunk(
  call: DiscardedToolCall,
  ctx: { agentId: string; agentType: string; step: number }
): StreamChunk; // a `tool_end` with errorCode: 'aborted'

Builds the tool_end a runtime writes for each call a rewind (rewindToCheckpoint or planRewindToCheckpoint) discarded, so a UI waiting on that client tool settles it. A later submitToolResult for the call returns { status: 'unknown_tool_call' }.

loadBranchSource ​

typescript
import { loadBranchSource } from '@helix-agents/core';

function loadBranchSource(
  store: SessionStateStore,
  branch: BranchOptions
): Promise<{ messages: Message[]; customState: unknown }>;

The history and customState a branch starts from, for runtimes that do not use store.cloneSession (Temporal, Cloudflare Workflows):

branchMessagescustomState
checkpointIdfirst checkpoint.messageCountthe checkpoint's
messageIndexfirst messageIndex (a count)the source's current
neitherallthe source's current

checkpointId wins when both are set. System messages are filtered out. Throws state_session_not_found, state_checkpoint_not_found, state_checkpoint_foreign or state_history_incomplete.

Commits and Run Ownership ​

The store-enforced commit model behind consistent snapshots, single-winner run starts and the superseded-run fence. Every runtime and store uses these primitives; a custom runtime or store must too. Migration detail: Snapshot and checkpoint truth.

Commits and checkpoints ​

typescript
import {
  NO_STREAM_SEQUENCE, // -1
  commitKindSchema,
  parseStoredCommitKind,
  resolveCommitSequence,
  resolveRunStartSequence,
  buildCommitCheckpoint,
  type CommitKind,
  type MessageCommit,
  type CommitContext,
} from '@helix-agents/core';

type CommitKind = 'input' | 'append' | 'step' | 'state' | 'truncate' | 'clone';

interface MessageCommit {
  streamSequence: number; // the live stream head at the commit
  runId: string; // the run the commit is attributed to (and fenced on)
  startRun?: StartRun; // present: a run-start commit
}

interface CommitContext {
  streamManager: StreamManager | undefined; // undefined → NO_STREAM_SEQUENCE
  streamId: string;
  runId: string;
}

function resolveCommitSequence(
  streamManager: Pick<StreamManager, 'getStreamInfo'> | undefined,
  streamId: string
): Promise<number>;
function resolveRunStartSequence(
  streamManager: Pick<StreamManager, 'getStreamInfo'> | undefined,
  streamId: string
): Promise<number>;
function parseStoredCommitKind(
  raw: unknown,
  where: { sessionId: string; checkpointId: string }
): CommitKind | null;
function buildCommitCheckpoint(a: {
  sessionId: string;
  storedState: SessionState;
  messageCount: number;
  streamSequence: number;
  kind: CommitKind;
  runId: string | null;
  keepCount?: number | null;
  now: number;
}): { checkpoint: Checkpoint; checkpointId: string };

A commit is every message-log or snapshot-visible mutation. It writes a checkpoint (id, kind, runId, messageCount = the live row count after the write, streamSequence, stepCount, state, keepCount) and moves the session's latest-checkpoint pointer, atomically with the mutation:

Store methodkind
appendMessages with commit.startRuninput
appendMessages without itappend
saveStateAndPromoteStaging / promoteStagingstep
commitStatestate
truncateMessages, or a run-start truncateTotruncate
cloneSessionclone

saveState and createCheckpoint are not commits. Rows written before this release have kind, runId and keepCount null; a session whose latest checkpoint is such a row is read in legacy mode by the snapshot reader until its first new commit.

resolveCommitSequence returns the stream's live head (latestSequence), or NO_STREAM_SEQUENCE (-1) when no stream manager is wired or the stream does not exist yet. When the head read throws it throws a retryable HelixErrorstate_checkpoint_sequence_unavailable; it never falls back to 0. resolveRunStartSequence is the same, except that a missing stream is at 0 when a stream manager is wired (the run-start winner creates it right after its commit). The old best-effort sequence helper (with a silent fallback) is removed.

commitKindSchema() is the Zod enum of the six kinds. parseStoredCommitKind reads a stored kind column: null / undefined is a legacy row (null), a known kind is returned, anything else throws UnknownCommitKindError (never read as legacy). buildCommitCheckpoint is the pure checkpoint builder stores use inside their own transaction. CommitContext is what core helpers that commit on your behalf (for example rewindToCheckpoint) need to build a MessageCommit themselves.

Run start ​

typescript
import {
  applyRunStartRules,
  planRunStartRewind,
  assertValidTruncateTo,
  startDigest,
  planPendingRestamp,
  applyPendingRestamp,
  mergeEntryPendingCalls,
  shouldRetryRunStart,
  asRunStartLoserError,
  RunStartConflictError,
  RunStartRejectedError,
  RunEntryFailedError,
  isRunEntryFailedError,
  RunStartAbandonedError,
  isRunStartAbandonedError,
  RunStartOutcomeUnknownError,
  isRunStartOutcomeUnknownError,
  RunStartStalledError,
  isRunStartStalledError,
  type StartRun,
  type OwnerlessMode,
  type RunParent,
  type PendingRestamp,
  type ResumedCompanionRef,
  type RunStartDecision,
  type RunStartFinish,
  type RunStartFinishFacts,
} from '@helix-agents/core';

interface StartRun {
  turn: number;
  startUIMessageCount: number;
  expectCurrentRunId: string | null; // the CAS precondition (run id)
  expectCurrentRunStatus: RunStatus | null; // the CAS precondition (its status); null iff the id is null
  entryStatuses: readonly SessionStatus[]; // the session status(es) the plan read; non-empty
  ownerToken: string | null; // identifies ONE execution; null on the CF DO
  ownerless: OwnerlessMode;
  truncateTo?: number; // atomic rewind: the keep-count
  finishCurrent?: { runId: string; status: 'completed' | 'failed' | 'interrupted' };
  pendingRestamp?: PendingRestamp;
  entryStepCount?: number;
  entryState?: RunEntryState; // DI-40: the run's entry forced phase / cleared output, on the row AND the input checkpoint
  parent?: RunParent; // the parent consult a child run serves
  resumedCompanionRef?: ResumedCompanionRef; // CP-103 A1: a resumed companion's parent ref
  requestKey?: string; // D-2: recorded as RunMetadata.requestKey; unique per session
  requestDigest?: string; // D-2: computeRequestDigest of the entry's input (RunMetadata.requestDigest)
}

type OwnerlessMode = { kind: 'lease'; ttlMs: number } | { kind: 'asserted' };
interface ResumedCompanionRef {
  parentSessionId: string; // the parent holding the ref; its subSessionId is the committing session
}
interface RunParent {
  sessionId: string;
  toolCallId: string;
}
interface PendingRestamp {
  adopt: readonly string[];
  drop: readonly string[];
  consume?: readonly PendingConsume[]; // W1 MR2b: calls whose results ride this commit's rows (gated)
  markCompleted?: boolean; // W1 MR2b: write each consumed call's already_completed marker (root only)
  stash?: PendingStash; // W1 MR2b: ONE carrier's entryInput, replaced (gated)
}
interface PendingConsume {
  id: string;
  expect: PendingConsumeExpectation; // pendingConsumeExpectation(entry) at plan time
}
interface PendingConsumeExpectation {
  runId: string | undefined;
  submittedAt: number | undefined;
  stashedRows: number; // entryInput's length, 0 when none
}
interface PendingStash {
  id: string;
  expect: PendingConsumeExpectation;
  entryInput: readonly Message[]; // the carrier's whole new entryInput
}

Every entry (execute(), resume(), retry(), a sub-agent spawn or continuation) starts its run with ONE run-start commit: appendMessages(sessionId, rows, { streamSequence, runId, startRun }), where rows (the entry's input, possibly empty) and the run are written together. Inside one transaction, on its own clock, the store applies applyRunStartRules:

  1. Idempotency. The same runId with an equal startDigest(rows, startRun) is a no-op; a different digest throws RunStartRejectedError('idempotency_mismatch'). A runId that abandonRunStart tombstoned throws RunStartRejectedError('entry_abandoned').

    Request key (D-2). With startRun.requestKey, the store reads the run of the session that holds the key inside the same atomic step and passes it as applyRunStartRules({ requestKeyHolder }). Another run holding it throws RunStartRejectedError('request_key_taken', { existingRunId }). The rule runs after the same-runId replay (a run holds its own key) and before the CAS, so a keyed entry that lost the race always learns the holder and re-plans (Request keys), never a stale-plan refusal.

  2. CAS on expectCurrentRunId. A mismatch throws RunStartConflictError('expected_mismatch') when the current run is live, else RunStartRejectedError('expected_mismatch_idle'). Then the run's status must be expectCurrentRunStatus, else RunStartRejectedError('expected_run_status_changed').

  3. Finish verification, then the owner guard. A current run named by finishCurrent is first verified against the store's own reads (the session is terminal with exactly that status and its latest checkpoint is the run's own state commit). A live current run that is not a verified finish may be superseded only by the same ownerToken, a lapsed or absent lease (ownerless.kind === 'lease', evaluated on the store clock), or ownerless.kind === 'asserted' (the runtime proved its own ownerless predicate first). Otherwise RunStartConflictError('run_active'). A verified finish skips the owner guard (CP-84 §3.15): its K5 has landed, so it has nothing left to write but its tail, and an unexpired JS lease no longer refuses the next entry. Then the session gate: the session status the store read inside the commit (the required sessionStatus input of applyRunStartRules) must be in entryStatuses, else RunStartRejectedError('entry_status_changed'). A verified finish still answers to the session gate. Stop rule (WA MR2): a live current run this entry would supersede (its owner guard passed, not a verified finish) that has a stop record rejects with RunStartRejectedError('expected_run_stopped'); the entry settles the stopped run (commitStopTerminal) and, unless it continues that run's suspension, re-plans once.

  4. Supersede. Every other live run becomes superseded (lease cleared). The verified finishCurrent run is ended with its terminal status instead. Its owner's own later updateRunStatus with that status is a no-op (decideRunStatusTransition returns 'noop'), so the old run does not stop as superseded.

  5. Optional rewind. With truncateTo below the live count (planRunStartRewind), the rows from it on are deleted and a truncate checkpoint attributed to the new run is written. pendingRestamp drops / re-stamps pending client-tool calls in the same transaction. With consume / stash (W1 MR2b) every named entry must still be the one the plan read (same runId, submittedAt and stashed-row count, not a stub), else the store throws RunStartRejectedError('consumed_pending_changed') and writes nothing; then the consumed entries are removed, the carrier's entryInput is set, and with markCompleted each consumed call's durable already_completed marker (completedClientToolCalls) is written at the store's commit time. With resumedCompanionRef (CP-103 A1: the run resumes an interrupted persistent companion), the parent's sub-session ref for this session turns status: 'running', completionDelivered: false in the same transaction. A parent (or ref) that does not exist throws RunStartRejectedError('resumed_companion_ref_missing') and writes nothing; a ref that is terminated (a companion terminate landed first) throws RunStartRejectedError('resumed_companion_ref_terminated') and writes nothing. A store that cannot hold the parent's ref and the child's run in one atomic step refuses the field (DO-SQLite: UnsupportedOperationError); it never writes the run without the ref.

  6. Create. The run (running, startSequence = streamSequence, startDigest, ownerToken, requestKey / requestDigest when given, and in lease mode leaseUntil = storeNow + ttlMs) becomes the session's current run; the rows and an input checkpoint are written.

A rejected run-start writes nothing. The commit is the only gate: the winner then claims the stream (claimStreamForCommittedRun), makes it live and writes run_started; a loser writes nothing — no input row, no counter, no stream status change, no chunk.

  • startDigest is a Workers-safe FNV-1a hash over rows and startRun, stable under key order. pendingRestamp, entryStepCount, entryState, parent, entryStatuses, expectCurrentRunStatus and resumedCompanionRef are not part of it, so a replay is a no-op before either gate (a matched replay applies no ref either). requestKey and requestDigest are part of it, so a same-runId replay carrying them is still a no-op.
  • assertValidTruncateTo throws validation_error unless truncateTo is absent or a non-negative integer; assertValidEntryGate(sessionId, startRun) throws it unless entryStatuses is a non-empty list of session statuses and expectCurrentRunStatus is a run status exactly when expectCurrentRunId is non-null. applyRunStartRules runs both first.
  • matchesSeenRun(current, seenRunId, seenRunStatus?) is the one comparison a builder uses to decide whether a fresh read still shows what the entry saw (id, and status when given).
  • planPendingRestamp(pending, discardedTail) plans startRun.pendingRestamp: a pending call whose tool_use sits in the rows the rewind removes is dropped, every other one is adopted by the new run (pre-submit stubs untouched). applyPendingRestamp(pending, restamp, runId, sessionId) is the store half: it runs the consume / stash gate (the typed consumed_pending_changed refusal names sessionId) and refuses overlapping id sets with a typed validation_error. applyConsumedCompletedMarkers(completed, restamp, now), consumedMarkerIds(restamp) and completedClientToolCallsSchema() are the marker half; pendingConsumeExpectation(entry) builds an expect. A replay that matches a committed run start (noop) applies none of it. mergeEntryPendingCalls(fresh, planned, runId) merges the pending calls an entry state writes from a fresh read, so a submit that landed after the commit survives.
  • shouldRetryRunStart({ store, sessionId, seenRunId, seenRunStatus?, entryStatuses?, attempt, err? }) decides the one allowed retry: only when a fresh read shows what the caller saw (the run id and seenRunStatus, or a session status still in entryStatuses), i.e. the attempt's own read was stale, never when a newer run started or the seen run moved on. An expected_run_status_changed rejection with a seen run id but no seenRunStatus never retries.
  • asRunStartLoserError(err, sessionId) maps RunStartRejectedError('expected_mismatch_idle') and ('expected_run_status_changed') to a RunStartConflictError, so every caller but the winner sees AgentAlreadyRunningError. Any other error is returned unchanged.

Errors:

typescript
class RunStartConflictError extends AgentAlreadyRunningError {
  readonly code: 'state_run_start_conflict';
  readonly conflictCause: 'run_active' | 'expected_mismatch';
  // name stays 'AgentAlreadyRunningError'
}

class RunStartRejectedError extends Error {
  readonly code: 'state_run_start_conflict';
  readonly sessionId: string;
  readonly rejectCause:
    | 'idempotency_mismatch'
    | 'expected_mismatch_idle'
    | 'expected_run_status_changed' // the current run's status is not the one the entry saw
    | 'entry_status_changed' // the session left the statuses the entry planned from
    | 'entry_abandoned'
    | 'resumed_companion_ref_missing' // StartRun.resumedCompanionRef names a missing parent ref (non-retryable)
    | 'resumed_companion_ref_terminated' // that parent ref is `terminated`: the terminate won (WA MR2)
    | 'expected_run_stopped' // the live run this entry would supersede has a stop record (WA MR2)
    | 'consumed_pending_changed' // W1 MR2b: a consumed / stashed pending call changed after the plan (retried once)
    | 'request_key_taken' // D-2: another run of the session holds startRun.requestKey (the entry re-plans)
    | 'sub_session_ref_conflict' // Invariant S: a settled ref was settled first (re-planned once)
    | 'stale_parent_ref_generation' // CP-161: a CF DO child refused an older parent-ref generation
    | 'resumed_companion_ref_moved' // CP-161 fix 3: the reopen CAS (ResumedCompanionRef.expect) lost
    | 'parent_run_superseded'; // CP-161 fix 4: expect.parentRunId is not the parent's current live run
  readonly details: { readonly existingRunId?: string }; // request_key_taken: the holder
}

class RunEntryFailedError extends Error {
  readonly sessionId: string;
  readonly runId: string;
  readonly errorDetail: ErrorDetail; // the original failure's classified detail
}

class RunStartAbandonedError extends HelixError {
  // code 'transport_error'; retryable by default only when ownerStopped
  readonly sessionId: string;
  readonly runId: string | undefined;
  readonly entryId: string | undefined; // workflow id (Temporal / DBOS) or instance id (CFW)
  readonly ownerStopped: boolean;
}

class RunStartOutcomeUnknownError extends HelixError {
  // code 'transport_error'; never retryable, except reason 'continuation_running'
  // (the caller's message was NOT written, so it may be sent again)
  readonly sessionId: string;
  readonly runId: string | undefined;
  readonly entryId: string | undefined;
  readonly ownerStopped: boolean;
  readonly reason: 'abandon_failed' | 'continuation_awaiting_predecessor' | 'continuation_running';
}

type RunStartStalledSettle = 'failed' | 'not_committed' | 'not_found' | 'superseded';

class RunStartStalledError extends HelixError {
  // code 'transport_error'; never retryable (its input is committed: recover with retry())
  constructor(
    opts: {
      sessionId: string;
      runId: string;
      entryId?: string;
      timeoutMs: number; // the bound that lapsed (named in the message as the bound, not the elapsed time)
      recovery?: RunStartStalledRecovery; // the recovery the message names; default 'retry'
    } & (
      | { ownerStopped: false; start?: 'not_started' | 'unknown' } // default 'not_started'
      | { ownerStopped: true; settle: RunStartStalledSettle; start?: RunStartStalledStart }
    )
  );
  readonly sessionId: string;
  readonly runId: string; // the run that committed but never started
  readonly entryId: string | undefined; // the CF Workflows instance id
  readonly ownerStopped: boolean;
  readonly settle: RunStartStalledSettle | undefined; // undefined when not stopped
  // 'not_started' (proven), 'unknown' (the start could not be read: the stall is not proven), or
  // 'started' (owner stopped only: the fail-safe's own terminate, after a failed abandon, killed a
  // run that had started)
  readonly start: RunStartStalledStart; // 'not_started' | 'unknown' | 'started'
  readonly startUnknown: boolean; // start === 'unknown'
  // 'retry' (executor.retry(agent, sessionId)), or 'resume' for an entry into a session paused on stored
  // answers, which the stall leaves paused (retry() refuses it): executor.resume(agent, sessionId, { mode: 'continue' })
  readonly recovery: RunStartStalledRecovery; // 'retry' | 'resume'
}
  • RunStartConflictError keeps the AgentAlreadyRunningError name on purpose: existing "another run is driving" fallbacks keep working. RunStartRejectedError has its own name so those fallbacks never swallow it. A chat host answers it with a 409 state_run_start_conflict (it retries expected_mismatch_idle once; expected_run_status_changed never reaches it, since asRunStartLoserError types it as RunStartConflictError first).
  • RunEntryFailedError: the run-start commit won, but the entry's stream steps (the claim, making the stream live, run_started) failed. The entry already ended the run with failCommittedRun before throwing; resolveErrorDetail(err) reports the original detail.
  • RunStartAbandonedError / RunStartOutcomeUnknownError are the run-start fail-safe's outcomes on the durable runtimes (Temporal entryOutcomeTimeoutMs, DBOS runStartFailSafeMs, CF Workflows entryOutcomeFailSafeMs). Abandoned: the entry reached no outcome in time and abandonRunStart won, so its input was not and will never be delivered; retrying is safe once its owner was stopped. Outcome unknown: the abandon itself failed, so the in-flight commit may still land; read the session before retrying. Neither is persisted on the session. Both are exported from core only, and the is* guards match by name, so they work across copies of core.
  • RunStartStalledError (CF Workflows only, for now) is the fail-safe's outcome for a run that COMMITTED but never started: at the bound its owner had not written its run_started (or its entry state), so a handle would have had no stream to read. The executor terminates the owner, then fails the run with failCommittedRun; settle says what that did, and the message says the same and names the recovery. It is NEVER retryable (retryable: false for every outcome): the run-start commit already appended the call's input, so repeating the call would append it twice. Every outcome's message names the recovery the same way: executor.retry(agent, sessionId) (pass options.message if the run committed a step or its entry carried no user message of its own). Without a message such a run's retry() is refused validation_error: a run whose start is unknown may have committed a step before its owner stopped, and a CF Workflows resume() in continue / from_checkpoint mode with no message commits only heal rows, so its run has no trigger to re-run. 'failed': the run was settled failed and the session's errorDetail is this error's own message / code / category / retryable; retry() re-runs it. 'not_committed': the settle's write did not land, so nothing was written; a reader (e.g. getHandle()) settles the run (its owner is closed) before the retry(). 'not_found': the session or run could not be read. 'superseded': a newer run had already taken the session over, so this call's committed input belongs to a run that lost; read the session first. A run that ended on its own before it could be failed is no stall: the call returns its handle. Not stopped (ownerStopped: false, no settle): nothing was settled and the run may still proceed (getHandle() finds it); read the session first (every outcome that says to read the session first then adds "if its run failed, recover with" the same retry(); with recovery: 'resume', set by the executor for an entry into a paused session whose run was proven not started (and not settled failed), every outcome names executor.resume(agent, sessionId, { mode: 'continue' }) instead). Start unknown (startUnknown: true): the reads that prove a start kept failing, so the executor could not tell a started run from a stalled one, and the message says its start could not be confirmed (never that it did not start). On a committed run's start grace this happens only past one more grace pass, and the executor stops nothing (ownerStopped: false); read the session first. After a FAILED abandon the executor has already terminated the owner (with no tombstone, that is the only fence against a late commit), so a run it then finds with an unreadable start is ownerStopped: true and settled like any stall, since a stopped owner can never finish it (settle says what that did). Such a run may have committed a step before it was stopped, so its retry() may need options.message (above). If that terminate failed too, it is ownerStopped: false and nothing is settled. The message names timeoutMs as the fail-safe's bound, not the time it waited: the wait runs past it through the start grace (up to about three times the bound for an unreadable start). Temporal and DBOS still return a handle for a committed run (FU-TEMPORAL-ENTRY-FAILSAFE-SILENT-HANDLE). isRunStartStalledError matches by name.

Run lease ​

typescript
import { RunLease, DEFAULT_RUN_LEASE_TTL_MS, type RunLeaseOptions } from '@helix-agents/core';

const DEFAULT_RUN_LEASE_TTL_MS = 30_000;

interface RunLeaseOptions {
  store: { renewRunLease: SessionStateStore['renewRunLease'] };
  sessionId: string;
  runId: string;
  ttlMs: number; // the TTL the run was started with
  onLost: (e: RunSupersededError) => void; // called exactly once
  setTimer?: typeof setTimeout;
  clearTimer?: typeof clearTimeout;
  now?: () => number;
}

class RunLease {
  constructor(opts: RunLeaseOptions);
  start(opts?: { grantedAt?: number }): void;
  isHeld(): boolean;
  stop(): void;
}

runtime-js has no durable owner to ask, so a JS run starts with ownerless: { kind: 'lease', ttlMs } and the store sets leaseUntil on its own clock. RunLease renews the lease every ttl / 3 through renewRunLease. A rejected renewal (RunSupersededError) fires onLost at once. A transient failure is retried while a retry can still land inside the lease (minus a safety margin of ttl / 10); past that, and whenever isHeld() finds the local deadline lapsed (for example after an event-loop stall), the lease is declared lost. isHeld() is the I/O-free check the executor gates every chunk write on; it stays true after stop(), which the executor calls right before the run's suspension or terminal status write (that write releases the lease). The executor's knob is runLease: { ttlMs } on runtime-js (JS_RUN_LEASE_MS there is this default). DEFAULT_RUN_LEASE_TTL_MS is the executor default only; the store uses the TTL carried in the run-start commit.

Superseded-run fence and errors ​

typescript
import {
  RunSupersededError,
  isRunSupersededError,
  isLostClaimError,
  assertRunOwnsSession,
  assertRunIsCurrentLive,
  assertRunIsCurrentIdle,
  decideRunStatusTransition,
  isLiveRunStatus,
  CommitStoppedError,
  isCommitStoppedError,
  CorruptMessageRowError,
  isCorruptMessageRowError,
  UnknownCommitKindError,
} from '@helix-agents/core';

class RunSupersededError extends Error {
  readonly code: 'state_run_superseded';
  readonly retryable: false;
  readonly sessionId: string;
  readonly runId: string;
}

function assertRunOwnsSession(
  store: Pick<SessionStateStore, 'getCurrentRun'>,
  sessionId: string,
  runId: string
): Promise<void>;

Every runId-carrying commit is a compare-and-set on "runId is the session's current live run"; executor saveState calls carry { fenceRunId } (or { fenceIdle }); and updateRunStatus refuses to change a terminal run. A write that fails the fence throws RunSupersededError and writes nothing. It is non-retryable on every runtime (Temporal ApplicationFailure.nonRetryable, DBOS excludes it from step retries, CF Workflows NonRetryableError), and the run stops with AgentResult.status: 'superseded'.

  • assertRunOwnsSession is the side-effect fence: every runtime calls it immediately before each tool execution, sub-agent spawn and client-tool / approval registration, so a superseded run never executes a tool. It reads the current run and throws unless it is runId and live. A session with no run record passes.
  • assertRunIsCurrentLive({ sessionId, runId, current }) is the same predicate over a run the caller already read (stores call it inside their transaction). assertRunIsCurrentIdle({ sessionId, expectRunId, current }) is the idle fence (saveState's fenceIdle). decideRunStatusTransition({ sessionId, run, to }) is the updateRunStatus fence. It returns a RunStatusTransition ('write' | 'noop'): 'write' for a live run, 'noop' for a terminal run written with the status it already has (CP-84 §3.15; callers MUST return without writing on 'noop'), else it throws RunSupersededError. isLiveRunStatus(status) is true for running and the suspended_* statuses.
  • isLostClaimError(e) is true for RunSupersededError, RunStartConflictError and RunStartRejectedError: "another execution won". RunStartRejectedError('resumed_companion_ref_missing') is excluded: no other execution won, the resumed companion's parent ref is gone, so a resume reports it typed, never as child_busy. Lost-claim handlers (the CF DO base class among them) use it so a session is never rolled back to failed for that.
  • CommitStoppedError(sessionId, stop) (code: 'state_commit_stopped', non-retryable; isCommitStoppedError matches it by name or code): a commit made with refuseIfStopped found a stop record for its run; nothing was written. It carries the stored stop, and the runtime ends the run by it (see Stop as one signal).
  • CorruptMessageRowError(sessionId, sequence | null, cause?): a store's getMessages found a persisted row that is not a valid Message. Stores throw it instead of skipping the row; readers turn it into state_history_incomplete with reason: 'corrupt_row'.
  • UnknownCommitKindError(sessionId, checkpointId, kind): a stored checkpoint kind outside the six commit kinds.

Committed run finish ​

typescript
import {
  committedTerminalStatus,
  committedTerminalStatusOf,
  finishCommittedRun,
  failCommittedRun,
  withoutUnansweredPendingOf,
  type CommittedTerminalStatus, // 'completed' | 'failed' | 'interrupted'
  type FinishedCommittedRun,
  type FailCommittedRunInput,
  type FailCommittedRunResult,
} from '@helix-agents/core';

function committedTerminalStatus(
  stateStore: SessionStateStore,
  sessionId: string,
  run: RunMetadata
): Promise<CommittedTerminalStatus | null>;

function committedTerminalStatusOf(a: {
  run: RunMetadata;
  session: SessionState;
  latestCheckpoint: Checkpoint | null;
}): CommittedTerminalStatus | null; // pure form over already-read state

function finishCommittedRun(
  stateStore: SessionStateStore,
  sessionId: string,
  run: RunMetadata
): Promise<FinishedCommittedRun | null>; // { session, status }

function failCommittedRun(a: FailCommittedRunInput): Promise<FailCommittedRunResult>;

function withoutUnansweredPendingOf(
  pending: SessionState['pendingClientToolCalls'],
  runId: string | null // null: only unstamped entries count as the run's
): SessionState['pendingClientToolCalls']; // undefined when none is left

interface FailCommittedRunInput {
  stateStore: Pick<
    SessionStateStore,
    'getCurrentRun' | 'getRun' | 'loadState' | 'commitState' | 'saveState' | 'updateRunStatus'
  >;
  streamManager?: StreamManager; // absent: only the session and run are failed
  sessionId: string;
  streamId: string;
  runId: string;
  ownsStream: boolean; // false for a child writing into its parent's stream
  error: unknown;
  errorDetail?: ErrorDetail;
  makeLive?: () => Promise<void>;
  requireSessionCommit?: boolean; // opt-in: step 1 must land before the run / stream
  logger?: { warn(message: string, data?: Record<string, unknown>): void };
}

type FailCommittedRunResult =
  | { errorDetail: ErrorDetail; failed: true; outcome: 'failed' }
  | { errorDetail: ErrorDetail; failed: false; outcome: 'superseded' | 'already_ended' }
  | {
      errorDetail: ErrorDetail;
      failed: false;
      outcome: 'finished'; // WA MR2: the run's own K5 had landed; finished with it, never failed
      status: CommittedTerminalStatus;
      session: SessionState;
      stream: ClosedOwnerStreamOutcome;
    }
  | { errorDetail: ErrorDetail; failed: false; outcome: 'not_committed'; cause: unknown };

The terminal order on every runtime is: the terminal state commit (K5, carrying the terminal status, error and the terminal hooks' writes) → the terminal run status → the stream end or failure. K5 is written on every terminal exit: completion, failure and stop.

  • committedTerminalStatus is read-only: when run is still LIVE (running or any suspended_* status) but the session is terminal and its latest checkpoint is that run's own state commit (K5 landed, the run status did not), it returns the session's terminal status, else null. A terminal or superseded run is never finished. An entry hands it to its run-start commit as startRun.finishCurrent, so the store ends that run in the same commit once its own reads verify the K5; a verified finish skips the owner guard (CP-84 §3.15).
  • committedTerminalStatusOf is the pure form of the same check over state the caller already read: the one definition of "the run's K5 landed" (the closed-owner planner below decides finish_committed with it).
  • finishCommittedRun is the recovery-only write for the same shape (any live run, a suspended_* one included; a live to terminal run-status transition is allowed): it records the run status and returns the session so the caller ends the stream. Call it only when the run's owner is known dead (JS recover(), the CF DO wake path); entries use finishCurrent.
  • failCommittedRun ends a run whose run-start commit won but whose entry failed before its loop ran (for example between the commit and the stream claim). In terminal order: the session failed with the classified errorDetail (a fenced state commit), the run failed, then, for a run that owns its stream (ownsStream), the stream claimed in terminal mode and failed, fenced on the run. The failed session keeps no interrupt context, parked remote call or unanswered pending client-tool call of the run (withoutUnansweredPendingOf, below). A run whose own record is no longer live is left alone (outcome: 'superseded' | 'already_ended'). A live run whose own K5 has landed (committedTerminalStatusOf on a fresh read, the R27 check) is finished with that outcome instead (finishCommittedRun, then its stream) and reported outcome: 'finished' with its status: never failed over a committed outcome (WA MR2 Ruling 36). A transient read failure in that check writes nothing (not_committed). Each step is best-effort and the function never throws; the caller throws RunEntryFailedError. With requireSessionCommit: true (the CF Workflows executor's abort, closed-owner settle and entry fail-safe), the session write is a precondition: when it fails for any reason other than the run's fence (only a RunSupersededError; a StaleStateError that exhausted its retries, such as D1's D1CommitContentionError, is contention and counts as a failure), nothing else is written and the result is outcome: 'not_committed' with the write's error as cause. The run stays live, so a later caller can settle it again (failing the run anyway would leave the session active behind a failed run that nothing settles).
  • withoutUnansweredPendingOf is the one rule for what a FAILED terminal keeps of the session's pending client-tool calls (CP-174, CP-256): it drops the run's UNANSWERED entries (no submittedAt; an entry with no runId counts as the run's) and keeps answered and denied entries, other runs' entries, and an unanswered entry that carries a stashed entry input (entryInput; a retry or a new turn delivers it). A failed run's unanswered call can never be answered (every submit is fenced on its run), so the next entry's C5 heal pairs its tool_use unpaired_on_continuation. runId: null counts only unstamped entries. failCommittedRun, failSessionWithoutRun (with its currentRunId) and every built-in runtime's own failed terminal apply it; a custom loop's failed terminal must too.

Committed run outcome ​

typescript
import {
  isCommittedTerminalStatus,
  readCommittedRunOutcome,
  type CommittedRunOutcome,
} from '@helix-agents/core';

function isCommittedTerminalStatus(status: RunStatus): status is CommittedTerminalStatus;
// true for 'completed' | 'failed' | 'interrupted'

function readCommittedRunOutcome(
  store: Pick<SessionStateStore, 'getCurrentRun' | 'getLatestCheckpoint' | 'loadState'>,
  sessionId: string,
  opts?: { countAnnounced?: boolean }
): Promise<CommittedRunOutcome | null>;

interface CommittedRunOutcome {
  run: RunMetadata & { status: CommittedTerminalStatus };
  session: SessionState<unknown, unknown>;
  via: 'k5' | 'announced'; // its own K5 is the latest checkpoint, or the DO's R77 announced commit
}

The committed outcome of a session's CURRENT run, from persisted state alone (spec 2026-10-15 §3.2). Committed: the run ENDED completed / failed / interrupted and its own state commit (K5) is the latest checkpoint (via: 'k5'). With countAnnounced: true (the CF Durable Object, whose R77 announced commit writes no checkpoint), a run that ended failed / interrupted whose session holds that same status is committed too (via: 'announced'). A live run, a superseded run, no run and no session are null. It writes nothing, and read errors propagate (the caller decides). CF Workflows' child consult and agent-server's /status call it without countAnnounced; the DO's /subagent/:type/status calls it with it. A caller that reports a run alongside the outcome should check that outcome.run.runId is the run it reports, since the helper reads the current run itself.

Session failure without a run ​

typescript
import { failSessionWithoutRun, type FailSessionWithoutRunInput } from '@helix-agents/core';

function failSessionWithoutRun(
  a: FailSessionWithoutRunInput
): Promise<{ outcome: 'failed' | 'superseded' | 'not_found' }>;

interface FailSessionWithoutRunInput {
  stateStore: Pick<SessionStateStore, 'loadState' | 'saveState'>;
  streamManager?: StreamManager; // used only with a currentRunId
  sessionId: string;
  streamId: string;
  currentRunId: string | null; // the run the caller saw as current; null: the session never ran
  errorDetail: ErrorDetail;
  logger?: { warn(message: string, data?: Record<string, unknown>): void };
}

The counterpart of failCommittedRun for a session that is active with no live run: an entry that failed before its run-start commit on a session that never ran, or a session whose current run has already ended.

  • It loads the session (missing: outcome: 'not_found', nothing written) and saves it failed with error = errorDetail.message and the errorDetail, clearing output, interruptContext and remotePendingCalls. It drops the unanswered pending client-tool calls of currentRunId and unstamped ones (withoutUnansweredPendingOf, CP-256); endSessionWithoutRun's interrupted form leaves the pending map as it is.
  • The save carries the IDLE fence fenceIdle: { runId: currentRunId } (null = no run): the store refuses it when a different run is current or the current run is live, so a run that started meanwhile keeps the session. That refusal (RunSupersededError) is outcome: 'superseded' with nothing written; any other save error is rethrown, so the caller's retry policy decides.
  • It is a saveState, so it writes no checkpoint, and it fires no lifecycle hook (an entry failure fires none).
  • Streams are written only by their runs (user ruling R43): with currentRunId: null the session save is the ONLY write, and streamManager is never called (no stream is created, read, failed or written). The stream is failed, fenced on that run ({ expectRunId: currentRunId }), only when currentRunId !== null and a streamManager is passed; a stream-fail error is logged (logger.warn) and rethrown.
  • CF Workflows records a never-run session's pre-commit failure with it (currentRunId: null, no stream manager), so that failure can never fail the next turn's live stream or leave an error chunk after its cursor.
  • runtime-js (execute() / retry() pre-commit failures) and the CF DO wake use it too.

Entry-failure classification ​

typescript
import { classifyEntryFailure, type EntryFailureClass } from '@helix-agents/core';

type EntryFailureClass = 'lost' | 'refused' | 'transient' | 'fatal';
function classifyEntryFailure(error: unknown): { cls: EntryFailureClass; detail: ErrorDetail };

Classifies a failed entry by error class, reject cause, then code (never by message text). detail is resolveErrorDetail(error).

ClassWhat it covers
lostsomeone else owns the session, or it is legitimately waiting: AgentAlreadyRunningError (and RunStartConflictError), RunSupersededError, StaleStateError, StepCommitConflictError, FencingTokenMismatchError, RunStartRejectedError with expected_mismatch_idle / expected_run_status_changed / entry_status_changed / entry_abandoned / expected_run_stopped / resumed_companion_ref_terminated, and code state_not_resumable
refuseda typed refusal before anything ran: framework_agent_not_found, state_agent_type_mismatch, validation_error, framework_not_supported, framework_companion_not_supported, framework_completion_strategy_unavailable, framework_completion_schema_unsupported
transientanything else whose retryable is not false, unclassified errors included, and RunStartRejectedError with consumed_pending_changed / request_key_taken (a keyed retry re-plans from its key and attaches)
fatala classified retryable: false error that is not lost or refused, a permanent store error (isPermanentStoreError: a corrupt row such as InvalidStoredStateError, CorruptMessageRowError, UnknownCommitKindError, which reads the same on every retry), and RunStartRejectedError with idempotency_mismatch / resumed_companion_ref_missing

The reject-cause map is exhaustive, so a new rejectCause is a typecheck error until it is classified. The CF Durable Object's drive pipeline maps the class onto its answer: a request answers lost with the busy 409, refused / fatal with the typed status, transient with 503 (409 / 500 once the input committed); a wake treats lost as a no-op, transient as a re-arm and refused / fatal as terminal.

Closed-owner reconciliation ​

A run whose owner is provably closed (a CF Workflows instance that ended, a Durable Object whose driver gave up) is settled from its durable state alone, never from its run status. A reader reads the run, the session, the latest checkpoint and the run's own stream status, asks the pure planner what to do, then runs the executor.

typescript
import {
  planClosedOwnerReconciliation,
  reconcileClosedOwnerRun,
  isSuspensionCommitted,
  suspendedStatusOf,
  suspendedResultOf,
  assertNeverClosedOwnerPlan,
  assertNeverClosedOwnerReconciliation,
  CLOSED_OWNER_PLAN_KINDS,
  isSuspendedRunStatus, // (status: RunStatus) => status is SuspendedRunStatus
  isPermanentStoreError, // (e: unknown) => e is Error
  type ClosedOwnerPlan,
  type ClosedOwnerPlanInput,
  type ClosedOwnerPlanNoneReason, // 'superseded' | 'not_current' | 'settled'
  type ClosedOwnerReconciliation,
  type ClosedOwnerNoneReason, // 'plan_none' | 'superseded' | 'not_current' | 'run_ended' | 'run_missing' | 'session_missing' | 'suspension_not_committed'
  type ClosedOwnerStreamOutcome, // 'written' | 'already_terminal' | 'not_owned' | 'foreign' | 'absent' | 'unavailable'
  type ReconcileClosedOwnerRunDeps,
  type ReconcileClosedOwnerRunInput,
  type SuspendedRunStatus, // derived in types/session.ts: Extract<RunStatus, `suspended_${string}`>
} from '@helix-agents/core';

interface ClosedOwnerPlanInput {
  run: RunMetadata;
  session: SessionState;
  latestCheckpoint: Checkpoint | null;
  ownStreamStatus: 'active' | 'paused' | 'ended' | 'failed' | null; // null: foreign, missing or unreadable stream
  currentRunId: string | null; // the session's current run
  stop: StopRequest | null; // the run's stop record (readStop), read in the same read set; required (WA MR2)
}

function planClosedOwnerReconciliation(input: ClosedOwnerPlanInput): ClosedOwnerPlan; // pure

// Every variant carries the `runId` it was planned for; the executor throws framework_internal_error
// (before any read) when `plan.runId !== input.run.runId`.
type ClosedOwnerPlan =
  | { kind: 'finish_committed'; runId: string }
  | {
      kind: 'complete_suspension';
      runId: string;
      status: SuspendedRunStatus;
      writeRunStatus: boolean;
      pauseStream: boolean;
    }
  | { kind: 'settle_stopped'; runId: string; stop: StopRequest } // WA MR2
  | { kind: 'fail_uncommitted'; runId: string }
  | { kind: 'repair_stream'; runId: string; outcome: CommittedTerminalStatus }
  | { kind: 'none'; runId: string; reason: ClosedOwnerPlanNoneReason };

function reconcileClosedOwnerRun(
  deps: {
    stateStore: SessionStateStore;
    streamManager?: StreamManager;
    logger?: { warn(m: string, d?: Record<string, unknown>): void };
  },
  input: {
    sessionId: string;
    run: RunMetadata;
    plan: ClosedOwnerPlan;
    failure?: { reason: string; errorDetail: ErrorDetail }; // required by 'fail_uncommitted'; an executor input because the DO decides re-drive vs fail after planning
    makeLive?: () => Promise<void>;
  }
): Promise<ClosedOwnerReconciliation>;

type ClosedOwnerReconciliation =
  | { outcome: 'finished'; status: CommittedTerminalStatus; stream: ClosedOwnerStreamOutcome }
  | { outcome: 'suspended'; status: SuspendedRunStatus; stream: ClosedOwnerStreamOutcome }
  | { outcome: 'settled'; status: 'interrupted' | 'failed'; stop: StopRequest } // settle_stopped
  | { outcome: 'failed' }
  | { outcome: 'stream_repaired'; stream: ClosedOwnerStreamOutcome } // only stream: 'written' repaired it
  | { outcome: 'none'; reason: 'plan_none'; planReason: ClosedOwnerPlanNoneReason }
  | { outcome: 'none'; reason: Exclude<ClosedOwnerNoneReason, 'plan_none'> }
  | { outcome: 'not_committed'; cause: unknown };

function isSuspensionCommitted(a: { session: SessionState }): boolean;
function suspendedStatusOf(session: SessionState): SuspendedRunStatus | null;
function suspendedResultOf(session: SessionState): NonNullable<AgentResult['suspended']>;

The planner is an ordered ladder; the first rung that answers wins:

Durable statePlan
the run is supersedednone / superseded
the run is not the session's current runnone / not_current
live (running or suspended_*) and its own K5 state commit is the latest checkpointfinish_committed: finish the run with the committed outcome, end or fail its own stream (an active or paused stream)
live, with a stop record (input.stop)settle_stopped: end it with the stop's terminal (commitStopTerminal: interrupted, or failed / framework_cancelled); never re-driven
live (running or suspended_*) and the suspension is committedcomplete_suspension: write the suspended_* status if the run still reads running, pause its own stream if active, report the suspension
live, nothing committedfail_uncommitted: fail the run (session K5, run, own stream) with the caller's failure
completed / failed / interrupted and its own stream is still active or pausedrepair_stream: end or fail the stream from the committed outcome
anything elsenone / settled
  • isSuspensionCommitted({ session }) is the one definition of "the suspension is committed": the session is paused AND awaits something (suspendedStatusOf(session) !== null). It deliberately reads the session, not the latest checkpoint, because suspension order differs per runtime (CF Workflows writes the run status first; runtime-js, the CF DO and Temporal write it last) and a CF Workflows cascade suspension is a step commit followed by a fenced paused save. A stale paused session behind a resume entry that crashed after its run-start commit also counts: failing it would destroy the user's turn, while reporting it suspended keeps it resumable.
  • suspendedStatusOf(session) reads only the durable suspension maps: awaited client calls and awaited children → suspended_step_partial; calls only → suspended_client_tool; children only → suspended_awaiting_children; nothing → null. A mixed server+client step (no children) killed between its suspension commit and its run-status write derives suspended_client_tool; only the status label differs, resume is unaffected.
  • suspendedResultOf(session) builds the AgentResult.suspended payload (toolCallIds, children, stepId) a reader reports for a suspended outcome.
  • isPermanentStoreError(err) is true for a store-path error a retry cannot fix: a non-retryable HelixError (an invariant, an unsupported operation) or a corrupt-row error (InvalidStoredStateError, CorruptMessageRowError, UnknownCommitKindError; matched by name, so a copy from another package or across an RPC boundary counts). A reader that would otherwise answer "nothing known yet" and try again later fails typed on it instead.
  • The executor never decides and never re-plans. Every write is fenced on the run: a run that ended, was superseded or is no longer current since the plan yields { outcome: 'none', reason } (superseded, not_current, run_ended, run_missing, session_missing; a none plan reports reason: 'plan_none' with its planReason). A complete_suspension re-checks its premise on its own session read: that session must still record a committed suspension (else none / suspension_not_committed, nothing written), and the status it writes is the one that read describes (suspendedStatusOf), not the planned status. It reads the session up front, and after a RunSupersededError re-reads the run to tell superseded from run_ended / run_missing. It writes to a stream only when its claim (claimStreamForCommittedRun, terminal mode) owns it, and never touches a child's stream in its parent (isForeignStream).
  • stream says what happened to the run's own stream (ClosedOwnerStreamOutcome): written (the plan's end / fail / pause was issued; for a suspension a stream already paused also counts), already_terminal, not_owned (the claim did not give the run the stream), foreign (a child's stream in its parent, never touched) or absent (no stream manager, or no such stream) or unavailable (a stream read, claim or transition threw, e.g. a stream Durable Object answering non-2xx; logged reconcile.stream_unavailable). A claim that owns nothing logs logger.warn('reconcile.stream_not_owned', { sessionId, runId, streamId, reason }); reason is the claim's reason, or 'active_unplanned' for a complete_suspension with pauseStream: false whose stream is active when observed (the plan read it paused, absent or unreadable; that observation is read-only, never a claim, and the next reader's plan pauses it).
  • repair_stream always returns stream_repaired. A repair that wrote nothing used to be none; check stream (written is the only repaired case). finish_committed and repair_stream end or fail an owned active or paused stream; a pause never revives a stream. The suspension status write carries stepCount only. fail_uncommitted uses failCommittedRun({ requireSessionCommit: true }), so a K5 write that does not land returns not_committed with its cause and leaves the run live.
  • The stream half never hides the outcome. It runs after the run-level write (for repair_stream, it is the whole plan), and a stream error there is logged and reported as stream: 'unavailable' instead of thrown, so a caller always learns finished / suspended / stream_repaired; a later reader's plan repairs the stream. A store error still rejects (the status write of finish_committed / complete_suspension, a fence read). A stream status the caller could not read is passed as null: the run-level rungs still answer and no repair_stream is planned.
  • A caller plans only an owner it proved closed; a live, paused or unreadable owner is never planned.
  • assertNeverClosedOwnerPlan(plan) is the default of an exhaustive switch (plan.kind); CLOSED_OWNER_PLAN_KINDS lists every kind. assertNeverClosedOwnerReconciliation(reconciled) is the default of an exhaustive switch (reconciled.outcome) (both throw framework_internal_error if reached).
  • Callers. The CF Workflows executor readers (each handle's result(), getHandle(), canResume() and abort(), which executes only finish_committed), the CF Workflows parent's consult of a child that closed without notifying, the CF Workflows owner's own R27 path (finish_committed and repair_stream only), and the CF Workflows entry fail-safe once it stopped a stalled run's owner. See Instances closed outside the product. runtime-js, Temporal and DBOS still use their own finish-or-fail paths.

Stop as one signal ​

WA MR2: every stop on every runtime records a run-scoped StopRequest, signals the run's owner and answers at once. Concepts: Stop as one signal; upgrade guide: stop-request.md.

typescript
import {
  // the record
  stopRequestSchema,
  parseStoredStopRequest, // (sessionId, raw) => StopRequest; a bad row throws InvalidStoredStateError (permanent)
  applyStopPrecedence, // (existing | null, incoming) => StopRequest
  stopRefusesCommit, // (refuse: RefuseIfStopped | undefined, stop | null) => boolean
  type StopKind, // 'interrupt' | 'abort'
  type StopRequest, // { sessionId, runId, kind, reason, requestedAt, deadlineAt: number | null }
  type StopTarget, // { kind: 'run'; runId } | { kind: 'current' }
  type RequestStopInput, // { kind, reason, target, deadlineMs: number | null }
  type RequestStopResult, // { recorded: true; stop } | { recorded: false; reason: 'no_live_run' }
  type RefuseIfStopped, // boolean | { allowKind: StopKind }
  // the default reason (one for every runtime, route and handle; WA MR2 F4-A m3)
  DEFAULT_ABORT_REASON, // 'Aborted by user'
  DEFAULT_INTERRUPT_REASON, // 'User requested interrupt'
  stopReasonOrDefault, // (kind, reason?) => reason ?? the kind's default
  // the answer
  stopAnswerBodySchema,
  stopAnswerResponse, // (answer) => { status: 202 | 200 | 409; body }
  type StopAnswer,
  // the one session-level stop path hosts share (CP-12)
  stopSession, // (deps, { sessionId, kind, reason }) => Promise<StopAnswer>
  hasSessionStop,
  type StopSessionDeps,
  type SessionStopExecutor,
  // the decision and the writers
  stopRun,
  commitStopTerminal,
  resolveTerminalWithStop,
  commitTerminalHonouringStop,
  stopTerminalOf,
  stopAbortDetail,
  type StopOwnerProbe,
  type StopRunDeps,
  type StopRunInput,
  type CommitStopTerminalInput,
  type CommitStopTerminalResult,
  discardUncommittedSteps,
  type DiscardUncommittedStepsInput,
  type PlannedTerminal,
  type StopTerminal,
  // companions
  type StopPersistentChild, // (childSessionId, kind, reason) => Promise<void>: resolves once recorded, else throws
  CompanionTerminateNotRecordedError, // terminateChild's tool failure (transport_error, retryable)
} from '@helix-agents/core';

type StopAnswer =
  | { status: 'stopping'; runId: string; kind: StopKind } // 202
  | { status: 'interrupted' | 'failed'; runId: string } // 200
  | { status: 'not_stoppable'; currentStatus: SessionStatus | 'none' }; // 409 state_not_stoppable

interface StopOwnerProbe {
  executing(runId: string): boolean | Promise<boolean>;
  signal(stop: StopRequest): void | Promise<void>;
  cancelPendingEntry?(reason: string): void;
}

function stopRun(
  deps: { stateStore; streamManager?; owner: StopOwnerProbe; streamIdFor?; logger? },
  input: {
    sessionId;
    kind: StopKind;
    reason: string;
    target: StopTarget;
    deadlineMs: number | null;
  }
): Promise<StopAnswer>;

type CommitStopTerminalResult =
  | { outcome: 'committed'; status: 'interrupted' | 'failed'; stop: StopRequest }
  | { outcome: 'already_ended'; status: RunStatus }
  | { outcome: 'superseded' }
  | { outcome: 'not_committed'; cause: unknown };
  • stopRun is the one stop decision every runtime, route and companion path calls. It validates deadlineMs (a non-negative integer or null, else validation_error before anything is written), cancels a pending entry (cancelPendingEntry), records the stop (requestStop; no_live_run → not_stoppable), then: an owner executing the run is signalled with the RECORDED stop (after precedence) → stopping; otherwise commitStopTerminal → interrupted / failed. already_ended answers that terminal when it is interrupted / failed (also when the run reached it on its own), else not_stoppable; not_committed (logged at warn) and superseded re-read the durable state once and answer from it (stopping while the run is still live: the wake or the deadline settles it). A missing session throws state_session_not_found.
  • commitStopTerminal is the one writer of a stop's terminal when nothing executes the run (a paused run, a stranded run, the DO's stop deadline, a closed owner's settle_stopped). In terminal order: K5 (a state commit fenced on the run, guarded { allowKind: stop.kind }, clearing pendingClientToolCalls; interrupted with interruptContext, or failed with stopAbortDetail(reason)), the run status, then the run's own stream (run_interrupted + end, or fail), never a child's stream in its parent. Before the terminal chunk / fail, on the run's own active stream, the run's uncommitted steps are marked step_discarded (discardUncommittedSteps, Ruling 66). A K5 that does not land writes nothing else (not_committed); a run that already ended, or whose own K5 landed, is already_ended and nothing is written. stepIdOf (also on StopRunDeps) is the runtime's step-id label for those markers (default step-${step}; DBOS passes its 0-based step-<n-1>).
  • discardUncommittedSteps({ stateStore, streamManager, streamId, sessionId, runId, agentType, reason, stepIdOf? }) is the one rule for a stopped run's step_discarded markers (WA MR2 Ruling 66, the checkpoint-truth step.coverage rule), shared by commitStopTerminal, runtime-js's in-run stop resolution, Temporal's refused step commit and DBOS's dead-owner settle. It reads the run's own content chunks (agentId === sessionId, stamped with the run, from its startSequence: bounded by the run's own length), drops tool chunks whose call has a persisted tool row (an append / run-start commit carried it), and marks every remaining step N that has no step checkpoint of THIS run with stepCount N (listRunCheckpoints) — never a step-count high-water, since a turn's run-start checkpoint carries the previous turn's stepCount. Exactly once: a step with the run's step_discarded or step_committed is skipped. The marker is attempt-neutral and run-stamped (the CP-85 chunk fence drops it once a newer run owns the stream). A run with no record writes and reads nothing. Returns the marked steps; throws a read / write failure (callers treat it as best-effort).
  • resolveTerminalWithStop(planned, stop, signal) is called before a run's K5 and suspension commits: a stop record, else a signal aborted with a StopRequest, wins over the plan (stopTerminalOf); any other aborted signal resolves to the abort terminal; a live signal returns planned itself. commitTerminalHonouringStop commits the resolved terminal under the guard (true for the run's own plan, { allowKind } for a stop resolution) and, on CommitStoppedError, re-resolves with the refused stop and commits that (at most twice, then rethrows).
  • stopAbortDetail(reason) is { message: reason, code: 'framework_cancelled', category: 'framework', retryable: false }, the detail every abort ends its run with.
  • stopSession(deps, { sessionId, kind, reason }) (CP-12) is the one session-level stop path: agent-server's /interrupt / /abort and /chat/{id}/interrupt|abort, and ai-sdk's handleChatStop.
    • It stops the session's CURRENT live run through the executor's session-level stop when it has one (runtime-js; hasSessionStop).
    • Otherwise it goes through the run's handle: deps.trackedHandle (a host's in-process handle), else executor.getHandle(deps.agentFor(type), sessionId). It then reads the run and its stop record back for the StopAnswer.
    • It throws state_session_not_found for a missing session, and framework_agent_not_found on the handle path with no registered agent.
  • stopReasonOrDefault(kind, reason?) is the one default reason (WA MR2 F4-A m3): reason, else DEFAULT_ABORT_REASON ('Aborted by user') or DEFAULT_INTERRUPT_REASON ('User requested interrupt'). runtime-js handles and stop(), agent-server's interruptAgent / abortAgent and the CF DO's stop routes use it; CF Workflows, DBOS and Temporal adopt it in F4-B / F4-C.
  • StopPersistentChild (WA MR2 F4-A S1) resolves once the child's stop is RECORDED (its signal or terminal may still have failed: the record ends the child) or the child has nothing to stop, and throws when the stop was not recorded. CP-161 fix 4 (I-B): companion__terminateChild records the stop FIRST and writes the ref terminated (with completedAt and completionDelivered: true, scoped to the generation it read) only once the stop is recorded. A stop that was not recorded writes nothing (no rollback, so no reopen and no generation bump) and fails the tool call with CompanionTerminateNotRecordedError (transport_error, retryable) — never { terminated: true }; the child's own outcome then lands and is delivered as usual. A ref write that fails after the recorded stop is logged (companion.terminate.ref_not_written) and answered { terminated: true }; the stopped child's own end is the ref's. The dep's fourth argument, target: { consult, generation }, names the generation the terminate read: runtime-js and CF Workflows stop only a run serving that consult (runServesRef); the CF DO (fix 5) reads its consult row, then the ref, and stops only while the ref is live at that generation. Fix 5 (I-2): a child's own end that races the terminate never replaces terminated (subSessionRefOutcomeWriteRefusal), so both orders end terminated.

SQL migration runner ​

One claim-first migration runner for D1-shaped (SQLite) databases. @helix-agents/store-cloudflare (D1 schema) and @helix-agents/memory-cloudflare (memory schema) both run on it. Core imports no Cloudflare type: a D1Database satisfies SqlMigrationDatabase structurally.

typescript
import {
  runSqlMigrations,
  readSqlMigrationVersion,
  splitSqlStatements,
  classifySqlStatement,
  type SqlMigration, // { version, description, statements }
  type SqlMigrationDatabase, // prepare() + batch() (one transaction)
  type SqlMigrationStatement,
  type RunSqlMigrationsOptions, // { versionTable, migrations, fresh?, now? }
  type SqlStatementClass,
} from '@helix-agents/core';

function runSqlMigrations(
  db: SqlMigrationDatabase,
  options: RunSqlMigrationsOptions
): Promise<void>;
function readSqlMigrationVersion(db: SqlMigrationDatabase, versionTable: string): Promise<number>; // validates versionTable
function splitSqlStatements(sql: string): string[]; // splits on ';' outside BEGIN ... END; strips '--' comments
function classifySqlStatement(sql: string): SqlStatementClass; // add_column | drop_column | other
  • Claim-first. Each migration is one batch whose FIRST statement inserts its version row. Of several runners racing for one version, exactly one batch commits; the others roll back whole, so a data statement (an UPDATE, a DELETE) never runs twice.
  • Guarded column changes. ALTER TABLE … ADD COLUMN is skipped when the column exists and DROP COLUMN when it is gone (SQLite has no IF [NOT] EXISTS for them). The column name is compared case-insensitively (COLLATE NOCASE), as SQLite compares column names. Every other statement must be safe to re-run on its own (CREATE … IF NOT EXISTS) or be a data statement the version claim runs once; the D1 and memory statement tests pin this.
  • A failed batch re-reads the version. Recorded → another runner applied it, continue. Not recorded → the original error is rethrown. If the re-read itself fails, an AggregateError([batchError, rereadError]) is thrown, original first, so the batch's error is never replaced. No error text is ever matched.
  • versionTable is validated. It is interpolated into SQL, so it must be a bare identifier ([A-Za-z_][A-Za-z0-9_]*); anything else throws framework_internal_error before any SQL runs. This holds for runSqlMigrations and the exported readSqlMigrationVersion alike.
  • Statement splitting (splitSqlStatements). A block opens at the whole word BEGIN followed by whitespace and closes at the first ; whose preceding text ends with the word END starting a body statement (at the start of a line or right after a ;, so a single-line trigger … BEGIN UPDATE a SET y = 1; END; closes). Identifiers such as begin_at, end_sequence, endpoint, t.end or a CASE … END in mid-line, never open or close a block, and neither does a bare end column before a ; as long as end is neither the first token on its line nor directly after a ;. A block still open at the end of the input throws framework_internal_error (no swallowed statement is emitted); that also fires for a trailing … END with no final ; and for a BEGIN inside a string literal. Limitations: -- comments are stripped to the end of the line everywhere, including inside string literals, and a CASE's END (or a column named end) that starts a line or follows a ;, and is followed by ;, closes the block early, so the trigger fails to prepare (loudly); keep such an END on the line of its CASE.
  • Fresh databases. With options.fresh, a database with no version row and no schema applies the collapsed schema and every version row in one batch (the first row is the claim).
  • readSqlMigrationVersion returns 0 for an empty version table; the version table must exist.

Retry planning ​

typescript
import {
  planRetry,
  type RetryPlan,
  type RetryPlanStore,
  type RetryRestoredFields,
} from '@helix-agents/core';

type RetryPlanStore = Pick<
  SessionStateStore,
  'getLatestCheckpoint' | 'getCurrentRun' | 'listCheckpoints' | 'getCheckpoint'
>;

function planRetry(a: {
  store: RetryPlanStore;
  sessionId: string;
  history: readonly Message[];
  explicitCheckpoint?: Checkpoint | null; // the RESOLVED options.checkpointId
  messages?: readonly Message[]; // options.message rows
  currentStepCount: number;
  pending?: Readonly<Record<string, PendingClientToolCall>> | null; // W1 MR2b: read with the history
  retryRunId?: string; // W1 MR2b: the run the retry commits (required where a stash may be pending)
}): Promise<RetryPlan>;

interface RetryPlan {
  target: Checkpoint | null; // null = genesis
  restored: RetryRestoredFields | null; // { customState, stepCount, forcedCompletion }
  truncateTo: number; // the run-start commit's keep-count
  input: Message[]; // the rows the retried run appends
  stash?: PendingStash; // rules 5–6: the gated re-stash for the retried run (then input is [])
  dropDelivered?: string[]; // carriers whose stash the log already holds: drop them
}

The target rules are listed under RetryOptions. The failed run's checkpoints are selected by runId only (never by a time window). A run-start target restores the run's entry state (its entry state commit when it wrote one, else the run-start commit's, with RunMetadata.entryStepCount as the step count, or 0 for a max_steps cut-off, which also needs no message), and the trigger is searched only within the run's own input rows (RunMetadata.inputRowCount). A session with no checkpoint attributed to the failed run (legacy rows) falls back to the latest checkpoint; one with none restarts from genesis. Throws validation_error when there is no user message to retry with.

W1 MR2b rules (the module header of store/retry-plan.ts has them in full):

  • A user row is a trigger only when it is neither a memory injection nor a hidden sub-agent completion notification (isSubAgentCompletionNotification). Notifications after the kept prefix are re-appended in log order.
  • Rule 4. A resumed run whose entry appended its own input row after its drain (marked with COMMON_METADATA_KEYS.ENTRY_INPUT, isEntryInputRow; never found by role) is rewound to just before the row and re-appends it and the rows after it; the restored state is the commit that contains the row. Applies only where the retry would otherwise throw validation_error and the run committed no step (rule 2's resume branch).
  • Rule 5. A pending carrier whose entryInput holds rows the kept log does not (undeliveredStashRows) makes the retry drain: input: [] and stash re-stashes the carrier for retryRunId (the failed run's own rows re-marked, or replaced by options.message; other runs' rows kept). A carrier whose stash was already delivered is returned in dropDelivered. More than one carrier, or a carrier with no retryRunId, is a non-retryable framework_internal_error.
  • Rule 6. With no stash, answered pending calls trailing the kept log make the retry stash its input on the last of them (only when retryRunId is given). An unmarked real user row in that stash is marked as the retried run's input; any other unmarked non-tool row (a hidden sub-agent completion notification, a memory injection) gets the stash-delivery marker (COMMON_METADATA_KEYS.STASH_DELIVERY, markStashDeliveryRow) instead, so the delivery check can prove it delivered and a later retry never appends it twice. Rule 5 re-marks such a row for the retried run and keeps it, with or without options.message.

Snapshot view ​

typescript
import {
  readSnapshotView,
  assembleSnapshotView,
  deriveSnapshotStatus,
  type SnapshotView,
  type SnapshotViewCheckpoint,
  type SnapshotViewRun,
  type SnapshotViewSource,
  type ReadSnapshotViewDeps,
  type ReadSnapshotViewOptions,
} from '@helix-agents/core';

function readSnapshotView(
  deps: ReadSnapshotViewDeps, // { stateStore, streamManager, logger? }
  sessionId: string,
  opts?: ReadSnapshotViewOptions
): Promise<SnapshotView | null>; // null only when the session does not exist

interface ReadSnapshotViewOptions {
  includeThinking?: boolean;
  passes?: number; // default 3
  sleepsMs?: number[]; // default [25, 50]; the last value repeats
  deadlineMs?: number; // default 10000: bounds the whole read; a pass has no bound of its own.
  // Infinity = no deadline; anything else outside (0, 2147483647] is a validation_error
  sleep?: (ms: number) => Promise<void>;
}

// deriveLegacySnapshotStatus(streamStatus, sessionStatus): the status of a legacy view
// (latest checkpoint kind null AND a run with no startDigest): the session / stream decide,
// because a pre-upgrade run record may never have been ended (RM-47).
interface SnapshotView {
  sessionId: string;
  status: SnapshotStatus; // deriveSnapshotStatus (deriveLegacySnapshotStatus for a legacy view)
  head: number; // the stream's latestSequence, -1 when there is no stream
  cursor: number; // checkpoint.streamSequence, or head in legacy mode
  legacy: boolean; // latest checkpoint has kind null, or there is none
  checkpoint: SnapshotViewCheckpoint | null;
  messages: Message[]; // [0, checkpoint.messageCount), or the whole log in legacy mode
  state: unknown; // custom state AT the cursor
  run: SnapshotViewRun | null; // { runId, status, startUIMessageCount, startSequence, turn, requestKey?, requestDigest? }
  session: SessionState;
}

interface SnapshotViewCheckpoint {
  id: string;
  kind: CommitKind | null;
  runId: string | null;
  keepCount: number | null;
  stepCount: number;
  messageCount: number;
  streamSequence: number;
  customState: unknown;
}

interface SnapshotViewSource {
  readSnapshotView(
    sessionId: string,
    opts?: { includeThinking?: boolean }
  ): Promise<SnapshotView | null>;
}

function deriveSnapshotStatus(
  streamStatus: ResumableStreamStatus | 'none',
  run: RunMetadata | null,
  sessionStatus: SessionState['status']
): SnapshotStatus;

A snapshot is taken from ONE checkpoint: its messages are the committed prefix [0, cp.messageCount) (uncapped, never partial), its cursor is cp.streamSequence, and its state is the checkpoint's customState. Because every commit writes the rows and the checkpoint atomically, a client that seeds the messages and resumes the stream from the cursor sees each committed chunk exactly once. @helix-agents/ai-sdk's buildSnapshot turns a SnapshotView into the client-facing FrontendSnapshot.

readSnapshotView is the generic reader over any store and stream manager. With no transaction across them, it fences each pass: it reads the status, current run and latest truncate checkpoint id, then the checkpoint and its prefix (loadHistoryPrefix), then the fence again, and returns the pass only if (status, current runId, latest truncate id) is unchanged (in legacy mode, also the stream head). A torn pass is retried after the configured sleeps; when no pass is stable it throws state_history_incomplete with reason: 'snapshot_unstable' (retryable). Passes run one after another and have no time bound of their own (a slow pass whose fence held is a correct view); deadlineMs (default 10 000) bounds the whole read: a read still running at the deadline stops issuing store calls and throws the same snapshot_unstable. The error is self-diagnosing: snapshotPasses holds one SnapshotPassOutcome per pass — a fence_changed with the first fence field that moved and its before/after values, and, last, a deadline (deadlineMs) with the read it was waiting on (null in the backoff, shown as deadline@backoff; a pass that never started is not counted) when the read missed its deadline — and the message ends with a short summary of them (deadline@getMessages, status paused->active, run ids shortened), kept within the 256 characters the snapshot routes put on the wire. A read failure inside a stable pass is thrown typed (short_read, corrupt_row, store_error). After the deadline, a failure of the store call the read was waiting on (the real cause behind deadline@<read>) is logged through the optional deps.logger (warn); the deadline's own abort is not. A store that can read everything in one transaction (the CF DO) implements SnapshotViewSource itself, and both use assembleSnapshotView to shape the view.

deriveSnapshotStatus: a live current run is paused when it is suspended or the stream is paused, else active — even before the run claimed its stream, while the stream still shows the previous turn's ended (the new input rows are already in the snapshot). Otherwise a failed run or stream is failed, and anything else is ended. With no run at all, a failed session reads failed (a session that never ran is failed on its session alone, with no stream write); with a run, the session status never overrides it. The run decides liveness, never the stream alone.

Legacy mode. A session whose latest checkpoint has kind: null (or has no checkpoint) is served the complete live log with the stream head as the cursor, until its first new commit.

UI rows ​

typescript
import {
  isUIIndexedRow,
  uiRowIndices,
  uiMessageGroups,
  responseIndexFor,
  runBoundaryFrom,
} from '@helix-agents/core';

function isUIIndexedRow(m: Message): boolean; // not `tool`, not metadata.hidden
function uiRowIndices(messages: readonly Message[]): (number | null)[];
function uiMessageGroups(
  messages: readonly Message[]
): Array<{ firstRowIndex: number; rows: number[] }>;
function responseIndexFor(prefix: readonly Message[]): number;
function runBoundaryFrom(prefix: readonly Message[]): {
  startMessageCount: number;
  startUIMessageCount: number;
};

The pinned rule the UI converter and the run records share: every row that is not a tool row and not metadata.hidden gets the next row index. Consecutive assistant rows (tool rows between them do not break the group) form one UI message whose id is generateId(index of its first row); every user or system row is its own UI message. responseIndexFor(prefix) is the first-row index of the UI message that will hold the next run's response: the trailing assistant group's first row on a continuation, otherwise the next row index. Runtimes record it as RunMetadata.startUIMessageCount; the resume target is generateId(startUIMessageCount). The old UI-message-count helper is removed (breaking); use these.

Stream stamping and run markers ​

typescript
import {
  withRun,
  withAttempt,
  newAttemptId,
  emitRunStarted,
  emitStepCommitted,
  emitStepDiscarded,
  claimStreamForCommittedRun,
  isStreamRunFenced,
  isForeignStream,
  chunkFenceRunId,
  isGuestChunk,
  decideStreamWrite,
  createGuestDropLog,
  YieldedFailures,
  StreamClaimStaleReadError,
  type StreamClaimMode, // 'entry' | 'terminal'
  type StreamClaimResult,
} from '@helix-agents/core';

function withRun(writer: StreamWriter, runId: string): StreamWriter;
function withAttempt(
  writer: StreamWriter,
  attemptId?: string
): { writer: StreamWriter; attemptId: string };
function newAttemptId(): string; // 'attempt-<ms>-<hex>'

function claimStreamForCommittedRun(a: {
  streamManager: StreamManager;
  stateStore: Pick<SessionStateStore, 'getCurrentRun' | 'getRun'>;
  sessionId: string;
  streamId: string;
  runId: string;
  makeLive?: () => Promise<void>;
  mode?: StreamClaimMode; // default 'entry'
}): Promise<StreamClaimResult>;

interface StreamClaimResult {
  owned: boolean;
  reason?: 'not_current' | 'ended' | 'outranked' | 'no_stream';
}
  • withRun / withAttempt wrap a writer so every chunk carries runId / attemptId. A chunk that already has the field keeps it. Every runtime writes through both.
  • emitRunStarted(writer, { sessionId, agentType, runId, step, timestamp? }), emitStepCommitted(writer, { ..., stepId, checkpointId, committedAttemptId }) and emitStepDiscarded(writer, { ..., stepId, reason }) write the marker chunks. A durable runtime whose entry may be retried passes a fixed timestamp so a re-emitted run_started is byte-identical.
  • claimStreamForCommittedRun makes a run that won its run-start commit the owner of its session's stream. Each round reads the owner first, then the current run, then claims with a compare-and-set on the owner it read, so a claim never overwrites a newer claim. It never takes the stream over from a run that is live or of a later turn, and a run that already ended never revives its stream. entry mode (right after the commit) creates or revives the stream with makeLive and throws the retryable StreamClaimStaleReadError (state_concurrency_conflict) when the current-run read lags the commit. terminal mode (before a fenced end / fail / pause) claims nothing for a run that is no longer current. Never call it for a sub-agent that writes into its parent's stream.
  • isStreamRunFenced(currentRunId, fence) is the managers' fence check: true when a different run owns the stream. chunkFenceRunId(streamId, chunk) returns the run a chunk write is fenced on (undefined for a child's chunk in its parent's stream, a chunk without runId, and run_terminated).
  • isGuestChunk(streamId, chunk) is true for a chunk of another session (agentId !== streamId): the one session test the CP-85 fence and the CP-129 write rule share.
  • decideStreamWrite({ streamId, chunk, status, currentRunId, remoteHighWater?, ownRunStopped? }) is the write decision every stream manager makes on the write's own status read (CP-129). It returns { kind: 'append' }, { kind: 'drop', reason: 'fenced' | 'guest_inactive' | 'duplicate_remote_chunk' | 'stopped_run' } or { kind: 'refuse', reason: 'not_active' }.
    • A chunk is the stream's OWN when its session is the stream's, or it carries the stream's owner run (currentRunId; e.g. a clone writing its source's stream). A GUEST chunk is another session's, stamped with its own run: a child's chunk in its parent's stream.
    • A guest chunk to an ended, failed or paused stream is dropped (guest_inactive). An own chunk there is refused (the manager throws, as before), unless its run has a recorded stop (ownRunStopped, CP-193): then it is dropped stopped_run. A chunk of another session with no runId is never dropped.
    • On an active stream, a superseded run's own-session chunk is dropped fenced (CP-85); everything else is appended. A custom stream manager calls it inside its write's atomic step.
  • createGuestDropLog(logger) returns the managers' rate-limited debug log of guest_inactive drops (stream.guest_chunk_dropped, at the 1st, 10th, 100th … drop per stream, guest session and status).
  • createStoppedRunDropLog(logger) returns the one warn per stream and run for stopped_run drops (core.stream.stopped_run_write_dropped, payload { streamId, runId, status, chunkType }). It remembers the last STOPPED_RUN_DROP_LOG_MAX_RUNS (256) pairs, oldest forgotten first.
  • isForeignStream(session, sessionId, streamId) is true when the session is a child (it has a parentSessionId) writing into a stream it does not own (its parent's, streamId !== sessionId). A foreign stream's liveness belongs to the parent run: a child never claims, revives, pauses, ends or fails it.
  • YieldedFailures records the error chunks a reader already yielded, so a manager does not deliver the same failure twice when the stream fails.

Client-tool submit helpers ​

typescript
import {
  resolveSupersededSubmit,
  shouldRereadAfterSupersededSubmit,
  isClientToolBatchPartial,
  unsubmittedClientToolCallIds,
  isSubmittedBatchReady,
  isSubmittedBatchReadyAtLastSubmit,
  resolveSubmitContinuation,
  planWithMessageEntry,
  markEntryInputRow,
  isEntryInputRowOf,
  isEntryInputRow,
  undeliveredStashRows,
  entryInputMarkerSchema,
  markStashDeliveryRow, // a stashed row that is not input: { runId } delivery proof
  isStashDeliveryRowOf,
  isStashDeliveryRow,
  stashDeliveryMarkerSchema,
  submitContinuationSchema,
  type SupersededSubmitVerdict, // 'proceed' | 'discarded'
  type PendingBatchEntryView,
  type SubmitContinuation,
  type SubmitStoreAnswer, // { status: 'accepted' | 'already_completed' } | UnknownToolCallAnswer
  UNKNOWN_TOOL_CALL_REASONS,
  type UnknownToolCallAnswer,
  type UnknownToolCallReason,
  SUBMIT_REFUSAL_CAUSES,
  type SubmitRefusalCause,
  refuseSubmitIfStopped,
  runStoppedSubmitAnswer,
  isRunEndedByStop,
  isSessionEndedByStop, // the session form (status + the abort's framework_cancelled detail)
  type StoppedSubmitSettle,
  parseSubmitToolResultAnswer, // (body, source) → SubmitToolResultResponse; a malformed peer answer is a typed, non-retryable transport_error
  type SubmitContinuationStart,
  type SubmitContinuationStartInput,
  type SubmitContinuationStartKind,
  classifySubmitOwner, // CP-250: 'root' | 'awaited_child' | 'companion' (SubmitOwnerRole)
  type SubmitOwnerRole,
  type WithMessageEntryPlan,
  type EntryInputMarker,
  type StashDeliveryMarker,
  approvalResponseResultSchema, // Zod: { approved: boolean, reason?: string }
  isApprovalResponseResult, // a submitted value is an approval answer (its shape only)
  type ApprovalResponseResult,
} from '@helix-agents/core';

// CP-154: a drain classifies a pending entry as an approval by its
// `approvalId` (written when the call suspends), never by finding an
// approval-gated tool; `isApprovalResponseResult` checks only the answer's
// shape. An approval-gated tool's answered entry without an `approvalId` is
// refused with a typed `framework_internal_error` result.

// W1 MR2b: what a stored answer caused; REQUIRED on accepted / already_completed.
type SubmitContinuation =
  | { kind: 'awaiting_batch' }
  | { kind: 'caller_resumes' }
  | { kind: 'new_run'; runId: string; startSequence?: number; sessionId?: string } // sessionId: another session's run (CP-250)
  | { kind: 'deferred'; blockingRunId: string } // a run of this session carries (or carried) the continuation
  | { kind: 'consumed' };

type SubmitToolResultResponse =
  | { status: 'accepted'; continuation: SubmitContinuation }
  | { status: 'already_completed'; continuation: SubmitContinuation }
  | UnknownToolCallAnswer;

// WA MR2 §7.1 (Ruling 49b): why no call was found, when the runtime knows.
type UnknownToolCallAnswer = { status: 'unknown_tool_call'; reason?: UnknownToolCallReason };
type UnknownToolCallReason = 'run_stopped' | 'owner_not_continuable'; // UNKNOWN_TOOL_CALL_REASONS (CP-250: a paused companion this runtime cannot continue)

// The `cause` of a chat route's 409 `state_not_resumable` for a submit request
// (`handleChatStream`, CP-179 / CP-180):
type SubmitRefusalCause = // SUBMIT_REFUSAL_CAUSES
  | 'unknown_tool_call' // no answer matches a pending call
  | 'run_stopped' // an answered call belongs to a stopped run
  | 'owner_not_continuable' // CP-250: an answered call belongs to a paused companion the runtime cannot continue
  | 'stale_run' // an answer names a run that is not the current one
  | 'answer_not_stored'; // an answer for a still-awaited call was not stored beside a stored one (`toolCallIds`)

function resolveSubmitContinuation(a: {
  stateStore: Pick<SessionStateStore, 'loadState'>;
  rootSessionId: string;
  ownerSessionId: string;
  toolCallId: string;
  response: SubmitStoreAnswer;
  start: SubmitContinuationStart; // your runtime's continuation for a complete batch (idempotent)
  ownerRole?: SubmitOwnerRole; // CP-250: passed to start(); default 'root'
}): Promise<SubmitToolResultResponse>;

The answer must match its call (CP-231). routeSubmitToolResult first runs assertSubmitAnswersPendingKind(submit, pendingEntry) on the owner's pending entry (and on a parked remote call), before anything is stored or forwarded; a runtime with its own store path (runtime-js's direct submit to a local sub-agent's session) calls it too. An approval (its entry carries an approvalId) accepts its approval-response with that approvalId, or an error-only client-tool-result (error, no result), which a drain pairs as not executed; a client-tool-result carrying a result is refused, and so is an approval-response for a client-tool call. A refusal throws a non-retryable HelixError with code: 'validation_error' (HTTP 400). An entry that does not say its kind (a pre-submit stub) is left to the drains, which refuse a tool result on an approval (isToolResultOnApproval, toolResultOnApprovalError).

A paused companion continues by itself (CP-250). routeSubmitToolResult classifies the answered call's owner (classifySubmitOwner) and passes it to start() as ownerRole. A companion owner (a local persistent companion no live ancestor awaits) is its own continuation: start() answers new_run with the companion's sessionId. The optional canSelfWake(owner) is asked first, for such an owner's unanswered call; false, or no hook, refuses the submit before anything is stored ({ status: 'unknown_tool_call', reason: 'owner_not_continuable' }). The continuation's run-start reopens the parent's ref with companionSelfWakeRef({ parentSessionId, ref }) (statuses ['paused_awaiting_client'] at the ref's generation, no parentRunId), the only reopen without a parent run: startDigest (every store, every run-start) calls assertResumedCompanionRefShape and refuses any other typed (validation_error).

Submit continuation (W1 MR2b, CP-104). routeSubmitToolResult takes a required startContinuation: SubmitContinuationStart; its localResolverSubmit returns a SubmitStoreAnswer, and its forwardToOwner returns a SubmitStoreAnswer or a peer's full, schema-valid answer (passed through). The router answers consumed for the durable completed marker and otherwise calls resolveSubmitContinuation, which re-reads the OWNER after the write: unknown_tool_call passes through; already_completed whose entry is gone is consumed (accepted never is); a batch not ready is awaiting_batch; else start(). A failed re-read rejects (a re-send decides again). Readiness is isSubmittedBatchReadyAtLastSubmit(entries): isSubmittedBatchReady (not partial, a lapsed deadline counting as answered, with at least one submitted answer) evaluated at the entries' last submittedAt, never a reader's clock, over the answered call's run's entries only. Runtimes answer: runtime-js and Temporal caller_resumes, DBOS new_run (its in-workflow continuation run), CF Workflows new_run (its __submit__ instance), the CF DO from its wake. SubmitContinuationStartKind is caller_resumes, new_run, deferred or consumed: CF Workflows' start() answers consumed when the batch's run has a recorded stop (WA MR2 §7.1 H1, an answer stored before the stop): it settles the run by its stop and starts nothing, and the stop's terminal discards the stored answer.

A submit over a stopped run (WA MR2 §7.1, Rulings 49 / 49b). Every runtime's submit reads the stop of the answered call's run BEFORE it stores anything, through ONE core decision, refuseSubmitIfStopped, called where the runtime has read the call's pending entry on its OWNER session:

typescript
function refuseSubmitIfStopped(a: {
  stateStore: Pick<SessionStateStore, 'readStop' | 'getCurrentRun' | 'getMessages'>;
  sessionId: string; // the call's owner session (the root, or a sub-agent session)
  toolCallId: string;
  entry: PendingClientToolCall | undefined; // as this submit read it
  settle: StoppedSubmitSettle; // the runtime's own stop path for that run
}): Promise<UnknownToolCallAnswer | null>; // the answer to give, or null: proceed

type StoppedSubmitSettle = (input: {
  sessionId: string;
  runId: string;
  stop: StopRequest;
}) => Promise<void>;
  • (a) a recorded stop on the entry's run (a record-only stop, or the window before its terminal): settle ends the run by its stop (interrupted, or failed / framework_cancelled; runtime-js and the CF DO call core stopRun on that run with the record's own kind and reason, so the record is unchanged and an executing owner is only re-signalled), and the answer is { status: 'unknown_tool_call', reason: 'run_stopped' }. Nothing is stored, no SubmitContinuation is decided, no continuation starts. A settle that throws (a store failure) fails the submit typed, having stored nothing.
  • (b) no entry, the stop already ended the run (its K5 cleared the call): run_stopped when the call is still an unpaired tool call in the owner's log and the owner's current run ended by its stop (isRunEndedByStop(status, stop): interrupted, or failed under an abort record); otherwise null, and the runtime answers the bare unknown call. A call a later turn already healed (paired) answers bare.

runStoppedSubmitAnswer() builds the answer; resolveSubmitContinuation passes an unknown_tool_call through with its reason; submitToolResultResponseSchema() parses reason (z.enum(UNKNOWN_TOOL_CALL_REASONS).optional()), so every host and client that parses the answer keeps it. Wired on runtime-js and the CF Durable Object (CF Workflows, DBOS and Temporal follow).

with_message entries (W1 MR2b). ResumeOptionsWithMessage.abandonPending?: true asks one with_message entry to answer every unanswered awaited call client_tool_abandoned (CF Workflows, runtime-js and the CF DO; Temporal and DBOS refuse it framework_not_supported). planWithMessageEntry({ sessionId, session, abandonPending }) is the one decision: input_after_drain for a session suspended on client-tool calls alone (suspendedStatusOf), paused or the active session of a crashed run that took them over (FU-JS-WITH-MESSAGE-AFTER-CRASHED-DRAIN-ORDER), so the message follows the drained results; input_in_run_start otherwise; an abandonPending it cannot honour throws a non-retryable state_not_resumable — or, over a paused session that awaits children, the busy AgentAlreadyRunningError (state_already_running, S-B; the chat route answers it as the retryable busy 409) — except over a session a stop already ended (isSessionEndedByStop: interrupted, or failed / framework_cancelled), where the PLAN treats it as a no-op (WA MR2 §7.3: nothing is left to abandon). The entry's own pre-checks still apply: every runtime refuses to resume a failed session (state_not_resumable: ResumeRefusedError on runtime-js and the CF DO, AgentNotResumableError on CF Workflows, Temporal and DBOS, also when the entry settled an abort and lost its re-plan; retry() is the path), so only an interrupted one becomes a new turn. (Temporal and DBOS do not support abandonPending; their plain with_message follows the same rule.) The message rows carry COMMON_METADATA_KEYS.ENTRY_INPUT ({ runId }): markEntryInputRow(row, runId) stamps it, isEntryInputRowOf(m, runId) / isEntryInputRow(m) read it through entryInputMarkerSchema(), and undeliveredStashRows(entryInput, log) returns the stashed rows the log does not hold yet (tool rows by pairing, input rows by marker). PendingClientToolCall.entryInput?: Message[] is the stash of a message that must wait for a tool result (runtime-js). isSubAgentCompletionNotification(m) (Zod-checked { hidden: true, source: SUB_AGENT_COMPLETION_SOURCE }) recognises a hidden sub-agent completion notification, which is never user input. notExecutedErrorText(reason) is the error text of a not-executed tool result.

PendingClientToolCall.runId (new) stamps a pending call with the run that suspended on it, and a submit's write is fenced on it, so a late submit to a superseded run is refused typed (a chat host answers 409 state_run_superseded).

PendingClientToolCall.completedRetentionMs (optional, z.number().nonnegative() in pendingClientToolCallSchema()) is the retention horizon of the call's completed-tombstone marker, recorded when the call is registered from the registering agent's AgentConfig.completedTombstoneRetentionMs (or DEFAULT_COMPLETED_RETENTION_MS, 24 h). The clear path reads it from the entry and never looks the agent up. runtime-js (and the CF DO) record it; the Temporal, DBOS and CF Workflows resolvers resolve retention their own way and leave the field unset. defineAgent refuses a completedTombstoneRetentionMs that is not a finite number >= 0; 0 turns retention off (each marker is reaped by the next write).

  • resolveSupersededSubmit({ stateStore, sessionId, toolCallId }) returns discarded when the entry still names a superseded run and its tool_use is gone from the log (a rewind dropped it: answer unknown_tool_call, write nothing), else proceed (the fenced write decides).
  • shouldRereadAfterSupersededSubmit(refusedRunId, fresh): after a fenced submit write was refused, re-read once when the entry now names another run or is gone.
  • isClientToolBatchPartial(pending, now?) is true while some awaited (non-stub) call of the batch has no answer; with now, a lapsed deadline counts as answered. unsubmittedClientToolCallIds(pending, now?) lists those calls.
  • INVALID_RESULT_MESSAGE is the fixed message a host answers a ClientToolSubmitInvalidResultError with when its verbose validation detail is off (the CF Durable Object and agent-server by default, the ai-sdk submitToolResultErrorResponse always): 'The submitted result failed the tool output validation'. It names no field, so the answer never reveals the tool's outputSchema.

Sub-session refs and the delete cascade ​

SubSessionRef.location: SubSessionRefLocation ('local' | 'remote') is required. The writer sets it when it creates the ref: 'local' for a sub-agent or companion that runs in this service, 'remote' for a child behind a remote agent tool. It is never changed later, and the optional remote stream metadata is not the marker. Every store persists it and reads it strictly (no default). See the upgrade guide.

ExportWhat it is
SubSessionRefLocationthe type 'local' | 'remote'
subSessionRefLocationSchema()Zod schema for the location (z.enum(['local', 'remote']))
subSessionRefSchema()Zod schema for a whole ref, including the required location
assertValidSubSessionRefs(sessionId, refs)every store's addSubSessionRefs calls it first: a ref that fails subSessionRefSchema() (for example no location) throws a non-retryable validation_error before anything is written
subSessionRefExpectRefusal(sessionId, subSessionId, expect, actual)CP-161: the one rule of a conditional ref write (UpdateSubSessionRef.expect, op reset / release): null when the ref's status is in expect.statuses, its generation is expect.generation and (when given) its call is expect.parentToolCallId (actual carries the stored call), else the SubSessionRefConflictError the store throws (nothing written)
subSessionRefWriteIsStale(atGeneration, actualGeneration)CP-161 rule 4: whether a generation-scoped outcome write (UpdateSubSessionRef.atGeneration) is stale; the store then writes nothing and resolves
subSessionRefOutcomeWriteRefusal(update, stored)CP-161 fix 5 (I-2): a scoped outcome write that would replace terminated at its generation (its own status not terminated); the store writes nothing and answers { written: false, reason: 'terminated' }
RESETTABLE_SUB_SESSION_STATUSESCP-161: the statuses a respawn's reset reopens from (completed, failed, terminated)
isPersistentSubSessionIdOf(parentSessionId, childSessionId)CP-161: the inverse of generatePersistentSubSessionId (true for some non-empty name); the CF DO binds a child's parent-ref generation record to the derived parent with it
AddSubSessionRefResult, SubSessionRefExpectCP-161: addSubSessionRefs' per-ref answer ({ inserted: true, generation } | { inserted: false, ref }) and the conditional write's { op, statuses, generation, parentRunId?, parentToolCallId? } (fix 4: the parent-run fence; the late release: the ref's call)
companionParentRunHolds(parentRunId, current)CP-161 fix 4 (I-A): the parent-run fence rule (the run is the parent session current live run)
companionParentRunFenceRefusal(sessionId, expect, run)CP-161 fix 4 (I-A): a reset refusal (RunSupersededError), checked before the CAS
writeChildOutcome(stateStore, parentSessionId, update, logger, site)CP-161 fix 3: an outcome ref write scoped to update.atGeneration; a stale one (the store's UpdateSubSessionRefOutcome) is logged at warn and answers 'stale'
runServesRef(run, ref)CP-161 fix 3: whether a child's run serves the ref's current generation (its parent.toolCallId is the ref's stamped call; no parent stamp: adopted)
companionSelfWakeRef({ parentSessionId, ref }), assertResumedCompanionRefShape(ref)CP-250: the companion self-wake's reopen (no parentRunId), and the shape rule startDigest enforces
resumedCompanionRefRefusal(childSessionId, companion, actual), resumedCompanionRefGeneration(expect)CP-161 fix 3: the store rule of a continue's / resume's reopen CAS (ResumedCompanionRef.expect), and the generation it opens; fix 4: a fourth argument, the parent's current run (expect.parentRunId: parent_run_superseded)
UpdateSubSessionRefOutcomeCP-161 fix 3: updateSubSessionRef's answer ({ written: true } or { written: false, reason: 'stale_generation', actualGeneration })
sessionTreeChildren({ refs, parentIndexChildren })the edge rule: local refs' targets plus parentIndexChildren, minus remote-ref targets; deduped, first-seen order
SessionTreeEdgesthe type of sessionTreeChildren's input: refs (only subSessionId and location are read) and parentIndexChildren (sessions whose parentSessionId is this one)
collectSessionTree(rootId, childrenOf)a breadth-first, cycle-safe walk; returns the delete order (deepest first, rootId last)

A custom store implements deleteSession with these two helpers (or one recursive query that follows the same rule) and runs the contract's cascade cases. See Deleting a session.

Child identity ​

A child's session id and its delegation's dispatch id are minted, never derived from the parent (guide: Child identity; upgrade: child dispatch and identity).

ExportWhat it is
mintChildSessionId(), mintDispatchId()the only minters: child_<uuid>, d_<uuid> (crypto.randomUUID()). A replaying runtime mints with its own replay-safe equivalent (CF Workflows in a step, Temporal wf.uuid4(), DBOS DBOS.randomUUID())
ChildBinding, ChildBindingKind, childBindingSchema()the delegation a child is bound to: { parentSessionId, parentRunId, parentToolCallId, dispatchId, kind: 'subagent' | 'remote' | 'companion', name?, metadata? }, written once at createSession
readChildBinding(state), isChildSession(state)the stored binding (Zod-checked), and whether a session is a child
decideCreateBinding(existing, incoming)a createSession with a binding: create, same_delegation (proceed, nothing written) or taken
decideEntryBinding(stored, incoming)a run-start: ok when both are absent or name the same delegation, else mismatch (RunStartRejectedError('child_binding_mismatch'), nothing written)
assertCreateSessionParent(sessionId, options)MR 1 interim: a parentSessionId that differs from the binding's parent is a validation_error
ChildSessionTakenError, isChildSessionTakenError(e)state_child_session_taken (409, non-retryable): the id belongs to another delegation or is a root session
adoptStoredBinding(state, pinned), AdoptedBinding, PinnedRunthe ONE way a runtime's own continuation of a child's existing work (a submit's continuation, a stranded reset, DBOS's in-workflow continuation) carries the stored binding, pinned to the run it read. Callers are allowlisted
respawnBindingFor(child, parent)the binding a parent's re-spawn of its suspended child carries (CF Workflows' and Temporal's resume drain): the child's stored binding, only when it names this parent and call, else a non-retryable framework_internal_error. Every re-spawn site calls it (respawn-binding-sites.test.ts)
childBindingColumn(binding), readStoredChildBinding(raw)the stores' column encoding of a binding
assertNoDispatchClash(...), dispatchClashError(...)one child per dispatch: a second ref for a dispatch id under one parent is refused
isDispatchedChildCall(tool)whether a call of tool starts a child (a sub-agent or remote sub-agent tool), so its row gets a dispatch id
dispatchResumeRequestKey(dispatchId)a delegation's re-attach /resume key

ToolCallRequest.dispatchId? carries the dispatch id on the persisted call; createAssistantMessage(plan, resolveProviderExecuted?, resolveDispatchId?) sets it, the resolver told (toolName, subAgentCall, callId). SubSessionRef.dispatchId is required on every ephemeral ref. Removed: childResumeRequestKey. childStartRequestKey(parentRunId, toolCallId) stays for a companion's CP-106 child-phase entries (one key per call for a start, a continue and a resume), until companions get a dispatch id (MR 3).

Store contract test suites (@helix-agents/core/testing) ​

stateStoreContractTests now also runs the commit, run-start and fence suites, and takes raw-storage hooks for the cases the public API cannot set up:

typescript
import { stateStoreContractTests } from '@helix-agents/core/testing';

stateStoreContractTests({
  name: 'MyStore',
  createStore: async () => ({ store: new MyStore() }),
  clearStore: async (store) => (store as MyStore).clear(),
  // also listSessions; deleteSession: '<why this store has no deleteSession>'
  skip: { cloneSession: false },
  // How the store applies StartRun.resumedCompanionRef: 'atomic' (default) or
  // 'refused' (a store that cannot hold the parent's ref and the child's run in one step).
  resumedCompanionRef: 'atomic',
  rawHooks: {
    // a pre-upgrade checkpoint row (no kind / runId / keepCount), or one with a raw `kind`
    insertLegacyCheckpoint: async (store, sessionId, cp) => insertRawCheckpoint(sessionId, cp),
    // fail inside a rewinding run-start commit, after the truncate (atomicity)
    armFault: async (store, sessionId, point) => armTrigger(sessionId, point),
    // one transport fault on the store's client, right before a commit (the outcome suite)
    armCommitTransport: async (store, sessionId, { phase, mode }) => armClientFault(phase, mode),
    // an in-process store: why it cannot lose a reply / fail a read on demand (those cases skip)
    // noLostResponse: '<reason>', noPreReadFault: '<reason>',
    // run a write between listCheckpoints' first and later backend reads (RM-53)
    interleaveListCheckpoints: (store, write) => armInterleave(write),
    // stores that own usage (Postgres, D1): the cascade asserts usage rows go with the tree
    usage: {
      insertRow: async (store, sessionId) => insertUsage(sessionId),
      countRows: async (store, sessionId) => countUsage(sessionId),
    },
    // everything the store still holds for a session, DISCOVERED from its own schema / keyspace
    // (never a hand list of what deleteSession deletes): one label per table or key, [] when nothing
    sessionResidue: async (store, sessionId) => discoverRowsFor(sessionId),
  },
});

A case whose hook is absent is skipped with a written reason. skip.deleteSession is the reason a runner's store does not implement deleteSession (a string, printed in each skipped case: the cascade cases and the stop suite's "removes the stop rows"); the DO-SQLite lane gives one and asserts the typed framework_not_supported refusal instead. rawHooks.sessionResidue (ContractSessionResidue) enables the cascade's residue case: after deleteSession, nothing is left for any deleted session, and the hook must find every session's data before the delete (non-vacuity). Discover the places from the store itself, as the built-in runners do: Postgres reads information_schema for tables with a session_id column, D1 reads sqlite_master + pragma_table_info, Redis scans the prefix and checks key names, values and index members, and memory walks the store's own maps. A table or key family a later feature adds is then covered without editing the hook. The option types are exported as StateStoreContractSkip, ContractUsageHooks and ContractSessionResidue. The suites are also exported on their own:

ExportWhat it checks
commitCheckpointOperationTests(store, opts?)every commit writes its checkpoint and moves the pointer; saveState / createCheckpoint never do; legacy rows parse
runStartOperationTests(store, opts?)idempotency, CAS, owner guard, supersede, truncateTo, finishCurrent (of a running or a suspended_* current run whose K5 landed, run.k5-landed-finishes-suspended), pendingRestamp, resumedCompanionRef (commit.start-companion-ref-atomic), abandonRunStart, the lease on the store clock, setRunOwnerUnknownSince, request keys (request-key.*: recorded, request_key_taken, per session, the race, freed by deleteSession)
fenceOperationTests(store)every run-attributed write by a superseded run is refused and writes nothing
sizeLimitOperationTests(sizeLimited?, skipReason?)state_size_exceeded at every bind (MA-31): a staged write one byte over and exactly at the limit, UTF-8 bytes not characters, a promote / step-commit fold over maxStateBytes, an oversized assistant row in a step commit, a commitState over the limit, an oversized or exactly-at-limit appendMessages row; each refusal writes nothing. Run by stateStoreContractTests with its sizeLimited option, else skipped with skip.sizeLimits
stopOperationTests(store, opts?: StopOperationTestOptions)requestStop / readStop (targets, precedence, no_live_run), the refuseIfStopped guard on every guarded write, expected_run_stopped, deleteSession removing the rows (skipped with the contract's skip.deleteSession: '<reason>'; opts.skipDeleteSession when run alone)
checkpointOperationTests(store, opts?)checkpoint reads, pointer-based getLatestCheckpoint, total listCheckpoints paging (RM-53 / RM-54)
commitOutcomeOperationTests(store, opts?)every commit kind's outcome after a transport fault: commit.outcome-applied-response-lost.*, -not-applied-transient.*, -unknown-read-back-fails.*, -pre-read-failure.*, -lost-race-pre-read.{step,state}
commitCheckpointScenarios, runStartScenarios, fenceScenariosthe cases as data (ContractScenario[]: { title, requiresClone?, run(store) })
streamManagerContractTests({ name, createManager })stream managers, including WL.* (waitForLive), RF.claim.* (the claim CAS), RF.run-fence.* G5.info-null-on-not-found (getStreamInfo is null for a stream never written) and G5.info-status-is-the-streams-own (the stream's status while a newer run is live; optional commitLiveRun)
streamSignalContractTests({ name, createManager, openStream })a runtime's handle.stream({ signal }) (openStream) over a real stream manager: an abort while a read is pending, between reads or before the first read closes the reader once; an already-aborted signal opens nothing; return() and the stream's end close it too

streamManagerContractTests asserts no wall-clock windows: each case awaits the read, caughtUp or the reader's end, and a reader that is never woken fails by the test timeout. So createManager must return a manager whose fallback poll is disabled (DOStreamManager / RedisStreamManager: the internal safetyNetPollMs: Infinity), or a missing wake would pass on the poll.

skipKnownFailures opts a manager out of named cases it is known to fail (it.skip, never it.fails), each with a filed follow-up: G1.concurrent-writers, G2.late-attach-during-burst, G3.bounded-staleness, G4.truncation-throws, G4.truncation-after-endStream-returns-done, G4.resetStream-clears-truncation-marker, G4.reactivateStream-clears-truncation-marker, G5.fail-detail-on-info, G5.info-null-on-not-found and the RF.* retention-floor cases.

Options and helper types: CommitCheckpointTestOptions (skipClone, insertLegacyCheckpoint), RunStartTestOptions (armFault, resumedCompanionRef), CheckpointOperationTestOptions (interleaveListCheckpoints), CommitOutcomeTestOptions (armCommitTransport, noLostResponse, noPreReadFault), ArmCommitTransport, CommitTransportPhase ('pre-read' | 'write'), CommitTransportMode ('fail-before' | 'drop-response' | 'drop-response-and-fail-read-back'), ArmContractFault, ContractFaultPoint ('run-start-after-truncate'), ArmListCheckpointsInterleave, ResumedCompanionRefSupport ('atomic' | 'refused': a 'refused' store must refuse a run-start carrying resumedCompanionRef typed and write nothing; DO-SQLite registers so).

Fixtures for tests that need committed history:

  • seedMessages(store, sessionId, messages, { streamSequence? }) commits rows: an append under the live current run, else a completed seed run's run-start commit. appendMessages always takes a commit, so tests seed through this.
  • startSeedRun(store, sessionId, rows, opts?) starts a live seed run and returns its id.
  • startRunCommit(store, sessionId, opts: StartRunOptions) sends one run-start commit (appendMessages with startRun) built from opts (runId, expect, expectStatus, parent, rows, pendingRestamp, resumedCompanionRef, ...) and returns it ({ runId, rows, startRun, commit }). expectStatus defaults to the expected run's status as the store reads it now. StartRunOptions is exported with it.
  • seedSupersededSubmitWindow(store, sessionId, { rewind, ... }) seeds a session whose suspended run was superseded by a newer run-start commit (with or without a rewind of the pending call), for submit-race tests.
  • createNaiveStore() is a deliberately broken store (no checkpoints, no CAS, no fences). Core runs every scenario against it and requires them to fail, which proves the suites are not vacuous. Never use it as a store.

Waiting between writes: elapseWallClock(ms) resolves once Date.now() has moved forward by ms. A contract case that needs two writes to carry different timestamps waits with it, never with a bare setTimeout(ms): workerd measures a timer from the start of the event that set it and a D1 call does not move that base, so after the isolate stalls, setTimeout(10) can resolve with the clock advanced 0-1 ms (and Node's timers can fire a millisecond early).

Lock Manager ​

Interface for distributed lock coordination.

LockManager Interface ​

typescript
import type {
  LockManager,
  DistributedLock,
  LockAcquisitionResult,
  AcquireOptions,
} from '@helix-agents/core';

interface LockManager {
  readonly holderId: string;
  acquire(resource: string, options: AcquireOptions): Promise<LockAcquisitionResult>;
  extend(lock: DistributedLock, ttlMs: number): Promise<DistributedLock | null>;
  release(lock: DistributedLock): Promise<boolean>;
  withLock<T>(
    resource: string,
    options: { ttlMs: number; heartbeatMs?: number },
    fn: (lock: DistributedLock) => Promise<T>
  ): Promise<T>;
}

interface DistributedLock {
  readonly resource: string;
  readonly lockId: string;
  readonly fencingToken: number; // Monotonic token for split-brain prevention
  readonly acquiredAt: number;
  readonly expiresAt: number;
  readonly holderId: string;
}

interface AcquireOptions {
  ttlMs: number; // Lock TTL in milliseconds
  wait?: boolean; // Wait for lock if held
  waitTimeoutMs?: number; // Max wait time
}

NoOpLockManager ​

No-op implementation for single-process deployments:

typescript
import { NoOpLockManager } from '@helix-agents/core';

const lockManager = new NoOpLockManager();
// All operations succeed immediately

Lock Errors ​

typescript
import { LockNotAcquiredError, LockLostError } from '@helix-agents/core';

class LockNotAcquiredError extends Error {
  readonly resource: string;
  readonly heldBy: string;
}

class LockLostError extends Error {
  readonly resource: string;
  readonly fencingToken: number;
}

Status Types ​

The framework uses different status types for different domains. The value lists are exported as plain as const tuples, which read without building a schema; each status schema is built from its tuple, in the same order:

typescript
import {
  SESSION_STATUSES, // ['active', 'completed', 'failed', 'interrupted', 'paused']
  RUN_STATUSES, // ['running', 'completed', 'failed', 'interrupted', 'superseded', 'suspended_client_tool', …]
  AGENT_STATUSES, // ['running', 'completed', 'failed', 'paused', 'waiting_tool', 'interrupted']
  sessionStatusSchema, // z.enum(SESSION_STATUSES)
  runStatusSchema, // z.enum(RUN_STATUSES)
  agentStatusSchema, // z.enum(AGENT_STATUSES)
} from '@helix-agents/core';

SessionStatus (Storage) ​

Used for persistent session state in SessionState.status:

typescript
type SessionStatus =
  | 'active' // Session is ready for execution
  | 'completed' // Session finished successfully
  | 'failed' // Session encountered an error
  | 'interrupted' // User interrupted, resumable
  | 'paused'; // Waiting for input (e.g., tool confirmation)

AgentStatusValue (Runtime) ​

Used during agent execution:

typescript
import { AgentStatusValues } from '@helix-agents/core';

AgentStatusValues.RUNNING; // 'running' - currently executing
AgentStatusValues.COMPLETED; // 'completed' - finished successfully
AgentStatusValues.FAILED; // 'failed' - encountered error
AgentStatusValues.PAUSED; // 'paused' - waiting for confirmation
AgentStatusValues.WAITING_TOOL; // 'waiting_tool' - awaiting tool results
AgentStatusValues.INTERRUPTED; // 'interrupted' - user interrupted

ResumableStreamStatus (Streams) ​

Used for a stream's own state (StreamInfo.status):

typescript
type ResumableStreamStatus =
  | 'active' // Stream is active, events flowing
  | 'paused' // Stream is paused
  | 'ended' // Stream completed (note: 'ended', not 'completed')
  | 'failed'; // Stream failed

SnapshotStatus (FrontendSnapshot.status, SnapshotView.status) has the same four values but is derived from the current run first, the stream second (deriveSnapshotStatus): a live run reads active / paused even while its stream still shows the previous turn's ended.

SubSessionStatusValue (Sub-agents) ​

Used for tracking sub-agent lifecycle:

typescript
import { SubSessionStatusValues } from '@helix-agents/core';

SubSessionStatusValues.RUNNING; // 'running'
SubSessionStatusValues.COMPLETED; // 'completed'
SubSessionStatusValues.FAILED; // 'failed'

Status Conversion ​

The framework provides helpers to convert between storage and runtime statuses:

typescript
import { sessionStatusToAgentStatus, agentStatusToSessionStatus } from '@helix-agents/core';

// Storage → Runtime
sessionStatusToAgentStatus('active'); // Returns 'running'

// Runtime → Storage
agentStatusToSessionStatus('running'); // Returns 'active'
agentStatusToSessionStatus('waiting_tool'); // Returns 'active'

Key distinction:

  • SessionStatus uses 'active' for ready/running state (storage perspective)
  • AgentStatusValue uses 'running' for active execution (runtime perspective)
  • ResumableStreamStatus uses 'ended' for completion (stream lifecycle)

InterruptContext ​

Context stored when an agent is interrupted:

typescript
import type { InterruptContext } from '@helix-agents/core';
import { interruptContextSchema } from '@helix-agents/core';

interface InterruptContext {
  reason?: string; // Why interrupted (e.g., 'user_requested')
  pendingToolCallId?: string; // Tool call waiting for confirmation
  pendingToolName?: string; // Tool name
  stepCount: number; // Step count at interruption
  timestamp: number; // When interrupted
}

// Zod schema for validation
const validated = interruptContextSchema().parse(context);

Utilities ​

createToolContext ​

Create a tool execution context:

typescript
import { createToolContext } from '@helix-agents/core';

const context = createToolContext({
  agentId: 'run-123',
  agentType: 'my-agent',
  stateTracker,
  streamWriter,
});

Logger Types ​

typescript
import { noopLogger, consoleLogger, type Logger } from '@helix-agents/core';

const logger: Logger = {
  debug: (msg, data) => { ... },
  info: (msg, data) => { ... },
  warn: (msg, data) => { ... },
  error: (msg, data) => { ... },
};

Type Re-exports ​

The package re-exports Draft from Immer for tool authors:

typescript
import type { Draft } from '@helix-agents/core';

// Use in updateState callbacks
context.updateState((draft: Draft<MyState>) => {
  draft.items.push(newItem);
});

Released under the MIT License.