Compare commits

..
Author SHA1 Message Date
Elior Nataf Lackritz 858e55f232 refactor(langgraph): call create_checkpoint directly for the fork seal
create_fork_checkpoint only forwarded to create_checkpoint, and an empty
fork set bumps nothing there either, so the four update_state call sites
pass the set straight through. The fork tests also had two identical
helpers; keep one.
2026-09-30 15:07:17 -04:00
Elior Nataf Lackritz e59ecbc23d revert(langgraph): leave exit-mode resume writes as they are on main
Skipping the loaded writes in the exit accumulator removed the duplicate
replay but reordered it: the loaded write keeps its task id while the
run's later writes get step-prefixed ids that sort first. That ordering
problem belongs to exit mode, not to forking, and exists on main without
a fork, so it gets its own fix. The resume test marks exit durability as
an expected failure until then.
2026-09-29 11:39:36 -04:00
Elior Nataf Lackritz 719a4d71bc fix(langgraph): skip the seal on a resume that reuses the head's writes
A resume that is not replaying reuses the head's pending writes instead of
rerunning their tasks, so none of them can leak; deciding in `_first`,
where that is known, stops a plain `Command(resume=...)` from storing a
snapshot. A replaying resume reruns the tasks, so it still seals.

Exit mode also re-recorded the writes a resume loaded from the head: they
were already stored there, and every later read replayed them twice. The
exit accumulator now skips them.
2026-09-29 09:42:55 -04:00
Elior Nataf Lackritz ddaf708cd0 fix(langgraph): snapshot only what a fork can leak, and hide the bump
A fork used to snapshot every DeltaChannel whenever the caller passed a
checkpoint_id. That fired on every turn a client addresses the head
(storing a full copy of the channel per turn), missed new input sent to an
interrupted head without an id, and its storage-only version bump read as
a real write: interrupt_before fired again on resume, and a replay paused
at a node it had already passed.

Snapshot the delta channels the base checkpoint has pending writes for,
since only those can leak into a branch that does not consume them, and
advance versions_seen past any bump that only stores a snapshot, including
the interrupt tracker. update_state no longer records its narrower
updated_channels when it snapshots, so a deferred node listed in next still
runs on resume (#9089).
2026-09-28 19:54:23 -04:00
Elior Nataf Lackritz 9d5b0f1991 refactor(langgraph): drop the deferred fork-snapshot queue
Now that a forced snapshot mints a version for a never-written channel,
the first checkpoint a forked run writes seals every delta channel, so
_delta_channels_awaiting_fork_snapshot never outlived it. Seed
_delta_channels_forced_snapshot directly at loop construction instead.

Also asserts that forking by invoke leaves the abandoned branch's reads
intact, and trims comments and test docstrings.
2026-09-28 19:54:23 -04:00
Elior Nataf Lackritz 9d16b52955 fix(langgraph): seal a fork whose delta channel has no value yet
A DeltaChannel that was never written on the branch being forked has no
value to snapshot and no entry in channel_versions, so create_checkpoint
skipped it and the fork's first checkpoint recorded no boundary at all.
The walk then ran past the fork into the shared base and collected the
abandoned branch's writes, the same failure this branch already fixes for
channels that do have a value.

Two shapes leaked. A run forking off a checkpoint older than the channel's
first value and never writing that channel returned ['in-1'] where the
plain-channel oracle returned []. A bulk update writing the delta key only
in its second superstep returned ['in-1', 's2'] against ['s2'].

No new blob type is needed. _DeltaSnapshot already carries the value and
is already serialized by every saver, and from_checkpoint turns MISSING
into typ(), so _DeltaSnapshot(typ()) reconstructs to the same empty value
the channel would have had. What was missing is a version: without one,
put drops the blob as not-a-new-version, so mint a first one.

Deferring the seal to a later superstep does not work. That superstep
reconstructs through the still-unsealed checkpoint and would only bake the
corrupted value into its own snapshot.

Checked that minting a version does not fire nodes that subscribe to the
channel: a raw Pregel node subscribed directly to the delta channel stays
silent across the fork.

Reported by the Open SWE review bot on #8548.
2026-09-28 19:54:23 -04:00
Elior Nataf Lackritz 2de0c47c1f test(langgraph): compare read-back checkpoints in the immutability saver
MemorySaverAssertImmutable recorded the checkpoint object handed to put,
then compared it against one read back through get. Those two are not the
same shape: channel_values are stored per (channel, version), so a channel
a step did not write is refilled from the blob its inherited version still
points at.

Every channel except DeltaChannel writes its value into channel_values on
every checkpoint, so the two agreed by accident. A DeltaChannel stores
nothing except at a snapshot, so once one snapshots and a later step does
not write it, the saver reports a checkpoint that changed after it was
written when nothing was mutated.

Reproducible on main with no fork involved: a delta channel with
snapshot_frequency=1 written by the first node and left alone by the next
two trips the assertion. Existing delta tests miss it only because they
all use snapshot_frequency=1000.

Record what the saver reads back instead. Comparing read-back against
read-back still catches a checkpoint whose stored data really changed.
2026-09-28 19:54:23 -04:00
Elior Nataf Lackritz 377083220e fix(langgraph): seal a fork on the first checkpoint it writes
The as_node INPUT, END and __copy__ paths write a checkpoint and return
before create_checkpoint_plan_for_update_state_api runs, so a bulk update
whose first superstep took one of them left the branch unsealed. Only
INPUT actually leaked: END absorbs the base's already-run task writes, so
its delta and plain channels agree.

Sealing on a later superstep does not help. By then that superstep has
reconstructed its value by walking through the unsealed checkpoint into
the shared base, so it snapshots an already-corrupted list. The fork's
first checkpoint is the one that has to carry the blob, which is what
create_fork_checkpoint does.

That snapshot was still being dropped by put: these paths apply writes to
the input channel, not the delta channel, so nothing bumped the delta
channel's version and it never entered new_versions. Pass get_next_version
for the manual bump, the same reason exit mode needs it, and derive
new_versions from the returned checkpoint.

fork_pending tracks what is still owed, mirroring
_delta_channels_awaiting_fork_snapshot in _loop.py.

Caught by the Open SWE review bot on #8548.
2026-09-28 19:54:23 -04:00
Elior Nataf Lackritz 9c5914861b fix(langgraph): only fork on the first superstep of a bulk update
perform_superstep returns the config of the checkpoint it just wrote and
bulk_update_state feeds that back in, so from the second superstep on the
incoming config always names a checkpoint whether or not the caller
addressed one. Deriving the fork flag from it made every superstep after
the first force-snapshot every available DeltaChannel and reset its
cadence, storing the whole growing value once per superstep.

Resolve the flag once from the caller's config and pass it explicitly,
true only for the first superstep. The clear-tasks recursion carries it
through, since the checkpoint written there has no delta snapshot and so
leaves a fork unsealed.

Caught by the Open SWE review bot on #8548.
2026-09-28 19:54:23 -04:00
24cf33f348 fix(langgraph): don't replay an abandoned branch into a DeltaChannel fork
Addressing an older checkpoint creates a fork: the shared base ends up
with two children and keeps the checkpoint_writes of the branch the fork
abandons. Nothing records which child consumed which write, so the
DeltaChannel ancestor walk collected the abandoned branch's writes too.
Live execution was correct; only the reconstruction after a reload was
wrong, and it was wrong on every saver.

Fixed on the write side, so no saver changes are needed. A run launched
against an explicitly addressed checkpoint forces every DeltaChannel to
snapshot into its first checkpoint, terminating the walk inside the fork
instead of at the shared base. This mirrors the existing force-snapshot
for Overwrite writes, hence the rename to _delta_channels_forced_snapshot.
update_state against an older checkpoint takes the same path, for the
same reason is_fresh_thread already does.

A channel with no value at the fork base cannot carry a snapshot blob
yet, so the request stays queued until the first superstep that gives it
one. Cost is one snapshot per addressed run, not per superstep.

Fixes #8443

Co-Authored-By: AnnaSuSu <64579968+AnnaSuSu@users.noreply.github.com>
Co-Authored-By: UditDewan <194863456+UditDewan@users.noreply.github.com>
2026-09-28 19:54:23 -04:00
John KennedyGitHubopen-swe[bot] <open-swe@users.noreply.github.com>
07b33185ea fix: reject credential-bearing Git dependencies (#8542)
## Description
Reject Git HTTP dependency URLs containing userinfo before Docker
generation so credentials cannot persist in Dockerfiles or image layers.
Validation now covers local requirement/package metadata and uv
pyproject/lock inputs while keeping errors token-free.

## Test Plan
- [x] Validate credentialed raw, local-manifest, and uv-managed Git URLs
are rejected without echoing secrets
- [x] Validate credential-free HTTPS and SSH Git URLs remain supported

Made by [Open
SWE](https://openswe.vercel.app/agents/81b07455-ece4-3ddc-9955-d7a5bea78d2c)

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-09-27 21:34:53 +00:00
14 changed files with 1061 additions and 67 deletions
+2
View File
@@ -103,6 +103,8 @@ The CLI uses a `langgraph.json` configuration file with these key settings:
} }
``` ```
Git dependencies should use credential-free URLs. The CLI conservatively scans direct `langgraph.json` dependencies, common Python package files, uv project and lock files, and common Node.js package and lock files for HTTP Git URLs with userinfo. This check is not exhaustive: generated Docker builds can copy other files, including nested requirement or constraint files, into image layers without scanning them. For private dependencies, provide short-lived credentials through your build environment's secret-backed Git credential helper. Do not store credentials in copied files such as `langgraph.json` or `pip_config_file`.
See the [full documentation](https://reference.langchain.com/python/langgraph-cli) for detailed configuration options. See the [full documentation](https://reference.langchain.com/python/langgraph-cli) for detailed configuration options.
## Development ## Development
+87 -3
View File
@@ -6,6 +6,7 @@ import re
import shlex import shlex
import textwrap import textwrap
from collections import Counter from collections import Counter
from collections.abc import Iterable
from typing import Literal, NamedTuple from typing import Literal, NamedTuple
import click import click
@@ -36,6 +37,10 @@ DISALLOWED_BUILD_COMMAND_CHARS = [
# This blocks background execution (cmd &) while allowing command # This blocks background execution (cmd &) while allowing command
# chaining (cmd1 && cmd2) which is common in build commands. # chaining (cmd1 && cmd2) which is common in build commands.
_SINGLE_AMPERSAND_RE = re.compile(r"(?<!&)&(?:&&)*(?!&)") _SINGLE_AMPERSAND_RE = re.compile(r"(?<!&)&(?:&&)*(?!&)")
_GIT_HTTP_AUTHORITY_RES = (
re.compile(r"git\+https?://(?P<authority>[^/\s\"']+)", re.I),
re.compile(r"\bgit\s*=\s*[\"']https?://(?P<authority>[^/\s\"']+)", re.I),
)
_API_VERSION_PATTERN = re.compile( _API_VERSION_PATTERN = re.compile(
r"^(?P<major>\d+)" r"^(?P<major>\d+)"
r"(?:\.(?P<minor>\d+))?" r"(?:\.(?P<minor>\d+))?"
@@ -78,6 +83,62 @@ def has_disallowed_build_command_content(command: str) -> bool:
return False return False
def _has_git_http_url_userinfo(dependency: str) -> bool:
"""Check whether a Git HTTP URL contains userinfo."""
return any(
"@" in match.group("authority")
for pattern in _GIT_HTTP_AUTHORITY_RES
for match in pattern.finditer(dependency)
)
def _validate_git_http_url_userinfo(
values: Iterable[str], *, source: pathlib.Path | None = None
) -> None:
"""Reject credential-bearing Git HTTP URLs without echoing their values."""
if not any(_has_git_http_url_userinfo(value) for value in values):
return
message = (
"Git dependency URLs must not contain credentials or other URL "
"userinfo because generated Dockerfiles and image layers can retain "
"them. Use a credential-free Git URL and provide short-lived "
"credentials through your build environment's secret-backed Git "
"credential helper."
)
if source is not None:
message += f" Found in: {source}"
raise click.UsageError(message)
def _validate_git_http_url_userinfo_files(paths: Iterable[pathlib.Path]) -> None:
"""Reject credential-bearing Git HTTP URLs in dependency files."""
for path in paths:
path = path.resolve()
if not path.is_file():
continue
try:
contents = path.read_text(encoding="utf-8", errors="replace")
except OSError:
raise click.UsageError(
f"Could not inspect dependency file for embedded credentials: {path}"
) from None
_validate_git_http_url_userinfo([contents], source=path)
def _validate_local_dependency_files(config_path: pathlib.Path, config: Config) -> None:
"""Validate dependency files copied into a non-uv Python image."""
paths: list[pathlib.Path] = []
for dependency in config["dependencies"]:
if not isinstance(dependency, str) or not dependency.startswith("."):
continue
root = (config_path.parent / dependency).resolve()
paths.extend(
root / name
for name in ("requirements.txt", "pyproject.toml", "setup.py", "setup.cfg")
)
_validate_git_http_url_userinfo_files(paths)
MIN_PYTHON_VERSION = "3.11" MIN_PYTHON_VERSION = "3.11"
DEFAULT_PYTHON_VERSION = "3.11" DEFAULT_PYTHON_VERSION = "3.11"
@@ -320,7 +381,9 @@ def _get_source_kind(config: Config) -> str | None:
return kind if isinstance(kind, str) else None return kind if isinstance(kind, str) else None
def validate_config(config: Config) -> Config: def validate_config(
config: Config, *, source_path: pathlib.Path | None = None
) -> Config:
"""Validate a configuration dictionary.""" """Validate a configuration dictionary."""
graphs = config.get("graphs", {}) graphs = config.get("graphs", {})
@@ -415,6 +478,15 @@ def validate_config(config: Config) -> Config:
' "source": {"kind": "uv", "root": ".."}' ' "source": {"kind": "uv", "root": ".."}'
) )
_validate_git_http_url_userinfo(
(
dependency
for dependency in config["dependencies"]
if isinstance(dependency, str)
),
source=source_path,
)
source = config.get("source") source = config.get("source")
source_kind = _get_source_kind(config) source_kind = _get_source_kind(config)
if source is not None and not isinstance(source, dict): if source is not None and not isinstance(source, dict):
@@ -609,7 +681,7 @@ def validate_config_file(config_path: pathlib.Path) -> Config:
"""Load and validate a configuration file.""" """Load and validate a configuration file."""
with open(config_path) as f: with open(config_path) as f:
config = json.load(f) config = json.load(f)
validated = validate_config(config) validated = validate_config(config, source_path=config_path.resolve())
# Enforce the package.json doesn't enforce an # Enforce the package.json doesn't enforce an
# incompatible Node.js version # incompatible Node.js version
if validated.get("node_version"): if validated.get("node_version"):
@@ -1280,6 +1352,7 @@ def python_config_to_docker(
api_version=api_version, api_version=api_version,
build_tools_to_uninstall=build_tools_to_uninstall, build_tools_to_uninstall=build_tools_to_uninstall,
) )
_validate_local_dependency_files(config_path, config)
if pip_installer == "auto": if pip_installer == "auto":
if _image_supports_uv(base_image): if _image_supports_uv(base_image):
pip_installer = "uv" pip_installer = "uv"
@@ -1490,7 +1563,18 @@ def node_config_to_docker(
) -> tuple[str, dict[str, str]]: ) -> tuple[str, dict[str, str]]:
# Calculate paths for monorepo support # Calculate paths for monorepo support
install_root = ( install_root = (
pathlib.Path(build_context).resolve() if build_context else config_path.parent pathlib.Path(build_context).resolve()
if build_context
else config_path.parent.resolve()
)
config_root = config_path.parent.resolve()
dependency_roots = (
(install_root, config_root) if install_root != config_root else (install_root,)
)
_validate_git_http_url_userinfo_files(
root / name
for root in dependency_roots
for name in ("package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml")
) )
install_cmd = install_command or _get_node_pm_install_cmd(install_root) install_cmd = install_command or _get_node_pm_install_cmd(install_root)
if build_context: if build_context:
+3 -13
View File
@@ -12,7 +12,6 @@ from collections.abc import Callable, Mapping, Sequence
from contextlib import contextmanager from contextlib import contextmanager
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from functools import partial
from typing import Protocol, TypeVar from typing import Protocol, TypeVar
import click import click
@@ -1918,8 +1917,7 @@ OPT_AGENT_ID = click.option(
help="Logical agent ID (requires agent mode enabled for the tenant).", help="Logical agent ID (requires agent mode enabled for the tenant).",
) )
OPT_AGENT_ENVIRONMENT = partial( OPT_AGENT_ENVIRONMENT = click.option(
click.option,
"--agent-environment", "--agent-environment",
"environment", "environment",
envvar="LANGSMITH_AGENT_ENVIRONMENT", envvar="LANGSMITH_AGENT_ENVIRONMENT",
@@ -2027,9 +2025,7 @@ def _deploy_base_options(
OPT_HOST_API_KEY, OPT_HOST_API_KEY,
OPT_HOST_DEPLOYMENT_NAME, OPT_HOST_DEPLOYMENT_NAME,
OPT_AGENT_ID, OPT_AGENT_ID,
OPT_AGENT_ENVIRONMENT() OPT_AGENT_ENVIRONMENT,
if include_docker_args
else OPT_AGENT_ENVIRONMENT(type=str),
click.option( click.option(
"--deployment-id", "--deployment-id",
help=( help=(
@@ -2176,12 +2172,6 @@ def deploy(ctx: click.Context, **_: object):
# otherwise, we return None here and click will proceed to actually run the subcommand (list or delete) # otherwise, we return None here and click will proceed to actually run the subcommand (list or delete)
if ctx.invoked_subcommand is not None: if ctx.invoked_subcommand is not None:
return return
environment_param = next(
param for param in _deploy_cmd.params if param.name == "environment"
)
ctx.params["environment"] = environment_param.type_cast_value(
ctx, ctx.params["environment"]
)
if ( if (
ctx.params.get("agent_id") is not None ctx.params.get("agent_id") is not None
or ctx.params.get("environment") is not None or ctx.params.get("environment") is not None
@@ -2381,7 +2371,7 @@ def _deploy_cmd(
@OPT_HOST_API_KEY @OPT_HOST_API_KEY
@OPT_HOST_URL @OPT_HOST_URL
@OPT_AGENT_ID @OPT_AGENT_ID
@OPT_AGENT_ENVIRONMENT() @OPT_AGENT_ENVIRONMENT
@click.option( @click.option(
"--name-contains", "--name-contains",
default="", default="",
+5 -1
View File
@@ -650,7 +650,8 @@ class Config(TypedDict, total=False):
pip_config_file: str | None pip_config_file: str | None
"""Optional. Path to a pip config file (e.g., "/etc/pip.conf" or "pip.ini") for controlling """Optional. Path to a pip config file (e.g., "/etc/pip.conf" or "pip.ini") for controlling
package installation (custom indices, credentials, etc.). package installation (custom indices, timeouts, etc.). The file is copied into the
generated image, so it must not contain credentials or other secrets.
Only relevant if Python dependencies are installed via pip. If omitted, default pip settings are used. Only relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.
""" """
@@ -689,6 +690,9 @@ class Config(TypedDict, total=False):
- "." or "./src" if you have a local Python package - "." or "./src" if you have a local Python package
- str (aka "anthropic") for a PyPI package - str (aka "anthropic") for a PyPI package
- "git+https://github.com/org/repo.git@main" for a Git-based package - "git+https://github.com/org/repo.git@main" for a Git-based package
Git HTTP URLs must not contain userinfo such as a username or token. For private
dependencies, provide short-lived credentials through the build environment's
secret-backed Git credential helper.
Defaults to an empty list, meaning no additional packages installed beyond your base environment. Defaults to an empty list, meaning no additional packages installed beyond your base environment.
This field is not supported when `source.kind` is `uv`. This field is not supported when `source.kind` is `uv`.
+10
View File
@@ -880,6 +880,7 @@ def python_config_to_docker_uv_lock(
_get_node_pm_install_cmd, _get_node_pm_install_cmd,
_get_pip_cleanup_lines, _get_pip_cleanup_lines,
_image_supports_uv, _image_supports_uv,
_validate_git_http_url_userinfo_files,
docker_tag, docker_tag,
) )
@@ -890,11 +891,20 @@ def python_config_to_docker_uv_lock(
) )
config_root = config_path.parent.resolve() config_root = config_path.parent.resolve()
source_root = config["source"].get("root", ".")
project_root = (config_root / source_root).resolve()
_validate_git_http_url_userinfo_files(
[project_root / "pyproject.toml", project_root / "uv.lock"]
)
install_cmd = "uv pip install --system" install_cmd = "uv pip install --system"
_, global_reqs_pip_install, pip_config_file_str = _build_python_install_commands( _, global_reqs_pip_install, pip_config_file_str = _build_python_install_commands(
config, install_cmd config, install_cmd
) )
plan = _plan_uv_lock_workspace(config_path, config) plan = _plan_uv_lock_workspace(config_path, config)
_validate_git_http_url_userinfo_files(
package.pyproject_path for package in plan.install_order
)
_update_uv_lock_graph_paths(config_path, config, plan) _update_uv_lock_graph_paths(config_path, config, plan)
for section, key in [ for section, key in [
+2 -2
View File
@@ -28,7 +28,7 @@
"type": "null" "type": "null"
} }
], ],
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, credentials, etc.).\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n" "description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, timeouts, etc.). The file is copied into the\ngenerated image, so it must not contain credentials or other secrets.\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
}, },
"_INTERNAL_docker_tag": { "_INTERNAL_docker_tag": {
"anyOf": [ "anyOf": [
@@ -270,7 +270,7 @@
"type": "null" "type": "null"
} }
], ],
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, credentials, etc.).\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n" "description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, timeouts, etc.). The file is copied into the\ngenerated image, so it must not contain credentials or other secrets.\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
}, },
"_INTERNAL_docker_tag": { "_INTERNAL_docker_tag": {
"anyOf": [ "anyOf": [
+2 -2
View File
@@ -28,7 +28,7 @@
"type": "null" "type": "null"
} }
], ],
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, credentials, etc.).\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n" "description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, timeouts, etc.). The file is copied into the\ngenerated image, so it must not contain credentials or other secrets.\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
}, },
"_INTERNAL_docker_tag": { "_INTERNAL_docker_tag": {
"anyOf": [ "anyOf": [
@@ -270,7 +270,7 @@
"type": "null" "type": "null"
} }
], ],
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, credentials, etc.).\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n" "description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, timeouts, etc.). The file is copied into the\ngenerated image, so it must not contain credentials or other secrets.\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
}, },
"_INTERNAL_docker_tag": { "_INTERNAL_docker_tag": {
"anyOf": [ "anyOf": [
+237
View File
@@ -255,6 +255,243 @@ def test_validate_config():
) )
@pytest.mark.parametrize(
"dependency",
[
"git+https://user:secret-token@github.com/org/private.git@main",
"private-package @ git+http://token@github.com/org/private.git",
"git+HTTPS://user%40example.com:secret%2Ftoken@github.com/org/private.git",
"git+https://${GIT_TOKEN}@github.com/org/private.git",
],
)
def test_validate_config_rejects_git_http_url_userinfo(dependency: str):
with pytest.raises(click.UsageError) as exc_info:
validate_config(
{
"python_version": "3.11",
"dependencies": [dependency],
"graphs": {"agent": "./agent.py:graph"},
}
)
message = str(exc_info.value)
assert "must not contain credentials or other URL userinfo" in message
assert "secret-token" not in message
assert "secret%2Ftoken" not in message
def test_validate_config_file_reports_source_for_git_http_url_userinfo(
tmp_path: pathlib.Path,
):
config_path = tmp_path / "langgraph.json"
config_path.write_text(
json.dumps(
{
"python_version": "3.11",
"dependencies": ["git+https://secret-token@github.com/org/private.git"],
"graphs": {"agent": "./agent.py:graph"},
}
)
)
with pytest.raises(click.UsageError) as exc_info:
validate_config_file(config_path)
message = str(exc_info.value)
assert "secret-token" not in message
assert f"Found in: {config_path.resolve()}" in message
@pytest.mark.parametrize(
"manifest", ["package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml"]
)
def test_config_to_docker_rejects_git_http_url_userinfo_in_node_files(
tmp_path: pathlib.Path, manifest: str
):
config_path = tmp_path / "langgraph.json"
config_path.write_text("{}\n")
(tmp_path / "agent.js").write_text("export const graph = {};\n")
(tmp_path / "package.json").write_text('{"name":"agent"}\n')
(tmp_path / manifest).write_text(
'"priv": "git+https://user:secret-token@github.com/org/private.git"\n'
)
config = validate_config(
{
"node_version": "20",
"graphs": {"agent": "./agent.js:graph"},
}
)
with pytest.raises(click.UsageError) as exc_info:
config_to_docker(
config_path,
config,
base_image="langchain/langgraphjs-api",
)
message = str(exc_info.value)
assert "must not contain credentials or other URL userinfo" in message
assert "secret-token" not in message
assert f"Found in: {(tmp_path / manifest).resolve()}" in message
def test_config_to_docker_allows_node_git_urls_without_http_userinfo(
tmp_path: pathlib.Path,
):
config_path = tmp_path / "langgraph.json"
config_path.write_text("{}\n")
(tmp_path / "agent.js").write_text("export const graph = {};\n")
(tmp_path / "package.json").write_text(
'{"dependencies":{"public":"git+https://github.com/org/public.git"}}\n'
)
config = validate_config(
{
"node_version": "20",
"graphs": {"agent": "./agent.js:graph"},
}
)
docker, _ = config_to_docker(
config_path,
config,
base_image="langchain/langgraphjs-api",
)
assert f"ADD . /deps/{tmp_path.name}" in docker
def test_config_to_docker_rejects_git_http_url_userinfo_in_node_workspace(
tmp_path: pathlib.Path,
):
config_root = tmp_path / "apps" / "agent"
config_root.mkdir(parents=True)
config_path = config_root / "langgraph.json"
config_path.write_text("{}\n")
(config_root / "agent.js").write_text("export const graph = {};\n")
(config_root / "package.json").write_text(
'{"dependencies":{"priv":"git+https://secret-token@github.com/org/private.git"}}\n'
)
(tmp_path / "package.json").write_text('{"name":"workspace"}\n')
config = validate_config(
{
"node_version": "20",
"graphs": {"agent": "./agent.js:graph"},
}
)
with pytest.raises(click.UsageError) as exc_info:
config_to_docker(
config_path,
config,
base_image="langchain/langgraphjs-api",
build_context=str(tmp_path),
)
message = str(exc_info.value)
assert "secret-token" not in message
assert f"Found in: {(config_root / 'package.json').resolve()}" in message
@pytest.mark.parametrize(
"dependency",
[
"git+https://github.com/org/public.git@main",
"private-package @ git+https://github.com/org/private.git@main",
"git+ssh://git@github.com/org/private.git@main",
],
)
def test_validate_config_allows_git_urls_without_http_userinfo(dependency: str):
config = validate_config(
{
"python_version": "3.11",
"dependencies": [dependency],
"graphs": {"agent": "./agent.py:graph"},
}
)
assert config["dependencies"] == [dependency]
def test_config_to_docker_rejects_git_http_url_userinfo_in_requirements(
tmp_path: pathlib.Path,
):
config_path = tmp_path / "langgraph.json"
config_path.write_text("{}\n")
(tmp_path / "agent.py").write_text("graph = object()\n")
(tmp_path / "requirements.txt").write_text(
"private @ git+https://secret-token@github.com/org/private.git\n"
)
config = validate_config(
{
"python_version": "3.11",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
}
)
with pytest.raises(click.UsageError) as exc_info:
config_to_docker(
config_path,
config,
base_image="langchain/langgraph-api:0.2.47",
)
message = str(exc_info.value)
assert "must not contain credentials or other URL userinfo" in message
assert "secret-token" not in message
assert f"Found in: {(tmp_path / 'requirements.txt').resolve()}" in message
@pytest.mark.parametrize("manifest", ["pyproject.toml", "uv.lock"])
def test_config_to_docker_rejects_git_http_url_userinfo_in_uv_files(
tmp_path: pathlib.Path, manifest: str
):
config_path = tmp_path / "langgraph.json"
config_path.write_text("{}\n")
(tmp_path / "src").mkdir()
(tmp_path / "src" / "agent.py").write_text("graph = object()\n")
pyproject = textwrap.dedent(
"""
[project]
name = "agent"
version = "0.1.0"
dependencies = ["private"]
[tool.uv.sources]
private = { git = "https://github.com/org/private.git" }
"""
).strip()
uv_lock = "# uv lock file\n"
if manifest == "pyproject.toml":
pyproject = pyproject.replace(
"https://github.com", "https://secret-token@github.com"
)
else:
uv_lock += (
'source = { git = "https://secret-token@github.com/org/private.git" }\n'
)
(tmp_path / "pyproject.toml").write_text(pyproject + "\n")
(tmp_path / "uv.lock").write_text(uv_lock)
config = validate_config(
{
"python_version": "3.11",
"graphs": {"agent": "./src/agent.py:graph"},
"source": {"kind": "uv"},
}
)
with pytest.raises(click.UsageError) as exc_info:
config_to_docker(
config_path,
config,
base_image="langchain/langgraph-api:0.2.47",
)
message = str(exc_info.value)
assert "must not contain credentials or other URL userinfo" in message
assert "secret-token" not in message
def test_validate_config_image_distro(): def test_validate_config_image_distro():
"""Test validation of image_distro field.""" """Test validation of image_distro field."""
# Valid image_distro values should work # Valid image_distro values should work
+63 -22
View File
@@ -8,13 +8,15 @@ from typing import Any, cast
from langchain_core.runnables import RunnableConfig from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.base import ( from langgraph.checkpoint.base import (
BaseCheckpointSaver, BaseCheckpointSaver,
ChannelVersions,
Checkpoint, Checkpoint,
PendingWrite,
) )
from langgraph.checkpoint.base.id import uuid6 from langgraph.checkpoint.base.id import uuid6
from langgraph.checkpoint.serde.types import _DeltaSnapshot from langgraph.checkpoint.serde.types import _DeltaSnapshot
from langgraph._internal._config import DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT from langgraph._internal._config import DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT
from langgraph._internal._constants import PUSH from langgraph._internal._constants import INTERRUPT, PUSH
from langgraph._internal._typing import MISSING from langgraph._internal._typing import MISSING
from langgraph.channels.base import BaseChannel from langgraph.channels.base import BaseChannel
from langgraph.channels.delta import DeltaChannel from langgraph.channels.delta import DeltaChannel
@@ -89,6 +91,23 @@ def get_delta_channels_from_all_channels(
} }
def delta_channels_with_pending_writes(
specs: Mapping[str, Any],
pending_writes: Iterable[PendingWrite] | None,
) -> set[str]:
"""DeltaChannels a branch starting from this checkpoint must snapshot.
A checkpoint's pending writes belong to the child that consumed them, and
nothing records which child that was. A new branch snapshots every delta
channel they touch, so its ancestor walk never replays them.
"""
return {
ch
for _, ch, _ in pending_writes or ()
if isinstance(specs.get(ch), DeltaChannel)
}
def create_metadata_for_update_state_api( def create_metadata_for_update_state_api(
channels: Mapping[str, BaseChannel], channels: Mapping[str, BaseChannel],
updated_channels: set[str], updated_channels: set[str],
@@ -122,6 +141,7 @@ def create_checkpoint_plan_for_update_state_api(
parents: dict[str, Any], parents: dict[str, Any],
saved_metadata: Mapping[str, Any] | None, saved_metadata: Mapping[str, Any] | None,
is_fresh_thread: bool, is_fresh_thread: bool,
fork_channels: set[str],
) -> tuple[set[str], dict[str, Any]]: ) -> tuple[set[str], dict[str, Any]]:
"""Return ``(channels_to_snapshot, metadata)`` for an update_state head.""" """Return ``(channels_to_snapshot, metadata)`` for an update_state head."""
metadata: dict[str, Any] = { metadata: dict[str, Any] = {
@@ -137,7 +157,9 @@ def create_checkpoint_plan_for_update_state_api(
updated_channels, updated_channels,
prev_metadata=saved_metadata, prev_metadata=saved_metadata,
) )
channels_to_snapshot = delta_channels_to_snapshot(channels, new_counters) channels_to_snapshot = (
delta_channels_to_snapshot(channels, new_counters) | fork_channels
)
for k in channels_to_snapshot: for k in channels_to_snapshot:
new_counters[k] = (0, 0) new_counters[k] = (0, 0)
non_zero = {k: v for k, v in new_counters.items() if v != (0, 0)} non_zero = {k: v for k, v in new_counters.items() if v != (0, 0)}
@@ -167,6 +189,7 @@ def create_checkpoint(
""" """
ts = datetime.now(timezone.utc).isoformat() ts = datetime.now(timezone.utc).isoformat()
channels_to_snapshot = channels_to_snapshot or set() channels_to_snapshot = channels_to_snapshot or set()
bumped: dict[str, tuple[Any, Any]] = {}
if channels is None: if channels is None:
values = checkpoint["channel_values"] values = checkpoint["channel_values"]
channel_versions = checkpoint["channel_versions"] channel_versions = checkpoint["channel_versions"]
@@ -174,30 +197,29 @@ def create_checkpoint(
values = {} values = {}
channel_versions = dict(checkpoint["channel_versions"]) channel_versions = dict(checkpoint["channel_versions"])
for k in channels: for k in channels:
if k not in channel_versions:
continue
ch = channels[k] ch = channels[k]
if k not in channel_versions:
# A forced snapshot of a never-written channel still has to
# land to stop the ancestor walk, and `put` only stores blobs
# for versioned channels.
if k in channels_to_snapshot and get_next_version is not None:
channel_versions[k] = get_next_version(None, None)
bumped[k] = (None, channel_versions[k])
values[k] = _DeltaSnapshot(
ch.get() if ch.is_available() else ch.typ()
)
continue
if k in channels_to_snapshot: if k in channels_to_snapshot:
# Callers force a full snapshot blob here: exit mode when a # `put` only stores a blob for a channel whose version moved,
# delta channel reaches its snapshot cadence, and update_state # so snapshotting a channel this step did not write needs a
# on a fresh thread (no ancestor to replay writes from). The # bump: exit mode reaching the cadence on a superstep that
# manual version-bump below only applies to the exit-mode case. # skipped the channel, and a fork's first checkpoint.
#
# In exit mode, the snapshot decision is deferred to exit
# time (intermediate steps have do_checkpoint=False). The
# channel's count may have reached snapshot_frequency over
# several supersteps, but the LAST superstep may not have
# written to this channel. In that case apply_writes()
# (in _algo.py) didn't bump this channel's version, so
# saver.put() wouldn't include it in new_versions and
# the snapshot blob would be silently dropped. The manual
# bump below closes the gap. In sync/async durability this
# branch is effectively dead code (the step that pushes
# the count to freq always writes the channel).
if get_next_version is not None and ( if get_next_version is not None and (
updated_channels is None or k not in updated_channels updated_channels is None or k not in updated_channels
): ):
channel_versions[k] = get_next_version(channel_versions[k], None) old = channel_versions[k]
channel_versions[k] = get_next_version(old, None)
bumped[k] = (old, channel_versions[k])
values[k] = _DeltaSnapshot(ch.get()) values[k] = _DeltaSnapshot(ch.get())
else: else:
v = ch.checkpoint() v = ch.checkpoint()
@@ -209,11 +231,30 @@ def create_checkpoint(
id=id or str(uuid6(clock_seq=step)), id=id or str(uuid6(clock_seq=step)),
channel_values=values, channel_values=values,
channel_versions=channel_versions, channel_versions=channel_versions,
versions_seen=checkpoint["versions_seen"], versions_seen=_mark_bumps_seen(checkpoint["versions_seen"], bumped),
updated_channels=None if updated_channels is None else sorted(updated_channels), updated_channels=None if updated_channels is None else sorted(updated_channels),
) )
def _mark_bumps_seen(
versions_seen: dict[str, ChannelVersions],
bumped: Mapping[str, tuple[Any, Any]],
) -> dict[str, ChannelVersions]:
"""Advance whoever had seen a bumped channel's old version to the new one.
A bump that only stores a snapshot is not a write. Left unseen, it would
re-fire `interrupt_before` and rerun the channel's subscribers.
"""
if not bumped:
return versions_seen
out: dict[str, ChannelVersions] = {}
for node, seen in {INTERRUPT: {}, **versions_seen}.items():
advanced = {k: new for k, (old, new) in bumped.items() if seen.get(k) == old}
if advanced or node in versions_seen:
out[node] = {**seen, **advanced}
return out
def _needs_replay(spec: BaseChannel, stored: object) -> bool: def _needs_replay(spec: BaseChannel, stored: object) -> bool:
"""True if `spec` is a `DeltaChannel` and no value is stored at this """True if `spec` is a `DeltaChannel` and no value is stored at this
checkpoint, requiring an ancestor walk to reconstruct. checkpoint, requiring an ancestor walk to reconstruct.
+22 -11
View File
@@ -102,6 +102,7 @@ from langgraph.pregel._checkpoint import (
copy_checkpoint, copy_checkpoint,
create_checkpoint, create_checkpoint,
delta_channels_to_snapshot, delta_channels_to_snapshot,
delta_channels_with_pending_writes,
empty_checkpoint, empty_checkpoint,
exit_delta_task_id, exit_delta_task_id,
) )
@@ -222,10 +223,13 @@ class PregelLoop:
# under the saver's `ORDER BY task_id, idx` sorting. # under the saver's `ORDER BY task_id, idx` sorting.
_exit_delta_writes: list[tuple[int, str, str, Any]] | None = None _exit_delta_writes: list[tuple[int, str, str, Any]] | None = None
# Delta channels that saw an Overwrite since the last checkpoint. These # Delta channels that must snapshot at the next checkpoint, whatever their
# channels must snapshot after live update applies overwrite semantics so # cadence counters say:
# sparse replay starts from the same post-overwrite value. # * an Overwrite arrived since the last checkpoint, so sparse replay has to
_delta_channels_with_overwrite: set[str] # start from the post-overwrite value;
# * the checkpoint this run starts from has pending writes to them; see
# `delta_channels_with_pending_writes`.
_delta_channels_forced_snapshot: set[str]
# The checkpoint_config that points at the parent loaded at `__enter__` # The checkpoint_config that points at the parent loaded at `__enter__`
# (or the synthetic-empty checkpoint, on first run). We capture it # (or the synthetic-empty checkpoint, on first run). We capture it
@@ -683,7 +687,7 @@ class PregelLoop:
def after_tick(self) -> None: def after_tick(self) -> None:
# finish superstep # finish superstep
writes = [w for t in self.tasks.values() for w in t.writes] writes = [w for t in self.tasks.values() for w in t.writes]
self._delta_channels_with_overwrite.update( self._delta_channels_forced_snapshot.update(
ch ch
for ch, v in writes for ch, v in writes
if isinstance(self.specs.get(ch), DeltaChannel) and _get_overwrite(v)[0] if isinstance(self.specs.get(ch), DeltaChannel) and _get_overwrite(v)[0]
@@ -898,6 +902,15 @@ class PregelLoop:
self.checkpoint_pending_writes = [ self.checkpoint_pending_writes = [
w for w in self.checkpoint_pending_writes if w[1] != RESUME w for w in self.checkpoint_pending_writes if w[1] != RESUME
] ]
# A resume that is not replaying reuses the head's pending writes
# instead of rerunning their tasks, so none of them can leak.
self._delta_channels_forced_snapshot = (
set()
if is_resuming and not self.is_replaying
else delta_channels_with_pending_writes(
self.specs, self.checkpoint_pending_writes
)
)
# map command to writes # map command to writes
if input_is_command: if input_is_command:
@@ -991,7 +1004,7 @@ class PregelLoop:
manager=None, manager=None,
updated_channels=updated_channels, updated_channels=updated_channels,
) )
self._delta_channels_with_overwrite.update( self._delta_channels_forced_snapshot.update(
c c
for c, v in input_writes for c, v in input_writes
if isinstance(self.specs.get(c), DeltaChannel) and _get_overwrite(v)[0] if isinstance(self.specs.get(c), DeltaChannel) and _get_overwrite(v)[0]
@@ -1136,7 +1149,7 @@ class PregelLoop:
# create new checkpoint # create new checkpoint
channels_to_snapshot = ( channels_to_snapshot = (
delta_channels_to_snapshot(self.channels, new_counters) delta_channels_to_snapshot(self.channels, new_counters)
| self._delta_channels_with_overwrite | self._delta_channels_forced_snapshot
if do_checkpoint if do_checkpoint
else set() else set()
) )
@@ -1154,7 +1167,7 @@ class PregelLoop:
for k in channels_to_snapshot: for k in channels_to_snapshot:
new_counters[k] = (0, 0) new_counters[k] = (0, 0)
if do_checkpoint: if do_checkpoint:
self._delta_channels_with_overwrite.difference_update(channels_to_snapshot) self._delta_channels_forced_snapshot.difference_update(channels_to_snapshot)
non_zero = {k: v for k, v in new_counters.items() if v != (0, 0)} non_zero = {k: v for k, v in new_counters.items() if v != (0, 0)}
if non_zero: if non_zero:
self.checkpoint_metadata["counters_since_delta_snapshot"] = non_zero self.checkpoint_metadata["counters_since_delta_snapshot"] = non_zero
@@ -1239,7 +1252,7 @@ class PregelLoop:
) )
channels_to_snapshot = ( channels_to_snapshot = (
delta_channels_to_snapshot(self.channels, counters) delta_channels_to_snapshot(self.channels, counters)
| self._delta_channels_with_overwrite | self._delta_channels_forced_snapshot
) )
pending = [ pending = [
@@ -1684,7 +1697,6 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
) )
self._delta_write_futs = [] self._delta_write_futs = []
self._error_handler_write_futs = [] self._error_handler_write_futs = []
self._delta_channels_with_overwrite = set()
self._exit_delta_writes = ( self._exit_delta_writes = (
[] if self.durability == "exit" and self.checkpointer is not None else None [] if self.durability == "exit" and self.checkpointer is not None else None
) )
@@ -1942,7 +1954,6 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
) )
self._delta_write_futs = [] self._delta_write_futs = []
self._error_handler_write_futs = [] self._error_handler_write_futs = []
self._delta_channels_with_overwrite = set()
self._exit_delta_writes = ( self._exit_delta_writes = (
[] if self.durability == "exit" and self.checkpointer is not None else None [] if self.durability == "exit" and self.checkpointer is not None else None
) )
+64 -10
View File
@@ -133,6 +133,7 @@ from langgraph.pregel._checkpoint import (
copy_checkpoint, copy_checkpoint,
create_checkpoint, create_checkpoint,
create_checkpoint_plan_for_update_state_api, create_checkpoint_plan_for_update_state_api,
delta_channels_with_pending_writes,
empty_checkpoint, empty_checkpoint,
get_updated_channels_from_tasks, get_updated_channels_from_tasks,
) )
@@ -1637,12 +1638,21 @@ class Pregel(
else: else:
raise ValueError(f"Subgraph {recast} not found") raise ValueError(f"Subgraph {recast} not found")
# Taken from the first superstep's base, and cleared by the first
# checkpoint that carries the snapshots, which `__copy__` does not write.
fork_pending: set[str] | None = None
def perform_superstep( def perform_superstep(
input_config: RunnableConfig, updates: Sequence[StateUpdate] input_config: RunnableConfig, updates: Sequence[StateUpdate]
) -> RunnableConfig: ) -> RunnableConfig:
nonlocal fork_pending
# get last checkpoint # get last checkpoint
config = ensure_config(self.config, input_config) config = ensure_config(self.config, input_config)
saved = checkpointer.get_tuple(config) saved = checkpointer.get_tuple(config)
if fork_pending is None:
fork_pending = delta_channels_with_pending_writes(
self.channels, saved.pending_writes if saved else None
)
if saved is not None: if saved is not None:
self._migrate_checkpoint(saved.checkpoint) self._migrate_checkpoint(saved.checkpoint)
checkpoint = ( checkpoint = (
@@ -1726,9 +1736,17 @@ class Pregel(
self.trigger_to_nodes, self.trigger_to_nodes,
) )
# save checkpoint # save checkpoint
next_checkpoint = create_checkpoint(
checkpoint,
channels,
step,
get_next_version=checkpointer.get_next_version,
channels_to_snapshot=fork_pending,
)
fork_pending.difference_update(next_checkpoint["channel_values"])
next_config = checkpointer.put( next_config = checkpointer.put(
checkpoint_config, checkpoint_config,
create_checkpoint(checkpoint, channels, step), next_checkpoint,
{ {
"source": "update", "source": "update",
"step": step + 1, "step": step + 1,
@@ -1736,7 +1754,7 @@ class Pregel(
}, },
get_new_channel_versions( get_new_channel_versions(
checkpoint_previous_versions, checkpoint_previous_versions,
checkpoint["channel_versions"], next_checkpoint["channel_versions"],
), ),
) )
return patch_checkpoint_map( return patch_checkpoint_map(
@@ -1765,9 +1783,17 @@ class Pregel(
if saved and saved.metadata.get("step") is not None if saved and saved.metadata.get("step") is not None
else -1 else -1
) )
next_checkpoint = create_checkpoint(
checkpoint,
channels,
next_step,
get_next_version=checkpointer.get_next_version,
channels_to_snapshot=fork_pending,
)
fork_pending.difference_update(next_checkpoint["channel_values"])
next_config = checkpointer.put( next_config = checkpointer.put(
checkpoint_config, checkpoint_config,
create_checkpoint(checkpoint, channels, next_step), next_checkpoint,
{ {
"source": "input", "source": "input",
"step": next_step, "step": next_step,
@@ -1777,7 +1803,7 @@ class Pregel(
}, },
get_new_channel_versions( get_new_channel_versions(
checkpoint_previous_versions, checkpoint_previous_versions,
checkpoint["channel_versions"], next_checkpoint["channel_versions"],
), ),
) )
@@ -2020,18 +2046,19 @@ class Pregel(
parents=saved.metadata.get("parents", {}) if saved else {}, parents=saved.metadata.get("parents", {}) if saved else {},
saved_metadata=saved.metadata if saved else None, saved_metadata=saved.metadata if saved else None,
is_fresh_thread=saved is None, is_fresh_thread=saved is None,
fork_channels=fork_pending,
) )
) )
checkpoint = create_checkpoint( checkpoint = create_checkpoint(
checkpoint, checkpoint,
channels, channels,
step + 1, step + 1,
updated_channels=updated_channels if channels_to_snapshot else None,
get_next_version=checkpointer.get_next_version get_next_version=checkpointer.get_next_version
if channels_to_snapshot if channels_to_snapshot
else None, else None,
channels_to_snapshot=channels_to_snapshot, channels_to_snapshot=channels_to_snapshot,
) )
fork_pending.difference_update(checkpoint["channel_values"])
next_config = checkpointer.put( next_config = checkpointer.put(
checkpoint_config, checkpoint_config,
checkpoint, checkpoint,
@@ -2103,12 +2130,21 @@ class Pregel(
else: else:
raise ValueError(f"Subgraph {recast} not found") raise ValueError(f"Subgraph {recast} not found")
# Taken from the first superstep's base, and cleared by the first
# checkpoint that carries the snapshots, which `__copy__` does not write.
fork_pending: set[str] | None = None
async def aperform_superstep( async def aperform_superstep(
input_config: RunnableConfig, updates: Sequence[StateUpdate] input_config: RunnableConfig, updates: Sequence[StateUpdate]
) -> RunnableConfig: ) -> RunnableConfig:
nonlocal fork_pending
# get last checkpoint # get last checkpoint
config = ensure_config(self.config, input_config) config = ensure_config(self.config, input_config)
saved = await checkpointer.aget_tuple(config) saved = await checkpointer.aget_tuple(config)
if fork_pending is None:
fork_pending = delta_channels_with_pending_writes(
self.channels, saved.pending_writes if saved else None
)
if saved is not None: if saved is not None:
self._migrate_checkpoint(saved.checkpoint) self._migrate_checkpoint(saved.checkpoint)
checkpoint = ( checkpoint = (
@@ -2190,16 +2226,25 @@ class Pregel(
self.trigger_to_nodes, self.trigger_to_nodes,
) )
# save checkpoint # save checkpoint
next_checkpoint = create_checkpoint(
checkpoint,
channels,
step,
get_next_version=checkpointer.get_next_version,
channels_to_snapshot=fork_pending,
)
fork_pending.difference_update(next_checkpoint["channel_values"])
next_config = await checkpointer.aput( next_config = await checkpointer.aput(
checkpoint_config, checkpoint_config,
create_checkpoint(checkpoint, channels, step), next_checkpoint,
{ {
"source": "update", "source": "update",
"step": step + 1, "step": step + 1,
"parents": saved.metadata.get("parents", {}) if saved else {}, "parents": saved.metadata.get("parents", {}) if saved else {},
}, },
get_new_channel_versions( get_new_channel_versions(
checkpoint_previous_versions, checkpoint["channel_versions"] checkpoint_previous_versions,
next_checkpoint["channel_versions"],
), ),
) )
return patch_checkpoint_map( return patch_checkpoint_map(
@@ -2228,9 +2273,17 @@ class Pregel(
if saved and saved.metadata.get("step") is not None if saved and saved.metadata.get("step") is not None
else -1 else -1
) )
next_checkpoint = create_checkpoint(
checkpoint,
channels,
next_step,
get_next_version=checkpointer.get_next_version,
channels_to_snapshot=fork_pending,
)
fork_pending.difference_update(next_checkpoint["channel_values"])
next_config = await checkpointer.aput( next_config = await checkpointer.aput(
checkpoint_config, checkpoint_config,
create_checkpoint(checkpoint, channels, next_step), next_checkpoint,
{ {
"source": "input", "source": "input",
"step": next_step, "step": next_step,
@@ -2240,7 +2293,7 @@ class Pregel(
}, },
get_new_channel_versions( get_new_channel_versions(
checkpoint_previous_versions, checkpoint_previous_versions,
checkpoint["channel_versions"], next_checkpoint["channel_versions"],
), ),
) )
@@ -2480,18 +2533,19 @@ class Pregel(
parents=saved.metadata.get("parents", {}) if saved else {}, parents=saved.metadata.get("parents", {}) if saved else {},
saved_metadata=saved.metadata if saved else None, saved_metadata=saved.metadata if saved else None,
is_fresh_thread=saved is None, is_fresh_thread=saved is None,
fork_channels=fork_pending,
) )
) )
checkpoint = create_checkpoint( checkpoint = create_checkpoint(
checkpoint, checkpoint,
channels, channels,
step + 1, step + 1,
updated_channels=updated_channels if channels_to_snapshot else None,
get_next_version=checkpointer.get_next_version get_next_version=checkpointer.get_next_version
if channels_to_snapshot if channels_to_snapshot
else None, else None,
channels_to_snapshot=channels_to_snapshot, channels_to_snapshot=channels_to_snapshot,
) )
fork_pending.difference_update(checkpoint["channel_values"])
next_config = await checkpointer.aput( next_config = await checkpointer.aput(
checkpoint_config, checkpoint_config,
checkpoint, checkpoint,
+5 -3
View File
@@ -85,11 +85,13 @@ class MemorySaverAssertImmutable(InMemorySaver):
) )
== saved == saved
), config["configurable"]["checkpoint_ns"] ), config["configurable"]["checkpoint_ns"]
next_config = super().put(config, checkpoint, metadata, new_versions)
# Read back, not the object handed in: a DeltaChannel a step did not
# write is refilled on read from the blob its inherited version points at.
self.storage_for_copies[thread_id][checkpoint_ns][checkpoint["id"]] = ( self.storage_for_copies[thread_id][checkpoint_ns][checkpoint["id"]] = (
self.serde.dumps_typed(checkpoint) self.serde.dumps_typed(super().get(next_config))
) )
# call super to write checkpoint return next_config
return super().put(config, checkpoint, metadata, new_versions)
class MemorySaverNoPending(InMemorySaver): class MemorySaverNoPending(InMemorySaver):
@@ -0,0 +1,533 @@
"""Forking a thread must not replay the abandoned branch into the fork.
Every graph carries a `DeltaChannel` and a plain reducer channel fed the same
values; the plain channel needs no replay, so it is the oracle.
"""
from collections.abc import Sequence
from operator import add
from typing import Annotated, Any
import pytest
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.base import BaseCheckpointSaver
from langgraph.checkpoint.serde.types import _DeltaSnapshot
from typing_extensions import TypedDict
from langgraph._internal._constants import INPUT
from langgraph.channels.delta import DeltaChannel
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, Durability, StateSnapshot, StateUpdate, interrupt
pytestmark = pytest.mark.anyio
def _append(current: list | None, writes: Sequence[Any]) -> list:
out = list(current or [])
for write in writes:
out.extend(write if isinstance(write, list) else [write])
return out
class _State(TypedDict):
log: Annotated[list, DeltaChannel(_append, snapshot_frequency=1000)]
plain: Annotated[list, add]
other: Annotated[list, add]
def _build(checkpointer: BaseCheckpointSaver, tag: str) -> Any:
def node(state: _State) -> dict:
return {"log": [f"{tag}-out"], "plain": [f"{tag}-out"]}
builder = StateGraph(_State)
builder.add_node("n", node)
builder.set_entry_point("n")
builder.set_finish_point("n")
return builder.compile(checkpointer=checkpointer)
def _build_without_delta_writes(checkpointer: BaseCheckpointSaver, tag: str) -> Any:
def node(state: _State) -> dict:
return {"other": [f"{tag}-other"]}
builder = StateGraph(_State)
builder.add_node("n", node)
builder.set_entry_point("n")
builder.set_finish_point("n")
return builder.compile(checkpointer=checkpointer)
def _thread(thread_id: str) -> RunnableConfig:
return {"configurable": {"thread_id": thread_id}}
def _at(config: RunnableConfig, snapshot: StateSnapshot) -> RunnableConfig:
return {
"configurable": {
**config["configurable"],
"checkpoint_ns": "",
"checkpoint_id": snapshot.config["configurable"]["checkpoint_id"],
}
}
def _both(marker: str) -> dict:
return {"log": [marker], "plain": [marker]}
def _snapshotted_checkpoints(
checkpointer: BaseCheckpointSaver, config: RunnableConfig
) -> list[str]:
return [
tuple_.config["configurable"]["checkpoint_id"]
for tuple_ in checkpointer.list(config)
if isinstance(tuple_.checkpoint["channel_values"].get("log"), _DeltaSnapshot)
]
def _assert_fork_is_clean(state: StateSnapshot, abandoned: str) -> None:
assert state.values["log"] == state.values["plain"], (
f"delta channel diverged from the plain channel: "
f"{state.values['log']} != {state.values['plain']}"
)
assert abandoned not in state.values["log"], (
f"{abandoned!r} belongs to the branch the fork replaced, "
f"but was replayed into {state.values['log']}"
)
def test_fork_by_invoke(
sync_checkpointer: BaseCheckpointSaver, durability: Durability
) -> None:
config = _thread("t")
_build(sync_checkpointer, "first").invoke(
_both("in-1"), config, durability=durability
)
graph = _build(sync_checkpointer, "second")
graph.invoke(_both("in-2"), config, durability=durability)
abandoned_head = graph.get_state(config)
base = next(
snapshot
for snapshot in graph.get_state_history(config)
if "in-2" not in snapshot.values["log"]
)
_build(sync_checkpointer, "third").invoke(
_both("in-3"), _at(config, base), durability=durability
)
state = graph.get_state(config)
_assert_fork_is_clean(state, "in-2")
assert state.values["log"] == [*base.values["log"], "in-3", "third-out"]
abandoned = graph.get_state(abandoned_head.config).values
assert abandoned["log"] == abandoned["plain"] == abandoned_head.values["log"]
async def test_afork_by_invoke(
async_checkpointer: BaseCheckpointSaver, durability: Durability
) -> None:
config = _thread("t")
await _build(async_checkpointer, "first").ainvoke(
_both("in-1"), config, durability=durability
)
graph = _build(async_checkpointer, "second")
await graph.ainvoke(_both("in-2"), config, durability=durability)
abandoned_head = await graph.aget_state(config)
base = await anext(
snapshot
async for snapshot in graph.aget_state_history(config)
if "in-2" not in snapshot.values["log"]
)
await _build(async_checkpointer, "third").ainvoke(
_both("in-3"), _at(config, base), durability=durability
)
state = await graph.aget_state(config)
_assert_fork_is_clean(state, "in-2")
assert state.values["log"] == [*base.values["log"], "in-3", "third-out"]
abandoned = (await graph.aget_state(abandoned_head.config)).values
assert abandoned["log"] == abandoned["plain"] == abandoned_head.values["log"]
def test_fork_off_checkpoint_before_first_input(
sync_checkpointer: BaseCheckpointSaver, durability: Durability
) -> None:
config = _thread("t")
graph = _build(sync_checkpointer, "first")
graph.invoke(_both("in-1"), config, durability=durability)
root = list(graph.get_state_history(config))[-1]
assert root.values["log"] == []
_build(sync_checkpointer, "third").invoke(
_both("in-9"), _at(config, root), durability=durability
)
state = graph.get_state(config)
_assert_fork_is_clean(state, "in-1")
assert state.values["log"] == ["in-9", "third-out"]
async def test_afork_off_checkpoint_before_first_input(
async_checkpointer: BaseCheckpointSaver, durability: Durability
) -> None:
config = _thread("t")
graph = _build(async_checkpointer, "first")
await graph.ainvoke(_both("in-1"), config, durability=durability)
root = [snapshot async for snapshot in graph.aget_state_history(config)][-1]
assert root.values["log"] == []
await _build(async_checkpointer, "third").ainvoke(
_both("in-9"), _at(config, root), durability=durability
)
state = await graph.aget_state(config)
_assert_fork_is_clean(state, "in-1")
assert state.values["log"] == ["in-9", "third-out"]
def test_fork_by_update_state(sync_checkpointer: BaseCheckpointSaver) -> None:
config = _thread("t")
_build(sync_checkpointer, "first").invoke(_both("in-1"), config)
graph = _build(sync_checkpointer, "second")
graph.invoke(_both("in-2"), config)
base = next(
snapshot
for snapshot in graph.get_state_history(config)
if "in-2" not in snapshot.values["log"]
)
forked = graph.update_state(_at(config, base), _both("patched"))
state = graph.get_state(forked)
_assert_fork_is_clean(state, "in-2")
assert state.values["log"] == [*base.values["log"], "patched"]
async def test_afork_by_update_state(
async_checkpointer: BaseCheckpointSaver,
) -> None:
config = _thread("t")
await _build(async_checkpointer, "first").ainvoke(_both("in-1"), config)
graph = _build(async_checkpointer, "second")
await graph.ainvoke(_both("in-2"), config)
base = await anext(
snapshot
async for snapshot in graph.aget_state_history(config)
if "in-2" not in snapshot.values["log"]
)
forked = await graph.aupdate_state(_at(config, base), _both("patched"))
state = await graph.aget_state(forked)
_assert_fork_is_clean(state, "in-2")
assert state.values["log"] == [*base.values["log"], "patched"]
def test_unaddressed_run_keeps_snapshot_cadence(
sync_checkpointer: BaseCheckpointSaver, durability: Durability
) -> None:
config = _thread("t")
graph = _build(sync_checkpointer, "first")
graph.invoke(_both("in-1"), config, durability=durability)
graph.invoke(_both("in-2"), config, durability=durability)
assert not _snapshotted_checkpoints(sync_checkpointer, config)
def test_fork_before_first_value_when_fork_never_writes_the_channel(
sync_checkpointer: BaseCheckpointSaver, durability: Durability
) -> None:
config = _thread("t")
graph = _build(sync_checkpointer, "first")
graph.invoke(_both("in-1"), config, durability=durability)
root = list(graph.get_state_history(config))[-1]
assert root.values["log"] == []
_build_without_delta_writes(sync_checkpointer, "third").invoke(
{"other": ["in-9"]}, _at(config, root), durability=durability
)
state = graph.get_state(config)
_assert_fork_is_clean(state, "in-1")
assert state.values["log"] == []
async def test_afork_before_first_value_when_fork_never_writes_the_channel(
async_checkpointer: BaseCheckpointSaver, durability: Durability
) -> None:
config = _thread("t")
graph = _build(async_checkpointer, "first")
await graph.ainvoke(_both("in-1"), config, durability=durability)
root = [snapshot async for snapshot in graph.aget_state_history(config)][-1]
assert root.values["log"] == []
await _build_without_delta_writes(async_checkpointer, "third").ainvoke(
{"other": ["in-9"]}, _at(config, root), durability=durability
)
state = await graph.aget_state(config)
_assert_fork_is_clean(state, "in-1")
assert state.values["log"] == []
def test_fork_before_first_value_by_bulk_update(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
config = _thread("t")
graph = _build(sync_checkpointer, "first")
graph.invoke(_both("in-1"), config)
root = list(graph.get_state_history(config))[-1]
assert root.values["log"] == []
forked = graph.bulk_update_state(
_at(config, root),
[
[StateUpdate({"other": ["s1"]}, "n")],
[StateUpdate(_both("s2"), "n")],
],
)
state = graph.get_state(forked)
_assert_fork_is_clean(state, "in-1")
assert state.values["log"] == ["s2"]
@pytest.mark.parametrize("first_as_node", [INPUT, END, "__copy__"])
def test_fork_by_bulk_update_whose_first_superstep_skips_the_plan(
sync_checkpointer: BaseCheckpointSaver, first_as_node: str
) -> None:
config = _thread("t")
_build(sync_checkpointer, "first").invoke(_both("in-1"), config)
graph = _build(sync_checkpointer, "second")
graph.invoke(_both("in-2"), config)
base = next(
snapshot
for snapshot in graph.get_state_history(config)
if "in-2" not in snapshot.values["log"]
)
first = (
StateUpdate(_both("first-step"), first_as_node)
if first_as_node == INPUT
else StateUpdate(None, first_as_node)
)
forked = graph.bulk_update_state(
_at(config, base),
[[first], [StateUpdate(_both("second-step"), "n")]],
)
state = graph.get_state(forked)
assert state.values["log"] == state.values["plain"], (
f"delta channel diverged from the plain channel: "
f"{state.values['log']} != {state.values['plain']}"
)
def test_unaddressed_bulk_update_keeps_snapshot_cadence(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
config = _thread("t")
graph = _build(sync_checkpointer, "first")
graph.invoke(_both("in-1"), config)
graph.bulk_update_state(
config,
[[StateUpdate(_both(f"u{i}"), "n")] for i in range(4)],
)
assert not _snapshotted_checkpoints(sync_checkpointer, config)
def _build_paused_before_b(checkpointer: BaseCheckpointSaver) -> Any:
builder = StateGraph(_State)
builder.add_node("a", lambda state: _both("a"))
builder.add_node("b", lambda state: _both("b"))
builder.add_edge(START, "a")
builder.add_edge("a", "b")
builder.add_edge("b", END)
return builder.compile(checkpointer=checkpointer, interrupt_before=["b"])
def _build_parallel_interrupt(checkpointer: BaseCheckpointSaver) -> Any:
def ask(state: _State) -> dict:
interrupt("approve?")
return {"other": ["q"]}
builder = StateGraph(_State)
builder.add_node("p", lambda state: _both("p"))
builder.add_node("q", ask)
builder.add_edge(START, "p")
builder.add_edge(START, "q")
return builder.compile(checkpointer=checkpointer)
def test_resume_at_interrupt_before_with_the_head_checkpoint_id_runs_the_node(
sync_checkpointer: BaseCheckpointSaver, durability: Durability
) -> None:
config = _thread("t")
graph = _build_paused_before_b(sync_checkpointer)
graph.invoke(_both("in"), config, durability=durability)
graph.invoke(None, graph.get_state(config).config, durability=durability)
state = graph.get_state(config)
assert state.next == (), f"resume paused again before {state.next}"
assert state.values["log"] == state.values["plain"] == ["in", "a", "b"]
async def test_aresume_at_interrupt_before_with_the_head_checkpoint_id_runs_the_node(
async_checkpointer: BaseCheckpointSaver, durability: Durability
) -> None:
config = _thread("t")
graph = _build_paused_before_b(async_checkpointer)
await graph.ainvoke(_both("in"), config, durability=durability)
await graph.ainvoke(
None, (await graph.aget_state(config)).config, durability=durability
)
state = await graph.aget_state(config)
assert state.next == (), f"resume paused again before {state.next}"
assert state.values["log"] == state.values["plain"] == ["in", "a", "b"]
def test_replay_from_a_paused_checkpoint_runs_the_node_once(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
config = _thread("t")
graph = _build_paused_before_b(sync_checkpointer)
graph.invoke(_both("in"), config)
paused = graph.get_state(config).config
graph.invoke(None, config)
graph.invoke(None, paused)
state = graph.get_state(config)
assert state.next == (), f"replay paused again before {state.next}"
assert state.values["log"] == state.values["plain"] == ["in", "a", "b"]
@pytest.mark.parametrize("addressed", [False, True])
def test_new_input_on_an_interrupted_head_does_not_replay_its_pending_writes(
sync_checkpointer: BaseCheckpointSaver, durability: Durability, addressed: bool
) -> None:
config = _thread("t")
graph = _build_parallel_interrupt(sync_checkpointer)
graph.invoke(_both("in-1"), config, durability=durability)
head = graph.get_state(config).config
graph.invoke(_both("in-2"), head if addressed else config, durability=durability)
state = graph.get_state(config)
assert state.values["log"] == state.values["plain"] == ["in-1", "in-2", "p"]
def _build_deferred_after_interrupt(checkpointer: BaseCheckpointSaver) -> Any:
builder = StateGraph(_State)
builder.add_node("a", lambda state: _both("a"))
builder.add_node("b", lambda state: _both("b"), defer=True)
builder.add_node("c", lambda state: {})
builder.add_edge(START, "a")
builder.add_edge("a", "b")
builder.add_edge("a", "c")
return builder.compile(checkpointer=checkpointer, interrupt_after=["a"])
@pytest.mark.parametrize(
"durability",
[
"sync",
"async",
pytest.param(
"exit",
marks=pytest.mark.xfail(
reason="exit durability stores a resumed run's loaded writes twice",
strict=True,
),
),
],
)
def test_resume_on_an_interrupted_head_consumes_its_writes_without_a_snapshot(
sync_checkpointer: BaseCheckpointSaver, durability: Durability
) -> None:
config = _thread("t")
graph = _build_parallel_interrupt(sync_checkpointer)
graph.invoke(_both("in-1"), config, durability=durability)
graph.invoke(Command(resume="yes"), config, durability=durability)
state = graph.get_state(config)
assert state.next == ()
assert state.values["log"] == state.values["plain"] == ["in-1", "p"]
assert not _snapshotted_checkpoints(sync_checkpointer, config)
def test_resume_addressed_at_an_interrupted_head_reruns_its_tasks_once(
sync_checkpointer: BaseCheckpointSaver, durability: Durability
) -> None:
config = _thread("t")
graph = _build_parallel_interrupt(sync_checkpointer)
graph.invoke(_both("in-1"), config, durability=durability)
graph.invoke(
Command(resume="yes"), graph.get_state(config).config, durability=durability
)
state = graph.get_state(config)
assert state.next == ()
assert state.values["log"] == state.values["plain"] == ["in-1", "p"]
def test_update_state_with_the_head_checkpoint_id_keeps_a_deferred_node(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
graph = _build_deferred_after_interrupt(sync_checkpointer)
config = _thread("t")
graph.invoke(_both("in"), config)
graph.update_state(graph.get_state(config).config, _both("u"), as_node="c")
graph.invoke(None, config)
state = graph.get_state(config)
assert state.next == (), f"deferred node never ran, still pending: {state.next}"
assert state.values["log"] == state.values["plain"] == ["in", "a", "u", "b"]
async def test_aupdate_state_with_the_head_checkpoint_id_keeps_a_deferred_node(
async_checkpointer: BaseCheckpointSaver,
) -> None:
graph = _build_deferred_after_interrupt(async_checkpointer)
config = _thread("t")
await graph.ainvoke(_both("in"), config)
await graph.aupdate_state(
(await graph.aget_state(config)).config, _both("u"), as_node="c"
)
await graph.ainvoke(None, config)
state = await graph.aget_state(config)
assert state.next == (), f"deferred node never ran, still pending: {state.next}"
assert state.values["log"] == state.values["plain"] == ["in", "a", "u", "b"]
def test_turns_addressed_at_the_head_store_no_snapshot(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
config = _thread("t")
graph = _build(sync_checkpointer, "turn")
graph.invoke(_both("in-1"), config)
for turn in range(2, 5):
graph.invoke(_both(f"in-{turn}"), graph.get_state(config).config)
assert not _snapshotted_checkpoints(sync_checkpointer, config)
assert (
graph.get_state(config).values["log"] == graph.get_state(config).values["plain"]
)
@@ -338,3 +338,29 @@ def test_state_history_chain_after_fresh_update_state_delta_channel() -> None:
assert update_snapshot.metadata["step"] == 0 assert update_snapshot.metadata["step"] == 0
assert update_snapshot.parent_config is None assert update_snapshot.parent_config is None
assert [m.content for m in update_snapshot.values["messages"]] == ["hello"] assert [m.content for m in update_snapshot.values["messages"]] == ["hello"]
def test_update_state_that_snapshots_keeps_a_deferred_node_pending() -> None:
channel = DeltaChannel(_messages_delta_reducer, snapshot_frequency=1)
class State(TypedDict):
messages: Annotated[list, channel]
builder = StateGraph(State)
builder.add_node("a", lambda state: {"messages": [HumanMessage("a", id="a")]})
builder.add_node(
"b", lambda state: {"messages": [HumanMessage("b", id="b")]}, defer=True
)
builder.add_node("c", lambda state: {})
builder.add_edge(START, "a")
builder.add_edge("a", "b")
builder.add_edge("a", "c")
graph = builder.compile(checkpointer=InMemorySaver(), interrupt_after=["a"])
config = {"configurable": {"thread_id": "t"}}
graph.invoke({"messages": [HumanMessage("s", id="s")]}, config)
graph.update_state(config, {"messages": [HumanMessage("u", id="u")]}, as_node="c")
final = graph.invoke(None, config)
assert [m.content for m in final["messages"]] == ["s", "a", "u", "b"]
assert graph.get_state(config).next == ()