Compare commits

..
Author SHA1 Message Date
lc-arjun 03ef1a26e0 feat: support list of hooks for pre_model_hook and post_model_hook
Allow `pre_model_hook` and `post_model_hook` in `create_react_agent`
to accept a list of `RunnableLike` callables in addition to a single
hook. When a list is provided the hooks are composed in order: each
hook receives the graph state merged with all prior hooks' updates,
and the final merged update is returned to the graph.

This mirrors the middleware-stack pattern familiar from HTTP frameworks
(Express, FastAPI, Starlette) and allows hook logic to be written as
small, reusable units that can be composed without manually threading
state between them.
2026-05-08 16:02:24 -04:00
58 changed files with 640 additions and 2917 deletions
+3 -7
View File
@@ -58,18 +58,14 @@ jobs:
} >> "$GITHUB_OUTPUT" } >> "$GITHUB_OUTPUT"
- name: Annotation - name: Annotation
uses: actions/github-script@v9 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: with:
script: | script: |
const file = JSON.parse(process.env.CHANGED_FILES || "[]")[0] const file = JSON.parse(`${{ steps.files.outputs.added_modified_renamed }}`)[0]
core.notice(process.env.BENCHMARK_OUTPUT || "", { core.notice(`${{ steps.benchmark.outputs.OUTPUT }}`, {
title: 'Benchmark results', title: 'Benchmark results',
file, file,
}) })
core.notice(process.env.COMPARE_OUTPUT || "", { core.notice(`${{ steps.compare.outputs.OUTPUT }}`, {
title: 'Comparison against main', title: 'Comparison against main',
file, file,
}) })
-3
View File
@@ -32,7 +32,6 @@ jobs:
with: with:
python-version: ${{ env.PYTHON_VERSION }} python-version: ${{ env.PYTHON_VERSION }}
cache-suffix: "release" cache-suffix: "release"
enable-cache: false
working-directory: ${{ inputs.working-directory }} working-directory: ${{ inputs.working-directory }}
# We want to keep this build stage *separate* from the release stage, # We want to keep this build stage *separate* from the release stage,
@@ -269,7 +268,6 @@ jobs:
with: with:
python-version: ${{ env.PYTHON_VERSION }} python-version: ${{ env.PYTHON_VERSION }}
cache-suffix: "release" cache-suffix: "release"
enable-cache: false
working-directory: ${{ inputs.working-directory }} working-directory: ${{ inputs.working-directory }}
- uses: actions/download-artifact@v8 - uses: actions/download-artifact@v8
@@ -311,7 +309,6 @@ jobs:
with: with:
python-version: ${{ env.PYTHON_VERSION }} python-version: ${{ env.PYTHON_VERSION }}
cache-suffix: "release" cache-suffix: "release"
enable-cache: false
working-directory: ${{ inputs.working-directory }} working-directory: ${{ inputs.working-directory }}
- uses: actions/download-artifact@v8 - uses: actions/download-artifact@v8
+4 -4
View File
@@ -208,11 +208,11 @@ wheels = [
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.15" version = "3.13"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@@ -279,7 +279,7 @@ wheels = [
[[package]] [[package]]
name = "langgraph-checkpoint" name = "langgraph-checkpoint"
version = "4.1.1" version = "4.1.0a4"
source = { editable = "../checkpoint" } source = { editable = "../checkpoint" }
dependencies = [ dependencies = [
{ name = "langchain-core" }, { name = "langchain-core" },
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "langgraph-checkpoint-postgres" name = "langgraph-checkpoint-postgres"
version = "3.1.0" version = "3.1.0a4"
description = "Library with a Postgres implementation of LangGraph checkpoint saver." description = "Library with a Postgres implementation of LangGraph checkpoint saver."
authors = [] authors = []
requires-python = ">=3.10" requires-python = ">=3.10"
@@ -12,7 +12,7 @@ readme = "README.md"
license = "MIT" license = "MIT"
license-files = ['LICENSE'] license-files = ['LICENSE']
dependencies = [ dependencies = [
"langgraph-checkpoint>=4.1.0,<5.0.0", "langgraph-checkpoint>=4.1.0a4,<5.0.0",
"orjson>=3.11.5", "orjson>=3.11.5",
"psycopg>=3.2.0", "psycopg>=3.2.0",
"psycopg-pool>=3.2.0", "psycopg-pool>=3.2.0",
+14 -14
View File
@@ -205,11 +205,11 @@ wheels = [
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.15" version = "3.11"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@@ -244,7 +244,7 @@ wheels = [
[[package]] [[package]]
name = "langchain-core" name = "langchain-core"
version = "1.3.3" version = "1.3.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "jsonpatch" }, { name = "jsonpatch" },
@@ -257,9 +257,9 @@ dependencies = [
{ name = "typing-extensions" }, { name = "typing-extensions" },
{ name = "uuid-utils" }, { 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 = [ 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]] [[package]]
@@ -276,7 +276,7 @@ wheels = [
[[package]] [[package]]
name = "langgraph-checkpoint" name = "langgraph-checkpoint"
version = "4.1.1" version = "4.1.0a4"
source = { editable = "../checkpoint" } source = { editable = "../checkpoint" }
dependencies = [ dependencies = [
{ name = "langchain-core" }, { name = "langchain-core" },
@@ -324,7 +324,7 @@ test = [
[[package]] [[package]]
name = "langgraph-checkpoint-postgres" name = "langgraph-checkpoint-postgres"
version = "3.1.0" version = "3.1.0a4"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "langgraph-checkpoint" }, { name = "langgraph-checkpoint" },
@@ -399,7 +399,7 @@ test = [
[[package]] [[package]]
name = "langsmith" name = "langsmith"
version = "0.8.0" version = "0.7.31"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "httpx" }, { name = "httpx" },
@@ -412,9 +412,9 @@ dependencies = [
{ name = "xxhash" }, { name = "xxhash" },
{ name = "zstandard" }, { 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 = [ 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]] [[package]]
@@ -1234,11 +1234,11 @@ wheels = [
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.7.0" version = "2.6.3"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "langgraph-checkpoint-sqlite" name = "langgraph-checkpoint-sqlite"
version = "3.1.0" version = "3.1.0a1"
description = "Library with a SQLite implementation of LangGraph checkpoint saver." description = "Library with a SQLite implementation of LangGraph checkpoint saver."
authors = [] authors = []
requires-python = ">=3.10" requires-python = ">=3.10"
@@ -12,7 +12,7 @@ readme = "README.md"
license = "MIT" license = "MIT"
license-files = ['LICENSE'] license-files = ['LICENSE']
dependencies = [ dependencies = [
"langgraph-checkpoint>=4.1.0,<5.0.0", "langgraph-checkpoint>=4.1.0a4,<5.0.0",
"aiosqlite>=0.20", "aiosqlite>=0.20",
"sqlite-vec>=0.1.6", "sqlite-vec>=0.1.6",
] ]
+14 -27
View File
@@ -214,11 +214,11 @@ wheels = [
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.15" version = "3.11"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@@ -253,11 +253,10 @@ wheels = [
[[package]] [[package]]
name = "langchain-core" name = "langchain-core"
version = "1.3.3" version = "1.2.28"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "jsonpatch" }, { name = "jsonpatch" },
{ name = "langchain-protocol" },
{ name = "langsmith" }, { name = "langsmith" },
{ name = "packaging" }, { name = "packaging" },
{ name = "pydantic" }, { name = "pydantic" },
@@ -266,26 +265,14 @@ dependencies = [
{ name = "typing-extensions" }, { name = "typing-extensions" },
{ name = "uuid-utils" }, { 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 = [ 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/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 = "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" },
] ]
[[package]] [[package]]
name = "langgraph-checkpoint" name = "langgraph-checkpoint"
version = "4.1.1" version = "4.1.0a4"
source = { editable = "../checkpoint" } source = { editable = "../checkpoint" }
dependencies = [ dependencies = [
{ name = "langchain-core" }, { name = "langchain-core" },
@@ -333,7 +320,7 @@ test = [
[[package]] [[package]]
name = "langgraph-checkpoint-sqlite" name = "langgraph-checkpoint-sqlite"
version = "3.1.0" version = "3.1.0a1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiosqlite" }, { name = "aiosqlite" },
@@ -402,7 +389,7 @@ test = [
[[package]] [[package]]
name = "langsmith" name = "langsmith"
version = "0.8.0" version = "0.7.31"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "httpx" }, { name = "httpx" },
@@ -415,9 +402,9 @@ dependencies = [
{ name = "xxhash" }, { name = "xxhash" },
{ name = "zstandard" }, { 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 = [ 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]] [[package]]
@@ -1161,11 +1148,11 @@ wheels = [
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.7.0" version = "2.6.3"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@@ -60,29 +60,20 @@ class CheckpointMetadata(TypedDict, total=False):
""" """
run_id: str run_id: str
"""The ID of the run that created this checkpoint.""" """The ID of the run that created this checkpoint."""
counters_since_delta_snapshot: dict[str, tuple[int, int]] delta_updates_since_snapshot: dict[str, int]
"""Per-channel counters since the last `_DeltaSnapshot` was written. """Per-channel update count since the last `_DeltaSnapshot` was written.
!!! warning "Beta" !!! warning "Beta"
This metadata field backs `DeltaChannel` (beta). The key name and This metadata field backs `DeltaChannel` (beta). The key name and
contents may change while the delta-channel design stabilizes. contents may change while the delta-channel design stabilizes.
Maps channel name -> `(updates, supersteps)`: Maps channel name → number of supersteps that wrote to this channel
since its last snapshot blob. Used by `pregel.create_checkpoint` to
- index 0 (`updates`): number of supersteps that wrote to this channel decide when to write the next snapshot (when the count reaches the
since its last snapshot blob. channel's `snapshot_frequency`, snapshot fires and the count resets
- index 1 (`supersteps`): total supersteps elapsed since this channel's to 0). Absent on threads that don't use delta channels. Version-format
last snapshot, regardless of whether the channel was written. independent — works for int, float, and string version schemes.
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).
""" """
@@ -157,6 +157,27 @@ class JsonPlusSerializer(SerializerProtocol):
) )
return clone 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: def _reviver(self, value: dict[str, Any]) -> Any:
if ( if (
value.get("lc", None) == 2 value.get("lc", None) == 2
@@ -182,46 +203,45 @@ class JsonPlusSerializer(SerializerProtocol):
self._check_allowed_json_modules(value) self._check_allowed_json_modules(value)
[*module, name] = value["id"] [*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: try:
mod = importlib.import_module(".".join(module)) mod = importlib.import_module(".".join(module))
cls = getattr(mod, name) cls = getattr(mod, name)
if isclass(cls) and issubclass(cls, BaseException): method = value.get("method")
return None 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") args = value.get("args")
kwargs = value.get("kwargs") kwargs = value.get("kwargs")
if args and kwargs: for method in methods:
return cls(*args, **kwargs) try:
elif args: if isclass(method) and issubclass(method, BaseException):
return cls(*args) return None
elif kwargs: if args and kwargs:
return cls(**kwargs) return method(*args, **kwargs)
else: elif args:
return cls() return method(*args)
except Exception as exc: elif kwargs:
# Method-field dispatch has been removed (GHSA-fjqc-hq36-qh5p), so return method(**kwargs)
# legacy pydantic payloads emitting `method=[None, "construct"]` else:
# no longer fall back to `cls.construct(**kwargs)` when the return method()
# default constructor rejects the serialized kwargs. Surface a except Exception:
# one-line warning so operators can spot payloads that now revive continue
# to `None` instead of silently degrading to the raw envelope. except Exception:
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__,
)
return None return None
def _check_allowed_json_modules(self, value: dict[str, Any]) -> None: def _check_allowed_json_modules(self, value: dict[str, Any]) -> None:
needed = tuple(value["id"]) 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) dotted = ".".join(needed)
# Safe types (the same set already allowed for msgpack deserialization) are # Safe types (the same set already allowed for msgpack deserialization) are
# permitted without an explicit allowlist — they are known-safe LangGraph and # permitted without an explicit allowlist — they are known-safe LangGraph and
@@ -232,7 +252,7 @@ class JsonPlusSerializer(SerializerProtocol):
if not self._allowed_json_modules: if not self._allowed_json_modules:
raise InvalidModuleError( 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" "No allowed_json_modules configured.\n\n"
"Unblock with ONE of:\n" "Unblock with ONE of:\n"
f" • JsonPlusSerializer(allowed_json_modules=[{needed!r}, ...])\n" f" • JsonPlusSerializer(allowed_json_modules=[{needed!r}, ...])\n"
@@ -247,7 +267,7 @@ class JsonPlusSerializer(SerializerProtocol):
return return
raise InvalidModuleError( 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" "Symbol is not in the deserialization allowlist.\n\n"
"Add exactly this symbol to unblock:\n" "Add exactly this symbol to unblock:\n"
f" JsonPlusSerializer(allowed_json_modules=[{needed!r}, ...])\n" f" JsonPlusSerializer(allowed_json_modules=[{needed!r}, ...])\n"
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "langgraph-checkpoint" name = "langgraph-checkpoint"
version = "4.1.1" version = "4.1.0a4"
description = "Library with base interfaces for LangGraph checkpoint savers." description = "Library with base interfaces for LangGraph checkpoint savers."
authors = [] authors = []
requires-python = ">=3.10" requires-python = ">=3.10"
-187
View File
@@ -398,193 +398,6 @@ def test_deserde_invalid_module() -> None:
serde.loads_typed(("json", json.dumps(load).encode("utf-8"))) 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: def test_serde_jsonplus_bytearray() -> None:
serde = JsonPlusSerializer() serde = JsonPlusSerializer()
+13 -13
View File
@@ -229,11 +229,11 @@ wheels = [
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.15" version = "3.11"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@@ -268,7 +268,7 @@ wheels = [
[[package]] [[package]]
name = "langchain-core" name = "langchain-core"
version = "1.3.3" version = "1.3.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "jsonpatch" }, { name = "jsonpatch" },
@@ -281,9 +281,9 @@ dependencies = [
{ name = "typing-extensions" }, { name = "typing-extensions" },
{ name = "uuid-utils" }, { 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 = [ 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]] [[package]]
@@ -300,7 +300,7 @@ wheels = [
[[package]] [[package]]
name = "langgraph-checkpoint" name = "langgraph-checkpoint"
version = "4.1.1" version = "4.1.0a4"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "langchain-core" }, { name = "langchain-core" },
@@ -383,7 +383,7 @@ test = [
[[package]] [[package]]
name = "langsmith" name = "langsmith"
version = "0.8.0" version = "0.7.31"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "httpx" }, { name = "httpx" },
@@ -396,9 +396,9 @@ dependencies = [
{ name = "xxhash" }, { name = "xxhash" },
{ name = "zstandard" }, { 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 = [ 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]] [[package]]
@@ -1463,11 +1463,11 @@ wheels = [
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.7.0" version = "2.6.3"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
+5 -4
View File
@@ -3675,11 +3675,12 @@ keyv@^4.5.4:
json-buffer "3.0.1" json-buffer "3.0.1"
"langsmith@>=0.5.0 <1.0.0": "langsmith@>=0.5.0 <1.0.0":
version "0.6.3" version "0.5.20"
resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.6.3.tgz#a3d8ad58d66a47d3697e3c69b2be3f6df5233190" resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.5.20.tgz#4021847d2ccd5a86c5eb96060f9bb5f19f80eca5"
integrity sha512-pXrQ4/4myQvjFFOAUmt5pWRrLEZR20gzIJD7MNdUH+5/S5nLI4ZRBo/SYKC6coaYj9pYTfQdBIzcs+3kfJ5uDA== integrity sha512-ULhLM8RswvQDXufLtNtvclHrWCBx8Cb5UPI6lAZC+8Dq59iHsVPz/3Ac9khWNm1VIvChRsuykixD/WrmzuuA3Q==
dependencies: dependencies:
p-queue "6.6.2" p-queue "6.6.2"
uuid "10.0.0"
leven@^3.1.0: leven@^3.1.0:
version "3.1.0" version "3.1.0"
@@ -4847,7 +4848,7 @@ uri-js@^4.2.2:
dependencies: dependencies:
punycode "^2.1.0" punycode "^2.1.0"
uuid@^10.0.0: uuid@10.0.0, uuid@^10.0.0:
version "10.0.0" version "10.0.0"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294" resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294"
integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ== integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==
+1 -1
View File
@@ -17,7 +17,7 @@
"lint": "eslint 'apps/**/*.ts' 'libs/**/*.ts'" "lint": "eslint 'apps/**/*.ts' 'libs/**/*.ts'"
}, },
"devDependencies": { "devDependencies": {
"turbo": "^2.9.14", "turbo": "^2.9.7",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"@tsconfig/recommended": "^1.0.13", "@tsconfig/recommended": "^1.0.13",
"@eslint/eslintrc": "^3.3.5", "@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" resolved "https://registry.yarnpkg.com/@tsconfig/recommended/-/recommended-1.0.13.tgz#269fce3ad04ca70b93269ff44cca81b950f542da"
integrity sha512-sySRuBfMKyKO/j2ZAhR8kSembhjuPEV4Ra3AHtmWLq51+iGaudr45crPSzNC5b7/Ctrh9dfUpBuTlYrH6rM58Q== integrity sha512-sySRuBfMKyKO/j2ZAhR8kSembhjuPEV4Ra3AHtmWLq51+iGaudr45crPSzNC5b7/Ctrh9dfUpBuTlYrH6rM58Q==
"@turbo/darwin-64@2.9.14": "@turbo/darwin-64@2.9.7":
version "2.9.14" version "2.9.7"
resolved "https://registry.yarnpkg.com/@turbo/darwin-64/-/darwin-64-2.9.14.tgz#b9ec6ac637b9c5fdba5dae9d743f5d121f091242" resolved "https://registry.yarnpkg.com/@turbo/darwin-64/-/darwin-64-2.9.7.tgz#46fddae01ea7817192dc6ff36a678cc3879b753b"
integrity sha512-t7QiPflaEyBE4oayeZtSmu4mEfjgIrcNlNNl1z1dmIVPqEdtA7+CfTf8d7KXsOGPh6aNgWjKxyvQg9uGfDQF+A== integrity sha512-wnvOWuVWJ5EUHNKxExEWiGlTeVpLG1L0PCu5MUozyC1P2SHGiWsmpW6/yAuShH91Fa2TAHOvdCRBzriZh4j4Eg==
"@turbo/darwin-arm64@2.9.14": "@turbo/darwin-arm64@2.9.7":
version "2.9.14" version "2.9.7"
resolved "https://registry.yarnpkg.com/@turbo/darwin-arm64/-/darwin-arm64-2.9.14.tgz#99d19f3e59842c595d2828c72a2e4ef537408f38" resolved "https://registry.yarnpkg.com/@turbo/darwin-arm64/-/darwin-arm64-2.9.7.tgz#cd7cf3a024509af0f59ae3885216d62fd090fdb6"
integrity sha512-d23147mC9BsCPA9mJ0h/ubcpbRgcJBXbcG3+Vq7YLhjz3IXuvQsJ1UXH8f4MD76ZjJ4m/E4aRdJV+MW88CDfbw== integrity sha512-mA0FIPMwwN3lodDkQYaGxj6PeT7ZaN5aCEbkKn/WB+ZB9yJdVWA4J83GH7t43jqDc5dcnVluVN5UFx3plRiXhA==
"@turbo/linux-64@2.9.14": "@turbo/linux-64@2.9.7":
version "2.9.14" version "2.9.7"
resolved "https://registry.yarnpkg.com/@turbo/linux-64/-/linux-64-2.9.14.tgz#9c907434f091cd75529f5496516f79b71935d2bb" resolved "https://registry.yarnpkg.com/@turbo/linux-64/-/linux-64-2.9.7.tgz#25ee9a2cb3042498a3203e14653b14041105020f"
integrity sha512-P3ZKB5tuUDdDQWuAsACGUR1qv9W7BNWxdxqVJ0kZNuNNPRaVYTPPikLcp79+GiEcW3npsR+KyP38lnQiBc5aSA== integrity sha512-fEbUYpgb5l7P+q+5tsWF2gw+/GSjUsuUTcnfm+f0lozUjgcjLKyOat6PgtAChmIFcTPchCL/8rJ3TvkBy01gfA==
"@turbo/linux-arm64@2.9.14": "@turbo/linux-arm64@2.9.7":
version "2.9.14" version "2.9.7"
resolved "https://registry.yarnpkg.com/@turbo/linux-arm64/-/linux-arm64-2.9.14.tgz#79ae9060e6ee9e0fb784ae0747e980f582e75313" resolved "https://registry.yarnpkg.com/@turbo/linux-arm64/-/linux-arm64-2.9.7.tgz#5b19d6249a50cb8153b8f2bc22ad10bbda614fbe"
integrity sha512-ZRTlzcUMrrPv9ZuDzRF9n60Ym13bKeG9jDB8WjxyLhWNzV+AJQN+zdpIk3NJYf2zQsGUm1mNar2P0elRzLw25g== integrity sha512-VkUjulo9ytfHKUHOS5gy0XPoh4CTKPXWCL8nLdrlHVi9fSut31ECeUqnm/dAbETP5D4xo9mH9XkJ+qMzGe/zmg==
"@turbo/windows-64@2.9.14": "@turbo/windows-64@2.9.7":
version "2.9.14" version "2.9.7"
resolved "https://registry.yarnpkg.com/@turbo/windows-64/-/windows-64-2.9.14.tgz#68a80f299f35189314184c88301caad982d1c0c2" resolved "https://registry.yarnpkg.com/@turbo/windows-64/-/windows-64-2.9.7.tgz#900645776fa44ff8333e801d890992736f6dbc72"
integrity sha512-exanwN6sIduZwykYeiTQj8kCmOhazP5WOz3bvXMcYtjhL6Z3iRWLewKrXCBq0bqwSP3iBMb/AerRCnHI4lx46A== integrity sha512-/GWdY6/x4aIHqkYJq596Rpdk1x0MkpRPkJcLAoB3yGRwyUms0+u2F1GnV54IbyAZTeKLRWSJKzNC+QwVGdYchA==
"@turbo/windows-arm64@2.9.14": "@turbo/windows-arm64@2.9.7":
version "2.9.14" version "2.9.7"
resolved "https://registry.yarnpkg.com/@turbo/windows-arm64/-/windows-arm64-2.9.14.tgz#4dc16684f0ddce53fafe56ad8f55205c4473d17a" resolved "https://registry.yarnpkg.com/@turbo/windows-arm64/-/windows-arm64-2.9.7.tgz#e678d73fdbcbb12a679403fe7ba070fb67f37810"
integrity sha512-fVdCsnmYoKICsycbWuuGp6Jvi51/3G/UluFWuAUCvR8PIW5IJkAk5BM9UF8PSm0Q2IphWHFZjYEgjHsh3B9y/g== integrity sha512-xBBgxCC5PK2+WZ1PPRZdp+aJ0bMBcEbweXWux3RUHJvX9ZodcoQySkrW6qt+ahb+uk8ZjyQodLfDwtVSoYds1w==
"@types/esrecurse@^4.3.1": "@types/esrecurse@^4.3.1":
version "4.3.1" version "4.3.1"
@@ -1327,11 +1327,12 @@ keyv@^4.5.4:
json-buffer "3.0.1" json-buffer "3.0.1"
"langsmith@>=0.5.0 <1.0.0": "langsmith@>=0.5.0 <1.0.0":
version "0.7.1" version "0.5.20"
resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.7.1.tgz#d721ad2e5211f9a5373e8a84eb4fed36b5621971" resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.5.20.tgz#4021847d2ccd5a86c5eb96060f9bb5f19f80eca5"
integrity sha512-Wjk90UjNoY5cBHMlNAC/eZx5clI8jnjBOBW8uJu8+MWBtx0QesNjsUiLtjI+I3UnrpxFFpDqGXcnhBjH654Mqg== integrity sha512-ULhLM8RswvQDXufLtNtvclHrWCBx8Cb5UPI6lAZC+8Dq59iHsVPz/3Ac9khWNm1VIvChRsuykixD/WrmzuuA3Q==
dependencies: dependencies:
p-queue "6.6.2" p-queue "6.6.2"
uuid "10.0.0"
levn@^0.4.1: levn@^0.4.1:
version "0.4.1" version "0.4.1"
@@ -1813,17 +1814,17 @@ tsconfig-paths@^3.15.0:
minimist "^1.2.6" minimist "^1.2.6"
strip-bom "^3.0.0" strip-bom "^3.0.0"
turbo@^2.9.14: turbo@^2.9.7:
version "2.9.14" version "2.9.7"
resolved "https://registry.yarnpkg.com/turbo/-/turbo-2.9.14.tgz#d412fcc4c9bd8dba29cec5bbd54d5a74ab2b1bee" resolved "https://registry.yarnpkg.com/turbo/-/turbo-2.9.7.tgz#da6d5a821f0bde5174e1a55af6690dad5c2fb004"
integrity sha512-BQqXRr4UoWI3UPFrtznCLykYHxwxWh53iCB57x092jPMjIlW1wnm3N895g5irpiXmnxUhREBB0n6+y8BHhs4nw== integrity sha512-epxzqVO2s0IxcSWcgb+qKrtco8isfe7g3VtiS6hkYnEK4A9XQDZbrtavQ6MtWR1KoQn+1fUomaQth2rfRHlUlg==
optionalDependencies: optionalDependencies:
"@turbo/darwin-64" "2.9.14" "@turbo/darwin-64" "2.9.7"
"@turbo/darwin-arm64" "2.9.14" "@turbo/darwin-arm64" "2.9.7"
"@turbo/linux-64" "2.9.14" "@turbo/linux-64" "2.9.7"
"@turbo/linux-arm64" "2.9.14" "@turbo/linux-arm64" "2.9.7"
"@turbo/windows-64" "2.9.14" "@turbo/windows-64" "2.9.7"
"@turbo/windows-arm64" "2.9.14" "@turbo/windows-arm64" "2.9.7"
type-check@^0.4.0, type-check@~0.4.0: type-check@^0.4.0, type-check@~0.4.0:
version "0.4.0" version "0.4.0"
@@ -1899,7 +1900,7 @@ uri-js@^4.2.2:
dependencies: dependencies:
punycode "^2.1.0" punycode "^2.1.0"
uuid@^10.0.0: uuid@10.0.0, uuid@^10.0.0:
version "10.0.0" version "10.0.0"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294" resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294"
integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ== 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 # This blocks background execution (cmd &) while allowing command
# chaining (cmd1 && cmd2) which is common in build commands. # chaining (cmd1 && cmd2) which is common in build commands.
_SINGLE_AMPERSAND_RE = re.compile(r"(?<!&)&(?:&&)*(?!&)") _SINGLE_AMPERSAND_RE = re.compile(r"(?<!&)&(?:&&)*(?!&)")
_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: def has_disallowed_build_command_content(command: str) -> bool:
@@ -129,18 +123,6 @@ def _parse_node_version(version_str: str) -> int:
) from None ) 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: def _is_node_graph(spec: str | dict) -> bool:
"""Check if a graph is a Node.js graph based on the file extension.""" """Check if a graph is a Node.js graph based on the file extension."""
if isinstance(spec, dict): if isinstance(spec, dict):
@@ -194,12 +176,12 @@ def validate_config(config: Config) -> Config:
) )
if api_version: if api_version:
try: try:
parts = _parse_api_version_parts(api_version) parts = tuple(map(int, api_version.split("-")[0].split(".")))
if len(parts) > 3: if len(parts) > 3:
raise ValueError( raise ValueError(
"Version must be major or major.minor or major.minor.patch." "Version must be major or major.minor or major.minor.patch."
) )
except (TypeError, ValueError): except TypeError:
raise click.UsageError( raise click.UsageError(
f"Invalid version format: {api_version}.\n\n" f"Invalid version format: {api_version}.\n\n"
"Pin to a minor version, e.g.:\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" 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(): def test_config_to_docker_with_api_version():
"""Test config_to_docker function with api_version parameter.""" """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" requires-python = ">=3.11"
dependencies = [ dependencies = [
"langgraph>=0.6.0,<2", "langgraph>=0.6.0,<2",
"langchain-core>=1.3.3", "langchain-core>=0.2.14",
] ]
[tool.uv.workspace] [tool.uv.workspace]
+10 -23
View File
@@ -215,11 +215,10 @@ wheels = [
[[package]] [[package]]
name = "langchain-core" name = "langchain-core"
version = "1.3.3" version = "1.2.28"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "jsonpatch" }, { name = "jsonpatch" },
{ name = "langchain-protocol" },
{ name = "langsmith" }, { name = "langsmith" },
{ name = "packaging" }, { name = "packaging" },
{ name = "pydantic" }, { name = "pydantic" },
@@ -228,21 +227,9 @@ dependencies = [
{ name = "typing-extensions" }, { name = "typing-extensions" },
{ name = "uuid-utils" }, { 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 = [ 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/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 = "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" },
] ]
[[package]] [[package]]
@@ -303,7 +290,7 @@ wheels = [
[[package]] [[package]]
name = "langsmith" name = "langsmith"
version = "0.8.0" version = "0.7.31"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "httpx" }, { name = "httpx" },
@@ -316,9 +303,9 @@ dependencies = [
{ name = "xxhash" }, { name = "xxhash" },
{ name = "zstandard" }, { 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 = [ 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]] [[package]]
@@ -677,11 +664,11 @@ wheels = [
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.7.0" version = "2.6.3"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@@ -724,7 +711,7 @@ dependencies = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "langchain-core", specifier = ">=1.3.3" }, { name = "langchain-core", specifier = ">=0.2.14" },
{ name = "langgraph", specifier = ">=0.6.0,<2" }, { 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" requires-python = ">=3.11"
dependencies = [ dependencies = [
"langgraph>=0.6.0,<2", "langgraph>=0.6.0,<2",
"langchain-core>=1.3.3", "langchain-core>=0.2.14",
] ]
[build-system] [build-system]
+10 -23
View File
@@ -191,11 +191,10 @@ wheels = [
[[package]] [[package]]
name = "langchain-core" name = "langchain-core"
version = "1.3.3" version = "1.2.28"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "jsonpatch" }, { name = "jsonpatch" },
{ name = "langchain-protocol" },
{ name = "langsmith" }, { name = "langsmith" },
{ name = "packaging" }, { name = "packaging" },
{ name = "pydantic" }, { name = "pydantic" },
@@ -204,21 +203,9 @@ dependencies = [
{ name = "typing-extensions" }, { name = "typing-extensions" },
{ name = "uuid-utils" }, { 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 = [ 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/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 = "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" },
] ]
[[package]] [[package]]
@@ -279,7 +266,7 @@ wheels = [
[[package]] [[package]]
name = "langsmith" name = "langsmith"
version = "0.8.0" version = "0.7.31"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "httpx" }, { name = "httpx" },
@@ -292,9 +279,9 @@ dependencies = [
{ name = "xxhash" }, { name = "xxhash" },
{ name = "zstandard" }, { 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 = [ 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]] [[package]]
@@ -627,7 +614,7 @@ dependencies = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "langchain-core", specifier = ">=1.3.3" }, { name = "langchain-core", specifier = ">=0.2.14" },
{ name = "langgraph", specifier = ">=0.6.0,<2" }, { name = "langgraph", specifier = ">=0.6.0,<2" },
] ]
@@ -663,11 +650,11 @@ wheels = [
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.7.0" version = "2.6.3"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
+12 -25
View File
@@ -797,11 +797,11 @@ wheels = [
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.15" version = "3.11"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@@ -946,11 +946,10 @@ wheels = [
[[package]] [[package]]
name = "langchain-core" name = "langchain-core"
version = "1.3.3" version = "1.3.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "jsonpatch", marker = "python_full_version >= '3.11'" }, { 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 = "langsmith", marker = "python_full_version >= '3.11'" },
{ name = "packaging", marker = "python_full_version >= '3.11'" }, { name = "packaging", marker = "python_full_version >= '3.11'" },
{ name = "pydantic", 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 = "typing-extensions", marker = "python_full_version >= '3.11'" },
{ name = "uuid-utils", 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 = [ 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/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]]
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" },
] ]
[[package]] [[package]]
@@ -1174,7 +1161,7 @@ wheels = [
[[package]] [[package]]
name = "langsmith" name = "langsmith"
version = "0.8.0" version = "0.7.32"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "httpx", marker = "python_full_version >= '3.11'" }, { name = "httpx", marker = "python_full_version >= '3.11'" },
@@ -1187,9 +1174,9 @@ dependencies = [
{ name = "xxhash", marker = "python_full_version >= '3.11'" }, { name = "xxhash", marker = "python_full_version >= '3.11'" },
{ name = "zstandard", 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 = [ 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] [package.optional-dependencies]
@@ -2320,11 +2307,11 @@ wheels = [
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.7.0" version = "2.6.3"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@@ -29,9 +29,6 @@ from langgraph._internal._constants import (
) )
DEFAULT_RECURSION_LIMIT = int(getenv("LANGGRAPH_DEFAULT_RECURSION_LIMIT", "10007")) 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: 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 change in future releases. Threads written with `DeltaChannel` today
are expected to remain readable, but the surrounding contract are expected to remain readable, but the surrounding contract
(`BaseCheckpointSaver.get_delta_channel_history`, the (`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. metadata field) is not yet stable.
The reducer receives the current accumulated value and a batch of writes 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 This lets LangGraph replay checkpointed writes in larger batches than they
were originally produced without changing reconstructed state. were originally produced without changing reconstructed state.
Snapshot cadence is driven by two counters: per-channel update count and Snapshot cadence is driven by per-channel update count. `create_checkpoint`
total supersteps since last snapshot. `create_checkpoint` writes a full writes a full `_DeltaSnapshot` blob every `snapshot_frequency` updates to
`_DeltaSnapshot` blob when EITHER the update count reaches this channel, bounding replay depth.
`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.
Parameters: Parameters:
reducer: `(state, list[writes]) -> new_state`. Must be deterministic reducer: `(state, list[writes]) -> new_state`. Must be deterministic
+2 -133
View File
@@ -6,7 +6,7 @@ import typing
import warnings import warnings
from collections import defaultdict from collections import defaultdict
from collections.abc import Awaitable, Callable, Hashable, Sequence from collections.abc import Awaitable, Callable, Hashable, Sequence
from dataclasses import dataclass, is_dataclass from dataclasses import is_dataclass
from datetime import timedelta from datetime import timedelta
from functools import partial from functools import partial
from inspect import isclass, isfunction, ismethod, signature from inspect import isclass, isfunction, ismethod, signature
@@ -95,17 +95,6 @@ __all__ = ("StateGraph", "CompiledStateGraph")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_CHANNEL_BRANCH_TO = "branch:to:{}" _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: 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.output_schema = cast(type[OutputT], output_schema or state_schema)
self.context_schema = context_schema self.context_schema = context_schema
self._node_defaults: _NodeDefaults = _NodeDefaults()
self._add_schema(self.state_schema) self._add_schema(self.state_schema)
self._add_schema(self.input_schema, allow_managed=False) self._add_schema(self.input_schema, allow_managed=False)
self._add_schema(self.output_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 @property
def _all_edges(self) -> set[tuple[str, str]]: def _all_edges(self) -> set[tuple[str, str]]:
return self.edges | { 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) 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_error_handler_map = {
node_name: spec.error_handler_node node_name: spec.error_handler_node
for node_name, spec in self.nodes.items() 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]( 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 ( from langgraph.checkpoint.base import (
BaseCheckpointSaver, BaseCheckpointSaver,
Checkpoint, Checkpoint,
CheckpointMetadata,
) )
from langgraph.checkpoint.base.id import uuid6 from langgraph.checkpoint.base.id import uuid6
from langgraph.checkpoint.serde.types import _DeltaSnapshot from langgraph.checkpoint.serde.types import _DeltaSnapshot
from langgraph._internal._config import DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT
from langgraph._internal._typing import MISSING from langgraph._internal._typing import MISSING
from langgraph.channels.base import BaseChannel from langgraph.channels.base import BaseChannel
from langgraph.channels.delta import DeltaChannel from langgraph.channels.delta import DeltaChannel
@@ -36,26 +36,21 @@ def empty_checkpoint() -> Checkpoint:
def delta_channels_to_snapshot( def delta_channels_to_snapshot(
channels: Mapping[str, BaseChannel], channels: Mapping[str, BaseChannel],
counters_since_delta_snapshot: Mapping[str, tuple[int, int]], counts: Mapping[str, int],
) -> set[str]: ) -> set[str]:
"""Return the set of DeltaChannel names that should snapshot now. """Return the set of DeltaChannel names that should snapshot now.
A channel snapshots when EITHER its accumulated update count reaches A channel snapshots when its accumulated update count (since the last
`snapshot_frequency` OR the total supersteps since its last snapshot snapshot) reaches or exceeds `snapshot_frequency`. This is a pure
reaches `DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT`. This is a pure
predicate — no mutation. predicate — no mutation.
""" """
result: set[str] = set() return {
for name, ch in channels.items(): name
if not isinstance(ch, DeltaChannel) or not ch.is_available(): for name, ch in channels.items()
continue if isinstance(ch, DeltaChannel)
updates, supersteps = counters_since_delta_snapshot.get(name, (0, 0)) and ch.is_available()
if ( and counts.get(name, 0) >= ch.snapshot_frequency
updates >= ch.snapshot_frequency }
or supersteps >= DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT
):
result.add(name)
return result
def create_checkpoint( def create_checkpoint(
@@ -74,7 +69,7 @@ def create_checkpoint(
is written into `channel_values[k]`. Other delta channels are omitted is written into `channel_values[k]`. Other delta channels are omitted
from `channel_values` — the ancestor walk reconstructs their state from `channel_values` — the ancestor walk reconstructs their state
from `checkpoint_writes`. Callers compute the set via 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. (no snapshots) when not provided.
""" """
ts = datetime.now(timezone.utc).isoformat() 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()}, versions_seen={k: v.copy() for k, v in checkpoint["versions_seen"].items()},
updated_channels=checkpoint.get("updated_channels", None), 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 {})
+40 -145
View File
@@ -115,7 +115,6 @@ from langgraph.pregel._io import (
map_output_values, map_output_values,
read_channels, read_channels,
) )
from langgraph.pregel._messages import ensure_message_ids
from langgraph.pregel._read import PregelNode from langgraph.pregel._read import PregelNode
from langgraph.pregel._utils import get_new_channel_versions, is_xxh3_128_hexdigest from langgraph.pregel._utils import get_new_channel_versions, is_xxh3_128_hexdigest
from langgraph.pregel.debug import ( from langgraph.pregel.debug import (
@@ -204,12 +203,6 @@ class PregelLoop:
# `__enter__`; stays `None` only when no checkpointer. # `__enter__`; stays `None` only when no checkpointer.
_delta_write_futs: list[Any] | None = None _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 # Exit-mode accumulator: every delta-channel write produced during this
# run (input writes from `_first` + per-superstep writes captured in # run (input writes from `_first` + per-superstep writes captured in
# `after_tick`). At exit, `_put_exit_delta_writes` filters out channels # `after_tick`). At exit, `_put_exit_delta_writes` filters out channels
@@ -448,14 +441,6 @@ class PregelLoop:
# save writes # save writes
self.checkpoint_pending_writes.extend((task_id, c, v) for c, v in writes) self.checkpoint_pending_writes.extend((task_id, c, v) for c, v in writes)
# Assign stable IDs to any id=None BaseMessages in DeltaChannel writes
# before the background thread serialises them. Without this, reducers
# that assign IDs inside apply_writes() race with serialisation and
# store id=None, causing get_state() replays to produce a different UUID
# on every call.
for c, v in writes_to_save:
if isinstance(self.specs.get(c), DeltaChannel):
ensure_message_ids(v)
if self.durability != "exit" and self.checkpointer_put_writes is not None: if self.durability != "exit" and self.checkpointer_put_writes is not None:
config = patch_configurable( config = patch_configurable(
self.checkpoint_config, self.checkpoint_config,
@@ -489,13 +474,6 @@ class PregelLoop:
isinstance(self.specs.get(c), DeltaChannel) for c, _ in writes_to_save isinstance(self.specs.get(c), DeltaChannel) for c, _ in writes_to_save
): ):
self._delta_write_futs.append(fut) 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 # output writes
if hasattr(self, "tasks"): if hasattr(self, "tasks"):
self.output_writes(task_id, writes) self.output_writes(task_id, writes)
@@ -575,7 +553,7 @@ class PregelLoop:
self.tasks[pushed.id] = pushed self.tasks[pushed.id] = pushed
# match any pending writes to the new task # match any pending writes to the new task
if not self.is_replaying: 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 the new task, to be started if not run before
return pushed return pushed
@@ -653,8 +631,7 @@ class PregelLoop:
# if there are pending writes from a previous loop, apply them # if there are pending writes from a previous loop, apply them
if not self.is_replaying and self.checkpoint_pending_writes: if not self.is_replaying and self.checkpoint_pending_writes:
self._reapply_writes_to_succeeded_nodes(self.tasks) self._match_writes(self.tasks)
self._resume_error_handlers_if_applicable()
# before execution, check if we should interrupt # before execution, check if we should interrupt
if self.interrupt_before and should_interrupt( if self.interrupt_before and should_interrupt(
@@ -721,88 +698,13 @@ class PregelLoop:
# private # private
def _reapply_writes_to_succeeded_nodes( def _match_writes(self, tasks: Mapping[str, PregelExecutableTask]) -> None:
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.
"""
for tid, k, v in self.checkpoint_pending_writes: for tid, k, v in self.checkpoint_pending_writes:
if k in (ERROR, ERROR_SOURCE_NODE, INTERRUPT, RESUME): if k in (ERROR, ERROR_SOURCE_NODE, INTERRUPT, RESUME):
continue continue
if task := tasks.get(tid): if task := tasks.get(tid):
task.writes.append((k, v)) 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]: def _pending_interrupts(self) -> set[str]:
"""Return the set of interrupt ids that are pending without corresponding resume values.""" """Return the set of interrupt ids that are pending without corresponding resume values."""
# mapping of task ids to interrupt ids # mapping of task ids to interrupt ids
@@ -1076,41 +978,35 @@ class PregelLoop:
if exiting and self.checkpoint["id"] == self.checkpoint_id_saved: if exiting and self.checkpoint["id"] == self.checkpoint_id_saved:
# checkpoint already saved # checkpoint already saved
return return
# Per-delta-channel counter bookkeeping. # Per-delta-channel update bookkeeping.
#
# Each delta channel tracks a (updates, supersteps) tuple:
# - `updates` increments only when the channel is written this step.
# - `supersteps` increments every superstep regardless.
# #
# `_put_checkpoint` is called once per superstep with a fresh # `_put_checkpoint` is called once per superstep with a fresh
# metadata dict (source="input"|"loop"|"fork") — those are the # 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) # `_suppress_interrupt`(will rename to _on_loop_exit soon)
# additionally calls `_put_checkpoint(self.checkpoint_metadata)` AT # additionally calls `_put_checkpoint(self.checkpoint_metadata)` AT
# EXIT to commit the final checkpoint — this runs *after* the last # EXIT to commit the final checkpoint — this runs *after* the last
# intermediate call already counted the last superstep. So the # intermediate call already counted the last superstep. So the
# exit call must NOT bump again or it would double-count the last # 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: if not exiting:
prev_counters = dict( prev_counts = dict(
self.checkpoint_metadata.get("counters_since_delta_snapshot") or {} self.checkpoint_metadata.get("delta_updates_since_snapshot", {}) or {}
) )
new_counters: dict[str, tuple[int, int]] = {} new_counts = dict(prev_counts)
updated = self.updated_channels or set() if self.updated_channels:
for ch_name, ch in self.channels.items(): for ch_name in self.updated_channels:
if not isinstance(ch, DeltaChannel): if isinstance(self.channels.get(ch_name), DeltaChannel):
continue new_counts[ch_name] = new_counts.get(ch_name, 0) + 1
u, s = prev_counters.get(ch_name, (0, 0))
s += 1
if ch_name in updated:
u += 1
new_counters[ch_name] = (u, s)
metadata["step"] = self.step metadata["step"] = self.step
metadata["parents"] = self.config[CONF].get(CONFIG_KEY_CHECKPOINT_MAP, {}) metadata["parents"] = self.config[CONF].get(CONFIG_KEY_CHECKPOINT_MAP, {})
self.checkpoint_metadata = metadata self.checkpoint_metadata = metadata
else: else:
new_counters = dict( new_counts = dict(
self.checkpoint_metadata.get("counters_since_delta_snapshot") or {} self.checkpoint_metadata.get("delta_updates_since_snapshot", {}) or {}
) )
# do checkpoint? # do checkpoint?
do_checkpoint = self._checkpointer_put_after_previous is not None and ( do_checkpoint = self._checkpointer_put_after_previous is not None and (
@@ -1118,7 +1014,7 @@ class PregelLoop:
) )
# create new checkpoint # create new checkpoint
channels_to_snapshot = ( channels_to_snapshot = (
delta_channels_to_snapshot(self.channels, new_counters) delta_channels_to_snapshot(self.channels, new_counts)
if do_checkpoint if do_checkpoint
else set() else set()
) )
@@ -1134,12 +1030,11 @@ class PregelLoop:
channels_to_snapshot=channels_to_snapshot, channels_to_snapshot=channels_to_snapshot,
) )
for k in channels_to_snapshot: for k in channels_to_snapshot:
new_counters[k] = (0, 0) new_counts[k] = 0
non_zero = {k: v for k, v in new_counters.items() if v != (0, 0)} if new_counts:
if non_zero: self.checkpoint_metadata["delta_updates_since_snapshot"] = new_counts
self.checkpoint_metadata["counters_since_delta_snapshot"] = non_zero elif "delta_updates_since_snapshot" in self.checkpoint_metadata:
elif "counters_since_delta_snapshot" in self.checkpoint_metadata: del self.checkpoint_metadata["delta_updates_since_snapshot"]
del self.checkpoint_metadata["counters_since_delta_snapshot"]
# sanitize TASK channel in the checkpoint before saving (durability=="exit") # sanitize TASK channel in the checkpoint before saving (durability=="exit")
if TASKS in self.checkpoint["channel_values"] and any( if TASKS in self.checkpoint["channel_values"] and any(
isinstance(channel, UntrackedValue) for channel in self.channels.values() isinstance(channel, UntrackedValue) for channel in self.channels.values()
@@ -1214,10 +1109,8 @@ class PregelLoop:
): ):
return return
counters = dict( counts = self.checkpoint_metadata.get("delta_updates_since_snapshot", {}) or {}
self.checkpoint_metadata.get("counters_since_delta_snapshot") or {} channels_to_snapshot = delta_channels_to_snapshot(self.channels, counts)
)
channels_to_snapshot = delta_channels_to_snapshot(self.channels, counters)
pending = [ pending = [
(step, tid, ch, v) (step, tid, ch, v)
@@ -1552,10 +1445,12 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
handler_node = self.nodes[failed_task.name].error_handler_node handler_node = self.nodes[failed_task.name].error_handler_node
if not handler_node: if not handler_node:
return None return None
# ensure error + ERROR_SOURCE_NODE writes are durable before handler runs writes = list(failed_task.writes)
if self._error_handler_write_futs: writes.append((ERROR_SOURCE_NODE, failed_task.name))
futs, self._error_handler_write_futs = self._error_handler_write_futs, [] self.put_writes(
concurrent.futures.wait(futs) failed_task.id,
writes,
)
handler_task = prepare_node_error_handler_task( handler_task = prepare_node_error_handler_task(
failed_task, failed_task,
handler_node_name=handler_node, handler_node_name=handler_node,
@@ -1578,7 +1473,7 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
return None return None
self.tasks[handler_task.id] = handler_task self.tasks[handler_task.id] = handler_task
if not self.is_replaying: 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(): for task in self.match_cached_writes():
self.output_writes(task.id, task.writes, cached=True) self.output_writes(task.id, task.writes, cached=True)
return handler_task return handler_task
@@ -1660,7 +1555,6 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
else [] else []
) )
self._delta_write_futs = [] self._delta_write_futs = []
self._error_handler_write_futs = []
self._exit_delta_writes = ( self._exit_delta_writes = (
[] if self.durability == "exit" and self.checkpointer is not None else None [] if self.durability == "exit" and self.checkpointer is not None else None
) )
@@ -1806,10 +1700,12 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
handler_node = self.nodes[failed_task.name].error_handler_node handler_node = self.nodes[failed_task.name].error_handler_node
if not handler_node: if not handler_node:
return None return None
# ensure error + ERROR_SOURCE_NODE writes are durable before handler runs writes = list(failed_task.writes)
if self._error_handler_write_futs: writes.append((ERROR_SOURCE_NODE, failed_task.name))
futs, self._error_handler_write_futs = self._error_handler_write_futs, [] self.put_writes(
await asyncio.gather(*futs) failed_task.id,
writes,
)
handler_task = prepare_node_error_handler_task( handler_task = prepare_node_error_handler_task(
failed_task, failed_task,
handler_node_name=handler_node, handler_node_name=handler_node,
@@ -1832,7 +1728,7 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
return None return None
self.tasks[handler_task.id] = handler_task self.tasks[handler_task.id] = handler_task
if not self.is_replaying: 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(): for task in await self.amatch_cached_writes():
self.output_writes(task.id, task.writes, cached=True) self.output_writes(task.id, task.writes, cached=True)
return handler_task return handler_task
@@ -1917,7 +1813,6 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
else [] else []
) )
self._delta_write_futs = [] self._delta_write_futs = []
self._error_handler_write_futs = []
self._exit_delta_writes = ( self._exit_delta_writes = (
[] if self.durability == "exit" and self.checkpointer is not None else None [] if self.durability == "exit" and self.checkpointer is not None else None
) )
+1 -84
View File
@@ -10,8 +10,7 @@ from typing import (
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from langchain_core.callbacks import BaseCallbackHandler from langchain_core.callbacks import BaseCallbackHandler
from langchain_core.messages import BaseMessage, ToolMessage from langchain_core.messages import BaseMessage
from langchain_core.messages.utils import convert_to_messages
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, LLMResult from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, LLMResult
from pydantic import BaseModel from pydantic import BaseModel
@@ -304,35 +303,6 @@ class StreamMessagesHandlerV2(StreamMessagesHandler, _V2StreamingCallbackHandler
super().__init__(stream, subgraphs, parent_ns=parent_ns) super().__init__(stream, subgraphs, parent_ns=parent_ns)
self._streamed_run_ids: set[UUID] = set() 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( def on_llm_end(
self, self,
response: LLMResult, response: LLMResult,
@@ -406,56 +376,3 @@ class StreamMessagesHandlerV2(StreamMessagesHandler, _V2StreamingCallbackHandler
self.seen.add(msg_id) self.seen.add(msg_id)
v2_meta = {**meta[1], "run_id": str(run_id)} v2_meta = {**meta[1], "run_id": str(run_id)}
self.stream((meta[0], "messages", (event, v2_meta))) self.stream((meta[0], "messages", (event, v2_meta)))
# Known role values (OpenAI-style) and type values (LangChain serialisation)
# that identify a dict as a message. Checked before coercing to BaseMessage so
# we don't accidentally touch unrelated dicts that happen to have a "role" key.
_MESSAGE_ROLES: frozenset[str] = frozenset(
{"user", "human", "assistant", "ai", "tool", "system", "function"}
)
_MESSAGE_TYPES: frozenset[str] = frozenset(
{"human", "ai", "tool", "system", "function", "remove"}
)
def _is_message_dict(item: dict) -> bool:
return item.get("role") in _MESSAGE_ROLES or item.get("type") in _MESSAGE_TYPES
def ensure_message_ids(value: Any) -> None:
"""Coerce message-like write values to typed BaseMessages with stable IDs.
Called in put_writes() before DeltaChannel writes are submitted to the
checkpointer. Without this the checkpoint may store raw dicts or id=None
BaseMessages; every get_state() replay then produces a different UUID and
the same message appears with a different ID in each LangSmith trace.
Handles three input shapes:
- BaseMessage objects: assign a UUID if id is None.
- Dicts with a known "role" (OpenAI-style) or "type" (LangChain format) at
the root level: stamp "id" into the dict in-place. The reducer's
convert_to_messages call will forward the id to the resulting BaseMessage.
- Lists of the above: apply the same logic to each element, replacing dict
items with coerced BaseMessages so the shared list reference seen by
checkpoint_pending_writes and the background thread both get typed messages.
Mutating synchronously here (before the background thread is submitted) is
safe: the serialised bytes always reflect the post-coercion state.
"""
if isinstance(value, BaseMessage):
if value.id is None:
value.id = str(uuid4())
elif isinstance(value, dict) and _is_message_dict(value):
if not value.get("id"):
value["id"] = str(uuid4())
elif isinstance(value, list):
for i, item in enumerate(value):
if isinstance(item, BaseMessage):
if item.id is None:
item.id = str(uuid4())
elif isinstance(item, dict) and _is_message_dict(item):
msg = convert_to_messages([item])[0]
if msg.id is None:
msg.id = str(uuid4())
value[i] = msg
+1 -2
View File
@@ -31,7 +31,6 @@ from langgraph._internal._constants import (
CONFIG_KEY_CALL, CONFIG_KEY_CALL,
CONFIG_KEY_SCRATCHPAD, CONFIG_KEY_SCRATCHPAD,
ERROR, ERROR,
ERROR_SOURCE_NODE,
INTERRUPT, INTERRUPT,
NO_WRITES, NO_WRITES,
RESUME, RESUME,
@@ -598,7 +597,7 @@ class PregelRunner:
if self._should_route_to_error_handler(task) and not isinstance( if self._should_route_to_error_handler(task) and not isinstance(
exception, GraphBubbleUp 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._handled_exception_ids.add(id(exception))
self.put_writes()(task.id, task.writes) # type: ignore[misc] self.put_writes()(task.id, task.writes) # type: ignore[misc]
else: else:
+5 -30
View File
@@ -62,14 +62,6 @@ class StreamMux:
`extensions`, `_native` keys are recorded in `native_keys`, and `extensions`, `_native` keys are recorded in `native_keys`, and
any StreamChannel instances are bound and (if named) wired. 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: Args:
transformers: Already-built transformer instances. Registered transformers: Already-built transformer instances. Registered
only on this mux — they are NOT cloned into child only on this mux — they are NOT cloned into child
@@ -120,31 +112,14 @@ class StreamMux:
self._pump_fn: Callable[[], bool] | None = None self._pump_fn: Callable[[], bool] | None = None
self._apump_fn: Callable[[], Awaitable[bool]] | None = None self._apump_fn: Callable[[], Awaitable[bool]] | None = None
# Factories run first (they propagate to child mini-muxes via # Factories run first (they propagate to child mini-muxes
# `_make_child`), then any pre-built `transformers=` instances # via `_make_child`), then any pre-built `transformers=`
# are registered as root-only — they aren't cloned for child # instances are registered as root-only — they aren't cloned
# scopes. Within each group, transformers with # for child scopes.
# `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] = []
if factories is not None: if factories is not None:
for factory in factories: for factory in factories:
transformer = factory(scope) self._register(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()
for transformer in transformers or (): for transformer in transformers or ():
(pre if getattr(transformer, "before_builtins", False) else rest).append(
transformer
)
for transformer in (*pre, *rest):
self._register(transformer) self._register(transformer)
def transformer_by_key(self, key: str) -> StreamTransformer | None: 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. which modes a `stream_events(version="v3")` run requests from the graph.
Empty tuple means the transformer consumes only synthetic Empty tuple means the transformer consumes only synthetic
events (or is purely passive). 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 requires_async: ClassVar[bool] = False
supports_sync: ClassVar[bool] = False supports_sync: ClassVar[bool] = False
required_stream_modes: ClassVar[tuple[str, ...]] = () required_stream_modes: ClassVar[tuple[str, ...]] = ()
before_builtins: ClassVar[bool] = False
def __init__(self, scope: tuple[str, ...] = ()) -> None: def __init__(self, scope: tuple[str, ...] = ()) -> None:
"""Initialize the transformer with its mux's scope. """Initialize the transformer with its mux's scope.
@@ -8,7 +8,7 @@ from langchain_core.language_models.chat_model_stream import (
AsyncChatModelStream, AsyncChatModelStream,
ChatModelStream, ChatModelStream,
) )
from langchain_core.messages import AIMessageChunk, BaseMessage, ToolMessage from langchain_core.messages import AIMessageChunk, BaseMessage
from langchain_protocol.protocol import MessagesData from langchain_protocol.protocol import MessagesData
from typing_extensions import NotRequired, TypedDict from typing_extensions import NotRequired, TypedDict
@@ -203,7 +203,6 @@ class MessagesTransformer(StreamTransformer):
# Correlate protocol events back to a ChatModelStream by run_id # Correlate protocol events back to a ChatModelStream by run_id
# (attached to the event's metadata by StreamMessagesHandler). # (attached to the event's metadata by StreamMessagesHandler).
self._by_run: dict[str, ChatModelStream] = {} self._by_run: dict[str, ChatModelStream] = {}
self._ignored_runs: set[str] = set()
self._pump_fn: Callable[[], bool] | None = None self._pump_fn: Callable[[], bool] | None = None
self._apump_fn: Callable[[], Awaitable[bool]] | None = None self._apump_fn: Callable[[], Awaitable[bool]] | None = None
# Cached as a list once for cheap equality with the protocol # Cached as a list once for cheap equality with the protocol
@@ -277,10 +276,8 @@ class MessagesTransformer(StreamTransformer):
self._route_protocol_event( self._route_protocol_event(
cast("MessagesData", payload), run_id=run_id, node=node cast("MessagesData", payload), run_id=run_id, node=node
) )
elif ( elif isinstance(payload, BaseMessage) and not isinstance(
isinstance(payload, BaseMessage) payload, AIMessageChunk
and not isinstance(payload, AIMessageChunk)
and not isinstance(payload, ToolMessage)
): ):
self._route_whole_message(payload, node=node) self._route_whole_message(payload, node=node)
# Legacy AIMessageChunk tuples (from on_llm_new_token) are ignored; # Legacy AIMessageChunk tuples (from on_llm_new_token) are ignored;
@@ -298,11 +295,6 @@ class MessagesTransformer(StreamTransformer):
) -> None: ) -> None:
event_type = event.get("event") event_type = event.get("event")
if event_type == "message-start": 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") message_id = event.get("message_id")
stream = self._make_stream( stream = self._make_stream(
namespace=[], namespace=[],
@@ -312,9 +304,6 @@ class MessagesTransformer(StreamTransformer):
self._by_run[run_id] = stream self._by_run[run_id] = stream
self._log.push(stream) self._log.push(stream)
stream.dispatch(event) 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: elif run_id in self._by_run:
stream = self._by_run[run_id] stream = self._by_run[run_id]
stream.dispatch(event) stream.dispatch(event)
@@ -330,14 +319,12 @@ class MessagesTransformer(StreamTransformer):
def finalize(self) -> None: def finalize(self) -> None:
"""Clear any routing state — streams close themselves via `message-finish`.""" """Clear any routing state — streams close themselves via `message-finish`."""
self._by_run.clear() self._by_run.clear()
self._ignored_runs.clear()
def fail(self, err: BaseException) -> None: def fail(self, err: BaseException) -> None:
"""Propagate run error to any streams still open when the graph fails.""" """Propagate run error to any streams still open when the graph fails."""
for stream in list(self._by_run.values()): for stream in list(self._by_run.values()):
stream.fail(err) stream.fail(err)
self._by_run.clear() self._by_run.clear()
self._ignored_runs.clear()
SubgraphStatus = Literal["started", "completed", "failed", "interrupted", "drained"] SubgraphStatus = Literal["started", "completed", "failed", "interrupted", "drained"]
+4 -4
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "langgraph" name = "langgraph"
version = "1.2.2" version = "1.2.0a7"
description = "Building stateful, multi-actor applications with LLMs" description = "Building stateful, multi-actor applications with LLMs"
authors = [] authors = []
requires-python = ">=3.10" requires-python = ">=3.10"
@@ -24,10 +24,10 @@ classifiers = [
'Programming Language :: Python :: 3.13', 'Programming Language :: Python :: 3.13',
] ]
dependencies = [ dependencies = [
"langchain-core>=1.4.0,<2", "langchain-core>=1.4.0a2,<2",
"langgraph-checkpoint>=4.1.0,<5.0.0", "langgraph-checkpoint>=4.1.0a4,<5.0.0",
"langgraph-sdk>=0.3.0,<0.4.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", "xxhash>=3.5.0",
"pydantic>=2.7.4", "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: async def test_exit_count_parity_sync_vs_exit() -> None:
"""Sync and exit durability produce the same update count in """Sync and exit durability produce the same delta_updates_since_snapshot
counters_since_delta_snapshot after an equivalent run.""" after an equivalent run."""
for durability in ("sync", "exit"): for durability in ("sync", "exit"):
saver = InMemorySaver() saver = InMemorySaver()
graph = _build_graph(saver) graph = _build_graph(saver)
@@ -175,13 +175,9 @@ async def test_exit_count_parity_sync_vs_exit() -> None:
head = saver.get_tuple(config) head = saver.get_tuple(config)
assert head is not None assert head is not None
counters = head.metadata.get("counters_since_delta_snapshot", {}) counts = head.metadata.get("delta_updates_since_snapshot", {})
updates, supersteps = counters.get("messages", (0, 0)) assert counts.get("messages") == 2, (
assert updates == 2, ( f"durability={durability}: expected count=2, got {counts}"
f"durability={durability}: expected updates=2, got {updates}"
)
assert supersteps >= 2, (
f"durability={durability}: expected supersteps>=2, got {supersteps}"
) )
@@ -200,9 +196,8 @@ async def test_exit_snapshot_fires_at_frequency() -> None:
) )
head = saver.get_tuple(config) head = saver.get_tuple(config)
assert head is not None assert head is not None
counters1 = head.metadata.get("counters_since_delta_snapshot", {}) count1 = head.metadata.get("delta_updates_since_snapshot", {}).get("messages", 0)
updates1 = counters1.get("messages", (0, 0))[0] assert count1 == 2
assert updates1 == 2
graph.invoke( graph.invoke(
{"messages": [HumanMessage(content="m2", id="h2")]}, {"messages": [HumanMessage(content="m2", id="h2")]},
@@ -211,9 +206,8 @@ async def test_exit_snapshot_fires_at_frequency() -> None:
) )
head = saver.get_tuple(config) head = saver.get_tuple(config)
assert head is not None assert head is not None
counters2 = head.metadata.get("counters_since_delta_snapshot", {}) count2 = head.metadata.get("delta_updates_since_snapshot", {}).get("messages", 0)
updates2 = counters2.get("messages", (0, 0))[0] assert count2 == 0, f"Expected reset to 0 after snapshot, got {count2}"
assert updates2 == 0, f"Expected reset to 0 after snapshot, got {updates2}"
assert isinstance(head.checkpoint["channel_values"].get("messages"), _DeltaSnapshot) 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: async def test_exit_metadata_round_trip() -> None:
"""K=5 consecutive exit runs with snapshot_frequency=5. Verify metadata """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 freq = 5
saver = InMemorySaver() saver = InMemorySaver()
graph = _build_graph(saver, freq=freq) graph = _build_graph(saver, freq=freq)
@@ -304,16 +298,15 @@ async def test_exit_metadata_round_trip() -> None:
) )
head = saver.get_tuple(config) head = saver.get_tuple(config)
assert head is not None assert head is not None
counters = head.metadata.get("counters_since_delta_snapshot", {}) count = head.metadata.get("delta_updates_since_snapshot", {}).get("messages", 0)
updates = counters.get("messages", (0, 0))[0]
cumulative = i * 2 cumulative = i * 2
if cumulative >= freq: if cumulative >= freq:
assert updates == 0 or updates == cumulative % freq or updates < freq, ( assert count == 0 or count == cumulative % freq or count < freq, (
f"After run {i}: updates={updates} should have reset or be partial" f"After run {i}: count={count} should have reset or be partial"
) )
else: else:
assert updates == cumulative, ( assert count == cumulative, (
f"After run {i}: expected {cumulative}, got {updates}" f"After run {i}: expected {cumulative}, got {count}"
) )
@@ -1,140 +0,0 @@
"""ensure_message_ids() assigns stable UUIDs to id=None BaseMessages
before DeltaChannel writes are serialised to the checkpoint.
Without this, the checkpoint stores id=None and every get_state() replay
produces a different UUID — the same HumanMessage appears with a different
ID in each LangSmith trace / on every resumed invocation.
"""
from __future__ import annotations
from typing import Annotated, Any
import pytest
from langchain_core.messages import AIMessage, AnyMessage, HumanMessage
from langgraph.checkpoint.memory import InMemorySaver
from typing_extensions import TypedDict
from langgraph.channels.delta import DeltaChannel
from langgraph.graph import END, START, StateGraph
pytestmark = pytest.mark.anyio
def _append_reducer(
state: list[AnyMessage], writes: list[list[AnyMessage]]
) -> list[AnyMessage]:
"""Simple append — no ID assignment. IDs come from ensure_message_ids()."""
result = list(state)
for w in writes:
if isinstance(w, list):
result.extend(w)
else:
result.append(w) # type: ignore[arg-type]
return result
def _build_graph(checkpointer: Any) -> Any:
State = TypedDict( # noqa: UP013
"State",
{
"messages": Annotated[
list, DeltaChannel(_append_reducer, snapshot_frequency=50)
]
},
) # type: ignore[call-overload]
def agent(state: dict) -> dict:
return {"messages": [AIMessage(content="reply", id="ai-1")]}
return (
StateGraph(State)
.add_node("agent", agent)
.add_edge(START, "agent")
.add_edge("agent", END)
.compile(checkpointer=checkpointer)
)
def test_delta_channel_message_gets_id_and_stays_stable() -> None:
"""Messages written with id=None must receive a stable UUID.
ensure_message_ids() is called in put_writes() before the background
thread serialises DeltaChannel writes. The checkpoint stores the
assigned UUID, so every get_state() replay sees the same ID.
"""
saver = InMemorySaver()
graph = _build_graph(saver)
config = {"configurable": {"thread_id": "id-stability"}}
graph.invoke({"messages": [HumanMessage(content="hello")]}, config)
ids = [
next(
m.id
for m in graph.get_state(config).values["messages"]
if isinstance(m, HumanMessage)
)
for _ in range(3)
]
assert ids[0] is not None, "ensure_message_ids should have assigned a UUID"
assert len(set(ids)) == 1, (
f"HumanMessage id must be stable across get_state() calls; "
f"got {ids}. The checkpoint is storing id=None."
)
async def test_delta_channel_message_gets_id_and_stays_stable_async() -> None:
"""Same check via ainvoke (AsyncPregelLoop path)."""
saver = InMemorySaver()
graph = _build_graph(saver)
config = {"configurable": {"thread_id": "id-stability-async"}}
await graph.ainvoke({"messages": [HumanMessage(content="hello")]}, config)
ids = [
next(
m.id
for m in (await graph.aget_state(config)).values["messages"]
if isinstance(m, HumanMessage)
)
for _ in range(3)
]
assert ids[0] is not None, "ensure_message_ids should have assigned a UUID"
assert len(set(ids)) == 1, (
f"Async path: HumanMessage id unstable across aget_state() calls: {ids}"
)
def test_delta_channel_dict_style_message_gets_stable_id() -> None:
"""Dict-style inputs (API / over-the-wire format) must also get stable IDs.
When the graph is invoked via the LangGraph API the input arrives as a raw
dict {"role": "user", "content": "..."} rather than a BaseMessage object.
ensure_message_ids() must coerce those dicts to typed BaseMessages and
stamp a UUID so the checkpoint never stores an id-less message.
"""
saver = InMemorySaver()
graph = _build_graph(saver)
config = {"configurable": {"thread_id": "dict-id-stability"}}
# Invoke with a raw dict (the format LangGraph API sends)
graph.invoke({"messages": [{"role": "user", "content": "hello"}]}, config)
ids = [
next(
m.id
for m in graph.get_state(config).values["messages"]
if isinstance(m, HumanMessage)
)
for _ in range(3)
]
assert ids[0] is not None, (
"dict-style message should have been coerced and assigned a UUID"
)
assert len(set(ids)) == 1, (
f"dict-style HumanMessage id must be stable across get_state() calls; got {ids}"
)
@@ -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.language_models.fake_chat_models import GenericFakeChatModel
from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage, HumanMessage from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage, HumanMessage
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult 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.memory import InMemorySaver, MemorySaver
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
from typing_extensions import TypedDict 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"): with pytest.raises(ValueError, match="no handler"):
graph.invoke({"foo": ""}) 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, AsyncChatModelStream,
ChatModelStream, ChatModelStream,
) )
from langchain_core.messages import AIMessage, AIMessageChunk, ToolMessage from langchain_core.messages import AIMessage, AIMessageChunk
from langchain_core.runnables import RunnableConfig from langchain_core.runnables import RunnableConfig
from typing_extensions import TypedDict from typing_extensions import TypedDict
@@ -213,23 +213,6 @@ class TestProtocolEventRouting:
log.close() log.close()
assert _unstamped(log._items) == [] 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: def test_concurrent_streams_routed_by_run_id(self) -> None:
t, log = _make_sync_transformer() t, log = _make_sync_transformer()
life_a = _lifecycle(text="aaaa", message_id="run-a") life_a = _lifecycle(text="aaaa", message_id="run-a")
@@ -290,29 +273,6 @@ class TestWholeMessageFallback:
assert stream.done assert stream.done
assert stream.output.text == "the full answer" 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: def test_whole_message_has_full_lifecycle(self) -> None:
t, log = _make_sync_transformer() t, log = _make_sync_transformer()
t.process(_whole_msg("full")) t.process(_whole_msg("full"))
@@ -889,23 +849,6 @@ class TestStreamMessagesHandlerV2Unit:
assert emitted == [] 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: 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 """A streamed v2 message should not be emitted again from the final
AIMessage fallback when its final id does not match `message-start`.""" AIMessage fallback when its final id does not match `message-start`."""
+21 -21
View File
@@ -871,11 +871,11 @@ wheels = [
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.15" version = "3.11"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@@ -1350,7 +1350,7 @@ wheels = [
[[package]] [[package]]
name = "langchain-core" name = "langchain-core"
version = "1.4.0" version = "1.4.0a2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "jsonpatch" }, { name = "jsonpatch" },
@@ -1363,9 +1363,9 @@ dependencies = [
{ name = "typing-extensions" }, { name = "typing-extensions" },
{ name = "uuid-utils" }, { 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 = [ 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]] [[package]]
@@ -1382,7 +1382,7 @@ wheels = [
[[package]] [[package]]
name = "langgraph" name = "langgraph"
version = "1.2.2" version = "1.2.0a7"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "langchain-core" }, { name = "langchain-core" },
@@ -1454,7 +1454,7 @@ test = [
[package.metadata] [package.metadata]
requires-dist = [ 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-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-prebuilt", editable = "../prebuilt" }, { name = "langgraph-prebuilt", editable = "../prebuilt" },
{ name = "langgraph-sdk", editable = "../sdk-py" }, { name = "langgraph-sdk", editable = "../sdk-py" },
@@ -1563,7 +1563,7 @@ wheels = [
[[package]] [[package]]
name = "langgraph-checkpoint" name = "langgraph-checkpoint"
version = "4.1.1" version = "4.1.0a4"
source = { editable = "../checkpoint" } source = { editable = "../checkpoint" }
dependencies = [ dependencies = [
{ name = "langchain-core" }, { name = "langchain-core" },
@@ -1611,7 +1611,7 @@ test = [
[[package]] [[package]]
name = "langgraph-checkpoint-postgres" name = "langgraph-checkpoint-postgres"
version = "3.1.0" version = "3.1.0a4"
source = { editable = "../checkpoint-postgres" } source = { editable = "../checkpoint-postgres" }
dependencies = [ dependencies = [
{ name = "langgraph-checkpoint" }, { name = "langgraph-checkpoint" },
@@ -1658,7 +1658,7 @@ test = [
[[package]] [[package]]
name = "langgraph-checkpoint-sqlite" name = "langgraph-checkpoint-sqlite"
version = "3.1.0" version = "3.1.0a1"
source = { editable = "../checkpoint-sqlite" } source = { editable = "../checkpoint-sqlite" }
dependencies = [ dependencies = [
{ name = "aiosqlite" }, { name = "aiosqlite" },
@@ -1757,7 +1757,7 @@ test = [
[[package]] [[package]]
name = "langgraph-prebuilt" name = "langgraph-prebuilt"
version = "1.1.0" version = "1.1.0a2"
source = { editable = "../prebuilt" } source = { editable = "../prebuilt" }
dependencies = [ dependencies = [
{ name = "langchain-core" }, { name = "langchain-core" },
@@ -1867,7 +1867,7 @@ test = [
[[package]] [[package]]
name = "langsmith" name = "langsmith"
version = "0.8.0" version = "0.7.31"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "httpx" }, { name = "httpx" },
@@ -1880,9 +1880,9 @@ dependencies = [
{ name = "xxhash" }, { name = "xxhash" },
{ name = "zstandard" }, { 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 = [ 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] [package.optional-dependencies]
@@ -2085,14 +2085,14 @@ wheels = [
[[package]] [[package]]
name = "mistune" name = "mistune"
version = "3.2.1" version = "3.2.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.11'" }, { 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 = [ 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]] [[package]]
@@ -3743,11 +3743,11 @@ wheels = [
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.7.0" version = "2.6.3"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@@ -5,42 +5,12 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from typing import Any from typing import Any
from langchain_core.messages import ToolMessage
from langgraph.stream._types import ProtocolEvent, StreamTransformer from langgraph.stream._types import ProtocolEvent, StreamTransformer
from langgraph.stream.stream_channel import StreamChannel from langgraph.stream.stream_channel import StreamChannel
from langgraph.prebuilt._tool_call_stream import ToolCallStream 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): class ToolCallTransformer(StreamTransformer):
"""Project `tools` channel events into `ToolCallStream` handles. """Project `tools` channel events into `ToolCallStream` handles.
@@ -139,7 +109,7 @@ class ToolCallTransformer(StreamTransformer):
elif event_type == "tool-finished": elif event_type == "tool-finished":
stream = self._active.pop(tool_call_id, None) stream = self._active.pop(tool_call_id, None)
if stream is not None: if stream is not None:
stream._finish(_normalize_tool_output(data.get("output"))) stream._finish(data.get("output"))
elif event_type == "tool-error": elif event_type == "tool-error":
stream = self._active.pop(tool_call_id, None) stream = self._active.pop(tool_call_id, None)
if stream is not None: if stream is not None:
@@ -6,6 +6,7 @@ from typing import (
Any, Any,
Literal, Literal,
TypeVar, TypeVar,
Union,
cast, cast,
get_type_hints, get_type_hints,
) )
@@ -125,6 +126,9 @@ Prompt = (
| Runnable[StateSchema, LanguageModelInput] | Runnable[StateSchema, LanguageModelInput]
) )
# A single hook or a list of hooks to be composed in order.
HookLike = Union[RunnableLike, Sequence[RunnableLike]]
def _get_state_value(state: StateSchema, key: str, default: Any = None) -> Any: def _get_state_value(state: StateSchema, key: str, default: Any = None) -> Any:
return ( return (
@@ -134,6 +138,131 @@ def _get_state_value(state: StateSchema, key: str, default: Any = None) -> Any:
) )
def _set_state_value(state: StateSchema, key: str, value: Any) -> None:
"""Set a value in the state, supporting both dict and Pydantic model states."""
if isinstance(state, dict):
state[key] = value
else:
setattr(state, key, value)
def _merge_state_update(state: StateSchema, update: dict) -> StateSchema:
"""Return a shallow copy of *state* with *update* applied.
This is used when chaining multiple hooks: each hook receives the state
as it would look after all previous hooks have run, so that hooks later
in the chain can observe updates made by earlier ones.
Note: only simple key-level merging is performed here (no reducer logic).
The full reducer logic is applied by the graph engine when the final
combined update dict is written back to the state.
"""
if isinstance(state, dict):
return {**state, **update} # type: ignore[return-value]
else:
# Pydantic / dataclass – make a shallow copy and patch fields
try:
merged = state.model_copy() # pydantic v2
except AttributeError:
merged = state.copy() # pydantic v1 / dataclass fallback
for k, v in update.items():
setattr(merged, k, v)
return merged # type: ignore[return-value]
def _coerce_to_runnable(hook: RunnableLike) -> RunnableCallable:
"""Wrap a plain callable into a RunnableCallable if necessary."""
if isinstance(hook, RunnableCallable):
return hook
if isinstance(hook, Runnable):
# Already a Runnable – wrap so we get a uniform interface
sync_fn = hook.invoke
async_fn = hook.ainvoke
return RunnableCallable(sync_fn, async_fn)
if inspect.iscoroutinefunction(hook):
return RunnableCallable(None, hook)
if callable(hook):
return RunnableCallable(hook)
raise TypeError(f"Expected a callable or Runnable, got {type(hook)!r}")
def _chain_hooks(hooks: Sequence[RunnableLike]) -> RunnableCallable:
"""Compose multiple hook callables into a single hook.
Each hook is called in order. After each hook the returned update dict is
merged into a running copy of the graph state so that subsequent hooks can
observe the changes made by earlier ones. The accumulated update dict
(union of all individual update dicts, with later hooks winning on key
conflicts) is returned as the final state update.
Args:
hooks: A sequence of :data:`RunnableLike` objects. Each must accept
the graph state as its first positional argument and return a
``dict`` of state updates.
Returns:
A :class:`~langgraph._internal._runnable.RunnableCallable` that behaves
like a single hook but applies all of *hooks* in sequence.
"""
if not hooks:
raise ValueError("_chain_hooks requires at least one hook")
if len(hooks) == 1:
return _coerce_to_runnable(hooks[0])
runnables = [_coerce_to_runnable(h) for h in hooks]
def _sync_chained(state: Any, **kwargs: Any) -> dict:
accumulated: dict = {}
current_state = state
for runnable in runnables:
# Pass extra kwargs (e.g. config, store) through if the hook
# accepts them; RunnableCallable handles introspection.
update = runnable.invoke(current_state, **kwargs)
if update:
accumulated.update(update)
current_state = _merge_state_update(current_state, update)
return accumulated
async def _async_chained(state: Any, **kwargs: Any) -> dict:
accumulated: dict = {}
current_state = state
for runnable in runnables:
update = await runnable.ainvoke(current_state, **kwargs)
if update:
accumulated.update(update)
current_state = _merge_state_update(current_state, update)
return accumulated
return RunnableCallable(_sync_chained, _async_chained, name="chained_hooks")
def _resolve_hook(hook: HookLike | None) -> RunnableLike | None:
"""Normalise *hook* to a single ``RunnableLike`` (or ``None``).
* If *hook* is ``None`` → return ``None``.
* If *hook* is already a ``RunnableLike`` → return it unchanged.
* If *hook* is a non-empty :class:`~collections.abc.Sequence` of
``RunnableLike`` → chain them with :func:`_chain_hooks`.
"""
if hook is None:
return None
# A Sequence[RunnableLike] but NOT a single Runnable/callable
if (
isinstance(hook, Sequence)
and not isinstance(hook, str)
and not isinstance(hook, Runnable)
and not callable(hook)
):
hooks_list: list[RunnableLike] = list(hook)
if not hooks_list:
return None
if len(hooks_list) == 1:
return hooks_list[0]
return _chain_hooks(hooks_list)
# Single hook – return as-is
return hook # type: ignore[return-value]
def _get_prompt_runnable(prompt: Prompt | None) -> Runnable: def _get_prompt_runnable(prompt: Prompt | None) -> Runnable:
prompt_runnable: Runnable prompt_runnable: Runnable
if prompt is None: if prompt is None:
@@ -293,8 +422,8 @@ def create_react_agent(
response_format: StructuredResponseSchema response_format: StructuredResponseSchema
| tuple[str, StructuredResponseSchema] | tuple[str, StructuredResponseSchema]
| None = None, | None = None,
pre_model_hook: RunnableLike | None = None, pre_model_hook: HookLike | None = None,
post_model_hook: RunnableLike | None = None, post_model_hook: HookLike | None = None,
state_schema: StateSchemaType | None = None, state_schema: StateSchemaType | None = None,
context_schema: type[Any] | None = None, context_schema: type[Any] | None = None,
checkpointer: Checkpointer | None = None, checkpointer: Checkpointer | None = None,
@@ -393,10 +522,21 @@ def create_react_agent(
The graph will make a separate call to the LLM to generate the structured response after the agent loop is finished. The graph will make a separate call to the LLM to generate the structured response after the agent loop is finished.
This is not the only strategy to get structured responses, see more options in [this guide](https://langchain-ai.github.io/langgraph/how-tos/react-agent-structured-output/). This is not the only strategy to get structured responses, see more options in [this guide](https://langchain-ai.github.io/langgraph/how-tos/react-agent-structured-output/).
pre_model_hook: An optional node to add before the `agent` node (i.e., the node that calls the LLM). pre_model_hook: An optional node (or list of nodes) to add before the
Useful for managing long message histories (e.g., message trimming, summarization, etc.). ``agent`` node (i.e., the node that calls the LLM). Useful for
Pre-model hook must be a callable or a runnable that takes in current graph state and returns a state update in the form of managing long message histories (e.g., message trimming,
```python summarization, etc.) or for composing multiple pre-processing
steps.
A single hook **or a list of hooks** may be provided. When a list
is given the hooks are executed in order: each hook receives the
graph state as updated by all preceding hooks, and the union of
all their return dicts is applied to the graph state before the
agent node runs.
Each hook must be a callable or a runnable that takes the current
graph state and returns a state update::
# At least one of `messages` or `llm_input_messages` MUST be provided # At least one of `messages` or `llm_input_messages` MUST be provided
{ {
# If provided, will UPDATE the `messages` in the state # If provided, will UPDATE the `messages` in the state
@@ -407,27 +547,63 @@ def create_react_agent(
# Any other state keys that need to be propagated # Any other state keys that need to be propagated
... ...
} }
```
!!! Important !!! Important
At least one of `messages` or `llm_input_messages` MUST be provided and will be used as an input to the `agent` node. At least one of `messages` or `llm_input_messages` MUST be
The rest of the keys will be added to the graph state. provided (by at least one hook in the chain) and will be used
as an input to the ``agent`` node. The rest of the keys will
be added to the graph state.
!!! Warning !!! Warning
If you are returning `messages` in the pre-model hook, you should OVERWRITE the `messages` key by doing the following: If you are returning `messages` in the pre-model hook, you
should OVERWRITE the `messages` key::
{
"messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES), *new_messages]
...
}
!!! Example "Composing multiple pre-model hooks"
```python ```python
{ from langchain_core.messages import RemoveMessage
"messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES), *new_messages] from langgraph.graph.message import REMOVE_ALL_MESSAGES
...
} def trim_messages(state):
# Keep only the last 10 messages
return {
"messages": [
RemoveMessage(id=REMOVE_ALL_MESSAGES),
*state["messages"][-10:],
]
}
def inject_system_prompt(state):
return {
"llm_input_messages": [
SystemMessage("You are a helpful assistant."),
*state["messages"],
]
}
agent = create_react_agent(
model,
tools,
pre_model_hook=[trim_messages, inject_system_prompt],
)
``` ```
post_model_hook: An optional node to add after the `agent` node (i.e., the node that calls the LLM).
Useful for implementing human-in-the-loop, guardrails, validation, or other post-processing. post_model_hook: An optional node (or list of nodes) to add after the
Post-model hook must be a callable or a runnable that takes in current graph state and returns a state update. ``agent`` node (i.e., the node that calls the LLM). Useful for
implementing human-in-the-loop, guardrails, validation, or other
post-processing steps.
Accepts the same single-hook-or-list-of-hooks form as
``pre_model_hook``.
!!! Note !!! Note
Only available with `version="v2"`. Only available with ``version="v2"``.
state_schema: An optional state schema that defines graph state. state_schema: An optional state schema that defines graph state.
Must have `messages` and `remaining_steps` keys. Must have `messages` and `remaining_steps` keys.
Defaults to `AgentState` that defines those two keys. Defaults to `AgentState` that defines those two keys.
@@ -551,6 +727,10 @@ def create_react_agent(
else AgentState else AgentState
) )
# Normalise hook arguments: a list of hooks is composed into a single hook.
resolved_pre_model_hook: RunnableLike | None = _resolve_hook(pre_model_hook)
resolved_post_model_hook: RunnableLike | None = _resolve_hook(post_model_hook)
llm_builtin_tools: list[dict] = [] llm_builtin_tools: list[dict] = []
if isinstance(tools, ToolNode): if isinstance(tools, ToolNode):
tool_classes = list(tools.tools_by_name.values()) tool_classes = list(tools.tools_by_name.values())
@@ -634,7 +814,7 @@ def create_react_agent(
return False return False
def _get_model_input_state(state: StateSchema) -> StateSchema: def _get_model_input_state(state: StateSchema) -> StateSchema:
if pre_model_hook is not None: if resolved_pre_model_hook is not None:
messages = ( messages = (
_get_state_value(state, "llm_input_messages") _get_state_value(state, "llm_input_messages")
) or _get_state_value(state, "messages") ) or _get_state_value(state, "messages")
@@ -721,7 +901,7 @@ def create_react_agent(
return {"messages": [response]} return {"messages": [response]}
input_schema: StateSchemaType input_schema: StateSchemaType
if pre_model_hook is not None: if resolved_pre_model_hook is not None:
# Dynamically create a schema that inherits from state_schema and adds 'llm_input_messages' # Dynamically create a schema that inherits from state_schema and adds 'llm_input_messages'
if isinstance(state_schema, type) and issubclass(state_schema, BaseModel): if isinstance(state_schema, type) and issubclass(state_schema, BaseModel):
# For Pydantic schemas # For Pydantic schemas
@@ -792,8 +972,8 @@ def create_react_agent(
RunnableCallable(call_model, acall_model), RunnableCallable(call_model, acall_model),
input_schema=input_schema, input_schema=input_schema,
) )
if pre_model_hook is not None: if resolved_pre_model_hook is not None:
workflow.add_node("pre_model_hook", pre_model_hook) # type: ignore[arg-type] workflow.add_node("pre_model_hook", resolved_pre_model_hook) # type: ignore[arg-type]
workflow.add_edge("pre_model_hook", "agent") workflow.add_edge("pre_model_hook", "agent")
entrypoint = "pre_model_hook" entrypoint = "pre_model_hook"
else: else:
@@ -801,8 +981,8 @@ def create_react_agent(
workflow.set_entry_point(entrypoint) workflow.set_entry_point(entrypoint)
if post_model_hook is not None: if resolved_post_model_hook is not None:
workflow.add_node("post_model_hook", post_model_hook) # type: ignore[arg-type] workflow.add_node("post_model_hook", resolved_post_model_hook) # type: ignore[arg-type]
workflow.add_edge("agent", "post_model_hook") workflow.add_edge("agent", "post_model_hook")
if response_format is not None: if response_format is not None:
@@ -813,7 +993,7 @@ def create_react_agent(
agenerate_structured_response, agenerate_structured_response,
), ),
) )
if post_model_hook is not None: if resolved_post_model_hook is not None:
workflow.add_edge("post_model_hook", "generate_structured_response") workflow.add_edge("post_model_hook", "generate_structured_response")
else: else:
workflow.add_edge("agent", "generate_structured_response") workflow.add_edge("agent", "generate_structured_response")
@@ -833,7 +1013,7 @@ def create_react_agent(
last_message = messages[-1] last_message = messages[-1]
# If there is no function call, then we finish # If there is no function call, then we finish
if not isinstance(last_message, AIMessage) or not last_message.tool_calls: if not isinstance(last_message, AIMessage) or not last_message.tool_calls:
if post_model_hook is not None: if resolved_post_model_hook is not None:
return "post_model_hook" return "post_model_hook"
elif response_format is not None: elif response_format is not None:
return "generate_structured_response" return "generate_structured_response"
@@ -844,7 +1024,7 @@ def create_react_agent(
if version == "v1": if version == "v1":
return "tools" return "tools"
elif version == "v2": elif version == "v2":
if post_model_hook is not None: if resolved_post_model_hook is not None:
return "post_model_hook" return "post_model_hook"
return [ return [
Send( Send(
@@ -873,8 +1053,8 @@ def create_react_agent(
# Optionally add a pre-model hook node that will be called # Optionally add a pre-model hook node that will be called
# every time before the "agent" (LLM-calling node) # every time before the "agent" (LLM-calling node)
if pre_model_hook is not None: if resolved_pre_model_hook is not None:
workflow.add_node("pre_model_hook", pre_model_hook) # type: ignore[arg-type] workflow.add_node("pre_model_hook", resolved_pre_model_hook) # type: ignore[arg-type]
workflow.add_edge("pre_model_hook", "agent") workflow.add_edge("pre_model_hook", "agent")
entrypoint = "pre_model_hook" entrypoint = "pre_model_hook"
else: else:
@@ -888,8 +1068,8 @@ def create_react_agent(
post_model_hook_paths = [entrypoint, "tools"] post_model_hook_paths = [entrypoint, "tools"]
# Add a post model hook node if post_model_hook is provided # Add a post model hook node if post_model_hook is provided
if post_model_hook is not None: if resolved_post_model_hook is not None:
workflow.add_node("post_model_hook", post_model_hook) # type: ignore[arg-type] workflow.add_node("post_model_hook", resolved_post_model_hook) # type: ignore[arg-type]
agent_paths.append("post_model_hook") agent_paths.append("post_model_hook")
workflow.add_edge("agent", "post_model_hook") workflow.add_edge("agent", "post_model_hook")
else: else:
@@ -904,17 +1084,17 @@ def create_react_agent(
agenerate_structured_response, agenerate_structured_response,
), ),
) )
if post_model_hook is not None: if resolved_post_model_hook is not None:
post_model_hook_paths.append("generate_structured_response") post_model_hook_paths.append("generate_structured_response")
else: else:
agent_paths.append("generate_structured_response") agent_paths.append("generate_structured_response")
else: else:
if post_model_hook is not None: if resolved_post_model_hook is not None:
post_model_hook_paths.append(END) post_model_hook_paths.append(END)
else: else:
agent_paths.append(END) agent_paths.append(END)
if post_model_hook is not None: if resolved_post_model_hook is not None:
def post_model_hook_router(state: StateSchema) -> str | list[Send]: def post_model_hook_router(state: StateSchema) -> str | list[Send]:
"""Route to the next node after post_model_hook. """Route to the next node after post_model_hook.
@@ -1012,4 +1192,5 @@ __all__ = [
"AgentStatePydantic", "AgentStatePydantic",
"AgentStateWithStructuredResponse", "AgentStateWithStructuredResponse",
"AgentStateWithStructuredResponsePydantic", "AgentStateWithStructuredResponsePydantic",
"HookLike",
] ]
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "langgraph-prebuilt" 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." description = "Library with high-level APIs for creating and executing LangGraph agents and tools."
authors = [] authors = []
requires-python = ">=3.10" requires-python = ">=3.10"
@@ -6,7 +6,7 @@ import time
from typing import Annotated, Any from typing import Annotated, Any
import pytest import pytest
from langchain_core.messages import AIMessage, ToolMessage from langchain_core.messages import AIMessage
from langchain_core.tools import tool from langchain_core.tools import tool
from langgraph.constants import END, START from langgraph.constants import END, START
from langgraph.graph import StateGraph from langgraph.graph import StateGraph
@@ -128,42 +128,6 @@ class TestToolCallTransformerUnit:
assert stream.error is None assert stream.error is None
assert "tc1" not in transformer._active 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: def test_error_closes_stream(self) -> None:
mux, transformer = _mux() mux, transformer = _mux()
mux.push(_tool_event("tool-started", "tc1", tool_name="boom")) mux.push(_tool_event("tool-started", "tc1", tool_name="boom"))
+18 -18
View File
@@ -214,11 +214,11 @@ wheels = [
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.15" version = "3.11"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@@ -253,7 +253,7 @@ wheels = [
[[package]] [[package]]
name = "langchain-core" name = "langchain-core"
version = "1.4.0" version = "1.4.0a2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "jsonpatch" }, { name = "jsonpatch" },
@@ -266,9 +266,9 @@ dependencies = [
{ name = "typing-extensions" }, { name = "typing-extensions" },
{ name = "uuid-utils" }, { 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 = [ 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]] [[package]]
@@ -285,7 +285,7 @@ wheels = [
[[package]] [[package]]
name = "langgraph" name = "langgraph"
version = "1.2.2" version = "1.2.0a7"
source = { editable = "../langgraph" } source = { editable = "../langgraph" }
dependencies = [ dependencies = [
{ name = "langchain-core" }, { name = "langchain-core" },
@@ -298,7 +298,7 @@ dependencies = [
[package.metadata] [package.metadata]
requires-dist = [ 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-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-prebuilt", editable = "." }, { name = "langgraph-prebuilt", editable = "." },
{ name = "langgraph-sdk", editable = "../sdk-py" }, { name = "langgraph-sdk", editable = "../sdk-py" },
@@ -369,7 +369,7 @@ test = [
[[package]] [[package]]
name = "langgraph-checkpoint" name = "langgraph-checkpoint"
version = "4.1.1" version = "4.1.0a4"
source = { editable = "../checkpoint" } source = { editable = "../checkpoint" }
dependencies = [ dependencies = [
{ name = "langchain-core" }, { name = "langchain-core" },
@@ -417,7 +417,7 @@ test = [
[[package]] [[package]]
name = "langgraph-checkpoint-postgres" name = "langgraph-checkpoint-postgres"
version = "3.1.0" version = "3.1.0a4"
source = { editable = "../checkpoint-postgres" } source = { editable = "../checkpoint-postgres" }
dependencies = [ dependencies = [
{ name = "langgraph-checkpoint" }, { name = "langgraph-checkpoint" },
@@ -464,7 +464,7 @@ test = [
[[package]] [[package]]
name = "langgraph-checkpoint-sqlite" name = "langgraph-checkpoint-sqlite"
version = "3.1.0" version = "3.1.0a1"
source = { editable = "../checkpoint-sqlite" } source = { editable = "../checkpoint-sqlite" }
dependencies = [ dependencies = [
{ name = "aiosqlite" }, { name = "aiosqlite" },
@@ -507,7 +507,7 @@ test = [
[[package]] [[package]]
name = "langgraph-prebuilt" name = "langgraph-prebuilt"
version = "1.1.0" version = "1.1.0a2"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "langchain-core" }, { name = "langchain-core" },
@@ -636,7 +636,7 @@ test = [
[[package]] [[package]]
name = "langsmith" name = "langsmith"
version = "0.8.0" version = "0.7.31"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "httpx" }, { name = "httpx" },
@@ -649,9 +649,9 @@ dependencies = [
{ name = "xxhash" }, { name = "xxhash" },
{ name = "zstandard" }, { 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 = [ 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]] [[package]]
@@ -1490,11 +1490,11 @@ wheels = [
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.7.0" version = "2.6.3"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[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 import Encryption
from langgraph_sdk.encryption.types import EncryptionContext from langgraph_sdk.encryption.types import EncryptionContext
__version__ = "0.3.15" __version__ = "0.3.14"
__all__ = ["Auth", "Encryption", "EncryptionContext", "get_client", "get_sync_client"] __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 import httpx
from langgraph_sdk._async.http import HttpClient from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._shared.utilities import _quote_path_param
from langgraph_sdk.schema import ( from langgraph_sdk.schema import (
Assistant, Assistant,
AssistantSelectField, AssistantSelectField,
@@ -85,9 +84,7 @@ class AssistantsClient:
``` ```
""" """
return await self.http.get( return await self.http.get(
f"/assistants/{_quote_path_param(assistant_id)}", f"/assistants/{assistant_id}", headers=headers, params=params
headers=headers,
params=params,
) )
async def get_graph( async def get_graph(
@@ -145,9 +142,7 @@ class AssistantsClient:
query_params.update(params) query_params.update(params)
return await self.http.get( return await self.http.get(
f"/assistants/{_quote_path_param(assistant_id)}/graph", f"/assistants/{assistant_id}/graph", params=query_params, headers=headers
params=query_params,
headers=headers,
) )
async def get_schemas( async def get_schemas(
@@ -268,9 +263,7 @@ class AssistantsClient:
""" """
return await self.http.get( return await self.http.get(
f"/assistants/{_quote_path_param(assistant_id)}/schemas", f"/assistants/{assistant_id}/schemas", headers=headers, params=params
headers=headers,
params=params,
) )
async def get_subgraphs( async def get_subgraphs(
@@ -300,13 +293,13 @@ class AssistantsClient:
get_params = {**get_params, **dict(params)} get_params = {**get_params, **dict(params)}
if namespace is not None: if namespace is not None:
return await self.http.get( 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, params=get_params,
headers=headers, headers=headers,
) )
else: else:
return await self.http.get( return await self.http.get(
f"/assistants/{_quote_path_param(assistant_id)}/subgraphs", f"/assistants/{assistant_id}/subgraphs",
params=get_params, params=get_params,
headers=headers, headers=headers,
) )
@@ -443,7 +436,7 @@ class AssistantsClient:
if description: if description:
payload["description"] = description payload["description"] = description
return await self.http.patch( return await self.http.patch(
f"/assistants/{_quote_path_param(assistant_id)}", f"/assistants/{assistant_id}",
json=payload, json=payload,
headers=headers, headers=headers,
params=params, params=params,
@@ -486,7 +479,7 @@ class AssistantsClient:
if params: if params:
query_params.update(params) query_params.update(params)
await self.http.delete( await self.http.delete(
f"/assistants/{_quote_path_param(assistant_id)}", f"/assistants/{assistant_id}",
headers=headers, headers=headers,
params=query_params or None, params=query_params or None,
) )
@@ -693,7 +686,7 @@ class AssistantsClient:
if metadata: if metadata:
payload["metadata"] = metadata payload["metadata"] = metadata
return await self.http.post( return await self.http.post(
f"/assistants/{_quote_path_param(assistant_id)}/versions", f"/assistants/{assistant_id}/versions",
json=payload, json=payload,
headers=headers, headers=headers,
params=params, params=params,
@@ -733,7 +726,7 @@ class AssistantsClient:
payload: dict[str, Any] = {"version": version} payload: dict[str, Any] = {"version": version}
return await self.http.post( return await self.http.post(
f"/assistants/{_quote_path_param(assistant_id)}/latest", f"/assistants/{assistant_id}/latest",
json=payload, json=payload,
headers=headers, headers=headers,
params=params, params=params,
+4 -6
View File
@@ -8,7 +8,7 @@ from datetime import datetime, tzinfo
from typing import Any from typing import Any
from langgraph_sdk._async.http import HttpClient 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 ( from langgraph_sdk.schema import (
All, All,
Config, Config,
@@ -166,7 +166,7 @@ class CronClient:
payload["multitask_strategy"] = multitask_strategy payload["multitask_strategy"] = multitask_strategy
payload = {k: v for k, v in payload.items() if v is not None} payload = {k: v for k, v in payload.items() if v is not None}
return await self.http.post( return await self.http.post(
f"/threads/{_quote_path_param(thread_id)}/runs/crons", f"/threads/{thread_id}/runs/crons",
json=payload, json=payload,
headers=headers, headers=headers,
params=params, params=params,
@@ -315,9 +315,7 @@ class CronClient:
``` ```
""" """
await self.http.delete( await self.http.delete(f"/runs/crons/{cron_id}", headers=headers, params=params)
f"/runs/crons/{_quote_path_param(cron_id)}", headers=headers, params=params
)
async def update( async def update(
self, self,
@@ -404,7 +402,7 @@ class CronClient:
} }
payload = {k: v for k, v in payload.items() if v is not None} payload = {k: v for k, v in payload.items() if v is not None}
return await self.http.patch( return await self.http.patch(
f"/runs/crons/{_quote_path_param(cron_id)}", f"/runs/crons/{cron_id}",
json=payload, json=payload,
headers=headers, headers=headers,
params=params, params=params,
+10 -19
View File
@@ -12,7 +12,6 @@ import httpx
from langgraph_sdk._async.http import HttpClient from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._shared.utilities import ( from langgraph_sdk._shared.utilities import (
_get_run_metadata_from_response, _get_run_metadata_from_response,
_quote_path_param,
_sse_to_v2_dict, _sse_to_v2_dict,
) )
from langgraph_sdk.schema import ( from langgraph_sdk.schema import (
@@ -338,7 +337,7 @@ class RunsClient:
"langsmith_tracer": langsmith_tracing, "langsmith_tracer": langsmith_tracing,
} }
endpoint = ( endpoint = (
f"/threads/{_quote_path_param(thread_id)}/runs/stream" f"/threads/{thread_id}/runs/stream"
if thread_id is not None if thread_id is not None
else "/runs/stream" else "/runs/stream"
) )
@@ -597,7 +596,7 @@ class RunsClient:
on_run_created(metadata) on_run_created(metadata)
return await self.http.post( 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, json=payload,
params=params, params=params,
headers=headers, headers=headers,
@@ -822,9 +821,7 @@ class RunsClient:
"langsmith_tracer": langsmith_tracing, "langsmith_tracer": langsmith_tracing,
} }
endpoint = ( endpoint = (
f"/threads/{_quote_path_param(thread_id)}/runs/wait" f"/threads/{thread_id}/runs/wait" if thread_id is not None else "/runs/wait"
if thread_id is not None
else "/runs/wait"
) )
def on_response(res: httpx.Response): def on_response(res: httpx.Response):
@@ -898,9 +895,7 @@ class RunsClient:
if params: if params:
query_params.update(params) query_params.update(params)
return await self.http.get( return await self.http.get(
f"/threads/{_quote_path_param(thread_id)}/runs", f"/threads/{thread_id}/runs", params=query_params, headers=headers
params=query_params,
headers=headers,
) )
async def get( async def get(
@@ -935,9 +930,7 @@ class RunsClient:
""" """
return await self.http.get( return await self.http.get(
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}", f"/threads/{thread_id}/runs/{run_id}", headers=headers, params=params
headers=headers,
params=params,
) )
async def cancel( async def cancel(
@@ -985,14 +978,14 @@ class RunsClient:
query_params.update(params) query_params.update(params)
if wait: if wait:
return await self.http.request_reconnect( 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", "POST",
params=query_params, params=query_params,
headers=headers, headers=headers,
) )
else: else:
return await self.http.post( 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, json=None,
params=query_params, params=query_params,
headers=headers, headers=headers,
@@ -1088,7 +1081,7 @@ class RunsClient:
""" """
return await self.http.request_reconnect( 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", "GET",
headers=headers, headers=headers,
params=params, params=params,
@@ -1143,7 +1136,7 @@ class RunsClient:
if params: if params:
query_params.update(params) query_params.update(params)
return self.http.stream( 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", "GET",
params=query_params, params=query_params,
headers={ headers={
@@ -1184,7 +1177,5 @@ class RunsClient:
""" """
await self.http.delete( await self.http.delete(
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}", f"/threads/{thread_id}/runs/{run_id}", headers=headers, params=params
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 typing import Any, Literal, overload
from langgraph_sdk._async.http import HttpClient from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._shared.utilities import _quote_path_param
from langgraph_sdk.schema import ( from langgraph_sdk.schema import (
Checkpoint, Checkpoint,
Json, Json,
@@ -91,7 +90,7 @@ class ThreadsClient:
if params: if params:
query_params.update(params) query_params.update(params)
return await self.http.get( return await self.http.get(
f"/threads/{_quote_path_param(thread_id)}", f"/threads/{thread_id}",
headers=headers, headers=headers,
params=query_params or None, params=query_params or None,
) )
@@ -255,7 +254,7 @@ class ThreadsClient:
if return_minimal: if return_minimal:
request_headers["Prefer"] = "return=minimal" request_headers["Prefer"] = "return=minimal"
return await self.http.patch( return await self.http.patch(
f"/threads/{_quote_path_param(thread_id)}", f"/threads/{thread_id}",
json=payload, json=payload,
headers=request_headers or None, headers=request_headers or None,
params=params, params=params,
@@ -288,9 +287,7 @@ class ThreadsClient:
``` ```
""" """
await self.http.delete( await self.http.delete(f"/threads/{thread_id}", headers=headers, params=params)
f"/threads/{_quote_path_param(thread_id)}", headers=headers, params=params
)
async def search( async def search(
self, self,
@@ -433,10 +430,7 @@ class ThreadsClient:
""" """
return await self.http.post( return await self.http.post(
f"/threads/{_quote_path_param(thread_id)}/copy", f"/threads/{thread_id}/copy", json=None, headers=headers, params=params
json=None,
headers=headers,
params=params,
) )
async def prune( async def prune(
@@ -592,7 +586,7 @@ class ThreadsClient:
""" """
if checkpoint: if checkpoint:
return await self.http.post( 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}, json={"checkpoint": checkpoint, "subgraphs": subgraphs},
headers=headers, headers=headers,
params=params, params=params,
@@ -602,7 +596,7 @@ class ThreadsClient:
if params: if params:
get_params = {**get_params, **dict(params)} get_params = {**get_params, **dict(params)}
return await self.http.get( 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, params=get_params,
headers=headers, headers=headers,
) )
@@ -611,7 +605,7 @@ class ThreadsClient:
if params: if params:
get_params = {**get_params, **dict(params)} get_params = {**get_params, **dict(params)}
return await self.http.get( return await self.http.get(
f"/threads/{_quote_path_param(thread_id)}/state", f"/threads/{thread_id}/state",
params=get_params, params=get_params,
headers=headers, headers=headers,
) )
@@ -676,10 +670,7 @@ class ThreadsClient:
if as_node: if as_node:
payload["as_node"] = as_node payload["as_node"] = as_node
return await self.http.post( return await self.http.post(
f"/threads/{_quote_path_param(thread_id)}/state", f"/threads/{thread_id}/state", json=payload, headers=headers, params=params
json=payload,
headers=headers,
params=params,
) )
async def get_history( async def get_history(
@@ -728,7 +719,7 @@ class ThreadsClient:
if checkpoint: if checkpoint:
payload["checkpoint"] = checkpoint payload["checkpoint"] = checkpoint
return await self.http.post( return await self.http.post(
f"/threads/{_quote_path_param(thread_id)}/history", f"/threads/{thread_id}/history",
json=payload, json=payload,
headers=headers, headers=headers,
params=params, params=params,
@@ -772,7 +763,7 @@ class ThreadsClient:
if params: if params:
query_params.update(params) query_params.update(params)
return self.http.stream( return self.http.stream(
f"/threads/{_quote_path_param(thread_id)}/stream", f"/threads/{thread_id}/stream",
"GET", "GET",
headers={ headers={
**({"Last-Event-ID": last_event_id} if last_event_id else {}), **({"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 collections.abc import Mapping
from datetime import tzinfo from datetime import tzinfo
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, cast
from urllib.parse import quote, urlparse from urllib.parse import urlparse
import httpx 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} 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] = [] _registered_transports: list[httpx.ASGITransport] = []
+9 -16
View File
@@ -7,7 +7,6 @@ from typing import Any, Literal, cast, overload
import httpx import httpx
from langgraph_sdk._shared.utilities import _quote_path_param
from langgraph_sdk._sync.http import SyncHttpClient from langgraph_sdk._sync.http import SyncHttpClient
from langgraph_sdk.schema import ( from langgraph_sdk.schema import (
Assistant, Assistant,
@@ -84,9 +83,7 @@ class SyncAssistantsClient:
""" """
return self.http.get( return self.http.get(
f"/assistants/{_quote_path_param(assistant_id)}", f"/assistants/{assistant_id}", headers=headers, params=params
headers=headers,
params=params,
) )
def get_graph( def get_graph(
@@ -139,9 +136,7 @@ class SyncAssistantsClient:
if params: if params:
query_params.update(params) query_params.update(params)
return self.http.get( return self.http.get(
f"/assistants/{_quote_path_param(assistant_id)}/graph", f"/assistants/{assistant_id}/graph", params=query_params, headers=headers
params=query_params,
headers=headers,
) )
def get_schemas( def get_schemas(
@@ -274,9 +269,7 @@ class SyncAssistantsClient:
""" """
return self.http.get( return self.http.get(
f"/assistants/{_quote_path_param(assistant_id)}/schemas", f"/assistants/{assistant_id}/schemas", headers=headers, params=params
headers=headers,
params=params,
) )
def get_subgraphs( def get_subgraphs(
@@ -304,13 +297,13 @@ class SyncAssistantsClient:
get_params = {**get_params, **dict(params)} get_params = {**get_params, **dict(params)}
if namespace is not None: if namespace is not None:
return self.http.get( 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, params=get_params,
headers=headers, headers=headers,
) )
else: else:
return self.http.get( return self.http.get(
f"/assistants/{_quote_path_param(assistant_id)}/subgraphs", f"/assistants/{assistant_id}/subgraphs",
params=get_params, params=get_params,
headers=headers, headers=headers,
) )
@@ -445,7 +438,7 @@ class SyncAssistantsClient:
if description: if description:
payload["description"] = description payload["description"] = description
return self.http.patch( return self.http.patch(
f"/assistants/{_quote_path_param(assistant_id)}", f"/assistants/{assistant_id}",
json=payload, json=payload,
headers=headers, headers=headers,
params=params, params=params,
@@ -488,7 +481,7 @@ class SyncAssistantsClient:
if params: if params:
query_params.update(params) query_params.update(params)
self.http.delete( self.http.delete(
f"/assistants/{_quote_path_param(assistant_id)}", f"/assistants/{assistant_id}",
headers=headers, headers=headers,
params=query_params or None, params=query_params or None,
) )
@@ -692,7 +685,7 @@ class SyncAssistantsClient:
if metadata: if metadata:
payload["metadata"] = metadata payload["metadata"] = metadata
return self.http.post( return self.http.post(
f"/assistants/{_quote_path_param(assistant_id)}/versions", f"/assistants/{assistant_id}/versions",
json=payload, json=payload,
headers=headers, headers=headers,
params=params, params=params,
@@ -731,7 +724,7 @@ class SyncAssistantsClient:
payload: dict[str, Any] = {"version": version} payload: dict[str, Any] = {"version": version}
return self.http.post( return self.http.post(
f"/assistants/{_quote_path_param(assistant_id)}/latest", f"/assistants/{assistant_id}/latest",
json=payload, json=payload,
headers=headers, headers=headers,
params=params, params=params,
+4 -6
View File
@@ -7,7 +7,7 @@ from collections.abc import Mapping, Sequence
from datetime import datetime, tzinfo from datetime import datetime, tzinfo
from typing import Any 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._sync.http import SyncHttpClient
from langgraph_sdk.schema import ( from langgraph_sdk.schema import (
All, All,
@@ -156,7 +156,7 @@ class SyncCronClient:
} }
payload = {k: v for k, v in payload.items() if v is not None} payload = {k: v for k, v in payload.items() if v is not None}
return self.http.post( return self.http.post(
f"/threads/{_quote_path_param(thread_id)}/runs/crons", f"/threads/{thread_id}/runs/crons",
json=payload, json=payload,
headers=headers, headers=headers,
params=params, params=params,
@@ -304,9 +304,7 @@ class SyncCronClient:
``` ```
""" """
self.http.delete( self.http.delete(f"/runs/crons/{cron_id}", headers=headers, params=params)
f"/runs/crons/{_quote_path_param(cron_id)}", headers=headers, params=params
)
def update( def update(
self, self,
@@ -393,7 +391,7 @@ class SyncCronClient:
} }
payload = {k: v for k, v in payload.items() if v is not None} payload = {k: v for k, v in payload.items() if v is not None}
return self.http.patch( return self.http.patch(
f"/runs/crons/{_quote_path_param(cron_id)}", f"/runs/crons/{cron_id}",
json=payload, json=payload,
headers=headers, headers=headers,
params=params, params=params,
+10 -19
View File
@@ -11,7 +11,6 @@ import httpx
from langgraph_sdk._shared.utilities import ( from langgraph_sdk._shared.utilities import (
_get_run_metadata_from_response, _get_run_metadata_from_response,
_quote_path_param,
_sse_to_v2_dict, _sse_to_v2_dict,
) )
from langgraph_sdk._sync.http import SyncHttpClient from langgraph_sdk._sync.http import SyncHttpClient
@@ -333,7 +332,7 @@ class SyncRunsClient:
"langsmith_tracer": langsmith_tracing, "langsmith_tracer": langsmith_tracing,
} }
endpoint = ( endpoint = (
f"/threads/{_quote_path_param(thread_id)}/runs/stream" f"/threads/{thread_id}/runs/stream"
if thread_id is not None if thread_id is not None
else "/runs/stream" else "/runs/stream"
) )
@@ -592,7 +591,7 @@ class SyncRunsClient:
on_run_created(metadata) on_run_created(metadata)
return self.http.post( 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, json=payload,
params=params, params=params,
headers=headers, headers=headers,
@@ -826,9 +825,7 @@ class SyncRunsClient:
on_run_created(metadata) on_run_created(metadata)
endpoint = ( endpoint = (
f"/threads/{_quote_path_param(thread_id)}/runs/wait" f"/threads/{thread_id}/runs/wait" if thread_id is not None else "/runs/wait"
if thread_id is not None
else "/runs/wait"
) )
return self.http.request_reconnect( return self.http.request_reconnect(
endpoint, endpoint,
@@ -882,9 +879,7 @@ class SyncRunsClient:
if params: if params:
query_params.update(params) query_params.update(params)
return self.http.get( return self.http.get(
f"/threads/{_quote_path_param(thread_id)}/runs", f"/threads/{thread_id}/runs", params=query_params, headers=headers
params=query_params,
headers=headers,
) )
def get( def get(
@@ -917,9 +912,7 @@ class SyncRunsClient:
""" """
return self.http.get( return self.http.get(
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}", f"/threads/{thread_id}/runs/{run_id}", headers=headers, params=params
headers=headers,
params=params,
) )
def cancel( def cancel(
@@ -967,14 +960,14 @@ class SyncRunsClient:
query_params.update(params) query_params.update(params)
if wait: if wait:
return self.http.request_reconnect( 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", "POST",
json=None, json=None,
params=query_params, params=query_params,
headers=headers, headers=headers,
) )
return self.http.post( 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, json=None,
params=query_params, params=query_params,
headers=headers, headers=headers,
@@ -1070,7 +1063,7 @@ class SyncRunsClient:
""" """
return self.http.request_reconnect( 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", "GET",
headers=headers, headers=headers,
params=params, params=params,
@@ -1124,7 +1117,7 @@ class SyncRunsClient:
if params: if params:
query_params.update(params) query_params.update(params)
return self.http.stream( 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", "GET",
params=query_params, params=query_params,
headers={ headers={
@@ -1165,7 +1158,5 @@ class SyncRunsClient:
""" """
self.http.delete( self.http.delete(
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}", f"/threads/{thread_id}/runs/{run_id}", headers=headers, params=params
headers=headers,
params=params,
) )
+10 -19
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
from collections.abc import Iterator, Mapping, Sequence from collections.abc import Iterator, Mapping, Sequence
from typing import Any, Literal, overload 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._sync.http import SyncHttpClient
from langgraph_sdk.schema import ( from langgraph_sdk.schema import (
Checkpoint, Checkpoint,
@@ -89,7 +88,7 @@ class SyncThreadsClient:
if params: if params:
query_params.update(params) query_params.update(params)
return self.http.get( return self.http.get(
f"/threads/{_quote_path_param(thread_id)}", f"/threads/{thread_id}",
headers=headers, headers=headers,
params=query_params or None, params=query_params or None,
) )
@@ -251,7 +250,7 @@ class SyncThreadsClient:
if return_minimal: if return_minimal:
request_headers["Prefer"] = "return=minimal" request_headers["Prefer"] = "return=minimal"
return self.http.patch( return self.http.patch(
f"/threads/{_quote_path_param(thread_id)}", f"/threads/{thread_id}",
json=payload, json=payload,
headers=request_headers or None, headers=request_headers or None,
params=params, params=params,
@@ -283,9 +282,7 @@ class SyncThreadsClient:
``` ```
""" """
self.http.delete( self.http.delete(f"/threads/{thread_id}", headers=headers, params=params)
f"/threads/{_quote_path_param(thread_id)}", headers=headers, params=params
)
def search( def search(
self, self,
@@ -424,10 +421,7 @@ class SyncThreadsClient:
""" """
return self.http.post( return self.http.post(
f"/threads/{_quote_path_param(thread_id)}/copy", f"/threads/{thread_id}/copy", json=None, headers=headers, params=params
json=None,
headers=headers,
params=params,
) )
def prune( def prune(
@@ -582,7 +576,7 @@ class SyncThreadsClient:
""" """
if checkpoint: if checkpoint:
return self.http.post( return self.http.post(
f"/threads/{_quote_path_param(thread_id)}/state/checkpoint", f"/threads/{thread_id}/state/checkpoint",
json={"checkpoint": checkpoint, "subgraphs": subgraphs}, json={"checkpoint": checkpoint, "subgraphs": subgraphs},
headers=headers, headers=headers,
params=params, params=params,
@@ -592,7 +586,7 @@ class SyncThreadsClient:
if params: if params:
get_params = {**get_params, **dict(params)} get_params = {**get_params, **dict(params)}
return self.http.get( 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, params=get_params,
headers=headers, headers=headers,
) )
@@ -601,7 +595,7 @@ class SyncThreadsClient:
if params: if params:
get_params = {**get_params, **dict(params)} get_params = {**get_params, **dict(params)}
return self.http.get( return self.http.get(
f"/threads/{_quote_path_param(thread_id)}/state", f"/threads/{thread_id}/state",
params=get_params, params=get_params,
headers=headers, headers=headers,
) )
@@ -663,10 +657,7 @@ class SyncThreadsClient:
if as_node: if as_node:
payload["as_node"] = as_node payload["as_node"] = as_node
return self.http.post( return self.http.post(
f"/threads/{_quote_path_param(thread_id)}/state", f"/threads/{thread_id}/state", json=payload, headers=headers, params=params
json=payload,
headers=headers,
params=params,
) )
def get_history( def get_history(
@@ -716,7 +707,7 @@ class SyncThreadsClient:
if checkpoint: if checkpoint:
payload["checkpoint"] = checkpoint payload["checkpoint"] = checkpoint
return self.http.post( return self.http.post(
f"/threads/{_quote_path_param(thread_id)}/history", f"/threads/{thread_id}/history",
json=payload, json=payload,
headers=headers, headers=headers,
params=params, params=params,
@@ -761,7 +752,7 @@ class SyncThreadsClient:
if params: if params:
query_params.update(params) query_params.update(params)
return self.http.stream( return self.http.stream(
f"/threads/{_quote_path_param(thread_id)}/stream", f"/threads/{thread_id}/stream",
"GET", "GET",
headers={ headers={
**({"Last-Event-ID": last_event_id} if last_event_id else {}), **({"Last-Event-ID": last_event_id} if last_event_id else {}),
-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]] [[package]]
name = "idna" name = "idna"
version = "3.15" version = "3.11"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@@ -266,7 +266,7 @@ wheels = [
[[package]] [[package]]
name = "langchain-core" name = "langchain-core"
version = "1.4.0" version = "1.4.0a2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "jsonpatch" }, { name = "jsonpatch" },
@@ -279,9 +279,9 @@ dependencies = [
{ name = "typing-extensions" }, { name = "typing-extensions" },
{ name = "uuid-utils" }, { 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 = [ 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]] [[package]]
@@ -298,7 +298,7 @@ wheels = [
[[package]] [[package]]
name = "langgraph" name = "langgraph"
version = "1.2.2" version = "1.2.0a7"
source = { editable = "../langgraph" } source = { editable = "../langgraph" }
dependencies = [ dependencies = [
{ name = "langchain-core" }, { name = "langchain-core" },
@@ -311,7 +311,7 @@ dependencies = [
[package.metadata] [package.metadata]
requires-dist = [ 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-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-prebuilt", editable = "../prebuilt" }, { name = "langgraph-prebuilt", editable = "../prebuilt" },
{ name = "langgraph-sdk", editable = "." }, { name = "langgraph-sdk", editable = "." },
@@ -382,7 +382,7 @@ test = [
[[package]] [[package]]
name = "langgraph-checkpoint" name = "langgraph-checkpoint"
version = "4.1.1" version = "4.1.0a4"
source = { editable = "../checkpoint" } source = { editable = "../checkpoint" }
dependencies = [ dependencies = [
{ name = "langchain-core" }, { name = "langchain-core" },
@@ -430,7 +430,7 @@ test = [
[[package]] [[package]]
name = "langgraph-prebuilt" name = "langgraph-prebuilt"
version = "1.1.0" version = "1.1.0a2"
source = { editable = "../prebuilt" } source = { editable = "../prebuilt" }
dependencies = [ dependencies = [
{ name = "langchain-core" }, { name = "langchain-core" },
@@ -551,7 +551,7 @@ test = [
[[package]] [[package]]
name = "langsmith" name = "langsmith"
version = "0.8.0" version = "0.7.31"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "httpx" }, { name = "httpx" },
@@ -564,9 +564,9 @@ dependencies = [
{ name = "xxhash" }, { name = "xxhash" },
{ name = "zstandard" }, { 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 = [ 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]] [[package]]
@@ -1320,11 +1320,11 @@ wheels = [
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.7.0" version = "2.6.3"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]