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

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-4o') },
  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-4o') },
});

// 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?: { description?: string; timeoutMs?: number }
): 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.

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';

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.

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)
  parseSkillFile, // Parse + validate a SKILL.md (frontmatter + body); pure/Workers-safe
} from '@helix-agents/core';
import type { ParseResult } from '@helix-agents/core';
  • 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 warns and returns '' (never crashes the agent).
  • 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_NAMESdefineTool() 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., executeinterruptresume 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;
  metadata?: Record<string, unknown>;
  providerOptions?: Record<string, Record<string, unknown>>;
}

interface ToolResultMessage {
  role: 'tool';
  toolCallId: string;
  toolName: string;
  content: string;
  metadata?: Record<string, unknown>;
  providerOptions?: Record<string, Record<string, unknown>>;
}

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)

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
  | RunInterruptedChunk // Agent interrupted
  | RunResumedChunk // Agent resumed
  | RunPausedChunk // Agent paused for confirmation
  | CheckpointCreatedChunk // Checkpoint saved
  | ExecutorSupersededChunk // Executor superseded
  | 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)

  /**
   * 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 (re-forwarded duplicates are otherwise undetectable).
  • 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.

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';
  checkpointId: string; // Checkpoint ID that was restored
  stepCount: number; // Step count at the checkpoint
  messageCount: number; // Message count at the checkpoint
  fromSequence: number; // Stream sequence at the checkpoint
  reason: 'crash_recovery' | 'rollback' | 'branch';
}

ForcedCompletionChunk

Diagnostic progress events for the forced-completion phase. Emitted startedattempt_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;
}

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,
  // Interrupt/Resume type guards
  isRunInterruptedChunk,
  isRunResumedChunk,
  isRunPausedChunk,
  isCheckpointCreatedChunk,
  isExecutorSupersededChunk,
  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 wire format used by StreamManager implementations for SSE / WebSocket / pub-sub transport: chunk | end | fail | status | truncated.

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,
  parseNamedSSEEvent,
  serializeStreamEvent,
  // Type guards
  isStreamChunkEvent,
  isStreamEndEvent,
  isStreamFailEvent,
  isStreamTruncatedEvent,
} from '@helix-agents/core';

StreamTruncatedEvent is the push-transport counterpart of G4 (see StreamManager and the stream-protocol internals doc). Its shape mirrors StreamTruncatedError:

typescript
interface StreamTruncatedEvent {
  type: 'truncated';
  // The `stepCount` boundary passed to `cleanupToStep`; chunks with
  // `step > truncatedAtStep` 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;
}

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<TOutput> =
  | TextStepResult
  | ToolCallsStepResult
  | StructuredOutputStepResult<TOutput>
  | ErrorStepResult;

interface TextStepResult {
  type: 'text';
  content: string;
  thinking?: ThinkingContent;
  shouldStop: boolean;
  stopReason?: StopReason;
}

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

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.

Execution Types

typescript
interface AgentExecutionHandle<TOutput> {
  readonly sessionId: string;
  readonly runId: string;
  stream(): 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 AgentResult<TOutput> {
  status: AgentStatus;
  output?: TOutput;
  error?: string;
}

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

RetryOptions

Options for the retry() method.

typescript
interface RetryOptions {
  /**
   * Which checkpoint to restore from. Defaults to the latest checkpoint.
   * If an explicit checkpointId is provided but cannot be resolved, retry()
   * throws rather than silently falling back to a genesis restart.
   */
  checkpointId?: string;
  /** Replacement message. If omitted, the original triggering message is preserved. */
  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;
}

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,
  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.end

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 error, 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;
    }
  | { type: 'error'; error: string; errorDetail?: ErrorDetail; recoverable: boolean };

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().

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;
}

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.

RemoteStatusResponse

Its state field has been removedgetSnapshot() 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.

Error codes

RemoteAgentErrorResponse.code ({ error, code, streamId? }):

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
TRUNCATEDfromSequence is below the stream's retention floor. Carries floor. Consumers re-snapshot
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

