Compare commits

..
Author SHA1 Message Date
Saad Farooq f58bdf7646 fix(cli): decline root copy on patterns Docker reads differently
Docker treats `[!...]` and inner `**` differently from pathspec, and
tarfile skips codecs Python was built without. Decline on those patterns,
detect compressed archives by magic bytes, and pin the saved-Dockerfile
and compose paths to per-entry copies.
2026-11-02 19:15:10 -05:00
Saad Farooq cfb0fcc435 fix(cli): only collapse the uv root copy for immediate builds
A saved Dockerfile can be built after the tree changes, and ADD . would
then pick up entries the generation-time check never saw (.venv, .git,
symlinks). Gate the single copy behind single_root_copy, which only
build_docker_image sets.
2026-11-02 18:51:49 -05:00
Saad Farooq 777df4d7da refactor(cli): trim root-copy guard and its tests 2026-11-02 18:24:40 -05:00
Saad Farooq 2d4b6a6ce7 perf(cli): copy uv project root in a single layer when equivalent
In uv.lock mode the root package was copied with one ADD per top-level
entry. Emit a single ADD . when that provably yields the same files, and
keep the per-entry walk otherwise.
2026-11-02 18:13:47 -05:00
31 changed files with 324 additions and 805 deletions

No files matched your search

