From f178eb821e52906e1705c9cc02533bb88854b409 Mon Sep 17 00:00:00 2001
From: Eugene Yurtsev
Date: Thu, 26 Feb 2026 13:26:48 -0500
Subject: [PATCH 01/11] fix(langgraph): correct ParentCommand bubbling when
checkpoint_ns includes numeric task segments (#6864)
Fixes incorrect `Command.PARENT` bubbling when checkpoint namespaces
include numeric task-disambiguation segments like `|1`. In some
nested-invoke/fanout scenarios, the runtime inserts a purely-numeric
namespace segment between `name:task_id` segments (e.g.
`parent_first:|1|node:`). The previous ParentCommand rewrite
logic only handled numeric segments at the end of the namespace, which
could produce a malformed parent graph identifier (e.g.
`parent_first:|1`) and prevent the command from routing to the
intended parent node.
This change normalizes checkpoint namespaces by dropping numeric
segments before computing the parent namespace in both sync and async
retry paths. Added a minimal regression test that exercises the
nested-invoke case and asserts that `Command(graph=Command.PARENT,
goto=...)` reliably routes to the parent graph, regardless of whether
the jump comes from the first or second nested invocation.
---
libs/langgraph/langgraph/pregel/_retry.py | 45 +++++++++++----
libs/langgraph/tests/test_parent_command.py | 53 +++++++++++++++++
.../tests/test_parent_command_async.py | 57 +++++++++++++++++++
libs/langgraph/tests/test_retry.py | 18 +++++-
4 files changed, 160 insertions(+), 13 deletions(-)
create mode 100644 libs/langgraph/tests/test_parent_command.py
create mode 100644 libs/langgraph/tests/test_parent_command_async.py
diff --git a/libs/langgraph/langgraph/pregel/_retry.py b/libs/langgraph/langgraph/pregel/_retry.py
index b42b63644..ba62bda6e 100644
--- a/libs/langgraph/langgraph/pregel/_retry.py
+++ b/libs/langgraph/langgraph/pregel/_retry.py
@@ -23,6 +23,35 @@ logger = logging.getLogger(__name__)
SUPPORTS_EXC_NOTES = sys.version_info >= (3, 11)
+def _checkpoint_ns_for_parent_command(ns: str) -> str:
+ """Return the checkpoint namespace for the parent graph.
+
+ The checkpoint namespace is a `|`-separated path. Each segment is usually
+ of the form `name:task_id` (e.g. `parent_first:|node:`), but the
+ runtime may also insert a purely-numeric segment (e.g. `|1`) to disambiguate
+ concurrent tasks (e.g. `parent_first:|1|node:`).
+
+ Numeric segments are not real path levels, so we drop them before computing
+ the parent namespace.
+ """
+
+ parts = ns.split(NS_SEP)
+
+ # Drop any trailing numeric selectors for the current frame (e.g. `...|node:|1`).
+ while parts and parts[-1].isdigit():
+ parts.pop()
+
+ # Drop the current frame segment itself (e.g. the `node:`).
+ if parts:
+ parts.pop()
+
+ # Drop any trailing numeric selectors for the parent frame (e.g. `...|1|node:`).
+ while parts and parts[-1].isdigit():
+ parts.pop()
+
+ return NS_SEP.join(parts)
+
+
def run_with_retry(
task: PregelExecutableTask,
retry_policy: Sequence[RetryPolicy] | None,
@@ -50,12 +79,8 @@ def run_with_retry(
w.invoke(cmd, config)
break
elif cmd.graph == Command.PARENT:
- # this command is for the parent graph, assign it to the parent
- parts = ns.split(NS_SEP)
- if parts[-1].isdigit():
- parts.pop()
- parent_ns = NS_SEP.join(parts[:-1])
- exc.args = (replace(cmd, graph=parent_ns),)
+ # this command is for the parent graph, assign it to the parent.
+ exc.args = (replace(cmd, graph=_checkpoint_ns_for_parent_command(ns)),)
# bubble up
raise
except GraphBubbleUp:
@@ -146,12 +171,8 @@ async def arun_with_retry(
w.invoke(cmd, config)
break
elif cmd.graph == Command.PARENT:
- # this command is for the parent graph, assign it to the parent
- parts = ns.split(NS_SEP)
- if parts[-1].isdigit():
- parts.pop()
- parent_ns = NS_SEP.join(parts[:-1])
- exc.args = (replace(cmd, graph=parent_ns),)
+ # this command is for the parent graph, assign it to the parent.
+ exc.args = (replace(cmd, graph=_checkpoint_ns_for_parent_command(ns)),)
# bubble up
raise
except GraphBubbleUp:
diff --git a/libs/langgraph/tests/test_parent_command.py b/libs/langgraph/tests/test_parent_command.py
new file mode 100644
index 000000000..6b368232b
--- /dev/null
+++ b/libs/langgraph/tests/test_parent_command.py
@@ -0,0 +1,53 @@
+from __future__ import annotations
+
+from typing_extensions import TypedDict
+
+from langgraph.graph import END, START, StateGraph
+from langgraph.types import Command
+
+
+def test_parent_command_from_nested_subgraph() -> None:
+ class ParentState(TypedDict):
+ jump_from_idx: int
+
+ class ChildState(TypedDict):
+ jump: bool
+
+ child_builder: StateGraph[ChildState] = StateGraph(ChildState)
+
+ def child_node(state: ChildState) -> Command | ChildState:
+ if state["jump"]:
+ return Command(graph=Command.PARENT, goto="parent_second")
+ return state
+
+ child_builder.add_node("node", child_node)
+ child_builder.add_edge(START, "node")
+
+ child_0 = child_builder.compile()
+ child_1 = child_builder.compile()
+
+ parent_builder: StateGraph[ParentState] = StateGraph(ParentState)
+
+ def parent_first(state: ParentState) -> ParentState:
+ child_0.invoke({"jump": state["jump_from_idx"] == 1})
+ if state["jump_from_idx"] == 1:
+ raise AssertionError("Shouldn't be here")
+
+ child_1.invoke({"jump": state["jump_from_idx"] == 2})
+ if state["jump_from_idx"] == 2:
+ raise AssertionError("Shouldn't be here")
+
+ return state
+
+ def parent_second(state: ParentState) -> ParentState:
+ return state
+
+ parent_builder.add_node("parent_first", parent_first)
+ parent_builder.add_node("parent_second", parent_second)
+ parent_builder.add_edge(START, "parent_first")
+ parent_builder.add_edge("parent_second", END)
+
+ graph = parent_builder.compile()
+
+ assert graph.invoke({"jump_from_idx": 1}) == {"jump_from_idx": 1}
+ assert graph.invoke({"jump_from_idx": 2}) == {"jump_from_idx": 2}
diff --git a/libs/langgraph/tests/test_parent_command_async.py b/libs/langgraph/tests/test_parent_command_async.py
new file mode 100644
index 000000000..39a077631
--- /dev/null
+++ b/libs/langgraph/tests/test_parent_command_async.py
@@ -0,0 +1,57 @@
+from __future__ import annotations
+
+import pytest
+from langchain_core.runnables import RunnableConfig
+from typing_extensions import TypedDict
+
+from langgraph.graph import END, START, StateGraph
+from langgraph.types import Command
+
+pytestmark = pytest.mark.anyio
+
+
+async def test_parent_command_from_nested_subgraph() -> None:
+ class ParentState(TypedDict):
+ jump_from_idx: int
+
+ class ChildState(TypedDict):
+ jump: bool
+
+ child_builder: StateGraph[ChildState] = StateGraph(ChildState)
+
+ async def child_node(state: ChildState) -> Command | ChildState:
+ if state["jump"]:
+ return Command(graph=Command.PARENT, goto="parent_second")
+ return state
+
+ child_builder.add_node("node", child_node)
+ child_builder.add_edge(START, "node")
+
+ child_0 = child_builder.compile()
+ child_1 = child_builder.compile()
+
+ parent_builder: StateGraph[ParentState] = StateGraph(ParentState)
+
+ async def parent_first(state: ParentState, config: RunnableConfig) -> ParentState:
+ await child_0.ainvoke({"jump": state["jump_from_idx"] == 1}, config)
+ if state["jump_from_idx"] == 1:
+ raise AssertionError("Shouldn't be here")
+
+ await child_1.ainvoke({"jump": state["jump_from_idx"] == 2}, config)
+ if state["jump_from_idx"] == 2:
+ raise AssertionError("Shouldn't be here")
+
+ return state
+
+ async def parent_second(state: ParentState) -> ParentState:
+ return state
+
+ parent_builder.add_node("parent_first", parent_first)
+ parent_builder.add_node("parent_second", parent_second)
+ parent_builder.add_edge(START, "parent_first")
+ parent_builder.add_edge("parent_second", END)
+
+ graph = parent_builder.compile().with_config(recursion_limit=10)
+
+ assert await graph.ainvoke({"jump_from_idx": 1}) == {"jump_from_idx": 1}
+ assert await graph.ainvoke({"jump_from_idx": 2}) == {"jump_from_idx": 2}
diff --git a/libs/langgraph/tests/test_retry.py b/libs/langgraph/tests/test_retry.py
index ac37bea91..864affe2f 100644
--- a/libs/langgraph/tests/test_retry.py
+++ b/libs/langgraph/tests/test_retry.py
@@ -4,7 +4,7 @@ import pytest
from typing_extensions import TypedDict
from langgraph.graph import START, StateGraph
-from langgraph.pregel._retry import _should_retry_on
+from langgraph.pregel._retry import _checkpoint_ns_for_parent_command, _should_retry_on
from langgraph.types import RetryPolicy
@@ -78,6 +78,22 @@ def test_should_retry_on_empty_sequence():
assert _should_retry_on(policy, ValueError("test error")) is False
+def test_checkpoint_ns_for_parent_command() -> None:
+ assert _checkpoint_ns_for_parent_command("") == ""
+ assert _checkpoint_ns_for_parent_command("node:1") == ""
+ assert _checkpoint_ns_for_parent_command("node:1|child:2") == "node:1"
+ assert _checkpoint_ns_for_parent_command("node:1|1|child:2") == "node:1"
+ assert _checkpoint_ns_for_parent_command("node:1|1|child:2|1") == "node:1"
+ assert (
+ _checkpoint_ns_for_parent_command("parent:1|1|child:1|1|node:1|1")
+ == "parent:1|1|child:1"
+ )
+ assert (
+ _checkpoint_ns_for_parent_command("parent:1|1|child:1|1|node:1")
+ == "parent:1|1|child:1"
+ )
+
+
def test_should_retry_default_retry_on():
"""Test the default retry_on function."""
import httpx
From c4a4a4647343d802d0ab909439806076bae15bd6 Mon Sep 17 00:00:00 2001
From: Sydney Runkle <54324534+sydney-runkle@users.noreply.github.com>
Date: Thu, 26 Feb 2026 13:33:46 -0500
Subject: [PATCH 02/11] chore: add tests to confirm expected subgraph
persistence behavior (#6943)
motivated by / in conjunction with new docs:
https://docs.langchain.com/oss/python/langgraph/use-subgraphs#subgraph-persistence
---
.../tests/test_subgraph_persistence.py | 641 +++++++++++++++++
.../tests/test_subgraph_persistence_async.py | 662 ++++++++++++++++++
2 files changed, 1303 insertions(+)
create mode 100644 libs/langgraph/tests/test_subgraph_persistence.py
create mode 100644 libs/langgraph/tests/test_subgraph_persistence_async.py
diff --git a/libs/langgraph/tests/test_subgraph_persistence.py b/libs/langgraph/tests/test_subgraph_persistence.py
new file mode 100644
index 000000000..58dfcf596
--- /dev/null
+++ b/libs/langgraph/tests/test_subgraph_persistence.py
@@ -0,0 +1,641 @@
+"""Tests for subgraph persistence behavior (sync).
+
+Covers three checkpointer settings for subgraph state:
+- checkpointer=False: no persistence, even when parent has a checkpointer
+- checkpointer=None (default): "stateless" — inherits parent checkpointer for
+ interrupt support, but state resets each invocation. This is the common case
+ when an agent is invoked from inside a tool used by another agent.
+- checkpointer=True: "stateful" — state accumulates across invocations on the same thread id
+"""
+
+from uuid import uuid4
+
+from langchain_core.messages import AIMessage, HumanMessage
+from langgraph.checkpoint.base import BaseCheckpointSaver
+from typing_extensions import TypedDict
+
+from langgraph.graph import START, StateGraph
+from langgraph.graph.message import MessagesState
+from langgraph.types import Command, Interrupt, interrupt
+from tests.any_str import AnyStr
+
+
+class ParentState(TypedDict):
+ result: str
+
+
+# -- checkpointer=None (stateless) --
+
+
+def test_stateless_interrupt_resume(
+ sync_checkpointer: BaseCheckpointSaver,
+) -> None:
+ """Tests that a subgraph compiled with checkpointer=None (the default) can
+ still support interrupt/resume when invoked from inside a parent graph that
+ has a checkpointer. This is the "stateless" pattern — the subgraph inherits
+ the parent's checkpointer just enough to pause and resume, but does not
+ retain any state across separate parent invocations. This pattern commonly
+ appears when an agent is invoked from inside a tool used by another agent.
+ """
+
+ # Build a subgraph that interrupts before echoing.
+ # Two nodes: "process" interrupts then echoes, "respond" returns "Done".
+ def process(state: MessagesState) -> dict:
+ interrupt("continue?")
+ return {
+ "messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")]
+ }
+
+ def respond(state: MessagesState) -> dict:
+ return {"messages": [AIMessage(content="Done")]}
+
+ inner = (
+ StateGraph(MessagesState)
+ .add_node("process", process)
+ .add_node("respond", respond)
+ .add_edge(START, "process")
+ .add_edge("process", "respond")
+ .compile()
+ )
+
+ def call_inner(state: ParentState) -> dict:
+ resp = inner.invoke({"messages": [HumanMessage(content="apples")]})
+ return {"result": resp["messages"][-1].text}
+
+ parent = (
+ StateGraph(ParentState)
+ .add_node("call_inner", call_inner)
+ .add_edge(START, "call_inner")
+ .compile(checkpointer=sync_checkpointer)
+ )
+ config = {"configurable": {"thread_id": str(uuid4())}}
+
+ # First invoke hits the interrupt
+ result = parent.invoke({"result": ""}, config)
+ assert result == {
+ "result": "",
+ "__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
+ }
+
+ # Resume completes the subgraph
+ result = parent.invoke(Command(resume=True), config)
+ assert result == {"result": "Done"}
+
+
+def test_stateless_state_resets(
+ sync_checkpointer: BaseCheckpointSaver,
+) -> None:
+ """Tests that a subgraph compiled with checkpointer=None (the default) does
+ not retain any message history between separate parent invocations. Each time
+ the parent graph invokes the subgraph, it starts with a clean slate. This
+ confirms the "stateless" behavior: even though the parent has a checkpointer,
+ the subgraph state is not persisted across calls.
+ """
+
+ # Build a simple echo subgraph: echoes "Processing: "
+ def echo(state: MessagesState) -> dict:
+ return {
+ "messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")]
+ }
+
+ inner = (
+ StateGraph(MessagesState)
+ .add_node("echo", echo)
+ .add_edge(START, "echo")
+ .compile()
+ )
+
+ subgraph_messages: list[list[str]] = []
+ call_count = 0
+
+ def call_inner(state: ParentState) -> dict:
+ nonlocal call_count
+ call_count += 1
+ topic = "apples" if call_count == 1 else "bananas"
+ resp = inner.invoke(
+ {"messages": [HumanMessage(content=f"tell me about {topic}")]}
+ )
+ subgraph_messages.append([m.text for m in resp["messages"]])
+ return {"result": resp["messages"][-1].text}
+
+ parent = (
+ StateGraph(ParentState)
+ .add_node("call_inner", call_inner)
+ .add_edge(START, "call_inner")
+ .compile(checkpointer=sync_checkpointer)
+ )
+ config = {"configurable": {"thread_id": str(uuid4())}}
+
+ result1 = parent.invoke({"result": ""}, config)
+ assert result1 == {"result": "Processing: tell me about apples"}
+
+ result2 = parent.invoke({"result": ""}, config)
+ assert result2 == {"result": "Processing: tell me about bananas"}
+
+ # Both invocations produce fresh history — no memory of prior call
+ assert subgraph_messages[0] == [
+ "tell me about apples",
+ "Processing: tell me about apples",
+ ]
+ assert subgraph_messages[1] == [
+ "tell me about bananas",
+ "Processing: tell me about bananas",
+ ]
+
+
+def test_stateless_state_resets_with_interrupt(
+ sync_checkpointer: BaseCheckpointSaver,
+) -> None:
+ """Tests that a subgraph compiled with checkpointer=None resets its state
+ between parent invocations even when interrupt/resume is used. The subgraph
+ is invoked twice from the parent, each time with an interrupt that must be
+ resumed. After both invoke+resume cycles, each subgraph run should only
+ contain its own messages — no bleed-over from the previous run.
+ """
+
+ # Build a subgraph that interrupts before echoing, then responds "Done"
+ def process(state: MessagesState) -> dict:
+ interrupt("continue?")
+ return {
+ "messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")]
+ }
+
+ def respond(state: MessagesState) -> dict:
+ return {"messages": [AIMessage(content="Done")]}
+
+ inner = (
+ StateGraph(MessagesState)
+ .add_node("process", process)
+ .add_node("respond", respond)
+ .add_edge(START, "process")
+ .add_edge("process", "respond")
+ .compile()
+ )
+
+ subgraph_messages: list[list[str]] = []
+ call_count = 0
+
+ def call_inner(state: ParentState) -> dict:
+ nonlocal call_count
+ call_count += 1
+ topic = "apples" if call_count == 1 else "bananas"
+ resp = inner.invoke(
+ {"messages": [HumanMessage(content=f"tell me about {topic}")]}
+ )
+ subgraph_messages.append([m.text for m in resp["messages"]])
+ return {"result": resp["messages"][-1].text}
+
+ parent = (
+ StateGraph(ParentState)
+ .add_node("call_inner", call_inner)
+ .add_edge(START, "call_inner")
+ .compile(checkpointer=sync_checkpointer)
+ )
+ config = {"configurable": {"thread_id": str(uuid4())}}
+
+ # First invoke+resume cycle
+ result = parent.invoke({"result": ""}, config)
+ assert result == {
+ "result": "",
+ "__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
+ }
+ result = parent.invoke(Command(resume=True), config)
+ assert result == {"result": "Done"}
+
+ # Second invoke+resume cycle
+ result = parent.invoke({"result": ""}, config)
+ assert result == {
+ "result": "",
+ "__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
+ }
+ result = parent.invoke(Command(resume=True), config)
+ assert result == {"result": "Done"}
+
+ # Both invocations produce fresh history — no memory of prior call
+ assert subgraph_messages[0] == [
+ "tell me about apples",
+ "Processing: tell me about apples",
+ "Done",
+ ]
+ assert subgraph_messages[1] == [
+ "tell me about bananas",
+ "Processing: tell me about bananas",
+ "Done",
+ ]
+
+
+# -- checkpointer=False --
+
+
+def test_checkpointer_false_no_persistence(
+ sync_checkpointer: BaseCheckpointSaver,
+) -> None:
+ """Tests that a subgraph compiled with checkpointer=False gets no
+ persistence at all, even when the parent graph has a checkpointer. Unlike
+ the default (checkpointer=None) which inherits just enough from the parent
+ to support interrupt/resume, checkpointer=False explicitly opts out of all
+ checkpoint behavior. Each invocation starts completely fresh.
+ """
+
+ # Build a simple echo subgraph with checkpointer=False
+ def echo(state: MessagesState) -> dict:
+ return {
+ "messages": [AIMessage(content=f"Processed: {state['messages'][-1].text}")]
+ }
+
+ inner = (
+ StateGraph(MessagesState)
+ .add_node("echo", echo)
+ .add_edge(START, "echo")
+ .compile(checkpointer=False)
+ )
+
+ subgraph_messages: list[list[str]] = []
+ call_count = 0
+
+ def call_inner(state: ParentState) -> dict:
+ nonlocal call_count
+ call_count += 1
+ topic = "apples" if call_count == 1 else "bananas"
+ resp = inner.invoke(
+ {"messages": [HumanMessage(content=f"tell me about {topic}")]}
+ )
+ subgraph_messages.append([m.text for m in resp["messages"]])
+ return {"result": resp["messages"][-1].text}
+
+ parent = (
+ StateGraph(ParentState)
+ .add_node("call_inner", call_inner)
+ .add_edge(START, "call_inner")
+ .compile(checkpointer=sync_checkpointer)
+ )
+ config = {"configurable": {"thread_id": str(uuid4())}}
+
+ result1 = parent.invoke({"result": ""}, config)
+ assert result1 == {"result": "Processed: tell me about apples"}
+
+ result2 = parent.invoke({"result": ""}, config)
+ assert result2 == {"result": "Processed: tell me about bananas"}
+
+ # Both start fresh — no history from first call
+ assert subgraph_messages[0] == [
+ "tell me about apples",
+ "Processed: tell me about apples",
+ ]
+ assert subgraph_messages[1] == [
+ "tell me about bananas",
+ "Processed: tell me about bananas",
+ ]
+
+
+# -- checkpointer=True (stateful) --
+
+
+def test_stateful_state_accumulates(
+ sync_checkpointer: BaseCheckpointSaver,
+) -> None:
+ """Tests that a subgraph compiled with checkpointer=True ("stateful")
+ retains its message history across separate parent invocations. To enable
+ this, the subgraph is wrapped in an outer graph compiled with
+ checkpointer=True — this wrapper gives the inner subgraph its own persistent
+ checkpoint namespace. After two parent calls, the second subgraph invocation
+ should see messages from both the first and second calls.
+ """
+
+ # Build a simple echo subgraph
+ def echo(state: MessagesState) -> dict:
+ return {
+ "messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")]
+ }
+
+ inner = (
+ StateGraph(MessagesState)
+ .add_node("echo", echo)
+ .add_edge(START, "echo")
+ .compile()
+ )
+
+ # Wrap the inner subgraph with checkpointer=True to enable stateful.
+ # The wrapper graph gives the subgraph its own persistent checkpoint
+ # namespace, keyed by the node name ("agent").
+ wrapper = (
+ StateGraph(MessagesState)
+ .add_node("agent", inner)
+ .add_edge(START, "agent")
+ .compile(checkpointer=True)
+ )
+
+ subgraph_messages: list[list[str]] = []
+ topics = ["apples", "bananas"]
+
+ def call_inner(state: ParentState) -> dict:
+ topic = topics[len(subgraph_messages)]
+ resp = wrapper.invoke(
+ {"messages": [HumanMessage(content=f"tell me about {topic}")]}
+ )
+ subgraph_messages.append([m.text for m in resp["messages"]])
+ return {"result": resp["messages"][-1].text}
+
+ parent = (
+ StateGraph(ParentState)
+ .add_node("call_inner", call_inner)
+ .add_edge(START, "call_inner")
+ .compile(checkpointer=sync_checkpointer)
+ )
+ config = {"configurable": {"thread_id": str(uuid4())}}
+
+ result1 = parent.invoke({"result": ""}, config)
+ assert result1 == {"result": "Processing: tell me about apples"}
+
+ result2 = parent.invoke({"result": ""}, config)
+ assert result2 == {"result": "Processing: tell me about bananas"}
+
+ # First call: fresh history
+ assert subgraph_messages[0] == [
+ "tell me about apples",
+ "Processing: tell me about apples",
+ ]
+ # Second call: retains messages from first call
+ assert subgraph_messages[1] == [
+ "tell me about apples",
+ "Processing: tell me about apples",
+ "tell me about bananas",
+ "Processing: tell me about bananas",
+ ]
+
+
+def test_stateful_state_accumulates_with_interrupt(
+ sync_checkpointer: BaseCheckpointSaver,
+) -> None:
+ """Tests that a stateful subgraph (checkpointer=True) retains its
+ message history across parent invocations even when interrupt/resume is
+ involved. The subgraph interrupts before echoing, then responds "Done".
+ After two invoke+resume cycles, the second run should contain the full
+ accumulated history from both calls.
+ """
+
+ # Build a subgraph that interrupts before echoing, then responds "Done"
+ def process(state: MessagesState) -> dict:
+ interrupt("continue?")
+ return {
+ "messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")]
+ }
+
+ def respond(state: MessagesState) -> dict:
+ return {"messages": [AIMessage(content="Done")]}
+
+ inner = (
+ StateGraph(MessagesState)
+ .add_node("process", process)
+ .add_node("respond", respond)
+ .add_edge(START, "process")
+ .add_edge("process", "respond")
+ .compile()
+ )
+
+ # Wrap with checkpointer=True for stateful
+ wrapper = (
+ StateGraph(MessagesState)
+ .add_node("agent", inner)
+ .add_edge(START, "agent")
+ .compile(checkpointer=True)
+ )
+
+ subgraph_messages: list[list[str]] = []
+ topics = ["apples", "bananas"]
+
+ def call_inner(state: ParentState) -> dict:
+ topic = topics[len(subgraph_messages)]
+ resp = wrapper.invoke(
+ {"messages": [HumanMessage(content=f"tell me about {topic}")]}
+ )
+ subgraph_messages.append([m.text for m in resp["messages"]])
+ return {"result": resp["messages"][-1].text}
+
+ parent = (
+ StateGraph(ParentState)
+ .add_node("call_inner", call_inner)
+ .add_edge(START, "call_inner")
+ .compile(checkpointer=sync_checkpointer)
+ )
+ config = {"configurable": {"thread_id": str(uuid4())}}
+
+ # First invoke+resume cycle
+ result = parent.invoke({"result": ""}, config)
+ assert result == {
+ "result": "",
+ "__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
+ }
+ result = parent.invoke(Command(resume=True), config)
+ assert result == {"result": "Done"}
+
+ # Second invoke+resume cycle
+ result = parent.invoke({"result": ""}, config)
+ assert result == {
+ "result": "",
+ "__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
+ }
+ result = parent.invoke(Command(resume=True), config)
+ assert result == {"result": "Done"}
+
+ # First call: fresh history
+ assert subgraph_messages[0] == [
+ "tell me about apples",
+ "Processing: tell me about apples",
+ "Done",
+ ]
+ # Second call: retains messages from first call
+ assert subgraph_messages[1] == [
+ "tell me about apples",
+ "Processing: tell me about apples",
+ "Done",
+ "tell me about bananas",
+ "Processing: tell me about bananas",
+ "Done",
+ ]
+
+
+def test_stateful_interrupt_resume(
+ sync_checkpointer: BaseCheckpointSaver,
+) -> None:
+ """Tests that a stateful subgraph (checkpointer=True) correctly
+ supports interrupt/resume while also accumulating state. Each invoke+resume
+ pair triggers the subgraph, and after the second pair completes we verify
+ both the per-step invoke outputs and the accumulated message history. This
+ exercises the full lifecycle: interrupt, resume, state accumulation.
+ """
+
+ # Build a subgraph that interrupts before echoing, then responds "Done"
+ def process(state: MessagesState) -> dict:
+ interrupt("continue?")
+ return {
+ "messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")]
+ }
+
+ def respond(state: MessagesState) -> dict:
+ return {"messages": [AIMessage(content="Done")]}
+
+ inner = (
+ StateGraph(MessagesState)
+ .add_node("process", process)
+ .add_node("respond", respond)
+ .add_edge(START, "process")
+ .add_edge("process", "respond")
+ .compile()
+ )
+
+ # Wrap with checkpointer=True for stateful
+ wrapper = (
+ StateGraph(MessagesState)
+ .add_node("agent", inner)
+ .add_edge(START, "agent")
+ .compile(checkpointer=True)
+ )
+
+ subgraph_messages: list[list[str]] = []
+ topics = ["apples", "bananas"]
+
+ def call_inner(state: ParentState) -> dict:
+ topic = topics[len(subgraph_messages)]
+ resp = wrapper.invoke(
+ {"messages": [HumanMessage(content=f"tell me about {topic}")]}
+ )
+ subgraph_messages.append([m.text for m in resp["messages"]])
+ return {"result": resp["messages"][-1].text}
+
+ parent = (
+ StateGraph(ParentState)
+ .add_node("call_inner", call_inner)
+ .add_edge(START, "call_inner")
+ .compile(checkpointer=sync_checkpointer)
+ )
+ config = {"configurable": {"thread_id": str(uuid4())}}
+
+ # First invocation: hits interrupt
+ result = parent.invoke({"result": ""}, config)
+ assert result == {
+ "result": "",
+ "__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
+ }
+
+ # Resume: completes first call
+ result = parent.invoke(Command(resume=True), config)
+ assert result == {"result": "Done"}
+ assert subgraph_messages[0] == [
+ "tell me about apples",
+ "Processing: tell me about apples",
+ "Done",
+ ]
+
+ # Second invocation: hits interrupt, state accumulated from first call
+ result = parent.invoke({"result": ""}, config)
+ assert result == {
+ "result": "",
+ "__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
+ }
+
+ # Resume: completes second call with accumulated state
+ result = parent.invoke(Command(resume=True), config)
+ assert result == {"result": "Done"}
+ assert subgraph_messages[1] == [
+ "tell me about apples",
+ "Processing: tell me about apples",
+ "Done",
+ "tell me about bananas",
+ "Processing: tell me about bananas",
+ "Done",
+ ]
+
+
+def test_stateful_namespace_isolation(
+ sync_checkpointer: BaseCheckpointSaver,
+) -> None:
+ """Tests that two different stateful subgraphs (checkpointer=True)
+ maintain completely independent state when they use different wrapper node
+ names. A "fruit_agent" and "veggie_agent" are each wrapped in their own
+ stateful graph. After two parent invocations, each agent should only
+ see its own accumulated history with no cross-contamination between them.
+ """
+
+ # Build two simple echo subgraphs with different prefixes
+ def fruit_echo(state: MessagesState) -> dict:
+ return {"messages": [AIMessage(content=f"Fruit: {state['messages'][-1].text}")]}
+
+ def veggie_echo(state: MessagesState) -> dict:
+ return {
+ "messages": [AIMessage(content=f"Veggie: {state['messages'][-1].text}")]
+ }
+
+ fruit_inner = (
+ StateGraph(MessagesState)
+ .add_node("echo", fruit_echo)
+ .add_edge(START, "echo")
+ .compile()
+ )
+ veggie_inner = (
+ StateGraph(MessagesState)
+ .add_node("echo", veggie_echo)
+ .add_edge(START, "echo")
+ .compile()
+ )
+
+ # Wrap each with checkpointer=True, using different node names to get
+ # independent checkpoint namespaces
+ fruit = (
+ StateGraph(MessagesState)
+ .add_node("fruit_agent", fruit_inner)
+ .add_edge(START, "fruit_agent")
+ .compile(checkpointer=True)
+ )
+ veggie = (
+ StateGraph(MessagesState)
+ .add_node("veggie_agent", veggie_inner)
+ .add_edge(START, "veggie_agent")
+ .compile(checkpointer=True)
+ )
+
+ fruit_msgs: list[list[str]] = []
+ veggie_msgs: list[list[str]] = []
+ call_count = 0
+
+ def call_both(state: ParentState) -> dict:
+ nonlocal call_count
+ call_count += 1
+ suffix = "round 1" if call_count == 1 else "round 2"
+ f = fruit.invoke({"messages": [HumanMessage(content=f"cherries {suffix}")]})
+ v = veggie.invoke({"messages": [HumanMessage(content=f"broccoli {suffix}")]})
+ fruit_msgs.append([m.text for m in f["messages"]])
+ veggie_msgs.append([m.text for m in v["messages"]])
+ return {"result": f["messages"][-1].text}
+
+ parent = (
+ StateGraph(ParentState)
+ .add_node("call_both", call_both)
+ .add_edge(START, "call_both")
+ .compile(checkpointer=sync_checkpointer)
+ )
+ config = {"configurable": {"thread_id": str(uuid4())}}
+
+ result1 = parent.invoke({"result": ""}, config)
+ assert result1 == {"result": "Fruit: cherries round 1"}
+
+ result2 = parent.invoke({"result": ""}, config)
+ assert result2 == {"result": "Fruit: cherries round 2"}
+
+ # First call: each agent sees only its own history
+ assert fruit_msgs[0] == ["cherries round 1", "Fruit: cherries round 1"]
+ assert veggie_msgs[0] == ["broccoli round 1", "Veggie: broccoli round 1"]
+
+ # Second call: each accumulated independently — no cross-contamination
+ assert fruit_msgs[1] == [
+ "cherries round 1",
+ "Fruit: cherries round 1",
+ "cherries round 2",
+ "Fruit: cherries round 2",
+ ]
+ assert veggie_msgs[1] == [
+ "broccoli round 1",
+ "Veggie: broccoli round 1",
+ "broccoli round 2",
+ "Veggie: broccoli round 2",
+ ]
diff --git a/libs/langgraph/tests/test_subgraph_persistence_async.py b/libs/langgraph/tests/test_subgraph_persistence_async.py
new file mode 100644
index 000000000..759df5549
--- /dev/null
+++ b/libs/langgraph/tests/test_subgraph_persistence_async.py
@@ -0,0 +1,662 @@
+"""Tests for subgraph persistence behavior (async).
+
+Covers three checkpointer settings for subgraph state:
+- checkpointer=False: no persistence, even when parent has a checkpointer
+- checkpointer=None (default): "stateless" — inherits parent checkpointer for
+ interrupt support, but state resets each invocation. This is the common case
+ when an agent is invoked from inside a tool used by another agent.
+- checkpointer=True: "stateful" — state accumulates across invocations on the same thread id
+"""
+
+import sys
+from uuid import uuid4
+
+import pytest
+from langchain_core.messages import AIMessage, HumanMessage
+from langgraph.checkpoint.base import BaseCheckpointSaver
+from typing_extensions import TypedDict
+
+from langgraph.graph import START, StateGraph
+from langgraph.graph.message import MessagesState
+from langgraph.types import Command, Interrupt, interrupt
+from tests.any_str import AnyStr
+
+pytestmark = pytest.mark.anyio
+
+NEEDS_CONTEXTVARS = pytest.mark.skipif(
+ sys.version_info < (3, 11),
+ reason="Python 3.11+ is required for async contextvars support",
+)
+
+
+class ParentState(TypedDict):
+ result: str
+
+
+# -- checkpointer=None (stateless) --
+
+
+@NEEDS_CONTEXTVARS
+async def test_stateless_interrupt_resume_async(
+ async_checkpointer: BaseCheckpointSaver,
+) -> None:
+ """Tests that a subgraph compiled with checkpointer=None (the default) can
+ still support interrupt/resume when invoked from inside a parent graph that
+ has a checkpointer. This is the "stateless" pattern — the subgraph inherits
+ the parent's checkpointer just enough to pause and resume, but does not
+ retain any state across separate parent invocations. This pattern commonly
+ appears when an agent is invoked from inside a tool used by another agent.
+ """
+
+ # Build a subgraph that interrupts before echoing.
+ # Two nodes: "process" interrupts then echoes, "respond" returns "Done".
+ def process(state: MessagesState) -> dict:
+ interrupt("continue?")
+ return {
+ "messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")]
+ }
+
+ def respond(state: MessagesState) -> dict:
+ return {"messages": [AIMessage(content="Done")]}
+
+ inner = (
+ StateGraph(MessagesState)
+ .add_node("process", process)
+ .add_node("respond", respond)
+ .add_edge(START, "process")
+ .add_edge("process", "respond")
+ .compile()
+ )
+
+ async def call_inner(state: ParentState) -> dict:
+ resp = await inner.ainvoke({"messages": [HumanMessage(content="apples")]})
+ return {"result": resp["messages"][-1].text}
+
+ parent = (
+ StateGraph(ParentState)
+ .add_node("call_inner", call_inner)
+ .add_edge(START, "call_inner")
+ .compile(checkpointer=async_checkpointer)
+ )
+ config = {"configurable": {"thread_id": str(uuid4())}}
+
+ # First invoke hits the interrupt
+ result = await parent.ainvoke({"result": ""}, config)
+ assert result == {
+ "result": "",
+ "__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
+ }
+
+ # Resume completes the subgraph
+ result = await parent.ainvoke(Command(resume=True), config)
+ assert result == {"result": "Done"}
+
+
+@NEEDS_CONTEXTVARS
+async def test_stateless_state_resets_async(
+ async_checkpointer: BaseCheckpointSaver,
+) -> None:
+ """Tests that a subgraph compiled with checkpointer=None (the default) does
+ not retain any message history between separate parent invocations. Each time
+ the parent graph invokes the subgraph, it starts with a clean slate. This
+ confirms the "stateless" behavior: even though the parent has a checkpointer,
+ the subgraph state is not persisted across calls.
+ """
+
+ # Build a simple echo subgraph: echoes "Processing: "
+ def echo(state: MessagesState) -> dict:
+ return {
+ "messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")]
+ }
+
+ inner = (
+ StateGraph(MessagesState)
+ .add_node("echo", echo)
+ .add_edge(START, "echo")
+ .compile()
+ )
+
+ subgraph_messages: list[list[str]] = []
+ call_count = 0
+
+ async def call_inner(state: ParentState) -> dict:
+ nonlocal call_count
+ call_count += 1
+ topic = "apples" if call_count == 1 else "bananas"
+ resp = await inner.ainvoke(
+ {"messages": [HumanMessage(content=f"tell me about {topic}")]}
+ )
+ subgraph_messages.append([m.text for m in resp["messages"]])
+ return {"result": resp["messages"][-1].text}
+
+ parent = (
+ StateGraph(ParentState)
+ .add_node("call_inner", call_inner)
+ .add_edge(START, "call_inner")
+ .compile(checkpointer=async_checkpointer)
+ )
+ config = {"configurable": {"thread_id": str(uuid4())}}
+
+ result1 = await parent.ainvoke({"result": ""}, config)
+ assert result1 == {"result": "Processing: tell me about apples"}
+
+ result2 = await parent.ainvoke({"result": ""}, config)
+ assert result2 == {"result": "Processing: tell me about bananas"}
+
+ # Both invocations produce fresh history — no memory of prior call
+ assert subgraph_messages[0] == [
+ "tell me about apples",
+ "Processing: tell me about apples",
+ ]
+ assert subgraph_messages[1] == [
+ "tell me about bananas",
+ "Processing: tell me about bananas",
+ ]
+
+
+@NEEDS_CONTEXTVARS
+async def test_stateless_state_resets_with_interrupt_async(
+ async_checkpointer: BaseCheckpointSaver,
+) -> None:
+ """Tests that a subgraph compiled with checkpointer=None resets its state
+ between parent invocations even when interrupt/resume is used. The subgraph
+ is invoked twice from the parent, each time with an interrupt that must be
+ resumed. After both invoke+resume cycles, each subgraph run should only
+ contain its own messages — no bleed-over from the previous run.
+ """
+
+ # Build a subgraph that interrupts before echoing, then responds "Done"
+ def process(state: MessagesState) -> dict:
+ interrupt("continue?")
+ return {
+ "messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")]
+ }
+
+ def respond(state: MessagesState) -> dict:
+ return {"messages": [AIMessage(content="Done")]}
+
+ inner = (
+ StateGraph(MessagesState)
+ .add_node("process", process)
+ .add_node("respond", respond)
+ .add_edge(START, "process")
+ .add_edge("process", "respond")
+ .compile()
+ )
+
+ subgraph_messages: list[list[str]] = []
+ call_count = 0
+
+ async def call_inner(state: ParentState) -> dict:
+ nonlocal call_count
+ call_count += 1
+ topic = "apples" if call_count == 1 else "bananas"
+ resp = await inner.ainvoke(
+ {"messages": [HumanMessage(content=f"tell me about {topic}")]}
+ )
+ subgraph_messages.append([m.text for m in resp["messages"]])
+ return {"result": resp["messages"][-1].text}
+
+ parent = (
+ StateGraph(ParentState)
+ .add_node("call_inner", call_inner)
+ .add_edge(START, "call_inner")
+ .compile(checkpointer=async_checkpointer)
+ )
+ config = {"configurable": {"thread_id": str(uuid4())}}
+
+ # First invoke+resume cycle
+ result = await parent.ainvoke({"result": ""}, config)
+ assert result == {
+ "result": "",
+ "__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
+ }
+ result = await parent.ainvoke(Command(resume=True), config)
+ assert result == {"result": "Done"}
+
+ # Second invoke+resume cycle
+ result = await parent.ainvoke({"result": ""}, config)
+ assert result == {
+ "result": "",
+ "__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
+ }
+ result = await parent.ainvoke(Command(resume=True), config)
+ assert result == {"result": "Done"}
+
+ # Both invocations produce fresh history — no memory of prior call
+ assert subgraph_messages[0] == [
+ "tell me about apples",
+ "Processing: tell me about apples",
+ "Done",
+ ]
+ assert subgraph_messages[1] == [
+ "tell me about bananas",
+ "Processing: tell me about bananas",
+ "Done",
+ ]
+
+
+# -- checkpointer=False --
+
+
+@NEEDS_CONTEXTVARS
+async def test_checkpointer_false_no_persistence_async(
+ async_checkpointer: BaseCheckpointSaver,
+) -> None:
+ """Tests that a subgraph compiled with checkpointer=False gets no
+ persistence at all, even when the parent graph has a checkpointer. Unlike
+ the default (checkpointer=None) which inherits just enough from the parent
+ to support interrupt/resume, checkpointer=False explicitly opts out of all
+ checkpoint behavior. Each invocation starts completely fresh.
+ """
+
+ # Build a simple echo subgraph with checkpointer=False
+ def echo(state: MessagesState) -> dict:
+ return {
+ "messages": [AIMessage(content=f"Processed: {state['messages'][-1].text}")]
+ }
+
+ inner = (
+ StateGraph(MessagesState)
+ .add_node("echo", echo)
+ .add_edge(START, "echo")
+ .compile(checkpointer=False)
+ )
+
+ subgraph_messages: list[list[str]] = []
+ call_count = 0
+
+ async def call_inner(state: ParentState) -> dict:
+ nonlocal call_count
+ call_count += 1
+ topic = "apples" if call_count == 1 else "bananas"
+ resp = await inner.ainvoke(
+ {"messages": [HumanMessage(content=f"tell me about {topic}")]}
+ )
+ subgraph_messages.append([m.text for m in resp["messages"]])
+ return {"result": resp["messages"][-1].text}
+
+ parent = (
+ StateGraph(ParentState)
+ .add_node("call_inner", call_inner)
+ .add_edge(START, "call_inner")
+ .compile(checkpointer=async_checkpointer)
+ )
+ config = {"configurable": {"thread_id": str(uuid4())}}
+
+ result1 = await parent.ainvoke({"result": ""}, config)
+ assert result1 == {"result": "Processed: tell me about apples"}
+
+ result2 = await parent.ainvoke({"result": ""}, config)
+ assert result2 == {"result": "Processed: tell me about bananas"}
+
+ # Both start fresh — no history from first call
+ assert subgraph_messages[0] == [
+ "tell me about apples",
+ "Processed: tell me about apples",
+ ]
+ assert subgraph_messages[1] == [
+ "tell me about bananas",
+ "Processed: tell me about bananas",
+ ]
+
+
+# -- checkpointer=True (stateful) --
+
+
+@NEEDS_CONTEXTVARS
+async def test_stateful_state_accumulates_async(
+ async_checkpointer: BaseCheckpointSaver,
+) -> None:
+ """Tests that a subgraph compiled with checkpointer=True ("stateful")
+ retains its message history across separate parent invocations. To enable
+ this, the subgraph is wrapped in an outer graph compiled with
+ checkpointer=True — this wrapper gives the inner subgraph its own persistent
+ checkpoint namespace. After two parent calls, the second subgraph invocation
+ should see messages from both the first and second calls.
+ """
+
+ # Build a simple echo subgraph
+ def echo(state: MessagesState) -> dict:
+ return {
+ "messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")]
+ }
+
+ inner = (
+ StateGraph(MessagesState)
+ .add_node("echo", echo)
+ .add_edge(START, "echo")
+ .compile()
+ )
+
+ # Wrap the inner subgraph with checkpointer=True to enable stateful.
+ # The wrapper graph gives the subgraph its own persistent checkpoint
+ # namespace, keyed by the node name ("agent").
+ wrapper = (
+ StateGraph(MessagesState)
+ .add_node("agent", inner)
+ .add_edge(START, "agent")
+ .compile(checkpointer=True)
+ )
+
+ subgraph_messages: list[list[str]] = []
+ topics = ["apples", "bananas"]
+
+ async def call_inner(state: ParentState) -> dict:
+ topic = topics[len(subgraph_messages)]
+ resp = await wrapper.ainvoke(
+ {"messages": [HumanMessage(content=f"tell me about {topic}")]}
+ )
+ subgraph_messages.append([m.text for m in resp["messages"]])
+ return {"result": resp["messages"][-1].text}
+
+ parent = (
+ StateGraph(ParentState)
+ .add_node("call_inner", call_inner)
+ .add_edge(START, "call_inner")
+ .compile(checkpointer=async_checkpointer)
+ )
+ config = {"configurable": {"thread_id": str(uuid4())}}
+
+ result1 = await parent.ainvoke({"result": ""}, config)
+ assert result1 == {"result": "Processing: tell me about apples"}
+
+ result2 = await parent.ainvoke({"result": ""}, config)
+ assert result2 == {"result": "Processing: tell me about bananas"}
+
+ # First call: fresh history
+ assert subgraph_messages[0] == [
+ "tell me about apples",
+ "Processing: tell me about apples",
+ ]
+ # Second call: retains messages from first call
+ assert subgraph_messages[1] == [
+ "tell me about apples",
+ "Processing: tell me about apples",
+ "tell me about bananas",
+ "Processing: tell me about bananas",
+ ]
+
+
+@NEEDS_CONTEXTVARS
+async def test_stateful_state_accumulates_with_interrupt_async(
+ async_checkpointer: BaseCheckpointSaver,
+) -> None:
+ """Tests that a stateful subgraph (checkpointer=True) retains its
+ message history across parent invocations even when interrupt/resume is
+ involved. The subgraph interrupts before echoing, then responds "Done".
+ After two invoke+resume cycles, the second run should contain the full
+ accumulated history from both calls.
+ """
+
+ # Build a subgraph that interrupts before echoing, then responds "Done"
+ def process(state: MessagesState) -> dict:
+ interrupt("continue?")
+ return {
+ "messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")]
+ }
+
+ def respond(state: MessagesState) -> dict:
+ return {"messages": [AIMessage(content="Done")]}
+
+ inner = (
+ StateGraph(MessagesState)
+ .add_node("process", process)
+ .add_node("respond", respond)
+ .add_edge(START, "process")
+ .add_edge("process", "respond")
+ .compile()
+ )
+
+ # Wrap with checkpointer=True for stateful
+ wrapper = (
+ StateGraph(MessagesState)
+ .add_node("agent", inner)
+ .add_edge(START, "agent")
+ .compile(checkpointer=True)
+ )
+
+ subgraph_messages: list[list[str]] = []
+ topics = ["apples", "bananas"]
+
+ async def call_inner(state: ParentState) -> dict:
+ topic = topics[len(subgraph_messages)]
+ resp = await wrapper.ainvoke(
+ {"messages": [HumanMessage(content=f"tell me about {topic}")]}
+ )
+ subgraph_messages.append([m.text for m in resp["messages"]])
+ return {"result": resp["messages"][-1].text}
+
+ parent = (
+ StateGraph(ParentState)
+ .add_node("call_inner", call_inner)
+ .add_edge(START, "call_inner")
+ .compile(checkpointer=async_checkpointer)
+ )
+ config = {"configurable": {"thread_id": str(uuid4())}}
+
+ # First invoke+resume cycle
+ result = await parent.ainvoke({"result": ""}, config)
+ assert result == {
+ "result": "",
+ "__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
+ }
+ result = await parent.ainvoke(Command(resume=True), config)
+ assert result == {"result": "Done"}
+
+ # Second invoke+resume cycle
+ result = await parent.ainvoke({"result": ""}, config)
+ assert result == {
+ "result": "",
+ "__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
+ }
+ result = await parent.ainvoke(Command(resume=True), config)
+ assert result == {"result": "Done"}
+
+ # First call: fresh history
+ assert subgraph_messages[0] == [
+ "tell me about apples",
+ "Processing: tell me about apples",
+ "Done",
+ ]
+ # Second call: retains messages from first call
+ assert subgraph_messages[1] == [
+ "tell me about apples",
+ "Processing: tell me about apples",
+ "Done",
+ "tell me about bananas",
+ "Processing: tell me about bananas",
+ "Done",
+ ]
+
+
+@NEEDS_CONTEXTVARS
+async def test_stateful_interrupt_resume_async(
+ async_checkpointer: BaseCheckpointSaver,
+) -> None:
+ """Tests that a stateful subgraph (checkpointer=True) correctly
+ supports interrupt/resume while also accumulating state. Each invoke+resume
+ pair triggers the subgraph, and after the second pair completes we verify
+ both the per-step invoke outputs and the accumulated message history. This
+ exercises the full lifecycle: interrupt, resume, state accumulation.
+ """
+
+ # Build a subgraph that interrupts before echoing, then responds "Done"
+ def process(state: MessagesState) -> dict:
+ interrupt("continue?")
+ return {
+ "messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")]
+ }
+
+ def respond(state: MessagesState) -> dict:
+ return {"messages": [AIMessage(content="Done")]}
+
+ inner = (
+ StateGraph(MessagesState)
+ .add_node("process", process)
+ .add_node("respond", respond)
+ .add_edge(START, "process")
+ .add_edge("process", "respond")
+ .compile()
+ )
+
+ # Wrap with checkpointer=True for stateful
+ wrapper = (
+ StateGraph(MessagesState)
+ .add_node("agent", inner)
+ .add_edge(START, "agent")
+ .compile(checkpointer=True)
+ )
+
+ subgraph_messages: list[list[str]] = []
+ topics = ["apples", "bananas"]
+
+ async def call_inner(state: ParentState) -> dict:
+ topic = topics[len(subgraph_messages)]
+ resp = await wrapper.ainvoke(
+ {"messages": [HumanMessage(content=f"tell me about {topic}")]}
+ )
+ subgraph_messages.append([m.text for m in resp["messages"]])
+ return {"result": resp["messages"][-1].text}
+
+ parent = (
+ StateGraph(ParentState)
+ .add_node("call_inner", call_inner)
+ .add_edge(START, "call_inner")
+ .compile(checkpointer=async_checkpointer)
+ )
+ config = {"configurable": {"thread_id": str(uuid4())}}
+
+ # First invocation: hits interrupt
+ result = await parent.ainvoke({"result": ""}, config)
+ assert result == {
+ "result": "",
+ "__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
+ }
+
+ # Resume: completes first call
+ result = await parent.ainvoke(Command(resume=True), config)
+ assert result == {"result": "Done"}
+ assert subgraph_messages[0] == [
+ "tell me about apples",
+ "Processing: tell me about apples",
+ "Done",
+ ]
+
+ # Second invocation: hits interrupt, state accumulated from first call
+ result = await parent.ainvoke({"result": ""}, config)
+ assert result == {
+ "result": "",
+ "__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
+ }
+
+ # Resume: completes second call with accumulated state
+ result = await parent.ainvoke(Command(resume=True), config)
+ assert result == {"result": "Done"}
+ assert subgraph_messages[1] == [
+ "tell me about apples",
+ "Processing: tell me about apples",
+ "Done",
+ "tell me about bananas",
+ "Processing: tell me about bananas",
+ "Done",
+ ]
+
+
+@NEEDS_CONTEXTVARS
+async def test_stateful_namespace_isolation_async(
+ async_checkpointer: BaseCheckpointSaver,
+) -> None:
+ """Tests that two different stateful subgraphs (checkpointer=True)
+ maintain completely independent state when they use different wrapper node
+ names. A "fruit_agent" and "veggie_agent" are each wrapped in their own
+ stateful graph. After two parent invocations, each agent should only
+ see its own accumulated history with no cross-contamination between them.
+ """
+
+ # Build two simple echo subgraphs with different prefixes
+ def fruit_echo(state: MessagesState) -> dict:
+ return {"messages": [AIMessage(content=f"Fruit: {state['messages'][-1].text}")]}
+
+ def veggie_echo(state: MessagesState) -> dict:
+ return {
+ "messages": [AIMessage(content=f"Veggie: {state['messages'][-1].text}")]
+ }
+
+ fruit_inner = (
+ StateGraph(MessagesState)
+ .add_node("echo", fruit_echo)
+ .add_edge(START, "echo")
+ .compile()
+ )
+ veggie_inner = (
+ StateGraph(MessagesState)
+ .add_node("echo", veggie_echo)
+ .add_edge(START, "echo")
+ .compile()
+ )
+
+ # Wrap each with checkpointer=True, using different node names to get
+ # independent checkpoint namespaces
+ fruit = (
+ StateGraph(MessagesState)
+ .add_node("fruit_agent", fruit_inner)
+ .add_edge(START, "fruit_agent")
+ .compile(checkpointer=True)
+ )
+ veggie = (
+ StateGraph(MessagesState)
+ .add_node("veggie_agent", veggie_inner)
+ .add_edge(START, "veggie_agent")
+ .compile(checkpointer=True)
+ )
+
+ fruit_msgs: list[list[str]] = []
+ veggie_msgs: list[list[str]] = []
+ call_count = 0
+
+ async def call_both(state: ParentState) -> dict:
+ nonlocal call_count
+ call_count += 1
+ suffix = "round 1" if call_count == 1 else "round 2"
+ f = await fruit.ainvoke(
+ {"messages": [HumanMessage(content=f"cherries {suffix}")]}
+ )
+ v = await veggie.ainvoke(
+ {"messages": [HumanMessage(content=f"broccoli {suffix}")]}
+ )
+ fruit_msgs.append([m.text for m in f["messages"]])
+ veggie_msgs.append([m.text for m in v["messages"]])
+ return {"result": f["messages"][-1].text}
+
+ parent = (
+ StateGraph(ParentState)
+ .add_node("call_both", call_both)
+ .add_edge(START, "call_both")
+ .compile(checkpointer=async_checkpointer)
+ )
+ config = {"configurable": {"thread_id": str(uuid4())}}
+
+ result1 = await parent.ainvoke({"result": ""}, config)
+ assert result1 == {"result": "Fruit: cherries round 1"}
+
+ result2 = await parent.ainvoke({"result": ""}, config)
+ assert result2 == {"result": "Fruit: cherries round 2"}
+
+ # First call: each agent sees only its own history
+ assert fruit_msgs[0] == ["cherries round 1", "Fruit: cherries round 1"]
+ assert veggie_msgs[0] == ["broccoli round 1", "Veggie: broccoli round 1"]
+
+ # Second call: each accumulated independently — no cross-contamination
+ assert fruit_msgs[1] == [
+ "cherries round 1",
+ "Fruit: cherries round 1",
+ "cherries round 2",
+ "Fruit: cherries round 2",
+ ]
+ assert veggie_msgs[1] == [
+ "broccoli round 1",
+ "Veggie: broccoli round 1",
+ "broccoli round 2",
+ "Veggie: broccoli round 2",
+ ]
From 50df7d423abebcb5a192f0a59c2952c68cb0df8c Mon Sep 17 00:00:00 2001
From: Eugene Yurtsev
Date: Thu, 26 Feb 2026 15:01:58 -0500
Subject: [PATCH 03/11] Merge commit from fork
* Patch
* Add more tests
* update idempotency tests
---------
Co-authored-by: William Fu-Hinthorn <13333726+hinthornw@users.noreply.github.com>
---
libs/checkpoint-postgres/uv.lock | 1 +
libs/checkpoint-sqlite/uv.lock | 1 +
libs/checkpoint/Makefile | 2 +-
.../langgraph/checkpoint/base/__init__.py | 37 +-
.../langgraph/checkpoint/serde/_msgpack.py | 71 +++
.../langgraph/checkpoint/serde/encrypted.py | 2 +-
.../langgraph/checkpoint/serde/jsonplus.py | 348 ++++++++++----
libs/checkpoint/pyproject.toml | 1 +
libs/checkpoint/tests/test_encrypted.py | 437 ++++++++++++++++++
libs/checkpoint/tests/test_jsonplus.py | 366 ++++++++++++++-
libs/checkpoint/tests/test_memory.py | 109 +++++
libs/checkpoint/uv.lock | 37 ++
libs/cli/langgraph_cli/schemas.py | 34 +-
libs/cli/schemas/schema.json | 22 +-
libs/cli/schemas/schema.v0.json | 22 +-
libs/langgraph/Makefile | 8 +-
libs/langgraph/bench/__main__.py | 5 +
libs/langgraph/bench/serde_allowlist.py | 81 ++++
libs/langgraph/langgraph/_internal/_serde.py | 253 ++++++++++
libs/langgraph/langgraph/func/__init__.py | 16 +-
libs/langgraph/langgraph/graph/state.py | 24 +
libs/langgraph/langgraph/pregel/main.py | 23 +
libs/langgraph/tests/conftest_checkpointer.py | 49 +-
libs/langgraph/tests/test_pydantic.py | 48 ++
libs/langgraph/tests/test_serde_allowlist.py | 159 +++++++
libs/langgraph/uv.lock | 1 +
libs/prebuilt/uv.lock | 1 +
27 files changed, 2038 insertions(+), 120 deletions(-)
create mode 100644 libs/checkpoint/langgraph/checkpoint/serde/_msgpack.py
create mode 100644 libs/checkpoint/tests/test_encrypted.py
create mode 100644 libs/langgraph/bench/serde_allowlist.py
create mode 100644 libs/langgraph/langgraph/_internal/_serde.py
create mode 100644 libs/langgraph/tests/test_serde_allowlist.py
diff --git a/libs/checkpoint-postgres/uv.lock b/libs/checkpoint-postgres/uv.lock
index d75989652..1cca56676 100644
--- a/libs/checkpoint-postgres/uv.lock
+++ b/libs/checkpoint-postgres/uv.lock
@@ -280,6 +280,7 @@ dev = [
{ name = "numpy" },
{ name = "pandas" },
{ name = "pandas-stubs", specifier = ">=2.2.2.240807" },
+ { name = "pycryptodome", specifier = ">=3.23.0" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
diff --git a/libs/checkpoint-sqlite/uv.lock b/libs/checkpoint-sqlite/uv.lock
index cc8cb0241..9bde94ddd 100644
--- a/libs/checkpoint-sqlite/uv.lock
+++ b/libs/checkpoint-sqlite/uv.lock
@@ -289,6 +289,7 @@ dev = [
{ name = "numpy" },
{ name = "pandas" },
{ name = "pandas-stubs", specifier = ">=2.2.2.240807" },
+ { name = "pycryptodome", specifier = ">=3.23.0" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
diff --git a/libs/checkpoint/Makefile b/libs/checkpoint/Makefile
index 66f7cf600..5c376936e 100644
--- a/libs/checkpoint/Makefile
+++ b/libs/checkpoint/Makefile
@@ -37,4 +37,4 @@ type:
format format_diff:
uv run ruff format $(PYTHON_FILES)
- uv run ruff check --select I --fix $(PYTHON_FILES)
+ uv run ruff check --fix $(PYTHON_FILES)
diff --git a/libs/checkpoint/langgraph/checkpoint/base/__init__.py b/libs/checkpoint/langgraph/checkpoint/base/__init__.py
index 5b59942e6..33495d5e3 100644
--- a/libs/checkpoint/langgraph/checkpoint/base/__init__.py
+++ b/libs/checkpoint/langgraph/checkpoint/base/__init__.py
@@ -1,6 +1,8 @@
from __future__ import annotations
-from collections.abc import AsyncIterator, Iterator, Mapping, Sequence
+import copy
+import logging
+from collections.abc import AsyncIterator, Collection, Iterator, Mapping, Sequence
from typing import ( # noqa: UP035
Any,
Generic,
@@ -14,6 +16,7 @@ from langchain_core.runnables import RunnableConfig
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 (
ERROR,
@@ -25,6 +28,7 @@ from langgraph.checkpoint.serde.types import (
V = TypeVar("V", int, float, str)
PendingWrite = tuple[str, str, Any]
+logger = logging.getLogger(__name__)
# Marked as total=False to allow for future expansion.
@@ -474,6 +478,37 @@ class BaseCheckpointSaver(Generic[V]):
else:
return current + 1
+ def with_allowlist(
+ self, extra_allowlist: Collection[tuple[str, ...]]
+ ) -> BaseCheckpointSaver[V]:
+ """Return a shallow clone with a derived msgpack allowlist."""
+ serde = _with_msgpack_allowlist(self.serde, extra_allowlist)
+ if serde is self.serde:
+ return self
+ clone = copy.copy(self)
+ clone.serde = maybe_add_typed_methods(serde)
+ return clone
+
+
+def _with_msgpack_allowlist(
+ serde: SerializerProtocol, extra_allowlist: Collection[tuple[str, ...]]
+) -> SerializerProtocol:
+ if isinstance(serde, JsonPlusSerializer):
+ return serde.with_msgpack_allowlist(extra_allowlist)
+ if isinstance(serde, EncryptedSerializer):
+ inner = serde.serde
+ if isinstance(inner, JsonPlusSerializer):
+ updated_inner = inner.with_msgpack_allowlist(extra_allowlist)
+ if updated_inner is inner:
+ return serde
+ return EncryptedSerializer(serde.cipher, updated_inner)
+ logger.warning(
+ "Serializer %s does not support msgpack allowlist. "
+ "Strict msgpack deserialization will not be enforced.",
+ type(serde).__name__,
+ )
+ return serde
+
class EmptyChannelError(Exception):
"""Raised when attempting to get the value of a channel that hasn't been updated
diff --git a/libs/checkpoint/langgraph/checkpoint/serde/_msgpack.py b/libs/checkpoint/langgraph/checkpoint/serde/_msgpack.py
new file mode 100644
index 000000000..9866806d4
--- /dev/null
+++ b/libs/checkpoint/langgraph/checkpoint/serde/_msgpack.py
@@ -0,0 +1,71 @@
+import os
+from collections.abc import Iterable
+from typing import cast
+
+STRICT_MSGPACK_ENABLED = os.getenv("LANGGRAPH_STRICT_MSGPACK", "false").lower() in (
+ "1",
+ "true",
+ "yes",
+)
+
+
+_SENTINEL = cast(None, object())
+
+SAFE_MSGPACK_TYPES: frozenset[tuple[str, ...]] = frozenset(
+ {
+ # datetime types
+ ("datetime", "datetime"),
+ ("datetime", "date"),
+ ("datetime", "time"),
+ ("datetime", "timedelta"),
+ ("datetime", "timezone"),
+ # uuid
+ ("uuid", "UUID"),
+ # numeric
+ ("decimal", "Decimal"),
+ # collections
+ ("builtins", "set"),
+ ("builtins", "frozenset"),
+ ("collections", "deque"),
+ # ip addresses
+ ("ipaddress", "IPv4Address"),
+ ("ipaddress", "IPv4Interface"),
+ ("ipaddress", "IPv4Network"),
+ ("ipaddress", "IPv6Address"),
+ ("ipaddress", "IPv6Interface"),
+ ("ipaddress", "IPv6Network"),
+ # pathlib
+ ("pathlib", "Path"),
+ ("pathlib", "PosixPath"),
+ ("pathlib", "WindowsPath"),
+ # pathlib in Python 3.13+
+ ("pathlib._local", "Path"),
+ ("pathlib._local", "PosixPath"),
+ ("pathlib._local", "WindowsPath"),
+ # zoneinfo
+ ("zoneinfo", "ZoneInfo"),
+ # regex
+ ("re", "compile"),
+ # langgraph
+ ("langgraph.types", "Send"),
+ ("langgraph.types", "Interrupt"),
+ ("langgraph.types", "Command"),
+ ("langgraph.types", "StateSnapshot"),
+ ("langgraph.types", "PregelTask"),
+ ("langgraph.types", "Overwrite"),
+ ("langgraph.store.base", "Item"),
+ ("langgraph.store.base", "GetOp"),
+ }
+)
+
+# Allowed (module, name, method) triples for EXT_METHOD_SINGLE_ARG.
+# Only these specific method invocations are permitted during deserialization.
+# This is separate from SAFE_MSGPACK_TYPES which only governs construction.
+SAFE_MSGPACK_METHODS: frozenset[tuple[str, str, str]] = frozenset(
+ {
+ ("datetime", "datetime", "fromisoformat"),
+ }
+)
+
+
+AllowedMsgpackModules = Iterable[tuple[str, ...] | type]
diff --git a/libs/checkpoint/langgraph/checkpoint/serde/encrypted.py b/libs/checkpoint/langgraph/checkpoint/serde/encrypted.py
index 829c7dd2a..9f517e405 100644
--- a/libs/checkpoint/langgraph/checkpoint/serde/encrypted.py
+++ b/libs/checkpoint/langgraph/checkpoint/serde/encrypted.py
@@ -41,7 +41,7 @@ class EncryptedSerializer(SerializerProtocol):
) -> "EncryptedSerializer":
"""Create an `EncryptedSerializer` using AES encryption."""
try:
- from Crypto.Cipher import AES # type: ignore
+ from Crypto.Cipher import AES
except ImportError:
raise ImportError(
"Pycryptodome is not installed. Please install it with `pip install pycryptodome`."
diff --git a/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py b/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py
index c4b550308..c267ac8be 100644
--- a/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py
+++ b/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py
@@ -10,7 +10,7 @@ import pickle
import re
import sys
from collections import deque
-from collections.abc import Callable, Sequence
+from collections.abc import Callable, Iterable, Sequence
from datetime import date, datetime, time, timedelta, timezone
from enum import Enum
from inspect import isclass
@@ -22,17 +22,24 @@ from ipaddress import (
IPv6Interface,
IPv6Network,
)
-from typing import Any, Literal
+from typing import TYPE_CHECKING, Any, Literal, cast
from uuid import UUID
from zoneinfo import ZoneInfo
import ormsgpack
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.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""
logger = logging.getLogger(__name__)
@@ -53,19 +60,59 @@ class JsonPlusSerializer(SerializerProtocol):
self,
*,
pickle_fallback: bool = False,
- allowed_json_modules: Sequence[tuple[str, ...]] | Literal[True] | None = None,
+ allowed_json_modules: Iterable[tuple[str, ...]] | Literal[True] | None = None,
+ allowed_msgpack_modules: (
+ AllowedMsgpackModules | Literal[True] | None
+ ) = _lg_msgpack._SENTINEL,
__unpack_ext_hook__: Callable[[int, bytes], Any] | None = None,
) -> None:
+ if allowed_msgpack_modules is _lg_msgpack._SENTINEL:
+ if _lg_msgpack.STRICT_MSGPACK_ENABLED:
+ allowed_msgpack_modules = None
+ else:
+ allowed_msgpack_modules = True
self.pickle_fallback = pickle_fallback
- self._allowed_modules = (
- {mod_and_name for mod_and_name in allowed_json_modules}
- if allowed_json_modules and allowed_json_modules is not True
- else (allowed_json_modules if allowed_json_modules is True else None)
+ self._allowed_json_modules: set[tuple[str, ...]] | Literal[True] | None = (
+ _normalize_allowlist(allowed_json_modules)
)
+ self._allowed_msgpack_modules = _normalize_allowlist(allowed_msgpack_modules)
+
+ self._custom_unpack_ext_hook = __unpack_ext_hook__ is not None
self._unpack_ext_hook = (
__unpack_ext_hook__
if __unpack_ext_hook__ is not None
- else _msgpack_ext_hook
+ else _create_msgpack_ext_hook(self._allowed_msgpack_modules)
+ )
+
+ def with_msgpack_allowlist(
+ self, extra_allowlist: Iterable[tuple[str, ...] | type]
+ ) -> JsonPlusSerializer:
+ """Return a new serializer with a merged msgpack allowlist."""
+ base_allowlist = self._allowed_msgpack_modules
+ if base_allowlist is True or base_allowlist is False:
+ return self
+ elif base_allowlist:
+ base_allowlist = set(base_allowlist)
+ else:
+ base_allowlist = set()
+ extra = _normalize_module_keys(tuple(extra_allowlist))
+ merged = base_allowlist | extra
+ if merged == base_allowlist:
+ return self
+ allowed_msgpack_modules: AllowedMsgpackModules | Literal[True] | None
+ if merged:
+ allowed_msgpack_modules = tuple(merged)
+ elif isinstance(self._allowed_msgpack_modules, set):
+ allowed_msgpack_modules = tuple(self._allowed_msgpack_modules)
+ else:
+ allowed_msgpack_modules = self._allowed_msgpack_modules
+ return self.__class__(
+ pickle_fallback=self.pickle_fallback,
+ allowed_json_modules=self._allowed_json_modules,
+ allowed_msgpack_modules=allowed_msgpack_modules,
+ __unpack_ext_hook__=(
+ self._unpack_ext_hook if self._custom_unpack_ext_hook else None
+ ),
)
def _encode_constructor_args(
@@ -90,7 +137,7 @@ class JsonPlusSerializer(SerializerProtocol):
return out
def _reviver(self, value: dict[str, Any]) -> Any:
- if self._allowed_modules and (
+ if self._allowed_json_modules and (
value.get("lc", None) == 2
and value.get("type", None) == "constructor"
and value.get("id", None) is not None
@@ -107,7 +154,7 @@ class JsonPlusSerializer(SerializerProtocol):
return LC_REVIVER(value)
def _revive_lc2(self, value: dict[str, Any]) -> Any:
- self._check_allowed_modules(value)
+ self._check_allowed_json_modules(value)
[*module, name] = value["id"]
try:
@@ -139,7 +186,7 @@ class JsonPlusSerializer(SerializerProtocol):
except Exception:
return None
- def _check_allowed_modules(self, value: dict[str, Any]) -> None:
+ def _check_allowed_json_modules(self, value: dict[str, Any]) -> None:
needed = tuple(value["id"])
method = value.get("method")
if isinstance(method, list):
@@ -150,7 +197,7 @@ class JsonPlusSerializer(SerializerProtocol):
method_display = ""
dotted = ".".join(needed)
- if not self._allowed_modules:
+ if not self._allowed_json_modules:
raise InvalidModuleError(
f"Refused to deserialize JSON constructor: {dotted} (method: {method_display}). "
"No allowed_json_modules configured.\n\n"
@@ -161,9 +208,9 @@ class JsonPlusSerializer(SerializerProtocol):
"or plain-JSON representations revived without import-time side effects."
)
- if self._allowed_modules is True:
+ if self._allowed_json_modules is True:
return
- if needed in self._allowed_modules:
+ if needed in self._allowed_json_modules:
return
raise InvalidModuleError(
@@ -448,92 +495,174 @@ def _msgpack_default(obj: Any) -> str | ormsgpack.Ext:
raise TypeError(f"Object of type {obj.__class__.__name__} is not serializable")
-def _msgpack_ext_hook(code: int, data: bytes) -> Any:
- if code == EXT_CONSTRUCTOR_SINGLE_ARG:
- try:
- tup = ormsgpack.unpackb(
- data, ext_hook=_msgpack_ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
- )
- # module, name, arg
- return getattr(importlib.import_module(tup[0]), tup[1])(tup[2])
- except Exception:
- return
- elif code == EXT_CONSTRUCTOR_POS_ARGS:
- try:
- tup = ormsgpack.unpackb(
- data, ext_hook=_msgpack_ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
- )
- # module, name, args
- return getattr(importlib.import_module(tup[0]), tup[1])(*tup[2])
- except Exception:
- return
- elif code == EXT_CONSTRUCTOR_KW_ARGS:
- try:
- tup = ormsgpack.unpackb(
- data, ext_hook=_msgpack_ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
- )
- # module, name, args
- return getattr(importlib.import_module(tup[0]), tup[1])(**tup[2])
- except Exception:
- return
- elif code == EXT_METHOD_SINGLE_ARG:
- try:
- tup = ormsgpack.unpackb(
- data, ext_hook=_msgpack_ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
- )
- # module, name, arg, method
- return getattr(getattr(importlib.import_module(tup[0]), tup[1]), tup[3])(
- tup[2]
- )
- except Exception:
- return
- elif code == EXT_PYDANTIC_V1:
- try:
- tup = ormsgpack.unpackb(
- data, ext_hook=_msgpack_ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
- )
- # module, name, kwargs
- cls = getattr(importlib.import_module(tup[0]), tup[1])
- try:
- return cls(**tup[2])
- except Exception:
- return cls.construct(**tup[2])
- except Exception:
- # for pydantic objects we can't find/reconstruct
- # let's return the kwargs dict instead
- try:
- return tup[2]
- except NameError:
- return
- elif code == EXT_PYDANTIC_V2:
- try:
- tup = ormsgpack.unpackb(
- data, ext_hook=_msgpack_ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
- )
- # module, name, kwargs, method
- cls = getattr(importlib.import_module(tup[0]), tup[1])
- try:
- return cls(**tup[2])
- except Exception:
- return cls.model_construct(**tup[2])
- except Exception:
- # for pydantic objects we can't find/reconstruct
- # let's return the kwargs dict instead
- try:
- return tup[2]
- except NameError:
- return
- elif code == EXT_NUMPY_ARRAY:
- try:
- import numpy as _np
+def _create_msgpack_ext_hook(
+ allowed_modules: set[tuple[str, ...]] | Literal[True] | None,
+) -> Callable[[int, bytes], Any]:
+ """Create msgpack ext hook with allowlist.
- dtype_str, shape, order, buf = ormsgpack.unpackb(
- data, ext_hook=_msgpack_ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
+ Args:
+ allowed_modules: Set of (module, name) tuples that are allowed to be
+ deserialized, or True to allow all with warnings for unregistered types, or None to only allow safe types.
+
+ Returns:
+ An ext_hook function for use with ormsgpack.unpackb.
+ """
+
+ def _check_allowed(module: str, name: str) -> bool:
+ """Check if type is allowed. Returns True if allowed, False if blocked."""
+ key = (module, name)
+
+ if key in _lg_msgpack.SAFE_MSGPACK_TYPES:
+ return True
+
+ if allowed_modules is True:
+ # default is to warn but allow unregistered types
+ logger.warning(
+ "Deserializing unregistered type %s.%s from checkpoint. "
+ "This will be blocked in a future version. "
+ "Add to allowed_msgpack_modules to silence: [(%r, %r)]",
+ module,
+ name,
+ module,
+ name,
)
- arr = _np.frombuffer(buf, dtype=_np.dtype(dtype_str))
- return arr.reshape(shape, order=order)
- except Exception:
- return
+ return True
+ if allowed_modules is not None:
+ if key in allowed_modules:
+ return True
+ # strict mode blocks unregistered types
+ logger.warning(
+ "Blocked deserialization of %s.%s - not in allowed_msgpack_modules. "
+ "Add to allowed_msgpack_modules to allow: [(%r, %r)]",
+ module,
+ name,
+ module,
+ name,
+ )
+ return False
+
+ def _check_allowed_method(module: str, name: str, method: str) -> bool:
+ """Check if a method invocation is allowed."""
+ key = (module, name, method)
+ if key in _lg_msgpack.SAFE_MSGPACK_METHODS:
+ return True
+ logger.warning(
+ "Blocked deserialization of method call %s.%s.%s - "
+ "not in allowed methods set.",
+ module,
+ name,
+ method,
+ )
+ return False
+
+ def ext_hook(code: int, data: bytes) -> Any:
+ if code == EXT_CONSTRUCTOR_SINGLE_ARG:
+ try:
+ tup = ormsgpack.unpackb(
+ data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
+ )
+ if not _check_allowed(tup[0], tup[1]):
+ # We default to returning the raw data. If the user
+ # is using this in the context of a pydantic state, etc., then
+ # it would be validated upon construction.
+ return tup[2]
+ # module, name, arg
+ return getattr(importlib.import_module(tup[0]), tup[1])(tup[2])
+ except Exception:
+ return None
+ elif code == EXT_CONSTRUCTOR_POS_ARGS:
+ try:
+ tup = ormsgpack.unpackb(
+ data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
+ )
+ if not _check_allowed(tup[0], tup[1]):
+ return tup[2]
+ # module, name, args
+ return getattr(importlib.import_module(tup[0]), tup[1])(*tup[2])
+ except Exception:
+ return None
+ elif code == EXT_CONSTRUCTOR_KW_ARGS:
+ try:
+ tup = ormsgpack.unpackb(
+ data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
+ )
+ if not _check_allowed(tup[0], tup[1]):
+ return tup[2]
+ # module, name, kwargs
+ return getattr(importlib.import_module(tup[0]), tup[1])(**tup[2])
+ except Exception:
+ return None
+ elif code == EXT_METHOD_SINGLE_ARG:
+ try:
+ tup = ormsgpack.unpackb(
+ data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
+ )
+ if not _check_allowed_method(tup[0], tup[1], tup[3]):
+ return tup[2]
+ # module, name, arg, method
+ return getattr(
+ getattr(importlib.import_module(tup[0]), tup[1]), tup[3]
+ )(tup[2])
+ except Exception:
+ return None
+ elif code == EXT_PYDANTIC_V1:
+ try:
+ tup = ormsgpack.unpackb(
+ data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
+ )
+ if not _check_allowed(tup[0], tup[1]):
+ return tup[2]
+ # module, name, kwargs
+ cls = getattr(importlib.import_module(tup[0]), tup[1])
+ try:
+ return cls(**tup[2])
+ except Exception:
+ return cls.construct(**tup[2])
+ except Exception:
+ # for pydantic objects we can't find/reconstruct
+ # let's return the kwargs dict instead
+ try:
+ return tup[2]
+ except NameError:
+ return None
+ elif code == EXT_PYDANTIC_V2:
+ try:
+ tup = ormsgpack.unpackb(
+ data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
+ )
+ if not _check_allowed(tup[0], tup[1]):
+ return tup[2]
+ # module, name, kwargs, method
+ cls = getattr(importlib.import_module(tup[0]), tup[1])
+ try:
+ return cls(**tup[2])
+ except Exception:
+ return cls.model_construct(**tup[2])
+ except Exception:
+ # for pydantic objects we can't find/reconstruct
+ # let's return the kwargs dict instead
+ try:
+ return tup[2]
+ except NameError:
+ return None
+ elif code == EXT_NUMPY_ARRAY:
+ try:
+ import numpy as _np
+
+ dtype_str, shape, order, buf = ormsgpack.unpackb(
+ data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
+ )
+ arr = _np.frombuffer(buf, dtype=_np.dtype(dtype_str))
+ return arr.reshape(shape, order=order)
+ except Exception:
+ return None
+ return None
+
+ return ext_hook
+
+
+# Aliasing in case anyone imported it directly
+_msgpack_ext_hook = _create_msgpack_ext_hook(allowed_modules=None)
def _msgpack_ext_hook_to_json(code: int, data: bytes) -> Any:
@@ -648,3 +777,26 @@ _option = (
def _msgpack_enc(data: Any) -> bytes:
return ormsgpack.packb(data, default=_msgpack_default, option=_option)
+
+
+def _normalize_allowlist(
+ allowlist: AllowedMsgpackModules | Literal[True] | None,
+) -> set[tuple[str, ...]] | Literal[True] | None:
+ if allowlist is True:
+ return allowlist
+ elif allowlist:
+ return _normalize_module_keys(allowlist)
+ else:
+ return None
+
+
+def _normalize_module_keys(
+ modules: AllowedMsgpackModules,
+) -> set[tuple[str, ...]]:
+ normalized: set[tuple[str, ...]] = set()
+ for module in modules:
+ if isclass(module):
+ normalized.add((module.__module__, module.__name__))
+ else:
+ normalized.add(cast(tuple[str, ...], module))
+ return normalized
diff --git a/libs/checkpoint/pyproject.toml b/libs/checkpoint/pyproject.toml
index 71d104de3..df8c1619f 100644
--- a/libs/checkpoint/pyproject.toml
+++ b/libs/checkpoint/pyproject.toml
@@ -42,6 +42,7 @@ lint = [
dev = [
{include-group = "test"},
{include-group = "lint"},
+ "pycryptodome>=3.23.0",
]
[tool.hatch.build.targets.wheel]
diff --git a/libs/checkpoint/tests/test_encrypted.py b/libs/checkpoint/tests/test_encrypted.py
new file mode 100644
index 000000000..696b93d32
--- /dev/null
+++ b/libs/checkpoint/tests/test_encrypted.py
@@ -0,0 +1,437 @@
+"""Tests for EncryptedSerializer with msgpack allowlist functionality.
+
+These tests mirror the msgpack allowlist tests in test_jsonplus.py but run them
+through the EncryptedSerializer to ensure the allowlist behavior is preserved
+when encryption is enabled.
+"""
+
+from __future__ import annotations
+
+import logging
+import pathlib
+import re
+import uuid
+from collections import deque
+from datetime import date, datetime, time, timezone
+from decimal import Decimal
+from ipaddress import IPv4Address
+from typing import Literal, cast
+
+import ormsgpack
+import pytest
+from pydantic import BaseModel
+
+from langgraph.checkpoint.base import BaseCheckpointSaver, _with_msgpack_allowlist
+from langgraph.checkpoint.serde import _msgpack as _lg_msgpack
+from langgraph.checkpoint.serde.base import CipherProtocol
+from langgraph.checkpoint.serde.encrypted import EncryptedSerializer
+from langgraph.checkpoint.serde.jsonplus import (
+ EXT_METHOD_SINGLE_ARG,
+ JsonPlusSerializer,
+ _msgpack_enc,
+)
+
+
+class InnerPydantic(BaseModel):
+ hello: str
+
+
+class MyPydantic(BaseModel):
+ foo: str
+ bar: int
+ inner: InnerPydantic
+
+
+class AnotherPydantic(BaseModel):
+ foo: str
+
+
+class _PassthroughCipher(CipherProtocol):
+ def encrypt(self, plaintext: bytes) -> tuple[str, bytes]:
+ return "passthrough", plaintext
+
+ def decrypt(self, ciphername: str, ciphertext: bytes) -> bytes:
+ assert ciphername == "passthrough"
+ return ciphertext
+
+
+def _make_encrypted_serde(
+ allowed_msgpack_modules: (
+ _lg_msgpack.AllowedMsgpackModules | Literal[True] | None | object
+ ) = _lg_msgpack._SENTINEL,
+) -> EncryptedSerializer:
+ """Create an EncryptedSerializer with AES encryption for testing."""
+ inner = JsonPlusSerializer(
+ allowed_msgpack_modules=cast(
+ _lg_msgpack.AllowedMsgpackModules | Literal[True] | None,
+ allowed_msgpack_modules,
+ )
+ )
+ return EncryptedSerializer.from_pycryptodome_aes(
+ serde=inner, key=b"1234567890123456"
+ )
+
+
+def test_msgpack_method_pathlib_blocked_encrypted_strict(
+ tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture
+) -> None:
+ target = tmp_path / "secret.txt"
+ target.write_text("secret")
+ payload = ormsgpack.packb(
+ ormsgpack.Ext(
+ EXT_METHOD_SINGLE_ARG,
+ _msgpack_enc(("pathlib", "Path", target, "read_text")),
+ ),
+ option=ormsgpack.OPT_NON_STR_KEYS,
+ )
+ serde = EncryptedSerializer(
+ _PassthroughCipher(),
+ JsonPlusSerializer(allowed_msgpack_modules=None),
+ )
+
+ caplog.set_level(logging.WARNING, logger="langgraph.checkpoint.serde.jsonplus")
+ caplog.clear()
+ result = serde.loads_typed(("msgpack+passthrough", payload))
+
+ assert result == target
+ assert "blocked deserialization of method call pathlib.path.read_text" in (
+ caplog.text.lower()
+ )
+
+
+class TestEncryptedSerializerMsgpackAllowlist:
+ """Test msgpack allowlist behavior through EncryptedSerializer."""
+
+ def test_safe_types_no_warning(self, caplog: pytest.LogCaptureFixture) -> None:
+ """Test safe types deserialize without warnings through encryption."""
+ serde = _make_encrypted_serde()
+
+ safe_objects = [
+ datetime.now(),
+ date.today(),
+ time(12, 30),
+ timezone.utc,
+ uuid.uuid4(),
+ Decimal("123.45"),
+ {1, 2, 3},
+ frozenset([1, 2, 3]),
+ deque([1, 2, 3]),
+ IPv4Address("192.168.1.1"),
+ pathlib.Path("/tmp/test"),
+ ]
+
+ for obj in safe_objects:
+ caplog.clear()
+ dumped = serde.dumps_typed(obj)
+ # Verify encryption is happening
+ assert "+aes" in dumped[0], f"Expected encryption for {type(obj)}"
+ result = serde.loads_typed(dumped)
+ assert "unregistered type" not in caplog.text.lower(), (
+ f"Unexpected warning for {type(obj)}"
+ )
+ assert result is not None
+
+ def test_pydantic_warns_by_default(self, caplog: pytest.LogCaptureFixture) -> None:
+ """Pydantic models not in allowlist should log warning but still deserialize."""
+ current = _lg_msgpack.STRICT_MSGPACK_ENABLED
+ _lg_msgpack.STRICT_MSGPACK_ENABLED = False
+ serde = _make_encrypted_serde()
+
+ obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world"))
+
+ caplog.clear()
+ dumped = serde.dumps_typed(obj)
+ assert "+aes" in dumped[0]
+ result = serde.loads_typed(dumped)
+
+ assert "unregistered type" in caplog.text.lower()
+ assert "allowed_msgpack_modules" in caplog.text
+ assert result == obj
+ _lg_msgpack.STRICT_MSGPACK_ENABLED = current
+
+ def test_strict_mode_blocks_unregistered(
+ self, caplog: pytest.LogCaptureFixture
+ ) -> None:
+ """Strict mode should block unregistered types through encryption."""
+ serde = _make_encrypted_serde(allowed_msgpack_modules=None)
+
+ obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world"))
+
+ caplog.clear()
+ dumped = serde.dumps_typed(obj)
+ assert "+aes" in dumped[0]
+ result = serde.loads_typed(dumped)
+
+ assert "blocked" in caplog.text.lower()
+ expected = obj.model_dump()
+ assert result == expected
+
+ def test_allowlist_silences_warning(self, caplog: pytest.LogCaptureFixture) -> None:
+ """Types in allowed_msgpack_modules should deserialize without warnings."""
+ serde = _make_encrypted_serde(
+ allowed_msgpack_modules=[
+ ("tests.test_encrypted", "MyPydantic"),
+ ("tests.test_encrypted", "InnerPydantic"),
+ ]
+ )
+
+ obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world"))
+
+ caplog.clear()
+ dumped = serde.dumps_typed(obj)
+ assert "+aes" in dumped[0]
+ result = serde.loads_typed(dumped)
+
+ assert "unregistered type" not in caplog.text.lower()
+ assert "blocked" not in caplog.text.lower()
+ assert result == obj
+
+ def test_allowlist_blocks_non_listed(
+ self, caplog: pytest.LogCaptureFixture
+ ) -> None:
+ """Allowlists should block unregistered types even through encryption."""
+ serde = _make_encrypted_serde(
+ allowed_msgpack_modules=[("tests.test_encrypted", "MyPydantic")]
+ )
+
+ obj = AnotherPydantic(foo="nope")
+
+ caplog.clear()
+ dumped = serde.dumps_typed(obj)
+ assert "+aes" in dumped[0]
+ result = serde.loads_typed(dumped)
+
+ assert "blocked" in caplog.text.lower()
+ expected = obj.model_dump()
+ assert result == expected
+
+ def test_safe_types_value_equality(self, caplog: pytest.LogCaptureFixture) -> None:
+ """Verify safe types are correctly restored with proper values through encryption."""
+ serde = _make_encrypted_serde(allowed_msgpack_modules=None)
+
+ test_cases = [
+ datetime(2024, 1, 15, 12, 30, 45, 123456),
+ date(2024, 6, 15),
+ time(14, 30, 0),
+ uuid.UUID("12345678-1234-5678-1234-567812345678"),
+ Decimal("123.456789"),
+ {1, 2, 3, 4, 5},
+ frozenset(["a", "b", "c"]),
+ deque([1, 2, 3]),
+ IPv4Address("10.0.0.1"),
+ pathlib.Path("/some/test/path"),
+ re.compile(r"\d+", re.MULTILINE),
+ ]
+
+ for obj in test_cases:
+ caplog.clear()
+ dumped = serde.dumps_typed(obj)
+ assert "+aes" in dumped[0], f"Expected encryption for {type(obj)}"
+ result = serde.loads_typed(dumped)
+
+ assert "blocked" not in caplog.text.lower(), f"Blocked for {type(obj)}"
+ if isinstance(obj, re.Pattern):
+ assert result.pattern == obj.pattern
+ assert result.flags == obj.flags
+ else:
+ assert result == obj, (
+ f"Value mismatch for {type(obj)}: {result} != {obj}"
+ )
+
+ def test_regex_safe_type(self, caplog: pytest.LogCaptureFixture) -> None:
+ """re.compile patterns should deserialize without warnings as a safe type."""
+ serde = _make_encrypted_serde(allowed_msgpack_modules=None)
+ pattern = re.compile(r"foo.*bar", re.IGNORECASE | re.DOTALL)
+
+ caplog.clear()
+ dumped = serde.dumps_typed(pattern)
+ assert "+aes" in dumped[0]
+ result = serde.loads_typed(dumped)
+
+ assert "blocked" not in caplog.text.lower()
+ assert "unregistered" not in caplog.text.lower()
+ assert result.pattern == pattern.pattern
+ assert result.flags == pattern.flags
+
+
+class TestWithMsgpackAllowlistEncrypted:
+ """Test _with_msgpack_allowlist function with EncryptedSerializer."""
+
+ def test_propagates_allowlist_to_inner_serde(self) -> None:
+ """_with_msgpack_allowlist should propagate allowlist to inner JsonPlusSerializer."""
+ inner = JsonPlusSerializer(allowed_msgpack_modules=None)
+ encrypted = EncryptedSerializer.from_pycryptodome_aes(
+ serde=inner, key=b"1234567890123456"
+ )
+
+ extra = [("my.module", "MyClass")]
+ result = _with_msgpack_allowlist(encrypted, extra)
+
+ # Should return a new EncryptedSerializer
+ assert isinstance(result, EncryptedSerializer)
+ assert result is not encrypted
+ # Inner serde should have the allowlist
+ assert isinstance(result.serde, JsonPlusSerializer)
+ assert isinstance(result.serde._allowed_msgpack_modules, set)
+ assert ("my.module", "MyClass") in result.serde._allowed_msgpack_modules
+
+ def test_preserves_cipher(self) -> None:
+ """_with_msgpack_allowlist should preserve the cipher from the original."""
+ inner = JsonPlusSerializer(allowed_msgpack_modules=None)
+ encrypted = EncryptedSerializer.from_pycryptodome_aes(
+ serde=inner, key=b"1234567890123456"
+ )
+
+ result = _with_msgpack_allowlist(encrypted, [("my.module", "MyClass")])
+
+ assert isinstance(result, EncryptedSerializer)
+ # Should use the same cipher
+ assert result.cipher is encrypted.cipher
+
+ def test_returns_same_if_not_jsonplus_inner(self) -> None:
+ """_with_msgpack_allowlist should return same serde if inner is not JsonPlusSerializer."""
+
+ class DummyInnerSerde:
+ def dumps_typed(self, obj: object) -> tuple[str, bytes]:
+ return ("dummy", b"")
+
+ def loads_typed(self, data: tuple[str, bytes]) -> None:
+ return None
+
+ from langgraph.checkpoint.serde.base import CipherProtocol
+
+ class DummyCipher(CipherProtocol):
+ def encrypt(self, plaintext: bytes) -> tuple[str, bytes]:
+ return "dummy", plaintext
+
+ def decrypt(self, ciphername: str, ciphertext: bytes) -> bytes:
+ return ciphertext
+
+ encrypted = EncryptedSerializer(DummyCipher(), DummyInnerSerde())
+ result = _with_msgpack_allowlist(encrypted, [("my.module", "MyClass")])
+
+ assert result is encrypted
+
+ def test_warns_if_allowlist_unsupported(
+ self, caplog: pytest.LogCaptureFixture
+ ) -> None:
+ class DummySerde:
+ def dumps_typed(self, obj: object) -> tuple[str, bytes]:
+ return ("dummy", b"")
+
+ def loads_typed(self, data: tuple[str, bytes]) -> object:
+ return data
+
+ serde = DummySerde()
+ caplog.set_level(logging.WARNING, logger="langgraph.checkpoint.base")
+ caplog.clear()
+
+ result = _with_msgpack_allowlist(serde, [("my.module", "MyClass")])
+
+ assert result is serde
+ assert "does not support msgpack allowlist" in caplog.text.lower()
+
+ def test_noop_allowlist_returns_same_encrypted_instance(self) -> None:
+ inner = JsonPlusSerializer(allowed_msgpack_modules=None)
+ encrypted = EncryptedSerializer.from_pycryptodome_aes(
+ serde=inner, key=b"1234567890123456"
+ )
+
+ result = _with_msgpack_allowlist(encrypted, ())
+
+ assert result is encrypted
+
+ def test_functional_roundtrip_with_allowlist(
+ self, caplog: pytest.LogCaptureFixture
+ ) -> None:
+ """End-to-end test: allowlist applied via _with_msgpack_allowlist works."""
+ inner = JsonPlusSerializer(allowed_msgpack_modules=None)
+ encrypted = EncryptedSerializer.from_pycryptodome_aes(
+ serde=inner, key=b"1234567890123456"
+ )
+
+ # Apply allowlist for MyPydantic
+ updated = _with_msgpack_allowlist(
+ encrypted,
+ [
+ ("tests.test_encrypted", "MyPydantic"),
+ ("tests.test_encrypted", "InnerPydantic"),
+ ],
+ )
+
+ obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world"))
+
+ caplog.clear()
+ dumped = updated.dumps_typed(obj)
+ assert "+aes" in dumped[0]
+ result = updated.loads_typed(dumped)
+
+ # Should deserialize without blocking
+ assert "blocked" not in caplog.text.lower()
+ assert result == obj
+
+ def test_original_still_blocks_after_with_allowlist(
+ self, caplog: pytest.LogCaptureFixture
+ ) -> None:
+ """Original serde should still block after _with_msgpack_allowlist creates a new one."""
+ inner = JsonPlusSerializer(allowed_msgpack_modules=None)
+ encrypted = EncryptedSerializer.from_pycryptodome_aes(
+ serde=inner, key=b"1234567890123456"
+ )
+
+ # Apply allowlist - this should create a NEW serde
+ _with_msgpack_allowlist(
+ encrypted,
+ [("tests.test_encrypted", "MyPydantic")],
+ )
+
+ # Original should still block
+ obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world"))
+
+ caplog.clear()
+ dumped = encrypted.dumps_typed(obj)
+ result = encrypted.loads_typed(dumped)
+
+ assert "blocked" in caplog.text.lower()
+ assert result == obj.model_dump()
+
+
+class TestEncryptedSerializerUnencryptedFallback:
+ """Test that EncryptedSerializer handles unencrypted data correctly."""
+
+ def test_loads_unencrypted_data(self) -> None:
+ """EncryptedSerializer should handle unencrypted data for backwards compat."""
+ plain = JsonPlusSerializer(allowed_msgpack_modules=None)
+ encrypted = _make_encrypted_serde(allowed_msgpack_modules=None)
+
+ obj = {"key": "value", "number": 42}
+
+ # Serialize with plain serde
+ dumped = plain.dumps_typed(obj)
+ assert "+aes" not in dumped[0]
+
+ # Should still deserialize with encrypted serde
+ result = encrypted.loads_typed(dumped)
+ assert result == obj
+
+
+def test_with_allowlist_uses_copy_protocol() -> None:
+ class CopyAwareSaver(BaseCheckpointSaver[str]):
+ def __init__(self) -> None:
+ super().__init__(serde=JsonPlusSerializer(allowed_msgpack_modules=None))
+ self.copy_was_used = False
+
+ def __copy__(self) -> object:
+ clone = object.__new__(self.__class__)
+ clone.__dict__ = self.__dict__.copy()
+ clone.copy_was_used = True
+ return clone
+
+ saver = CopyAwareSaver()
+
+ updated = saver.with_allowlist([("tests.test_encrypted", "MyPydantic")])
+
+ assert isinstance(updated, CopyAwareSaver)
+ assert updated is not saver
+ assert updated.copy_was_used is True
+ assert saver.copy_was_used is False
diff --git a/libs/checkpoint/tests/test_jsonplus.py b/libs/checkpoint/tests/test_jsonplus.py
index c2ff5f2d0..e2d9690d3 100644
--- a/libs/checkpoint/tests/test_jsonplus.py
+++ b/libs/checkpoint/tests/test_jsonplus.py
@@ -1,5 +1,6 @@
import dataclasses
import json
+import logging
import pathlib
import re
import sys
@@ -13,15 +14,20 @@ from zoneinfo import ZoneInfo
import dataclasses_json
import numpy as np
+import ormsgpack
import pandas as pd
import pytest
from pydantic import BaseModel, SecretStr
from pydantic.v1 import BaseModel as BaseModelV1
from pydantic.v1 import SecretStr as SecretStrV1
+from langgraph.checkpoint.serde import _msgpack as _lg_msgpack
+from langgraph.checkpoint.serde._msgpack import AllowedMsgpackModules
from langgraph.checkpoint.serde.jsonplus import (
+ EXT_METHOD_SINGLE_ARG,
InvalidModuleError,
JsonPlusSerializer,
+ _msgpack_enc,
_msgpack_ext_hook_to_json,
)
from langgraph.store.base import Item
@@ -37,6 +43,10 @@ class MyPydantic(BaseModel):
inner: InnerPydantic
+class AnotherPydantic(BaseModel):
+ foo: str
+
+
class InnerPydanticV1(BaseModelV1):
hello: str
@@ -138,7 +148,27 @@ def test_serde_jsonplus() -> None:
)
to_serialize["my_secret_str_v1"] = SecretStrV1("meow")
- serde = JsonPlusSerializer()
+ allowed_msgpack_modules: AllowedMsgpackModules = [
+ InnerDataclass,
+ MyDataclass,
+ MyDataclassWSlots,
+ MyEnum,
+ InnerPydantic,
+ MyPydantic,
+ # Testing that it supports both.
+ (Person.__module__, Person.__name__),
+ (SecretStr.__module__, SecretStr.__name__),
+ ]
+ if sys.version_info < (3, 14):
+ allowed_msgpack_modules.extend( # type: ignore
+ [
+ (InnerPydanticV1.__module__, InnerPydanticV1.__name__),
+ (MyPydanticV1.__module__, MyPydanticV1.__name__),
+ (SecretStrV1.__module__, SecretStrV1.__name__),
+ ]
+ )
+
+ serde = JsonPlusSerializer(allowed_msgpack_modules=allowed_msgpack_modules)
dumped = serde.dumps_typed(to_serialize)
@@ -512,5 +542,337 @@ def test_serde_jsonplus_pandas_series(series: pd.Series) -> None:
assert dumped[0] == "pickle"
result = serde.loads_typed(dumped)
-
assert result.equals(series)
+
+
+def test_msgpack_safe_types_no_warning(caplog: pytest.LogCaptureFixture) -> None:
+ """Test safe types deserialize without warnings."""
+
+ serde = JsonPlusSerializer()
+
+ safe_objects = [
+ datetime.now(),
+ date.today(),
+ time(12, 30),
+ timezone.utc,
+ uuid.uuid4(),
+ Decimal("123.45"),
+ {1, 2, 3},
+ frozenset([1, 2, 3]),
+ deque([1, 2, 3]),
+ IPv4Address("192.168.1.1"),
+ pathlib.Path("/tmp/test"),
+ ]
+
+ for obj in safe_objects:
+ caplog.clear()
+ dumped = serde.dumps_typed(obj)
+ result = serde.loads_typed(dumped)
+ assert "unregistered type" not in caplog.text.lower(), (
+ f"Unexpected warning for {type(obj)}"
+ )
+ assert result is not None
+
+
+def test_msgpack_pydantic_warns_by_default(caplog: pytest.LogCaptureFixture) -> None:
+ """Pydantic models not in allowlist should log warning but still deserialize."""
+ current = _lg_msgpack.STRICT_MSGPACK_ENABLED
+ _lg_msgpack.STRICT_MSGPACK_ENABLED = False
+ serde = JsonPlusSerializer()
+
+ obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world"))
+
+ caplog.clear()
+ dumped = serde.dumps_typed(obj)
+ result = serde.loads_typed(dumped)
+
+ assert "unregistered type" in caplog.text.lower()
+ assert "allowed_msgpack_modules" in caplog.text
+ assert result == obj
+ _lg_msgpack.STRICT_MSGPACK_ENABLED = current
+
+
+def test_msgpack_env_strict_default(
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ """Strict msgpack env should default to blocking unregistered types."""
+ current = _lg_msgpack.STRICT_MSGPACK_ENABLED
+ _lg_msgpack.STRICT_MSGPACK_ENABLED = True
+ serde = JsonPlusSerializer()
+
+ obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world"))
+
+ caplog.clear()
+ dumped = serde.dumps_typed(obj)
+ result = serde.loads_typed(dumped)
+
+ assert "blocked" in caplog.text.lower()
+ assert result == obj.model_dump()
+ _lg_msgpack.STRICT_MSGPACK_ENABLED = current
+
+
+def test_msgpack_allowlist_silences_warning(caplog: pytest.LogCaptureFixture) -> None:
+ """Types in allowed_msgpack_modules should deserialize without warnings."""
+
+ serde = JsonPlusSerializer(
+ allowed_msgpack_modules=[
+ ("tests.test_jsonplus", "MyPydantic"),
+ ("tests.test_jsonplus", "InnerPydantic"),
+ ]
+ )
+
+ obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world"))
+
+ caplog.clear()
+ dumped = serde.dumps_typed(obj)
+ result = serde.loads_typed(dumped)
+
+ assert "unregistered type" not in caplog.text.lower()
+ assert result == obj
+
+
+def test_msgpack_none_blocks_unregistered(caplog: pytest.LogCaptureFixture) -> None:
+ """allowed_msgpack_modules=None should block unregistered types."""
+
+ serde = JsonPlusSerializer(allowed_msgpack_modules=None)
+
+ obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world"))
+
+ caplog.clear()
+ dumped = serde.dumps_typed(obj)
+ result = serde.loads_typed(dumped)
+
+ assert "blocked" in caplog.text.lower()
+ expected = obj.model_dump()
+ assert result == expected
+
+
+def test_msgpack_allowlist_blocks_non_listed(
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ """Allowlists should block unregistered types even if msgpack is enabled."""
+
+ serde = JsonPlusSerializer(
+ allowed_msgpack_modules=[("tests.test_jsonplus", "MyPydantic")]
+ )
+
+ obj = AnotherPydantic(foo="nope")
+
+ caplog.clear()
+ dumped = serde.dumps_typed(obj)
+ result = serde.loads_typed(dumped)
+
+ assert "blocked" in caplog.text.lower()
+ expected = obj.model_dump()
+ # It's not allowed, so we just leave it as a dict
+ assert result == expected
+
+
+def test_msgpack_strict_allows_safe_types(
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ """Safe types should still deserialize in strict mode without warnings."""
+
+ serde = JsonPlusSerializer(allowed_msgpack_modules=None)
+ safe = uuid.uuid4()
+
+ caplog.clear()
+ dumped = serde.dumps_typed(safe)
+ result = serde.loads_typed(dumped)
+
+ assert "blocked" not in caplog.text.lower()
+ assert result == safe
+
+
+def test_msgpack_regex_safe_type(caplog: pytest.LogCaptureFixture) -> None:
+ """re.compile patterns should deserialize without warnings as a safe type."""
+
+ serde = JsonPlusSerializer(allowed_msgpack_modules=None)
+ pattern = re.compile(r"foo.*bar", re.IGNORECASE | re.DOTALL)
+
+ caplog.clear()
+ dumped = serde.dumps_typed(pattern)
+ result = serde.loads_typed(dumped)
+
+ assert "blocked" not in caplog.text.lower()
+ assert "unregistered" not in caplog.text.lower()
+ assert result.pattern == pattern.pattern
+ assert result.flags == pattern.flags
+
+
+def test_msgpack_method_pathlib_blocked_in_strict(
+ tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture
+) -> None:
+ target = tmp_path / "secret.txt"
+ target.write_text("secret")
+ serde = JsonPlusSerializer(allowed_msgpack_modules=None)
+ payload = ormsgpack.packb(
+ ormsgpack.Ext(
+ EXT_METHOD_SINGLE_ARG,
+ _msgpack_enc(("pathlib", "Path", target, "read_text")),
+ ),
+ option=ormsgpack.OPT_NON_STR_KEYS,
+ )
+
+ caplog.set_level(logging.WARNING, logger="langgraph.checkpoint.serde.jsonplus")
+ caplog.clear()
+ result = serde.loads_typed(("msgpack", payload))
+
+ assert result == target
+ assert "blocked deserialization of method call pathlib.path.read_text" in (
+ caplog.text.lower()
+ )
+
+
+def test_msgpack_method_pathlib_blocked_default_mode(
+ tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture
+) -> None:
+ target = tmp_path / "secret.txt"
+ target.write_text("secret")
+ serde = JsonPlusSerializer(allowed_msgpack_modules=True)
+ payload = ormsgpack.packb(
+ ormsgpack.Ext(
+ EXT_METHOD_SINGLE_ARG,
+ _msgpack_enc(("pathlib", "Path", target, "read_text")),
+ ),
+ option=ormsgpack.OPT_NON_STR_KEYS,
+ )
+
+ caplog.set_level(logging.WARNING, logger="langgraph.checkpoint.serde.jsonplus")
+ caplog.clear()
+ result = serde.loads_typed(("msgpack", payload))
+
+ assert result == target
+ assert "blocked deserialization of method call pathlib.path.read_text" in (
+ caplog.text.lower()
+ )
+
+
+def test_msgpack_regex_still_works_strict(caplog: pytest.LogCaptureFixture) -> None:
+ serde = JsonPlusSerializer(allowed_msgpack_modules=None)
+ pattern = re.compile(r"pattern", re.IGNORECASE | re.MULTILINE)
+
+ caplog.clear()
+ result = serde.loads_typed(serde.dumps_typed(pattern))
+
+ assert "blocked" not in caplog.text.lower()
+ assert result.pattern == pattern.pattern
+ assert result.flags == pattern.flags
+
+
+def test_msgpack_path_constructor_still_works() -> None:
+ serde = JsonPlusSerializer(allowed_msgpack_modules=None)
+ path_obj = pathlib.Path("/tmp/foo")
+
+ result = serde.loads_typed(serde.dumps_typed(path_obj))
+
+ assert result == path_obj
+
+
+def test_with_msgpack_allowlist_noop_returns_same_instance() -> None:
+ serde = JsonPlusSerializer(allowed_msgpack_modules=None)
+
+ result = serde.with_msgpack_allowlist(())
+
+ assert result is serde
+
+
+@pytest.mark.skipif(sys.version_info >= (3, 14), reason="pydantic v1 not on 3.14+")
+def test_msgpack_pydantic_v1_allowlist(caplog: pytest.LogCaptureFixture) -> None:
+ """Pydantic v1 models in allowlist should deserialize without warnings."""
+
+ serde = JsonPlusSerializer(
+ allowed_msgpack_modules=[
+ ("tests.test_jsonplus", "MyPydanticV1"),
+ ("tests.test_jsonplus", "InnerPydanticV1"),
+ ]
+ )
+
+ obj = MyPydanticV1(foo="test", bar=42, inner=InnerPydanticV1(hello="world"))
+
+ caplog.clear()
+ dumped = serde.dumps_typed(obj)
+ result = serde.loads_typed(dumped)
+
+ assert "unregistered type" not in caplog.text.lower()
+ assert "blocked" not in caplog.text.lower()
+ assert result == obj
+
+
+def test_msgpack_dataclass_allowlist(caplog: pytest.LogCaptureFixture) -> None:
+ """Dataclasses in allowlist should deserialize without warnings."""
+
+ serde = JsonPlusSerializer(
+ allowed_msgpack_modules=[
+ ("tests.test_jsonplus", "MyDataclass"),
+ ("tests.test_jsonplus", "InnerDataclass"),
+ ]
+ )
+
+ obj = MyDataclass(foo="test", bar=42, inner=InnerDataclass(hello="world"))
+
+ caplog.clear()
+ dumped = serde.dumps_typed(obj)
+ result = serde.loads_typed(dumped)
+
+ assert "unregistered type" not in caplog.text.lower()
+ assert "blocked" not in caplog.text.lower()
+ assert result == obj
+
+
+def test_msgpack_safe_types_value_equality(caplog: pytest.LogCaptureFixture) -> None:
+ """Verify safe types are correctly restored with proper values."""
+
+ serde = JsonPlusSerializer(allowed_msgpack_modules=None)
+
+ test_cases = [
+ datetime(2024, 1, 15, 12, 30, 45, 123456),
+ date(2024, 6, 15),
+ time(14, 30, 0),
+ uuid.UUID("12345678-1234-5678-1234-567812345678"),
+ Decimal("123.456789"),
+ {1, 2, 3, 4, 5},
+ frozenset(["a", "b", "c"]),
+ deque([1, 2, 3]),
+ IPv4Address("10.0.0.1"),
+ pathlib.Path("/some/test/path"),
+ re.compile(r"\d+", re.MULTILINE),
+ ]
+
+ for obj in test_cases:
+ caplog.clear()
+ dumped = serde.dumps_typed(obj)
+ result = serde.loads_typed(dumped)
+
+ assert "blocked" not in caplog.text.lower(), f"Blocked for {type(obj)}"
+ # For regex patterns, compare pattern and flags
+ if isinstance(obj, re.Pattern):
+ assert result.pattern == obj.pattern
+ assert result.flags == obj.flags
+ else:
+ assert result == obj, f"Value mismatch for {type(obj)}: {result} != {obj}"
+
+
+def test_msgpack_nested_pydantic_serializes_as_dict(
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ """Nested Pydantic models are serialized via model_dump() as dicts.
+
+ This means nested models don't go through the ext hook and don't need
+ to be in the allowlist - only the outer type does.
+ """
+
+ # Only allow outer type - inner is serialized as dict via model_dump()
+ serde = JsonPlusSerializer(
+ allowed_msgpack_modules=[("tests.test_jsonplus", "MyPydantic")]
+ )
+
+ obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world"))
+
+ caplog.clear()
+ dumped = serde.dumps_typed(obj)
+ result = serde.loads_typed(dumped)
+
+ # No blocking should occur - inner is serialized as dict, not ext
+ assert "blocked" not in caplog.text.lower()
+ assert result == obj
diff --git a/libs/checkpoint/tests/test_memory.py b/libs/checkpoint/tests/test_memory.py
index 7d85f4ed5..a68a23d90 100644
--- a/libs/checkpoint/tests/test_memory.py
+++ b/libs/checkpoint/tests/test_memory.py
@@ -1,7 +1,9 @@
+import logging
from typing import Any
import pytest
from langchain_core.runnables import RunnableConfig
+from pydantic import BaseModel
from langgraph.checkpoint.base import (
Checkpoint,
@@ -10,6 +12,11 @@ from langgraph.checkpoint.base import (
empty_checkpoint,
)
from langgraph.checkpoint.memory import InMemorySaver
+from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
+
+
+class MemoryPydantic(BaseModel):
+ foo: str
class TestMemorySaver:
@@ -199,3 +206,105 @@ async def test_memory_saver() -> None:
with memory_saver as sync_memory_saver:
assert sync_memory_saver is memory_saver
+
+
+def test_memory_saver_warns_on_unregistered_msgpack(
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ serde = JsonPlusSerializer()
+ memory_saver = InMemorySaver(serde=serde)
+ obj = MemoryPydantic(foo="bar")
+
+ checkpoint = empty_checkpoint()
+ checkpoint["channel_values"] = {"foo": obj}
+ checkpoint["channel_versions"] = {"foo": 1}
+
+ config: RunnableConfig = {
+ "configurable": {"thread_id": "thread-1", "checkpoint_ns": ""}
+ }
+
+ caplog.set_level(logging.WARNING, logger="langgraph.checkpoint.serde.jsonplus")
+ new_config = memory_saver.put(config, checkpoint, {}, {"foo": 1})
+ result = memory_saver.get_tuple(new_config)
+
+ assert result is not None
+ assert "unregistered type" in caplog.text.lower()
+ assert result.checkpoint["channel_values"]["foo"] == obj
+
+
+def test_memory_saver_allowlist_silences_warning(
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ serde = JsonPlusSerializer(
+ allowed_msgpack_modules=[("tests.test_memory", "MemoryPydantic")]
+ )
+ memory_saver = InMemorySaver(serde=serde)
+ obj = MemoryPydantic(foo="bar")
+
+ checkpoint = empty_checkpoint()
+ checkpoint["channel_values"] = {"foo": obj}
+ checkpoint["channel_versions"] = {"foo": 1}
+
+ config: RunnableConfig = {
+ "configurable": {"thread_id": "thread-1", "checkpoint_ns": ""}
+ }
+
+ caplog.set_level(logging.WARNING, logger="langgraph.checkpoint.serde.jsonplus")
+ new_config = memory_saver.put(config, checkpoint, {}, {"foo": 1})
+ result = memory_saver.get_tuple(new_config)
+
+ assert result is not None
+ assert "unregistered type" not in caplog.text.lower()
+ assert result.checkpoint["channel_values"]["foo"] == obj
+
+
+def test_memory_saver_strict_blocks_unregistered(
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ serde = JsonPlusSerializer(allowed_msgpack_modules=None)
+ memory_saver = InMemorySaver(serde=serde)
+ obj = MemoryPydantic(foo="bar")
+
+ checkpoint = empty_checkpoint()
+ checkpoint["channel_values"] = {"foo": obj}
+ checkpoint["channel_versions"] = {"foo": 1}
+
+ config: RunnableConfig = {
+ "configurable": {"thread_id": "thread-1", "checkpoint_ns": ""}
+ }
+
+ caplog.set_level(logging.WARNING, logger="langgraph.checkpoint.serde.jsonplus")
+ new_config = memory_saver.put(config, checkpoint, {}, {"foo": 1})
+ result = memory_saver.get_tuple(new_config)
+
+ assert result is not None
+ assert "blocked" in caplog.text.lower()
+ expected = obj.model_dump() if hasattr(obj, "model_dump") else obj.dict()
+ assert result.checkpoint["channel_values"]["foo"] == expected
+
+
+def test_memory_saver_with_allowlist_proxy_isolated() -> None:
+ serde = JsonPlusSerializer(allowed_msgpack_modules=None)
+ memory_saver = InMemorySaver(serde=serde)
+ proxy = memory_saver.with_allowlist([("tests.test_memory", "MemoryPydantic")])
+
+ obj = MemoryPydantic(foo="bar")
+
+ checkpoint = empty_checkpoint()
+ checkpoint["channel_values"] = {"foo": obj}
+ checkpoint["channel_versions"] = {"foo": 1}
+
+ config: RunnableConfig = {
+ "configurable": {"thread_id": "thread-1", "checkpoint_ns": ""}
+ }
+
+ new_config = proxy.put(config, checkpoint, {}, {"foo": 1})
+
+ proxied = proxy.get_tuple(new_config)
+ assert proxied is not None
+ assert proxied.checkpoint["channel_values"]["foo"] == obj
+
+ direct = memory_saver.get_tuple(new_config)
+ assert direct is not None
+ expected = obj.model_dump() if hasattr(obj, "model_dump") else obj.dict()
+ assert direct.checkpoint["channel_values"]["foo"] == expected
diff --git a/libs/checkpoint/uv.lock b/libs/checkpoint/uv.lock
index 8490472a2..cc4384e3d 100644
--- a/libs/checkpoint/uv.lock
+++ b/libs/checkpoint/uv.lock
@@ -302,6 +302,7 @@ dev = [
{ name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "pandas" },
{ name = "pandas-stubs" },
+ { name = "pycryptodome" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
@@ -341,6 +342,7 @@ dev = [
{ name = "numpy" },
{ name = "pandas" },
{ name = "pandas-stubs", specifier = ">=2.2.2.240807" },
+ { name = "pycryptodome", specifier = ">=3.23.0" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
@@ -912,6 +914,41 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
+[[package]]
+name = "pycryptodome"
+version = "3.23.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" },
+ { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" },
+ { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
+ { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
+ { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
+ { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
+ { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
+ { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
+ { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/12/e33935a0709c07de084d7d58d330ec3f4daf7910a18e77937affdb728452/pycryptodome-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379", size = 1623886, upload-time = "2025-05-17T17:21:20.614Z" },
+ { url = "https://files.pythonhosted.org/packages/22/0b/aa8f9419f25870889bebf0b26b223c6986652bdf071f000623df11212c90/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4", size = 1672151, upload-time = "2025-05-17T17:21:22.666Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/5e/63f5cbde2342b7f70a39e591dbe75d9809d6338ce0b07c10406f1a140cdc/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630", size = 1664461, upload-time = "2025-05-17T17:21:25.225Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/92/608fbdad566ebe499297a86aae5f2a5263818ceeecd16733006f1600403c/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353", size = 1702440, upload-time = "2025-05-17T17:21:27.991Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/92/2eadd1341abd2989cce2e2740b4423608ee2014acb8110438244ee97d7ff/pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5", size = 1803005, upload-time = "2025-05-17T17:21:31.37Z" },
+]
+
[[package]]
name = "pydantic"
version = "2.12.5"
diff --git a/libs/cli/langgraph_cli/schemas.py b/libs/cli/langgraph_cli/schemas.py
index 011ce4752..1a1669e6f 100644
--- a/libs/cli/langgraph_cli/schemas.py
+++ b/libs/cli/langgraph_cli/schemas.py
@@ -128,7 +128,7 @@ class SerdeConfig(TypedDict, total=False):
If omitted, no serde is set up (the object store will still be present, however)."""
allowed_json_modules: list[list[str]] | bool | None
- """Optional. List of allowed python modules to de-serialize custom objects from.
+ """Optional. List of allowed python modules to de-serialize custom objects from JSON.
If provided, only the specified modules will be allowed to be deserialized.
If omitted, no modules are allowed, and the object returned will simply be a json object OR
@@ -148,7 +148,34 @@ class SerdeConfig(TypedDict, total=False):
Example:
{...
"serde": {
- "allowed_json_modules": true
+ "allowed_json_modules": True
+ }
+ }
+
+ """
+ allowed_msgpack_modules: list[list[str]] | bool | None
+ """Optional. List of allowed python modules to de-serialize custom objects from msgpack.
+
+ Known safe types (langgraph.checkpoint.serde.jsonplus.SAFE_MSGPACK_TYPES) are always
+ allowed regardless of this setting. Use this to allowlist your custom Pydantic models,
+ dataclasses, and other user-defined types.
+
+ If True (default), unregistered types will log a warning but still be deserialized.
+ If None, only known safe types will be deserialized; unregistered types will be blocked.
+
+ Example - allowlist specific types (no warnings for these):
+ {...
+ "serde": {
+ "allowed_msgpack_modules": [
+ ["my_agent.models", "MyState"],
+ ]
+ }
+ }
+
+ Example - strict mode (only safe types allowed):
+ {...
+ "serde": {
+ "allowed_msgpack_modules": null
}
}
@@ -328,8 +355,7 @@ class EncryptionConfig(TypedDict, total=False):
"""Configuration for custom at-rest encryption logic.
Allows you to implement custom encryption for sensitive data stored in the database,
- including metadata fields and checkpoint blobs.
- """
+ including metadata fields and checkpoint blobs."""
path: str
"""Required. Path to an instance of the Encryption() class that implements custom encryption handlers.
diff --git a/libs/cli/schemas/schema.json b/libs/cli/schemas/schema.json
index 4ba2700e8..a29d82027 100644
--- a/libs/cli/schemas/schema.json
+++ b/libs/cli/schemas/schema.json
@@ -608,7 +608,27 @@
"type": "null"
}
],
- "description": "Optional. List of allowed python modules to de-serialize custom objects from.\n\nIf provided, only the specified modules will be allowed to be deserialized.\nIf omitted, no modules are allowed, and the object returned will simply be a json object OR\na deserialized langchain object.\n"
+ "description": "Optional. List of allowed python modules to de-serialize custom objects from JSON.\n\nIf provided, only the specified modules will be allowed to be deserialized.\nIf omitted, no modules are allowed, and the object returned will simply be a json object OR\na deserialized langchain object.\n"
+ },
+ "allowed_msgpack_modules": {
+ "anyOf": [
+ {
+ "type": "array",
+ "items": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Optional. List of allowed python modules to de-serialize custom objects from msgpack.\n\nKnown safe types (langgraph.checkpoint.serde.jsonplus.SAFE_MSGPACK_TYPES) are always\nallowed regardless of this setting. Use this to allowlist your custom Pydantic models,\ndataclasses, and other user-defined types.\n\nIf True (default), unregistered types will log a warning but still be deserialized.\nIf None, only known safe types will be deserialized; unregistered types will be blocked.\n\n{...\n[\"my_agent.models\", \"MyState\"],\n]\n}\n}\n\n{...\n}\n}\n\n"
},
"pickle_fallback": {
"type": "boolean",
diff --git a/libs/cli/schemas/schema.v0.json b/libs/cli/schemas/schema.v0.json
index 4ba2700e8..a29d82027 100644
--- a/libs/cli/schemas/schema.v0.json
+++ b/libs/cli/schemas/schema.v0.json
@@ -608,7 +608,27 @@
"type": "null"
}
],
- "description": "Optional. List of allowed python modules to de-serialize custom objects from.\n\nIf provided, only the specified modules will be allowed to be deserialized.\nIf omitted, no modules are allowed, and the object returned will simply be a json object OR\na deserialized langchain object.\n"
+ "description": "Optional. List of allowed python modules to de-serialize custom objects from JSON.\n\nIf provided, only the specified modules will be allowed to be deserialized.\nIf omitted, no modules are allowed, and the object returned will simply be a json object OR\na deserialized langchain object.\n"
+ },
+ "allowed_msgpack_modules": {
+ "anyOf": [
+ {
+ "type": "array",
+ "items": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Optional. List of allowed python modules to de-serialize custom objects from msgpack.\n\nKnown safe types (langgraph.checkpoint.serde.jsonplus.SAFE_MSGPACK_TYPES) are always\nallowed regardless of this setting. Use this to allowlist your custom Pydantic models,\ndataclasses, and other user-defined types.\n\nIf True (default), unregistered types will log a warning but still be deserialized.\nIf None, only known safe types will be deserialized; unregistered types will be blocked.\n\n{...\n[\"my_agent.models\", \"MyState\"],\n]\n}\n}\n\n{...\n}\n}\n\n"
},
"pickle_fallback": {
"type": "boolean",
diff --git a/libs/langgraph/Makefile b/libs/langgraph/Makefile
index 3b406baf4..e2c57e19d 100644
--- a/libs/langgraph/Makefile
+++ b/libs/langgraph/Makefile
@@ -87,15 +87,15 @@ integration_tests:
WORKERS ?= auto
XDIST_ARGS := $(if $(WORKERS),-n $(WORKERS) --dist worksteal,)
-MAXFAIL ?=
-MAXFAIL_ARGS := $(if $(MAXFAIL),--maxfail $(MAXFAIL),)
+MAXFAIL ?= 1
+MAXFAIL_ARGS = $(if $(MAXFAIL),--maxfail $(MAXFAIL),)
# Add an '-x' if xdist is enabled
XDIST_ARGS := $(if $(WORKERS),-x $(XDIST_ARGS),)
test_watch:
make start-services &&\
make start-dev-server &&\
- uv run ptw . -- --ff -vv $(XDIST_ARGS) $(MAXFAIL_ARGS) $(TEST); \
+ uv run ptw -- --ff -vv $(XDIST_ARGS) $(MAXFAIL_ARGS) $(TEST); \
EXIT_CODE=$$?; \
make stop-services; \
make stop-dev-server; \
@@ -130,7 +130,7 @@ type:
format format_diff:
uv run ruff format $(PYTHON_FILES)
- uv run ruff check --select I --fix $(PYTHON_FILES)
+ uv run ruff check --fix $(PYTHON_FILES)
spell_check:
uv run codespell --toml pyproject.toml
diff --git a/libs/langgraph/bench/__main__.py b/libs/langgraph/bench/__main__.py
index 0bfa16ad1..d824abe5f 100644
--- a/libs/langgraph/bench/__main__.py
+++ b/libs/langgraph/bench/__main__.py
@@ -10,6 +10,7 @@ from bench.fanout_to_subgraph import fanout_to_subgraph, fanout_to_subgraph_sync
from bench.pydantic_state import pydantic_state
from bench.react_agent import react_agent
from bench.sequential import create_sequential
+from bench.serde_allowlist import collect_allowlist_large, collect_allowlist_small
from bench.wide_dict import wide_dict
from bench.wide_state import wide_state
from langgraph.graph import StateGraph
@@ -513,3 +514,7 @@ compilation_benchmarks = (
for name, graph in compilation_benchmarks:
r.bench_func(name + "_compilation", compile_graph, graph)
+
+# Serde allowlist collection
+r.bench_func("serde_allowlist_small", collect_allowlist_small)
+r.bench_func("serde_allowlist_large", collect_allowlist_large)
diff --git a/libs/langgraph/bench/serde_allowlist.py b/libs/langgraph/bench/serde_allowlist.py
new file mode 100644
index 000000000..4278b9af5
--- /dev/null
+++ b/libs/langgraph/bench/serde_allowlist.py
@@ -0,0 +1,81 @@
+from __future__ import annotations
+
+from collections import deque
+from dataclasses import dataclass
+from enum import Enum
+from typing import Annotated
+
+from pydantic import BaseModel
+from typing_extensions import NotRequired, TypedDict
+
+from langgraph._internal._serde import collect_allowlist_from_schemas
+
+
+class Color(Enum):
+ RED = "red"
+ BLUE = "blue"
+
+
+@dataclass
+class InnerDataclass:
+ value: int
+
+
+class InnerModel(BaseModel):
+ name: str
+
+
+class InnerTyped(TypedDict):
+ payload: InnerDataclass
+ optional: NotRequired[InnerModel]
+
+
+@dataclass
+class Node:
+ value: int
+ child: Node | None = None
+
+
+@dataclass
+class NestedDataclass:
+ inner: InnerDataclass
+ items: list[InnerModel]
+ mapping: dict[str, InnerDataclass]
+ optional: InnerModel | None
+ union: InnerDataclass | InnerModel
+ queue: deque[InnerDataclass]
+ frozen: frozenset[InnerModel]
+
+
+AnnotatedList = Annotated[list[InnerDataclass], "meta"]
+
+
+class DummyChannel:
+ @property
+ def ValueType(self) -> type[InnerDataclass]:
+ return InnerDataclass
+
+ @property
+ def UpdateType(self) -> type[InnerModel]:
+ return InnerModel
+
+
+SCHEMAS_SMALL = [InnerDataclass, InnerModel, Color]
+SCHEMAS_LARGE = [
+ InnerDataclass,
+ InnerModel,
+ Color,
+ InnerTyped,
+ Node,
+ NestedDataclass,
+ AnnotatedList,
+]
+CHANNELS = {"a": DummyChannel(), "b": DummyChannel()}
+
+
+def collect_allowlist_small() -> None:
+ collect_allowlist_from_schemas(schemas=SCHEMAS_SMALL, channels=CHANNELS)
+
+
+def collect_allowlist_large() -> None:
+ collect_allowlist_from_schemas(schemas=SCHEMAS_LARGE, channels=CHANNELS)
diff --git a/libs/langgraph/langgraph/_internal/_serde.py b/libs/langgraph/langgraph/_internal/_serde.py
new file mode 100644
index 000000000..775242a87
--- /dev/null
+++ b/libs/langgraph/langgraph/_internal/_serde.py
@@ -0,0 +1,253 @@
+from __future__ import annotations
+
+import dataclasses
+import logging
+import sys
+import types
+from collections import deque
+from enum import Enum
+from typing import (
+ Annotated,
+ Any,
+ Literal,
+ Union,
+ get_args,
+ get_origin,
+ get_type_hints,
+)
+
+from langchain_core import messages as lc_messages
+from langgraph.checkpoint.base import BaseCheckpointSaver
+from pydantic import BaseModel
+from typing_extensions import NotRequired, Required, is_typeddict
+
+try:
+ from langgraph.checkpoint.serde._msgpack import ( # noqa: F401
+ STRICT_MSGPACK_ENABLED,
+ )
+except ImportError:
+ STRICT_MSGPACK_ENABLED = False
+
+_warned_allowlist_unsupported = False
+
+logger = logging.getLogger(__name__)
+
+
+def _supports_checkpointer_allowlist() -> bool:
+ return hasattr(BaseCheckpointSaver, "with_allowlist")
+
+
+_SUPPORTS_ALLOWLIST = _supports_checkpointer_allowlist()
+
+
+def apply_checkpointer_allowlist(
+ checkpointer: Any, allowlist: set[tuple[str, ...]] | None
+) -> Any:
+ if not checkpointer or allowlist is None or checkpointer in (True, False):
+ return checkpointer
+ if not _SUPPORTS_ALLOWLIST:
+ global _warned_allowlist_unsupported
+ if not _warned_allowlist_unsupported:
+ logger.warning(
+ "Checkpointer does not support with_allowlist; strict msgpack "
+ "allowlist will be skipped."
+ )
+ _warned_allowlist_unsupported = True
+ return checkpointer
+ return checkpointer.with_allowlist(allowlist)
+
+
+def curated_core_allowlist() -> set[tuple[str, ...]]:
+ allowlist: set[tuple[str, ...]] = set()
+ for name in (
+ "BaseMessage",
+ "BaseMessageChunk",
+ "HumanMessage",
+ "HumanMessageChunk",
+ "AIMessage",
+ "AIMessageChunk",
+ "SystemMessage",
+ "SystemMessageChunk",
+ "ChatMessage",
+ "ChatMessageChunk",
+ "ToolMessage",
+ "ToolMessageChunk",
+ "FunctionMessage",
+ "FunctionMessageChunk",
+ "RemoveMessage",
+ ):
+ cls = getattr(lc_messages, name, None)
+ if cls is None:
+ continue
+ allowlist.add((cls.__module__, cls.__name__))
+
+ return allowlist
+
+
+def build_serde_allowlist(
+ *,
+ schemas: list[type[Any]] | None = None,
+ channels: dict[str, Any] | None = None,
+) -> set[tuple[str, ...]]:
+ allowlist = curated_core_allowlist()
+ if schemas:
+ schemas = [schema for schema in schemas if schema is not None]
+ return allowlist | collect_allowlist_from_schemas(
+ schemas=schemas,
+ channels=channels,
+ )
+
+
+def collect_allowlist_from_schemas(
+ *,
+ schemas: list[type[Any]] | None = None,
+ channels: dict[str, Any] | None = None,
+) -> set[tuple[str, ...]]:
+ allowlist: set[tuple[str, ...]] = set()
+ seen: set[Any] = set()
+ seen_ids: set[int] = set()
+
+ if schemas:
+ for schema in schemas:
+ _collect_from_type(schema, allowlist, seen, seen_ids)
+
+ if channels:
+ for channel in channels.values():
+ value_type = getattr(channel, "ValueType", None)
+ if value_type is not None:
+ _collect_from_type(value_type, allowlist, seen, seen_ids)
+ update_type = getattr(channel, "UpdateType", None)
+ if update_type is not None:
+ _collect_from_type(update_type, allowlist, seen, seen_ids)
+
+ return allowlist
+
+
+def _collect_from_type(
+ typ: Any,
+ allowlist: set[tuple[str, ...]],
+ seen: set[Any],
+ seen_ids: set[int],
+) -> None:
+ if _already_seen(typ, seen, seen_ids):
+ return
+
+ if typ is Any or typ is None:
+ return
+
+ if typ is Literal:
+ return
+
+ if isinstance(typ, types.UnionType):
+ for arg in typ.__args__:
+ _collect_from_type(arg, allowlist, seen, seen_ids)
+ return
+
+ origin = get_origin(typ)
+ if origin is Union:
+ for arg in get_args(typ):
+ _collect_from_type(arg, allowlist, seen, seen_ids)
+ return
+ if origin is Annotated or origin in (Required, NotRequired):
+ args = get_args(typ)
+ if args:
+ _collect_from_type(args[0], allowlist, seen, seen_ids)
+ return
+
+ if origin is Literal:
+ return
+
+ if origin in (list, set, tuple, dict, deque, frozenset):
+ for arg in get_args(typ):
+ _collect_from_type(arg, allowlist, seen, seen_ids)
+ return
+
+ if hasattr(typ, "__supertype__"):
+ _collect_from_type(typ.__supertype__, allowlist, seen, seen_ids)
+ return
+
+ if is_typeddict(typ):
+ for field_type in _safe_get_type_hints(typ).values():
+ _collect_from_type(field_type, allowlist, seen, seen_ids)
+ return
+
+ if _is_pydantic_model(typ):
+ allowlist.add((typ.__module__, typ.__name__))
+ field_types = _safe_get_type_hints(typ)
+ if field_types:
+ for field_type in field_types.values():
+ _collect_from_type(field_type, allowlist, seen, seen_ids)
+ else:
+ for field_type in _pydantic_field_types(typ):
+ _collect_from_type(field_type, allowlist, seen, seen_ids)
+ return
+
+ if dataclasses.is_dataclass(typ):
+ if typ_name := getattr(typ, "__name__", None):
+ allowlist.add((typ.__module__, typ_name))
+ field_types = _safe_get_type_hints(typ)
+ if field_types:
+ for field_type in field_types.values():
+ _collect_from_type(field_type, allowlist, seen, seen_ids)
+ else:
+ for field in dataclasses.fields(typ):
+ _collect_from_type(field.type, allowlist, seen, seen_ids)
+ return
+
+ if isinstance(typ, type) and issubclass(typ, Enum):
+ allowlist.add((typ.__module__, typ.__name__))
+ return
+
+
+def _already_seen(typ: Any, seen: set[Any], seen_ids: set[int]) -> bool:
+ try:
+ if typ in seen:
+ return True
+ seen.add(typ)
+ return False
+ except TypeError:
+ typ_id = id(typ)
+ if typ_id in seen_ids:
+ return True
+ seen_ids.add(typ_id)
+ return False
+
+
+def _safe_get_type_hints(typ: Any) -> dict[str, Any]:
+ try:
+ module = sys.modules.get(getattr(typ, "__module__", ""))
+ globalns = module.__dict__ if module else None
+ localns = dict(vars(typ)) if hasattr(typ, "__dict__") else None
+ return get_type_hints(
+ typ, globalns=globalns, localns=localns, include_extras=True
+ )
+ except Exception:
+ return {}
+
+
+def _is_pydantic_model(typ: Any) -> bool:
+ if not isinstance(typ, type):
+ return False
+ if issubclass(typ, BaseModel):
+ return True
+ try:
+ from pydantic.v1 import BaseModel as BaseModelV1
+ except Exception:
+ return False
+ return issubclass(typ, BaseModelV1)
+
+
+def _pydantic_field_types(typ: type[Any]) -> list[Any]:
+ if hasattr(typ, "model_fields"):
+ return [
+ field.annotation
+ for field in typ.model_fields.values()
+ if getattr(field, "annotation", None) is not None
+ ]
+ if hasattr(typ, "__fields__"):
+ return [
+ field.outer_type_
+ for field in typ.__fields__.values()
+ if getattr(field, "outer_type_", None) is not None
+ ]
+ return []
diff --git a/libs/langgraph/langgraph/func/__init__.py b/libs/langgraph/langgraph/func/__init__.py
index d1587077d..c7443e0a2 100644
--- a/libs/langgraph/langgraph/func/__init__.py
+++ b/libs/langgraph/langgraph/func/__init__.py
@@ -20,6 +20,7 @@ from langgraph.checkpoint.base import BaseCheckpointSaver
from langgraph.store.base import BaseStore
from typing_extensions import Unpack
+from langgraph._internal import _serde
from langgraph._internal._constants import CACHE_NS_WRITES, PREVIOUS
from langgraph._internal._typing import MISSING, DeprecatedKwargs
from langgraph.channels.ephemeral_value import EphemeralValue
@@ -528,7 +529,7 @@ class entrypoint(Generic[ContextT]):
else:
output_type = save_type = sig.return_annotation
- return Pregel(
+ graph: Pregel[Any, ContextT, Any, Any] = Pregel(
nodes={
func.__name__: PregelNode(
bound=bound,
@@ -559,5 +560,16 @@ class entrypoint(Generic[ContextT]):
cache=self.cache,
cache_policy=self.cache_policy,
retry_policy=self.retry_policy or (),
- context_schema=self.context_schema, # type: ignore[arg-type]
+ context_schema=self.context_schema,
)
+ if _serde.STRICT_MSGPACK_ENABLED:
+ serde_allowlist = _serde.build_serde_allowlist(
+ schemas=[input_type, output_type, save_type]
+ + ([self.context_schema] if self.context_schema is not None else []),
+ channels=graph.channels,
+ )
+ graph._serde_allowlist = serde_allowlist
+ graph.checkpointer = _serde.apply_checkpointer_allowlist(
+ graph.checkpointer, serde_allowlist
+ )
+ return graph
diff --git a/libs/langgraph/langgraph/graph/state.py b/libs/langgraph/langgraph/graph/state.py
index 4d0c90457..a87c17527 100644
--- a/libs/langgraph/langgraph/graph/state.py
+++ b/libs/langgraph/langgraph/graph/state.py
@@ -29,6 +29,7 @@ from langgraph.store.base import BaseStore
from pydantic import BaseModel, TypeAdapter
from typing_extensions import NotRequired, Required, Self, Unpack, is_typeddict
+from langgraph._internal import _serde
from langgraph._internal._constants import (
INTERRUPT,
NS_END,
@@ -1079,6 +1080,28 @@ 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]] = [
+ self.state_schema,
+ self.input_schema,
+ self.output_schema,
+ ]
+ if self.context_schema is not None:
+ schema_types.append(self.context_schema)
+ for node in self.nodes.values():
+ schema_types.append(node.input_schema)
+ for branches in self.branches.values():
+ for branch in branches.values():
+ if branch.input_schema is not None:
+ schema_types.append(branch.input_schema)
+ serde_allowlist = _serde.build_serde_allowlist(
+ schemas=schema_types,
+ channels=self.channels,
+ )
+ checkpointer = _serde.apply_checkpointer_allowlist(
+ checkpointer, serde_allowlist
+ )
# assign default values
interrupt_before = interrupt_before or []
@@ -1135,6 +1158,7 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
cache=cache,
name=name or "LangGraph",
)
+ compiled._serde_allowlist = serde_allowlist
compiled.attach_node(START, None)
for key, node in self.nodes.items():
diff --git a/libs/langgraph/langgraph/pregel/main.py b/libs/langgraph/langgraph/pregel/main.py
index 37e8125f9..ae6a0014d 100644
--- a/libs/langgraph/langgraph/pregel/main.py
+++ b/libs/langgraph/langgraph/pregel/main.py
@@ -48,6 +48,7 @@ from langgraph.store.base import BaseStore
from pydantic import BaseModel, TypeAdapter
from typing_extensions import Self, Unpack, deprecated, is_typeddict
+from langgraph._internal import _serde
from langgraph._internal._config import (
ensure_config,
merge_configs,
@@ -698,9 +699,17 @@ class Pregel(
self.config = config
self.trigger_to_nodes = trigger_to_nodes or {}
self.name = name
+ self._serde_allowlist: set[tuple[str, ...]] | None = None
if auto_validate:
self.validate()
+ def _apply_checkpointer_allowlist(
+ self, checkpointer: BaseCheckpointSaver | None
+ ) -> BaseCheckpointSaver | None:
+ if not _serde.STRICT_MSGPACK_ENABLED:
+ return checkpointer
+ return _serde.apply_checkpointer_allowlist(checkpointer, self._serde_allowlist)
+
def get_graph(
self, config: RunnableConfig | None = None, *, xray: int | bool = False
) -> Graph:
@@ -1239,6 +1248,8 @@ class Pregel(
checkpointer: BaseCheckpointSaver | None = ensure_config(config)[CONF].get(
CONFIG_KEY_CHECKPOINTER, self.checkpointer
)
+ if isinstance(checkpointer, BaseCheckpointSaver):
+ checkpointer = self._apply_checkpointer_allowlist(checkpointer)
if not checkpointer:
raise ValueError("No checkpointer set")
@@ -1281,6 +1292,8 @@ class Pregel(
checkpointer: BaseCheckpointSaver | None = ensure_config(config)[CONF].get(
CONFIG_KEY_CHECKPOINTER, self.checkpointer
)
+ if isinstance(checkpointer, BaseCheckpointSaver):
+ checkpointer = self._apply_checkpointer_allowlist(checkpointer)
if not checkpointer:
raise ValueError("No checkpointer set")
@@ -1329,6 +1342,8 @@ class Pregel(
checkpointer: BaseCheckpointSaver | None = config[CONF].get(
CONFIG_KEY_CHECKPOINTER, self.checkpointer
)
+ if isinstance(checkpointer, BaseCheckpointSaver):
+ checkpointer = self._apply_checkpointer_allowlist(checkpointer)
if not checkpointer:
raise ValueError("No checkpointer set")
@@ -1380,6 +1395,8 @@ class Pregel(
checkpointer: BaseCheckpointSaver | None = ensure_config(config)[CONF].get(
CONFIG_KEY_CHECKPOINTER, self.checkpointer
)
+ if isinstance(checkpointer, BaseCheckpointSaver):
+ checkpointer = self._apply_checkpointer_allowlist(checkpointer)
if not checkpointer:
raise ValueError("No checkpointer set")
@@ -1446,6 +1463,8 @@ class Pregel(
checkpointer: BaseCheckpointSaver | None = ensure_config(config)[CONF].get(
CONFIG_KEY_CHECKPOINTER, self.checkpointer
)
+ if isinstance(checkpointer, BaseCheckpointSaver):
+ checkpointer = self._apply_checkpointer_allowlist(checkpointer)
if not checkpointer:
raise ValueError("No checkpointer set")
@@ -1890,6 +1909,8 @@ class Pregel(
checkpointer: BaseCheckpointSaver | None = ensure_config(config)[CONF].get(
CONFIG_KEY_CHECKPOINTER, self.checkpointer
)
+ if isinstance(checkpointer, BaseCheckpointSaver):
+ checkpointer = self._apply_checkpointer_allowlist(checkpointer)
if not checkpointer:
raise ValueError("No checkpointer set")
@@ -2378,6 +2399,8 @@ class Pregel(
raise RuntimeError("checkpointer=True cannot be used for root graphs.")
else:
checkpointer = self.checkpointer
+ if isinstance(checkpointer, BaseCheckpointSaver):
+ checkpointer = self._apply_checkpointer_allowlist(checkpointer)
if checkpointer and not config.get(CONF):
raise ValueError(
"Checkpointer requires one or more of the following 'configurable' "
diff --git a/libs/langgraph/tests/conftest_checkpointer.py b/libs/langgraph/tests/conftest_checkpointer.py
index 99c0686fc..b74e55923 100644
--- a/libs/langgraph/tests/conftest_checkpointer.py
+++ b/libs/langgraph/tests/conftest_checkpointer.py
@@ -1,3 +1,4 @@
+import os
from contextlib import asynccontextmanager, contextmanager
from uuid import uuid4
@@ -5,6 +6,7 @@ import pytest
from langgraph.checkpoint.postgres import PostgresSaver
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from langgraph.checkpoint.serde.encrypted import EncryptedSerializer
+from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
from psycopg import AsyncConnection, Connection
@@ -18,30 +20,60 @@ from tests.memory_assert import ( # noqa: E402
)
DEFAULT_POSTGRES_URI = "postgres://postgres:postgres@localhost:5442/"
+STRICT_MSGPACK = os.getenv("LANGGRAPH_STRICT_MSGPACK", "false").lower() in (
+ "1",
+ "true",
+ "yes",
+)
+
+
+def _strict_msgpack_serde() -> JsonPlusSerializer:
+ return JsonPlusSerializer(allowed_msgpack_modules=None)
+
+
+def _apply_strict_msgpack(checkpointer) -> None:
+ if not STRICT_MSGPACK:
+ return
+ serde = _strict_msgpack_serde()
+ if hasattr(checkpointer, "serde"):
+ checkpointer.serde = serde
+ if hasattr(checkpointer, "saver") and hasattr(checkpointer.saver, "serde"):
+ checkpointer.saver.serde = serde
@contextmanager
def _checkpointer_memory():
- yield MemorySaverAssertImmutable()
+ if STRICT_MSGPACK:
+ yield MemorySaverAssertImmutable(serde=_strict_msgpack_serde())
+ else:
+ yield MemorySaverAssertImmutable()
@contextmanager
def _checkpointer_memory_migrate_sends():
- yield MemorySaverNeedsPendingSendsMigration()
+ checkpointer = MemorySaverNeedsPendingSendsMigration()
+ _apply_strict_msgpack(checkpointer)
+ yield checkpointer
@contextmanager
def _checkpointer_sqlite():
with SqliteSaver.from_conn_string(":memory:") as checkpointer:
+ _apply_strict_msgpack(checkpointer)
yield checkpointer
@contextmanager
def _checkpointer_sqlite_aes():
with SqliteSaver.from_conn_string(":memory:") as checkpointer:
- checkpointer.serde = EncryptedSerializer.from_pycryptodome_aes(
- key=b"1234567890123456"
- )
+ if STRICT_MSGPACK:
+ checkpointer.serde = EncryptedSerializer.from_pycryptodome_aes(
+ serde=_strict_msgpack_serde(), key=b"1234567890123456"
+ )
+ else:
+ checkpointer.serde = EncryptedSerializer.from_pycryptodome_aes(
+ key=b"1234567890123456"
+ )
yield checkpointer
@@ -57,6 +89,7 @@ def _checkpointer_postgres():
DEFAULT_POSTGRES_URI + database
) as checkpointer:
checkpointer.setup()
+ _apply_strict_msgpack(checkpointer)
yield checkpointer
finally:
# drop unique db
@@ -79,6 +112,7 @@ def _checkpointer_postgres_pipe():
# setup can't run inside pipeline because of implicit transaction
with checkpointer.conn.pipeline() as pipe:
checkpointer.pipe = pipe
+ _apply_strict_msgpack(checkpointer)
yield checkpointer
finally:
# drop unique db
@@ -99,6 +133,7 @@ def _checkpointer_postgres_pool():
) as pool:
checkpointer = PostgresSaver(pool)
checkpointer.setup()
+ _apply_strict_msgpack(checkpointer)
yield checkpointer
finally:
# drop unique db
@@ -109,6 +144,7 @@ def _checkpointer_postgres_pool():
@asynccontextmanager
async def _checkpointer_sqlite_aio():
async with AsyncSqliteSaver.from_conn_string(":memory:") as checkpointer:
+ _apply_strict_msgpack(checkpointer)
yield checkpointer
@@ -126,6 +162,7 @@ async def _checkpointer_postgres_aio():
DEFAULT_POSTGRES_URI + database
) as checkpointer:
await checkpointer.setup()
+ _apply_strict_msgpack(checkpointer)
yield checkpointer
finally:
# drop unique db
@@ -152,6 +189,7 @@ async def _checkpointer_postgres_aio_pipe():
# setup can't run inside pipeline because of implicit transaction
async with checkpointer.conn.pipeline() as pipe:
checkpointer.pipe = pipe
+ _apply_strict_msgpack(checkpointer)
yield checkpointer
finally:
# drop unique db
@@ -176,6 +214,7 @@ async def _checkpointer_postgres_aio_pool():
) as pool:
checkpointer = AsyncPostgresSaver(pool)
await checkpointer.setup()
+ _apply_strict_msgpack(checkpointer)
yield checkpointer
finally:
# drop unique db
diff --git a/libs/langgraph/tests/test_pydantic.py b/libs/langgraph/tests/test_pydantic.py
index 87049204f..f49a02871 100644
--- a/libs/langgraph/tests/test_pydantic.py
+++ b/libs/langgraph/tests/test_pydantic.py
@@ -8,6 +8,7 @@ import uuid
from enum import Enum
from typing import Annotated, Literal, Optional
+from langgraph.checkpoint.base import BaseCheckpointSaver
from pydantic import (
BaseModel,
ByteSize,
@@ -23,7 +24,10 @@ from pydantic import (
from langgraph._internal._pydantic import is_supported_by_pydantic
from langgraph.constants import END, START
+from langgraph.func import entrypoint, task
from langgraph.graph.state import StateGraph
+from langgraph.types import Command, Interrupt, interrupt
+from tests.any_str import AnyStr
def test_is_supported_by_pydantic() -> None:
@@ -312,3 +316,47 @@ def test_pydantic_state_field_validator():
g = builder.compile()
res = g.invoke(input_state)
assert res["text"] == "Hello, Validated John!"
+
+
+class FunctionalState(BaseModel):
+ a: str
+ b: str | None = None
+
+
+def test_interrupt_functional_pydantic(sync_checkpointer: BaseCheckpointSaver) -> None:
+ called_count = 0
+
+ @task
+ def foo(state: FunctionalState) -> FunctionalState:
+ nonlocal called_count
+ called_count += 1
+ return FunctionalState(**{"a": state.a + "foo"})
+
+ @task
+ def bar(state: FunctionalState) -> dict:
+ return {"a": state.a + "bar", "b": state.b}
+
+ @entrypoint(checkpointer=sync_checkpointer)
+ def graph(inputs: FunctionalState) -> FunctionalState:
+ fut_foo = foo(inputs)
+ value = interrupt("Provide value for bar:")
+ foo_res = fut_foo.result()
+ assert isinstance(foo_res, FunctionalState)
+ bar_input = FunctionalState(a=foo_res.a, b=value)
+ fut_bar = bar(bar_input)
+ return fut_bar.result()
+
+ config = {"configurable": {"thread_id": "1"}}
+ # First run, interrupted at bar
+ assert graph.invoke(FunctionalState(a=""), config) == {
+ "__interrupt__": [
+ Interrupt(
+ value="Provide value for bar:",
+ id=AnyStr(),
+ )
+ ]
+ }
+ # Resume with an answer
+ res = graph.invoke(Command(resume="bar"), config)
+ assert res == {"a": "foobar", "b": "bar"}
+ assert called_count == 1
diff --git a/libs/langgraph/tests/test_serde_allowlist.py b/libs/langgraph/tests/test_serde_allowlist.py
new file mode 100644
index 000000000..2a90389da
--- /dev/null
+++ b/libs/langgraph/tests/test_serde_allowlist.py
@@ -0,0 +1,159 @@
+from __future__ import annotations
+
+from collections import deque
+from dataclasses import dataclass
+from enum import Enum
+from typing import TYPE_CHECKING, Annotated, Any, Literal, NewType, Optional, Union
+
+import pytest
+from pydantic import BaseModel
+from typing_extensions import NotRequired, Required, TypedDict
+
+from langgraph._internal._serde import (
+ collect_allowlist_from_schemas,
+ curated_core_allowlist,
+)
+
+
+class Color(Enum):
+ RED = "red"
+ BLUE = "blue"
+
+
+@dataclass
+class InnerDataclass:
+ value: int
+
+
+class InnerModel(BaseModel):
+ name: str
+
+
+@dataclass
+class Node:
+ value: int
+ child: Node | None = None
+
+
+if TYPE_CHECKING:
+
+ class MissingType:
+ pass
+
+
+@dataclass
+class MissingRefDataclass:
+ payload: MissingType
+
+
+class Payload(TypedDict):
+ item: InnerDataclass
+ maybe: NotRequired[InnerModel]
+ required: Required[str]
+
+
+@dataclass
+class NestedDataclass:
+ inner: InnerDataclass
+ items: list[InnerModel]
+ mapping: dict[str, InnerDataclass]
+ optional: InnerModel | None
+ union: InnerDataclass | InnerModel
+ queue: deque[InnerDataclass]
+ frozen: frozenset[InnerModel]
+
+
+AnnotatedList = Annotated[list[InnerDataclass], "meta"]
+UserId = NewType("UserId", int)
+
+
+class DummyChannel:
+ @property
+ def ValueType(self) -> type[InnerDataclass]:
+ return InnerDataclass
+
+ @property
+ def UpdateType(self) -> type[InnerModel]:
+ return InnerModel
+
+
+def test_curated_core_allowlist_includes_messages() -> None:
+ try:
+ from langchain_core.messages import BaseMessage
+ except Exception:
+ pytest.skip("langchain_core not available")
+ allowlist = curated_core_allowlist()
+ assert (BaseMessage.__module__, BaseMessage.__name__) in allowlist
+
+
+def test_collect_allowlist_basic_models() -> None:
+ allowlist = collect_allowlist_from_schemas(
+ schemas=[InnerDataclass, InnerModel, Color]
+ )
+ assert (InnerDataclass.__module__, InnerDataclass.__name__) in allowlist
+ assert (InnerModel.__module__, InnerModel.__name__) in allowlist
+ assert (Color.__module__, Color.__name__) in allowlist
+
+
+def test_collect_allowlist_nested_containers() -> None:
+ allowlist = collect_allowlist_from_schemas(schemas=[NestedDataclass])
+ assert (NestedDataclass.__module__, NestedDataclass.__name__) in allowlist
+ assert (InnerDataclass.__module__, InnerDataclass.__name__) in allowlist
+ assert (InnerModel.__module__, InnerModel.__name__) in allowlist
+
+
+def test_collect_allowlist_annotated_and_union() -> None:
+ allowlist = collect_allowlist_from_schemas(
+ schemas=[AnnotatedList, InnerModel | None, InnerDataclass | None]
+ )
+ assert (InnerDataclass.__module__, InnerDataclass.__name__) in allowlist
+ assert (InnerModel.__module__, InnerModel.__name__) in allowlist
+
+
+def test_collect_allowlist_literal_and_any() -> None:
+ allowlist = collect_allowlist_from_schemas(schemas=[Any, Literal["a"]])
+ assert allowlist == set()
+
+
+def test_collect_allowlist_typeddict_fields_only() -> None:
+ allowlist = collect_allowlist_from_schemas(schemas=[Payload])
+ assert (InnerDataclass.__module__, InnerDataclass.__name__) in allowlist
+ assert (InnerModel.__module__, InnerModel.__name__) in allowlist
+ assert (Payload.__module__, Payload.__name__) not in allowlist
+
+
+def test_collect_allowlist_forward_refs() -> None:
+ allowlist = collect_allowlist_from_schemas(schemas=[Node])
+ assert (Node.__module__, Node.__name__) in allowlist
+
+
+def test_collect_allowlist_missing_forward_ref() -> None:
+ allowlist = collect_allowlist_from_schemas(schemas=[MissingRefDataclass])
+ assert allowlist == {(MissingRefDataclass.__module__, MissingRefDataclass.__name__)}
+
+
+def test_collect_allowlist_newtype_supertype() -> None:
+ allowlist = collect_allowlist_from_schemas(schemas=[UserId])
+ assert allowlist == set()
+
+
+def test_collect_allowlist_channels() -> None:
+ channels = {"a": DummyChannel(), "b": DummyChannel()}
+ allowlist = collect_allowlist_from_schemas(channels=channels)
+ assert (InnerDataclass.__module__, InnerDataclass.__name__) in allowlist
+ assert (InnerModel.__module__, InnerModel.__name__) in allowlist
+
+
+def test_collect_allowlist_pep604_union() -> None:
+ schema = InnerDataclass | InnerModel
+ allowlist = collect_allowlist_from_schemas(schemas=[schema])
+ assert (InnerDataclass.__module__, InnerDataclass.__name__) in allowlist
+ assert (InnerModel.__module__, InnerModel.__name__) in allowlist
+
+
+def test_collect_allowlist_typing_union_optional() -> None:
+ typing_optional = Optional[InnerDataclass] # noqa: UP045
+ typing_union = Union[InnerDataclass, InnerModel] # noqa: UP007
+ allowlist = collect_allowlist_from_schemas(schemas=[typing_optional, typing_union])
+ assert (InnerDataclass.__module__, InnerDataclass.__name__) in allowlist
+ assert (InnerModel.__module__, InnerModel.__name__) in allowlist
diff --git a/libs/langgraph/uv.lock b/libs/langgraph/uv.lock
index a5186b591..9b75fe18d 100644
--- a/libs/langgraph/uv.lock
+++ b/libs/langgraph/uv.lock
@@ -1569,6 +1569,7 @@ dev = [
{ name = "numpy" },
{ name = "pandas" },
{ name = "pandas-stubs", specifier = ">=2.2.2.240807" },
+ { name = "pycryptodome", specifier = ">=3.23.0" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
diff --git a/libs/prebuilt/uv.lock b/libs/prebuilt/uv.lock
index 109c99461..351084784 100644
--- a/libs/prebuilt/uv.lock
+++ b/libs/prebuilt/uv.lock
@@ -373,6 +373,7 @@ dev = [
{ name = "numpy" },
{ name = "pandas" },
{ name = "pandas-stubs", specifier = ">=2.2.2.240807" },
+ { name = "pycryptodome", specifier = ">=3.23.0" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
From a04ec5d6f00fa6583b2d98dfe789da741204b767 Mon Sep 17 00:00:00 2001
From: William FH <13333726+hinthornw@users.noreply.github.com>
Date: Thu, 26 Feb 2026 12:09:34 -0800
Subject: [PATCH 04/11] release: Candidate (#6947)
---
libs/checkpoint-postgres/uv.lock | 2 +-
libs/checkpoint-sqlite/uv.lock | 2 +-
libs/checkpoint/pyproject.toml | 2 +-
libs/checkpoint/uv.lock | 2 +-
libs/langgraph/pyproject.toml | 2 +-
libs/langgraph/uv.lock | 4 ++--
libs/prebuilt/uv.lock | 4 ++--
libs/sdk-py/uv.lock | 5 +++--
8 files changed, 12 insertions(+), 11 deletions(-)
diff --git a/libs/checkpoint-postgres/uv.lock b/libs/checkpoint-postgres/uv.lock
index 1cca56676..220d25518 100644
--- a/libs/checkpoint-postgres/uv.lock
+++ b/libs/checkpoint-postgres/uv.lock
@@ -259,7 +259,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
-version = "4.0.0"
+version = "4.0.1rc1"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
diff --git a/libs/checkpoint-sqlite/uv.lock b/libs/checkpoint-sqlite/uv.lock
index 9bde94ddd..6309c1d7a 100644
--- a/libs/checkpoint-sqlite/uv.lock
+++ b/libs/checkpoint-sqlite/uv.lock
@@ -268,7 +268,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
-version = "4.0.0"
+version = "4.0.1rc1"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
diff --git a/libs/checkpoint/pyproject.toml b/libs/checkpoint/pyproject.toml
index df8c1619f..4616f5485 100644
--- a/libs/checkpoint/pyproject.toml
+++ b/libs/checkpoint/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph-checkpoint"
-version = "4.0.0"
+version = "4.0.1rc1"
description = "Library with base interfaces for LangGraph checkpoint savers."
authors = []
requires-python = ">=3.10"
diff --git a/libs/checkpoint/uv.lock b/libs/checkpoint/uv.lock
index cc4384e3d..4149aa0f5 100644
--- a/libs/checkpoint/uv.lock
+++ b/libs/checkpoint/uv.lock
@@ -286,7 +286,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
-version = "4.0.0"
+version = "4.0.1rc1"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
diff --git a/libs/langgraph/pyproject.toml b/libs/langgraph/pyproject.toml
index 6872cfad0..ae93168b3 100644
--- a/libs/langgraph/pyproject.toml
+++ b/libs/langgraph/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph"
-version = "1.0.9"
+version = "1.0.10rc1"
description = "Building stateful, multi-actor applications with LLMs"
authors = []
requires-python = ">=3.10"
diff --git a/libs/langgraph/uv.lock b/libs/langgraph/uv.lock
index 9b75fe18d..f025df068 100644
--- a/libs/langgraph/uv.lock
+++ b/libs/langgraph/uv.lock
@@ -1367,7 +1367,7 @@ wheels = [
[[package]]
name = "langgraph"
-version = "1.0.9"
+version = "1.0.10rc1"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
@@ -1548,7 +1548,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
-version = "4.0.0"
+version = "4.0.1rc1"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
diff --git a/libs/prebuilt/uv.lock b/libs/prebuilt/uv.lock
index 351084784..7a4b5544a 100644
--- a/libs/prebuilt/uv.lock
+++ b/libs/prebuilt/uv.lock
@@ -268,7 +268,7 @@ wheels = [
[[package]]
name = "langgraph"
-version = "1.0.9"
+version = "1.0.10rc1"
source = { editable = "../langgraph" }
dependencies = [
{ name = "langchain-core" },
@@ -352,7 +352,7 @@ test = [
[[package]]
name = "langgraph-checkpoint"
-version = "4.0.0"
+version = "4.0.1rc1"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
diff --git a/libs/sdk-py/uv.lock b/libs/sdk-py/uv.lock
index e0df98114..3a3c95562 100644
--- a/libs/sdk-py/uv.lock
+++ b/libs/sdk-py/uv.lock
@@ -265,7 +265,7 @@ wheels = [
[[package]]
name = "langgraph"
-version = "1.0.9"
+version = "1.0.10rc1"
source = { editable = "../langgraph" }
dependencies = [
{ name = "langchain-core" },
@@ -349,7 +349,7 @@ test = [
[[package]]
name = "langgraph-checkpoint"
-version = "4.0.0"
+version = "4.0.1rc1"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
@@ -370,6 +370,7 @@ dev = [
{ name = "numpy" },
{ name = "pandas" },
{ name = "pandas-stubs", specifier = ">=2.2.2.240807" },
+ { name = "pycryptodome", specifier = ">=3.23.0" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
From d542d8aecb1a7be445a485524258b905ddf99af0 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Thu, 26 Feb 2026 12:59:47 -0800
Subject: [PATCH 05/11] chore(deps-dev): bump the all-dependencies group across
1 directory with 3 updates (#6946)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps the all-dependencies group with 3 updates in the /libs/langgraph
directory: [langchain-core](https://github.com/langchain-ai/langchain),
[redis](https://github.com/redis/redis-py) and
[ruff](https://github.com/astral-sh/ruff).
Updates `langchain-core` from 1.2.13 to 1.2.16
Release notes
Sourced from langchain-core's
releases .
langchain-core==1.2.16
Changes since langchain-core==1.2.15
release(core): 1.2.16 (#35439 )
fix(core): treat empty tool chunk ids as missing in merge (#35414 )
langchain-core==1.2.15
Changes since langchain-core==1.2.14
fix(core): improve error message for non-JSON-serializable tool
schemas (#34376 )
fix(core): improve typing/docs for on_chat_model_start to clarify
required positional args (#35324 )
perf(core): defer specific langsmith imports to reduce
import time (#35298 )
revert: add ChatAnthropicBedrockWrapper (#35371 )
release(core): 1.2.15 (#35367 )
fix(anthropic): replace retired model IDs in tests and docstrings (#35365 )
feat(anthropic): add ChatAnthropicBedrock wrapper (#35091 )
style: fix some ruff noqa (#35321 )
langchain-core==1.2.14
Changes since langchain-core==1.2.13
release(core): 1.2.14 (#35328 )
chore(core): remove langserve from sys info util, add
deepagents (#35325 )
fix(core): fix merge_lists incorrectly merging parallel tool calls (#35281 )
fix(core): accept int temperature in _get_ls_params for LangSmith
tracing (#35302 )
revert: accept integer temperature values in _get_ls_params (#35319 )
fix(core): accept integer temperature values in _get_ls_params (#35317 )
docs(core): update load note to be precise (#35309 )
fix(core): prevent recursion error when args_schema is dict (#35260 )
fix(core): preserve index and timestamp fields when merging (#34731 )
docs(core): add security warnings and best practices for deserialization
(#35282 )
docs: fix docstring inaccuracies and update outdated LangSmith URLs (#35283 )
fix(core): correct misleading jinja2 sandboxing comment (#35183 )
chore: bump the langchain-deps group across 3 directories with 8 updates
(#35257 )
Commits
94a5882
release(core): 1.2.16 (#35439 )
7867853
fix(core): treat empty tool chunk ids as missing in merge (#35414 )
4ffb584
release(anthropic): 1.3.4 (#35418 )
cdb9742
fix(anthropic): filter out common OpenAI Responses block types (#35417 )
0b975d4
chore: bump the other-deps group across 3 directories with 2 updates (#35407 )
2d1492a
fix(core): improve error message for non-JSON-serializable tool schemas
(#34376 )
d6e46bb
fix(core): improve typing/docs for on_chat_model_start to clarify
required po...
875c3c5
chore: bump google-cloud-aiplatform from 1.127.0 to 1.133.0 in
/libs/langchai...
32725b3
chore: bump google-cloud-aiplatform from 1.117.0 to 1.133.0 in
/libs/langchai...
2fa460d
fix(anthropic): update integration tests (#35396 )
Additional commits viewable in compare
view
Updates `redis` from 7.2.0 to 7.2.1
Release notes
Sourced from redis's
releases .
7.2.1
Changes
🐛 Bug Fixes
Handle connection attributes conditionally for metrics and set
connection data on exceptions in cluster error handling (#3964 )
⚠️ Deprecations
Removed batch_size and consumer_name attributes from OTel metrics
(#3978 )
🧰 Maintenance
Fixing error handling of connection buffer purging of closed
connections. Enabling troubleshooting logging for maintenance
notifications e2e tests. (#3971 )
Fix protocol validation: replace finally with else and store parsed
int (#3965 )
Return copies from _get_free/in_use_connections and fix async _mock
(#3967 )
Add missing shard channel message types to async PubSub (#3966 )
Fix issues with ClusterPipeline connection management (#3804 )
fix(pubsub): avoid UnicodeDecodeError on reconnect with binary
channel names (#3944 )
Hold references to ClusterNode disconnect task (#3826 )
remove remaining imports of typing_extensions (#3873 )
We'd like to thank all the contributors who worked on this release!
@dotlambda @rhoboro @skylarkoo7 @praboud @bysiber @vladvildanov
@petyaslavova
Commits
56859cf
Updating lib version to 7.2.1
c671fd9
remove remaining imports of typing_extensions (#3873 )
e203796
Hold references to ClusterNode disconnect task (#3826 )
a21f768
Removed batch_size and consumer_name attributes from OTel metrics (#3978 )
2098114
fix(pubsub): avoid UnicodeDecodeError on reconnect with binary channel
names ...
f02c66b
Fix issues with ClusterPipeline connection management (#3804 )
1958065
Add missing shard channel message types to async PubSub (#3966 )
abc519d
Return copies from _get_free/in_use_connections and fix async _mock (#3967 )
bb2b6f3
Fix protocol validation: replace finally with else and store parsed int
(#3965 )
631c053
Fixing error handling of connection buffer purging of closed connecton.
Enabl...
Additional commits viewable in compare
view
Updates `ruff` from 0.15.1 to 0.15.4
Release notes
Sourced from ruff's
releases .
0.15.4
Release Notes
Released on 2026-02-26.
This is a follow-up release to 0.15.3 that resolves a panic when the
new rule PLR1712 was enabled with any rule that analyzes
definitions, such as many of the ANN or D
rules.
Bug fixes
Fix panic on access to definitions after analyzing definitions (#23588 )
[pyflakes] Suppress false positive in F821
for names used before del in stub files (#23550 )
Documentation
Clarify first-party import detection in Ruff (#23591 )
Fix incorrect import-heading example (#23568 )
Contributors
Install ruff 0.15.4
Install prebuilt binaries via shell script
curl --proto '=https' --tlsv1.2 -LsSf
https://github.com/astral-sh/ruff/releases/download/0.15.4/ruff-installer.sh
| sh
Install prebuilt binaries via powershell script
powershell -ExecutionPolicy Bypass -c "irm
https://github.com/astral-sh/ruff/releases/download/0.15.4/ruff-installer.ps1
| iex"
Download ruff 0.15.4
... (truncated)
Changelog
Sourced from ruff's
changelog .
0.15.4
Released on 2026-02-26.
This is a follow-up release to 0.15.3 that resolves a panic when the
new rule PLR1712 was enabled with any rule that analyzes
definitions, such as many of the ANN or D
rules.
Bug fixes
Fix panic on access to definitions after analyzing definitions (#23588 )
[pyflakes] Suppress false positive in F821
for names used before del in stub files (#23550 )
Documentation
Clarify first-party import detection in Ruff (#23591 )
Fix incorrect import-heading example (#23568 )
Contributors
0.15.3
Released on 2026-02-26.
Preview features
Drop explicit support for .qmd file extension (#23572 )
This can now be enabled instead by setting the extension
option:
# ruff.toml
extension = { qmd = "markdown" }
pyproject.toml
[tool.ruff]
extension = { qmd = "markdown" }
Include configured extensions in file discovery (#23400 )
[flake8-bandit] Allow suspicious imports in
TYPE_CHECKING blocks (S401-S415)
(#23441 )
[flake8-bugbear] Allow B901 in pytest hook
wrappers (#21931 )
[flake8-import-conventions] Add missing conventions from
upstream (ICN001, ICN002) (#21373 )
... (truncated)
Commits
f14edd8
Bump 0.15.4 (#23595 )
fd09d37
Fix panic on access to definitions after analyzing definitions (#23588 )
81d655f
[pyflakes] suppress false positive in F821 for
names used before del in...
625b4f5
[ruff] docs: Clarify first-party import detection in Ruff (#23591 )
60facfa
one word typo fix in a while_loop.md test case (#23589 )
fbb9fa7
docs: fix incorrect import-heading example (#23568 )
5bc49a9
Increase the ruleset size to 16 bits (#23586 )
a62ba8c
[ty] Fix overloaded callable assignability for unary Callable targets
(#23277 )
e5f2f36
Bump 0.15.3 (#23585 )
0e19fc9
[ty] defer calculating conjunctions in narrowing constraints (#23552 )
Additional commits viewable in compare
view
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore ` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore ` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore ` will
remove the ignore condition of the specified dependency and ignore
conditions
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
libs/langgraph/uv.lock | 50 +++++++++++++++++++++---------------------
1 file changed, 25 insertions(+), 25 deletions(-)
diff --git a/libs/langgraph/uv.lock b/libs/langgraph/uv.lock
index f025df068..f0a1c2a8e 100644
--- a/libs/langgraph/uv.lock
+++ b/libs/langgraph/uv.lock
@@ -1348,7 +1348,7 @@ wheels = [
[[package]]
name = "langchain-core"
-version = "1.2.13"
+version = "1.2.16"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonpatch" },
@@ -1360,9 +1360,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "uuid-utils" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/fb/bb/c501ca60556c11ac80d1454bdcac63cb33583ce4e64fc4535ad5a7d5c6ba/langchain_core-1.2.13.tar.gz", hash = "sha256:d2773d0d0130a356378db9a858cfeef64c3d64bc03722f1d4d6c40eb46fdf01b", size = 831612, upload-time = "2026-02-15T07:45:57.014Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/2e/a7/4c992456dae89a8704afec03e3c2a0149ccc5f29c1cbdd5f4aa77628e921/langchain_core-1.2.16.tar.gz", hash = "sha256:055a4bfe7d62f4ac45ed49fd759ee2e6bdd15abf998fbeea695fda5da2de6413", size = 835286, upload-time = "2026-02-25T16:27:30.551Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/12/ab/60fd69e5d55f67d422baefddaaca523c42cd7510ab6aeb17db6ae57fb107/langchain_core-1.2.13-py3-none-any.whl", hash = "sha256:b31823e28d3eff1e237096d0bd3bf80c6f9624eb471a9496dbfbd427779f8d82", size = 500485, upload-time = "2026-02-15T07:45:55.422Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/a1/57d5feaa11dc2ebb40f3bc3d7bf4294b6703e152e56edea9d4c622475a6a/langchain_core-1.2.16-py3-none-any.whl", hash = "sha256:2768add9aa97232a7712580f678e0ba045ee1036c71fe471355be0434fcb6e30", size = 502219, upload-time = "2026-02-25T16:27:29.379Z" },
]
[[package]]
@@ -3181,14 +3181,14 @@ wheels = [
[[package]]
name = "redis"
-version = "7.2.0"
+version = "7.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "async-timeout", marker = "python_full_version < '3.11.3'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/9f/32/6fac13a11e73e1bc67a2ae821a72bfe4c2d8c4c48f0267e4a952be0f1bae/redis-7.2.0.tar.gz", hash = "sha256:4dd5bf4bd4ae80510267f14185a15cba2a38666b941aff68cccf0256b51c1f26", size = 4901247, upload-time = "2026-02-16T17:16:22.797Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e9/31/1476f206482dd9bc53fdbbe9f6fbd5e05d153f18e54667ce839df331f2e6/redis-7.2.1.tar.gz", hash = "sha256:6163c1a47ee2d9d01221d8456bc1c75ab953cbda18cfbc15e7140e9ba16ca3a5", size = 4906735, upload-time = "2026-02-25T20:05:18.171Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/86/cf/f6180b67f99688d83e15c84c5beda831d1d341e95872d224f87ccafafe61/redis-7.2.0-py3-none-any.whl", hash = "sha256:01f591f8598e483f1842d429e8ae3a820804566f1c73dca1b80e23af9fba0497", size = 394898, upload-time = "2026-02-16T17:16:20.693Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/98/1dd1a5c060916cf21d15e67b7d6a7078e26e2605d5c37cbc9f4f5454c478/redis-7.2.1-py3-none-any.whl", hash = "sha256:49e231fbc8df2001436ae5252b3f0f3dc930430239bfeb6da4c7ee92b16e5d33", size = 396057, upload-time = "2026-02-25T20:05:16.533Z" },
]
[[package]]
@@ -3389,27 +3389,27 @@ wheels = [
[[package]]
name = "ruff"
-version = "0.15.1"
+version = "0.15.4"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/04/dc/4e6ac71b511b141cf626357a3946679abeba4cf67bc7cc5a17920f31e10d/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f", size = 4540855, upload-time = "2026-02-12T23:09:09.998Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/da/31/d6e536cdebb6568ae75a7f00e4b4819ae0ad2640c3604c305a0428680b0c/ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1", size = 4569550, upload-time = "2026-02-26T20:04:14.959Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/23/bf/e6e4324238c17f9d9120a9d60aa99a7daaa21204c07fcd84e2ef03bb5fd1/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a", size = 10367819, upload-time = "2026-02-12T23:09:03.598Z" },
- { url = "https://files.pythonhosted.org/packages/b3/ea/c8f89d32e7912269d38c58f3649e453ac32c528f93bb7f4219258be2e7ed/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602", size = 10798618, upload-time = "2026-02-12T23:09:22.928Z" },
- { url = "https://files.pythonhosted.org/packages/5e/0f/1d0d88bc862624247d82c20c10d4c0f6bb2f346559d8af281674cf327f15/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899", size = 10148518, upload-time = "2026-02-12T23:08:58.339Z" },
- { url = "https://files.pythonhosted.org/packages/f5/c8/291c49cefaa4a9248e986256df2ade7add79388fe179e0691be06fae6f37/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16", size = 10518811, upload-time = "2026-02-12T23:09:31.865Z" },
- { url = "https://files.pythonhosted.org/packages/c3/1a/f5707440e5ae43ffa5365cac8bbb91e9665f4a883f560893829cf16a606b/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc", size = 10196169, upload-time = "2026-02-12T23:09:17.306Z" },
- { url = "https://files.pythonhosted.org/packages/2a/ff/26ddc8c4da04c8fd3ee65a89c9fb99eaa5c30394269d424461467be2271f/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779", size = 10990491, upload-time = "2026-02-12T23:09:25.503Z" },
- { url = "https://files.pythonhosted.org/packages/fc/00/50920cb385b89413f7cdb4bb9bc8fc59c1b0f30028d8bccc294189a54955/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb", size = 11843280, upload-time = "2026-02-12T23:09:19.88Z" },
- { url = "https://files.pythonhosted.org/packages/5d/6d/2f5cad8380caf5632a15460c323ae326f1e1a2b5b90a6ee7519017a017ca/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83", size = 11274336, upload-time = "2026-02-12T23:09:14.907Z" },
- { url = "https://files.pythonhosted.org/packages/a3/1d/5f56cae1d6c40b8a318513599b35ea4b075d7dc1cd1d04449578c29d1d75/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2", size = 11137288, upload-time = "2026-02-12T23:09:07.475Z" },
- { url = "https://files.pythonhosted.org/packages/cd/20/6f8d7d8f768c93b0382b33b9306b3b999918816da46537d5a61635514635/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454", size = 11070681, upload-time = "2026-02-12T23:08:55.43Z" },
- { url = "https://files.pythonhosted.org/packages/9a/67/d640ac76069f64cdea59dba02af2e00b1fa30e2103c7f8d049c0cff4cafd/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c", size = 10486401, upload-time = "2026-02-12T23:09:27.927Z" },
- { url = "https://files.pythonhosted.org/packages/65/3d/e1429f64a3ff89297497916b88c32a5cc88eeca7e9c787072d0e7f1d3e1e/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330", size = 10197452, upload-time = "2026-02-12T23:09:12.147Z" },
- { url = "https://files.pythonhosted.org/packages/78/83/e2c3bade17dad63bf1e1c2ffaf11490603b760be149e1419b07049b36ef2/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61", size = 10693900, upload-time = "2026-02-12T23:09:34.418Z" },
- { url = "https://files.pythonhosted.org/packages/a1/27/fdc0e11a813e6338e0706e8b39bb7a1d61ea5b36873b351acee7e524a72a/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f", size = 11227302, upload-time = "2026-02-12T23:09:36.536Z" },
- { url = "https://files.pythonhosted.org/packages/f6/58/ac864a75067dcbd3b95be5ab4eb2b601d7fbc3d3d736a27e391a4f92a5c1/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098", size = 10462555, upload-time = "2026-02-12T23:09:29.899Z" },
- { url = "https://files.pythonhosted.org/packages/e0/5e/d4ccc8a27ecdb78116feac4935dfc39d1304536f4296168f91ed3ec00cd2/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336", size = 11599956, upload-time = "2026-02-12T23:09:01.157Z" },
- { url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/82/c11a03cfec3a4d26a0ea1e571f0f44be5993b923f905eeddfc397c13d360/ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0", size = 10453333, upload-time = "2026-02-26T20:04:20.093Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/5d/6a1f271f6e31dffb31855996493641edc3eef8077b883eaf007a2f1c2976/ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992", size = 10853356, upload-time = "2026-02-26T20:04:05.808Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/d8/0fab9f8842b83b1a9c2bf81b85063f65e93fb512e60effa95b0be49bfc54/ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba", size = 10187434, upload-time = "2026-02-26T20:03:54.656Z" },
+ { url = "https://files.pythonhosted.org/packages/85/cc/cc220fd9394eff5db8d94dec199eec56dd6c9f3651d8869d024867a91030/ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75", size = 10535456, upload-time = "2026-02-26T20:03:52.738Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/0f/bced38fa5cf24373ec767713c8e4cadc90247f3863605fb030e597878661/ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac", size = 10287772, upload-time = "2026-02-26T20:04:08.138Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/90/58a1802d84fed15f8f281925b21ab3cecd813bde52a8ca033a4de8ab0e7a/ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a", size = 11049051, upload-time = "2026-02-26T20:04:03.53Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/ac/b7ad36703c35f3866584564dc15f12f91cb1a26a897dc2fd13d7cb3ae1af/ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85", size = 11890494, upload-time = "2026-02-26T20:04:10.497Z" },
+ { url = "https://files.pythonhosted.org/packages/93/3d/3eb2f47a39a8b0da99faf9c54d3eb24720add1e886a5309d4d1be73a6380/ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db", size = 11326221, upload-time = "2026-02-26T20:04:12.84Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/90/bf134f4c1e5243e62690e09d63c55df948a74084c8ac3e48a88468314da6/ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec", size = 11168459, upload-time = "2026-02-26T20:04:00.969Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/e5/a64d27688789b06b5d55162aafc32059bb8c989c61a5139a36e1368285eb/ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f", size = 11104366, upload-time = "2026-02-26T20:03:48.099Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/f6/32d1dcb66a2559763fc3027bdd65836cad9eb09d90f2ed6a63d8e9252b02/ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338", size = 10510887, upload-time = "2026-02-26T20:03:45.771Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/92/22d1ced50971c5b6433aed166fcef8c9343f567a94cf2b9d9089f6aa80fe/ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc", size = 10285939, upload-time = "2026-02-26T20:04:22.42Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/f4/7c20aec3143837641a02509a4668fb146a642fd1211846634edc17eb5563/ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68", size = 10765471, upload-time = "2026-02-26T20:03:58.924Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/09/6d2f7586f09a16120aebdff8f64d962d7c4348313c77ebb29c566cefc357/ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3", size = 11263382, upload-time = "2026-02-26T20:04:24.424Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/fa/2ef715a1cd329ef47c1a050e10dee91a9054b7ce2fcfdd6a06d139afb7ec/ruff-0.15.4-py3-none-win32.whl", hash = "sha256:65594a2d557d4ee9f02834fcdf0a28daa8b3b9f6cb2cb93846025a36db47ef22", size = 10506664, upload-time = "2026-02-26T20:03:50.56Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/a8/c688ef7e29983976820d18710f955751d9f4d4eb69df658af3d006e2ba3e/ruff-0.15.4-py3-none-win_amd64.whl", hash = "sha256:04196ad44f0df220c2ece5b0e959c2f37c777375ec744397d21d15b50a75264f", size = 11651048, upload-time = "2026-02-26T20:04:17.191Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/0a/9e1be9035b37448ce2e68c978f0591da94389ade5a5abafa4cf99985d1b2/ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453", size = 10966776, upload-time = "2026-02-26T20:03:56.908Z" },
]
[[package]]
From e2e90da5dc8edc5b52a73de2ffaf6a99923c67da Mon Sep 17 00:00:00 2001
From: William FH <13333726+hinthornw@users.noreply.github.com>
Date: Thu, 26 Feb 2026 13:08:00 -0800
Subject: [PATCH 06/11] chore: improve subclass handling (#6948)
If subclass doesn't support the new parameter, we the current
implementation would create an error.
---
.../langgraph/checkpoint/serde/jsonplus.py | 18 +++++----
libs/checkpoint/tests/test_jsonplus.py | 37 +++++++++++++++++++
2 files changed, 47 insertions(+), 8 deletions(-)
diff --git a/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py b/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py
index c267ac8be..e144303bc 100644
--- a/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py
+++ b/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import copy
import dataclasses
import decimal
import importlib
@@ -106,14 +107,15 @@ class JsonPlusSerializer(SerializerProtocol):
allowed_msgpack_modules = tuple(self._allowed_msgpack_modules)
else:
allowed_msgpack_modules = self._allowed_msgpack_modules
- return self.__class__(
- pickle_fallback=self.pickle_fallback,
- allowed_json_modules=self._allowed_json_modules,
- allowed_msgpack_modules=allowed_msgpack_modules,
- __unpack_ext_hook__=(
- self._unpack_ext_hook if self._custom_unpack_ext_hook else None
- ),
- )
+
+ clone = copy.copy(self)
+ clone._allowed_json_modules = _normalize_allowlist(self._allowed_json_modules)
+ clone._allowed_msgpack_modules = _normalize_allowlist(allowed_msgpack_modules)
+ if not clone._custom_unpack_ext_hook:
+ clone._unpack_ext_hook = _create_msgpack_ext_hook(
+ clone._allowed_msgpack_modules
+ )
+ return clone
def _encode_constructor_args(
self,
diff --git a/libs/checkpoint/tests/test_jsonplus.py b/libs/checkpoint/tests/test_jsonplus.py
index e2d9690d3..151b1224e 100644
--- a/libs/checkpoint/tests/test_jsonplus.py
+++ b/libs/checkpoint/tests/test_jsonplus.py
@@ -777,6 +777,43 @@ def test_with_msgpack_allowlist_noop_returns_same_instance() -> None:
assert result is serde
+def test_with_msgpack_allowlist_supports_subclass_without_init_kwargs() -> None:
+ class CustomSerializer(JsonPlusSerializer):
+ def __init__(self) -> None:
+ super().__init__(allowed_msgpack_modules=None)
+
+ serde = CustomSerializer()
+ result = serde.with_msgpack_allowlist([MyDataclass])
+
+ assert isinstance(result, CustomSerializer)
+ assert result is not serde
+ assert serde._allowed_msgpack_modules is None
+ assert result._allowed_msgpack_modules == {
+ (MyDataclass.__module__, MyDataclass.__name__)
+ }
+
+
+def test_with_msgpack_allowlist_rebuilds_default_unpack_hook() -> None:
+ serde = JsonPlusSerializer(allowed_msgpack_modules=None)
+ original_hook = serde._unpack_ext_hook
+
+ result = serde.with_msgpack_allowlist([MyDataclass])
+
+ assert result._unpack_ext_hook is not original_hook
+
+
+def test_with_msgpack_allowlist_preserves_custom_unpack_hook() -> None:
+ def custom_hook(code: int, data: bytes) -> None:
+ return None
+
+ serde = JsonPlusSerializer(
+ allowed_msgpack_modules=None, __unpack_ext_hook__=custom_hook
+ )
+ result = serde.with_msgpack_allowlist([MyDataclass])
+
+ assert result._unpack_ext_hook is custom_hook
+
+
@pytest.mark.skipif(sys.version_info >= (3, 14), reason="pydantic v1 not on 3.14+")
def test_msgpack_pydantic_v1_allowlist(caplog: pytest.LogCaptureFixture) -> None:
"""Pydantic v1 models in allowlist should deserialize without warnings."""
From 1b37ece92f73e3e7b4268027da1f41c0be724f58 Mon Sep 17 00:00:00 2001
From: William FH <13333726+hinthornw@users.noreply.github.com>
Date: Thu, 26 Feb 2026 13:20:13 -0800
Subject: [PATCH 07/11] release: rc2 (#6949)
---
libs/checkpoint-postgres/uv.lock | 2 +-
libs/checkpoint-sqlite/uv.lock | 2 +-
libs/checkpoint/pyproject.toml | 2 +-
libs/checkpoint/uv.lock | 2 +-
libs/langgraph/uv.lock | 2 +-
libs/prebuilt/uv.lock | 2 +-
libs/sdk-py/uv.lock | 2 +-
7 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/libs/checkpoint-postgres/uv.lock b/libs/checkpoint-postgres/uv.lock
index 220d25518..436318950 100644
--- a/libs/checkpoint-postgres/uv.lock
+++ b/libs/checkpoint-postgres/uv.lock
@@ -259,7 +259,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
-version = "4.0.1rc1"
+version = "4.0.1rc2"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
diff --git a/libs/checkpoint-sqlite/uv.lock b/libs/checkpoint-sqlite/uv.lock
index 6309c1d7a..1248a4542 100644
--- a/libs/checkpoint-sqlite/uv.lock
+++ b/libs/checkpoint-sqlite/uv.lock
@@ -268,7 +268,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
-version = "4.0.1rc1"
+version = "4.0.1rc2"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
diff --git a/libs/checkpoint/pyproject.toml b/libs/checkpoint/pyproject.toml
index 4616f5485..86fb3dfc8 100644
--- a/libs/checkpoint/pyproject.toml
+++ b/libs/checkpoint/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph-checkpoint"
-version = "4.0.1rc1"
+version = "4.0.1rc2"
description = "Library with base interfaces for LangGraph checkpoint savers."
authors = []
requires-python = ">=3.10"
diff --git a/libs/checkpoint/uv.lock b/libs/checkpoint/uv.lock
index 4149aa0f5..59b87504c 100644
--- a/libs/checkpoint/uv.lock
+++ b/libs/checkpoint/uv.lock
@@ -286,7 +286,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
-version = "4.0.1rc1"
+version = "4.0.1rc2"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
diff --git a/libs/langgraph/uv.lock b/libs/langgraph/uv.lock
index f0a1c2a8e..4c25db91c 100644
--- a/libs/langgraph/uv.lock
+++ b/libs/langgraph/uv.lock
@@ -1548,7 +1548,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
-version = "4.0.1rc1"
+version = "4.0.1rc2"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
diff --git a/libs/prebuilt/uv.lock b/libs/prebuilt/uv.lock
index 7a4b5544a..b1ef0cf3b 100644
--- a/libs/prebuilt/uv.lock
+++ b/libs/prebuilt/uv.lock
@@ -352,7 +352,7 @@ test = [
[[package]]
name = "langgraph-checkpoint"
-version = "4.0.1rc1"
+version = "4.0.1rc2"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
diff --git a/libs/sdk-py/uv.lock b/libs/sdk-py/uv.lock
index 3a3c95562..8429a175c 100644
--- a/libs/sdk-py/uv.lock
+++ b/libs/sdk-py/uv.lock
@@ -349,7 +349,7 @@ test = [
[[package]]
name = "langgraph-checkpoint"
-version = "4.0.1rc1"
+version = "4.0.1rc2"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
From 1f31e0b9b6c70dff7b56d81d77357bf2176b6907 Mon Sep 17 00:00:00 2001
From: William FH <13333726+hinthornw@users.noreply.github.com>
Date: Thu, 26 Feb 2026 14:22:43 -0800
Subject: [PATCH 08/11] chore: add wf dispatch to CI (#6951)
---
.github/workflows/ci.yml | 1 +
1 file changed, 1 insertion(+)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d359a595c..f3207c4fe 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,6 +2,7 @@
name: CI
on:
+ workflow_dispatch:
push:
branches:
- main
From 5ddfce18144cc257992185151267cc0f545dfb80 Mon Sep 17 00:00:00 2001
From: William FH <13333726+hinthornw@users.noreply.github.com>
Date: Thu, 26 Feb 2026 14:45:02 -0800
Subject: [PATCH 09/11] chore: support workflow dispatch on ci (#6952)
---
.github/workflows/_integration_test.yml | 23 ++++++-------
.github/workflows/_lint.yml | 15 +++++----
.github/workflows/_test_langgraph.yml | 7 ++++
.github/workflows/ci.yml | 6 ++--
.../langgraph/checkpoint/serde/_msgpack.py | 18 +++++++++++
libs/checkpoint/tests/test_jsonplus.py | 32 +++++++++++++++++++
6 files changed, 81 insertions(+), 20 deletions(-)
diff --git a/.github/workflows/_integration_test.yml b/.github/workflows/_integration_test.yml
index 1f0bf5f2e..1b3b533c9 100644
--- a/.github/workflows/_integration_test.yml
+++ b/.github/workflows/_integration_test.yml
@@ -40,11 +40,12 @@ jobs:
- uses: actions/checkout@v6
- name: Get changed files
id: changed-files
+ if: github.event_name != 'workflow_dispatch'
uses: Ana06/get-changed-files@v2.3.0
with:
filter: "libs/cli/**"
- name: Set up Python ${{ matrix.python-version }}
- if: steps.changed-files.outputs.all
+ if: (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch')
uses: astral-sh/setup-uv@v7
with:
python-version: ${{ matrix.python-version }}
@@ -52,15 +53,15 @@ jobs:
cache-suffix: "cli-integration-test"
ignore-nothing-to-cache: true
- name: Install cli globally
- if: steps.changed-files.outputs.all
+ if: (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch')
run: pip install -e .
- name: Build service ${{ matrix.example.name }}
- if: steps.changed-files.outputs.all
+ if: (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch')
working-directory: ${{ matrix.example.workdir }}
run: |
langgraph build -t ${{ matrix.example.tag }}
- name: Test service ${{ matrix.example.name }}
- if: ${{ steps.changed-files.outputs.all && env.HAS_LANGSMITH_API_KEY == 'true' }}
+ if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&env.HAS_LANGSMITH_API_KEY == 'true' }}
working-directory: ${{ matrix.example.workdir }}
env:
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
@@ -74,24 +75,24 @@ jobs:
timeout 60 python "$REPO_ROOT/.github/scripts/run_langgraph_cli_test.py" -t ${{ matrix.example.tag }}
- name: Build JS service
- if: ${{ steps.changed-files.outputs.all && matrix.example.name == 'A' }}
+ if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
working-directory: libs/cli/js-examples
run: |
langgraph build -t langgraph-test-e
- name: Build JS monorepo service
- if: ${{ steps.changed-files.outputs.all && matrix.example.name == 'A' }}
+ if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
working-directory: libs/cli/js-monorepo-example
run: |
langgraph build -t langgraph-test-f -c apps/agent/langgraph.json --build-command "yarn run turbo build" --install-command "yarn install"
- name: Build Python monorepo service
- if: ${{ steps.changed-files.outputs.all && matrix.example.name == 'A' }}
+ if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
working-directory: libs/cli/python-monorepo-example
run: |
langgraph build -t langgraph-test-g -c apps/agent/langgraph.json
- name: Test Python monorepo service
- if: ${{ steps.changed-files.outputs.all && matrix.example.name == 'A' && env.HAS_LANGSMITH_API_KEY == 'true' }}
+ if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' && env.HAS_LANGSMITH_API_KEY == 'true' }}
working-directory: libs/cli/python-monorepo-example
env:
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
@@ -101,12 +102,12 @@ jobs:
timeout 60 python ../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-g -c apps/agent/langgraph.json
- name: Build prerelease reqs service
- if: ${{ steps.changed-files.outputs.all && matrix.example.name == 'A' }}
+ if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
working-directory: libs/cli/examples/graph_prerelease_reqs
run: |
langgraph build -t langgraph-test-h
- name: Test prerelease reqs service
- if: ${{ steps.changed-files.outputs.all && matrix.example.name == 'A' && env.HAS_LANGSMITH_API_KEY == 'true' }}
+ if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' && env.HAS_LANGSMITH_API_KEY == 'true' }}
working-directory: libs/cli/examples/graph_prerelease_reqs
env:
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
@@ -132,7 +133,7 @@ jobs:
fi
- name: Build and test prerelease reqs fail service
- if: ${{ steps.changed-files.outputs.all && matrix.example.name == 'A' }}
+ if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
working-directory: libs/cli/examples/graph_prerelease_reqs_fail
run: |
langgraph build -t langgraph-test-i || [ $? -eq 1 ]
diff --git a/.github/workflows/_lint.yml b/.github/workflows/_lint.yml
index 260347379..b47172bde 100644
--- a/.github/workflows/_lint.yml
+++ b/.github/workflows/_lint.yml
@@ -34,11 +34,12 @@ jobs:
- uses: actions/checkout@v6
- name: Get changed files
id: changed-files
+ if: github.event_name != 'workflow_dispatch'
uses: Ana06/get-changed-files@v2.3.0
with:
filter: "${{ inputs.working-directory }}/**"
- name: Set up Python ${{ matrix.python-version }}
- if: steps.changed-files.outputs.all
+ if: steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch'
uses: astral-sh/setup-uv@v7
with:
python-version: ${{ matrix.python-version }}
@@ -46,12 +47,12 @@ jobs:
cache-suffix: lint-${{ inputs.working-directory }}
- name: Install dependencies
- if: steps.changed-files.outputs.all
+ if: steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch'
working-directory: ${{ inputs.working-directory }}
run: uv sync --frozen --group lint
- name: Get .mypy_cache to speed up mypy
- if: steps.changed-files.outputs.all
+ if: steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch'
uses: actions/cache@v5
env:
SEGMENT_DOWNLOAD_TIMEOUT_MIN: "2"
@@ -61,7 +62,7 @@ jobs:
key: mypy-lint-${{ runner.os }}-${{ runner.arch }}-py${{ matrix.python-version }}-${{ inputs.working-directory }}-${{ hashFiles(format('{0}/uv.lock', inputs.working-directory)) }}
- name: Analysing package code with our lint
- if: steps.changed-files.outputs.all
+ if: steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch'
working-directory: ${{ inputs.working-directory }}
run: |
if make lint_package > /dev/null 2>&1; then
@@ -72,12 +73,12 @@ jobs:
fi
- name: Install test dependencies
- if: steps.changed-files.outputs.all
+ if: steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch'
working-directory: ${{ inputs.working-directory }}
run: uv sync --group lint
- name: Get .mypy_cache_test to speed up mypy
- if: steps.changed-files.outputs.all
+ if: steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch'
uses: actions/cache@v5
env:
SEGMENT_DOWNLOAD_TIMEOUT_MIN: "2"
@@ -87,7 +88,7 @@ jobs:
key: mypy-test-${{ runner.os }}-${{ runner.arch }}-py${{ matrix.python-version }}-${{ inputs.working-directory }}-${{ hashFiles(format('{0}/uv.lock', inputs.working-directory)) }}
- name: Analysing tests with our lint
- if: steps.changed-files.outputs.all
+ if: steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch'
working-directory: ${{ inputs.working-directory }}
run: |
if make lint_tests > /dev/null 2>&1; then
diff --git a/.github/workflows/_test_langgraph.yml b/.github/workflows/_test_langgraph.yml
index 3aee8b3f9..6763a9def 100644
--- a/.github/workflows/_test_langgraph.yml
+++ b/.github/workflows/_test_langgraph.yml
@@ -45,6 +45,13 @@ jobs:
shell: bash
run: make test_parallel
+ - name: Run strict msgpack pregel tests
+ if: ${{ matrix.python-version == '3.13' }}
+ shell: bash
+ env:
+ LANGGRAPH_STRICT_MSGPACK: "true"
+ run: make test TEST="tests/test_pregel.py tests/test_pregel_async.py"
+
- name: Ensure the tests did not create any additional files
shell: bash
run: |
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f3207c4fe..31c8231e2 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -7,6 +7,7 @@ on:
branches:
- main
pull_request:
+
permissions:
contents: read
@@ -25,11 +26,12 @@ jobs:
changes:
runs-on: ubuntu-latest
outputs:
- python: ${{ steps.filter.outputs.python }}
- deps: ${{ steps.filter.outputs.deps }}
+ python: ${{ steps.filter.outputs.python || 'true' }}
+ deps: ${{ steps.filter.outputs.deps || 'true' }}
steps:
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v3
+ if: github.event_name != 'workflow_dispatch'
id: filter
with:
filters: |
diff --git a/libs/checkpoint/langgraph/checkpoint/serde/_msgpack.py b/libs/checkpoint/langgraph/checkpoint/serde/_msgpack.py
index 9866806d4..0b09a9a4e 100644
--- a/libs/checkpoint/langgraph/checkpoint/serde/_msgpack.py
+++ b/libs/checkpoint/langgraph/checkpoint/serde/_msgpack.py
@@ -46,6 +46,24 @@ SAFE_MSGPACK_TYPES: frozenset[tuple[str, ...]] = frozenset(
("zoneinfo", "ZoneInfo"),
# regex
("re", "compile"),
+ # langchain-core messages (safe container types used by graph state)
+ ("langchain_core.messages.base", "BaseMessage"),
+ ("langchain_core.messages.base", "BaseMessageChunk"),
+ ("langchain_core.messages.human", "HumanMessage"),
+ ("langchain_core.messages.human", "HumanMessageChunk"),
+ ("langchain_core.messages.ai", "AIMessage"),
+ ("langchain_core.messages.ai", "AIMessageChunk"),
+ ("langchain_core.messages.system", "SystemMessage"),
+ ("langchain_core.messages.system", "SystemMessageChunk"),
+ ("langchain_core.messages.chat", "ChatMessage"),
+ ("langchain_core.messages.chat", "ChatMessageChunk"),
+ ("langchain_core.messages.tool", "ToolMessage"),
+ ("langchain_core.messages.tool", "ToolMessageChunk"),
+ ("langchain_core.messages.function", "FunctionMessage"),
+ ("langchain_core.messages.function", "FunctionMessageChunk"),
+ ("langchain_core.messages.modifier", "RemoveMessage"),
+ # langchain-core document model
+ ("langchain_core.documents.base", "Document"),
# langgraph
("langgraph.types", "Send"),
("langgraph.types", "Interrupt"),
diff --git a/libs/checkpoint/tests/test_jsonplus.py b/libs/checkpoint/tests/test_jsonplus.py
index 151b1224e..d153d3c7c 100644
--- a/libs/checkpoint/tests/test_jsonplus.py
+++ b/libs/checkpoint/tests/test_jsonplus.py
@@ -17,6 +17,8 @@ import numpy as np
import ormsgpack
import pandas as pd
import pytest
+from langchain_core.documents.base import Document
+from langchain_core.messages import HumanMessage
from pydantic import BaseModel, SecretStr
from pydantic.v1 import BaseModel as BaseModelV1
from pydantic.v1 import SecretStr as SecretStrV1
@@ -684,6 +686,36 @@ def test_msgpack_strict_allows_safe_types(
assert result == safe
+def test_msgpack_strict_allows_core_langchain_messages(
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ serde = JsonPlusSerializer(allowed_msgpack_modules=None)
+ msg = HumanMessage(content="hello")
+
+ caplog.clear()
+ result = serde.loads_typed(serde.dumps_typed(msg))
+
+ assert "blocked" not in caplog.text.lower()
+ assert "unregistered" not in caplog.text.lower()
+ assert isinstance(result, HumanMessage)
+ assert result == msg
+
+
+def test_msgpack_strict_allows_langchain_document(
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ serde = JsonPlusSerializer(allowed_msgpack_modules=None)
+ doc = Document(page_content="hello", metadata={"k": "v"})
+
+ caplog.clear()
+ result = serde.loads_typed(serde.dumps_typed(doc))
+
+ assert "blocked" not in caplog.text.lower()
+ assert "unregistered" not in caplog.text.lower()
+ assert isinstance(result, Document)
+ assert result == doc
+
+
def test_msgpack_regex_safe_type(caplog: pytest.LogCaptureFixture) -> None:
"""re.compile patterns should deserialize without warnings as a safe type."""
From adb953ddd47378c7dad11a534d0508cb3e97a98d Mon Sep 17 00:00:00 2001
From: William FH <13333726+hinthornw@users.noreply.github.com>
Date: Thu, 26 Feb 2026 14:46:02 -0800
Subject: [PATCH 10/11] chore: update defaults (#6953)
---
libs/checkpoint-postgres/uv.lock | 2 +-
libs/checkpoint-sqlite/uv.lock | 2 +-
libs/checkpoint/pyproject.toml | 2 +-
libs/checkpoint/uv.lock | 2 +-
libs/langgraph/uv.lock | 2 +-
libs/prebuilt/uv.lock | 2 +-
libs/sdk-py/uv.lock | 2 +-
7 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/libs/checkpoint-postgres/uv.lock b/libs/checkpoint-postgres/uv.lock
index 436318950..e6b1ad9ef 100644
--- a/libs/checkpoint-postgres/uv.lock
+++ b/libs/checkpoint-postgres/uv.lock
@@ -259,7 +259,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
-version = "4.0.1rc2"
+version = "4.0.1rc3"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
diff --git a/libs/checkpoint-sqlite/uv.lock b/libs/checkpoint-sqlite/uv.lock
index 1248a4542..894822b64 100644
--- a/libs/checkpoint-sqlite/uv.lock
+++ b/libs/checkpoint-sqlite/uv.lock
@@ -268,7 +268,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
-version = "4.0.1rc2"
+version = "4.0.1rc3"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
diff --git a/libs/checkpoint/pyproject.toml b/libs/checkpoint/pyproject.toml
index 86fb3dfc8..54c62ea73 100644
--- a/libs/checkpoint/pyproject.toml
+++ b/libs/checkpoint/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph-checkpoint"
-version = "4.0.1rc2"
+version = "4.0.1rc3"
description = "Library with base interfaces for LangGraph checkpoint savers."
authors = []
requires-python = ">=3.10"
diff --git a/libs/checkpoint/uv.lock b/libs/checkpoint/uv.lock
index 59b87504c..e80420d55 100644
--- a/libs/checkpoint/uv.lock
+++ b/libs/checkpoint/uv.lock
@@ -286,7 +286,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
-version = "4.0.1rc2"
+version = "4.0.1rc3"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
diff --git a/libs/langgraph/uv.lock b/libs/langgraph/uv.lock
index 4c25db91c..5c09be36d 100644
--- a/libs/langgraph/uv.lock
+++ b/libs/langgraph/uv.lock
@@ -1548,7 +1548,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
-version = "4.0.1rc2"
+version = "4.0.1rc3"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
diff --git a/libs/prebuilt/uv.lock b/libs/prebuilt/uv.lock
index b1ef0cf3b..1f7cd6bb2 100644
--- a/libs/prebuilt/uv.lock
+++ b/libs/prebuilt/uv.lock
@@ -352,7 +352,7 @@ test = [
[[package]]
name = "langgraph-checkpoint"
-version = "4.0.1rc2"
+version = "4.0.1rc3"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
diff --git a/libs/sdk-py/uv.lock b/libs/sdk-py/uv.lock
index 8429a175c..285f2738c 100644
--- a/libs/sdk-py/uv.lock
+++ b/libs/sdk-py/uv.lock
@@ -349,7 +349,7 @@ test = [
[[package]]
name = "langgraph-checkpoint"
-version = "4.0.1rc2"
+version = "4.0.1rc3"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
From 901ab6b3f8e4543ec80c69725f0f8c0915e0e3a4 Mon Sep 17 00:00:00 2001
From: William FH <13333726+hinthornw@users.noreply.github.com>
Date: Thu, 26 Feb 2026 17:20:42 -0800
Subject: [PATCH 11/11] chore: add serde events (#6954)
---
libs/checkpoint-postgres/uv.lock | 2 +-
libs/checkpoint-sqlite/uv.lock | 2 +-
.../langgraph/checkpoint/serde/event_hooks.py | 52 +++++++++++++++++++
.../langgraph/checkpoint/serde/jsonplus.py | 23 ++++++++
libs/checkpoint/pyproject.toml | 2 +-
libs/checkpoint/tests/test_jsonplus.py | 38 ++++++++++++++
libs/checkpoint/uv.lock | 2 +-
libs/langgraph/uv.lock | 2 +-
libs/prebuilt/uv.lock | 2 +-
libs/sdk-py/uv.lock | 2 +-
10 files changed, 120 insertions(+), 7 deletions(-)
create mode 100644 libs/checkpoint/langgraph/checkpoint/serde/event_hooks.py
diff --git a/libs/checkpoint-postgres/uv.lock b/libs/checkpoint-postgres/uv.lock
index e6b1ad9ef..3b0643904 100644
--- a/libs/checkpoint-postgres/uv.lock
+++ b/libs/checkpoint-postgres/uv.lock
@@ -259,7 +259,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
-version = "4.0.1rc3"
+version = "4.0.1rc4"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
diff --git a/libs/checkpoint-sqlite/uv.lock b/libs/checkpoint-sqlite/uv.lock
index 894822b64..00ad52282 100644
--- a/libs/checkpoint-sqlite/uv.lock
+++ b/libs/checkpoint-sqlite/uv.lock
@@ -268,7 +268,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
-version = "4.0.1rc3"
+version = "4.0.1rc4"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
diff --git a/libs/checkpoint/langgraph/checkpoint/serde/event_hooks.py b/libs/checkpoint/langgraph/checkpoint/serde/event_hooks.py
new file mode 100644
index 000000000..9ea0a5b49
--- /dev/null
+++ b/libs/checkpoint/langgraph/checkpoint/serde/event_hooks.py
@@ -0,0 +1,52 @@
+from __future__ import annotations
+
+import logging
+from collections.abc import Callable
+from threading import Lock
+from typing import TypedDict
+
+from typing_extensions import NotRequired
+
+logger = logging.getLogger(__name__)
+
+
+class SerdeEvent(TypedDict):
+ kind: str
+ module: str
+ name: str
+ method: NotRequired[str]
+
+
+SerdeEventListener = Callable[[SerdeEvent], None]
+
+_listeners: list[SerdeEventListener] = []
+_listeners_lock = Lock()
+
+
+def register_serde_event_listener(listener: SerdeEventListener) -> Callable[[], None]:
+ """Register a listener for serde allowlist events."""
+ with _listeners_lock:
+ _listeners.append(listener)
+
+ def unregister() -> None:
+ with _listeners_lock:
+ try:
+ _listeners.remove(listener)
+ except ValueError:
+ pass
+
+ return unregister
+
+
+def emit_serde_event(event: SerdeEvent) -> None:
+ """Emit a serde event to all listeners.
+
+ Listener failures are isolated and logged.
+ """
+ with _listeners_lock:
+ listeners = tuple(_listeners)
+ for listener in listeners:
+ try:
+ listener(event)
+ except Exception:
+ logger.warning("Serde listener failed", exc_info=True)
diff --git a/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py b/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py
index e144303bc..4ef4a40b2 100644
--- a/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py
+++ b/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py
@@ -32,6 +32,7 @@ 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 SendProtocol
from langgraph.store.base import Item
@@ -519,6 +520,13 @@ def _create_msgpack_ext_hook(
if allowed_modules is True:
# default is to warn but allow unregistered types
+ emit_serde_event(
+ {
+ "kind": "msgpack_unregistered_allowed",
+ "module": module,
+ "name": name,
+ }
+ )
logger.warning(
"Deserializing unregistered type %s.%s from checkpoint. "
"This will be blocked in a future version. "
@@ -533,6 +541,13 @@ def _create_msgpack_ext_hook(
if key in allowed_modules:
return True
# strict mode blocks unregistered types
+ emit_serde_event(
+ {
+ "kind": "msgpack_blocked",
+ "module": module,
+ "name": name,
+ }
+ )
logger.warning(
"Blocked deserialization of %s.%s - not in allowed_msgpack_modules. "
"Add to allowed_msgpack_modules to allow: [(%r, %r)]",
@@ -548,6 +563,14 @@ def _create_msgpack_ext_hook(
key = (module, name, method)
if key in _lg_msgpack.SAFE_MSGPACK_METHODS:
return True
+ emit_serde_event(
+ {
+ "kind": "msgpack_method_blocked",
+ "module": module,
+ "name": name,
+ "method": method,
+ }
+ )
logger.warning(
"Blocked deserialization of method call %s.%s.%s - "
"not in allowed methods set.",
diff --git a/libs/checkpoint/pyproject.toml b/libs/checkpoint/pyproject.toml
index 54c62ea73..1e51065d4 100644
--- a/libs/checkpoint/pyproject.toml
+++ b/libs/checkpoint/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph-checkpoint"
-version = "4.0.1rc3"
+version = "4.0.1rc4"
description = "Library with base interfaces for LangGraph checkpoint savers."
authors = []
requires-python = ">=3.10"
diff --git a/libs/checkpoint/tests/test_jsonplus.py b/libs/checkpoint/tests/test_jsonplus.py
index d153d3c7c..9242c59bf 100644
--- a/libs/checkpoint/tests/test_jsonplus.py
+++ b/libs/checkpoint/tests/test_jsonplus.py
@@ -25,6 +25,10 @@ from pydantic.v1 import SecretStr as SecretStrV1
from langgraph.checkpoint.serde import _msgpack as _lg_msgpack
from langgraph.checkpoint.serde._msgpack import AllowedMsgpackModules
+from langgraph.checkpoint.serde.event_hooks import (
+ SerdeEvent,
+ register_serde_event_listener,
+)
from langgraph.checkpoint.serde.jsonplus import (
EXT_METHOD_SINGLE_ARG,
InvalidModuleError,
@@ -670,6 +674,40 @@ def test_msgpack_allowlist_blocks_non_listed(
assert result == expected
+def test_msgpack_blocked_emits_event() -> None:
+ events: list[SerdeEvent] = []
+ unregister = register_serde_event_listener(events.append)
+ try:
+ serde = JsonPlusSerializer(allowed_msgpack_modules=None)
+ obj = AnotherPydantic(foo="nope")
+ serde.loads_typed(serde.dumps_typed(obj))
+ finally:
+ unregister()
+
+ assert {
+ "kind": "msgpack_blocked",
+ "module": "tests.test_jsonplus",
+ "name": "AnotherPydantic",
+ } in events
+
+
+def test_msgpack_unregistered_allowed_emits_event() -> None:
+ events: list[SerdeEvent] = []
+ unregister = register_serde_event_listener(events.append)
+ try:
+ serde = JsonPlusSerializer(allowed_msgpack_modules=True)
+ obj = AnotherPydantic(foo="ok")
+ serde.loads_typed(serde.dumps_typed(obj))
+ finally:
+ unregister()
+
+ assert {
+ "kind": "msgpack_unregistered_allowed",
+ "module": "tests.test_jsonplus",
+ "name": "AnotherPydantic",
+ } in events
+
+
def test_msgpack_strict_allows_safe_types(
caplog: pytest.LogCaptureFixture,
) -> None:
diff --git a/libs/checkpoint/uv.lock b/libs/checkpoint/uv.lock
index e80420d55..f95cf7a79 100644
--- a/libs/checkpoint/uv.lock
+++ b/libs/checkpoint/uv.lock
@@ -286,7 +286,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
-version = "4.0.1rc3"
+version = "4.0.1rc4"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
diff --git a/libs/langgraph/uv.lock b/libs/langgraph/uv.lock
index 5c09be36d..75146458e 100644
--- a/libs/langgraph/uv.lock
+++ b/libs/langgraph/uv.lock
@@ -1548,7 +1548,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
-version = "4.0.1rc3"
+version = "4.0.1rc4"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
diff --git a/libs/prebuilt/uv.lock b/libs/prebuilt/uv.lock
index 1f7cd6bb2..32c0ab357 100644
--- a/libs/prebuilt/uv.lock
+++ b/libs/prebuilt/uv.lock
@@ -352,7 +352,7 @@ test = [
[[package]]
name = "langgraph-checkpoint"
-version = "4.0.1rc3"
+version = "4.0.1rc4"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
diff --git a/libs/sdk-py/uv.lock b/libs/sdk-py/uv.lock
index 285f2738c..14f3b92c5 100644
--- a/libs/sdk-py/uv.lock
+++ b/libs/sdk-py/uv.lock
@@ -349,7 +349,7 @@ test = [
[[package]]
name = "langgraph-checkpoint"
-version = "4.0.1rc3"
+version = "4.0.1rc4"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },