@helix-agents/core
Core agent framework functionality - types, factories, state management, LLM interface, store interfaces, stream utilities, and orchestration functions.
Installation
npm install @helix-agents/coreAgent Definition
defineAgent
Create an agent configuration.
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 configurationAgent<TState, TOutput>- Shorthand for configured agentLLMConfig- Model configuration (model, temperature, maxOutputTokens, cache, providerOptions,strictOutput).strictOutput?: boolean(defaultfalse) 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.
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 definitionToolConfig<TInput, TOutput>- Tool configurationToolContext- 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.
// 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 withrequireApproval !== 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.
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:
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:
| Option | Type | Description |
|---|---|---|
description | string | Override the tool description (defaults to the agent's description) |
timeoutMs | number | Wall-clock timeout in milliseconds. In the DO runtime, defaults to 10 minutes if not set. |
Types:
SubAgentTool- Sub-agent tool definition (extendsToolwith_isSubAgent,_agentConfig,_timeoutMs)
Tool Utilities
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.
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:
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>-- ExtendsToolwith_isCompanionTool: trueand_companionType: CompanionTypeCompanionType--'spawnAgent' | 'sendMessage' | 'listChildren' | 'getChildStatus' | 'waitForResult' | 'terminateChild'COMPANION_TOOL_PREFIX--'companion__'(the prefix for all companion tool names)
Detection:
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:
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:
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
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'],
});| Field | Type | Description |
|---|---|---|
skills | SkillsConfig | A SkillProvider or an array of SkillDefinition (sugar for inCodeSkillProvider). Unset = no-op. |
preloadSkills | string[] | 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
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
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
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 returnsdefunchanged. Throws on an invalid name/description or empty body.defineAgent()runs this on each entry of an array-formskills.inCodeSkillProvider(skills: SkillDefinition[])— returns a dependency-free, Workers-safeSkillProvider. Throws on a duplicate skill name. Passing an array directly toskillscalls 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 intobuildMessagesForLLM. 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 viaload_skill. The building block for programmatic dedup / round-trip assertions.parseSkillFile(raw: string): ParseResult— parses aSKILL.md(YAML frontmatter + markdown body), validates the metadata againstskillMetadataSchema, and normalizes the open-standardallowed-toolsfield (string or list) tostring[]. 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
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
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).
import type {
Attachment,
ImageUrlAttachment,
ImageDataAttachment,
FileUrlAttachment,
FileDataAttachment,
} from '@helix-agents/core';Types:
Attachment— Union of all attachment typesImageUrlAttachment—{ 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:
| Identifier | Purpose | Usage |
|---|---|---|
sessionId | Primary key for all state storage operations | Use for loading/saving state, messages, checkpoints |
runId | Execution metadata identifying a specific run | Use 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→resumecreates 2 runs in 1 session).
Best practices:
- Use
sessionIdfor all state store operations - Pass the same
sessionIdto continue a conversation - Use
runIdfor tracking/tracing specific executions
AgentState
The full state structure for a running agent (used internally for execution and checkpoints).
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
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.
import {
COMMON_METADATA_KEYS,
stripMetadata,
filterByMetadata,
getMessagesWithMetadataKey,
} from '@helix-agents/core';COMMON_METADATA_KEYS
Standard metadata key constants:
| Key | Value | Description |
|---|---|---|
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):
const cleanMessage = stripMetadata(message);
// cleanMessage.metadata is undefinedfilterByMetadata
Filter messages by metadata predicate:
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:
const messagesWithSource = getMessagesWithMetadataKey(messages, 'source');State Helpers
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:
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:
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 matchingstreamId).
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.
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 started → attempt_started → (attempt_failed)* → exactly one terminal succeeded | exhausted.
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
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
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.
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:
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
isStreamStatusEventguard — narrow onevent.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.
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.
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.
interface SuspendedChildWait {
childSessionId: string;
parentToolCallId: string;
spawnedAt: number;
}StepResult
Result from an LLM generation step:
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:
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)- Returnstrueformax_tokens,content_filter,refusal,error,unknownisRecoverableErrorStopReason(reason)- Returnstrueformax_tokensonly. 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
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.
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.
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
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
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
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
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 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.
Error codes
RemoteAgentErrorResponse.code ({ error, code, streamId? }):
| Code | Meaning |
|---|---|
NOT_FOUND | Unknown 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_COMPLETED | Terminal session |
FEATURE_UNAVAILABLE | Route/feature not implemented (e.g. /usage with no usageStore wired) — consumers degrade |
TRUNCATED | fromSequence is below the stream's retention floor. Carries floor. Consumers re-snapshot |
INVALID_REQUEST, INTERNAL_ERROR | Malformed request / producer-side failure |
BAD_REQUEST, UNAUTHORIZED, METHOD_NOT_ALLOWED, UPSTREAM_ERROR | Worker-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:
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 foldRemoteAgentTransport
getSnapshot and getUsage are required on the interface — both shipped transports (HttpRemoteAgentTransport in core, DOStubTransport in runtime-cloudflare) implement them.
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:
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';
}childCustomStateis terminal-only and gated onend.status === 'completed'; it is sourced fromend.state(fast path) ortransport.getSnapshot()(recovery), and skipped with a warning when the serialized state exceedsRemoteSubAgentConfig.maxFoldStateBytes(defaultDEFAULT_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/remoteRollupOriginexist because the lazysubSessionIdlookup cannot work across a service boundary (the child's entries live in the producer's store). StoregetRollupimplementations resolve locally first; if that yields nothing they select exactly one embedded rollup persubSessionIdby the total order origin ('recovery'>'end'), then timestamp, then insertion ordinal — entries are deduped bysubSessionIdand never summed per-entry. Embedded rollups are already producer-sideincludeSubAgents: trueresults, 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.
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.
// 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.
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 chunkNote:
stepWritesToRFC6902(tracker.getStepWrites())is the only supported path to RFC 6902 patches — it takes the canonicalStepWritesrepresentation and produces correct/-end-of-array paths for pure-append mutations. (The oldconvertImmerPatchToRFC6902Immer-patch helper was removed.)
LLM Module
LLMAdapter Interface
Interface for LLM providers:
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):
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'sVercelAIAdapteralways forwardstoolChoiceand always honors named tool choice, so it never throwsframework_completion_tool_choice_unsupported. If a non-conforming adapter silently ignorestoolChoiceinstead of throwing, the run does not silently succeed: the coreclassifyForcedCompletionResultpath still catches the missing tool call and, on budget exhaustion, fails withframework_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:
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:
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
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.
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:
- On
execute():createRun()is called withturn: 1 - On
resume():createRun()is called with incremented turn number - On completion/failure/interrupt:
updateRunStatus()is called with final status
Query Methods:
getCurrentRun(sessionId): Returns the most recent (active) run for a sessionlistRuns(sessionId): Returns all runs for a session (for debugging/auditing)
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:
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
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
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
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
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:
import type { UserInputMessage, FileInput, AgentInput, SendInput } from '@helix-agents/core';UserInputMessage — A structured user message with optional metadata and file attachments:
interface UserInputMessage {
role: 'user';
content: string;
metadata?: Record<string, unknown>;
files?: FileInput[];
}FileInput — A file attachment (base64-encoded data with media type):
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():
// 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:
type SendInput = string | UserInputMessage[];initializeAgentState
Create initial state from input:
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:
import { buildMessagesForLLM } from '@helix-agents/core';
const messages = buildMessagesForLLM(state.messages, agent.systemPrompt, state.customState);buildEffectiveTools
Get tools including __finish__:
import { buildEffectiveTools } from '@helix-agents/core';
const tools = buildEffectiveTools(agent);planStepProcessing
Analyze LLM result and plan actions:
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:
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.
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:
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.
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:
| Function | When 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.
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 cachingCacheRequest—{ messages, tools, config: LLMConfig, context: { sessionId } }— inputs to a strategyCacheResult—{ messages?, tools?, providerOptions?, headers? }— annotations returned by a strategyAppliedCacheResult— result of folding all strategies;messagesandtoolsare always presentAnthropicCacheOptions—{ ttl?: string }— options foranthropicCache()
Built-in helpers:
| Helper | Provider | Effect |
|---|---|---|
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() | OpenAI | Sets 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:
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.
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
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.
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);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
remoteSourcebefore it is written to the parent stream. ALREADY_RUNNING→ attach — aRemoteAgentAlreadyRunningErrorfromstartis 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'withisExecuting: falseand no stream progress, the dispatch fails fast withfailureReason: 'producer-dead'rather than burning the retry budget. SetresumeDeadProducer: trueto opt into a single blindtransport.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).
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).
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:
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:
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:
import {
AgentAlreadyRunningError,
AgentNotResumableError,
FencingTokenMismatchError,
StaleStateError,
ExecutorSupersededError,
StreamTruncatedError,
} from '@helix-agents/core';AgentAlreadyRunningError
Thrown when attempting to execute/resume an agent that is already running.
class AgentAlreadyRunningError extends Error {
readonly sessionId: string;
readonly currentStatus: string;
}AgentNotResumableError
Thrown when attempting to resume an agent that cannot be resumed.
class AgentNotResumableError extends Error {
readonly sessionId: string;
readonly currentStatus: string;
}StaleStateError
Thrown when a state save fails due to version mismatch (optimistic concurrency).
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.
class ExecutorSupersededError extends Error {
readonly sessionId: string;
}FencingTokenMismatchError
Thrown when a fencing token doesn't match expected value (split-brain detection).
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.
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.
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:
| Category | Codes |
|---|---|
provider | provider_overloaded, provider_rate_limited, provider_auth_error, provider_content_filtered, provider_refused, provider_timeout, provider_invalid_request, provider_error |
tool | tool_input_invalid, tool_execution_failed, tool_not_found, tool_timeout |
state | state_concurrency_conflict, state_session_not_found, state_already_running, state_not_resumable |
transport | transport_error, transport_stream_drop, transport_remote_agent_failed |
validation | validation_error |
framework | framework_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:
import { classifyError } from '@helix-agents/core';
const classified = classifyError(unknownError);
// Returns HelixError with appropriate code, category, retryableHandles HelixError (pass-through), AbortError → framework_cancelled, internal agent state errors → specific state codes, generic errors → framework_internal_error.
extractErrorMessage
Extract a string message from any error type:
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:
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
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
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
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:
import { NoOpLockManager } from '@helix-agents/core';
const lockManager = new NoOpLockManager();
// All operations succeed immediatelyLock Errors
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:
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:
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 interruptedResumableStreamStatus (Streams)
Used for stream state in FrontendSnapshot.status:
type ResumableStreamStatus =
| 'active' // Stream is active, events flowing
| 'paused' // Stream is paused
| 'ended' // Stream completed (note: 'ended', not 'completed')
| 'failed'; // Stream failedSubSessionStatusValue (Sub-agents)
Used for tracking sub-agent lifecycle:
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:
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:
SessionStatususes'active'for ready/running state (storage perspective)AgentStatusValueuses'running'for active execution (runtime perspective)ResumableStreamStatususes'ended'for completion (stream lifecycle)
InterruptContext
Context stored when an agent is interrupted:
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:
import { createToolContext } from '@helix-agents/core';
const context = createToolContext({
agentId: 'run-123',
agentType: 'my-agent',
stateTracker,
streamWriter,
});Logger Types
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:
import type { Draft } from '@helix-agents/core';
// Use in updateState callbacks
context.updateState((draft: Draft<MyState>) => {
draft.items.push(newItem);
});