Skip to content

Upgrade: user-safe error bodies, lapsed client calls (late-answers MR 2) ​

This release changes what a Helix HTTP error body's error field and a stream error's errorText hold, and makes the browser skip a client-tool call whose deadline lapsed. It is a major release of @helix-agents/core, @helix-agents/ai-sdk, @helix-agents/runtime-cloudflare and @helix-agents/agent-server (their error bodies change on the wire).

Design: 2026-10-26-client-tool-late-answers-design.md (§3, §4).

Deploying ​

Deploy the client and the server together. A new server's bodies put the developer text in detail; an old client that logs error logs the user sentence instead. A new client reads detail and falls back to error.

error is user-safe; detail is the developer text (SDK-5) ​

Every service deploys together, across services too. The remote-agent protocol's error envelope (RemoteAgentErrorResponse) splits the same way, and a parent rebuilds a remote child's failure from its detail. A child service on this version behind a parent on an older one hands the parent's MODEL the user sentence instead of the child's failure. Upgrade producers (agent-server, CF DO /subagent routes) and consumers (every parent) together.

Every typed error body (core typedErrorBody / typedErrorResponse: handleChatStream, the chat route helpers, the CF Durable Object's routes, @helix-agents/agent-server) is now:

ts
{
  error: string;   // userMessageFor(code): a short user-safe sentence. Render this.
  code: string;
  detail?: string; // the error's own message, scrubbed. Log this; never render it.
  cause?: string;  // unchanged
  // …the other typed extras, unchanged (retryable, limitBytes, sizeBytes, retryAt, runId)
}
  • Before: error was the error's own message (scrubbed). Apps that rendered it showed developer text, internal causes and instructions such as "re-read the session snapshot (useHelixChat().retry())".
  • After: error is userMessageFor(code) (core), one sentence per code, "Something went wrong." for an unmapped code; it never carries instructions, a cause or an id. The old text is detail.
  • Streams: a stream's error event's errorText is userMessageFor(code) too. The run's message rides the transient data-error-metadata part, which gains detail ({ code, recoverable, detail }); the part is now always sent before an error event. HelixStreamError.fromEvent(event, metadata) takes its message from metadata.detail.
  • The 409 for a resume of a failed stream has error: userMessageFor(<the run's code>) (it was the literal 'stream_failed') and detail: <message>; code, retryable, message and errorDetail are unchanged.

What to change ​

  • Render error (a body) or errorText (a stream); never render detail.
  • Log detail: parseHelixChatError(chat.error)?.detail for an HTTP refusal; for a stream error, capture the data-error-metadata part in onData and log its data.detail.
  • A stock useChat app: chat.error.message for an HTTP refusal is the whole JSON body (ai's transport throws new Error(await response.text())), detail included. Render parseHelixChatError(chat.error)?.error ?? <your own fallback>, never chat.error.message.
  • Code that parsed error for developer text (a log line, an error report, a test pin) reads detail instead.
  • The stream error a stock useChat shows (chat.error.message) is now the user sentence, also for a provider failure: an unclassified one reads "Something went wrong.". Log the provider's text from the error metadata's detail (the data-error-metadata part, in onData).
  • A peer client (a custom DO client, a remote transport) rebuilds the error with core helixErrorFromPeerBody, which now reads detail as the message; a remote-protocol envelope with core remoteProtocolError, which reads detail ?? error. A custom transport that builds its own error from an envelope reads detail first.
  • @helix-agents/agent-server: its typed refusals (/start, /resume, the completion-support refusals, the submit routes, /chat) answer { error: userMessageFor(code), code, detail }; its remote-protocol envelopes (ALREADY_COMPLETED, ALREADY_RUNNING, INTERNAL_ERROR, …) put the user sentence in error (for the typed errorCode, else its protocol code's typed code) and the framework or failure text in detail. The protocol-code map is exhaustive: NOT_FOUND → state_session_not_found, ALREADY_RUNNING → state_already_running, ALREADY_COMPLETED → state_not_resumable, INVALID_REQUEST → validation_error, INTERNAL_ERROR and WORKSPACE_WIRING → framework_internal_error, NOT_IMPLEMENTED and FEATURE_UNAVAILABLE → framework_not_supported.
    • An unknown agent type (/start, /resume) now carries errorCode: 'framework_agent_not_found' beside its NOT_FOUND, so it reads "This assistant is not available." rather than the session sentence.
    • The chat routes' 501 NOT_IMPLEMENTED (no chatHandler wired) moves its wiring hint from error to detail.

A transient submit answer is a re-anchor (SDK-5) ​

When the CF Durable Object stored a client-tool answer but the continuation it owes did not start (it is retried on its own), the submit answers 500 transport_continuation_pending (was the run's own code, e.g. framework_agent_resolution_failed, or 409 state_run_start_conflict). The run's code, when it had one, is the body's cause; the guidance is detail.

  • A submit that lost the start race (its continuation hit consumed_pending_changed) used to answer 409 state_run_start_conflict and now answers 500 transport_continuation_pending with cause: 'state_run_start_conflict'. An app that alerts on 409 versus 5xx sees this one move.
  • useHelixChat and useReanchorOnError treat it as a re-anchor, never an error: they clear chat.error, re-read the snapshot while it says continuationOwed (1 s, 2 s, 4 s, then every 8 s, at most 2 minutes), and follow the run once it starts. Past the bound they report the typed transport_continuation_pending (offer retry()).
  • The snapshot gains continuationOwed: boolean (core isSubmitContinuationOwed).
  • A custom client: on this code, do not re-submit; wait with waitWhileContinuationOwed and re-anchor.

Lapsed client-tool calls are skipped in the browser (SDK-3) ​

  • A client-tool call's tool_start carries deadlineAt; its UI part carries callProviderMetadata.helix{ deadlineAt, serverNow } (live stream, reloaded snapshot, replayed prefix).
  • useResumeClientTools / useHelixChat no longer run a call whose deadline lapsed (the server clock, estimated as serverNow + (Date.now() - receivedAt), is past deadlineAt + 1 s; a re-anchor that re-stamps serverNow restarts receivedAt, so no time is counted twice): the server already answered it client_tool_timeout. Pass runLapsed: true (or a list of tool names) to run them anyway; the handler context gains deadlineAt and lapsed.
  • A stock useChat sees only the extra metadata.

Late answers (onLateAnswer) ​

useHelixChat gains onLateAnswer(toolCallId), called once per transient data-helix-late-answer part (a real answer that arrived after its call's timeout was consumed; the server side ships in the next release). A stock useChat reads the part through onData. With a pre-built chat, onLateAnswer cannot fire (the hook cannot install its onData on a Chat it did not construct): handle data-helix-late-answer (data: { toolCallId }) in that Chat's own onData; the hook warns once per Chat instance in non-production builds.

For maintainers: a new ErrorCode needs its sentence ​

core USER_MESSAGES is a Record<ErrorCode, string>, so a new code in helix-error.ts does not build until it has a user-safe sentence there. This includes a main merge that brings new codes into a branch (the merge that brought in state_child_session_taken and framework_companion_not_supported failed its build until both had one). Write a sentence an app can show as is: no code, id, cause or developer instruction (the core pin over KNOWN_ERROR_CODES_LIST checks the shape of every entry). Add an exact-sentence pin only where the sentence carries contract weight (as for transport_continuation_pending).

Released under the MIT License.