Skip to content

Upgrade: a retryable model error is retried (MA-26) ​

A model call that fails with an error classified retryable: true no longer fails the run on runtime-js and the CF Durable Object: the step calls the model again, as a new attempt of the same step, under a retry budget. CF Workflows already retried such errors through step.do; it now follows the same rule and stops retrying an unclassified error. Temporal's ordinary calls and DBOS are unchanged (they still fail the run; FU-E5-LLM-ERROR-STEP-NOT-RETRIED).

It is a major release of @helix-agents/runtime-js and @helix-agents/runtime-cloudflare (their default behaviour changes: runtime-js and the DO now retry; CF Workflows stops retrying an unclassified error) and a minor release of @helix-agents/core (new exports and an option). Nothing in a store, a migration or a deploy order changes.

Guide: Retrying a failed model call.

What changes ​

runtime-js and the CF Durable Object retry a retryable model error ​

A model call whose error is retryable: true (core isRetryableLLMStepError) is called again, whether the adapter returned it as an error step or threw it. With @helix-agents/llm-vercel that is, in practice, a connection drop after output started (provider_error) and a retryable 429 / 5xx when you set llmConfig.maxRetries: 0; failures the adapter already retried end retryable: false and are not retried again.

The default budget is CF Workflows' default: 3 retries after the first call, waiting 1 s, 2 s and 4 s. Set it, or turn it off, on the executor or the DO config:

typescript
// runtime-js
new JSAgentExecutor(stateStore, streamManager, llmAdapter, {
  llmStepRetry: { limit: 0, delayMs: 0 }, // off: one model call per step, as before
});

// CF Durable Object
createAgentServer({
  llmAdapter,
  agents,
  llmStepRetry: { limit: 5, delayMs: 500, backoff: 'linear' },
});

What you may notice:

  • More model calls, each billed and traced. beforeLLMCall and afterLLMCall fire for every attempt, and each attempt's usage is recorded. A test whose mock adapter scripts one failing response per step now sees the step call it again: script the retry, or set llmStepRetry: { limit: 0, delayMs: 0 }.
  • The run fails later, with the last attempt's error. A persistent retryable failure fails the run once the budget is spent (by default after about 7 s of waits), with the last attempt's errorDetail. One error chunk is written, at the end.
  • A stop during the wait ends the run as a stop does (framework_cancelled for a hard abort, interrupted for a soft interrupt), including a stop recorded by another process: the wait re-reads the run's stop record before the next attempt, so no further model call is billed.
  • A forced-completion call that fails retryably is retried without consuming a forced attempt. It used to fail the run at once.

The stream: a retried attempt is superseded, as on CF Workflows ​

Each retry streams under a new attemptId, with its own step_start and the same step number. The failed attempt's partial output stays in the stream (it is append-only), scoped to its attempt; nothing of it is committed, the snapshot never holds it, and a replay drops it. The step's step_committed.committedAttemptId names the winning attempt.

On the client, the switch is a data-attempt-superseded resync trigger. A client with resync, such as useHelixChat({ resync }) or useAutoResync, re-anchors and shows only the retry. A stock useChat with no resync shows the failed attempt's partial text followed by the retry's until the page is reloaded (CF Workflows behaves the same way today). If your client is a stock useChat, turn resync on, or turn the retry off.

CF Workflows no longer retries an unclassified error (DI-35) ​

step.do used to retry an LLM step whose error was retryable: true or unclassified (no code). An unclassified error may be deterministic (a bug in a hook, an invalid skills configuration), and retrying it only repeated the failure three times. Now only retryable: true is retried, on CF Workflows as everywhere; an unclassified error fails the run at once. If a custom adapter of yours throws plain Errors for transient provider failures, classify them: throw a HelixError with retryable: true (or attach an ErrorDetail), and they are retried.

The rule covers the whole LLM step: an error classified retryable: false in the step's own work before the model call (a persistent history mismatch) is no longer repeated by step.do either. Before the model call, an unclassified error from the step's I/O (its reads, its step_start write, a beforeLLMCall hook's state save) is an unknown, possibly transient, fault and keeps step.do's retry; one from the step's deterministic work (its tool set and prompt, your systemPrompt function) or from a hook fails the run at once.

New exports (@helix-agents/core) ​

  • LLMStepRetryPolicy ({ limit, delayMs, backoff?: 'constant' | 'linear' | 'exponential' }) and DEFAULT_LLM_STEP_RETRY.
  • isRetryableLLMStepError(result) and isRetryableStepErrorDetail(detail): the one retry rule.
  • llmStepRetryDelayMs(policy, retry).
  • StepIterationDeps.llmStepRetry ({ policy, newAttempt, sleep? }): a custom loop built on runStepIteration opts in by passing it, and mints a fresh attempt (its attemptId and step_start) in newAttempt().

Released under the MIT License.