Skip to content

Commit 71e8d51

Browse files
committed
fix: honor checkpointer.serde.allowed_msgpack_modules in langgraph.json (#1500)
1 parent a3c7f53 commit 71e8d51

13 files changed

Lines changed: 329 additions & 26 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-langchain"
3-
version = "0.10.10"
3+
version = "0.10.11"
44
description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# checkpoint-allowlist
2+
3+
Shows how to silence langgraph's `Deserializing unregistered type` warning by declaring custom types in `langgraph.json`'s `serde.allowed_msgpack_modules` block.
4+
5+
Graph: `START -> evaluate -> finalize -> END`. State carries a custom Pydantic `Score` value, which langgraph reconstructs on every checkpoint round-trip.
6+
7+
## Run
8+
9+
```bash
10+
uv sync
11+
uv run uipath init
12+
uv run uipath run agent --file input.json
13+
```
14+
15+
Expect: agent completes, no `Deserializing unregistered type ...Score` warning. Remove the `serde` block from `langgraph.json` to see the warning return.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"topic": "weather"
3+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"dependencies": ["."],
3+
"graphs": {
4+
"agent": "./main.py:graph"
5+
},
6+
"env": ".env",
7+
"checkpointer": {
8+
"serde": {
9+
"allowed_msgpack_modules": [
10+
["main", "Score"]
11+
]
12+
}
13+
}
14+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from pydantic import BaseModel
2+
3+
from langgraph.graph import END, START, StateGraph
4+
5+
6+
class Score(BaseModel):
7+
label: str = ""
8+
value: float = 0.0
9+
10+
11+
class State(BaseModel):
12+
topic: str
13+
score: Score | None = None
14+
15+
16+
async def evaluate(state: State) -> State:
17+
return State(topic=state.topic, score=Score(label="ok", value=1.0))
18+
19+
20+
async def finalize(state: State) -> State:
21+
return state
22+
23+
24+
builder = StateGraph(State)
25+
builder.add_node("evaluate", evaluate)
26+
builder.add_node("finalize", finalize)
27+
builder.add_edge(START, "evaluate")
28+
builder.add_edge("evaluate", "finalize")
29+
builder.add_edge("finalize", END)
30+
31+
graph = builder.compile()
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
[project]
2+
name = "checkpoint-allowlist-sample"
3+
version = "0.0.1"
4+
description = "Sample showing how to allowlist custom types in langgraph.json's serde block"
5+
authors = [{ name = "UiPath" }]
6+
requires-python = ">=3.11"
7+
dependencies = [
8+
"uipath-langchain>=0.10.11,<0.11.0",
9+
]

src/uipath_langchain/runtime/config.py

Lines changed: 52 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import json
44
import os
5+
from typing import Any
56

67

78
class LangGraphConfig:
@@ -15,13 +16,25 @@ def __init__(self, config_path: str = "langgraph.json"):
1516
config_path: Path to langgraph.json file
1617
"""
1718
self.config_path = config_path
18-
self._graphs: dict[str, str] | None = None
19+
self._raw: dict[str, Any] | None = None
1920

2021
@property
2122
def exists(self) -> bool:
2223
"""Check if langgraph.json exists."""
2324
return os.path.exists(self.config_path)
2425

26+
def _load(self) -> dict[str, Any]:
27+
if self._raw is not None:
28+
return self._raw
29+
if not self.exists:
30+
raise FileNotFoundError(f"Config file not found: {self.config_path}")
31+
try:
32+
with open(self.config_path, "r") as f:
33+
self._raw = json.load(f)
34+
except json.JSONDecodeError as e:
35+
raise ValueError(f"Invalid JSON in {self.config_path}: {e}") from e
36+
return self._raw
37+
2538
@property
2639
def graphs(self) -> dict[str, str]:
2740
"""
@@ -30,30 +43,45 @@ def graphs(self) -> dict[str, str]:
3043
Returns:
3144
Dictionary mapping graph names to file paths (e.g., {"agent": "agent.py:graph"})
3245
"""
33-
if self._graphs is None:
34-
self._graphs = self._load_graphs()
35-
return self._graphs
36-
37-
def _load_graphs(self) -> dict[str, str]:
38-
"""Load graph definitions from langgraph.json."""
39-
if not self.exists:
40-
raise FileNotFoundError(f"Config file not found: {self.config_path}")
46+
config = self._load()
47+
if "graphs" not in config:
48+
raise ValueError("Missing required 'graphs' field in langgraph.json")
49+
graphs = config["graphs"]
50+
if not isinstance(graphs, dict):
51+
raise ValueError("'graphs' must be a dictionary")
52+
return graphs
4153

42-
try:
43-
with open(self.config_path, "r") as f:
44-
config = json.load(f)
45-
46-
if "graphs" not in config:
47-
raise ValueError("Missing required 'graphs' field in langgraph.json")
48-
49-
graphs = config["graphs"]
50-
if not isinstance(graphs, dict):
51-
raise ValueError("'graphs' must be a dictionary")
52-
53-
return graphs
54-
55-
except json.JSONDecodeError as e:
56-
raise ValueError(f"Invalid JSON in {self.config_path}: {e}") from e
54+
@property
55+
def allowed_msgpack_modules(self) -> list[tuple[str, str]] | None:
56+
"""Read `checkpointer.serde.allowed_msgpack_modules` from langgraph.json."""
57+
config = self._load()
58+
checkpointer = config.get("checkpointer")
59+
if not isinstance(checkpointer, dict):
60+
return None
61+
serde = checkpointer.get("serde")
62+
if not isinstance(serde, dict):
63+
return None
64+
modules = serde.get("allowed_msgpack_modules")
65+
if modules is None:
66+
return None
67+
if not isinstance(modules, list):
68+
raise ValueError(
69+
"'checkpointer.serde.allowed_msgpack_modules' must be a list "
70+
"of [module, class_name] pairs"
71+
)
72+
result: list[tuple[str, str]] = []
73+
for entry in modules:
74+
if (
75+
not isinstance(entry, list)
76+
or len(entry) != 2
77+
or not all(isinstance(part, str) for part in entry)
78+
):
79+
raise ValueError(
80+
f"Invalid entry in checkpointer.serde.allowed_msgpack_modules: "
81+
f"{entry!r} (expected [module, class_name])"
82+
)
83+
result.append((entry[0], entry[1]))
84+
return result
5785

5886
@property
5987
def entrypoints(self) -> list[str]:

src/uipath_langchain/runtime/factory.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import asyncio
2+
import inspect
23
import os
34
from typing import Any, AsyncContextManager
45

6+
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
57
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
68
from langgraph.graph.state import CompiledStateGraph, StateGraph
79
from openinference.instrumentation.langchain import (
@@ -30,6 +32,17 @@
3032
from uipath_langchain.runtime.storage import SqliteResumableStorage
3133

3234

35+
def _collect_sdk_interrupt_modules() -> list[tuple[str, str]]:
36+
"""Return `(module, class_name)` pairs for every SDK interrupt model."""
37+
from uipath.platform.common import interrupt_models
38+
39+
return [
40+
(cls.__module__, cls.__name__)
41+
for _, cls in inspect.getmembers(interrupt_models, inspect.isclass)
42+
if cls.__module__ == interrupt_models.__name__
43+
]
44+
45+
3346
class UiPathLangGraphRuntimeFactory:
3447
"""Factory for creating LangGraph runtimes from langgraph.json configuration."""
3548

@@ -95,8 +108,19 @@ async def _get_memory(self) -> AsyncSqliteSaver:
95108
self._memory_cm = AsyncSqliteSaver.from_conn_string(connection_string)
96109
self._memory = await self._memory_cm.__aenter__()
97110
await self._memory.setup()
111+
self._apply_msgpack_allowlist(self._memory)
98112
return self._memory
99113

114+
def _apply_msgpack_allowlist(self, memory: AsyncSqliteSaver) -> None:
115+
"""Apply the user's msgpack allowlist (unioned with SDK interrupt models)."""
116+
user_modules = self._load_config().allowed_msgpack_modules
117+
if user_modules is None:
118+
return
119+
sdk_modules = _collect_sdk_interrupt_modules()
120+
memory.serde = JsonPlusSerializer(
121+
allowed_msgpack_modules=[*sdk_modules, *user_modules],
122+
)
123+
100124
def _load_config(self) -> LangGraphConfig:
101125
"""Load langgraph.json configuration."""
102126
if self._config is None:
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
[project]
2+
name = "checkpoint-allowlist"
3+
version = "0.0.1"
4+
description = "Verifies serde.allowed_msgpack_modules in langgraph.json silences langgraph warnings"
5+
authors = [{ name = "UiPath" }]
6+
requires-python = ">=3.11"
7+
dependencies = [
8+
"uipath-langchain",
9+
]
10+
11+
[tool.uv.sources]
12+
uipath-langchain = { path = "../../", editable = true }
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#!/bin/bash
2+
set -e
3+
4+
SAMPLE_DIR="../../samples/checkpoint-allowlist"
5+
6+
echo "Copying sample files..."
7+
cp "$SAMPLE_DIR/main.py" main.py
8+
cp "$SAMPLE_DIR/langgraph.json" langgraph.json
9+
cp "$SAMPLE_DIR/input.json" input.json
10+
11+
echo "Syncing dependencies..."
12+
uv sync
13+
14+
echo "Authenticating with UiPath..."
15+
uv run uipath auth --client-id="$CLIENT_ID" --client-secret="$CLIENT_SECRET" --base-url="$BASE_URL"
16+
17+
echo "Initializing the project..."
18+
uv run uipath init
19+
20+
echo "Running agent (with serde block) — capturing log..."
21+
uv run uipath run agent --file input.json 2>&1 | tee local_run_output.log

0 commit comments

Comments
 (0)