mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-26 19:45:00 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2848b19c9 |
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Callable, Mapping
|
from collections.abc import Callable, Iterable, Mapping, Sequence
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ from langgraph.checkpoint.base.id import uuid6
|
|||||||
from langgraph.checkpoint.serde.types import _DeltaSnapshot
|
from langgraph.checkpoint.serde.types import _DeltaSnapshot
|
||||||
|
|
||||||
from langgraph._internal._config import DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT
|
from langgraph._internal._config import DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT
|
||||||
|
from langgraph._internal._constants import PUSH
|
||||||
from langgraph._internal._typing import MISSING
|
from langgraph._internal._typing import MISSING
|
||||||
from langgraph.channels.base import BaseChannel
|
from langgraph.channels.base import BaseChannel
|
||||||
from langgraph.channels.delta import DeltaChannel
|
from langgraph.channels.delta import DeltaChannel
|
||||||
@@ -70,6 +71,26 @@ def delta_channels_to_snapshot(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def update_state_channels_plan(
|
||||||
|
run_tasks: Iterable[Any],
|
||||||
|
channels: Mapping[str, BaseChannel],
|
||||||
|
) -> tuple[set[str], set[str]]:
|
||||||
|
"""Return channels written and DeltaChannels to snapshot on update_state."""
|
||||||
|
updated_channels = {c for task in run_tasks for c, _ in task.writes if c != PUSH}
|
||||||
|
channels_to_snapshot = {
|
||||||
|
c for c in updated_channels if isinstance(channels.get(c), DeltaChannel)
|
||||||
|
}
|
||||||
|
return updated_channels, channels_to_snapshot
|
||||||
|
|
||||||
|
|
||||||
|
def update_state_channel_writes(
|
||||||
|
writes: Sequence[tuple[str, Any]],
|
||||||
|
channels_to_snapshot: set[str],
|
||||||
|
) -> list[tuple[str, Any]]:
|
||||||
|
"""Channel writes to persist separately from a head snapshot."""
|
||||||
|
return [w for w in writes if w[0] != PUSH and w[0] not in channels_to_snapshot]
|
||||||
|
|
||||||
|
|
||||||
def create_checkpoint(
|
def create_checkpoint(
|
||||||
checkpoint: Checkpoint,
|
checkpoint: Checkpoint,
|
||||||
channels: Mapping[str, BaseChannel] | None,
|
channels: Mapping[str, BaseChannel] | None,
|
||||||
|
|||||||
@@ -108,7 +108,6 @@ from langgraph.callbacks import (
|
|||||||
get_sync_graph_callback_manager_for_config,
|
get_sync_graph_callback_manager_for_config,
|
||||||
)
|
)
|
||||||
from langgraph.channels.base import BaseChannel
|
from langgraph.channels.base import BaseChannel
|
||||||
from langgraph.channels.delta import DeltaChannel
|
|
||||||
from langgraph.channels.topic import Topic
|
from langgraph.channels.topic import Topic
|
||||||
from langgraph.config import get_config
|
from langgraph.config import get_config
|
||||||
from langgraph.constants import END
|
from langgraph.constants import END
|
||||||
@@ -134,6 +133,8 @@ from langgraph.pregel._checkpoint import (
|
|||||||
copy_checkpoint,
|
copy_checkpoint,
|
||||||
create_checkpoint,
|
create_checkpoint,
|
||||||
empty_checkpoint,
|
empty_checkpoint,
|
||||||
|
update_state_channel_writes,
|
||||||
|
update_state_channels_plan,
|
||||||
)
|
)
|
||||||
from langgraph.pregel._draw import draw_graph
|
from langgraph.pregel._draw import draw_graph
|
||||||
from langgraph.pregel._io import map_input, read_channels
|
from langgraph.pregel._io import map_input, read_channels
|
||||||
@@ -1996,35 +1997,19 @@ class Pregel(
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
# save task writes
|
updated_channels, channels_to_snapshot = update_state_channels_plan(
|
||||||
has_delta_writes = any(
|
run_tasks, channels
|
||||||
isinstance(channels.get(c), DeltaChannel)
|
|
||||||
for task in run_tasks
|
|
||||||
for c, _ in task.writes
|
|
||||||
)
|
|
||||||
should_put_writes = saved is not None or has_delta_writes
|
|
||||||
|
|
||||||
if saved is None and has_delta_writes:
|
|
||||||
# If there is no previous checkpoint, we need to create a stub checkpoint
|
|
||||||
# so the first delta writes has a parent to anchor under.
|
|
||||||
# This is the model of DeltaChannel.
|
|
||||||
stub = empty_checkpoint()
|
|
||||||
checkpoint_config = checkpointer.put(
|
|
||||||
patch_configurable(
|
|
||||||
checkpoint_config, {CONFIG_KEY_CHECKPOINT_ID: None}
|
|
||||||
),
|
|
||||||
stub,
|
|
||||||
{"source": "update", "step": -1, "parents": {}},
|
|
||||||
{},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if saved is not None:
|
||||||
for task_id, task in zip(run_task_ids, run_tasks):
|
for task_id, task in zip(run_task_ids, run_tasks):
|
||||||
# channel writes are saved to current checkpoint
|
if channel_writes := update_state_channel_writes(
|
||||||
channel_writes = [w for w in task.writes if w[0] != PUSH]
|
task.writes, channels_to_snapshot
|
||||||
if should_put_writes and channel_writes:
|
):
|
||||||
checkpointer.put_writes(checkpoint_config, channel_writes, task_id)
|
checkpointer.put_writes(
|
||||||
|
checkpoint_config, channel_writes, task_id
|
||||||
|
)
|
||||||
|
|
||||||
# apply to checkpoint and save
|
|
||||||
apply_writes(
|
apply_writes(
|
||||||
checkpoint,
|
checkpoint,
|
||||||
channels,
|
channels,
|
||||||
@@ -2032,7 +2017,14 @@ class Pregel(
|
|||||||
checkpointer.get_next_version,
|
checkpointer.get_next_version,
|
||||||
self.trigger_to_nodes,
|
self.trigger_to_nodes,
|
||||||
)
|
)
|
||||||
checkpoint = create_checkpoint(checkpoint, channels, step + 1)
|
checkpoint = create_checkpoint(
|
||||||
|
checkpoint,
|
||||||
|
channels,
|
||||||
|
step + 1,
|
||||||
|
updated_channels=updated_channels,
|
||||||
|
get_next_version=checkpointer.get_next_version,
|
||||||
|
channels_to_snapshot=channels_to_snapshot,
|
||||||
|
)
|
||||||
next_config = checkpointer.put(
|
next_config = checkpointer.put(
|
||||||
checkpoint_config,
|
checkpoint_config,
|
||||||
checkpoint,
|
checkpoint,
|
||||||
@@ -2046,7 +2038,11 @@ class Pregel(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
for task_id, task in zip(run_task_ids, run_tasks):
|
for task_id, task in zip(run_task_ids, run_tasks):
|
||||||
# save push writes
|
if saved is None:
|
||||||
|
if channel_writes := update_state_channel_writes(
|
||||||
|
task.writes, channels_to_snapshot
|
||||||
|
):
|
||||||
|
checkpointer.put_writes(next_config, channel_writes, task_id)
|
||||||
if push_writes := [w for w in task.writes if w[0] == PUSH]:
|
if push_writes := [w for w in task.writes if w[0] == PUSH]:
|
||||||
checkpointer.put_writes(next_config, push_writes, task_id)
|
checkpointer.put_writes(next_config, push_writes, task_id)
|
||||||
|
|
||||||
@@ -2463,36 +2459,19 @@ class Pregel(
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
# save task writes
|
updated_channels, channels_to_snapshot = update_state_channels_plan(
|
||||||
has_delta_writes = any(
|
run_tasks, channels
|
||||||
isinstance(channels.get(c), DeltaChannel)
|
|
||||||
for task in run_tasks
|
|
||||||
for c, _ in task.writes
|
|
||||||
)
|
|
||||||
should_put_writes = saved is not None or has_delta_writes
|
|
||||||
|
|
||||||
if saved is None and has_delta_writes:
|
|
||||||
# If there is no previous checkpoint, we need to create a stub checkpoint
|
|
||||||
# so the first delta writes has a parent to anchor under.
|
|
||||||
# This is the model of DeltaChannel.
|
|
||||||
stub = empty_checkpoint()
|
|
||||||
checkpoint_config = await checkpointer.aput(
|
|
||||||
patch_configurable(
|
|
||||||
checkpoint_config, {CONFIG_KEY_CHECKPOINT_ID: None}
|
|
||||||
),
|
|
||||||
stub,
|
|
||||||
{"source": "update", "step": -1, "parents": {}},
|
|
||||||
{},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if saved is not None:
|
||||||
for task_id, task in zip(run_task_ids, run_tasks):
|
for task_id, task in zip(run_task_ids, run_tasks):
|
||||||
# channel writes are saved to current checkpoint
|
if channel_writes := update_state_channel_writes(
|
||||||
channel_writes = [w for w in task.writes if w[0] != PUSH]
|
task.writes, channels_to_snapshot
|
||||||
if should_put_writes and channel_writes:
|
):
|
||||||
await checkpointer.aput_writes(
|
await checkpointer.aput_writes(
|
||||||
checkpoint_config, channel_writes, task_id
|
checkpoint_config, channel_writes, task_id
|
||||||
)
|
)
|
||||||
# apply to checkpoint and save
|
|
||||||
apply_writes(
|
apply_writes(
|
||||||
checkpoint,
|
checkpoint,
|
||||||
channels,
|
channels,
|
||||||
@@ -2500,8 +2479,14 @@ class Pregel(
|
|||||||
checkpointer.get_next_version,
|
checkpointer.get_next_version,
|
||||||
self.trigger_to_nodes,
|
self.trigger_to_nodes,
|
||||||
)
|
)
|
||||||
checkpoint = create_checkpoint(checkpoint, channels, step + 1)
|
checkpoint = create_checkpoint(
|
||||||
# save checkpoint, after applying writes
|
checkpoint,
|
||||||
|
channels,
|
||||||
|
step + 1,
|
||||||
|
updated_channels=updated_channels,
|
||||||
|
get_next_version=checkpointer.get_next_version,
|
||||||
|
channels_to_snapshot=channels_to_snapshot,
|
||||||
|
)
|
||||||
next_config = await checkpointer.aput(
|
next_config = await checkpointer.aput(
|
||||||
checkpoint_config,
|
checkpoint_config,
|
||||||
checkpoint,
|
checkpoint,
|
||||||
@@ -2515,7 +2500,13 @@ class Pregel(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
for task_id, task in zip(run_task_ids, run_tasks):
|
for task_id, task in zip(run_task_ids, run_tasks):
|
||||||
# save push writes
|
if saved is None:
|
||||||
|
if channel_writes := update_state_channel_writes(
|
||||||
|
task.writes, channels_to_snapshot
|
||||||
|
):
|
||||||
|
await checkpointer.aput_writes(
|
||||||
|
next_config, channel_writes, task_id
|
||||||
|
)
|
||||||
if push_writes := [w for w in task.writes if w[0] == PUSH]:
|
if push_writes := [w for w in task.writes if w[0] == PUSH]:
|
||||||
await checkpointer.aput_writes(next_config, push_writes, task_id)
|
await checkpointer.aput_writes(next_config, push_writes, task_id)
|
||||||
return patch_checkpoint_map(next_config, saved.metadata if saved else None)
|
return patch_checkpoint_map(next_config, saved.metadata if saved else None)
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
"""Tests for `update_state` / `aupdate_state` against `DeltaChannel`.
|
"""Tests for `update_state` / `aupdate_state` against `DeltaChannel`.
|
||||||
|
|
||||||
Originally a regression suite for deepagents#3774 — `update_state` on a *fresh*
|
Regression suite for deepagents#3774 and Postgres read-path compatibility:
|
||||||
thread silently dropped the first write to a `DeltaChannel`-backed channel
|
fresh-thread `update_state` must persist DeltaChannel state correctly.
|
||||||
because channel writes were only persisted when a previous checkpoint existed.
|
|
||||||
Fixed by lazily persisting an empty stub checkpoint on a fresh thread so the
|
Fresh-thread updates snapshot updated DeltaChannels on the head checkpoint
|
||||||
first write has a parent to anchor under (mirrors the exit-mode lazy-stub
|
(self-contained for Postgres readers). Delta writes are not persisted via
|
||||||
pattern in `_loop._put_exit_delta_writes`).
|
`put_writes`; non-delta channel writes on a fresh thread are attached to
|
||||||
|
the head after it is saved.
|
||||||
|
|
||||||
Coverage:
|
Coverage:
|
||||||
|
|
||||||
@@ -13,8 +14,8 @@ Coverage:
|
|||||||
* non-fresh thread: `update_state` after `invoke`, after another `update_state`,
|
* non-fresh thread: `update_state` after `invoke`, after another `update_state`,
|
||||||
and `bulk_update_state` with multiple per-superstep updates
|
and `bulk_update_state` with multiple per-superstep updates
|
||||||
* update-by-id end-to-end via `update_state` (DeltaChannel reducer semantics)
|
* update-by-id end-to-end via `update_state` (DeltaChannel reducer semantics)
|
||||||
* state-history chain shape on a fresh thread (lazy stub + update checkpoint
|
* state-history shape on a fresh thread (single snapshotted head checkpoint)
|
||||||
with correct parent linking)
|
* head checkpoint snapshots updated DeltaChannels for Postgres read paths
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
@@ -22,6 +23,7 @@ from typing import Annotated, Any
|
|||||||
import pytest
|
import pytest
|
||||||
from langchain_core.messages import HumanMessage
|
from langchain_core.messages import HumanMessage
|
||||||
from langgraph.checkpoint.memory import InMemorySaver
|
from langgraph.checkpoint.memory import InMemorySaver
|
||||||
|
from langgraph.checkpoint.serde.types import _DeltaSnapshot
|
||||||
from typing_extensions import TypedDict
|
from typing_extensions import TypedDict
|
||||||
|
|
||||||
from langgraph.channels.delta import DeltaChannel
|
from langgraph.channels.delta import DeltaChannel
|
||||||
@@ -93,8 +95,7 @@ async def test_aupdate_state_fresh_thread_delta_channel() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_update_state_after_invoke_delta_channel() -> None:
|
def test_update_state_after_invoke_delta_channel() -> None:
|
||||||
"""The non-fresh-thread path was already working before the fix; pin it
|
"""The non-fresh-thread path must keep working across snapshot changes."""
|
||||||
down so the lazy-stub change for fresh threads doesn't regress it."""
|
|
||||||
saver = InMemorySaver()
|
saver = InMemorySaver()
|
||||||
graph = _build_graph(saver)
|
graph = _build_graph(saver)
|
||||||
config = {"configurable": {"thread_id": "after-invoke-sync"}}
|
config = {"configurable": {"thread_id": "after-invoke-sync"}}
|
||||||
@@ -133,9 +134,8 @@ async def test_aupdate_state_after_invoke_delta_channel() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_consecutive_update_states_delta_channel() -> None:
|
def test_consecutive_update_states_delta_channel() -> None:
|
||||||
"""First update_state lazily persists a stub; the second sees a real
|
"""Two consecutive fresh-thread-style updates: the first creates a
|
||||||
parent (`saved is not None`) and takes the original write path. Both
|
snapshotted head; the second anchors on it. Both messages round-trip."""
|
||||||
messages must round-trip in chronological order."""
|
|
||||||
saver = InMemorySaver()
|
saver = InMemorySaver()
|
||||||
graph = _build_graph(saver)
|
graph = _build_graph(saver)
|
||||||
config = {"configurable": {"thread_id": "consecutive-sync"}}
|
config = {"configurable": {"thread_id": "consecutive-sync"}}
|
||||||
@@ -209,10 +209,8 @@ def test_update_state_replaces_message_by_id_delta_channel() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_bulk_update_state_multi_task_per_superstep_delta_channel() -> None:
|
def test_bulk_update_state_multi_task_per_superstep_delta_channel() -> None:
|
||||||
"""`bulk_update_state` with N updates in one superstep produces N tasks
|
"""`bulk_update_state` with N updates in one superstep must accumulate
|
||||||
that each call `put_writes`. Guards the regression where moving
|
all N message writes in the snapshotted head state.
|
||||||
`put_writes` outside the per-task loop would persist only the last
|
|
||||||
task's writes.
|
|
||||||
|
|
||||||
Explicit `task_id`s are required to disambiguate writes belonging to
|
Explicit `task_id`s are required to disambiguate writes belonging to
|
||||||
different `StateUpdate`s targeting the same node — otherwise both share
|
different `StateUpdate`s targeting the same node — otherwise both share
|
||||||
@@ -252,14 +250,13 @@ def test_bulk_update_state_multi_task_per_superstep_delta_channel() -> None:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Public-API observation of the lazy-stub mechanism
|
# Public-API observation of fresh-thread checkpoint shape
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_state_history_chain_after_fresh_update_state_delta_channel() -> None:
|
def test_state_history_chain_after_fresh_update_state_delta_channel() -> None:
|
||||||
"""A fresh-thread `update_state` should produce two checkpoints visible
|
"""Fresh-thread `update_state` on snapshotted DeltaChannels yields one
|
||||||
via `get_state_history`: a stub (step=-1, no parent) and the update
|
self-contained checkpoint (step=0, no parent, source='update')."""
|
||||||
(step=0, parent=stub). Both attributed `source='update'`."""
|
|
||||||
saver = InMemorySaver()
|
saver = InMemorySaver()
|
||||||
graph = _build_graph(saver)
|
graph = _build_graph(saver)
|
||||||
config = {"configurable": {"thread_id": "history-chain"}}
|
config = {"configurable": {"thread_id": "history-chain"}}
|
||||||
@@ -270,25 +267,35 @@ def test_state_history_chain_after_fresh_update_state_delta_channel() -> None:
|
|||||||
as_node="model",
|
as_node="model",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Newest first per `get_state_history` ordering.
|
|
||||||
history = list(graph.get_state_history(config))
|
history = list(graph.get_state_history(config))
|
||||||
assert len(history) == 2
|
assert len(history) == 1
|
||||||
|
|
||||||
update_snapshot, stub_snapshot = history
|
update_snapshot = history[0]
|
||||||
|
|
||||||
assert update_snapshot.metadata is not None
|
assert update_snapshot.metadata is not None
|
||||||
assert update_snapshot.metadata["source"] == "update"
|
assert update_snapshot.metadata["source"] == "update"
|
||||||
assert update_snapshot.metadata["step"] == 0
|
assert update_snapshot.metadata["step"] == 0
|
||||||
|
assert update_snapshot.parent_config is None
|
||||||
assert [m.content for m in update_snapshot.values["messages"]] == ["hello"]
|
assert [m.content for m in update_snapshot.values["messages"]] == ["hello"]
|
||||||
|
|
||||||
assert stub_snapshot.metadata is not None
|
|
||||||
assert stub_snapshot.metadata["source"] == "update"
|
|
||||||
assert stub_snapshot.metadata["step"] == -1
|
|
||||||
assert stub_snapshot.parent_config is None
|
|
||||||
|
|
||||||
# The update checkpoint's parent is the stub.
|
def test_fresh_update_state_head_snapshots_delta_channel() -> None:
|
||||||
assert update_snapshot.parent_config is not None
|
"""Postgres checkpointers skip the ancestor walk when the head checkpoint
|
||||||
assert (
|
has no `counters_since_delta_snapshot` entry. Force-snapshot updated
|
||||||
update_snapshot.parent_config["configurable"]["checkpoint_id"]
|
DeltaChannels on the update checkpoint so the head is self-contained."""
|
||||||
== stub_snapshot.config["configurable"]["checkpoint_id"]
|
saver = InMemorySaver()
|
||||||
|
graph = _build_graph(saver)
|
||||||
|
config = {"configurable": {"thread_id": "head-snapshot"}}
|
||||||
|
|
||||||
|
graph.update_state(
|
||||||
|
config,
|
||||||
|
{"messages": [HumanMessage(content="hello", id="m1")]},
|
||||||
|
as_node="model",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
head = saver.get_tuple(config)
|
||||||
|
assert head is not None
|
||||||
|
assert isinstance(head.checkpoint["channel_values"].get("messages"), _DeltaSnapshot)
|
||||||
|
assert [m.content for m in head.checkpoint["channel_values"]["messages"].value] == [
|
||||||
|
"hello"
|
||||||
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user