+15 -10
View File
@@ -115,16 +115,21 @@ jobs:
echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> .env
timeout --kill-after=30 300 python ../../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-h
echo "Finished starting up langgraph-test-h"
pins=$(grep -oE '"[a-z0-9-]+==[^"]+"' pyproject.toml | tr -d '"')
[ -n "$pins" ] || { echo "no == pins found in pyproject.toml"; exit 1; }
for pin in $pins; do
pkg=${pin%%==*}
installed=$(docker run --rm --entrypoint "" langgraph-test-h python -c "from importlib.metadata import version; print(version('$pkg'))")
if [ "$installed" != "${pin#*==}" ]; then
echo "$pkg $installed != pinned ${pin#*==}"
exit 1
fi
done
LANGGRAPH_VERSION=$(docker run --rm --entrypoint "" langgraph-test-h python -c "import sys; from importlib.metadata import version; v = version('langgraph'); print(v);")
if [ "$LANGGRAPH_VERSION" != "1.2.6" ]; then
echo "LANGGRAPH_VERSION != 1.2.6; $LANGGRAPH_VERSION"
exit 1
fi
LANGCHAIN_OPENAI_VERSION=$(docker run --rm --entrypoint "" langgraph-test-h python -c "import sys; from importlib.metadata import version; v = version('langchain-openai'); print(v);")
if [ "$LANGCHAIN_OPENAI_VERSION" != "1.1.14" ]; then
echo "LANGCHAIN_OPENAI_VERSION != 1.1.14; $LANGCHAIN_OPENAI_VERSION"
exit 1
fi
LANGCHAIN_ANTHROPIC_VERSION=$(docker run --rm --entrypoint "" langgraph-test-h python -c "import sys; from importlib.metadata import version; v = version('langchain-anthropic'); print(v);")
if [ "$LANGCHAIN_ANTHROPIC_VERSION" != "1.4.6" ]; then
echo "LANGCHAIN_ANTHROPIC_VERSION != 1.4.6; $LANGCHAIN_ANTHROPIC_VERSION"
exit 1
fi
- name: Build and test prerelease reqs fail service
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.4.41"
__version__ = "0.4.40"
+4
View File
@@ -1400,6 +1400,7 @@ def python_config_to_docker(
api_version: str | None = None,
*,
escape_variables: bool = False,
single_root_copy: bool = False,
) -> tuple[str, dict[str, str]]:
"""Generate a Dockerfile from the configuration."""
source_kind = _get_source_kind(config)
@@ -1412,6 +1413,7 @@ def python_config_to_docker(
base_image,
api_version=api_version,
build_tools_to_uninstall=build_tools_to_uninstall,
single_root_copy=single_root_copy,
)
_validate_local_dependency_files(config_path, config)
if pip_installer == "auto":
@@ -1772,6 +1774,7 @@ def config_to_docker(
build_command: str | None = None,
build_context: str | None = None,
escape_variables: bool = False,
single_root_copy: bool = False,
) -> tuple[str, dict[str, str]]:
base_image = base_image or default_base_image(config)
@@ -1792,6 +1795,7 @@ def config_to_docker(
base_image=base_image,
api_version=api_version,
escape_variables=escape_variables,
single_root_copy=single_root_copy,
)
+2
View File
@@ -387,6 +387,8 @@ def build_docker_image(
install_command=install_command,
build_command=build_command,
build_context=build_context,
# Safe because this Dockerfile is built immediately, not saved for later.
single_root_copy=True,
)
# add additional_contexts
if additional_contexts:
+63 -2
View File
@@ -2,6 +2,7 @@ import os
import pathlib
import re
import shlex
import tarfile
from dataclasses import dataclass
try:
@@ -13,6 +14,7 @@ import click
import pathspec
from langgraph_cli._ignore import (
_ALWAYS_EXCLUDE_NAMES,
_build_dockerignore_negation_hints,
_build_ignore_spec,
_is_always_excluded,
@@ -517,6 +519,56 @@ def _uv_lock_package_copy_items(
return iter_entries(plan.project_root)
_COMPRESSION_MAGIC = (b"\x1f\x8b", b"BZh", b"\xfd7zXZ\x00", b"\x28\xb5\x2f\xfd")
def _is_local_archive(path: pathlib.Path) -> bool:
"""Whether Docker's `ADD` would auto-extract this local file."""
with path.open("rb") as f:
# Match gzip/bzip2/xz/zstd by magic, since tarfile skips codecs Python lacks.
if f.read(6).startswith(_COMPRESSION_MAGIC):
return True
f.seek(0)
return tarfile.is_tarfile(f)
def _root_copy_is_equivalent(
plan: UvLockPlan,
config_root: pathlib.Path,
config: Config,
copy_items: tuple[tuple[pathlib.PurePosixPath, pathlib.PurePosixPath], ...],
) -> bool:
"""Whether one `ADD .` produces the same files as the per-entry ADDs."""
root = plan.project_root
# Only the build context's .dockerignore applies, and nested members need the walk.
if root != config_root or any(root in m.parents for m in plan.all_workspace_roots):
return False
ignore_file = root / ".dockerignore"
text = ignore_file.read_text(encoding="utf-8") if ignore_file.is_file() else ""
patterns = {s for line in text.splitlines() if (s := line.strip()) and s[0] != "#"}
# Negations need the walk; Docker reads `[...]` and inner `**` differently.
if any(
p.startswith("!") or "[" in p or "**" in p.removeprefix("**/") for p in patterns
):
return False
literals = {p.lstrip("/").removeprefix("./").rstrip("/") for p in patterns}
# Docker doesn't apply the CLI's built-in exclusions, so .dockerignore must.
if any(
c.name in _ALWAYS_EXCLUDE_NAMES
and c.is_dir()
and not {c.name, f"**/{c.name}"} & literals
for c in root.iterdir()
):
return False
# Per-entry ADD follows symlinks and extracts archives; `ADD .` does neither.
paths = [root / source for source, _ in copy_items]
if any(p.is_symlink() or (p.is_file() and _is_local_archive(p)) for p in paths):
return False
# Custom lines that touch /deps could make the destination merge differ.
return not any("/deps" in line for line in config["dockerfile_lines"])
def _resolve_uv_lock_container_path(
host_path: pathlib.Path, plan: UvLockPlan
) -> pathlib.PurePosixPath | None:
@@ -872,6 +924,7 @@ def python_config_to_docker_uv_lock(
api_version: str | None = None,
*,
build_tools_to_uninstall: tuple[str, ...] | None,
single_root_copy: bool = False,
) -> tuple[str, dict[str, str]]:
from langgraph_cli.config import (
DEFAULT_NODE_VERSION,
@@ -1023,9 +1076,17 @@ def python_config_to_docker_uv_lock(
for package in plan.install_order:
package_label = package.root.relative_to(plan.project_root).as_posix() or "."
docker_plan.add_raw(f"# -- Adding workspace package {package_label} --")
for source, destination in _uv_lock_package_copy_items(
package, plan, ignore_spec
copy_items = _uv_lock_package_copy_items(package, plan, ignore_spec)
# Only for immediate builds: `ADD .` copies the tree as it is at build time.
if (
single_root_copy
and package.root == plan.project_root
and _root_copy_is_equivalent(plan, config_root, config, copy_items)
):
copy_items = (
(pathlib.PurePosixPath("."), plan.container_roots[package.root]),
)
for source, destination in copy_items:
docker_plan.add_raw(copy_from_project_root(source, destination.as_posix()))
docker_plan.add_instruction(
"WORKDIR", plan.container_roots[package.root].as_posix()
+171
View File
@@ -2,13 +2,16 @@ import copy
import json
import os
import pathlib
import tarfile
import tempfile
import textwrap
from unittest.mock import patch
import click
import pytest
from click.testing import CliRunner
from langgraph_cli.cli import cli
from langgraph_cli.config import (
_BUILD_TOOLS,
_get_pip_cleanup_lines,
@@ -2360,6 +2363,174 @@ def test_config_to_docker_uv_lock_does_not_apply_gitignore():
assert "ADD README.md /deps/workspace/README.md" in docker
def _write_single_uv_project(project_root: pathlib.Path) -> None:
project_root.mkdir(parents=True)
(project_root / "uv.lock").write_text("# uv lock file\n")
(project_root / "pyproject.toml").write_text(
'[project]\nname = "single-app"\nversion = "0.1.0"\n'
'dependencies = ["httpx>=0.28"]\n'
)
(project_root / "langgraph.json").write_text("{}\n")
(project_root / "src").mkdir()
(project_root / "src" / "agent.py").write_text("graph = object()\n")
(project_root / "README.md").write_text("# hi\n")
def _single_uv_docker(
config_path: pathlib.Path, single_root_copy: bool = True, **overrides
) -> str:
config = validate_config(
{
"python_version": "3.11",
"graphs": {"agent": "./src/agent.py:graph"},
"source": {"kind": "uv"},
**overrides,
}
)
docker, _ = config_to_docker(
config_path,
config,
base_image="langchain/langgraph-api:0.2.47",
single_root_copy=single_root_copy,
)
return docker
def test_config_to_docker_uv_lock_copies_safe_root_in_one_layer():
with tempfile.TemporaryDirectory() as tmpdir:
project_root = pathlib.Path(tmpdir) / "single"
_write_single_uv_project(project_root)
(project_root / "docs").mkdir()
(project_root / "docs" / "notes.md").write_text("notes\n")
# Built-in exclusions are fine when Docker excludes them too.
(project_root / ".venv").mkdir()
(project_root / "__pycache__").mkdir()
(project_root / ".dockerignore").write_text("/.venv/\n**/__pycache__\n")
docker = _single_uv_docker(project_root / "langgraph.json")
source_copies = [
line
for line in docker.splitlines()
if line.startswith("ADD ") and "/deps/workspace" in line
]
assert source_copies == ["ADD . /deps/workspace"]
# Dependency metadata is still copied separately ahead of uv export.
assert "ADD pyproject.toml /tmp/uv_export/project/pyproject.toml" in docker
assert "ADD uv.lock /tmp/uv_export/project/uv.lock" in docker
# A saved Dockerfile may be built after the tree changes, so keep the list.
saved = _single_uv_docker(
project_root / "langgraph.json", single_root_copy=False
)
assert "ADD . /deps/workspace" not in saved
assert "ADD src /deps/workspace/src" in saved
def _write_tar(root: pathlib.Path) -> None:
# No extension, so detection must go by content.
with tarfile.open(root / "bundle.bin", "w") as tar:
tar.add(root / "README.md", arcname="README.md")
def _add_unrelated_member(root: pathlib.Path) -> None:
with (root / "pyproject.toml").open("a") as f:
f.write('\n[tool.uv.workspace]\nmembers = ["libs/*"]\n')
(root / "libs" / "extra").mkdir(parents=True)
(root / "libs" / "extra" / "pyproject.toml").write_text(
'[project]\nname = "extra"\nversion = "0.1.0"\n'
)
def _venv_ignored_by(patterns: str):
def setup(root: pathlib.Path) -> None:
(root / ".venv").mkdir()
(root / ".dockerignore").write_text(patterns)
return setup
@pytest.mark.parametrize(
("setup", "overrides"),
[
pytest.param(lambda r: (r / ".venv").mkdir(), {}, id="unexcluded_venv"),
pytest.param(
_venv_ignored_by(".venv-old\nsrc/.venv\n"), {}, id="near_miss_literal"
),
pytest.param(_venv_ignored_by(".venv\n[!t]*.log\n"), {}, id="char_class"),
pytest.param(_venv_ignored_by(".venv\ndata/**\n"), {}, id="inner_globstar"),
pytest.param(_add_unrelated_member, {}, id="workspace_member"),
pytest.param(
lambda r: (r / ".dockerignore").write_text("*.log\n!keep.log\n"),
{},
id="negation",
),
pytest.param(lambda r: (r / "linked").symlink_to("src"), {}, id="symlink"),
pytest.param(_write_tar, {}, id="tar"),
pytest.param(
lambda r: (r / "data.zst").write_bytes(b"\x28\xb5\x2f\xfd" + bytes(16)),
{},
id="zstd",
),
pytest.param(
lambda r: None,
{"dockerfile_lines": ["RUN mkdir -p /deps/workspace/cache"]},
id="deps_dockerfile_line",
),
],
)
def test_config_to_docker_uv_lock_keeps_per_entry_copies_when_unsafe(setup, overrides):
with tempfile.TemporaryDirectory() as tmpdir:
project_root = pathlib.Path(tmpdir) / "single"
_write_single_uv_project(project_root)
setup(project_root)
docker = _single_uv_docker(project_root / "langgraph.json", **overrides)
assert "ADD . /deps/workspace" not in docker
assert "ADD src /deps/workspace/src" in docker
assert "ADD README.md /deps/workspace/README.md" in docker
def test_saved_and_compose_dockerfiles_keep_per_entry_copies():
with tempfile.TemporaryDirectory() as tmpdir:
project_root = pathlib.Path(tmpdir) / "single"
_write_single_uv_project(project_root)
config_path = project_root / "langgraph.json"
raw = {"graphs": {"agent": "./src/agent.py:graph"}, "source": {"kind": "uv"}}
config_path.write_text(json.dumps({"python_version": "3.11", **raw}))
compose = config_to_compose(
config_path, validate_config_file(config_path), "langchain/langgraph-api"
)
saved = project_root / "Dockerfile"
result = CliRunner().invoke(
cli, ["dockerfile", str(saved), "--config", str(config_path)]
)
assert result.exit_code == 0, result.output
for dockerfile in (compose, saved.read_text()):
assert "ADD . /deps/workspace" not in dockerfile
assert "ADD src /deps/workspace/src" in dockerfile
def test_config_to_docker_uv_lock_keeps_per_entry_copies_outside_build_context():
with tempfile.TemporaryDirectory() as tmpdir:
project_root = pathlib.Path(tmpdir) / "single"
_write_single_uv_project(project_root)
(project_root / "deploy").mkdir()
(project_root / "deploy" / "langgraph.json").write_text("{}\n")
docker = _single_uv_docker(
project_root / "deploy" / "langgraph.json",
graphs={"agent": "../src/agent.py:graph"},
source={"kind": "uv", "root": ".."},
)
assert "COPY --from=uv-workspace-root . /deps/workspace" not in docker
assert "COPY --from=uv-workspace-root src /deps/workspace/src" in docker
def test_config_to_docker_uv_lock_skips_dockerignore_entries_in_workspace():
"""Multi-member workspace: ignore patterns must filter root-level entries
AND entries encountered while recursing into directories that contain
+24
View File
@@ -1,3 +1,6 @@
import pathlib
from unittest.mock import MagicMock, patch
import pytest
from langgraph_cli.docker import (
@@ -5,6 +8,7 @@ from langgraph_cli.docker import (
DockerCapabilities,
Version,
_parse_version,
build_docker_image,
compose,
)
from langgraph_cli.util import clean_empty_lines
@@ -440,3 +444,23 @@ def test_compose_combined_mode_has_no_n_jobs():
)
def test_parse_version_w_edge_cases(input_str, expected):
assert _parse_version(input_str) == expected
def test_build_docker_image_opts_into_single_root_copy():
with (
patch(
"langgraph_cli.config.config_to_docker", return_value=("", {})
) as to_docker,
patch("langgraph_cli.docker.subp_exec"),
):
build_docker_image(
MagicMock(),
lambda _: None,
pathlib.Path("/tmp/langgraph.json"),
{"python_version": "3.11"},
base_image=None,
api_version=None,
pull=False,
tag="probe",
)
assert to_docker.call_args.kwargs["single_root_copy"] is True
@@ -74,8 +74,6 @@ CONFIG_KEY_RUNTIME = sys.intern("__pregel_runtime")
# holds a `Runtime` instance with context, store, stream writer, etc.
CONFIG_KEY_RESUME_MAP = sys.intern("__pregel_resume_map")
# holds a mapping of task ns -> resume value for resuming tasks
CONFIG_KEY_STRICT_RESUME = sys.intern("__pregel_strict_resume")
# when True, the root graph refuses a resume that can't be matched to its pending interrupts
CONFIG_KEY_STREAM_MESSAGES_V2 = sys.intern("__pregel_stream_messages_v2")
# when True, attach StreamMessagesHandlerV2 so content-block (v2) events
# flow through stream_mode="messages"; set by StreamingHandler only.
@@ -136,7 +134,6 @@ RESERVED = {
CONFIG_KEY_CHECKPOINT_NS,
CONFIG_KEY_TIMED_ATTEMPT_OBSERVER,
CONFIG_KEY_RESUME_MAP,
CONFIG_KEY_STRICT_RESUME,
CONFIG_KEY_STREAM_MESSAGES_V2,
# other constants
PUSH,
-40
View File
@@ -28,7 +28,6 @@ __all__ = (
"ParentCommand",
"EmptyInputError",
"TaskNotFound",
"UnknownInterruptError",
"is_invalid_resume",
)
@@ -147,45 +146,6 @@ class TaskNotFound(Exception):
pass
class UnknownInterruptError(Exception):
"""Raised in strict resume mode when a resume doesn't match the pending interrupts.
`reason` says how it doesn't match:
- `"not_pending"`: the resume names interrupts that aren't pending, listed in
`interrupt_ids`.
- `"empty_map"`: the resume is an empty map.
- `"ambiguous"`: the resume is a single value while several interrupts are
pending.
`pending_ids` lists the interrupts still waiting for an answer. The error is
raised before anything is written, so they can still be answered.
A single value with no interrupt pending is accepted, because it resumes a
graph paused at a static breakpoint (`interrupt_before` or `interrupt_after`).
The interrupts one task raises share an id, so an answer retried after the
task asked its next question answers that question.
"""
reason: Literal["not_pending", "empty_map", "ambiguous"]
interrupt_ids: tuple[str, ...]
pending_ids: tuple[str, ...]
def __init__(
self,
message: str,
*,
reason: Literal["not_pending", "empty_map", "ambiguous"],
interrupt_ids: Sequence[str] = (),
pending_ids: Sequence[str] = (),
) -> None:
super().__init__(message)
self.reason = reason
self.interrupt_ids = tuple(interrupt_ids)
self.pending_ids = tuple(pending_ids)
@dataclass(frozen=True, slots=True)
class NodeError:
"""Failure context passed to a node-level error handler.
+9 -15
View File
@@ -85,7 +85,6 @@ from langgraph.types import (
RetryPolicy,
Send,
TimeoutPolicy,
_interrupt_id,
)
GetNextVersion = Callable[[V | None, None], V]
@@ -627,7 +626,7 @@ def prepare_single_task(
config[CONF].get(CONFIG_KEY_SCRATCHPAD),
pending_writes,
task_id,
task_checkpoint_ns,
xxh3_128_hexdigest(task_checkpoint_ns.encode()),
config[CONF].get(CONFIG_KEY_RESUME_MAP),
step,
stop,
@@ -874,7 +873,7 @@ def prepare_push_task_functional(
configurable.get(CONFIG_KEY_SCRATCHPAD),
pending_writes,
task_id,
task_checkpoint_ns,
xxh3_128_hexdigest(task_checkpoint_ns.encode()),
configurable.get(CONFIG_KEY_RESUME_MAP),
step,
stop,
@@ -1036,7 +1035,7 @@ def prepare_push_task_send(
config[CONF].get(CONFIG_KEY_SCRATCHPAD),
pending_writes,
task_id,
task_checkpoint_ns,
xxh3_128_hexdigest(task_checkpoint_ns.encode()),
config[CONF].get(CONFIG_KEY_RESUME_MAP),
step,
stop,
@@ -1185,7 +1184,7 @@ def prepare_node_error_handler_task(
config[CONF].get(CONFIG_KEY_SCRATCHPAD),
pending_writes,
task_id,
task_checkpoint_ns,
xxh3_128_hexdigest(task_checkpoint_ns.encode()),
config[CONF].get(CONFIG_KEY_RESUME_MAP),
step,
stop,
@@ -1281,7 +1280,7 @@ def _scratchpad(
parent_scratchpad: PregelScratchpad | None,
pending_writes: list[PendingWrite],
task_id: str,
task_checkpoint_ns: str,
namespace_hash: str,
resume_map: dict[str, Any] | None,
step: int,
stop: int,
@@ -1308,15 +1307,10 @@ def _scratchpad(
task_resume_write = []
del w
# find the resume value for the task's next interrupt
if resume_map:
next_id = _interrupt_id(task_checkpoint_ns, len(task_resume_write))
first_id = _interrupt_id(task_checkpoint_ns, 0)
if next_id in resume_map:
task_resume_write.append(resume_map[next_id])
# Older versions gave all of a task's interrupts the first id.
elif first_id in resume_map:
task_resume_write.append(resume_map[first_id])
# find namespace and task-specific resume value
if resume_map and namespace_hash in resume_map:
mapped_resume_write = resume_map[namespace_hash]
task_resume_write.append(mapped_resume_write)
else:
null_resume_write = None
+3 -33
View File
@@ -48,7 +48,6 @@ from langgraph._internal._constants import (
CONFIG_KEY_RUNTIME,
CONFIG_KEY_SCRATCHPAD,
CONFIG_KEY_STREAM,
CONFIG_KEY_STRICT_RESUME,
CONFIG_KEY_TASK_ID,
CONFIG_KEY_THREAD_ID,
ERROR,
@@ -78,7 +77,6 @@ from langgraph.constants import TAG_HIDDEN
from langgraph.errors import (
EmptyInputError,
GraphInterrupt,
UnknownInterruptError,
)
from langgraph.managed.base import (
ManagedValueMapping,
@@ -1026,45 +1024,17 @@ class PregelLoop:
"Cannot use Command(resume=...) without checkpointer"
)
strict = (
self.config[CONF].get(CONFIG_KEY_STRICT_RESUME, False)
# Nested calls inherit the parent's configurable.
and not self.is_nested
# A finished task can rerun and interrupt again on replay, while
# its earlier output still makes its interrupt look answered.
and not self._addressed_checkpoint_superseded
)
if resume_is_map := (
isinstance(resume, dict)
and all(is_xxh3_128_hexdigest(k) for k in resume)
):
if strict:
pending = self._pending_interrupts()
if not resume:
raise UnknownInterruptError(
"The resume map names no interrupt",
reason="empty_map",
pending_ids=sorted(pending),
)
if unknown := sorted(resume.keys() - pending):
raise UnknownInterruptError(
f"Unknown or already-consumed interrupt: {unknown[0]}",
reason="not_pending",
interrupt_ids=unknown,
pending_ids=sorted(pending),
)
self.config[CONF][CONFIG_KEY_RESUME_MAP] = resume
else:
if len(pending := self._pending_interrupts()) > 1:
message = (
if len(self._pending_interrupts()) > 1:
raise RuntimeError(
"When there are multiple pending interrupts, you must specify the interrupt id when resuming. "
"Docs: https://docs.langchain.com/oss/python/langgraph/interrupts#handling-multiple-interrupts."
"Docs: https://docs.langchain.com/oss/python/langgraph/add-human-in-the-loop#resume-multiple-interrupts-with-one-invocation."
)
if strict:
raise UnknownInterruptError(
message, reason="ambiguous", pending_ids=sorted(pending)
)
raise RuntimeError(message)
writes: defaultdict[str, list[tuple[str, Any]]] = defaultdict(list)
# group writes by task ID
-21
View File
@@ -28,7 +28,6 @@ from langgraph._internal._constants import (
CONFIG_KEY_CHECKPOINT_NS,
CONFIG_KEY_RESUMING,
CONFIG_KEY_RUNTIME,
CONFIG_KEY_SCRATCHPAD,
CONFIG_KEY_SEND,
CONFIG_KEY_STREAM,
CONFIG_KEY_TASK_ID,
@@ -45,7 +44,6 @@ from langgraph.errors import (
NodeTimeoutError,
ParentCommand,
)
from langgraph.pregel._algo import LazyAtomicCounter
from langgraph.pregel.protocol import StreamProtocol
from langgraph.runtime import ExecutionInfo, Runtime
from langgraph.types import Command, PregelExecutableTask, RetryPolicy, TimeoutPolicy
@@ -573,23 +571,6 @@ def _checkpoint_ns_for_parent_command(ns: str) -> str:
return NS_SEP.join(parts)
def _reset_interrupt_counter(config: RunnableConfig) -> RunnableConfig:
# A retry reruns the node from its first line, so its interrupt() calls count
# from the first again to reuse the answers its task already has, rather than
# asking again under an id the next resume can't match.
scratchpad = config[CONF].get(CONFIG_KEY_SCRATCHPAD)
if scratchpad is None:
return config
return patch_configurable(
config,
{
CONFIG_KEY_SCRATCHPAD: replace(
scratchpad, interrupt_counter=LazyAtomicCounter()
)
},
)
def run_with_retry(
task: PregelExecutableTask,
retry_policy: Sequence[RetryPolicy] | None,
@@ -700,7 +681,6 @@ def run_with_retry(
)
# signal subgraphs to resume (if available)
config = patch_configurable(config, {CONFIG_KEY_RESUMING: True})
config = _reset_interrupt_counter(config)
async def arun_with_retry(
@@ -849,7 +829,6 @@ async def arun_with_retry(
)
# signal subgraphs to resume (if available)
config = patch_configurable(config, {CONFIG_KEY_RESUMING: True})
config = _reset_interrupt_counter(config)
def _return_value(task: PregelExecutableTask) -> Any:
@@ -50,7 +50,6 @@ from langgraph._internal._constants import (
CONFIG_KEY_CHECKPOINT_MAP,
CONFIG_KEY_CHECKPOINT_NS,
CONFIG_KEY_STREAM,
CONFIG_KEY_STRICT_RESUME,
CONFIG_KEY_TASK_ID,
INTERRUPT,
NS_SEP,
@@ -82,7 +81,6 @@ _CONF_DROPLIST = frozenset(
CONFIG_KEY_CHECKPOINT_ID,
CONFIG_KEY_CHECKPOINT_NS,
CONFIG_KEY_TASK_ID,
CONFIG_KEY_STRICT_RESUME,
),
)
+3 -15
View File
@@ -33,7 +33,6 @@ from xxhash import xxh3_128_hexdigest
from langgraph._internal._cache import default_cache_key
from langgraph._internal._constants import INTERRUPT as _INTERRUPT_KEY
from langgraph._internal._constants import NS_SEP
from langgraph._internal._fields import get_cached_annotated_keys, get_update_as_tuples
from langgraph._internal._retry import default_retry_on
from langgraph._internal._typing import MISSING, DeprecatedKwargs
@@ -580,15 +579,6 @@ def omit_payload(_value: Any) -> dict[str, Any]:
_DEFAULT_INTERRUPT_ID = "placeholder-id"
def _interrupt_id(ns: str, answered: int) -> str:
# Keyed by how many answers the task already has, the slot this interrupt's
# answer fills; call order isn't stable across parallel calls in one task.
# With none it's the bare namespace hash older versions gave every
# interrupt, so saved ids still match. The suffix can't form another task's
# namespace, whose segments all contain ":".
return xxh3_128_hexdigest((f"{ns}{NS_SEP}{answered}" if answered else ns).encode())
@final
@dataclass(init=False, slots=True)
class Interrupt(Generic[ResponseT]):
@@ -651,7 +641,7 @@ class Interrupt(Generic[ResponseT]):
) -> Interrupt[ResponseT]:
return cls(
value=value,
id=_interrupt_id(ns, 0),
id=xxh3_128_hexdigest(ns.encode()),
response_schema=response_schema,
)
@@ -1047,11 +1037,9 @@ def interrupt(
# no resume value found
raise GraphInterrupt(
(
Interrupt(
Interrupt.from_ns(
value=value,
id=_interrupt_id(
conf[CONFIG_KEY_CHECKPOINT_NS], len(scratchpad.resume)
),
ns=conf[CONFIG_KEY_CHECKPOINT_NS],
response_schema=adapter.json_schema() if adapter else response_schema,
),
)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph"
version = "1.2.20"
version = "1.2.19"
description = "Building stateful, multi-actor applications with LLMs"
authors = []
requires-python = ">=3.10"
+1 -2
View File
@@ -16,7 +16,7 @@ from langgraph_sdk.schema import StreamPart
from pydantic import BaseModel
from typing_extensions import TypedDict
from langgraph._internal._constants import CONFIG_KEY_STREAM, CONFIG_KEY_STRICT_RESUME
from langgraph._internal._constants import CONFIG_KEY_STREAM
from langgraph.errors import GraphInterrupt
from langgraph.graph import END, START, MessagesState, StateGraph, add_messages
from langgraph.pregel import Pregel
@@ -1360,7 +1360,6 @@ def test_sanitize_config():
"checkpoint_map": {"key": "value"}, # Should be dropped
"checkpoint_id": "123", # Should be dropped
"checkpoint_ns": "ns", # Should be dropped
CONFIG_KEY_STRICT_RESUME: True, # Should be dropped
}
}
sanitized = remote._sanitize_config(config_with_drops)
-423
View File
@@ -1,423 +0,0 @@
import operator
import sys
import uuid
from typing import Annotated
import pytest
from langchain_core.runnables import RunnableConfig
from langchain_core.runnables.config import get_executor_for_config
from langgraph.checkpoint.base import BaseCheckpointSaver
from typing_extensions import TypedDict
from xxhash import xxh3_128_hexdigest
from langgraph._internal._constants import CONFIG_KEY_STRICT_RESUME, INTERRUPT
from langgraph.errors import UnknownInterruptError
from langgraph.graph import START, StateGraph
from langgraph.graph.state import CompiledStateGraph
from langgraph.types import Command, Durability, Interrupt, RetryPolicy, interrupt
pytestmark = pytest.mark.anyio
NEEDS_CONTEXTVARS = pytest.mark.skipif(
sys.version_info < (3, 11),
reason="Python 3.11+ is required for async contextvars support",
)
UNKNOWN_ID = "0" * 32
class State(TypedDict, total=False):
log: Annotated[list[str], operator.add]
def _config(*, strict: bool = True) -> RunnableConfig:
return {
"configurable": {
"thread_id": str(uuid.uuid4()),
CONFIG_KEY_STRICT_RESUME: strict,
}
}
def _parallel(checkpointer: BaseCheckpointSaver) -> CompiledStateGraph:
def a(state: State):
return {"log": [f"a:{interrupt('a?')}"]}
def b(state: State):
return {"log": [f"b:{interrupt('b?')}"]}
return (
StateGraph(State)
.add_node("a", a)
.add_node("b", b)
.add_edge(START, "a")
.add_edge(START, "b")
.compile(checkpointer=checkpointer)
)
def _ask_twice(checkpointer: BaseCheckpointSaver) -> CompiledStateGraph:
def ask_twice(state: State):
first = interrupt("first?")
second = interrupt("second?")
return {"log": [f"{first},{second}"]}
return (
StateGraph(State)
.add_node("ask", ask_twice)
.add_edge(START, "ask")
.compile(checkpointer=checkpointer)
)
def _ask_then_fail_once(checkpointer: BaseCheckpointSaver) -> CompiledStateGraph:
failures = [ConnectionError("flaky api")]
def ask(state: State):
answer = interrupt("delete a.txt?")
if failures:
raise failures.pop()
return {"log": [answer]}
return (
StateGraph(State)
.add_node(
"ask", ask, retry_policy=RetryPolicy(initial_interval=0, jitter=False)
)
.add_edge(START, "ask")
.compile(checkpointer=checkpointer)
)
def _pending_ids(graph: CompiledStateGraph, config: RunnableConfig) -> dict[str, str]:
return {i.value: i.id for i in graph.get_state(config).interrupts}
def test_unknown_id_is_refused_before_anything_is_written(
sync_checkpointer: BaseCheckpointSaver, durability: Durability
) -> None:
graph = _parallel(sync_checkpointer)
config = _config()
graph.invoke({"log": []}, config, durability=durability)
before = graph.get_state(config)
checkpoints = len(list(sync_checkpointer.list(config)))
ids = _pending_ids(graph, config)
with pytest.raises(
UnknownInterruptError,
match=f"Unknown or already-consumed interrupt: {UNKNOWN_ID}",
) as refused:
graph.invoke(Command(resume={UNKNOWN_ID: "x"}), config, durability=durability)
assert (
refused.value.reason,
refused.value.interrupt_ids,
refused.value.pending_ids,
) == ("not_pending", (UNKNOWN_ID,), tuple(sorted(ids.values())))
after = graph.get_state(config)
assert len(list(sync_checkpointer.list(config))) == checkpoints
assert (after.values, after.next, after.interrupts) == (
before.values,
before.next,
before.interrupts,
)
result = graph.invoke(
Command(resume={ids["a?"]: "yes", ids["b?"]: "no"}),
config,
durability=durability,
)
assert sorted(result["log"]) == ["a:yes", "b:no"]
def test_an_answered_id_is_refused(sync_checkpointer: BaseCheckpointSaver) -> None:
graph = _parallel(sync_checkpointer)
config = _config()
graph.invoke({"log": []}, config)
ids = _pending_ids(graph, config)
graph.invoke(Command(resume={ids["a?"]: "yes"}), config)
with pytest.raises(UnknownInterruptError, match=ids["a?"]) as refused:
graph.invoke(Command(resume={ids["a?"]: "again"}), config)
assert (
refused.value.reason,
refused.value.interrupt_ids,
refused.value.pending_ids,
) == ("not_pending", (ids["a?"],), (ids["b?"],))
result = graph.invoke(Command(resume={ids["b?"]: "no"}), config)
assert sorted(result["log"]) == ["a:yes", "b:no"]
def test_an_empty_map_is_refused(sync_checkpointer: BaseCheckpointSaver) -> None:
graph = _parallel(sync_checkpointer)
config = _config()
graph.invoke({"log": []}, config)
with pytest.raises(UnknownInterruptError, match="names no interrupt") as refused:
graph.invoke(Command(resume={}), config)
assert (
refused.value.reason,
refused.value.interrupt_ids,
refused.value.pending_ids,
) == ("empty_map", (), tuple(sorted(_pending_ids(graph, config).values())))
def test_one_value_is_refused_while_several_are_pending(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
graph = _parallel(sync_checkpointer)
config = _config()
graph.invoke({"log": []}, config)
with pytest.raises(
UnknownInterruptError, match="multiple pending interrupts"
) as refused:
graph.invoke(Command(resume="yes"), config)
ids = _pending_ids(graph, config)
assert (refused.value.reason, refused.value.pending_ids) == (
"ambiguous",
tuple(sorted(ids.values())),
)
graph.invoke(Command(resume={ids["a?"]: "yes"}), config)
result = graph.invoke(Command(resume="no"), config)
assert sorted(result["log"]) == ["a:yes", "b:no"]
def test_one_value_resumes_a_static_breakpoint(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
graph = (
StateGraph(State)
.add_node("a", lambda state: {"log": ["a"]})
.add_edge(START, "a")
.compile(checkpointer=sync_checkpointer, interrupt_before=["a"])
)
config = _config()
graph.invoke({"log": []}, config)
with pytest.raises(UnknownInterruptError, match=UNKNOWN_ID):
graph.invoke(Command(resume={UNKNOWN_ID: "x"}), config)
assert graph.invoke(Command(resume="go"), config) == {"log": ["a"]}
def test_without_strict_mode_nothing_changes(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
graph = _parallel(sync_checkpointer)
config = _config(strict=False)
graph.invoke({"log": []}, config)
graph.invoke(Command(resume={UNKNOWN_ID: "x"}), config)
assert set(_pending_ids(graph, config)) == {"a?", "b?"}
with pytest.raises(RuntimeError, match="multiple pending interrupts"):
graph.invoke(Command(resume="yes"), config)
def test_a_subgraph_interrupt_is_answered_by_its_id(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
def ask(state: State):
return {"log": [f"asked:{interrupt('sub?')}"]}
subgraph = StateGraph(State).add_node("ask", ask).add_edge(START, "ask").compile()
graph = (
StateGraph(State)
.add_node("sub", subgraph)
.add_edge(START, "sub")
.compile(checkpointer=sync_checkpointer)
)
config = _config()
graph.invoke({"log": []}, config)
result = graph.invoke(
Command(resume={_pending_ids(graph, config)["sub?"]: "yes"}), config
)
assert result["log"] == ["asked:yes"]
def test_a_second_interrupt_in_one_node_gets_its_own_id(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
graph = _ask_twice(sync_checkpointer)
config = _config()
graph.invoke({"log": []}, config)
state = graph.get_state(config)
(card,) = state.interrupts
assert card.id == xxh3_128_hexdigest(f"ask:{state.tasks[0].id}".encode())
graph.invoke(Command(resume={card.id: "1"}), config)
(second,) = graph.get_state(config).interrupts
assert second.id != card.id
result = graph.invoke(Command(resume={second.id: "2"}), config)
assert result["log"] == ["1,2"]
def test_a_retried_answer_in_one_node_is_refused(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
graph = _ask_twice(sync_checkpointer)
config = _config()
graph.invoke({"log": []}, config)
(card,) = graph.get_state(config).interrupts
graph.invoke(Command(resume={card.id: "yes"}), config)
(second,) = graph.get_state(config).interrupts
with pytest.raises(UnknownInterruptError) as refused:
graph.invoke(Command(resume={card.id: "yes"}), config)
assert (
refused.value.reason,
refused.value.interrupt_ids,
refused.value.pending_ids,
) == ("not_pending", (card.id,), (second.id,))
result = graph.invoke(Command(resume={second.id: "no"}), config)
assert result["log"] == ["yes,no"]
def test_parallel_interrupts_in_one_node_each_take_an_answer(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
def ask(name: str) -> str:
return interrupt(f"{name}?")
def ask_in_parallel(state: State, config: RunnableConfig):
with get_executor_for_config(config) as pool:
b = pool.submit(ask, "b")
b.exception()
a = pool.submit(ask, "a")
return {"log": [f"{a.result()},{b.result()}"]}
graph = (
StateGraph(State)
.add_node("ask", ask_in_parallel)
.add_edge(START, "ask")
.compile(checkpointer=sync_checkpointer)
)
config = _config()
graph.invoke({"log": []}, config)
for answer in ("1", "2"):
(card,) = graph.get_state(config).interrupts
graph.invoke(Command(resume={card.id: answer}), config)
assert graph.get_state(config).next == ()
def test_a_later_interrupt_saved_with_the_first_id_is_still_answered(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
graph = _ask_twice(sync_checkpointer)
config = _config()
graph.invoke({"log": []}, config)
(card,) = graph.get_state(config).interrupts
graph.invoke(Command(resume={card.id: "1"}), config)
state = graph.get_state(config)
sync_checkpointer.put_writes(
state.config,
[(INTERRUPT, (Interrupt(value="second?", id=card.id),))],
state.tasks[0].id,
)
result = graph.invoke(Command(resume={card.id: "2"}), config)
assert result["log"] == ["1,2"]
def test_a_retried_node_reuses_its_answer(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
graph = _ask_then_fail_once(sync_checkpointer)
config = _config()
graph.invoke({"log": []}, config)
(card,) = graph.get_state(config).interrupts
result = graph.invoke(Command(resume={card.id: "yes"}), config)
assert result["log"] == ["yes"]
def test_a_card_whose_resumed_task_raised_is_not_refused(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
failures = ["once"]
def ask_then_fail_once(state: State):
answer = interrupt("approve?")
if failures:
failures.pop()
raise ValueError("failed after the answer")
return {"log": [f"answered:{answer}"]}
graph = (
StateGraph(State)
.add_node("ask", ask_then_fail_once)
.add_edge(START, "ask")
.compile(checkpointer=sync_checkpointer)
)
config = _config()
graph.invoke({"log": []}, config)
(card,) = graph.get_state(config).interrupts
with pytest.raises(ValueError, match="failed after the answer"):
graph.invoke(Command(resume={card.id: "first"}), config)
graph.invoke(Command(resume={card.id: "second"}), config)
assert graph.get_state(config).next == ()
def test_a_pinned_head_checkpoint_is_checked(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
graph = _parallel(sync_checkpointer)
config = _config()
graph.invoke({"log": []}, config)
head = graph.get_state(config).config
pinned = {"configurable": {**config["configurable"], **head["configurable"]}}
with pytest.raises(UnknownInterruptError):
graph.invoke(Command(resume={UNKNOWN_ID: "x"}), pinned)
ids = _pending_ids(graph, config)
result = graph.invoke(Command(resume={ids["a?"]: "yes", ids["b?"]: "no"}), pinned)
assert sorted(result["log"]) == ["a:yes", "b:no"]
def test_a_superseded_checkpoint_is_not_checked(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
graph = _parallel(sync_checkpointer)
config = _config()
graph.invoke({"log": []}, config)
paused = graph.get_state(config).config
ids = _pending_ids(graph, config)
graph.invoke(Command(resume={ids["a?"]: "yes", ids["b?"]: "no"}), config)
pinned = {"configurable": {**config["configurable"], **paused["configurable"]}}
graph.invoke(Command(resume={ids["a?"]: "again", ids["b?"]: "again"}), pinned)
@NEEDS_CONTEXTVARS
async def test_unknown_id_is_refused_async(
async_checkpointer: BaseCheckpointSaver,
) -> None:
graph = _parallel(async_checkpointer)
config = _config()
await graph.ainvoke({"log": []}, config)
with pytest.raises(UnknownInterruptError, match=UNKNOWN_ID):
await graph.ainvoke(Command(resume={UNKNOWN_ID: "x"}), config)
state = await graph.aget_state(config)
assert {i.value for i in state.interrupts} == {"a?", "b?"}
@NEEDS_CONTEXTVARS
async def test_a_retried_node_reuses_its_answer_async(
async_checkpointer: BaseCheckpointSaver,
) -> None:
graph = _ask_then_fail_once(async_checkpointer)
config = _config()
await graph.ainvoke({"log": []}, config)
(card,) = (await graph.aget_state(config)).interrupts
result = await graph.ainvoke(Command(resume={card.id: "yes"}), config)
assert result["log"] == ["yes"]
+1 -1
View File
@@ -1439,7 +1439,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "1.2.20"
version = "1.2.19"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
+1 -1
View File
@@ -286,7 +286,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "1.2.20"
version = "1.2.19"
source = { editable = "../langgraph" }
dependencies = [
{ name = "langchain-core" },
+2 -17
View File
@@ -1,21 +1,6 @@
# Changelog
## 0.4.12
### Removed
- **`PrioritySettings.fairness_weight`** — removed. The server stores only
`priority` and `fairness_group` on the thread; it ignores a
`fairness_weight` and logs a warning. Remove it from your callback.
### Changed
- **`PrioritySettings.priority`** — now says -5 runs first and 5 runs last,
matching how the server orders runs. 0.4.9 to 0.4.11 said the reverse, and
their example gave premium users 3, which runs them after everyone at the
default 0. Check any callback written from those docs.
## 0.4.11 and earlier
## Unreleased
### Added
@@ -53,7 +38,7 @@
and name the object in the `priority` section of `langgraph.json`. The
server calls the function with a `PriorityContext` at the first run on a
thread with no settings and stores the `PrioritySettings` it returns
(`priority`, `fairness_group`) on the thread. Requires
(`priority`, `fairness_group`, `fairness_weight`) on the thread. Requires
`langgraph-api` 0.18.0 or later and a `langgraph-cli` that supports the
section; older versions of either ignore it.
+1 -1
View File
@@ -5,7 +5,7 @@ from langgraph_sdk.encryption.types import DecryptResult, EncryptionContext
from langgraph_sdk.priority import Priority
from langgraph_sdk.priority.types import PriorityContext, PrioritySettings
__version__ = "0.4.13"
__version__ = "0.4.11"
__all__ = [
"Auth",
+1 -1
View File
@@ -305,7 +305,7 @@ class RunsClient:
StreamPart(event='metadata', data={'run_id': '1ef4a9b8-d7da-679a-a45a-872054341df2'})
StreamPart(event='values', data={'messages': [{'content': 'how are you?', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': 'fe0a5778-cfe9-42ee-b807-0adaa1873c10', 'example': False}]})
StreamPart(event='values', data={'messages': [{'content': 'how are you?', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': 'fe0a5778-cfe9-42ee-b807-0adaa1873c10', 'example': False}, {'content': "I'm doing well, thanks for asking! I'm an AI assistant created by Anthropic to be helpful, honest, and harmless.", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'ai', 'name': None, 'id': 'run-159b782c-b679-4830-83c6-cef87798fe8b', 'example': False, 'tool_calls': [], 'invalid_tool_calls': [], 'usage_metadata': None}]})
StreamPart(event='end', data={'run_id': '1ef4a9b8-d7da-679a-a45a-872054341df2', 'status': 'success'})
StreamPart(event='end', data=None)
```
"""
+3 -30
View File
@@ -21,7 +21,6 @@ from langgraph_sdk.schema import (
ThreadSelectField,
ThreadSortBy,
ThreadState,
ThreadStateSelectField,
ThreadStatus,
ThreadStreamMode,
ThreadUpdateStateResponse,
@@ -490,7 +489,6 @@ class ThreadsClient:
checkpoint_id: str | None = None, # deprecated
*,
subgraphs: bool = False,
select: list[ThreadStateSelectField] | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> ThreadState:
@@ -501,9 +499,6 @@ class ThreadsClient:
checkpoint: The checkpoint to get the state of.
checkpoint_id: (deprecated) The checkpoint ID to get the state of.
subgraphs: Include subgraphs states.
select: Fields to include in the response. Use `values.<key>` to
return a single state key (e.g. `values.messages`), which lets the
server skip loading the rest of the state. Defaults to all fields.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
@@ -596,30 +591,16 @@ class ThreadsClient:
}
}
```
Fetch only the `messages` state key:
```python
thread_state = await client.threads.get_state(
thread_id="my_thread_id",
select=["values.messages"],
)
```
"""
if checkpoint:
payload: dict[str, Any] = {"checkpoint": checkpoint, "subgraphs": subgraphs}
if select:
payload["select"] = select
return await self.http.post(
f"/threads/{_quote_path_param(thread_id)}/state/checkpoint",
json=payload,
json={"checkpoint": checkpoint, "subgraphs": subgraphs},
headers=headers,
params=params,
)
elif checkpoint_id:
get_params: dict[str, Any] = {"subgraphs": subgraphs}
if select:
get_params["select"] = select
get_params = {"subgraphs": subgraphs}
if params:
get_params = {**get_params, **dict(params)}
return await self.http.get(
@@ -628,9 +609,7 @@ class ThreadsClient:
headers=headers,
)
else:
get_params: dict[str, Any] = {"subgraphs": subgraphs}
if select:
get_params["select"] = select
get_params = {"subgraphs": subgraphs}
if params:
get_params = {**get_params, **dict(params)}
return await self.http.get(
@@ -713,7 +692,6 @@ class ThreadsClient:
before: str | Checkpoint | None = None,
metadata: Mapping[str, Any] | None = None,
checkpoint: Checkpoint | None = None,
select: list[ThreadStateSelectField] | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> list[ThreadState]:
@@ -725,9 +703,6 @@ class ThreadsClient:
limit: The maximum number of states to return.
before: Return states before this checkpoint.
metadata: Filter states by metadata key-value pairs.
select: Fields to include in the response. Use `values.<key>` to
return a single state key (e.g. `values.messages`), which lets the
server skip loading the rest of the state. Defaults to all fields.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
@@ -754,8 +729,6 @@ class ThreadsClient:
payload["metadata"] = metadata
if checkpoint:
payload["checkpoint"] = checkpoint
if select:
payload["select"] = select
return await self.http.post(
f"/threads/{_quote_path_param(thread_id)}/history",
json=payload,
+1 -1
View File
@@ -302,7 +302,7 @@ class SyncRunsClient:
StreamPart(event='metadata', data={'run_id': '1ef4a9b8-d7da-679a-a45a-872054341df2'})
StreamPart(event='values', data={'messages': [{'content': 'how are you?', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': 'fe0a5778-cfe9-42ee-b807-0adaa1873c10', 'example': False}]})
StreamPart(event='values', data={'messages': [{'content': 'how are you?', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': 'fe0a5778-cfe9-42ee-b807-0adaa1873c10', 'example': False}, {'content': "I'm doing well, thanks for asking! I'm an AI assistant created by Anthropic to be helpful, honest, and harmless.", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'ai', 'name': None, 'id': 'run-159b782c-b679-4830-83c6-cef87798fe8b', 'example': False, 'tool_calls': [], 'invalid_tool_calls': [], 'usage_metadata': None}]})
StreamPart(event='end', data={'run_id': '1ef4a9b8-d7da-679a-a45a-872054341df2', 'status': 'success'})
StreamPart(event='end', data=None)
```
"""
if checkpoint_during is not None:
+3 -29
View File
@@ -21,7 +21,6 @@ from langgraph_sdk.schema import (
ThreadSelectField,
ThreadSortBy,
ThreadState,
ThreadStateSelectField,
ThreadStatus,
ThreadStreamMode,
ThreadUpdateStateResponse,
@@ -481,7 +480,6 @@ class SyncThreadsClient:
checkpoint_id: str | None = None, # deprecated
*,
subgraphs: bool = False,
select: list[ThreadStateSelectField] | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> ThreadState:
@@ -491,9 +489,6 @@ class SyncThreadsClient:
thread_id: The ID of the thread to get the state of.
checkpoint: The checkpoint to get the state of.
subgraphs: Include subgraphs states.
select: Fields to include in the response. Use `values.<key>` to
return a single state key (e.g. `values.messages`), which lets the
server skip loading the rest of the state. Defaults to all fields.
headers: Optional custom headers to include with the request.
Returns:
@@ -586,29 +581,16 @@ class SyncThreadsClient:
}
```
Fetch only the `messages` state key:
```python
thread_state = client.threads.get_state(
thread_id="my_thread_id",
select=["values.messages"],
)
```
"""
if checkpoint:
payload: dict[str, Any] = {"checkpoint": checkpoint, "subgraphs": subgraphs}
if select:
payload["select"] = select
return self.http.post(
f"/threads/{_quote_path_param(thread_id)}/state/checkpoint",
json=payload,
json={"checkpoint": checkpoint, "subgraphs": subgraphs},
headers=headers,
params=params,
)
elif checkpoint_id:
get_params: dict[str, Any] = {"subgraphs": subgraphs}
if select:
get_params["select"] = select
get_params = {"subgraphs": subgraphs}
if params:
get_params = {**get_params, **dict(params)}
return self.http.get(
@@ -617,9 +599,7 @@ class SyncThreadsClient:
headers=headers,
)
else:
get_params: dict[str, Any] = {"subgraphs": subgraphs}
if select:
get_params["select"] = select
get_params = {"subgraphs": subgraphs}
if params:
get_params = {**get_params, **dict(params)}
return self.http.get(
@@ -699,7 +679,6 @@ class SyncThreadsClient:
before: str | Checkpoint | None = None,
metadata: Mapping[str, Any] | None = None,
checkpoint: Checkpoint | None = None,
select: list[ThreadStateSelectField] | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> list[ThreadState]:
@@ -711,9 +690,6 @@ class SyncThreadsClient:
limit: The maximum number of states to return.
before: Return states before this checkpoint.
metadata: Filter states by metadata key-value pairs.
select: Fields to include in the response. Use `values.<key>` to
return a single state key (e.g. `values.messages`), which lets the
server skip loading the rest of the state. Defaults to all fields.
headers: Optional custom headers to include with the request.
Returns:
@@ -741,8 +717,6 @@ class SyncThreadsClient:
payload["metadata"] = metadata
if checkpoint:
payload["checkpoint"] = checkpoint
if select:
payload["select"] = select
return self.http.post(
f"/threads/{_quote_path_param(thread_id)}/history",
json=payload,
@@ -102,7 +102,7 @@ class Priority:
@priority.assign
async def assign(ctx: PriorityContext) -> PrioritySettings | None:
return {
"priority": -3 if "premium" in ctx.permissions else 0,
"priority": 3 if "premium" in ctx.permissions else 0,
# Stored unencrypted, so an opaque ID rather than personal data.
"fairness_group": ctx.user["org_id"],
}
@@ -134,7 +134,7 @@ class Priority:
@priority.assign
async def assign(ctx: PriorityContext) -> PrioritySettings | None:
if ctx.cron_id is not None:
return {"priority": 2}
return {"priority": -2}
return None
```
+6 -8
View File
@@ -50,20 +50,18 @@ class PrioritySettings(typing.TypedDict, total=False):
Every key is optional: a missing key or a key set to `None` takes its
default, and returning `None` instead of a dict gives every key its default.
A `priority` outside -5 to 5, or a `fairness_group` longer than 256
characters, is an error.
A value outside its range, or an empty `fairness_group`, is an error.
"""
priority: int | None
"""An integer from -5 to 5, where -5 runs first and 5 runs last. Defaults to 0."""
"""An integer from -5 to 5, where 5 runs first and -5 runs last. Defaults to 0."""
fairness_group: str | None
"""Currently disabled.
The thread's fairness group, a string of at most 256 characters.
Defaults to `"default"`, as does an empty string. It has no effect on run
order for now.
"""The thread's fairness group, a non-empty string. Defaults to `"default"`.
The server stores it unencrypted, even when the deployment uses custom
encryption, so use an opaque ID rather than personal data.
"""
fairness_weight: int | None
"""The weight of the thread's fairness group, an integer from 1 to 100. Defaults to 1."""
-18
View File
@@ -380,8 +380,6 @@ class Run(TypedDict):
"""The run metadata."""
multitask_strategy: MultitaskStrategy
"""Strategy to handle concurrent runs on the same thread."""
cancel_reason: NotRequired[str | None]
"""Why the run was cancelled, if it was. Omitted by older servers."""
class Cron(TypedDict):
@@ -482,21 +480,6 @@ ThreadSelectField = Literal[
"interrupts",
]
ThreadStateSelectField = (
Literal[
"values",
"next",
"tasks",
"checkpoint",
"metadata",
"created_at",
"parent_checkpoint",
"interrupts",
]
| str
)
"""Also accepts `values.<key>` for a single state key, e.g. `values.messages`."""
RunSelectField = Literal[
"run_id",
"thread_id",
@@ -507,7 +490,6 @@ RunSelectField = Literal[
"metadata",
"kwargs",
"multitask_strategy",
"cancel_reason",
]
CronSelectField = Literal[
+5 -1
View File
@@ -120,5 +120,9 @@ def test_context_is_frozen():
def test_every_settings_key_is_optional():
assert PrioritySettings.__optional_keys__ == {"priority", "fairness_group"}
assert PrioritySettings.__optional_keys__ == {
"priority",
"fairness_group",
"fairness_weight",
}
assert PrioritySettings.__required_keys__ == frozenset()
-126
View File
@@ -11,14 +11,6 @@ from langgraph_sdk.client import (
SyncThreadsClient,
ThreadsClient,
)
from langgraph_sdk.schema import Checkpoint
CHECKPOINT: Checkpoint = {
"thread_id": "thread_123",
"checkpoint_ns": "",
"checkpoint_id": "cp_1",
"checkpoint_map": None,
}
@pytest.mark.asyncio
@@ -64,121 +56,3 @@ def test_sync_threads_update_return_minimal():
)
assert result is None
@pytest.mark.asyncio
async def test_async_threads_get_state_select():
requests: list[httpx.Request] = []
async def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(200, json={})
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(
transport=transport, base_url="https://example.com"
) as client:
threads_client = ThreadsClient(HttpClient(client))
await threads_client.get_state("thread_123", select=["values.messages", "next"])
await threads_client.get_state(
"thread_123", checkpoint_id="cp_1", select=["values.messages"]
)
await threads_client.get_state(
"thread_123",
checkpoint=CHECKPOINT,
select=["values.messages"],
)
await threads_client.get_state("thread_123")
latest, by_id, by_checkpoint, default = requests
assert latest.method == "GET"
assert latest.url.path == "/threads/thread_123/state"
assert latest.url.params.get_list("select") == ["values.messages", "next"]
assert by_id.method == "GET"
assert by_id.url.path == "/threads/thread_123/state/cp_1"
assert by_id.url.params.get_list("select") == ["values.messages"]
assert by_checkpoint.method == "POST"
assert by_checkpoint.url.path == "/threads/thread_123/state/checkpoint"
assert json.loads(by_checkpoint.content) == {
"checkpoint": CHECKPOINT,
"subgraphs": False,
"select": ["values.messages"],
}
assert "select" not in default.url.params
@pytest.mark.asyncio
async def test_async_threads_get_history_select():
bodies: list[dict] = []
async def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "POST"
assert request.url.path == "/threads/thread_123/history"
bodies.append(json.loads(request.content))
return httpx.Response(200, json=[])
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(
transport=transport, base_url="https://example.com"
) as client:
threads_client = ThreadsClient(HttpClient(client))
await threads_client.get_history("thread_123", select=["values.messages"])
await threads_client.get_history("thread_123")
assert bodies == [{"limit": 10, "select": ["values.messages"]}, {"limit": 10}]
def test_sync_threads_get_state_select():
requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(200, json={})
transport = httpx.MockTransport(handler)
with httpx.Client(transport=transport, base_url="https://example.com") as client:
threads_client = SyncThreadsClient(SyncHttpClient(client))
threads_client.get_state("thread_123", select=["values.messages", "next"])
threads_client.get_state(
"thread_123", checkpoint_id="cp_1", select=["values.messages"]
)
threads_client.get_state(
"thread_123",
checkpoint=CHECKPOINT,
select=["values.messages"],
)
threads_client.get_state("thread_123")
latest, by_id, by_checkpoint, default = requests
assert latest.method == "GET"
assert latest.url.path == "/threads/thread_123/state"
assert latest.url.params.get_list("select") == ["values.messages", "next"]
assert by_id.method == "GET"
assert by_id.url.path == "/threads/thread_123/state/cp_1"
assert by_id.url.params.get_list("select") == ["values.messages"]
assert by_checkpoint.method == "POST"
assert by_checkpoint.url.path == "/threads/thread_123/state/checkpoint"
assert json.loads(by_checkpoint.content) == {
"checkpoint": CHECKPOINT,
"subgraphs": False,
"select": ["values.messages"],
}
assert "select" not in default.url.params
def test_sync_threads_get_history_select():
bodies: list[dict] = []
def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "POST"
assert request.url.path == "/threads/thread_123/history"
bodies.append(json.loads(request.content))
return httpx.Response(200, json=[])
transport = httpx.MockTransport(handler)
with httpx.Client(transport=transport, base_url="https://example.com") as client:
threads_client = SyncThreadsClient(SyncHttpClient(client))
threads_client.get_history("thread_123", select=["values.messages"])
threads_client.get_history("thread_123")
assert bodies == [{"limit": 10, "select": ["values.messages"]}, {"limit": 10}]
+1 -1
View File
@@ -301,7 +301,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "1.2.20"
version = "1.2.19"
source = { editable = "../langgraph" }
dependencies = [
{ name = "langchain-core" },