Add a standalone recovery tool at `examples/delta-channel-dump/` for operators rolling back from langgraph >= 1.2 / deepagents 0.6.x to an older runtime that does not understand `EXT_DELTA_SNAPSHOT` msgpack blobs. Without this, channels like `messages` can appear empty after rollback because the old reducer does not decode delta snapshots. `dump.py` connects directly to Postgres, walks the checkpoint parent chain (mirroring `aget_delta_channel_history`), decodes msgpack blobs via `ormsgpack` with a hand-rolled EXT 0–7 hook (no langgraph imports), and emits JSON with per-channel `seed` + oldest-to-newest `writes`. The operator reduces and re-applies via `update_state` manually. ## Scope - Postgres checkpointer only - Deps: `psycopg[binary]`, `ormsgpack` (not added to repo `pyproject.toml` — operator installs at runtime) - No reducer application, no DB writes, no AES/custom encryption support (fails loudly on encrypted blob types) ## How verified - E2E against local Postgres: 15 runs of `delta_channel_messages_freq` (`snapshot_frequency=10`) - Asserted `delta_kind == "snapshot"`, `len(seed) == 10`, `len(writes) == 5`, message ids `ai-0`..`ai-14` - Confirmed portability: fresh venv with only `pip install "psycopg[binary]" ormsgpack` produced identical output
delta-channel-dump
Recover messages (and other channels) from a Postgres-backed LangGraph thread
written by langgraph >= 1.2 (DeltaChannel format) — including LangGraph
Server / langgraph-api deployments on the Postgres runtime, deepagents
0.6.x, or any OSS app using PostgresSaver — before rolling back to an older
runtime such as deepagents 0.5.x / langgraph < 1.2.
langgraph-api uses the same checkpoints / checkpoint_blobs / checkpoint_writes
schema as OSS checkpoint-postgres; this script reads those tables directly.
On the older runtime, add_messages does not understand the EXT_DELTA_SNAPSHOT
msgpack ext code and silently returns an empty list for affected channels. This
tool reads the raw checkpoint blobs from Postgres and emits JSON you can inspect
and re-apply via update_state (LangGraph Server SDK) or graph.update_state
(OSS).
Install
pip install "psycopg[binary]" ormsgpack
Run
export DATABASE_URI=postgres://user:pass@host:5432/dbname
python3 dump.py \
--thread-id <uuid> \
--channel messages \
[--channel files ...] \
[--checkpoint-id <uuid>] \
[--checkpoint-ns ""] \
--output recovery.json
--thread-id(required): thread UUID--channel(required, repeatable): channel names to recover--checkpoint-id(optional): target checkpoint; defaults to latest--checkpoint-ns(optional): namespace; defaults to""--database-uri(optional): Postgres URI; defaults toDATABASE_URIenv var--output(optional): output file; defaults to stdout
Output
{
"thread_id": "...",
"checkpoint_ns": "",
"target_checkpoint_id": "...",
"parent_checkpoint_id": "...",
"channels": {
"messages": {
"delta_kind": "snapshot",
"seed_checkpoint_id": "...",
"seed_version": "...",
"seed": [{ "type": "ai", "content": "...", "id": "ai-0" }],
"writes": [
{
"checkpoint_id": "...",
"task_id": "...",
"idx": 0,
"value": [{ "type": "ai", "content": "...", "id": "ai-10" }]
}
]
}
}
}
delta_kind is one of:
snapshot— DeltaChannel snapshot blob (channel_values[ch] == true)legacy_plain— pre-DeltaChannel inline or blob valueno_seed— walked to root without finding a populated ancestor
writes are ordered oldest-to-newest (the order a reducer would replay them).
Reducing back to a single list
For deepagents-style messages, combine seed and writes, then deduplicate:
import json
data = json.load(open("recovery.json"))
ch = data["channels"]["messages"]
messages = list(ch["seed"] or [])
for w in ch["writes"]:
messages.extend(w["value"] or [])
# Dedup by id, keep last; drop RemoveMessage tombstones
by_id = {}
for m in messages:
if isinstance(m, dict) and m.get("type") == "remove":
by_id.pop(m.get("id"), None)
elif isinstance(m, dict) and m.get("id"):
by_id[m["id"]] = m
else:
by_id[id(m)] = m
reduced = list(by_id.values())
This approximates _messages_delta_reducer semantics; adjust for your graph.
Re-applying
from langgraph_sdk import get_client
client = get_client(url="http://localhost:8123")
await client.threads.update_state(
thread_id,
values={"messages": reduced},
)
Review the recovered JSON before calling update_state. This tool is
read-only and intentionally does not mutate the database.
Scope / non-goals (v1)
- Postgres only — OSS
PostgresSaveror langgraph-api Postgres runtime; not inmem, gRPC core, Mongo, or Redis checkpointer backends - No AES decryption (
LANGGRAPH_AES_KEY) or custom encryption - No reducer — raw seed + writes only
- No automatic
update_state— operator applies manually
Copying
dump.py is self-contained. Copy it anywhere; only psycopg[binary] and
ormsgpack are required at runtime. No langgraph imports.