From 1e44bda48ff4982b8ccfeec9c14156ea9e8ae5a2 Mon Sep 17 00:00:00 2001 From: Igor Soarez Date: Tue, 18 Aug 2026 20:04:28 +0100 Subject: [PATCH 1/6] fix(langgraph): detect subgraphs from bytecode instead of source (#8569) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes langchain-ai/langgraph#8559 `find_subgraph_pregel` runs once per node at build time and recovers each node function's reachable values with `inspect.getsource` + `ast.parse`; langchain-ai/langgraph#8559 measures that source parsing at 80% of `StateGraph.compile()`. This replaces it with a `dis` walk over `func.__code__`, which reads the same information already in memory. Closure cells and the globals named in `co_names` supply the values directly, and a walk over the instruction stream recovers the attribute paths the function actually takes. The closure alone cannot express those: a captured `holder` whose graph lives at `holder.graph` is reachable only if something records that `graph` is loaded off `holder`. Both implementations over-declare — a node can reference a graph it never invokes — and this one over-declares a different set. It no longer reports a graph named only along an attribute path in code the compiler removed; such a path cannot execute, so that entry was always a phantom. Nothing that can actually run stopped being detected. `find_subgraph_pregel` returns the first `PregelProtocol` it finds, so the remaining extra candidates only widen detection. ## Release note Subgraph auto-detection now reads node functions' bytecode instead of parsing their source. Graph builds with many function-backed nodes are substantially faster, and subgraphs are now detected inside functions with no retrievable source — defined in a REPL or notebook cell, or via `exec` — where detection previously failed silently and returned nothing. ## Performance Against [langgraph-build-bench]() — 713 nodes, 500 state fields, 264 tools; CPython 3.12.8, Apple silicon: ``` before build 1412 ms subgraph detection 1132 ms (80%) after build 286 ms subgraph detection 4 ms (2%) ``` **4.9x on total build, \~266x on detection.** The bench prints whole ms; at full precision detection is 1139 ms -> 4.23 ms. Co-authored-by: Elior Nataf Lackritz --- libs/langgraph/langgraph/pregel/_utils.py | 212 +++++-------- .../tests/test_subgraph_detection.py | 286 ++++++++++++++++++ 2 files changed, 357 insertions(+), 141 deletions(-) create mode 100644 libs/langgraph/tests/test_subgraph_detection.py diff --git a/libs/langgraph/langgraph/pregel/_utils.py b/libs/langgraph/langgraph/pregel/_utils.py index f7f91e08b..dabc309e0 100644 --- a/libs/langgraph/langgraph/pregel/_utils.py +++ b/libs/langgraph/langgraph/pregel/_utils.py @@ -1,11 +1,10 @@ from __future__ import annotations -import ast -import inspect +import dis import re -import textwrap from collections.abc import Callable, Sequence from functools import partial +from types import CodeType, FunctionType from typing import Any from langchain_core.runnables import ( @@ -17,7 +16,6 @@ from langchain_core.runnables import ( from langchain_core.runnables.base import RunnableBindingBase from langchain_core.runnables.config import run_in_executor from langgraph.checkpoint.base import ChannelVersions -from typing_extensions import override from langgraph._internal._runnable import RunnableCallable, RunnableSeq from langgraph._internal._timeout import sync_timeout_unsupported @@ -137,155 +135,87 @@ def validate_timeout_supported(runnable: Runnable, *, name: str) -> None: raise sync_timeout_unsupported(name) +# Values treated as dead ends when deciding whether to walk a function's +# bytecode. A container can hold a graph, but `find_subgraph_pregel` does not +# look inside one, so skipping it costs nothing while that holds. Matched by +# exact type, since a subclass of a builtin can carry attributes. +_LEAF_TYPES = frozenset( + { + int, + float, + complex, + bool, + str, + bytes, + bytearray, + list, + tuple, + dict, + set, + frozenset, + type(None), + } +) + + def get_function_nonlocals(func: Callable) -> list[Any]: - """Get the nonlocal variables accessed by a function. + """Get the values a function reaches from outside its own scope. Args: func: The function to check. Returns: - List[Any]: The nonlocal variables accessed by the function. + Every captured cell value, the globals the function names, and each + value along an attribute path it loads. Over-approximates: a value can + come back without the function reaching it at runtime. """ - try: - code = inspect.getsource(func) - tree = ast.parse(textwrap.dedent(code)) - visitor = FunctionNonLocals() - visitor.visit(tree) - values: list[Any] = [] - closure = ( - inspect.getclosurevars(func.__wrapped__) - if hasattr(func, "__wrapped__") and callable(func.__wrapped__) - else inspect.getclosurevars(func) - ) - candidates = {**closure.globals, **closure.nonlocals} - for k, v in candidates.items(): - if k in visitor.nonlocals: - values.append(v) - for kk in visitor.nonlocals: - if "." in kk and kk.startswith(k): - vv = v - for part in kk.split(".")[1:]: - if vv is None: - break - else: - try: - vv = getattr(vv, part) - except AttributeError: - break - else: - values.append(vv) - except (SyntaxError, TypeError, OSError, SystemError): + func = getattr(func, "__func__", func) # bound method -> function + wrapped = getattr(func, "__wrapped__", None) + if callable(wrapped): + func = getattr(wrapped, "__func__", wrapped) + if not isinstance(func, FunctionType): return [] + code = func.__code__ + cells: dict[str, Any] = {} + for name, cell in zip(code.co_freevars, func.__closure__ or ()): + try: + cells[name] = cell.cell_contents + except ValueError: + continue # empty cell: a recursive def not yet bound + + # Every captured value counts, referenced or not: over-declaring costs an + # introspection entry, under-declaring drops the subgraph's checkpoints and + # stream events. Checking each cell against the bytecode would cost more and + # only trade the cheap error for the expensive one. + values: list[Any] = list(cells.values()) + global_ns = func.__globals__ + globals_ = {name: global_ns[name] for name in code.co_names if name in global_ns} + if all(type(v) in _LEAF_TYPES for v in (*cells.values(), *globals_.values())): + return values + + # Nested code objects hold the references made by inner defs, lambdas and + # comprehensions, which resolve against the namespaces gathered above. + codes = [code] + for c in codes: + codes.extend(k for k in c.co_consts if isinstance(k, CodeType)) + value: Any = None + for instruction in dis.get_instructions(c): + opname = instruction.opname + if opname == "LOAD_GLOBAL": + value = globals_.get(instruction.argval) + elif opname == "LOAD_DEREF": + value = cells.get(instruction.argval) + elif opname in ("LOAD_ATTR", "LOAD_METHOD"): + value = getattr(value, instruction.argval, None) + else: + value = None # anything else ends the chain: `a, b.c` is not `a.c` + continue + if value is not None: + values.append(value) return values -class FunctionNonLocals(ast.NodeVisitor): - """Get the nonlocal variables accessed of a function.""" - - def __init__(self) -> None: - self.nonlocals: set[str] = set() - - @override - def visit_FunctionDef(self, node: ast.FunctionDef) -> Any: - """Visit a function definition. - - Args: - node: The node to visit. - - Returns: - Any: The result of the visit. - """ - visitor = NonLocals() - visitor.visit(node) - self.nonlocals.update(visitor.loads - visitor.stores) - - @override - def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> Any: - """Visit an async function definition. - - Args: - node: The node to visit. - - Returns: - Any: The result of the visit. - """ - visitor = NonLocals() - visitor.visit(node) - self.nonlocals.update(visitor.loads - visitor.stores) - - @override - def visit_Lambda(self, node: ast.Lambda) -> Any: - """Visit a lambda function. - - Args: - node: The node to visit. - - Returns: - Any: The result of the visit. - """ - visitor = NonLocals() - visitor.visit(node) - self.nonlocals.update(visitor.loads - visitor.stores) - - -class NonLocals(ast.NodeVisitor): - """Get nonlocal variables accessed.""" - - def __init__(self) -> None: - self.loads: set[str] = set() - self.stores: set[str] = set() - - @override - def visit_Name(self, node: ast.Name) -> Any: - """Visit a name node. - - Args: - node: The node to visit. - - Returns: - Any: The result of the visit. - """ - if isinstance(node.ctx, ast.Load): - self.loads.add(node.id) - elif isinstance(node.ctx, ast.Store): - self.stores.add(node.id) - - @override - def visit_Attribute(self, node: ast.Attribute) -> Any: - """Visit an attribute node. - - Args: - node: The node to visit. - - Returns: - Any: The result of the visit. - """ - if isinstance(node.ctx, ast.Load): - parent = node.value - attr_expr = node.attr - while isinstance(parent, ast.Attribute): - attr_expr = parent.attr + "." + attr_expr - parent = parent.value - if isinstance(parent, ast.Name): - self.loads.add(parent.id + "." + attr_expr) - self.loads.discard(parent.id) - elif isinstance(parent, ast.Call): - if isinstance(parent.func, ast.Name): - self.loads.add(parent.func.id) - else: - parent = parent.func - attr_expr = "" - while isinstance(parent, ast.Attribute): - if attr_expr: - attr_expr = parent.attr + "." + attr_expr - else: - attr_expr = parent.attr - parent = parent.value - if isinstance(parent, ast.Name): - self.loads.add(parent.id + "." + attr_expr) - - def is_xxh3_128_hexdigest(value: str) -> bool: """Check if the given string matches the format of xxh3_128_hexdigest.""" return bool(re.fullmatch(r"[0-9a-f]{32}", value)) diff --git a/libs/langgraph/tests/test_subgraph_detection.py b/libs/langgraph/tests/test_subgraph_detection.py new file mode 100644 index 000000000..dca56139f --- /dev/null +++ b/libs/langgraph/tests/test_subgraph_detection.py @@ -0,0 +1,286 @@ +"""Tests for subgraph auto-detection (`pregel/_utils.py`). + +Detection failing is silent — the graph still runs, only introspection goes +quiet — so every shape a node can hold a graph in is pinned here. The expected +values are what the source-parsing implementation this replaced produced for +the same shapes, except for `sourceless`, whose source it could not read, +`empty_closure_cell`, on which it raised, and `unreachable_attribute_chain`, +where it reported a graph that dropped code could never invoke. +""" + +import functools +import operator +from typing import Annotated, Any + +import pytest +from typing_extensions import TypedDict + +from langgraph.graph import END, START, StateGraph +from langgraph.pregel._utils import get_function_nonlocals + + +class State(TypedDict): + log: Annotated[list, operator.add] + + +def _leaf(tag: str) -> Any: + """Return a compiled graph that reports itself as `tag`.""" + builder = StateGraph(State) + builder.add_node(tag, lambda s: {"log": [tag]}) + builder.add_edge(START, tag) + builder.add_edge(tag, END) + compiled = builder.compile() + compiled.name = tag + return compiled + + +def _detect(node: Any) -> str | None: + """Return the name of the subgraph detected for `node`, or None.""" + builder = StateGraph(State) + builder.add_node("n", node) + builder.add_edge(START, "n") + builder.add_edge("n", END) + subgraphs = builder.compile().nodes["n"].subgraphs + return getattr(subgraphs[0], "name", "?") if subgraphs else None + + +class _Box: + def __init__(self, payload: Any) -> None: + self.payload = payload + + +class _ListSubclass(list): + pass + + +class _MethodHolder: + def __init__(self) -> None: + self.graph = _leaf("via_self") + + def as_node(self, state: State) -> Any: + return self.graph.invoke(state) + + +MODULE_GRAPH = _leaf("module_global") +CHAIN = _Box(_Box(_leaf("attr_chain"))) +GRAPH_IN_PLAIN_LIST = [_leaf("in_list")] +METHOD_HOLDER = _MethodHolder() + + +def closure_capture() -> Any: + sub = _leaf("closure") + + def node(state: State) -> Any: + return sub.invoke(state) + + return node + + +def module_global() -> Any: + def node(state: State) -> Any: + return MODULE_GRAPH.invoke(state) + + return node + + +def attribute_chain() -> Any: + def node(state: State) -> Any: + return CHAIN.payload.payload.invoke(state) + + return node + + +def nested_def_captured_attribute() -> Any: + """A chain on a captured holder, named only inside a nested code object. + + The captured value is the holder, not the graph, so the chain itself has to + be recovered from the nested scope. + """ + holder = _Box(_leaf("nested_captured")) + + def node(state: State) -> Any: + def inner() -> Any: + return holder.payload.invoke(state) + + return inner() + + return node + + +def unreachable_branch() -> Any: + """A captured graph referenced only from code the compiler removes.""" + sub = _leaf("unreachable") + + def node(state: State) -> Any: + if False: + sub.invoke(state) + return {"log": []} + + return node + + +def unreachable_attribute_chain() -> Any: + """A graph named only along an attribute path the compiler dropped. + + The closure keeps `holder`, but the `.payload` load is gone. The source + parser reported this one; dropped code cannot invoke anything, so that was + a phantom rather than a detection. + """ + holder = _Box(_leaf("unreachable_attr")) + + def node(state: State) -> Any: + if False: + holder.payload.invoke(state) + return {"log": []} + + return node + + +def wrapper_referencing_nothing() -> Any: + """A wrapper whose own scope holds nothing, so only `__wrapped__` leads on. + + `functools.wraps` would leave the wrapper closing over the inner function; + setting the attribute by hand does not. + """ + sub = _leaf("via_wrapped") + + def inner(state: State) -> Any: + return sub.invoke(state) + + def wrapper(state: State) -> Any: + return {"log": []} + + wrapper.__wrapped__ = inner + return wrapper + + +def captured_list_subclass() -> Any: + """A `list` subclass is not a leaf: it can carry a graph as an attribute.""" + holder = _ListSubclass() + holder.payload = _leaf("list_subclass") + + def node(state: State) -> Any: + return holder.payload.invoke(state) + + return node + + +def empty_closure_cell() -> Any: + """An unassigned closure variable leaves a cell that cannot be read.""" + sub = _leaf("beside_empty_cell") + + def node(state: State) -> Any: + return unassigned, sub.invoke(state) + + return node + unassigned = 1 # never runs, so the cell it creates is never filled + + +def sourceless() -> Any: + """A node compiled without a source file, which `getsource` could not read.""" + namespace: dict[str, Any] = {"SOURCELESS": _leaf("sourceless")} + exec( + compile( + "def node(state):\n return SOURCELESS.invoke(state)", "", "exec" + ), + namespace, + ) + return namespace["node"] + + +async def _async_node(state: State) -> Any: + return await MODULE_GRAPH.ainvoke(state) + + +def async_node() -> Any: + return _async_node + + +def no_subgraph() -> Any: + """Nothing but leaf values in reach, so the bytecode walk is skipped.""" + + def node(state: State) -> Any: + return {"log": [len("abc") + 1]} + + return node + + +def recombined_names() -> Any: + """Loads `CHAIN.payload` and `local.payload`, never `CHAIN.payload.payload`.""" + + def node(state: State) -> Any: + local = _Box("not a graph") + return {"log": [CHAIN.payload, local.payload]} + + return node + + +def broken_attribute_chain() -> Any: + holder = _Box("a string, so `.payload.missing` cannot resolve") + + def node(state: State) -> Any: + return holder.payload.missing.invoke(state) + + return node + + +def nested_def_global() -> Any: + """A global named only in a nested code object: out of reach, as before.""" + + def node(state: State) -> Any: + def inner() -> Any: + return MODULE_GRAPH.invoke(state) + + return inner() + + return node + + +def graph_in_plain_list() -> Any: + def node(state: State) -> Any: + return GRAPH_IN_PLAIN_LIST[0].invoke(state) + + return node + + +def bound_method_self() -> Any: + return METHOD_HOLDER.as_node + + +@pytest.mark.parametrize( + ("factory", "expected"), + [ + (closure_capture, "closure"), + (module_global, "module_global"), + (attribute_chain, "attr_chain"), + (nested_def_captured_attribute, "nested_captured"), + (unreachable_branch, "unreachable"), + (wrapper_referencing_nothing, "via_wrapped"), + (captured_list_subclass, "list_subclass"), + (empty_closure_cell, "beside_empty_cell"), + (sourceless, "sourceless"), + (async_node, "module_global"), + # Shapes no reference chain reaches: a subscript, an instance attribute + # of `self`, a global named only in a nested scope, and an attribute + # path the compiler dropped. + (no_subgraph, None), + (recombined_names, None), + (broken_attribute_chain, None), + (nested_def_global, None), + (graph_in_plain_list, None), + (bound_method_self, None), + (unreachable_attribute_chain, None), + ], + ids=lambda value: value.__name__ if callable(value) else str(value), +) +def test_subgraph_detection(factory: Any, expected: str | None) -> None: + assert _detect(factory()) == expected + + +@pytest.mark.parametrize( + "candidate", + [functools.partial(lambda state, extra: {"log": [extra]}, extra="x"), len], + ids=["partial", "builtin"], +) +def test_callables_without_a_code_object_are_handled(candidate: Any) -> None: + assert get_function_nonlocals(candidate) == [] From 70918557cadb78b72877efac0cf725b347aee675 Mon Sep 17 00:00:00 2001 From: Connor Braa <3478454+cwlbraa@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:45:55 -0700 Subject: [PATCH 2/6] feat(sdk-py): add decrypt replacement result (#8598) ## Summary - add `DecryptResult` with plaintext and optional replacement ciphertext - keep raw decrypt return values backward compatible - export the result from `langgraph_sdk` ## Merge order Independent of langgraph-api#3884. Merge and release this SDK change before LSD-1489 consumes the result in langgraph-api. ## Test plan - `make format` - `make lint` - `make test` --- .gitignore | 5 +++ libs/sdk-py/langgraph_sdk/__init__.py | 11 ++++-- .../langgraph_sdk/encryption/__init__.py | 17 +++++++--- libs/sdk-py/langgraph_sdk/encryption/types.py | 34 ++++++++++++++++--- libs/sdk-py/tests/test_encryption.py | 32 +++++++++++++++++ 5 files changed, 88 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index b154d3b56..7cf97c0d0 100644 --- a/.gitignore +++ b/.gitignore @@ -77,6 +77,10 @@ __pypackages__/ # Environments .env .envrc +*.crt +*.key +*.pem +credentials.json .venv .venvs env/ @@ -98,6 +102,7 @@ dmypy.json .vercel .turbo +node_modules/ .editorconfig .scratch .worktrees/ diff --git a/libs/sdk-py/langgraph_sdk/__init__.py b/libs/sdk-py/langgraph_sdk/__init__.py index dbe4ffa45..d91ba52b5 100644 --- a/libs/sdk-py/langgraph_sdk/__init__.py +++ b/libs/sdk-py/langgraph_sdk/__init__.py @@ -1,8 +1,15 @@ from langgraph_sdk.auth import Auth from langgraph_sdk.client import get_client, get_sync_client from langgraph_sdk.encryption import Encryption -from langgraph_sdk.encryption.types import EncryptionContext +from langgraph_sdk.encryption.types import DecryptResult, EncryptionContext __version__ = "0.4.2" -__all__ = ["Auth", "Encryption", "EncryptionContext", "get_client", "get_sync_client"] +__all__ = [ + "Auth", + "DecryptResult", + "Encryption", + "EncryptionContext", + "get_client", + "get_sync_client", +] diff --git a/libs/sdk-py/langgraph_sdk/encryption/__init__.py b/libs/sdk-py/langgraph_sdk/encryption/__init__.py index 79611de05..c2b3f8a03 100644 --- a/libs/sdk-py/langgraph_sdk/encryption/__init__.py +++ b/libs/sdk-py/langgraph_sdk/encryption/__init__.py @@ -18,6 +18,9 @@ import warnings from langgraph_sdk.encryption import types +_BlobDecryptorT = typing.TypeVar("_BlobDecryptorT", bound=types.BlobDecryptor) +_JsonDecryptorT = typing.TypeVar("_JsonDecryptorT", bound=types.JsonDecryptor) + class LangGraphBetaWarning(UserWarning): """Warning for beta features in LangGraph SDK.""" @@ -141,7 +144,7 @@ class _DecryptDecorators: def __init__(self, parent: Encryption): self._parent = parent - def blob(self, fn: types.BlobDecryptor) -> types.BlobDecryptor: + def blob(self, fn: _BlobDecryptorT) -> _BlobDecryptorT: """Register a blob decryption handler. The handler will be called to decrypt opaque data like checkpoint blobs. @@ -149,7 +152,9 @@ class _DecryptDecorators: Example: ```python @encryption.decrypt.blob - async def decrypt_blob(ctx: EncryptionContext, blob: bytes) -> bytes: + async def decrypt_blob( + ctx: EncryptionContext, blob: bytes + ) -> bytes | DecryptResult[bytes]: # Decrypt the blob using your encryption service return decrypted_blob ``` @@ -170,13 +175,15 @@ class _DecryptDecorators: self._parent._blob_decryptor = fn return fn - def json(self, fn: types.JsonDecryptor) -> types.JsonDecryptor: + def json(self, fn: _JsonDecryptorT) -> _JsonDecryptorT: """Register the JSON decryption handler. Example: ```python @encryption.decrypt.json - async def decrypt_json(ctx: EncryptionContext, data: dict) -> dict: + async def decrypt_json( + ctx: EncryptionContext, data: dict + ) -> dict | DecryptResult[dict]: # Decrypt the data return decrypt_data(data) ``` @@ -369,7 +376,7 @@ class Encryption: """Reference to encryption type definitions. Provides access to all type definitions used in the encryption system, - including EncryptionContext, BlobEncryptor, BlobDecryptor, + including EncryptionContext, DecryptResult, BlobEncryptor, BlobDecryptor, JsonEncryptor, and JsonDecryptor. """ diff --git a/libs/sdk-py/langgraph_sdk/encryption/types.py b/libs/sdk-py/langgraph_sdk/encryption/types.py index 92e65a8c6..fca78fa8e 100644 --- a/libs/sdk-py/langgraph_sdk/encryption/types.py +++ b/libs/sdk-py/langgraph_sdk/encryption/types.py @@ -9,10 +9,30 @@ from __future__ import annotations import typing from collections.abc import Awaitable, Callable +from dataclasses import dataclass Json = dict[str, typing.Any] """JSON-serializable dictionary type for structured data encryption.""" +T = typing.TypeVar("T") + + +@dataclass(frozen=True, slots=True) +class DecryptResult(typing.Generic[T]): + """Decrypted data and optional replacement ciphertext. + + Return this from a decrypt handler when encrypted data should be replaced, + such as after rotating its encryption key. Returning plaintext directly + remains supported when no replacement is needed. + + Attributes: + plaintext: Decrypted data returned to the caller + replacement: New encrypted data to persist in place of the input + """ + + plaintext: T + replacement: T | None = None + class EncryptionContext: """Context passed to encryption/decryption handlers. @@ -57,7 +77,9 @@ Returns: Awaitable that resolves to encrypted bytes """ -BlobDecryptor = Callable[[EncryptionContext, bytes], Awaitable[bytes]] +BlobDecryptor = Callable[ + [EncryptionContext, bytes], Awaitable[bytes | DecryptResult[bytes]] +] """Handler for decrypting opaque blob data like checkpoints. Note: Must be an async function. Decryption typically involves I/O operations @@ -68,7 +90,8 @@ Args: blob: The encrypted bytes to decrypt Returns: - Awaitable that resolves to decrypted bytes + Awaitable that resolves to decrypted bytes, or a DecryptResult containing + decrypted bytes and replacement ciphertext """ JsonEncryptor = Callable[[EncryptionContext, Json], Awaitable[Json]] @@ -101,7 +124,9 @@ Returns: Awaitable that resolves to encrypted JSON dictionary """ -JsonDecryptor = Callable[[EncryptionContext, Json], Awaitable[Json]] +JsonDecryptor = Callable[ + [EncryptionContext, Json], Awaitable[Json | DecryptResult[Json]] +] """Handler for decrypting structured JSON data. Note: Must be an async function. Decryption typically involves I/O operations @@ -115,7 +140,8 @@ Args: data: The encrypted JSON dictionary Returns: - Awaitable that resolves to decrypted JSON dictionary + Awaitable that resolves to a decrypted JSON dictionary, or a DecryptResult + containing decrypted JSON and replacement ciphertext """ if typing.TYPE_CHECKING: diff --git a/libs/sdk-py/tests/test_encryption.py b/libs/sdk-py/tests/test_encryption.py index 90804c2a1..5fabbe952 100644 --- a/libs/sdk-py/tests/test_encryption.py +++ b/libs/sdk-py/tests/test_encryption.py @@ -1,8 +1,40 @@ +from collections.abc import Awaitable, Callable + import pytest +from langgraph_sdk import DecryptResult, EncryptionContext from langgraph_sdk.encryption import DuplicateHandlerError, Encryption +def test_decrypt_result(): + result = DecryptResult(plaintext=b"plain", replacement=b"rotated") + + assert result.plaintext == b"plain" + assert result.replacement == b"rotated" + assert DecryptResult(plaintext={"plain": True}).replacement is None + + +def test_decrypt_decorators_preserve_return_types(): + encryption = Encryption() + + @encryption.decrypt.blob + async def blob_dec(_ctx: EncryptionContext, data: bytes) -> bytes: + return data + + @encryption.decrypt.json + async def json_dec( + _ctx: EncryptionContext, data: dict[str, object] + ) -> dict[str, object]: + return data + + blob_handler: Callable[[EncryptionContext, bytes], Awaitable[bytes]] = blob_dec + json_handler: Callable[ + [EncryptionContext, dict[str, object]], Awaitable[dict[str, object]] + ] = json_dec + assert blob_handler is blob_dec + assert json_handler is json_dec + + class TestHandlerValidation: """Test duplicate handler and signature validation.""" From 837212b9699bc4c3400b97714988344d348598f8 Mon Sep 17 00:00:00 2001 From: Connor Braa <3478454+cwlbraa@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:59:36 -0700 Subject: [PATCH 3/6] release(sdk-py): 0.4.3 (#8657) ## Summary Release `langgraph-sdk` 0.4.3, including `DecryptResult` support. ## Test plan - `make format` - `make lint` - `make test` --- libs/sdk-py/langgraph_sdk/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/sdk-py/langgraph_sdk/__init__.py b/libs/sdk-py/langgraph_sdk/__init__.py index d91ba52b5..a56e0b3b3 100644 --- a/libs/sdk-py/langgraph_sdk/__init__.py +++ b/libs/sdk-py/langgraph_sdk/__init__.py @@ -3,7 +3,7 @@ from langgraph_sdk.client import get_client, get_sync_client from langgraph_sdk.encryption import Encryption from langgraph_sdk.encryption.types import DecryptResult, EncryptionContext -__version__ = "0.4.2" +__version__ = "0.4.3" __all__ = [ "Auth", From f09cfe8ffc1eeffd68f4b628ed69c30f7cad229f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:02:48 +0000 Subject: [PATCH 4/6] chore(deps): bump langgraph-checkpoint-postgres from 3.0.5 to 3.1.1 in /libs/cli/uv-examples/monorepo in the uv group across 1 directory (#8646) Bumps the uv group with 1 update in the /libs/cli/uv-examples/monorepo directory: [langgraph-checkpoint-postgres](https://github.com/langchain-ai/langgraph). Updates `langgraph-checkpoint-postgres` from 3.0.5 to 3.1.1
Release notes

Sourced from langgraph-checkpoint-postgres's releases.

langgraph-checkpoint-postgres==3.1.1

Changes since checkpointpostgres==3.1.0

  • release(checkpoint-postgres): 3.1.1 (#8480)
  • fix(checkpoint-postgres,checkpoint-sqlite): scope namespace matching to segment boundaries (#8478)
  • feat(checkpoint,checkpoint-postgres): add opt-in omit_expired to skip expired rows on read (#8354)
  • chore(deps): bump the minor-and-patch group in /libs/checkpoint-postgres with 5 updates (#8250)
  • chore(deps): bump langsmith from 0.8.0 to 0.8.18 in /libs/checkpoint-postgres (#8171)
  • docs: standardize package README.md structure (#8064)
  • chore: migrate Python type checking to ty (#8002)
  • chore(deps): bump the minor-and-patch group in /libs/checkpoint-postgres with 7 updates (#7965)
  • release(checkpoint): 4.1.1 (#7890)
  • chore(deps): bump idna from 3.11 to 3.15 in /libs/checkpoint-postgres (#7861)
  • chore(deps): bump langsmith from 0.7.31 to 0.8.0 in /libs/checkpoint-postgres (#7785)

langgraph-checkpoint-sqlite==3.1.1

Changes since checkpointsqlite==3.1.0

  • release(checkpoint-sqlite): 3.1.1 (#8481)
  • fix(checkpoint-postgres,checkpoint-sqlite): scope namespace matching to segment boundaries (#8478)
  • chore(deps): bump the minor-and-patch group in /libs/checkpoint-sqlite with 4 updates (#8249)
  • chore(deps): bump langsmith from 0.8.0 to 0.8.18 in /libs/checkpoint-sqlite (#8177)
  • docs: standardize package README.md structure (#8064)
  • chore: migrate Python type checking to ty (#8002)
  • chore(deps): bump the minor-and-patch group in /libs/checkpoint-sqlite with 3 updates (#7961)
  • release(checkpoint): 4.1.1 (#7890)
  • chore(deps): bump langsmith from 0.7.31 to 0.8.0 in /libs/checkpoint-sqlite (#7786)
  • chore(deps): bump idna from 3.11 to 3.15 in /libs/checkpoint-sqlite (#7862)

langgraph-checkpoint-postgres==3.1.0

Changes since checkpointpostgres==3.1.0a4

  • release: bump alpha packages to official versions (#7775)
  • chore(deps): bump urllib3 from 2.6.3 to 2.7.0 in /libs/checkpoint-postgres (#7761)
  • chore(deps): bump langchain-core from 1.3.2 to 1.3.3 in /libs/checkpoint-postgres (#7754)
  • fix(checkpoint-postgres): add column aliases to seed-blob branch of delta stage-2 UNION ALL (#7728)

langgraph-checkpoint-sqlite==3.1.0

Changes since checkpointsqlite==3.1.0a1

  • release: bump alpha packages to official versions (#7775)
  • chore(deps): bump urllib3 from 2.6.3 to 2.7.0 in /libs/checkpoint-sqlite (#7760)
  • chore(deps): bump langchain-core from 1.2.28 to 1.3.3 in /libs/checkpoint-sqlite (#7751)
  • chore: remove keepset helper (#7745)
  • chore(langgraph): add guide/conformance for delta channel checkpointer (#7736)

langgraph-checkpoint-postgres==3.1.0a4

Changes since checkpointpostgres==3.1.0a3

  • release: alpha bump (a4) for langgraph, checkpoint, checkpoint-postgres (#7701)

... (truncated)

Commits
  • b2926a0 release(checkpoint-sqlite): 3.1.1 (#8481)
  • fcdf520 release(checkpoint-postgres): 3.1.1 (#8480)
  • 66ebe1a fix(checkpoint-postgres,checkpoint-sqlite): scope namespace matching to segme...
  • 4134145 release(langgraph): 1.2.10 (#8462)
  • 30c4d58 chore(deps): bump jupyterlab from 4.5.9 to 4.5.10 in /libs/langgraph (#8440)
  • 1f2f88b chore(deps): bump js-yaml from 4.2.0 to 4.3.0 in /libs/cli/js-monorepo-exampl...
  • 2708203 chore(deps): bump setuptools from 82.0.1 to 83.0.0 in /libs/cli (#8434)
  • 9f1e40b chore(deps): bump setuptools from 80.9.0 to 83.0.0 in /libs/langgraph (#8435)
  • 1e1ca88 feat(langgraph): type v3 stream_events return and native projections (#8389)
  • 31f90df revert(langgraph): delete TracePolicy (#8403)
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=langgraph-checkpoint-postgres&package-manager=uv&previous-version=3.0.5&new-version=3.1.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/langchain-ai/langgraph/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- libs/cli/langgraph_cli/uv_lock.py | 16 ++++++++++++++++ libs/cli/tests/unit_tests/test_config.py | 13 +++++++++++++ libs/cli/uv-examples/monorepo/uv.lock | 12 ++++++------ 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/libs/cli/langgraph_cli/uv_lock.py b/libs/cli/langgraph_cli/uv_lock.py index 1e33d16d7..985b7d9ef 100644 --- a/libs/cli/langgraph_cli/uv_lock.py +++ b/libs/cli/langgraph_cli/uv_lock.py @@ -970,6 +970,22 @@ def python_config_to_docker_uv_lock( f"{uv_export_project_dir}/uv.lock", ) ) + for package_root in sorted( + plan.all_workspace_roots, + key=lambda root: root.as_posix(), + ): + if package_root == plan.project_root: + continue + package_relative_path = pathlib.PurePosixPath( + package_root.relative_to(plan.project_root).as_posix() + ) + package_pyproject_path = package_relative_path / "pyproject.toml" + docker_plan.add_raw( + copy_from_project_root( + package_pyproject_path, + f"{uv_export_project_dir}/{package_pyproject_path.as_posix()}", + ) + ) docker_plan.add_instruction("WORKDIR", uv_export_project_dir) docker_plan.add_instruction( "RUN", diff --git a/libs/cli/tests/unit_tests/test_config.py b/libs/cli/tests/unit_tests/test_config.py index c8b24020c..fb9a36ccf 100644 --- a/libs/cli/tests/unit_tests/test_config.py +++ b/libs/cli/tests/unit_tests/test_config.py @@ -1403,6 +1403,19 @@ def test_config_to_docker_uv_lock(): "COPY --from=uv-workspace-root uv.lock /tmp/uv_export/project/uv.lock" in docker ) + workspace_pyprojects = [ + "apps/agent/pyproject.toml", + "libs/extra/pyproject.toml", + "libs/shared/pyproject.toml", + ] + export_instruction = "RUN uv export --package agent" + for pyproject_path in workspace_pyprojects: + copy_instruction = ( + "COPY --from=uv-workspace-root " + f"{pyproject_path} /tmp/uv_export/project/{pyproject_path}" + ) + assert copy_instruction in docker + assert docker.index(copy_instruction) < docker.index(export_instruction) assert additional_contexts == {"uv-workspace-root": str(project_root.resolve())} assert ( diff --git a/libs/cli/uv-examples/monorepo/uv.lock b/libs/cli/uv-examples/monorepo/uv.lock index 623882281..b7a1c926c 100644 --- a/libs/cli/uv-examples/monorepo/uv.lock +++ b/libs/cli/uv-examples/monorepo/uv.lock @@ -266,20 +266,20 @@ wheels = [ [[package]] name = "langgraph-checkpoint" -version = "4.0.1" +version = "4.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, { name = "ormsgpack" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/44/a8df45d1e8b4637e29789fa8bae1db022c953cc7ac80093cfc52e923547e/langgraph_checkpoint-4.0.1.tar.gz", hash = "sha256:b433123735df11ade28829e40ce25b9be614930cd50245ff2af60629234befd9", size = 158135, upload-time = "2026-02-27T21:06:16.092Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/e1/089c4c9e0a2fec7f883f82ae8e6a727138d50074cfeb6644bc2d13b1019b/langgraph_checkpoint-4.2.0.tar.gz", hash = "sha256:51a593b6bee684b0818e5d6e58e28ab340c6db7794575056ce7bd1b746a84ed7", size = 180239, upload-time = "2026-08-07T20:05:03.756Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/65/4c/09a4a0c42f5d2fc38d6c4d67884788eff7fd2cfdf367fdf7033de908b4c0/langgraph_checkpoint-4.0.1-py3-none-any.whl", hash = "sha256:e3adcd7a0e0166f3b48b8cf508ce0ea366e7420b5a73aa81289888727769b034", size = 50453, upload-time = "2026-02-27T21:06:14.293Z" }, + { url = "https://files.pythonhosted.org/packages/05/71/3b475f09bd57d3a5649792c66353312b4432afd843f301739dfcebd157f0/langgraph_checkpoint-4.2.0-py3-none-any.whl", hash = "sha256:0547fd228935a0b758865de3a3d6d7a2537c308895d0f9ab092ce9151b5da942", size = 56833, upload-time = "2026-08-07T20:05:02.655Z" }, ] [[package]] name = "langgraph-checkpoint-postgres" -version = "3.0.5" +version = "3.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langgraph-checkpoint" }, @@ -287,9 +287,9 @@ dependencies = [ { name = "psycopg" }, { name = "psycopg-pool" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/95/7a/8f439966643d32111248a225e6cb33a182d07c90de780c4dbfc1e0377832/langgraph_checkpoint_postgres-3.0.5.tar.gz", hash = "sha256:a8fd7278a63f4f849b5cbc7884a15ca8f41e7d5f7467d0a66b31e8c24492f7eb", size = 127856, upload-time = "2026-03-18T21:25:29.785Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/92/1e8959f8cd1b56e672fde3227f6fd642be85af6c5fd662d73921074aa39d/langgraph_checkpoint_postgres-3.1.1.tar.gz", hash = "sha256:d320e147ddad8c374cd546df0b52b532dd54d0541dd9fd23fc738cbd5de76f41", size = 150413, upload-time = "2026-07-30T19:15:39.014Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/87/b0f98b33a67204bca9d5619bcd9574222f6b025cf3c125eedcec9a50ecbc/langgraph_checkpoint_postgres-3.0.5-py3-none-any.whl", hash = "sha256:86d7040a88fd70087eaafb72251d796696a0a2d856168f5c11ef620771411552", size = 42907, upload-time = "2026-03-18T21:25:28.75Z" }, + { url = "https://files.pythonhosted.org/packages/03/32/ba457698a48a0e18d786caa770033067049fbe36d6846f8e50f13b594b51/langgraph_checkpoint_postgres-3.1.1-py3-none-any.whl", hash = "sha256:6e353aecd8150de144fef8e51a49076f58b7d6830d4cf51392b7ad4d79832ba7", size = 50778, upload-time = "2026-07-30T19:15:37.405Z" }, ] [[package]] From 38031739e551638e373fb553453256c23feeb41f Mon Sep 17 00:00:00 2001 From: John Kennedy <65985482+jkennedyvz@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:42:27 -0700 Subject: [PATCH 5/6] docs: add Corridor agent instructions (#8677) Add a repository-wide `` block to `AGENTS.md` requiring agents to plan first and run Corridor `analyzePlan` before generating or modifying code. The tags are explicit and balanced so Corridor-specific guidance remains scoped. Validation: `git diff --check` and a tag-balance assertion. --------- Co-authored-by: langsmith-fleet[bot] --- AGENTS.md | 8 ++++++++ CLAUDE.md | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 7a8d1dc12..5e5f0c00b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,14 @@ This repository is a monorepo. Each library lives in a subdirectory under `libs/`. + + +## Corridor security analysis + +When Corridor's `analyzePlan` tool is available, create a plan and use the tool to analyze it before generating or modifying code. Apply the resulting security guidance before writing code. + + + When you modify code in any library, run the following commands in that library's directory before creating a pull request: - `make format` – run code formatters diff --git a/CLAUDE.md b/CLAUDE.md index cc4ee1a9a..e86d91eaf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,14 @@ This repository is a monorepo. Each library lives in a subdirectory under `libs/`. + + +## Corridor security analysis + +Before generating or modifying code, create a plan and use Corridor's `analyzePlan` tool to analyze it. Apply the resulting security guidance before writing code. + + + When you modify code in any library, run the following commands in that library's directory before creating a pull request: - `make format` – run code formatters From bdb8a9c7a4aa1390af225f6a5d292e5088659bd5 Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Wed, 26 Aug 2026 16:57:39 -0400 Subject: [PATCH 6/6] feat: route LangSmith traces from thread streams (#8723) ## Description Expose the existing `langsmith_tracing` option on Python sync and async thread-stream run starts and forward it through the protocol. ## Release Note Python thread streams can route traces to an additional LangSmith project per run. ## Test Plan - [x] Verify sync and async run-start payloads include tracing settings ## Related PRs - langchain-ai/agent-protocol#95 - langchain-ai/langgraphjs#2745 - langchain-ai/langgraph-api#4033 Made by [Open SWE](https://openswe.vercel.app/agents/f9e34294-b9c3-52f0-815a-0102188e1181) Co-authored-by: open-swe[bot] --- libs/sdk-py/langgraph_sdk/_async/stream.py | 5 ++++- libs/sdk-py/langgraph_sdk/_sync/stream.py | 5 ++++- libs/sdk-py/tests/streaming/test_sync_thread_stream.py | 8 +++++++- libs/sdk-py/tests/streaming/test_thread_stream.py | 10 +++++++++- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/libs/sdk-py/langgraph_sdk/_async/stream.py b/libs/sdk-py/langgraph_sdk/_async/stream.py index 870ce2556..bc7e68ff6 100644 --- a/libs/sdk-py/langgraph_sdk/_async/stream.py +++ b/libs/sdk-py/langgraph_sdk/_async/stream.py @@ -24,7 +24,7 @@ from langchain_core.language_models.chat_model_stream import AsyncChatModelStrea from langchain_protocol import Event, SubscribeParams from langgraph_sdk._async.http import HttpClient -from langgraph_sdk.schema import QueryParamTypes +from langgraph_sdk.schema import LangSmithTracing, QueryParamTypes from langgraph_sdk.stream.controller import _SeenEventIds from langgraph_sdk.stream.decoders import ( DataDecoder, @@ -172,6 +172,7 @@ class RunModule: input: Any = None, config: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None, + langsmith_tracing: LangSmithTracing | None = None, ) -> dict[str, Any]: """Send `run.start` to the server. Returns the result (`{"run_id": ...}`).""" params: dict[str, Any] = {"assistant_id": self._owner.assistant_id} @@ -181,6 +182,8 @@ class RunModule: params["config"] = config if metadata is not None: params["metadata"] = metadata + if langsmith_tracing is not None: + params["langsmith_tracer"] = langsmith_tracing loop = asyncio.get_running_loop() gate: asyncio.Future[None] = loop.create_future() self._owner._run_start_ready = gate diff --git a/libs/sdk-py/langgraph_sdk/_sync/stream.py b/libs/sdk-py/langgraph_sdk/_sync/stream.py index 9405e7f83..fabbff24d 100644 --- a/libs/sdk-py/langgraph_sdk/_sync/stream.py +++ b/libs/sdk-py/langgraph_sdk/_sync/stream.py @@ -23,7 +23,7 @@ from langchain_core.language_models.chat_model_stream import ChatModelStream from langchain_protocol import Event, SubscribeParams from langgraph_sdk._sync.http import SyncHttpClient -from langgraph_sdk.schema import QueryParamTypes +from langgraph_sdk.schema import LangSmithTracing, QueryParamTypes from langgraph_sdk.stream.decoders import ( DataDecoder, Decoder, @@ -215,6 +215,7 @@ class SyncRunModule: input: Any = None, config: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None, + langsmith_tracing: LangSmithTracing | None = None, ) -> dict[str, Any]: """Send `run.start` to the server. Returns the result (`{"run_id": ...}`).""" params: dict[str, Any] = {"assistant_id": self._owner.assistant_id} @@ -224,6 +225,8 @@ class SyncRunModule: params["config"] = config if metadata is not None: params["metadata"] = metadata + if langsmith_tracing is not None: + params["langsmith_tracer"] = langsmith_tracing result = self._owner._send_command("run.start", params) self._owner._run_seen = True controller = self._owner._controller diff --git a/libs/sdk-py/tests/streaming/test_sync_thread_stream.py b/libs/sdk-py/tests/streaming/test_sync_thread_stream.py index 59a02da5d..e5469bb59 100644 --- a/libs/sdk-py/tests/streaming/test_sync_thread_stream.py +++ b/libs/sdk-py/tests/streaming/test_sync_thread_stream.py @@ -426,11 +426,17 @@ def test_sync_run_start_sends_command(): with httpx.Client(transport=fake.transport, base_url="http://test") as raw: threads = SyncThreadsClient(SyncHttpClient(raw)) with threads.stream(thread_id="t-1", assistant_id="agent") as thread: - result = thread.run.start(input={"x": 1}) + result = thread.run.start( + input={"x": 1}, + langsmith_tracing={"project_name": "replica-project"}, + ) assert result == {"run_id": "run-1"} assert fake.received_commands[0]["method"] == "run.start" assert fake.received_commands[0]["params"]["assistant_id"] == "agent" + assert fake.received_commands[0]["params"]["langsmith_tracer"] == { + "project_name": "replica-project" + } def test_sync_events_iterates_raw_events(): diff --git a/libs/sdk-py/tests/streaming/test_thread_stream.py b/libs/sdk-py/tests/streaming/test_thread_stream.py index 4617f4690..f683928be 100644 --- a/libs/sdk-py/tests/streaming/test_thread_stream.py +++ b/libs/sdk-py/tests/streaming/test_thread_stream.py @@ -287,7 +287,7 @@ async def test_command_ids_are_monotonic(): assert [c["id"] for c in fake.received_commands] == [1, 2] -async def test_run_start_forwards_config_and_metadata(): +async def test_run_start_forwards_config_metadata_and_langsmith_tracing(): fake = FakeServer() transport = httpx.ASGITransport(app=fake.app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw: @@ -297,10 +297,18 @@ async def test_run_start_forwards_config_and_metadata(): input={"x": 1}, config={"recursion_limit": 5}, metadata={"trace": "abc"}, + langsmith_tracing={ + "project_name": "replica-project", + "example_id": "example-1", + }, ) params = fake.received_commands[0]["params"] assert params["config"] == {"recursion_limit": 5} assert params["metadata"] == {"trace": "abc"} + assert params["langsmith_tracer"] == { + "project_name": "replica-project", + "example_id": "example-1", + } async def test_run_start_raises_outside_context_manager():