Upgrade: an oversized state write is typed, never lost (MA-31)
A tool whose state write is too large for the store no longer loses the write silently: its result is a typed failed result and the run continues. A step, terminal or message commit that is too large fails the run typed and non-retryable. One core error replaces the three store classes, and the CF stores' option is renamed. Client and server deploy together (the failed row's metadata.helixSynthetic and the HTTP 413 are new).
What changed
One error
Every store throws core StateSizeExceededError (code: 'state_size_exceeded', category state, retryable: false) and writes nothing of the refused write (Postgres maps the database's SQLSTATE 54000 after the bind): operation (stage | step | state | append), sessionId, limitBytes, sizeBytes, changedKeys, toolCallId?. Check it with isStateSizeExceededError (by shape, not instanceof), and never branch on operation (a step commit refuses an oversized output or pending map as state). Over HTTP it is 413 on every route, body:
{
"error": "The session state at the state commit for session 's1' is 1900211 bytes, over the store's 1800000-byte limit",
"code": "state_size_exceeded",
"retryable": false,
"cause": "state",
"limitBytes": 1800000,
"sizeBytes": 1900211
}A store declares what it enforces as SessionStateStore.limits?: StoreLimits (maxStateBytes, maxStagedWriteBytes, maxMessageBytes, UTF-8 bytes of the JSON it binds; Infinity = none):
| Store | Limits |
|---|---|
| DO-SQLite | one maxStateBytes for all three, default 1.8 MB (DurableObjectAgentConfig.maxStateBytes / DOStateStoreOptions.maxStateBytes) |
| D1 | the same, D1StateStoreOptions.maxStateBytes, default 1.8 MB |
| Redis | maxStagedWriteBytes only (1.8 MB, a Lua-time budget); the folded state and message rows are unlimited |
| Postgres | none declared; SQLSTATE 54000 (a jsonb value over Postgres's own cap) is mapped to the typed error, limitBytes = POSTGRES_MAX_JSONB_BYTES (2^28 − 1) |
| In-memory | none |
A driver "too big" error that gets past a store's own check (a limit raised above the platform cap) is mapped to the same error by the measured bytes, never by its text.
A tool's write over the limit: a typed failed row, the run continues
The tool ran, so its result is recorded, as a failure; its state writes are discarded. Its row is metadata.helixToolFailed: true, metadata.helixSynthetic: { kind: 'state_write_rejected', code: 'state_size_exceeded', operation, sizeBytes, limitBytes, changedKeys } (isStateWriteRejectedToolResult), content {"error": "Tool 'x' ran but its state write (N bytes) exceeds the store's M-byte limit (keys: …) and was discarded; store large values outside agent state and keep a reference"}. The tool_end chunk carries the same text, no state_patch is written, onStateChange does not fire, afterTool sees success: false, parallel siblings are unaffected, and a finishWith tool so refused produces no output.
Before: runtime-js and the CF DO logged a warning and recorded a SUCCESS row; CF Workflows rethrew into step.do, which re-ran the tool on every retry, then failed the run with the engine's message and no code; Temporal and DBOS recorded an untyped failed row. On Temporal and DBOS afterTool receives new Error(<the row's text>), not the typed error (the result crosses a JSON boundary).
A tool that THREW is never this row: it keeps its own failed row. On CF Workflows its partial write is still staged, and when the store refuses it as too large it is discarded (no rethrow, no step retry).
Any other staging error (runtime-js and the CF DO)
It used to be swallowed with a warning (the tool reported success, its write lost). It now fails the run in the run loop. In the approve drain (executeSingleToolCall, which never throws) it is the tool's typed failed result. The drain also commits the approved tool's state itself, split by what the store said: a size refusal is the typed state_write_rejected row (with the folded state's size, operation: 'state', and its own text: "Tool 'x' ran but the session state with its write would be N bytes, …"); a definite failure (non-retryable or a typed refusal) is a failed row; a retryable or unclassified fault fails the entry with nothing paired, so a re-drive runs the tool again.
An approved tool that fails is a failed row (RM-84)
On runtime-js and the CF DO, an approved tool that threw was recorded as a success with null content, and afterTool saw success: true. Its drain row is now a failed tool result carrying the tool's error, and afterTool sees success: false with it.
A commit over the limit fails the run typed
Not a tool result. A tool or sub-agent result row over the store's maxMessageBytes no longer fails the step commit on runtime-js and the CF DO (before, the store's size check refused it, failing the run): it is measured before its tool_end and hooks (core messageRowBytes, the store's own measure) and replaced by core's typed failed row ("Tool 'x' ran, but its result was too large to record: the recorded row would be N bytes, over the M-byte message limit. Return large data by reference …"; a plain failed row, helixToolFailed: true), and the run continues. The tool_end has that text and no result, and afterTool / afterSubAgent get the StateSizeExceededError (operation step). It is the row CF Workflows writes for a result over its 1 MiB step cap (createResultTooLargeToolResult). Return large results by reference.
A step commit over the limit (the folded state, or the model's own reply) fails the run with errorDetail.code: 'state_size_exceeded', retryable: false. Its failure terminal commits the stored state, so the session never stays active. runtime-js (and so the CF DO) also measures the folded state against a finite maxStateBytes before the step commit, failing earlier with the step's keys, and its terminal-commit loop no longer retries a size refusal. Before: the DO's store let SQLite reject the row and reported a retryable transport_error (CommitNotAppliedError): a deterministic refusal misclassified as transient. On runtime-js and the CF DO, a completed terminal that an onAgentComplete hook grew over the limit ends failed the same way, persisted over the stored state by a run-fenced saveState (no terminal checkpoint on that path).
CF Workflows: every step result is capped at 1 MiB
The Workflows engine caps one step.do result at 1 MiB (limits) and retried an oversized one, re-running the step's work: a model reply over it re-called the model, a tool over it re-ran the tool. The runtime now measures every step result (a conservative bound of the engine's V8 structured serialization) before it returns (CFW_MAX_STEP_RESULT_BYTES, stepResultSize). A tool, sub-agent, remote sub-agent or companion result over it is a typed failed result ("… ran, but its result was too large to record: …") and the run continues; a model reply or any other step result over it fails the run state_size_exceeded, with no retry. So on CF Workflows 1 MiB, not the D1 store's 1.8 MB, is the effective limit for a reply, a tool result or a child outcome.
A hidden companion completion notice too large to record
A persistent companion's completion notice embeds its output escaped, so its stored row is about twice the output. One over the store's row limit (or, on CF Workflows, the step-result cap) is now delivered once as a hidden notice saying so ("Sub-agent '<name>' completed, but its result was too large to record: …"), and the child is marked delivered (MA-37, MA-38). Before: runtime-js and the CF DO swallowed the store's refusal and retried it on every iteration, and CF Workflows failed every later turn at that step; the model never learnt the child finished. A companion that returns large data should return it by reference. On CF Workflows, rows the runtime records for a sub-agent call or an approved tool are also fitted under a D1 limit set below 1 MiB (MA-31).
Submits: the store's limit, refused at submit and at construction
A submitted client-tool answer waits in the session's stored pending map. An answer that would push the map over the store's limit is refused with the 413 above before anything is stored (a batch's earlier answers stay stored). Before, the DO stored it where the platform cap is not enforced (Miniflare) and failed it untyped in production. agent-server's /submit-tool-result answered it 500; it answers 413. createHelixChatTransport throws core's StateSizeExceededError for it (it threw HelixPayloadTooLargeError).
Behaviour change at startup: JSAgentExecutor, CloudflareAgentExecutor and the CF Durable Object throw a typed validation_error when constructed with a submitToolResultSchema whose maxResultBytes is over the store's finite maxStateBytes (such a result could never be stored). A store with no limit is unaffected. Lower maxResultBytes, or raise the store limit.
Removed exports (no back-compat)
| Package | Removed | Use instead |
|---|---|---|
@helix-agents/runtime-cloudflare | StateSizeExceededError (the extends Error class), DEFAULT_MAX_STAGED_CHANGES_BYTES, DOStateStoreOptions.maxStagedChangesBytes | core StateSizeExceededError / isStateSizeExceededError, DEFAULT_MAX_STATE_BYTES, DurableObjectAgentConfig.maxStateBytes / DOStateStoreOptions.maxStateBytes |
@helix-agents/store-cloudflare | D1StateSizeExceededError, DEFAULT_D1_MAX_STAGED_CHANGES_BYTES, D1StateStoreOptions.maxStagedChangesBytes | core's error, DEFAULT_D1_MAX_STATE_BYTES, D1StateStoreOptions.maxStateBytes |
@helix-agents/store-redis | RedisStateSizeExceededError, DEFAULT_REDIS_MAX_STAGED_CHANGES_BYTES, RedisStateStoreOptions.maxStagedChangesBytes | core's error, DEFAULT_REDIS_MAX_STAGED_WRITE_BYTES, RedisStateStoreOptions.maxStagedWriteBytes |
The old writesBytes / totalBytes / limit fields are gone: read sizeBytes, limitBytes and changedKeys. The renamed options are validated: a positive integer or Infinity (Redis: a positive number or Infinity), else a typed validation_error.
New exports
@helix-agents/core:StateSizeExceededError,stateSizeExceededError,isStateSizeExceededError,stateSizeExceededMessage,STATE_SIZE_OPERATIONS;UNLIMITED_STORE_LIMITS,uniformStoreLimits,limitFor,utf8ByteLength,BoundSizeWitness,assertSubmitLimitFitsStore;STATE_WRITE_REJECTED_KIND,StateWriteRejectedToolResultMetaSchema,stateWriteRejectedMeta,stateWriteRejectedErrorText,createStateWriteRejectedToolResult,isStateWriteRejectedToolResult;classifyKnownError. Types:StateSizeExceededInput,StateSizeOperation,StoreLimits,BoundSizeContext,StateWriteRejectedMeta;SessionStateStore.limits?,RegularToolResult.stateWriteRejected?.helixErrorFromPeerBodytakes an optional third argument,{ sessionId? }.@helix-agents/core/testing:sizeLimitOperationTestsandSizeLimitContractOptions, run bystateStoreContractTestswith its newsizeLimitedoption (skip.sizeLimitsgives the reason a store skips them).@helix-agents/runtime-cloudflare:DEFAULT_MAX_STATE_BYTES,CF_SQL_MAX_VALUE_BYTES,CFW_MAX_STEP_RESULT_BYTES,stepResultSize(typeStepResultSize);DurableObjectAgentConfig.maxStateBytes?,DOStateStoreOptions.maxStateBytes?;SubAgentCompletionEventSchemagains an optionalerrorDetail.@helix-agents/store-cloudflare:DEFAULT_D1_MAX_STATE_BYTES;D1StateStoreOptions.maxStateBytes?.@helix-agents/store-redis:DEFAULT_REDIS_MAX_STAGED_WRITE_BYTES;RedisStateStoreOptions.maxStagedWriteBytes?.@helix-agents/store-postgres:POSTGRES_MAX_JSONB_BYTES,isPostgresSizeError.
Migration
- Replace
err instanceof <Store>StateSizeExceededErrorwithisStateSizeExceededError(err), and readerr.sizeBytes/err.limitBytes/err.changedKeys. - Rename the option: DO / D1
maxStagedChangesBytes→maxStateBytes(a DO sets it once, onDurableObjectAgentConfig); Redis →maxStagedWriteBytes. - Check every
submitToolResultSchema: amaxResultBytesover a CF store'smaxStateBytesnow fails at startup. - A UI that renders failed tool rows:
helixSynthetic.kind === 'state_write_rejected'is a tool that ran whose state was not kept. A client that catchesHelixPayloadTooLargeErrorfrom a submit should also handle a 413state_size_exceeded(parseHelixChatError(chat.error).code). - A
retry()after astate_size_exceededfailure re-runs the same tools: shrink the write first (paginate, or keep the blob outside agent state and store a reference). - A custom
SessionStateStorewith a size limit declareslimitsand throwsstateSizeExceededError(...); run the store contract withsizeLimited.