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 d359a595c..31c8231e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,10 +2,12 @@ name: CI on: + workflow_dispatch: push: branches: - main pull_request: + permissions: contents: read @@ -24,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-postgres/uv.lock b/libs/checkpoint-postgres/uv.lock index d75989652..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.0" +version = "4.0.1rc4" source = { editable = "../checkpoint" } dependencies = [ { name = "langchain-core" }, @@ -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..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.0" +version = "4.0.1rc4" source = { editable = "../checkpoint" } dependencies = [ { name = "langchain-core" }, @@ -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..0b09a9a4e --- /dev/null +++ b/libs/checkpoint/langgraph/checkpoint/serde/_msgpack.py @@ -0,0 +1,89 @@ +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"), + # 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"), + ("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/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 c4b550308..4ef4a40b2 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 @@ -10,7 +11,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 +23,25 @@ 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.event_hooks import emit_serde_event 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,21 +62,62 @@ 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 + + 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, constructor: Callable | type[Any], @@ -90,7 +140,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 +157,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 +189,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 +200,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 +211,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 +498,196 @@ 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 + emit_serde_event( + { + "kind": "msgpack_unregistered_allowed", + "module": module, + "name": name, + } ) - arr = _np.frombuffer(buf, dtype=_np.dtype(dtype_str)) - return arr.reshape(shape, order=order) - except Exception: - return + 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, + ) + return True + if allowed_modules is not None: + 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)]", + 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 + 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.", + 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 +802,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..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.0" +version = "4.0.1rc4" description = "Library with base interfaces for LangGraph checkpoint savers." authors = [] requires-python = ">=3.10" @@ -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..9242c59bf 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,26 @@ from zoneinfo import ZoneInfo import dataclasses_json 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 +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, JsonPlusSerializer, + _msgpack_enc, _msgpack_ext_hook_to_json, ) from langgraph.store.base import Item @@ -37,6 +49,10 @@ class MyPydantic(BaseModel): inner: InnerPydantic +class AnotherPydantic(BaseModel): + foo: str + + class InnerPydanticV1(BaseModelV1): hello: str @@ -138,7 +154,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 +548,438 @@ 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_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: + """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_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.""" + + 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 + + +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.""" + + 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..f95cf7a79 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.1rc4" source = { editable = "." } dependencies = [ { name = "langchain-core" }, @@ -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/_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/langgraph/pregel/main.py b/libs/langgraph/langgraph/pregel/main.py index 4c1dfbb91..597a60922 100644 --- a/libs/langgraph/langgraph/pregel/main.py +++ b/libs/langgraph/langgraph/pregel/main.py @@ -50,6 +50,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, @@ -701,9 +702,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: @@ -1242,6 +1251,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") @@ -1284,6 +1295,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") @@ -1332,6 +1345,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") @@ -1383,6 +1398,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") @@ -1449,6 +1466,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") @@ -1893,6 +1912,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") @@ -2381,6 +2402,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/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/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_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_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_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 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/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", + ] diff --git a/libs/langgraph/uv.lock b/libs/langgraph/uv.lock index a5186b591..75146458e 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,14 +1360,14 @@ 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]] 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.1rc4" source = { editable = "../checkpoint" } dependencies = [ { name = "langchain-core" }, @@ -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" }, @@ -3180,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]] @@ -3388,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]] diff --git a/libs/prebuilt/uv.lock b/libs/prebuilt/uv.lock index 109c99461..32c0ab357 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.1rc4" source = { editable = "../checkpoint" } dependencies = [ { name = "langchain-core" }, @@ -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" }, diff --git a/libs/sdk-py/uv.lock b/libs/sdk-py/uv.lock index e0df98114..14f3b92c5 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.1rc4" 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" },