Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
15 changes: 15 additions & 0 deletions samples/checkpoint-allowlist/README.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions samples/checkpoint-allowlist/input.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"topic": "weather"
}
14 changes: 14 additions & 0 deletions samples/checkpoint-allowlist/langgraph.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"dependencies": ["."],
"graphs": {
"agent": "./main.py:graph"
},
"env": ".env",
"checkpointer": {
"serde": {
"allowed_msgpack_modules": [
["main", "Score"]
]
}
}
}
31 changes: 31 additions & 0 deletions samples/checkpoint-allowlist/main.py
Original file line number Diff line number Diff line change
@@ -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()
9 changes: 9 additions & 0 deletions samples/checkpoint-allowlist/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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",
]
76 changes: 52 additions & 24 deletions src/uipath_langchain/runtime/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import json
import os
from typing import Any


class LangGraphConfig:
Expand All @@ -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]:
"""
Expand All @@ -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]:
Expand Down
24 changes: 24 additions & 0 deletions src/uipath_langchain/runtime/factory.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions testcases/checkpoint-allowlist/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 }
21 changes: 21 additions & 0 deletions testcases/checkpoint-allowlist/run.sh
Original file line number Diff line number Diff line change
@@ -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
39 changes: 39 additions & 0 deletions testcases/checkpoint-allowlist/src/assert.py
Original file line number Diff line number Diff line change
@@ -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())
Loading
Loading