Skip to content

@helix-agents/runtime-dbos ​

DBOS Transact runtime for durable agent execution on Postgres. Provides crash recovery, automatic step retries, and durable interrupt/abort. Implements the standard AgentExecutor interface, so the public surface mirrors JSAgentExecutor and TemporalAgentExecutor.

DBOS-native suspension

Unlike the v7 stateless-suspension runtimes (JS, Temporal, Cloudflare) that suspend via a durable-state suspensionContext, runtime-dbos uses DBOS-native DBOS.recv / DBOS.send primitives over Postgres-backed workflow replay. Functionally equivalent for callers, architecturally separate. See the DBOS Runtime guide.

Known limitations

Memory auto-injection/extraction is unsupported. The memory: config field is accepted at the type level but is never invoked on DBOS. Agents requiring memory recall or storage should run on the JS or Cloudflare runtimes.

Approved tools do not stream live through handleChatStream (a W1 MR2b regression, until FU-DBOS-CONTINUATION-ENTRY-ORDER lands). (a) An approved tool's live frames (state_patch, custom ctx.emit events, subagent_*) are not streamed; they reach the client only through the snapshot (the re-anchor, retry() or a reload). (b) An approved tool that runs past the 30 s live-wait bound gets the non-retryable 500 run_not_live, and the client must call retry(). Client-tool continuations are unaffected. See the DBOS Runtime guide.

Prompt caching is supported

LLMConfig.cache strategies (anthropicCache, openaiCache, xaiCache, or a custom CacheStrategy) are applied on DBOS at parity with the other runtimes. Strategies are not JSON-serializable, so the executor registers the live strategy in a process-local registry (alongside the live model) and the workflow body applies it before each LLM call.

Breaking: run-start commits, drain before deploying

Every entry now runs a guarded run-start step first, workflow ids changed (…__run__<runId>, …__resume__<runId> for a resume or retry) and the workflow bodies changed. This release does not use DBOS.patch, so a workflow started by the previous release cannot recover on this one: let in-flight DBOS workflows finish, or cancel them, before you deploy. The new run-start fail-safe (runStartFailSafeMs) throws core's RunStartAbandonedError / RunStartOutcomeUnknownError. Custom stores must implement the new commit contract. See the upgrade guide.

Installation ​

bash
npm install @helix-agents/runtime-dbos @dbos-inc/dbos-sdk

DBOSAgentExecutor ​

The top-level AgentExecutor implementation. The constructor binds every @DBOS.step's static dependencies and calls registerDBOSAgentWorkflows() (idempotent).

typescript
import { DBOS } from '@dbos-inc/dbos-sdk';
import { DBOSAgentExecutor, registerDBOSAgentWorkflows } from '@helix-agents/runtime-dbos';
import { PostgresStateStore } from '@helix-agents/store-postgres';
import { RedisStreamManager } from '@helix-agents/store-redis';
import { VercelAIAdapter } from '@helix-agents/llm-vercel';

registerDBOSAgentWorkflows(); // before DBOS.launch()
await DBOS.launch();

const executor = new DBOSAgentExecutor({
  stateStore: new PostgresStateStore({ connectionString: process.env.DATABASE_URL! }),
  streamManager: new RedisStreamManager({ url: process.env.REDIS_URL! }),
  llmAdapter: new VercelAIAdapter({ model: openai('gpt-6-luna') }),
});

DBOSAgentExecutorConfig ​

Constructor configuration. stateStore, streamManager, and llmAdapter are required; all other fields are optional.

typescript
interface DBOSAgentExecutorConfig {
  /** State store for session data, messages, and checkpoints. `PostgresStateStore` recommended. */
  stateStore: SessionStateStore;
  /** Stream manager for real-time token streaming. `RedisStreamManager` required in production. */
  streamManager: StreamManager;
  /** LLM adapter (e.g., `VercelAIAdapter`). */
  llmAdapter: LLMAdapter;
  /** Optional usage store for token-count tracking. */
  usageStore?: UsageStore;
  /** Optional hook manager for lifecycle hooks. */
  hookManager?: HookManager;
  /** Optional logger (compatible with pino, winston, etc.). Defaults to no-op. */
  logger?: Logger;
  /** Default idle timeout for persistent workflows (ms). Default: 86_400_000 (24h). */
  defaultPersistentIdleTimeoutMs?: number;
  /** Per-runtime tool retry policy. Defaults match runtime-temporal. */
  toolRetryPolicy?: ToolRetryPolicy;
  /**
   * Fail-safe bound (ms) on an executor call's wait for its workflow's
   * run-start outcome. Default 120 000.
   */
  runStartFailSafeMs?: number;
  /** The submit size limit this executor owns (DI-51); default 1 MB. */
  submitToolResultSchema?: SubmitToolResultSchemaOption;
}

Submit size limit (DI-51). The executor owns it: submitToolResultSchema (any factory-built schema, createSubmitToolResultSchema({ maxResultBytes }); default 1 MB) sets it, executor.submitLimits exposes it, and submitToolResult refuses a result over it with core's typed 413 validation_payload_too_large before anything is read or stored. Hosts (agent-server, handleChatStream) read it from the executor. See the submit size limit.

Request keys ​

This runtime declares supportsRequestKey: false: it does not record request keys yet. A requestKey passed to execute() / resume() / retry() is ignored (the entry runs keyless) with one request_key.ignored warning per executor instance, and handleChatStream sends its requests keyless.

Run start ​

execute(), resume() and retry() mint the run's runId, start the workflow (owner token: the DBOS workflowID) and wait for its first step, the guarded run-start, to publish an outcome:

  • Won: the run committed (input rows, a rewind's truncateTo), then claimed the stream, made it live and wrote run_started. The handle is returned.
  • Lost: RunStartConflictError (named AgentAlreadyRunningError) or RunStartRejectedError. Nothing was written.
  • Committed, then the entry failed: the run was ended failed; the call throws RunEntryFailedError.
  • No outcome within runStartFailSafeMs: the executor calls the store's abandonRunStart (a tombstone that refuses a late commit) and cancels the workflow. A run that committed meanwhile is returned. Otherwise the call throws core's RunStartAbandonedError (retryable: a retry starts a fresh workflow id), or RunStartOutcomeUnknownError when the tombstone write failed (non-retryable: read the session first).

The winner of a resume or retry bumps resumeCount inside its run-start commit. Every step whose retries are exhausted settles the turn failed. A run superseded by a newer entry stops with RunSupersededError, which DBOS excludes from step retries; its result is status: 'superseded', onAgentSuperseded fires, and neither onAgentComplete nor onAgentFail does (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). onAgentSuspended fires after the suspension commit. A suspended root run writes its suspended_* run status, the pending calls' frames, the suspension commit, then pauses the stream.

No registry argument

DBOSAgentExecutor does not take an AgentRegistry. Agents are registered lazily per execute() / resume() / retry() call. Pass stateStore, streamManager, and llmAdapter directly.

The default tool retry policy:

typescript
const DEFAULT_TOOL_RETRY_POLICY = {
  retriesAllowed: true,
  maxAttempts: 3,
  initialIntervalSeconds: 1.0,
  backoffCoefficient: 2.0,
  maximumIntervalSeconds: 60.0,
};

Methods ​

The executor implements the standard AgentExecutor interface plus internal helpers invoked by handles.

execute ​

Starts a new agent execution (standard mode) or routes a message into an existing persistent session (persistent mode). options.sessionId is required — execute() throws if it is omitted.

typescript
execute<TState, TOutput>(
  agent: AgentConfig<z.ZodType<TState>, z.ZodType<TOutput>>,
  input: AgentInput<TState>,
  options?: ExecuteOptions
): Promise<AgentExecutionHandle<TOutput>>;

In standard mode, starts a new short-lived DBOS workflow (agent__<name>__<sessionId>__run__<runId>); a call for a session whose run is still live loses its run-start commit and throws AgentAlreadyRunningError. In persistent mode, the first call starts the recv-loop workflow and subsequent calls DBOS.send a message into it.

getHandle ​

Reconnects to an existing session without starting new execution. Returns null if the session does not exist.

typescript
getHandle<TState, TOutput>(
  agent: AgentConfig<z.ZodType<TState>, z.ZodType<TOutput>>,
  sessionId: string,
  options?: Pick<ExecuteOptions, 'usageStore'>
): Promise<AgentExecutionHandle<TOutput> | null>;

resume ​

Resumes execution after an interrupt or pause. CAS-transitions the session status from interrupted to active, then starts a new DBOS workflow (standard mode) or sends a wake signal (persistent mode).

typescript
resume<TState, TOutput>(
  agent: AgentConfig<z.ZodType<TState>, z.ZodType<TOutput>>,
  sessionId: string,
  options?: ResumeOptions  // mode: 'continue' | 'with_message' | 'with_confirmation' | 'from_checkpoint'
): Promise<AgentExecutionHandle<TOutput>>;

resume() does not restore input lost to an early interrupt (CP-79)

In standard mode, interrupt() on the handle execute() returned cancels the workflow. If it lands before the workflow's appendMessages step, the turn's user input is never stored, and resume() starts the new workflow with no input, so it answers without it and reports completed. See CP-79 in the conformance findings.

A resume runs in a new workflow agent__<name>__<sessionId>__resume__<runId>. While the session's client-tool batch is partial (some awaited call has no result yet), resume() is refused with a retryable HelixError state_not_resumable (R44): submit the remaining results first (the runtime then continues), submit error results, or interrupt() (which ends the paused run by the stop). A suspended run with a recorded stop is ended by that stop first (its parked workflow is cancelled, then the run is settled): a continue resume then reports the stopped run (no model call), and a with_message resume proceeds as a new turn after an interrupt. A completed or failed session (an abort stop's included) is not resumable: continue / with_message throw AgentNotResumableError (state_not_resumable, non-retryable) before any write; retry() a failed session.

from_checkpoint resolves the checkpoint before any state change (state_checkpoint_foreign / state_checkpoint_not_found); the rewind then happens inside the new workflow's run-start commit (truncateTo). A session suspended on a client tool whose workflow is still waiting in DBOS.recv is claimed, its workflow cancelled and replaced, rather than attached to. Concurrent from_checkpoint resumes give at most one rewind and one workflow: once the suspended workflow is cancelled, a second rewind finds no PENDING workflow and throws AgentAlreadyRunningError, and the rewind save is pinned to the claim. A claim from interrupted / paused that an unrelated write made lose is rolled back to that status.

retry ​

Retries a failed session in a fresh workflow (…__resume__<runId>) whose run-start commit rewinds to the target and re-appends the input (CP-30: RetryOptions are honoured). The default target is the failed run's last step boundary (core planRetry): its latest step commit, or, for a run that failed on its first step, its run-start commit, re-running the triggering user message (then options.message, when given). An explicit checkpointId is resolved before any write (state_checkpoint_not_found / state_checkpoint_foreign) and restored as given. A retry with nothing to retry with (no message and no user row after the target) throws validation_error before any write. The retried run fires onAgentResumed, and its stream_resync (reason: 'retry') carries the rewind's truncateCheckpointId.

typescript
retry<TState, TOutput>(
  agent: AgentConfig<z.ZodType<TState>, z.ZodType<TOutput>>,
  sessionId: string,
  options?: RetryOptions
): Promise<AgentExecutionHandle<TOutput>>;

submitToolResult ​

Submits the outcome of a client-executed tool call (or an approval decision) back into the durable agent loop. Routing flows through the shared routeSubmitToolResult helper, which resolves the owning workflow and delivers the payload via DBOS.send(workflowId, payload, toolCallId) — the toolCallId doubles as both the recv topic and the idempotency key.

typescript
submitToolResult(params: SubmitToolResult): Promise<SubmitToolResultResponse>;

SubmitToolResult is a discriminated union on kind:

  • { kind: 'client-tool-result', sessionId, toolCallId, result? , error? } — a client tool's output (or error).
  • { kind: 'approval-response', sessionId, toolCallId, approvalId, approved, reason? } — an approval-gate decision.

The response status is one of 'accepted', 'already_completed', or 'unknown_tool_call'. Submission is exactly-once: concurrent / retried submits on the same toolCallId are serialized by an OCC-stamped submittedAt marker (first writer returns 'accepted'; losers return 'already_completed'), and the DBOS.send idempotency key is scoped by destination workflow ID. A durable completedClientToolCalls marker makes 'already_completed' survive executor restart and persistent-workflow exit.

