@helix-agents/runtime-temporal
Temporal runtime for durable agent execution. Provides crash recovery, automatic retries, and distributed execution using Temporal workflows.
v7 Stateless Suspension
v7 (the A.2 Temporal HITL rewrite, Tasks 3.2/3.3) collapsed the v6 runAgentWorkflow + WorkflowActivities shim into a single agentWorkflow body. The workflow exits on every HITL boundary instead of waiting on signals — submissions are durable state, and resume is driven by executor.resume() starting a new workflow instance with a __resume-<runId> suffix. The v6 signals (submitToolResult, interruptAgent, runResumed, childSuspended, childWoke) have been removed; only INTERRUPT_SIGNAL_NAME remains: the signal the executor's handle.interrupt() sends.
Breaking: run-start commits, replay-incompatible workflows
This release routes every entry through a run-start commit and adds activities (startRunCommit, commitTerminalState, finalizeTerminalRun, suspendRun, pauseSuspendedStream, notifySuperseded, abandonCompanionContinuation). Workflow ids changed (__resume-<runId>, __retry__<runId>). An in-flight workflow started by the previous release cannot replay on this one: let in-flight workflows finish, or terminate them, before you deploy. TemporalClient.describeWorkflow is now required, the @temporalio/* peer range is ^1.13.0, and zod (^4.1.8) is now a peer dependency. See the upgrade guide.
Installation
npm install @helix-agents/runtime-temporalAgentRegistry
Maps agent type names to configurations. Supports both static registration and factory functions for dynamic agent creation.
import { AgentRegistry } from '@helix-agents/runtime-temporal';
const registry = new AgentRegistry();
// Static registration
registry.register(ResearchAgent);
registry.register(SummarizerAgent);
// Factory registration (for agents needing runtime deps)
registry.registerFactory<Env>('dynamic-agent', (ctx) =>
createAgent({ apiKey: ctx.env.API_KEY, userId: ctx.userId })
);
// Resolve (handles both static and factory)
const agent = registry.resolve('dynamic-agent', {
env,
sessionId: 'session-123',
runId: 'run-456',
userId: 'user-789',
});
// Get a static registration only (a factory-registered type throws `framework_agent_not_found`)
const agent = registry.get('research-assistant');
// Check if registered
const exists = registry.has('research-assistant');
// Remove registration
registry.unregister('research-assistant');
// Get all registered types
const types = registry.getRegisteredTypes();
// Clear all registrations (for testing)
registry.clear();
// Use default global registry
import { defaultAgentRegistry } from '@helix-agents/runtime-temporal';AgentFactoryContext
Context passed to factory functions:
interface AgentFactoryContext<TEnv = unknown> {
/** Environment bindings (e.g., API keys, database connections) */
env: TEnv;
/** Current session identifier */
sessionId: string;
/** Current run identifier */
runId: string;
/** Optional user identifier */
userId?: string;
}AgentFactory
Factory function type:
type AgentFactory<TEnv = unknown> = (
context: AgentFactoryContext<TEnv>
) => AgentConfig<z.ZodType, z.ZodType>;AgentResolverInterface
Interface implemented by AgentRegistry:
interface AgentResolverInterface<TEnv = unknown> {
/**
* Resolve an agent configuration by type.
* @throws AgentResolutionError (`framework_agent_not_found`) if the agent type is not registered
*/
resolve(agentType: string, context: AgentFactoryContext<TEnv>): AgentConfig<z.ZodType, z.ZodType>;
/**
* Check if an agent type is registered.
*/
has(agentType: string): boolean;
}Unknown agent types
get() and resolve() throw core AgentResolutionError.notFound for a type that is not registered: code: 'framework_agent_not_found', non-retryable, with agentType and availableTypes. resolve() lists every registered type; get() returns only static registrations, so it lists only those, and for a factory-registered type its message says so (Agent type '<type>' is not available: it is registered as a factory, …) with the same code. The package no longer exports an AgentNotFoundError class.
resolve() stays synchronous: activities resolve through your synchronous AgentResolver.resolveAgent(type), so a factory must return the config, not a promise.
import { isAgentNotFoundError } from '@helix-agents/core';
try {
const agent = registry.resolve('unknown', context);
} catch (error) {
if (isAgentNotFoundError(error)) {
console.error(`Unknown agent type: ${error.agentType}`);
console.error(`Available types: ${error.availableTypes?.join(', ')}`);
}
}isAgentNotFoundError matches by name and code, so it recognises an error from another copy of core. A custom resolveAgent should throw AgentResolutionError.notFound(type, available) for an unknown type: the completion-support checks treat only that as "no refusal" and let any other resolver error fail the activity. A miss that escapes an activity still loses its code at the activity boundary (Temporal's failure conversion; DI-04). See the DO drive pipeline upgrade guide.
Registry Methods Summary
| Method | Description |
|---|---|
register(config) | Register a static agent configuration |
registerFactory(type, factory) | Register a factory function |
resolve(type, context) | Resolve agent (factory-first, then static) |
get(type) | Get a static registration only |
has(type) | Check if agent type is registered |
unregister(type) | Remove an agent registration |
getRegisteredTypes() | Get all registered agent type names |
clear() | Remove all registrations (for testing) |
GenericActivities
Activity implementations for Temporal workflows. No decorators — wrap these with platform-specific activity decorators.
import { GenericActivities } from '@helix-agents/runtime-temporal';
const activities = new GenericActivities({
stateStore, // SessionStateStore
streamManager, // StreamManager
llmAdapter, // LLMAdapter
agentResolver: {
// AgentResolverInterface (typically wraps AgentRegistry)
resolveAgent: (type) => registry.resolve(type, ctx),
},
logger, // Optional: Logger
usageStore, // Optional: UsageTrackingStore
});Optional tuning, mostly for tests:
| Option | Default | Description |
|---|---|---|
ownerProbe | describe() via the activity's client | Answers whether a live run's owner execution (from its owner token) is still running, for the run-start owner guard. |
ownerWait | 30 s total, polling from 100 ms | Bounds an entry's wait for a suspending owner workflow to close ({ timeoutMs?, initialIntervalMs?, ... }). |
workflowProbe | the activity's client | Describes a workflow by id; used to recognise a stalled companion continuation. |
llmAttemptFenceIntervalMs | 2 000 | How often a running runLLMStep attempt heartbeats and checks the stream for a newer attempt of its step. |
The probe types (TemporalOwnerProbe, WorkflowIdProbe, OwnerWaitOptions) are not exported from the package entry point.
v7 activity surface
agentWorkflow proxies these methods via wf.proxyActivities<GenericActivities>. When wiring a worker, bind every method below that agentWorkflow calls — the workflow looks them up by name, and an unregistered one fails the run at its first call. Binding every method on the GenericActivities prototype (as examples/research-assistant-temporal does) keeps the registration in step with the workflow. loadStateForResume, evaluateApprovalGate and persistTerminalState are public methods agentWorkflow does not call.
| Activity | Purpose |
|---|---|
initializeAgentState | Entry of a fresh turn, a child or a companion continuation: creates a missing session row (write-once), commits the turn's input rows as its run-start commit, then writes the turn's state and fires onAgentStart / onMessage. Also the hook half of a retry (after startRunCommit), which keeps the forced phase the retry restored. Before the run-start commit it refuses an outputSchema agent the worker's adapter cannot finish (framework_completion_strategy_unavailable / framework_completion_schema_unsupported, a non-retryable ApplicationFailure); the check is skipped once the entry's run exists (a retry's hook half, an activity retry). |
applyResultsAndReload | Resume entry point. Drains submitted client-tool / approval results and completed children, then reloads session state. |
refreshState | Reload session state (cache-bust before a step). |
loadStateForResume | Load session state without bumping cache invariants. Not called by agentWorkflow. |
runLLMStep | Execute one LLM step: load the complete history in the activity (loadLLMHistory), build messages, invoke adapter, stage assistant message + tool calls. Returns a delta-only RunLLMStepResult (nextState.messages: [], newMessages, baseMessageCount); a history failure is an ApplicationFailure (non-retryable for a count mismatch, retryable for a store throw) (state_history_incomplete). Resolves the model's capabilities on every call and a forced call's completion strategy (forced_tool / native_output), and runs the prompt-prefix check before the attempt's step_start. The result carries the forced call's outcome (forcedTransition, decided by core resolveForcedCallTransition), an error step's classified errorDetail, and resultVersion (absent on a result from an activity older than @helix-agents/runtime-temporal 4.0, which the workflow classifies the legacy way). A forced call's retryable or unclassified provider error is rethrown as a retryable ApplicationFailure (type: the error's code, or helix_llm_step_retryable when it has none), so the activity retry repeats the same forced attempt. The adapter's held onError report is written as the step's last action, and only for a terminal failure. |
executeToolActivity | Run one regular server tool of a step (the workflow dispatches one per tool with Promise.all): seeds from the committed pre-step state, fires beforeTool / onStateChange and STAGES the tool's state writes for the step commit. |
fireDeferredToolHooks | Fire the step's onMessage / afterTool hooks after its tool results are appended, and stage their state writes under the step's stepId. |
commitStep | Promote staged step output (assistant message + tool results) to durable state. |
commitSuspendedStep | HITL boundary. Persist a suspended step's durable state (pending client tool calls / approval gates / awaiting children). |
runPhase2FinishWith | Execute deferred finishWith* tools after the main step. |
persistForcedCompletion | Persist a forced-completion phase transition the workflow computed (a state commit; the session stays active). |
evaluateApprovalGate | Evaluate an approval-gated tool call against its policy (always / hook / external). Not called by agentWorkflow. |
executeCompanionToolCall | Run companion tools (companion__spawnAgent, companion__sendMessage, …). |
executeRemoteSubAgentCall | Execute a createRemoteSubAgentTool call with crash recovery + stream proxying. |
recordSubAgentResult | Persist a finished local sub-agent's output back to the parent. |
foldSubAgentResults | Fire the parent's afterSubAgent/onMessage hooks for a settled wave (ephemeral, remote, AND respawn) and stage the composed customState fold under the step's stepId (or a step-respawn-* override). |
recordSubSessionResult | Persist a finished sub-session into the parent's subSessionRefs (message append, ref update, usage). Returns the appended message + index so the afterSubAgent/onMessage fold runs via foldSubAgentResults — it no longer fires the fold itself. |
addRemoteSubSessionRef | Append a remote sub-agent's SubSessionRef to the parent. |
markPersistentChildCompleted | Patch a persistent child's SubSessionRef to a terminal status. |
commitChildRespawnResults | Resume bookkeeping for sub-agents that were re-spawned after a parent resume; also promotes the staged step-respawn-* afterSubAgent fold via promoteStaging. |
loadChildState | Load a child session's state for the parent's wait-for-child loop. |
appendMessages | Append messages to a session (used by resume drain) as an append commit fenced on the executing run. |
startRunCommit | Entry of a resume or a retry: the run-start commit (with a retry's or a from_checkpoint resume's rewind), then for the winner only: claim the stream, make it live, run_started, the entry's stream_resync. A retry restores the target's customState, stepCount and forced phase. Activity-retry safe; a lost entry fails non-retryably (state_run_start_conflict). Before the commit it refuses an outputSchema agent the worker's adapter cannot finish, like initializeAgentState (skipped once the run exists). |
commitTerminalState | The durable half of a terminal: the terminal hooks (once per run), then the run's state commit (commitState). Returns the committed outcome. |
finalizeTerminalRun | The post-commit half of a terminal: the terminal run status, then the stream end / failure (root sessions only). Idempotent; runs on a long retry budget. |
suspendRun | Record a resumed run's suspended_* status when it re-suspends without an LLM step. |
pauseSuspendedStream | Pause the stream of a suspended root run, after its suspended_* status write. |
notifySuperseded | Fire onAgentSuperseded for a run the workflow stopped as superseded. Writes nothing. |
abandonCompanionContinuation | Roll back a companion continuation whose child workflow could not start. |
readStopActivity | Read the run's durable stop record (readStop(sessionId, runId)): at the top of every main-loop iteration and on a resume after applyResultsAndReload. Run-scoped; never cleared (WA MR2). |
agentDeclaresPersistentChildren | Whether the agent declares persistent children; gates the per-turn completion notifier. |
injectPendingCompletionNotifications | Append a hidden completion message for each terminal persistent child not yet delivered, before runLLMStep. |
persistTerminalState | Not called by agentWorkflow (it calls the two halves itself). commitTerminalState then finalizeTerminalRun: the terminal hooks, the run's state commit (terminal status, output / error), the run status, the stream end. A superseded run writes nothing and throws RunSupersededError. |
emitChunk | Emit a single StreamChunk to the stream. |
emitSuspensionMarker | Emit a suspension-boundary chunk (UI signals the run is paused). |
The legacy v6 activities (executeAgentStep, executeToolCall, registerSubAgents, markAgentFailed, markAgentAborted, endAgentStream, failAgentStream, checkExistingState, updateAgentStatus) have been removed. See Quick start workflow file for the canonical wiring.
agentWorkflow
The v7 stateless-suspension workflow body. Imported from the workflow-safe entry point and wrapped by your registered Temporal workflow.
// In your workflow file (no Node built-ins allowed)
import { agentWorkflow as runAgentWorkflow } from '@helix-agents/runtime-temporal/workflow';
import type { AgentWorkflowInput, AgentWorkflowResult } from '@helix-agents/runtime-temporal';
export async function agentWorkflow(input: AgentWorkflowInput): Promise<AgentWorkflowResult> {
return runAgentWorkflow(input);
}agentWorkflow proxies activities internally via wf.proxyActivities<GenericActivities> — there is no WorkflowActivities shim and no runAgentWorkflow(input, activities, options) two-arg form. The workflow exits on every HITL boundary, returning a suspended_* status that the executor observes. resume() starts a new workflow instance with id ${prefix}__${agentType}__${sessionId}__resume-${runId} and retry() one with id ${prefix}__${agentType}__${sessionId}__retry__${runId}, where runId is the run the executor minted for that call and passes in the workflow input. The executor finds the live workflow from the current run's owner token (${workflowId}:${workflowRunId}), never by rebuilding an id from a counter.
The post-commit terminal finalization (run status, stream end) runs on a 3-minute retry budget (workflow input terminalFinalizeTimeoutMs; the executor does not set it). Past it, the workflow closes with the committed outcome, and the next getHandle() / resume() finishes the run.
TemporalAgentExecutor
Executor that starts and manages Temporal workflows. Same API as JSAgentExecutor.
import { TemporalAgentExecutor } from '@helix-agents/runtime-temporal';
const executor = new TemporalAgentExecutor({
client, // TemporalClient (see below)
workflowName, // Registered workflow name
taskQueue, // Task queue name
stateStore, // SessionStateStore
streamManager, // StreamManager
logger, // Optional
entryOutcomeTimeoutMs, // Optional, default 120 000
submitToolResultSchema, // Optional: the submit size limit this executor owns (DI-51)
});
**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](/guide/client-executed-tools#the-submit-size-limit).
// Execute agent
const handle = await executor.execute(MyAgent, 'Hello');
// Get existing handle
const handle = await executor.getHandle(MyAgent, 'session-123');
// Check if resumable
const result = await executor.canResume(MyAgent, 'session-123');TemporalClient
The client abstraction the executor uses. describeWorkflow is required (breaking):
interface TemporalClient {
startWorkflow<T>(workflowName: string, options: StartWorkflowOptions): Promise<WorkflowHandle<T>>;
/** `runId` pins ONE execution of a reused workflow id; adapters must forward it. */
getHandle<T>(workflowId: string, runId?: string): WorkflowHandle<T>;
/**
* Describe ONE execution (`client.workflow.getHandle(id, runId).describe()`):
* its status name ('RUNNING', 'COMPLETED', ...), or null when the server does
* not know it (WorkflowNotFoundError).
*/
describeWorkflow(workflowId: string, runId: string): Promise<{ status: string } | null>;
}It backs the synchronous AgentAlreadyRunningError for an entry into a session whose live run's workflow is still running, and the recovery of a run whose workflow closed between its terminal commit and its terminal status.
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 and the entry fail-safe
execute(), resume() and retry() mint the run's runId and start a workflow whose first activity, startRunCommit, writes the run-start commit with owner token ${workflowId}:${workflowRunId}. The call returns once that entry has an outcome:
- Won: the run committed and wrote to its stream. The handle is returned.
- Lost: the typed loser error.
RunStartConflictError(namedAgentAlreadyRunningError) when another run is driving;RunStartRejectedErrorforentry_status_changedoridempotency_mismatch. The loser wrote nothing. The commit also gates on the status of the run the builder read (expected_run_status_changed, typed as the C3 conflict) and on the session status its plan read (entry_status_changed); see Entry gates. - No outcome within
entryOutcomeTimeoutMs(default 120 000 ms; for example no worker polls the task queue): the executor calls the store'sabandonRunStart(a tombstone, so a late commit is refused withRunStartRejectedError('entry_abandoned')) and terminates the workflow. A run that committed meanwhile is returned. Otherwise the call throws core'sRunStartAbandonedError(transport_error, retryable once the workflow was terminated), orRunStartOutcomeUnknownErrorwhen the tombstone write failed (non-retryable: read the session before retrying, the input may have been delivered). - Refused: the entry activity refused an
outputSchemaagent its worker's adapter cannot finish (framework_completion_strategy_unavailable/framework_completion_schema_unsupported). The call throws that typed, non-retryableHelixError. A brand-new session (one with no run yet) is markedfailedand the stream this call made live is failed; an existing session is untouched. See Temporal → Completion strategies.
The winner of a resume or retry entry bumps resumeCount inside its run-start commit (a child re-spawn does not).
Superseded runs
A run that a newer entry superseded stops at its next fenced write with RunSupersededError (state_run_superseded), thrown as a non-retryable ApplicationFailure. Its result is status: 'superseded'; onAgentSuperseded fires (notifySuperseded) 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). A detached child's live chunks that arrive after the parent's turn ended are dropped.
Companion continuation marker
When a consult reopens a completed persistent companion, the child session's metadata carries a pending-continuation marker under __helix.companionContinue until the child's continuation run commits (or is rolled back). It is visible to listSessions metadata filters.
AgentExecutionHandle
Handle returned by execute() for interacting with a running agent.
Properties
handle.sessionId; // Unique run identifier
handle.streamId; // Stream identifierstream
Get a stream of events from the execution.
const stream = await handle.stream();
for await (const chunk of stream) {
switch (chunk.type) {
case 'text_delta':
process.stdout.write(chunk.delta);
break;
case 'tool_start':
console.log(`Tool: ${chunk.toolName}`);
break;
case 'run_interrupted':
console.log(`Interrupted: ${chunk.reason}`);
break;
}
}result
Wait for the execution to complete and get the result.
const result = await handle.result();
if (result.status === 'completed') {
console.log('Output:', result.output);
} else if (result.status === 'interrupted') {
console.log('Interrupted:', result.interruptReason);
}abort
Cancel the execution. This is a HARD stop: it records an abort stop on the handle's run, signals the workflow and cancels it (cancel(), no terminate()), then resolves once the stop is durable. It does not wait for the workflow to close. The cancelled workflow commits its own failed / framework_cancelled terminal (its terminal runs non-cancellable), and result() reports it. A run that nothing executes (a suspended run, or a closed workflow) is ended by the stop at once.
await handle.abort('User requested cancellation');interrupt
Interrupt execution for later resumption. This is a SOFT stop — the agent can be resumed.
await handle.interrupt('user_requested');
// Once the workflow has observed it
const result = await handle.result();
console.log(result.status); // 'interrupted'interrupt() records the stop on the handle's run (core stopRun), signals the workflow while it is open, and returns once the stop is durable (a suspended run whose workflow closed is ended by the call itself). The workflow reads the run's stop record at its next observation point (the top of the next main-loop iteration, or on a resume after the drain), persists interrupted and exits, so the status reads interrupted only after that.
canResume
Check if execution can be resumed.
const { canResume, reason } = await handle.canResume();
if (canResume) {
const resumed = await handle.resume();
}resume
Resume interrupted, paused, or HITL-suspended execution. Returns a new handle for the resumed execution. Resume starts a new workflow instance with workflow ID suffix __resume-<runId> (the run the call minted) and mode: 'resume'; the workflow body's first command is the startRunCommit activity (the guarded run-start commit), then applyResultsAndReload drains the pending results.
continue / with_message on an active session with no pending calls (its workflow is running) throws AgentAlreadyRunningError before any write; it used to start a second workflow.
Concurrent resumes (any mode) give at most one run: the rewind of a from_checkpoint resume is part of the resume workflow's run-start commit (StartRun.truncateTo, planned by planRewindToCheckpoint), so the truncate, the re-stamped pending calls and the new run land in ONE commit, and of concurrent entries exactly one commit wins (a CAS on the current run); each loser writes nothing and gets AgentAlreadyRunningError / RunStartRejectedError. The executor resolves the checkpoint and runs assertRewindable (while the owner workflow is still running) before any write. A completed or failed session is rewound too.
// Continue from where it stopped
const newHandle = await handle.resume();
// Resume with a new message
const newHandle = await handle.resume({
mode: 'with_message',
message: 'Continue with this context',
});
// Time-travel to a specific checkpoint
const newHandle = await handle.resume({
mode: 'from_checkpoint',
checkpointId: 'cpv1-session-123-s5-...',
});An outputSchema agent whose model the worker's adapter cannot finish is refused by startRunCommit before the run-start commit: resume() throws the typed, non-retryable HelixError (framework_completion_strategy_unavailable / framework_completion_schema_unsupported) and the session is left as it was, so the same resume() succeeds once the adapter declares the model.
The resumed handle's failed result() can precede the stored failure (CP-80)
The returned handle settles result() on the first error chunk, which the LLM activity writes before the workflow persists failed. After a terminal LLM error, result() can report failed while the session still reads active, and an immediate retry() throws Cannot retry: agent status is 'running'. Observed live with no injected delay. See CP-80 in the conformance findings.
retry
Retry a failed execution. Returns a new handle. Retry starts a new workflow instance with workflow ID suffix __retry__<runId> and mode: 'retry'; its startRunCommit rewinds to the retry target (core planRetry, or checkpointId) and restores the target's customState, stepCount and forced-completion phase, so a retry into a pending forced phase continues the restored attempt.
const retried = await handle.retry();
// after committed steps the retry needs a message
const retriedWithMessage = await handle.retry({ message: 'Try again' });Like resume(), a retry of an outputSchema agent whose model the worker's adapter cannot finish throws the typed, non-retryable HelixError (framework_completion_strategy_unavailable / framework_completion_schema_unsupported) before its run-start commit, and the session is left as it was. See Temporal → retry().
getState
Get current agent state.
const state = await handle.getState();
console.log('Status:', state.status);
console.log('Step count:', state.stepCount);send
Continue the conversation after completion. Returns a new handle.
const handle1 = await executor.execute(agent, 'Hello');
await handle1.result();
const handle2 = await handle1.send('Tell me more');
const result = await handle2.result();DTOs
Type-safe schemas for workflow and activity inputs/outputs.
Session vs Run Identifiers
sessionId: Primary key for state storage. A session contains all messages, state, and checkpoints.runId: Identifies a specific workflow execution. Multiple runs can occur within one session.
In Temporal, the workflow ID is tied to sessionId for session continuity. The runId is execution metadata for tracing.
Workflow DTOs
import {
agentWorkflowInputSchema,
agentWorkflowResultSchema,
type AgentWorkflowInput,
type AgentWorkflowResult,
} from '@helix-agents/runtime-temporal';
interface AgentWorkflowInput {
agentType: string;
sessionId: string;
streamId?: string;
newMessages?: Array<{ role: 'user' | 'assistant' | 'system'; content?: string /* ... */ }>;
initialState?: Record<string, unknown>;
parentSessionId?: string;
parentStreamId?: string;
userId?: string;
tags?: string[];
metadata?: Record<string, string>;
branch?: { fromSessionId: string; checkpointId?: string; messageIndex?: number }; // honoured since CP-33; a foreign checkpointId throws state_checkpoint_foreign from execute()
files?: Array<{ data: string; mediaType: string; filename?: string }>;
maxSteps?: number;
clientToolTimeoutMs?: number;
workflowIdPrefix?: string;
runId?: string;
/**
* CP-103 A1: this child workflow resumes an INTERRUPTED persistent companion
* for its parent's `companion__sendMessage`. Its entry
* (`initializeAgentState`) passes `StartRun.resumedCompanionRef`, so its
* run-start commit turns the parent's ref `running` (+ `completionDelivered:
* false`); a refused or lost entry writes no ref. An ungated payload field.
*/
companionResume?: boolean;
/**
* v7 entry mode (A.2 rewrite). `'fresh'` runs `initializeAgentState`
* and starts the loop from scratch; `'resume'` runs
* `applyResultsAndReload` to drain submissions before re-entering
* the loop. Set by `TemporalAgentExecutor.resume()`.
*/
mode?: 'fresh' | 'resume';
/**
* Resume counter of a CHILD re-spawn (the parent workflow's
* `__resume-<n>` child id). Informational. `executor.resume()` /
* `retry()` do not set it: their ids derive from the minted `runId`.
*/
resumeCount?: number;
/** Budget (ms) for the post-commit terminal finalization. Default 3 minutes. */
terminalFinalizeTimeoutMs?: number;
}
interface AgentWorkflowResult {
sessionId: string;
status:
| 'completed'
| 'failed'
| 'cancelled'
| 'interrupted'
// v7 stateless-suspension statuses (A.2):
| 'suspended_client_tool'
| 'suspended_awaiting_children'
| 'suspended_step_partial'
// a newer entry superseded this run (it wrote nothing more):
| 'superseded';
output?: unknown;
error?: string;
checkpointId?: string;
suspended?: {
/* discriminated union per status */
};
}Activity DTOs
import {
initializeAgentInputSchema,
executeAgentStepInputSchema,
executeToolCallInputSchema,
agentStepResultSchema,
toolCallResultSchema,
commitStepInputSchema,
// ... and more
} from '@helix-agents/runtime-temporal';Key fields added for error classification:
agentStepResultSchema()— IncludeserrorCode: z.string().optional()for propagating error classification from activities to workflow.failAgentStreamInputSchema()— IncludeserrorCode: z.string().optional()for writing classified error chunks when failing a stream.
Signal DTOs
v7 deleted the v6/early-v7 signals (submitToolResult, interruptAgent, runResumed, childSuspended, childWoke). Submissions are durable state, resume creates a fresh workflow instance with a __resume-<runId> suffix, and child completion is observed via state-store reads. The remaining signal-related exports are pause/resume/abort scaffolding for backward-compat:
import {
pauseAgentSignalSchema,
resumeAgentSignalSchema,
abortAgentSignalSchema,
} from '@helix-agents/runtime-temporal';
// The only signal the workflow body still handles ('agent_interrupt'), sent by
// the executor's `handle.interrupt()`. Exported from the workflow-safe entry.
import { INTERRUPT_SIGNAL_NAME } from '@helix-agents/runtime-temporal/workflow';Cancellation & Heartbeats
The runtime-temporal package does not export bespoke cancellation or heartbeat helpers. Use Temporal's native primitives directly inside activities:
import { Context, heartbeat } from '@temporalio/activity';
export async function longRunningActivity(input: ActivityInput) {
const cancelSignal = Context.current().cancellationSignal;
// Periodic heartbeats so Temporal knows the activity is alive
const interval = setInterval(() => heartbeat(), 5_000);
try {
await doWork({ signal: cancelSignal });
} finally {
clearInterval(interval);
}
}Temporal automatically cancels long-running activities via heartbeatTimeout configuration on proxyActivities. No additional helpers are needed from @helix-agents/runtime-temporal.
Complete Example
activities.ts
import { GenericActivities, AgentRegistry } from '@helix-agents/runtime-temporal';
import { InMemoryStateStore, InMemoryStreamManager } from '@helix-agents/store-memory';
import { VercelAIAdapter } from '@helix-agents/llm-vercel';
import { MyAgent } from './agent.js';
export function createActivities() {
const registry = new AgentRegistry();
registry.register(MyAgent);
const activities = new GenericActivities({
stateStore: new InMemoryStateStore(),
streamManager: new InMemoryStreamManager(),
llmAdapter: new VercelAIAdapter(),
agentResolver: {
resolveAgent: (type) => registry.resolve(type, { env: undefined, sessionId: '', runId: '' }),
},
});
return {
initializeAgentState: activities.initializeAgentState.bind(activities),
applyResultsAndReload: activities.applyResultsAndReload.bind(activities),
refreshState: activities.refreshState.bind(activities),
runLLMStep: activities.runLLMStep.bind(activities),
commitStep: activities.commitStep.bind(activities),
commitSuspendedStep: activities.commitSuspendedStep.bind(activities),
runPhase2FinishWith: activities.runPhase2FinishWith.bind(activities),
evaluateApprovalGate: activities.evaluateApprovalGate.bind(activities),
executeCompanionToolCall: activities.executeCompanionToolCall.bind(activities),
executeRemoteSubAgentCall: activities.executeRemoteSubAgentCall.bind(activities),
// Required for outputSchema agents: without it, the workflow throws
// "activity not registered" the first time forced completion fires.
persistForcedCompletion: activities.persistForcedCompletion.bind(activities),
recordSubAgentResult: activities.recordSubAgentResult.bind(activities),
foldSubAgentResults: activities.foldSubAgentResults.bind(activities),
recordSubSessionResult: activities.recordSubSessionResult.bind(activities),
addRemoteSubSessionRef: activities.addRemoteSubSessionRef.bind(activities),
markPersistentChildCompleted: activities.markPersistentChildCompleted.bind(activities),
commitChildRespawnResults: activities.commitChildRespawnResults.bind(activities),
loadChildState: activities.loadChildState.bind(activities),
appendMessages: activities.appendMessages.bind(activities),
readStopActivity: activities.readStopActivity.bind(activities),
persistTerminalState: activities.persistTerminalState.bind(activities),
emitChunk: activities.emitChunk.bind(activities),
emitSuspensionMarker: activities.emitSuspensionMarker.bind(activities),
};
}Quick start workflow file
// workflows.ts — must be bundled separately (no Node built-ins).
import { agentWorkflow as runAgentWorkflow } from '@helix-agents/runtime-temporal/workflow';
import type { AgentWorkflowInput, AgentWorkflowResult } from '@helix-agents/runtime-temporal';
export async function agentWorkflow(input: AgentWorkflowInput): Promise<AgentWorkflowResult> {
return runAgentWorkflow(input);
}worker.ts
import { Worker, bundleWorkflowCode, NativeConnection } from '@temporalio/worker';
import { createActivities } from './activities.js';
async function main() {
const connection = await NativeConnection.connect({ address: 'localhost:7233' });
const workflowBundle = await bundleWorkflowCode({
workflowsPath: './workflows.ts',
});
const worker = await Worker.create({
connection,
taskQueue: 'my-agents',
workflowBundle,
activities: createActivities(),
});
await worker.run();
}See examples/research-assistant-temporal/src/{worker,client,workflows,activities}.ts for the canonical v7 wiring.
Interrupt and Resume
The Temporal runtime provides durable interrupt/resume via the v7 stateless-suspension model.
v7 Stateless Suspension Model
When the agent hits a HITL boundary (client-executed tool, approval gate, awaiting sub-agent children), the workflow exits with a suspended status:
| Status | Meaning |
|---|---|
'suspended_client_tool' | A client-executed tool is awaiting submitToolResult. |
'suspended_awaiting_children' | A sub-agent child is still running (or itself suspended). |
'suspended_step_partial' | The step ran partially before suspending (e.g. mid-batch tools). |
Submissions are durable state stored on the session. When the caller calls executor.resume():
- The executor mints the resume's
runId. - A new workflow instance is started with id
${prefix}__${agentType}__${sessionId}__resume-${runId}andmode: 'resume'. - Its run-start commit decides the single winner; the winner bumps
resumeCountinside that commit. - The workflow body runs
applyResultsAndReload, which drains submitted client-tool results, completed children, and approval-gate decisions into the agent's message history. - The main loop re-enters from the reloaded state.
There are no submitToolResult / runResumed / childSuspended / childWoke signals — those were deleted in A.2 Task 3.3. Resume composes via fresh workflow instances; HTTP callers go through POST /submit-tool-result (handled by the executor's durable submission state) and then POST /resume.
Cross-process interrupt
handle.interrupt() records the stop on the handle's run (core stopRun), then sends the INTERRUPT_SIGNAL_NAME signal, carrying the StopRequest, to the run's open workflow execution. The workflow reads the run's durable stop record through readStopActivity, so a stop recorded from another process or replica needs no Temporal signal. It is read at the top of every main-loop iteration (before the step's LLM call) and on a resume right after applyResultsAndReload; never while a step's LLM call, tools or sub-agent children are in flight.
Resuming after process restart
Temporal provides built-in durability. After worker restart, reconnect via the executor:
import { TemporalAgentExecutor } from '@helix-agents/runtime-temporal';
import { Client, Connection } from '@temporalio/client';
const connection = await Connection.connect({ address: 'localhost:7233' });
const client = new Client({ connection });
const executor = new TemporalAgentExecutor({
client,
taskQueue: 'my-agents',
stateStore,
streamManager,
});
// Reconnect to the existing session and resume
const handle = await executor.getHandle(MyAgent, savedSessionId);
if (handle) {
const { canResume } = await handle.canResume();
if (canResume) {
const resumed = await handle.resume();
const result = await resumed.result();
}
}Workflow execution semantics
| Action | Workflow Behavior |
|---|---|
interrupt() | Records the stop on the run and signals the workflow → observed before the next step's LLM call (or at a resume's entry) → persists and exits as interrupted. |
resume() | Starts new workflow instance __resume-<runId> with mode: 'resume'; applyResultsAndReload drains state. |
| HITL suspension | Workflow exits with suspended_* status; no signals required. |
abort() | Records an abort stop, signals and cancels the workflow, and resolves once the stop is durable (WA MR2 F4-C m4). The cancelled workflow commits its own terminal (failed, framework_cancelled), because its terminal runs non-cancellable. |
retry() | Its run-start commit pairs the kept prefix's trailing unpaired calls with not-executed results (C5), ahead of its input (F4-C P1). |
result() never reports a terminal that did not commit (WA MR2 F4-C S3). A workflow whose K5 does not land, on its terminal path and again in its top-level catch, fails with the ApplicationFailure type temporal_terminal_not_committed (TERMINAL_NOT_COMMITTED_FAILURE_TYPE, non-retryable; details[0] is { errorDetail? }). Every handle's result() then reconciles the run from the store, because its owner is closed:
- an ended run, or one whose own K5 landed, reports that outcome;
- a recorded stop commits the stop's terminal (
commitStopTerminal); - anything else fails with the carried detail (
failCommittedRun).
A reconciliation that does not land rejects result() with its error. Nothing is cached, so the next call retries. A handle whose workflow ended suspended_* over a recorded stop settles the run by that stop and reports the stopped outcome.
A companion terminateChild's stopPersistentChild resolves once the child's stop is recorded, even when a later read of the stop path fails, and when the child's session is gone. It throws only when the stop was not recorded, which is core's StopPersistentChild contract (F4-A S1).