JavaScript Runtime
The JavaScript runtime (@helix-agents/runtime-js) executes agents in-process within your Node.js application. It's the simplest runtime to set up and ideal for development, testing, and simple deployments.
When to Use
Good fit:
- Local development and testing
- Prototyping and experimentation
- Single-process deployments
- Short-lived agent executions (< 30 minutes)
- Serverless functions (Lambda, Cloud Functions)
Not ideal for:
- Long-running agents that may outlive the process
- Production workloads requiring crash recovery
- Multi-process distributed systems
Installation
npm install @helix-agents/runtime-js @helix-agents/store-memoryOr use the SDK which bundles everything:
npm install @helix-agents/sdkBasic Setup
import { JSAgentExecutor, InMemoryStateStore, InMemoryStreamManager } from '@helix-agents/sdk';
import { VercelAIAdapter } from '@helix-agents/llm-vercel';
// Create stores
const stateStore = new InMemoryStateStore();
const streamManager = new InMemoryStreamManager();
const llmAdapter = new VercelAIAdapter();
// Create executor
const executor = new JSAgentExecutor(stateStore, streamManager, llmAdapter);Constructor
new JSAgentExecutor(
stateStore: SessionStateStore,
streamManager: StreamManager,
llmAdapter: LLMAdapter,
options?: {
logger?: Logger;
runLease?: { ttlMs?: number }; // default JS_RUN_LEASE_MS (30 000 ms)
llmStepRetry?: LLMStepRetryPolicy; // default DEFAULT_LLM_STEP_RETRY (3 retries, 1 s, exponential)
// ...workspaceProviders, agentRegistry, orphanedChildDetection, ...
}
)Parameters:
stateStore- Where agent state is persisted (Memory, Redis). The store must implement the run methods (getCurrentRun,getRun,listRuns,updateRunStatus,commitState,renewRunLease); the constructor throws avalidation_errornaming the missing ones otherwisestreamManager- How events are streamed (Memory, Redis)llmAdapter- LLM provider adapter (Vercel, Custom)options.runLease- The run lease (JSRunLeaseOptions)options.llmStepRetry- How a model call that fails with aretryable: trueerror is retried, as a new attempt of the same step (Retrying a failed model call)
Executing Agents
Basic Execution
const handle = await executor.execute(MyAgent, 'Research the benefits of TypeScript');
// Stream events
const stream = await handle.stream();
if (stream) {
for await (const chunk of stream) {
if (chunk.type === 'text_delta') {
process.stdout.write(chunk.delta);
}
}
}
// Get result
const result = await handle.result();
console.log(result.output);With Multiple Messages
Send multiple user messages in a single execution. This is useful for injecting context alongside a question, providing system-generated context, or batching messages from an async channel:
const handle = await executor.execute(
MyAgent,
{
message: [
{
role: 'user',
content: 'Background: user is on the enterprise plan',
metadata: { source: 'system' },
},
{ role: 'user', content: 'What features do I have access to?' },
],
},
{ sessionId: 'my-session' }
);Each message in the array is persisted individually and visible to the LLM. You can attach files to any message:
const handle = await executor.execute(
MyAgent,
{
message: [
{
role: 'user',
content: 'Analyze this screenshot',
files: [
{
data: 'data:image/png;base64,iVBORw0KGgo...',
mediaType: 'image/png',
filename: 'screenshot.png',
},
],
},
],
},
{ sessionId: 'my-session' }
);String and multi-message inputs can be mixed freely across turns in the same session.
With Initial State
const handle = await executor.execute(MyAgent, {
message: 'Continue the research',
state: {
previousFindings: ['Finding 1', 'Finding 2'],
phase: 'analyzing',
},
});With Options
const handle = await executor.execute(MyAgent, 'Research topic', {
sessionId: 'my-session-id', // Session ID for conversation continuity
parentStreamId: 'parent-stream', // For sub-agent streaming
parentSessionId: 'parent-session-id', // Parent session reference (for sub-agents)
});With Conversation History
When you manage your own message history externally:
const handle = await executor.execute(MyAgent, {
message: 'Continue from here',
messages: [
{ role: 'user', content: 'Previous question' },
{ role: 'assistant', content: 'Previous answer' },
],
});Execution Handle
The handle returned from execute() provides these methods:
stream()
Get an async iterable of stream chunks:
const stream = await handle.stream();
if (stream) {
for await (const chunk of stream) {
console.log(chunk.type, chunk);
}
}Returns null if streaming is not available.
result()
Wait for and get the final result:
const result = await handle.result();
if (result.status === 'completed') {
console.log('Output:', result.output);
} else {
console.log('Failed:', result.error);
}abort(reason?)
Cancel the agent execution:
await handle.abort('User requested cancellation');The agent checks the abort signal between steps and during tool execution.
getState()
Get current agent state:
const state = await handle.getState();
console.log('Step count:', state.stepCount);
console.log('Messages:', state.messages.length);
console.log('Custom state:', state.customState);canResume()
Check if the agent can be resumed:
const { canResume, reason } = await handle.canResume();
if (canResume) {
const newHandle = await handle.resume();
}resume()
Resume a paused or interrupted agent:
const { canResume, reason } = await handle.canResume();
if (canResume) {
const newHandle = await handle.resume();
const result = await newHandle.result();
}retry()
Retry a failed execution:
const result = await handle.result();
if (result.status === 'failed') {
const retryHandle = await handle.retry();
const retryResult = await retryHandle.result();
}The retry is one run-start commit: it rewinds the log to the target (atomically, inside the commit) and appends the input. Which target and input it uses (core planRetry, the same on every runtime):
- Default target: the failed run's last committed step. A failure writes a terminal checkpoint, but
retry()never targets it. It restores the failed run's latest step commit (itscustomState,stepCount,forcedCompletion). The triggering user message is already in that prefix, so passmessage(appended after it); without oneretry()throws avalidation_error(No user message found to retry with). - The run failed on its first step. The retry re-runs the run's entry: the log is rewound to just before the run's own triggering user message, which the retried run re-appends, followed by
messagewhen given. Without amessagethe trigger is re-run (CP-58; this used to throw). - The session failed before its run started. If the failing entry never reached its run-start commit, nothing of it is in the log: the whole log is kept, and
messageis required. checkpointId- Restore exactly this checkpoint. It must belong to this session: an unknown id throwsstate_checkpoint_not_found, another session's checkpointstate_checkpoint_foreign, before any state change. A real user message after the checkpoint is re-run; with none,messageis required.- If the session has no checkpoint at all (pre-upgrade data),
retry()restarts fresh from the triggering message.
See Retrying Failed Agents and the upgrade guide.
send()
Continue the conversation with another message. This is syntactic sugar for continuing the conversation in the same session:
// Simple string input (becomes user message)
const handle2 = await handle1.send('Tell me more about that');
const result = await handle2.result();
// Message array input (for advanced use cases)
const handle2 = await handle1.send([
{ role: 'user', content: 'Here is some context' },
{ role: 'user', content: 'Now my actual question' },
]);State: the new turn continues from the session's committed state. send() takes no state (input state seeds a fresh session only, U23 / CP-223); to change state on a later turn, write it from a tool or a hook such as onAgentStart.
A new request: send() does not inherit the original execute()'s abortSignal, requestKey or branch. The first request's signal may abort long after its run ended (a closed HTTP request, a caller's stop), and it must not refuse or stop a later turn. Stop a send's run through the handle send() returns (interrupt() / abort()), or start the turn with executor.execute(agent, input, { sessionId, abortSignal }) to give it a signal of its own.
Important: send() waits for the current execution to complete before starting the new one. If you need parallel conversations, create separate handles via executor.execute().
History and time travel
Every entry point that loads the conversation into memory (execute() on an existing session, resume(), retry(), branch, sub-agent reloads, a companion resumed from interrupted) uses core loadLLMHistory. If the store returns fewer rows than its live count, the run fails with errorDetail.code === 'state_history_incomplete' (persisted, on the stream and in onAgentFail) instead of calling the model on a partial transcript. When that read happens before the entry's run-start commit (execute()'s history load and its run-start plan, retry()'s plan), the entry never started a run: the caller gets the typed failure (execute() returns a handle whose result is failed with that code, or rejects), and a session that already has a run is left untouched (its status, errorDetail, stream and hooks). Only a session that never had a run is marked failed. execute() on a session that is still running throws AgentAlreadyRunningError as before.
resume({ mode: 'from_checkpoint', checkpointId }) rewinds the durable log and the in-memory state to the target checkpoint. Previously only the in-memory state was restored while the log was truncated to the latest checkpoint, so the model still saw messages from after the target (RM-25). The rewind happens inside the resume's run-start commit (startRun.truncateTo), atomically with the new run, so a concurrent resume() gets AgentAlreadyRunningError with nothing changed, and the winner writes a stream_resync (with the rewind's truncateCheckpointId) after its run_started. Pending client-tool calls made after the checkpoint get an aborted tool_end; see from_checkpoint.
Reconnecting to Sessions
Use getHandle() to reconnect to an existing session:
// Get handle for existing session
const handle = await executor.getHandle(MyAgent, 'session-123');
if (handle) {
// Check if we can resume
const { canResume, reason } = await handle.canResume();
if (canResume) {
// Resume execution
const resumedHandle = await handle.resume();
const result = await resumedHandle.result();
} else {
// Get completed result
const result = await handle.result();
}
}The sessionId is the primary identifier for conversation continuity. Persist it to reconnect to conversations across server restarts or process boundaries.
Multi-Turn Conversations
Enable conversation continuation where each message builds on the previous exchange using the session-centric model.
Using sessionId
Pass the same sessionId to continue a conversation within the same session:
// First message - creates a new session
const handle1 = await executor.execute(agent, 'Hello, my name is Alice', {
sessionId: 'session-123',
});
await handle1.result();
// Continue the conversation - agent remembers the name
const handle2 = await executor.execute(agent, 'What is my name?', {
sessionId: 'session-123', // Same session continues the conversation
});
const result = await handle2.result();
// Agent responds: "Your name is Alice"A turn started while the previous run is still finishing (CP-85)
The previous run writes its terminal commit, then its run status, then ends the session's stream. A turn started before that run status lands is refused with AgentAlreadyRunningError (the previous run still holds its lease). A turn started after it wins its run-start commit and claims the stream; the old run's late stream end is fenced to its own run id and changes nothing, so it no longer ends the new turn's stream. The finding record stays open until its conformance cell lands; see CP-85 in the conformance findings.
Using handle.send()
Syntactic sugar for continuation within the same session:
const handle1 = await executor.execute(agent, 'Hello, my name is Alice', {
sessionId: 'session-123',
});
await handle1.result();
// Equivalent to execute() with same sessionId
const handle2 = await handle1.send('What is my name?');
const result = await handle2.result();Using Direct Messages
When you manage your own message history in an external database:
const handle = await executor.execute(agent, {
message: 'What is my name?',
messages: [
{ role: 'user', content: 'Hello, my name is Alice' },
{ role: 'assistant', content: 'Hello Alice! How can I help you today?' },
],
});This is useful when:
- You store conversation history in your own database
- You want full control over what context the agent sees
- You're building chat features outside the framework's state store
Note: System messages in messages are filtered out and re-added dynamically by the agent.
Input Formats
The message field accepts two formats:
string- Simple text message (sugar for a single user message)UserInputMessage[]- Multiple user messages, each with optionalmetadataandfiles
// String shorthand
await executor.execute(agent, 'Hello');
// Structured with multi-message
await executor.execute(agent, {
message: [
{ role: 'user', content: 'System context', metadata: { hidden: true } },
{ role: 'user', content: 'User question' },
],
});Behavior Table
Both messages and state have override semantics when combined with existing session state:
| Input | Messages Source | State Source |
|---|---|---|
message only (new session) | Empty (fresh) | Empty (fresh) |
message + sessionId (existing) | From session | From session |
message + messages | From messages | Empty (fresh) |
message + state | Empty (fresh) | From state |
message + sessionId + messages | From messages (override) | From session |
message + sessionId + state | From session | From state (override) |
| All four | From messages (override) | From state (override) |
Key points:
messagecan be astringorUserInputMessage[]in all rows above- Sessions contain all messages and state for a conversation
- Each execution creates a new run within the session (for debugging, billing, tracing)
messages(conversation history) overrides history from session when both are providedstateoverrides state from session when both are provided- Non-existent sessions are automatically created on first message
Branching Conversations
Use the branch option to create a new session from an existing checkpoint:
// Create a new session branching from an existing checkpoint
const handle = await executor.execute(agent, 'What if we tried a different approach?', {
sessionId: 'new-session-456',
branch: { fromSessionId: 'session-123', checkpointId: 'cp_abc' },
});
// new-session-456 starts with state from checkpoint cp_abcThe new session gets exactly the checkpoint's first messageCount messages and its customState. Use messageIndex instead of checkpointId to copy the first N messages. The checkpoint must belong to fromSessionId (state_checkpoint_foreign otherwise, before anything is cloned).
Method Comparison
| Method | Purpose | Stream Behavior | Valid From Status |
|---|---|---|---|
execute() | New/continue conversation | Resets stream | Any except running |
resume() | Continue after interrupt | Preserves stream | interrupted, paused |
retry() | Recover from failure | Resets to its target | failed |
Common Patterns
// Multi-turn conversation
const h1 = await executor.execute(agent, 'Hello', { sessionId: 'chat-1' });
await h1.result();
const h2 = await executor.execute(agent, 'Tell me more', { sessionId: 'chat-1' });
// Interrupt and resume
const h = await executor.execute(agent, 'Long task', { sessionId: 'task-1' });
await h.interrupt();
const resumed = await h.resume();
// Retry after failure
const h = await executor.execute(agent, 'Risky task', { sessionId: 'task-2' });
const result = await h.result();
if (result.status === 'failed') {
const retried = await h.retry();
}Concurrency Protection
The JS runtime prevents concurrent executions on the same session:
import { AgentAlreadyRunningError } from '@helix-agents/core';
const handle1 = await executor.execute(agent, 'First', { sessionId: 'sess-1' });
// Throws AgentAlreadyRunningError
try {
await executor.execute(agent, 'Second', { sessionId: 'sess-1' });
} catch (error) {
if (error instanceof AgentAlreadyRunningError) {
console.log('Session already running');
}
}Protection Mechanisms
Every entry into a run (execute(), resume() in any mode, retry(), a client-tool continuation, a sub-agent turn) writes one run-start commit before anything else: appendMessages(sessionId, inputRows, { streamSequence, runId, startRun }). The store applies it atomically: it checks that the session's current run is still the one the caller read, refuses a run whose owner is still live, supersedes any other live run, applies a rewind (resume from a checkpoint, retry()), appends the input rows and creates the run record and its input checkpoint.
| Outcome | What the caller sees |
|---|---|
| Winner | Claims the stream (a compare-and-set), makes it live, writes run_started, then the run's content |
| Loser | AgentAlreadyRunningError (RunStartConflictError); it wrote nothing: no input row, no stream status change, no chunk |
| Refused | RunStartRejectedError when the call's own commit cannot apply (for example the same runId with a different input, idempotency_mismatch); the chat handler maps it to a 409 state_run_start_conflict |
The only earlier write is a new session's write-once createSession (two concurrent execute() calls on a new id: one gets AgentAlreadyRunningError). A fresh owner token is minted per run.
resume() in continue / with_message mode on a paused session whose client-tool batch is still partial (some awaited call has no submitted result) is refused with a retryable state_not_resumable before any write (R44). Submit every result of the batch (the runtime then continues), call interrupt() (it ends the paused run at once), or submit error results for the calls you want to abandon. A from_checkpoint rewind that discards the unanswered calls proceeds.
Run lease and superseded runs
A JS run is owned through a lease, not a process lock, so a crashed process never bricks a session:
- The run-start commit grants the run a lease of
runLease.ttlMs(defaultJS_RUN_LEASE_MS, 30 000 ms, exported by@helix-agents/runtime-js). The store evaluates it on its own clock. - The executor renews it every
ttl / 3through the store'srenewRunLease(a compare-and-set), on a timer, so a long LLM or tool call keeps it. The suspension or terminal commit releases it. - An entry into a session whose current run holds a valid lease gets
AgentAlreadyRunningError. A run whose lease lapsed (its process crashed, or a pre-upgrade run with no lease) is superseded by the nextexecute()/resume(). - A run whose renewal the store rejects (another entry took the session over) stops at once. A transient or hung renewal is retried and never stops the run on its own. Every chunk it writes is gated on the lease (past the local deadline the gate renews first), so a lapsed run never writes to a stream a newer run may have claimed, and every tool execution checks that the run still owns the session first.
- A blocked event loop can be superseded. If the process's event loop is blocked for longer than about
ttl - ttl/3(20 s with the default), renewals cannot run, the lease can lapse and a concurrent entry may take over (with no concurrent entry, the run renews and continues once the loop frees up). RaiserunLease.ttlMsif your tools block the loop for long periods.
A run that was taken over stops as superseded, never failed:
const result = await handle.result();
if (result.status === 'superseded') {
// errorDetail.code === 'state_run_superseded', retryable: false.
// Another run now drives the session; reconnect to it with getHandle().
}onAgentComplete / onAgentFail do not fire for a superseded run (one exception: a supersede that lands after onAgentComplete fired and before the run's terminal commit leaves onAgentComplete then onAgentSuperseded; the last terminal hook is authoritative); the optional onAgentSuperseded hook does. Every write a superseded run attempts is fenced on its run id (RunSupersededError, state_run_superseded) and changes nothing. In-process sub-agent runs keep their own leases. The Cloudflare DO, which runs this executor, keeps no lease: its synchronous claim decides the owner.
resume() refuses a terminal session with an untyped error (DI-31)
resume() refuses a session whose run is still executing with AgentAlreadyRunningError in every mode, from_checkpoint included (before this release a from_checkpoint resume was accepted and superseded the live run, CP-83). A completed or failed session is still refused with a plain Error, not AgentNotResumableError (DI-31). See the conformance findings.
Client-tool deadlines
A run suspended on a client-tool call that no client answers is advanced at the call's deadline (clientToolTimeoutMs) by the executor itself:
- The suspension arms an in-process timer at the earliest deadline of the run's unanswered calls.
- At the deadline the executor expires the lapsed calls as
client_tool_timeout. The write is run-fenced and stop-guarded, so a submit or a stop that lands first wins. - Once the batch is complete, the executor continues the run with an ordinary
resume(). A call with a later deadline, or a step still waiting on sub-agents, is not continued early. - No request is needed. The stream carries the timeout's
tool_endand the continuation's chunks, so a client that reconnects through the snapshot sees them.
The limit: it runs only while the process lives. If the process exits after the suspension, or its host freezes it between requests (serverless, scale-to-zero), nothing fires. The lapsed call is then expired by the next request that touches the session:
- a reconnect's
getHandle()or asubmitToolResult()re-arms the timer; - a
resume()expires the call in its drain.
submitToolResult() re-arms only with an agentRegistry, because it needs the session's agent. Sub-agent sessions are advanced through their parent run, not on their own.
Upgrading: after a deadline the run may already have advanced. Code that waited past a deadline and then called resume() to move the run on now usually finds it already continued, and the late resume() is refused, typed:
ResumeRefusedError(code: 'state_not_resumable',sessionStatus: 'completed', not retryable) once the continuation completed the run;AgentAlreadyRunningErrorwhile it is still running. It is a plainErrorsubclass, not aHelixError: match it by class orname. Its run-start-commit form, theRunStartConflictErrorsubclass, carriescode: 'state_run_start_conflict'; coreclassifyErrormaps both tostate_already_running.
Read the outcome instead: (await executor.getHandle(agent, sessionId))?.result(), or the session snapshot.
A resume({ mode: 'with_message' }) that arrives while the deadline's continuation is running is refused state_already_running, as against any live run; nothing is written and the client may re-send it (a new turn once the run has completed). Whether such a message should instead be queued onto the continuation is an open design question.
The Cloudflare Durable Object, which runs this executor, fires deadlines from its own alarm instead (it survives eviction). See the client-tools guide for every runtime.
Execution Flow
Here's how the JS runtime executes an agent:
flowchart TB
Start["execute() called"]
subgraph Init ["1. Start the run"]
I1["Mint run ID; parse initial state from schema defaults"]
I2["Run-start commit: user message + run record + input checkpoint"]
I3["Winner only: claim the stream, run_started"]
I4["Start the run lease"]
end
subgraph Loop ["2. Execution loop (while status === 'running')"]
subgraph Build ["3. Build messages"]
B1["Add system prompt"]
B2["Include conversation history"]
end
subgraph LLM ["4. Call LLM"]
L1["Stream text deltas"]
L2["Get tool calls"]
end
subgraph Process ["5. Process step result"]
P1["Check for __finish__ tool"]
P2["Extract output if complete"]
P3["Plan tool executions"]
end
subgraph Tools ["6. Execute tools (parallel)"]
T1["Regular tools: execute directly"]
T2["Sub-agent tools: recursive execute()"]
end
subgraph Update ["7. Commit the step"]
U1["Add assistant message"]
U2["Add tool results"]
U3["Step commit (rows + state + checkpoint)"]
end
subgraph Stop ["8. Stop decision (resolveStopDecision)"]
S1["Terminal step? (final answer, output)"]
S2["stopWhen predicate? → completed"]
S3["maxSteps reached? → failed (framework_max_steps_exhausted)"]
end
end
Return["9. Return handle immediately<br/>(Execution continues in background)"]
Start --> Init
Init --> Loop
Build --> LLM --> Process --> Tools --> Update --> Stop
Stop -->|Continue| Build
Loop --> ReturnParallel Tool Execution
The JS runtime executes tool calls in parallel:
// If LLM returns multiple tool calls:
// [search('topic A'), search('topic B'), analyze('data')]
// All three execute concurrentlyThis includes sub-agent calls - multiple sub-agents can run simultaneously.
Parallel state updates:
When parallel tools update state, the runtime uses delta merging:
- Array pushes are accumulated (not overwritten)
- Object properties are merged
- Conflicts are resolved via last-write-wins
Sub-Agent Handling
Sub-agents execute recursively within the same process:
// Parent agent calls sub-agent tool
// JS runtime:
// 1. Detects sub-agent tool call
// 2. Creates new state for sub-agent (same streamId)
// 3. Recursively calls runLoop()
// 4. Sub-agent events stream to same stream
// 5. Sub-agent output becomes tool resultSub-agents share the stream but have isolated state.
Persistent Sub-Agent Handling
Persistent sub-agents configured via persistentAgents are managed through companion tools (companion__spawnAgent, companion__sendMessage, etc.). In the JS runtime:
- Blocking spawn: The child agent's full execution loop runs inline within the parent's step. The parent waits for the child to complete before continuing.
- Non-blocking spawn: The child starts immediately and the parent continues. The child runs concurrently via
Promise.resolve(). - sendMessage: Sends a new user message to an already-running persistent child, triggering another execution loop.
- Companion tool results are returned directly as tool results in the parent's conversation.
Persistent children are tracked via SubSessionRef entries with mode: 'persistent' in the state store.
Re-spawning a completed persistent child continues it on its preserved session (memory retained) rather than recreating it — see Re-consulting a persistent companion (the critic loop). A child without outputSchema that runs out of maxSteps ends failed, so it is re-spawned fresh (its memory is lost); give such a companion a stopWhen or a larger maxSteps if it must stay continuable.
Error Handling
Tool Errors
Tool errors are caught and returned to the LLM:
const searchTool = defineTool({
name: 'search',
execute: async (input) => {
throw new Error('API rate limited');
},
});
// LLM sees: "Tool 'search' failed: API rate limited"
// LLM can decide to retry, use different approach, etc.Execution Errors
Fatal errors fail the agent:
try {
const result = await handle.result();
} catch (error) {
// LLM API failed, state store failed, etc.
}Check result.status for graceful handling:
const result = await handle.result();
if (result.status === 'failed') {
console.error('Agent failed:', result.error);
}Error Classification
When LLM calls fail, errors are automatically classified into typed ErrorChunk events in the stream:
import { HelixError } from '@helix-agents/core';
// Stream error chunks include classification when available
for await (const chunk of stream) {
if (chunk.type === 'error') {
console.log(chunk.error); // 'Provider overloaded'
console.log(chunk.code); // 'provider_overloaded' (from HelixError classification)
console.log(chunk.recoverable); // true (whether client can retry)
}
}The JS runtime's onError callback checks if the error is a HelixError instance. If so, the ErrorChunk includes code and recoverable from the classified error. For unclassified errors, code is omitted and recoverable defaults to false.
See Error Handling Guide for the complete error pipeline.
Completion-support refusal
execute(), resume(), retry() and handle.resume() throw a typed, non-retryable HelixError before any store read or write when an outputSchema agent's model has no completion strategy (framework_completion_strategy_unavailable, for example an unrecognised Claude model id) or its completion schema cannot be sent as native structured output (framework_completion_schema_unsupported). Sub-agents and persistent companions are checked with their own config and adapter when they start; a refusal surfaces to the parent as a failed delegation. An agent with an llmConfigOverride is not refused at entry, because the override may pick the forced call's model; a forced call whose override selects such a model fails that call with the same code instead.
Limitations
No Crash Recovery
If the process dies, in-flight executions are lost:
// Process starts
const handle = await executor.execute(agent, 'Long task');
// Process crashes here - execution is lost
// After restart, state exists but execution stopped
const reconnected = await executor.getHandle(agent, handle.sessionId);
// reconnected.canResume() returns true
// But original execution context is goneMitigation: Use Redis (or Postgres) stores to preserve state, then resume. The crashed run stops renewing its lease; until the lease lapses (up to runLease.ttlMs, 30 s by default) an entry is refused with AgentAlreadyRunningError, after it the entry supersedes the stranded run:
// After crash/restart
const handle = await executor.getHandle(agent, savedSessionId);
if (handle) {
const { canResume } = await handle.canResume();
if (canResume) {
const resumed = await handle.resume();
// Continues from last saved state
}
}No Distributed Execution
Everything runs in one process. For distributed execution, use Temporal.
No Per-Tool Timeouts
Tools run without individual timeout enforcement. Add your own:
const toolWithTimeout = defineTool({
name: 'slow_api',
execute: async (input, context) => {
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Tool timeout')), 30000)
);
const apiPromise = callSlowApi(input);
return Promise.race([apiPromise, timeoutPromise]);
},
});Best Practices
1. Use Redis for Production
In-memory stores lose data on restart:
import { RedisStateStore, RedisStreamManager } from '@helix-agents/store-redis';
const executor = new JSAgentExecutor(
new RedisStateStore(redis),
new RedisStreamManager(redis),
llmAdapter
);2. Handle Abort Signals
Check abort signal in long-running tools:
execute: async (input, context) => {
for (const item of items) {
if (context.abortSignal.aborted) {
throw new Error('Aborted');
}
await processItem(item);
}
};3. Set Appropriate maxSteps
Prevent runaway agents:
const agent = defineAgent({
maxSteps: 20, // Reasonable limit for your use case
});4. Monitor Step Count
Track execution progress:
// In your tool
const state = await handle.getState();
console.log(`Step ${state.stepCount} of ${agent.maxSteps}`);Next Steps
- Temporal Runtime - For durable, production workloads
- Cloudflare Runtime - For edge deployment
- Storage: Memory - In-memory stores for development
- Storage: Redis - Production-ready stores