Transports map the envelope onto typed errors — RemoteAgentAlreadyRunningError, RemoteAgentNotFoundError, RemoteAgentFeatureUnavailableError (all exported from core). A non-envelope (bare) 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

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>;
  interrupt(sessionId: string, reason?: string): Promise<void>;
  abort(sessionId: string, reason?: string): Promise<void>;
}

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<unknown>>;
}

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).
  toolChoice?: LLMToolChoice;
  reasoningMode?: ReasoningMode;
  executionPhase?: ExecutionPhase;
}

interface LLMStreamCallbacks {
  onTextDelta?: (delta: string) => void;
  onThinking?: (content: string, isComplete: boolean) => void;
  onToolCall?: (toolCall: ParsedToolCall) => void;
  onError?: (error: Error) => void;
}

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 serving outputSchema agents MUST forward toolChoice to the provider and throw a classified framework_completion_tool_choice_unsupported error if it cannot honor a named { type: 'tool', toolName } selection — never silently fall back to 'auto'. See Forced Completion below.

Implementation status: this is a conformance CONTRACT for adapter authors, not a path exercised by any first-party adapter today — llm-vercel's VercelAIAdapter always forwards toolChoice and always honors named tool choice, so it never throws framework_completion_tool_choice_unsupported. If a non-conforming adapter silently ignores toolChoice instead of throwing, the run does not silently succeed: the core classifyForcedCompletionResult path still catches the missing tool call and, on budget exhaustion, fails with framework_completion_protocol_violation — the classified-error backstop for exactly this case.

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:

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

const mock = new MockLLMAdapter([
  { type: 'text', content: 'Hello!' },
  { type: 'tool_calls', toolCalls: [{ id: 't1', name: 'search', arguments: {} }] },
  { type: 'structured_output', output: { result: 'done' } },
]);

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.

typescript
interface SessionStateStore {
  // Session lifecycle
  createSession<TState>(sessionId: string, options?: CreateSessionOptions<TState>): Promise<void>;
  sessionExists(sessionId: string): Promise<boolean>;
  deleteSession(sessionId: string): Promise<void>;

  // State operations
  loadState<TState, TOutput>(sessionId: string): Promise<SessionState<TState, TOutput> | null>;
  saveState(sessionId: string, state: UntypedSessionState): Promise<void>;

  // Atomic operations (safe for parallel tool execution)
  appendMessages(sessionId: string, messages: Message[]): Promise<void>;
  mergeCustomState(sessionId: string, writes: StepWrites): Promise<{ warnings: string[] }>;
  updateStatus(
    sessionId: string,
    status: AgentStatus,
    context?: { interruptContext?: InterruptContext }
  ): Promise<void>;
  incrementStepCount(sessionId: string): Promise<number>;

  // Sub-agent management
  addSubSessionRefs(
    sessionId: string,
    refs: Array<{
      subSessionId: string;
      agentType: string;
      parentToolCallId: string;
      startedAt: number;
    }>
  ): Promise<void>;
  updateSubSessionRef(
    sessionId: string,
    update: {
      subSessionId: string;
      status: 'running' | 'completed' | 'failed';
      completedAt?: number;
    }
  ): Promise<void>;
  getSubSessionRefs(sessionId: string): Promise<SubSessionRef[]>;

  // Message queries
  getMessages(sessionId: string, options?: GetMessagesOptions): Promise<PaginatedMessages>;
  getMessageCount(sessionId: string): Promise<number>;

  // Message cleanup (for crash recovery)
  truncateMessages(sessionId: string, messageCount: number): Promise<void>;

  // Checkpoint operations
  getCheckpoint(checkpointId: string): Promise<Checkpoint | null>;
  getLatestCheckpoint(sessionId: string): Promise<Checkpoint | null>;
  listCheckpoints(sessionId: string, options?: ListCheckpointsOptions): Promise<PaginatedCheckpoints>;
  createCheckpoint(sessionId: string, params: CreateCheckpointParams): Promise<string>;

  // Staging operations (for atomic step commits)
  stageChanges(sessionId: string, stepId: string, changes: StagedChanges): Promise<void>;
  getStaged(sessionId: string, stepId: string): Promise<StagedChanges[] | null>;
  promoteStaging(sessionId: string, stepId: string): Promise<void>;
  discardStaging(sessionId: string, stepId: string): Promise<void>;

