diff --git a/libs/checkpoint-postgres/uv.lock b/libs/checkpoint-postgres/uv.lock index e6b1ad9ef..3b0643904 100644 --- a/libs/checkpoint-postgres/uv.lock +++ b/libs/checkpoint-postgres/uv.lock @@ -259,7 +259,7 @@ wheels = [ [[package]] name = "langgraph-checkpoint" -version = "4.0.1rc3" +version = "4.0.1rc4" source = { editable = "../checkpoint" } dependencies = [ { name = "langchain-core" }, diff --git a/libs/checkpoint-sqlite/uv.lock b/libs/checkpoint-sqlite/uv.lock index 894822b64..00ad52282 100644 --- a/libs/checkpoint-sqlite/uv.lock +++ b/libs/checkpoint-sqlite/uv.lock @@ -268,7 +268,7 @@ wheels = [ [[package]] name = "langgraph-checkpoint" -version = "4.0.1rc3" +version = "4.0.1rc4" source = { editable = "../checkpoint" } dependencies = [ { name = "langchain-core" }, diff --git a/libs/checkpoint/langgraph/checkpoint/serde/event_hooks.py b/libs/checkpoint/langgraph/checkpoint/serde/event_hooks.py new file mode 100644 index 000000000..9ea0a5b49 --- /dev/null +++ b/libs/checkpoint/langgraph/checkpoint/serde/event_hooks.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import logging +from collections.abc import Callable +from threading import Lock +from typing import TypedDict + +from typing_extensions import NotRequired + +logger = logging.getLogger(__name__) + + +class SerdeEvent(TypedDict): + kind: str + module: str + name: str + method: NotRequired[str] + + +SerdeEventListener = Callable[[SerdeEvent], None] + +_listeners: list[SerdeEventListener] = [] +_listeners_lock = Lock() + + +def register_serde_event_listener(listener: SerdeEventListener) -> Callable[[], None]: + """Register a listener for serde allowlist events.""" + with _listeners_lock: + _listeners.append(listener) + + def unregister() -> None: + with _listeners_lock: + try: + _listeners.remove(listener) + except ValueError: + pass + + return unregister + + +def emit_serde_event(event: SerdeEvent) -> None: + """Emit a serde event to all listeners. + + Listener failures are isolated and logged. + """ + with _listeners_lock: + listeners = tuple(_listeners) + for listener in listeners: + try: + listener(event) + except Exception: + logger.warning("Serde listener failed", exc_info=True) diff --git a/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py b/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py index e144303bc..4ef4a40b2 100644 --- a/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py +++ b/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py @@ -32,6 +32,7 @@ from langchain_core.load.load import Reviver from langgraph.checkpoint.serde import _msgpack as _lg_msgpack from langgraph.checkpoint.serde.base import SerializerProtocol +from langgraph.checkpoint.serde.event_hooks import emit_serde_event from langgraph.checkpoint.serde.types import SendProtocol from langgraph.store.base import Item @@ -519,6 +520,13 @@ def _create_msgpack_ext_hook( if allowed_modules is True: # default is to warn but allow unregistered types + emit_serde_event( + { + "kind": "msgpack_unregistered_allowed", + "module": module, + "name": name, + } + ) logger.warning( "Deserializing unregistered type %s.%s from checkpoint. " "This will be blocked in a future version. " @@ -533,6 +541,13 @@ def _create_msgpack_ext_hook( if key in allowed_modules: return True # strict mode blocks unregistered types + emit_serde_event( + { + "kind": "msgpack_blocked", + "module": module, + "name": name, + } + ) logger.warning( "Blocked deserialization of %s.%s - not in allowed_msgpack_modules. " "Add to allowed_msgpack_modules to allow: [(%r, %r)]", @@ -548,6 +563,14 @@ def _create_msgpack_ext_hook( key = (module, name, method) if key in _lg_msgpack.SAFE_MSGPACK_METHODS: return True + emit_serde_event( + { + "kind": "msgpack_method_blocked", + "module": module, + "name": name, + "method": method, + } + ) logger.warning( "Blocked deserialization of method call %s.%s.%s - " "not in allowed methods set.", diff --git a/libs/checkpoint/pyproject.toml b/libs/checkpoint/pyproject.toml index 54c62ea73..1e51065d4 100644 --- a/libs/checkpoint/pyproject.toml +++ b/libs/checkpoint/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "langgraph-checkpoint" -version = "4.0.1rc3" +version = "4.0.1rc4" description = "Library with base interfaces for LangGraph checkpoint savers." authors = [] requires-python = ">=3.10" diff --git a/libs/checkpoint/tests/test_jsonplus.py b/libs/checkpoint/tests/test_jsonplus.py index d153d3c7c..9242c59bf 100644 --- a/libs/checkpoint/tests/test_jsonplus.py +++ b/libs/checkpoint/tests/test_jsonplus.py @@ -25,6 +25,10 @@ from pydantic.v1 import SecretStr as SecretStrV1 from langgraph.checkpoint.serde import _msgpack as _lg_msgpack from langgraph.checkpoint.serde._msgpack import AllowedMsgpackModules +from langgraph.checkpoint.serde.event_hooks import ( + SerdeEvent, + register_serde_event_listener, +) from langgraph.checkpoint.serde.jsonplus import ( EXT_METHOD_SINGLE_ARG, InvalidModuleError, @@ -670,6 +674,40 @@ def test_msgpack_allowlist_blocks_non_listed( assert result == expected +def test_msgpack_blocked_emits_event() -> None: + events: list[SerdeEvent] = [] + unregister = register_serde_event_listener(events.append) + try: + serde = JsonPlusSerializer(allowed_msgpack_modules=None) + obj = AnotherPydantic(foo="nope") + serde.loads_typed(serde.dumps_typed(obj)) + finally: + unregister() + + assert { + "kind": "msgpack_blocked", + "module": "tests.test_jsonplus", + "name": "AnotherPydantic", + } in events + + +def test_msgpack_unregistered_allowed_emits_event() -> None: + events: list[SerdeEvent] = [] + unregister = register_serde_event_listener(events.append) + try: + serde = JsonPlusSerializer(allowed_msgpack_modules=True) + obj = AnotherPydantic(foo="ok") + serde.loads_typed(serde.dumps_typed(obj)) + finally: + unregister() + + assert { + "kind": "msgpack_unregistered_allowed", + "module": "tests.test_jsonplus", + "name": "AnotherPydantic", + } in events + + def test_msgpack_strict_allows_safe_types( caplog: pytest.LogCaptureFixture, ) -> None: diff --git a/libs/checkpoint/uv.lock b/libs/checkpoint/uv.lock index e80420d55..f95cf7a79 100644 --- a/libs/checkpoint/uv.lock +++ b/libs/checkpoint/uv.lock @@ -286,7 +286,7 @@ wheels = [ [[package]] name = "langgraph-checkpoint" -version = "4.0.1rc3" +version = "4.0.1rc4" source = { editable = "." } dependencies = [ { name = "langchain-core" }, diff --git a/libs/langgraph/uv.lock b/libs/langgraph/uv.lock index 5c09be36d..75146458e 100644 --- a/libs/langgraph/uv.lock +++ b/libs/langgraph/uv.lock @@ -1548,7 +1548,7 @@ wheels = [ [[package]] name = "langgraph-checkpoint" -version = "4.0.1rc3" +version = "4.0.1rc4" source = { editable = "../checkpoint" } dependencies = [ { name = "langchain-core" }, diff --git a/libs/prebuilt/uv.lock b/libs/prebuilt/uv.lock index 1f7cd6bb2..32c0ab357 100644 --- a/libs/prebuilt/uv.lock +++ b/libs/prebuilt/uv.lock @@ -352,7 +352,7 @@ test = [ [[package]] name = "langgraph-checkpoint" -version = "4.0.1rc3" +version = "4.0.1rc4" source = { editable = "../checkpoint" } dependencies = [ { name = "langchain-core" }, diff --git a/libs/sdk-py/uv.lock b/libs/sdk-py/uv.lock index 285f2738c..14f3b92c5 100644 --- a/libs/sdk-py/uv.lock +++ b/libs/sdk-py/uv.lock @@ -349,7 +349,7 @@ test = [ [[package]] name = "langgraph-checkpoint" -version = "4.0.1rc3" +version = "4.0.1rc4" source = { editable = "../checkpoint" } dependencies = [ { name = "langchain-core" },