Skip to content

Commit 787950f

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

13 files changed

Lines changed: 374 additions & 23 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: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""Minimal LangGraph agent with custom Pydantic types in graph state.
2+
3+
LangGraph's `JsonPlusSerializer` warns whenever a checkpoint contains a custom
4+
class that has not been allowlisted (`Deserializing unregistered type ...`).
5+
This sample shows how to silence the warning by declaring the types in
6+
`langgraph.json`'s `serde.allowed_msgpack_modules` block. uipath-langchain
7+
unions the user's list with its own SDK interrupt models when the block is
8+
present.
9+
10+
Two nodes, no LLM, no interrupt — the warning fires on the in-process
11+
checkpoint round-trip between nodes.
12+
"""
13+
14+
from pydantic import BaseModel
15+
16+
from langgraph.graph import END, START, StateGraph
17+
18+
19+
class Score(BaseModel):
20+
label: str = ""
21+
value: float = 0.0
22+
23+
24+
class State(BaseModel):
25+
topic: str
26+
score: Score | None = None
27+
28+
29+
async def evaluate(state: State) -> State:
30+
return State(topic=state.topic, score=Score(label="ok", value=1.0))
31+
32+
33+
async def finalize(state: State) -> State:
34+
return state
35+
36+
37+
builder = StateGraph(State)
38+
builder.add_node("evaluate", evaluate)
39+
builder.add_node("finalize", finalize)
40+
builder.add_edge(START, "evaluate")
41+
builder.add_edge("evaluate", "finalize")
42+
builder.add_edge("finalize", END)
43+
44+
graph = builder.compile()
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
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+
"langgraph>=1.1.8,<2.0.0",
9+
"langgraph-checkpoint-sqlite>=3.0.0",
10+
"pydantic>=2.10.6",
11+
"uipath",
12+
"uipath-langchain",
13+
]

src/uipath_langchain/runtime/config.py

Lines changed: 66 additions & 21 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,62 @@ 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
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
3653

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}")
41-
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")
54+
@property
55+
def allowed_msgpack_modules(self) -> list[tuple[str, str]] | None:
56+
"""Read `checkpointer.serde.allowed_msgpack_modules` from langgraph.json.
4857
49-
graphs = config["graphs"]
50-
if not isinstance(graphs, dict):
51-
raise ValueError("'graphs' must be a dictionary")
58+
Returns the list of `(module, class_name)` pairs the user opted into,
59+
or `None` if no opt-in — in which case the runtime keeps langgraph's
60+
default permissive behavior.
5261
53-
return graphs
62+
Schema (matches langgraph CLI's `CheckpointerConfig.serde`):
5463
55-
except json.JSONDecodeError as e:
56-
raise ValueError(f"Invalid JSON in {self.config_path}: {e}") from e
64+
{
65+
"checkpointer": {
66+
"serde": {
67+
"allowed_msgpack_modules": [
68+
["my_app.state", "MyState"]
69+
]
70+
}
71+
}
72+
}
73+
"""
74+
config = self._load()
75+
checkpointer = config.get("checkpointer")
76+
if not isinstance(checkpointer, dict):
77+
return None
78+
serde = checkpointer.get("serde")
79+
if not isinstance(serde, dict):
80+
return None
81+
modules = serde.get("allowed_msgpack_modules")
82+
if modules is None:
83+
return None
84+
if not isinstance(modules, list):
85+
raise ValueError(
86+
"'checkpointer.serde.allowed_msgpack_modules' must be a list "
87+
"of [module, class_name] pairs"
88+
)
89+
result: list[tuple[str, str]] = []
90+
for entry in modules:
91+
if (
92+
not isinstance(entry, list)
93+
or len(entry) != 2
94+
or not all(isinstance(part, str) for part in entry)
95+
):
96+
raise ValueError(
97+
f"Invalid entry in checkpointer.serde.allowed_msgpack_modules: "
98+
f"{entry!r} (expected [module, class_name])"
99+
)
100+
result.append((entry[0], entry[1]))
101+
return result
57102

58103
@property
59104
def entrypoints(self) -> list[str]:

src/uipath_langchain/runtime/factory.py

Lines changed: 38 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,22 @@
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+
38+
These are the first-party types the SDK itself places into checkpoints
39+
(`CreateTask`, `InvokeProcess`, `WaitJob`, …) and that users should not
40+
have to register manually.
41+
"""
42+
from uipath.platform.common import interrupt_models
43+
44+
return [
45+
(cls.__module__, cls.__name__)
46+
for _, cls in inspect.getmembers(interrupt_models, inspect.isclass)
47+
if cls.__module__ == interrupt_models.__name__
48+
]
49+
50+
3351
class UiPathLangGraphRuntimeFactory:
3452
"""Factory for creating LangGraph runtimes from langgraph.json configuration."""
3553

@@ -95,8 +113,28 @@ async def _get_memory(self) -> AsyncSqliteSaver:
95113
self._memory_cm = AsyncSqliteSaver.from_conn_string(connection_string)
96114
self._memory = await self._memory_cm.__aenter__()
97115
await self._memory.setup()
116+
self._apply_msgpack_allowlist(self._memory)
98117
return self._memory
99118

119+
def _apply_msgpack_allowlist(self, memory: AsyncSqliteSaver) -> None:
120+
"""Replace the saver's serde when the user declared a msgpack allowlist.
121+
122+
When `langgraph.json` contains `serde.allowed_msgpack_modules`, build
123+
a strict-mode `JsonPlusSerializer` whose allowlist is the union of the
124+
SDK's interrupt models (so checkpoint payloads like `CreateTask` and
125+
`InvokeProcess` keep round-tripping) and the user's declared types.
126+
127+
Without an opt-in, the saver keeps langgraph's default permissive
128+
serde — preserving today's behavior for existing agents.
129+
"""
130+
user_modules = self._load_config().allowed_msgpack_modules
131+
if user_modules is None:
132+
return
133+
sdk_modules = _collect_sdk_interrupt_modules()
134+
memory.serde = JsonPlusSerializer(
135+
allowed_msgpack_modules=[*sdk_modules, *user_modules],
136+
)
137+
100138
def _load_config(self) -> LangGraphConfig:
101139
"""Load langgraph.json configuration."""
102140
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)