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

File Platform Checksum
ruff-aarch64-apple-darwin.tar.gz Apple Silicon macOS checksum
ruff-x86_64-apple-darwin.tar.gz Intel macOS checksum
ruff-aarch64-pc-windows-msvc.zip ARM64 Windows checksum
ruff-i686-pc-windows-msvc.zip x86 Windows checksum
ruff-x86_64-pc-windows-msvc.zip x64 Windows checksum
ruff-aarch64-unknown-linux-gnu.tar.gz ARM64 Linux checksum
ruff-i686-unknown-linux-gnu.tar.gz x86 Linux checksum
ruff-powerpc64-unknown-linux-gnu.tar.gz PPC64 Linux checksum

... (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" },