mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-10-18 05:57:37 +02:00
Fixes langchain-ai/langgraph#8470 Fixes langchain-ai/langgraph#8653 Reported by @gururafiki, with a self-contained `InMemorySaver` repro and the observation that non-delta channels in the same namespace hydrate fine, which is what makes the failure silent. @doniyor2109 reported #8653, the same missing saver at the root: a graph compiled without a checkpointer, whose saver arrives through the config, read the channel back empty in `get_state`, `get_state_history` and `update_state`. ### The bug, in one read One subgraph state, two channels written by the same nodes: `delta` is a `DeltaChannel`, `plain` is an `operator.add` list. Pause the subgraph before its second node and read it through the documented `get_state(config, subgraphs=True).tasks[0].state`: ``` main: {'delta': [], 'plain': ['a1']} branch: {'delta': ['a1'], 'plain': ['a1']} ``` Same call, same instant, same state object. If this were a rule about how subgraphs manage state, both channels would be empty. Only the delta one is, because it is the one channel type that stores nothing in `channel_values` and has to replay its writes through a saver. ### Why only the delta channel A subgraph has no saver of its own. It either inherits the parent's (the default, "per-invocation") or opts in with `checkpointer=True` ("per-thread"), and in both cases the saver reaches it through `CONFIG_KEY_CHECKPOINTER`. All four public readers resolve it from there. `_prepare_state_snapshot` then ignored that and re-derived the saver from `self.checkpointer`: - per-invocation: `self.checkpointer` is `None` - per-thread: `self.checkpointer` is `True`, and `isinstance(True, BaseCheckpointSaver)` is `False` So the explicit per-thread opt-in, the mode that exists to make subgraph state readable across invocations, was broken too. With no saver the ancestor walk never runs and the channel falls through to `from_checkpoint(MISSING)`: an empty value, indistinguishable from never written, raising nothing. The saver can't be recovered inside the function: `get_state_history` passes the checkpointer's stored config, which never carries a live saver. The caller is the only place it exists, so the caller passes it, as a required keyword argument. ### What stays unchanged The two modes from the [subgraph persistence docs](https://docs.langchain.com/oss/python/langgraph/use-subgraphs#subgraph-persistence) where an empty read is correct are pinned by tests that pass before and after: - `checkpointer=False` persists nothing, so there is no subgraph namespace to read. - after a subgraph completes, `subgraphs=True` has no pending task to attach state to, for every channel type alike. ### The write path loses data on disk `bulk_update_state` / `abulk_update_state` carried the same expression. When an update reaches the channel's snapshot cadence, `create_checkpoint` persists a `_DeltaSnapshot` built from the empty hydration, so `update_state` on a subgraph namespace replaces `['a1', 'b1', 'b2']` with `['manual']` permanently. At the default `snapshot_frequency=1000` no snapshot is written and the loss stays latent; the tests use `snapshot_frequency=2` to reach it. `prepare_next_tasks` in `_prepare_state_snapshot` now receives the same saver too. It was inert there (the task config falls back to `CONFIG_KEY_CHECKPOINTER`, and `PregelTask` exposes no config), but one expression is easier to trust than two. ### One saver lookup and namespace rule The run loop and all six state methods now share one saver lookup (`_resolve_checkpointer`) and one namespace rule for `checkpointer=True` subgraphs, and the state methods find the subgraph a namespaced config addresses through one helper (`_subgraph_for_namespace`). Before, only `get_state` / `aget_state` stripped the task id, so `get_state_history` and `update_state` given the subgraph task's config looked under `name:<task_id>`, where the run never stores anything: the history came back empty and the update was silently lost. The namespace rule strips only the task ids: a task that calls the same `checkpointer=True` subgraph twice stores the second call under `<node>|1`, and `recast_checkpoint_ns` would drop that counter. Sharing the lookup with runs also fixes two places where a state method disagreed with a run: a `checkpointer=False` graph ignores a saver lent through the config, so `update_state` raises "No checkpointer set" instead of writing a checkpoint no run reads, and a `checkpointer=True` graph used as root raises the run's `checkpointer=True cannot be used for root graphs.` instead of an AttributeError. `channels_from_checkpoint` now raises when a `DeltaChannel` has a version but no stored value and no saver or config was passed, so a caller that forgets either fails instead of reading `[]`. Keying on the version keeps a never-written channel reading as empty. ### Verification `tests/test_delta_channel_subgraph.py` runs against the shared `sync_checkpointer` / `async_checkpointer` fixtures (memory, sqlite, postgres), and every assertion compares both channels: | surface | on main | | -- | -- | | `get_state` / `aget_state` on a subgraph ns | `delta: []` | | `get_state_history` / `aget_state_history` | every snapshot `delta: []` | | two levels of nesting | `delta: []` | | `get_state(subgraphs=True)` / async, interrupted, per-invocation and per-thread | `delta: []` | | `update_state` / `aupdate_state` | `delta: ['manual']`, prior history lost | | `get_state_history` / `update_state` from a `checkpointer=True` subgraph's task config, sync and async | no snapshots / update lost | | `channels_from_checkpoint` with a written delta channel and no saver or config | silent `[]` | | `get_state` / `update_state` / `get_state_history` on a graph compiled without a checkpointer, saver lent through the config (#8653), sync and async | `delta: []` | | `get_state` of a `checkpointer=True` subgraph's second call in one task (`node\|1`), sync and async | the first call's state | 159 cases: 144 fail on main, all 159 pass here. The 15 that pass on main are the two unchanged-mode controls and the never-written case. --------- Co-authored-by: gururafiki <22777967+gururafiki@users.noreply.github.com> Co-authored-by: Yuan Gao <119447586+DavidGao520@users.noreply.github.com>