langchain-ai/deepagents · error · RuntimeError
Server offload operations may not write {sorted(forbidden)}
Error message
Server offload operations may not write {sorted(forbidden)} to the checkpoint. What it means
The server-side offload handler validates that the merged state update only touches allowlisted checkpoint channels (`_WRITABLE_STATE_CHANNELS`). If the offload execution result or the cost-preparation update contributes any other channel (notably `messages`), it rolls back the drained cost record and raises this RuntimeError. This is a deliberate security boundary: because the route commits to the latest checkpoint rather than the one it read, an unattributed `messages` write could clobber messages a concurrent run appended.
Source
Thrown at libs/code/deepagents_code/offload_api.py:993
prepared = prepare_operation_cost(state, thread_id)
update: dict[str, Any] = {**execution.update, **prepared.update}
if forbidden := set(update) - _WRITABLE_STATE_CHANNELS:
# A security boundary, not a defensive assertion: this route commits
# to the latest checkpoint rather than the one it read, so a
# `messages` write here would be unattributed to any run and could
# clobber messages a concurrent run appended in that window. See
# THREAT_MODEL.md (TB10/DF27) before relaxing this.
#
# Checked as an allowlist against `OffloadStateUpdate` rather than
# for `messages` alone, so the runtime guard enforces the same
# invariant the type states instead of a subset of it: a future
# merge that adds any other channel is refused here too.
msg = (
"Server offload operations may not write "
f"{sorted(forbidden)} to the checkpoint."
)
prepared.rollback()
raise RuntimeError(msg)
if not update:
# Nothing to persist, but `prepare_operation_cost` already drained
# the recorder. Returning without rolling back would delete that
# spend from the thread's lifetime total (the drain is destructive).
prepared.rollback()
return {"status": "complete", "result": execution.result}
commit = asyncio.create_task(
_commit_deferred_archive(
client,
thread_id,
checkpoint_id,
execution,
update,
prepared,
)
)
cancellation = await _join_task_deferring_cancellation(commit)
commit.result()View on GitHub (pinned to a1af029e6e)
Solutions
- Change the offload operation to return only allowlisted channels (e.g. `_summarization_event`, token counters) instead of `messages` or other channels.
- If a new channel is legitimately required, extend `_WRITABLE_STATE_CHANNELS` after reviewing THREAT_MODEL.md (TB10/DF27).
- If caused by an SDK upgrade, pin the previous `deepagents` version and update the offload middleware to the new summarization-event contract.
Example fix
// before
class MyOffload(OffloadOperation):
def execute(self, state, runtime):
return {"messages": summarized, ...}
// after
class MyOffload(OffloadOperation):
def execute(self, state, runtime):
# emit an event, not a messages write
return {"_summarization_event": {"cutoff_index": idx, "summary_message": summary}} Defensive patterns
Strategy: validation
Validate before calling
FORBIDDEN = set(execution.update) - _WRITABLE_STATE_CHANNELS
assert not FORBIDDEN, f"offload update writes {FORBIDDEN} — restrict to allowlisted channels" Type guard
def is_allowlisted(update: dict[str, Any]) -> bool:
return set(update) <= _WRITABLE_STATE_CHANNELS Try / catch
try:
result = await offload_endpoint(request)
except RuntimeError as e:
if "may not write" in str(e):
fix_operation_channels() # inspect operation.update keys
raise Prevention
- Type custom operations against OffloadStateUpdate so only allowlisted channels compile.
- Add a unit test asserting the operation's update keys are a subset of _WRITABLE_STATE_CHANNELS.
- Re-read THREAT_MODEL.md (TB10/DF27) before touching the allowlist.
When it happens
Trigger: Calling POST offload (via `offload`) where `OffloadOperation.execute` returns an update dict containing channels outside the allowlist — e.g. a custom OffloadOperation writing `messages`, or a modified/merged execution returning extra state keys.
Common situations: Custom offload/compaction operations that write summarized `messages` server-side (moved to client-side event-based compaction); SDK upgrades that add channels to `OffloadStateUpdate` without updating `_WRITABLE_STATE_CHANNELS`; test doubles of the operation that return full state.
Understand the failure class
- Authentication and authorization failures — expired tokens, bad credentials, and missing scopes.
Related errors
- debug log directory is not a real directory: {path}
- debug log directory is not owned by the current user: {path}
- Cannot determine whether {str(left)!r} is {str(right)!r}: {e
- Repository root must be an absolute contained path: {root!r}
- allow_fs_tools must be None or a non-empty list
AI-assisted analysis of langchain-ai/deepagents@a1af029e6e (2026-08-29).
Data as JSON: /api/errors/a42000571ccaee10.
Report an issue: GitHub.