Compare commits

...
Author SHA1 Message Date
Elior Nataf Lackritz 3521f08e21 release(checkpoint): 4.3.1 (#9447)
Version bump only. No library code changes in this PR.

Unreleased since `checkpoint==4.3.0`:

- #9383 - reject NUL bytes in store namespace labels
2026-10-26 19:47:44 +00:00
Elior Nataf Lackritz 24294fad37 release(langgraph): 1.2.18 (#9445)
Bumps langgraph from 1.2.17 to 1.2.18.

Includes:

- Default `DeltaChannel` snapshots to every 100 updates instead of 1000
(#9420)
- Let `error_handler` recover on every path and stream failed task
results (#9408)
- Stream the last checkpoint of a run stopped by `interrupt_after` or
the recursion limit (#9409)
- Stop duplicating tags at every subgraph level (#9400)
2026-10-26 15:40:09 -04:00
Elior Nataf Lackritz f6317d5cb1 release(sdk-py): 0.4.10 (#9446)
Bumps the Python SDK version from 0.4.9 to 0.4.10.

Includes:

- Retry `runs.wait` with a bodyless GET and raise run errors in the sync
`wait` (#9399)
- Follow the SSE spec for multi-line data, empty ids and a leading BOM
(#9402)
- Accept `durability`, `stream_resumable` and `context` in the `runs`
overloads (#9403)
2026-10-26 15:40:04 -04:00
Elior Nataf Lackritz 7e56e6c80b fix(langgraph): let error_handler recover on every path and stream failed task results (#9408)
- A node's `error_handler` ran, but the original error was still raised
when the node ran on the executor path: when streaming `messages` or
`custom`, with `subgraphs=True`, or next to a parallel node. The runner
marked the handled task's future, but the executors re-raised every
failed future on exit without checking the mark. The mark now lives in
the executor module, and both executors skip those futures. Fixes #8608.
- With only `tasks` or `debug` streaming, a failing task's result (with
its `error`) was never streamed: the single-task fast path raised before
the caller drained the stream queue. It now yields once before
re-raising. Fixes #5764.

The new tests fail on `main`.

Ideas from #8278 by @UdayKiranJadi, #8611 by @SnowingFox, #8823 by
@sailikhithk, #6984 by @Br1an67 and #8400 by @cms19859230182-lang.
2026-10-26 14:15:25 -04:00
0fc4f35b66 feat(langgraph)!: default delta snapshots to 100 updates (#9420)
Change `DeltaChannel`'s default `snapshot_frequency` from 1000 to 100
updates, including channels used for messages, to bound replay latency
with more frequent snapshots. Explicit overrides remain unchanged.

Co-authored-by: Elior Nataf Lackritz <eliornl@users.noreply.github.com>
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-10-26 13:28:01 -04:00
17 changed files with 165 additions and 22 deletions

No files matched your search

+1 -1
View File
@@ -279,7 +279,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
version = "4.3.0"
version = "4.3.1"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
+1 -1
View File
@@ -278,7 +278,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
version = "4.3.0"
version = "4.3.1"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
+1 -1
View File
@@ -285,7 +285,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
version = "4.3.0"
version = "4.3.1"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph-checkpoint"
version = "4.3.0"
version = "4.3.1"
description = "Library with base interfaces for LangGraph checkpoint savers."
authors = []
requires-python = ">=3.10"
+1 -1
View File
@@ -301,7 +301,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
version = "4.3.0"
version = "4.3.1"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
+2 -2
View File
@@ -60,7 +60,7 @@ class DeltaChannel(Generic[Value], BaseChannel[Any, Any, Any]):
typ: The value type (e.g. `list`, `dict`). Inferred automatically
from the outer type when used inside `Annotated[T, DeltaChannel(...)]`.
snapshot_frequency: Every Nth update to this channel writes a snapshot
blob (default `1000`). Must be a positive int.
blob (default `100`). Must be a positive int.
"""
__slots__ = ("value", "reducer", "snapshot_frequency")
@@ -71,7 +71,7 @@ class DeltaChannel(Generic[Value], BaseChannel[Any, Any, Any]):
reducer: Callable[[Any, Sequence[Any]], Any],
typ: type[Value] | None = None,
*,
snapshot_frequency: int = 1000,
snapshot_frequency: int = 100,
) -> None:
if snapshot_frequency <= 0:
raise ValueError(
+10 -2
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio
import concurrent.futures
import time
import weakref
from collections.abc import Awaitable, Callable, Coroutine
from contextlib import AbstractAsyncContextManager, AbstractContextManager, ExitStack
from contextvars import copy_context
@@ -37,6 +38,13 @@ class Submit(Protocol[P, T]):
) -> concurrent.futures.Future[T]: ...
# Futures whose exception a node's error handler took over; the executors
# don't re-raise them on exit.
SKIP_RERAISE_SET: weakref.WeakSet[concurrent.futures.Future | asyncio.Future] = (
weakref.WeakSet()
)
class BackgroundExecutor(AbstractContextManager):
"""A context manager that runs sync tasks in the background.
Uses a thread pool executor to delegate tasks to separate threads.
@@ -111,7 +119,7 @@ class BackgroundExecutor(AbstractContextManager):
if exc_type is None:
# re-raise the first exception that occurred in a task
for task, (_, reraise) in tasks.items():
if not reraise:
if not reraise or task in SKIP_RERAISE_SET:
continue
try:
task.result()
@@ -202,7 +210,7 @@ class AsyncBackgroundExecutor(AbstractAsyncContextManager):
if exc_type is None:
# re-raise the first exception that occurred in a task
for task, (_, reraise) in tasks.items():
if not reraise:
if not reraise or task in SKIP_RERAISE_SET:
continue
try:
if exc := task.exception():
+5 -5
View File
@@ -43,7 +43,7 @@ from langgraph._internal._typing import MISSING
from langgraph.constants import TAG_HIDDEN
from langgraph.errors import GraphBubbleUp, GraphInterrupt, is_invalid_resume
from langgraph.pregel._algo import Call
from langgraph.pregel._executor import Submit
from langgraph.pregel._executor import SKIP_RERAISE_SET, Submit
from langgraph.pregel._retry import arun_with_retry, run_with_retry
from langgraph.pregel._task_status import CONTROL_WRITES
from langgraph.types import (
@@ -68,10 +68,6 @@ EXCLUDED_FRAME_FNAMES = (
"concurrent/futures/_base.py",
)
SKIP_RERAISE_SET: weakref.WeakSet[concurrent.futures.Future | asyncio.Future] = (
weakref.WeakSet()
)
class FuturesDict(Generic[F, E], dict[F, PregelExecutableTask | None]):
event: E
@@ -239,6 +235,8 @@ class PregelRunner:
fut.set_exception(exc)
futures.done.add(fut)
elif reraise and id(exc) not in self._handled_exception_ids:
# let the caller stream this task's result before it raises
yield
if tb := exc.__traceback__:
while tb.tb_next is not None and any(
tb.tb_frame.f_code.co_filename.endswith(name)
@@ -430,6 +428,8 @@ class PregelRunner:
fut.set_exception(exc)
futures.done.add(fut)
elif reraise and id(exc) not in self._handled_exception_ids:
# let the caller stream this task's result before it raises
yield
if tb := exc.__traceback__:
while tb.tb_next is not None and any(
tb.tb_frame.f_code.co_filename.endswith(name)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph"
version = "1.2.17"
version = "1.2.18"
description = "Building stateful, multi-actor applications with LLMs"
authors = []
requires-python = ">=3.10"
+21
View File
@@ -20,6 +20,7 @@ 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.pregel._checkpoint import delta_channels_to_snapshot
from langgraph.types import Overwrite
pytestmark = pytest.mark.anyio
@@ -342,6 +343,26 @@ def test_delta_channel_checkpoint_returns_missing() -> None:
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("frequency", [None, 50, 1000])
def test_delta_channel_snapshot_frequency(frequency: int | None) -> None:
spec = (
DeltaChannel(_messages_delta_reducer)
if frequency is None
else DeltaChannel(_messages_delta_reducer, snapshot_frequency=frequency)
)
expected = 100 if frequency is None else frequency
assert spec.snapshot_frequency == expected
channel = spec.from_checkpoint(MISSING)
channel.update([HumanMessage(content="hi", id="h1")])
assert channel.copy().snapshot_frequency == expected
for updates in (expected - 1, expected):
assert delta_channels_to_snapshot(
{"messages": channel},
{"messages": (updates, updates)},
{"messages": 1},
) == ({"messages"} if updates == expected else set())
def test_delta_channel_snapshot_version_based() -> None:
"""Snapshots fire when a channel accumulates `snapshot_frequency` updates.
+19
View File
@@ -4346,6 +4346,25 @@ def test_enum_node_names():
assert graph.invoke({"foo": "hello"}) == {"foo": "hello", "bar": "hello!"}
@pytest.mark.parametrize("stream_mode", ["tasks", "debug"])
def test_failing_task_result_is_streamed_before_the_error(stream_mode: str) -> None:
class State(TypedDict):
x: str
def failing(state: State) -> State:
raise ValueError("boom")
graph = StateGraph(State).add_node("failing", failing).add_edge(START, "failing")
errors = []
with pytest.raises(ValueError, match="boom"):
for chunk in graph.compile().stream({"x": ""}, stream_mode=stream_mode):
payload = chunk["payload"] if stream_mode == "debug" else chunk
if "error" in payload:
errors.append(payload["error"])
assert [type(error) for error in errors] == [ValueError]
def test_debug_retry(sync_checkpointer: BaseCheckpointSaver):
class State(TypedDict):
messages: Annotated[list[str], operator.add]
+54
View File
@@ -5809,6 +5809,60 @@ async def test_store_injected_async(
) # still overwriting the same one
@pytest.mark.parametrize("stream_mode", ["tasks", "debug"])
async def test_failing_task_result_is_streamed_before_the_error(
stream_mode: str,
) -> None:
class State(TypedDict):
x: str
async def failing(state: State) -> State:
raise ValueError("boom")
graph = StateGraph(State).add_node("failing", failing).add_edge(START, "failing")
errors = []
with pytest.raises(ValueError, match="boom"):
async for chunk in graph.compile().astream({"x": ""}, stream_mode=stream_mode):
payload = chunk["payload"] if stream_mode == "debug" else chunk
if "error" in payload:
errors.append(payload["error"])
assert [type(error) for error in errors] == [ValueError]
@pytest.mark.parametrize(
("parallel", "stream_mode"), [(False, "custom"), (True, "values")]
)
async def test_graph_error_handler_recovers_on_the_executor_path(
parallel: bool, stream_mode: str
) -> None:
class State(TypedDict):
x: str
async def failing(state: State) -> State:
raise ValueError("boom")
async def handler(state: State) -> Command:
return Command(update={"x": "handled"}, goto=END)
builder = (
StateGraph(State)
.add_node("failing", failing, error_handler=handler)
.add_edge(START, "failing")
)
if parallel:
builder.add_node("sibling", lambda state: {}).add_edge(START, "sibling")
chunks = [
chunk
async for chunk in builder.compile().astream(
{"x": ""}, stream_mode=[stream_mode, "values"]
)
]
assert [c[-1] for c in chunks if c[0] == "values"][-1] == {"x": "handled"}
async def test_debug_retry(async_checkpointer: BaseCheckpointSaver):
class State(TypedDict):
messages: Annotated[list[str], operator.add]
+41
View File
@@ -2097,6 +2097,47 @@ def test_graph_error_handler_can_route_with_command():
assert result["foo"] == "handled_next"
# Each case runs the failing node on the executor path instead of the
# single-task fast path.
@pytest.mark.parametrize(
("parallel", "stream_mode", "subgraphs"),
[
(False, "custom", False),
(False, "messages", False),
(False, "values", True),
(True, "values", False),
],
)
def test_graph_error_handler_recovers_on_the_executor_path(
parallel: bool, stream_mode: str, subgraphs: bool
) -> None:
class State(TypedDict):
x: str
def failing(state: State) -> State:
raise ValueError("boom")
def handler(state: State) -> Command:
return Command(update={"x": "handled"}, goto=END)
builder = (
StateGraph(State)
.add_node("failing", failing, error_handler=handler)
.add_edge(START, "failing")
)
if parallel:
builder.add_node("sibling", lambda state: {}).add_edge(START, "sibling")
graph = builder.compile()
chunks = list(
graph.stream(
{"x": ""}, stream_mode=[stream_mode, "values"], subgraphs=subgraphs
)
)
assert [c[-1] for c in chunks if c[-2] == "values"][-1] == {"x": "handled"}
def test_graph_error_handler_failure_fails_run():
class State(TypedDict):
foo: str
+2 -2
View File
@@ -1439,7 +1439,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "1.2.17"
version = "1.2.18"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
@@ -1625,7 +1625,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
version = "4.3.0"
version = "4.3.1"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
+2 -2
View File
@@ -286,7 +286,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "1.2.17"
version = "1.2.18"
source = { editable = "../langgraph" }
dependencies = [
{ name = "langchain-core" },
@@ -370,7 +370,7 @@ test = [
[[package]]
name = "langgraph-checkpoint"
version = "4.3.0"
version = "4.3.1"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
+1 -1
View File
@@ -5,7 +5,7 @@ from langgraph_sdk.encryption.types import DecryptResult, EncryptionContext
from langgraph_sdk.priority import Priority
from langgraph_sdk.priority.types import PriorityContext, PrioritySettings
__version__ = "0.4.9"
__version__ = "0.4.10"
__all__ = [
"Auth",
+2 -2
View File
@@ -301,7 +301,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "1.2.17"
version = "1.2.18"
source = { editable = "../langgraph" }
dependencies = [
{ name = "langchain-core" },
@@ -385,7 +385,7 @@ test = [
[[package]]
name = "langgraph-checkpoint"
version = "4.3.0"
version = "4.3.1"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },