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/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 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]] 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) == [] diff --git a/libs/sdk-py/langgraph_sdk/__init__.py b/libs/sdk-py/langgraph_sdk/__init__.py index dbe4ffa45..a56e0b3b3 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" +__version__ = "0.4.3" -__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/_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/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/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(): 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."""