A call of a stopped run (WA MR2 §7.1). The submit reads the stop of the call's run on its owner session before it writes a marker or sends anything (core refuseSubmitIfStopped). A stop that is recorded but not yet resolved is settled through the DBOS stop path (the run's parked workflow is signalled and cancelled, then the run ends by the stop: interrupted, or failed / framework_cancelled), and the submit answers { status: 'unknown_tool_call', reason: 'run_stopped' }: no marker, no DBOS.send, no continuation. A stop that already ended the run answers the same while the call is still unanswered in the log; that includes a root-owned call whose ownership entry the stop's settle cleared. A sub-agent's call whose ownership entry the stop cleared keeps the bare unknown_tool_call.

Clients always submit against the root sessionId. The framework demuxes toolCallId → owner workflow (for sub-agent-owned tool calls). Submit-side outputSchema validation is currently skipped on DBOS (the registry stores SerializedAgent records without Zod schemas).

describeCapabilities / getWorkspaceRegistry ​

typescript
describeCapabilities(): { workspaceProviderKinds: readonly string[] }; // returns { workspaceProviderKinds: [] }
getWorkspaceRegistry(): undefined;

runtime-dbos does not support workspaces. describeCapabilities() returns an empty list so AgentServer surfaces a RUNTIME_NO_WORKSPACE_SUPPORT 404 on the workspace HTTP route.

Internal handle callbacks ​

_interrupt(sessionId, workflowId) and _abort(sessionId, workflowId, mode) are internal methods invoked by handle.interrupt() / handle.abort(). Callers use the handle methods rather than these directly. In persistent mode, interrupt cancels the current turn (the workflow returns to its recv loop and hibernates again); abort kills the entire workflow. An abort over a session with no live run, other than a persistent loop that is still open, answers not_stoppable and writes nothing: an interrupted session stays resumable. A stop with no reason records core's DEFAULT_ABORT_REASON / DEFAULT_INTERRUPT_REASON.

AgentExecutionHandle ​

Returned by execute(), resume(), retry(), and (when found) getHandle(). Provides the standard handle surface: stream(), result(), getState(), canResume(), resume(options?), retry(options?), abort(reason?), interrupt(reason?), and send(input) for post-completion continuation. Executor callbacks (resume / retry / send) are injected into each handle at construction so they delegate back to the executor without holding a direct reference to it.

result() keeps polling the store after it settles (DI-32)

result() races the workflow's result against a poll for pending client-tool calls, and nothing stops the poll when the workflow wins. For every handle whose run finishes without a client-tool suspension, the poll keeps reading the session about every 500 ms for up to 5 minutes (then logs a poll_for_pending_client_tool_exhausted warning that does not indicate a real problem) and keeps a timer alive. Observed in a probe: 11 more reads of a finished session in the 5 s after result() resolved. Expect this extra read load on the state store at high request volume. See DI-32 in the conformance findings.

Key Exports ​

typescript
import {
  DBOSAgentExecutor,
  registerDBOSAgentWorkflows,
  registerAgent,
  bindCallLLMStep,
  DbosClientToolResolver,
  ClientToolStep,
  bindClientToolStep,
  resolveOwnerWorkflowId,
  DBOSPersistentSessionTerminatedError,
  DBOSWorkflowNotFoundError,
  DBOSModeMismatchError,
} from '@helix-agents/runtime-dbos';

import type {
  DBOSAgentExecutorConfig,
  DBOSAgentMode,
  ToolRetryPolicy,
  CompletionReason,
} from '@helix-agents/runtime-dbos';

registerDBOSAgentWorkflows ​

Evaluates the @DBOS.workflow() decorators and wires the sub-agent start-workflow callback. Must be called before DBOS.launch() and before any execute() call. Idempotent — subsequent calls are no-ops. The DBOSAgentExecutor constructor calls it internally; call it yourself at app startup to make the dependency explicit (as shown in Setup).

typescript
function registerDBOSAgentWorkflows(): void;

registerAgent ​

Populates the process-local registries (tools, agent definitions, live LanguageModel, remote sub-agent transports) for an agent.

typescript
function registerAgent(agent: AgentConfig): void;

Must be called before DBOS.launch() when a process may host a recovered workflow without first going through execute() / resume() / retry() (e.g., a submit-only or recovery-only process). Those executor methods call it internally, so the common path is covered. Idempotent; re-registering the same agent.name with a different live LanguageModel logs a warning and overwrites (last writer wins).

bindCallLLMStep ​

typescript
function bindCallLLMStep(llmAdapter: LLMAdapter, streamManager?: StreamManager): void;

Wires the LLM adapter (and optionally a stream manager) into the DBOS CallLLMStep static slot. Internal — the DBOSAgentExecutor constructor calls it from your config. Exposed only because e2e test infrastructure needs to swap the adapter between runs. Application code should configure the adapter via the constructor, not by calling this directly.

Client-tool exports ​

The public client-tool surface is small; applications normally only need these for advanced custom-executor integration or tests.

  • DbosClientToolResolver — the resolver implementation; register additional resolver behavior in a custom executor.
  • ClientToolStep / bindClientToolStep — the durable step for pending/ownership writes and its binder.
  • resolveOwnerWorkflowId(stateStore, ownerSessionId, agentType, findLive?) — resolves an owner session's live DBOS workflow ID. Standard mode: a run started by execute() runs under …__run__<runId>, one started by resume() / retry() under …__resume__<runId> (a legacy one under …__resume__<N>); it asks DBOS for the session's PENDING workflow by id prefix (findLive, default DBOS.listWorkflows), so it finds either, and falls back to the current run's …__run__ id. Persistent-mode IDs are read from state.metadata.dbosWorkflowId.

Errors ​

typescript
import {
  DBOSModeMismatchError,
  DBOSWorkflowNotFoundError,
  DBOSPersistentSessionTerminatedError,
} from '@helix-agents/runtime-dbos';
  • DBOSModeMismatchError ({ sessionId, expected, actual }) — thrown when execute() is called with a mode that conflicts with the mode recorded at session creation (standard vs persistent). A session's mode is fixed at first execute(). Recovery: use the same mode, or start a new session with a different sessionId.
  • DBOSWorkflowNotFoundError ({ workflowId }) — a DBOS workflow ID expected to exist (based on the Helix state store) cannot be found in the DBOS system database (e.g., the DBOS system DB was reset independently, or retention GC'd the workflow). Recovery: executor.retry() from the last Helix checkpoint.
  • RunStartAbandonedError / RunStartOutcomeUnknownError (from @helix-agents/core) — the run-start fail-safe (see Run start). They replace the removed RunStartFailSafeError.
  • DBOSPersistentSessionTerminatedError ({ sessionId }) — routing a message to a persistent session failed because its workflow terminated and a concurrent start attempt also failed (CAS conflict). Rare; typically a transient high-concurrency conflict. Recovery: retry execute() once.

See the Error Reference deep-dive for the full catalog.

Modes & Types ​

typescript
type DBOSAgentMode = 'standard' | 'persistent';

interface ToolRetryPolicy {
  retriesAllowed?: boolean; // default true
  maxAttempts?: number; // default 3
  initialIntervalSeconds?: number; // default 1.0
  backoffCoefficient?: number; // default 2.0
  maximumIntervalSeconds?: number; // default 60.0
}

CompletionReason is re-exported from @helix-agents/core — the user-facing reason a run halted (e.g. 'finish_tool', 'max_steps', 'stop_when', 'idle_timeout', 'shutdown', 'interrupted', 'aborted', 'failed'). The persistent-mode-specific reasons ('idle_timeout', 'shutdown') only arise on the DBOS runtime.

completionReason reads 'failed' for ordinary text answers (DI-27, DI-25)

When a run ends on a text step, DBOS maps the step's provider stop reason (end_turn, stop_sequence, content_filter, …) through a table that knows only the framework reasons above, so every such run is stored with status completed and completionReason: 'failed', including a normal end_turn answer (DI-27, confirmed by a unit probe). The status field is correct; do not use completionReason to detect failures, and do not read 'failed' there as a sign of a filtered or truncated reply (DI-26). Reasons the workflow sets itself ('finish_tool', 'max_steps', 'idle_timeout', 'shutdown', 'interrupted') are not remapped this way. runtime-js, the Cloudflare DO and Cloudflare Workflows write completionReason only for a stopWhen stop ('stop_when') or a maxSteps cut-off ('max_steps', status failed), and Temporal writes none. DBOS adopting that contract is FU-CFD1C-DI29-DI25-DBOS (DI-25, DI-29). See the conformance findings.

See Also ​

Released under the MIT License.