From 71e8d51a7c41fbe8749c76dd1ffa4b5fef9c9c0d Mon Sep 17 00:00:00 2001 From: radu-mocanu Date: Wed, 29 Apr 2026 13:40:28 +0300 Subject: [PATCH] fix: honor checkpointer.serde.allowed_msgpack_modules in langgraph.json (#1500) --- pyproject.toml | 2 +- samples/checkpoint-allowlist/README.md | 15 +++ samples/checkpoint-allowlist/input.json | 3 + samples/checkpoint-allowlist/langgraph.json | 14 +++ samples/checkpoint-allowlist/main.py | 31 +++++ samples/checkpoint-allowlist/pyproject.toml | 9 ++ src/uipath_langchain/runtime/config.py | 76 +++++++++---- src/uipath_langchain/runtime/factory.py | 24 ++++ testcases/checkpoint-allowlist/pyproject.toml | 12 ++ testcases/checkpoint-allowlist/run.sh | 21 ++++ testcases/checkpoint-allowlist/src/assert.py | 39 +++++++ tests/runtime/test_msgpack_allowlist.py | 107 ++++++++++++++++++ uv.lock | 2 +- 13 files changed, 329 insertions(+), 26 deletions(-) create mode 100644 samples/checkpoint-allowlist/README.md create mode 100644 samples/checkpoint-allowlist/input.json create mode 100644 samples/checkpoint-allowlist/langgraph.json create mode 100644 samples/checkpoint-allowlist/main.py create mode 100644 samples/checkpoint-allowlist/pyproject.toml create mode 100644 testcases/checkpoint-allowlist/pyproject.toml create mode 100644 testcases/checkpoint-allowlist/run.sh create mode 100644 testcases/checkpoint-allowlist/src/assert.py create mode 100644 tests/runtime/test_msgpack_allowlist.py diff --git a/pyproject.toml b/pyproject.toml index 84eb00578..bb36cc976 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-langchain" -version = "0.10.10" +version = "0.10.11" description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/samples/checkpoint-allowlist/README.md b/samples/checkpoint-allowlist/README.md new file mode 100644 index 000000000..c50a0baea --- /dev/null +++ b/samples/checkpoint-allowlist/README.md @@ -0,0 +1,15 @@ +# checkpoint-allowlist + +Shows how to silence langgraph's `Deserializing unregistered type` warning by declaring custom types in `langgraph.json`'s `serde.allowed_msgpack_modules` block. + +Graph: `START -> evaluate -> finalize -> END`. State carries a custom Pydantic `Score` value, which langgraph reconstructs on every checkpoint round-trip. + +## Run + +```bash +uv sync +uv run uipath init +uv run uipath run agent --file input.json +``` + +Expect: agent completes, no `Deserializing unregistered type ...Score` warning. Remove the `serde` block from `langgraph.json` to see the warning return. diff --git a/samples/checkpoint-allowlist/input.json b/samples/checkpoint-allowlist/input.json new file mode 100644 index 000000000..9bb2a8d38 --- /dev/null +++ b/samples/checkpoint-allowlist/input.json @@ -0,0 +1,3 @@ +{ + "topic": "weather" +} diff --git a/samples/checkpoint-allowlist/langgraph.json b/samples/checkpoint-allowlist/langgraph.json new file mode 100644 index 000000000..4dcfd640c --- /dev/null +++ b/samples/checkpoint-allowlist/langgraph.json @@ -0,0 +1,14 @@ +{ + "dependencies": ["."], + "graphs": { + "agent": "./main.py:graph" + }, + "env": ".env", + "checkpointer": { + "serde": { + "allowed_msgpack_modules": [ + ["main", "Score"] + ] + } + } +} diff --git a/samples/checkpoint-allowlist/main.py b/samples/checkpoint-allowlist/main.py new file mode 100644 index 000000000..41550ccc4 --- /dev/null +++ b/samples/checkpoint-allowlist/main.py @@ -0,0 +1,31 @@ +from pydantic import BaseModel + +from langgraph.graph import END, START, StateGraph + + +class Score(BaseModel): + label: str = "" + value: float = 0.0 + + +class State(BaseModel): + topic: str + score: Score | None = None + + +async def evaluate(state: State) -> State: + return State(topic=state.topic, score=Score(label="ok", value=1.0)) + + +async def finalize(state: State) -> State: + return state + + +builder = StateGraph(State) +builder.add_node("evaluate", evaluate) +builder.add_node("finalize", finalize) +builder.add_edge(START, "evaluate") +builder.add_edge("evaluate", "finalize") +builder.add_edge("finalize", END) + +graph = builder.compile() diff --git a/samples/checkpoint-allowlist/pyproject.toml b/samples/checkpoint-allowlist/pyproject.toml new file mode 100644 index 000000000..b80e1e13a --- /dev/null +++ b/samples/checkpoint-allowlist/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "checkpoint-allowlist-sample" +version = "0.0.1" +description = "Sample showing how to allowlist custom types in langgraph.json's serde block" +authors = [{ name = "UiPath" }] +requires-python = ">=3.11" +dependencies = [ + "uipath-langchain>=0.10.11,<0.11.0", +] diff --git a/src/uipath_langchain/runtime/config.py b/src/uipath_langchain/runtime/config.py index 3549ed8ad..f88847822 100644 --- a/src/uipath_langchain/runtime/config.py +++ b/src/uipath_langchain/runtime/config.py @@ -2,6 +2,7 @@ import json import os +from typing import Any class LangGraphConfig: @@ -15,13 +16,25 @@ def __init__(self, config_path: str = "langgraph.json"): config_path: Path to langgraph.json file """ self.config_path = config_path - self._graphs: dict[str, str] | None = None + self._raw: dict[str, Any] | None = None @property def exists(self) -> bool: """Check if langgraph.json exists.""" return os.path.exists(self.config_path) + def _load(self) -> dict[str, Any]: + if self._raw is not None: + return self._raw + if not self.exists: + raise FileNotFoundError(f"Config file not found: {self.config_path}") + try: + with open(self.config_path, "r") as f: + self._raw = json.load(f) + except json.JSONDecodeError as e: + raise ValueError(f"Invalid JSON in {self.config_path}: {e}") from e + return self._raw + @property def graphs(self) -> dict[str, str]: """ @@ -30,30 +43,45 @@ def graphs(self) -> dict[str, str]: Returns: Dictionary mapping graph names to file paths (e.g., {"agent": "agent.py:graph"}) """ - if self._graphs is None: - self._graphs = self._load_graphs() - return self._graphs - - def _load_graphs(self) -> dict[str, str]: - """Load graph definitions from langgraph.json.""" - if not self.exists: - raise FileNotFoundError(f"Config file not found: {self.config_path}") + config = self._load() + if "graphs" not in config: + raise ValueError("Missing required 'graphs' field in langgraph.json") + graphs = config["graphs"] + if not isinstance(graphs, dict): + raise ValueError("'graphs' must be a dictionary") + return graphs - try: - with open(self.config_path, "r") as f: - config = json.load(f) - - if "graphs" not in config: - raise ValueError("Missing required 'graphs' field in langgraph.json") - - graphs = config["graphs"] - if not isinstance(graphs, dict): - raise ValueError("'graphs' must be a dictionary") - - return graphs - - except json.JSONDecodeError as e: - raise ValueError(f"Invalid JSON in {self.config_path}: {e}") from e + @property + def allowed_msgpack_modules(self) -> list[tuple[str, str]] | None: + """Read `checkpointer.serde.allowed_msgpack_modules` from langgraph.json.""" + config = self._load() + checkpointer = config.get("checkpointer") + if not isinstance(checkpointer, dict): + return None + serde = checkpointer.get("serde") + if not isinstance(serde, dict): + return None + modules = serde.get("allowed_msgpack_modules") + if modules is None: + return None + if not isinstance(modules, list): + raise ValueError( + "'checkpointer.serde.allowed_msgpack_modules' must be a list " + "of [module, class_name] pairs" + ) + result: list[tuple[str, str]] = [] + for entry in modules: + if ( + not isinstance(entry, list) + or len(entry) != 2 + or not all(isinstance(part, str) for part in entry) + ): + raise ValueError( + f"Invalid entry in checkpointer.serde.allowed_msgpack_modules: " + f"{entry!r} (expected [module, class_name])" + ) + result.append((entry[0], entry[1])) + return result @property def entrypoints(self) -> list[str]: diff --git a/src/uipath_langchain/runtime/factory.py b/src/uipath_langchain/runtime/factory.py index b8f6565f8..001026480 100644 --- a/src/uipath_langchain/runtime/factory.py +++ b/src/uipath_langchain/runtime/factory.py @@ -1,7 +1,9 @@ import asyncio +import inspect import os from typing import Any, AsyncContextManager +from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver from langgraph.graph.state import CompiledStateGraph, StateGraph from openinference.instrumentation.langchain import ( @@ -30,6 +32,17 @@ from uipath_langchain.runtime.storage import SqliteResumableStorage +def _collect_sdk_interrupt_modules() -> list[tuple[str, str]]: + """Return `(module, class_name)` pairs for every SDK interrupt model.""" + from uipath.platform.common import interrupt_models + + return [ + (cls.__module__, cls.__name__) + for _, cls in inspect.getmembers(interrupt_models, inspect.isclass) + if cls.__module__ == interrupt_models.__name__ + ] + + class UiPathLangGraphRuntimeFactory: """Factory for creating LangGraph runtimes from langgraph.json configuration.""" @@ -95,8 +108,19 @@ async def _get_memory(self) -> AsyncSqliteSaver: self._memory_cm = AsyncSqliteSaver.from_conn_string(connection_string) self._memory = await self._memory_cm.__aenter__() await self._memory.setup() + self._apply_msgpack_allowlist(self._memory) return self._memory + def _apply_msgpack_allowlist(self, memory: AsyncSqliteSaver) -> None: + """Apply the user's msgpack allowlist (unioned with SDK interrupt models).""" + user_modules = self._load_config().allowed_msgpack_modules + if user_modules is None: + return + sdk_modules = _collect_sdk_interrupt_modules() + memory.serde = JsonPlusSerializer( + allowed_msgpack_modules=[*sdk_modules, *user_modules], + ) + def _load_config(self) -> LangGraphConfig: """Load langgraph.json configuration.""" if self._config is None: diff --git a/testcases/checkpoint-allowlist/pyproject.toml b/testcases/checkpoint-allowlist/pyproject.toml new file mode 100644 index 000000000..4f09cd39d --- /dev/null +++ b/testcases/checkpoint-allowlist/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "checkpoint-allowlist" +version = "0.0.1" +description = "Verifies serde.allowed_msgpack_modules in langgraph.json silences langgraph warnings" +authors = [{ name = "UiPath" }] +requires-python = ">=3.11" +dependencies = [ + "uipath-langchain", +] + +[tool.uv.sources] +uipath-langchain = { path = "../../", editable = true } diff --git a/testcases/checkpoint-allowlist/run.sh b/testcases/checkpoint-allowlist/run.sh new file mode 100644 index 000000000..2ba57ea55 --- /dev/null +++ b/testcases/checkpoint-allowlist/run.sh @@ -0,0 +1,21 @@ +#!/bin/bash +set -e + +SAMPLE_DIR="../../samples/checkpoint-allowlist" + +echo "Copying sample files..." +cp "$SAMPLE_DIR/main.py" main.py +cp "$SAMPLE_DIR/langgraph.json" langgraph.json +cp "$SAMPLE_DIR/input.json" input.json + +echo "Syncing dependencies..." +uv sync + +echo "Authenticating with UiPath..." +uv run uipath auth --client-id="$CLIENT_ID" --client-secret="$CLIENT_SECRET" --base-url="$BASE_URL" + +echo "Initializing the project..." +uv run uipath init + +echo "Running agent (with serde block) — capturing log..." +uv run uipath run agent --file input.json 2>&1 | tee local_run_output.log diff --git a/testcases/checkpoint-allowlist/src/assert.py b/testcases/checkpoint-allowlist/src/assert.py new file mode 100644 index 000000000..52b228a2e --- /dev/null +++ b/testcases/checkpoint-allowlist/src/assert.py @@ -0,0 +1,39 @@ +"""Assert serde.allowed_msgpack_modules silences langgraph's unregistered-type warning. + +The sample's langgraph.json declares `Score` in `serde.allowed_msgpack_modules`. +With the fix, the runtime constructs a strict-mode JsonPlusSerializer that +unions the user's list with the SDK interrupt models, so no warning fires. +""" + +import os +import sys + +LOG_PATH = "local_run_output.log" +WARNINGS = ( + "Deserializing unregistered type", + "Blocked deserialization", +) + + +def main() -> int: + if not os.path.isfile(LOG_PATH): + print(f"ERROR: {LOG_PATH} not found") + return 1 + + with open(LOG_PATH, "r", encoding="utf-8", errors="replace") as f: + log = f.read() + + found = [w for w in WARNINGS if w in log] + if found: + print(f"FAIL: serde warning(s) appeared in log: {found}") + for line in log.splitlines(): + if any(w in line for w in WARNINGS): + print(f" > {line}") + return 1 + + print("OK: no langgraph serde warnings in run output") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/runtime/test_msgpack_allowlist.py b/tests/runtime/test_msgpack_allowlist.py new file mode 100644 index 000000000..b2867209a --- /dev/null +++ b/tests/runtime/test_msgpack_allowlist.py @@ -0,0 +1,107 @@ +"""Verify the msgpack allowlist plumbing from langgraph.json -> saver.serde.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer +from uipath.platform.common.interrupt_models import CreateTask, InvokeProcess + +from uipath_langchain.runtime.config import LangGraphConfig +from uipath_langchain.runtime.factory import _collect_sdk_interrupt_modules + + +def _write_config(path: Path, payload: dict[str, object]) -> LangGraphConfig: + path.write_text(json.dumps(payload)) + return LangGraphConfig(str(path)) + + +def test_no_serde_block_means_no_opt_in(tmp_path: Path) -> None: + config = _write_config(tmp_path / "langgraph.json", {"graphs": {"a": "x.py:g"}}) + assert config.allowed_msgpack_modules is None + + +def test_explicit_user_list_is_parsed(tmp_path: Path) -> None: + config = _write_config( + tmp_path / "langgraph.json", + { + "graphs": {"a": "x.py:g"}, + "checkpointer": { + "serde": { + "allowed_msgpack_modules": [ + ["my_app.state", "MyState"], + ["my_app.tools", "ToolResult"], + ] + } + }, + }, + ) + assert config.allowed_msgpack_modules == [ + ("my_app.state", "MyState"), + ("my_app.tools", "ToolResult"), + ] + + +def test_top_level_serde_block_is_ignored(tmp_path: Path) -> None: + """Only `checkpointer.serde` is read — top-level `serde` is not a recognized location.""" + config = _write_config( + tmp_path / "langgraph.json", + { + "graphs": {"a": "x.py:g"}, + "serde": { + "allowed_msgpack_modules": [["ignored", "Type"]], + }, + }, + ) + assert config.allowed_msgpack_modules is None + + +def test_malformed_entry_raises(tmp_path: Path) -> None: + config = _write_config( + tmp_path / "langgraph.json", + { + "graphs": {"a": "x.py:g"}, + "checkpointer": { + "serde": {"allowed_msgpack_modules": [["only_module_no_class"]]}, + }, + }, + ) + with pytest.raises(ValueError, match="Invalid entry"): + _ = config.allowed_msgpack_modules + + +def test_sdk_interrupt_modules_include_create_task_and_invoke_process() -> None: + sdk_modules = _collect_sdk_interrupt_modules() + assert (CreateTask.__module__, "CreateTask") in sdk_modules + assert (InvokeProcess.__module__, "InvokeProcess") in sdk_modules + + +def test_sdk_types_round_trip_when_user_opts_in() -> None: + """Strict-mode serde must still reconstruct CreateTask without manual config.""" + sdk_modules = _collect_sdk_interrupt_modules() + serde = JsonPlusSerializer( + allowed_msgpack_modules=[*sdk_modules, ("my_app", "MyState")] + ) + + task = CreateTask(title="hi", data={}) + type_, payload = serde.dumps_typed(task) + restored = serde.loads_typed((type_, payload)) + + assert isinstance(restored, CreateTask) + assert restored.title == "hi" + + +def test_unlisted_user_type_is_blocked_in_strict_mode() -> None: + """Sanity: types absent from the allowlist degrade to dict (langgraph behaviour).""" + from pydantic import BaseModel + + class UnlistedState(BaseModel): + x: int = 0 + + serde = JsonPlusSerializer(allowed_msgpack_modules=[("my_app", "MyState")]) + type_, payload = serde.dumps_typed(UnlistedState(x=7)) + restored = serde.loads_typed((type_, payload)) + assert isinstance(restored, dict) + assert restored == {"x": 7} diff --git a/uv.lock b/uv.lock index 911291179..d6bd8be24 100644 --- a/uv.lock +++ b/uv.lock @@ -4375,7 +4375,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.10.10" +version = "0.10.11" source = { editable = "." } dependencies = [ { name = "a2a-sdk" },