Compare commits

..
Author SHA1 Message Date
Nick Hollon c19cffefc9 bump langchain-core to cb/chat-model-updates branch 2026-04-29 12:54:30 -04:00
64 changed files with 277 additions and 7248 deletions
-1
View File
@@ -100,4 +100,3 @@ dmypy.json
.turbo
.editorconfig
.scratch
.worktrees/
+1 -1
View File
@@ -16,7 +16,7 @@
<a href="https://opensource.org/licenses/MIT" target="_blank"><img src="https://img.shields.io/pypi/l/langgraph" alt="PyPI - License"></a>
<a href="https://pypistats.org/packages/langgraph" target="_blank"><img src="https://img.shields.io/pepy/dt/langgraph" alt="PyPI - Downloads"></a>
<a href="https://pypi.org/project/langgraph/" target="_blank"><img src="https://img.shields.io/pypi/v/langgraph.svg?label=%20" alt="Version"></a>
<a href="https://x.com/langchain_oss" target="_blank"><img src="https://img.shields.io/twitter/url/https/twitter.com/langchain_oss.svg?style=social&label=Follow%20%40LangChain" alt="Twitter / X"></a>
<a href="https://x.com/langchain" target="_blank"><img src="https://img.shields.io/twitter/url/https/twitter.com/langchain.svg?style=social&label=Follow%20%40LangChain" alt="Twitter / X"></a>
</div>
<br>
@@ -4,17 +4,15 @@ import threading
from collections import defaultdict
from collections.abc import Iterator, Sequence
from contextlib import contextmanager
from typing import Any, cast
from typing import Any
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.base import (
DELTA_SENTINEL,
WRITES_IDX_MAP,
ChannelVersions,
Checkpoint,
CheckpointMetadata,
CheckpointTuple,
_ChannelWritesHistory,
get_checkpoint_id,
get_serializable_checkpoint_metadata,
)
@@ -25,11 +23,7 @@ from psycopg.types.json import Jsonb
from psycopg_pool import ConnectionPool
from langgraph.checkpoint.postgres import _internal
from langgraph.checkpoint.postgres.base import (
SELECT_DELTA_COMBINED_SQL,
BasePostgresSaver,
_DeltaCombinedRow,
)
from langgraph.checkpoint.postgres.base import BasePostgresSaver
from langgraph.checkpoint.postgres.shallow import ShallowPostgresSaver
Conn = _internal.Conn # For backward compatibility
@@ -436,48 +430,6 @@ class PostgresSaver(BasePostgresSaver):
with conn.cursor(binary=True, row_factory=dict_row) as cur:
yield cur
def _get_channel_writes_history(
self, config: RunnableConfig, channel: str
) -> _ChannelWritesHistory:
"""Fast-path override of `BaseCheckpointSaver._get_channel_writes_history`.
One combined UNION ALL query (`SELECT_DELTA_COMBINED_SQL`) fetches rows
from `checkpoints`, `checkpoint_writes`, and `checkpoint_blobs` in a
single roundtrip; the ancestor walk runs in Python.
"""
thread_id = config["configurable"]["thread_id"]
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
checkpoint_id = get_checkpoint_id(config)
if checkpoint_id is None:
# Caller didn't specify a target — resolve to the latest
# checkpoint on the thread. `get_tuple` without `checkpoint_id`
# returns the newest; its config carries the resolved id.
target = self.get_tuple(config)
if target is None:
return _ChannelWritesHistory(seed=DELTA_SENTINEL, writes=[])
checkpoint_id = target.config["configurable"]["checkpoint_id"]
with self._cursor() as cur:
cur.execute(
SELECT_DELTA_COMBINED_SQL,
(
channel,
thread_id,
checkpoint_ns,
thread_id,
checkpoint_ns,
channel,
thread_id,
checkpoint_ns,
channel,
),
)
rows = cur.fetchall()
return self._build_delta_channel_writes_history(
channel=channel,
target_id=checkpoint_id,
rows=cast("list[_DeltaCombinedRow]", rows),
)
def _load_checkpoint_tuple(self, value: DictRow) -> CheckpointTuple:
"""
Convert a database row into a CheckpointTuple object.
@@ -4,17 +4,15 @@ import asyncio
from collections import defaultdict
from collections.abc import AsyncIterator, Iterator, Sequence
from contextlib import asynccontextmanager
from typing import Any, cast
from typing import Any
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.base import (
DELTA_SENTINEL,
WRITES_IDX_MAP,
ChannelVersions,
Checkpoint,
CheckpointMetadata,
CheckpointTuple,
_ChannelWritesHistory,
get_checkpoint_id,
get_serializable_checkpoint_metadata,
)
@@ -25,11 +23,7 @@ from psycopg.types.json import Jsonb
from psycopg_pool import AsyncConnectionPool
from langgraph.checkpoint.postgres import _ainternal
from langgraph.checkpoint.postgres.base import (
SELECT_DELTA_COMBINED_SQL,
BasePostgresSaver,
_DeltaCombinedRow,
)
from langgraph.checkpoint.postgres.base import BasePostgresSaver
from langgraph.checkpoint.postgres.shallow import AsyncShallowPostgresSaver
Conn = _ainternal.Conn # For backward compatibility
@@ -397,46 +391,6 @@ class AsyncPostgresSaver(BasePostgresSaver):
async with conn.cursor(binary=True, row_factory=dict_row) as cur:
yield cur
async def _aget_channel_writes_history(
self, config: RunnableConfig, channel: str
) -> _ChannelWritesHistory:
"""Fast-path override of `BaseCheckpointSaver._aget_channel_writes_history`.
One combined UNION ALL query (`SELECT_DELTA_COMBINED_SQL`) fetches rows
from `checkpoints`, `checkpoint_writes`, and `checkpoint_blobs` in a
single roundtrip; rows are assembled by the shared pure helper on
`BasePostgresSaver`.
"""
thread_id = config["configurable"]["thread_id"]
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
checkpoint_id = get_checkpoint_id(config)
if checkpoint_id is None:
target = await self.aget_tuple(config)
if target is None:
return _ChannelWritesHistory(seed=DELTA_SENTINEL, writes=[])
checkpoint_id = target.config["configurable"]["checkpoint_id"]
async with self._cursor() as cur:
await cur.execute(
SELECT_DELTA_COMBINED_SQL,
(
channel,
thread_id,
checkpoint_ns,
thread_id,
checkpoint_ns,
channel,
thread_id,
checkpoint_ns,
channel,
),
)
rows = await cur.fetchall()
return self._build_delta_channel_writes_history(
channel=channel,
target_id=checkpoint_id,
rows=cast("list[_DeltaCombinedRow]", rows),
)
async def _load_checkpoint_tuple(self, value: DictRow) -> CheckpointTuple:
"""
Convert a database row into a CheckpointTuple object.
@@ -4,16 +4,13 @@ import random
import warnings
from collections.abc import Sequence
from importlib.metadata import version as get_version
from typing import Any, TypedDict, cast
from typing import Any, cast
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.base import (
DELTA_SENTINEL,
WRITES_IDX_MAP,
BaseCheckpointSaver,
ChannelVersions,
PendingWrite,
_ChannelWritesHistory,
get_checkpoint_id,
)
from langgraph.checkpoint.serde.types import TASKS
@@ -156,62 +153,6 @@ INSERT_CHECKPOINT_WRITES_SQL = """
"""
class _DeltaCombinedRow(TypedDict, total=False):
"""One row from `SELECT_DELTA_COMBINED_SQL` (a UNION ALL of three tables).
Every row carries `_kind` ("p" / "w" / "b") plus whichever columns are
relevant for that kind; irrelevant columns are NULL and typed as `None`.
"""
_kind: str # always present: "p", "w", or "b"
# checkpoint row ("p")
checkpoint_id: str | None
parent_checkpoint_id: str | None
ver: str | None
# write / blob rows ("w", "b")
type: str | None
blob: bytes | None
# write row only ("w")
task_id: str | None
idx: int | None
# blob row only ("b")
version: str | None
# DeltaChannel reconstruction: one UNION ALL query fetches checkpoints,
# writes, and blobs for `channel` in one roundtrip; the ancestor walk runs
# in Python in `_build_delta_channel_writes_history`.
#
# Parameter order: (channel, thread_id, checkpoint_ns,
# thread_id, checkpoint_ns, channel,
# thread_id, checkpoint_ns, channel)
SELECT_DELTA_COMBINED_SQL = """
SELECT 'p'::text AS _kind,
checkpoint_id,
parent_checkpoint_id,
checkpoint -> 'channel_versions' ->> %s AS ver,
NULL::text AS type,
NULL::bytea AS blob,
NULL::text AS task_id,
NULL::int AS idx,
NULL::text AS version
FROM checkpoints
WHERE thread_id = %s AND checkpoint_ns = %s
UNION ALL
SELECT 'w',
checkpoint_id, NULL, NULL,
type, blob, task_id, idx, NULL
FROM checkpoint_writes
WHERE thread_id = %s AND checkpoint_ns = %s AND channel = %s
UNION ALL
SELECT 'b',
NULL, NULL, NULL,
type, blob, NULL, NULL, version
FROM checkpoint_blobs
WHERE thread_id = %s AND checkpoint_ns = %s AND channel = %s
"""
class BasePostgresSaver(BaseCheckpointSaver[str]):
SELECT_SQL = SELECT_SQL
SELECT_PENDING_SENDS_SQL = SELECT_PENDING_SENDS_SQL
@@ -254,83 +195,6 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
if t.decode() != "empty"
}
def _build_delta_channel_writes_history(
self,
*,
channel: str,
target_id: str,
rows: Sequence[_DeltaCombinedRow],
) -> _ChannelWritesHistory:
"""Reconstruct one delta channel's history from the combined UNION ALL rows.
Pure data transform shared by sync (`PostgresSaver`) and async
(`AsyncPostgresSaver`); both paths run `SELECT_DELTA_COMBINED_SQL`
and feed the tagged rows here.
Walk is newest → oldest from the target's parent. A non-sentinel
blob in `checkpoint_blobs` (a pre-delta snapshot) terminates the
walk and is returned as the seed so replay starts from it.
Writes stored at `target_id` itself are pending writes for the next
step and are excluded — the walk begins at the target's parent.
"""
parent_of: dict[str, str | None] = {}
ver_of: dict[str, str | None] = {}
writes_by_cid: dict[str, list[tuple[str, bytes, str, int]]] = {}
blob_by_ver: dict[str, tuple[str, bytes]] = {}
for r in rows:
kind = r["_kind"]
if kind == "p":
cid = cast(str, r["checkpoint_id"])
parent_of[cid] = r["parent_checkpoint_id"]
ver_of[cid] = r["ver"]
elif kind == "w":
cid = cast(str, r["checkpoint_id"])
writes_by_cid.setdefault(cid, []).append(
cast(
"tuple[str, bytes, str, int]",
(r["type"], r["blob"], r["task_id"], r["idx"]),
)
)
else: # kind == "b"
blob_by_ver[cast(str, r["version"])] = cast(
"tuple[str, bytes]", (r["type"], r["blob"])
)
# newest write first per ancestor (task_id DESC, idx DESC)
for ws in writes_by_cid.values():
ws.sort(key=lambda w: (w[2], w[3]), reverse=True)
ancestors: list[str] = []
cur_cid: str | None = parent_of.get(target_id)
while cur_cid is not None:
ancestors.append(cur_cid)
cur_cid = parent_of.get(cur_cid)
if not ancestors:
return _ChannelWritesHistory(seed=DELTA_SENTINEL, writes=[])
collected: list[PendingWrite] = [] # newest first; reversed at the end
for cid in ancestors:
# Collect writes first — they encode the transition FROM this
# ancestor's state to its child's and must be included even if
# this ancestor is also the seed checkpoint.
for type_tag, write_blob, task_id, _idx in writes_by_cid.get(cid, []):
val = self.serde.loads_typed((type_tag, write_blob))
collected.append((task_id, channel, val))
# Then check seed terminator.
ver = ver_of.get(cid)
if ver is not None:
seed_blob = blob_by_ver.get(ver)
if seed_blob is not None and seed_blob[0] != "empty":
blob_value = self.serde.loads_typed(seed_blob)
if blob_value is not DELTA_SENTINEL:
collected.reverse()
return _ChannelWritesHistory(seed=blob_value, writes=collected)
collected.reverse() # oldest → newest
return _ChannelWritesHistory(seed=DELTA_SENTINEL, writes=collected)
def _dump_blobs(
self,
thread_id: str,
+3 -3
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph-checkpoint-postgres"
version = "3.1.0a1"
version = "3.0.5"
description = "Library with a Postgres implementation of LangGraph checkpoint saver."
authors = []
requires-python = ">=3.10"
@@ -12,7 +12,7 @@ readme = "README.md"
license = "MIT"
license-files = ['LICENSE']
dependencies = [
"langgraph-checkpoint>=4.1.0a1,<5.0.0",
"langgraph-checkpoint>=2.1.2,<5.0.0",
"orjson>=3.11.5",
"psycopg>=3.2.0",
"psycopg-pool>=3.2.0",
@@ -20,7 +20,7 @@ dependencies = [
[project.urls]
Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-postgres"
Twitter = "https://x.com/langchain_oss"
Twitter = "https://x.com/LangChain"
Slack = "https://www.langchain.com/join-community"
Reddit = "https://www.reddit.com/r/LangChain/"
+2 -46
View File
@@ -361,9 +361,9 @@ async def test_get_checkpoint_no_channel_values(
load_checkpoint_tuple = saver._load_checkpoint_tuple
async def patched_load_checkpoint_tuple(value):
def patched_load_checkpoint_tuple(value):
value["checkpoint"].pop("channel_values", None)
return await load_checkpoint_tuple(value)
return load_checkpoint_tuple(value)
monkeypatch.setattr(
saver, "_load_checkpoint_tuple", patched_load_checkpoint_tuple
@@ -371,47 +371,3 @@ async def test_get_checkpoint_no_channel_values(
checkpoint = await saver.aget_tuple(config)
assert checkpoint.checkpoint["channel_values"] == {}
@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe"])
async def test_delta_channel_chain_reconstruction(saver_name: str) -> None:
"""AsyncPostgresSaver reconstructs DeltaChannel chain via point-lookup traversal."""
pytest.importorskip(
"langgraph.channels.delta", reason="langgraph core not installed"
)
from typing import Annotated
from langchain_core.messages import AIMessage, HumanMessage
from langgraph.channels.delta import DeltaChannel
from langgraph.graph import START, StateGraph
from langgraph.graph.message import _messages_delta_reducer
from typing_extensions import TypedDict
class State(TypedDict):
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
def respond(state: State) -> dict:
n = len(state["messages"])
return {"messages": [AIMessage(content=f"reply-{n}", id=f"ai-{n}")]}
builder = StateGraph(State)
builder.add_node("respond", respond)
builder.add_edge(START, "respond")
async with _saver(saver_name) as saver:
graph = builder.compile(checkpointer=saver)
config = {"configurable": {"thread_id": "diff-channel-test-1"}}
await graph.ainvoke({"messages": [HumanMessage(content="hi", id="h1")]}, config)
await graph.ainvoke(
{"messages": [HumanMessage(content="there", id="h2")]}, config
)
state = await graph.aget_state(config)
msgs = state.values["messages"]
assert len(msgs) == 4, f"expected 4, got {len(msgs)}: {msgs}"
assert msgs[0].content == "hi"
assert msgs[1].content == "reply-1"
assert msgs[2].content == "there"
assert msgs[3].content == "reply-3"
+2 -2
View File
@@ -259,7 +259,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
version = "4.1.0a1"
version = "4.0.3"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
@@ -307,7 +307,7 @@ test = [
[[package]]
name = "langgraph-checkpoint-postgres"
version = "3.1.0a1"
version = "3.0.5"
source = { editable = "." }
dependencies = [
{ name = "langgraph-checkpoint" },
+1 -1
View File
@@ -19,7 +19,7 @@ dependencies = [
[project.urls]
Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-sqlite"
Twitter = "https://x.com/langchain_oss"
Twitter = "https://x.com/LangChain"
Slack = "https://www.langchain.com/join-community"
Reddit = "https://www.reddit.com/r/LangChain/"
+1 -1
View File
@@ -268,7 +268,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
version = "4.1.0a1"
version = "4.0.3"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
@@ -3,7 +3,7 @@ from __future__ import annotations
import copy
import logging
from collections.abc import AsyncIterator, Collection, Iterator, Mapping, Sequence
from typing import (
from typing import ( # noqa: UP035
Any,
Generic,
Literal,
@@ -18,9 +18,6 @@ from langgraph.checkpoint.base.id import uuid6
from langgraph.checkpoint.serde.base import SerializerProtocol, maybe_add_typed_methods
from langgraph.checkpoint.serde.encrypted import EncryptedSerializer
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
from langgraph.checkpoint.serde.types import (
DELTA_SENTINEL as DELTA_SENTINEL,
)
from langgraph.checkpoint.serde.types import (
ERROR,
INTERRUPT,
@@ -31,8 +28,6 @@ from langgraph.checkpoint.serde.types import (
V = TypeVar("V", int, float, str)
PendingWrite = tuple[str, str, Any]
logger = logging.getLogger(__name__)
@@ -124,30 +119,6 @@ class CheckpointTuple(NamedTuple):
pending_writes: list[PendingWrite] | None = None
class _ChannelWritesHistory(NamedTuple):
"""Result of `BaseCheckpointSaver._get_channel_writes_history`.
Storage-level view of what one channel wrote across the ancestor chain
of a target checkpoint:
* `seed` — the nearest ancestor's stored blob value for this channel,
or `DELTA_SENTINEL` if the walk reached the root without finding a
stored value. A non-sentinel seed typically indicates a pre-delta
snapshot preserved across a channel-type migration (e.g.
`BinaryOperatorAggregate` storage extended under `DeltaChannel`).
* `writes` — on-path deltas oldest→newest, one `PendingWrite` per
step that wrote to this channel. Writes stored at the target
checkpoint itself are pending for the next super-step and are
excluded.
Experimental: method surface may change; the NamedTuple shape is the
contract.
"""
seed: Any
writes: list[PendingWrite]
class BaseCheckpointSaver(Generic[V]):
"""Base class for creating a graph checkpointer.
@@ -486,104 +457,6 @@ class BaseCheckpointSaver(Generic[V]):
"""
raise NotImplementedError
def _get_tuple_raw(self, config: RunnableConfig) -> CheckpointTuple | None:
"""Pure storage read used by `_get_channel_writes_history`.
Must return the same value as `get_tuple` but must NOT trigger channel
reconstruction; otherwise the channel-hydration path would re-enter
`_get_channel_writes_history`. Override only if `get_tuple` itself
performs channel hydration.
"""
return self.get_tuple(config)
async def _aget_tuple_raw(self, config: RunnableConfig) -> CheckpointTuple | None:
"""Async version of `_get_tuple_raw`. See docstring there."""
return await self.aget_tuple(config)
def _get_channel_writes_history(
self, config: RunnableConfig, channel: str
) -> _ChannelWritesHistory:
"""**Experimental.** Query one channel's writes along the parent chain.
Storage-level query, not channel semantics: returns `(seed, writes)`
reflecting what storage knows about a single channel across the
ancestor chain of the target checkpoint identified by `config`.
* `writes` — on-path deltas oldest→newest as `PendingWrite` tuples.
Writes stored at the target `checkpoint_id` itself are pending
for the next super-step and are excluded.
* `seed` — the nearest ancestor's stored blob value for this
channel; `DELTA_SENTINEL` if the walk reached the root without
finding a stored value. A non-sentinel seed typically indicates
a pre-delta snapshot preserved across a channel-type migration.
Walks the **parent chain** (not `list(before=...)`): for forked
threads, only on-path ancestors contribute.
Reference implementation walks `get_tuple` + `parent_config`,
inspecting each ancestor's `channel_values[channel]` for the seed
terminator. Savers with direct storage access (`InMemorySaver`,
`PostgresSaver`) override for performance; the return contract is
fixed here.
Underscore-prefixed because the method surface is experimental.
"""
collected: list[PendingWrite] = [] # newest first; reversed at the end
target_tuple = self._get_tuple_raw(config)
cursor_config: RunnableConfig | None = (
target_tuple.parent_config if target_tuple else None
)
while cursor_config is not None:
tup = self._get_tuple_raw(cursor_config)
if tup is None:
break
# Collect this ancestor's writes FIRST — they encode the
# transition from this ancestor's state to its child's, so
# they must be included whether or not this ancestor is the
# seed terminator.
if tup.pending_writes:
# Within a superstep, pending_writes are oldest→newest;
# reverse to scan newest-first.
for write in reversed(tup.pending_writes):
if write[1] != channel:
continue
collected.append(write)
# Seed terminator: any non-sentinel blob on an ancestor
# establishes the reconstruction base. Stop here.
ancestor_value = tup.checkpoint["channel_values"].get(channel)
if ancestor_value is not None and ancestor_value is not DELTA_SENTINEL:
collected.reverse()
return _ChannelWritesHistory(seed=ancestor_value, writes=collected)
cursor_config = tup.parent_config
collected.reverse()
return _ChannelWritesHistory(seed=DELTA_SENTINEL, writes=collected)
async def _aget_channel_writes_history(
self, config: RunnableConfig, channel: str
) -> _ChannelWritesHistory:
"""Async version of `_get_channel_writes_history`. See docstring there."""
collected: list[PendingWrite] = []
target_tuple = await self._aget_tuple_raw(config)
cursor_config: RunnableConfig | None = (
target_tuple.parent_config if target_tuple else None
)
while cursor_config is not None:
tup = await self._aget_tuple_raw(cursor_config)
if tup is None:
break
if tup.pending_writes:
for write in reversed(tup.pending_writes):
if write[1] != channel:
continue
collected.append(write)
ancestor_value = tup.checkpoint["channel_values"].get(channel)
if ancestor_value is not None and ancestor_value is not DELTA_SENTINEL:
collected.reverse()
return _ChannelWritesHistory(seed=ancestor_value, writes=collected)
cursor_config = tup.parent_config
collected.reverse()
return _ChannelWritesHistory(seed=DELTA_SENTINEL, writes=collected)
def get_next_version(self, current: V | None, channel: None) -> V:
"""Generate the next version ID for a channel.
@@ -14,20 +14,16 @@ from typing import Any
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.base import (
DELTA_SENTINEL,
WRITES_IDX_MAP,
BaseCheckpointSaver,
ChannelVersions,
Checkpoint,
CheckpointMetadata,
CheckpointTuple,
PendingWrite,
SerializerProtocol,
_ChannelWritesHistory,
get_checkpoint_id,
get_checkpoint_metadata,
)
from langgraph.checkpoint.serde.types import _DeltaSnapshot
logger = logging.getLogger(__name__)
@@ -125,114 +121,16 @@ class InMemorySaver(
return self.stack.__exit__(__exc_type, __exc_value, __traceback)
def _load_blobs(
self,
thread_id: str,
checkpoint_ns: str,
versions: ChannelVersions,
self, thread_id: str, checkpoint_ns: str, versions: ChannelVersions
) -> dict[str, Any]:
result: dict[str, Any] = {}
for k, ver in versions.items():
kk = (thread_id, checkpoint_ns, k, ver)
if kk not in self.blobs:
continue
vv = self.blobs[kk]
if vv[0] == "empty":
continue
result[k] = self.serde.loads_typed(vv)
return result
def _get_channel_writes_history(
self, config: RunnableConfig, channel: str
) -> _ChannelWritesHistory:
thread_id = config["configurable"]["thread_id"]
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
checkpoint_id = config["configurable"].get("checkpoint_id", "")
ns_storage = self.storage.get(thread_id, {}).get(checkpoint_ns, {})
# Walk the parent chain newest→oldest. Skip the target itself —
# writes stored AT `checkpoint_id` are pending for the next step
# (pregel applies them via `apply_writes`; they aren't part of the
# snapshot value AT `checkpoint_id`).
chain: list[str] = []
target_entry = ns_storage.get(checkpoint_id)
current: str | None = target_entry[2] if target_entry is not None else None
while current is not None:
entry = ns_storage.get(current)
if entry is None:
break
chain.append(current)
_, _, parent = entry
current = parent
# Scan newest→oldest. A pre-delta blob on an ancestor terminates the
# walk and is bound as `seed`; without this, a thread migrated from
# pre-delta storage would replay ancestor writes all the way to the
# root AND miss any value that lived only in the old blob (e.g. from
# `update_state`).
#
# At each ancestor, check the blob BEFORE processing its pending
# writes: a pre-delta blob represents the state AT that ancestor,
# which already subsumes any writes stored under it. Processing
# those writes first would fold them into the reconstructed value
# twice (once via the blob, once via replay).
collected: list[PendingWrite] = [] # newest first
for cp_id in chain: # newest → oldest
entry = ns_storage.get(cp_id)
if entry is not None:
ckpt = self.serde.loads_typed(entry[0])
ver = ckpt.get("channel_versions", {}).get(channel)
if ver is not None:
blob_entry = self.blobs.get(
(thread_id, checkpoint_ns, channel, ver)
)
if blob_entry is not None and blob_entry[0] != "empty":
blob_value = self.serde.loads_typed(blob_entry)
if blob_value is not DELTA_SENTINEL:
if isinstance(blob_value, _DeltaSnapshot):
# Step-based snapshot: the blob is state AT this
# ancestor, but the ancestor's pending_writes
# encode the NEXT step's transition and are NOT
# subsumed by the snapshot — collect them first.
step_writes = self.writes.get(
(thread_id, checkpoint_ns, cp_id), {}
)
for (_task_id, _idx), (
tid,
ch,
serialized,
_,
) in sorted(step_writes.items(), reverse=True):
if ch != channel:
continue
collected.append(
(tid, ch, self.serde.loads_typed(serialized))
)
collected.reverse()
return _ChannelWritesHistory(
seed=blob_value, writes=collected
)
# Pre-delta blob: state AT this ancestor already
# subsumes its pending_writes — skip them.
collected.reverse()
return _ChannelWritesHistory(
seed=blob_value, writes=collected
)
step_writes = self.writes.get((thread_id, checkpoint_ns, cp_id), {})
# Within a superstep, sorted by (task_id, idx) = oldest → newest;
# reverse for newest-first scan.
for (_task_id, _idx), (tid, ch, serialized, _) in sorted(
step_writes.items(), reverse=True
):
if ch != channel:
continue
val = self.serde.loads_typed(serialized)
collected.append((tid, ch, val))
collected.reverse()
return _ChannelWritesHistory(seed=DELTA_SENTINEL, writes=collected)
async def _aget_channel_writes_history(
self, config: RunnableConfig, channel: str
) -> _ChannelWritesHistory:
return self._get_channel_writes_history(config, channel)
channel_values: dict[str, Any] = {}
for k, v in versions.items():
kk = (thread_id, checkpoint_ns, k, v)
if kk in self.blobs:
vv = self.blobs[kk]
if vv[0] != "empty":
channel_values[k] = self.serde.loads_typed(vv)
return channel_values
def get_tuple(self, config: RunnableConfig) -> CheckpointTuple | None:
"""Get a checkpoint tuple from the in-memory storage.
@@ -73,7 +73,6 @@ SAFE_MSGPACK_TYPES: frozenset[tuple[str, ...]] = frozenset(
("langchain_core.documents.base", "Document"),
# langgraph
("langgraph.types", "Send"),
("langgraph.types", "TimeoutPolicy"),
("langgraph.types", "Interrupt"),
("langgraph.types", "Command"),
("langgraph.types", "StateSnapshot"),
@@ -33,18 +33,14 @@ from langchain_core.load.load import Reviver
from langgraph.checkpoint.serde import _msgpack as _lg_msgpack
from langgraph.checkpoint.serde.base import SerializerProtocol
from langgraph.checkpoint.serde.event_hooks import emit_serde_event
from langgraph.checkpoint.serde.types import (
DELTA_SENTINEL,
SendProtocol,
_DeltaSentinel,
_DeltaSnapshot,
)
from langgraph.checkpoint.serde.types import SendProtocol
from langgraph.store.base import Item
if TYPE_CHECKING:
from langgraph.checkpoint.serde._msgpack import (
AllowedMsgpackModules,
)
from langgraph.checkpoint.serde.types import SendProtocol
LC_REVIVER = Reviver()
EMPTY_BYTES = b""
@@ -321,16 +317,10 @@ EXT_METHOD_SINGLE_ARG = 3
EXT_PYDANTIC_V1 = 4
EXT_PYDANTIC_V2 = 5
EXT_NUMPY_ARRAY = 6
EXT_DELTA_SNAPSHOT = 7
EXT_DELTA_SENTINEL = 8
def _msgpack_default(obj: Any) -> str | ormsgpack.Ext:
if isinstance(obj, _DeltaSnapshot):
return ormsgpack.Ext(EXT_DELTA_SNAPSHOT, _msgpack_enc(obj.value))
elif isinstance(obj, _DeltaSentinel):
return ormsgpack.Ext(EXT_DELTA_SENTINEL, b"")
elif hasattr(obj, "model_dump") and callable(obj.model_dump): # pydantic v2
if hasattr(obj, "model_dump") and callable(obj.model_dump): # pydantic v2
return ormsgpack.Ext(
EXT_PYDANTIC_V2,
_msgpack_enc(
@@ -502,13 +492,10 @@ def _msgpack_default(obj: Any) -> str | ormsgpack.Ext:
),
)
elif isinstance(obj, SendProtocol):
args: tuple[Any, ...] = (obj.node, obj.arg)
if (timeout := getattr(obj, "timeout", None)) is not None:
args = (obj.node, obj.arg, timeout)
return ormsgpack.Ext(
EXT_CONSTRUCTOR_POS_ARGS,
_msgpack_enc(
(obj.__class__.__module__, obj.__class__.__name__, args),
(obj.__class__.__module__, obj.__class__.__name__, (obj.node, obj.arg)),
),
)
elif dataclasses.is_dataclass(obj):
@@ -559,15 +546,6 @@ def _msgpack_default(obj: Any) -> str | ormsgpack.Ext:
raise TypeError(f"Object of type {obj.__class__.__name__} is not serializable")
def _send_from_args(args: Sequence[Any]) -> Any:
# ya we have a cyclic import here ¯\_(ツ)_/¯
from langgraph.types import Send # type: ignore
if len(args) == 2:
return Send(*args)
return Send(args[0], args[1], timeout=args[2])
def _create_msgpack_ext_hook(
allowed_modules: set[tuple[str, ...]] | Literal[True] | None,
) -> Callable[[int, bytes], Any]:
@@ -656,15 +634,7 @@ def _create_msgpack_ext_hook(
return False
def ext_hook(code: int, data: bytes) -> Any:
if code == EXT_DELTA_SENTINEL:
return DELTA_SENTINEL
elif code == EXT_DELTA_SNAPSHOT:
return _DeltaSnapshot(
ormsgpack.unpackb(
data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
)
)
elif code == EXT_CONSTRUCTOR_SINGLE_ARG:
if code == EXT_CONSTRUCTOR_SINGLE_ARG:
try:
tup = ormsgpack.unpackb(
data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
@@ -685,8 +655,6 @@ def _create_msgpack_ext_hook(
)
if not _check_allowed(tup[0], tup[1]):
return tup[2]
if tup[0] == "langgraph.types" and tup[1] == "Send":
return _send_from_args(tup[2])
# module, name, args
return getattr(importlib.import_module(tup[0]), tup[1])(*tup[2])
except Exception:
@@ -800,7 +768,9 @@ def _msgpack_ext_hook_to_json(code: int, data: bytes) -> Any:
option=ormsgpack.OPT_NON_STR_KEYS,
)
if tup[0] == "langgraph.types" and tup[1] == "Send":
return _send_from_args(tup[2])
from langgraph.types import Send # type: ignore
return Send(*tup[2])
# module, name, args
return tup[2]
except Exception:
@@ -1,7 +1,6 @@
from collections.abc import Sequence
from typing import (
Any,
NamedTuple,
Protocol,
TypeVar,
runtime_checkable,
@@ -15,39 +14,6 @@ INTERRUPT = "__interrupt__"
RESUME = "__resume__"
TASKS = "__pregel_tasks"
class _DeltaSentinel:
"""Singleton marker stored (as zero bytes) in checkpoint_blobs for a
DeltaChannel field. The actual per-step writes live in checkpoint_writes
and are replayed through the reducer at load time.
Compare with `is DELTA_SENTINEL` — `loads_typed` always returns the same
module-level instance.
"""
__slots__ = ()
def __repr__(self) -> str:
return "DELTA_SENTINEL"
DELTA_SENTINEL = _DeltaSentinel()
class _DeltaSnapshot(NamedTuple):
"""Snapshot blob for a DeltaChannel with finite snapshot_frequency.
Stored in checkpoint_blobs via the `EXT_DELTA_SNAPSHOT` msgpack ext code.
The ancestor walk in `_get_channel_writes_history` terminates when it
encounters this type (any non-sentinel blob stops the walk).
`from_checkpoint` reconstructs the channel value directly from `.value`
without replaying writes — the snapshot IS the accumulated state.
"""
value: Any
Value = TypeVar("Value", covariant=True)
Update = TypeVar("Update", contravariant=True)
C = TypeVar("C")
+5 -2
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph-checkpoint"
version = "4.1.0a1"
version = "4.0.3"
description = "Library with base interfaces for LangGraph checkpoint savers."
authors = []
requires-python = ">=3.10"
@@ -18,7 +18,7 @@ dependencies = [
[project.urls]
Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint"
Twitter = "https://x.com/langchain_oss"
Twitter = "https://x.com/LangChain"
Slack = "https://www.langchain.com/join-community"
Reddit = "https://www.reddit.com/r/LangChain/"
@@ -45,6 +45,9 @@ dev = [
"pycryptodome>=3.23.0",
]
[tool.uv.sources]
langchain-core = { git = "https://github.com/langchain-ai/langchain", branch = "cb/chat-model-updates", subdirectory = "libs/core" }
[tool.hatch.build.targets.wheel]
include = ["langgraph"]
-12
View File
@@ -1048,15 +1048,3 @@ def test_msgpack_nested_pydantic_serializes_as_dict(
# No blocking should occur - inner is serialized as dict, not ext
assert "blocked" not in caplog.text.lower()
assert result == obj
def test_delta_sentinel_serde_round_trip() -> None:
from langgraph.checkpoint.base import DELTA_SENTINEL
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
serde = JsonPlusSerializer()
type_tag, blob = serde.dumps_typed(DELTA_SENTINEL)
assert type_tag == "msgpack"
assert blob # non-empty ext envelope
loaded = serde.loads_typed((type_tag, blob))
assert loaded is DELTA_SENTINEL
+2 -345
View File
@@ -6,7 +6,6 @@ from langchain_core.runnables import RunnableConfig
from pydantic import BaseModel
from langgraph.checkpoint.base import (
DELTA_SENTINEL,
Checkpoint,
CheckpointMetadata,
create_checkpoint,
@@ -209,6 +208,8 @@ class TestMemorySaver:
async def test_memory_saver() -> None:
from langgraph.checkpoint.memory import InMemorySaver
memory_saver = InMemorySaver()
assert isinstance(memory_saver, InMemorySaver)
@@ -319,347 +320,3 @@ def test_memory_saver_with_allowlist_proxy_isolated() -> None:
assert direct is not None
expected = obj.model_dump() if hasattr(obj, "model_dump") else obj.dict()
assert direct.checkpoint["channel_values"]["foo"] == expected
class TestInMemorySaverDeltaChannel:
def test_load_blobs_returns_sentinel_for_delta_channel(self) -> None:
"""_load_blobs returns DELTA_SENTINEL for delta channels (reconstruction deferred)."""
saver = InMemorySaver()
serde = JsonPlusSerializer()
thread_id, ns, channel = "t1", "", "messages"
v1 = "00000000000000000000000000000001.0000000000000000"
saver.blobs[(thread_id, ns, channel, v1)] = serde.dumps_typed(DELTA_SENTINEL)
cp1 = empty_checkpoint()
cp1["id"] = "cp1"
cp1["channel_versions"][channel] = v1
saver.storage[thread_id][ns] = {
"cp1": (serde.dumps_typed(cp1), serde.dumps_typed({}), None),
}
result = saver._load_blobs(thread_id, ns, {channel: v1})
assert channel in result
assert result[channel] is DELTA_SENTINEL
def test_get_channel_writes_collects_ancestor_writes_only(self) -> None:
"""_get_channel_writes_history collects ancestor writes oldest→newest,
and excludes writes stored at the target checkpoint itself (those are
pending writes for the next step, applied separately by pregel)."""
saver = InMemorySaver()
serde = JsonPlusSerializer()
thread_id, ns, channel = "t1", "", "messages"
cp1 = empty_checkpoint()
cp1["id"] = "cp1"
cp2 = empty_checkpoint()
cp2["id"] = "cp2"
saver.storage[thread_id][ns] = {
"cp1": (serde.dumps_typed(cp1), serde.dumps_typed({}), None),
"cp2": (serde.dumps_typed(cp2), serde.dumps_typed({}), "cp1"),
}
# Writes stored at cp1 produced the cp1 snapshot; part of history.
saver.writes[(thread_id, ns, "cp1")][("task1", 0)] = (
"task1",
channel,
serde.dumps_typed({"content": "hi"}),
"",
)
# Writes stored at cp2 are pending — they will produce cp3 when the
# step that loaded cp2 completes. They MUST NOT appear in the
# reconstructed snapshot value at cp2.
saver.writes[(thread_id, ns, "cp2")][("task2", 0)] = (
"task2",
channel,
serde.dumps_typed({"content": "pending"}),
"",
)
config: RunnableConfig = {
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": ns,
"checkpoint_id": "cp2",
}
}
result = saver._get_channel_writes_history(config, channel)
assert result.seed is DELTA_SENTINEL
values = [v for _, _, v in result.writes]
assert values == [{"content": "hi"}]
def test_get_channel_writes_at_root_returns_empty(self) -> None:
"""Reconstructing the root checkpoint's state: no ancestors → []."""
saver = InMemorySaver()
serde = JsonPlusSerializer()
thread_id, ns, channel = "t1", "", "messages"
cp1 = empty_checkpoint()
cp1["id"] = "cp1"
saver.storage[thread_id][ns] = {
"cp1": (serde.dumps_typed(cp1), serde.dumps_typed({}), None),
}
saver.writes[(thread_id, ns, "cp1")][("task1", 0)] = (
"task1",
channel,
serde.dumps_typed({"content": "pending"}),
"",
)
config: RunnableConfig = {
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": ns,
"checkpoint_id": "cp1",
}
}
result = saver._get_channel_writes_history(config, channel)
assert result.seed is DELTA_SENTINEL
assert result.writes == []
class TestBaseFallbackGetChannelWrites:
"""Exercises the `BaseCheckpointSaver._get_channel_writes_history` default
implementation — the path third-party savers inherit when they don't
override `_get_channel_writes_history` themselves.
Regression guard for a bug where the fallback passed the caller's config
(with `checkpoint_id`) straight to `self.list()`, which most savers
collapse to a single row — causing the fallback to return `[]`.
"""
def _build_saver_with_chain(self) -> tuple[InMemorySaver, str, str]:
"""Build an InMemorySaver with a 3-checkpoint chain and per-step writes
for a `messages` channel.
Returns `(saver, thread_id, namespace)`. The saver subclass deletes the
InMemorySaver override so the base class fallback is exercised.
"""
class _ThirdPartyStyleSaver(InMemorySaver):
_get_channel_writes_history = (
InMemorySaver.__mro__[1]._get_channel_writes_history # type: ignore[attr-defined]
)
_aget_channel_writes_history = (
InMemorySaver.__mro__[1]._aget_channel_writes_history # type: ignore[attr-defined]
)
saver = _ThirdPartyStyleSaver()
serde = JsonPlusSerializer()
thread_id, ns, channel = "t1", "", "messages"
cp0 = empty_checkpoint()
cp0["id"] = "00000000000000000000000000000001.0000000000000000"
cp1 = empty_checkpoint()
cp1["id"] = "00000000000000000000000000000002.0000000000000000"
cp2 = empty_checkpoint()
cp2["id"] = "00000000000000000000000000000003.0000000000000000"
saver.storage[thread_id][ns] = {
cp0["id"]: (serde.dumps_typed(cp0), serde.dumps_typed({}), None),
cp1["id"]: (serde.dumps_typed(cp1), serde.dumps_typed({}), cp0["id"]),
cp2["id"]: (serde.dumps_typed(cp2), serde.dumps_typed({}), cp1["id"]),
}
# Writes under cp0 produced cp1's state; writes under cp1 produced cp2's.
saver.writes[(thread_id, ns, cp0["id"])][("task1", 0)] = (
"task1",
channel,
serde.dumps_typed({"content": "first"}),
"",
)
saver.writes[(thread_id, ns, cp1["id"])][("task2", 0)] = (
"task2",
channel,
serde.dumps_typed({"content": "second"}),
"",
)
return saver, thread_id, ns
def test_fallback_returns_ancestor_writes_oldest_first(self) -> None:
saver, thread_id, ns = self._build_saver_with_chain()
target_id = "00000000000000000000000000000003.0000000000000000"
config: RunnableConfig = {
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": ns,
"checkpoint_id": target_id,
}
}
result = saver._get_channel_writes_history(config, "messages")
assert result.seed is DELTA_SENTINEL
values = [v for _, _, v in result.writes]
assert values == [{"content": "first"}, {"content": "second"}]
async def test_async_fallback_returns_ancestor_writes_oldest_first(self) -> None:
saver, thread_id, ns = self._build_saver_with_chain()
target_id = "00000000000000000000000000000003.0000000000000000"
config: RunnableConfig = {
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": ns,
"checkpoint_id": target_id,
}
}
result = await saver._aget_channel_writes_history(config, "messages")
assert result.seed is DELTA_SENTINEL
values = [v for _, _, v in result.writes]
assert values == [{"content": "first"}, {"content": "second"}]
async def test_async_fallback_concurrent_tasks_do_not_interfere(self) -> None:
"""Regression: the re-entrancy guard must be task-local, not thread-local.
Two concurrent `_aget_channel_writes_history` calls on the same
event-loop thread must each see their full reconstructed writes. A
`threading.local()` guard would let whichever task set it first
short-circuit the other to `writes=[]`.
"""
import asyncio
saver, thread_id, ns = self._build_saver_with_chain()
# Force the two tasks to interleave across the `set(True)` boundary:
# each `aget_tuple` yields control, so if the guard were thread-local
# the second task would observe `active=True` set by the first.
orig_aget_tuple = saver.aget_tuple
async def slow_aget_tuple(config: RunnableConfig) -> Any:
await asyncio.sleep(0)
return await orig_aget_tuple(config)
saver.aget_tuple = slow_aget_tuple # type: ignore[method-assign]
target_id = "00000000000000000000000000000003.0000000000000000"
config: RunnableConfig = {
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": ns,
"checkpoint_id": target_id,
}
}
results = await asyncio.gather(
saver._aget_channel_writes_history(config, "messages"),
saver._aget_channel_writes_history(config, "messages"),
)
expected_values = [{"content": "first"}, {"content": "second"}]
for result in results:
assert result.seed is DELTA_SENTINEL
values = [v for _, _, v in result.writes]
assert values == expected_values
class TestPreDeltaBlobTerminator:
"""Verify the pre-delta blob terminator: when the ancestor walk hits a
checkpoint whose blob for the channel is a real value (not
DELTA_SENTINEL), reconstruction seeds from it and stops. This guards
* back-compat: a thread written by pre-delta code, then extended under
delta — reconstruction must return the correct value without walking
past the last pre-delta ancestor;
* perf: without the terminator, every reconstruct-after-migration would
walk all the way to the thread root.
"""
def _build_mixed_thread(self) -> tuple[InMemorySaver, str, str, str, str]:
"""Three-checkpoint chain: cp1 (pre-delta, blob=[A]), cp2 (delta,
write=B), cp3 (delta, write=C). Reconstructing at cp3 must yield
seed=[A] + writes=[B, C].
Returns `(saver, thread_id, ns, channel, cp3_id)`.
"""
saver = InMemorySaver()
serde = JsonPlusSerializer()
thread_id, ns, channel = "t1", "", "messages"
v1 = "00000000000000000000000000000001.0"
v2 = "00000000000000000000000000000002.0"
v3 = "00000000000000000000000000000003.0"
# Pre-delta: cp1 stored a real blob for the channel.
saver.blobs[(thread_id, ns, channel, v1)] = serde.dumps_typed(["A"])
# Delta-era: cp2 and cp3 store sentinels; real writes in checkpoint_writes.
saver.blobs[(thread_id, ns, channel, v2)] = serde.dumps_typed(DELTA_SENTINEL)
saver.blobs[(thread_id, ns, channel, v3)] = serde.dumps_typed(DELTA_SENTINEL)
cp1 = empty_checkpoint()
cp1["id"] = "cp1"
cp1["channel_versions"][channel] = v1
cp2 = empty_checkpoint()
cp2["id"] = "cp2"
cp2["channel_versions"][channel] = v2
cp3 = empty_checkpoint()
cp3["id"] = "cp3"
cp3["channel_versions"][channel] = v3
saver.storage[thread_id][ns] = {
"cp1": (serde.dumps_typed(cp1), serde.dumps_typed({}), None),
"cp2": (serde.dumps_typed(cp2), serde.dumps_typed({}), "cp1"),
"cp3": (serde.dumps_typed(cp3), serde.dumps_typed({}), "cp2"),
}
# Write under cp1 would be from the pre-delta era and MUST be ignored
# (the blob already captures it). We add one and assert it is not
# folded into the reconstructed result.
saver.writes[(thread_id, ns, "cp1")][("task0", 0)] = (
"task0",
channel,
serde.dumps_typed("PRE-DELTA-WRITE"),
"",
)
saver.writes[(thread_id, ns, "cp2")][("task2", 0)] = (
"task2",
channel,
serde.dumps_typed("B"),
"",
)
saver.writes[(thread_id, ns, "cp3")][("task3", 0)] = (
"task3",
channel,
serde.dumps_typed("PENDING-AT-TARGET"),
"",
)
return saver, thread_id, ns, channel, "cp3"
def test_seed_from_pre_delta_ancestor_blob(self) -> None:
saver, thread_id, ns, channel, target = self._build_mixed_thread()
config: RunnableConfig = {
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": ns,
"checkpoint_id": target,
}
}
result = saver._get_channel_writes_history(config, channel)
# Seed came from the pre-delta blob at cp1.
assert result.seed == ["A"]
# Delta-era writes from cp2 replay through the reducer on top of seed.
# cp3 is the target — its own write is pending for the NEXT step and
# must be excluded.
values = [v for _, _, v in result.writes]
assert values == ["B"]
def test_pre_delta_blob_terminates_walk_before_older_writes(self) -> None:
"""Writes stored at the pre-delta ancestor itself must not be replayed
(the blob subsumes them)."""
saver, thread_id, ns, channel, target = self._build_mixed_thread()
config: RunnableConfig = {
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": ns,
"checkpoint_id": target,
}
}
result = saver._get_channel_writes_history(config, channel)
values = [v for _, _, v in result.writes]
# The pre-delta write under cp1 must not appear (the blob subsumes it).
assert "PRE-DELTA-WRITE" not in values
# And the pending write at the target is never folded in.
assert "PENDING-AT-TARGET" not in values
+15 -6
View File
@@ -267,10 +267,11 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.2.28"
source = { registry = "https://pypi.org/simple" }
version = "1.3.2"
source = { git = "https://github.com/langchain-ai/langchain?subdirectory=libs%2Fcore&branch=cb%2Fchat-model-updates#aee50839376e379891c99fcbe6d5264f66dedc68" }
dependencies = [
{ name = "jsonpatch" },
{ name = "langchain-protocol" },
{ name = "langsmith" },
{ name = "packaging" },
{ name = "pydantic" },
@@ -279,14 +280,22 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "uuid-utils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f8/a4/317a1a3ac1df33a64adb3670bf88bbe3b3d5baa274db6863a979db472897/langchain_core-1.2.28.tar.gz", hash = "sha256:271a3d8bd618f795fdeba112b0753980457fc90537c46a0c11998516a74dc2cb", size = 846119, upload-time = "2026-04-08T18:19:34.867Z" }
[[package]]
name = "langchain-protocol"
version = "0.0.14"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/05/bf/efb5e2ed832e4d6d45590e25a9e5191986b291b543bc6a807b48bee070b0/langchain_protocol-0.0.14.tar.gz", hash = "sha256:bc1e8553122e6ede310280462d5813023a172ff2785ccbbdec54d43f3a15e5f2", size = 5862, upload-time = "2026-04-29T16:40:18.657Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/92/32f785f077c7e898da97064f113c73fbd9ad55d1e2169cf3a391b183dedb/langchain_core-1.2.28-py3-none-any.whl", hash = "sha256:80764232581eaf8057bcefa71dbf8adc1f6a28d257ebd8b95ba9b8b452e8c6ac", size = 508727, upload-time = "2026-04-08T18:19:32.823Z" },
{ url = "https://files.pythonhosted.org/packages/c2/e9/06c47ecb2aff08f83dfa30058da3bf86be64862c19569043ed5331bbeecd/langchain_protocol-0.0.14-py3-none-any.whl", hash = "sha256:ffc35089779bd8ca217015180cef5e660fc3b074efdaa0f2e95df73583f1a047", size = 6984, upload-time = "2026-04-29T16:40:17.841Z" },
]
[[package]]
name = "langgraph-checkpoint"
version = "4.1.0a1"
version = "4.0.3"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
@@ -330,7 +339,7 @@ test = [
[package.metadata]
requires-dist = [
{ name = "langchain-core", specifier = ">=0.2.38" },
{ name = "langchain-core", git = "https://github.com/langchain-ai/langchain?subdirectory=libs%2Fcore&branch=cb%2Fchat-model-updates" },
{ name = "ormsgpack", specifier = ">=1.12.0" },
]
+1 -1
View File
@@ -29,7 +29,7 @@ inmem = [
[project.urls]
Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/cli"
Twitter = "https://x.com/langchain_oss"
Twitter = "https://x.com/LangChain"
Slack = "https://www.langchain.com/join-community"
Reddit = "https://www.reddit.com/r/LangChain/"
+1 -1
View File
@@ -18,7 +18,7 @@
<a href="https://pypi.org/project/langgraph/" target="_blank"><img src="https://img.shields.io/pypi/v/langgraph.svg?label=%20" alt="Version"></a>
<a href="https://github.com/langchain-ai/langgraph/issues" target="_blank"><img src="https://img.shields.io/github/issues-raw/langchain-ai/langgraph" alt="Open Issues"></a>
<a href="https://docs.langchain.com/oss/python/langgraph/overview" target="_blank"><img src="https://img.shields.io/badge/docs-latest-blue" alt="Docs"></a>
<a href="https://x.com/langchain_oss" target="_blank"><img src="https://img.shields.io/twitter/url/https/twitter.com/langchain_oss.svg?style=social&label=Follow%20%40LangChain" alt="Twitter / X"></a>
<a href="https://x.com/langchain" target="_blank"><img src="https://img.shields.io/twitter/url/https/twitter.com/langchain.svg?style=social&label=Follow%20%40LangChain" alt="Twitter / X"></a>
</div>
<br>
@@ -56,8 +56,6 @@ CONFIG_KEY_CHECKPOINT_NS = sys.intern("checkpoint_ns")
# holds the current checkpoint_ns, "" for root graph
CONFIG_KEY_NODE_FINISHED = sys.intern("__pregel_node_finished")
# holds a callback to be called when a node is finished
CONFIG_KEY_TIMED_ATTEMPT_OBSERVER = sys.intern("__pregel_timed_attempt_observer")
# holds a callback to be called when an idle-timed node attempt starts or finishes
CONFIG_KEY_SCRATCHPAD = sys.intern("__pregel_scratchpad")
# holds a mutable dict for temporary storage scoped to the current task
CONFIG_KEY_RUNNER_SUBMIT = sys.intern("__pregel_runner_submit")
@@ -111,7 +109,6 @@ RESERVED = {
CONFIG_KEY_CHECKPOINT_MAP,
CONFIG_KEY_CHECKPOINT_ID,
CONFIG_KEY_CHECKPOINT_NS,
CONFIG_KEY_TIMED_ATTEMPT_OBSERVER,
CONFIG_KEY_RESUME_MAP,
CONFIG_KEY_STREAM_MESSAGES_V2,
# other constants
@@ -117,19 +117,6 @@ def set_config_context(
ctx.run(_unset_config_context, config_token, run)
def create_task_in_config_context(
coro_factory: Callable[[], Coroutine[Any, Any, Any]], config: RunnableConfig
) -> asyncio.Task[Any]:
"""Create an asyncio.Task that inherits `config` as the child runnable context.
`asyncio.create_task` snapshots the current contextvars onto the new task,
so calling `create_task` while the config context is set ensures the task
sees `config` via `var_child_runnable_config` and any tracing parent.
"""
with set_config_context(config) as context:
return context.run(lambda: asyncio.create_task(coro_factory()))
# Before Python 3.11 native StrEnum is not available
class StrEnum(str, enum.Enum):
"""A string enum."""
@@ -1,25 +0,0 @@
from __future__ import annotations
from datetime import timedelta
from typing import Literal
from langgraph.types import TimeoutPolicy
_SYNC_TIMEOUT_PREFIX = (
"Node timeouts are only supported for async nodes because sync Python "
"execution cannot be safely cancelled in-process."
)
def coerce_timeout_policy(
value: float | timedelta | TimeoutPolicy | None,
) -> TimeoutPolicy | None:
"""Normalize a timeout value to positive-second policy fields."""
return TimeoutPolicy.coerce(value)
def sync_timeout_unsupported(
name: str, *, kind: Literal["Node", "Task"] = "Node"
) -> ValueError:
"""Build the canonical error for using `timeout` with a sync target."""
return ValueError(f"{_SYNC_TIMEOUT_PREFIX} {kind} {name!r} is sync.")
@@ -1,7 +1,6 @@
from langgraph.channels.any_value import AnyValue
from langgraph.channels.base import BaseChannel
from langgraph.channels.binop import BinaryOperatorAggregate
from langgraph.channels.delta import DeltaChannel
from langgraph.channels.ephemeral_value import EphemeralValue
from langgraph.channels.last_value import LastValue, LastValueAfterFinish
from langgraph.channels.named_barrier_value import (
@@ -21,7 +20,6 @@ __all__ = (
"UntrackedValue",
"EphemeralValue",
"BinaryOperatorAggregate",
"DeltaChannel",
"NamedBarrierValue",
"NamedBarrierValueAfterFinish",
# topics
+9 -16
View File
@@ -22,9 +22,10 @@ __all__ = ("BinaryOperatorAggregate",)
def _strip_extras(t): # type: ignore[no-untyped-def]
"""Strips Annotated, Required and NotRequired from a given type."""
if hasattr(t, "__origin__"):
if t.__origin__ in (Required, NotRequired):
return _strip_extras(t.__args__[0])
return _strip_extras(t.__origin__)
if hasattr(t, "__origin__") and t.__origin__ in (Required, NotRequired):
return _strip_extras(t.__args__[0])
return t
@@ -32,22 +33,11 @@ def _get_overwrite(value: Any) -> tuple[bool, Any]:
"""Inspects the given value and returns (is_overwrite, overwrite_value)."""
if isinstance(value, Overwrite):
return True, value.value
if isinstance(value, dict) and len(value) == 1 and OVERWRITE in value:
if isinstance(value, dict) and set(value.keys()) == {OVERWRITE}:
return True, value[OVERWRITE]
return False, None
def _operators_equal(a: Callable, b: Callable) -> bool:
"""Return True if two reducer operators should be considered equal.
Lambdas all share the name '<lambda>' so identity comparison is
unreliable; treat any pairing that includes a lambda as equal.
"""
if a.__name__ == "<lambda>" or b.__name__ == "<lambda>":
return True
return a is b
class BinaryOperatorAggregate(Generic[Value], BaseChannel[Value, Value, Value]):
"""Stores the result of applying a binary operator to the current value and each new value.
@@ -78,8 +68,11 @@ class BinaryOperatorAggregate(Generic[Value], BaseChannel[Value, Value, Value]):
self.value = MISSING
def __eq__(self, value: object) -> bool:
return isinstance(value, BinaryOperatorAggregate) and _operators_equal(
self.operator, value.operator
return isinstance(value, BinaryOperatorAggregate) and (
value.operator is self.operator
if value.operator.__name__ != "<lambda>"
and self.operator.__name__ != "<lambda>"
else True
)
@property
-197
View File
@@ -1,197 +0,0 @@
from __future__ import annotations
import collections.abc
import copy as _copy
from collections.abc import Callable, Sequence
from typing import Any, Generic
from langgraph.checkpoint.base import DELTA_SENTINEL, PendingWrite
from langgraph.checkpoint.serde.types import _DeltaSnapshot
from typing_extensions import Self
from langgraph._internal._typing import MISSING
from langgraph.channels.base import BaseChannel, Value
from langgraph.channels.binop import _get_overwrite, _operators_equal, _strip_extras
from langgraph.errors import (
EmptyChannelError,
ErrorCode,
InvalidUpdateError,
create_error_message,
)
__all__ = ("DeltaChannel",)
class DeltaChannel(Generic[Value], BaseChannel[Any, Any, Any]):
"""Reducer channel that stores only a sentinel in checkpoint blobs and
reconstructs state by replaying ancestor writes through the reducer.
The reducer receives the current accumulated value and a batch of writes
in one call: `reducer(state, [write1, write2, ...]) -> new_state`.
Reducers must be deterministic and batching-invariant (associative across
folds): applying two consecutive write batches separately must produce the
same state as applying their concatenation once:
reducer(reducer(state, xs), ys) == reducer(state, xs + ys)
This lets LangGraph replay checkpointed writes in larger batches than they
were originally produced without changing reconstructed state.
`snapshot_frequency=None` (default): pure delta; stores only
`DELTA_SENTINEL` in checkpoint blobs; reads replay all ancestor writes.
`snapshot_frequency=N`: `create_checkpoint` writes a full `_DeltaSnapshot`
blob every N steps, bounding replay depth to N.
Parameters:
reducer: `(state, list[writes]) -> new_state`. Must be deterministic
and batching-invariant as described above.
typ: The value type (e.g. `list`, `dict`). Inferred automatically
from the outer type when used inside `Annotated[T, DeltaChannel(...)]`.
snapshot_frequency: Every Nth pregel step writes a snapshot blob.
`None` (default) = pure delta, never snapshot.
"""
__slots__ = ("value", "reducer", "snapshot_frequency")
value: Value | Any
def __init__(
self,
reducer: Callable[[Any, Sequence[Any]], Any],
typ: type[Value] | None = None,
*,
snapshot_frequency: int | None = None,
) -> None:
if typ is None:
typ = list # type: ignore[assignment] # placeholder; overridden by _is_field_channel
super().__init__(typ)
self.reducer = reducer
self.snapshot_frequency = snapshot_frequency
typ = _strip_extras(typ)
if typ in (collections.abc.Sequence, collections.abc.MutableSequence):
typ = list
if typ in (collections.abc.Set, collections.abc.MutableSet):
typ = set
if typ in (collections.abc.Mapping, collections.abc.MutableMapping):
typ = dict
self.typ = typ
self.value: Any = MISSING
def __eq__(self, other: object) -> bool:
if not isinstance(other, DeltaChannel):
return False
if self.snapshot_frequency != other.snapshot_frequency:
return False
return _operators_equal(self.reducer, other.reducer)
@property
def ValueType(self) -> Any:
return self.typ
@property
def UpdateType(self) -> Any:
return self.typ
def is_snapshot_step(self, step: int) -> bool:
"""True if pregel should write a snapshot blob at this step."""
return (
self.snapshot_frequency is not None
and step > 0
and step % self.snapshot_frequency == 0
)
def copy(self) -> Self:
new = self.__class__(
self.reducer, self.typ, snapshot_frequency=self.snapshot_frequency
)
new.key = self.key
new.value = self.value if self.value is MISSING else _copy.copy(self.value)
return new
def from_checkpoint(self, checkpoint: Any) -> Self:
"""Initialize from a stored blob or sentinel.
Blob types (dispatched via serde ext code, not dict key inspection):
* `DELTA_SENTINEL` / `MISSING`: start empty; caller replays writes.
* `_DeltaSnapshot(value)`: restore value directly from snapshot.
* plain value (migration from old BinOp blobs): use directly.
"""
new = self.__class__(
self.reducer, self.typ, snapshot_frequency=self.snapshot_frequency
)
new.key = self.key
if checkpoint is MISSING or checkpoint is DELTA_SENTINEL:
new.value = self.typ()
elif isinstance(checkpoint, _DeltaSnapshot):
new.value = checkpoint.value
else:
new.value = checkpoint
return new
def replay_writes(self, writes: Sequence[PendingWrite]) -> None:
"""Apply ancestor writes oldest-to-newest via a single reducer call.
If any write is an Overwrite, the last one in the sequence acts as
the reset point: its value becomes the new base and only writes
after it are passed to the reducer.
"""
values = [v for _, _, v in writes]
if not values:
return
base = self.value
start = 0
for i, v in enumerate(values):
is_ow, ow_value = _get_overwrite(v)
if is_ow:
base = _copy.copy(ow_value) if ow_value is not None else self.typ()
start = i + 1
remaining = values[start:]
self.value = self.reducer(base, remaining) if remaining else base
def update(self, values: Sequence[Any]) -> bool:
if not values:
return False
overwrite_idx: int | None = None
for i, v in enumerate(values):
is_ow, _ = _get_overwrite(v)
if is_ow:
if overwrite_idx is not None:
msg = create_error_message(
message="Can receive only one Overwrite value per super-step.",
error_code=ErrorCode.INVALID_CONCURRENT_GRAPH_UPDATE,
)
raise InvalidUpdateError(msg)
overwrite_idx = i
if overwrite_idx is not None:
_, overwrite_value = _get_overwrite(values[overwrite_idx])
base = (
_copy.copy(overwrite_value)
if overwrite_value is not None
else self.typ()
)
remaining = [v for i, v in enumerate(values) if i != overwrite_idx]
self.value = self.reducer(base, remaining) if remaining else base
return True
base = self.typ() if self.value is MISSING else self.value
self.value = self.reducer(base, list(values))
return True
def get(self) -> Any:
if self.value is MISSING:
raise EmptyChannelError()
return self.value
def is_available(self) -> bool:
return self.value is not MISSING
def checkpoint(self) -> Any:
"""Return stored representation: always `DELTA_SENTINEL`.
Snapshot decisions are made by `create_checkpoint` in pregel (which
has the step number) via `is_snapshot_step`. `checkpoint()` is only
called for non-snapshot steps or when no checkpointer is available.
"""
if self.value is MISSING:
return MISSING
return DELTA_SENTINEL
+5 -75
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from collections.abc import Sequence
from enum import Enum
from typing import Any, Literal
from typing import Any
from warnings import warn
# EmptyChannelError is re-exported from langgraph.channels.base
@@ -15,13 +15,11 @@ from langgraph.warnings import LangGraphDeprecatedSinceV10
__all__ = (
"EmptyChannelError",
"ErrorCode",
"GraphDrained",
"GraphRecursionError",
"InvalidUpdateError",
"GraphBubbleUp",
"GraphInterrupt",
"NodeInterrupt",
"NodeTimeoutError",
"ParentCommand",
"EmptyInputError",
"TaskNotFound",
@@ -44,23 +42,6 @@ def create_error_message(*, message: str, error_code: ErrorCode) -> str:
)
class GraphBubbleUp(Exception):
pass
class GraphDrained(GraphBubbleUp):
"""Raised when a graph run exits early due to a drain request.
This indicates the graph stopped cooperatively at a superstep boundary
because `RunControl.request_drain()` was called (e.g., in response to
SIGTERM). The checkpoint is saved and the run can be resumed later.
"""
def __init__(self, reason: str = "shutdown") -> None:
self.reason = reason
super().__init__(f"Graph drained: {reason}")
class GraphRecursionError(RecursionError):
"""Raised when the graph has exhausted the maximum number of steps.
@@ -96,6 +77,10 @@ class InvalidUpdateError(Exception):
pass
class GraphBubbleUp(Exception):
pass
class GraphInterrupt(GraphBubbleUp):
"""Raised when a subgraph is interrupted, suppressed by the root graph.
Never raised directly, or surfaced to the user."""
@@ -140,58 +125,3 @@ class TaskNotFound(Exception):
"""Raised when the executor is unable to find a task (for distributed mode)."""
pass
class NodeTimeoutError(TimeoutError):
"""Raised when a node invocation exceeds one of its configured timeouts.
Subclasses the built-in `TimeoutError`, so existing `except TimeoutError`
handlers keep working. If the node has a `retry_policy` whose `retry_on`
permits `TimeoutError`, the attempt will be retried.
Both `idle_timeout` and `run_timeout` reflect the configured policy at the
time of the failure (each is `None` if not configured). `kind` and
`timeout` identify which one fired.
"""
node: str
timeout: float
run_timeout: float | None
idle_timeout: float | None
elapsed: float
kind: Literal["idle", "run"]
def __init__(
self,
node: str,
elapsed: float,
*,
kind: Literal["idle", "run"],
idle_timeout: float | None = None,
run_timeout: float | None = None,
) -> None:
if kind == "idle":
if idle_timeout is None:
raise ValueError("idle_timeout is required when kind='idle'")
message = (
f"Node '{node}' exceeded its idle timeout of "
f"{idle_timeout:.3f}s without making progress "
f"(elapsed: {elapsed:.3f}s)."
)
self.timeout = idle_timeout
elif kind == "run":
if run_timeout is None:
raise ValueError("run_timeout is required when kind='run'")
message = (
f"Node '{node}' exceeded its run timeout of "
f"{run_timeout:.3f}s (elapsed: {elapsed:.3f}s)."
)
self.timeout = run_timeout
else:
raise ValueError("kind must be 'idle' or 'run'")
super().__init__(message)
self.node = node
self.elapsed = elapsed
self.kind = kind
self.idle_timeout = idle_timeout
self.run_timeout = run_timeout
+6 -51
View File
@@ -5,7 +5,6 @@ import inspect
import warnings
from collections.abc import Awaitable, Callable, Sequence
from dataclasses import dataclass
from datetime import timedelta
from typing import (
Any,
Generic,
@@ -23,11 +22,6 @@ from typing_extensions import Unpack
from langgraph._internal import _serde
from langgraph._internal._constants import CACHE_NS_WRITES, PREVIOUS
from langgraph._internal._runnable import is_async_callable
from langgraph._internal._timeout import (
coerce_timeout_policy,
sync_timeout_unsupported,
)
from langgraph._internal._typing import MISSING, DeprecatedKwargs
from langgraph.channels.ephemeral_value import EphemeralValue
from langgraph.channels.last_value import LastValue
@@ -37,19 +31,13 @@ from langgraph.pregel._call import (
P,
SyncAsyncFuture,
T,
_call_with_options,
call,
get_runnable_for_entrypoint,
identifier,
)
from langgraph.pregel._read import PregelNode
from langgraph.pregel._write import ChannelWrite, ChannelWriteEntry
from langgraph.types import (
_DC_KWARGS,
CachePolicy,
RetryPolicy,
StreamMode,
TimeoutPolicy,
)
from langgraph.types import _DC_KWARGS, CachePolicy, RetryPolicy, StreamMode
from langgraph.typing import ContextT
from langgraph.warnings import LangGraphDeprecatedSinceV05, LangGraphDeprecatedSinceV10
@@ -63,7 +51,6 @@ class _TaskFunction(Generic[P, T]):
*,
retry_policy: Sequence[RetryPolicy],
cache_policy: CachePolicy[Callable[P, str | bytes]] | None = None,
timeout: TimeoutPolicy | None = None,
name: str | None = None,
) -> None:
if name is not None:
@@ -80,17 +67,15 @@ class _TaskFunction(Generic[P, T]):
self.func = func
self.retry_policy = retry_policy
self.cache_policy = cache_policy
self.timeout = timeout
functools.update_wrapper(self, func)
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> SyncAsyncFuture[T]:
return _call_with_options(
return call(
self.func,
args,
kwargs,
retry_policy=self.retry_policy,
cache_policy=self.cache_policy,
timeout=self.timeout,
*args,
**kwargs,
)
def clear_cache(self, cache: BaseCache) -> None:
@@ -113,7 +98,6 @@ def task(
name: str | None = None,
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
cache_policy: CachePolicy[Callable[P, str | bytes]] | None = None,
timeout: float | timedelta | TimeoutPolicy | None = None,
**kwargs: Unpack[DeprecatedKwargs],
) -> Callable[
[Callable[P, Awaitable[T]] | Callable[P, T]],
@@ -135,7 +119,6 @@ def task(
name: str | None = None,
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
cache_policy: CachePolicy[Callable[P, str | bytes]] | None = None,
timeout: float | timedelta | TimeoutPolicy | None = None,
**kwargs: Unpack[DeprecatedKwargs],
) -> (
Callable[[Callable[P, Awaitable[T]] | Callable[P, T]], _TaskFunction[P, T]]
@@ -159,14 +142,6 @@ def task(
name: An optional name for the task. If not provided, the function name will be used.
retry_policy: An optional retry policy (or list of policies) to use for the task in case of a failure.
cache_policy: An optional cache policy to use for the task. This allows caching of the task results.
timeout: Timeout for each task attempt. A number or `timedelta` is a hard
wall-clock cap and is not refreshed. Use `TimeoutPolicy` to configure
both a wall-clock `run_timeout` and an `idle_timeout` refreshed by
progress signals. For long-running work that doesn't naturally emit
progress, call `runtime.heartbeat()` from inside the task. When the
timeout fires, `NodeTimeoutError` is raised and the retry policy (if
any) decides whether to retry. Supported only for async tasks; sync
tasks cannot be safely cancelled in-process.
Returns:
A callable function when used as a decorator.
@@ -221,7 +196,6 @@ def task(
)
if retry_policy is None:
retry_policy = retry # type: ignore[assignment]
timeout_policy = coerce_timeout_policy(timeout)
retry_policies: Sequence[RetryPolicy] = (
()
@@ -234,15 +208,8 @@ def task(
def decorator(
func: Callable[P, Awaitable[T]] | Callable[P, T],
) -> Callable[P, SyncAsyncFuture[T]]:
if timeout_policy is not None and not is_async_callable(func):
name_ = name or getattr(func, "__name__", func.__class__.__name__)
raise sync_timeout_unsupported(str(name_), kind="Task")
return _TaskFunction(
func,
retry_policy=retry_policies,
cache_policy=cache_policy,
timeout=timeout_policy,
name=name,
func, retry_policy=retry_policies, cache_policy=cache_policy, name=name
)
if __func_or_none__ is not None:
@@ -301,15 +268,6 @@ class entrypoint(Generic[ContextT]):
passed to the workflow.
cache_policy: A cache policy to use for caching the results of the workflow.
retry_policy: A retry policy (or list of policies) to use for the workflow in case of a failure.
timeout: Timeout for each workflow attempt. A number or `timedelta` is a
hard wall-clock cap and is not refreshed. Use `TimeoutPolicy` to
configure both a wall-clock `run_timeout` and an `idle_timeout`
refreshed by progress signals. For long-running work that doesn't
naturally emit progress, call `runtime.heartbeat()` from inside the
workflow. When the timeout fires, `NodeTimeoutError` is raised and
the retry policy (if any) decides whether to retry. Supported only
for async workflows; sync workflows cannot be safely cancelled
in-process.
!!! warning "`config_schema` Deprecated"
The `config_schema` parameter is deprecated in v0.6.0 and support will be removed in v2.0.0.
@@ -442,7 +400,6 @@ class entrypoint(Generic[ContextT]):
context_schema: type[ContextT] | None = None,
cache_policy: CachePolicy | None = None,
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
timeout: float | timedelta | TimeoutPolicy | None = None,
**kwargs: Unpack[DeprecatedKwargs],
) -> None:
"""Initialize the entrypoint decorator."""
@@ -469,7 +426,6 @@ class entrypoint(Generic[ContextT]):
self.cache = cache
self.cache_policy = cache_policy
self.retry_policy = retry_policy
self.timeout = coerce_timeout_policy(timeout)
self.context_schema = context_schema
@dataclass(**_DC_KWARGS)
@@ -579,7 +535,6 @@ class entrypoint(Generic[ContextT]):
bound=bound,
triggers=[START],
channels=START,
timeout=self.timeout,
writers=[
ChannelWrite(
[
+1 -2
View File
@@ -9,7 +9,7 @@ from langgraph.store.base import BaseStore
from langgraph._internal._typing import EMPTY_SEQ
from langgraph.runtime import Runtime
from langgraph.types import CachePolicy, RetryPolicy, StreamWriter, TimeoutPolicy
from langgraph.types import CachePolicy, RetryPolicy, StreamWriter
from langgraph.typing import ContextT, NodeInputT, NodeInputT_contra
@@ -90,4 +90,3 @@ class StateNodeSpec(Generic[NodeInputT, ContextT]):
cache_policy: CachePolicy | None
ends: tuple[str, ...] | dict[str, str] | None = EMPTY_SEQ
defer: bool = False
timeout: TimeoutPolicy | None = None
-46
View File
@@ -244,52 +244,6 @@ def add_messages(
return merged
def _messages_delta_reducer(
state: list[AnyMessage], writes: list[list[AnyMessage]]
) -> list[AnyMessage]:
"""**Experimental.** Batch reducer for use with `DeltaChannel`.
Processes all writes in one pass — dedup by ID, `RemoveMessage`
tombstoning — without calling `add_messages`. Assumes writes contain
already-typed `BaseMessage` objects (no raw-dict coercion).
This reducer is batching-invariant, as required by `DeltaChannel`:
`reducer(reducer(state, xs), ys) == reducer(state, xs + ys)`.
Use `add_messages` as the reducer for `BinaryOperatorAggregate` or
anywhere raw message dicts / strings need to be coerced first.
Example::
from typing import Annotated
from langgraph.channels.delta import DeltaChannel
from langgraph.graph.message import _messages_delta_reducer
class State(TypedDict):
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
"""
from itertools import chain
index: dict[str, int] = {m.id: i for i, m in enumerate(state) if m.id is not None}
result: list[AnyMessage | None] = list(state)
for msg in chain.from_iterable(
[w] if isinstance(w, BaseMessage) else w for w in writes
):
mid = msg.id
if mid is None:
result.append(msg)
elif isinstance(msg, RemoveMessage):
if mid in index:
result[index[mid]] = None
del index[mid]
elif mid in index:
result[index[mid]] = msg
else:
index[mid] = len(result)
result.append(msg)
return [m for m in result if m is not None]
@deprecated(
"MessageGraph is deprecated in langgraph 1.0.0, to be removed in 2.0.0. Please use StateGraph with a `messages` key instead.",
category=None,
-37
View File
@@ -7,7 +7,6 @@ import warnings
from collections import defaultdict
from collections.abc import Awaitable, Callable, Hashable, Sequence
from dataclasses import is_dataclass
from datetime import timedelta
from functools import partial
from inspect import isclass, isfunction, ismethod, signature
from types import FunctionType
@@ -46,11 +45,9 @@ from langgraph._internal._fields import (
)
from langgraph._internal._pydantic import create_model
from langgraph._internal._runnable import coerce_to_runnable
from langgraph._internal._timeout import coerce_timeout_policy
from langgraph._internal._typing import EMPTY_SEQ, MISSING, DeprecatedKwargs
from langgraph.channels.base import BaseChannel
from langgraph.channels.binop import BinaryOperatorAggregate
from langgraph.channels.delta import DeltaChannel
from langgraph.channels.ephemeral_value import EphemeralValue
from langgraph.channels.last_value import LastValue, LastValueAfterFinish
from langgraph.channels.named_barrier_value import (
@@ -84,7 +81,6 @@ from langgraph.types import (
Command,
RetryPolicy,
Send,
TimeoutPolicy,
ensure_valid_checkpointer,
)
from langgraph.typing import ContextT, InputT, NodeInputT, OutputT, StateT
@@ -304,7 +300,6 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
cache_policy: CachePolicy | None = None,
destinations: dict[str, str] | tuple[str, ...] | None = None,
timeout: float | timedelta | TimeoutPolicy | None = None,
**kwargs: Unpack[DeprecatedKwargs],
) -> Self:
"""Add a new node to the `StateGraph`, input schema is inferred as the state schema.
@@ -372,7 +367,6 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
cache_policy: CachePolicy | None = None,
destinations: dict[str, str] | tuple[str, ...] | None = None,
timeout: float | timedelta | TimeoutPolicy | None = None,
**kwargs: Unpack[DeprecatedKwargs],
) -> Self:
"""Add a new node to the `StateGraph` where input schema is specified.
@@ -445,7 +439,6 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
cache_policy: CachePolicy | None = None,
destinations: dict[str, str] | tuple[str, ...] | None = None,
timeout: float | timedelta | TimeoutPolicy | None = None,
**kwargs: Unpack[DeprecatedKwargs],
) -> Self:
"""Add a new node to the `StateGraph`, input schema is inferred as the state schema.
@@ -513,7 +506,6 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
cache_policy: CachePolicy | None = None,
destinations: dict[str, str] | tuple[str, ...] | None = None,
timeout: float | timedelta | TimeoutPolicy | None = None,
**kwargs: Unpack[DeprecatedKwargs],
) -> Self:
"""Add a new node to the `StateGraph`, input schema is specified.
@@ -588,7 +580,6 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
cache_policy: CachePolicy | None = None,
destinations: dict[str, str] | tuple[str, ...] | None = None,
timeout: float | timedelta | TimeoutPolicy | None = None,
**kwargs: Unpack[DeprecatedKwargs],
) -> Self:
"""Add a new node to the `StateGraph`.
@@ -618,14 +609,6 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
!!! warning
This is only used for graph rendering and doesn't have any effect on the graph execution.
timeout: Timeout for each node attempt. A number or `timedelta` is
a hard wall-clock cap and is not refreshed. Use `TimeoutPolicy`
to configure both a wall-clock `run_timeout` and an
`idle_timeout` refreshed by progress signals. When exceeded, a
[`NodeTimeoutError`][langgraph.errors.NodeTimeoutError] is raised
and the retry policy (if any) decides whether to retry. Timeouts
are supported only for async nodes; sync nodes cannot be safely
cancelled in-process.
Example:
```python
@@ -679,7 +662,6 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
)
if input_schema is None:
input_schema = cast(type[NodeInputT] | None, input_)
timeout = coerce_timeout_policy(timeout)
if not isinstance(node, str):
action = node
@@ -775,7 +757,6 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
cache_policy=cache_policy,
ends=ends,
defer=defer,
timeout=timeout,
)
elif inferred_input_schema is not None:
self.nodes[node] = StateNodeSpec(
@@ -786,7 +767,6 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
cache_policy=cache_policy,
ends=ends,
defer=defer,
timeout=timeout,
)
else:
self.nodes[node] = StateNodeSpec[StateT, ContextT](
@@ -797,7 +777,6 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
cache_policy=cache_policy,
ends=ends,
defer=defer,
timeout=timeout,
)
input_schema = input_schema or inferred_input_schema
@@ -1111,7 +1090,6 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
CompiledStateGraph: The compiled `StateGraph`.
"""
checkpointer = ensure_valid_checkpointer(checkpointer)
serde_allowlist: set[tuple[str, ...]] | None = None
if _serde.STRICT_MSGPACK_ENABLED:
schema_types: list[type[Any]] = [
@@ -1363,7 +1341,6 @@ class CompiledStateGraph(
retry_policy=node.retry_policy,
cache_policy=node.cache_policy,
bound=node.runnable, # type: ignore[arg-type]
timeout=node.timeout,
)
else:
raise RuntimeError
@@ -1699,20 +1676,6 @@ def _is_field_channel(typ: type[Any]) -> BaseChannel | None:
# Search through all annotated medata to find channel annotations
for item in meta:
if isinstance(item, BaseChannel):
if isinstance(item, DeltaChannel) and hasattr(typ, "__origin__"):
origin = typ.__origin__
# Unwrap parameterized Required[X]/NotRequired[X] to X
# (e.g. Annotated[NotRequired[dict[...]], ...]).
if hasattr(origin, "__origin__") and origin.__origin__ in (
Required,
NotRequired,
):
origin = origin.__args__[0]
item = item.__class__(
item.reducer,
origin,
snapshot_frequency=item.snapshot_frequency,
)
return item
elif isclass(item) and issubclass(item, BaseChannel):
# ex, Annotated[int, EphemeralValue, SomeOtherAnnotation]
+2 -16
View File
@@ -80,7 +80,6 @@ from langgraph.types import (
PregelTask,
RetryPolicy,
Send,
TimeoutPolicy,
)
GetNextVersion = Callable[[V | None, None], V]
@@ -115,21 +114,13 @@ class PregelTaskWrites(NamedTuple):
class Call:
__slots__ = (
"func",
"input",
"retry_policy",
"cache_policy",
"callbacks",
"timeout",
)
__slots__ = ("func", "input", "retry_policy", "cache_policy", "callbacks")
func: Callable
input: tuple[tuple[Any, ...], dict[str, Any]]
retry_policy: Sequence[RetryPolicy] | None
cache_policy: CachePolicy | None
callbacks: Callbacks
timeout: TimeoutPolicy | None
def __init__(
self,
@@ -139,14 +130,12 @@ class Call:
retry_policy: Sequence[RetryPolicy] | None,
cache_policy: CachePolicy | None,
callbacks: Callbacks,
timeout: TimeoutPolicy | None = None,
) -> None:
self.func = func
self.input = input
self.retry_policy = retry_policy
self.cache_policy = cache_policy
self.callbacks = callbacks
self.timeout = timeout
def should_interrupt(
@@ -744,7 +733,6 @@ def prepare_single_task(
task_path[:3],
writers=proc.flat_writers,
subgraphs=proc.subgraphs,
timeout=proc.timeout,
)
else:
return PregelTask(task_id, name, task_path[:3])
@@ -882,7 +870,6 @@ def prepare_push_task_functional(
cache_key,
task_id,
in_progress_task_path,
timeout=call.timeout,
)
else:
return PregelTask(task_id, name, in_progress_task_path)
@@ -1054,7 +1041,6 @@ def prepare_push_task_send(
translated_task_path,
writers=proc.flat_writers,
subgraphs=proc.subgraphs,
timeout=packet.timeout if packet.timeout is not None else proc.timeout,
)
else:
return PregelTask(task_id, packet.node, translated_task_path)
@@ -1269,4 +1255,4 @@ def sanitize_untracked_values_in_send(
for k, v in packet.arg.items()
if not isinstance(channels.get(k), UntrackedValue)
}
return Send(node=packet.node, arg=sanitized_arg, timeout=packet.timeout)
return Send(node=packet.node, arg=sanitized_arg)
+1 -30
View File
@@ -8,7 +8,6 @@ import inspect
import sys
import types
from collections.abc import Awaitable, Callable, Generator, Sequence
from datetime import timedelta
from typing import Any, Generic, TypeVar, cast
from langchain_core.runnables import Runnable
@@ -21,13 +20,9 @@ from langgraph._internal._runnable import (
is_async_callable,
run_in_executor,
)
from langgraph._internal._timeout import (
coerce_timeout_policy,
sync_timeout_unsupported,
)
from langgraph.config import get_config
from langgraph.pregel._write import ChannelWrite, ChannelWriteEntry
from langgraph.types import CachePolicy, RetryPolicy, TimeoutPolicy
from langgraph.types import CachePolicy, RetryPolicy
##
# Utilities borrowed from cloudpickle.
@@ -260,31 +255,8 @@ def call(
*args: Any,
retry_policy: Sequence[RetryPolicy] | None = None,
cache_policy: CachePolicy | None = None,
timeout: float | timedelta | TimeoutPolicy | None = None,
**kwargs: Any,
) -> SyncAsyncFuture[T]:
return _call_with_options(
func,
args,
kwargs,
retry_policy=retry_policy,
cache_policy=cache_policy,
timeout=coerce_timeout_policy(timeout),
)
def _call_with_options(
func: Callable[P, Awaitable[T]] | Callable[P, T],
args: tuple[Any, ...],
kwargs: dict[str, Any],
*,
retry_policy: Sequence[RetryPolicy] | None = None,
cache_policy: CachePolicy | None = None,
timeout: TimeoutPolicy | None = None,
) -> SyncAsyncFuture[T]:
if timeout is not None and not is_async_callable(func):
name = getattr(func, "__name__", func.__class__.__name__)
raise sync_timeout_unsupported(name, kind="Task")
config = get_config()
impl = config[CONF][CONFIG_KEY_CALL]
fut = impl(
@@ -293,6 +265,5 @@ def _call_with_options(
retry_policy=retry_policy,
cache_policy=cache_policy,
callbacks=config["callbacks"],
timeout=timeout,
)
return fut
+16 -114
View File
@@ -1,23 +1,17 @@
from __future__ import annotations
from collections.abc import Callable, Mapping
from collections.abc import Mapping
from datetime import datetime, timezone
from typing import Any, cast
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.base import DELTA_SENTINEL, BaseCheckpointSaver, Checkpoint
from langgraph.checkpoint.base import Checkpoint
from langgraph.checkpoint.base.id import uuid6
from langgraph.checkpoint.serde.types import _DeltaSnapshot
from langgraph._internal._typing import MISSING
from langgraph.channels.base import BaseChannel
from langgraph.channels.delta import DeltaChannel
from langgraph.managed.base import ManagedValueMapping, ManagedValueSpec
LATEST_VERSION = 4
GetNextVersion = Callable[[Any, None], Any]
def empty_checkpoint() -> Checkpoint:
return Checkpoint(
@@ -37,87 +31,35 @@ def create_checkpoint(
*,
id: str | None = None,
updated_channels: set[str] | None = None,
get_next_version: GetNextVersion | None = None,
force_delta_snapshot: bool = False,
) -> Checkpoint:
"""Create a checkpoint for the given channels.
For `DeltaChannel` with `snapshot_frequency=N`, snapshot steps write a
`_DeltaSnapshot` blob rather than `DELTA_SENTINEL`, bounding the ancestor
walk to at most N steps. Snapshots are eager: even if the channel had no
write this step, a version bump is forced (via `get_next_version`) so the
blob is stored by `put()`. Without `get_next_version` (e.g. static
contexts), snapshot steps gracefully fall back to sentinel.
`force_delta_snapshot` writes available `DeltaChannel` values as snapshots
regardless of `snapshot_frequency`. This is used by `durability="exit"`,
where intermediate writes are not stored as ancestor `checkpoint_writes`.
"""
"""Create a checkpoint for the given channels."""
ts = datetime.now(timezone.utc).isoformat()
if channels is None:
values = checkpoint["channel_values"]
channel_versions = checkpoint["channel_versions"]
else:
values = {}
channel_versions = dict(checkpoint["channel_versions"])
for k in channels:
if k not in channel_versions:
if k not in checkpoint["channel_versions"]:
continue
ch = channels[k]
if (
isinstance(ch, DeltaChannel)
and (force_delta_snapshot or ch.is_snapshot_step(step))
and ch.is_available()
):
# Eager snapshot: bump version if not already written this step
# so put() includes this channel in new_versions and stores blob.
if get_next_version is not None and (
updated_channels is None or k not in updated_channels
):
channel_versions[k] = get_next_version(channel_versions[k], None)
values[k] = _DeltaSnapshot(ch.get())
else:
v = ch.checkpoint()
if v is not MISSING:
values[k] = v
v = channels[k].checkpoint()
if v is not MISSING:
values[k] = v
return Checkpoint(
v=LATEST_VERSION,
ts=ts,
id=id or str(uuid6(clock_seq=step)),
channel_values=values,
channel_versions=channel_versions,
channel_versions=checkpoint["channel_versions"],
versions_seen=checkpoint["versions_seen"],
updated_channels=None if updated_channels is None else sorted(updated_channels),
)
def _needs_replay(spec: BaseChannel, stored: object) -> bool:
"""True if `spec` is a `DeltaChannel` and the stored blob is a sentinel,
requiring an ancestor walk to reconstruct.
`_DeltaSnapshot` blobs and plain values (migration) resolve directly via
`from_checkpoint` only `DELTA_SENTINEL` / `MISSING` trigger replay.
"""
if not isinstance(spec, DeltaChannel):
return False
return stored is MISSING or stored is DELTA_SENTINEL
def channels_from_checkpoint(
specs: Mapping[str, BaseChannel | ManagedValueSpec],
checkpoint: Checkpoint,
*,
saver: BaseCheckpointSaver | None = None,
config: RunnableConfig | None = None,
) -> tuple[Mapping[str, BaseChannel], ManagedValueMapping]:
"""Hydrate channels from a checkpoint.
For most channels, `spec.from_checkpoint(checkpoint["channel_values"][k])`
is sufficient. `DeltaChannel` is the exception: sentinel blobs require an
ancestor walk via `saver._get_channel_writes_history`. The walk terminates
at the nearest `_DeltaSnapshot` blob (step-based) or a pre-migration plain
value, so read depth is bounded by `snapshot_frequency`.
"""
"""Get channels from a checkpoint."""
channel_specs: dict[str, BaseChannel] = {}
managed_specs: dict[str, ManagedValueSpec] = {}
for k, v in specs.items():
@@ -125,53 +67,13 @@ def channels_from_checkpoint(
channel_specs[k] = v
else:
managed_specs[k] = v
channels: dict[str, BaseChannel] = {}
for k, spec in channel_specs.items():
ch: BaseChannel
stored = checkpoint["channel_values"].get(k, MISSING)
if _needs_replay(spec, stored) and saver is not None and config is not None:
delta_spec = cast(DeltaChannel, spec)
history = saver._get_channel_writes_history(config, k)
replay_ch = delta_spec.from_checkpoint(history.seed)
replay_ch.replay_writes(history.writes)
ch = replay_ch
else:
ch = spec.from_checkpoint(stored)
channels[k] = ch
return channels, managed_specs
async def achannels_from_checkpoint(
specs: Mapping[str, BaseChannel | ManagedValueSpec],
checkpoint: Checkpoint,
*,
saver: BaseCheckpointSaver | None = None,
config: RunnableConfig | None = None,
) -> tuple[Mapping[str, BaseChannel], ManagedValueMapping]:
"""Async version of `channels_from_checkpoint`. See docstring there."""
channel_specs: dict[str, BaseChannel] = {}
managed_specs: dict[str, ManagedValueSpec] = {}
for k, v in specs.items():
if isinstance(v, BaseChannel):
channel_specs[k] = v
else:
managed_specs[k] = v
channels: dict[str, BaseChannel] = {}
for k, spec in channel_specs.items():
ch: BaseChannel
stored = checkpoint["channel_values"].get(k, MISSING)
if _needs_replay(spec, stored) and saver is not None and config is not None:
delta_spec = cast(DeltaChannel, spec)
history = await saver._aget_channel_writes_history(config, k)
replay_ch = delta_spec.from_checkpoint(history.seed)
replay_ch.replay_writes(history.writes)
ch = replay_ch
else:
ch = spec.from_checkpoint(stored)
channels[k] = ch
return channels, managed_specs
return (
{
k: v.from_checkpoint(checkpoint["channel_values"].get(k, MISSING))
for k, v in channel_specs.items()
},
managed_specs,
)
def copy_checkpoint(checkpoint: Checkpoint) -> Checkpoint:
+7 -46
View File
@@ -45,7 +45,6 @@ from langgraph._internal._constants import (
CONFIG_KEY_REPLAY_STATE,
CONFIG_KEY_RESUME_MAP,
CONFIG_KEY_RESUMING,
CONFIG_KEY_RUNTIME,
CONFIG_KEY_SCRATCHPAD,
CONFIG_KEY_STREAM,
CONFIG_KEY_TASK_ID,
@@ -69,7 +68,6 @@ from langgraph.callbacks import (
GraphResumeEvent,
)
from langgraph.channels.base import BaseChannel
from langgraph.channels.delta import DeltaChannel
from langgraph.channels.untracked_value import UntrackedValue
from langgraph.constants import TAG_HIDDEN
from langgraph.errors import (
@@ -94,7 +92,6 @@ from langgraph.pregel._algo import (
task_path_str,
)
from langgraph.pregel._checkpoint import (
achannels_from_checkpoint,
channels_from_checkpoint,
copy_checkpoint,
create_checkpoint,
@@ -120,7 +117,6 @@ from langgraph.pregel.debug import (
map_debug_tasks,
)
from langgraph.pregel.protocol import StreamChunk, StreamProtocol
from langgraph.runtime import RunControl, Runtime
from langgraph.types import (
All,
CachePolicy,
@@ -192,8 +188,6 @@ class PregelLoop:
_migrate_checkpoint: Callable[[Checkpoint], None] | None
submit: Submit
channels: Mapping[str, BaseChannel]
# Only set on AsyncPregelLoop; sync loops keep this as None.
_delta_write_futs: list[Any] | None = None
managed: ManagedValueMapping
checkpoint: Checkpoint
checkpoint_id_saved: str
@@ -208,12 +202,10 @@ class PregelLoop:
"input",
"pending",
"done",
"draining",
"interrupt_before",
"interrupt_after",
"out_of_steps",
]
control: RunControl | None
tasks: dict[str, PregelExecutableTask]
output: None | dict[str, Any] | Any = None
updated_channels: set[str] | None = None
@@ -321,8 +313,6 @@ class PregelLoop:
else ()
)
self.prev_checkpoint_config = None
runtime = self.config[CONF].get(CONFIG_KEY_RUNTIME)
self.control = runtime.control if isinstance(runtime, Runtime) else None
def _push_graph_lifecycle_event(
self,
@@ -330,16 +320,11 @@ class PregelLoop:
*,
interrupts: tuple[Interrupt, ...] = (),
) -> None:
# drain status never reaches lifecycle events: tick() returns False
# before pushing, and interrupts are raised through GraphInterrupt
if self.status == "draining":
raise RuntimeError("Draining status cannot emit lifecycle events")
status = self.status
if kind == "resume":
self._graph_lifecycle_events.append(
GraphResumeEvent(
run_id=None,
status=status,
status=self.status,
checkpoint_id=self.checkpoint["id"],
checkpoint_ns=self.checkpoint_ns,
)
@@ -348,7 +333,7 @@ class PregelLoop:
self._graph_lifecycle_events.append(
GraphInterruptEvent(
run_id=None,
status=status,
status=self.status,
checkpoint_id=self.checkpoint["id"],
checkpoint_ns=self.checkpoint_ns,
interrupts=interrupts,
@@ -421,7 +406,7 @@ class PregelLoop:
task = self.tasks.get(task_id)
else:
task = None
fut = self.submit(
self.submit(
self.checkpointer_put_writes,
config,
writes_to_save,
@@ -429,16 +414,12 @@ class PregelLoop:
task_path_str(task.path) if task else "",
)
else:
fut = self.submit(
self.submit(
self.checkpointer_put_writes,
config,
writes_to_save,
task_id,
)
if self._delta_write_futs is not None and any(
isinstance(self.specs.get(c), DeltaChannel) for c, _ in writes_to_save
):
self._delta_write_futs.append(fut)
# output writes
if hasattr(self, "tasks"):
self.output_writes(task_id, writes)
@@ -580,10 +561,6 @@ class PregelLoop:
self.status = "done"
return False
if self.control is not None and self.control.drain_requested:
self.status = "draining"
return False
# if there are pending writes from a previous loop, apply them
if not self.is_replaying and self.checkpoint_pending_writes:
self._match_writes(self.tasks)
@@ -913,10 +890,6 @@ class PregelLoop:
self.step,
id=self.checkpoint["id"] if exiting else None,
updated_channels=self.updated_channels,
get_next_version=self.checkpointer_get_next_version
if do_checkpoint
else None,
force_delta_snapshot=exiting and self.durability == "exit",
)
# sanitize TASK channel in the checkpoint before saving (durability=="exit")
if TASKS in self.checkpoint["channel_values"] and any(
@@ -1300,10 +1273,7 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
)
self.submit = self.stack.enter_context(BackgroundExecutor(self.config))
self.channels, self.managed = channels_from_checkpoint(
self.specs,
self.checkpoint,
saver=self.checkpointer,
config=self.checkpoint_config,
self.specs, self.checkpoint
)
self.stack.push(self._suppress_interrupt)
self.status = "input"
@@ -1398,11 +1368,6 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
metadata: CheckpointMetadata,
new_versions: ChannelVersions,
) -> RunnableConfig:
# Drain DeltaChannel write futures before committing the checkpoint so
# DELTA_SENTINEL blobs are never saved ahead of their backing writes.
if self._delta_write_futs:
futs, self._delta_write_futs = self._delta_write_futs, []
await asyncio.gather(*futs)
try:
if prev is not None:
await prev
@@ -1508,15 +1473,11 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
if saved.pending_writes is not None
else []
)
self._delta_write_futs = []
self.submit = await self.stack.enter_async_context(
AsyncBackgroundExecutor(self.config)
)
self.channels, self.managed = await achannels_from_checkpoint(
self.specs,
self.checkpoint,
saver=self.checkpointer,
config=self.checkpoint_config,
self.channels, self.managed = channels_from_checkpoint(
self.specs, self.checkpoint
)
self.stack.push(self._suppress_interrupt)
self.status = "input"
+1 -12
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
from collections.abc import AsyncIterator, Callable, Iterator, Mapping, Sequence
from datetime import timedelta
from functools import cached_property
from typing import (
Any,
@@ -12,11 +11,10 @@ from langchain_core.runnables import Runnable, RunnableConfig
from langgraph._internal._config import merge_configs
from langgraph._internal._constants import CONF, CONFIG_KEY_READ
from langgraph._internal._runnable import RunnableCallable, RunnableSeq
from langgraph._internal._timeout import coerce_timeout_policy
from langgraph.pregel._utils import find_subgraph_pregel
from langgraph.pregel._write import ChannelWrite
from langgraph.pregel.protocol import PregelProtocol
from langgraph.types import CachePolicy, RetryPolicy, TimeoutPolicy
from langgraph.types import CachePolicy, RetryPolicy
READ_TYPE = Callable[[str | Sequence[str], bool], Any | dict[str, Any]]
INPUT_CACHE_KEY_TYPE = tuple[Callable[..., Any], tuple[str, ...]]
@@ -125,13 +123,6 @@ class PregelNode:
cache_policy: CachePolicy | None
"""The cache policy to use when invoking the node."""
timeout: TimeoutPolicy | None
"""Timeout policy for a single invocation.
If exceeded, `NodeTimeoutError` is raised and the retry policy (if any)
decides whether to retry. Supported only for async nodes.
"""
tags: Sequence[str] | None
"""Tags to attach to the node for tracing."""
@@ -154,7 +145,6 @@ class PregelNode:
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
cache_policy: CachePolicy | None = None,
subgraphs: Sequence[PregelProtocol] | None = None,
timeout: float | timedelta | TimeoutPolicy | None = None,
) -> None:
self.channels = channels
self.triggers = list(triggers)
@@ -166,7 +156,6 @@ class PregelNode:
self.retry_policy = (retry_policy,)
else:
self.retry_policy = retry_policy
self.timeout = coerce_timeout_policy(timeout)
self.tags = tags
self.metadata = metadata
if subgraphs is not None:
+15 -495
View File
@@ -4,487 +4,32 @@ import asyncio
import logging
import random
import sys
import threading
import time
import weakref
from collections.abc import Awaitable, Callable, Sequence
from contextlib import suppress
from dataclasses import dataclass, replace
from datetime import datetime, timedelta, timezone
from typing import Any, Literal, NamedTuple
from dataclasses import replace
from typing import Any
from langchain_core.callbacks import BaseCallbackHandler
from langchain_core.runnables import RunnableConfig
from langgraph._internal._config import (
merge_configs,
patch_configurable,
recast_checkpoint_ns,
)
from langgraph._internal._config import patch_configurable, recast_checkpoint_ns
from langgraph._internal._constants import (
CONF,
CONFIG_KEY_CALL,
CONFIG_KEY_CHECKPOINT_ID,
CONFIG_KEY_CHECKPOINT_NS,
CONFIG_KEY_RESUMING,
CONFIG_KEY_RUNTIME,
CONFIG_KEY_SEND,
CONFIG_KEY_STREAM,
CONFIG_KEY_TASK_ID,
CONFIG_KEY_THREAD_ID,
CONFIG_KEY_TIMED_ATTEMPT_OBSERVER,
NS_SEP,
)
from langgraph._internal._runnable import create_task_in_config_context
from langgraph._internal._timeout import sync_timeout_unsupported
from langgraph.errors import GraphBubbleUp, NodeTimeoutError, ParentCommand
from langgraph.pregel.protocol import StreamProtocol
from langgraph.errors import GraphBubbleUp, ParentCommand
from langgraph.runtime import ExecutionInfo, Runtime
from langgraph.types import Command, PregelExecutableTask, RetryPolicy, TimeoutPolicy
from langgraph.types import Command, PregelExecutableTask, RetryPolicy
logger = logging.getLogger(__name__)
SUPPORTS_EXC_NOTES = sys.version_info >= (3, 11)
def _timeout_secs(value: float | timedelta) -> float:
return value.total_seconds() if isinstance(value, timedelta) else value
@dataclass(frozen=True, slots=True)
class _ResolvedTimeout:
run_timeout_secs: float | None
idle_timeout_secs: float | None
refresh_on: Literal["auto", "heartbeat"] | None
def _resolve_timeout(timeout: TimeoutPolicy) -> _ResolvedTimeout:
idle_timeout_secs = (
_timeout_secs(timeout.idle_timeout)
if timeout.idle_timeout is not None
else None
)
return _ResolvedTimeout(
run_timeout_secs=(
_timeout_secs(timeout.run_timeout)
if timeout.run_timeout is not None
else None
),
idle_timeout_secs=idle_timeout_secs,
refresh_on=timeout.refresh_on if idle_timeout_secs is not None else None,
)
class _AttemptContext(NamedTuple):
"""Immutable per-attempt metadata shared across start/progress/finish events.
Built once at attempt start and referenced (not copied) by every emitted
`_AttemptEvent`, so per-event allocation is just the small event wrapper.
Intentionally underscore-prefixed: this and `_AttemptEvent` are part of an
internal observer contract consumed by langgraph-server. Do not move to
`langgraph.types` server imports them by this path.
"""
task_id: str
task_name: str
attempt: int
run_id: str | None
thread_id: str | None
checkpoint_ns: str | None
started_at: datetime
run_timeout_secs: float | None
idle_timeout_secs: float | None
refresh_on: Literal["auto", "heartbeat"] | None
@dataclass(frozen=True, slots=True)
class _AttemptEvent:
"""One lifecycle event for a timed attempt.
Holds a reference to the shared `_AttemptContext` and the event-specific
fields. The observer must treat this and `context` as read-only they
are reused across all events for the same attempt.
"""
context: _AttemptContext
event: Literal["start", "progress", "finish"]
progress_at: datetime | None = None
finished_at: datetime | None = None
status: Literal["success", "error"] | None = None
error_type: str | None = None
error_message: str | None = None
class _TimedAttemptScope:
"""Guarded-config window for timed attempts.
The wrapped config marks writes, stream events, runtime stream writer calls,
child task scheduling, and any LangChain callback event emitted under the
node's run as observable progress when `refresh_on="auto"`.
`runtime.heartbeat()` exposes a manual progress signal for work that doesn't
otherwise emit any of these, and is the only progress signal when
`refresh_on="heartbeat"`.
Guarded writes are serialized with `close()` so cancelled background tasks
cannot persist writes past the timeout boundary. Stream/custom output is
best-effort: it is dropped after close is observed, but callbacks run outside
the lock because they may contain arbitrary user/runtime code.
"""
__slots__ = (
"__weakref__",
"_active",
"_last_progress",
"_last_progress_emit",
"_lock",
"_on_progress",
"_progress_min_interval",
"_refresh_on",
)
def __init__(
self,
on_progress: Callable[[], None] | None = None,
progress_min_interval: float = 0.0,
refresh_on: Literal["auto", "heartbeat"] | None = None,
) -> None:
self._active = True
self._last_progress = time.monotonic()
self._lock = threading.Lock()
self._on_progress = on_progress
self._progress_min_interval = progress_min_interval
self._refresh_on = refresh_on
# `-inf` so the first touch always passes the rate-limit gate.
self._last_progress_emit: float = float("-inf")
def wrap_config(self, config: RunnableConfig) -> RunnableConfig:
configurable = config.get(CONF, {})
patch: dict[str, Any] = {}
if (send := configurable.get(CONFIG_KEY_SEND)) is not None:
patch[CONFIG_KEY_SEND] = self._guard_send(send)
if (stream := configurable.get(CONFIG_KEY_STREAM)) is not None:
patch[CONFIG_KEY_STREAM] = self._guard_stream(stream)
if (call := configurable.get(CONFIG_KEY_CALL)) is not None:
patch[CONFIG_KEY_CALL] = self._guard_call(call)
if isinstance(runtime := configurable.get(CONFIG_KEY_RUNTIME), Runtime):
if self._refresh_on is not None:
patch[CONFIG_KEY_RUNTIME] = runtime.override(
stream_writer=self._guard_stream_writer(runtime.stream_writer),
heartbeat=self.touch,
)
else:
patch[CONFIG_KEY_RUNTIME] = runtime.override(
stream_writer=self._guard_stream_writer(runtime.stream_writer)
)
new_config = patch_configurable(config, patch) if patch else config
if self._refresh_on == "auto":
return merge_configs(
new_config, {"callbacks": [_IdleProgressCallbackHandler(self)]}
)
return new_config
def touch(self) -> None:
# Avoid locking this hot progress path. We accept a small race window in
# timestamp ordering because idle_timeout is expected to be coarse compared
# with scheduler/thread timing.
now = time.monotonic()
self._last_progress = now
if self._on_progress is None:
return
# Best-effort rate limit: a benign race may emit a duplicate progress
# event under heavy concurrency, which observers must already tolerate
# (callbacks fire from arbitrary threads).
if now - self._last_progress_emit < self._progress_min_interval:
return
self._last_progress_emit = now
self._on_progress()
def close(self) -> None:
with self._lock:
self._active = False
async def wait_for_idle_timeout(self, idle_timeout_s: float) -> None:
while True:
with self._lock:
if not self._active:
return
remaining = self._last_progress + idle_timeout_s - time.monotonic()
if remaining <= 0:
raise asyncio.TimeoutError
await asyncio.sleep(remaining)
def _guard_send(
self, send: Callable[[Sequence[tuple[str, Any]]], None]
) -> Callable[[Sequence[tuple[str, Any]]], None]:
def guarded_send(writes: Sequence[tuple[str, Any]]) -> None:
with self._lock:
if self._active:
if writes and self._refresh_on == "auto":
self._last_progress = time.monotonic()
send(writes)
return guarded_send
def _guard_stream(self, stream: StreamProtocol) -> StreamProtocol:
# No lock: stream callbacks fire from the event loop only, so the
# active-check + write happen atomically between awaits.
def guarded_stream(chunk: tuple[tuple[str, ...], str, Any]) -> None:
if not self._active:
return
if self._refresh_on == "auto":
self._last_progress = time.monotonic()
stream(chunk)
return StreamProtocol(guarded_stream, stream.modes)
def _guard_call(self, call: Callable[..., Any]) -> Callable[..., Any]:
# No lock: child-task scheduling happens from the event loop only.
def guarded_call(*args: Any, **kwargs: Any) -> Any:
if not self._active:
raise asyncio.CancelledError
if self._refresh_on == "auto":
self._last_progress = time.monotonic()
return call(*args, **kwargs)
return guarded_call
def _guard_stream_writer(
self, stream_writer: Callable[[Any], None]
) -> Callable[[Any], None]:
def guarded_stream_writer(chunk: Any) -> None:
with self._lock:
if not self._active:
return
if self._refresh_on == "auto":
self._last_progress = time.monotonic()
stream_writer(chunk)
return guarded_stream_writer
class _IdleProgressCallbackHandler(BaseCallbackHandler):
"""Resets the idle timeout clock on any LangChain callback event.
Inherits via `config["callbacks"]`, so it sees only events emitted by
runs descended from the node's attempt — sibling nodes do not bleed
through. Holds the scope by weakref so a child manager that outlives
the attempt cannot keep the scope alive.
"""
# Run inline so progress is recorded in callback emission order;
# thread-pool dispatch would introduce extra reordering.
run_inline = True
def __init__(self, scope: _TimedAttemptScope) -> None:
self._scope_ref = weakref.ref(scope)
def _touch(self, *args: Any, **kwargs: Any) -> None:
if (scope := self._scope_ref()) is not None:
scope.touch()
on_llm_start = _touch
on_chat_model_start = _touch
on_llm_new_token = _touch
on_llm_end = _touch
on_llm_error = _touch
on_chain_start = _touch
on_chain_end = _touch
on_chain_error = _touch
on_tool_start = _touch
on_tool_end = _touch
on_tool_error = _touch
on_retriever_start = _touch
on_retriever_end = _touch
on_retriever_error = _touch
on_agent_action = _touch
on_agent_finish = _touch
on_text = _touch
on_retry = _touch
on_custom_event = _touch
def _drain_cancelled(task: asyncio.Task[Any]) -> None:
# Mark the abandoned task's exception as retrieved so asyncio doesn't log it.
with suppress(asyncio.CancelledError):
task.exception()
def _start_timed_attempt(
task: PregelExecutableTask, config: RunnableConfig, timeout: _ResolvedTimeout
) -> _AttemptContext | None:
configurable = config.get(CONF, {})
callback = configurable.get(CONFIG_KEY_TIMED_ATTEMPT_OBSERVER)
if callback is None:
return None
runtime = configurable.get(CONFIG_KEY_RUNTIME)
execution_info = runtime.execution_info if isinstance(runtime, Runtime) else None
context = _AttemptContext(
task_id=task.id,
task_name=task.name,
attempt=execution_info.node_attempt if execution_info is not None else 1,
run_id=execution_info.run_id if execution_info is not None else None,
thread_id=execution_info.thread_id if execution_info is not None else None,
checkpoint_ns=(
execution_info.checkpoint_ns if execution_info is not None else None
),
started_at=datetime.now(timezone.utc),
run_timeout_secs=timeout.run_timeout_secs,
idle_timeout_secs=timeout.idle_timeout_secs,
refresh_on=timeout.refresh_on,
)
_dispatch_observer(callback, _AttemptEvent(context=context, event="start"))
return context
def _finish_timed_attempt(
config: RunnableConfig,
context: _AttemptContext | None,
error: BaseException | None = None,
) -> None:
if context is None:
return
callback = config.get(CONF, {}).get(CONFIG_KEY_TIMED_ATTEMPT_OBSERVER)
if callback is None:
return
_dispatch_observer(
callback,
_AttemptEvent(
context=context,
event="finish",
finished_at=datetime.now(timezone.utc),
status="error" if error is not None else "success",
error_type=type(error).__name__ if error is not None else None,
error_message=str(error) if error is not None else None,
),
)
def _emit_progress(
callback: Callable[[_AttemptEvent], None],
context: _AttemptContext,
) -> None:
_dispatch_observer(
callback,
_AttemptEvent(
context=context,
event="progress",
progress_at=datetime.now(timezone.utc),
),
)
def _dispatch_observer(
callback: Callable[[_AttemptEvent], None],
event: _AttemptEvent,
) -> None:
try:
callback(event)
except Exception:
logger.warning("Timed attempt observer failed", exc_info=True)
async def _run_timeout_watchdog(run_timeout_s: float) -> None:
await asyncio.sleep(run_timeout_s)
raise asyncio.TimeoutError
async def _arun_with_timeout(
task: PregelExecutableTask,
config: RunnableConfig,
timeout: _ResolvedTimeout,
attempt_ctx: _AttemptContext | None,
*,
stream: bool,
) -> Any:
run_timeout_s = timeout.run_timeout_secs
idle_timeout_s = timeout.idle_timeout_secs
on_progress: Callable[[], None] | None = None
if attempt_ctx is not None:
callback = config.get(CONF, {}).get(CONFIG_KEY_TIMED_ATTEMPT_OBSERVER)
if callback is not None and idle_timeout_s is not None:
on_progress = lambda: _emit_progress(callback, attempt_ctx) # noqa: E731
scope = _TimedAttemptScope(
on_progress=on_progress,
# Cap progress emission at ~4 events per idle window so token-rate
# callbacks don't flood the observer.
progress_min_interval=idle_timeout_s / 4 if idle_timeout_s is not None else 0.0,
refresh_on=timeout.refresh_on,
)
scoped_config = scope.wrap_config(config)
start = time.monotonic()
if stream:
# Yielded chunks count as progress only under `refresh_on="auto"`.
# `refresh_on="heartbeat"` is the strict mode where only explicit
# `runtime.heartbeat()` calls reset the idle clock.
async def run() -> Any:
async for _ in task.proc.astream(task.input, scoped_config):
if timeout.refresh_on == "auto":
scope.touch()
else:
async def run() -> Any:
return await task.proc.ainvoke(task.input, scoped_config)
bg = create_task_in_config_context(run, scoped_config)
watchdogs: dict[asyncio.Task[None], Literal["idle", "run"]] = {}
if idle_timeout_s is not None:
watchdogs[asyncio.create_task(scope.wait_for_idle_timeout(idle_timeout_s))] = (
"idle"
)
if run_timeout_s is not None:
watchdogs[asyncio.create_task(_run_timeout_watchdog(run_timeout_s))] = "run"
try:
done, _ = await asyncio.wait(
{bg, *watchdogs}, return_when=asyncio.FIRST_COMPLETED
)
if bg in done:
# Task completed in time.
for watchdog in watchdogs:
watchdog.cancel()
# FIRST_COMPLETED can return both; a watchdog may have
# already raised TimeoutError before we cancelled it.
for watchdog in watchdogs:
with suppress(asyncio.CancelledError, asyncio.TimeoutError):
await watchdog
return await bg
# bg was not in `done`, so every member of `done` is one of our
# watchdogs. Only a watchdog's TimeoutError converts to
# NodeTimeoutError; any TimeoutError raised by the proc itself
# propagates unchanged.
for watchdog in done:
kind = watchdogs[watchdog]
try:
await watchdog
except asyncio.TimeoutError as exc:
elapsed = time.monotonic() - start
scope.close()
task.writes.clear()
bg.cancel()
bg.add_done_callback(_drain_cancelled)
raise NodeTimeoutError(
task.name,
elapsed,
kind=kind,
idle_timeout=idle_timeout_s,
run_timeout=run_timeout_s,
) from exc
raise RuntimeError(
f"{kind} timeout watchdog completed without raising TimeoutError"
)
raise RuntimeError("timeout wait completed without task or watchdog")
except asyncio.CancelledError:
scope.close()
bg.cancel()
for watchdog in watchdogs:
watchdog.cancel()
bg.add_done_callback(_drain_cancelled)
raise
finally:
scope.close()
for watchdog in watchdogs:
watchdog.cancel()
def _ensure_execution_info(
runtime: Runtime, config: RunnableConfig, task: PregelExecutableTask
) -> Runtime:
@@ -545,10 +90,6 @@ def run_with_retry(
) -> None:
"""Run a task with retries."""
retry_policy = task.retry_policy or retry_policy
if task.timeout is not None:
# `validate_timeout_supported` catches sync nodes at compile time;
# this is a runtime safety net for paths that may bypass that validation.
raise sync_timeout_unsupported(task.name)
attempts = 0
node_first_attempt_time = time.time()
config = task.config
@@ -654,9 +195,6 @@ async def arun_with_retry(
) -> None:
"""Run a task asynchronously with retries."""
retry_policy = task.retry_policy or retry_policy
resolved_timeout = (
_resolve_timeout(task.timeout) if task.timeout is not None else None
)
attempts = 0
node_first_attempt_time = time.time()
config = task.config
@@ -691,53 +229,35 @@ async def arun_with_retry(
)
},
)
attempt_ctx = (
_start_timed_attempt(task, config, resolved_timeout)
if resolved_timeout is not None
else None
)
try:
# clear any writes from previous attempts
task.writes.clear()
if resolved_timeout is None:
if stream:
async for _ in task.proc.astream(task.input, config):
pass
break
return await task.proc.ainvoke(task.input, config)
result = await _arun_with_timeout(
task, config, resolved_timeout, attempt_ctx, stream=stream
)
_finish_timed_attempt(config, attempt_ctx)
# run the task
if stream:
async for _ in task.proc.astream(task.input, config):
pass
# if successful, end
break
return result
else:
return await task.proc.ainvoke(task.input, config)
except ParentCommand as exc:
ns: str = config[CONF][CONFIG_KEY_CHECKPOINT_NS]
cmd = exc.args[0]
# strip task_ids from namespace for comparison (ns format: "node1|node2:task_id")
if cmd.graph in (ns, recast_checkpoint_ns(ns), task.name):
try:
# this command is for the current graph, handle it
for w in task.writers:
w.invoke(cmd, config)
except Exception as writer_exc:
_finish_timed_attempt(config, attempt_ctx, writer_exc)
raise
_finish_timed_attempt(config, attempt_ctx)
# this command is for the current graph, handle it
for w in task.writers:
w.invoke(cmd, config)
break
elif cmd.graph == Command.PARENT:
# this command is for the parent graph, assign it to the parent.
exc.args = (replace(cmd, graph=_checkpoint_ns_for_parent_command(ns)),)
_finish_timed_attempt(config, attempt_ctx)
# bubble up the exception to the parent graph
# bubble up
raise
except GraphBubbleUp:
# if interrupted, end
_finish_timed_attempt(config, attempt_ctx)
raise
except Exception as exc:
_finish_timed_attempt(config, attempt_ctx, exc)
if SUPPORTS_EXC_NOTES:
exc.add_note(f"During task with name '{task.name}' and id '{task.id}'")
if not retry_policy:
@@ -46,7 +46,6 @@ from langgraph.types import (
CachePolicy,
PregelExecutableTask,
RetryPolicy,
TimeoutPolicy,
)
F = TypeVar("F", concurrent.futures.Future, asyncio.Future)
@@ -538,7 +537,6 @@ def _call(
*,
retry_policy: Sequence[RetryPolicy] | None = None,
cache_policy: CachePolicy | None = None,
timeout: TimeoutPolicy | None = None,
callbacks: Callbacks = None,
futures: weakref.ref[FuturesDict],
schedule_task: Callable[
@@ -562,7 +560,6 @@ def _call(
retry_policy=retry_policy,
cache_policy=cache_policy,
callbacks=callbacks,
timeout=timeout,
),
):
if fut := next(
@@ -627,7 +624,6 @@ def _acall(
*,
retry_policy: Sequence[RetryPolicy] | None = None,
cache_policy: CachePolicy | None = None,
timeout: TimeoutPolicy | None = None,
callbacks: Callbacks = None,
# injected dependencies
futures: weakref.ref[FuturesDict],
@@ -661,7 +657,6 @@ def _acall(
input,
retry_policy=retry_policy,
cache_policy=cache_policy,
timeout=timeout,
callbacks=callbacks,
futures=futures,
schedule_task=schedule_task,
@@ -683,7 +678,6 @@ async def _acall_impl(
*,
retry_policy: Sequence[RetryPolicy] | None = None,
cache_policy: CachePolicy | None = None,
timeout: TimeoutPolicy | None = None,
callbacks: Callbacks = None,
# injected dependencies
futures: weakref.ref[FuturesDict[asyncio.Future, asyncio.Event]],
@@ -709,7 +703,6 @@ async def _acall_impl(
retry_policy=retry_policy,
cache_policy=cache_policy,
callbacks=callbacks,
timeout=timeout,
),
):
if fut := next(
+2 -75
View File
@@ -4,27 +4,16 @@ import ast
import inspect
import re
import textwrap
from collections.abc import Callable, Sequence
from functools import partial
from collections.abc import Callable
from typing import Any
from langchain_core.runnables import (
Runnable,
RunnableLambda,
RunnableParallel,
RunnableSequence,
)
from langchain_core.runnables.base import RunnableBindingBase
from langchain_core.runnables.config import run_in_executor
from langchain_core.runnables import Runnable, RunnableLambda, RunnableSequence
from langgraph.checkpoint.base import ChannelVersions
from typing_extensions import override
from langgraph._internal._runnable import RunnableCallable, RunnableSeq
from langgraph._internal._timeout import sync_timeout_unsupported
from langgraph.pregel.protocol import PregelProtocol
_SEQUENCE_TYPES = (RunnableSeq, RunnableSequence)
def get_new_channel_versions(
previous_versions: ChannelVersions, current_versions: ChannelVersions
@@ -75,68 +64,6 @@ def find_subgraph_pregel(candidate: Runnable) -> PregelProtocol | None:
return None
def _sequence_steps(runnable: Runnable) -> Sequence[Runnable] | None:
if isinstance(runnable, _SEQUENCE_TYPES):
return runnable.steps
return None
def _parallel_steps(runnable: Runnable) -> Sequence[Runnable] | None:
if isinstance(runnable, RunnableParallel):
return tuple(runnable.steps__.values())
return None
def _has_method_override(runnable: Runnable, method_name: str) -> bool:
method = getattr(type(runnable), method_name, None)
return method is not None and method is not getattr(Runnable, method_name)
def _is_executor_backed_afunc(afunc: Callable[..., Any] | None) -> bool:
return isinstance(afunc, partial) and afunc.func is run_in_executor
def _has_native_async(runnable: Runnable) -> bool:
if isinstance(runnable, RunnableCallable):
return runnable.afunc is not None and not _is_executor_backed_afunc(
runnable.afunc
)
if isinstance(runnable, RunnableLambda):
return bool(getattr(runnable, "afunc", False))
return _has_method_override(runnable, "ainvoke")
def _runnable_has_native_async(runnable: Runnable) -> bool:
"""Return whether a runnable can be idle-timed without known sync code.
For custom runnable subclasses, an `ainvoke` override is treated as the
async contract. We do not introspect whether that implementation delegates
to blocking work internally e.g. a subclass whose `ainvoke` calls
`asyncio.to_thread(self.invoke, ...)` will pass this check but the wrapped
sync work is still uncancellable. Idle-timeout enforcement on such a
runnable will fire `NodeTimeoutError` correctly, but the background thread
will keep running until its sync work returns.
"""
while isinstance(runnable, RunnableBindingBase):
runnable = runnable.bound
steps = _sequence_steps(runnable)
if steps is None:
steps = _parallel_steps(runnable)
if steps is not None:
return all(_runnable_has_native_async(step) for step in steps)
# Raw callables and the common composition wrappers created by graph
# builders fall through here. We do not exhaustively unwrap every Runnable
# wrapper — wrappers that provide `ainvoke` are treated as owning the async
# contract.
return _has_native_async(runnable)
def validate_timeout_supported(runnable: Runnable, *, name: str) -> None:
if not _runnable_has_native_async(runnable):
raise sync_timeout_unsupported(name)
def get_function_nonlocals(func: Callable) -> list[Any]:
"""Get the nonlocal variables accessed by a function.
+27 -100
View File
@@ -17,7 +17,6 @@ from collections.abc import (
Sequence,
)
from dataclasses import is_dataclass, replace
from datetime import timedelta
from functools import partial
from inspect import isclass
from typing import (
@@ -97,7 +96,6 @@ from langgraph._internal._runnable import (
RunnableSeq,
coerce_to_runnable,
)
from langgraph._internal._timeout import coerce_timeout_policy
from langgraph._internal._typing import MISSING, DeprecatedKwargs
from langgraph.callbacks import (
GraphInterruptEvent,
@@ -111,7 +109,6 @@ from langgraph.config import get_config
from langgraph.constants import END
from langgraph.errors import (
ErrorCode,
GraphDrained,
GraphRecursionError,
InvalidUpdateError,
create_error_message,
@@ -126,7 +123,6 @@ from langgraph.pregel._algo import (
)
from langgraph.pregel._call import identifier
from langgraph.pregel._checkpoint import (
achannels_from_checkpoint,
channels_from_checkpoint,
copy_checkpoint,
create_checkpoint,
@@ -146,10 +142,7 @@ from langgraph.pregel._read import DEFAULT_BOUND, PregelNode
from langgraph.pregel._retry import RetryPolicy
from langgraph.pregel._runner import PregelRunner
from langgraph.pregel._tools import StreamToolCallHandler
from langgraph.pregel._utils import (
get_new_channel_versions,
validate_timeout_supported,
)
from langgraph.pregel._utils import get_new_channel_versions
from langgraph.pregel._validate import validate_graph, validate_keys
from langgraph.pregel._write import ChannelWrite, ChannelWriteEntry
from langgraph.pregel.debug import get_bolded_text, get_colored_text, tasks_w_writes
@@ -157,7 +150,6 @@ from langgraph.pregel.protocol import PregelProtocol, StreamChunk, StreamProtoco
from langgraph.runtime import (
DEFAULT_RUNTIME,
BaseUser,
RunControl,
Runtime,
ServerInfo,
)
@@ -183,7 +175,6 @@ from langgraph.types import (
StateUpdate,
StreamMode,
StreamPart,
TimeoutPolicy,
ensure_valid_checkpointer,
)
from langgraph.typing import ContextT, InputT, OutputT, StateT
@@ -209,7 +200,6 @@ class NodeBuilder:
"_bound",
"_retry_policy",
"_cache_policy",
"_timeout",
)
_channels: str | list[str]
@@ -220,7 +210,6 @@ class NodeBuilder:
_bound: Runnable
_retry_policy: list[RetryPolicy]
_cache_policy: CachePolicy | None
_timeout: TimeoutPolicy | None
def __init__(
self,
@@ -233,7 +222,6 @@ class NodeBuilder:
self._bound = DEFAULT_BOUND
self._retry_policy = []
self._cache_policy = None
self._timeout = None
def subscribe_only(
self,
@@ -352,11 +340,6 @@ class NodeBuilder:
self._cache_policy = policy
return self
def set_timeout(self, timeout: float | timedelta | TimeoutPolicy | None) -> Self:
"""Set the per-attempt timeout policy for this node."""
self._timeout = coerce_timeout_policy(timeout)
return self
def build(self) -> PregelNode:
"""Builds the node."""
return PregelNode(
@@ -368,7 +351,6 @@ class NodeBuilder:
bound=self._bound,
retry_policy=self._retry_policy,
cache_policy=self._cache_policy,
timeout=self._timeout,
)
@@ -905,9 +887,6 @@ class Pregel(
)
def validate(self) -> Self:
for name, node in self.nodes.items():
if node.timeout is not None:
validate_timeout_supported(node.node or node.bound, name=name)
validate_graph(
self.nodes,
{k: v for k, v in self.channels.items() if isinstance(v, BaseChannel)},
@@ -1143,10 +1122,6 @@ class Pregel(
channels, managed = channels_from_checkpoint(
self.channels,
saved.checkpoint,
saver=self.checkpointer
if isinstance(self.checkpointer, BaseCheckpointSaver)
else None,
config=saved.config,
)
# tasks for this checkpoint
next_tasks = prepare_next_tasks(
@@ -1263,13 +1238,9 @@ class Pregel(
step = saved.metadata.get("step", -1) + 1
stop = step + 2
channels, managed = await achannels_from_checkpoint(
channels, managed = channels_from_checkpoint(
self.channels,
saved.checkpoint,
saver=self.checkpointer
if isinstance(self.checkpointer, BaseCheckpointSaver)
else None,
config=saved.config,
)
# tasks for this checkpoint
next_tasks = prepare_next_tasks(
@@ -1640,11 +1611,6 @@ class Pregel(
channels, managed = channels_from_checkpoint(
self.channels,
checkpoint,
saver=self.checkpointer
if saved is not None
and isinstance(self.checkpointer, BaseCheckpointSaver)
else None,
config=saved.config if saved is not None else None,
)
values, as_node = updates[0][:2]
@@ -2088,14 +2054,9 @@ class Pregel(
)
if saved:
checkpoint_config = patch_configurable(config, saved.config[CONF])
channels, managed = await achannels_from_checkpoint(
channels, managed = channels_from_checkpoint(
self.channels,
checkpoint,
saver=self.checkpointer
if saved is not None
and isinstance(self.checkpointer, BaseCheckpointSaver)
else None,
config=saved.config if saved is not None else None,
)
values, as_node = updates[0][:2]
# no values, just clear all tasks
@@ -2572,7 +2533,6 @@ class Pregel(
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
durability: Durability | None = None,
control: RunControl | None = None,
subgraphs: bool = False,
debug: bool | None = None,
version: Literal["v2"],
@@ -2592,7 +2552,6 @@ class Pregel(
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
durability: Durability | None = None,
control: RunControl | None = None,
subgraphs: bool = False,
debug: bool | None = None,
version: Literal["v1"] = ...,
@@ -2611,7 +2570,6 @@ class Pregel(
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
durability: Durability | None = None,
control: RunControl | None = None,
subgraphs: bool = False,
debug: bool | None = None,
version: Literal["v1", "v2"] = "v1",
@@ -2656,7 +2614,6 @@ class Pregel(
- `"sync"`: Changes are persisted synchronously before the next step starts.
- `"async"`: Changes are persisted asynchronously while the next step executes.
- `"exit"`: Changes are persisted only when the graph exits.
control: Optional run control used to request cooperative drain.
subgraphs: Whether to stream events from inside subgraphs, defaults to `False`.
If `True`, the events will be emitted as tuples `(namespace, data)`,
@@ -2821,7 +2778,6 @@ class Pregel(
previous=None,
execution_info=None,
server_info=server_info,
control=control or parent_runtime.control or RunControl(),
)
runtime = parent_runtime.merge(runtime)
config[CONF][CONFIG_KEY_RUNTIME] = runtime
@@ -2952,10 +2908,6 @@ class Pregel(
error_code=ErrorCode.GRAPH_RECURSION_LIMIT,
)
raise GraphRecursionError(msg)
elif loop.status == "draining":
if loop.control is None:
raise RuntimeError("Draining status requires run control")
raise GraphDrained(loop.control.drain_reason or "shutdown")
# set final channel values as run output
run_manager.on_chain_end(loop.output)
except BaseException as e:
@@ -2976,7 +2928,6 @@ class Pregel(
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
durability: Durability | None = None,
control: RunControl | None = None,
subgraphs: bool = False,
debug: bool | None = None,
version: Literal["v2"],
@@ -2996,7 +2947,6 @@ class Pregel(
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
durability: Durability | None = None,
control: RunControl | None = None,
subgraphs: bool = False,
debug: bool | None = None,
version: Literal["v1"] = ...,
@@ -3015,7 +2965,6 @@ class Pregel(
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
durability: Durability | None = None,
control: RunControl | None = None,
subgraphs: bool = False,
debug: bool | None = None,
version: Literal["v1", "v2"] = "v1",
@@ -3060,7 +3009,6 @@ class Pregel(
- `"sync"`: Changes are persisted synchronously before the next step starts.
- `"async"`: Changes are persisted asynchronously while the next step executes.
- `"exit"`: Changes are persisted only when the graph exits.
control: Optional run control used to request cooperative drain.
subgraphs: Whether to stream events from inside subgraphs, defaults to `False`.
If `True`, the events will be emitted as tuples `(namespace, data)`,
@@ -3260,7 +3208,6 @@ class Pregel(
previous=None,
execution_info=None,
server_info=server_info,
control=control or parent_runtime.control or RunControl(),
)
runtime = parent_runtime.merge(runtime)
config[CONF][CONFIG_KEY_RUNTIME] = runtime
@@ -3429,10 +3376,6 @@ class Pregel(
error_code=ErrorCode.GRAPH_RECURSION_LIMIT,
)
raise GraphRecursionError(msg)
elif loop.status == "draining":
if loop.control is None:
raise RuntimeError("Draining status requires run control")
raise GraphDrained(loop.control.drain_reason or "shutdown")
# set final channel values as run output
await run_manager.on_chain_end(loop.output)
except BaseException as e:
@@ -3447,37 +3390,35 @@ class Pregel(
*,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
control: RunControl | None = None,
transformers: Sequence[Callable[[tuple[str, ...]], Any]] | None = None,
) -> Any:
"""Start a sync v2 streaming run driven by transformer projections.
Builds a `StreamMux` from the built-in transformers, this
graph's compile-time `stream_transformers`, and any additional
`transformers=` supplied at the call site. Returns a
`GraphRunStream` that the caller drives by iterating any
projection no background thread.
`run.output`, `run.interrupted` and `run.interrupts` work
regardless of which transformers are registered.
Builds a `StreamMux` from the built-in `ValuesTransformer` /
`MessagesTransformer`, this graph's compile-time
`stream_transformers`, and any additional `transformers=`
supplied at the call site. Returns a `GraphRunStream` that the
caller drives by iterating any projection no background
thread.
Note:
Nesting v1 `stream(stream_mode="messages")` inside a node
of a `stream_v2` run is not fully supported. The outer v2
messages handler reroutes `BaseChatModel.invoke` through
the v2 event protocol, so the inner v1 handler does not see
`on_llm_new_token` chunks. The inner stream still yields a
finalized message via `on_llm_end`. Use `stream_v2` for
the inner graph as well, or call
`chat_model.stream(...)` explicitly, to get token-level
streaming.
messages handler is inheritable, so it sits in the inner
chat model's callback chain; `BaseChatModel.invoke` then
routes through the v2 event protocol and the inner v1
messages handler does not see `on_llm_new_token` chunks.
The inner stream still yields a finalized message via
`on_llm_end`, but token-by-token output is lost. Use
`stream_v2` for the inner graph as well, or call
`chat_model.stream(...)` explicitly inside the node, to
get token-level streaming.
Args:
input: Graph input.
config: Optional runnable config forwarded to the graph.
interrupt_before: Nodes to interrupt before, if any.
interrupt_after: Nodes to interrupt after, if any.
control: Optional run control used to request cooperative drain.
transformers: Extra transformer classes or configured factories
appended after compile-time `stream_transformers`. Factories
are called as `factory(scope)` so they can propagate to
@@ -3503,6 +3444,7 @@ class Pregel(
scope=parent_ns,
is_async=False,
)
values_t = cast(ValuesTransformer, mux.transformer_by_key("values"))
graph_iter = iter(
self.stream(
input,
@@ -3512,10 +3454,9 @@ class Pregel(
version="v2",
interrupt_before=interrupt_before,
interrupt_after=interrupt_after,
control=control,
)
)
return GraphRunStream(graph_iter, mux)
return GraphRunStream(graph_iter, mux, values_t)
async def astream_v2(
self,
@@ -3524,7 +3465,6 @@ class Pregel(
*,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
control: RunControl | None = None,
transformers: Sequence[Callable[[tuple[str, ...]], Any]] | None = None,
) -> Any:
"""Async counterpart to `stream_v2`.
@@ -3538,16 +3478,17 @@ class Pregel(
`astream(stream_mode="messages")` inside a node of an
`astream_v2` run drops `on_llm_new_token` chunks because
the outer v2 handler reroutes `BaseChatModel.invoke`
through the v2 event protocol. Use `astream_v2` for the
inner graph as well, or call `chat_model.astream(...)`
explicitly, to get token-level streaming.
through the v2 event protocol. The inner stream still
yields a finalized message at end-of-call. Use
`astream_v2` for the inner graph as well, or call
`chat_model.astream(...)` explicitly inside the node, to
get token-level streaming.
Args:
input: Graph input.
config: Optional runnable config forwarded to the graph.
interrupt_before: Nodes to interrupt before, if any.
interrupt_after: Nodes to interrupt after, if any.
control: Optional run control used to request cooperative drain.
transformers: Extra transformer classes or configured factories
appended after compile-time `stream_transformers`. Factories
are called as `factory(scope)` so they can propagate to
@@ -3570,6 +3511,7 @@ class Pregel(
scope=parent_ns,
is_async=True,
)
values_t = cast(ValuesTransformer, mux.transformer_by_key("values"))
graph_aiter = self.astream(
input,
patch_configurable(config, {CONFIG_KEY_STREAM_MESSAGES_V2: True}),
@@ -3578,9 +3520,8 @@ class Pregel(
version="v2",
interrupt_before=interrupt_before,
interrupt_after=interrupt_after,
control=control,
).__aiter__()
return AsyncGraphRunStream(graph_aiter, mux)
return AsyncGraphRunStream(graph_aiter, mux, values_t)
@overload
def invoke(
@@ -3595,7 +3536,6 @@ class Pregel(
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
durability: Durability | None = None,
control: RunControl | None = None,
version: Literal["v2"],
**kwargs: Any,
) -> GraphOutput[OutputT]: ...
@@ -3613,7 +3553,6 @@ class Pregel(
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
durability: Durability | None = None,
control: RunControl | None = None,
version: Literal["v2"],
**kwargs: Any,
) -> list[StreamPart[StateT, OutputT]]: ...
@@ -3631,7 +3570,6 @@ class Pregel(
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
durability: Durability | None = None,
control: RunControl | None = None,
version: Literal["v1"] = ...,
**kwargs: Any,
) -> dict[str, Any] | Any: ...
@@ -3648,7 +3586,6 @@ class Pregel(
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
durability: Durability | None = None,
control: RunControl | None = None,
version: Literal["v1", "v2"] = "v1",
**kwargs: Any,
) -> dict[str, Any] | Any:
@@ -3673,7 +3610,6 @@ class Pregel(
- `"sync"`: Changes are persisted synchronously before the next step starts.
- `"async"`: Changes are persisted asynchronously while the next step executes.
- `"exit"`: Changes are persisted only when the graph exits.
control: Optional run control used to request cooperative drain.
version: The streaming format version. `"v1"` (default) returns the
traditional format, `"v2"` returns `StreamPart` typed dicts when
`stream_mode` is not `"values"`.
@@ -3701,7 +3637,6 @@ class Pregel(
interrupt_before=interrupt_before,
interrupt_after=interrupt_after,
durability=durability,
control=control,
version=version,
**kwargs,
):
@@ -3725,7 +3660,6 @@ class Pregel(
interrupt_before=interrupt_before,
interrupt_after=interrupt_after,
durability=durability,
control=control,
**kwargs,
):
if stream_mode == "values":
@@ -3772,7 +3706,6 @@ class Pregel(
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
durability: Durability | None = None,
control: RunControl | None = None,
version: Literal["v2"],
**kwargs: Any,
) -> GraphOutput[OutputT]: ...
@@ -3790,7 +3723,6 @@ class Pregel(
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
durability: Durability | None = None,
control: RunControl | None = None,
version: Literal["v2"],
**kwargs: Any,
) -> list[StreamPart[StateT, OutputT]]: ...
@@ -3808,7 +3740,6 @@ class Pregel(
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
durability: Durability | None = None,
control: RunControl | None = None,
version: Literal["v1"] = ...,
**kwargs: Any,
) -> dict[str, Any] | Any: ...
@@ -3825,7 +3756,6 @@ class Pregel(
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
durability: Durability | None = None,
control: RunControl | None = None,
version: Literal["v1", "v2"] = "v1",
**kwargs: Any,
) -> dict[str, Any] | Any:
@@ -3850,7 +3780,6 @@ class Pregel(
- `"sync"`: Changes are persisted synchronously before the next step starts.
- `"async"`: Changes are persisted asynchronously while the next step executes.
- `"exit"`: Changes are persisted only when the graph exits.
control: Optional run control used to request cooperative drain.
version: The streaming format version. `"v1"` (default) returns the
traditional format, `"v2"` returns `StreamPart` typed dicts when
`stream_mode` is not `"values"`.
@@ -3878,7 +3807,6 @@ class Pregel(
interrupt_before=interrupt_before,
interrupt_after=interrupt_after,
durability=durability,
control=control,
version=version,
**kwargs,
):
@@ -3902,7 +3830,6 @@ class Pregel(
interrupt_before=interrupt_before,
interrupt_after=interrupt_after,
durability=durability,
control=control,
**kwargs,
):
if stream_mode == "values":
+2 -68
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field, replace
from typing import Any, Generic, cast
@@ -16,7 +15,6 @@ from langgraph.typing import ContextT
__all__ = (
"BaseUser",
"ExecutionInfo",
"RunControl",
"Runtime",
"ServerInfo",
"get_runtime",
@@ -76,49 +74,16 @@ class ServerInfo:
"""
class RunControl:
"""Run-scoped control surface for cooperative draining.
Intended for a single graph run. Create a fresh `RunControl` per run;
reusing a control after `request_drain()` leaves it drained.
Safe to call from any thread: the drain request is represented by a
single attribute write, so no lock is needed for this signal.
If more mutable state is added here, add synchronization.
"""
__slots__ = ("_drain_reason",)
def __init__(self) -> None:
self._drain_reason: str | None = None
def request_drain(self, reason: str = "shutdown") -> None:
self._drain_reason = reason
@property
def drain_requested(self) -> bool:
return self._drain_reason is not None
@property
def drain_reason(self) -> str | None:
return self._drain_reason
def _no_op_stream_writer(_: Any) -> None: ...
def _no_op_heartbeat() -> None: ...
class _RuntimeOverrides(TypedDict, Generic[ContextT], total=False):
context: ContextT
store: BaseStore | None
stream_writer: StreamWriter
heartbeat: Callable[[], None]
previous: Any
execution_info: ExecutionInfo
server_info: ServerInfo | None
control: RunControl | None
@dataclass(**_DC_KWARGS)
@@ -197,7 +162,7 @@ class Runtime(Generic[ContextT]):
context: ContextT = field(default=None) # type: ignore[assignment]
"""Static context for the graph run, like `user_id`, `db_conn`, etc.
Can also be thought of as 'run dependencies'."""
store: BaseStore | None = field(default=None)
@@ -206,19 +171,9 @@ class Runtime(Generic[ContextT]):
stream_writer: StreamWriter = field(default=_no_op_stream_writer)
"""Function that writes to the custom stream."""
heartbeat: Callable[[], None] = field(default=_no_op_heartbeat)
"""Record progress for the current node's `idle_timeout`.
Call this from inside long-running work that does not naturally emit
writes, stream chunks, child tasks, or LangChain callback events, to
prevent the node from being treated as idle. It is also the only
progress signal honored under `TimeoutPolicy(refresh_on="heartbeat")`.
Outside an idle-timed attempt this is a no-op.
"""
previous: Any = field(default=None)
"""The previous return value for the given thread.
Only available with the functional API when a checkpointer is provided.
"""
@@ -230,13 +185,6 @@ class Runtime(Generic[ContextT]):
server_info: ServerInfo | None = field(default=None)
"""Metadata injected by LangGraph Server. None when running open-source LangGraph without LangSmith deployments."""
control: RunControl | None = field(default=None)
"""Run-scoped control plane for cooperative draining.
Populated automatically during graph runs. None outside an active
graph runtime.
"""
def merge(self, other: Runtime[ContextT]) -> Runtime[ContextT]:
"""Merge two runtimes together.
@@ -248,13 +196,9 @@ class Runtime(Generic[ContextT]):
stream_writer=other.stream_writer
if other.stream_writer is not _no_op_stream_writer
else self.stream_writer,
heartbeat=other.heartbeat
if other.heartbeat is not _no_op_heartbeat
else self.heartbeat,
previous=self.previous if other.previous is None else other.previous,
execution_info=other.execution_info or self.execution_info,
server_info=other.server_info or self.server_info,
control=other.control or self.control,
)
def override(
@@ -273,23 +217,13 @@ class Runtime(Generic[ContextT]):
execution_info=self.execution_info.patch(**overrides),
)
@property
def drain_requested(self) -> bool:
return self.control.drain_requested if self.control is not None else False
@property
def drain_reason(self) -> str | None:
return self.control.drain_reason if self.control is not None else None
DEFAULT_RUNTIME = Runtime(
context=None,
store=None,
stream_writer=_no_op_stream_writer,
heartbeat=_no_op_heartbeat,
previous=None,
execution_info=None,
control=None,
)
@@ -14,23 +14,15 @@ from langgraph.stream.run_stream import (
)
from langgraph.stream.stream_channel import StreamChannel
from langgraph.stream.transformers import (
CheckpointsTransformer,
CustomTransformer,
DebugTransformer,
LifecyclePayload,
LifecycleTransformer,
SubgraphStatus,
SubgraphTransformer,
TasksTransformer,
UpdatesTransformer,
)
__all__ = [
"AsyncGraphRunStream",
"AsyncSubgraphRunStream",
"CheckpointsTransformer",
"CustomTransformer",
"DebugTransformer",
"GraphRunStream",
"LifecyclePayload",
"LifecycleTransformer",
@@ -40,6 +32,4 @@ __all__ = [
"SubgraphRunStream",
"SubgraphStatus",
"SubgraphTransformer",
"TasksTransformer",
"UpdatesTransformer",
]
+27 -53
View File
@@ -10,7 +10,7 @@ from langgraph.stream._mux import StreamMux
from langgraph.stream._types import ProtocolEvent
if TYPE_CHECKING:
from langgraph.stream.transformers import SubgraphStatus
from langgraph.stream.transformers import SubgraphStatus, ValuesTransformer
def _drive_until_done(pump: Callable[[], bool]) -> None:
@@ -44,6 +44,7 @@ class GraphRunStream:
self,
graph_iter: Iterator[Any] | None,
mux: StreamMux,
values_transformer: ValuesTransformer,
*,
wire_pump: bool = True,
) -> None:
@@ -54,6 +55,8 @@ class GraphRunStream:
or `None` for nested run streams whose pump is driven
by an outer run (e.g. `SubgraphRunStream`).
mux: The StreamMux owning projections and the main log.
values_transformer: The built-in values transformer
providing `output` / `interrupted` / `interrupts`.
wire_pump: When True (default), bind `_pump_next` as the
mux's pump callable. Subclasses that inherit a parent
pump via `StreamMux._make_child` should pass False to
@@ -62,11 +65,8 @@ class GraphRunStream:
self._graph_iter = graph_iter
self._mux = mux
self.extensions: Mapping[str, Any] = MappingProxyType(mux.extensions)
self._values_transformer = values_transformer
self._exhausted = False
self._latest: dict[str, Any] | None = None
self._interrupted = False
self._interrupts: list[Any] = []
self._scope_list: list[str] = list(mux.scope)
for key in mux.native_keys:
setattr(self, key, mux.extensions[key])
if wire_pump:
@@ -83,19 +83,6 @@ class GraphRunStream:
"""
mux.bind_pump(self._pump_next)
def _observe_event(self, event: ProtocolEvent) -> None:
"""Track values-event state for output/interrupted/interrupts."""
if event["method"] != "values":
return
params = event["params"]
if params["namespace"] != self._scope_list:
return
self._latest = params["data"]
interrupts = params.get("interrupts", ())
if interrupts:
self._interrupted = True
self._interrupts.extend(interrupts)
def _pump_next(self) -> bool:
"""Pull one event from the graph and push it through the mux.
@@ -108,9 +95,7 @@ class GraphRunStream:
return False
try:
part = next(self._graph_iter)
event = convert_to_protocol_event(part)
self._observe_event(event)
self._mux.push(event)
self._mux.push(convert_to_protocol_event(part))
return True
except StopIteration:
self._mux.close()
@@ -151,9 +136,9 @@ class GraphRunStream:
def output(self) -> dict[str, Any] | None:
"""Drive the run to completion and return the final state."""
_drive_until_done(self._pump_next)
if (err := self._mux._events._error) is not None:
if (err := self._values_transformer.error) is not None:
raise err
return self._latest
return self._values_transformer._latest
@property
def interrupted(self) -> bool:
@@ -164,9 +149,9 @@ class GraphRunStream:
BaseException: If the run ended with an error.
"""
_drive_until_done(self._pump_next)
if (err := self._mux._events._error) is not None:
if (err := self._values_transformer.error) is not None:
raise err
return self._interrupted
return self._values_transformer._interrupted
@property
def interrupts(self) -> list[Any]:
@@ -176,9 +161,9 @@ class GraphRunStream:
BaseException: If the run ended with an error.
"""
_drive_until_done(self._pump_next)
if (err := self._mux._events._error) is not None:
if (err := self._values_transformer.error) is not None:
raise err
return self._interrupts
return self._values_transformer._interrupts
def __iter__(self) -> Iterator[ProtocolEvent]:
"""Subscribe to the main event log and iterate protocol events."""
@@ -262,6 +247,7 @@ class AsyncGraphRunStream:
self,
graph_aiter: AsyncIterator[Any] | None,
mux: StreamMux,
values_transformer: ValuesTransformer,
*,
wire_pump: bool = True,
) -> None:
@@ -272,6 +258,8 @@ class AsyncGraphRunStream:
`None` for nested run streams whose pump is driven by
an outer run (e.g. `AsyncSubgraphRunStream`).
mux: The StreamMux owning projections and the main log.
values_transformer: The built-in values transformer
providing `output` / `interrupted` / `interrupts`.
wire_pump: When True (default), bind `_apump_next` as the
mux's async pump callable. Subclasses that inherit a
parent pump via `StreamMux._make_child` should pass
@@ -280,11 +268,8 @@ class AsyncGraphRunStream:
self._graph_aiter = graph_aiter
self._mux = mux
self.extensions: Mapping[str, Any] = MappingProxyType(mux.extensions)
self._values_transformer = values_transformer
self._exhausted = False
self._latest: dict[str, Any] | None = None
self._interrupted = False
self._interrupts: list[Any] = []
self._scope_list: list[str] = list(mux.scope)
self._pump_cond = asyncio.Condition()
self._pumping = False
for key in mux.native_keys:
@@ -292,19 +277,6 @@ class AsyncGraphRunStream:
if wire_pump:
self._wire_arequest_more(mux)
def _observe_event(self, event: ProtocolEvent) -> None:
"""Track values-event state for output/interrupted/interrupts."""
if event["method"] != "values":
return
params = event["params"]
if params["namespace"] != self._scope_list:
return
self._latest = params["data"]
interrupts = params.get("interrupts", ())
if interrupts:
self._interrupted = True
self._interrupts.extend(interrupts)
def _wire_arequest_more(self, mux: StreamMux) -> None:
"""Wire the async pull callback through the mux.
@@ -347,9 +319,7 @@ class AsyncGraphRunStream:
try:
try:
part = await self._graph_aiter.__anext__()
event = convert_to_protocol_event(part)
self._observe_event(event)
await self._mux.apush(event)
await self._mux.apush(convert_to_protocol_event(part))
return True
except StopAsyncIteration:
self._exhausted = True
@@ -408,9 +378,9 @@ class AsyncGraphRunStream:
BaseException: If the run ended with an error.
"""
await _adrive_until_done(self._apump_next)
if (err := self._mux._events._error) is not None:
if (err := self._values_transformer.error) is not None:
raise err
return self._latest
return self._values_transformer._latest
async def interrupted(self) -> bool:
"""Drive the run to completion and return whether it was
@@ -420,9 +390,9 @@ class AsyncGraphRunStream:
BaseException: If the run ended with an error.
"""
await _adrive_until_done(self._apump_next)
if (err := self._mux._events._error) is not None:
if (err := self._values_transformer.error) is not None:
raise err
return self._interrupted
return self._values_transformer._interrupted
async def interrupts(self) -> list[Any]:
"""Drive the run to completion and return interrupt payloads.
@@ -431,9 +401,9 @@ class AsyncGraphRunStream:
BaseException: If the run ended with an error.
"""
await _adrive_until_done(self._apump_next)
if (err := self._mux._events._error) is not None:
if (err := self._values_transformer.error) is not None:
raise err
return self._interrupts
return self._values_transformer._interrupts
def __aiter__(self) -> AsyncIterator[ProtocolEvent]:
"""Subscribe to the main event log and iterate protocol events."""
@@ -474,6 +444,7 @@ class SubgraphRunStream(GraphRunStream, _SubgraphRunStreamMixin):
def __init__(
self,
mux: StreamMux,
values_transformer: ValuesTransformer,
*,
path: tuple[str, ...],
graph_name: str | None = None,
@@ -485,6 +456,7 @@ class SubgraphRunStream(GraphRunStream, _SubgraphRunStreamMixin):
super().__init__(
graph_iter=None,
mux=mux,
values_transformer=values_transformer,
wire_pump=False,
)
self.path = path
@@ -517,6 +489,7 @@ class AsyncSubgraphRunStream(AsyncGraphRunStream, _SubgraphRunStreamMixin):
def __init__(
self,
mux: StreamMux,
values_transformer: ValuesTransformer,
*,
path: tuple[str, ...],
graph_name: str | None = None,
@@ -526,6 +499,7 @@ class AsyncSubgraphRunStream(AsyncGraphRunStream, _SubgraphRunStreamMixin):
super().__init__(
graph_aiter=None,
mux=mux,
values_transformer=values_transformer,
wire_pump=False,
)
self.path = path
+34 -213
View File
@@ -12,7 +12,7 @@ from langchain_core.messages import AIMessageChunk, BaseMessage
from langchain_protocol.protocol import MessagesData
from typing_extensions import NotRequired, TypedDict
from langgraph.errors import GraphDrained, GraphInterrupt
from langgraph.errors import GraphInterrupt
from langgraph.stream._types import ProtocolEvent, StreamTransformer
from langgraph.stream.run_stream import AsyncSubgraphRunStream, SubgraphRunStream
from langgraph.stream.stream_channel import StreamChannel
@@ -28,9 +28,10 @@ _logger = logging.getLogger(__name__)
class ValuesTransformer(StreamTransformer):
"""Capture values events as a drainable stream of state snapshots.
Provides the `run.values` projection. `run.output`,
`run.interrupted` and `run.interrupts` are tracked directly
by the run stream and do not depend on this transformer.
Keeps `_latest` / `_interrupted` / `_interrupts` as scalar state
regardless of whether the log has a subscriber so `run.output()`
and `run.interrupted` work without forcing the caller to iterate
`run.values`. Log pushes are silent no-ops when unsubscribed.
Native transformer projection keys are exposed as direct
attributes on the run stream (e.g. `run.values`).
@@ -38,9 +39,9 @@ class ValuesTransformer(StreamTransformer):
Only values events at the run's own level are captured; snapshots
from deeper subgraphs are left in the main event log but excluded
from the projection. "Own level" is defined by `scope`, which
`stream_v2` / `astream_v2` populate from the caller's
checkpoint namespace so that a nested `stream_v2` call still
sees its own root snapshots.
`stream_v2` / `astream_v2` populate from the caller's checkpoint
namespace so that a nested `stream_v2` call still sees its own
root snapshots.
"""
_native = True
@@ -82,76 +83,6 @@ class ValuesTransformer(StreamTransformer):
return True
class CustomTransformer(StreamTransformer):
"""Capture custom events as a drainable stream of arbitrary payloads.
Nodes emit custom data via `get_stream_writer()`. This transformer
surfaces those events on `run.custom` as a `StreamChannel[Any]`,
preserving payloads in arrival order.
Only events at the run's own scope are captured; custom data from
deeper subgraphs is available on the respective subgraph handle's
`.custom` projection.
Native transformer `run.custom` is a direct attribute.
"""
_native = True
required_stream_modes = ("custom",)
def __init__(self, scope: tuple[str, ...] = ()) -> None:
super().__init__(scope)
self._log: StreamChannel[Any] = StreamChannel()
self._scope_list: list[str] = list(scope)
def init(self) -> dict[str, Any]:
return {"custom": self._log}
def process(self, event: ProtocolEvent) -> bool:
if event["method"] != "custom":
return True
params = event["params"]
if params["namespace"] != self._scope_list:
return True
self._log.push(params["data"])
return True
class UpdatesTransformer(StreamTransformer):
"""Capture updates events as a drainable stream of node outputs.
Surfaces `stream_mode="updates"` data on `run.updates` as a
`StreamChannel[dict[str, Any]]`. Each item is a dict mapping a node
(or task) name to the update it returned after a step.
Only events at the run's own scope are captured; updates from deeper
subgraphs are available on the respective subgraph handle's
`.updates` projection.
Native transformer `run.updates` is a direct attribute.
"""
_native = True
required_stream_modes = ("updates",)
def __init__(self, scope: tuple[str, ...] = ()) -> None:
super().__init__(scope)
self._log: StreamChannel[dict[str, Any]] = StreamChannel()
self._scope_list: list[str] = list(scope)
def init(self) -> dict[str, Any]:
return {"updates": self._log}
def process(self, event: ProtocolEvent) -> bool:
if event["method"] != "updates":
return True
params = event["params"]
if params["namespace"] != self._scope_list:
return True
self._log.push(params["data"])
return True
class MessagesTransformer(StreamTransformer):
"""Capture messages events as ChatModelStream objects.
@@ -327,7 +258,7 @@ class MessagesTransformer(StreamTransformer):
self._by_run.clear()
SubgraphStatus = Literal["started", "completed", "failed", "interrupted", "drained"]
SubgraphStatus = Literal["started", "completed", "failed", "interrupted"]
def _parse_ns_segment(segment: str) -> tuple[str, str | None]:
@@ -472,8 +403,10 @@ class _TasksLifecycleBase(StreamTransformer):
self._open.clear()
def fail(self, err: BaseException) -> None:
"""Emit terminal status for any tracked namespace still open."""
status, error_str = _status_from_exception(err)
"""Emit `failed` / `interrupted` for any tracked namespace still open."""
is_interrupt = isinstance(err, GraphInterrupt)
status: SubgraphStatus = "interrupted" if is_interrupt else "failed"
error_str = None if is_interrupt else str(err)
for ns in list(self._open):
self._on_terminal(ns, status, error_str)
self._open.clear()
@@ -481,8 +414,6 @@ class _TasksLifecycleBase(StreamTransformer):
def _status_from_exception(err: BaseException) -> tuple[SubgraphStatus, str | None]:
"""Map a run exception to a subgraph terminal status and error string."""
if isinstance(err, GraphDrained):
return "drained", None
if isinstance(err, GraphInterrupt):
return "interrupted", None
return "failed", str(err)
@@ -618,10 +549,17 @@ class SubgraphTransformer(_TasksLifecycleBase):
try:
child_mux = self._mux._make_child(ns)
except RuntimeError:
# Mux wasn't built from factories — no mini-mux navigation
# available. Skip; LifecycleTransformer still tracks the
# subgraph via the flat event stream.
return
values_t = child_mux.transformer_by_key("values")
if not isinstance(values_t, ValuesTransformer):
return
handle_cls = AsyncSubgraphRunStream if child_mux.is_async else SubgraphRunStream
handle = handle_cls(
mux=child_mux,
values_transformer=values_t,
path=ns,
graph_name=graph_name,
trigger_call_id=trigger_call_id,
@@ -692,9 +630,7 @@ class SubgraphTransformer(_TasksLifecycleBase):
else:
await handle._mux.aclose()
def _handle_for_event(
self, event: ProtocolEvent
) -> SubgraphRunStream | AsyncSubgraphRunStream | None:
def _child_mux_for_event(self, event: ProtocolEvent) -> StreamMux | None:
ns = tuple(event["params"]["namespace"])
depth = len(self.scope)
if len(ns) < depth + 1:
@@ -702,21 +638,22 @@ class SubgraphTransformer(_TasksLifecycleBase):
handle = self._handles.get(ns[: depth + 1])
if handle is None or handle._mux is None or handle._mux._events._closed:
return None
return handle
return handle._mux
def process(self, event: ProtocolEvent) -> bool:
# Run tasks bookkeeping first so a `started` handle exists
# by the time we forward the event to the child mini-mux.
# Discover / update terminal status before forwarding so a
# `started` handle exists by the time the child mini-mux sees
# its own first event.
keep = super().process(event)
handle = self._handle_for_event(event)
if handle is not None:
handle._observe_event(event)
handle._mux.push(event)
child_mux = self._child_mux_for_event(event)
if child_mux is not None:
child_mux.push(event)
return keep
async def aprocess(self, event: ProtocolEvent) -> bool:
# Async counterpart: repeats the tasks bookkeeping here so
# child mini-muxes receive events through their async lane.
# Async counterpart to `process`: repeat the tasks bookkeeping
# here instead of delegating to `process`, so child mini-muxes
# receive events through their async lane.
if event["method"] == "tasks":
ns = tuple(event["params"]["namespace"])
data = event["params"]["data"]
@@ -728,10 +665,9 @@ class SubgraphTransformer(_TasksLifecycleBase):
keep = False
else:
keep = True
handle = self._handle_for_event(event)
if handle is not None:
handle._observe_event(event)
await handle._mux.apush(event)
child_mux = self._child_mux_for_event(event)
if child_mux is not None:
await child_mux.apush(event)
return keep
def _complete_open_handles(self) -> BaseException | None:
@@ -811,118 +747,3 @@ class SubgraphTransformer(_TasksLifecycleBase):
handle.path,
exc_info=True,
)
class CheckpointsTransformer(StreamTransformer):
"""Capture checkpoint events as a drainable stream.
Surfaces `stream_mode="checkpoints"` data on `run.checkpoints` as
a `StreamChannel[dict[str, Any]]`. Each item is in the same format
as returned by `get_state()`.
Checkpoint events are only emitted when a checkpointer is configured
on the graph. When no checkpointer is present, the projection exists
but receives no events.
Only events at the run's own scope are captured; checkpoint data from
deeper subgraphs is available on the respective subgraph handle's
`.checkpoints` projection.
Native transformer `run.checkpoints` is a direct attribute.
"""
_native = True
required_stream_modes = ("checkpoints",)
def __init__(self, scope: tuple[str, ...] = ()) -> None:
super().__init__(scope)
self._log: StreamChannel[dict[str, Any]] = StreamChannel()
self._scope_list: list[str] = list(scope)
def init(self) -> dict[str, Any]:
return {"checkpoints": self._log}
def process(self, event: ProtocolEvent) -> bool:
if event["method"] != "checkpoints":
return True
params = event["params"]
if params["namespace"] != self._scope_list:
return True
self._log.push(params["data"])
return True
class DebugTransformer(StreamTransformer):
"""Capture debug events as a drainable stream.
Surfaces `stream_mode="debug"` data on `run.debug` as a
`StreamChannel[dict[str, Any]]`. Each item is a debug event with
step-level detail (checkpoint snapshots, task payloads, and
task results wrapped with step number and timestamp).
Only events at the run's own scope are captured; debug data from
deeper subgraphs is available on the respective subgraph handle's
`.debug` projection.
Native transformer `run.debug` is a direct attribute.
"""
_native = True
required_stream_modes = ("debug",)
def __init__(self, scope: tuple[str, ...] = ()) -> None:
super().__init__(scope)
self._log: StreamChannel[dict[str, Any]] = StreamChannel()
self._scope_list: list[str] = list(scope)
def init(self) -> dict[str, Any]:
return {"debug": self._log}
def process(self, event: ProtocolEvent) -> bool:
if event["method"] != "debug":
return True
params = event["params"]
if params["namespace"] != self._scope_list:
return True
self._log.push(params["data"])
return True
class TasksTransformer(StreamTransformer):
"""Capture raw task events as a drainable stream.
Surfaces `stream_mode="tasks"` data on `run.tasks` as a
`StreamChannel[dict[str, Any]]`. Each item is a task payload
(start or result).
`LifecycleTransformer` and `SubgraphTransformer` also consume
`tasks` events for subgraph discovery and lifecycle tracking.
This transformer captures the raw payloads independently for
consumers who need task-level detail.
Only events at the run's own scope are captured; task data from
deeper subgraphs is available on the respective subgraph handle's
`.tasks` projection.
Native transformer `run.tasks` is a direct attribute.
"""
_native = True
required_stream_modes = ("tasks",)
def __init__(self, scope: tuple[str, ...] = ()) -> None:
super().__init__(scope)
self._log: StreamChannel[dict[str, Any]] = StreamChannel()
self._scope_list: list[str] = list(scope)
def init(self) -> dict[str, Any]:
return {"tasks": self._log}
def process(self, event: ProtocolEvent) -> bool:
if event["method"] != "tasks":
return True
params = event["params"]
if params["namespace"] != self._scope_list:
return True
self._log.push(params["data"])
return True
+4 -100
View File
@@ -4,7 +4,6 @@ import sys
from collections import deque
from collections.abc import Callable, Hashable, Sequence
from dataclasses import asdict, dataclass
from datetime import timedelta
from typing import (
TYPE_CHECKING,
Any,
@@ -68,7 +67,6 @@ __all__ = (
"CheckpointPayload",
"DebugPayload",
"RetryPolicy",
"TimeoutPolicy",
"CachePolicy",
"Interrupt",
"StateUpdate",
@@ -425,83 +423,6 @@ class RetryPolicy(NamedTuple):
"""List of exception classes that should trigger a retry, or a callable that returns `True` for exceptions that should trigger a retry."""
def _coerce_timeout_seconds(
value: float | timedelta | None, *, field: str
) -> float | None:
if value is None:
return None
seconds = value.total_seconds() if isinstance(value, timedelta) else float(value)
if seconds <= 0:
raise ValueError(f"{field} must be greater than 0")
return seconds
@dataclass(**_DC_KWARGS)
class TimeoutPolicy:
"""Configuration for timing out node attempts.
!!! note "Cooperative cancellation"
Timeouts rely on asyncio cancellation. If your node uses synchronous
time.sleep() or other CPU-bound work that blocks the GIL, the timeout will not
be fired until after the event loop has been released.
!!! note "Inline callback dispatch"
Under `refresh_on="auto"`, an internal handler refreshes the timeout on any
callback event that occurs in the execution of the node or its nested descendants.
"""
run_timeout: float | timedelta | None = None
"""Hard wall-clock cap (in seconds) for a single node attempt.
This timeout is never refreshed by progress signals or `runtime.heartbeat()`.
"""
idle_timeout: float | timedelta | None = None
"""Maximum time (in seconds) a single node attempt may go without observable progress."""
refresh_on: Literal["auto", "heartbeat"] = "auto"
"""Which signals refresh `idle_timeout`.
`"auto"` refreshes on standard graph progress signals and explicit heartbeats.
`"heartbeat"` refreshes only on explicit `runtime.heartbeat()` calls.
"""
@classmethod
def coerce(
cls, value: float | timedelta | TimeoutPolicy | None
) -> TimeoutPolicy | None:
"""Normalize a timeout value to positive-second policy fields."""
if value is None:
return None
if isinstance(value, TimeoutPolicy):
# Fast path: a policy already produced by coerce() has float
# timeouts and a validated refresh_on, so we can return it as-is.
# `frozen=True` makes this safe to share.
rt, it = value.run_timeout, value.idle_timeout
if (
value.refresh_on in ("auto", "heartbeat")
and (rt is None or (type(rt) is float and rt > 0))
and (it is None or (type(it) is float and it > 0))
and (rt is not None or it is not None)
):
return value
else:
value = cls(run_timeout=value)
if value.refresh_on not in ("auto", "heartbeat"):
raise ValueError("refresh_on must be 'auto' or 'heartbeat'")
run_timeout = _coerce_timeout_seconds(value.run_timeout, field="run_timeout")
idle_timeout = _coerce_timeout_seconds(value.idle_timeout, field="idle_timeout")
if run_timeout is None and idle_timeout is None:
return None
return cls(
run_timeout=run_timeout,
idle_timeout=idle_timeout,
refresh_on=value.refresh_on,
)
KeyFuncT = TypeVar("KeyFuncT", bound=Callable[..., str | bytes])
@@ -627,7 +548,6 @@ class PregelExecutableTask:
path: tuple[str | int | tuple, ...]
writers: Sequence[Runnable] = ()
subgraphs: Sequence[PregelProtocol] = ()
timeout: TimeoutPolicy | None = None
class StateSnapshot(NamedTuple):
@@ -667,8 +587,6 @@ class Send:
Attributes:
node (str): The name of the target node to send the message to.
arg (Any): The state or message to send to the target node.
timeout (TimeoutPolicy | None): Optional timeout policy for this specific
pushed task. If omitted, the target node's timeout policy is used.
!!! example
@@ -698,47 +616,33 @@ class Send:
```
"""
__slots__ = ("node", "arg", "timeout")
__slots__ = ("node", "arg")
node: str
arg: Any
timeout: TimeoutPolicy | None
def __init__(
self,
/,
node: str,
arg: Any,
*,
timeout: float | timedelta | TimeoutPolicy | None = None,
) -> None:
def __init__(self, /, node: str, arg: Any) -> None:
"""
Initialize a new instance of the `Send` class.
Args:
node: The name of the target node to send the message to.
arg: The state or message to send to the target node.
timeout: Optional timeout policy for this specific pushed task. A
number or `timedelta` is treated as a hard `run_timeout`.
"""
self.node = node
self.arg = arg
self.timeout = TimeoutPolicy.coerce(timeout)
def __hash__(self) -> int:
return hash((self.node, self.arg, self.timeout))
return hash((self.node, self.arg))
def __repr__(self) -> str:
if self.timeout is None:
return f"Send(node={self.node!r}, arg={self.arg!r})"
return f"Send(node={self.node!r}, arg={self.arg!r}, timeout={self.timeout!r})"
return f"Send(node={self.node!r}, arg={self.arg!r})"
def __eq__(self, value: object) -> bool:
return (
isinstance(value, Send)
and self.node == value.node
and self.arg == value.arg
and self.timeout == value.timeout
)
+4 -3
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph"
version = "1.2.0a1"
version = "1.1.10"
description = "Building stateful, multi-actor applications with LLMs"
authors = []
requires-python = ">=3.10"
@@ -25,7 +25,7 @@ classifiers = [
]
dependencies = [
"langchain-core>=1.3.2,<2",
"langgraph-checkpoint>=4.1.0a1,<5.0.0",
"langgraph-checkpoint>=2.1.0,<5.0.0",
"langgraph-sdk>=0.3.0,<0.4.0",
"langgraph-prebuilt>=1.0.12,<1.1.0",
"xxhash>=3.5.0",
@@ -38,7 +38,7 @@ Homepage = "https://docs.langchain.com/oss/python/langgraph/overview"
Documentation = "https://reference.langchain.com/python/langgraph/"
Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/langgraph"
Changelog = "https://github.com/langchain-ai/langgraph/releases"
Twitter = "https://x.com/langchain_oss"
Twitter = "https://x.com/LangChain"
Slack = "https://www.langchain.com/join-community"
Reddit = "https://www.reddit.com/r/LangChain/"
@@ -81,6 +81,7 @@ dev = [
[tool.uv.sources]
langchain-core = { git = "https://github.com/langchain-ai/langchain", branch = "cb/chat-model-updates", subdirectory = "libs/core" }
langgraph-prebuilt = { path = "../prebuilt", editable = true }
langgraph-checkpoint = { path = "../checkpoint", editable = true }
langgraph-checkpoint-sqlite = { path = "../checkpoint-sqlite", editable = true }
+5 -539
View File
@@ -1,34 +1,18 @@
import operator
from collections.abc import Sequence
from typing import Annotated
import pytest
from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage
from langgraph.checkpoint.base import DELTA_SENTINEL
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.checkpoint.serde.types import _DeltaSnapshot
from typing_extensions import NotRequired, TypedDict
from langgraph._internal._typing import MISSING
from langgraph.channels.binop import BinaryOperatorAggregate
from langgraph.channels.delta import DeltaChannel
from langgraph.channels.last_value import LastValue
from langgraph.channels.topic import Topic
from langgraph.channels.untracked_value import UntrackedValue
from langgraph.errors import EmptyChannelError, InvalidUpdateError
from langgraph.graph import START, StateGraph
from langgraph.graph.message import _messages_delta_reducer
from langgraph.graph.state import _get_channel
from langgraph.types import Overwrite
pytestmark = pytest.mark.anyio
# ---------------------------------------------------------------------------
# Core channel primitives
# ---------------------------------------------------------------------------
def test_last_value() -> None:
channel = LastValue(int).from_checkpoint(MISSING)
assert channel.ValueType is int
@@ -111,543 +95,25 @@ def test_untracked_value() -> None:
assert channel.ValueType is dict
assert channel.UpdateType is dict
# UntrackedValue should start empty
with pytest.raises(EmptyChannelError):
channel.get()
# Should be able to update with a value
test_data = {"session": "test", "temp": "dir"}
channel.update([test_data])
assert channel.get() == test_data
# Update with new value
new_data = {"session": "updated", "temp": "newdir"}
channel.update([new_data])
assert channel.get() == new_data
# On checkpoint, UntrackedValue should return MISSING
checkpoint = channel.checkpoint()
assert checkpoint is MISSING
# Creating from checkpoint with MISSING should start empty
new_channel = UntrackedValue(dict).from_checkpoint(checkpoint)
with pytest.raises(EmptyChannelError):
new_channel.get()
# ---------------------------------------------------------------------------
# DeltaChannel — message reducer
# ---------------------------------------------------------------------------
def test_delta_channel_basic_two_steps() -> None:
ch = DeltaChannel(_messages_delta_reducer, list).from_checkpoint(MISSING)
ch.update([HumanMessage(content="hi", id="h1")])
d1 = ch.checkpoint()
assert d1 is DELTA_SENTINEL
ch.update([AIMessage(content="hello", id="a1")])
d2 = ch.checkpoint()
assert d2 is DELTA_SENTINEL
assert len(ch.get()) == 2
assert ch.get()[0].content == "hi"
assert ch.get()[1].content == "hello"
def test_delta_channel_from_checkpoint_writes_list() -> None:
"""replay_writes on a fresh channel replays through the operator."""
spec = DeltaChannel(_messages_delta_reducer, list)
ch = spec.from_checkpoint(DELTA_SENTINEL)
ch.replay_writes(
[
("t0", "messages", HumanMessage(content="hi", id="h1")),
("t1", "messages", AIMessage(content="hello", id="a1")),
("t2", "messages", HumanMessage(content="bye", id="h2")),
]
)
msgs = ch.get()
assert len(msgs) == 3
assert msgs[0].content == "hi"
assert msgs[1].content == "hello"
assert msgs[2].content == "bye"
def test_delta_channel_from_checkpoint_backwards_compat() -> None:
spec = DeltaChannel(_messages_delta_reducer, list)
old_value = [HumanMessage(content="old", id="h1")]
ch = spec.from_checkpoint(old_value)
assert ch.get() == old_value
def test_delta_channel_overwrite() -> None:
ch = DeltaChannel(_messages_delta_reducer, list).from_checkpoint(MISSING)
ch.update([HumanMessage(content="old", id="h1")])
ch.update([Overwrite([HumanMessage(content="new", id="h2")])])
d = ch.checkpoint()
assert d is DELTA_SENTINEL
assert len(ch.get()) == 1
assert ch.get()[0].content == "new"
def test_delta_channel_remove_message_and_replay() -> None:
"""RemoveMessage must round-trip correctly when writes are replayed."""
spec = DeltaChannel(_messages_delta_reducer, list)
ch = spec.from_checkpoint(MISSING)
ch.update([HumanMessage(content="hi", id="h1")])
ch.update([AIMessage(content="hello", id="a1")])
assert ch.get() == [
HumanMessage(content="hi", id="h1"),
AIMessage(content="hello", id="a1"),
]
ch.update([RemoveMessage(id="a1")])
assert ch.get() == [HumanMessage(content="hi", id="h1")]
ch2 = spec.from_checkpoint(DELTA_SENTINEL)
ch2.replay_writes(
[
("t0", "messages", HumanMessage(content="hi", id="h1")),
("t1", "messages", AIMessage(content="hello", id="a1")),
("t2", "messages", RemoveMessage(id="a1")),
]
)
assert ch2.get() == [HumanMessage(content="hi", id="h1")]
def test_delta_channel_update_by_id_and_replay() -> None:
"""Updating a message by ID must round-trip correctly through writes replay."""
spec = DeltaChannel(_messages_delta_reducer, list)
ch = spec.from_checkpoint(MISSING)
ch.update([HumanMessage(content="original", id="h1")])
ch.update([HumanMessage(content="updated", id="h1")])
assert ch.get() == [HumanMessage(content="updated", id="h1")]
ch2 = spec.from_checkpoint(DELTA_SENTINEL)
ch2.replay_writes(
[
("t0", "messages", HumanMessage(content="original", id="h1")),
("t1", "messages", HumanMessage(content="updated", id="h1")),
]
)
assert len(ch2.get()) == 1
assert ch2.get()[0].content == "updated"
def test_delta_channel_checkpoint_returns_sentinel() -> None:
"""checkpoint() always returns DELTA_SENTINEL regardless of state."""
ch = DeltaChannel(_messages_delta_reducer, list).from_checkpoint(MISSING)
assert ch.checkpoint() is DELTA_SENTINEL
ch.update([HumanMessage(content="hi", id="h1")])
assert ch.checkpoint() is DELTA_SENTINEL
# ---------------------------------------------------------------------------
# DeltaChannel — snapshot frequency
# ---------------------------------------------------------------------------
def test_delta_channel_snapshot_step_based() -> None:
"""Snapshots fire on every Nth step regardless of whether the channel was written.
With snapshot_frequency=N, every Nth pregel step produces a _DeltaSnapshot
blob even if the channel had no write that step (eager snapshot). This
bounds the ancestor walk to at most N steps on any read.
"""
class State(TypedDict):
messages: Annotated[
list, DeltaChannel(_messages_delta_reducer, snapshot_frequency=5)
]
other: str
def node_a(state: State) -> dict:
i = len(state["messages"]) // 2
return {"messages": [AIMessage(content=f"a{i}", id=f"a{i}")]}
def node_b(state: State) -> dict:
return {"other": "y"}
g = StateGraph(State)
g.add_node("a", node_a)
g.add_node("b", node_b)
g.add_edge(START, "a")
g.add_edge("a", "b")
saver = InMemorySaver()
graph = g.compile(checkpointer=saver)
config = {"configurable": {"thread_id": "t1"}}
for i in range(6):
graph.invoke(
{"messages": [HumanMessage(content=f"h{i}", id=f"h{i}")], "other": ""},
config,
)
msg_blob_values = [
saver.serde.loads_typed((type_tag, blob))
for k, (type_tag, blob) in saver.blobs.items()
if k[2] == "messages" and type_tag == "msgpack" and blob
]
snapshots = [v for v in msg_blob_values if isinstance(v, _DeltaSnapshot)]
assert snapshots, "expected at least one _DeltaSnapshot blob for messages"
state = graph.get_state(config)
assert len(state.values["messages"]) == 12 # 6 human + 6 AI
def test_delta_channel_snapshot_fires_even_when_not_written() -> None:
"""Eager snapshot: _DeltaSnapshot stored at snapshot step even when the
channel had no write that step (node_b doesn't touch messages).
"""
class State(TypedDict):
messages: Annotated[
list, DeltaChannel(_messages_delta_reducer, snapshot_frequency=3)
]
tick: int
def writer(state: State) -> dict:
i = len(state["messages"]) // 2
return {"messages": [AIMessage(content=f"a{i}", id=f"a{i}")]}
def ticker(state: State) -> dict:
return {"tick": state["tick"] + 1}
g = StateGraph(State)
g.add_node("writer", writer)
g.add_node("ticker", ticker)
g.add_edge(START, "writer")
g.add_edge("writer", "ticker")
saver = InMemorySaver()
graph = g.compile(checkpointer=saver)
config = {"configurable": {"thread_id": "t1"}}
for i in range(5):
graph.invoke(
{"messages": [HumanMessage(content=f"h{i}", id=f"h{i}")], "tick": 0},
config,
)
msg_blobs = {
k: saver.serde.loads_typed((t, b))
for k, (t, b) in saver.blobs.items()
if k[2] == "messages" and t == "msgpack" and b
}
snapshots = {k: v for k, v in msg_blobs.items() if isinstance(v, _DeltaSnapshot)}
assert snapshots, (
"eager snapshots must fire even on steps where messages wasn't written"
)
state = graph.get_state(config)
assert len(state.values["messages"]) == 10 # 5 human + 5 AI
# ---------------------------------------------------------------------------
# DeltaChannel — end-to-end (InMemorySaver)
# ---------------------------------------------------------------------------
def test_delta_channel_inmemory_saver_assembles_writes() -> None:
"""InMemorySaver assembles writes from checkpoint_writes inside get_tuple."""
class State(TypedDict):
messages: Annotated[list, DeltaChannel(_messages_delta_reducer, list)]
n = {"v": 0}
def respond(state: State) -> dict:
n["v"] += 1
return {"messages": [AIMessage(content=f"ok{n['v']}", id=f"ai{n['v']}")]}
builder = StateGraph(State)
builder.add_node("respond", respond)
builder.add_edge(START, "respond")
saver = InMemorySaver()
graph = builder.compile(checkpointer=saver)
config = {"configurable": {"thread_id": "t1"}}
graph.invoke({"messages": [HumanMessage(content="hi", id="h1")]}, config)
graph.invoke({"messages": [HumanMessage(content="bye", id="h2")]}, config)
saved = saver.get_tuple(config)
assert saved is not None
assert "messages" in saved.checkpoint["channel_values"]
assert saved.checkpoint["channel_values"]["messages"] is DELTA_SENTINEL
state = graph.get_state(config)
assert len(state.values["messages"]) == 4 # 2 human + 2 AI
# ---------------------------------------------------------------------------
# DeltaChannel — dict reducer
# ---------------------------------------------------------------------------
def _delta_channel_with_type(op, typ):
"""Build a DeltaChannel with an explicit type via the Annotated injection path."""
return _get_channel("_test", Annotated[typ, DeltaChannel(op)])
def test_delta_channel_dict_reducer_fresh_channel() -> None:
"""DeltaChannel with a dict reducer starts as empty dict on MISSING checkpoint."""
def merge_dicts(state: dict, writes: list) -> dict:
result = dict(state)
for w in writes:
result.update(w)
return result
ch = _delta_channel_with_type(merge_dicts, dict).from_checkpoint(MISSING)
assert ch.is_available()
assert ch.get() == {}
def test_delta_channel_dict_reducer_basic_updates() -> None:
"""DeltaChannel with a dict reducer accumulates key/value pairs across steps."""
def merge_dicts(state: dict, writes: list) -> dict:
result = dict(state)
for w in writes:
result.update(w)
return result
ch = _delta_channel_with_type(merge_dicts, dict).from_checkpoint(MISSING)
ch.update([{"a": 1}])
d1 = ch.checkpoint()
assert d1 is DELTA_SENTINEL
ch.update([{"b": 2}])
d2 = ch.checkpoint()
assert d2 is DELTA_SENTINEL
assert ch.get() == {"a": 1, "b": 2}
def test_delta_channel_dict_reducer_writes_reconstruction() -> None:
"""replay_writes on a fresh channel replays through a dict merge reducer."""
def merge_dicts(state: dict, writes: list) -> dict:
result = dict(state)
for w in writes:
result.update(w)
return result
spec = _delta_channel_with_type(merge_dicts, dict)
ch = spec.from_checkpoint(DELTA_SENTINEL)
ch.replay_writes(
[
("t0", "files", {"a": 1}),
("t1", "files", {"b": 2}),
("t2", "files", {"c": 3}),
]
)
assert ch.get() == {"a": 1, "b": 2, "c": 3}
def test_delta_channel_dict_reducer_with_deletions() -> None:
"""Dict reducer that treats None values as deletions works end-to-end."""
def merge_files(state: dict, writes: list) -> dict:
result = dict(state)
for w in writes:
for k, v in w.items():
if v is None:
result.pop(k, None)
else:
result[k] = v
return result
ch = _delta_channel_with_type(merge_files, dict).from_checkpoint(MISSING)
ch.update([{"file1.py": "content1", "file2.py": "content2"}])
ch.update([{"file1.py": None, "file3.py": "content3"}])
assert ch.get() == {"file2.py": "content2", "file3.py": "content3"}
spec = _delta_channel_with_type(merge_files, dict)
ch2 = spec.from_checkpoint(DELTA_SENTINEL)
ch2.replay_writes(
[
("t0", "files", {"file1.py": "content1", "file2.py": "content2"}),
("t1", "files", {"file1.py": None, "file3.py": "content3"}),
]
)
assert ch2.get() == {"file2.py": "content2", "file3.py": "content3"}
def test_delta_channel_dict_reducer_overwrite_in_update() -> None:
"""Overwrite(dict) in update() must preserve dict shape, not coerce to list."""
def merge_dicts(state: dict, writes: list) -> dict:
result = dict(state)
for w in writes:
result.update(w)
return result
ch = _delta_channel_with_type(merge_dicts, dict).from_checkpoint(MISSING)
ch.update([{"a": 1}])
ch.update([Overwrite({"b": 2, "c": 3})])
assert ch.get() == {"b": 2, "c": 3}
def test_delta_channel_dict_reducer_overwrite_in_writes_replay() -> None:
"""Overwrite(dict) embedded in replayed writes must reconstruct as dict."""
def merge_dicts(state: dict, writes: list) -> dict:
result = dict(state)
for w in writes:
result.update(w)
return result
spec = _delta_channel_with_type(merge_dicts, dict)
ch = spec.from_checkpoint(DELTA_SENTINEL)
ch.replay_writes(
[
("t0", "files", {"a": 1}),
("t1", "files", Overwrite({"x": 10, "y": 20})),
("t2", "files", {"z": 30}),
]
)
assert ch.get() == {"x": 10, "y": 20, "z": 30}
def test_delta_channel_dict_reducer_with_notrequired_annotation() -> None:
"""DeltaChannel infers dict type through `Annotated[NotRequired[dict[...]], ch]`."""
def merge_dicts(state: dict, writes: list) -> dict:
result = dict(state)
for w in writes:
result.update(w)
return result
annotation = Annotated[NotRequired[dict[str, int]], DeltaChannel(merge_dicts)]
ch = _get_channel("files", annotation).from_checkpoint(MISSING)
assert ch.get() == {}
ch.update([{"a": 1}])
ch.update([{"b": 2}])
assert ch.get() == {"a": 1, "b": 2}
def test_delta_channel_dict_reducer_end_to_end_filesystem() -> None:
"""End-to-end: graph with dict-reducer (filesystem-style) channel wrapped in DeltaChannel."""
def merge_files(state: dict, writes: list) -> dict:
result = dict(state)
for w in writes:
for k, v in w.items():
if v is None:
result.pop(k, None)
else:
result[k] = v
return result
class State(TypedDict):
files: Annotated[dict[str, str], DeltaChannel(merge_files)]
turn = {"v": 0}
def write_file(state: State) -> dict:
turn["v"] += 1
n = turn["v"]
return {"files": {f"/doc_{n}.txt": f"content for turn {n}"}}
builder = StateGraph(State)
builder.add_node("write_file", write_file)
builder.add_edge(START, "write_file")
saver = InMemorySaver()
graph = builder.compile(checkpointer=saver)
config = {"configurable": {"thread_id": "fs"}}
for _ in range(3):
graph.invoke({"files": {}}, config)
saved = saver.get_tuple(config)
assert saved is not None
assert saved.checkpoint["channel_values"]["files"] is DELTA_SENTINEL
state = graph.get_state(config)
assert state.values["files"] == {
"/doc_1.txt": "content for turn 1",
"/doc_2.txt": "content for turn 2",
"/doc_3.txt": "content for turn 3",
}
def delete_file(state: State) -> dict:
return {"files": {"/doc_1.txt": None}}
builder2 = StateGraph(State)
builder2.add_node("write_file", write_file)
builder2.add_node("delete_file", delete_file)
builder2.add_edge(START, "write_file")
builder2.add_edge("write_file", "delete_file")
turn["v"] = 0
saver2 = InMemorySaver()
graph2 = builder2.compile(checkpointer=saver2)
config2 = {"configurable": {"thread_id": "fs2"}}
graph2.invoke({"files": {}}, config2)
state2 = graph2.get_state(config2)
assert state2.values["files"] == {}
def test_delta_channel_dict_reducer_backwards_compat() -> None:
"""A pre-DeltaChannel dict checkpoint must load as a dict, not be listified."""
def merge_dicts(state: dict, writes: list) -> dict:
result = dict(state)
for w in writes:
result.update(w)
return result
spec = _delta_channel_with_type(merge_dicts, dict)
old_value = {"a": 1, "b": 2}
ch = spec.from_checkpoint(old_value)
assert ch.get() == {"a": 1, "b": 2}
# ---------------------------------------------------------------------------
# DeltaChannel — seed / pre-delta migration
# ---------------------------------------------------------------------------
def test_delta_channel_from_checkpoint_honors_seed() -> None:
"""A non-sentinel value to from_checkpoint is used as the pre-delta seed.
Guards the pre-delta migration path: when the saver's ancestor walk hits
a pre-DeltaChannel blob it passes it as `seed` so replay reconstructs
the post-migration state correctly rather than replaying from empty.
"""
spec = DeltaChannel(_messages_delta_reducer, list)
seed = [HumanMessage(content="pre-delta", id="p1")]
ch = spec.from_checkpoint(seed)
ch.replay_writes(
[
("t0", "messages", AIMessage(content="delta-1", id="d1")),
("t1", "messages", HumanMessage(content="delta-2", id="d2")),
]
)
msgs = ch.get()
assert [m.content for m in msgs] == ["pre-delta", "delta-1", "delta-2"]
def test_delta_channel_from_checkpoint_seed_without_writes() -> None:
"""Reconstruction at a pre-delta ancestor with no newer deltas returns
just the seed the saver's terminator fired immediately."""
spec = DeltaChannel(_messages_delta_reducer, list)
seed = [HumanMessage(content="only-snap", id="s1")]
ch = spec.from_checkpoint(seed)
ch.replay_writes([])
assert ch.get() == seed
def test_delta_channel_from_checkpoint_seed_none_is_distinct_from_sentinel() -> None:
"""`seed=None` must start replay from None, not from an empty channel.
The DELTA_SENTINEL / MISSING sentinels mean 'no seed'; passing `None`
explicitly should feed None to the reducer as the left operand.
"""
def replace(state, writes):
return writes[-1] if writes else state
spec = DeltaChannel(replace, list)
ch = spec.from_checkpoint(None)
ch.replay_writes([("t0", "x", "after")])
assert ch.get() == "after"
@@ -1,478 +0,0 @@
"""Benchmark: DeltaChannel snapshot_frequency — storage vs. read-depth tradeoff.
Run directly: python tests/test_delta_channel_benchmark.py
Run via pytest: pytest tests/test_delta_channel_benchmark.py -s
Part 1 baseline (original): DeltaChannel(inf) vs add_messages (BinOp).
Part 2 snapshot_frequency sweep: shows the storage/read-latency tradeoff
across frequencies [1, 5, 10, 50, inf] at scale.
Key insight:
snapshot_frequency=inf O(N) storage, O(N) read depth (pure delta)
snapshot_frequency=N O(/N) storage, O(N) read depth bounded by freq
snapshot_frequency=1 O() storage, O(1) read depth (full snapshot)
"""
from __future__ import annotations
import contextlib
import math
import os
import sys
import time
from typing import Annotated, Any
import pytest
from langchain_core.messages import AIMessage, HumanMessage
from langgraph.checkpoint.memory import MemorySaver
from typing_extensions import TypedDict
from langgraph.channels.delta import DeltaChannel
from langgraph.graph import END, StateGraph
from langgraph.graph.message import _messages_delta_reducer, add_messages
try:
from langgraph.checkpoint.postgres import PostgresSaver
_POSTGRES_AVAILABLE = True
_POSTGRES_URI = os.environ.get(
"LANGGRAPH_BENCH_POSTGRES_URI",
"postgres://postgres@localhost:5432/postgres?sslmode=disable",
)
except ImportError:
_POSTGRES_AVAILABLE = False
# ---------------------------------------------------------------------------
# Realistic message payload (~100 tokens / ~400 chars each)
# ---------------------------------------------------------------------------
_HUMAN_TEMPLATE = (
"I need help understanding the implications of {topic} on our system architecture. "
"Specifically, I'm concerned about how this interacts with our existing {concern} "
"and whether we need to refactor the {component} layer before proceeding. "
"We've had prior incidents in this area and want to be deliberate. "
"What should we prioritize first, and are there known failure modes we should design around from the start?"
)
_AI_TEMPLATE = (
"Great question about {topic}. The key insight here is that {concern} introduces "
"a subtle ordering dependency that most teams overlook until they hit it in production. "
"For your {component} layer specifically, I'd recommend starting with a careful audit "
"of the interface boundaries before making any structural changes. This will give you "
"a clear picture of the blast radius and let you sequence the migration safely."
)
_TOPICS = [
"distributed tracing",
"eventual consistency",
"schema migration",
"backpressure handling",
"idempotency guarantees",
"cache invalidation",
"connection pooling",
"rate limiting",
"circuit breaking",
"observability pipelines",
]
_CONCERNS = [
"concurrency model",
"retry semantics",
"state management",
"error propagation",
"latency budget",
]
_COMPONENTS = [
"persistence",
"routing",
"ingestion",
"aggregation",
"serialization",
]
def _human_content(i: int) -> str:
return _HUMAN_TEMPLATE.format(
topic=_TOPICS[i % len(_TOPICS)],
concern=_CONCERNS[i % len(_CONCERNS)],
component=_COMPONENTS[i % len(_COMPONENTS)],
)
def _ai_content(i: int) -> str:
return _AI_TEMPLATE.format(
topic=_TOPICS[i % len(_TOPICS)],
concern=_CONCERNS[i % len(_CONCERNS)],
component=_COMPONENTS[i % len(_COMPONENTS)],
)
# ---------------------------------------------------------------------------
# State definitions
# ---------------------------------------------------------------------------
class BinaryState(TypedDict):
messages: Annotated[list, add_messages]
class DeltaState(TypedDict):
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
def _make_delta_state(snapshot_frequency: int | float) -> type:
"""Create a TypedDict with DeltaChannel at the given snapshot_frequency."""
channel = DeltaChannel(
_messages_delta_reducer, snapshot_frequency=snapshot_frequency
)
# Use the functional TypedDict form so the Annotated type is stored as an
# already-evaluated object rather than a forward-reference string (which
# would fail when get_type_hints tries to resolve 'snapshot_frequency').
return TypedDict( # type: ignore[return-value]
f"DeltaState_freq{snapshot_frequency}",
{"messages": Annotated[list, channel]},
)
# ---------------------------------------------------------------------------
# Graph factory
# ---------------------------------------------------------------------------
def _make_graph(state_cls: type, checkpointer: Any = None) -> Any:
def human_node(state: Any) -> dict:
return {}
def ai_node(state: Any) -> dict:
i = len(state["messages"]) // 2
return {"messages": [AIMessage(content=_ai_content(i), id=f"a{i}")]}
g = StateGraph(state_cls)
g.add_node("human", human_node)
g.add_node("ai", ai_node)
g.add_edge("human", "ai")
g.add_edge("ai", END)
g.set_entry_point("human")
return g.compile(checkpointer=checkpointer or MemorySaver())
# ---------------------------------------------------------------------------
# Measurement helpers
# ---------------------------------------------------------------------------
def _total_blob_bytes(saver: MemorySaver) -> int:
total = 0
for (_, _, _, _), (type_tag, blob) in saver.blobs.items():
if blob is not None:
total += len(blob)
return total
def _run_turns(
n_turns: int,
state_cls: type,
checkpointer: Any = None,
) -> tuple[float, float, int]:
"""Run n_turns conversation turns.
Returns (write_elapsed_s, read_elapsed_s, total_blob_bytes).
Read latency is the average of 5 get_state calls after the full history
is built forces state rehydration including ancestor replay if needed.
"""
graph = _make_graph(state_cls, checkpointer)
config = {"configurable": {"thread_id": "bench"}}
t0 = time.perf_counter()
for i in range(n_turns):
graph.invoke(
{"messages": [HumanMessage(content=_human_content(i), id=f"h{i}")]},
config,
)
write_elapsed = time.perf_counter() - t0
t1 = time.perf_counter()
for _ in range(5):
graph.get_state(config)
read_elapsed = (time.perf_counter() - t1) / 5
blob_bytes = (
_total_blob_bytes(graph.checkpointer)
if isinstance(graph.checkpointer, MemorySaver)
else -1
)
return write_elapsed, read_elapsed, blob_bytes
def _fmt_bytes(n: int) -> str:
if n >= 1_000_000:
return f"{n / 1_000_000:.1f} MB"
if n >= 1_000:
return f"{n / 1_000:.1f} KB"
return f"{n} B"
def _approx_tokens(n_turns: int) -> str:
tokens = n_turns * 200
if tokens >= 1_000_000:
return f"~{tokens / 1_000_000:.1f}M tok"
if tokens >= 1_000:
return f"~{tokens / 1_000:.0f}K tok"
return f"~{tokens} tok"
# ---------------------------------------------------------------------------
# Checkpointer factories
# ---------------------------------------------------------------------------
@contextlib.contextmanager
def _pg_saver(thread_id: str = "bench"):
"""Context manager that yields a fresh PostgresSaver and cleans up after."""
with PostgresSaver.from_conn_string(_POSTGRES_URI) as saver:
saver.setup()
with saver._cursor() as cur:
for tbl in ("checkpoints", "checkpoint_blobs", "checkpoint_writes"):
cur.execute(f"DELETE FROM {tbl} WHERE thread_id = %s", (thread_id,))
yield saver
with saver._cursor() as cur:
for tbl in ("checkpoints", "checkpoint_blobs", "checkpoint_writes"):
cur.execute(f"DELETE FROM {tbl} WHERE thread_id = %s", (thread_id,))
def _checkpointers() -> list[tuple[str, Any]]:
"""Return (label, saver_or_None) pairs for available checkpointers."""
result: list[tuple[str, Any]] = [("InMemory", None)]
if _POSTGRES_AVAILABLE:
try:
import psycopg
psycopg.connect(_POSTGRES_URI).close()
result.append(("Postgres", "postgres"))
except Exception:
pass
return result
# ---------------------------------------------------------------------------
# Part 1: baseline DeltaChannel(inf) vs add_messages
# ---------------------------------------------------------------------------
BASELINE_TURN_COUNTS = [10, 25, 50, 100, 500]
DELTA_ONLY_TURN_COUNTS = [1000]
def _run_baseline_for_checkpointer(cp_label: str, cp_hint: Any) -> None:
W = 72
def _make_saver():
if cp_hint is None:
return contextlib.nullcontext(None)
return _pg_saver()
rows: list[tuple[int, Any, Any, Any, Any, Any, Any]] = []
for turns in BASELINE_TURN_COUNTS:
with _make_saver() as saver:
b_wt, b_rt, b_bytes = _run_turns(turns, BinaryState, saver)
with _make_saver() as saver:
d_wt, d_rt, d_bytes = _run_turns(turns, DeltaState, saver)
rows.append((turns, b_bytes, d_bytes, b_rt, d_rt, b_wt, d_wt))
for turns in DELTA_ONLY_TURN_COUNTS:
with _make_saver() as saver:
d_wt, d_rt, d_bytes = _run_turns(turns, DeltaState, saver)
rows.append((turns, None, d_bytes, None, d_rt, None, d_wt))
def _bytes_or_na(v: Any) -> str:
if v is None or v < 0:
return "n/a"
return _fmt_bytes(v)
def _ms_or_na(v: Any) -> str:
return "n/a" if v is None else f"{v * 1000:.1f}ms"
print(f"\n [{cp_label}] Storage (blob bytes)")
print(
f" {'turns':>6} {'ctx':>10} {'add_msgs':>12} {'delta(inf)':>12} {'savings':>8}"
)
print(" " + "-" * (W - 2))
for turns, b_bytes, d_bytes, b_rt, d_rt, b_wt, d_wt in rows:
if b_bytes is None or b_bytes < 0 or d_bytes is None or d_bytes < 0:
ratio_str = "n/a"
else:
ratio = b_bytes / d_bytes if d_bytes else float("inf")
ratio_str = f"{ratio:.0f}x"
print(
f" {turns:>6} {_approx_tokens(turns):>10} "
f"{_bytes_or_na(b_bytes):>12} {_bytes_or_na(d_bytes):>12} {ratio_str:>8}"
)
print(f"\n [{cp_label}] Read latency (avg of 5 get_state calls)")
print(f" {'turns':>6} {'ctx':>10} {'add_msgs':>12} {'delta(inf)':>12}")
print(" " + "-" * (W - 2))
for turns, b_bytes, d_bytes, b_rt, d_rt, b_wt, d_wt in rows:
print(
f" {turns:>6} {_approx_tokens(turns):>10} "
f"{_ms_or_na(b_rt):>12} {_ms_or_na(d_rt):>12}"
)
def run_baseline_benchmark() -> None:
print()
print("Part 1 — DeltaChannel(inf) vs add_messages: storage & latency")
print("=" * 72)
for cp_label, cp_hint in _checkpointers():
_run_baseline_for_checkpointer(cp_label, cp_hint)
print()
# ---------------------------------------------------------------------------
# Part 2: snapshot_frequency sweep
# ---------------------------------------------------------------------------
# Frequencies to test. 1 = always snapshot (like BinOp), inf = pure delta.
SNAPSHOT_FREQUENCIES: list[int | float] = [1, 5, 10, 50, math.inf]
# Turn counts for the sweep — high enough to show storage divergence.
SWEEP_TURN_COUNTS = [50, 100, 500]
def _freq_label(freq: int | float) -> str:
if freq == math.inf:
return "inf"
return str(int(freq))
def _run_sweep_for_checkpointer(cp_label: str, cp_hint: Any) -> None:
def _make_saver():
if cp_hint is None:
return contextlib.nullcontext(None)
return _pg_saver()
# Collect results: {turns: {freq_label: (write_s, read_s, bytes)}}
results: dict[int, dict[str, tuple[float, float, int]]] = {}
for turns in SWEEP_TURN_COUNTS:
results[turns] = {}
for freq in SNAPSHOT_FREQUENCIES:
state_cls = _make_delta_state(freq)
with _make_saver() as saver:
wt, rt, bb = _run_turns(turns, state_cls, saver)
results[turns][_freq_label(freq)] = (wt, rt, bb)
freq_labels = [_freq_label(f) for f in SNAPSHOT_FREQUENCIES]
col_w = 12
header = f" {'turns':>6} {'ctx':>10}" + "".join(
f" {f'freq={freq_label}':>{col_w}}" for freq_label in freq_labels
)
print(f"\n [{cp_label}] Storage (blob bytes) — lower is better")
print(header)
print(" " + "-" * (len(header) - 2))
for turns in SWEEP_TURN_COUNTS:
row = f" {turns:>6} {_approx_tokens(turns):>10}"
for label in freq_labels:
_, _, bb = results[turns][label]
row += f" {_fmt_bytes(bb) if bb >= 0 else 'n/a':>{col_w}}"
print(row)
print(f"\n [{cp_label}] Read latency (avg of 5 get_state) — lower is better")
print(header)
print(" " + "-" * (len(header) - 2))
for turns in SWEEP_TURN_COUNTS:
row = f" {turns:>6} {_approx_tokens(turns):>10}"
for label in freq_labels:
_, rt, _ = results[turns][label]
row += f" {f'{rt * 1000:.1f}ms':>{col_w}}"
print(row)
print(
f"\n [{cp_label}] Per-invoke write latency (total / turns) — lower is better"
)
print(header)
print(" " + "-" * (len(header) - 2))
for turns in SWEEP_TURN_COUNTS:
row = f" {turns:>6} {_approx_tokens(turns):>10}"
for label in freq_labels:
wt, _, _ = results[turns][label]
row += f" {f'{(wt / turns) * 1000:.1f}ms':>{col_w}}"
print(row)
def run_snapshot_freq_benchmark() -> None:
print()
print("Part 2 — DeltaChannel snapshot_frequency sweep")
print("Lower freq → fewer snapshots → less storage but deeper read replay")
print("=" * 80)
for cp_label, cp_hint in _checkpointers():
_run_sweep_for_checkpointer(cp_label, cp_hint)
print()
print("Legend:")
print(
" freq=1 snapshot every write (full blob always — same as add_messages / BinOp)"
)
print(" freq=N snapshot every N writes; read walks at most N ancestor writes")
print(" freq=inf pure delta; read walks entire ancestor chain")
print()
# ---------------------------------------------------------------------------
# Pytest entry points
# ---------------------------------------------------------------------------
@pytest.mark.skip(
reason="slow benchmark — run manually with: python tests/test_delta_channel_benchmark.py"
)
def test_delta_channel_baseline_benchmark(capsys: Any) -> None:
"""DeltaChannel(inf) uses less storage than add_messages at scale."""
with capsys.disabled():
run_baseline_benchmark()
for turns in [25, 50]:
_, _, b_bytes = _run_turns(turns, BinaryState)
_, _, d_bytes = _run_turns(turns, DeltaState)
assert d_bytes < b_bytes, (
f"DeltaChannel should use less storage at {turns} turns, "
f"got delta={d_bytes} binary={b_bytes}"
)
@pytest.mark.skip(
reason="slow benchmark — run manually with: python tests/test_delta_channel_benchmark.py"
)
def test_snapshot_freq_benchmark(capsys: Any) -> None:
"""snapshot_frequency trades storage for bounded read depth."""
with capsys.disabled():
run_snapshot_freq_benchmark()
# Correctness: results at all frequencies should agree on final state.
n_turns = 20
states: dict[str, list] = {}
for freq in SNAPSHOT_FREQUENCIES:
state_cls = _make_delta_state(freq)
graph = _make_graph(state_cls)
config = {"configurable": {"thread_id": "correctness"}}
for i in range(n_turns):
graph.invoke(
{"messages": [HumanMessage(content=_human_content(i), id=f"h{i}")]},
config,
)
state = graph.get_state(config)
states[_freq_label(freq)] = [m.id for m in state.values["messages"]]
ref = states["inf"]
for label, msg_ids in states.items():
assert msg_ids == ref, (
f"freq={label} produced different message IDs than freq=inf"
)
# ---------------------------------------------------------------------------
# Script entry point
# ---------------------------------------------------------------------------
if __name__ == "__main__":
run_baseline_benchmark()
run_snapshot_freq_benchmark()
sys.exit(0)
@@ -1,613 +0,0 @@
"""Tests for the BinaryOperatorAggregate -> DeltaChannel migration path.
A thread written under `BinaryOperatorAggregate(...)` must keep working
after its annotation is swapped to `DeltaChannel(...)` on the same
checkpointer pre-migration state visible at each *settled* ancestor
checkpoint is preserved, and post-migration writes fold on top through
the reducer.
Mechanism under test: the saver's `_get_channel_writes_history(config,
channel)` walks the parent chain; when it encounters an ancestor whose
`channel_values[channel]` is a real value (not `DELTA_SENTINEL`), it
returns that as the `seed`. `DeltaChannel.from_checkpoint(seed)` uses
it as the base value, and `replay_writes(writes)` folds on-path deltas.
Scenarios covered:
1. **Basic migration (sync + async)**: build pre-migration state with
`BinaryOperatorAggregate`, swap the annotation to `DeltaChannel` on
the same checkpointer, and verify that every settled pre-migration
super-step boundary (`next=('__start__',)`) round-trips exactly
under the delta-channel view.
2. **Time travel into a pre-migration checkpoint** after migration
`graph.get_state(pre_migration_config)` at a settled ancestor
returns the same state as under the binop channel.
3. **Continuing a migrated thread**: driving one more super-step after
migration produces a state that includes the pre-migration settled
prefix plus the new delta write proving `from_checkpoint(seed)` +
`replay_writes` correctly fold post-migration deltas onto the
pre-migration seed.
4. **Base-saver fallback path**: a third-party-style subclass that
removes the optimized `InMemorySaver` override and falls back to
`BaseCheckpointSaver._get_channel_writes_history` must produce the
same result as the optimized path.
5. **Channel-type isolation across threads**: two threads on the same
checkpointer under the delta-channel graph one freshly-started,
one migrated from pre-migration state don't cross-contaminate.
The parent-chain walk is scoped to the thread.
TODO: add postgres variants in the existing `libs/checkpoint-postgres`
test files (different fixture setup; not this file).
"""
from __future__ import annotations
import operator
from typing import Annotated, Any
import pytest
from langchain_core.messages import AIMessage, HumanMessage
from langgraph.checkpoint.memory import InMemorySaver
from typing_extensions import TypedDict
from langgraph.channels.binop import BinaryOperatorAggregate
from langgraph.channels.delta import DeltaChannel
from langgraph.graph import END, START, StateGraph
from langgraph.graph.message import _messages_delta_reducer, add_messages
pytestmark = pytest.mark.anyio
# ---------------------------------------------------------------------------
# Graph factories
#
# A minimal reducer (`operator.add` on lists of str) with a noop node keeps
# state change localized to the HumanMessage-like payload passed through
# `invoke`. That isolates the pre/post-migration parity assertions to
# channel-hydration semantics.
# ---------------------------------------------------------------------------
def _noop(_state: Any) -> dict:
return {}
def _list_concat(state: list, writes: list) -> list:
result = list(state)
for w in writes:
result.extend(w if isinstance(w, list) else [w])
return result
def _binop_graph(checkpointer: Any) -> Any:
class BinopState(TypedDict):
items: Annotated[list, BinaryOperatorAggregate(list, operator.add)]
return (
StateGraph(BinopState)
.add_node("noop", _noop)
.add_edge(START, "noop")
.add_edge("noop", END)
.compile(checkpointer=checkpointer)
)
def _delta_graph(checkpointer: Any) -> Any:
class DeltaState(TypedDict):
items: Annotated[list, DeltaChannel(_list_concat)]
return (
StateGraph(DeltaState)
.add_node("noop", _noop)
.add_edge(START, "noop")
.add_edge("noop", END)
.compile(checkpointer=checkpointer)
)
def _drive(graph: Any, config: dict, tag: str, n: int) -> None:
for i in range(n):
graph.invoke({"items": [f"{tag}{i}"]}, config)
async def _adrive(graph: Any, config: dict, tag: str, n: int) -> None:
for i in range(n):
await graph.ainvoke({"items": [f"{tag}{i}"]}, config)
def _settled_boundaries(history: list) -> list[tuple[dict, list]]:
"""Return `[(config, items), ...]` for every checkpoint in `history`
whose `next == ('__start__',)` the stable boundaries between invokes.
"""
return [
(s.config, list(s.values.get("items", [])))
for s in history
if s.next == ("__start__",)
]
# ---------------------------------------------------------------------------
# 1. Basic migration (sync + async)
# ---------------------------------------------------------------------------
def test_basic_migration_preserves_pre_migration_state() -> None:
"""Build state under `BinaryOperatorAggregate`, migrate to
`DeltaChannel` on the same checkpointer, and verify that every
settled pre-migration super-step boundary round-trips exactly.
Settled boundaries (`next=('__start__',)`) are the stable hydration
targets for the migration path: writes that produced the NEXT
super-step are kept as `pending_writes` on the ancestor, so walking
from a descendant finds the ancestor's blob as the seed and
reconstructs the correct state.
"""
checkpointer = InMemorySaver()
config = {"configurable": {"thread_id": "basic-sync"}}
# Pre-migration: accumulate items across 3 invokes.
binop = _binop_graph(checkpointer)
_drive(binop, config, "u", 3)
pre_boundaries = _settled_boundaries(list(binop.get_state_history(config)))
assert len(pre_boundaries) >= 2, "expected multiple settled boundaries"
# Migrate: swap the annotation on the same checkpointer.
delta = _delta_graph(checkpointer)
for cfg, items in pre_boundaries:
snap = delta.get_state(cfg)
assert list(snap.values.get("items", [])) == items, (
f"snapshot mismatch at {cfg['configurable']['checkpoint_id']}: "
f"expected {items}, got {snap.values.get('items', [])}"
)
async def test_basic_migration_preserves_pre_migration_state_async() -> None:
"""Async variant of the basic migration scenario."""
checkpointer = InMemorySaver()
config = {"configurable": {"thread_id": "basic-async"}}
binop = _binop_graph(checkpointer)
await _adrive(binop, config, "u", 3)
pre_history = [s async for s in binop.aget_state_history(config)]
pre_boundaries = _settled_boundaries(pre_history)
assert len(pre_boundaries) >= 2
delta = _delta_graph(checkpointer)
for cfg, items in pre_boundaries:
snap = await delta.aget_state(cfg)
assert list(snap.values.get("items", [])) == items, (
f"async snapshot mismatch at {cfg['configurable']['checkpoint_id']}"
)
# ---------------------------------------------------------------------------
# 2. Time travel into a pre-migration checkpoint after migration
# ---------------------------------------------------------------------------
def test_time_travel_into_pre_migration_checkpoint() -> None:
"""After migration, `graph.get_state(pre_migration_config)` at a
settled ancestor returns the state as stored at that point."""
checkpointer = InMemorySaver()
config = {"configurable": {"thread_id": "time-travel"}}
binop = _binop_graph(checkpointer)
_drive(binop, config, "u", 3)
pre_boundaries = _settled_boundaries(list(binop.get_state_history(config)))
assert pre_boundaries, "no settled ancestors to time-travel to"
delta = _delta_graph(checkpointer)
# Pick the oldest non-empty boundary — a long distance to walk back.
non_empty = [(cfg, items) for cfg, items in pre_boundaries if items]
assert non_empty, "expected at least one non-empty boundary"
target_cfg, expected_items = non_empty[-1]
snap = delta.get_state(target_cfg)
assert list(snap.values.get("items", [])) == expected_items
# ---------------------------------------------------------------------------
# 3. Continuing a migrated thread: deltas fold onto pre-migration seed
# ---------------------------------------------------------------------------
def test_continuing_migrated_thread_folds_deltas_on_seed() -> None:
"""Resume a pre-migration settled ancestor via `invoke(None, cfg)`
under the delta-channel graph. Since the pre-migration checkpoint
has an existing `pending_writes` entry (the input for the NEXT
super-step), re-running from that ancestor reproduces the same
post-ancestor state as the original binop run.
This proves the seed-terminator + write-replay pipeline works
end-to-end across the migration boundary.
"""
checkpointer = InMemorySaver()
config = {"configurable": {"thread_id": "continue"}}
binop = _binop_graph(checkpointer)
_drive(binop, config, "u", 2)
# Pick the oldest settled boundary with non-empty state.
pre_boundaries = _settled_boundaries(list(binop.get_state_history(config)))
target_cfg, seed_items = next(
(cfg, items) for cfg, items in reversed(pre_boundaries) if items
)
assert seed_items, "need a non-empty seed boundary"
# Migrate and resume from the pre-migration ancestor. `invoke(None,
# cfg)` replays the pending writes staged at `cfg` under the new
# channel; the reducer folds those deltas onto the seed.
delta = _delta_graph(checkpointer)
result = delta.invoke(None, target_cfg)
# The resumed state must include the pre-migration seed items in order.
result_items = list(result.get("items", []))
for idx, prefix_item in enumerate(seed_items):
assert result_items[idx] == prefix_item, (
f"pre-migration seed item at {idx} not preserved: "
f"got {result_items[: idx + 1]}, expected {seed_items}"
)
# ---------------------------------------------------------------------------
# 4. Base-saver fallback path
# ---------------------------------------------------------------------------
class _ThirdPartyStyleSaver(InMemorySaver):
"""Simulates a third-party saver that inherits the reference
`_get_channel_writes_history` implementation from
`BaseCheckpointSaver` rather than overriding it.
We rebind the two methods to the base-class versions (via MRO) so
the fallback path is exercised even though the storage layer is
still the in-memory one.
"""
# MRO: [_ThirdPartyStyleSaver, InMemorySaver, BaseCheckpointSaver, ...]
_get_channel_writes_history = ( # type: ignore[assignment]
InMemorySaver.__mro__[1]._get_channel_writes_history # type: ignore[attr-defined]
)
_aget_channel_writes_history = ( # type: ignore[assignment]
InMemorySaver.__mro__[1]._aget_channel_writes_history # type: ignore[attr-defined]
)
def test_base_saver_fallback_matches_optimized_override() -> None:
"""The reference `BaseCheckpointSaver` implementation must produce
the same migration behavior as the optimized `InMemorySaver`
override. We drive the same migration scenario through both savers
and assert per-snapshot parity in the delta-channel view."""
# Fast path: optimized InMemorySaver override.
fast_saver = InMemorySaver()
fast_config = {"configurable": {"thread_id": "fast"}}
fast_binop = _binop_graph(fast_saver)
_drive(fast_binop, fast_config, "u", 3)
fast_delta = _delta_graph(fast_saver)
fast_history = [
(s.next, list(s.values.get("items", [])))
for s in fast_delta.get_state_history(fast_config)
]
# Slow path: base-class fallback.
slow_saver = _ThirdPartyStyleSaver()
slow_config = {"configurable": {"thread_id": "slow"}}
slow_binop = _binop_graph(slow_saver)
_drive(slow_binop, slow_config, "u", 3)
slow_delta = _delta_graph(slow_saver)
slow_history = [
(s.next, list(s.values.get("items", [])))
for s in slow_delta.get_state_history(slow_config)
]
assert slow_history == fast_history, (
"base-saver fallback should match optimized-override behavior; "
f"fast={fast_history}, slow={slow_history}"
)
# ---------------------------------------------------------------------------
# 5. Thread isolation under mixed-generation storage
# ---------------------------------------------------------------------------
def test_delta_and_migrated_threads_do_not_cross_contaminate() -> None:
"""Two threads sharing a checkpointer — one migrated from
pre-migration state, one freshly-started under DeltaChannel must
maintain independent state. The parent-chain walk in
`_get_channel_writes_history` must be scoped to the target thread.
"""
checkpointer = InMemorySaver()
migrated_cfg = {"configurable": {"thread_id": "migrated"}}
fresh_cfg = {"configurable": {"thread_id": "fresh"}}
# Thread A: pre-migration build-up.
binop = _binop_graph(checkpointer)
_drive(binop, migrated_cfg, "m", 2)
# Thread B: fresh delta-channel run.
delta = _delta_graph(checkpointer)
_drive(delta, fresh_cfg, "f", 2)
# Thread A: migrate and confirm its state is anchored in its own
# thread's pre-migration history (tag 'm'), never mixing in tag 'f'.
migrated_boundaries = _settled_boundaries(
list(delta.get_state_history(migrated_cfg))
)
assert migrated_boundaries, "migrated thread has no settled boundaries"
for _, items in migrated_boundaries:
for it in items:
assert it.startswith("m"), (
f"migrated thread leaked item from other thread: {it}"
)
# Thread B: settled boundaries must only contain 'f' tags.
fresh_boundaries = _settled_boundaries(list(delta.get_state_history(fresh_cfg)))
assert fresh_boundaries, "fresh thread has no settled boundaries"
for _, items in fresh_boundaries:
for it in items:
assert it.startswith("f"), (
f"fresh thread leaked item from migrated thread: {it}"
)
# ---------------------------------------------------------------------------
# 6. Tip-of-pre-migration hydration: the latest checkpoint from a binop-run
# thread has a real accumulated value in its own `channel_values["items"]`.
# When hydrated under the delta-channel graph via `get_state(config)` with no
# `checkpoint_id`, the short-circuit must use that value directly instead of
# walking ancestors (which would skip the tip's own blob).
# ---------------------------------------------------------------------------
def test_tip_of_pre_migration_hydrates_directly() -> None:
"""`graph.get_state(config)` at the latest (pre-migration) checkpoint
returns the full accumulated list stored in that checkpoint's own
`channel_values`. The hydration must not walk ancestors past it."""
checkpointer = InMemorySaver()
config = {"configurable": {"thread_id": "tip-sync"}}
binop = _binop_graph(checkpointer)
_drive(binop, config, "u", 3)
binop_tip = binop.get_state(config)
expected_items = list(binop_tip.values.get("items", []))
assert expected_items == ["u0", "u1", "u2"], (
f"sanity: pre-migration tip should accumulate all 3 items, got {expected_items}"
)
delta = _delta_graph(checkpointer)
snap = delta.get_state(config)
assert list(snap.values.get("items", [])) == expected_items, (
f"tip hydration mismatch: expected {expected_items}, "
f"got {snap.values.get('items', [])}"
)
async def test_tip_of_pre_migration_hydrates_directly_async() -> None:
"""Async variant of the tip-of-pre-migration hydration scenario."""
checkpointer = InMemorySaver()
config = {"configurable": {"thread_id": "tip-async"}}
binop = _binop_graph(checkpointer)
await _adrive(binop, config, "u", 3)
binop_tip = await binop.aget_state(config)
expected_items = list(binop_tip.values.get("items", []))
assert expected_items == ["u0", "u1", "u2"]
delta = _delta_graph(checkpointer)
snap = await delta.aget_state(config)
assert list(snap.values.get("items", [])) == expected_items, (
f"async tip hydration mismatch: expected {expected_items}, "
f"got {snap.values.get('items', [])}"
)
# ---------------------------------------------------------------------------
# 7. `update_state` after migration writes a real value to the new
# checkpoint's `channel_values` (not a sentinel). Hydration must use it
# directly — the ancestor walk would skip this blob and return stale state.
# ---------------------------------------------------------------------------
def test_update_state_after_migration_uses_written_value() -> None:
"""After migrating and running at least one post-migration super-step
(so the thread's tip has a `DELTA_SENTINEL`), `update_state` writes a
concrete value to a new checkpoint's `channel_values`. `get_state`
must reflect that concrete value."""
checkpointer = InMemorySaver()
config = {"configurable": {"thread_id": "update-state"}}
# Pre-migration: accumulate a little state.
binop = _binop_graph(checkpointer)
_drive(binop, config, "u", 2)
# Migrate and run one more super-step so the tip is a post-migration
# checkpoint with `DELTA_SENTINEL` in its own `channel_values`.
delta = _delta_graph(checkpointer)
delta.invoke({"items": ["post"]}, config)
# `update_state` writes a concrete value into a new checkpoint's blob
# via the reducer against the hydrated prior state.
delta.update_state(config, {"items": ["x", "y"]})
snap = delta.get_state(config)
updated_items = list(snap.values.get("items", []))
# Must include the "x","y" update; without the hydration fix, the
# update_state-written blob would be skipped in favor of an ancestor
# walk, and the update values would disappear.
assert "x" in updated_items and "y" in updated_items, (
f"update_state values missing from snapshot: {updated_items}"
)
# The "x","y" items should be folded onto the prior accumulated state,
# not stand alone. This verifies the update-written blob is used
# directly by `get_state` (no ancestor walk past it).
assert len(updated_items) >= 4, (
f"update_state snapshot should preserve pre-update state, got {updated_items}"
)
assert updated_items[-2:] == ["x", "y"], (
f"update_state deltas should be at the tail, got {updated_items}"
)
# ---------------------------------------------------------------------------
# 8. Fork from an `update_state` checkpoint: a new run branched off the
# update_state-produced checkpoint must see that checkpoint's concrete
# `channel_values` as its base, with new deltas folded on top.
# ---------------------------------------------------------------------------
def test_fork_from_update_state_checkpoint() -> None:
"""Branching a new run from the checkpoint produced by `update_state`
must use that checkpoint's concrete blob as the base. Additional
deltas from the forked run fold onto it through the reducer."""
checkpointer = InMemorySaver()
config = {"configurable": {"thread_id": "fork"}}
# Pre-migration build-up, then migrate and add one post-migration step.
binop = _binop_graph(checkpointer)
_drive(binop, config, "u", 2)
delta = _delta_graph(checkpointer)
delta.invoke({"items": ["post"]}, config)
# Apply `update_state` and capture the returned config (references
# the new checkpoint produced by the update).
update_cfg = delta.update_state(config, {"items": ["x", "y"]})
update_snap = delta.get_state(update_cfg)
base_items = list(update_snap.values.get("items", []))
assert "x" in base_items and "y" in base_items, (
f"update_state values missing from snapshot: {base_items}"
)
assert base_items[-2:] == ["x", "y"], (
f"sanity: update_state deltas should be at the tail, got {base_items}"
)
# Fork: invoke from the update_state checkpoint with a new delta.
forked = delta.invoke({"items": ["fork0"]}, update_cfg)
forked_items = list(forked.get("items", []))
# The fork must see the update_state-written blob as its base (not
# walk past it), and the new delta must fold on top of it.
assert forked_items[: len(base_items)] == base_items, (
f"fork lost update_state base: base={base_items}, forked={forked_items}"
)
assert forked_items[-1] == "fork0", f"fork delta not appended: {forked_items}"
# ---------------------------------------------------------------------------
# 9. Migration from `add_messages` → `DeltaChannel(_messages_delta_reducer)`
#
# `add_messages` is the primary real-world use case: it creates a
# BinaryOperatorAggregate with dedup-by-ID and RemoveMessage semantics.
# After swapping the annotation to DeltaChannel, pre-migration blobs
# (plain lists of Message objects) must be used directly as the seed.
# ---------------------------------------------------------------------------
def _add_messages_graph(checkpointer: Any) -> Any:
class MessagesState(TypedDict):
messages: Annotated[list, add_messages]
return (
StateGraph(MessagesState)
.add_node("noop", _noop)
.add_edge(START, "noop")
.add_edge("noop", END)
.compile(checkpointer=checkpointer)
)
def _delta_messages_graph(checkpointer: Any) -> Any:
class DeltaMessagesState(TypedDict):
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
return (
StateGraph(DeltaMessagesState)
.add_node("noop", _noop)
.add_edge(START, "noop")
.add_edge("noop", END)
.compile(checkpointer=checkpointer)
)
def test_add_messages_to_delta_migration_preserves_message_history() -> None:
"""Migration from `add_messages` to `DeltaChannel(_messages_delta_reducer)`
preserves message ordering and IDs at both the tip and settled ancestor
boundaries.
The pre-migration blob is a plain list of Message objects; DeltaChannel
must use it directly as the seed without walking ancestors past it.
"""
checkpointer = InMemorySaver()
config = {"configurable": {"thread_id": "add-messages-migration"}}
pre_graph = _add_messages_graph(checkpointer)
pre_graph.invoke({"messages": [HumanMessage(content="hello", id="h1")]}, config)
pre_graph.invoke({"messages": [AIMessage(content="hi", id="a1")]}, config)
pre_graph.invoke({"messages": [HumanMessage(content="thanks", id="h2")]}, config)
pre_tip = pre_graph.get_state(config)
assert [m.id for m in pre_tip.values["messages"]] == ["h1", "a1", "h2"]
delta_graph = _delta_messages_graph(checkpointer)
# Tip: latest checkpoint has a full list blob — must use it directly.
snap = delta_graph.get_state(config)
assert [m.id for m in snap.values["messages"]] == ["h1", "a1", "h2"], (
f"tip hydration mismatch: got {[m.id for m in snap.values['messages']]}"
)
# Settled ancestor boundaries must also match.
pre_settled = [
[m.id for m in s.values.get("messages", [])]
for s in pre_graph.get_state_history(config)
if s.next == ("__start__",)
]
delta_settled = [
[m.id for m in s.values.get("messages", [])]
for s in delta_graph.get_state_history(config)
if s.next == ("__start__",)
]
assert delta_settled == pre_settled, (
f"settled boundary mismatch after migration: "
f"pre={pre_settled}, delta={delta_settled}"
)
async def test_add_messages_to_delta_migration_preserves_message_history_async() -> (
None
):
"""Async variant of the add_messages migration test."""
checkpointer = InMemorySaver()
config = {"configurable": {"thread_id": "add-messages-migration-async"}}
pre_graph = _add_messages_graph(checkpointer)
await pre_graph.ainvoke(
{"messages": [HumanMessage(content="hello", id="h1")]}, config
)
await pre_graph.ainvoke({"messages": [AIMessage(content="hi", id="a1")]}, config)
delta_graph = _delta_messages_graph(checkpointer)
snap = await delta_graph.aget_state(config)
assert [m.id for m in snap.values["messages"]] == ["h1", "a1"], (
f"async tip hydration mismatch: got {[m.id for m in snap.values['messages']]}"
)
+2 -278
View File
@@ -16,7 +16,7 @@ from typing import Annotated, Any, Literal, get_type_hints
import pytest
from langchain_core.language_models import GenericFakeChatModel
from langchain_core.messages import AIMessage, AnyMessage, HumanMessage, RemoveMessage
from langchain_core.messages import AIMessage, AnyMessage, HumanMessage
from langchain_core.runnables import (
RunnableConfig,
RunnableLambda,
@@ -25,7 +25,6 @@ from langchain_core.runnables import (
from langchain_core.runnables.graph import Edge
from langgraph.cache.base import BaseCache
from langgraph.checkpoint.base import (
DELTA_SENTINEL,
BaseCheckpointSaver,
Checkpoint,
CheckpointMetadata,
@@ -42,7 +41,6 @@ from typing_extensions import NotRequired, TypedDict
from langgraph._internal._constants import CONFIG_KEY_NODE_FINISHED, ERROR, PULL
from langgraph.channels.binop import BinaryOperatorAggregate
from langgraph.channels.delta import DeltaChannel
from langgraph.channels.ephemeral_value import EphemeralValue
from langgraph.channels.last_value import LastValue
from langgraph.channels.topic import Topic
@@ -51,7 +49,7 @@ from langgraph.config import get_stream_writer
from langgraph.errors import GraphRecursionError, InvalidUpdateError, ParentCommand
from langgraph.func import entrypoint, task
from langgraph.graph import END, START, StateGraph
from langgraph.graph.message import MessagesState, _messages_delta_reducer, add_messages
from langgraph.graph.message import MessagesState, add_messages
from langgraph.pregel import (
NodeBuilder,
Pregel,
@@ -122,29 +120,6 @@ def test_graph_validation() -> None:
graph.invoke({"hello": "there"})
def test_request_drain_allows_inflight_call_scheduling(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
from langgraph.runtime import RunControl
@task
def child(x: int) -> int:
return x + 1
control = RunControl()
@entrypoint(checkpointer=sync_checkpointer)
def graph(x: int) -> int:
control.request_drain()
fut = child(x)
return fut.result()
config = {"configurable": {"thread_id": "drain-call-sync"}}
assert graph.invoke(1, config=config, control=control) == 2
assert control.drain_requested
def test_invalid_checkpointer_type() -> None:
class State(TypedDict):
foo: str
@@ -9425,254 +9400,3 @@ def test_fork_does_not_apply_pending_writes(
# Should be: 1 (input) + 20 (forked node_a) + 100 (node_b) = 121
assert result == {"value": 121}
async def test_delta_channel_end_to_end_inmemory() -> None:
"""Full graph run: DeltaChannel accumulates correctly across multiple turns."""
class State(TypedDict):
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
def respond(state: State) -> dict:
n = len(state["messages"])
return {"messages": [AIMessage(content=f"reply-{n}", id=f"ai-{n}")]}
builder = StateGraph(State)
builder.add_node("respond", respond)
builder.add_edge(START, "respond")
graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "diff-test-1"}}
# Turn 1
graph.invoke({"messages": [HumanMessage(content="hello", id="h1")]}, config)
# Turn 2
graph.invoke({"messages": [HumanMessage(content="world", id="h2")]}, config)
# Turn 3
graph.invoke({"messages": [HumanMessage(content="bye", id="h3")]}, config)
state = graph.get_state(config)
msgs = state.values["messages"]
# 3 human + 3 AI = 6 total
assert len(msgs) == 6, f"expected 6 messages, got {len(msgs)}: {msgs}"
assert msgs[0].content == "hello"
assert msgs[2].content == "world"
assert msgs[4].content == "bye"
assert msgs[1].content == "reply-1"
assert msgs[3].content == "reply-3"
assert msgs[5].content == "reply-5"
async def test_delta_channel_time_travel() -> None:
"""Time-travel back to turn-1 checkpoint and resume; continuation must not include turn-2 deltas."""
class State(TypedDict):
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
counter = {"n": 0}
def respond(state: State) -> dict:
counter["n"] += 1
return {
"messages": [
AIMessage(content=f"ai-{counter['n']}", id=f"ai-{counter['n']}")
]
}
builder = StateGraph(State)
builder.add_node("respond", respond)
builder.add_edge(START, "respond")
saver = InMemorySaver()
graph = builder.compile(checkpointer=saver)
config = {"configurable": {"thread_id": "diff-time-travel"}}
# Run 2 turns: h1→ai-1, h2→ai-2
graph.invoke({"messages": [HumanMessage(content="h1", id="h1")]}, config)
graph.invoke({"messages": [HumanMessage(content="h2", id="h2")]}, config)
# Find the checkpoint after turn 1 (2 messages: h1 + ai-1)
history = list(graph.get_state_history(config))
after_turn1 = next(h for h in history if len(h.values.get("messages", [])) == 2)
assert len(after_turn1.values["messages"]) == 2
assert after_turn1.values["messages"][0].content == "h1"
assert after_turn1.values["messages"][1].content == "ai-1"
# Resume from turn-1 checkpoint: inject h3, expect 3 messages total (h1, ai-1, ai-N)
# NOT 5 messages (turn-2 deltas must not bleed into the resumed run)
result = graph.invoke(
{"messages": [HumanMessage(content="h3", id="h3")]},
after_turn1.config,
)
msgs = result["messages"]
# Should be: h1, ai-1, h3, ai-N — 4 messages total
assert len(msgs) == 4, (
f"expected 4 messages after time-travel resume, got {len(msgs)}: {msgs}"
)
assert msgs[0].content == "h1"
assert msgs[1].content == "ai-1"
assert msgs[2].content == "h3"
async def test_delta_channel_remove_message_end_to_end() -> None:
"""RemoveMessage inside a DeltaChannel graph must persist and reload correctly."""
class State(TypedDict):
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
def respond(state: State) -> dict:
return {"messages": [AIMessage(content="reply", id="ai-1")]}
def delete_first(state: State) -> dict:
# removes the first message
return {"messages": [RemoveMessage(id=state["messages"][0].id)]}
builder = StateGraph(State)
builder.add_node("respond", respond)
builder.add_node("delete_first", delete_first)
builder.add_edge(START, "respond")
builder.add_edge("respond", "delete_first")
graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "diff-remove-test"}}
graph.invoke({"messages": [HumanMessage(content="hello", id="h1")]}, config)
state = graph.get_state(config)
msgs = state.values["messages"]
# h1 was removed, only ai-1 should remain
assert len(msgs) == 1, f"expected 1 message, got {len(msgs)}: {msgs}"
assert msgs[0].id == "ai-1"
# A subsequent turn must reconstruct from the checkpoint correctly
graph.invoke({"messages": [HumanMessage(content="again", id="h2")]}, config)
state = graph.get_state(config)
msgs = state.values["messages"]
# ai-1 + h2 + ai-1(second reply, same id overwrites) + h2 removed
# more simply: after second run we expect ai-1 updated + h2 remaining minus deleted h2
# just assert h1 is still gone
assert all(m.id != "h1" for m in msgs), (
"h1 should still be absent after second turn"
)
async def test_delta_channel_update_by_id_end_to_end() -> None:
"""Updating a message by ID via DeltaChannel must persist and reload correctly."""
class State(TypedDict):
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
def update_msg(state: State) -> dict:
# re-send h1 with updated content
return {"messages": [HumanMessage(content="updated", id="h1")]}
builder = StateGraph(State)
builder.add_node("update_msg", update_msg)
builder.add_edge(START, "update_msg")
graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "diff-update-id-test"}}
graph.invoke({"messages": [HumanMessage(content="original", id="h1")]}, config)
state = graph.get_state(config)
msgs = state.values["messages"]
assert len(msgs) == 1, f"expected 1 message, got {len(msgs)}: {msgs}"
assert msgs[0].content == "updated"
assert msgs[0].id == "h1"
# Second turn: verify the updated state is the base for further accumulation
graph.invoke({"messages": [HumanMessage(content="new", id="h2")]}, config)
state = graph.get_state(config)
msgs = state.values["messages"]
ids = [m.id for m in msgs]
assert "h1" in ids # h1 persists (updated, not duplicated)
assert "h2" in ids
assert ids.count("h1") == 1, "h1 must not be duplicated"
async def test_delta_channel_durability_exit_stores_snapshot() -> None:
"""DeltaChannel must reload from a durability='exit' checkpoint."""
class State(TypedDict):
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
def respond(state: State) -> dict:
return {"messages": [AIMessage(content="reply", id="ai1")]}
builder = StateGraph(State)
builder.add_node("respond", respond)
builder.add_edge(START, "respond")
graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "delta-exit-test"}}
result = graph.invoke(
{"messages": [HumanMessage(content="hello", id="h1")]},
config,
durability="exit",
)
assert [m.content for m in result["messages"]] == ["hello", "reply"]
state = graph.get_state(config)
assert [m.content for m in state.values["messages"]] == ["hello", "reply"]
async def test_delta_channel_async_write_ordering() -> None:
"""In async mode, DeltaChannel write futures are awaited before the checkpoint
is committed, so aput_writes always precedes aput for sentinel checkpoints."""
class State(TypedDict):
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
def respond(state: State) -> dict:
i = len(state["messages"])
return {"messages": [AIMessage(content=f"r{i}", id=f"ai{i}")]}
order: list[str] = []
original_aput_writes = InMemorySaver.aput_writes
original_aput = InMemorySaver.aput
async def tracked_aput_writes(self, config, writes, task_id, task_path=""):
result = await original_aput_writes(self, config, writes, task_id, task_path)
order.append("aput_writes")
return result
async def tracked_aput(self, config, checkpoint, metadata, new_versions):
has_sentinel = any(
v is DELTA_SENTINEL for v in checkpoint.get("channel_values", {}).values()
)
order.append("aput_sentinel" if has_sentinel else "aput_other")
return await original_aput(self, config, checkpoint, metadata, new_versions)
InMemorySaver.aput_writes = tracked_aput_writes
InMemorySaver.aput = tracked_aput
try:
builder = StateGraph(State)
builder.add_node("respond", respond)
builder.add_edge(START, "respond")
graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "async-ordering-test"}}
for i in range(3):
await graph.ainvoke(
{"messages": [HumanMessage(content=f"h{i}", id=f"h{i}")]}, config
)
# Every aput_sentinel must be preceded by at least one aput_writes
for i, event in enumerate(order):
if event == "aput_sentinel":
preceding = order[:i]
assert "aput_writes" in preceding, (
f"aput_sentinel at {i} had no preceding aput_writes: {order}"
)
last_write_idx = max(
j for j, e in enumerate(order[:i]) if e == "aput_writes"
)
assert last_write_idx < i, (
f"aput_writes at {last_write_idx} should precede aput_sentinel at {i}: {order}"
)
finally:
InMemorySaver.aput_writes = original_aput_writes
InMemorySaver.aput = original_aput
state = await graph.aget_state(config)
assert len(state.values["messages"]) == 6 # 3 human + 3 AI
-54
View File
@@ -215,30 +215,6 @@ async def test_checkpoint_errors() -> None:
pass
@NEEDS_CONTEXTVARS
async def test_request_drain_allows_inflight_acall_scheduling(
async_checkpointer: BaseCheckpointSaver,
) -> None:
from langgraph.runtime import RunControl
@task
async def child(x: int) -> int:
return x + 1
control = RunControl()
@entrypoint(checkpointer=async_checkpointer)
async def graph(x: int) -> int:
control.request_drain()
fut = child(x)
return await fut
config = {"configurable": {"thread_id": "drain-call-async"}}
assert await graph.ainvoke(1, config=config, control=control) == 2
assert control.drain_requested
async def test_py_async_with_cancel_behavior() -> None:
"""This test confirms that in all versions of Python we support, __aexit__
is not cancelled when the coroutine containing the async with block is cancelled."""
@@ -6125,36 +6101,6 @@ async def test_parent_command(
)
async def test_delta_channel_durability_exit_stores_snapshot_async() -> None:
"""DeltaChannel must reload from an async durability='exit' checkpoint."""
from langchain_core.messages import AIMessage
from langgraph.channels.delta import DeltaChannel
from langgraph.graph.message import _messages_delta_reducer
class State(TypedDict):
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
async def respond(state: State) -> dict:
return {"messages": [AIMessage(content="reply", id="ai1")]}
builder = StateGraph(State)
builder.add_node("respond", respond)
builder.add_edge(START, "respond")
graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "delta-exit-async-test"}}
result = await graph.ainvoke(
{"messages": [HumanMessage(content="hello", id="h1")]},
config,
durability="exit",
)
assert [m.content for m in result["messages"]] == ["hello", "reply"]
state = await graph.aget_state(config)
assert [m.content for m in state.values["messages"]] == ["hello", "reply"]
@NEEDS_CONTEXTVARS
async def test_interrupt_subgraph(async_checkpointer: BaseCheckpointSaver) -> None:
class State(TypedDict):
@@ -21,7 +21,6 @@ from langgraph.stream import (
from langgraph.stream._convert import convert_to_protocol_event
from langgraph.stream._mux import StreamMux
from langgraph.stream._types import ProtocolEvent
from langgraph.stream.run_stream import AsyncGraphRunStream, GraphRunStream
from langgraph.stream.transformers import MessagesTransformer, ValuesTransformer
from langgraph.types import StreamWriter, interrupt
@@ -780,43 +779,6 @@ class TestValuesTransformer:
assert len(t._interrupts) == 2
class TestOutputWithoutValuesTransformer:
"""run.output / run.interrupted / run.interrupts must work even when
ValuesTransformer is not registered."""
def _stream_part(
self, method: str, data: Any, namespace: tuple[str, ...] = ()
) -> dict[str, Any]:
return {"type": method, "ns": namespace, "data": data}
def test_output_without_values_transformer(self) -> None:
mux = StreamMux(factories=[MessagesTransformer], is_async=False)
run = GraphRunStream(
iter([self._stream_part("values", {"v": "final"})]),
mux,
)
assert "values" not in run.extensions
assert run.output == {"v": "final"}
def test_interrupts_without_values_transformer(self) -> None:
part = self._stream_part("values", {"v": 1})
part["interrupts"] = ({"value": "pause"},)
mux = StreamMux(factories=[MessagesTransformer], is_async=False)
run = GraphRunStream(iter([part]), mux)
assert run.interrupted is True
assert len(run.interrupts) == 1
@pytest.mark.anyio
async def test_async_output_without_values_transformer(self) -> None:
async def _parts() -> Any:
yield {"type": "values", "ns": (), "data": {"v": "async_final"}}
mux = StreamMux(factories=[MessagesTransformer], is_async=True)
run = AsyncGraphRunStream(_parts(), mux)
assert "values" not in run.extensions
assert await run.output() == {"v": "async_final"}
class TestMessagesTransformer:
def test_captures_root_messages(self) -> None:
t = MessagesTransformer()
File diff suppressed because it is too large Load Diff
+1 -516
View File
@@ -1,6 +1,3 @@
import asyncio
import threading
import time
from dataclasses import dataclass
from typing import Any
@@ -9,15 +6,8 @@ from langgraph.checkpoint.memory import MemorySaver
from pydantic import BaseModel, ValidationError
from typing_extensions import TypedDict
from langgraph.errors import GraphDrained
from langgraph.graph import END, START, StateGraph
from langgraph.runtime import (
ExecutionInfo,
RunControl,
Runtime,
ServerInfo,
get_runtime,
)
from langgraph.runtime import ExecutionInfo, Runtime, ServerInfo, get_runtime
def test_injected_runtime() -> None:
@@ -89,183 +79,6 @@ def test_merge_runtime() -> None:
assert runtime1.merge(runtime3).context.api_key == "abc" # type: ignore
def test_merge_runtime_preserves_run_control() -> None:
control = RunControl()
runtime1 = Runtime(control=control)
runtime2 = Runtime(context=None)
assert runtime1.merge(runtime2).control is control
def test_run_control_request_drain_stops_future_steps() -> None:
class State(TypedDict, total=False):
first: str
second: str
control = RunControl()
def first_node(state: State) -> dict[str, str]:
control.request_drain()
return {"first": "done"}
def second_node(state: State) -> dict[str, str]:
return {"second": "should-not-run"}
graph = StateGraph(State)
graph.add_node("first", first_node)
graph.add_node("second", second_node)
graph.add_edge(START, "first")
graph.add_edge("first", "second")
graph.add_edge("second", END)
with pytest.raises(GraphDrained, match="shutdown"):
graph.compile().invoke({}, control=control)
@pytest.mark.anyio
async def test_run_control_request_drain_stops_future_steps_async() -> None:
class State(TypedDict, total=False):
first: str
second: str
control = RunControl()
async def first_node(state: State) -> dict[str, str]:
control.request_drain()
return {"first": "done"}
async def second_node(state: State) -> dict[str, str]:
return {"second": "should-not-run"}
graph = StateGraph(State)
graph.add_node("first", first_node)
graph.add_node("second", second_node)
graph.add_edge(START, "first")
graph.add_edge("first", "second")
graph.add_edge("second", END)
with pytest.raises(GraphDrained, match="shutdown"):
await graph.compile().ainvoke({}, control=control)
def test_drain_requested_in_terminal_step_finishes_normally() -> None:
class State(TypedDict, total=False):
value: str
control = RunControl()
def node(state: State) -> dict[str, str]:
control.request_drain()
return {"value": "done"}
graph = StateGraph(State)
graph.add_node("node", node)
graph.add_edge(START, "node")
graph.add_edge("node", END)
assert graph.compile().invoke({}, control=control) == {"value": "done"}
assert control.drain_requested
def test_drain_with_exit_durability_persists_resume_checkpoint() -> None:
class State(TypedDict, total=False):
first: str
second: str
control = RunControl()
def first_node(state: State) -> dict[str, str]:
control.request_drain("sigterm")
return {"first": "done"}
def second_node(state: State) -> dict[str, str]:
return {"second": "done"}
graph = StateGraph(State)
graph.add_node("first", first_node)
graph.add_node("second", second_node)
graph.add_edge(START, "first")
graph.add_edge("first", "second")
graph.add_edge("second", END)
compiled = graph.compile(checkpointer=MemorySaver())
config = {"configurable": {"thread_id": "drain-exit"}}
with pytest.raises(GraphDrained, match="sigterm"):
compiled.invoke({}, config, durability="exit", control=control)
assert compiled.invoke(None, config, durability="exit") == {
"first": "done",
"second": "done",
}
def test_drain_from_subgraph_can_resume_parent() -> None:
class State(TypedDict, total=False):
child_first: str
child_second: str
parent_second: str
control = RunControl()
def child_first(state: State) -> dict[str, str]:
control.request_drain("sigterm")
return {"child_first": "done"}
def child_second(state: State) -> dict[str, str]:
return {"child_second": "done"}
child_builder = StateGraph(State)
child_builder.add_node("child_first", child_first)
child_builder.add_node("child_second", child_second)
child_builder.add_edge(START, "child_first")
child_builder.add_edge("child_first", "child_second")
child_builder.add_edge("child_second", END)
child_graph = child_builder.compile(checkpointer=True)
def parent_second(state: State) -> dict[str, str]:
return {"parent_second": "done"}
parent_builder = StateGraph(State)
parent_builder.add_node("child", child_graph)
parent_builder.add_node("parent_second", parent_second)
parent_builder.add_edge(START, "child")
parent_builder.add_edge("child", "parent_second")
parent_builder.add_edge("parent_second", END)
compiled = parent_builder.compile(checkpointer=MemorySaver())
config = {"configurable": {"thread_id": "drain-subgraph"}}
with pytest.raises(GraphDrained, match="sigterm"):
compiled.invoke({}, config, control=control)
assert compiled.invoke(None, config) == {
"child_first": "done",
"child_second": "done",
"parent_second": "done",
}
@pytest.mark.anyio
async def test_drain_requested_in_terminal_step_finishes_normally_async() -> None:
class State(TypedDict, total=False):
value: str
control = RunControl()
async def node(state: State) -> dict[str, str]:
control.request_drain()
return {"value": "done"}
graph = StateGraph(State)
graph.add_node("node", node)
graph.add_edge(START, "node")
graph.add_edge("node", END)
assert await graph.compile().ainvoke({}, control=control) == {"value": "done"}
assert control.drain_requested
def test_runtime_propogated_to_subgraph() -> None:
@dataclass
class Context:
@@ -579,334 +392,6 @@ def test_context_coercion_pydantic_validation_errors() -> None:
)
def test_external_drain_concurrent_sync() -> None:
"""External thread calls request_drain() while graph is mid-execution."""
class State(TypedDict, total=False):
first: str
second: str
started = threading.Event()
def first_node(state: State) -> dict[str, str]:
started.set()
time.sleep(0.05)
return {"first": "done"}
def second_node(state: State) -> dict[str, str]:
return {"second": "should-not-run"}
graph = StateGraph(State)
graph.add_node("first", first_node)
graph.add_node("second", second_node)
graph.add_edge(START, "first")
graph.add_edge("first", "second")
graph.add_edge("second", END)
control = RunControl()
compiled = graph.compile()
exc_holder: list[BaseException | None] = [None]
def run_graph() -> None:
try:
compiled.invoke({}, control=control)
except GraphDrained as e:
exc_holder[0] = e
t = threading.Thread(target=run_graph)
t.start()
started.wait(timeout=5)
control.request_drain("sigterm")
t.join(timeout=10)
exc = exc_holder[0]
assert isinstance(exc, GraphDrained)
assert exc.reason == "sigterm"
@pytest.mark.anyio
async def test_external_drain_concurrent_async() -> None:
"""External task calls request_drain() while graph is mid-execution."""
class State(TypedDict, total=False):
first: str
second: str
started = asyncio.Event()
async def first_node(state: State) -> dict[str, str]:
started.set()
await asyncio.sleep(0.05)
return {"first": "done"}
async def second_node(state: State) -> dict[str, str]:
return {"second": "should-not-run"}
graph = StateGraph(State)
graph.add_node("first", first_node)
graph.add_node("second", second_node)
graph.add_edge(START, "first")
graph.add_edge("first", "second")
graph.add_edge("second", END)
control = RunControl()
compiled = graph.compile()
async def drain_after_start() -> None:
await started.wait()
control.request_drain("sigterm")
drain_task = asyncio.create_task(drain_after_start())
with pytest.raises(GraphDrained, match="sigterm"):
await compiled.ainvoke({}, control=control)
await drain_task
@pytest.mark.anyio
async def test_drain_then_cancel_after_graceful_timeout() -> None:
"""Simulate: drain requested -> node still running -> graceful timeout -> cancel.
This shows what happens when a long-running node doesn't finish within
the graceful period after drain is requested.
"""
class State(TypedDict, total=False):
first: str
second: str
node_started = asyncio.Event()
node_cancelled = asyncio.Event()
node_finished = asyncio.Event()
async def slow_node(state: State) -> dict[str, str]:
node_started.set()
try:
await asyncio.sleep(30) # very long operation
except asyncio.CancelledError:
node_cancelled.set()
raise
node_finished.set()
return {"first": "done"}
async def second_node(state: State) -> dict[str, str]:
return {"second": "should-not-run"}
graph = StateGraph(State)
graph.add_node("first", slow_node)
graph.add_node("second", second_node)
graph.add_edge(START, "first")
graph.add_edge("first", "second")
graph.add_edge("second", END)
control = RunControl()
compiled = graph.compile()
# Phase 1: start graph
graph_task = asyncio.create_task(compiled.ainvoke({}, control=control))
# Phase 2: wait for node to start, then request drain
await node_started.wait()
control.request_drain("sigterm")
# Phase 3: graceful timeout — node is still running, cancel after 1s
graceful_timeout = 1.0
await asyncio.sleep(graceful_timeout)
assert not node_finished.is_set(), "node should still be running"
assert not node_cancelled.is_set(), "node should not be cancelled yet"
# Phase 4: force cancel
graph_task.cancel()
with pytest.raises(asyncio.CancelledError):
await graph_task
# The node received CancelledError at the await point
assert node_cancelled.is_set(), "node should have received CancelledError"
assert not node_finished.is_set(), "node should NOT have finished normally"
@pytest.mark.anyio
async def test_cancel_ainvoke_with_async_node() -> None:
"""Cancel ainvoke running an async node: CancelledError is delivered
at the await point and the node stops immediately."""
class State(TypedDict, total=False):
first: str
second: str
timeline: list[str] = []
node_started = asyncio.Event()
async def slow_async_node(state: State) -> dict[str, str]:
timeline.append(f"async_node:start thread={threading.current_thread().name}")
node_started.set()
try:
await asyncio.sleep(30)
except asyncio.CancelledError:
timeline.append("async_node:cancelled")
raise
timeline.append("async_node:finished")
return {"first": "done"}
async def second_node(state: State) -> dict[str, str]:
timeline.append("second_node:run")
return {"second": "should-not-run"}
graph = StateGraph(State)
graph.add_node("first", slow_async_node)
graph.add_node("second", second_node)
graph.add_edge(START, "first")
graph.add_edge("first", "second")
graph.add_edge("second", END)
compiled = graph.compile()
graph_task = asyncio.create_task(compiled.ainvoke({}))
await node_started.wait()
timeline.append("test:cancel")
graph_task.cancel()
with pytest.raises(asyncio.CancelledError):
await graph_task
timeline.append("test:done")
# async node runs on the event loop thread (MainThread)
assert any("MainThread" in e for e in timeline if "async_node:start" in e)
# CancelledError was delivered at the await point — node stopped
assert "async_node:cancelled" in timeline
# Node did NOT run to completion
assert "async_node:finished" not in timeline
# Second node never ran
assert "second_node:run" not in timeline
@pytest.mark.anyio
async def test_cancel_ainvoke_with_sync_node() -> None:
"""Cancel ainvoke running a sync node.
Sync nodes in ainvoke run on a separate thread (via run_in_executor),
NOT on the event loop thread. Cancelling the asyncio task disconnects
from the thread future, but the thread keeps running as an orphan and
completes on its own.
Key difference from async nodes:
- async node: CancelledError stops the coroutine at an await point
- sync node: cancel only disconnects asyncio; the thread runs to completion
In shutdown case, we will ignore this because the instance will be destroyed soon.
"""
class State(TypedDict, total=False):
first: str
second: str
timeline: list[str] = []
node_started = threading.Event()
node_finished = threading.Event()
def slow_sync_node(state: State) -> dict[str, str]:
timeline.append(f"sync_node:start thread={threading.current_thread().name}")
node_started.set()
time.sleep(1)
timeline.append("sync_node:after_sleep")
node_finished.set()
return {"first": "done"}
def second_node(state: State) -> dict[str, str]:
timeline.append("second_node:run")
return {"second": "should-not-run"}
graph = StateGraph(State)
graph.add_node("first", slow_sync_node)
graph.add_node("second", second_node)
graph.add_edge(START, "first")
graph.add_edge("first", "second")
graph.add_edge("second", END)
control = RunControl()
compiled = graph.compile()
timeline.append(f"test:main thread={threading.current_thread().name}")
graph_task = asyncio.create_task(compiled.ainvoke({}, control=control))
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, node_started.wait, 5)
timeline.append("test:cancel+drain")
graph_task.cancel()
control.request_drain("sigterm")
with pytest.raises(asyncio.CancelledError):
await graph_task
timeline.append("test:exc=CancelledError")
# Sync node runs on a background thread (asyncio_*), NOT MainThread
sync_start = next(e for e in timeline if "sync_node:start" in e)
assert "MainThread" not in sync_start, (
"sync node should run on a background thread, not the event loop thread"
)
# At this point, the asyncio task is done but the thread is orphaned.
# The sync node has NOT finished yet — cancel only disconnected asyncio.
assert not node_finished.is_set(), (
"sync node should still be running in its background thread"
)
# Wait for the orphaned thread to complete on its own.
await loop.run_in_executor(None, node_finished.wait, 5)
assert node_finished.is_set()
# After the orphaned thread finishes, the full timeline looks like:
# test:main thread=MainThread
# sync_node:start thread=asyncio_N <- background thread
# test:cancel+drain <- cancel + drain fired
# test:exc=CancelledError <- asyncio disconnected
# sync_node:after_sleep <- thread ran to completion anyway
assert "sync_node:after_sleep" in timeline
# Second node never ran
assert "second_node:run" not in timeline
# Verify timeline ordering: cancel happened before node finished
cancel_idx = timeline.index("test:cancel+drain")
sleep_idx = timeline.index("sync_node:after_sleep")
assert cancel_idx < sleep_idx, (
"cancel was issued while the sync node was still sleeping"
)
def test_drain_with_control_parameter_sync() -> None:
"""Control parameter is wired through invoke -> stream."""
class State(TypedDict, total=False):
value: str
ran = False
def node(state: State) -> dict[str, str]:
nonlocal ran
ran = True
return {"value": "done"}
graph = StateGraph(State)
graph.add_node("node", node)
graph.add_edge(START, "node")
graph.add_edge("node", END)
# Pre-drained control stops before executing the first pending task.
control = RunControl()
control.request_drain("pre-drained")
with pytest.raises(GraphDrained, match="pre-drained"):
graph.compile().invoke({}, control=control)
assert not ran
# --- ExecutionInfo unit tests ---
@@ -1,694 +0,0 @@
"""Tests for CustomTransformer, UpdatesTransformer, CheckpointsTransformer, DebugTransformer, TasksTransformer.
These transformers capture raw protocol events for their respective stream
modes and expose them as native projections on the run stream (run.custom,
run.updates, run.checkpoints, run.debug, run.tasks). Tests dispatch synthetic
protocol events through a StreamMux to isolate transformer logic; the final
group exercises real graphs through stream_v2.
"""
from __future__ import annotations
import operator
import time
from typing import Annotated, Any
from typing_extensions import TypedDict
from langgraph.constants import END, START
from langgraph.graph import StateGraph
from langgraph.stream._mux import StreamMux
from langgraph.stream.stream_channel import StreamChannel
from langgraph.stream.transformers import (
CheckpointsTransformer,
CustomTransformer,
DebugTransformer,
LifecycleTransformer,
TasksTransformer,
UpdatesTransformer,
)
TS = int(time.time() * 1000)
def _custom_event(namespace: list[str], data: Any) -> dict[str, Any]:
return {
"type": "event",
"method": "custom",
"params": {"namespace": namespace, "timestamp": TS, "data": data},
}
def _checkpoints_event(namespace: list[str], data: Any) -> dict[str, Any]:
return {
"type": "event",
"method": "checkpoints",
"params": {"namespace": namespace, "timestamp": TS, "data": data},
}
def _debug_event(namespace: list[str], data: Any) -> dict[str, Any]:
return {
"type": "event",
"method": "debug",
"params": {"namespace": namespace, "timestamp": TS, "data": data},
}
def _tasks_event(namespace: list[str], data: Any) -> dict[str, Any]:
return {
"type": "event",
"method": "tasks",
"params": {"namespace": namespace, "timestamp": TS, "data": data},
}
def _updates_event(namespace: list[str], data: Any) -> dict[str, Any]:
return {
"type": "event",
"method": "updates",
"params": {"namespace": namespace, "timestamp": TS, "data": data},
}
def _arm(mux: StreamMux, transformer: Any) -> None:
"""Force projection logs to accept pushes (skip lazy-subscribe gate)."""
mux._events._subscribed = True
transformer._log._subscribed = True
def _drain(transformer: Any) -> list[Any]:
return list(transformer._log._items)
# ---------------------------------------------------------------------------
# CustomTransformer
# ---------------------------------------------------------------------------
def test_custom_captures_root_scope_events() -> None:
t = CustomTransformer()
mux = StreamMux([t], is_async=False)
_arm(mux, t)
mux.push(_custom_event([], {"status": "processing"}))
mux.push(_custom_event([], {"status": "done"}))
items = _drain(t)
assert items == [{"status": "processing"}, {"status": "done"}]
def test_custom_ignores_subgraph_scope_events() -> None:
t = CustomTransformer()
mux = StreamMux([t], is_async=False)
_arm(mux, t)
mux.push(_custom_event(["subgraph:abc"], {"from": "child"}))
assert _drain(t) == []
def test_custom_scoped_transformer_captures_own_scope() -> None:
t = CustomTransformer(scope=("agent:abc",))
mux = StreamMux([t], is_async=False)
_arm(mux, t)
mux.push(_custom_event([], {"from": "root"}))
mux.push(_custom_event(["agent:abc"], {"from": "self"}))
mux.push(_custom_event(["agent:abc", "deep:def"], {"from": "child"}))
items = _drain(t)
assert items == [{"from": "self"}]
def test_custom_preserves_any_payload_type() -> None:
t = CustomTransformer()
mux = StreamMux([t], is_async=False)
_arm(mux, t)
mux.push(_custom_event([], "string_payload"))
mux.push(_custom_event([], 42))
mux.push(_custom_event([], [1, 2, 3]))
assert _drain(t) == ["string_payload", 42, [1, 2, 3]]
def test_custom_does_not_suppress_from_main_log() -> None:
t = CustomTransformer()
mux = StreamMux([t], is_async=False)
_arm(mux, t)
mux.push(_custom_event([], "data"))
methods = [evt["method"] for evt in mux._events._items]
assert "custom" in methods
def test_custom_ignores_other_methods() -> None:
t = CustomTransformer()
mux = StreamMux([t], is_async=False)
_arm(mux, t)
mux.push(
{
"type": "event",
"method": "values",
"params": {"namespace": [], "timestamp": TS, "data": {}},
}
)
assert _drain(t) == []
def test_custom_required_stream_modes() -> None:
assert CustomTransformer.required_stream_modes == ("custom",)
def test_custom_is_native() -> None:
assert getattr(CustomTransformer, "_native", False) is True
def test_custom_init_returns_correct_key() -> None:
t = CustomTransformer()
projection = t.init()
assert "custom" in projection
assert isinstance(projection["custom"], StreamChannel)
# ---------------------------------------------------------------------------
# CheckpointsTransformer
# ---------------------------------------------------------------------------
def test_checkpoints_captures_root_scope_events() -> None:
t = CheckpointsTransformer()
mux = StreamMux([t], is_async=False)
_arm(mux, t)
checkpoint_data = {"values": {"x": 1}, "next": ["node_b"]}
mux.push(_checkpoints_event([], checkpoint_data))
items = _drain(t)
assert items == [checkpoint_data]
def test_checkpoints_ignores_subgraph_events() -> None:
t = CheckpointsTransformer()
mux = StreamMux([t], is_async=False)
_arm(mux, t)
mux.push(_checkpoints_event(["child:abc"], {"values": {"x": 1}}))
assert _drain(t) == []
def test_checkpoints_scoped_transformer() -> None:
t = CheckpointsTransformer(scope=("sub:abc",))
mux = StreamMux([t], is_async=False)
_arm(mux, t)
mux.push(_checkpoints_event([], {"from": "root"}))
mux.push(_checkpoints_event(["sub:abc"], {"from": "self"}))
assert _drain(t) == [{"from": "self"}]
def test_checkpoints_does_not_suppress_from_main_log() -> None:
t = CheckpointsTransformer()
mux = StreamMux([t], is_async=False)
_arm(mux, t)
mux.push(_checkpoints_event([], {"values": {}}))
methods = [evt["method"] for evt in mux._events._items]
assert "checkpoints" in methods
def test_checkpoints_required_stream_modes() -> None:
assert CheckpointsTransformer.required_stream_modes == ("checkpoints",)
def test_checkpoints_is_native() -> None:
assert getattr(CheckpointsTransformer, "_native", False) is True
# ---------------------------------------------------------------------------
# DebugTransformer
# ---------------------------------------------------------------------------
def test_debug_captures_root_scope_events() -> None:
t = DebugTransformer()
mux = StreamMux([t], is_async=False)
_arm(mux, t)
debug_data = {
"step": 0,
"type": "checkpoint",
"timestamp": "2026-01-01T00:00:00Z",
"payload": {"values": {"x": 1}},
}
mux.push(_debug_event([], debug_data))
items = _drain(t)
assert items == [debug_data]
def test_debug_ignores_subgraph_events() -> None:
t = DebugTransformer()
mux = StreamMux([t], is_async=False)
_arm(mux, t)
mux.push(_debug_event(["child:abc"], {"step": 0, "type": "task"}))
assert _drain(t) == []
def test_debug_captures_multiple_event_types() -> None:
t = DebugTransformer()
mux = StreamMux([t], is_async=False)
_arm(mux, t)
mux.push(_debug_event([], {"step": 0, "type": "checkpoint", "payload": {}}))
mux.push(_debug_event([], {"step": 1, "type": "task", "payload": {}}))
mux.push(_debug_event([], {"step": 1, "type": "task_result", "payload": {}}))
items = _drain(t)
assert len(items) == 3
assert [d["type"] for d in items] == ["checkpoint", "task", "task_result"]
def test_debug_does_not_suppress_from_main_log() -> None:
t = DebugTransformer()
mux = StreamMux([t], is_async=False)
_arm(mux, t)
mux.push(_debug_event([], {"step": 0}))
methods = [evt["method"] for evt in mux._events._items]
assert "debug" in methods
def test_debug_required_stream_modes() -> None:
assert DebugTransformer.required_stream_modes == ("debug",)
def test_debug_is_native() -> None:
assert getattr(DebugTransformer, "_native", False) is True
# ---------------------------------------------------------------------------
# TasksTransformer
# ---------------------------------------------------------------------------
def test_tasks_captures_root_scope_events() -> None:
t = TasksTransformer()
mux = StreamMux([t], is_async=False)
_arm(mux, t)
task_start = {"id": "t1", "name": "my_node", "input": None, "triggers": []}
mux.push(_tasks_event([], task_start))
items = _drain(t)
assert items == [task_start]
def test_tasks_captures_start_and_result() -> None:
t = TasksTransformer()
mux = StreamMux([t], is_async=False)
_arm(mux, t)
start = {"id": "t1", "name": "a", "input": None, "triggers": []}
result = {"id": "t1", "name": "a", "result": {"output": 42}, "error": None}
mux.push(_tasks_event([], start))
mux.push(_tasks_event([], result))
items = _drain(t)
assert items == [start, result]
def test_tasks_ignores_subgraph_events() -> None:
t = TasksTransformer()
mux = StreamMux([t], is_async=False)
_arm(mux, t)
mux.push(_tasks_event(["child:abc"], {"id": "t1", "name": "x"}))
assert _drain(t) == []
def test_tasks_scoped_transformer() -> None:
t = TasksTransformer(scope=("agent:abc",))
mux = StreamMux([t], is_async=False)
_arm(mux, t)
mux.push(_tasks_event([], {"id": "t1"}))
mux.push(_tasks_event(["agent:abc"], {"id": "t2"}))
mux.push(_tasks_event(["agent:abc", "deep:def"], {"id": "t3"}))
assert _drain(t) == [{"id": "t2"}]
def test_tasks_does_not_suppress_from_main_log() -> None:
"""TasksTransformer returns True — it doesn't suppress tasks events.
(LifecycleTransformer suppresses them, but that's independent.)
"""
t = TasksTransformer()
mux = StreamMux([t], is_async=False)
_arm(mux, t)
mux.push(_tasks_event([], {"id": "t1"}))
methods = [evt["method"] for evt in mux._events._items]
assert "tasks" in methods
def test_tasks_required_stream_modes() -> None:
assert TasksTransformer.required_stream_modes == ("tasks",)
def test_tasks_is_native() -> None:
assert getattr(TasksTransformer, "_native", False) is True
# ---------------------------------------------------------------------------
# UpdatesTransformer
# ---------------------------------------------------------------------------
def test_updates_captures_root_scope_events() -> None:
t = UpdatesTransformer()
mux = StreamMux([t], is_async=False)
_arm(mux, t)
update = {"my_node": {"value": "hello!"}}
mux.push(_updates_event([], update))
items = _drain(t)
assert items == [update]
def test_updates_captures_multiple_steps() -> None:
t = UpdatesTransformer()
mux = StreamMux([t], is_async=False)
_arm(mux, t)
mux.push(_updates_event([], {"node_a": {"x": 1}}))
mux.push(_updates_event([], {"node_b": {"x": 2}}))
items = _drain(t)
assert items == [{"node_a": {"x": 1}}, {"node_b": {"x": 2}}]
def test_updates_ignores_subgraph_events() -> None:
t = UpdatesTransformer()
mux = StreamMux([t], is_async=False)
_arm(mux, t)
mux.push(_updates_event(["child:abc"], {"inner_node": {"v": 1}}))
assert _drain(t) == []
def test_updates_scoped_transformer() -> None:
t = UpdatesTransformer(scope=("agent:abc",))
mux = StreamMux([t], is_async=False)
_arm(mux, t)
mux.push(_updates_event([], {"from": "root"}))
mux.push(_updates_event(["agent:abc"], {"from": "self"}))
assert _drain(t) == [{"from": "self"}]
def test_updates_does_not_suppress_from_main_log() -> None:
t = UpdatesTransformer()
mux = StreamMux([t], is_async=False)
_arm(mux, t)
mux.push(_updates_event([], {"n": {}}))
methods = [evt["method"] for evt in mux._events._items]
assert "updates" in methods
def test_updates_required_stream_modes() -> None:
assert UpdatesTransformer.required_stream_modes == ("updates",)
def test_updates_is_native() -> None:
assert getattr(UpdatesTransformer, "_native", False) is True
# ---------------------------------------------------------------------------
# Cross-transformer: unrelated events pass through
# ---------------------------------------------------------------------------
def test_unrelated_events_ignored_by_all() -> None:
"""Non-matching method events don't land in any transformer's log."""
transformers = [
CustomTransformer(),
UpdatesTransformer(),
CheckpointsTransformer(),
DebugTransformer(),
TasksTransformer(),
]
mux = StreamMux(transformers, is_async=False)
mux._events._subscribed = True
for t in transformers:
t._log._subscribed = True
mux.push(
{
"type": "event",
"method": "values",
"params": {"namespace": [], "timestamp": TS, "data": {"x": 1}},
}
)
for t in transformers:
assert list(t._log._items) == []
# ---------------------------------------------------------------------------
# End-to-end: real graphs through stream_v2
# ---------------------------------------------------------------------------
class _State(TypedDict):
value: str
items: Annotated[list[str], operator.add]
def _my_node(state: _State) -> dict[str, Any]:
from langgraph.config import get_stream_writer
writer = get_stream_writer()
writer({"status": "working", "node": "my_node"})
return {"value": state["value"] + "!", "items": ["done"]}
def _make_simple_graph() -> Any:
builder = StateGraph(_State, input_schema=_State)
builder.add_node("my_node", _my_node)
builder.add_edge(START, "my_node")
builder.add_edge("my_node", END)
return builder.compile()
def test_stream_v2_custom_projection_opt_in() -> None:
"""run.custom surfaces get_stream_writer() payloads when opted in."""
graph = _make_simple_graph()
run = graph.stream_v2(
{"value": "hello", "items": []}, transformers=[CustomTransformer]
)
custom_events = list(run.custom)
assert len(custom_events) >= 1
assert any(e.get("status") == "working" for e in custom_events)
def test_stream_v2_custom_and_values_coexist() -> None:
"""Both run.custom and run.values work in the same run."""
graph = _make_simple_graph()
run = graph.stream_v2(
{"value": "hello", "items": []}, transformers=[CustomTransformer]
)
custom_events = list(run.custom)
assert run.output is not None
assert run.output["value"] == "hello!"
assert len(custom_events) >= 1
def test_stream_v2_tasks_projection_opt_in() -> None:
"""run.tasks surfaces raw task events when opted in via transformers=."""
graph = _make_simple_graph()
run = graph.stream_v2({"value": "x", "items": []}, transformers=[TasksTransformer])
tasks_events = list(run.tasks)
assert len(tasks_events) >= 1
names = [t.get("name") for t in tasks_events if "name" in t]
assert "my_node" in names
def test_stream_v2_debug_projection_opt_in() -> None:
"""run.debug surfaces debug events when opted in via transformers=."""
graph = _make_simple_graph()
run = graph.stream_v2({"value": "x", "items": []}, transformers=[DebugTransformer])
debug_events = list(run.debug)
assert len(debug_events) >= 1
types = {d.get("type") for d in debug_events}
assert types & {"checkpoint", "task", "task_result"}
def test_stream_v2_updates_projection_opt_in() -> None:
"""run.updates surfaces node output dicts when opted in via transformers=."""
graph = _make_simple_graph()
run = graph.stream_v2(
{"value": "x", "items": []}, transformers=[UpdatesTransformer]
)
updates = list(run.updates)
assert len(updates) >= 1
node_names = {k for u in updates for k in u if k != "__interrupt__"}
assert "my_node" in node_names
def test_stream_v2_all_transformers_interleaved() -> None:
"""All five transformers registered together, consumed via interleave."""
graph = _make_simple_graph()
run = graph.stream_v2(
{"value": "x", "items": []},
transformers=[
CustomTransformer,
UpdatesTransformer,
CheckpointsTransformer,
DebugTransformer,
TasksTransformer,
],
)
collected: dict[str, list[Any]] = {
"custom": [],
"updates": [],
"debug": [],
"tasks": [],
}
for name, item in run.interleave("custom", "updates", "debug", "tasks"):
collected[name].append(item)
assert len(collected["custom"]) >= 1
assert len(collected["updates"]) >= 1
assert len(collected["tasks"]) >= 1
assert len(collected["debug"]) >= 1
types = {d.get("type") for d in collected["debug"]}
assert types & {"checkpoint", "task", "task_result"}
node_names = {k for u in collected["updates"] for k in u if k != "__interrupt__"}
assert "my_node" in node_names
assert run.output is not None
assert run.output["value"] == "x!"
def test_stream_v2_all_transformers_with_checkpointer() -> None:
"""All transformers with a checkpointer — run.checkpoints populated."""
from langgraph.checkpoint.memory import InMemorySaver
builder = StateGraph(_State, input_schema=_State)
builder.add_node("my_node", _my_node)
builder.add_edge(START, "my_node")
builder.add_edge("my_node", END)
graph = builder.compile(checkpointer=InMemorySaver())
run = graph.stream_v2(
{"value": "x", "items": []},
config={"configurable": {"thread_id": "test-all"}},
transformers=[
CustomTransformer,
UpdatesTransformer,
CheckpointsTransformer,
DebugTransformer,
TasksTransformer,
],
)
collected: dict[str, list[Any]] = {
"custom": [],
"updates": [],
"checkpoints": [],
"debug": [],
"tasks": [],
}
for name, item in run.interleave(
"custom", "updates", "checkpoints", "debug", "tasks"
):
collected[name].append(item)
assert len(collected["checkpoints"]) >= 1
assert len(collected["custom"]) >= 1
def test_stream_v2_checkpoints_projection_opt_in() -> None:
"""run.checkpoints surfaces checkpoint data when opted in with a checkpointer."""
from langgraph.checkpoint.memory import InMemorySaver
builder = StateGraph(_State, input_schema=_State)
builder.add_node("my_node", _my_node)
builder.add_edge(START, "my_node")
builder.add_edge("my_node", END)
graph = builder.compile(checkpointer=InMemorySaver())
run = graph.stream_v2(
{"value": "x", "items": []},
config={"configurable": {"thread_id": "test-ckpt-standalone"}},
transformers=[CheckpointsTransformer],
)
checkpoints = list(run.checkpoints)
assert len(checkpoints) >= 1
# ---------------------------------------------------------------------------
# TasksTransformer + LifecycleTransformer co-registration
# ---------------------------------------------------------------------------
def test_tasks_and_lifecycle_coregistration() -> None:
"""When both are in the same StreamMux, LifecycleTransformer suppresses
tasks events from the main log (returns False) while TasksTransformer
still captures them into its own log.
"""
lifecycle = LifecycleTransformer()
tasks = TasksTransformer()
mux = StreamMux([lifecycle, tasks], is_async=False)
mux._events._subscribed = True
tasks._log._subscribed = True
lifecycle._channel._subscribed = True
task_data = {"id": "t1", "name": "my_node", "input": None, "triggers": []}
mux.push(_tasks_event([], task_data))
assert _drain(tasks) == [task_data]
methods = [evt["method"] for evt in mux._events._items]
assert "tasks" not in methods
def test_tasks_and_lifecycle_coregistration_e2e() -> None:
"""E2e: TasksTransformer captures task events even when LifecycleTransformer
is present and suppressing them from the main log.
"""
graph = _make_simple_graph()
run = graph.stream_v2(
{"value": "x", "items": []},
transformers=[TasksTransformer],
)
tasks_events = list(run.tasks)
assert len(tasks_events) >= 1
names = [t.get("name") for t in tasks_events if "name" in t]
assert "my_node" in names
@@ -403,7 +403,7 @@ class TestWireRequestMore:
mux = StreamMux([values_t, messages_t], is_async=False)
assert messages_t._pump_fn is None
run = GraphRunStream(iter([]), mux)
run = GraphRunStream(iter([]), mux, values_t)
assert messages_t._pump_fn is not None
assert messages_t._pump_fn() is False
assert run._exhausted
@@ -412,7 +412,7 @@ class TestWireRequestMore:
values_t = ValuesTransformer()
messages_t = MessagesTransformer()
mux = StreamMux([values_t, messages_t], is_async=False)
GraphRunStream(iter([]), mux)
GraphRunStream(iter([]), mux, values_t)
log: StreamChannel[ChatModelStream] = mux.extensions["messages"]
log._subscribed = True
@@ -546,6 +546,8 @@ def test_child_forwarding_errors_fail_sync_run() -> None:
],
is_async=False,
)
values_t = mux.transformer_by_key("values")
assert isinstance(values_t, ValuesTransformer)
run = GraphRunStream(
iter(
[
@@ -563,6 +565,7 @@ def test_child_forwarding_errors_fail_sync_run() -> None:
]
),
mux,
values_t,
)
handle = next(iter(run.subgraphs))
@@ -584,6 +587,8 @@ async def test_child_forwarding_errors_fail_async_run() -> None:
],
is_async=True,
)
values_t = mux.transformer_by_key("values")
assert isinstance(values_t, ValuesTransformer)
run = AsyncGraphRunStream(
_astream_parts(
_stream_part(
@@ -599,6 +604,7 @@ async def test_child_forwarding_errors_fail_async_run() -> None:
_stream_part("values", ("agent:abc",), {"x": 1}),
),
mux,
values_t,
)
handle = await run.subgraphs.__aiter__().__anext__()
@@ -619,6 +625,8 @@ def test_child_finalize_errors_propagate_to_sync_run() -> None:
],
is_async=False,
)
values_t = mux.transformer_by_key("values")
assert isinstance(values_t, ValuesTransformer)
run = GraphRunStream(
iter(
[
@@ -635,6 +643,7 @@ def test_child_finalize_errors_propagate_to_sync_run() -> None:
]
),
mux,
values_t,
)
with pytest.raises(RuntimeError, match="child finalize boom"):
@@ -653,6 +662,8 @@ async def test_child_finalize_errors_propagate_to_async_run() -> None:
],
is_async=True,
)
values_t = mux.transformer_by_key("values")
assert isinstance(values_t, ValuesTransformer)
run = AsyncGraphRunStream(
_astream_parts(
_stream_part(
@@ -667,6 +678,7 @@ async def test_child_finalize_errors_propagate_to_async_run() -> None:
)
),
mux,
values_t,
)
with pytest.raises(RuntimeError, match="child afinalize boom"):
-28
View File
@@ -19,11 +19,9 @@ from typing_extensions import TypedDict, assert_type
from langgraph._internal._constants import INTERRUPT
from langgraph.constants import END, START
from langgraph.errors import GraphDrained
from langgraph.func import entrypoint
from langgraph.graph import StateGraph
from langgraph.graph.message import MessagesState
from langgraph.runtime import RunControl
from langgraph.types import (
CheckpointPayload,
CheckpointStreamPart,
@@ -231,32 +229,6 @@ class TestV2Stream:
for c in chunks:
_assert_stream_part_shape(c)
def test_stream_v2_accepts_control_for_drain(self) -> None:
class DrainState(TypedDict, total=False):
value: str
skipped: str
control = RunControl()
def first_node(state: DrainState) -> dict[str, str]:
control.request_drain("sigterm")
return {"value": "done"}
def second_node(state: DrainState) -> dict[str, str]:
return {"skipped": "nope"}
builder = StateGraph(DrainState)
builder.add_node("first", first_node)
builder.add_node("second", second_node)
builder.add_edge(START, "first")
builder.add_edge("first", "second")
builder.add_edge("second", END)
graph = builder.compile()
run = graph.stream_v2({}, control=control)
with pytest.raises(GraphDrained, match="sigterm"):
list(run.values)
def test_subgraphs_ns(self) -> None:
outer = _make_subgraph()
chunks = list(
+14 -18
View File
@@ -1349,7 +1349,7 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.3.2"
source = { registry = "https://pypi.org/simple" }
source = { git = "https://github.com/langchain-ai/langchain?subdirectory=libs%2Fcore&branch=cb%2Fchat-model-updates#aee50839376e379891c99fcbe6d5264f66dedc68" }
dependencies = [
{ name = "jsonpatch" },
{ name = "langchain-protocol" },
@@ -1361,26 +1361,22 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "uuid-utils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a8/03/7219502e8ca728d65eb44d7a3eb60239230742a70dbfc9241b9bfd61c4ab/langchain_core-1.3.2.tar.gz", hash = "sha256:fd7a50b2f28ba561fd9d7f5d2760bc9e06cf00cdf820a3ccafe88a94ffa8d5b7", size = 911813, upload-time = "2026-04-24T15:49:23.699Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7d/d5/8fa4431007cbb7cfed7590f4d6a5dea3ad724f4174d248f6642ef5ce7d05/langchain_core-1.3.2-py3-none-any.whl", hash = "sha256:d44a66127f9f8db735bdfd0ab9661bccb47a97113cfd3f2d89c74864422b7274", size = 542390, upload-time = "2026-04-24T15:49:21.991Z" },
]
[[package]]
name = "langchain-protocol"
version = "0.0.12"
version = "0.0.14"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5c/51/1157009b6f94e6e58be58fa8b620187d657909a8b36a6bf5b0c52a2711f6/langchain_protocol-0.0.12.tar.gz", hash = "sha256:5e14c434290a705c9510fdb1a83ecf7561a5e6e0dfd053930ade80dba069269f", size = 6408, upload-time = "2026-04-25T01:05:01.489Z" }
sdist = { url = "https://files.pythonhosted.org/packages/05/bf/efb5e2ed832e4d6d45590e25a9e5191986b291b543bc6a807b48bee070b0/langchain_protocol-0.0.14.tar.gz", hash = "sha256:bc1e8553122e6ede310280462d5813023a172ff2785ccbbdec54d43f3a15e5f2", size = 5862, upload-time = "2026-04-29T16:40:18.657Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/82/3431e3061c917439589fa88a6b23c9bc0e154cba0f05d2e895a68c76ff74/langchain_protocol-0.0.12-py3-none-any.whl", hash = "sha256:402b61f42d4139692528cf37226c367bb6efc8ff8165b29380accb0abfece7b2", size = 6639, upload-time = "2026-04-25T01:05:00.487Z" },
{ url = "https://files.pythonhosted.org/packages/c2/e9/06c47ecb2aff08f83dfa30058da3bf86be64862c19569043ed5331bbeecd/langchain_protocol-0.0.14-py3-none-any.whl", hash = "sha256:ffc35089779bd8ca217015180cef5e660fc3b074efdaa0f2e95df73583f1a047", size = 6984, upload-time = "2026-04-29T16:40:17.841Z" },
]
[[package]]
name = "langgraph"
version = "1.2.0a1"
version = "1.1.10"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
@@ -1452,7 +1448,7 @@ test = [
[package.metadata]
requires-dist = [
{ name = "langchain-core", specifier = ">=1.3.2,<2" },
{ name = "langchain-core", git = "https://github.com/langchain-ai/langchain?subdirectory=libs%2Fcore&branch=cb%2Fchat-model-updates" },
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-prebuilt", editable = "../prebuilt" },
{ name = "langgraph-sdk", editable = "../sdk-py" },
@@ -1464,7 +1460,7 @@ requires-dist = [
dev = [
{ name = "httpx" },
{ name = "jupyter" },
{ name = "langchain-core", specifier = ">=1.0.0" },
{ name = "langchain-core", git = "https://github.com/langchain-ai/langchain?subdirectory=libs%2Fcore&branch=cb%2Fchat-model-updates" },
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-checkpoint-postgres", editable = "../checkpoint-postgres" },
{ name = "langgraph-checkpoint-sqlite", editable = "../checkpoint-sqlite" },
@@ -1497,7 +1493,7 @@ lint = [
]
test = [
{ name = "httpx" },
{ name = "langchain-core", specifier = ">=1.0.0" },
{ name = "langchain-core", git = "https://github.com/langchain-ai/langchain?subdirectory=libs%2Fcore&branch=cb%2Fchat-model-updates" },
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-checkpoint-postgres", editable = "../checkpoint-postgres" },
{ name = "langgraph-checkpoint-sqlite", editable = "../checkpoint-sqlite" },
@@ -1561,7 +1557,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
version = "4.1.0a1"
version = "4.0.3"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
@@ -1570,7 +1566,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "langchain-core", specifier = ">=0.2.38" },
{ name = "langchain-core", git = "https://github.com/langchain-ai/langchain?subdirectory=libs%2Fcore&branch=cb%2Fchat-model-updates" },
{ name = "ormsgpack", specifier = ">=1.12.0" },
]
@@ -1609,7 +1605,7 @@ test = [
[[package]]
name = "langgraph-checkpoint-postgres"
version = "3.1.0a1"
version = "3.0.5"
source = { editable = "../checkpoint-postgres" }
dependencies = [
{ name = "langgraph-checkpoint" },
@@ -1764,14 +1760,14 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "langchain-core", specifier = ">=1.3.1" },
{ name = "langchain-core", git = "https://github.com/langchain-ai/langchain?subdirectory=libs%2Fcore&branch=cb%2Fchat-model-updates" },
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
]
[package.metadata.requires-dev]
dev = [
{ name = "codespell" },
{ name = "langchain-core" },
{ name = "langchain-core", git = "https://github.com/langchain-ai/langchain?subdirectory=libs%2Fcore&branch=cb%2Fchat-model-updates" },
{ name = "langgraph", editable = "." },
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-checkpoint-postgres", editable = "../checkpoint-postgres" },
@@ -1791,7 +1787,7 @@ lint = [
{ name = "ruff" },
]
test = [
{ name = "langchain-core" },
{ name = "langchain-core", git = "https://github.com/langchain-ai/langchain?subdirectory=libs%2Fcore&branch=cb%2Fchat-model-updates" },
{ name = "langgraph", editable = "." },
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-checkpoint-postgres", editable = "../checkpoint-postgres" },
+2 -1
View File
@@ -30,7 +30,7 @@ dependencies = [
[project.urls]
Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/prebuilt"
Twitter = "https://x.com/langchain_oss"
Twitter = "https://x.com/LangChain"
Slack = "https://www.langchain.com/join-community"
Reddit = "https://www.reddit.com/r/LangChain/"
@@ -62,6 +62,7 @@ dev = [
default-groups = ['dev']
[tool.uv.sources]
langchain-core = { git = "https://github.com/langchain-ai/langchain", branch = "cb/chat-model-updates", subdirectory = "libs/core" }
langgraph = { path = "../langgraph", editable = true }
langgraph-checkpoint = { path = "../checkpoint", editable = true }
langgraph-checkpoint-sqlite = { path = "../checkpoint-sqlite", editable = true }
+14 -18
View File
@@ -250,7 +250,7 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.3.2"
source = { registry = "https://pypi.org/simple" }
source = { git = "https://github.com/langchain-ai/langchain?subdirectory=libs%2Fcore&branch=cb%2Fchat-model-updates#aee50839376e379891c99fcbe6d5264f66dedc68" }
dependencies = [
{ name = "jsonpatch" },
{ name = "langchain-protocol" },
@@ -262,26 +262,22 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "uuid-utils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a8/03/7219502e8ca728d65eb44d7a3eb60239230742a70dbfc9241b9bfd61c4ab/langchain_core-1.3.2.tar.gz", hash = "sha256:fd7a50b2f28ba561fd9d7f5d2760bc9e06cf00cdf820a3ccafe88a94ffa8d5b7", size = 911813, upload-time = "2026-04-24T15:49:23.699Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7d/d5/8fa4431007cbb7cfed7590f4d6a5dea3ad724f4174d248f6642ef5ce7d05/langchain_core-1.3.2-py3-none-any.whl", hash = "sha256:d44a66127f9f8db735bdfd0ab9661bccb47a97113cfd3f2d89c74864422b7274", size = 542390, upload-time = "2026-04-24T15:49:21.991Z" },
]
[[package]]
name = "langchain-protocol"
version = "0.0.12"
version = "0.0.14"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5c/51/1157009b6f94e6e58be58fa8b620187d657909a8b36a6bf5b0c52a2711f6/langchain_protocol-0.0.12.tar.gz", hash = "sha256:5e14c434290a705c9510fdb1a83ecf7561a5e6e0dfd053930ade80dba069269f", size = 6408, upload-time = "2026-04-25T01:05:01.489Z" }
sdist = { url = "https://files.pythonhosted.org/packages/05/bf/efb5e2ed832e4d6d45590e25a9e5191986b291b543bc6a807b48bee070b0/langchain_protocol-0.0.14.tar.gz", hash = "sha256:bc1e8553122e6ede310280462d5813023a172ff2785ccbbdec54d43f3a15e5f2", size = 5862, upload-time = "2026-04-29T16:40:18.657Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/82/3431e3061c917439589fa88a6b23c9bc0e154cba0f05d2e895a68c76ff74/langchain_protocol-0.0.12-py3-none-any.whl", hash = "sha256:402b61f42d4139692528cf37226c367bb6efc8ff8165b29380accb0abfece7b2", size = 6639, upload-time = "2026-04-25T01:05:00.487Z" },
{ url = "https://files.pythonhosted.org/packages/c2/e9/06c47ecb2aff08f83dfa30058da3bf86be64862c19569043ed5331bbeecd/langchain_protocol-0.0.14-py3-none-any.whl", hash = "sha256:ffc35089779bd8ca217015180cef5e660fc3b074efdaa0f2e95df73583f1a047", size = 6984, upload-time = "2026-04-29T16:40:17.841Z" },
]
[[package]]
name = "langgraph"
version = "1.2.0a1"
version = "1.1.10"
source = { editable = "../langgraph" }
dependencies = [
{ name = "langchain-core" },
@@ -294,7 +290,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "langchain-core", specifier = ">=1.3.2,<2" },
{ name = "langchain-core", git = "https://github.com/langchain-ai/langchain?subdirectory=libs%2Fcore&branch=cb%2Fchat-model-updates" },
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-prebuilt", editable = "." },
{ name = "langgraph-sdk", editable = "../sdk-py" },
@@ -306,7 +302,7 @@ requires-dist = [
dev = [
{ name = "httpx" },
{ name = "jupyter" },
{ name = "langchain-core", specifier = ">=1.0.0" },
{ name = "langchain-core", git = "https://github.com/langchain-ai/langchain?subdirectory=libs%2Fcore&branch=cb%2Fchat-model-updates" },
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-checkpoint-postgres", editable = "../checkpoint-postgres" },
{ name = "langgraph-checkpoint-sqlite", editable = "../checkpoint-sqlite" },
@@ -339,7 +335,7 @@ lint = [
]
test = [
{ name = "httpx" },
{ name = "langchain-core", specifier = ">=1.0.0" },
{ name = "langchain-core", git = "https://github.com/langchain-ai/langchain?subdirectory=libs%2Fcore&branch=cb%2Fchat-model-updates" },
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-checkpoint-postgres", editable = "../checkpoint-postgres" },
{ name = "langgraph-checkpoint-sqlite", editable = "../checkpoint-sqlite" },
@@ -365,7 +361,7 @@ test = [
[[package]]
name = "langgraph-checkpoint"
version = "4.1.0a1"
version = "4.0.3"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
@@ -374,7 +370,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "langchain-core", specifier = ">=0.2.38" },
{ name = "langchain-core", git = "https://github.com/langchain-ai/langchain?subdirectory=libs%2Fcore&branch=cb%2Fchat-model-updates" },
{ name = "ormsgpack", specifier = ">=1.12.0" },
]
@@ -413,7 +409,7 @@ test = [
[[package]]
name = "langgraph-checkpoint-postgres"
version = "3.1.0a1"
version = "3.0.5"
source = { editable = "../checkpoint-postgres" }
dependencies = [
{ name = "langgraph-checkpoint" },
@@ -548,14 +544,14 @@ test = [
[package.metadata]
requires-dist = [
{ name = "langchain-core", specifier = ">=1.3.1" },
{ name = "langchain-core", git = "https://github.com/langchain-ai/langchain?subdirectory=libs%2Fcore&branch=cb%2Fchat-model-updates" },
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
]
[package.metadata.requires-dev]
dev = [
{ name = "codespell" },
{ name = "langchain-core" },
{ name = "langchain-core", git = "https://github.com/langchain-ai/langchain?subdirectory=libs%2Fcore&branch=cb%2Fchat-model-updates" },
{ name = "langgraph", editable = "../langgraph" },
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-checkpoint-postgres", editable = "../checkpoint-postgres" },
@@ -575,7 +571,7 @@ lint = [
{ name = "ruff" },
]
test = [
{ name = "langchain-core" },
{ name = "langchain-core", git = "https://github.com/langchain-ai/langchain?subdirectory=libs%2Fcore&branch=cb%2Fchat-model-updates" },
{ name = "langgraph", editable = "../langgraph" },
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-checkpoint-postgres", editable = "../checkpoint-postgres" },
+1 -1
View File
@@ -18,7 +18,7 @@ path = "langgraph_sdk/__init__.py"
[project.urls]
Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/sdk-py"
Twitter = "https://x.com/langchain_oss"
Twitter = "https://x.com/LangChain"
Slack = "https://www.langchain.com/join-community"
Reddit = "https://www.reddit.com/r/LangChain/"
+2 -2
View File
@@ -298,7 +298,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "1.2.0a1"
version = "1.1.10"
source = { editable = "../langgraph" }
dependencies = [
{ name = "langchain-core" },
@@ -382,7 +382,7 @@ test = [
[[package]]
name = "langgraph-checkpoint"
version = "4.1.0a1"
version = "4.0.3"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },