mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-28 10:49:56 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c19cffefc9 |
@@ -100,4 +100,3 @@ dmypy.json
|
||||
.turbo
|
||||
.editorconfig
|
||||
.scratch
|
||||
.worktrees/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+2
-2
@@ -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" },
|
||||
|
||||
@@ -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/"
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -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")
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+15
-6
@@ -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" },
|
||||
]
|
||||
|
||||
|
||||
@@ -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/"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,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 }
|
||||
|
||||
@@ -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²/N) storage, O(N) read depth bounded by freq
|
||||
snapshot_frequency=1 → O(N²) 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']]}"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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,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"):
|
||||
|
||||
@@ -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(
|
||||
|
||||
Generated
+14
-18
@@ -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" },
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Generated
+14
-18
@@ -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" },
|
||||
|
||||
@@ -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/"
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user