  // Atomic single-write primitive (v7) — saves state, appends messages,
  // promotes staging, and creates a checkpoint in one operation.
  saveStateAndPromoteStaging(
    sessionId: string,
    state: SessionState,
    appendMessages: Message[],
    checkpointMeta: { stepId: string; stepCount: number; streamSequence: number },
    options?: { expectedVersion?: number }
  ): Promise<{ checkpointId: string; newVersion: number }>;

  // Distributed coordination — v7 returns a discriminated union
  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 }
  >;
  incrementResumeCount(sessionId: string): Promise<number>;

  // Run tracking
  createRun(sessionId: string, runId: string, metadata: RunMetadata): Promise<void>;
  updateRunStatus(runId: string, status: RunStatus, updates?: RunStatusUpdate): Promise<void>;
  getCurrentRun(sessionId: string): Promise<RunRecord | null>;
  listRuns(sessionId: string): Promise<RunRecord[]>;
}

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

### Run Tracking Types

```typescript
// Status of a run
type RunStatus = 'running' | 'completed' | 'failed' | 'interrupted';

// Metadata when creating a run
interface RunMetadata {
  turn: number;          // Turn number (1 for execute, incremented for resume)
  startSequence?: number; // Stream sequence at start
}

// Updates when changing run status
interface RunStatusUpdate {
  stepCount?: number;
  completedAt?: number;
  error?: string;
}

// Full run record
interface RunRecord {
  runId: string;
  sessionId: string;
  turn: number;
  status: RunStatus;
  stepCount: number;
  startedAt: number;
  completedAt?: number;
  error?: string;
}

Run Tracking Lifecycle:

  1. On execute(): createRun() is called with turn: 1
  2. On resume(): createRun() is called with incremented turn number
  3. On completion/failure/interrupt: updateRunStatus() is called with final status

Query Methods:

  • getCurrentRun(sessionId): Returns the most recent (active) run for a session
  • listRuns(sessionId): Returns all runs for a session (for debugging/auditing)
typescript
interface PaginatedCheckpoints {
  items: CheckpointMeta[];
  total: number;
  hasMore: boolean;
}

interface GetMessagesOptions {
  offset?: number; // Starting position (default: 0)
  limit?: number; // Max messages (default: 50)
  includeThinking?: boolean; // Include thinking content (default: true)
}

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

StreamManager

Interface for real-time streaming:

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 (optional, for crash recovery)
  createResumableReader?(
    streamId: string,
    options?: ResumableReaderOptions
  ): Promise<ResumableStreamReader | null>;

  // Mark stream as complete
  endStream(streamId: string, output?: unknown): Promise<void>;

  // Mark stream as failed
  failStream(streamId: string, error: string): Promise<void>;

  // Stream cleanup (for crash recovery). G4: removing chunks past an
  // ATTACHED reader's cursor surfaces a `StreamTruncatedError` on that
  // reader. Push transports (Cloudflare DO SSE/WS) broadcast a `truncated`
  // wire 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): Promise<void>;
  resetStream(streamId: string): Promise<void>;

  // Stream info (for snapshot status)
  getStreamInfo?(streamId: string): Promise<StreamInfo | null>;
}

interface StreamInfo {
  status: 'active' | 'paused' | 'ended' | 'failed';
  latestSequence: number;
  chunkCount: number;
}

interface StreamWriter {
  write(chunk: StreamChunk): 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;
}

Stream Utilities

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.handle({
  streamId: 'run-123',
  resumeAt: extractResumePosition(lastEventId),
});

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 } from '@helix-agents/core';

const tools = buildEffectiveTools(agent);

planStepProcessing

Analyze LLM result and plan actions:

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

const plan = planStepProcessing(stepResult, {
  outputSchema: agent.outputSchema,
});

// 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)

shouldStopExecution

Check if agent should stop:

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

const shouldStop = shouldStopExecution(stepResult, stepCount, {
  maxSteps: 10,
  stopWhen: (result) => result.type === 'text' && result.content.includes('DONE'),
});

const finalStatus = determineFinalStatus(stepResult);

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 identically, so replay/checkpoint determinism holds everywhere.

typescript
import {
  classifyForcedCompletionEntry,
  beginForcedCompletion,
  prepareForcedCompletionCall,
  classifyForcedCompletionResult,
  recordForcedCompletionMiss,
  recordForcedCompletionSuccess,
  failForcedCompletion,
  buildForcedCompletionMessage,
  resolveExpectedToolName,
  DEFAULT_MAX_COMPLETION_RETRIES, // 2 — the default forced-completion budget
} from '@helix-agents/core';
import type {
  ForcedCompletionTrigger, // 'terminal_text' | 'max_tokens' | 'max_steps'
  ForcedCompletionState,
  ForcedCompletionTransition,
} 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,
  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.

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;
}

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()Narrows the effective tool list to [expectedTool] and builds the { tools, toolChoice, reasoningMode: 'disabled', executionPhase: 'forced_completion' } directive. Fails framework_completion_tool_missing before any LLM call if the expected tool is absent.
classifyForcedCompletionResult()Classifies a completed forced call: { kind: 'success', output }, { kind: 'miss', recoverable: true, protocolViolation } (consumes one attempt; protocolViolation: true when the adapter returned text under a named tool choice), or { kind: 'nonRecoverable', code, message }.
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 (e.g. from prepareForcedCompletionCall's tool-missing case, or a nonRecoverable classification).

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 }) — explanatory context for the model, never the enforcement mechanism (the named toolChoice 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'), added to both StreamChunkSchema and StreamMessageSchema. See Stream Chunks and Chunk Type Guards above for ForcedCompletionChunk and isForcedCompletionChunk.

Error codes: five dedicated framework_* codes never collapse into a generic "returned text without structured output" message — framework_completion_tool_missing, framework_completion_tool_choice_unsupported, framework_completion_protocol_violation, framework_completion_invalid_output (a forced completion whose output fails schema validation is a recoverable miss), framework_forced_completion_exhausted. See Error Classification below. framework_completion_tool_choice_unsupported is part of the adapter conformance contract (see the LLMGenerateInput note above) but is not thrown by any first-party adapter today; see that note for the framework_completion_protocol_violation backstop when a non-conforming adapter silently ignores toolChoice.

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,
});

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;
      lastSequence: number;
      streamId?: string;
      failureReason: RemoteSubAgentFailureReason;
    };

type RemoteSubAgentFailureReason =
  | 'stream-drop'
  | 'remote-failed'
  | 'timeout'
  | 'cancelled'
  | 'transport-error'
  | 'client-tool-unsupported'
  | 'producer-dead';

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 (this is required anyway, because the HTTP transport's at-least-once POST can 409 against its own lost-response first attempt).
  • 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).

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,
  ExecutorSupersededError,
  StreamTruncatedError,
} from '@helix-agents/core';

AgentAlreadyRunningError

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

typescript
class AgentAlreadyRunningError extends Error {
  readonly sessionId: string;
  readonly currentStatus: string;
}

AgentNotResumableError

Thrown when attempting to resume an agent that cannot be resumed.

typescript
class AgentNotResumableError extends Error {
  readonly sessionId: string;
  readonly currentStatus: string;
}

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;
}

ExecutorSupersededError

Thrown when an executor is superseded by another executor. This is a graceful shutdown signal, not a failure.

typescript
class ExecutorSupersededError extends Error {
  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_overloadedprovider).

Error Codes:

CategoryCodes
providerprovider_overloaded, provider_rate_limited, provider_auth_error, provider_content_filtered, provider_refused, provider_timeout, 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
transporttransport_error, transport_stream_drop, transport_remote_agent_failed
validationvalidation_error
frameworkframework_internal_error, framework_not_supported, framework_cancelled, framework_completion_tool_missing, framework_completion_tool_choice_unsupported, framework_completion_protocol_violation, framework_completion_invalid_output, framework_forced_completion_exhausted

The five framework_completion_*/framework_forced_completion_exhausted codes are dedicated to the forced-completion phase and are never collapsed into a generic message — see that section for when each fires.

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), AbortErrorframework_cancelled, internal agent state errors → specific state codes, generic errors → framework_internal_error.

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; // Stream sequence at checkpoint (for resumption)
}

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

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' }

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:

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 stream state in FrontendSnapshot.status:

typescript
type ResumableStreamStatus =
  | 'active' // Stream is active, events flowing
  | 'paused' // Stream is paused
  | 'ended' // Stream completed (note: 'ended', not 'completed')
  | 'failed'; // Stream failed

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.