Compare commits

..
67 changed files with 1036 additions and 2865 deletions
+3 -7
View File
@@ -58,18 +58,14 @@ jobs:
} >> "$GITHUB_OUTPUT"
- name: Annotation
uses: actions/github-script@v9
env:
CHANGED_FILES: ${{ steps.files.outputs.added_modified_renamed }}
BENCHMARK_OUTPUT: ${{ steps.benchmark.outputs.OUTPUT }}
COMPARE_OUTPUT: ${{ steps.compare.outputs.OUTPUT }}
with:
script: |
const file = JSON.parse(process.env.CHANGED_FILES || "[]")[0]
core.notice(process.env.BENCHMARK_OUTPUT || "", {
const file = JSON.parse(`${{ steps.files.outputs.added_modified_renamed }}`)[0]
core.notice(`${{ steps.benchmark.outputs.OUTPUT }}`, {
title: 'Benchmark results',
file,
})
core.notice(process.env.COMPARE_OUTPUT || "", {
core.notice(`${{ steps.compare.outputs.OUTPUT }}`, {
title: 'Comparison against main',
file,
})
-3
View File
@@ -32,7 +32,6 @@ jobs:
with:
python-version: ${{ env.PYTHON_VERSION }}
cache-suffix: "release"
enable-cache: false
working-directory: ${{ inputs.working-directory }}
# We want to keep this build stage *separate* from the release stage,
@@ -269,7 +268,6 @@ jobs:
with:
python-version: ${{ env.PYTHON_VERSION }}
cache-suffix: "release"
enable-cache: false
working-directory: ${{ inputs.working-directory }}
- uses: actions/download-artifact@v8
@@ -311,7 +309,6 @@ jobs:
with:
python-version: ${{ env.PYTHON_VERSION }}
cache-suffix: "release"
enable-cache: false
working-directory: ${{ inputs.working-directory }}
- uses: actions/download-artifact@v8
+2
View File
@@ -64,6 +64,8 @@ The suite tests **base** capabilities (required) and **extended** capabilities (
| `copy_thread` | no | `acopy_thread` |
| `prune` | no | `aprune` |
| `delta_channel_history` | no | `aget_delta_channel_history` |
| `delta_channel_keepset` | no | `aget_delta_channel_keepset` |
| `delta_channel_reconstruction` | no | `aput` |
Extended capabilities are detected by checking whether the method is overridden from `BaseCheckpointSaver`. If not overridden, those tests are skipped.
@@ -24,6 +24,8 @@ class Capability(str, Enum):
COPY_THREAD = "copy_thread"
PRUNE = "prune"
DELTA_CHANNEL_HISTORY = "delta_channel_history"
DELTA_CHANNEL_KEEPSET = "delta_channel_keepset"
DELTA_CHANNEL_RECONSTRUCTION = "delta_channel_reconstruction"
# Capabilities that every checkpointer must support.
@@ -44,6 +46,8 @@ EXTENDED_CAPABILITIES = frozenset(
Capability.COPY_THREAD,
Capability.PRUNE,
Capability.DELTA_CHANNEL_HISTORY,
Capability.DELTA_CHANNEL_KEEPSET,
Capability.DELTA_CHANNEL_RECONSTRUCTION,
}
)
@@ -60,6 +64,8 @@ _CAPABILITY_METHOD_MAP: dict[Capability, str] = {
Capability.COPY_THREAD: "acopy_thread",
Capability.PRUNE: "aprune",
Capability.DELTA_CHANNEL_HISTORY: "aget_delta_channel_history",
Capability.DELTA_CHANNEL_KEEPSET: "aget_tuple",
Capability.DELTA_CHANNEL_RECONSTRUCTION: "aput",
}
@@ -12,6 +12,12 @@ from langgraph.checkpoint.conformance.spec.test_delete_thread import (
from langgraph.checkpoint.conformance.spec.test_delta_channel_history import (
run_delta_channel_history_tests,
)
from langgraph.checkpoint.conformance.spec.test_delta_channel_keepset import (
run_delta_channel_keepset_tests,
)
from langgraph.checkpoint.conformance.spec.test_delta_channel_reconstruction import (
run_delta_channel_reconstruction_tests,
)
from langgraph.checkpoint.conformance.spec.test_get_tuple import run_get_tuple_tests
from langgraph.checkpoint.conformance.spec.test_list import run_list_tests
from langgraph.checkpoint.conformance.spec.test_prune import run_prune_tests
@@ -28,4 +34,6 @@ __all__ = [
"run_copy_thread_tests",
"run_prune_tests",
"run_delta_channel_history_tests",
"run_delta_channel_keepset_tests",
"run_delta_channel_reconstruction_tests",
]
@@ -0,0 +1,172 @@
"""DELTA_CHANNEL_KEEPSET capability tests — aget_delta_channel_keepset contract."""
from __future__ import annotations
import traceback
from collections.abc import Callable
from uuid import uuid4
from langgraph.checkpoint.base import BaseCheckpointSaver
from langgraph.checkpoint.conformance.spec._delta_fixtures import build_delta_chain
async def test_keepset_empty_channels_returns_target_only(
saver: BaseCheckpointSaver,
) -> None:
"""Empty channels → keep-set is just {target_id}."""
tid = str(uuid4())
configs = await build_delta_chain(
saver, thread_id=tid, channel="ch", snapshots_at_steps=[0], total_steps=4
)
head = configs[-1]
keep = await saver.aget_delta_channel_keepset(config=head, channels=[])
head_id = head["configurable"]["checkpoint_id"]
assert keep == {head_id}, f"Expected only target, got {keep}"
async def test_keepset_snapshot_at_target(
saver: BaseCheckpointSaver,
) -> None:
"""When target itself has a snapshot, keep-set is just {target_id}."""
tid = str(uuid4())
configs = await build_delta_chain(
saver,
thread_id=tid,
channel="ch",
snapshots_at_steps=[0, 3],
total_steps=4,
)
head = configs[3]
keep = await saver.aget_delta_channel_keepset(config=head, channels=["ch"])
head_id = head["configurable"]["checkpoint_id"]
assert keep == {head_id}, f"Snapshot at target should yield only target, got {keep}"
async def test_keepset_snapshot_n_back(
saver: BaseCheckpointSaver,
) -> None:
"""Snapshot N steps back → target + intermediates + snapshot ancestor."""
tid = str(uuid4())
configs = await build_delta_chain(
saver, thread_id=tid, channel="ch", snapshots_at_steps=[0], total_steps=5
)
head = configs[-1]
keep = await saver.aget_delta_channel_keepset(config=head, channels=["ch"])
expected_ids = {c["configurable"]["checkpoint_id"] for c in configs}
assert keep == expected_ids, f"Expected all ancestors, got {keep}"
async def test_keepset_multi_channel_union(
saver: BaseCheckpointSaver,
) -> None:
"""Multi-channel keep-set is the union (max chain per channel)."""
tid = str(uuid4())
from langgraph.checkpoint.base import Checkpoint
from langgraph.checkpoint.base.id import uuid6
from langgraph.checkpoint.serde.types import _DeltaSnapshot
from langgraph.checkpoint.conformance.test_utils import generate_metadata
configs: list = []
parent_cfg = None
for step in range(6):
config = {"configurable": {"thread_id": tid, "checkpoint_ns": ""}}
if parent_cfg:
config["configurable"]["checkpoint_id"] = parent_cfg["configurable"][
"checkpoint_id"
]
cv: dict = {}
cvs: dict = {}
# Channel "a" has snapshot at step 3 (recent)
if step == 3:
cv["a"] = _DeltaSnapshot("snap_a")
cvs["a"] = step + 1
# Channel "b" has snapshot at step 1 (further back)
if step == 1:
cv["b"] = _DeltaSnapshot("snap_b")
cvs["b"] = step + 1
cp = Checkpoint(
v=1,
id=str(uuid6(clock_seq=-1)),
ts="",
channel_values=cv,
channel_versions=cvs,
versions_seen={},
updated_channels=None,
)
parent_cfg = await saver.aput(config, cp, generate_metadata(step=step), cvs)
configs.append(parent_cfg)
head = configs[-1]
keep = await saver.aget_delta_channel_keepset(config=head, channels=["a", "b"])
# Union: b needs back to step 1, so steps 1..5 (all except step 0) plus head
expected_ids = {c["configurable"]["checkpoint_id"] for c in configs[1:]}
expected_ids.add(head["configurable"]["checkpoint_id"])
assert keep == expected_ids, f"Expected union, got {keep} vs {expected_ids}"
async def test_keepset_walk_to_root(
saver: BaseCheckpointSaver,
) -> None:
"""No snapshot anywhere → entire chain to root is in keep-set."""
tid = str(uuid4())
configs = await build_delta_chain(
saver, thread_id=tid, channel="ch", snapshots_at_steps=[], total_steps=4
)
head = configs[-1]
keep = await saver.aget_delta_channel_keepset(config=head, channels=["ch"])
all_ids = {c["configurable"]["checkpoint_id"] for c in configs}
assert keep == all_ids, f"Expected full chain, got {keep}"
async def test_keepset_deterministic(
saver: BaseCheckpointSaver,
) -> None:
"""Same inputs return identical sets."""
tid = str(uuid4())
configs = await build_delta_chain(
saver, thread_id=tid, channel="ch", snapshots_at_steps=[0], total_steps=5
)
head = configs[-1]
keep1 = await saver.aget_delta_channel_keepset(config=head, channels=["ch"])
keep2 = await saver.aget_delta_channel_keepset(config=head, channels=["ch"])
assert keep1 == keep2
ALL_DELTA_CHANNEL_KEEPSET_TESTS = [
test_keepset_empty_channels_returns_target_only,
test_keepset_snapshot_at_target,
test_keepset_snapshot_n_back,
test_keepset_multi_channel_union,
test_keepset_walk_to_root,
test_keepset_deterministic,
]
async def run_delta_channel_keepset_tests(
saver: BaseCheckpointSaver,
on_test_result: Callable[[str, str, bool, str | None], None] | None = None,
) -> tuple[int, int, list[str]]:
"""Run all delta_channel_keepset tests. Returns (passed, failed, failure_names)."""
passed = 0
failed = 0
failures: list[str] = []
for test_fn in ALL_DELTA_CHANNEL_KEEPSET_TESTS:
try:
await test_fn(saver)
passed += 1
if on_test_result:
on_test_result("delta_channel_keepset", test_fn.__name__, True, None)
except Exception:
failed += 1
msg = f"{test_fn.__name__}: {traceback.format_exc()}"
failures.append(msg)
if on_test_result:
on_test_result(
"delta_channel_keepset",
test_fn.__name__,
False,
traceback.format_exc(),
)
return passed, failed, failures
@@ -0,0 +1,164 @@
"""DELTA_CHANNEL_RECONSTRUCTION capability tests — end-to-end round-trip.
Exercises: aput + aput_writes + aget_delta_channel_history + reconstruction.
This catches the most common silent-corruption mode: failing to round-trip
`_DeltaSnapshot` blobs through serialization.
NOTE: This test does NOT import from `langgraph` (which is not a dependency
of checkpoint-conformance). Instead it inlines a minimal reconstruction
equivalent: seed + fold writes through a simple list-append reducer.
"""
from __future__ import annotations
import traceback
from collections.abc import Callable
from typing import Any
from uuid import uuid4
from langgraph.checkpoint.base import BaseCheckpointSaver
from langgraph.checkpoint.conformance.spec._delta_fixtures import build_delta_chain
def _reconstruct(seed: Any, writes: list) -> list:
"""Minimal DeltaChannel reconstruction: list-append reducer.
Mirrors DeltaChannel.from_checkpoint(seed) + replay_writes(writes).
"""
from langgraph.checkpoint.serde.types import _DeltaSnapshot
if seed is None:
base: list = []
elif isinstance(seed, _DeltaSnapshot):
base = list(seed.value)
else:
base = list(seed)
for _task_id, _ch, value in writes:
base = base + value
return base
async def test_reconstruction_basic(
saver: BaseCheckpointSaver,
) -> None:
"""Reconstruct delta channel value from history matches expected."""
tid = str(uuid4())
# 5 steps: snapshot at 0 (value=[0]), writes at 1,2,3,4.
# Head = step 4. Walk from parent (step 3) collects writes 1,2,3.
configs = await build_delta_chain(
saver,
thread_id=tid,
channel="msgs",
snapshots_at_steps=[0],
total_steps=5,
write_value_fn=lambda step: [step],
)
head = configs[-1]
result = await saver.aget_delta_channel_history(config=head, channels=["msgs"])
history = result["msgs"]
seed = history.get("seed")
reconstructed = _reconstruct(seed, history["writes"])
# seed=[0] from step 0, writes from steps 1,2,3
expected = [0] + [1] + [2] + [3]
assert reconstructed == expected, (
f"Reconstructed {reconstructed} != expected {expected}"
)
async def test_reconstruction_mid_chain_snapshot(
saver: BaseCheckpointSaver,
) -> None:
"""Reconstruction works when snapshot is mid-chain."""
tid = str(uuid4())
# 6 steps: snapshots at 0 and 3, writes at 1,2,4,5.
# Head = step 5. Walk from step 4 stops at step 3 (snapshot).
# Collects writes from step 4.
configs = await build_delta_chain(
saver,
thread_id=tid,
channel="msgs",
snapshots_at_steps=[0, 3],
total_steps=6,
write_value_fn=lambda step: [step],
)
head = configs[-1]
result = await saver.aget_delta_channel_history(config=head, channels=["msgs"])
history = result["msgs"]
seed = history.get("seed")
reconstructed = _reconstruct(seed, history["writes"])
# Snapshot at step 3 = [3], write from step 4
expected = [3] + [4]
assert reconstructed == expected, (
f"Reconstructed {reconstructed} != expected {expected}"
)
async def test_reconstruction_no_snapshot(
saver: BaseCheckpointSaver,
) -> None:
"""Reconstruction from root (no snapshot) gives all writes accumulated."""
tid = str(uuid4())
# 4 steps: no snapshot, writes at 0,1,2,3.
# Head = step 3. Walk from step 2 collects writes 0,1,2.
configs = await build_delta_chain(
saver,
thread_id=tid,
channel="msgs",
snapshots_at_steps=[],
total_steps=4,
write_value_fn=lambda step: [step],
)
head = configs[-1]
result = await saver.aget_delta_channel_history(config=head, channels=["msgs"])
history = result["msgs"]
seed = history.get("seed")
reconstructed = _reconstruct(seed, history["writes"])
# No seed → start empty, writes from steps 0,1,2
expected = [0] + [1] + [2]
assert reconstructed == expected, (
f"Reconstructed {reconstructed} != expected {expected}"
)
ALL_DELTA_CHANNEL_RECONSTRUCTION_TESTS = [
test_reconstruction_basic,
test_reconstruction_mid_chain_snapshot,
test_reconstruction_no_snapshot,
]
async def run_delta_channel_reconstruction_tests(
saver: BaseCheckpointSaver,
on_test_result: Callable[[str, str, bool, str | None], None] | None = None,
) -> tuple[int, int, list[str]]:
"""Run all reconstruction tests. Returns (passed, failed, failure_names)."""
passed = 0
failed = 0
failures: list[str] = []
for test_fn in ALL_DELTA_CHANNEL_RECONSTRUCTION_TESTS:
try:
await test_fn(saver)
passed += 1
if on_test_result:
on_test_result(
"delta_channel_reconstruction", test_fn.__name__, True, None
)
except Exception:
failed += 1
msg = f"{test_fn.__name__}: {traceback.format_exc()}"
failures.append(msg)
if on_test_result:
on_test_result(
"delta_channel_reconstruction",
test_fn.__name__,
False,
traceback.format_exc(),
)
return passed, failed, failures
@@ -22,6 +22,12 @@ from langgraph.checkpoint.conformance.spec.test_delete_thread import (
from langgraph.checkpoint.conformance.spec.test_delta_channel_history import (
run_delta_channel_history_tests,
)
from langgraph.checkpoint.conformance.spec.test_delta_channel_keepset import (
run_delta_channel_keepset_tests,
)
from langgraph.checkpoint.conformance.spec.test_delta_channel_reconstruction import (
run_delta_channel_reconstruction_tests,
)
from langgraph.checkpoint.conformance.spec.test_get_tuple import run_get_tuple_tests
from langgraph.checkpoint.conformance.spec.test_list import run_list_tests
from langgraph.checkpoint.conformance.spec.test_prune import run_prune_tests
@@ -39,6 +45,8 @@ _RUNNERS = {
Capability.COPY_THREAD: run_copy_thread_tests,
Capability.PRUNE: run_prune_tests,
Capability.DELTA_CHANNEL_HISTORY: run_delta_channel_history_tests,
Capability.DELTA_CHANNEL_KEEPSET: run_delta_channel_keepset_tests,
Capability.DELTA_CHANNEL_RECONSTRUCTION: run_delta_channel_reconstruction_tests,
}
@@ -50,6 +50,7 @@ unresolved-attribute = "ignore"
unresolved-import = "ignore"
invalid-argument-type = "ignore"
invalid-return-type = "ignore"
missing-typed-dict-key = "ignore"
[tool.ruff]
lint.select = [
+4 -4
View File
@@ -208,11 +208,11 @@ wheels = [
[[package]]
name = "idna"
version = "3.15"
version = "3.13"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
{ url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" },
]
[[package]]
@@ -279,7 +279,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
version = "4.1.1"
version = "4.1.0a4"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph-checkpoint-postgres"
version = "3.1.0"
version = "3.1.0a4"
description = "Library with a Postgres implementation of LangGraph checkpoint saver."
authors = []
requires-python = ">=3.10"
@@ -12,7 +12,7 @@ readme = "README.md"
license = "MIT"
license-files = ['LICENSE']
dependencies = [
"langgraph-checkpoint>=4.1.0,<5.0.0",
"langgraph-checkpoint>=4.1.0a4,<5.0.0",
"orjson>=3.11.5",
"psycopg>=3.2.0",
"psycopg-pool>=3.2.0",
+14 -14
View File
@@ -205,11 +205,11 @@ wheels = [
[[package]]
name = "idna"
version = "3.15"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
@@ -244,7 +244,7 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.3.3"
version = "1.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonpatch" },
@@ -257,9 +257,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "uuid-utils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d3/ae/8b74458fc3850ec3d150eb9f45e857db129dafa801fb5cf173dfc9f8bbf3/langchain_core-1.3.3.tar.gz", hash = "sha256:fa510a5db8efdc0c6ff41c0939fb5c00a0183c11f6b84233e892e3227ff69182", size = 915041, upload-time = "2026-05-05T19:02:36.612Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a8/03/7219502e8ca728d65eb44d7a3eb60239230742a70dbfc9241b9bfd61c4ab/langchain_core-1.3.2.tar.gz", hash = "sha256:fd7a50b2f28ba561fd9d7f5d2760bc9e06cf00cdf820a3ccafe88a94ffa8d5b7", size = 911813, upload-time = "2026-04-24T15:49:23.699Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/01/4771b7ab2af1d1aba5b710bd8f13d9225c609425214b357590a17b01be77/langchain_core-1.3.3-py3-none-any.whl", hash = "sha256:18aae8506f37da7f74398492279a7d6efcee4f8e23c4c41c7af080eeb7ef7bd1", size = 543857, upload-time = "2026-05-05T19:02:34.52Z" },
{ url = "https://files.pythonhosted.org/packages/7d/d5/8fa4431007cbb7cfed7590f4d6a5dea3ad724f4174d248f6642ef5ce7d05/langchain_core-1.3.2-py3-none-any.whl", hash = "sha256:d44a66127f9f8db735bdfd0ab9661bccb47a97113cfd3f2d89c74864422b7274", size = 542390, upload-time = "2026-04-24T15:49:21.991Z" },
]
[[package]]
@@ -276,7 +276,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
version = "4.1.1"
version = "4.1.0a4"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
@@ -324,7 +324,7 @@ test = [
[[package]]
name = "langgraph-checkpoint-postgres"
version = "3.1.0"
version = "3.1.0a4"
source = { editable = "." }
dependencies = [
{ name = "langgraph-checkpoint" },
@@ -399,7 +399,7 @@ test = [
[[package]]
name = "langsmith"
version = "0.8.0"
version = "0.7.31"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
@@ -412,9 +412,9 @@ dependencies = [
{ name = "xxhash" },
{ name = "zstandard" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a8/64/95f1f013531395f4e8ed73caeee780f65c7c58fe028cb543f8937b45611b/langsmith-0.8.0.tar.gz", hash = "sha256:59fe5b2a56bbbe14a08aa76691f84b49e8675dd21e11b57d80c6db8c08bac2e3", size = 4432996, upload-time = "2026-04-30T22:13:07.341Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/e1/a4be2e696c9473bb53298df398237da5674704d781d4b748ed35aeef592a/langsmith-0.8.0-py3-none-any.whl", hash = "sha256:12cc4bc5622b835a6d841964d6034df3617bdb912dae0c1381fd0a68a9b3a3ef", size = 393268, upload-time = "2026-04-30T22:13:05.56Z" },
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
]
[[package]]
@@ -1234,11 +1234,11 @@ wheels = [
[[package]]
name = "urllib3"
version = "2.7.0"
version = "2.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
[[package]]
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph-checkpoint-sqlite"
version = "3.1.0"
version = "3.1.0a1"
description = "Library with a SQLite implementation of LangGraph checkpoint saver."
authors = []
requires-python = ">=3.10"
@@ -12,7 +12,7 @@ readme = "README.md"
license = "MIT"
license-files = ['LICENSE']
dependencies = [
"langgraph-checkpoint>=4.1.0,<5.0.0",
"langgraph-checkpoint>=4.1.0a4,<5.0.0",
"aiosqlite>=0.20",
"sqlite-vec>=0.1.6",
]
@@ -27,6 +27,8 @@ async def test_delta_channel_conformance():
sqlite_saver,
capabilities={
"delta_channel_history",
"delta_channel_keepset",
"delta_channel_reconstruction",
},
)
for cap, result in report.results.items():
+14 -27
View File
@@ -214,11 +214,11 @@ wheels = [
[[package]]
name = "idna"
version = "3.15"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
@@ -253,11 +253,10 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.3.3"
version = "1.2.28"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonpatch" },
{ name = "langchain-protocol" },
{ name = "langsmith" },
{ name = "packaging" },
{ name = "pydantic" },
@@ -266,26 +265,14 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "uuid-utils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d3/ae/8b74458fc3850ec3d150eb9f45e857db129dafa801fb5cf173dfc9f8bbf3/langchain_core-1.3.3.tar.gz", hash = "sha256:fa510a5db8efdc0c6ff41c0939fb5c00a0183c11f6b84233e892e3227ff69182", size = 915041, upload-time = "2026-05-05T19:02:36.612Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/a4/317a1a3ac1df33a64adb3670bf88bbe3b3d5baa274db6863a979db472897/langchain_core-1.2.28.tar.gz", hash = "sha256:271a3d8bd618f795fdeba112b0753980457fc90537c46a0c11998516a74dc2cb", size = 846119, upload-time = "2026-04-08T18:19:34.867Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/01/4771b7ab2af1d1aba5b710bd8f13d9225c609425214b357590a17b01be77/langchain_core-1.3.3-py3-none-any.whl", hash = "sha256:18aae8506f37da7f74398492279a7d6efcee4f8e23c4c41c7af080eeb7ef7bd1", size = 543857, upload-time = "2026-05-05T19:02:34.52Z" },
]
[[package]]
name = "langchain-protocol"
version = "0.0.15"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4f/24/9777489d6fbbee64af0c8f96d4f840239c408cf694f3394672807dafc490/langchain_protocol-0.0.15.tar.gz", hash = "sha256:9ab2d11ee73944754f10e037e717098d3a6796f0e58afa9cadda6154e7655ade", size = 5862, upload-time = "2026-05-01T22:30:04.748Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/7a/9c97a7b9cbe4c5dc6a44cdb1545450c28f0c8ce89b9c1f0ee7fbad896263/langchain_protocol-0.0.15-py3-none-any.whl", hash = "sha256:461eb794358f83d5e42635a5797799ffec7b4702314e34edf73ac21e75d3ef79", size = 6982, upload-time = "2026-05-01T22:30:03.877Z" },
{ url = "https://files.pythonhosted.org/packages/a8/92/32f785f077c7e898da97064f113c73fbd9ad55d1e2169cf3a391b183dedb/langchain_core-1.2.28-py3-none-any.whl", hash = "sha256:80764232581eaf8057bcefa71dbf8adc1f6a28d257ebd8b95ba9b8b452e8c6ac", size = 508727, upload-time = "2026-04-08T18:19:32.823Z" },
]
[[package]]
name = "langgraph-checkpoint"
version = "4.1.1"
version = "4.1.0a4"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
@@ -333,7 +320,7 @@ test = [
[[package]]
name = "langgraph-checkpoint-sqlite"
version = "3.1.0"
version = "3.1.0a1"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },
@@ -402,7 +389,7 @@ test = [
[[package]]
name = "langsmith"
version = "0.8.0"
version = "0.7.31"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
@@ -415,9 +402,9 @@ dependencies = [
{ name = "xxhash" },
{ name = "zstandard" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a8/64/95f1f013531395f4e8ed73caeee780f65c7c58fe028cb543f8937b45611b/langsmith-0.8.0.tar.gz", hash = "sha256:59fe5b2a56bbbe14a08aa76691f84b49e8675dd21e11b57d80c6db8c08bac2e3", size = 4432996, upload-time = "2026-04-30T22:13:07.341Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/e1/a4be2e696c9473bb53298df398237da5674704d781d4b748ed35aeef592a/langsmith-0.8.0-py3-none-any.whl", hash = "sha256:12cc4bc5622b835a6d841964d6034df3617bdb912dae0c1381fd0a68a9b3a3ef", size = 393268, upload-time = "2026-04-30T22:13:05.56Z" },
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
]
[[package]]
@@ -1161,11 +1148,11 @@ wheels = [
[[package]]
name = "urllib3"
version = "2.7.0"
version = "2.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
[[package]]
@@ -60,29 +60,20 @@ class CheckpointMetadata(TypedDict, total=False):
"""
run_id: str
"""The ID of the run that created this checkpoint."""
counters_since_delta_snapshot: dict[str, tuple[int, int]]
"""Per-channel counters since the last `_DeltaSnapshot` was written.
delta_updates_since_snapshot: dict[str, int]
"""Per-channel update count since the last `_DeltaSnapshot` was written.
!!! warning "Beta"
This metadata field backs `DeltaChannel` (beta). The key name and
contents may change while the delta-channel design stabilizes.
Maps channel name -> `(updates, supersteps)`:
- index 0 (`updates`): number of supersteps that wrote to this channel
since its last snapshot blob.
- index 1 (`supersteps`): total supersteps elapsed since this channel's
last snapshot, regardless of whether the channel was written.
A snapshot fires when EITHER `updates >= ch.snapshot_frequency` OR
`supersteps >= DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT` (system-wide bound,
default 5000, env `LANGGRAPH_DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT`).
The supersteps bound prevents unbounded ancestor walks on threads where
a delta channel exists but is no longer being updated.
Absent on threads that don't use delta channels. Persisted as a
2-element list in JSON (no native tuple).
Maps channel name → number of supersteps that wrote to this channel
since its last snapshot blob. Used by `pregel.create_checkpoint` to
decide when to write the next snapshot (when the count reaches the
channel's `snapshot_frequency`, snapshot fires and the count resets
to 0). Absent on threads that don't use delta channels. Version-format
independent — works for int, float, and string version schemes.
"""
@@ -689,6 +680,123 @@ class BaseCheckpointSaver(Generic[V]):
result[ch] = entry
return result
def get_delta_channel_keepset(
self,
*,
config: RunnableConfig,
channels: Sequence[str],
) -> set[str]:
"""Return ancestor checkpoint_ids that must survive deletion.
!!! warning "Beta"
This method is part of the `DeltaChannel` support surface and is
in beta. The signature may change while the delta-channel design
stabilizes.
Walks the parent chain from `config` backward, collecting visited
checkpoint_ids (inclusive of the target), and terminates per-channel
when that channel has a populated `channel_values[ch]` (a
`_DeltaSnapshot` blob or a pre-migration plain value). The returned
set is the minimum keep-set: every checkpoint_id whose removal would
break reconstruction of the listed channels at `config`.
Pass `channels=[]` to return just `{config.checkpoint_id}` — useful
for graphs that don't use `DeltaChannel`.
Compose this into custom `prune` / `delete_for_runs` / `copy_thread`::
keep = saver.get_delta_channel_keepset(
config=head_config, channels=delta_channels,
)
delete_rows_not_in(keep)
Note:
The default implementation here uses repeated `get_tuple` calls
to walk the parent chain. This is a basic reference implementation
suitable for low-frequency maintenance operations (prune, etc.).
Custom checkpointer backends may override with a more efficient
version tailored to their data model (e.g. a single SQL query
with a recursive CTE), but it is not required — the default works
correctly for any saver that implements `get_tuple`.
Args:
config: Configuration identifying the target checkpoint.
channels: Channel names whose delta history must be preserved.
Empty sequence means only the target checkpoint_id is kept.
Returns:
Set of checkpoint_ids that must not be deleted.
"""
target_tuple = self.get_tuple(config)
if target_tuple is None:
return set()
target_id = target_tuple.config["configurable"]["checkpoint_id"]
keep: set[str] = {target_id}
if not channels:
return keep
remaining: set[str] = set(channels)
for ch in list(remaining):
if ch in target_tuple.checkpoint["channel_values"]:
remaining.discard(ch)
if not remaining:
return keep
cursor_config: RunnableConfig | None = target_tuple.parent_config
while cursor_config is not None and remaining:
tup = self.get_tuple(cursor_config)
if tup is None:
break
cid = tup.config["configurable"]["checkpoint_id"]
keep.add(cid)
for ch in list(remaining):
if ch in tup.checkpoint["channel_values"]:
remaining.discard(ch)
if not remaining:
break
cursor_config = tup.parent_config
return keep
async def aget_delta_channel_keepset(
self,
*,
config: RunnableConfig,
channels: Sequence[str],
) -> set[str]:
"""Async version of `get_delta_channel_keepset`.
!!! warning "Beta"
This method is part of the `DeltaChannel` support surface and is
in beta. See `get_delta_channel_keepset` for full documentation.
"""
target_tuple = await self.aget_tuple(config)
if target_tuple is None:
return set()
target_id = target_tuple.config["configurable"]["checkpoint_id"]
keep: set[str] = {target_id}
if not channels:
return keep
remaining: set[str] = set(channels)
for ch in list(remaining):
if ch in target_tuple.checkpoint["channel_values"]:
remaining.discard(ch)
if not remaining:
return keep
cursor_config: RunnableConfig | None = target_tuple.parent_config
while cursor_config is not None and remaining:
tup = await self.aget_tuple(cursor_config)
if tup is None:
break
cid = tup.config["configurable"]["checkpoint_id"]
keep.add(cid)
for ch in list(remaining):
if ch in tup.checkpoint["channel_values"]:
remaining.discard(ch)
if not remaining:
break
cursor_config = tup.parent_config
return keep
def get_next_version(self, current: V | None, channel: None) -> V:
"""Generate the next version ID for a channel.
@@ -44,7 +44,7 @@ if TYPE_CHECKING:
AllowedMsgpackModules,
)
LC_REVIVER = Reviver(allowed_objects="core")
LC_REVIVER = Reviver()
EMPTY_BYTES = b""
logger = logging.getLogger(__name__)
@@ -157,6 +157,27 @@ class JsonPlusSerializer(SerializerProtocol):
)
return clone
def _encode_constructor_args(
self,
constructor: Callable | type[Any],
*,
method: None | str | Sequence[None | str] = None,
args: Sequence[Any] | None = None,
kwargs: dict[str, Any] | None = None,
) -> dict[str, Any]:
out = {
"lc": 2,
"type": "constructor",
"id": (*constructor.__module__.split("."), constructor.__name__),
}
if method is not None:
out["method"] = method
if args is not None:
out["args"] = args
if kwargs is not None:
out["kwargs"] = kwargs
return out
def _reviver(self, value: dict[str, Any]) -> Any:
if (
value.get("lc", None) == 2
@@ -182,46 +203,45 @@ class JsonPlusSerializer(SerializerProtocol):
self._check_allowed_json_modules(value)
[*module, name] = value["id"]
# The `method` field on lc:2 envelopes is intentionally ignored.
# Revival is restricted to the default constructor (no `getattr`
# dispatch on attacker-influenced names). The framework's own
# encoder has not emitted `method=` since the msgpack migration,
# and the only legacy emission was `method=(None, "construct")`
# for pydantic models, where the first entry (`None`) already
# meant "default constructor", which is what we do here.
try:
mod = importlib.import_module(".".join(module))
cls = getattr(mod, name)
if isclass(cls) and issubclass(cls, BaseException):
return None
method = value.get("method")
if isinstance(method, str):
methods = [getattr(cls, method)]
elif isinstance(method, list):
methods = [cls if m is None else getattr(cls, m) for m in method]
else:
methods = [cls]
args = value.get("args")
kwargs = value.get("kwargs")
if args and kwargs:
return cls(*args, **kwargs)
elif args:
return cls(*args)
elif kwargs:
return cls(**kwargs)
else:
return cls()
except Exception as exc:
# Method-field dispatch has been removed (GHSA-fjqc-hq36-qh5p), so
# legacy pydantic payloads emitting `method=[None, "construct"]`
# no longer fall back to `cls.construct(**kwargs)` when the
# default constructor rejects the serialized kwargs. Surface a
# one-line warning so operators can spot payloads that now revive
# to `None` instead of silently degrading to the raw envelope.
logger.warning(
"Failed to revive lc:2 envelope %s "
"(legacy_method_field=%s, error=%s); returning None",
".".join((*module, name)),
"method" in value,
type(exc).__name__,
)
for method in methods:
try:
if isclass(method) and issubclass(method, BaseException):
return None
if args and kwargs:
return method(*args, **kwargs)
elif args:
return method(*args)
elif kwargs:
return method(**kwargs)
else:
return method()
except Exception:
continue
except Exception:
return None
def _check_allowed_json_modules(self, value: dict[str, Any]) -> None:
needed = tuple(value["id"])
method = value.get("method")
if isinstance(method, list):
method_display = ",".join(m or "<init>" for m in method)
elif isinstance(method, str):
method_display = method
else:
method_display = "<init>"
dotted = ".".join(needed)
# Safe types (the same set already allowed for msgpack deserialization) are
# permitted without an explicit allowlist — they are known-safe LangGraph and
@@ -232,7 +252,7 @@ class JsonPlusSerializer(SerializerProtocol):
if not self._allowed_json_modules:
raise InvalidModuleError(
f"Refused to deserialize JSON constructor: {dotted}. "
f"Refused to deserialize JSON constructor: {dotted} (method: {method_display}). "
"No allowed_json_modules configured.\n\n"
"Unblock with ONE of:\n"
f" • JsonPlusSerializer(allowed_json_modules=[{needed!r}, ...])\n"
@@ -247,7 +267,7 @@ class JsonPlusSerializer(SerializerProtocol):
return
raise InvalidModuleError(
f"Refused to deserialize JSON constructor: {dotted}. "
f"Refused to deserialize JSON constructor: {dotted} (method: {method_display}). "
"Symbol is not in the deserialization allowlist.\n\n"
"Add exactly this symbol to unblock:\n"
f" JsonPlusSerializer(allowed_json_modules=[{needed!r}, ...])\n"
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph-checkpoint"
version = "4.1.1"
version = "4.1.0a4"
description = "Library with base interfaces for LangGraph checkpoint savers."
authors = []
requires-python = ">=3.10"
@@ -25,6 +25,8 @@ async def test_delta_channel_conformance():
mem_saver,
capabilities={
"delta_channel_history",
"delta_channel_keepset",
"delta_channel_reconstruction",
},
)
for cap, result in report.results.items():
-187
View File
@@ -398,193 +398,6 @@ def test_deserde_invalid_module() -> None:
serde.loads_typed(("json", json.dumps(load).encode("utf-8")))
def test_lc2_json_method_field_is_ignored() -> None:
"""The `method` field on lc=2 envelopes is ignored.
Regression test for GHSA-fjqc-hq36-qh5p: `_revive_lc2` previously resolved
`getattr(cls, method)` from the envelope, which let an attacker pivot a
safe pydantic class (e.g., AIMessage) to `parse_raw(..., allow_pickle=True)`
and reach `pickle.loads`. Revival now uses only the default constructor.
Verifies that an envelope carrying ``method="parse_raw"`` does not dispatch
to that method: the result is whatever ``AIMessage(*args, **kwargs)`` would
produce, which proves the default constructor ran instead of ``parse_raw``.
"""
from langchain_core.messages import AIMessage
serde = JsonPlusSerializer()
load = {
"lc": 2,
"type": "constructor",
"id": ["langchain_core", "messages", "ai", "AIMessage"],
"method": "parse_raw",
"args": ["default-ctor-ran"],
"kwargs": {"content_type": "application/pickle", "allow_pickle": True},
}
result = serde._revive_lc2(load)
# Default constructor accepts `content` as first positional arg. If parse_raw
# had been invoked instead, it would have attempted JSON/pickle parsing and
# raised (or executed the pickle gadget); neither would produce this result.
assert isinstance(result, AIMessage)
assert result.content == "default-ctor-ran"
def test_lc2_json_method_field_is_ignored_for_allowlisted_types() -> None:
"""The `method` field is ignored even when the class is explicitly allowlisted.
A user who configures ``allowed_json_modules`` for a class no longer gets
method dispatch as a side effect. Revival is restricted to the default
constructor regardless of how the class reached the revival path.
"""
from langchain_core.messages import AIMessage
serde = JsonPlusSerializer(
allowed_json_modules=[("langchain_core.messages.ai", "AIMessage")]
)
load = {
"lc": 2,
"type": "constructor",
"id": ["langchain_core", "messages", "ai", "AIMessage"],
"method": "parse_raw",
"args": ["default-ctor-ran"],
}
result = serde._revive_lc2(load)
assert isinstance(result, AIMessage)
assert result.content == "default-ctor-ran"
def test_lc2_json_safe_type_init_still_works() -> None:
"""SAFE-type lc=2 revival without a `method` field still constructs the class."""
from langchain_core.messages import AIMessage
serde = JsonPlusSerializer()
load = {
"lc": 2,
"type": "constructor",
"id": ["langchain_core", "messages", "ai", "AIMessage"],
"kwargs": {"content": "hi", "type": "ai"},
}
result = serde._revive_lc2(load)
assert isinstance(result, AIMessage)
assert result.content == "hi"
def test_lc2_json_legacy_pydantic_method_list_falls_back_to_default() -> None:
"""Legacy ``method=(None, "construct")`` envelopes still revive via the default ctor.
Pre-October-2025 langgraph emitted pydantic models with
``method=(None, "construct")`` meaning "try default constructor, fall back
to pydantic ``construct``". The first entry (``None``) was always the
default constructor, which is what we now do unconditionally. Envelopes of
this shape continue to revive correctly as long as the default constructor
accepts the serialized kwargs.
"""
from langchain_core.messages import AIMessage
serde = JsonPlusSerializer()
load = {
"lc": 2,
"type": "constructor",
"id": ["langchain_core", "messages", "ai", "AIMessage"],
"method": [None, "construct"],
"kwargs": {"content": "legacy", "type": "ai"},
}
result = serde._revive_lc2(load)
assert isinstance(result, AIMessage)
assert result.content == "legacy"
def test_lc2_json_legacy_construct_payload_logs_warning_when_default_init_rejects(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Legacy `method=[None, "construct"]` envelopes whose kwargs the default
`__init__` rejects now revive to `None` and emit an observable warning.
Pre-October-2025 langgraph emitted pydantic payloads with
`method=[None, "construct"]` so the reviver could fall back to
`cls.construct(**kwargs)` when the default constructor raised a
validation error. That fallback was removed with method-field dispatch
(GHSA-fjqc-hq36-qh5p), so these payloads now silently fail validation. A
`logger.warning` makes the regression observable to operators instead of
letting the envelope quietly degrade to its raw-dict form.
"""
serde = JsonPlusSerializer()
load = {
"lc": 2,
"type": "constructor",
"id": ["langchain_core", "messages", "ai", "AIMessage"],
# Legacy two-entry method tuple: try default ctor, fall back to construct.
"method": [None, "construct"],
# ``type="not-a-real-message-type"`` fails AIMessage's Literal["ai"]
# validator under the default constructor. Before the GHSA patch this
# would have fallen back to ``cls.construct(**kwargs)``; now it must
# return None and log.
"kwargs": {"content": "legacy", "type": "not-a-real-message-type"},
}
with caplog.at_level(logging.WARNING, logger="langgraph.checkpoint.serde.jsonplus"):
result = serde._revive_lc2(load)
assert result is None, (
"Legacy method=[None, 'construct'] payloads with kwargs the default "
"ctor rejects must now return None (no construct() fallback)."
)
matching = [
r
for r in caplog.records
if r.name == "langgraph.checkpoint.serde.jsonplus"
and r.levelno == logging.WARNING
and "langchain_core.messages.ai.AIMessage" in r.getMessage()
and "legacy_method_field=True" in r.getMessage()
]
assert matching, (
"Expected a WARNING from langgraph.checkpoint.serde.jsonplus "
"referencing the class id and legacy_method_field=True; "
f"got records: {[(r.name, r.levelname, r.getMessage()) for r in caplog.records]}"
)
def test_lc2_json_safe_type_pickle_payload_does_not_execute() -> None:
"""End-to-end: a `parse_raw` pickle gadget payload on a SAFE type must not run.
With method dispatch removed from `_revive_lc2`, the gadget bytes are never
passed to `parse_raw` and therefore never reach `pickle.loads`.
"""
import os
import pickle
import tempfile
marker = tempfile.NamedTemporaryFile(
prefix="lc2_block_proof_", suffix=".out", delete=False
).name
os.remove(marker) # ensure absent before the test runs
class _Gadget:
def __reduce__(self) -> tuple:
return (os.system, (f"touch {marker}",))
gadget_bytes = pickle.dumps(_Gadget(), protocol=0).decode("latin1")
envelope = {
"lc": 2,
"type": "constructor",
"id": ["langchain_core", "messages", "ai", "AIMessage"],
"method": "parse_raw",
"args": [gadget_bytes],
"kwargs": {"content_type": "application/pickle", "allow_pickle": True},
}
serde = JsonPlusSerializer()
try:
serde.loads_typed(("json", json.dumps(envelope).encode()))
except Exception:
pass
assert not os.path.exists(marker), (
"Pickle gadget executed via parse_raw on AIMessage lc=2 envelope"
)
def test_serde_jsonplus_bytearray() -> None:
serde = JsonPlusSerializer()
+13 -13
View File
@@ -229,11 +229,11 @@ wheels = [
[[package]]
name = "idna"
version = "3.15"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
@@ -268,7 +268,7 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.3.3"
version = "1.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonpatch" },
@@ -281,9 +281,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "uuid-utils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d3/ae/8b74458fc3850ec3d150eb9f45e857db129dafa801fb5cf173dfc9f8bbf3/langchain_core-1.3.3.tar.gz", hash = "sha256:fa510a5db8efdc0c6ff41c0939fb5c00a0183c11f6b84233e892e3227ff69182", size = 915041, upload-time = "2026-05-05T19:02:36.612Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a8/03/7219502e8ca728d65eb44d7a3eb60239230742a70dbfc9241b9bfd61c4ab/langchain_core-1.3.2.tar.gz", hash = "sha256:fd7a50b2f28ba561fd9d7f5d2760bc9e06cf00cdf820a3ccafe88a94ffa8d5b7", size = 911813, upload-time = "2026-04-24T15:49:23.699Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/01/4771b7ab2af1d1aba5b710bd8f13d9225c609425214b357590a17b01be77/langchain_core-1.3.3-py3-none-any.whl", hash = "sha256:18aae8506f37da7f74398492279a7d6efcee4f8e23c4c41c7af080eeb7ef7bd1", size = 543857, upload-time = "2026-05-05T19:02:34.52Z" },
{ url = "https://files.pythonhosted.org/packages/7d/d5/8fa4431007cbb7cfed7590f4d6a5dea3ad724f4174d248f6642ef5ce7d05/langchain_core-1.3.2-py3-none-any.whl", hash = "sha256:d44a66127f9f8db735bdfd0ab9661bccb47a97113cfd3f2d89c74864422b7274", size = 542390, upload-time = "2026-04-24T15:49:21.991Z" },
]
[[package]]
@@ -300,7 +300,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
version = "4.1.1"
version = "4.1.0a4"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
@@ -383,7 +383,7 @@ test = [
[[package]]
name = "langsmith"
version = "0.8.0"
version = "0.7.31"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
@@ -396,9 +396,9 @@ dependencies = [
{ name = "xxhash" },
{ name = "zstandard" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a8/64/95f1f013531395f4e8ed73caeee780f65c7c58fe028cb543f8937b45611b/langsmith-0.8.0.tar.gz", hash = "sha256:59fe5b2a56bbbe14a08aa76691f84b49e8675dd21e11b57d80c6db8c08bac2e3", size = 4432996, upload-time = "2026-04-30T22:13:07.341Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/e1/a4be2e696c9473bb53298df398237da5674704d781d4b748ed35aeef592a/langsmith-0.8.0-py3-none-any.whl", hash = "sha256:12cc4bc5622b835a6d841964d6034df3617bdb912dae0c1381fd0a68a9b3a3ef", size = 393268, upload-time = "2026-04-30T22:13:05.56Z" },
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
]
[[package]]
@@ -1463,11 +1463,11 @@ wheels = [
[[package]]
name = "urllib3"
version = "2.7.0"
version = "2.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
[[package]]
+5 -4
View File
@@ -3675,11 +3675,12 @@ keyv@^4.5.4:
json-buffer "3.0.1"
"langsmith@>=0.5.0 <1.0.0":
version "0.6.3"
resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.6.3.tgz#a3d8ad58d66a47d3697e3c69b2be3f6df5233190"
integrity sha512-pXrQ4/4myQvjFFOAUmt5pWRrLEZR20gzIJD7MNdUH+5/S5nLI4ZRBo/SYKC6coaYj9pYTfQdBIzcs+3kfJ5uDA==
version "0.5.20"
resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.5.20.tgz#4021847d2ccd5a86c5eb96060f9bb5f19f80eca5"
integrity sha512-ULhLM8RswvQDXufLtNtvclHrWCBx8Cb5UPI6lAZC+8Dq59iHsVPz/3Ac9khWNm1VIvChRsuykixD/WrmzuuA3Q==
dependencies:
p-queue "6.6.2"
uuid "10.0.0"
leven@^3.1.0:
version "3.1.0"
@@ -4847,7 +4848,7 @@ uri-js@^4.2.2:
dependencies:
punycode "^2.1.0"
uuid@^10.0.0:
uuid@10.0.0, uuid@^10.0.0:
version "10.0.0"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294"
integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==
+1 -1
View File
@@ -17,7 +17,7 @@
"lint": "eslint 'apps/**/*.ts' 'libs/**/*.ts'"
},
"devDependencies": {
"turbo": "^2.9.14",
"turbo": "^2.9.7",
"typescript": "^5.9.3",
"@tsconfig/recommended": "^1.0.13",
"@eslint/eslintrc": "^3.3.5",
+39 -38
View File
@@ -166,35 +166,35 @@
resolved "https://registry.yarnpkg.com/@tsconfig/recommended/-/recommended-1.0.13.tgz#269fce3ad04ca70b93269ff44cca81b950f542da"
integrity sha512-sySRuBfMKyKO/j2ZAhR8kSembhjuPEV4Ra3AHtmWLq51+iGaudr45crPSzNC5b7/Ctrh9dfUpBuTlYrH6rM58Q==
"@turbo/darwin-64@2.9.14":
version "2.9.14"
resolved "https://registry.yarnpkg.com/@turbo/darwin-64/-/darwin-64-2.9.14.tgz#b9ec6ac637b9c5fdba5dae9d743f5d121f091242"
integrity sha512-t7QiPflaEyBE4oayeZtSmu4mEfjgIrcNlNNl1z1dmIVPqEdtA7+CfTf8d7KXsOGPh6aNgWjKxyvQg9uGfDQF+A==
"@turbo/darwin-64@2.9.7":
version "2.9.7"
resolved "https://registry.yarnpkg.com/@turbo/darwin-64/-/darwin-64-2.9.7.tgz#46fddae01ea7817192dc6ff36a678cc3879b753b"
integrity sha512-wnvOWuVWJ5EUHNKxExEWiGlTeVpLG1L0PCu5MUozyC1P2SHGiWsmpW6/yAuShH91Fa2TAHOvdCRBzriZh4j4Eg==
"@turbo/darwin-arm64@2.9.14":
version "2.9.14"
resolved "https://registry.yarnpkg.com/@turbo/darwin-arm64/-/darwin-arm64-2.9.14.tgz#99d19f3e59842c595d2828c72a2e4ef537408f38"
integrity sha512-d23147mC9BsCPA9mJ0h/ubcpbRgcJBXbcG3+Vq7YLhjz3IXuvQsJ1UXH8f4MD76ZjJ4m/E4aRdJV+MW88CDfbw==
"@turbo/darwin-arm64@2.9.7":
version "2.9.7"
resolved "https://registry.yarnpkg.com/@turbo/darwin-arm64/-/darwin-arm64-2.9.7.tgz#cd7cf3a024509af0f59ae3885216d62fd090fdb6"
integrity sha512-mA0FIPMwwN3lodDkQYaGxj6PeT7ZaN5aCEbkKn/WB+ZB9yJdVWA4J83GH7t43jqDc5dcnVluVN5UFx3plRiXhA==
"@turbo/linux-64@2.9.14":
version "2.9.14"
resolved "https://registry.yarnpkg.com/@turbo/linux-64/-/linux-64-2.9.14.tgz#9c907434f091cd75529f5496516f79b71935d2bb"
integrity sha512-P3ZKB5tuUDdDQWuAsACGUR1qv9W7BNWxdxqVJ0kZNuNNPRaVYTPPikLcp79+GiEcW3npsR+KyP38lnQiBc5aSA==
"@turbo/linux-64@2.9.7":
version "2.9.7"
resolved "https://registry.yarnpkg.com/@turbo/linux-64/-/linux-64-2.9.7.tgz#25ee9a2cb3042498a3203e14653b14041105020f"
integrity sha512-fEbUYpgb5l7P+q+5tsWF2gw+/GSjUsuUTcnfm+f0lozUjgcjLKyOat6PgtAChmIFcTPchCL/8rJ3TvkBy01gfA==
"@turbo/linux-arm64@2.9.14":
version "2.9.14"
resolved "https://registry.yarnpkg.com/@turbo/linux-arm64/-/linux-arm64-2.9.14.tgz#79ae9060e6ee9e0fb784ae0747e980f582e75313"
integrity sha512-ZRTlzcUMrrPv9ZuDzRF9n60Ym13bKeG9jDB8WjxyLhWNzV+AJQN+zdpIk3NJYf2zQsGUm1mNar2P0elRzLw25g==
"@turbo/linux-arm64@2.9.7":
version "2.9.7"
resolved "https://registry.yarnpkg.com/@turbo/linux-arm64/-/linux-arm64-2.9.7.tgz#5b19d6249a50cb8153b8f2bc22ad10bbda614fbe"
integrity sha512-VkUjulo9ytfHKUHOS5gy0XPoh4CTKPXWCL8nLdrlHVi9fSut31ECeUqnm/dAbETP5D4xo9mH9XkJ+qMzGe/zmg==
"@turbo/windows-64@2.9.14":
version "2.9.14"
resolved "https://registry.yarnpkg.com/@turbo/windows-64/-/windows-64-2.9.14.tgz#68a80f299f35189314184c88301caad982d1c0c2"
integrity sha512-exanwN6sIduZwykYeiTQj8kCmOhazP5WOz3bvXMcYtjhL6Z3iRWLewKrXCBq0bqwSP3iBMb/AerRCnHI4lx46A==
"@turbo/windows-64@2.9.7":
version "2.9.7"
resolved "https://registry.yarnpkg.com/@turbo/windows-64/-/windows-64-2.9.7.tgz#900645776fa44ff8333e801d890992736f6dbc72"
integrity sha512-/GWdY6/x4aIHqkYJq596Rpdk1x0MkpRPkJcLAoB3yGRwyUms0+u2F1GnV54IbyAZTeKLRWSJKzNC+QwVGdYchA==
"@turbo/windows-arm64@2.9.14":
version "2.9.14"
resolved "https://registry.yarnpkg.com/@turbo/windows-arm64/-/windows-arm64-2.9.14.tgz#4dc16684f0ddce53fafe56ad8f55205c4473d17a"
integrity sha512-fVdCsnmYoKICsycbWuuGp6Jvi51/3G/UluFWuAUCvR8PIW5IJkAk5BM9UF8PSm0Q2IphWHFZjYEgjHsh3B9y/g==
"@turbo/windows-arm64@2.9.7":
version "2.9.7"
resolved "https://registry.yarnpkg.com/@turbo/windows-arm64/-/windows-arm64-2.9.7.tgz#e678d73fdbcbb12a679403fe7ba070fb67f37810"
integrity sha512-xBBgxCC5PK2+WZ1PPRZdp+aJ0bMBcEbweXWux3RUHJvX9ZodcoQySkrW6qt+ahb+uk8ZjyQodLfDwtVSoYds1w==
"@types/esrecurse@^4.3.1":
version "4.3.1"
@@ -1327,11 +1327,12 @@ keyv@^4.5.4:
json-buffer "3.0.1"
"langsmith@>=0.5.0 <1.0.0":
version "0.7.1"
resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.7.1.tgz#d721ad2e5211f9a5373e8a84eb4fed36b5621971"
integrity sha512-Wjk90UjNoY5cBHMlNAC/eZx5clI8jnjBOBW8uJu8+MWBtx0QesNjsUiLtjI+I3UnrpxFFpDqGXcnhBjH654Mqg==
version "0.5.20"
resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.5.20.tgz#4021847d2ccd5a86c5eb96060f9bb5f19f80eca5"
integrity sha512-ULhLM8RswvQDXufLtNtvclHrWCBx8Cb5UPI6lAZC+8Dq59iHsVPz/3Ac9khWNm1VIvChRsuykixD/WrmzuuA3Q==
dependencies:
p-queue "6.6.2"
uuid "10.0.0"
levn@^0.4.1:
version "0.4.1"
@@ -1813,17 +1814,17 @@ tsconfig-paths@^3.15.0:
minimist "^1.2.6"
strip-bom "^3.0.0"
turbo@^2.9.14:
version "2.9.14"
resolved "https://registry.yarnpkg.com/turbo/-/turbo-2.9.14.tgz#d412fcc4c9bd8dba29cec5bbd54d5a74ab2b1bee"
integrity sha512-BQqXRr4UoWI3UPFrtznCLykYHxwxWh53iCB57x092jPMjIlW1wnm3N895g5irpiXmnxUhREBB0n6+y8BHhs4nw==
turbo@^2.9.7:
version "2.9.7"
resolved "https://registry.yarnpkg.com/turbo/-/turbo-2.9.7.tgz#da6d5a821f0bde5174e1a55af6690dad5c2fb004"
integrity sha512-epxzqVO2s0IxcSWcgb+qKrtco8isfe7g3VtiS6hkYnEK4A9XQDZbrtavQ6MtWR1KoQn+1fUomaQth2rfRHlUlg==
optionalDependencies:
"@turbo/darwin-64" "2.9.14"
"@turbo/darwin-arm64" "2.9.14"
"@turbo/linux-64" "2.9.14"
"@turbo/linux-arm64" "2.9.14"
"@turbo/windows-64" "2.9.14"
"@turbo/windows-arm64" "2.9.14"
"@turbo/darwin-64" "2.9.7"
"@turbo/darwin-arm64" "2.9.7"
"@turbo/linux-64" "2.9.7"
"@turbo/linux-arm64" "2.9.7"
"@turbo/windows-64" "2.9.7"
"@turbo/windows-arm64" "2.9.7"
type-check@^0.4.0, type-check@~0.4.0:
version "0.4.0"
@@ -1899,7 +1900,7 @@ uri-js@^4.2.2:
dependencies:
punycode "^2.1.0"
uuid@^10.0.0:
uuid@10.0.0, uuid@^10.0.0:
version "10.0.0"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294"
integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.4.26"
__version__ = "0.4.25"
+2 -20
View File
@@ -35,12 +35,6 @@ DISALLOWED_BUILD_COMMAND_CHARS = [
# This blocks background execution (cmd &) while allowing command
# chaining (cmd1 && cmd2) which is common in build commands.
_SINGLE_AMPERSAND_RE = re.compile(r"(?<!&)&(?:&&)*(?!&)")
_API_VERSION_PATTERN = re.compile(
r"^(?P<major>\d+)"
r"(?:\.(?P<minor>\d+))?"
r"(?:\.(?P<patch>\d+))?"
r"(?:(?:\.|)(?:[A-Za-z][0-9A-Za-z]*))?$"
)
def has_disallowed_build_command_content(command: str) -> bool:
@@ -129,18 +123,6 @@ def _parse_node_version(version_str: str) -> int:
) from None
def _parse_api_version_parts(version_str: str) -> tuple[int, ...]:
"""Parse an API version into numeric components.
Supports optional prerelease suffixes, e.g. `0.9.0rc1`.
"""
version_core = version_str.split("-", 1)[0]
match = _API_VERSION_PATTERN.fullmatch(version_core)
if not match:
raise ValueError("Version must be major or major.minor or major.minor.patch.")
return tuple(int(part) for part in match.groups() if part is not None)
def _is_node_graph(spec: str | dict) -> bool:
"""Check if a graph is a Node.js graph based on the file extension."""
if isinstance(spec, dict):
@@ -194,12 +176,12 @@ def validate_config(config: Config) -> Config:
)
if api_version:
try:
parts = _parse_api_version_parts(api_version)
parts = tuple(map(int, api_version.split("-")[0].split(".")))
if len(parts) > 3:
raise ValueError(
"Version must be major or major.minor or major.minor.patch."
)
except (TypeError, ValueError):
except TypeError:
raise click.UsageError(
f"Invalid version format: {api_version}.\n\n"
"Pin to a minor version, e.g.:\n"
-17
View File
@@ -2944,23 +2944,6 @@ def test_docker_tag_with_api_version(in_config: bool):
assert tag == f"langchain/langgraph-server:{version}-py3.11"
@pytest.mark.parametrize("in_config", [False, True])
@pytest.mark.parametrize("version", ["0.9.0rc1", "0.9.0.dev1"])
def test_docker_tag_with_prerelease_api_version(version: str, in_config: bool):
"""Test docker_tag with prerelease and dev api_version values."""
config = validate_config(
{
"python_version": "3.11",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"api_version": version if in_config else None,
}
)
tag = docker_tag(config, api_version=version if not in_config else None)
assert tag == f"langchain/langgraph-api:{version}-py3.11"
def test_config_to_docker_with_api_version():
"""Test config_to_docker function with api_version parameter."""
+1 -1
View File
@@ -5,7 +5,7 @@ description = "uv workspace monorepo example for LangGraph CLI integration test"
requires-python = ">=3.11"
dependencies = [
"langgraph>=0.6.0,<2",
"langchain-core>=1.3.3",
"langchain-core>=0.2.14",
]
[tool.uv.workspace]
+10 -23
View File
@@ -215,11 +215,10 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.3.3"
version = "1.2.28"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonpatch" },
{ name = "langchain-protocol" },
{ name = "langsmith" },
{ name = "packaging" },
{ name = "pydantic" },
@@ -228,21 +227,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "uuid-utils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d3/ae/8b74458fc3850ec3d150eb9f45e857db129dafa801fb5cf173dfc9f8bbf3/langchain_core-1.3.3.tar.gz", hash = "sha256:fa510a5db8efdc0c6ff41c0939fb5c00a0183c11f6b84233e892e3227ff69182", size = 915041, upload-time = "2026-05-05T19:02:36.612Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/a4/317a1a3ac1df33a64adb3670bf88bbe3b3d5baa274db6863a979db472897/langchain_core-1.2.28.tar.gz", hash = "sha256:271a3d8bd618f795fdeba112b0753980457fc90537c46a0c11998516a74dc2cb", size = 846119, upload-time = "2026-04-08T18:19:34.867Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/01/4771b7ab2af1d1aba5b710bd8f13d9225c609425214b357590a17b01be77/langchain_core-1.3.3-py3-none-any.whl", hash = "sha256:18aae8506f37da7f74398492279a7d6efcee4f8e23c4c41c7af080eeb7ef7bd1", size = 543857, upload-time = "2026-05-05T19:02:34.52Z" },
]
[[package]]
name = "langchain-protocol"
version = "0.0.15"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4f/24/9777489d6fbbee64af0c8f96d4f840239c408cf694f3394672807dafc490/langchain_protocol-0.0.15.tar.gz", hash = "sha256:9ab2d11ee73944754f10e037e717098d3a6796f0e58afa9cadda6154e7655ade", size = 5862, upload-time = "2026-05-01T22:30:04.748Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/7a/9c97a7b9cbe4c5dc6a44cdb1545450c28f0c8ce89b9c1f0ee7fbad896263/langchain_protocol-0.0.15-py3-none-any.whl", hash = "sha256:461eb794358f83d5e42635a5797799ffec7b4702314e34edf73ac21e75d3ef79", size = 6982, upload-time = "2026-05-01T22:30:03.877Z" },
{ url = "https://files.pythonhosted.org/packages/a8/92/32f785f077c7e898da97064f113c73fbd9ad55d1e2169cf3a391b183dedb/langchain_core-1.2.28-py3-none-any.whl", hash = "sha256:80764232581eaf8057bcefa71dbf8adc1f6a28d257ebd8b95ba9b8b452e8c6ac", size = 508727, upload-time = "2026-04-08T18:19:32.823Z" },
]
[[package]]
@@ -303,7 +290,7 @@ wheels = [
[[package]]
name = "langsmith"
version = "0.8.0"
version = "0.7.31"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
@@ -316,9 +303,9 @@ dependencies = [
{ name = "xxhash" },
{ name = "zstandard" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a8/64/95f1f013531395f4e8ed73caeee780f65c7c58fe028cb543f8937b45611b/langsmith-0.8.0.tar.gz", hash = "sha256:59fe5b2a56bbbe14a08aa76691f84b49e8675dd21e11b57d80c6db8c08bac2e3", size = 4432996, upload-time = "2026-04-30T22:13:07.341Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/e1/a4be2e696c9473bb53298df398237da5674704d781d4b748ed35aeef592a/langsmith-0.8.0-py3-none-any.whl", hash = "sha256:12cc4bc5622b835a6d841964d6034df3617bdb912dae0c1381fd0a68a9b3a3ef", size = 393268, upload-time = "2026-04-30T22:13:05.56Z" },
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
]
[[package]]
@@ -677,11 +664,11 @@ wheels = [
[[package]]
name = "urllib3"
version = "2.7.0"
version = "2.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
[[package]]
@@ -724,7 +711,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "langchain-core", specifier = ">=1.3.3" },
{ name = "langchain-core", specifier = ">=0.2.14" },
{ name = "langgraph", specifier = ">=0.6.0,<2" },
]
+1 -1
View File
@@ -5,7 +5,7 @@ description = "Simple single-package uv example for LangGraph CLI integration te
requires-python = ">=3.11"
dependencies = [
"langgraph>=0.6.0,<2",
"langchain-core>=1.3.3",
"langchain-core>=0.2.14",
]
[build-system]
+10 -23
View File
@@ -191,11 +191,10 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.3.3"
version = "1.2.28"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonpatch" },
{ name = "langchain-protocol" },
{ name = "langsmith" },
{ name = "packaging" },
{ name = "pydantic" },
@@ -204,21 +203,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "uuid-utils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d3/ae/8b74458fc3850ec3d150eb9f45e857db129dafa801fb5cf173dfc9f8bbf3/langchain_core-1.3.3.tar.gz", hash = "sha256:fa510a5db8efdc0c6ff41c0939fb5c00a0183c11f6b84233e892e3227ff69182", size = 915041, upload-time = "2026-05-05T19:02:36.612Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/a4/317a1a3ac1df33a64adb3670bf88bbe3b3d5baa274db6863a979db472897/langchain_core-1.2.28.tar.gz", hash = "sha256:271a3d8bd618f795fdeba112b0753980457fc90537c46a0c11998516a74dc2cb", size = 846119, upload-time = "2026-04-08T18:19:34.867Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/01/4771b7ab2af1d1aba5b710bd8f13d9225c609425214b357590a17b01be77/langchain_core-1.3.3-py3-none-any.whl", hash = "sha256:18aae8506f37da7f74398492279a7d6efcee4f8e23c4c41c7af080eeb7ef7bd1", size = 543857, upload-time = "2026-05-05T19:02:34.52Z" },
]
[[package]]
name = "langchain-protocol"
version = "0.0.15"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4f/24/9777489d6fbbee64af0c8f96d4f840239c408cf694f3394672807dafc490/langchain_protocol-0.0.15.tar.gz", hash = "sha256:9ab2d11ee73944754f10e037e717098d3a6796f0e58afa9cadda6154e7655ade", size = 5862, upload-time = "2026-05-01T22:30:04.748Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/7a/9c97a7b9cbe4c5dc6a44cdb1545450c28f0c8ce89b9c1f0ee7fbad896263/langchain_protocol-0.0.15-py3-none-any.whl", hash = "sha256:461eb794358f83d5e42635a5797799ffec7b4702314e34edf73ac21e75d3ef79", size = 6982, upload-time = "2026-05-01T22:30:03.877Z" },
{ url = "https://files.pythonhosted.org/packages/a8/92/32f785f077c7e898da97064f113c73fbd9ad55d1e2169cf3a391b183dedb/langchain_core-1.2.28-py3-none-any.whl", hash = "sha256:80764232581eaf8057bcefa71dbf8adc1f6a28d257ebd8b95ba9b8b452e8c6ac", size = 508727, upload-time = "2026-04-08T18:19:32.823Z" },
]
[[package]]
@@ -279,7 +266,7 @@ wheels = [
[[package]]
name = "langsmith"
version = "0.8.0"
version = "0.7.31"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
@@ -292,9 +279,9 @@ dependencies = [
{ name = "xxhash" },
{ name = "zstandard" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a8/64/95f1f013531395f4e8ed73caeee780f65c7c58fe028cb543f8937b45611b/langsmith-0.8.0.tar.gz", hash = "sha256:59fe5b2a56bbbe14a08aa76691f84b49e8675dd21e11b57d80c6db8c08bac2e3", size = 4432996, upload-time = "2026-04-30T22:13:07.341Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/e1/a4be2e696c9473bb53298df398237da5674704d781d4b748ed35aeef592a/langsmith-0.8.0-py3-none-any.whl", hash = "sha256:12cc4bc5622b835a6d841964d6034df3617bdb912dae0c1381fd0a68a9b3a3ef", size = 393268, upload-time = "2026-04-30T22:13:05.56Z" },
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
]
[[package]]
@@ -627,7 +614,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "langchain-core", specifier = ">=1.3.3" },
{ name = "langchain-core", specifier = ">=0.2.14" },
{ name = "langgraph", specifier = ">=0.6.0,<2" },
]
@@ -663,11 +650,11 @@ wheels = [
[[package]]
name = "urllib3"
version = "2.7.0"
version = "2.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
[[package]]
+12 -25
View File
@@ -797,11 +797,11 @@ wheels = [
[[package]]
name = "idna"
version = "3.15"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
@@ -946,11 +946,10 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.3.3"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonpatch", marker = "python_full_version >= '3.11'" },
{ name = "langchain-protocol", marker = "python_full_version >= '3.11'" },
{ name = "langsmith", marker = "python_full_version >= '3.11'" },
{ name = "packaging", marker = "python_full_version >= '3.11'" },
{ name = "pydantic", marker = "python_full_version >= '3.11'" },
@@ -959,21 +958,9 @@ dependencies = [
{ name = "typing-extensions", marker = "python_full_version >= '3.11'" },
{ name = "uuid-utils", marker = "python_full_version >= '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d3/ae/8b74458fc3850ec3d150eb9f45e857db129dafa801fb5cf173dfc9f8bbf3/langchain_core-1.3.3.tar.gz", hash = "sha256:fa510a5db8efdc0c6ff41c0939fb5c00a0183c11f6b84233e892e3227ff69182", size = 915041, upload-time = "2026-05-05T19:02:36.612Z" }
sdist = { url = "https://files.pythonhosted.org/packages/92/fe/20190232d9b513242899dbb0c2bb77e31b4d61e343743adbe90ebc2603d2/langchain_core-1.3.0.tar.gz", hash = "sha256:14a39f528bf459aa3aa40d0a7f7f1bae7520d435ef991ae14a4ceb74d8c49046", size = 860755, upload-time = "2026-04-17T14:51:38.298Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/01/4771b7ab2af1d1aba5b710bd8f13d9225c609425214b357590a17b01be77/langchain_core-1.3.3-py3-none-any.whl", hash = "sha256:18aae8506f37da7f74398492279a7d6efcee4f8e23c4c41c7af080eeb7ef7bd1", size = 543857, upload-time = "2026-05-05T19:02:34.52Z" },
]
[[package]]
name = "langchain-protocol"
version = "0.0.15"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version >= '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4f/24/9777489d6fbbee64af0c8f96d4f840239c408cf694f3394672807dafc490/langchain_protocol-0.0.15.tar.gz", hash = "sha256:9ab2d11ee73944754f10e037e717098d3a6796f0e58afa9cadda6154e7655ade", size = 5862, upload-time = "2026-05-01T22:30:04.748Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/7a/9c97a7b9cbe4c5dc6a44cdb1545450c28f0c8ce89b9c1f0ee7fbad896263/langchain_protocol-0.0.15-py3-none-any.whl", hash = "sha256:461eb794358f83d5e42635a5797799ffec7b4702314e34edf73ac21e75d3ef79", size = 6982, upload-time = "2026-05-01T22:30:03.877Z" },
{ url = "https://files.pythonhosted.org/packages/f8/e2/dbfa347aa072a6dc4cd38d6f9ebfc730b4c14c258c47f480f4c5c546f177/langchain_core-1.3.0-py3-none-any.whl", hash = "sha256:baf16ee028475df177b9ab8869a751c79406d64a6f12125b93802991b566cced", size = 515140, upload-time = "2026-04-17T14:51:36.274Z" },
]
[[package]]
@@ -1174,7 +1161,7 @@ wheels = [
[[package]]
name = "langsmith"
version = "0.8.0"
version = "0.7.32"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx", marker = "python_full_version >= '3.11'" },
@@ -1187,9 +1174,9 @@ dependencies = [
{ name = "xxhash", marker = "python_full_version >= '3.11'" },
{ name = "zstandard", marker = "python_full_version >= '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a8/64/95f1f013531395f4e8ed73caeee780f65c7c58fe028cb543f8937b45611b/langsmith-0.8.0.tar.gz", hash = "sha256:59fe5b2a56bbbe14a08aa76691f84b49e8675dd21e11b57d80c6db8c08bac2e3", size = 4432996, upload-time = "2026-04-30T22:13:07.341Z" }
sdist = { url = "https://files.pythonhosted.org/packages/2f/b4/a0b4a501bee6b8a741ce29f8c48155b132118483cddc6f9247735ddb38fa/langsmith-0.7.32.tar.gz", hash = "sha256:b59b8e106d0e4c4842e158229296086e2aa7c561e3f602acda73d3ad0062e915", size = 1184518, upload-time = "2026-04-15T23:42:41.885Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/e1/a4be2e696c9473bb53298df398237da5674704d781d4b748ed35aeef592a/langsmith-0.8.0-py3-none-any.whl", hash = "sha256:12cc4bc5622b835a6d841964d6034df3617bdb912dae0c1381fd0a68a9b3a3ef", size = 393268, upload-time = "2026-04-30T22:13:05.56Z" },
{ url = "https://files.pythonhosted.org/packages/62/bc/148f98ac7dad73ac5e1b1c985290079cfeeb9ba13d760a24f25002beb2c9/langsmith-0.7.32-py3-none-any.whl", hash = "sha256:e1fde928990c4c52f47dc5132708cec674355d9101723d564183e965f383bf5f", size = 378272, upload-time = "2026-04-15T23:42:39.905Z" },
]
[package.optional-dependencies]
@@ -2320,11 +2307,11 @@ wheels = [
[[package]]
name = "urllib3"
version = "2.7.0"
version = "2.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
[[package]]
@@ -29,9 +29,6 @@ from langgraph._internal._constants import (
)
DEFAULT_RECURSION_LIMIT = int(getenv("LANGGRAPH_DEFAULT_RECURSION_LIMIT", "10007"))
DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT = int(
getenv("LANGGRAPH_DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT", "5000")
)
def recast_checkpoint_ns(ns: str) -> str:
+4 -7
View File
@@ -32,7 +32,7 @@ class DeltaChannel(Generic[Value], BaseChannel[Any, Any, Any]):
change in future releases. Threads written with `DeltaChannel` today
are expected to remain readable, but the surrounding contract
(`BaseCheckpointSaver.get_delta_channel_history`, the
`_DeltaSnapshot` blob shape, the `counters_since_delta_snapshot`
`_DeltaSnapshot` blob shape, the `delta_updates_since_snapshot`
metadata field) is not yet stable.
The reducer receives the current accumulated value and a batch of writes
@@ -47,12 +47,9 @@ class DeltaChannel(Generic[Value], BaseChannel[Any, Any, Any]):
This lets LangGraph replay checkpointed writes in larger batches than they
were originally produced without changing reconstructed state.
Snapshot cadence is driven by two counters: per-channel update count and
total supersteps since last snapshot. `create_checkpoint` writes a full
`_DeltaSnapshot` blob when EITHER the update count reaches
`snapshot_frequency` OR the supersteps count reaches the system-wide
`DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT` bound (default 5000), bounding
replay depth even for channels that stop receiving writes.
Snapshot cadence is driven by per-channel update count. `create_checkpoint`
writes a full `_DeltaSnapshot` blob every `snapshot_frequency` updates to
this channel, bounding replay depth.
Parameters:
reducer: `(state, list[writes]) -> new_state`. Must be deterministic
+2 -133
View File
@@ -6,7 +6,7 @@ import typing
import warnings
from collections import defaultdict
from collections.abc import Awaitable, Callable, Hashable, Sequence
from dataclasses import dataclass, is_dataclass
from dataclasses import is_dataclass
from datetime import timedelta
from functools import partial
from inspect import isclass, isfunction, ismethod, signature
@@ -95,17 +95,6 @@ __all__ = ("StateGraph", "CompiledStateGraph")
logger = logging.getLogger(__name__)
_CHANNEL_BRANCH_TO = "branch:to:{}"
_DEFAULT_ERROR_HANDLER_NODE = "__default_error_handler__"
@dataclass(slots=True)
class _NodeDefaults:
"""Default node policies applied to every node at compile time."""
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None
cache_policy: CachePolicy | None = None
error_handler: StateNode[Any, Any] | None = None
timeout: TimeoutPolicy | None = None
def _warn_invalid_state_schema(schema: type[Any] | Any) -> None:
@@ -262,77 +251,10 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
self.output_schema = cast(type[OutputT], output_schema or state_schema)
self.context_schema = context_schema
self._node_defaults: _NodeDefaults = _NodeDefaults()
self._add_schema(self.state_schema)
self._add_schema(self.input_schema, allow_managed=False)
self._add_schema(self.output_schema, allow_managed=False)
def set_node_defaults(
self,
*,
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
cache_policy: CachePolicy | None = None,
error_handler: StateNode[Any, ContextT] | None = None,
timeout: float | timedelta | TimeoutPolicy | None = None,
) -> Self:
"""Set default node policies that apply to every node in this graph.
Per-node values passed to `add_node` always take precedence over these
defaults. Defaults are applied at `compile()` time. Policies set here
are **not** inherited by subgraphs.
`retry_policy` and `timeout` defaults apply to **all** nodes,
including error-handler nodes. `cache_policy` and `error_handler`
defaults only apply to regular nodes -- caching error-handler results
is unsafe, and handlers must never catch themselves.
Args:
retry_policy: Default retry policy for nodes that don't specify
their own via `add_node(..., retry_policy=...)`. Also applies
to error-handler nodes.
cache_policy: Default cache policy for nodes that don't specify
their own via `add_node(..., cache_policy=...)`. Does **not**
apply to error-handler nodes.
error_handler: Default error handler invoked when any regular node
raises and does not have its own `error_handler` set via
`add_node`. The handler is **not** invoked when an
error-handler node itself raises -- handler failures fail the
run.
timeout: Default timeout policy for nodes that don't specify their
own via `add_node(..., timeout=...)`. Also applies to
error-handler nodes. Accepts a `TimeoutPolicy`, a number of
seconds (`float`), or a `timedelta`.
Returns:
Self: The builder instance, for chaining.
Example:
```python
graph = (
StateGraph(State)
.set_node_defaults(
retry_policy=RetryPolicy(max_attempts=3),
error_handler=my_fallback_handler,
)
.add_node("a", node_a)
.add_node("b", node_b, retry_policy=custom_retry) # overrides default
.add_edge(START, "a")
.compile()
)
```
"""
defaults = self._node_defaults
if retry_policy is not None:
defaults.retry_policy = retry_policy
if cache_policy is not None:
defaults.cache_policy = cache_policy
if error_handler is not None:
defaults.error_handler = error_handler
if timeout is not None:
defaults.timeout = coerce_timeout_policy(timeout)
return self
@property
def _all_edges(self) -> set[tuple[str, str]]:
return self.edges | {
@@ -1271,63 +1193,10 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
key for key, val in self.channels.items() if not is_managed_value(val)
]
)
# Apply builder defaults to node specs. Per-node values always win.
# Error-handler routing and cache_policy are only assigned to regular
# nodes. Retry and timeout defaults also apply to error-handler nodes.
defaults = self._node_defaults
default_handler_name: str | None = None
if defaults.error_handler is not None:
if _DEFAULT_ERROR_HANDLER_NODE in self.nodes:
raise ValueError(
f"Auto-generated default error handler node "
f"`{_DEFAULT_ERROR_HANDLER_NODE}` already exists."
)
default_handler_name = _DEFAULT_ERROR_HANDLER_NODE
self.nodes[default_handler_name] = StateNodeSpec[Any, ContextT](
coerce_to_runnable(
defaults.error_handler, # type: ignore[arg-type]
name=default_handler_name,
trace=False,
),
metadata=None,
input_schema=self.state_schema,
retry_policy=None,
cache_policy=None,
is_error_handler=True,
)
# Apply builder defaults to node specs. Per-node values always win.
for spec in self.nodes.values():
# error_handler: regular nodes only — handlers must never
# catch themselves or other handlers.
if (
not spec.is_error_handler
and default_handler_name is not None
and spec.error_handler_node is None
):
spec.error_handler_node = default_handler_name
# retry: all nodes — handlers should be retried on transient
# failures just like regular nodes.
if defaults.retry_policy is not None and spec.retry_policy is None:
spec.retry_policy = defaults.retry_policy
# cache: regular nodes only — caching an error-handler result
# is unsafe because the input (failed-node state) may differ
# across failures even when the cache key matches.
if (
not spec.is_error_handler
and defaults.cache_policy is not None
and spec.cache_policy is None
):
spec.cache_policy = defaults.cache_policy
# timeout: all nodes — a stuck handler should be cancelled the
# same way a stuck regular node would be.
if defaults.timeout is not None and spec.timeout is None:
spec.timeout = defaults.timeout
node_error_handler_map = {
node_name: spec.error_handler_node
for node_name, spec in self.nodes.items()
if not spec.is_error_handler and spec.error_handler_node is not None
if spec.error_handler_node is not None
}
compiled = CompiledStateGraph[StateT, ContextT, InputT, OutputT](
+26 -17
View File
@@ -8,11 +8,11 @@ from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.base import (
BaseCheckpointSaver,
Checkpoint,
CheckpointMetadata,
)
from langgraph.checkpoint.base.id import uuid6
from langgraph.checkpoint.serde.types import _DeltaSnapshot
from langgraph._internal._config import DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT
from langgraph._internal._typing import MISSING
from langgraph.channels.base import BaseChannel
from langgraph.channels.delta import DeltaChannel
@@ -36,26 +36,21 @@ def empty_checkpoint() -> Checkpoint:
def delta_channels_to_snapshot(
channels: Mapping[str, BaseChannel],
counters_since_delta_snapshot: Mapping[str, tuple[int, int]],
counts: Mapping[str, int],
) -> set[str]:
"""Return the set of DeltaChannel names that should snapshot now.
A channel snapshots when EITHER its accumulated update count reaches
`snapshot_frequency` OR the total supersteps since its last snapshot
reaches `DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT`. This is a pure
A channel snapshots when its accumulated update count (since the last
snapshot) reaches or exceeds `snapshot_frequency`. This is a pure
predicate no mutation.
"""
result: set[str] = set()
for name, ch in channels.items():
if not isinstance(ch, DeltaChannel) or not ch.is_available():
continue
updates, supersteps = counters_since_delta_snapshot.get(name, (0, 0))
if (
updates >= ch.snapshot_frequency
or supersteps >= DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT
):
result.add(name)
return result
return {
name
for name, ch in channels.items()
if isinstance(ch, DeltaChannel)
and ch.is_available()
and counts.get(name, 0) >= ch.snapshot_frequency
}
def create_checkpoint(
@@ -74,7 +69,7 @@ def create_checkpoint(
is written into `channel_values[k]`. Other delta channels are omitted
from `channel_values` the ancestor walk reconstructs their state
from `checkpoint_writes`. Callers compute the set via
`delta_channels_to_snapshot(channels, counters)`; defaults to empty
`delta_channels_to_snapshot(channels, counts)`; defaults to empty
(no snapshots) when not provided.
"""
ts = datetime.now(timezone.utc).isoformat()
@@ -236,3 +231,17 @@ def copy_checkpoint(checkpoint: Checkpoint) -> Checkpoint:
versions_seen={k: v.copy() for k, v in checkpoint["versions_seen"].items()},
updated_channels=checkpoint.get("updated_channels", None),
)
def read_delta_updates_since_snapshot(
metadata: CheckpointMetadata | None,
) -> dict[str, int]:
"""Read the per-channel update counter from checkpoint metadata.
Returns an empty dict for missing/None metadata; the dict is
`total=False` on `CheckpointMetadata`, so absence means "no prior
delta-channel activity tracked."
"""
if not metadata:
return {}
return dict(metadata.get("delta_updates_since_snapshot", {}) or {})
+75 -136
View File
@@ -203,12 +203,6 @@ class PregelLoop:
# `__enter__`; stays `None` only when no checkpointer.
_delta_write_futs: list[Any] | None = None
# Same pattern as `_delta_write_futs` but for error-handler writes.
# When `put_writes` persists an ERROR_SOURCE_NODE marker, the future is
# appended here. `schedule_error_handler` / `aschedule_error_handler`
# drain this list so the write is durable before the handler starts.
_error_handler_write_futs: list[Any] | None = None
# Exit-mode accumulator: every delta-channel write produced during this
# run (input writes from `_first` + per-superstep writes captured in
# `after_tick`). At exit, `_put_exit_delta_writes` filters out channels
@@ -480,13 +474,6 @@ class PregelLoop:
isinstance(self.specs.get(c), DeltaChannel) for c, _ in writes_to_save
):
self._delta_write_futs.append(fut)
# ERROR_SOURCE_NODE is only appended by commit() when the task
# has an error handler (_should_route_to_error_handler), so this
# check naturally limits future collection to those tasks.
if self._error_handler_write_futs is not None and any(
c == ERROR_SOURCE_NODE for c, _ in writes
):
self._error_handler_write_futs.append(fut)
# output writes
if hasattr(self, "tasks"):
self.output_writes(task_id, writes)
@@ -566,7 +553,7 @@ class PregelLoop:
self.tasks[pushed.id] = pushed
# match any pending writes to the new task
if not self.is_replaying:
self._reapply_writes_to_succeeded_nodes({pushed.id: pushed})
self._match_writes({pushed.id: pushed})
# return the new task, to be started if not run before
return pushed
@@ -644,8 +631,7 @@ class PregelLoop:
# if there are pending writes from a previous loop, apply them
if not self.is_replaying and self.checkpoint_pending_writes:
self._reapply_writes_to_succeeded_nodes(self.tasks)
self._resume_error_handlers_if_applicable()
self._match_writes(self.tasks)
# before execution, check if we should interrupt
if self.interrupt_before and should_interrupt(
@@ -712,88 +698,13 @@ class PregelLoop:
# private
def _reapply_writes_to_succeeded_nodes(
self, tasks: Mapping[str, PregelExecutableTask]
) -> None:
"""Restore successful channel writes from checkpoint to in-memory tasks.
Skips control signals (ERROR, ERROR_SOURCE_NODE, INTERRUPT, RESUME)
so that failed/interrupted tasks remain with empty writes and will be
re-executed (or routed to error handlers) by the runner.
"""
def _match_writes(self, tasks: Mapping[str, PregelExecutableTask]) -> None:
for tid, k, v in self.checkpoint_pending_writes:
if k in (ERROR, ERROR_SOURCE_NODE, INTERRUPT, RESUME):
continue
if task := tasks.get(tid):
task.writes.append((k, v))
def _resume_error_handlers_if_applicable(self) -> None:
"""On resume, schedule error handlers for tasks that failed in a prior run.
Called right after ``_reapply_writes_to_succeeded_nodes`` during ``tick()``.
At that point, ``_reapply_writes_to_succeeded_nodes`` has already skipped
ERROR / ERROR_SOURCE_NODE writes, so a previously-failed task still has
empty ``writes``. Without intervention the runner (which executes only
tasks where ``not t.writes``) would re-run the original node.
This method prevents that re-execution for nodes that have an error
handler:
1. Scan ``checkpoint_pending_writes`` for ERROR_SOURCE_NODE markers
persisted by a prior ``commit()``. Each marker means "this task
already failed and was routed to an error handler".
2. For each such task, write ``(ERROR, error)`` into ``task.writes``
so the task is no longer empty the runner will skip it.
3. Prepare a fresh error-handler task and add it to ``self.tasks``.
Because the handler task starts with empty ``writes``, the runner
will pick it up and execute it.
"""
# Phase 1: collect task-ids that have ERROR_SOURCE_NODE + ERROR pairs.
failed: dict[str, BaseException] = {}
for tid, chan, val in self.checkpoint_pending_writes:
if chan == ERROR_SOURCE_NODE:
error = next(
(
v
for t, c, v in self.checkpoint_pending_writes
if t == tid and c == ERROR
),
None,
)
if error is not None:
failed[tid] = error
# Phase 2: mark originals as done, schedule handler tasks.
for task_id, error in failed.items():
task = self.tasks.get(task_id)
if task is None:
continue
handler_node = self.nodes[task.name].error_handler_node
if not handler_node:
continue
# Non-empty writes → runner's `not t.writes` filter skips this task.
task.writes.append((ERROR, error))
# The handler task starts with empty writes → runner will execute it.
handler_task = prepare_node_error_handler_task(
task,
handler_node_name=handler_node,
failed_error=error,
checkpoint=self.checkpoint,
pending_writes=self.checkpoint_pending_writes,
processes=self.nodes,
channels=self.channels,
managed=self.managed,
config=task.config,
step=self.step,
stop=self.stop,
store=self.store,
checkpointer=self.checkpointer,
manager=self.manager,
retry_policy=self.retry_policy,
cache_policy=self.cache_policy,
)
if handler_task is not None:
self.tasks[handler_task.id] = handler_task
def _pending_interrupts(self) -> set[str]:
"""Return the set of interrupt ids that are pending without corresponding resume values."""
# mapping of task ids to interrupt ids
@@ -865,6 +776,17 @@ class PregelLoop:
and configurable.get(CONFIG_KEY_CHECKPOINT_NS, "")
in configurable.get(CONFIG_KEY_CHECKPOINT_MAP, {})
)
# Outer graph: time-travel-resume — explicit non-head
# checkpoint paired with a Command(resume=...). Without this,
# `Command(resume=...) + checkpoint=<non-head>` would match the
# plain-resume exclusion below and skip the cleanup needed to
# treat this as a fork.
or (
not self.is_nested
and getattr(self, "_loaded_explicit_non_head", False)
and input_is_command
and cast(Command, self.input).resume is not None
)
or not (
# Outer graph: resume arrives as Command(resume=...)
(input_is_command and cast(Command, self.input).resume is not None)
@@ -1067,41 +989,35 @@ class PregelLoop:
if exiting and self.checkpoint["id"] == self.checkpoint_id_saved:
# checkpoint already saved
return
# Per-delta-channel counter bookkeeping.
#
# Each delta channel tracks a (updates, supersteps) tuple:
# - `updates` increments only when the channel is written this step.
# - `supersteps` increments every superstep regardless.
# Per-delta-channel update bookkeeping.
#
# `_put_checkpoint` is called once per superstep with a fresh
# metadata dict (source="input"|"loop"|"fork") — those are the
# intermediate calls that bump counters. In exit mode,
# intermediate calls that bump the count by +1 for each delta
# channel touched that step. In exit mode,
# `_suppress_interrupt`(will rename to _on_loop_exit soon)
# additionally calls `_put_checkpoint(self.checkpoint_metadata)` AT
# EXIT to commit the final checkpoint — this runs *after* the last
# intermediate call already counted the last superstep. So the
# exit call must NOT bump again or it would double-count the last
# superstep.
# superstep. (Sync/async durability does not call `_put_checkpoint`
# at exit, so the issue only surfaces in exit mode. force_delta_snapshot
# used to mask this latent bug by resetting every count to 0.)
if not exiting:
prev_counters = dict(
self.checkpoint_metadata.get("counters_since_delta_snapshot") or {}
prev_counts = dict(
self.checkpoint_metadata.get("delta_updates_since_snapshot", {}) or {}
)
new_counters: dict[str, tuple[int, int]] = {}
updated = self.updated_channels or set()
for ch_name, ch in self.channels.items():
if not isinstance(ch, DeltaChannel):
continue
u, s = prev_counters.get(ch_name, (0, 0))
s += 1
if ch_name in updated:
u += 1
new_counters[ch_name] = (u, s)
new_counts = dict(prev_counts)
if self.updated_channels:
for ch_name in self.updated_channels:
if isinstance(self.channels.get(ch_name), DeltaChannel):
new_counts[ch_name] = new_counts.get(ch_name, 0) + 1
metadata["step"] = self.step
metadata["parents"] = self.config[CONF].get(CONFIG_KEY_CHECKPOINT_MAP, {})
self.checkpoint_metadata = metadata
else:
new_counters = dict(
self.checkpoint_metadata.get("counters_since_delta_snapshot") or {}
new_counts = dict(
self.checkpoint_metadata.get("delta_updates_since_snapshot", {}) or {}
)
# do checkpoint?
do_checkpoint = self._checkpointer_put_after_previous is not None and (
@@ -1109,7 +1025,7 @@ class PregelLoop:
)
# create new checkpoint
channels_to_snapshot = (
delta_channels_to_snapshot(self.channels, new_counters)
delta_channels_to_snapshot(self.channels, new_counts)
if do_checkpoint
else set()
)
@@ -1125,12 +1041,11 @@ class PregelLoop:
channels_to_snapshot=channels_to_snapshot,
)
for k in channels_to_snapshot:
new_counters[k] = (0, 0)
non_zero = {k: v for k, v in new_counters.items() if v != (0, 0)}
if non_zero:
self.checkpoint_metadata["counters_since_delta_snapshot"] = non_zero
elif "counters_since_delta_snapshot" in self.checkpoint_metadata:
del self.checkpoint_metadata["counters_since_delta_snapshot"]
new_counts[k] = 0
if new_counts:
self.checkpoint_metadata["delta_updates_since_snapshot"] = new_counts
elif "delta_updates_since_snapshot" in self.checkpoint_metadata:
del self.checkpoint_metadata["delta_updates_since_snapshot"]
# sanitize TASK channel in the checkpoint before saving (durability=="exit")
if TASKS in self.checkpoint["channel_values"] and any(
isinstance(channel, UntrackedValue) for channel in self.channels.values()
@@ -1205,10 +1120,8 @@ class PregelLoop:
):
return
counters = dict(
self.checkpoint_metadata.get("counters_since_delta_snapshot") or {}
)
channels_to_snapshot = delta_channels_to_snapshot(self.channels, counters)
counts = self.checkpoint_metadata.get("delta_updates_since_snapshot", {}) or {}
channels_to_snapshot = delta_channels_to_snapshot(self.channels, counts)
pending = [
(step, tid, ch, v)
@@ -1543,10 +1456,12 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
handler_node = self.nodes[failed_task.name].error_handler_node
if not handler_node:
return None
# ensure error + ERROR_SOURCE_NODE writes are durable before handler runs
if self._error_handler_write_futs:
futs, self._error_handler_write_futs = self._error_handler_write_futs, []
concurrent.futures.wait(futs)
writes = list(failed_task.writes)
writes.append((ERROR_SOURCE_NODE, failed_task.name))
self.put_writes(
failed_task.id,
writes,
)
handler_task = prepare_node_error_handler_task(
failed_task,
handler_node_name=handler_node,
@@ -1569,7 +1484,7 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
return None
self.tasks[handler_task.id] = handler_task
if not self.is_replaying:
self._reapply_writes_to_succeeded_nodes({handler_task.id: handler_task})
self._match_writes({handler_task.id: handler_task})
for task in self.match_cached_writes():
self.output_writes(task.id, task.writes, cached=True)
return handler_task
@@ -1596,6 +1511,13 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
def __enter__(self) -> Self:
self._graph_lifecycle_events = deque()
# Set by the explicit-id branch when the loaded checkpoint is not
# the latest for the (thread, ns). Lets _first distinguish a
# plain resume (`Command(resume=...)` against head, which carries
# an explicit checkpoint_id only because clients like LangGraph
# Studio echo it back) from time-travel-resume (`Command(resume=
# ...)` against an explicit non-head checkpoint).
self._loaded_explicit_non_head = False
if not self.checkpointer:
saved = None
elif self.checkpoint_config[CONF].get(CONFIG_KEY_CHECKPOINT_ID):
@@ -1603,6 +1525,14 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
# This covers both normal replay and subgraphs resolved via
# checkpoint_map during time-travel.
saved = self.checkpointer.get_tuple(self.checkpoint_config)
if saved is not None and not self.is_nested:
head = self.checkpointer.get_tuple(
patch_configurable(
self.checkpoint_config, {CONFIG_KEY_CHECKPOINT_ID: None}
)
)
if head is not None and head.checkpoint["id"] != saved.checkpoint["id"]:
self._loaded_explicit_non_head = True
elif replay_state := self.config[CONF].get(CONFIG_KEY_REPLAY_STATE):
# Subgraph replay: the parent is replaying and passed us a
# replay_state with its checkpoint_id. Look up our checkpoint
@@ -1651,7 +1581,6 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
else []
)
self._delta_write_futs = []
self._error_handler_write_futs = []
self._exit_delta_writes = (
[] if self.durability == "exit" and self.checkpointer is not None else None
)
@@ -1797,10 +1726,12 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
handler_node = self.nodes[failed_task.name].error_handler_node
if not handler_node:
return None
# ensure error + ERROR_SOURCE_NODE writes are durable before handler runs
if self._error_handler_write_futs:
futs, self._error_handler_write_futs = self._error_handler_write_futs, []
await asyncio.gather(*futs)
writes = list(failed_task.writes)
writes.append((ERROR_SOURCE_NODE, failed_task.name))
self.put_writes(
failed_task.id,
writes,
)
handler_task = prepare_node_error_handler_task(
failed_task,
handler_node_name=handler_node,
@@ -1823,7 +1754,7 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
return None
self.tasks[handler_task.id] = handler_task
if not self.is_replaying:
self._reapply_writes_to_succeeded_nodes({handler_task.id: handler_task})
self._match_writes({handler_task.id: handler_task})
for task in await self.amatch_cached_writes():
self.output_writes(task.id, task.writes, cached=True)
return handler_task
@@ -1853,6 +1784,7 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
async def __aenter__(self) -> Self:
self._graph_lifecycle_events = deque()
self._loaded_explicit_non_head = False
if not self.checkpointer:
saved = None
elif self.checkpoint_config[CONF].get(CONFIG_KEY_CHECKPOINT_ID):
@@ -1860,6 +1792,14 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
# This covers both normal replay and subgraphs resolved via
# checkpoint_map during time-travel.
saved = await self.checkpointer.aget_tuple(self.checkpoint_config)
if saved is not None and not self.is_nested:
head = await self.checkpointer.aget_tuple(
patch_configurable(
self.checkpoint_config, {CONFIG_KEY_CHECKPOINT_ID: None}
)
)
if head is not None and head.checkpoint["id"] != saved.checkpoint["id"]:
self._loaded_explicit_non_head = True
elif replay_state := self.config[CONF].get(CONFIG_KEY_REPLAY_STATE):
# Subgraph replay: the parent is replaying and passed us a
# replay_state with its checkpoint_id. Look up our checkpoint
@@ -1908,7 +1848,6 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
else []
)
self._delta_write_futs = []
self._error_handler_write_futs = []
self._exit_delta_writes = (
[] if self.durability == "exit" and self.checkpointer is not None else None
)
+1 -30
View File
@@ -10,7 +10,7 @@ from typing import (
from uuid import UUID, uuid4
from langchain_core.callbacks import BaseCallbackHandler
from langchain_core.messages import BaseMessage, ToolMessage
from langchain_core.messages import BaseMessage
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, LLMResult
from pydantic import BaseModel
@@ -303,35 +303,6 @@ class StreamMessagesHandlerV2(StreamMessagesHandler, _V2StreamingCallbackHandler
super().__init__(stream, subgraphs, parent_ns=parent_ns)
self._streamed_run_ids: set[UUID] = set()
def _find_and_emit_messages(self, meta: Meta, response: Any) -> None:
"""Like the v1 handler, but skip ToolMessage from node outputs.
Tool results belong on the tools channel / state in v3; v2-flagged streams
must not replay finalized ToolMessages as chat tokens (see MessagesTransformer).
Legacy v1-only `stream_mode="messages"` still emits ToolMessages (see subgraph
streaming tests).
"""
if isinstance(response, BaseMessage) and not isinstance(response, ToolMessage):
self._emit(meta, response, dedupe=True)
elif isinstance(response, Sequence):
for value in response:
if isinstance(value, BaseMessage) and not isinstance(
value, ToolMessage
):
self._emit(meta, value, dedupe=True)
else:
for value in _state_values(response):
if isinstance(value, BaseMessage) and not isinstance(
value, ToolMessage
):
self._emit(meta, value, dedupe=True)
elif isinstance(value, Sequence):
for item in value:
if isinstance(item, BaseMessage) and not isinstance(
item, ToolMessage
):
self._emit(meta, item, dedupe=True)
def on_llm_end(
self,
response: LLMResult,
+1 -2
View File
@@ -31,7 +31,6 @@ from langgraph._internal._constants import (
CONFIG_KEY_CALL,
CONFIG_KEY_SCRATCHPAD,
ERROR,
ERROR_SOURCE_NODE,
INTERRUPT,
NO_WRITES,
RESUME,
@@ -598,7 +597,7 @@ class PregelRunner:
if self._should_route_to_error_handler(task) and not isinstance(
exception, GraphBubbleUp
):
task.writes.append((ERROR_SOURCE_NODE, task.name))
# Mark early in commit path; loop-side routing may happen later.
self._handled_exception_ids.add(id(exception))
self.put_writes()(task.id, task.writes) # type: ignore[misc]
else:
+5 -30
View File
@@ -62,14 +62,6 @@ class StreamMux:
`extensions`, `_native` keys are recorded in `native_keys`, and
any StreamChannel instances are bound and (if named) wired.
Transformers with `StreamTransformer.before_builtins = True` are
registered ahead of the rest, preserving relative order within
each lane. This lets content-mutating transformers (PII
redaction, content filters, etc.) run before built-ins like
`MessagesTransformer` that eagerly snapshot text fields into
their projections. See `StreamTransformer.before_builtins` for
the contract and foot-guns.
Args:
transformers: Already-built transformer instances. Registered
only on this mux they are NOT cloned into child
@@ -120,31 +112,14 @@ class StreamMux:
self._pump_fn: Callable[[], bool] | None = None
self._apump_fn: Callable[[], Awaitable[bool]] | None = None
# Factories run first (they propagate to child mini-muxes via
# `_make_child`), then any pre-built `transformers=` instances
# are registered as root-only — they aren't cloned for child
# scopes. Within each group, transformers with
# `before_builtins = True` are registered ahead of the rest so
# they observe (and may mutate) events before built-ins like
# `MessagesTransformer`. The order *within* each lane matches
# the supplied sequence.
pre: list[StreamTransformer] = []
rest: list[StreamTransformer] = []
# Factories run first (they propagate to child mini-muxes
# via `_make_child`), then any pre-built `transformers=`
# instances are registered as root-only — they aren't cloned
# for child scopes.
if factories is not None:
for factory in factories:
transformer = factory(scope)
(
pre if getattr(transformer, "before_builtins", False) else rest
).append(transformer)
for transformer in (*pre, *rest):
self._register(transformer)
pre.clear()
rest.clear()
self._register(factory(scope))
for transformer in transformers or ():
(pre if getattr(transformer, "before_builtins", False) else rest).append(
transformer
)
for transformer in (*pre, *rest):
self._register(transformer)
def transformer_by_key(self, key: str) -> StreamTransformer | None:
-17
View File
@@ -91,28 +91,11 @@ class StreamTransformer(ABC):
which modes a `stream_events(version="v3")` run requests from the graph.
Empty tuple means the transformer consumes only synthetic
events (or is purely passive).
before_builtins: Opt-in for transformers that must run *before*
built-in transformers like `MessagesTransformer` and
`ToolCallTransformer`. The mux partitions factories by this
flag at registration time: `before_builtins = True`
transformers are registered first, then everything else in
the order supplied. Within each lane, registration order is
preserved. This is the supported hook for content-mutating
transformers (PII redaction, profanity filters, etc.) whose
mutations must land before built-ins eagerly snapshot text
fields into their projections. **Foot-gun:** transformers
in this lane see `tasks` events before `LifecycleTransformer`
and `SubgraphTransformer` consume them mutating
`event["params"]["namespace"]` or the data dict's
`id` / `result` / `error` / `interrupts` fields will desync
their bookkeeping. Observe freely; mutate only fields no
built-in reads (e.g. `delta.text` on `messages` events).
"""
requires_async: ClassVar[bool] = False
supports_sync: ClassVar[bool] = False
required_stream_modes: ClassVar[tuple[str, ...]] = ()
before_builtins: ClassVar[bool] = False
def __init__(self, scope: tuple[str, ...] = ()) -> None:
"""Initialize the transformer with its mux's scope.
@@ -8,7 +8,7 @@ from langchain_core.language_models.chat_model_stream import (
AsyncChatModelStream,
ChatModelStream,
)
from langchain_core.messages import AIMessageChunk, BaseMessage, ToolMessage
from langchain_core.messages import AIMessageChunk, BaseMessage
from langchain_protocol.protocol import MessagesData
from typing_extensions import NotRequired, TypedDict
@@ -203,7 +203,6 @@ class MessagesTransformer(StreamTransformer):
# Correlate protocol events back to a ChatModelStream by run_id
# (attached to the event's metadata by StreamMessagesHandler).
self._by_run: dict[str, ChatModelStream] = {}
self._ignored_runs: set[str] = set()
self._pump_fn: Callable[[], bool] | None = None
self._apump_fn: Callable[[], Awaitable[bool]] | None = None
# Cached as a list once for cheap equality with the protocol
@@ -277,10 +276,8 @@ class MessagesTransformer(StreamTransformer):
self._route_protocol_event(
cast("MessagesData", payload), run_id=run_id, node=node
)
elif (
isinstance(payload, BaseMessage)
and not isinstance(payload, AIMessageChunk)
and not isinstance(payload, ToolMessage)
elif isinstance(payload, BaseMessage) and not isinstance(
payload, AIMessageChunk
):
self._route_whole_message(payload, node=node)
# Legacy AIMessageChunk tuples (from on_llm_new_token) are ignored;
@@ -298,11 +295,6 @@ class MessagesTransformer(StreamTransformer):
) -> None:
event_type = event.get("event")
if event_type == "message-start":
# Tool results are exposed on the tools projection and state
# snapshots; run.messages is the chat-token projection.
if event.get("role") == "tool":
self._ignored_runs.add(run_id)
return
message_id = event.get("message_id")
stream = self._make_stream(
namespace=[],
@@ -312,9 +304,6 @@ class MessagesTransformer(StreamTransformer):
self._by_run[run_id] = stream
self._log.push(stream)
stream.dispatch(event)
elif run_id in self._ignored_runs:
if event_type == "message-finish":
self._ignored_runs.discard(run_id)
elif run_id in self._by_run:
stream = self._by_run[run_id]
stream.dispatch(event)
@@ -330,14 +319,12 @@ class MessagesTransformer(StreamTransformer):
def finalize(self) -> None:
"""Clear any routing state — streams close themselves via `message-finish`."""
self._by_run.clear()
self._ignored_runs.clear()
def fail(self, err: BaseException) -> None:
"""Propagate run error to any streams still open when the graph fails."""
for stream in list(self._by_run.values()):
stream.fail(err)
self._by_run.clear()
self._ignored_runs.clear()
SubgraphStatus = Literal["started", "completed", "failed", "interrupted", "drained"]
+4 -4
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph"
version = "1.2.1"
version = "1.2.0a7"
description = "Building stateful, multi-actor applications with LLMs"
authors = []
requires-python = ">=3.10"
@@ -24,10 +24,10 @@ classifiers = [
'Programming Language :: Python :: 3.13',
]
dependencies = [
"langchain-core>=1.4.0,<2",
"langgraph-checkpoint>=4.1.0,<5.0.0",
"langchain-core>=1.4.0a2,<2",
"langgraph-checkpoint>=4.1.0a4,<5.0.0",
"langgraph-sdk>=0.3.0,<0.4.0",
"langgraph-prebuilt>=1.1.0,<1.2.0",
"langgraph-prebuilt>=1.1.0a2,<1.2.0",
"xxhash>=3.5.0",
"pydantic>=2.7.4",
]
@@ -160,8 +160,8 @@ async def test_exit_resumed_run_sub_freq() -> None:
async def test_exit_count_parity_sync_vs_exit() -> None:
"""Sync and exit durability produce the same update count in
counters_since_delta_snapshot after an equivalent run."""
"""Sync and exit durability produce the same delta_updates_since_snapshot
after an equivalent run."""
for durability in ("sync", "exit"):
saver = InMemorySaver()
graph = _build_graph(saver)
@@ -175,13 +175,9 @@ async def test_exit_count_parity_sync_vs_exit() -> None:
head = saver.get_tuple(config)
assert head is not None
counters = head.metadata.get("counters_since_delta_snapshot", {})
updates, supersteps = counters.get("messages", (0, 0))
assert updates == 2, (
f"durability={durability}: expected updates=2, got {updates}"
)
assert supersteps >= 2, (
f"durability={durability}: expected supersteps>=2, got {supersteps}"
counts = head.metadata.get("delta_updates_since_snapshot", {})
assert counts.get("messages") == 2, (
f"durability={durability}: expected count=2, got {counts}"
)
@@ -200,9 +196,8 @@ async def test_exit_snapshot_fires_at_frequency() -> None:
)
head = saver.get_tuple(config)
assert head is not None
counters1 = head.metadata.get("counters_since_delta_snapshot", {})
updates1 = counters1.get("messages", (0, 0))[0]
assert updates1 == 2
count1 = head.metadata.get("delta_updates_since_snapshot", {}).get("messages", 0)
assert count1 == 2
graph.invoke(
{"messages": [HumanMessage(content="m2", id="h2")]},
@@ -211,9 +206,8 @@ async def test_exit_snapshot_fires_at_frequency() -> None:
)
head = saver.get_tuple(config)
assert head is not None
counters2 = head.metadata.get("counters_since_delta_snapshot", {})
updates2 = counters2.get("messages", (0, 0))[0]
assert updates2 == 0, f"Expected reset to 0 after snapshot, got {updates2}"
count2 = head.metadata.get("delta_updates_since_snapshot", {}).get("messages", 0)
assert count2 == 0, f"Expected reset to 0 after snapshot, got {count2}"
assert isinstance(head.checkpoint["channel_values"].get("messages"), _DeltaSnapshot)
@@ -290,7 +284,7 @@ async def test_exit_multi_run_replay_chain() -> None:
async def test_exit_metadata_round_trip() -> None:
"""K=5 consecutive exit runs with snapshot_frequency=5. Verify metadata
counters_since_delta_snapshot increments correctly across runs."""
delta_updates_since_snapshot increments correctly across runs."""
freq = 5
saver = InMemorySaver()
graph = _build_graph(saver, freq=freq)
@@ -304,16 +298,15 @@ async def test_exit_metadata_round_trip() -> None:
)
head = saver.get_tuple(config)
assert head is not None
counters = head.metadata.get("counters_since_delta_snapshot", {})
updates = counters.get("messages", (0, 0))[0]
count = head.metadata.get("delta_updates_since_snapshot", {}).get("messages", 0)
cumulative = i * 2
if cumulative >= freq:
assert updates == 0 or updates == cumulative % freq or updates < freq, (
f"After run {i}: updates={updates} should have reset or be partial"
assert count == 0 or count == cumulative % freq or count < freq, (
f"After run {i}: count={count} should have reset or be partial"
)
else:
assert updates == cumulative, (
f"After run {i}: expected {cumulative}, got {updates}"
assert count == cumulative, (
f"After run {i}: expected {cumulative}, got {count}"
)
@@ -1,195 +0,0 @@
"""Tests for the supersteps-since-last-snapshot bound on DeltaChannel.
Validates that a delta channel which stops receiving writes is still
force-snapshotted after DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT supersteps,
preventing unbounded ancestor walks.
"""
from typing import Annotated, Any
from unittest.mock import patch
import pytest
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.checkpoint.serde.types import _DeltaSnapshot
from typing_extensions import TypedDict
from langgraph.channels.delta import DeltaChannel
from langgraph.graph import END, START, StateGraph
from langgraph.pregel._checkpoint import delta_channels_to_snapshot
pytestmark = pytest.mark.anyio
def _simple_reducer(current: list, updates: list) -> list:
"""Flatten updates into current list (each update is itself a list)."""
result = list(current)
for u in updates:
if isinstance(u, list):
result.extend(u)
else:
result.append(u)
return result
def _build_two_channel_graph(
checkpointer: InMemorySaver,
*,
freq_a: int = 10_000,
freq_b: int = 10_000,
n_loops: int = 1,
) -> Any:
"""Graph with two delta channels A and B.
The node only writes to channel A; B is never written by the node.
`n_loops` controls how many supersteps the graph runs (via chained nodes).
"""
ch_a = DeltaChannel(_simple_reducer, list, snapshot_frequency=freq_a)
ch_b = DeltaChannel(_simple_reducer, list, snapshot_frequency=freq_b)
State = TypedDict( # noqa: UP013
"State",
{"a": Annotated[list, ch_a], "b": Annotated[list, ch_b]},
) # type: ignore[call-overload]
builder = StateGraph(State)
for i in range(n_loops):
name = f"step_{i}"
def node_fn(state: dict, _i: int = i) -> dict:
return {"a": [f"a-val-{_i}"]}
builder.add_node(name, node_fn)
if i == 0:
builder.add_edge(START, name)
else:
builder.add_edge(f"step_{i - 1}", name)
if i == n_loops - 1:
builder.add_edge(name, END)
return builder.compile(checkpointer=checkpointer)
async def test_forced_snapshot_single_run() -> None:
"""A single invoke with enough supersteps triggers snapshot on the
unwritten channel B via the supersteps bound."""
max_ss = 3
with patch(
"langgraph.pregel._checkpoint.DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT",
max_ss,
):
saver = InMemorySaver()
graph = _build_two_channel_graph(saver, n_loops=4)
config = {"configurable": {"thread_id": "single-run-ss"}}
graph.invoke({"a": ["seed-a"], "b": ["seed-b"]}, config)
head = saver.get_tuple(config)
assert head is not None
assert isinstance(head.checkpoint["channel_values"].get("b"), _DeltaSnapshot), (
"Channel B should have been force-snapshotted via supersteps bound"
)
state = graph.get_state(config)
assert state.values["b"] == ["seed-b"]
assert "seed-a" in state.values["a"]
async def test_forced_snapshot_accumulates_across_runs() -> None:
"""Supersteps counter for an unwritten channel persists across separate
invoke() calls. After enough runs, the channel is force-snapshotted."""
max_ss = 5
with patch(
"langgraph.pregel._checkpoint.DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT",
max_ss,
):
saver = InMemorySaver()
graph = _build_two_channel_graph(saver, n_loops=1)
config = {"configurable": {"thread_id": "multi-run-ss"}}
graph.invoke({"a": ["init-a"], "b": ["init-b"]}, config)
for i in range(1, 6):
graph.invoke({"a": [f"run-{i}"]}, config)
head = saver.get_tuple(config)
assert head is not None
counters = head.metadata.get("counters_since_delta_snapshot", {})
b_counters = counters.get("b", (0, 0))
if b_counters == (0, 0):
assert isinstance(
head.checkpoint["channel_values"].get("b"), _DeltaSnapshot
), f"Run {i}: counter reset but no snapshot blob for B"
break
else:
pytest.fail("Channel B was never force-snapshotted after multiple runs")
state = graph.get_state(config)
assert state.values["b"] == ["init-b"]
assert "init-a" in state.values["a"]
async def test_predicate_fires_on_supersteps_overflow() -> None:
"""Unit test: delta_channels_to_snapshot fires when supersteps >= MAX
even when updates == 0."""
ch = DeltaChannel(_simple_reducer, list, snapshot_frequency=10_000)
ch.key = "x"
ch_instance = ch.from_checkpoint(None)
channels = {"x": ch_instance}
counters: dict[str, tuple[int, int]] = {"x": (0, 5000)}
result = delta_channels_to_snapshot(channels, counters)
assert "x" in result
counters_below: dict[str, tuple[int, int]] = {"x": (0, 4999)}
result2 = delta_channels_to_snapshot(channels, counters_below)
assert "x" not in result2
async def test_counter_reset_after_supersteps_snapshot() -> None:
"""After the supersteps bound triggers a snapshot, the counters for
that channel reset. Verify by using a bound higher than one run's
supersteps so we can see the counter in an intermediate state."""
max_ss = 15
with patch(
"langgraph.pregel._checkpoint.DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT",
max_ss,
):
saver = InMemorySaver()
graph = _build_two_channel_graph(saver, n_loops=4)
config = {"configurable": {"thread_id": "counter-reset"}}
graph.invoke({"a": ["seed-a"], "b": ["seed-b"]}, config)
head = saver.get_tuple(config)
assert head is not None
counters = head.metadata.get("counters_since_delta_snapshot", {})
b_counters = counters.get("b", (0, 0))
run1_supersteps = b_counters[1]
assert run1_supersteps > 0, "Should have some supersteps"
assert b_counters[0] == 1, "B written once (input step)"
graph.invoke({"a": ["more-a"]}, config)
head2 = saver.get_tuple(config)
assert head2 is not None
counters2 = head2.metadata.get("counters_since_delta_snapshot", {})
b_counters2 = counters2.get("b", (0, 0))
run2_supersteps = b_counters2[1]
assert run2_supersteps > run1_supersteps, "Supersteps should accumulate"
assert b_counters2[0] == 1, "B written once total (only original input)"
graph.invoke({"a": ["even-more"]}, config)
head3 = saver.get_tuple(config)
assert head3 is not None
assert isinstance(
head3.checkpoint["channel_values"].get("b"), _DeltaSnapshot
), "B should have snapshotted at supersteps >= max_ss"
counters3 = head3.metadata.get("counters_since_delta_snapshot", {})
b_counters3 = counters3.get("b", (0, 0))
assert b_counters3[1] < max_ss, (
f"After snapshot, supersteps should have reset, got {b_counters3}"
)
state = graph.get_state(config)
assert state.values["b"] == ["seed-b"]
+1 -523
View File
@@ -15,7 +15,7 @@ from langchain_core.callbacks import AsyncCallbackManagerForLLMRun, BaseCallback
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage, HumanMessage
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
from langchain_core.runnables import RunnableConfig, RunnableLambda, RunnableParallel
from langchain_core.runnables import RunnableLambda, RunnableParallel
from langgraph.checkpoint.memory import InMemorySaver, MemorySaver
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
from typing_extensions import TypedDict
@@ -2280,525 +2280,3 @@ def test_node_without_error_handler_still_fails_run():
with pytest.raises(ValueError, match="no handler"):
graph.invoke({"foo": ""})
# ---------------------------------------------------------------------------
# set_node_defaults()
# ---------------------------------------------------------------------------
def test_set_node_defaults_error_handler_catches_all_nodes():
class State(TypedDict):
route: str
foo: Annotated[list[str], operator.add]
def route_node(state: State) -> Command:
return Command(goto=state["route"])
def fail_a(state: State) -> State:
raise RuntimeError("a failed")
def fail_b(state: State) -> State:
raise RuntimeError("b failed")
captured: dict[str, list[str]] = {"nodes": []}
def default_handler(state: State, error: NodeError) -> State:
captured["nodes"].append(error.node)
return {"foo": [f"handled_{error.node}"]}
graph = (
StateGraph(State)
.set_node_defaults(error_handler=default_handler)
.add_node("route_node", route_node)
.add_node("fail_a", fail_a)
.add_node("fail_b", fail_b)
.add_edge(START, "route_node")
.add_conditional_edges(
"route_node", lambda s: s["route"], path_map=["fail_a", "fail_b"]
)
.compile()
)
result_a = graph.invoke({"route": "fail_a", "foo": []})
result_b = graph.invoke({"route": "fail_b", "foo": []})
assert result_a["foo"] == ["handled_fail_a"]
assert result_b["foo"] == ["handled_fail_b"]
assert "fail_a" in captured["nodes"]
assert "fail_b" in captured["nodes"]
def test_set_node_defaults_error_handler_overridden_by_node_handler():
class State(TypedDict):
route: str
foo: Annotated[list[str], operator.add]
def route_node(state: State) -> Command:
return Command(goto=state["route"])
def fail_a(state: State) -> State:
raise RuntimeError("a failed")
def fail_b(state: State) -> State:
raise RuntimeError("b failed")
captured: dict[str, list[str]] = {"handler": []}
def node_handler(state: State, error: NodeError) -> State:
captured["handler"].append(f"node:{error.node}")
return {"foo": [f"node_handled_{error.node}"]}
def default_handler(state: State, error: NodeError) -> State:
captured["handler"].append(f"default:{error.node}")
return {"foo": [f"default_handled_{error.node}"]}
graph = (
StateGraph(State)
.set_node_defaults(error_handler=default_handler)
.add_node("route_node", route_node)
.add_node("fail_a", fail_a, error_handler=node_handler)
.add_node("fail_b", fail_b)
.add_edge(START, "route_node")
.add_conditional_edges(
"route_node", lambda s: s["route"], path_map=["fail_a", "fail_b"]
)
.compile()
)
result_a = graph.invoke({"route": "fail_a", "foo": []})
assert result_a["foo"] == ["node_handled_fail_a"]
assert "node:fail_a" in captured["handler"]
assert "default:fail_a" not in captured["handler"]
result_b = graph.invoke({"route": "fail_b", "foo": []})
assert result_b["foo"] == ["default_handled_fail_b"]
assert "default:fail_b" in captured["handler"]
def test_set_node_defaults_error_handler_skips_per_node_handler_nodes():
"""If a per-node error handler itself raises, the default handler must NOT
catch it -- the run should fail."""
class State(TypedDict):
foo: str
def always_failing(state: State) -> State:
raise RuntimeError("node boom")
def broken_handler(state: State, error: NodeError) -> State:
raise RuntimeError("handler boom")
def default_handler(state: State, error: NodeError) -> State:
return {"foo": "default recovered"}
graph = (
StateGraph(State)
.set_node_defaults(error_handler=default_handler)
.add_node("always_failing", always_failing, error_handler=broken_handler)
.add_edge(START, "always_failing")
.compile()
)
with pytest.raises(RuntimeError, match="handler boom"):
graph.invoke({"foo": ""})
def test_set_node_defaults_error_handler_failure_fails_run():
"""When the default handler itself raises, the run fails (no infinite
recursion, no double-routing)."""
class State(TypedDict):
foo: str
def always_failing(state: State) -> State:
raise RuntimeError("node boom")
def broken_default_handler(state: State, error: NodeError) -> State:
raise RuntimeError("default handler boom")
graph = (
StateGraph(State)
.set_node_defaults(error_handler=broken_default_handler)
.add_node("always_failing", always_failing)
.add_edge(START, "always_failing")
.compile()
)
with pytest.raises(RuntimeError, match="default handler boom"):
graph.invoke({"foo": ""})
def test_set_node_defaults_error_handler_receives_runnable_config():
class State(TypedDict):
foo: str
def always_failing(state: State) -> State:
raise RuntimeError("boom")
captured: dict[str, Any] = {}
def default_handler(
state: State, error: NodeError, config: RunnableConfig
) -> State:
captured["thread_id"] = config["configurable"].get("thread_id")
return {"foo": "handled"}
checkpointer = MemorySaver()
graph = (
StateGraph(State)
.set_node_defaults(error_handler=default_handler)
.add_node("always_failing", always_failing)
.add_edge(START, "always_failing")
.compile(checkpointer=checkpointer)
)
thread_id = str(uuid4())
result = graph.invoke(
{"foo": ""}, config={"configurable": {"thread_id": thread_id}}
)
assert result["foo"] == "handled"
assert captured["thread_id"] == thread_id
def test_set_node_defaults_error_handler_collides_with_user_node():
class State(TypedDict):
foo: str
def default_handler(state: State, error: NodeError) -> State:
return {"foo": "handled"}
builder = (
StateGraph(State)
.set_node_defaults(error_handler=default_handler)
.add_node("__default_error_handler__", lambda s: s)
.add_edge(START, "__default_error_handler__")
)
with pytest.raises(ValueError, match="__default_error_handler__"):
builder.compile()
def test_set_node_defaults_retry_policy():
class State(TypedDict):
foo: str
attempts = 0
def flaky_node(state: State) -> State:
nonlocal attempts
attempts += 1
if attempts < 3:
raise ValueError("not yet")
return {"foo": "ok"}
graph = (
StateGraph(State)
.set_node_defaults(
retry_policy=RetryPolicy(
max_attempts=3, initial_interval=0.01, jitter=False, retry_on=ValueError
)
)
.add_node("flaky", flaky_node)
.add_edge(START, "flaky")
.compile()
)
with patch("time.sleep"):
result = graph.invoke({"foo": ""})
assert result["foo"] == "ok"
assert attempts == 3
def test_set_node_defaults_retry_policy_per_node_wins():
class State(TypedDict):
foo: str
attempts = 0
def flaky_node(state: State) -> State:
nonlocal attempts
attempts += 1
if attempts < 2:
raise ValueError("not yet")
return {"foo": "ok"}
graph = (
StateGraph(State)
.set_node_defaults(
retry_policy=RetryPolicy(
max_attempts=1, initial_interval=0.01, jitter=False, retry_on=ValueError
)
)
.add_node(
"flaky",
flaky_node,
retry_policy=RetryPolicy(
max_attempts=3,
initial_interval=0.01,
jitter=False,
retry_on=ValueError,
),
)
.add_edge(START, "flaky")
.compile()
)
with patch("time.sleep"):
result = graph.invoke({"foo": ""})
assert result["foo"] == "ok"
assert attempts == 2
@pytest.mark.anyio
async def test_set_node_defaults_timeout():
class State(TypedDict):
foo: str
async def slow_node(state: State) -> State:
await asyncio.sleep(10)
return {"foo": "should-not-happen"}
graph = (
StateGraph(State)
.set_node_defaults(timeout=TimeoutPolicy(run_timeout=0.05))
.add_node("slow", slow_node)
.add_edge(START, "slow")
.compile()
)
from langgraph.errors import NodeTimeoutError
with pytest.raises(NodeTimeoutError):
await graph.ainvoke({"foo": ""})
@pytest.mark.anyio
async def test_set_node_defaults_timeout_per_node_wins():
"""Per-node timeout overrides the default; a generous per-node timeout
allows a node to complete even when the builder default is very short."""
class State(TypedDict):
foo: str
async def quick_node(state: State) -> State:
await asyncio.sleep(0.05)
return {"foo": "done"}
graph = (
StateGraph(State)
.set_node_defaults(timeout=TimeoutPolicy(run_timeout=0.01))
.add_node("quick", quick_node, timeout=TimeoutPolicy(run_timeout=5.0))
.add_edge(START, "quick")
.compile()
)
result = await graph.ainvoke({"foo": ""})
assert result["foo"] == "done"
def test_set_node_defaults_chaining():
"""set_node_defaults() is chainable and can be called in any order relative to add_node."""
class State(TypedDict):
foo: str
def always_failing(state: State) -> State:
raise RuntimeError("boom")
def handler(state: State, error: NodeError) -> State:
return {"foo": "handled"}
graph = (
StateGraph(State)
.add_node("a", always_failing)
.add_edge(START, "a")
.set_node_defaults(
retry_policy=RetryPolicy(
max_attempts=1, initial_interval=0.01, jitter=False
),
error_handler=handler,
)
.compile()
)
result = graph.invoke({"foo": ""})
assert result["foo"] == "handled"
def test_set_node_defaults_combined_retry_and_error_handler():
"""Retries are exhausted first, then the error handler runs."""
class State(TypedDict):
foo: str
attempts = 0
captured: dict[str, Any] = {}
def always_failing(state: State) -> State:
nonlocal attempts
attempts += 1
raise ValueError("Always fails")
def handler(state: State, error: NodeError) -> State:
captured["error"] = str(error.error)
return {"foo": "handled"}
graph = (
StateGraph(State)
.set_node_defaults(
retry_policy=RetryPolicy(
max_attempts=2,
initial_interval=0.01,
jitter=False,
retry_on=ValueError,
),
error_handler=handler,
)
.add_node("fail", always_failing)
.add_edge(START, "fail")
.compile()
)
with patch("time.sleep"):
result = graph.invoke({"foo": ""})
assert result["foo"] == "handled"
assert attempts == 2
assert captured["error"] == "Always fails"
def test_error_handler_resumes_after_crash():
"""If the error handler crashes, resuming should re-schedule the handler
(not re-execute the original failed node)."""
class State(TypedDict):
foo: str
call_count = {"node": 0, "handler": 0}
captured_errors: list[NodeError] = []
def failing_node(state: State) -> State:
call_count["node"] += 1
raise RuntimeError("boom")
handler_should_fail = [True]
def handler(state: State, error: NodeError) -> State:
call_count["handler"] += 1
captured_errors.append(error)
if handler_should_fail[0]:
raise RuntimeError("handler crash")
return {"foo": "recovered"}
checkpointer = MemorySaver()
graph = (
StateGraph(State)
.set_node_defaults(error_handler=handler)
.add_node("fail", failing_node)
.add_edge(START, "fail")
.compile(checkpointer=checkpointer)
)
config = {"configurable": {"thread_id": "t1"}}
# First invoke: node fails -> handler runs -> handler crashes -> run fails
with pytest.raises(RuntimeError, match="handler crash"):
graph.invoke({"foo": ""}, config)
assert call_count["node"] == 1
assert call_count["handler"] == 1
assert captured_errors[0].node == "fail"
assert isinstance(captured_errors[0].error, RuntimeError)
assert str(captured_errors[0].error) == "boom"
# Resume: handler should run again, NOT the original node
handler_should_fail[0] = False
result = graph.invoke(None, config)
assert result["foo"] == "recovered"
assert call_count["node"] == 1 # NOT re-executed
assert call_count["handler"] == 2 # ran again on resume
# on resume the error was round-tripped through the checkpointer, so it
# may be deserialized as a string representation rather than the original
# exception type — verify the node name and that the error content matches.
assert captured_errors[1].node == "fail"
assert "boom" in str(captured_errors[1].error)
def test_error_handler_resumes_after_crash_multiple_nodes():
"""When multiple nodes fail in the same superstep and all have error handlers:
- error handlers start running while other nodes may still be in-flight
- resuming re-schedules each handler (not re-executes the original nodes)
"""
class State(TypedDict):
results: Annotated[list[str], operator.add]
call_count = {"a": 0, "b": 0, "handler_a": 0, "handler_b": 0}
handler_a_started = threading.Event()
def node_a(state: State) -> State:
call_count["a"] += 1
raise RuntimeError("a failed")
def node_b(state: State) -> State:
call_count["b"] += 1
# Block until handler_a has started — proves the error handler runs
# concurrently with in-flight nodes in the same superstep.
assert handler_a_started.wait(timeout=5), "handler_a never started"
raise RuntimeError("b failed")
handler_should_fail = [True]
def handler_a(state: State, error: NodeError) -> State:
call_count["handler_a"] += 1
assert error.node == "a"
assert "a failed" in str(error.error)
handler_a_started.set()
if handler_should_fail[0]:
raise RuntimeError("handler_a crash")
return {"results": [f"recovered_a:{error.node}"]}
def handler_b(state: State, error: NodeError) -> State:
call_count["handler_b"] += 1
assert error.node == "b"
assert "b failed" in str(error.error)
if handler_should_fail[0]:
raise RuntimeError("handler_b crash")
return {"results": [f"recovered_b:{error.node}"]}
checkpointer = MemorySaver()
graph = (
StateGraph(State)
.add_node("a", node_a, error_handler=handler_a)
.add_node("b", node_b, error_handler=handler_b)
.add_edge(START, "a")
.add_edge(START, "b")
.compile(checkpointer=checkpointer)
)
config = {"configurable": {"thread_id": "t1"}}
# First invoke: node_a fails immediately -> handler_a starts (sets event) ->
# node_b unblocks and fails -> handler_b starts -> both handlers crash
with pytest.raises(RuntimeError):
graph.invoke({"results": []}, config)
assert call_count["a"] == 1
assert call_count["b"] == 1
assert call_count["handler_a"] == 1
assert call_count["handler_b"] == 1
# Resume: both handlers should run again, NOT the original nodes
handler_should_fail[0] = False
handler_a_started.clear()
result = graph.invoke(None, config)
assert call_count["a"] == 1 # NOT re-executed
assert call_count["b"] == 1 # NOT re-executed
assert call_count["handler_a"] == 2 # ran again on resume
assert call_count["handler_b"] == 2 # ran again on resume
assert "recovered_a:a" in result["results"]
assert "recovered_b:b" in result["results"]
@@ -1,296 +0,0 @@
"""Tests for `StreamTransformer.before_builtins` lane ordering.
`before_builtins = True` transformers are registered ahead of the
rest, preserving relative order within each lane. This lets
content-mutating transformers run before built-ins like
`MessagesTransformer` that eagerly snapshot text fields into their
projections.
"""
from __future__ import annotations
import time
from typing import Any, ClassVar
from langgraph.stream._mux import StreamMux
from langgraph.stream._types import StreamTransformer
from langgraph.stream.stream_channel import StreamChannel
from langgraph.stream.transformers import (
LifecycleTransformer,
MessagesTransformer,
TasksTransformer,
)
TS = int(time.time() * 1000)
def _messages_event(namespace: list[str], data: Any) -> dict[str, Any]:
return {
"type": "event",
"method": "messages",
"params": {"namespace": namespace, "timestamp": TS, "data": data},
}
class _Tap(StreamTransformer):
"""Records the order it observed each event."""
required_stream_modes: ClassVar[tuple[str, ...]] = ()
def __init__(self, scope: tuple[str, ...] = (), *, label: str = "tap") -> None:
super().__init__(scope)
self.label = label
self.log: list[str] = []
self._channel: StreamChannel[str] = StreamChannel()
def init(self) -> dict[str, Any]:
return {f"tap_{self.label}": self._channel}
def process(self, event: dict[str, Any]) -> bool:
self.log.append(self.label)
return True
class _PreTap(_Tap):
before_builtins: ClassVar[bool] = True
class _TextRedactor(StreamTransformer):
"""Mutates `text-delta` events in place to a fixed redacted string."""
before_builtins: ClassVar[bool] = True
required_stream_modes: ClassVar[tuple[str, ...]] = ("messages",)
def __init__(self, scope: tuple[str, ...] = ()) -> None:
super().__init__(scope)
self._channel: StreamChannel[str] = StreamChannel()
def init(self) -> dict[str, Any]:
return {"redactor": self._channel}
def process(self, event: dict[str, Any]) -> bool:
if event.get("method") != "messages":
return True
payload, _meta = event["params"]["data"]
if isinstance(payload, dict) and payload.get("event") == "content-block-delta":
delta = payload.get("delta") or {}
if delta.get("type") == "text-delta":
delta["text"] = "[REDACTED]"
return True
def test_before_builtins_factories_run_before_others() -> None:
"""A `before_builtins=True` factory is registered ahead of the rest."""
seen: list[type[StreamTransformer]] = []
class _PostTap(_Tap):
def __init__(self, scope: tuple[str, ...] = ()) -> None:
super().__init__(scope, label="post")
def process(self, event: dict[str, Any]) -> bool:
seen.append(_PostTap)
return True
class _EagerTap(_Tap):
before_builtins: ClassVar[bool] = True
def __init__(self, scope: tuple[str, ...] = ()) -> None:
super().__init__(scope, label="eager")
def process(self, event: dict[str, Any]) -> bool:
seen.append(_EagerTap)
return True
mux = StreamMux(
factories=[_PostTap, _EagerTap],
scope=(),
is_async=False,
)
# `_EagerTap` was supplied second but should be registered first.
types_in_order = [type(t) for t in mux._transformers]
assert types_in_order.index(_EagerTap) < types_in_order.index(_PostTap)
mux.push(
_messages_event([], ({"event": "message-start", "role": "ai", "id": "m1"}, {}))
)
# And it ran first when the event was dispatched.
assert seen == [_EagerTap, _PostTap]
def test_within_lane_order_preserved() -> None:
"""Within each lane, the supplied order is the registration order."""
class _A(_PreTap):
pass
class _B(_PreTap):
pass
class _C(_Tap):
pass
class _D(_Tap):
pass
mux = StreamMux(
factories=[
lambda scope: _C(scope, label="c"),
lambda scope: _A(scope, label="a"),
lambda scope: _D(scope, label="d"),
lambda scope: _B(scope, label="b"),
],
scope=(),
is_async=False,
)
order = [t.label for t in mux._transformers] # type: ignore[attr-defined]
# Pre lane (a, b) ahead of default lane (c, d). Within each, supplied order kept.
assert order == ["a", "b", "c", "d"]
def test_redactor_runs_before_messages_transformer() -> None:
"""Content mutated by a pre-lane transformer reaches `MessagesTransformer`."""
# Order supplied: built-ins first (as in pregel/main.py), then the
# opt-in pre-lane redactor. Partitioning should still register the
# redactor first.
mux = StreamMux(
factories=[MessagesTransformer, _TextRedactor],
scope=(),
is_async=False,
)
types_in_order = [type(t) for t in mux._transformers]
assert types_in_order.index(_TextRedactor) < types_in_order.index(
MessagesTransformer
)
def test_lifecycle_unaffected_by_pre_lane_observer() -> None:
"""An observer-only pre-lane transformer doesn't break lifecycle bookkeeping."""
class _NoopPreObserver(StreamTransformer):
before_builtins: ClassVar[bool] = True
required_stream_modes: ClassVar[tuple[str, ...]] = ("tasks",)
def __init__(self, scope: tuple[str, ...] = ()) -> None:
super().__init__(scope)
self._channel: StreamChannel[str] = StreamChannel()
self.seen: list[tuple[str, ...]] = []
def init(self) -> dict[str, Any]:
return {"noop_observer": self._channel}
def process(self, event: dict[str, Any]) -> bool:
if event.get("method") == "tasks":
self.seen.append(tuple(event["params"]["namespace"]))
return True
mux = StreamMux(
factories=[LifecycleTransformer, TasksTransformer, _NoopPreObserver],
scope=(),
is_async=False,
)
types_in_order = [type(t) for t in mux._transformers]
assert types_in_order.index(_NoopPreObserver) < types_in_order.index(
LifecycleTransformer
)
observer = next(t for t in mux._transformers if isinstance(t, _NoopPreObserver))
lifecycle = next(
t for t in mux._transformers if isinstance(t, LifecycleTransformer)
)
# Push a synthetic `tasks` event that lifecycle would normally track.
mux.push(
{
"type": "event",
"method": "tasks",
"params": {
"namespace": ["child:abc"],
"timestamp": TS,
"data": {"name": "child"},
},
}
)
# Pre-lane observer saw the event, AND lifecycle's bookkeeping still
# registered the new namespace (the observer didn't mutate anything).
assert observer.seen == [("child:abc",)]
assert ("child:abc",) in lifecycle._seen # type: ignore[attr-defined]
def test_default_is_false() -> None:
"""`StreamTransformer.before_builtins` defaults to False."""
assert StreamTransformer.before_builtins is False
assert MessagesTransformer.before_builtins is False
assert LifecycleTransformer.before_builtins is False
def test_pre_lane_mutation_lands_in_messages_projection() -> None:
"""End-to-end: text mutated by a pre-lane transformer is what
`MessagesTransformer` snapshots into its `ChatModelStream` projection.
Without `before_builtins`, the redactor would run after
MessagesTransformer's eager extraction and the projection would
contain the raw, un-redacted text.
"""
mux = StreamMux(
factories=[MessagesTransformer, _TextRedactor],
scope=(),
is_async=False,
)
messages_transformer = next(
t for t in mux._transformers if isinstance(t, MessagesTransformer)
)
# Unblock both the mux's main log and the messages projection log so
# synthetic pushes are accepted without a real consumer attached.
mux._events._subscribed = True
messages_transformer._log._subscribed = True
meta = {"langgraph_node": "model", "run_id": "run-1"}
# message-start → MessagesTransformer creates a ChatModelStream.
mux.push(
_messages_event(
[],
({"event": "message-start", "role": "ai", "id": "msg-1"}, meta),
)
)
# content-block-delta carrying the secret. The redactor (pre-lane)
# mutates `delta.text` BEFORE MessagesTransformer snapshots it.
mux.push(
_messages_event(
[],
(
{
"event": "content-block-delta",
"index": 0,
"delta": {"type": "text-delta", "text": "secret@example.com"},
},
meta,
),
)
)
# Capture the still-open stream before message-finish removes it from
# MessagesTransformer's `_by_run` dict.
chat_stream = messages_transformer._by_run["run-1"] # type: ignore[attr-defined]
# message-finish → closes the stream.
mux.push(
_messages_event(
[],
({"event": "message-finish"}, meta),
)
)
# The redactor mutated `delta.text` to "[REDACTED]" before
# MessagesTransformer snapshotted the string into the text
# accumulator. Without `before_builtins`, the accumulator would hold
# the raw "secret@example.com".
assert chat_stream._text_acc == "[REDACTED]", ( # type: ignore[attr-defined]
f"expected redacted text in projection, got {chat_stream._text_acc!r}"
)
@@ -12,7 +12,7 @@ from langchain_core.language_models.chat_model_stream import (
AsyncChatModelStream,
ChatModelStream,
)
from langchain_core.messages import AIMessage, AIMessageChunk, ToolMessage
from langchain_core.messages import AIMessage, AIMessageChunk
from langchain_core.runnables import RunnableConfig
from typing_extensions import TypedDict
@@ -213,23 +213,6 @@ class TestProtocolEventRouting:
log.close()
assert _unstamped(log._items) == []
def test_tool_role_protocol_events_are_ignored(self) -> None:
t, log = _make_sync_transformer()
for evt in [
{"event": "message-start", "role": "tool", "message_id": "tool-msg-1"},
{
"event": "content-block-delta",
"index": 0,
"content_block": {"type": "text", "text": "[]"},
},
{"event": "message-finish", "reason": "stop"},
]:
t.process(_proto_event(evt, run_id="tool-run"))
log.close()
assert _unstamped(log._items) == []
assert t._ignored_runs == set()
def test_concurrent_streams_routed_by_run_id(self) -> None:
t, log = _make_sync_transformer()
life_a = _lifecycle(text="aaaa", message_id="run-a")
@@ -290,29 +273,6 @@ class TestWholeMessageFallback:
assert stream.done
assert stream.output.text == "the full answer"
def test_whole_tool_message_is_ignored(self) -> None:
t, log = _make_sync_transformer()
t.process(
{
"type": "event",
"method": "messages",
"params": {
"namespace": [],
"timestamp": TS,
"data": (
ToolMessage(
content="[]",
id="tool-msg-1",
tool_call_id="call_1",
),
{"langgraph_node": "tools"},
),
},
}
)
log.close()
assert _unstamped(log._items) == []
def test_whole_message_has_full_lifecycle(self) -> None:
t, log = _make_sync_transformer()
t.process(_whole_msg("full"))
@@ -889,23 +849,6 @@ class TestStreamMessagesHandlerV2Unit:
assert emitted == []
def test_on_chain_end_does_not_emit_tool_messages(self) -> None:
from uuid import uuid4
from langgraph.pregel._messages import StreamMessagesHandlerV2
emitted: list[Any] = []
handler = StreamMessagesHandlerV2(emitted.append, subgraphs=False)
run_id = uuid4()
handler.metadata[run_id] = ((), {"langgraph_node": "tools"})
handler.on_chain_end(
{"messages": [ToolMessage(content="[]", tool_call_id="call_1")]},
run_id=run_id,
)
assert emitted == []
def test_on_llm_end_dedupes_when_final_message_id_differs(self) -> None:
"""A streamed v2 message should not be emitted again from the final
AIMessage fallback when its final id does not match `message-start`."""
+93
View File
@@ -3964,3 +3964,96 @@ def test_subgraph_called_in_loop_loads_state_on_replay(
assert len(observed) == 1
assert observed[0] == ("sub_step", {"sub_trail": ["s", "s", "s"]})
def test_subgraph_time_travel_resume_with_command_in_one_call(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
"""Repro for time-travel-resume issued as a SINGLE call:
`invoke(Command(resume=...), config={..., checkpoint_id: <non-head>})`.
Equivalent to the existing two-call pattern
(`test_subgraph_time_travel_resume_from_first_interrupt`) which does
`invoke(None, sub_config)` then `invoke(Command(resume=...))`. Clients
like LangGraph API server / Studio can submit this as one request.
"""
called: list[str] = []
def step_a(state: State) -> State:
called.append("step_a")
return {"value": ["step_a_done"]}
def ask_1(state: State) -> State:
called.append("ask_1")
answer = interrupt("Question 1?")
return {"value": [f"ask_1:{answer}"]}
def ask_2(state: State) -> State:
called.append("ask_2")
answer = interrupt("Question 2?")
return {"value": [f"ask_2:{answer}"]}
executor = (
StateGraph(State)
.add_node("step_a", step_a)
.add_node("ask_1", ask_1)
.add_node("ask_2", ask_2)
.add_edge(START, "step_a")
.add_edge("step_a", "ask_1")
.add_edge("ask_1", "ask_2")
.add_edge("ask_2", "__end__")
.compile(checkpointer=True)
)
graph = (
StateGraph(State)
.add_node("executor", executor)
.add_edge(START, "executor")
.compile(checkpointer=sync_checkpointer)
)
config = {"configurable": {"thread_id": "1"}}
# Run to completion through both interrupts.
graph.invoke({"value": []}, config)
parent_state_at_first = graph.get_state(config, subgraphs=True)
sub_config_at_first = parent_state_at_first.tasks[0].state.config
graph.invoke(Command(resume="answer_1"), config)
graph.invoke(Command(resume="answer_2"), config)
final_after_original = graph.get_state(config).values
assert final_after_original["value"] == [
"step_a_done",
"ask_1:answer_1",
"ask_2:answer_2",
]
# ONE-CALL time-travel-resume: pair Command(resume=...) with a
# non-head parent checkpoint_id in the same invocation.
called.clear()
result = graph.invoke(Command(resume="new_answer_1"), sub_config_at_first)
# `step_a` ran before the time-travel target — must not re-execute.
assert "step_a" not in called, f"step_a re-executed; called={called}"
# The new answer must be applied (not the cached `answer_1`).
assert result.get("__interrupt__"), (
f"expected to pause at the next interrupt; got result={result}"
)
assert result["__interrupt__"][0].value == "Question 2?"
sub_state_now = graph.get_state(config, subgraphs=True).tasks[0].state
assert sub_state_now.values["value"] == ["step_a_done", "ask_1:new_answer_1"], (
f"subgraph state at int2 should reflect new_answer_1; "
f"got {sub_state_now.values}"
)
# Resume the second interrupt with another new answer; ensure the
# whole branch concludes consistently.
called.clear()
final = graph.invoke(Command(resume="new_answer_2"), config)
assert "step_a" not in called
assert "ask_1" not in called
assert final["value"] == [
"step_a_done",
"ask_1:new_answer_1",
"ask_2:new_answer_2",
]
+21 -21
View File
@@ -871,11 +871,11 @@ wheels = [
[[package]]
name = "idna"
version = "3.15"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
@@ -1350,7 +1350,7 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.4.0"
version = "1.4.0a2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonpatch" },
@@ -1363,9 +1363,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "uuid-utils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/59/de/679a53472c25860837e32c0442c962fa86e95317a36460e2c9d5c91b17c2/langchain_core-1.4.0.tar.gz", hash = "sha256:1dc341eed802ed9c117c0df3923c991e5e9e226571e5725c194eeb5bd93d1a7f", size = 920260, upload-time = "2026-05-11T18:42:35.919Z" }
sdist = { url = "https://files.pythonhosted.org/packages/3c/93/68bafa047f8e1770d0cf0f61d6c70889f1dec42ef6bd263540d916c421b9/langchain_core-1.4.0a2.tar.gz", hash = "sha256:b723c7961b615c7f2180ce2bcf352fdad8247bc51a60adecd3d97088235c120d", size = 916486, upload-time = "2026-05-01T15:02:19.029Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/1a/86c38c27b81913a1c6c12448cab55defb5a1097c7dc9a4cea83f55477a2d/langchain_core-1.4.0-py3-none-any.whl", hash = "sha256:23cbbdb46e38ddd1dd5247e6167e96013eae74bea4c5949c550809970a9e565c", size = 548120, upload-time = "2026-05-11T18:42:33.992Z" },
{ url = "https://files.pythonhosted.org/packages/4e/8e/933e0ba7ba0430ce264e36b178d581b255239ad45093872483142d93478c/langchain_core-1.4.0a2-py3-none-any.whl", hash = "sha256:a5c689f8404357df797120c012da7704144a953b2ae18f258df263301e7badd5", size = 546297, upload-time = "2026-05-01T15:02:17.731Z" },
]
[[package]]
@@ -1382,7 +1382,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "1.2.1"
version = "1.2.0a7"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
@@ -1454,7 +1454,7 @@ test = [
[package.metadata]
requires-dist = [
{ name = "langchain-core", specifier = ">=1.4.0,<2" },
{ name = "langchain-core", specifier = ">=1.4.0a2,<2" },
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-prebuilt", editable = "../prebuilt" },
{ name = "langgraph-sdk", editable = "../sdk-py" },
@@ -1563,7 +1563,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
version = "4.1.1"
version = "4.1.0a4"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
@@ -1611,7 +1611,7 @@ test = [
[[package]]
name = "langgraph-checkpoint-postgres"
version = "3.1.0"
version = "3.1.0a4"
source = { editable = "../checkpoint-postgres" }
dependencies = [
{ name = "langgraph-checkpoint" },
@@ -1658,7 +1658,7 @@ test = [
[[package]]
name = "langgraph-checkpoint-sqlite"
version = "3.1.0"
version = "3.1.0a1"
source = { editable = "../checkpoint-sqlite" }
dependencies = [
{ name = "aiosqlite" },
@@ -1757,7 +1757,7 @@ test = [
[[package]]
name = "langgraph-prebuilt"
version = "1.1.0"
version = "1.1.0a2"
source = { editable = "../prebuilt" }
dependencies = [
{ name = "langchain-core" },
@@ -1867,7 +1867,7 @@ test = [
[[package]]
name = "langsmith"
version = "0.8.0"
version = "0.7.31"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
@@ -1880,9 +1880,9 @@ dependencies = [
{ name = "xxhash" },
{ name = "zstandard" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a8/64/95f1f013531395f4e8ed73caeee780f65c7c58fe028cb543f8937b45611b/langsmith-0.8.0.tar.gz", hash = "sha256:59fe5b2a56bbbe14a08aa76691f84b49e8675dd21e11b57d80c6db8c08bac2e3", size = 4432996, upload-time = "2026-04-30T22:13:07.341Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/e1/a4be2e696c9473bb53298df398237da5674704d781d4b748ed35aeef592a/langsmith-0.8.0-py3-none-any.whl", hash = "sha256:12cc4bc5622b835a6d841964d6034df3617bdb912dae0c1381fd0a68a9b3a3ef", size = 393268, upload-time = "2026-04-30T22:13:05.56Z" },
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
]
[package.optional-dependencies]
@@ -2085,14 +2085,14 @@ wheels = [
[[package]]
name = "mistune"
version = "3.2.1"
version = "3.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ca/84/620cc3f7e3adf6f5067e10f4dbae71295d8f9e16d5d3f9ef97c40f2f592c/mistune-3.2.1.tar.gz", hash = "sha256:7c8e5501d38bac1582e067e46c8343f17d57ea1aaa735823f3aba1fd59c88a28", size = 98003, upload-time = "2026-05-03T14:33:22.312Z" }
sdist = { url = "https://files.pythonhosted.org/packages/9d/55/d01f0c4b45ade6536c51170b9043db8b2ec6ddf4a35c7ea3f5f559ac935b/mistune-3.2.0.tar.gz", hash = "sha256:708487c8a8cdd99c9d90eb3ed4c3ed961246ff78ac82f03418f5183ab70e398a", size = 95467, upload-time = "2025-12-23T11:36:34.994Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/7f/a946aa4f8752b37102b41e64dca18a1976ac705c3a0d1dfe74d820a02552/mistune-3.2.1-py3-none-any.whl", hash = "sha256:78cdb0ba5e938053ccf63651b352508d2efa9411dc8810bfb05f2dc5140c0048", size = 53749, upload-time = "2026-05-03T14:33:20.551Z" },
{ url = "https://files.pythonhosted.org/packages/9b/f7/4a5e785ec9fbd65146a27b6b70b6cdc161a66f2024e4b04ac06a67f5578b/mistune-3.2.0-py3-none-any.whl", hash = "sha256:febdc629a3c78616b94393c6580551e0e34cc289987ec6c35ed3f4be42d0eee1", size = 53598, upload-time = "2025-12-23T11:36:33.211Z" },
]
[[package]]
@@ -3743,11 +3743,11 @@ wheels = [
[[package]]
name = "urllib3"
version = "2.7.0"
version = "2.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
[[package]]
@@ -5,42 +5,12 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import Any
from langchain_core.messages import ToolMessage
from langgraph.stream._types import ProtocolEvent, StreamTransformer
from langgraph.stream.stream_channel import StreamChannel
from langgraph.prebuilt._tool_call_stream import ToolCallStream
def _is_serialized_tool_message(value: Any) -> bool:
"""Detect a serialized LangChain `ToolMessage` payload.
Example:
{
"lc": 1,
"type": "constructor",
"id": ["langchain_core", "messages", "ToolMessage"],
"kwargs": {"content": "raw tool result", "tool_call_id": "call_1"},
}
"""
return (
isinstance(value, dict)
and value.get("type") == "constructor"
and isinstance(value.get("id"), list)
and value["id"][-1] == "ToolMessage"
)
def _normalize_tool_output(output: Any) -> Any:
if isinstance(output, ToolMessage):
return output.content
if _is_serialized_tool_message(output):
kwargs = output.get("kwargs")
if isinstance(kwargs, dict):
return kwargs.get("content")
return output
class ToolCallTransformer(StreamTransformer):
"""Project `tools` channel events into `ToolCallStream` handles.
@@ -139,7 +109,7 @@ class ToolCallTransformer(StreamTransformer):
elif event_type == "tool-finished":
stream = self._active.pop(tool_call_id, None)
if stream is not None:
stream._finish(_normalize_tool_output(data.get("output")))
stream._finish(data.get("output"))
elif event_type == "tool-error":
stream = self._active.pop(tool_call_id, None)
if stream is not None:
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph-prebuilt"
version = "1.1.0"
version = "1.1.0a2"
description = "Library with high-level APIs for creating and executing LangGraph agents and tools."
authors = []
requires-python = ">=3.10"
@@ -6,7 +6,7 @@ import time
from typing import Annotated, Any
import pytest
from langchain_core.messages import AIMessage, ToolMessage
from langchain_core.messages import AIMessage
from langchain_core.tools import tool
from langgraph.constants import END, START
from langgraph.graph import StateGraph
@@ -128,42 +128,6 @@ class TestToolCallTransformerUnit:
assert stream.error is None
assert "tc1" not in transformer._active
def test_finish_unwraps_tool_message_output(self) -> None:
mux, transformer = _mux()
mux.push(_tool_event("tool-started", "tc1", tool_name="echo"))
stream = transformer._active["tc1"]
mux.push(
_tool_event(
"tool-finished",
"tc1",
output=ToolMessage(content="done", tool_call_id="tc1"),
)
)
assert stream.completed is True
assert stream.output == "done"
def test_finish_unwraps_serialized_tool_message_output(self) -> None:
mux, transformer = _mux()
mux.push(_tool_event("tool-started", "tc1", tool_name="echo"))
stream = transformer._active["tc1"]
mux.push(
_tool_event(
"tool-finished",
"tc1",
output={
"lc": 1,
"type": "constructor",
"id": ["langchain_core", "messages", "ToolMessage"],
"kwargs": {
"content": "serialized done",
"tool_call_id": "tc1",
},
},
)
)
assert stream.completed is True
assert stream.output == "serialized done"
def test_error_closes_stream(self) -> None:
mux, transformer = _mux()
mux.push(_tool_event("tool-started", "tc1", tool_name="boom"))
+18 -18
View File
@@ -214,11 +214,11 @@ wheels = [
[[package]]
name = "idna"
version = "3.15"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
@@ -253,7 +253,7 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.4.0"
version = "1.4.0a2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonpatch" },
@@ -266,9 +266,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "uuid-utils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/59/de/679a53472c25860837e32c0442c962fa86e95317a36460e2c9d5c91b17c2/langchain_core-1.4.0.tar.gz", hash = "sha256:1dc341eed802ed9c117c0df3923c991e5e9e226571e5725c194eeb5bd93d1a7f", size = 920260, upload-time = "2026-05-11T18:42:35.919Z" }
sdist = { url = "https://files.pythonhosted.org/packages/3c/93/68bafa047f8e1770d0cf0f61d6c70889f1dec42ef6bd263540d916c421b9/langchain_core-1.4.0a2.tar.gz", hash = "sha256:b723c7961b615c7f2180ce2bcf352fdad8247bc51a60adecd3d97088235c120d", size = 916486, upload-time = "2026-05-01T15:02:19.029Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/1a/86c38c27b81913a1c6c12448cab55defb5a1097c7dc9a4cea83f55477a2d/langchain_core-1.4.0-py3-none-any.whl", hash = "sha256:23cbbdb46e38ddd1dd5247e6167e96013eae74bea4c5949c550809970a9e565c", size = 548120, upload-time = "2026-05-11T18:42:33.992Z" },
{ url = "https://files.pythonhosted.org/packages/4e/8e/933e0ba7ba0430ce264e36b178d581b255239ad45093872483142d93478c/langchain_core-1.4.0a2-py3-none-any.whl", hash = "sha256:a5c689f8404357df797120c012da7704144a953b2ae18f258df263301e7badd5", size = 546297, upload-time = "2026-05-01T15:02:17.731Z" },
]
[[package]]
@@ -285,7 +285,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "1.2.1"
version = "1.2.0a7"
source = { editable = "../langgraph" }
dependencies = [
{ name = "langchain-core" },
@@ -298,7 +298,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "langchain-core", specifier = ">=1.4.0,<2" },
{ name = "langchain-core", specifier = ">=1.4.0a2,<2" },
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-prebuilt", editable = "." },
{ name = "langgraph-sdk", editable = "../sdk-py" },
@@ -369,7 +369,7 @@ test = [
[[package]]
name = "langgraph-checkpoint"
version = "4.1.1"
version = "4.1.0a4"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
@@ -417,7 +417,7 @@ test = [
[[package]]
name = "langgraph-checkpoint-postgres"
version = "3.1.0"
version = "3.1.0a4"
source = { editable = "../checkpoint-postgres" }
dependencies = [
{ name = "langgraph-checkpoint" },
@@ -464,7 +464,7 @@ test = [
[[package]]
name = "langgraph-checkpoint-sqlite"
version = "3.1.0"
version = "3.1.0a1"
source = { editable = "../checkpoint-sqlite" }
dependencies = [
{ name = "aiosqlite" },
@@ -507,7 +507,7 @@ test = [
[[package]]
name = "langgraph-prebuilt"
version = "1.1.0"
version = "1.1.0a2"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
@@ -636,7 +636,7 @@ test = [
[[package]]
name = "langsmith"
version = "0.8.0"
version = "0.7.31"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
@@ -649,9 +649,9 @@ dependencies = [
{ name = "xxhash" },
{ name = "zstandard" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a8/64/95f1f013531395f4e8ed73caeee780f65c7c58fe028cb543f8937b45611b/langsmith-0.8.0.tar.gz", hash = "sha256:59fe5b2a56bbbe14a08aa76691f84b49e8675dd21e11b57d80c6db8c08bac2e3", size = 4432996, upload-time = "2026-04-30T22:13:07.341Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/e1/a4be2e696c9473bb53298df398237da5674704d781d4b748ed35aeef592a/langsmith-0.8.0-py3-none-any.whl", hash = "sha256:12cc4bc5622b835a6d841964d6034df3617bdb912dae0c1381fd0a68a9b3a3ef", size = 393268, upload-time = "2026-04-30T22:13:05.56Z" },
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
]
[[package]]
@@ -1490,11 +1490,11 @@ wheels = [
[[package]]
name = "urllib3"
version = "2.7.0"
version = "2.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
[[package]]
+1 -1
View File
@@ -3,6 +3,6 @@ from langgraph_sdk.client import get_client, get_sync_client
from langgraph_sdk.encryption import Encryption
from langgraph_sdk.encryption.types import EncryptionContext
__version__ = "0.3.15"
__version__ = "0.3.14"
__all__ = ["Auth", "Encryption", "EncryptionContext", "get_client", "get_sync_client"]
+9 -16
View File
@@ -8,7 +8,6 @@ from typing import Any, Literal, cast, overload
import httpx
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._shared.utilities import _quote_path_param
from langgraph_sdk.schema import (
Assistant,
AssistantSelectField,
@@ -85,9 +84,7 @@ class AssistantsClient:
```
"""
return await self.http.get(
f"/assistants/{_quote_path_param(assistant_id)}",
headers=headers,
params=params,
f"/assistants/{assistant_id}", headers=headers, params=params
)
async def get_graph(
@@ -145,9 +142,7 @@ class AssistantsClient:
query_params.update(params)
return await self.http.get(
f"/assistants/{_quote_path_param(assistant_id)}/graph",
params=query_params,
headers=headers,
f"/assistants/{assistant_id}/graph", params=query_params, headers=headers
)
async def get_schemas(
@@ -268,9 +263,7 @@ class AssistantsClient:
"""
return await self.http.get(
f"/assistants/{_quote_path_param(assistant_id)}/schemas",
headers=headers,
params=params,
f"/assistants/{assistant_id}/schemas", headers=headers, params=params
)
async def get_subgraphs(
@@ -300,13 +293,13 @@ class AssistantsClient:
get_params = {**get_params, **dict(params)}
if namespace is not None:
return await self.http.get(
f"/assistants/{_quote_path_param(assistant_id)}/subgraphs/{_quote_path_param(namespace)}",
f"/assistants/{assistant_id}/subgraphs/{namespace}",
params=get_params,
headers=headers,
)
else:
return await self.http.get(
f"/assistants/{_quote_path_param(assistant_id)}/subgraphs",
f"/assistants/{assistant_id}/subgraphs",
params=get_params,
headers=headers,
)
@@ -443,7 +436,7 @@ class AssistantsClient:
if description:
payload["description"] = description
return await self.http.patch(
f"/assistants/{_quote_path_param(assistant_id)}",
f"/assistants/{assistant_id}",
json=payload,
headers=headers,
params=params,
@@ -486,7 +479,7 @@ class AssistantsClient:
if params:
query_params.update(params)
await self.http.delete(
f"/assistants/{_quote_path_param(assistant_id)}",
f"/assistants/{assistant_id}",
headers=headers,
params=query_params or None,
)
@@ -693,7 +686,7 @@ class AssistantsClient:
if metadata:
payload["metadata"] = metadata
return await self.http.post(
f"/assistants/{_quote_path_param(assistant_id)}/versions",
f"/assistants/{assistant_id}/versions",
json=payload,
headers=headers,
params=params,
@@ -733,7 +726,7 @@ class AssistantsClient:
payload: dict[str, Any] = {"version": version}
return await self.http.post(
f"/assistants/{_quote_path_param(assistant_id)}/latest",
f"/assistants/{assistant_id}/latest",
json=payload,
headers=headers,
params=params,
+4 -17
View File
@@ -8,7 +8,7 @@ from datetime import datetime, tzinfo
from typing import Any
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._shared.utilities import _quote_path_param, _resolve_timezone
from langgraph_sdk._shared.utilities import _resolve_timezone
from langgraph_sdk.schema import (
All,
Config,
@@ -18,7 +18,6 @@ from langgraph_sdk.schema import (
CronSortBy,
Durability,
Input,
Json,
OnCompletionBehavior,
QueryParamTypes,
Run,
@@ -166,7 +165,7 @@ class CronClient:
payload["multitask_strategy"] = multitask_strategy
payload = {k: v for k, v in payload.items() if v is not None}
return await self.http.post(
f"/threads/{_quote_path_param(thread_id)}/runs/crons",
f"/threads/{thread_id}/runs/crons",
json=payload,
headers=headers,
params=params,
@@ -315,9 +314,7 @@ class CronClient:
```
"""
await self.http.delete(
f"/runs/crons/{_quote_path_param(cron_id)}", headers=headers, params=params
)
await self.http.delete(f"/runs/crons/{cron_id}", headers=headers, params=params)
async def update(
self,
@@ -404,7 +401,7 @@ class CronClient:
}
payload = {k: v for k, v in payload.items() if v is not None}
return await self.http.patch(
f"/runs/crons/{_quote_path_param(cron_id)}",
f"/runs/crons/{cron_id}",
json=payload,
headers=headers,
params=params,
@@ -416,7 +413,6 @@ class CronClient:
assistant_id: str | None = None,
thread_id: str | None = None,
enabled: bool | None = None,
metadata: Json = None,
limit: int = 10,
offset: int = 0,
sort_by: CronSortBy | None = None,
@@ -431,8 +427,6 @@ class CronClient:
assistant_id: The assistant ID or graph name to search for.
thread_id: the thread ID to search for.
enabled: The enabled status to search for.
metadata: Metadata to filter by. Exact match filter for each KV pair.
!!! version-added "Added in Agent Server version 0.9.0"
limit: The maximum number of results to return.
offset: The number of results to skip.
headers: Optional custom headers to include with the request.
@@ -487,8 +481,6 @@ class CronClient:
"limit": limit,
"offset": offset,
}
if metadata:
payload["metadata"] = metadata
if sort_by:
payload["sort_by"] = sort_by
if sort_order:
@@ -505,7 +497,6 @@ class CronClient:
*,
assistant_id: str | None = None,
thread_id: str | None = None,
metadata: Json = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> int:
@@ -514,8 +505,6 @@ class CronClient:
Args:
assistant_id: Assistant ID to filter by.
thread_id: Thread ID to filter by.
metadata: Metadata to filter by. Exact match filter for each KV pair.
!!! version-added "Added in Agent Server version 0.9.0"
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
@@ -527,8 +516,6 @@ class CronClient:
payload["assistant_id"] = assistant_id
if thread_id:
payload["thread_id"] = thread_id
if metadata:
payload["metadata"] = metadata
return await self.http.post(
"/runs/crons/count", json=payload, headers=headers, params=params
)
+10 -19
View File
@@ -12,7 +12,6 @@ import httpx
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._shared.utilities import (
_get_run_metadata_from_response,
_quote_path_param,
_sse_to_v2_dict,
)
from langgraph_sdk.schema import (
@@ -338,7 +337,7 @@ class RunsClient:
"langsmith_tracer": langsmith_tracing,
}
endpoint = (
f"/threads/{_quote_path_param(thread_id)}/runs/stream"
f"/threads/{thread_id}/runs/stream"
if thread_id is not None
else "/runs/stream"
)
@@ -597,7 +596,7 @@ class RunsClient:
on_run_created(metadata)
return await self.http.post(
f"/threads/{_quote_path_param(thread_id)}/runs" if thread_id else "/runs",
f"/threads/{thread_id}/runs" if thread_id else "/runs",
json=payload,
params=params,
headers=headers,
@@ -822,9 +821,7 @@ class RunsClient:
"langsmith_tracer": langsmith_tracing,
}
endpoint = (
f"/threads/{_quote_path_param(thread_id)}/runs/wait"
if thread_id is not None
else "/runs/wait"
f"/threads/{thread_id}/runs/wait" if thread_id is not None else "/runs/wait"
)
def on_response(res: httpx.Response):
@@ -898,9 +895,7 @@ class RunsClient:
if params:
query_params.update(params)
return await self.http.get(
f"/threads/{_quote_path_param(thread_id)}/runs",
params=query_params,
headers=headers,
f"/threads/{thread_id}/runs", params=query_params, headers=headers
)
async def get(
@@ -935,9 +930,7 @@ class RunsClient:
"""
return await self.http.get(
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}",
headers=headers,
params=params,
f"/threads/{thread_id}/runs/{run_id}", headers=headers, params=params
)
async def cancel(
@@ -985,14 +978,14 @@ class RunsClient:
query_params.update(params)
if wait:
return await self.http.request_reconnect(
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/cancel",
f"/threads/{thread_id}/runs/{run_id}/cancel",
"POST",
params=query_params,
headers=headers,
)
else:
return await self.http.post(
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/cancel",
f"/threads/{thread_id}/runs/{run_id}/cancel",
json=None,
params=query_params,
headers=headers,
@@ -1088,7 +1081,7 @@ class RunsClient:
"""
return await self.http.request_reconnect(
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/join",
f"/threads/{thread_id}/runs/{run_id}/join",
"GET",
headers=headers,
params=params,
@@ -1143,7 +1136,7 @@ class RunsClient:
if params:
query_params.update(params)
return self.http.stream(
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/stream",
f"/threads/{thread_id}/runs/{run_id}/stream",
"GET",
params=query_params,
headers={
@@ -1184,7 +1177,5 @@ class RunsClient:
"""
await self.http.delete(
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}",
headers=headers,
params=params,
f"/threads/{thread_id}/runs/{run_id}", headers=headers, params=params
)
+10 -19
View File
@@ -6,7 +6,6 @@ from collections.abc import AsyncIterator, Mapping, Sequence
from typing import Any, Literal, overload
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._shared.utilities import _quote_path_param
from langgraph_sdk.schema import (
Checkpoint,
Json,
@@ -91,7 +90,7 @@ class ThreadsClient:
if params:
query_params.update(params)
return await self.http.get(
f"/threads/{_quote_path_param(thread_id)}",
f"/threads/{thread_id}",
headers=headers,
params=query_params or None,
)
@@ -255,7 +254,7 @@ class ThreadsClient:
if return_minimal:
request_headers["Prefer"] = "return=minimal"
return await self.http.patch(
f"/threads/{_quote_path_param(thread_id)}",
f"/threads/{thread_id}",
json=payload,
headers=request_headers or None,
params=params,
@@ -288,9 +287,7 @@ class ThreadsClient:
```
"""
await self.http.delete(
f"/threads/{_quote_path_param(thread_id)}", headers=headers, params=params
)
await self.http.delete(f"/threads/{thread_id}", headers=headers, params=params)
async def search(
self,
@@ -433,10 +430,7 @@ class ThreadsClient:
"""
return await self.http.post(
f"/threads/{_quote_path_param(thread_id)}/copy",
json=None,
headers=headers,
params=params,
f"/threads/{thread_id}/copy", json=None, headers=headers, params=params
)
async def prune(
@@ -592,7 +586,7 @@ class ThreadsClient:
"""
if checkpoint:
return await self.http.post(
f"/threads/{_quote_path_param(thread_id)}/state/checkpoint",
f"/threads/{thread_id}/state/checkpoint",
json={"checkpoint": checkpoint, "subgraphs": subgraphs},
headers=headers,
params=params,
@@ -602,7 +596,7 @@ class ThreadsClient:
if params:
get_params = {**get_params, **dict(params)}
return await self.http.get(
f"/threads/{_quote_path_param(thread_id)}/state/{_quote_path_param(checkpoint_id)}",
f"/threads/{thread_id}/state/{checkpoint_id}",
params=get_params,
headers=headers,
)
@@ -611,7 +605,7 @@ class ThreadsClient:
if params:
get_params = {**get_params, **dict(params)}
return await self.http.get(
f"/threads/{_quote_path_param(thread_id)}/state",
f"/threads/{thread_id}/state",
params=get_params,
headers=headers,
)
@@ -676,10 +670,7 @@ class ThreadsClient:
if as_node:
payload["as_node"] = as_node
return await self.http.post(
f"/threads/{_quote_path_param(thread_id)}/state",
json=payload,
headers=headers,
params=params,
f"/threads/{thread_id}/state", json=payload, headers=headers, params=params
)
async def get_history(
@@ -728,7 +719,7 @@ class ThreadsClient:
if checkpoint:
payload["checkpoint"] = checkpoint
return await self.http.post(
f"/threads/{_quote_path_param(thread_id)}/history",
f"/threads/{thread_id}/history",
json=payload,
headers=headers,
params=params,
@@ -772,7 +763,7 @@ class ThreadsClient:
if params:
query_params.update(params)
return self.http.stream(
f"/threads/{_quote_path_param(thread_id)}/stream",
f"/threads/{thread_id}/stream",
"GET",
headers={
**({"Last-Event-ID": last_event_id} if last_event_id else {}),
+1 -34
View File
@@ -8,7 +8,7 @@ import re
from collections.abc import Mapping
from datetime import tzinfo
from typing import TYPE_CHECKING, Any, cast
from urllib.parse import quote, urlparse
from urllib.parse import urlparse
import httpx
@@ -198,39 +198,6 @@ def _provided_vals(d: Mapping[str, Any]) -> dict[str, Any]:
return {k: v for k, v in d.items() if v is not None}
def _quote_path_param(value: Any) -> str:
"""Encode a value for safe interpolation into a request path segment.
Path segments are encoded with ``safe=""`` so that ``/`` and other reserved
characters are escaped. Standalone dot-segments (``.`` and ``..``) are also
encoded because some URL-handling stacks (including ``httpx``) collapse
them client-side as relative-path traversal before transmission. The value
is coerced to ``str`` so callers can pass ``uuid.UUID`` and similar types
directly without changing call sites.
A properly formed identifier (for example, a standard UUID, which contains
no dots or reserved characters) round-trips through this function
unchanged.
Raises:
TypeError: If `value` is `None` or a `bytes`/`bytearray` instance.
Coercing those would produce misleading paths (e.g. `/threads/None`),
so surface the caller bug instead.
"""
if value is None:
raise TypeError("path parameter must not be None")
if isinstance(value, (bytes, bytearray)):
raise TypeError("path parameter must not be bytes; pass a str or uuid.UUID")
quoted = quote(str(value), safe="")
# Bare "." or ".." (or any all-dot string) acts as a relative-path segment
# that some HTTP stacks (including ``httpx``) collapse client-side before
# transmission. Encode the dots so the segment becomes opaque to that
# logic. Mixed values like "agent.v1" are unaffected.
if quoted and all(c == "." for c in quoted):
quoted = "%2E" * len(quoted)
return quoted
_registered_transports: list[httpx.ASGITransport] = []
+9 -16
View File
@@ -7,7 +7,6 @@ from typing import Any, Literal, cast, overload
import httpx
from langgraph_sdk._shared.utilities import _quote_path_param
from langgraph_sdk._sync.http import SyncHttpClient
from langgraph_sdk.schema import (
Assistant,
@@ -84,9 +83,7 @@ class SyncAssistantsClient:
"""
return self.http.get(
f"/assistants/{_quote_path_param(assistant_id)}",
headers=headers,
params=params,
f"/assistants/{assistant_id}", headers=headers, params=params
)
def get_graph(
@@ -139,9 +136,7 @@ class SyncAssistantsClient:
if params:
query_params.update(params)
return self.http.get(
f"/assistants/{_quote_path_param(assistant_id)}/graph",
params=query_params,
headers=headers,
f"/assistants/{assistant_id}/graph", params=query_params, headers=headers
)
def get_schemas(
@@ -274,9 +269,7 @@ class SyncAssistantsClient:
"""
return self.http.get(
f"/assistants/{_quote_path_param(assistant_id)}/schemas",
headers=headers,
params=params,
f"/assistants/{assistant_id}/schemas", headers=headers, params=params
)
def get_subgraphs(
@@ -304,13 +297,13 @@ class SyncAssistantsClient:
get_params = {**get_params, **dict(params)}
if namespace is not None:
return self.http.get(
f"/assistants/{_quote_path_param(assistant_id)}/subgraphs/{_quote_path_param(namespace)}",
f"/assistants/{assistant_id}/subgraphs/{namespace}",
params=get_params,
headers=headers,
)
else:
return self.http.get(
f"/assistants/{_quote_path_param(assistant_id)}/subgraphs",
f"/assistants/{assistant_id}/subgraphs",
params=get_params,
headers=headers,
)
@@ -445,7 +438,7 @@ class SyncAssistantsClient:
if description:
payload["description"] = description
return self.http.patch(
f"/assistants/{_quote_path_param(assistant_id)}",
f"/assistants/{assistant_id}",
json=payload,
headers=headers,
params=params,
@@ -488,7 +481,7 @@ class SyncAssistantsClient:
if params:
query_params.update(params)
self.http.delete(
f"/assistants/{_quote_path_param(assistant_id)}",
f"/assistants/{assistant_id}",
headers=headers,
params=query_params or None,
)
@@ -692,7 +685,7 @@ class SyncAssistantsClient:
if metadata:
payload["metadata"] = metadata
return self.http.post(
f"/assistants/{_quote_path_param(assistant_id)}/versions",
f"/assistants/{assistant_id}/versions",
json=payload,
headers=headers,
params=params,
@@ -731,7 +724,7 @@ class SyncAssistantsClient:
payload: dict[str, Any] = {"version": version}
return self.http.post(
f"/assistants/{_quote_path_param(assistant_id)}/latest",
f"/assistants/{assistant_id}/latest",
json=payload,
headers=headers,
params=params,
+4 -17
View File
@@ -7,7 +7,7 @@ from collections.abc import Mapping, Sequence
from datetime import datetime, tzinfo
from typing import Any
from langgraph_sdk._shared.utilities import _quote_path_param, _resolve_timezone
from langgraph_sdk._shared.utilities import _resolve_timezone
from langgraph_sdk._sync.http import SyncHttpClient
from langgraph_sdk.schema import (
All,
@@ -18,7 +18,6 @@ from langgraph_sdk.schema import (
CronSortBy,
Durability,
Input,
Json,
OnCompletionBehavior,
QueryParamTypes,
Run,
@@ -156,7 +155,7 @@ class SyncCronClient:
}
payload = {k: v for k, v in payload.items() if v is not None}
return self.http.post(
f"/threads/{_quote_path_param(thread_id)}/runs/crons",
f"/threads/{thread_id}/runs/crons",
json=payload,
headers=headers,
params=params,
@@ -304,9 +303,7 @@ class SyncCronClient:
```
"""
self.http.delete(
f"/runs/crons/{_quote_path_param(cron_id)}", headers=headers, params=params
)
self.http.delete(f"/runs/crons/{cron_id}", headers=headers, params=params)
def update(
self,
@@ -393,7 +390,7 @@ class SyncCronClient:
}
payload = {k: v for k, v in payload.items() if v is not None}
return self.http.patch(
f"/runs/crons/{_quote_path_param(cron_id)}",
f"/runs/crons/{cron_id}",
json=payload,
headers=headers,
params=params,
@@ -405,7 +402,6 @@ class SyncCronClient:
assistant_id: str | None = None,
thread_id: str | None = None,
enabled: bool | None = None,
metadata: Json = None,
limit: int = 10,
offset: int = 0,
sort_by: CronSortBy | None = None,
@@ -420,8 +416,6 @@ class SyncCronClient:
assistant_id: The assistant ID or graph name to search for.
thread_id: the thread ID to search for.
enabled: Whether the cron job is enabled.
metadata: Metadata to filter by. Exact match filter for each KV pair.
!!! version-added "Added in Agent Server version 0.9.0"
limit: The maximum number of results to return.
offset: The number of results to skip.
headers: Optional custom headers to include with the request.
@@ -474,8 +468,6 @@ class SyncCronClient:
"limit": limit,
"offset": offset,
}
if metadata:
payload["metadata"] = metadata
if sort_by:
payload["sort_by"] = sort_by
if sort_order:
@@ -492,7 +484,6 @@ class SyncCronClient:
*,
assistant_id: str | None = None,
thread_id: str | None = None,
metadata: Json = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> int:
@@ -501,8 +492,6 @@ class SyncCronClient:
Args:
assistant_id: Assistant ID to filter by.
thread_id: Thread ID to filter by.
metadata: Metadata to filter by. Exact match filter for each KV pair.
!!! version-added "Added in Agent Server version 0.9.0"
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
@@ -514,8 +503,6 @@ class SyncCronClient:
payload["assistant_id"] = assistant_id
if thread_id:
payload["thread_id"] = thread_id
if metadata:
payload["metadata"] = metadata
return self.http.post(
"/runs/crons/count", json=payload, headers=headers, params=params
)
+10 -19
View File
@@ -11,7 +11,6 @@ import httpx
from langgraph_sdk._shared.utilities import (
_get_run_metadata_from_response,
_quote_path_param,
_sse_to_v2_dict,
)
from langgraph_sdk._sync.http import SyncHttpClient
@@ -333,7 +332,7 @@ class SyncRunsClient:
"langsmith_tracer": langsmith_tracing,
}
endpoint = (
f"/threads/{_quote_path_param(thread_id)}/runs/stream"
f"/threads/{thread_id}/runs/stream"
if thread_id is not None
else "/runs/stream"
)
@@ -592,7 +591,7 @@ class SyncRunsClient:
on_run_created(metadata)
return self.http.post(
f"/threads/{_quote_path_param(thread_id)}/runs" if thread_id else "/runs",
f"/threads/{thread_id}/runs" if thread_id else "/runs",
json=payload,
params=params,
headers=headers,
@@ -826,9 +825,7 @@ class SyncRunsClient:
on_run_created(metadata)
endpoint = (
f"/threads/{_quote_path_param(thread_id)}/runs/wait"
if thread_id is not None
else "/runs/wait"
f"/threads/{thread_id}/runs/wait" if thread_id is not None else "/runs/wait"
)
return self.http.request_reconnect(
endpoint,
@@ -882,9 +879,7 @@ class SyncRunsClient:
if params:
query_params.update(params)
return self.http.get(
f"/threads/{_quote_path_param(thread_id)}/runs",
params=query_params,
headers=headers,
f"/threads/{thread_id}/runs", params=query_params, headers=headers
)
def get(
@@ -917,9 +912,7 @@ class SyncRunsClient:
"""
return self.http.get(
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}",
headers=headers,
params=params,
f"/threads/{thread_id}/runs/{run_id}", headers=headers, params=params
)
def cancel(
@@ -967,14 +960,14 @@ class SyncRunsClient:
query_params.update(params)
if wait:
return self.http.request_reconnect(
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/cancel",
f"/threads/{thread_id}/runs/{run_id}/cancel",
"POST",
json=None,
params=query_params,
headers=headers,
)
return self.http.post(
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/cancel",
f"/threads/{thread_id}/runs/{run_id}/cancel",
json=None,
params=query_params,
headers=headers,
@@ -1070,7 +1063,7 @@ class SyncRunsClient:
"""
return self.http.request_reconnect(
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/join",
f"/threads/{thread_id}/runs/{run_id}/join",
"GET",
headers=headers,
params=params,
@@ -1124,7 +1117,7 @@ class SyncRunsClient:
if params:
query_params.update(params)
return self.http.stream(
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/stream",
f"/threads/{thread_id}/runs/{run_id}/stream",
"GET",
params=query_params,
headers={
@@ -1165,7 +1158,5 @@ class SyncRunsClient:
"""
self.http.delete(
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}",
headers=headers,
params=params,
f"/threads/{thread_id}/runs/{run_id}", headers=headers, params=params
)
+10 -19
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
from collections.abc import Iterator, Mapping, Sequence
from typing import Any, Literal, overload
from langgraph_sdk._shared.utilities import _quote_path_param
from langgraph_sdk._sync.http import SyncHttpClient
from langgraph_sdk.schema import (
Checkpoint,
@@ -89,7 +88,7 @@ class SyncThreadsClient:
if params:
query_params.update(params)
return self.http.get(
f"/threads/{_quote_path_param(thread_id)}",
f"/threads/{thread_id}",
headers=headers,
params=query_params or None,
)
@@ -251,7 +250,7 @@ class SyncThreadsClient:
if return_minimal:
request_headers["Prefer"] = "return=minimal"
return self.http.patch(
f"/threads/{_quote_path_param(thread_id)}",
f"/threads/{thread_id}",
json=payload,
headers=request_headers or None,
params=params,
@@ -283,9 +282,7 @@ class SyncThreadsClient:
```
"""
self.http.delete(
f"/threads/{_quote_path_param(thread_id)}", headers=headers, params=params
)
self.http.delete(f"/threads/{thread_id}", headers=headers, params=params)
def search(
self,
@@ -424,10 +421,7 @@ class SyncThreadsClient:
"""
return self.http.post(
f"/threads/{_quote_path_param(thread_id)}/copy",
json=None,
headers=headers,
params=params,
f"/threads/{thread_id}/copy", json=None, headers=headers, params=params
)
def prune(
@@ -582,7 +576,7 @@ class SyncThreadsClient:
"""
if checkpoint:
return self.http.post(
f"/threads/{_quote_path_param(thread_id)}/state/checkpoint",
f"/threads/{thread_id}/state/checkpoint",
json={"checkpoint": checkpoint, "subgraphs": subgraphs},
headers=headers,
params=params,
@@ -592,7 +586,7 @@ class SyncThreadsClient:
if params:
get_params = {**get_params, **dict(params)}
return self.http.get(
f"/threads/{_quote_path_param(thread_id)}/state/{_quote_path_param(checkpoint_id)}",
f"/threads/{thread_id}/state/{checkpoint_id}",
params=get_params,
headers=headers,
)
@@ -601,7 +595,7 @@ class SyncThreadsClient:
if params:
get_params = {**get_params, **dict(params)}
return self.http.get(
f"/threads/{_quote_path_param(thread_id)}/state",
f"/threads/{thread_id}/state",
params=get_params,
headers=headers,
)
@@ -663,10 +657,7 @@ class SyncThreadsClient:
if as_node:
payload["as_node"] = as_node
return self.http.post(
f"/threads/{_quote_path_param(thread_id)}/state",
json=payload,
headers=headers,
params=params,
f"/threads/{thread_id}/state", json=payload, headers=headers, params=params
)
def get_history(
@@ -716,7 +707,7 @@ class SyncThreadsClient:
if checkpoint:
payload["checkpoint"] = checkpoint
return self.http.post(
f"/threads/{_quote_path_param(thread_id)}/history",
f"/threads/{thread_id}/history",
json=payload,
headers=headers,
params=params,
@@ -761,7 +752,7 @@ class SyncThreadsClient:
if params:
query_params.update(params)
return self.http.stream(
f"/threads/{_quote_path_param(thread_id)}/stream",
f"/threads/{thread_id}/stream",
"GET",
headers={
**({"Last-Event-ID": last_event_id} if last_event_id else {}),
-162
View File
@@ -485,165 +485,3 @@ def test_sync_update_with_enabled_parameter(enabled_value):
)
assert result == cron
@pytest.mark.asyncio
async def test_async_search_with_metadata():
"""Test that CronClient.search forwards metadata in the request body."""
cron = _cron_response()
async def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "POST"
assert request.url.path == "/runs/crons/search"
body = json.loads(request.content)
assert body["metadata"] == {"owner": "alice"}
assert body["limit"] == 10
assert body["offset"] == 0
return httpx.Response(200, json=[cron])
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(
transport=transport, base_url="https://example.com"
) as client:
http_client = HttpClient(client)
cron_client = CronClient(http_client)
result = await cron_client.search(metadata={"owner": "alice"})
assert result == [cron]
@pytest.mark.asyncio
async def test_async_search_omits_empty_metadata():
"""Test that CronClient.search does not send metadata when not provided."""
cron = _cron_response()
async def handler(request: httpx.Request) -> httpx.Response:
body = json.loads(request.content)
assert "metadata" not in body
return httpx.Response(200, json=[cron])
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(
transport=transport, base_url="https://example.com"
) as client:
http_client = HttpClient(client)
cron_client = CronClient(http_client)
await cron_client.search()
@pytest.mark.asyncio
async def test_async_count_with_metadata():
"""Test that CronClient.count forwards metadata in the request body."""
async def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "POST"
assert request.url.path == "/runs/crons/count"
body = json.loads(request.content)
assert body["metadata"] == {"team": "infra"}
return httpx.Response(200, json=2)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(
transport=transport, base_url="https://example.com"
) as client:
http_client = HttpClient(client)
cron_client = CronClient(http_client)
result = await cron_client.count(metadata={"team": "infra"})
assert result == 2
@pytest.mark.asyncio
async def test_async_count_omits_empty_metadata():
"""Test that CronClient.count does not send metadata when not provided."""
async def handler(request: httpx.Request) -> httpx.Response:
body = json.loads(request.content)
assert "metadata" not in body
return httpx.Response(200, json=0)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(
transport=transport, base_url="https://example.com"
) as client:
http_client = HttpClient(client)
cron_client = CronClient(http_client)
await cron_client.count()
def test_sync_search_with_metadata():
"""Test that SyncCronClient.search forwards metadata in the request body."""
cron = _cron_response()
def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "POST"
assert request.url.path == "/runs/crons/search"
body = json.loads(request.content)
assert body["metadata"] == {"owner": "alice"}
return httpx.Response(200, json=[cron])
transport = httpx.MockTransport(handler)
with httpx.Client(transport=transport, base_url="https://example.com") as client:
http_client = SyncHttpClient(client)
cron_client = SyncCronClient(http_client)
result = cron_client.search(metadata={"owner": "alice"})
assert result == [cron]
def test_sync_search_omits_empty_metadata():
"""Test that SyncCronClient.search does not send metadata when not provided."""
cron = _cron_response()
def handler(request: httpx.Request) -> httpx.Response:
body = json.loads(request.content)
assert "metadata" not in body
return httpx.Response(200, json=[cron])
transport = httpx.MockTransport(handler)
with httpx.Client(transport=transport, base_url="https://example.com") as client:
http_client = SyncHttpClient(client)
cron_client = SyncCronClient(http_client)
cron_client.search()
def test_sync_count_with_metadata():
"""Test that SyncCronClient.count forwards metadata in the request body."""
def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "POST"
assert request.url.path == "/runs/crons/count"
body = json.loads(request.content)
assert body["metadata"] == {"team": "infra"}
return httpx.Response(200, json=2)
transport = httpx.MockTransport(handler)
with httpx.Client(transport=transport, base_url="https://example.com") as client:
http_client = SyncHttpClient(client)
cron_client = SyncCronClient(http_client)
result = cron_client.count(metadata={"team": "infra"})
assert result == 2
def test_sync_count_omits_empty_metadata():
"""Test that SyncCronClient.count does not send metadata when not provided."""
def handler(request: httpx.Request) -> httpx.Response:
body = json.loads(request.content)
assert "metadata" not in body
return httpx.Response(200, json=0)
transport = httpx.MockTransport(handler)
with httpx.Client(transport=transport, base_url="https://example.com") as client:
http_client = SyncHttpClient(client)
cron_client = SyncCronClient(http_client)
cron_client.count()
-448
View File
@@ -1,448 +0,0 @@
"""Regression tests for path-segment encoding of caller-supplied identifiers.
Covers GHSA-w39p-vh2g-g8g5: identifier values interpolated into request paths
are encoded so the resulting request addresses the resource the SDK method
indicates, even if the identifier contains characters with special meaning in
URL paths.
"""
from __future__ import annotations
import httpx
import pytest
from langgraph_sdk._shared.utilities import _quote_path_param
from langgraph_sdk.client import (
AssistantsClient,
CronClient,
HttpClient,
RunsClient,
SyncAssistantsClient,
SyncCronClient,
SyncHttpClient,
SyncRunsClient,
SyncThreadsClient,
ThreadsClient,
)
class TestQuotePathParam:
"""Unit tests for the encoding helper itself."""
def test_uuid_round_trips_unchanged(self) -> None:
uuid_value = "550e8400-e29b-41d4-a716-446655440000"
assert _quote_path_param(uuid_value) == uuid_value
def test_simple_opaque_id_round_trips_unchanged(self) -> None:
assert _quote_path_param("thread_123") == "thread_123"
assert _quote_path_param("asst_abc") == "asst_abc"
def test_slash_is_encoded(self) -> None:
assert _quote_path_param("foo/bar") == "foo%2Fbar"
def test_bare_dot_segments_are_encoded(self) -> None:
# All-dot strings are encoded to make them opaque to HTTP stacks that
# collapse "./.." path segments client-side.
assert _quote_path_param(".") == "%2E"
assert _quote_path_param("..") == "%2E%2E"
assert _quote_path_param("...") == "%2E%2E%2E"
# Mixed values that happen to contain dots are not affected.
assert _quote_path_param("agent.v1") == "agent.v1"
# Subsequent ``/`` characters are encoded regardless.
assert _quote_path_param("../bar") == "..%2Fbar"
def test_full_pivot_payload_is_encoded(self) -> None:
# A caller-supplied identifier that, if interpolated raw, would route
# the request to a different resource type.
payload = "../assistants/abc-123"
encoded = _quote_path_param(payload)
assert encoded == "..%2Fassistants%2Fabc-123"
assert "/" not in encoded
def test_non_string_values_are_coerced_to_str(self) -> None:
import uuid
uid = uuid.UUID("550e8400-e29b-41d4-a716-446655440000")
assert _quote_path_param(uid) == str(uid)
assert _quote_path_param(42) == "42"
def test_none_value_raises_type_error(self) -> None:
with pytest.raises(TypeError, match="must not be None"):
_quote_path_param(None)
def test_bytes_value_raises_type_error(self) -> None:
with pytest.raises(TypeError, match="must not be bytes"):
_quote_path_param(b"bytes")
with pytest.raises(TypeError, match="must not be bytes"):
_quote_path_param(bytearray(b"bytes"))
def _wire_path(request: httpx.Request) -> str:
"""Return the path as it goes on the wire (preserves percent-encoding)."""
return request.url.raw_path.decode("ascii")
@pytest.mark.asyncio
class TestAsyncPathEncoding:
"""Async-client tests that verify the encoded path actually lands on the wire.
Note: ``request.url.path`` is the percent-decoded display form. The bytes
that actually go on the wire are in ``request.url.raw_path``; that is what
the server's router sees and what these tests inspect.
"""
async def test_threads_get_with_pivot_payload_stays_on_threads(self) -> None:
captured: list[str] = []
async def handler(request: httpx.Request) -> httpx.Response:
captured.append(_wire_path(request))
return httpx.Response(200, json={"thread_id": "anything"})
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("../assistants/abc-123")
assert len(captured) == 1
wire = captured[0]
# The identifier is encoded so the wire path stays inside `/threads/...`.
# The encoded segment must not contain literal slashes that could let
# the server re-route to a different resource type.
assert wire.startswith("/threads/")
segment = wire[len("/threads/") :]
assert "/" not in segment
assert "%2F" in segment
assert segment == "..%2Fassistants%2Fabc-123"
async def test_threads_update_with_pivot_payload_stays_on_threads(self) -> None:
captured: list[tuple[str, str]] = []
async def handler(request: httpx.Request) -> httpx.Response:
captured.append((request.method, _wire_path(request)))
return httpx.Response(200, json={"thread_id": "anything"})
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.update("../assistants/abc-123", metadata={"x": 1})
assert len(captured) == 1
method, wire = captured[0]
assert method == "PATCH"
assert wire.startswith("/threads/")
segment = wire[len("/threads/") :]
assert "/" not in segment
async def test_threads_delete_with_pivot_payload_stays_on_threads(self) -> None:
captured: list[tuple[str, str]] = []
async def handler(request: httpx.Request) -> httpx.Response:
captured.append((request.method, _wire_path(request)))
return httpx.Response(200)
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.delete("../runs/crons/some-cron-id")
assert len(captured) == 1
method, wire = captured[0]
assert method == "DELETE"
assert wire.startswith("/threads/")
segment = wire[len("/threads/") :]
assert "/" not in segment
async def test_assistants_get_with_pivot_payload_stays_on_assistants(
self,
) -> None:
captured: list[str] = []
async def handler(request: httpx.Request) -> httpx.Response:
captured.append(_wire_path(request))
return httpx.Response(200, json={"assistant_id": "anything"})
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(
transport=transport, base_url="https://example.com"
) as client:
assistants_client = AssistantsClient(HttpClient(client))
await assistants_client.get("../threads/abc-123")
assert len(captured) == 1
wire = captured[0]
assert wire.startswith("/assistants/")
segment = wire[len("/assistants/") :]
assert "/" not in segment
async def test_runs_delete_double_id_pivot_stays_on_threads_runs(self) -> None:
captured: list[tuple[str, str]] = []
async def handler(request: httpx.Request) -> httpx.Response:
captured.append((request.method, _wire_path(request)))
return httpx.Response(200)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(
transport=transport, base_url="https://example.com"
) as client:
runs_client = RunsClient(HttpClient(client))
# Both identifier values supplied as path-traversal payloads.
await runs_client.delete("..", "../runs/crons/cron-id")
assert len(captured) == 1
method, wire = captured[0]
assert method == "DELETE"
# The path should match `/threads/{quoted_thread}/runs/{quoted_run}`
# exactly. Neither segment should contain literal slashes.
assert wire.startswith("/threads/")
assert "/runs/crons/" not in wire
parts = wire.split("/")
# Expected shape: ['', 'threads', '<encoded ..>', 'runs', '<encoded ..>']
assert len(parts) == 5
assert parts[1] == "threads"
assert parts[3] == "runs"
# Encoded thread_id and run_id are between literal slashes.
assert parts[2] == "%2E%2E"
assert parts[4] == "..%2Fruns%2Fcrons%2Fcron-id"
async def test_crons_delete_with_pivot_payload_stays_on_crons(self) -> None:
captured: list[tuple[str, str]] = []
async def handler(request: httpx.Request) -> httpx.Response:
captured.append((request.method, _wire_path(request)))
return httpx.Response(200)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(
transport=transport, base_url="https://example.com"
) as client:
crons_client = CronClient(HttpClient(client))
await crons_client.delete("../../assistants/abc-123")
assert len(captured) == 1
method, wire = captured[0]
assert method == "DELETE"
assert wire.startswith("/runs/crons/")
segment = wire[len("/runs/crons/") :]
assert "/" not in segment
async def test_threads_get_state_with_pivot_checkpoint_id_stays_on_state(
self,
) -> None:
captured: list[str] = []
async def handler(request: httpx.Request) -> httpx.Response:
captured.append(_wire_path(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_id="tid-1", checkpoint_id="../runs")
assert len(captured) == 1
wire = captured[0]
# Wire path must stay on `/threads/{tid}/state/...`, not pivot to
# `/threads/tid-1/runs`.
assert wire.startswith("/threads/tid-1/state/")
# Strip query string before checking the checkpoint segment.
path_only = wire.split("?", 1)[0]
segment = path_only[len("/threads/tid-1/state/") :]
assert "/" not in segment
assert segment == "..%2Fruns"
async def test_assistants_get_subgraphs_with_pivot_namespace_stays_on_subgraphs(
self,
) -> None:
captured: list[str] = []
async def handler(request: httpx.Request) -> httpx.Response:
captured.append(_wire_path(request))
return httpx.Response(200, json={})
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(
transport=transport, base_url="https://example.com"
) as client:
assistants_client = AssistantsClient(HttpClient(client))
await assistants_client.get_subgraphs("aid-1", namespace="../foo")
assert len(captured) == 1
wire = captured[0]
# Wire path must stay on `/assistants/{aid}/subgraphs/...`.
assert wire.startswith("/assistants/aid-1/subgraphs/")
# Strip query string before checking the namespace segment.
path_only = wire.split("?", 1)[0]
segment = path_only[len("/assistants/aid-1/subgraphs/") :]
assert "/" not in segment
assert segment == "..%2Ffoo"
async def test_bare_double_dot_thread_id_survives_to_wire(self) -> None:
"""The all-dot encoding branch must survive httpx's relative-path collapse."""
captured: list[str] = []
async def handler(request: httpx.Request) -> httpx.Response:
captured.append(_wire_path(request))
return httpx.Response(200, json={"thread_id": "anything"})
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("..")
assert len(captured) == 1
# The all-dot identifier is fully percent-encoded so httpx does NOT
# collapse it client-side as a relative-path traversal.
assert captured[0].endswith("/threads/%2E%2E")
async def test_bare_single_dot_thread_id_survives_to_wire(self) -> None:
captured: list[str] = []
async def handler(request: httpx.Request) -> httpx.Response:
captured.append(_wire_path(request))
return httpx.Response(200, json={"thread_id": "anything"})
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(".")
assert len(captured) == 1
assert captured[0].endswith("/threads/%2E")
async def test_uuid_identifier_lands_on_intended_path(self) -> None:
"""Legitimate UUID identifiers round-trip without encoding artifacts."""
captured: list[str] = []
async def handler(request: httpx.Request) -> httpx.Response:
captured.append(_wire_path(request))
return httpx.Response(200, json={"thread_id": "anything"})
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("550e8400-e29b-41d4-a716-446655440000")
assert captured == ["/threads/550e8400-e29b-41d4-a716-446655440000"]
class TestSyncPathEncoding:
"""Sync-client tests that mirror the async coverage on a representative subset."""
def test_threads_get_with_pivot_payload_stays_on_threads(self) -> None:
captured: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
captured.append(_wire_path(request))
return httpx.Response(200, json={"thread_id": "anything"})
transport = httpx.MockTransport(handler)
with httpx.Client(
transport=transport, base_url="https://example.com"
) as client:
threads_client = SyncThreadsClient(SyncHttpClient(client))
threads_client.get("../assistants/abc-123")
assert len(captured) == 1
wire = captured[0]
assert wire.startswith("/threads/")
segment = wire[len("/threads/") :]
assert "/" not in segment
assert segment == "..%2Fassistants%2Fabc-123"
def test_assistants_get_with_pivot_payload_stays_on_assistants(self) -> None:
captured: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
captured.append(_wire_path(request))
return httpx.Response(200, json={"assistant_id": "anything"})
transport = httpx.MockTransport(handler)
with httpx.Client(
transport=transport, base_url="https://example.com"
) as client:
assistants_client = SyncAssistantsClient(SyncHttpClient(client))
assistants_client.get("../threads/abc-123")
assert len(captured) == 1
wire = captured[0]
assert wire.startswith("/assistants/")
segment = wire[len("/assistants/") :]
assert "/" not in segment
def test_runs_delete_double_id_pivot_stays_on_threads_runs(self) -> None:
captured: list[tuple[str, str]] = []
def handler(request: httpx.Request) -> httpx.Response:
captured.append((request.method, _wire_path(request)))
return httpx.Response(200)
transport = httpx.MockTransport(handler)
with httpx.Client(
transport=transport, base_url="https://example.com"
) as client:
runs_client = SyncRunsClient(SyncHttpClient(client))
runs_client.delete("..", "../runs/crons/cron-id")
assert len(captured) == 1
method, wire = captured[0]
assert method == "DELETE"
assert wire.startswith("/threads/")
assert "/runs/crons/" not in wire
parts = wire.split("/")
assert len(parts) == 5
assert parts[1] == "threads"
assert parts[3] == "runs"
assert parts[2] == "%2E%2E"
assert parts[4] == "..%2Fruns%2Fcrons%2Fcron-id"
def test_crons_delete_with_pivot_payload_stays_on_crons(self) -> None:
captured: list[tuple[str, str]] = []
def handler(request: httpx.Request) -> httpx.Response:
captured.append((request.method, _wire_path(request)))
return httpx.Response(200)
transport = httpx.MockTransport(handler)
with httpx.Client(
transport=transport, base_url="https://example.com"
) as client:
crons_client = SyncCronClient(SyncHttpClient(client))
crons_client.delete("../../assistants/abc-123")
assert len(captured) == 1
method, wire = captured[0]
assert method == "DELETE"
assert wire.startswith("/runs/crons/")
segment = wire[len("/runs/crons/") :]
assert "/" not in segment
def test_uuid_identifier_lands_on_intended_path(self) -> None:
captured: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
captured.append(_wire_path(request))
return httpx.Response(200, json={"thread_id": "anything"})
transport = httpx.MockTransport(handler)
with httpx.Client(
transport=transport, base_url="https://example.com"
) as client:
threads_client = SyncThreadsClient(SyncHttpClient(client))
threads_client.get("550e8400-e29b-41d4-a716-446655440000")
assert captured == ["/threads/550e8400-e29b-41d4-a716-446655440000"]
+16 -16
View File
@@ -227,11 +227,11 @@ wheels = [
[[package]]
name = "idna"
version = "3.15"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
@@ -266,7 +266,7 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.4.0"
version = "1.4.0a2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonpatch" },
@@ -279,9 +279,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "uuid-utils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/59/de/679a53472c25860837e32c0442c962fa86e95317a36460e2c9d5c91b17c2/langchain_core-1.4.0.tar.gz", hash = "sha256:1dc341eed802ed9c117c0df3923c991e5e9e226571e5725c194eeb5bd93d1a7f", size = 920260, upload-time = "2026-05-11T18:42:35.919Z" }
sdist = { url = "https://files.pythonhosted.org/packages/3c/93/68bafa047f8e1770d0cf0f61d6c70889f1dec42ef6bd263540d916c421b9/langchain_core-1.4.0a2.tar.gz", hash = "sha256:b723c7961b615c7f2180ce2bcf352fdad8247bc51a60adecd3d97088235c120d", size = 916486, upload-time = "2026-05-01T15:02:19.029Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/1a/86c38c27b81913a1c6c12448cab55defb5a1097c7dc9a4cea83f55477a2d/langchain_core-1.4.0-py3-none-any.whl", hash = "sha256:23cbbdb46e38ddd1dd5247e6167e96013eae74bea4c5949c550809970a9e565c", size = 548120, upload-time = "2026-05-11T18:42:33.992Z" },
{ url = "https://files.pythonhosted.org/packages/4e/8e/933e0ba7ba0430ce264e36b178d581b255239ad45093872483142d93478c/langchain_core-1.4.0a2-py3-none-any.whl", hash = "sha256:a5c689f8404357df797120c012da7704144a953b2ae18f258df263301e7badd5", size = 546297, upload-time = "2026-05-01T15:02:17.731Z" },
]
[[package]]
@@ -298,7 +298,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "1.2.1"
version = "1.2.0a7"
source = { editable = "../langgraph" }
dependencies = [
{ name = "langchain-core" },
@@ -311,7 +311,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "langchain-core", specifier = ">=1.4.0,<2" },
{ name = "langchain-core", specifier = ">=1.4.0a2,<2" },
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-prebuilt", editable = "../prebuilt" },
{ name = "langgraph-sdk", editable = "." },
@@ -382,7 +382,7 @@ test = [
[[package]]
name = "langgraph-checkpoint"
version = "4.1.1"
version = "4.1.0a4"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
@@ -430,7 +430,7 @@ test = [
[[package]]
name = "langgraph-prebuilt"
version = "1.1.0"
version = "1.1.0a2"
source = { editable = "../prebuilt" }
dependencies = [
{ name = "langchain-core" },
@@ -551,7 +551,7 @@ test = [
[[package]]
name = "langsmith"
version = "0.8.0"
version = "0.7.31"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
@@ -564,9 +564,9 @@ dependencies = [
{ name = "xxhash" },
{ name = "zstandard" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a8/64/95f1f013531395f4e8ed73caeee780f65c7c58fe028cb543f8937b45611b/langsmith-0.8.0.tar.gz", hash = "sha256:59fe5b2a56bbbe14a08aa76691f84b49e8675dd21e11b57d80c6db8c08bac2e3", size = 4432996, upload-time = "2026-04-30T22:13:07.341Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/e1/a4be2e696c9473bb53298df398237da5674704d781d4b748ed35aeef592a/langsmith-0.8.0-py3-none-any.whl", hash = "sha256:12cc4bc5622b835a6d841964d6034df3617bdb912dae0c1381fd0a68a9b3a3ef", size = 393268, upload-time = "2026-04-30T22:13:05.56Z" },
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
]
[[package]]
@@ -1320,11 +1320,11 @@ wheels = [
[[package]]
name = "urllib3"
version = "2.7.0"
version = "2.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
[[package]]