Compare commits

..
Author SHA1 Message Date
Nick Hollon 7fbd9bc3d3 Add sync StreamMux, SubgraphRunStream, and chat model stream enhancements
Adds AsyncStreamMux sync counterpart, exports SubgraphRunStream, improves
EventLog future resolution safety, and expands run_stream with sync graph
run support. Includes comprehensive test updates across event log, mux,
reducers, and run stream modules.
2026-04-15 11:00:46 -04:00
Nick Hollon 803a268b39 feat(langgraph): add streamV2 infrastructure with unified transformer extensions
Adds the stream v2 protocol layer: StreamMux, EventLog, StreamTransformer
protocol, built-in ValuesTransformer/MessagesTransformer, StreamChannel,
ChatModelStream, GraphRunStream/AsyncGraphRunStream, and StreamingHandler.

Transformers use a unified name/value interface so built-in and user
transformers are exposed through the same extensions mechanism. Sync
projections (.values, .messages, extensions) all use _PumpDrivenLog for
consistent lazy pump-driven iteration.

Includes StreamProtocolMessagesHandler for converting LangChain message
chunks to protocol events (message-start, content-block-delta, etc.)
and wires it into Pregel.stream()/astream() via a config flag.
2026-04-14 15:20:03 -04:00
72 changed files with 7524 additions and 4839 deletions
+2 -2
View File
@@ -121,8 +121,8 @@ jobs:
exit 1
fi
LANGCHAIN_OPENAI_VERSION=$(docker run --rm --entrypoint "" langgraph-test-h python -c "import sys; from importlib.metadata import version; v = version('langchain-openai'); print(v);")
if [ "$LANGCHAIN_OPENAI_VERSION" != "1.1.14" ]; then
echo "LANGCHAIN_OPENAI_VERSION != 1.1.14; $LANGCHAIN_OPENAI_VERSION"
if [ "$LANGCHAIN_OPENAI_VERSION" != "1.0.1" ]; then
echo "LANGCHAIN_OPENAI_VERSION != 1.0.1; $LANGCHAIN_OPENAI_VERSION"
exit 1
fi
LANGCHAIN_ANTHROPIC_VERSION=$(docker run --rm --entrypoint "" langgraph-test-h python -c "import sys; from importlib.metadata import version; v = version('langchain-anthropic'); print(v);")
+6 -6
View File
@@ -306,7 +306,7 @@ test = [
[[package]]
name = "langsmith"
version = "0.7.31"
version = "0.7.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
@@ -319,9 +319,9 @@ dependencies = [
{ name = "xxhash" },
{ name = "zstandard" },
]
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" }
sdist = { url = "https://files.pythonhosted.org/packages/8d/bc/8172fefad4f2da888a6d564a27d1fb7d4dbf3c640899c2b40c46235cbe98/langsmith-0.7.3.tar.gz", hash = "sha256:0223b97021af62d2cf53c8a378a27bd22e90a7327e45b353e0069ae60d5d6f9e", size = 988575, upload-time = "2026-02-13T23:25:32.916Z" }
wheels = [
{ 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" },
{ url = "https://files.pythonhosted.org/packages/f4/9d/5a68b6b5e313ffabbb9725d18a71edb48177fd6d3ad329c07801d2a8e862/langsmith-0.7.3-py3-none-any.whl", hash = "sha256:03659bf9274e6efcead361c9c31a7849ea565ae0d6c0d73e1d8b239029eff3be", size = 325718, upload-time = "2026-02-13T23:25:31.52Z" },
]
[[package]]
@@ -623,7 +623,7 @@ wheels = [
[[package]]
name = "pytest"
version = "9.0.3"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -634,9 +634,9 @@ dependencies = [
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
-5
View File
@@ -6,11 +6,6 @@ Implementation of LangGraph CheckpointSaver that uses Postgres.
By default `langgraph-checkpoint-postgres` installs `psycopg` (Psycopg 3) without any extras. However, you can choose a specific installation that best suits your needs [here](https://www.psycopg.org/psycopg3/docs/basic/install.html) (for example, `psycopg[binary]`).
## Security
> [!IMPORTANT]
> Set `LANGGRAPH_STRICT_MSGPACK=true` or pass an explicit `allowed_msgpack_modules` list when creating your checkpointer. This restricts checkpoint deserialization to known-safe types, preventing code execution if the database is compromised. See the [langgraph-checkpoint README](https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint#serde) for details.
## Usage
> [!IMPORTANT]
+7 -126
View File
@@ -259,7 +259,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
version = "4.0.3"
version = "4.0.1"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
@@ -382,7 +382,7 @@ test = [
[[package]]
name = "langsmith"
version = "0.7.31"
version = "0.6.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
@@ -392,12 +392,11 @@ dependencies = [
{ name = "requests" },
{ name = "requests-toolbelt" },
{ name = "uuid-utils" },
{ name = "xxhash" },
{ name = "zstandard" },
]
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" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/85/9c7933052a997da1b85bc5c774f3865e9b1da1c8d71541ea133178b13229/langsmith-0.6.4.tar.gz", hash = "sha256:36f7223a01c218079fbb17da5e536ebbaf5c1468c028abe070aa3ae59bc99ec8", size = 919964, upload-time = "2026-01-15T20:02:28.873Z" }
wheels = [
{ 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" },
{ url = "https://files.pythonhosted.org/packages/66/0f/09a6637a7ba777eb307b7c80852d9ee26438e2bdafbad6fcc849ff9d9192/langsmith-0.6.4-py3-none-any.whl", hash = "sha256:ac4835860160be371042c7adbba3cb267bcf8d96a5ea976c33a8a4acad6c5486", size = 283503, upload-time = "2026-01-15T20:02:26.662Z" },
]
[[package]]
@@ -951,7 +950,7 @@ wheels = [
[[package]]
name = "pytest"
version = "9.0.3"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -962,9 +961,9 @@ dependencies = [
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
@@ -1285,124 +1284,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
]
[[package]]
name = "xxhash"
version = "3.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/02/84/30869e01909fb37a6cc7e18688ee8bf1e42d57e7e0777636bd47524c43c7/xxhash-3.6.0.tar.gz", hash = "sha256:f0162a78b13a0d7617b2845b90c763339d1f1d82bb04a4b07f4ab535cc5e05d6", size = 85160, upload-time = "2025-10-02T14:37:08.097Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/34/ee/f9f1d656ad168681bb0f6b092372c1e533c4416b8069b1896a175c46e484/xxhash-3.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:87ff03d7e35c61435976554477a7f4cd1704c3596a89a8300d5ce7fc83874a71", size = 32845, upload-time = "2025-10-02T14:33:51.573Z" },
{ url = "https://files.pythonhosted.org/packages/a3/b1/93508d9460b292c74a09b83d16750c52a0ead89c51eea9951cb97a60d959/xxhash-3.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f572dfd3d0e2eb1a57511831cf6341242f5a9f8298a45862d085f5b93394a27d", size = 30807, upload-time = "2025-10-02T14:33:52.964Z" },
{ url = "https://files.pythonhosted.org/packages/07/55/28c93a3662f2d200c70704efe74aab9640e824f8ce330d8d3943bf7c9b3c/xxhash-3.6.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:89952ea539566b9fed2bbd94e589672794b4286f342254fad28b149f9615fef8", size = 193786, upload-time = "2025-10-02T14:33:54.272Z" },
{ url = "https://files.pythonhosted.org/packages/c1/96/fec0be9bb4b8f5d9c57d76380a366f31a1781fb802f76fc7cda6c84893c7/xxhash-3.6.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e6f2ffb07a50b52465a1032c3cf1f4a5683f944acaca8a134a2f23674c2058", size = 212830, upload-time = "2025-10-02T14:33:55.706Z" },
{ url = "https://files.pythonhosted.org/packages/c4/a0/c706845ba77b9611f81fd2e93fad9859346b026e8445e76f8c6fd057cc6d/xxhash-3.6.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b5b848ad6c16d308c3ac7ad4ba6bede80ed5df2ba8ed382f8932df63158dd4b2", size = 211606, upload-time = "2025-10-02T14:33:57.133Z" },
{ url = "https://files.pythonhosted.org/packages/67/1e/164126a2999e5045f04a69257eea946c0dc3e86541b400d4385d646b53d7/xxhash-3.6.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a034590a727b44dd8ac5914236a7b8504144447a9682586c3327e935f33ec8cc", size = 444872, upload-time = "2025-10-02T14:33:58.446Z" },
{ url = "https://files.pythonhosted.org/packages/2d/4b/55ab404c56cd70a2cf5ecfe484838865d0fea5627365c6c8ca156bd09c8f/xxhash-3.6.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a8f1972e75ebdd161d7896743122834fe87378160c20e97f8b09166213bf8cc", size = 193217, upload-time = "2025-10-02T14:33:59.724Z" },
{ url = "https://files.pythonhosted.org/packages/45/e6/52abf06bac316db33aa269091ae7311bd53cfc6f4b120ae77bac1b348091/xxhash-3.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ee34327b187f002a596d7b167ebc59a1b729e963ce645964bbc050d2f1b73d07", size = 210139, upload-time = "2025-10-02T14:34:02.041Z" },
{ url = "https://files.pythonhosted.org/packages/34/37/db94d490b8691236d356bc249c08819cbcef9273a1a30acf1254ff9ce157/xxhash-3.6.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:339f518c3c7a850dd033ab416ea25a692759dc7478a71131fe8869010d2b75e4", size = 197669, upload-time = "2025-10-02T14:34:03.664Z" },
{ url = "https://files.pythonhosted.org/packages/b7/36/c4f219ef4a17a4f7a64ed3569bc2b5a9c8311abdb22249ac96093625b1a4/xxhash-3.6.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:bf48889c9630542d4709192578aebbd836177c9f7a4a2778a7d6340107c65f06", size = 210018, upload-time = "2025-10-02T14:34:05.325Z" },
{ url = "https://files.pythonhosted.org/packages/fd/06/bfac889a374fc2fc439a69223d1750eed2e18a7db8514737ab630534fa08/xxhash-3.6.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:5576b002a56207f640636056b4160a378fe36a58db73ae5c27a7ec8db35f71d4", size = 413058, upload-time = "2025-10-02T14:34:06.925Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d1/555d8447e0dd32ad0930a249a522bb2e289f0d08b6b16204cfa42c1f5a0c/xxhash-3.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af1f3278bd02814d6dedc5dec397993b549d6f16c19379721e5a1d31e132c49b", size = 190628, upload-time = "2025-10-02T14:34:08.669Z" },
{ url = "https://files.pythonhosted.org/packages/d1/15/8751330b5186cedc4ed4b597989882ea05e0408b53fa47bcb46a6125bfc6/xxhash-3.6.0-cp310-cp310-win32.whl", hash = "sha256:aed058764db109dc9052720da65fafe84873b05eb8b07e5e653597951af57c3b", size = 30577, upload-time = "2025-10-02T14:34:10.234Z" },
{ url = "https://files.pythonhosted.org/packages/bb/cc/53f87e8b5871a6eb2ff7e89c48c66093bda2be52315a8161ddc54ea550c4/xxhash-3.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:e82da5670f2d0d98950317f82a0e4a0197150ff19a6df2ba40399c2a3b9ae5fb", size = 31487, upload-time = "2025-10-02T14:34:11.618Z" },
{ url = "https://files.pythonhosted.org/packages/9f/00/60f9ea3bb697667a14314d7269956f58bf56bb73864f8f8d52a3c2535e9a/xxhash-3.6.0-cp310-cp310-win_arm64.whl", hash = "sha256:4a082ffff8c6ac07707fb6b671caf7c6e020c75226c561830b73d862060f281d", size = 27863, upload-time = "2025-10-02T14:34:12.619Z" },
{ url = "https://files.pythonhosted.org/packages/17/d4/cc2f0400e9154df4b9964249da78ebd72f318e35ccc425e9f403c392f22a/xxhash-3.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b47bbd8cf2d72797f3c2772eaaac0ded3d3af26481a26d7d7d41dc2d3c46b04a", size = 32844, upload-time = "2025-10-02T14:34:14.037Z" },
{ url = "https://files.pythonhosted.org/packages/5e/ec/1cc11cd13e26ea8bc3cb4af4eaadd8d46d5014aebb67be3f71fb0b68802a/xxhash-3.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2b6821e94346f96db75abaa6e255706fb06ebd530899ed76d32cd99f20dc52fa", size = 30809, upload-time = "2025-10-02T14:34:15.484Z" },
{ url = "https://files.pythonhosted.org/packages/04/5f/19fe357ea348d98ca22f456f75a30ac0916b51c753e1f8b2e0e6fb884cce/xxhash-3.6.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d0a9751f71a1a65ce3584e9cae4467651c7e70c9d31017fa57574583a4540248", size = 194665, upload-time = "2025-10-02T14:34:16.541Z" },
{ url = "https://files.pythonhosted.org/packages/90/3b/d1f1a8f5442a5fd8beedae110c5af7604dc37349a8e16519c13c19a9a2de/xxhash-3.6.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b29ee68625ab37b04c0b40c3fafdf24d2f75ccd778333cfb698f65f6c463f62", size = 213550, upload-time = "2025-10-02T14:34:17.878Z" },
{ url = "https://files.pythonhosted.org/packages/c4/ef/3a9b05eb527457d5db13a135a2ae1a26c80fecd624d20f3e8dcc4cb170f3/xxhash-3.6.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6812c25fe0d6c36a46ccb002f40f27ac903bf18af9f6dd8f9669cb4d176ab18f", size = 212384, upload-time = "2025-10-02T14:34:19.182Z" },
{ url = "https://files.pythonhosted.org/packages/0f/18/ccc194ee698c6c623acbf0f8c2969811a8a4b6185af5e824cd27b9e4fd3e/xxhash-3.6.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4ccbff013972390b51a18ef1255ef5ac125c92dc9143b2d1909f59abc765540e", size = 445749, upload-time = "2025-10-02T14:34:20.659Z" },
{ url = "https://files.pythonhosted.org/packages/a5/86/cf2c0321dc3940a7aa73076f4fd677a0fb3e405cb297ead7d864fd90847e/xxhash-3.6.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:297b7fbf86c82c550e12e8fb71968b3f033d27b874276ba3624ea868c11165a8", size = 193880, upload-time = "2025-10-02T14:34:22.431Z" },
{ url = "https://files.pythonhosted.org/packages/82/fb/96213c8560e6f948a1ecc9a7613f8032b19ee45f747f4fca4eb31bb6d6ed/xxhash-3.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dea26ae1eb293db089798d3973a5fc928a18fdd97cc8801226fae705b02b14b0", size = 210912, upload-time = "2025-10-02T14:34:23.937Z" },
{ url = "https://files.pythonhosted.org/packages/40/aa/4395e669b0606a096d6788f40dbdf2b819d6773aa290c19e6e83cbfc312f/xxhash-3.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7a0b169aafb98f4284f73635a8e93f0735f9cbde17bd5ec332480484241aaa77", size = 198654, upload-time = "2025-10-02T14:34:25.644Z" },
{ url = "https://files.pythonhosted.org/packages/67/74/b044fcd6b3d89e9b1b665924d85d3f400636c23590226feb1eb09e1176ce/xxhash-3.6.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:08d45aef063a4531b785cd72de4887766d01dc8f362a515693df349fdb825e0c", size = 210867, upload-time = "2025-10-02T14:34:27.203Z" },
{ url = "https://files.pythonhosted.org/packages/bc/fd/3ce73bf753b08cb19daee1eb14aa0d7fe331f8da9c02dd95316ddfe5275e/xxhash-3.6.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:929142361a48ee07f09121fe9e96a84950e8d4df3bb298ca5d88061969f34d7b", size = 414012, upload-time = "2025-10-02T14:34:28.409Z" },
{ url = "https://files.pythonhosted.org/packages/ba/b3/5a4241309217c5c876f156b10778f3ab3af7ba7e3259e6d5f5c7d0129eb2/xxhash-3.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:51312c768403d8540487dbbfb557454cfc55589bbde6424456951f7fcd4facb3", size = 191409, upload-time = "2025-10-02T14:34:29.696Z" },
{ url = "https://files.pythonhosted.org/packages/c0/01/99bfbc15fb9abb9a72b088c1d95219fc4782b7d01fc835bd5744d66dd0b8/xxhash-3.6.0-cp311-cp311-win32.whl", hash = "sha256:d1927a69feddc24c987b337ce81ac15c4720955b667fe9b588e02254b80446fd", size = 30574, upload-time = "2025-10-02T14:34:31.028Z" },
{ url = "https://files.pythonhosted.org/packages/65/79/9d24d7f53819fe301b231044ea362ce64e86c74f6e8c8e51320de248b3e5/xxhash-3.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:26734cdc2d4ffe449b41d186bbeac416f704a482ed835d375a5c0cb02bc63fef", size = 31481, upload-time = "2025-10-02T14:34:32.062Z" },
{ url = "https://files.pythonhosted.org/packages/30/4e/15cd0e3e8772071344eab2961ce83f6e485111fed8beb491a3f1ce100270/xxhash-3.6.0-cp311-cp311-win_arm64.whl", hash = "sha256:d72f67ef8bf36e05f5b6c65e8524f265bd61071471cd4cf1d36743ebeeeb06b7", size = 27861, upload-time = "2025-10-02T14:34:33.555Z" },
{ url = "https://files.pythonhosted.org/packages/9a/07/d9412f3d7d462347e4511181dea65e47e0d0e16e26fbee2ea86a2aefb657/xxhash-3.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01362c4331775398e7bb34e3ab403bc9ee9f7c497bc7dee6272114055277dd3c", size = 32744, upload-time = "2025-10-02T14:34:34.622Z" },
{ url = "https://files.pythonhosted.org/packages/79/35/0429ee11d035fc33abe32dca1b2b69e8c18d236547b9a9b72c1929189b9a/xxhash-3.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b7b2df81a23f8cb99656378e72501b2cb41b1827c0f5a86f87d6b06b69f9f204", size = 30816, upload-time = "2025-10-02T14:34:36.043Z" },
{ url = "https://files.pythonhosted.org/packages/b7/f2/57eb99aa0f7d98624c0932c5b9a170e1806406cdbcdb510546634a1359e0/xxhash-3.6.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dc94790144e66b14f67b10ac8ed75b39ca47536bf8800eb7c24b50271ea0c490", size = 194035, upload-time = "2025-10-02T14:34:37.354Z" },
{ url = "https://files.pythonhosted.org/packages/4c/ed/6224ba353690d73af7a3f1c7cdb1fc1b002e38f783cb991ae338e1eb3d79/xxhash-3.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93f107c673bccf0d592cdba077dedaf52fe7f42dcd7676eba1f6d6f0c3efffd2", size = 212914, upload-time = "2025-10-02T14:34:38.6Z" },
{ url = "https://files.pythonhosted.org/packages/38/86/fb6b6130d8dd6b8942cc17ab4d90e223653a89aa32ad2776f8af7064ed13/xxhash-3.6.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aa5ee3444c25b69813663c9f8067dcfaa2e126dc55e8dddf40f4d1c25d7effa", size = 212163, upload-time = "2025-10-02T14:34:39.872Z" },
{ url = "https://files.pythonhosted.org/packages/ee/dc/e84875682b0593e884ad73b2d40767b5790d417bde603cceb6878901d647/xxhash-3.6.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7f99123f0e1194fa59cc69ad46dbae2e07becec5df50a0509a808f90a0f03f0", size = 445411, upload-time = "2025-10-02T14:34:41.569Z" },
{ url = "https://files.pythonhosted.org/packages/11/4f/426f91b96701ec2f37bb2b8cec664eff4f658a11f3fa9d94f0a887ea6d2b/xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49e03e6fe2cac4a1bc64952dd250cf0dbc5ef4ebb7b8d96bce82e2de163c82a2", size = 193883, upload-time = "2025-10-02T14:34:43.249Z" },
{ url = "https://files.pythonhosted.org/packages/53/5a/ddbb83eee8e28b778eacfc5a85c969673e4023cdeedcfcef61f36731610b/xxhash-3.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd17fede52a17a4f9a7bc4472a5867cb0b160deeb431795c0e4abe158bc784e9", size = 210392, upload-time = "2025-10-02T14:34:45.042Z" },
{ url = "https://files.pythonhosted.org/packages/1e/c2/ff69efd07c8c074ccdf0a4f36fcdd3d27363665bcdf4ba399abebe643465/xxhash-3.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6fb5f5476bef678f69db04f2bd1efbed3030d2aba305b0fc1773645f187d6a4e", size = 197898, upload-time = "2025-10-02T14:34:46.302Z" },
{ url = "https://files.pythonhosted.org/packages/58/ca/faa05ac19b3b622c7c9317ac3e23954187516298a091eb02c976d0d3dd45/xxhash-3.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:843b52f6d88071f87eba1631b684fcb4b2068cd2180a0224122fe4ef011a9374", size = 210655, upload-time = "2025-10-02T14:34:47.571Z" },
{ url = "https://files.pythonhosted.org/packages/d4/7a/06aa7482345480cc0cb597f5c875b11a82c3953f534394f620b0be2f700c/xxhash-3.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7d14a6cfaf03b1b6f5f9790f76880601ccc7896aff7ab9cd8978a939c1eb7e0d", size = 414001, upload-time = "2025-10-02T14:34:49.273Z" },
{ url = "https://files.pythonhosted.org/packages/23/07/63ffb386cd47029aa2916b3d2f454e6cc5b9f5c5ada3790377d5430084e7/xxhash-3.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:418daf3db71e1413cfe211c2f9a528456936645c17f46b5204705581a45390ae", size = 191431, upload-time = "2025-10-02T14:34:50.798Z" },
{ url = "https://files.pythonhosted.org/packages/0f/93/14fde614cadb4ddf5e7cebf8918b7e8fac5ae7861c1875964f17e678205c/xxhash-3.6.0-cp312-cp312-win32.whl", hash = "sha256:50fc255f39428a27299c20e280d6193d8b63b8ef8028995323bf834a026b4fbb", size = 30617, upload-time = "2025-10-02T14:34:51.954Z" },
{ url = "https://files.pythonhosted.org/packages/13/5d/0d125536cbe7565a83d06e43783389ecae0c0f2ed037b48ede185de477c0/xxhash-3.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:c0f2ab8c715630565ab8991b536ecded9416d615538be8ecddce43ccf26cbc7c", size = 31534, upload-time = "2025-10-02T14:34:53.276Z" },
{ url = "https://files.pythonhosted.org/packages/54/85/6ec269b0952ec7e36ba019125982cf11d91256a778c7c3f98a4c5043d283/xxhash-3.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:eae5c13f3bc455a3bbb68bdc513912dc7356de7e2280363ea235f71f54064829", size = 27876, upload-time = "2025-10-02T14:34:54.371Z" },
{ url = "https://files.pythonhosted.org/packages/33/76/35d05267ac82f53ae9b0e554da7c5e281ee61f3cad44c743f0fcd354f211/xxhash-3.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:599e64ba7f67472481ceb6ee80fa3bd828fd61ba59fb11475572cc5ee52b89ec", size = 32738, upload-time = "2025-10-02T14:34:55.839Z" },
{ url = "https://files.pythonhosted.org/packages/31/a8/3fbce1cd96534a95e35d5120637bf29b0d7f5d8fa2f6374e31b4156dd419/xxhash-3.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d8b8aaa30fca4f16f0c84a5c8d7ddee0e25250ec2796c973775373257dde8f1", size = 30821, upload-time = "2025-10-02T14:34:57.219Z" },
{ url = "https://files.pythonhosted.org/packages/0c/ea/d387530ca7ecfa183cb358027f1833297c6ac6098223fd14f9782cd0015c/xxhash-3.6.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d597acf8506d6e7101a4a44a5e428977a51c0fadbbfd3c39650cca9253f6e5a6", size = 194127, upload-time = "2025-10-02T14:34:59.21Z" },
{ url = "https://files.pythonhosted.org/packages/ba/0c/71435dcb99874b09a43b8d7c54071e600a7481e42b3e3ce1eb5226a5711a/xxhash-3.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:858dc935963a33bc33490128edc1c12b0c14d9c7ebaa4e387a7869ecc4f3e263", size = 212975, upload-time = "2025-10-02T14:35:00.816Z" },
{ url = "https://files.pythonhosted.org/packages/84/7a/c2b3d071e4bb4a90b7057228a99b10d51744878f4a8a6dd643c8bd897620/xxhash-3.6.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba284920194615cb8edf73bf52236ce2e1664ccd4a38fdb543506413529cc546", size = 212241, upload-time = "2025-10-02T14:35:02.207Z" },
{ url = "https://files.pythonhosted.org/packages/81/5f/640b6eac0128e215f177df99eadcd0f1b7c42c274ab6a394a05059694c5a/xxhash-3.6.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b54219177f6c6674d5378bd862c6aedf64725f70dd29c472eaae154df1a2e89", size = 445471, upload-time = "2025-10-02T14:35:03.61Z" },
{ url = "https://files.pythonhosted.org/packages/5e/1e/3c3d3ef071b051cc3abbe3721ffb8365033a172613c04af2da89d5548a87/xxhash-3.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42c36dd7dbad2f5238950c377fcbf6811b1cdb1c444fab447960030cea60504d", size = 193936, upload-time = "2025-10-02T14:35:05.013Z" },
{ url = "https://files.pythonhosted.org/packages/2c/bd/4a5f68381939219abfe1c22a9e3a5854a4f6f6f3c4983a87d255f21f2e5d/xxhash-3.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f22927652cba98c44639ffdc7aaf35828dccf679b10b31c4ad72a5b530a18eb7", size = 210440, upload-time = "2025-10-02T14:35:06.239Z" },
{ url = "https://files.pythonhosted.org/packages/eb/37/b80fe3d5cfb9faff01a02121a0f4d565eb7237e9e5fc66e73017e74dcd36/xxhash-3.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b45fad44d9c5c119e9c6fbf2e1c656a46dc68e280275007bbfd3d572b21426db", size = 197990, upload-time = "2025-10-02T14:35:07.735Z" },
{ url = "https://files.pythonhosted.org/packages/d7/fd/2c0a00c97b9e18f72e1f240ad4e8f8a90fd9d408289ba9c7c495ed7dc05c/xxhash-3.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6f2580ffab1a8b68ef2b901cde7e55fa8da5e4be0977c68f78fc80f3c143de42", size = 210689, upload-time = "2025-10-02T14:35:09.438Z" },
{ url = "https://files.pythonhosted.org/packages/93/86/5dd8076a926b9a95db3206aba20d89a7fc14dd5aac16e5c4de4b56033140/xxhash-3.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40c391dd3cd041ebc3ffe6f2c862f402e306eb571422e0aa918d8070ba31da11", size = 414068, upload-time = "2025-10-02T14:35:11.162Z" },
{ url = "https://files.pythonhosted.org/packages/af/3c/0bb129170ee8f3650f08e993baee550a09593462a5cddd8e44d0011102b1/xxhash-3.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f205badabde7aafd1a31e8ca2a3e5a763107a71c397c4481d6a804eb5063d8bd", size = 191495, upload-time = "2025-10-02T14:35:12.971Z" },
{ url = "https://files.pythonhosted.org/packages/e9/3a/6797e0114c21d1725e2577508e24006fd7ff1d8c0c502d3b52e45c1771d8/xxhash-3.6.0-cp313-cp313-win32.whl", hash = "sha256:2577b276e060b73b73a53042ea5bd5203d3e6347ce0d09f98500f418a9fcf799", size = 30620, upload-time = "2025-10-02T14:35:14.129Z" },
{ url = "https://files.pythonhosted.org/packages/86/15/9bc32671e9a38b413a76d24722a2bf8784a132c043063a8f5152d390b0f9/xxhash-3.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:757320d45d2fbcce8f30c42a6b2f47862967aea7bf458b9625b4bbe7ee390392", size = 31542, upload-time = "2025-10-02T14:35:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/39/c5/cc01e4f6188656e56112d6a8e0dfe298a16934b8c47a247236549a3f7695/xxhash-3.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:457b8f85dec5825eed7b69c11ae86834a018b8e3df5e77783c999663da2f96d6", size = 27880, upload-time = "2025-10-02T14:35:16.315Z" },
{ url = "https://files.pythonhosted.org/packages/f3/30/25e5321c8732759e930c555176d37e24ab84365482d257c3b16362235212/xxhash-3.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a42e633d75cdad6d625434e3468126c73f13f7584545a9cf34e883aa1710e702", size = 32956, upload-time = "2025-10-02T14:35:17.413Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3c/0573299560d7d9f8ab1838f1efc021a280b5ae5ae2e849034ef3dee18810/xxhash-3.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:568a6d743219e717b07b4e03b0a828ce593833e498c3b64752e0f5df6bfe84db", size = 31072, upload-time = "2025-10-02T14:35:18.844Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1c/52d83a06e417cd9d4137722693424885cc9878249beb3a7c829e74bf7ce9/xxhash-3.6.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bec91b562d8012dae276af8025a55811b875baace6af510412a5e58e3121bc54", size = 196409, upload-time = "2025-10-02T14:35:20.31Z" },
{ url = "https://files.pythonhosted.org/packages/e3/8e/c6d158d12a79bbd0b878f8355432075fc82759e356ab5a111463422a239b/xxhash-3.6.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78e7f2f4c521c30ad5e786fdd6bae89d47a32672a80195467b5de0480aa97b1f", size = 215736, upload-time = "2025-10-02T14:35:21.616Z" },
{ url = "https://files.pythonhosted.org/packages/bc/68/c4c80614716345d55071a396cf03d06e34b5f4917a467faf43083c995155/xxhash-3.6.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ed0df1b11a79856df5ffcab572cbd6b9627034c1c748c5566fa79df9048a7c5", size = 214833, upload-time = "2025-10-02T14:35:23.32Z" },
{ url = "https://files.pythonhosted.org/packages/7e/e9/ae27c8ffec8b953efa84c7c4a6c6802c263d587b9fc0d6e7cea64e08c3af/xxhash-3.6.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0e4edbfc7d420925b0dd5e792478ed393d6e75ff8fc219a6546fb446b6a417b1", size = 448348, upload-time = "2025-10-02T14:35:25.111Z" },
{ url = "https://files.pythonhosted.org/packages/d7/6b/33e21afb1b5b3f46b74b6bd1913639066af218d704cc0941404ca717fc57/xxhash-3.6.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fba27a198363a7ef87f8c0f6b171ec36b674fe9053742c58dd7e3201c1ab30ee", size = 196070, upload-time = "2025-10-02T14:35:26.586Z" },
{ url = "https://files.pythonhosted.org/packages/96/b6/fcabd337bc5fa624e7203aa0fa7d0c49eed22f72e93229431752bddc83d9/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:794fe9145fe60191c6532fa95063765529770edcdd67b3d537793e8004cabbfd", size = 212907, upload-time = "2025-10-02T14:35:28.087Z" },
{ url = "https://files.pythonhosted.org/packages/4b/d3/9ee6160e644d660fcf176c5825e61411c7f62648728f69c79ba237250143/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:6105ef7e62b5ac73a837778efc331a591d8442f8ef5c7e102376506cb4ae2729", size = 200839, upload-time = "2025-10-02T14:35:29.857Z" },
{ url = "https://files.pythonhosted.org/packages/0d/98/e8de5baa5109394baf5118f5e72ab21a86387c4f89b0e77ef3e2f6b0327b/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f01375c0e55395b814a679b3eea205db7919ac2af213f4a6682e01220e5fe292", size = 213304, upload-time = "2025-10-02T14:35:31.222Z" },
{ url = "https://files.pythonhosted.org/packages/7b/1d/71056535dec5c3177eeb53e38e3d367dd1d16e024e63b1cee208d572a033/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d706dca2d24d834a4661619dcacf51a75c16d65985718d6a7d73c1eeeb903ddf", size = 416930, upload-time = "2025-10-02T14:35:32.517Z" },
{ url = "https://files.pythonhosted.org/packages/dc/6c/5cbde9de2cd967c322e651c65c543700b19e7ae3e0aae8ece3469bf9683d/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5f059d9faeacd49c0215d66f4056e1326c80503f51a1532ca336a385edadd033", size = 193787, upload-time = "2025-10-02T14:35:33.827Z" },
{ url = "https://files.pythonhosted.org/packages/19/fa/0172e350361d61febcea941b0cc541d6e6c8d65d153e85f850a7b256ff8a/xxhash-3.6.0-cp313-cp313t-win32.whl", hash = "sha256:1244460adc3a9be84731d72b8e80625788e5815b68da3da8b83f78115a40a7ec", size = 30916, upload-time = "2025-10-02T14:35:35.107Z" },
{ url = "https://files.pythonhosted.org/packages/ad/e6/e8cf858a2b19d6d45820f072eff1bea413910592ff17157cabc5f1227a16/xxhash-3.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b1e420ef35c503869c4064f4a2f2b08ad6431ab7b229a05cce39d74268bca6b8", size = 31799, upload-time = "2025-10-02T14:35:36.165Z" },
{ url = "https://files.pythonhosted.org/packages/56/15/064b197e855bfb7b343210e82490ae672f8bc7cdf3ddb02e92f64304ee8a/xxhash-3.6.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ec44b73a4220623235f67a996c862049f375df3b1052d9899f40a6382c32d746", size = 28044, upload-time = "2025-10-02T14:35:37.195Z" },
{ url = "https://files.pythonhosted.org/packages/7e/5e/0138bc4484ea9b897864d59fce9be9086030825bc778b76cb5a33a906d37/xxhash-3.6.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a40a3d35b204b7cc7643cbcf8c9976d818cb47befcfac8bbefec8038ac363f3e", size = 32754, upload-time = "2025-10-02T14:35:38.245Z" },
{ url = "https://files.pythonhosted.org/packages/18/d7/5dac2eb2ec75fd771957a13e5dda560efb2176d5203f39502a5fc571f899/xxhash-3.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a54844be970d3fc22630b32d515e79a90d0a3ddb2644d8d7402e3c4c8da61405", size = 30846, upload-time = "2025-10-02T14:35:39.6Z" },
{ url = "https://files.pythonhosted.org/packages/fe/71/8bc5be2bb00deb5682e92e8da955ebe5fa982da13a69da5a40a4c8db12fb/xxhash-3.6.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:016e9190af8f0a4e3741343777710e3d5717427f175adfdc3e72508f59e2a7f3", size = 194343, upload-time = "2025-10-02T14:35:40.69Z" },
{ url = "https://files.pythonhosted.org/packages/e7/3b/52badfb2aecec2c377ddf1ae75f55db3ba2d321c5e164f14461c90837ef3/xxhash-3.6.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f6f72232f849eb9d0141e2ebe2677ece15adfd0fa599bc058aad83c714bb2c6", size = 213074, upload-time = "2025-10-02T14:35:42.29Z" },
{ url = "https://files.pythonhosted.org/packages/a2/2b/ae46b4e9b92e537fa30d03dbc19cdae57ed407e9c26d163895e968e3de85/xxhash-3.6.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63275a8aba7865e44b1813d2177e0f5ea7eadad3dd063a21f7cf9afdc7054063", size = 212388, upload-time = "2025-10-02T14:35:43.929Z" },
{ url = "https://files.pythonhosted.org/packages/f5/80/49f88d3afc724b4ac7fbd664c8452d6db51b49915be48c6982659e0e7942/xxhash-3.6.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cd01fa2aa00d8b017c97eb46b9a794fbdca53fc14f845f5a328c71254b0abb7", size = 445614, upload-time = "2025-10-02T14:35:45.216Z" },
{ url = "https://files.pythonhosted.org/packages/ed/ba/603ce3961e339413543d8cd44f21f2c80e2a7c5cfe692a7b1f2cccf58f3c/xxhash-3.6.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0226aa89035b62b6a86d3c68df4d7c1f47a342b8683da2b60cedcddb46c4d95b", size = 194024, upload-time = "2025-10-02T14:35:46.959Z" },
{ url = "https://files.pythonhosted.org/packages/78/d1/8e225ff7113bf81545cfdcd79eef124a7b7064a0bba53605ff39590b95c2/xxhash-3.6.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c6e193e9f56e4ca4923c61238cdaced324f0feac782544eb4c6d55ad5cc99ddd", size = 210541, upload-time = "2025-10-02T14:35:48.301Z" },
{ url = "https://files.pythonhosted.org/packages/6f/58/0f89d149f0bad89def1a8dd38feb50ccdeb643d9797ec84707091d4cb494/xxhash-3.6.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9176dcaddf4ca963d4deb93866d739a343c01c969231dbe21680e13a5d1a5bf0", size = 198305, upload-time = "2025-10-02T14:35:49.584Z" },
{ url = "https://files.pythonhosted.org/packages/11/38/5eab81580703c4df93feb5f32ff8fa7fe1e2c51c1f183ee4e48d4bb9d3d7/xxhash-3.6.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c1ce4009c97a752e682b897aa99aef84191077a9433eb237774689f14f8ec152", size = 210848, upload-time = "2025-10-02T14:35:50.877Z" },
{ url = "https://files.pythonhosted.org/packages/5e/6b/953dc4b05c3ce678abca756416e4c130d2382f877a9c30a20d08ee6a77c0/xxhash-3.6.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8cb2f4f679b01513b7adbb9b1b2f0f9cdc31b70007eaf9d59d0878809f385b11", size = 414142, upload-time = "2025-10-02T14:35:52.15Z" },
{ url = "https://files.pythonhosted.org/packages/08/a9/238ec0d4e81a10eb5026d4a6972677cbc898ba6c8b9dbaec12ae001b1b35/xxhash-3.6.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:653a91d7c2ab54a92c19ccf43508b6a555440b9be1bc8be553376778be7f20b5", size = 191547, upload-time = "2025-10-02T14:35:53.547Z" },
{ url = "https://files.pythonhosted.org/packages/f1/ee/3cf8589e06c2164ac77c3bf0aa127012801128f1feebf2a079272da5737c/xxhash-3.6.0-cp314-cp314-win32.whl", hash = "sha256:a756fe893389483ee8c394d06b5ab765d96e68fbbfe6fde7aa17e11f5720559f", size = 31214, upload-time = "2025-10-02T14:35:54.746Z" },
{ url = "https://files.pythonhosted.org/packages/02/5d/a19552fbc6ad4cb54ff953c3908bbc095f4a921bc569433d791f755186f1/xxhash-3.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:39be8e4e142550ef69629c9cd71b88c90e9a5db703fecbcf265546d9536ca4ad", size = 32290, upload-time = "2025-10-02T14:35:55.791Z" },
{ url = "https://files.pythonhosted.org/packages/b1/11/dafa0643bc30442c887b55baf8e73353a344ee89c1901b5a5c54a6c17d39/xxhash-3.6.0-cp314-cp314-win_arm64.whl", hash = "sha256:25915e6000338999236f1eb68a02a32c3275ac338628a7eaa5a269c401995679", size = 28795, upload-time = "2025-10-02T14:35:57.162Z" },
{ url = "https://files.pythonhosted.org/packages/2c/db/0e99732ed7f64182aef4a6fb145e1a295558deec2a746265dcdec12d191e/xxhash-3.6.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c5294f596a9017ca5a3e3f8884c00b91ab2ad2933cf288f4923c3fd4346cf3d4", size = 32955, upload-time = "2025-10-02T14:35:58.267Z" },
{ url = "https://files.pythonhosted.org/packages/55/f4/2a7c3c68e564a099becfa44bb3d398810cc0ff6749b0d3cb8ccb93f23c14/xxhash-3.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1cf9dcc4ab9cff01dfbba78544297a3a01dafd60f3bde4e2bfd016cf7e4ddc67", size = 31072, upload-time = "2025-10-02T14:35:59.382Z" },
{ url = "https://files.pythonhosted.org/packages/c6/d9/72a29cddc7250e8a5819dad5d466facb5dc4c802ce120645630149127e73/xxhash-3.6.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01262da8798422d0685f7cef03b2bd3f4f46511b02830861df548d7def4402ad", size = 196579, upload-time = "2025-10-02T14:36:00.838Z" },
{ url = "https://files.pythonhosted.org/packages/63/93/b21590e1e381040e2ca305a884d89e1c345b347404f7780f07f2cdd47ef4/xxhash-3.6.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51a73fb7cb3a3ead9f7a8b583ffd9b8038e277cdb8cb87cf890e88b3456afa0b", size = 215854, upload-time = "2025-10-02T14:36:02.207Z" },
{ url = "https://files.pythonhosted.org/packages/ce/b8/edab8a7d4fa14e924b29be877d54155dcbd8b80be85ea00d2be3413a9ed4/xxhash-3.6.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9c6df83594f7df8f7f708ce5ebeacfc69f72c9fbaaababf6cf4758eaada0c9b", size = 214965, upload-time = "2025-10-02T14:36:03.507Z" },
{ url = "https://files.pythonhosted.org/packages/27/67/dfa980ac7f0d509d54ea0d5a486d2bb4b80c3f1bb22b66e6a05d3efaf6c0/xxhash-3.6.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:627f0af069b0ea56f312fd5189001c24578868643203bca1abbc2c52d3a6f3ca", size = 448484, upload-time = "2025-10-02T14:36:04.828Z" },
{ url = "https://files.pythonhosted.org/packages/8c/63/8ffc2cc97e811c0ca5d00ab36604b3ea6f4254f20b7bc658ca825ce6c954/xxhash-3.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa912c62f842dfd013c5f21a642c9c10cd9f4c4e943e0af83618b4a404d9091a", size = 196162, upload-time = "2025-10-02T14:36:06.182Z" },
{ url = "https://files.pythonhosted.org/packages/4b/77/07f0e7a3edd11a6097e990f6e5b815b6592459cb16dae990d967693e6ea9/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b465afd7909db30168ab62afe40b2fcf79eedc0b89a6c0ab3123515dc0df8b99", size = 213007, upload-time = "2025-10-02T14:36:07.733Z" },
{ url = "https://files.pythonhosted.org/packages/ae/d8/bc5fa0d152837117eb0bef6f83f956c509332ce133c91c63ce07ee7c4873/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a881851cf38b0a70e7c4d3ce81fc7afd86fbc2a024f4cfb2a97cf49ce04b75d3", size = 200956, upload-time = "2025-10-02T14:36:09.106Z" },
{ url = "https://files.pythonhosted.org/packages/26/a5/d749334130de9411783873e9b98ecc46688dad5db64ca6e04b02acc8b473/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9b3222c686a919a0f3253cfc12bb118b8b103506612253b5baeaac10d8027cf6", size = 213401, upload-time = "2025-10-02T14:36:10.585Z" },
{ url = "https://files.pythonhosted.org/packages/89/72/abed959c956a4bfc72b58c0384bb7940663c678127538634d896b1195c10/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:c5aa639bc113e9286137cec8fadc20e9cd732b2cc385c0b7fa673b84fc1f2a93", size = 417083, upload-time = "2025-10-02T14:36:12.276Z" },
{ url = "https://files.pythonhosted.org/packages/0c/b3/62fd2b586283b7d7d665fb98e266decadf31f058f1cf6c478741f68af0cb/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c1343d49ac102799905e115aee590183c3921d475356cb24b4de29a4bc56518", size = 193913, upload-time = "2025-10-02T14:36:14.025Z" },
{ url = "https://files.pythonhosted.org/packages/9a/9a/c19c42c5b3f5a4aad748a6d5b4f23df3bed7ee5445accc65a0fb3ff03953/xxhash-3.6.0-cp314-cp314t-win32.whl", hash = "sha256:5851f033c3030dd95c086b4a36a2683c2ff4a799b23af60977188b057e467119", size = 31586, upload-time = "2025-10-02T14:36:15.603Z" },
{ url = "https://files.pythonhosted.org/packages/03/d6/4cc450345be9924fd5dc8c590ceda1db5b43a0a889587b0ae81a95511360/xxhash-3.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0444e7967dac37569052d2409b00a8860c2135cff05502df4da80267d384849f", size = 32526, upload-time = "2025-10-02T14:36:16.708Z" },
{ url = "https://files.pythonhosted.org/packages/0f/c9/7243eb3f9eaabd1a88a5a5acadf06df2d83b100c62684b7425c6a11bcaa8/xxhash-3.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bb79b1e63f6fd84ec778a4b1916dfe0a7c3fdb986c06addd5db3a0d413819d95", size = 28898, upload-time = "2025-10-02T14:36:17.843Z" },
{ url = "https://files.pythonhosted.org/packages/93/1e/8aec23647a34a249f62e2398c42955acd9b4c6ed5cf08cbea94dc46f78d2/xxhash-3.6.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0f7b7e2ec26c1666ad5fc9dbfa426a6a3367ceaf79db5dd76264659d509d73b0", size = 30662, upload-time = "2025-10-02T14:37:01.743Z" },
{ url = "https://files.pythonhosted.org/packages/b8/0b/b14510b38ba91caf43006209db846a696ceea6a847a0c9ba0a5b1adc53d6/xxhash-3.6.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5dc1e14d14fa0f5789ec29a7062004b5933964bb9b02aae6622b8f530dc40296", size = 41056, upload-time = "2025-10-02T14:37:02.879Z" },
{ url = "https://files.pythonhosted.org/packages/50/55/15a7b8a56590e66ccd374bbfa3f9ffc45b810886c8c3b614e3f90bd2367c/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:881b47fc47e051b37d94d13e7455131054b56749b91b508b0907eb07900d1c13", size = 36251, upload-time = "2025-10-02T14:37:04.44Z" },
{ url = "https://files.pythonhosted.org/packages/62/b2/5ac99a041a29e58e95f907876b04f7067a0242cb85b5f39e726153981503/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6dc31591899f5e5666f04cc2e529e69b4072827085c1ef15294d91a004bc1bd", size = 32481, upload-time = "2025-10-02T14:37:05.869Z" },
{ url = "https://files.pythonhosted.org/packages/7b/d9/8d95e906764a386a3d3b596f3c68bb63687dfca806373509f51ce8eea81f/xxhash-3.6.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:15e0dac10eb9309508bfc41f7f9deaa7755c69e35af835db9cb10751adebc35d", size = 31565, upload-time = "2025-10-02T14:37:06.966Z" },
]
[[package]]
name = "zstandard"
version = "0.25.0"
-5
View File
@@ -2,11 +2,6 @@
Implementation of LangGraph CheckpointSaver that uses SQLite DB (both sync and async, via `aiosqlite`)
## Security
> [!IMPORTANT]
> Set `LANGGRAPH_STRICT_MSGPACK=true` or pass an explicit `allowed_msgpack_modules` list when creating your checkpointer. This restricts checkpoint deserialization to known-safe types, preventing code execution if the database is compromised. See the [langgraph-checkpoint README](https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint#serde) for details.
## Usage
```python
+10 -129
View File
@@ -249,7 +249,7 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.2.28"
version = "1.2.22"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonpatch" },
@@ -261,14 +261,14 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "uuid-utils" },
]
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" }
sdist = { url = "https://files.pythonhosted.org/packages/b1/a3/c4cd6827a1df46c821e7214b7f7b7a28b189e6c9b84ef15c6d629c5e3179/langchain_core-1.2.22.tar.gz", hash = "sha256:8d8f726d03d3652d403da915126626bb6250747e8ba406537d849e68b9f5d058", size = 842487, upload-time = "2026-03-24T18:48:44.9Z" }
wheels = [
{ 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" },
{ url = "https://files.pythonhosted.org/packages/c7/a6/2ffacf0f1a3788f250e75d0b52a24896c413be11be3a6d42bcdf46fbea48/langchain_core-1.2.22-py3-none-any.whl", hash = "sha256:7e30d586b75918e828833b9ec1efc25465723566845dd652c277baf751e9c04b", size = 506829, upload-time = "2026-03-24T18:48:43.286Z" },
]
[[package]]
name = "langgraph-checkpoint"
version = "4.0.3"
version = "4.0.1"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
@@ -385,7 +385,7 @@ test = [
[[package]]
name = "langsmith"
version = "0.7.31"
version = "0.6.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
@@ -395,12 +395,11 @@ dependencies = [
{ name = "requests" },
{ name = "requests-toolbelt" },
{ name = "uuid-utils" },
{ name = "xxhash" },
{ name = "zstandard" },
]
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" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/85/9c7933052a997da1b85bc5c774f3865e9b1da1c8d71541ea133178b13229/langsmith-0.6.4.tar.gz", hash = "sha256:36f7223a01c218079fbb17da5e536ebbaf5c1468c028abe070aa3ae59bc99ec8", size = 919964, upload-time = "2026-01-15T20:02:28.873Z" }
wheels = [
{ 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" },
{ url = "https://files.pythonhosted.org/packages/66/0f/09a6637a7ba777eb307b7c80852d9ee26438e2bdafbad6fcc849ff9d9192/langsmith-0.6.4-py3-none-any.whl", hash = "sha256:ac4835860160be371042c7adbba3cb267bcf8d96a5ea976c33a8a4acad6c5486", size = 283503, upload-time = "2026-01-15T20:02:26.662Z" },
]
[[package]]
@@ -863,7 +862,7 @@ wheels = [
[[package]]
name = "pytest"
version = "9.0.3"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -874,9 +873,9 @@ dependencies = [
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
@@ -1212,124 +1211,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
]
[[package]]
name = "xxhash"
version = "3.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/02/84/30869e01909fb37a6cc7e18688ee8bf1e42d57e7e0777636bd47524c43c7/xxhash-3.6.0.tar.gz", hash = "sha256:f0162a78b13a0d7617b2845b90c763339d1f1d82bb04a4b07f4ab535cc5e05d6", size = 85160, upload-time = "2025-10-02T14:37:08.097Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/34/ee/f9f1d656ad168681bb0f6b092372c1e533c4416b8069b1896a175c46e484/xxhash-3.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:87ff03d7e35c61435976554477a7f4cd1704c3596a89a8300d5ce7fc83874a71", size = 32845, upload-time = "2025-10-02T14:33:51.573Z" },
{ url = "https://files.pythonhosted.org/packages/a3/b1/93508d9460b292c74a09b83d16750c52a0ead89c51eea9951cb97a60d959/xxhash-3.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f572dfd3d0e2eb1a57511831cf6341242f5a9f8298a45862d085f5b93394a27d", size = 30807, upload-time = "2025-10-02T14:33:52.964Z" },
{ url = "https://files.pythonhosted.org/packages/07/55/28c93a3662f2d200c70704efe74aab9640e824f8ce330d8d3943bf7c9b3c/xxhash-3.6.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:89952ea539566b9fed2bbd94e589672794b4286f342254fad28b149f9615fef8", size = 193786, upload-time = "2025-10-02T14:33:54.272Z" },
{ url = "https://files.pythonhosted.org/packages/c1/96/fec0be9bb4b8f5d9c57d76380a366f31a1781fb802f76fc7cda6c84893c7/xxhash-3.6.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e6f2ffb07a50b52465a1032c3cf1f4a5683f944acaca8a134a2f23674c2058", size = 212830, upload-time = "2025-10-02T14:33:55.706Z" },
{ url = "https://files.pythonhosted.org/packages/c4/a0/c706845ba77b9611f81fd2e93fad9859346b026e8445e76f8c6fd057cc6d/xxhash-3.6.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b5b848ad6c16d308c3ac7ad4ba6bede80ed5df2ba8ed382f8932df63158dd4b2", size = 211606, upload-time = "2025-10-02T14:33:57.133Z" },
{ url = "https://files.pythonhosted.org/packages/67/1e/164126a2999e5045f04a69257eea946c0dc3e86541b400d4385d646b53d7/xxhash-3.6.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a034590a727b44dd8ac5914236a7b8504144447a9682586c3327e935f33ec8cc", size = 444872, upload-time = "2025-10-02T14:33:58.446Z" },
{ url = "https://files.pythonhosted.org/packages/2d/4b/55ab404c56cd70a2cf5ecfe484838865d0fea5627365c6c8ca156bd09c8f/xxhash-3.6.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a8f1972e75ebdd161d7896743122834fe87378160c20e97f8b09166213bf8cc", size = 193217, upload-time = "2025-10-02T14:33:59.724Z" },
{ url = "https://files.pythonhosted.org/packages/45/e6/52abf06bac316db33aa269091ae7311bd53cfc6f4b120ae77bac1b348091/xxhash-3.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ee34327b187f002a596d7b167ebc59a1b729e963ce645964bbc050d2f1b73d07", size = 210139, upload-time = "2025-10-02T14:34:02.041Z" },
{ url = "https://files.pythonhosted.org/packages/34/37/db94d490b8691236d356bc249c08819cbcef9273a1a30acf1254ff9ce157/xxhash-3.6.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:339f518c3c7a850dd033ab416ea25a692759dc7478a71131fe8869010d2b75e4", size = 197669, upload-time = "2025-10-02T14:34:03.664Z" },
{ url = "https://files.pythonhosted.org/packages/b7/36/c4f219ef4a17a4f7a64ed3569bc2b5a9c8311abdb22249ac96093625b1a4/xxhash-3.6.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:bf48889c9630542d4709192578aebbd836177c9f7a4a2778a7d6340107c65f06", size = 210018, upload-time = "2025-10-02T14:34:05.325Z" },
{ url = "https://files.pythonhosted.org/packages/fd/06/bfac889a374fc2fc439a69223d1750eed2e18a7db8514737ab630534fa08/xxhash-3.6.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:5576b002a56207f640636056b4160a378fe36a58db73ae5c27a7ec8db35f71d4", size = 413058, upload-time = "2025-10-02T14:34:06.925Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d1/555d8447e0dd32ad0930a249a522bb2e289f0d08b6b16204cfa42c1f5a0c/xxhash-3.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af1f3278bd02814d6dedc5dec397993b549d6f16c19379721e5a1d31e132c49b", size = 190628, upload-time = "2025-10-02T14:34:08.669Z" },
{ url = "https://files.pythonhosted.org/packages/d1/15/8751330b5186cedc4ed4b597989882ea05e0408b53fa47bcb46a6125bfc6/xxhash-3.6.0-cp310-cp310-win32.whl", hash = "sha256:aed058764db109dc9052720da65fafe84873b05eb8b07e5e653597951af57c3b", size = 30577, upload-time = "2025-10-02T14:34:10.234Z" },
{ url = "https://files.pythonhosted.org/packages/bb/cc/53f87e8b5871a6eb2ff7e89c48c66093bda2be52315a8161ddc54ea550c4/xxhash-3.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:e82da5670f2d0d98950317f82a0e4a0197150ff19a6df2ba40399c2a3b9ae5fb", size = 31487, upload-time = "2025-10-02T14:34:11.618Z" },
{ url = "https://files.pythonhosted.org/packages/9f/00/60f9ea3bb697667a14314d7269956f58bf56bb73864f8f8d52a3c2535e9a/xxhash-3.6.0-cp310-cp310-win_arm64.whl", hash = "sha256:4a082ffff8c6ac07707fb6b671caf7c6e020c75226c561830b73d862060f281d", size = 27863, upload-time = "2025-10-02T14:34:12.619Z" },
{ url = "https://files.pythonhosted.org/packages/17/d4/cc2f0400e9154df4b9964249da78ebd72f318e35ccc425e9f403c392f22a/xxhash-3.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b47bbd8cf2d72797f3c2772eaaac0ded3d3af26481a26d7d7d41dc2d3c46b04a", size = 32844, upload-time = "2025-10-02T14:34:14.037Z" },
{ url = "https://files.pythonhosted.org/packages/5e/ec/1cc11cd13e26ea8bc3cb4af4eaadd8d46d5014aebb67be3f71fb0b68802a/xxhash-3.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2b6821e94346f96db75abaa6e255706fb06ebd530899ed76d32cd99f20dc52fa", size = 30809, upload-time = "2025-10-02T14:34:15.484Z" },
{ url = "https://files.pythonhosted.org/packages/04/5f/19fe357ea348d98ca22f456f75a30ac0916b51c753e1f8b2e0e6fb884cce/xxhash-3.6.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d0a9751f71a1a65ce3584e9cae4467651c7e70c9d31017fa57574583a4540248", size = 194665, upload-time = "2025-10-02T14:34:16.541Z" },
{ url = "https://files.pythonhosted.org/packages/90/3b/d1f1a8f5442a5fd8beedae110c5af7604dc37349a8e16519c13c19a9a2de/xxhash-3.6.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b29ee68625ab37b04c0b40c3fafdf24d2f75ccd778333cfb698f65f6c463f62", size = 213550, upload-time = "2025-10-02T14:34:17.878Z" },
{ url = "https://files.pythonhosted.org/packages/c4/ef/3a9b05eb527457d5db13a135a2ae1a26c80fecd624d20f3e8dcc4cb170f3/xxhash-3.6.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6812c25fe0d6c36a46ccb002f40f27ac903bf18af9f6dd8f9669cb4d176ab18f", size = 212384, upload-time = "2025-10-02T14:34:19.182Z" },
{ url = "https://files.pythonhosted.org/packages/0f/18/ccc194ee698c6c623acbf0f8c2969811a8a4b6185af5e824cd27b9e4fd3e/xxhash-3.6.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4ccbff013972390b51a18ef1255ef5ac125c92dc9143b2d1909f59abc765540e", size = 445749, upload-time = "2025-10-02T14:34:20.659Z" },
{ url = "https://files.pythonhosted.org/packages/a5/86/cf2c0321dc3940a7aa73076f4fd677a0fb3e405cb297ead7d864fd90847e/xxhash-3.6.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:297b7fbf86c82c550e12e8fb71968b3f033d27b874276ba3624ea868c11165a8", size = 193880, upload-time = "2025-10-02T14:34:22.431Z" },
{ url = "https://files.pythonhosted.org/packages/82/fb/96213c8560e6f948a1ecc9a7613f8032b19ee45f747f4fca4eb31bb6d6ed/xxhash-3.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dea26ae1eb293db089798d3973a5fc928a18fdd97cc8801226fae705b02b14b0", size = 210912, upload-time = "2025-10-02T14:34:23.937Z" },
{ url = "https://files.pythonhosted.org/packages/40/aa/4395e669b0606a096d6788f40dbdf2b819d6773aa290c19e6e83cbfc312f/xxhash-3.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7a0b169aafb98f4284f73635a8e93f0735f9cbde17bd5ec332480484241aaa77", size = 198654, upload-time = "2025-10-02T14:34:25.644Z" },
{ url = "https://files.pythonhosted.org/packages/67/74/b044fcd6b3d89e9b1b665924d85d3f400636c23590226feb1eb09e1176ce/xxhash-3.6.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:08d45aef063a4531b785cd72de4887766d01dc8f362a515693df349fdb825e0c", size = 210867, upload-time = "2025-10-02T14:34:27.203Z" },
{ url = "https://files.pythonhosted.org/packages/bc/fd/3ce73bf753b08cb19daee1eb14aa0d7fe331f8da9c02dd95316ddfe5275e/xxhash-3.6.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:929142361a48ee07f09121fe9e96a84950e8d4df3bb298ca5d88061969f34d7b", size = 414012, upload-time = "2025-10-02T14:34:28.409Z" },
{ url = "https://files.pythonhosted.org/packages/ba/b3/5a4241309217c5c876f156b10778f3ab3af7ba7e3259e6d5f5c7d0129eb2/xxhash-3.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:51312c768403d8540487dbbfb557454cfc55589bbde6424456951f7fcd4facb3", size = 191409, upload-time = "2025-10-02T14:34:29.696Z" },
{ url = "https://files.pythonhosted.org/packages/c0/01/99bfbc15fb9abb9a72b088c1d95219fc4782b7d01fc835bd5744d66dd0b8/xxhash-3.6.0-cp311-cp311-win32.whl", hash = "sha256:d1927a69feddc24c987b337ce81ac15c4720955b667fe9b588e02254b80446fd", size = 30574, upload-time = "2025-10-02T14:34:31.028Z" },
{ url = "https://files.pythonhosted.org/packages/65/79/9d24d7f53819fe301b231044ea362ce64e86c74f6e8c8e51320de248b3e5/xxhash-3.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:26734cdc2d4ffe449b41d186bbeac416f704a482ed835d375a5c0cb02bc63fef", size = 31481, upload-time = "2025-10-02T14:34:32.062Z" },
{ url = "https://files.pythonhosted.org/packages/30/4e/15cd0e3e8772071344eab2961ce83f6e485111fed8beb491a3f1ce100270/xxhash-3.6.0-cp311-cp311-win_arm64.whl", hash = "sha256:d72f67ef8bf36e05f5b6c65e8524f265bd61071471cd4cf1d36743ebeeeb06b7", size = 27861, upload-time = "2025-10-02T14:34:33.555Z" },
{ url = "https://files.pythonhosted.org/packages/9a/07/d9412f3d7d462347e4511181dea65e47e0d0e16e26fbee2ea86a2aefb657/xxhash-3.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01362c4331775398e7bb34e3ab403bc9ee9f7c497bc7dee6272114055277dd3c", size = 32744, upload-time = "2025-10-02T14:34:34.622Z" },
{ url = "https://files.pythonhosted.org/packages/79/35/0429ee11d035fc33abe32dca1b2b69e8c18d236547b9a9b72c1929189b9a/xxhash-3.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b7b2df81a23f8cb99656378e72501b2cb41b1827c0f5a86f87d6b06b69f9f204", size = 30816, upload-time = "2025-10-02T14:34:36.043Z" },
{ url = "https://files.pythonhosted.org/packages/b7/f2/57eb99aa0f7d98624c0932c5b9a170e1806406cdbcdb510546634a1359e0/xxhash-3.6.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dc94790144e66b14f67b10ac8ed75b39ca47536bf8800eb7c24b50271ea0c490", size = 194035, upload-time = "2025-10-02T14:34:37.354Z" },
{ url = "https://files.pythonhosted.org/packages/4c/ed/6224ba353690d73af7a3f1c7cdb1fc1b002e38f783cb991ae338e1eb3d79/xxhash-3.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93f107c673bccf0d592cdba077dedaf52fe7f42dcd7676eba1f6d6f0c3efffd2", size = 212914, upload-time = "2025-10-02T14:34:38.6Z" },
{ url = "https://files.pythonhosted.org/packages/38/86/fb6b6130d8dd6b8942cc17ab4d90e223653a89aa32ad2776f8af7064ed13/xxhash-3.6.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aa5ee3444c25b69813663c9f8067dcfaa2e126dc55e8dddf40f4d1c25d7effa", size = 212163, upload-time = "2025-10-02T14:34:39.872Z" },
{ url = "https://files.pythonhosted.org/packages/ee/dc/e84875682b0593e884ad73b2d40767b5790d417bde603cceb6878901d647/xxhash-3.6.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7f99123f0e1194fa59cc69ad46dbae2e07becec5df50a0509a808f90a0f03f0", size = 445411, upload-time = "2025-10-02T14:34:41.569Z" },
{ url = "https://files.pythonhosted.org/packages/11/4f/426f91b96701ec2f37bb2b8cec664eff4f658a11f3fa9d94f0a887ea6d2b/xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49e03e6fe2cac4a1bc64952dd250cf0dbc5ef4ebb7b8d96bce82e2de163c82a2", size = 193883, upload-time = "2025-10-02T14:34:43.249Z" },
{ url = "https://files.pythonhosted.org/packages/53/5a/ddbb83eee8e28b778eacfc5a85c969673e4023cdeedcfcef61f36731610b/xxhash-3.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd17fede52a17a4f9a7bc4472a5867cb0b160deeb431795c0e4abe158bc784e9", size = 210392, upload-time = "2025-10-02T14:34:45.042Z" },
{ url = "https://files.pythonhosted.org/packages/1e/c2/ff69efd07c8c074ccdf0a4f36fcdd3d27363665bcdf4ba399abebe643465/xxhash-3.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6fb5f5476bef678f69db04f2bd1efbed3030d2aba305b0fc1773645f187d6a4e", size = 197898, upload-time = "2025-10-02T14:34:46.302Z" },
{ url = "https://files.pythonhosted.org/packages/58/ca/faa05ac19b3b622c7c9317ac3e23954187516298a091eb02c976d0d3dd45/xxhash-3.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:843b52f6d88071f87eba1631b684fcb4b2068cd2180a0224122fe4ef011a9374", size = 210655, upload-time = "2025-10-02T14:34:47.571Z" },
{ url = "https://files.pythonhosted.org/packages/d4/7a/06aa7482345480cc0cb597f5c875b11a82c3953f534394f620b0be2f700c/xxhash-3.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7d14a6cfaf03b1b6f5f9790f76880601ccc7896aff7ab9cd8978a939c1eb7e0d", size = 414001, upload-time = "2025-10-02T14:34:49.273Z" },
{ url = "https://files.pythonhosted.org/packages/23/07/63ffb386cd47029aa2916b3d2f454e6cc5b9f5c5ada3790377d5430084e7/xxhash-3.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:418daf3db71e1413cfe211c2f9a528456936645c17f46b5204705581a45390ae", size = 191431, upload-time = "2025-10-02T14:34:50.798Z" },
{ url = "https://files.pythonhosted.org/packages/0f/93/14fde614cadb4ddf5e7cebf8918b7e8fac5ae7861c1875964f17e678205c/xxhash-3.6.0-cp312-cp312-win32.whl", hash = "sha256:50fc255f39428a27299c20e280d6193d8b63b8ef8028995323bf834a026b4fbb", size = 30617, upload-time = "2025-10-02T14:34:51.954Z" },
{ url = "https://files.pythonhosted.org/packages/13/5d/0d125536cbe7565a83d06e43783389ecae0c0f2ed037b48ede185de477c0/xxhash-3.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:c0f2ab8c715630565ab8991b536ecded9416d615538be8ecddce43ccf26cbc7c", size = 31534, upload-time = "2025-10-02T14:34:53.276Z" },
{ url = "https://files.pythonhosted.org/packages/54/85/6ec269b0952ec7e36ba019125982cf11d91256a778c7c3f98a4c5043d283/xxhash-3.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:eae5c13f3bc455a3bbb68bdc513912dc7356de7e2280363ea235f71f54064829", size = 27876, upload-time = "2025-10-02T14:34:54.371Z" },
{ url = "https://files.pythonhosted.org/packages/33/76/35d05267ac82f53ae9b0e554da7c5e281ee61f3cad44c743f0fcd354f211/xxhash-3.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:599e64ba7f67472481ceb6ee80fa3bd828fd61ba59fb11475572cc5ee52b89ec", size = 32738, upload-time = "2025-10-02T14:34:55.839Z" },
{ url = "https://files.pythonhosted.org/packages/31/a8/3fbce1cd96534a95e35d5120637bf29b0d7f5d8fa2f6374e31b4156dd419/xxhash-3.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d8b8aaa30fca4f16f0c84a5c8d7ddee0e25250ec2796c973775373257dde8f1", size = 30821, upload-time = "2025-10-02T14:34:57.219Z" },
{ url = "https://files.pythonhosted.org/packages/0c/ea/d387530ca7ecfa183cb358027f1833297c6ac6098223fd14f9782cd0015c/xxhash-3.6.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d597acf8506d6e7101a4a44a5e428977a51c0fadbbfd3c39650cca9253f6e5a6", size = 194127, upload-time = "2025-10-02T14:34:59.21Z" },
{ url = "https://files.pythonhosted.org/packages/ba/0c/71435dcb99874b09a43b8d7c54071e600a7481e42b3e3ce1eb5226a5711a/xxhash-3.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:858dc935963a33bc33490128edc1c12b0c14d9c7ebaa4e387a7869ecc4f3e263", size = 212975, upload-time = "2025-10-02T14:35:00.816Z" },
{ url = "https://files.pythonhosted.org/packages/84/7a/c2b3d071e4bb4a90b7057228a99b10d51744878f4a8a6dd643c8bd897620/xxhash-3.6.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba284920194615cb8edf73bf52236ce2e1664ccd4a38fdb543506413529cc546", size = 212241, upload-time = "2025-10-02T14:35:02.207Z" },
{ url = "https://files.pythonhosted.org/packages/81/5f/640b6eac0128e215f177df99eadcd0f1b7c42c274ab6a394a05059694c5a/xxhash-3.6.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b54219177f6c6674d5378bd862c6aedf64725f70dd29c472eaae154df1a2e89", size = 445471, upload-time = "2025-10-02T14:35:03.61Z" },
{ url = "https://files.pythonhosted.org/packages/5e/1e/3c3d3ef071b051cc3abbe3721ffb8365033a172613c04af2da89d5548a87/xxhash-3.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42c36dd7dbad2f5238950c377fcbf6811b1cdb1c444fab447960030cea60504d", size = 193936, upload-time = "2025-10-02T14:35:05.013Z" },
{ url = "https://files.pythonhosted.org/packages/2c/bd/4a5f68381939219abfe1c22a9e3a5854a4f6f6f3c4983a87d255f21f2e5d/xxhash-3.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f22927652cba98c44639ffdc7aaf35828dccf679b10b31c4ad72a5b530a18eb7", size = 210440, upload-time = "2025-10-02T14:35:06.239Z" },
{ url = "https://files.pythonhosted.org/packages/eb/37/b80fe3d5cfb9faff01a02121a0f4d565eb7237e9e5fc66e73017e74dcd36/xxhash-3.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b45fad44d9c5c119e9c6fbf2e1c656a46dc68e280275007bbfd3d572b21426db", size = 197990, upload-time = "2025-10-02T14:35:07.735Z" },
{ url = "https://files.pythonhosted.org/packages/d7/fd/2c0a00c97b9e18f72e1f240ad4e8f8a90fd9d408289ba9c7c495ed7dc05c/xxhash-3.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6f2580ffab1a8b68ef2b901cde7e55fa8da5e4be0977c68f78fc80f3c143de42", size = 210689, upload-time = "2025-10-02T14:35:09.438Z" },
{ url = "https://files.pythonhosted.org/packages/93/86/5dd8076a926b9a95db3206aba20d89a7fc14dd5aac16e5c4de4b56033140/xxhash-3.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40c391dd3cd041ebc3ffe6f2c862f402e306eb571422e0aa918d8070ba31da11", size = 414068, upload-time = "2025-10-02T14:35:11.162Z" },
{ url = "https://files.pythonhosted.org/packages/af/3c/0bb129170ee8f3650f08e993baee550a09593462a5cddd8e44d0011102b1/xxhash-3.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f205badabde7aafd1a31e8ca2a3e5a763107a71c397c4481d6a804eb5063d8bd", size = 191495, upload-time = "2025-10-02T14:35:12.971Z" },
{ url = "https://files.pythonhosted.org/packages/e9/3a/6797e0114c21d1725e2577508e24006fd7ff1d8c0c502d3b52e45c1771d8/xxhash-3.6.0-cp313-cp313-win32.whl", hash = "sha256:2577b276e060b73b73a53042ea5bd5203d3e6347ce0d09f98500f418a9fcf799", size = 30620, upload-time = "2025-10-02T14:35:14.129Z" },
{ url = "https://files.pythonhosted.org/packages/86/15/9bc32671e9a38b413a76d24722a2bf8784a132c043063a8f5152d390b0f9/xxhash-3.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:757320d45d2fbcce8f30c42a6b2f47862967aea7bf458b9625b4bbe7ee390392", size = 31542, upload-time = "2025-10-02T14:35:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/39/c5/cc01e4f6188656e56112d6a8e0dfe298a16934b8c47a247236549a3f7695/xxhash-3.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:457b8f85dec5825eed7b69c11ae86834a018b8e3df5e77783c999663da2f96d6", size = 27880, upload-time = "2025-10-02T14:35:16.315Z" },
{ url = "https://files.pythonhosted.org/packages/f3/30/25e5321c8732759e930c555176d37e24ab84365482d257c3b16362235212/xxhash-3.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a42e633d75cdad6d625434e3468126c73f13f7584545a9cf34e883aa1710e702", size = 32956, upload-time = "2025-10-02T14:35:17.413Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3c/0573299560d7d9f8ab1838f1efc021a280b5ae5ae2e849034ef3dee18810/xxhash-3.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:568a6d743219e717b07b4e03b0a828ce593833e498c3b64752e0f5df6bfe84db", size = 31072, upload-time = "2025-10-02T14:35:18.844Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1c/52d83a06e417cd9d4137722693424885cc9878249beb3a7c829e74bf7ce9/xxhash-3.6.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bec91b562d8012dae276af8025a55811b875baace6af510412a5e58e3121bc54", size = 196409, upload-time = "2025-10-02T14:35:20.31Z" },
{ url = "https://files.pythonhosted.org/packages/e3/8e/c6d158d12a79bbd0b878f8355432075fc82759e356ab5a111463422a239b/xxhash-3.6.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78e7f2f4c521c30ad5e786fdd6bae89d47a32672a80195467b5de0480aa97b1f", size = 215736, upload-time = "2025-10-02T14:35:21.616Z" },
{ url = "https://files.pythonhosted.org/packages/bc/68/c4c80614716345d55071a396cf03d06e34b5f4917a467faf43083c995155/xxhash-3.6.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ed0df1b11a79856df5ffcab572cbd6b9627034c1c748c5566fa79df9048a7c5", size = 214833, upload-time = "2025-10-02T14:35:23.32Z" },
{ url = "https://files.pythonhosted.org/packages/7e/e9/ae27c8ffec8b953efa84c7c4a6c6802c263d587b9fc0d6e7cea64e08c3af/xxhash-3.6.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0e4edbfc7d420925b0dd5e792478ed393d6e75ff8fc219a6546fb446b6a417b1", size = 448348, upload-time = "2025-10-02T14:35:25.111Z" },
{ url = "https://files.pythonhosted.org/packages/d7/6b/33e21afb1b5b3f46b74b6bd1913639066af218d704cc0941404ca717fc57/xxhash-3.6.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fba27a198363a7ef87f8c0f6b171ec36b674fe9053742c58dd7e3201c1ab30ee", size = 196070, upload-time = "2025-10-02T14:35:26.586Z" },
{ url = "https://files.pythonhosted.org/packages/96/b6/fcabd337bc5fa624e7203aa0fa7d0c49eed22f72e93229431752bddc83d9/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:794fe9145fe60191c6532fa95063765529770edcdd67b3d537793e8004cabbfd", size = 212907, upload-time = "2025-10-02T14:35:28.087Z" },
{ url = "https://files.pythonhosted.org/packages/4b/d3/9ee6160e644d660fcf176c5825e61411c7f62648728f69c79ba237250143/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:6105ef7e62b5ac73a837778efc331a591d8442f8ef5c7e102376506cb4ae2729", size = 200839, upload-time = "2025-10-02T14:35:29.857Z" },
{ url = "https://files.pythonhosted.org/packages/0d/98/e8de5baa5109394baf5118f5e72ab21a86387c4f89b0e77ef3e2f6b0327b/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f01375c0e55395b814a679b3eea205db7919ac2af213f4a6682e01220e5fe292", size = 213304, upload-time = "2025-10-02T14:35:31.222Z" },
{ url = "https://files.pythonhosted.org/packages/7b/1d/71056535dec5c3177eeb53e38e3d367dd1d16e024e63b1cee208d572a033/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d706dca2d24d834a4661619dcacf51a75c16d65985718d6a7d73c1eeeb903ddf", size = 416930, upload-time = "2025-10-02T14:35:32.517Z" },
{ url = "https://files.pythonhosted.org/packages/dc/6c/5cbde9de2cd967c322e651c65c543700b19e7ae3e0aae8ece3469bf9683d/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5f059d9faeacd49c0215d66f4056e1326c80503f51a1532ca336a385edadd033", size = 193787, upload-time = "2025-10-02T14:35:33.827Z" },
{ url = "https://files.pythonhosted.org/packages/19/fa/0172e350361d61febcea941b0cc541d6e6c8d65d153e85f850a7b256ff8a/xxhash-3.6.0-cp313-cp313t-win32.whl", hash = "sha256:1244460adc3a9be84731d72b8e80625788e5815b68da3da8b83f78115a40a7ec", size = 30916, upload-time = "2025-10-02T14:35:35.107Z" },
{ url = "https://files.pythonhosted.org/packages/ad/e6/e8cf858a2b19d6d45820f072eff1bea413910592ff17157cabc5f1227a16/xxhash-3.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b1e420ef35c503869c4064f4a2f2b08ad6431ab7b229a05cce39d74268bca6b8", size = 31799, upload-time = "2025-10-02T14:35:36.165Z" },
{ url = "https://files.pythonhosted.org/packages/56/15/064b197e855bfb7b343210e82490ae672f8bc7cdf3ddb02e92f64304ee8a/xxhash-3.6.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ec44b73a4220623235f67a996c862049f375df3b1052d9899f40a6382c32d746", size = 28044, upload-time = "2025-10-02T14:35:37.195Z" },
{ url = "https://files.pythonhosted.org/packages/7e/5e/0138bc4484ea9b897864d59fce9be9086030825bc778b76cb5a33a906d37/xxhash-3.6.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a40a3d35b204b7cc7643cbcf8c9976d818cb47befcfac8bbefec8038ac363f3e", size = 32754, upload-time = "2025-10-02T14:35:38.245Z" },
{ url = "https://files.pythonhosted.org/packages/18/d7/5dac2eb2ec75fd771957a13e5dda560efb2176d5203f39502a5fc571f899/xxhash-3.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a54844be970d3fc22630b32d515e79a90d0a3ddb2644d8d7402e3c4c8da61405", size = 30846, upload-time = "2025-10-02T14:35:39.6Z" },
{ url = "https://files.pythonhosted.org/packages/fe/71/8bc5be2bb00deb5682e92e8da955ebe5fa982da13a69da5a40a4c8db12fb/xxhash-3.6.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:016e9190af8f0a4e3741343777710e3d5717427f175adfdc3e72508f59e2a7f3", size = 194343, upload-time = "2025-10-02T14:35:40.69Z" },
{ url = "https://files.pythonhosted.org/packages/e7/3b/52badfb2aecec2c377ddf1ae75f55db3ba2d321c5e164f14461c90837ef3/xxhash-3.6.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f6f72232f849eb9d0141e2ebe2677ece15adfd0fa599bc058aad83c714bb2c6", size = 213074, upload-time = "2025-10-02T14:35:42.29Z" },
{ url = "https://files.pythonhosted.org/packages/a2/2b/ae46b4e9b92e537fa30d03dbc19cdae57ed407e9c26d163895e968e3de85/xxhash-3.6.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63275a8aba7865e44b1813d2177e0f5ea7eadad3dd063a21f7cf9afdc7054063", size = 212388, upload-time = "2025-10-02T14:35:43.929Z" },
{ url = "https://files.pythonhosted.org/packages/f5/80/49f88d3afc724b4ac7fbd664c8452d6db51b49915be48c6982659e0e7942/xxhash-3.6.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cd01fa2aa00d8b017c97eb46b9a794fbdca53fc14f845f5a328c71254b0abb7", size = 445614, upload-time = "2025-10-02T14:35:45.216Z" },
{ url = "https://files.pythonhosted.org/packages/ed/ba/603ce3961e339413543d8cd44f21f2c80e2a7c5cfe692a7b1f2cccf58f3c/xxhash-3.6.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0226aa89035b62b6a86d3c68df4d7c1f47a342b8683da2b60cedcddb46c4d95b", size = 194024, upload-time = "2025-10-02T14:35:46.959Z" },
{ url = "https://files.pythonhosted.org/packages/78/d1/8e225ff7113bf81545cfdcd79eef124a7b7064a0bba53605ff39590b95c2/xxhash-3.6.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c6e193e9f56e4ca4923c61238cdaced324f0feac782544eb4c6d55ad5cc99ddd", size = 210541, upload-time = "2025-10-02T14:35:48.301Z" },
{ url = "https://files.pythonhosted.org/packages/6f/58/0f89d149f0bad89def1a8dd38feb50ccdeb643d9797ec84707091d4cb494/xxhash-3.6.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9176dcaddf4ca963d4deb93866d739a343c01c969231dbe21680e13a5d1a5bf0", size = 198305, upload-time = "2025-10-02T14:35:49.584Z" },
{ url = "https://files.pythonhosted.org/packages/11/38/5eab81580703c4df93feb5f32ff8fa7fe1e2c51c1f183ee4e48d4bb9d3d7/xxhash-3.6.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c1ce4009c97a752e682b897aa99aef84191077a9433eb237774689f14f8ec152", size = 210848, upload-time = "2025-10-02T14:35:50.877Z" },
{ url = "https://files.pythonhosted.org/packages/5e/6b/953dc4b05c3ce678abca756416e4c130d2382f877a9c30a20d08ee6a77c0/xxhash-3.6.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8cb2f4f679b01513b7adbb9b1b2f0f9cdc31b70007eaf9d59d0878809f385b11", size = 414142, upload-time = "2025-10-02T14:35:52.15Z" },
{ url = "https://files.pythonhosted.org/packages/08/a9/238ec0d4e81a10eb5026d4a6972677cbc898ba6c8b9dbaec12ae001b1b35/xxhash-3.6.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:653a91d7c2ab54a92c19ccf43508b6a555440b9be1bc8be553376778be7f20b5", size = 191547, upload-time = "2025-10-02T14:35:53.547Z" },
{ url = "https://files.pythonhosted.org/packages/f1/ee/3cf8589e06c2164ac77c3bf0aa127012801128f1feebf2a079272da5737c/xxhash-3.6.0-cp314-cp314-win32.whl", hash = "sha256:a756fe893389483ee8c394d06b5ab765d96e68fbbfe6fde7aa17e11f5720559f", size = 31214, upload-time = "2025-10-02T14:35:54.746Z" },
{ url = "https://files.pythonhosted.org/packages/02/5d/a19552fbc6ad4cb54ff953c3908bbc095f4a921bc569433d791f755186f1/xxhash-3.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:39be8e4e142550ef69629c9cd71b88c90e9a5db703fecbcf265546d9536ca4ad", size = 32290, upload-time = "2025-10-02T14:35:55.791Z" },
{ url = "https://files.pythonhosted.org/packages/b1/11/dafa0643bc30442c887b55baf8e73353a344ee89c1901b5a5c54a6c17d39/xxhash-3.6.0-cp314-cp314-win_arm64.whl", hash = "sha256:25915e6000338999236f1eb68a02a32c3275ac338628a7eaa5a269c401995679", size = 28795, upload-time = "2025-10-02T14:35:57.162Z" },
{ url = "https://files.pythonhosted.org/packages/2c/db/0e99732ed7f64182aef4a6fb145e1a295558deec2a746265dcdec12d191e/xxhash-3.6.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c5294f596a9017ca5a3e3f8884c00b91ab2ad2933cf288f4923c3fd4346cf3d4", size = 32955, upload-time = "2025-10-02T14:35:58.267Z" },
{ url = "https://files.pythonhosted.org/packages/55/f4/2a7c3c68e564a099becfa44bb3d398810cc0ff6749b0d3cb8ccb93f23c14/xxhash-3.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1cf9dcc4ab9cff01dfbba78544297a3a01dafd60f3bde4e2bfd016cf7e4ddc67", size = 31072, upload-time = "2025-10-02T14:35:59.382Z" },
{ url = "https://files.pythonhosted.org/packages/c6/d9/72a29cddc7250e8a5819dad5d466facb5dc4c802ce120645630149127e73/xxhash-3.6.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01262da8798422d0685f7cef03b2bd3f4f46511b02830861df548d7def4402ad", size = 196579, upload-time = "2025-10-02T14:36:00.838Z" },
{ url = "https://files.pythonhosted.org/packages/63/93/b21590e1e381040e2ca305a884d89e1c345b347404f7780f07f2cdd47ef4/xxhash-3.6.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51a73fb7cb3a3ead9f7a8b583ffd9b8038e277cdb8cb87cf890e88b3456afa0b", size = 215854, upload-time = "2025-10-02T14:36:02.207Z" },
{ url = "https://files.pythonhosted.org/packages/ce/b8/edab8a7d4fa14e924b29be877d54155dcbd8b80be85ea00d2be3413a9ed4/xxhash-3.6.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9c6df83594f7df8f7f708ce5ebeacfc69f72c9fbaaababf6cf4758eaada0c9b", size = 214965, upload-time = "2025-10-02T14:36:03.507Z" },
{ url = "https://files.pythonhosted.org/packages/27/67/dfa980ac7f0d509d54ea0d5a486d2bb4b80c3f1bb22b66e6a05d3efaf6c0/xxhash-3.6.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:627f0af069b0ea56f312fd5189001c24578868643203bca1abbc2c52d3a6f3ca", size = 448484, upload-time = "2025-10-02T14:36:04.828Z" },
{ url = "https://files.pythonhosted.org/packages/8c/63/8ffc2cc97e811c0ca5d00ab36604b3ea6f4254f20b7bc658ca825ce6c954/xxhash-3.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa912c62f842dfd013c5f21a642c9c10cd9f4c4e943e0af83618b4a404d9091a", size = 196162, upload-time = "2025-10-02T14:36:06.182Z" },
{ url = "https://files.pythonhosted.org/packages/4b/77/07f0e7a3edd11a6097e990f6e5b815b6592459cb16dae990d967693e6ea9/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b465afd7909db30168ab62afe40b2fcf79eedc0b89a6c0ab3123515dc0df8b99", size = 213007, upload-time = "2025-10-02T14:36:07.733Z" },
{ url = "https://files.pythonhosted.org/packages/ae/d8/bc5fa0d152837117eb0bef6f83f956c509332ce133c91c63ce07ee7c4873/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a881851cf38b0a70e7c4d3ce81fc7afd86fbc2a024f4cfb2a97cf49ce04b75d3", size = 200956, upload-time = "2025-10-02T14:36:09.106Z" },
{ url = "https://files.pythonhosted.org/packages/26/a5/d749334130de9411783873e9b98ecc46688dad5db64ca6e04b02acc8b473/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9b3222c686a919a0f3253cfc12bb118b8b103506612253b5baeaac10d8027cf6", size = 213401, upload-time = "2025-10-02T14:36:10.585Z" },
{ url = "https://files.pythonhosted.org/packages/89/72/abed959c956a4bfc72b58c0384bb7940663c678127538634d896b1195c10/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:c5aa639bc113e9286137cec8fadc20e9cd732b2cc385c0b7fa673b84fc1f2a93", size = 417083, upload-time = "2025-10-02T14:36:12.276Z" },
{ url = "https://files.pythonhosted.org/packages/0c/b3/62fd2b586283b7d7d665fb98e266decadf31f058f1cf6c478741f68af0cb/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c1343d49ac102799905e115aee590183c3921d475356cb24b4de29a4bc56518", size = 193913, upload-time = "2025-10-02T14:36:14.025Z" },
{ url = "https://files.pythonhosted.org/packages/9a/9a/c19c42c5b3f5a4aad748a6d5b4f23df3bed7ee5445accc65a0fb3ff03953/xxhash-3.6.0-cp314-cp314t-win32.whl", hash = "sha256:5851f033c3030dd95c086b4a36a2683c2ff4a799b23af60977188b057e467119", size = 31586, upload-time = "2025-10-02T14:36:15.603Z" },
{ url = "https://files.pythonhosted.org/packages/03/d6/4cc450345be9924fd5dc8c590ceda1db5b43a0a889587b0ae81a95511360/xxhash-3.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0444e7967dac37569052d2409b00a8860c2135cff05502df4da80267d384849f", size = 32526, upload-time = "2025-10-02T14:36:16.708Z" },
{ url = "https://files.pythonhosted.org/packages/0f/c9/7243eb3f9eaabd1a88a5a5acadf06df2d83b100c62684b7425c6a11bcaa8/xxhash-3.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bb79b1e63f6fd84ec778a4b1916dfe0a7c3fdb986c06addd5db3a0d413819d95", size = 28898, upload-time = "2025-10-02T14:36:17.843Z" },
{ url = "https://files.pythonhosted.org/packages/93/1e/8aec23647a34a249f62e2398c42955acd9b4c6ed5cf08cbea94dc46f78d2/xxhash-3.6.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0f7b7e2ec26c1666ad5fc9dbfa426a6a3367ceaf79db5dd76264659d509d73b0", size = 30662, upload-time = "2025-10-02T14:37:01.743Z" },
{ url = "https://files.pythonhosted.org/packages/b8/0b/b14510b38ba91caf43006209db846a696ceea6a847a0c9ba0a5b1adc53d6/xxhash-3.6.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5dc1e14d14fa0f5789ec29a7062004b5933964bb9b02aae6622b8f530dc40296", size = 41056, upload-time = "2025-10-02T14:37:02.879Z" },
{ url = "https://files.pythonhosted.org/packages/50/55/15a7b8a56590e66ccd374bbfa3f9ffc45b810886c8c3b614e3f90bd2367c/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:881b47fc47e051b37d94d13e7455131054b56749b91b508b0907eb07900d1c13", size = 36251, upload-time = "2025-10-02T14:37:04.44Z" },
{ url = "https://files.pythonhosted.org/packages/62/b2/5ac99a041a29e58e95f907876b04f7067a0242cb85b5f39e726153981503/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6dc31591899f5e5666f04cc2e529e69b4072827085c1ef15294d91a004bc1bd", size = 32481, upload-time = "2025-10-02T14:37:05.869Z" },
{ url = "https://files.pythonhosted.org/packages/7b/d9/8d95e906764a386a3d3b596f3c68bb63687dfca806373509f51ce8eea81f/xxhash-3.6.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:15e0dac10eb9309508bfc41f7f9deaa7755c69e35af835db9cb10751adebc35d", size = 31565, upload-time = "2025-10-02T14:37:06.966Z" },
]
[[package]]
name = "zstandard"
version = "0.25.0"
-3
View File
@@ -26,9 +26,6 @@ You must pass these when invoking the graph as part of the configurable part of
`langgraph_checkpoint` also defines protocol for serialization/deserialization (serde) and provides an default implementation (`langgraph.checkpoint.serde.jsonplus.JsonPlusSerializer`) that handles a wide variety of types, including LangChain and LangGraph primitives, datetimes, enums and more.
> [!IMPORTANT]
> **Checkpoint deserialization security:** By default the serializer allows any Python type found in checkpoint data. New applications should set the environment variable `LANGGRAPH_STRICT_MSGPACK=true` or pass an explicit `allowed_msgpack_modules` list to `JsonPlusSerializer` to restrict deserialization to known-safe types.
### Pending writes
When a graph node fails mid-execution at a given superstep, LangGraph stores pending checkpoint writes from any other nodes that completed successfully at that superstep, so that whenever we resume graph execution from that superstep we don't re-run the successful nodes.
@@ -1,10 +1,3 @@
"""Msgpack deserialization safety controls.
Set ``LANGGRAPH_STRICT_MSGPACK=true`` to restrict checkpoint deserialization
to the types listed in ``SAFE_MSGPACK_TYPES``. Without this, any Python
callable stored in checkpoint data will be imported and executed on load.
"""
import os
from collections.abc import Iterable
from typing import cast
@@ -46,36 +46,6 @@ LC_REVIVER = Reviver()
EMPTY_BYTES = b""
logger = logging.getLogger(__name__)
# Dedup log warnings across process lifetime; cap bounds state if types are
# dynamically generated (also acts as a circuit breaker on warning volume).
# Dedup is best-effort: racing threads may each emit once for the same key,
# and warnings are silently dropped once _MAX_WARNED_TYPES is reached.
_MAX_WARNED_TYPES = 1000
_warned_unregistered_types: set[tuple[str, str]] = set()
_warned_blocked_types: set[tuple[str, str]] = set()
def _is_safe_json_type(id_list: list[str]) -> bool:
"""Return True if an lc=2 id refers to a type in SAFE_MSGPACK_TYPES.
Safe types bypass the ``allowed_json_modules`` gate so that old "json" format
checkpoints (written before the msgpack migration) can be resumed without
requiring users to configure an explicit allowlist.
"""
if len(id_list) < 2:
return False
module_name = ".".join(id_list[:-1])
return (module_name, id_list[-1]) in _lg_msgpack.SAFE_MSGPACK_TYPES
def _warn_once(
seen: set[tuple[str, str]], key: tuple[str, str], msg: str, *args: object
) -> None:
if key in seen or len(seen) >= _MAX_WARNED_TYPES:
return
seen.add(key)
logger.warning(msg, *args)
class JsonPlusSerializer(SerializerProtocol):
"""Serializer that uses ormsgpack, with optional fallbacks.
@@ -86,10 +56,6 @@ class JsonPlusSerializer(SerializerProtocol):
class and called within the Pregel loop. It should not be used on untrusted
python objects. If an attacker can write directly to your checkpoint database,
they may be able to trigger code execution when data is deserialized.
Set the environment variable ``LANGGRAPH_STRICT_MSGPACK=true`` to restrict
deserialization to a built-in allowlist of safe types. You can also pass
an explicit ``allowed_msgpack_modules`` to the constructor.
"""
def __init__(
@@ -104,11 +70,8 @@ class JsonPlusSerializer(SerializerProtocol):
) -> None:
if allowed_msgpack_modules is _lg_msgpack._SENTINEL:
if _lg_msgpack.STRICT_MSGPACK_ENABLED:
# Strict: only SAFE_MSGPACK_TYPES are allowed.
allowed_msgpack_modules = None
else:
# Permissive (default): all types allowed with a warning.
# Set LANGGRAPH_STRICT_MSGPACK=true to lock this down.
allowed_msgpack_modules = True
self.pickle_fallback = pickle_fallback
self._allowed_json_modules: set[tuple[str, ...]] | Literal[True] | None = (
@@ -177,23 +140,19 @@ class JsonPlusSerializer(SerializerProtocol):
return out
def _reviver(self, value: dict[str, Any]) -> Any:
if (
if self._allowed_json_modules and (
value.get("lc", None) == 2
and value.get("type", None) == "constructor"
and value.get("id", None) is not None
):
id_list = value["id"]
is_safe = _is_safe_json_type(id_list)
if self._allowed_json_modules or is_safe:
try:
return self._revive_lc2(value)
except InvalidModuleError as e:
if not is_safe:
logger.warning(
"Object %s is not in the deserialization allowlist.\n%s",
value["id"],
e.message,
)
try:
return self._revive_lc2(value)
except InvalidModuleError as e:
logger.warning(
"Object %s is not in the deserialization allowlist.\n%s",
value["id"],
e.message,
)
return LC_REVIVER(value)
@@ -241,13 +200,6 @@ class JsonPlusSerializer(SerializerProtocol):
method_display = "<init>"
dotted = ".".join(needed)
# Safe types (the same set already allowed for msgpack deserialization) are
# permitted without an explicit allowlist — they are known-safe LangGraph and
# LangChain types. This restores backwards-compat for old "json" checkpoints
# that pre-date the msgpack migration without reopening the broader security gate.
if _is_safe_json_type(list(needed)):
return
if not self._allowed_json_modules:
raise InvalidModuleError(
f"Refused to deserialize JSON constructor: {dotted} (method: {method_display}). "
@@ -575,13 +527,10 @@ def _create_msgpack_ext_hook(
"name": name,
}
)
_warn_once(
_warned_unregistered_types,
key,
logger.warning(
"Deserializing unregistered type %s.%s from checkpoint. "
"This will be blocked in a future version. "
"Set LANGGRAPH_STRICT_MSGPACK=true to block now, or add "
"to allowed_msgpack_modules to allow explicitly: [(%r, %r)]",
"Add to allowed_msgpack_modules to silence: [(%r, %r)]",
module,
name,
module,
@@ -599,9 +548,7 @@ def _create_msgpack_ext_hook(
"name": name,
}
)
_warn_once(
_warned_blocked_types,
key,
logger.warning(
"Blocked deserialization of %s.%s - not in allowed_msgpack_modules. "
"Add to allowed_msgpack_modules to allow: [(%r, %r)]",
module,
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph-checkpoint"
version = "4.0.3"
version = "4.0.1"
description = "Library with base interfaces for LangGraph checkpoint savers."
authors = []
requires-python = ">=3.10"
-9
View File
@@ -29,8 +29,6 @@ from langgraph.checkpoint.serde.jsonplus import (
EXT_METHOD_SINGLE_ARG,
JsonPlusSerializer,
_msgpack_enc,
_warned_blocked_types,
_warned_unregistered_types,
)
@@ -104,13 +102,6 @@ def test_msgpack_method_pathlib_blocked_encrypted_strict(
class TestEncryptedSerializerMsgpackAllowlist:
"""Test msgpack allowlist behavior through EncryptedSerializer."""
@pytest.fixture(autouse=True)
def _reset_warned_types(self) -> None:
# Warning dedup state is process-global; reset per-test so each case
# sees a fresh slate and assertions about warning emission are stable.
_warned_unregistered_types.clear()
_warned_blocked_types.clear()
def test_safe_types_no_warning(self, caplog: pytest.LogCaptureFixture) -> None:
"""Test safe types deserialize without warnings through encryption."""
serde = _make_encrypted_serde()
+2 -67
View File
@@ -35,8 +35,6 @@ from langgraph.checkpoint.serde.jsonplus import (
JsonPlusSerializer,
_msgpack_enc,
_msgpack_ext_hook_to_json,
_warned_blocked_types,
_warned_unregistered_types,
)
from langgraph.store.base import Item
@@ -333,57 +331,6 @@ def test_serde_jsonplus_bytes() -> None:
assert serde.loads_typed(dumped) == some_bytes
def test_lc2_json_safe_type_revives_without_allowlist() -> None:
"""Old 'json' blobs with lc=2 for safe types must revive without an explicit allowlist.
Regression test for: https://github.com/langchain-ai/langgraph/issues/7498
Threads checkpointed before v1.0.1 (pre-msgpack) stored messages as lc=2 JSON
constructor dicts. Resuming those threads must reconstruct proper BaseMessage objects
rather than returning raw dicts that cause MESSAGE_COERCION_FAILURE in add_messages.
"""
from langchain_core.messages import AIMessage
serde = JsonPlusSerializer() # default: _allowed_json_modules=None
human_blob = {
"lc": 2,
"type": "constructor",
"id": ["langchain_core", "messages", "human", "HumanMessage"],
"kwargs": {"content": "hello", "type": "human"},
}
ai_blob = {
"lc": 2,
"type": "constructor",
"id": ["langchain_core", "messages", "ai", "AIMessage"],
"kwargs": {"content": "hi there", "type": "ai"},
}
result = serde.loads_typed(("json", json.dumps([human_blob, ai_blob]).encode()))
assert len(result) == 2
assert isinstance(result[0], HumanMessage), (
f"Expected HumanMessage, got {type(result[0])}: {result[0]!r}\n"
"lc=2 JSON blobs for safe types must deserialize without an explicit allowlist"
)
assert result[0].content == "hello"
assert isinstance(result[1], AIMessage)
assert result[1].content == "hi there"
def test_lc2_json_unknown_type_stays_blocked_without_allowlist() -> None:
"""lc=2 JSON blobs for types NOT in SAFE_MSGPACK_TYPES still require an allowlist."""
serde = JsonPlusSerializer()
load = {
"lc": 2,
"type": "constructor",
"id": ["pprint", "pprint"],
"kwargs": {"object": "HELLO"},
}
# No allowlist configured → raw dict returned (not raised, not reconstructed)
result = serde.loads_typed(("json", json.dumps(load).encode()))
assert isinstance(result, dict), "Unknown lc=2 type must stay as raw dict"
assert result.get("lc") == 2
def test_deserde_invalid_module() -> None:
serde = JsonPlusSerializer()
load = {
@@ -633,14 +580,6 @@ def test_msgpack_safe_types_no_warning(caplog: pytest.LogCaptureFixture) -> None
assert result is not None
@pytest.fixture(autouse=True)
def _reset_warned_types() -> None:
# Warning dedup state is process-global; reset per-test so each case sees
# a fresh slate and assertions about warning emission are stable.
_warned_unregistered_types.clear()
_warned_blocked_types.clear()
def test_msgpack_pydantic_warns_by_default(caplog: pytest.LogCaptureFixture) -> None:
"""Pydantic models not in allowlist should log warning but still deserialize."""
current = _lg_msgpack.STRICT_MSGPACK_ENABLED
@@ -656,12 +595,6 @@ def test_msgpack_pydantic_warns_by_default(caplog: pytest.LogCaptureFixture) ->
assert "unregistered type" in caplog.text.lower()
assert "allowed_msgpack_modules" in caplog.text
assert result == obj
# Second deserialization of the same type should NOT produce another warning
caplog.clear()
result2 = serde.loads_typed(dumped)
assert "unregistered type" not in caplog.text.lower()
assert result2 == obj
_lg_msgpack.STRICT_MSGPACK_ENABLED = current
@@ -706,6 +639,7 @@ def test_msgpack_allowlist_silences_warning(caplog: pytest.LogCaptureFixture) ->
def test_msgpack_none_blocks_unregistered(caplog: pytest.LogCaptureFixture) -> None:
"""allowed_msgpack_modules=None should block unregistered types."""
serde = JsonPlusSerializer(allowed_msgpack_modules=None)
obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world"))
@@ -723,6 +657,7 @@ def test_msgpack_allowlist_blocks_non_listed(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Allowlists should block unregistered types even if msgpack is enabled."""
serde = JsonPlusSerializer(
allowed_msgpack_modules=[("tests.test_jsonplus", "MyPydantic")]
)
+1 -13
View File
@@ -12,25 +12,13 @@ from langgraph.checkpoint.base import (
empty_checkpoint,
)
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.checkpoint.serde.jsonplus import (
JsonPlusSerializer,
_warned_blocked_types,
_warned_unregistered_types,
)
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
class MemoryPydantic(BaseModel):
foo: str
@pytest.fixture(autouse=True)
def _reset_warned_types() -> None:
# Warning dedup state is process-global; reset per-test so each case sees
# a fresh slate and assertions about warning emission are stable.
_warned_unregistered_types.clear()
_warned_blocked_types.clear()
class TestMemorySaver:
@pytest.fixture(autouse=True)
def setup(self) -> None:
+7 -126
View File
@@ -286,7 +286,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
version = "4.0.3"
version = "4.0.1"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
@@ -369,7 +369,7 @@ test = [
[[package]]
name = "langsmith"
version = "0.7.31"
version = "0.6.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
@@ -379,12 +379,11 @@ dependencies = [
{ name = "requests" },
{ name = "requests-toolbelt" },
{ name = "uuid-utils" },
{ name = "xxhash" },
{ name = "zstandard" },
]
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" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/85/9c7933052a997da1b85bc5c774f3865e9b1da1c8d71541ea133178b13229/langsmith-0.6.4.tar.gz", hash = "sha256:36f7223a01c218079fbb17da5e536ebbaf5c1468c028abe070aa3ae59bc99ec8", size = 919964, upload-time = "2026-01-15T20:02:28.873Z" }
wheels = [
{ 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" },
{ url = "https://files.pythonhosted.org/packages/66/0f/09a6637a7ba777eb307b7c80852d9ee26438e2bdafbad6fcc849ff9d9192/langsmith-0.6.4-py3-none-any.whl", hash = "sha256:ac4835860160be371042c7adbba3cb267bcf8d96a5ea976c33a8a4acad6c5486", size = 283503, upload-time = "2026-01-15T20:02:26.662Z" },
]
[[package]]
@@ -1118,7 +1117,7 @@ wheels = [
[[package]]
name = "pytest"
version = "9.0.3"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -1129,9 +1128,9 @@ dependencies = [
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
@@ -1516,124 +1515,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
]
[[package]]
name = "xxhash"
version = "3.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/02/84/30869e01909fb37a6cc7e18688ee8bf1e42d57e7e0777636bd47524c43c7/xxhash-3.6.0.tar.gz", hash = "sha256:f0162a78b13a0d7617b2845b90c763339d1f1d82bb04a4b07f4ab535cc5e05d6", size = 85160, upload-time = "2025-10-02T14:37:08.097Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/34/ee/f9f1d656ad168681bb0f6b092372c1e533c4416b8069b1896a175c46e484/xxhash-3.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:87ff03d7e35c61435976554477a7f4cd1704c3596a89a8300d5ce7fc83874a71", size = 32845, upload-time = "2025-10-02T14:33:51.573Z" },
{ url = "https://files.pythonhosted.org/packages/a3/b1/93508d9460b292c74a09b83d16750c52a0ead89c51eea9951cb97a60d959/xxhash-3.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f572dfd3d0e2eb1a57511831cf6341242f5a9f8298a45862d085f5b93394a27d", size = 30807, upload-time = "2025-10-02T14:33:52.964Z" },
{ url = "https://files.pythonhosted.org/packages/07/55/28c93a3662f2d200c70704efe74aab9640e824f8ce330d8d3943bf7c9b3c/xxhash-3.6.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:89952ea539566b9fed2bbd94e589672794b4286f342254fad28b149f9615fef8", size = 193786, upload-time = "2025-10-02T14:33:54.272Z" },
{ url = "https://files.pythonhosted.org/packages/c1/96/fec0be9bb4b8f5d9c57d76380a366f31a1781fb802f76fc7cda6c84893c7/xxhash-3.6.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e6f2ffb07a50b52465a1032c3cf1f4a5683f944acaca8a134a2f23674c2058", size = 212830, upload-time = "2025-10-02T14:33:55.706Z" },
{ url = "https://files.pythonhosted.org/packages/c4/a0/c706845ba77b9611f81fd2e93fad9859346b026e8445e76f8c6fd057cc6d/xxhash-3.6.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b5b848ad6c16d308c3ac7ad4ba6bede80ed5df2ba8ed382f8932df63158dd4b2", size = 211606, upload-time = "2025-10-02T14:33:57.133Z" },
{ url = "https://files.pythonhosted.org/packages/67/1e/164126a2999e5045f04a69257eea946c0dc3e86541b400d4385d646b53d7/xxhash-3.6.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a034590a727b44dd8ac5914236a7b8504144447a9682586c3327e935f33ec8cc", size = 444872, upload-time = "2025-10-02T14:33:58.446Z" },
{ url = "https://files.pythonhosted.org/packages/2d/4b/55ab404c56cd70a2cf5ecfe484838865d0fea5627365c6c8ca156bd09c8f/xxhash-3.6.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a8f1972e75ebdd161d7896743122834fe87378160c20e97f8b09166213bf8cc", size = 193217, upload-time = "2025-10-02T14:33:59.724Z" },
{ url = "https://files.pythonhosted.org/packages/45/e6/52abf06bac316db33aa269091ae7311bd53cfc6f4b120ae77bac1b348091/xxhash-3.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ee34327b187f002a596d7b167ebc59a1b729e963ce645964bbc050d2f1b73d07", size = 210139, upload-time = "2025-10-02T14:34:02.041Z" },
{ url = "https://files.pythonhosted.org/packages/34/37/db94d490b8691236d356bc249c08819cbcef9273a1a30acf1254ff9ce157/xxhash-3.6.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:339f518c3c7a850dd033ab416ea25a692759dc7478a71131fe8869010d2b75e4", size = 197669, upload-time = "2025-10-02T14:34:03.664Z" },
{ url = "https://files.pythonhosted.org/packages/b7/36/c4f219ef4a17a4f7a64ed3569bc2b5a9c8311abdb22249ac96093625b1a4/xxhash-3.6.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:bf48889c9630542d4709192578aebbd836177c9f7a4a2778a7d6340107c65f06", size = 210018, upload-time = "2025-10-02T14:34:05.325Z" },
{ url = "https://files.pythonhosted.org/packages/fd/06/bfac889a374fc2fc439a69223d1750eed2e18a7db8514737ab630534fa08/xxhash-3.6.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:5576b002a56207f640636056b4160a378fe36a58db73ae5c27a7ec8db35f71d4", size = 413058, upload-time = "2025-10-02T14:34:06.925Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d1/555d8447e0dd32ad0930a249a522bb2e289f0d08b6b16204cfa42c1f5a0c/xxhash-3.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af1f3278bd02814d6dedc5dec397993b549d6f16c19379721e5a1d31e132c49b", size = 190628, upload-time = "2025-10-02T14:34:08.669Z" },
{ url = "https://files.pythonhosted.org/packages/d1/15/8751330b5186cedc4ed4b597989882ea05e0408b53fa47bcb46a6125bfc6/xxhash-3.6.0-cp310-cp310-win32.whl", hash = "sha256:aed058764db109dc9052720da65fafe84873b05eb8b07e5e653597951af57c3b", size = 30577, upload-time = "2025-10-02T14:34:10.234Z" },
{ url = "https://files.pythonhosted.org/packages/bb/cc/53f87e8b5871a6eb2ff7e89c48c66093bda2be52315a8161ddc54ea550c4/xxhash-3.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:e82da5670f2d0d98950317f82a0e4a0197150ff19a6df2ba40399c2a3b9ae5fb", size = 31487, upload-time = "2025-10-02T14:34:11.618Z" },
{ url = "https://files.pythonhosted.org/packages/9f/00/60f9ea3bb697667a14314d7269956f58bf56bb73864f8f8d52a3c2535e9a/xxhash-3.6.0-cp310-cp310-win_arm64.whl", hash = "sha256:4a082ffff8c6ac07707fb6b671caf7c6e020c75226c561830b73d862060f281d", size = 27863, upload-time = "2025-10-02T14:34:12.619Z" },
{ url = "https://files.pythonhosted.org/packages/17/d4/cc2f0400e9154df4b9964249da78ebd72f318e35ccc425e9f403c392f22a/xxhash-3.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b47bbd8cf2d72797f3c2772eaaac0ded3d3af26481a26d7d7d41dc2d3c46b04a", size = 32844, upload-time = "2025-10-02T14:34:14.037Z" },
{ url = "https://files.pythonhosted.org/packages/5e/ec/1cc11cd13e26ea8bc3cb4af4eaadd8d46d5014aebb67be3f71fb0b68802a/xxhash-3.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2b6821e94346f96db75abaa6e255706fb06ebd530899ed76d32cd99f20dc52fa", size = 30809, upload-time = "2025-10-02T14:34:15.484Z" },
{ url = "https://files.pythonhosted.org/packages/04/5f/19fe357ea348d98ca22f456f75a30ac0916b51c753e1f8b2e0e6fb884cce/xxhash-3.6.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d0a9751f71a1a65ce3584e9cae4467651c7e70c9d31017fa57574583a4540248", size = 194665, upload-time = "2025-10-02T14:34:16.541Z" },
{ url = "https://files.pythonhosted.org/packages/90/3b/d1f1a8f5442a5fd8beedae110c5af7604dc37349a8e16519c13c19a9a2de/xxhash-3.6.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b29ee68625ab37b04c0b40c3fafdf24d2f75ccd778333cfb698f65f6c463f62", size = 213550, upload-time = "2025-10-02T14:34:17.878Z" },
{ url = "https://files.pythonhosted.org/packages/c4/ef/3a9b05eb527457d5db13a135a2ae1a26c80fecd624d20f3e8dcc4cb170f3/xxhash-3.6.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6812c25fe0d6c36a46ccb002f40f27ac903bf18af9f6dd8f9669cb4d176ab18f", size = 212384, upload-time = "2025-10-02T14:34:19.182Z" },
{ url = "https://files.pythonhosted.org/packages/0f/18/ccc194ee698c6c623acbf0f8c2969811a8a4b6185af5e824cd27b9e4fd3e/xxhash-3.6.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4ccbff013972390b51a18ef1255ef5ac125c92dc9143b2d1909f59abc765540e", size = 445749, upload-time = "2025-10-02T14:34:20.659Z" },
{ url = "https://files.pythonhosted.org/packages/a5/86/cf2c0321dc3940a7aa73076f4fd677a0fb3e405cb297ead7d864fd90847e/xxhash-3.6.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:297b7fbf86c82c550e12e8fb71968b3f033d27b874276ba3624ea868c11165a8", size = 193880, upload-time = "2025-10-02T14:34:22.431Z" },
{ url = "https://files.pythonhosted.org/packages/82/fb/96213c8560e6f948a1ecc9a7613f8032b19ee45f747f4fca4eb31bb6d6ed/xxhash-3.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dea26ae1eb293db089798d3973a5fc928a18fdd97cc8801226fae705b02b14b0", size = 210912, upload-time = "2025-10-02T14:34:23.937Z" },
{ url = "https://files.pythonhosted.org/packages/40/aa/4395e669b0606a096d6788f40dbdf2b819d6773aa290c19e6e83cbfc312f/xxhash-3.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7a0b169aafb98f4284f73635a8e93f0735f9cbde17bd5ec332480484241aaa77", size = 198654, upload-time = "2025-10-02T14:34:25.644Z" },
{ url = "https://files.pythonhosted.org/packages/67/74/b044fcd6b3d89e9b1b665924d85d3f400636c23590226feb1eb09e1176ce/xxhash-3.6.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:08d45aef063a4531b785cd72de4887766d01dc8f362a515693df349fdb825e0c", size = 210867, upload-time = "2025-10-02T14:34:27.203Z" },
{ url = "https://files.pythonhosted.org/packages/bc/fd/3ce73bf753b08cb19daee1eb14aa0d7fe331f8da9c02dd95316ddfe5275e/xxhash-3.6.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:929142361a48ee07f09121fe9e96a84950e8d4df3bb298ca5d88061969f34d7b", size = 414012, upload-time = "2025-10-02T14:34:28.409Z" },
{ url = "https://files.pythonhosted.org/packages/ba/b3/5a4241309217c5c876f156b10778f3ab3af7ba7e3259e6d5f5c7d0129eb2/xxhash-3.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:51312c768403d8540487dbbfb557454cfc55589bbde6424456951f7fcd4facb3", size = 191409, upload-time = "2025-10-02T14:34:29.696Z" },
{ url = "https://files.pythonhosted.org/packages/c0/01/99bfbc15fb9abb9a72b088c1d95219fc4782b7d01fc835bd5744d66dd0b8/xxhash-3.6.0-cp311-cp311-win32.whl", hash = "sha256:d1927a69feddc24c987b337ce81ac15c4720955b667fe9b588e02254b80446fd", size = 30574, upload-time = "2025-10-02T14:34:31.028Z" },
{ url = "https://files.pythonhosted.org/packages/65/79/9d24d7f53819fe301b231044ea362ce64e86c74f6e8c8e51320de248b3e5/xxhash-3.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:26734cdc2d4ffe449b41d186bbeac416f704a482ed835d375a5c0cb02bc63fef", size = 31481, upload-time = "2025-10-02T14:34:32.062Z" },
{ url = "https://files.pythonhosted.org/packages/30/4e/15cd0e3e8772071344eab2961ce83f6e485111fed8beb491a3f1ce100270/xxhash-3.6.0-cp311-cp311-win_arm64.whl", hash = "sha256:d72f67ef8bf36e05f5b6c65e8524f265bd61071471cd4cf1d36743ebeeeb06b7", size = 27861, upload-time = "2025-10-02T14:34:33.555Z" },
{ url = "https://files.pythonhosted.org/packages/9a/07/d9412f3d7d462347e4511181dea65e47e0d0e16e26fbee2ea86a2aefb657/xxhash-3.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01362c4331775398e7bb34e3ab403bc9ee9f7c497bc7dee6272114055277dd3c", size = 32744, upload-time = "2025-10-02T14:34:34.622Z" },
{ url = "https://files.pythonhosted.org/packages/79/35/0429ee11d035fc33abe32dca1b2b69e8c18d236547b9a9b72c1929189b9a/xxhash-3.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b7b2df81a23f8cb99656378e72501b2cb41b1827c0f5a86f87d6b06b69f9f204", size = 30816, upload-time = "2025-10-02T14:34:36.043Z" },
{ url = "https://files.pythonhosted.org/packages/b7/f2/57eb99aa0f7d98624c0932c5b9a170e1806406cdbcdb510546634a1359e0/xxhash-3.6.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dc94790144e66b14f67b10ac8ed75b39ca47536bf8800eb7c24b50271ea0c490", size = 194035, upload-time = "2025-10-02T14:34:37.354Z" },
{ url = "https://files.pythonhosted.org/packages/4c/ed/6224ba353690d73af7a3f1c7cdb1fc1b002e38f783cb991ae338e1eb3d79/xxhash-3.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93f107c673bccf0d592cdba077dedaf52fe7f42dcd7676eba1f6d6f0c3efffd2", size = 212914, upload-time = "2025-10-02T14:34:38.6Z" },
{ url = "https://files.pythonhosted.org/packages/38/86/fb6b6130d8dd6b8942cc17ab4d90e223653a89aa32ad2776f8af7064ed13/xxhash-3.6.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aa5ee3444c25b69813663c9f8067dcfaa2e126dc55e8dddf40f4d1c25d7effa", size = 212163, upload-time = "2025-10-02T14:34:39.872Z" },
{ url = "https://files.pythonhosted.org/packages/ee/dc/e84875682b0593e884ad73b2d40767b5790d417bde603cceb6878901d647/xxhash-3.6.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7f99123f0e1194fa59cc69ad46dbae2e07becec5df50a0509a808f90a0f03f0", size = 445411, upload-time = "2025-10-02T14:34:41.569Z" },
{ url = "https://files.pythonhosted.org/packages/11/4f/426f91b96701ec2f37bb2b8cec664eff4f658a11f3fa9d94f0a887ea6d2b/xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49e03e6fe2cac4a1bc64952dd250cf0dbc5ef4ebb7b8d96bce82e2de163c82a2", size = 193883, upload-time = "2025-10-02T14:34:43.249Z" },
{ url = "https://files.pythonhosted.org/packages/53/5a/ddbb83eee8e28b778eacfc5a85c969673e4023cdeedcfcef61f36731610b/xxhash-3.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd17fede52a17a4f9a7bc4472a5867cb0b160deeb431795c0e4abe158bc784e9", size = 210392, upload-time = "2025-10-02T14:34:45.042Z" },
{ url = "https://files.pythonhosted.org/packages/1e/c2/ff69efd07c8c074ccdf0a4f36fcdd3d27363665bcdf4ba399abebe643465/xxhash-3.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6fb5f5476bef678f69db04f2bd1efbed3030d2aba305b0fc1773645f187d6a4e", size = 197898, upload-time = "2025-10-02T14:34:46.302Z" },
{ url = "https://files.pythonhosted.org/packages/58/ca/faa05ac19b3b622c7c9317ac3e23954187516298a091eb02c976d0d3dd45/xxhash-3.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:843b52f6d88071f87eba1631b684fcb4b2068cd2180a0224122fe4ef011a9374", size = 210655, upload-time = "2025-10-02T14:34:47.571Z" },
{ url = "https://files.pythonhosted.org/packages/d4/7a/06aa7482345480cc0cb597f5c875b11a82c3953f534394f620b0be2f700c/xxhash-3.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7d14a6cfaf03b1b6f5f9790f76880601ccc7896aff7ab9cd8978a939c1eb7e0d", size = 414001, upload-time = "2025-10-02T14:34:49.273Z" },
{ url = "https://files.pythonhosted.org/packages/23/07/63ffb386cd47029aa2916b3d2f454e6cc5b9f5c5ada3790377d5430084e7/xxhash-3.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:418daf3db71e1413cfe211c2f9a528456936645c17f46b5204705581a45390ae", size = 191431, upload-time = "2025-10-02T14:34:50.798Z" },
{ url = "https://files.pythonhosted.org/packages/0f/93/14fde614cadb4ddf5e7cebf8918b7e8fac5ae7861c1875964f17e678205c/xxhash-3.6.0-cp312-cp312-win32.whl", hash = "sha256:50fc255f39428a27299c20e280d6193d8b63b8ef8028995323bf834a026b4fbb", size = 30617, upload-time = "2025-10-02T14:34:51.954Z" },
{ url = "https://files.pythonhosted.org/packages/13/5d/0d125536cbe7565a83d06e43783389ecae0c0f2ed037b48ede185de477c0/xxhash-3.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:c0f2ab8c715630565ab8991b536ecded9416d615538be8ecddce43ccf26cbc7c", size = 31534, upload-time = "2025-10-02T14:34:53.276Z" },
{ url = "https://files.pythonhosted.org/packages/54/85/6ec269b0952ec7e36ba019125982cf11d91256a778c7c3f98a4c5043d283/xxhash-3.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:eae5c13f3bc455a3bbb68bdc513912dc7356de7e2280363ea235f71f54064829", size = 27876, upload-time = "2025-10-02T14:34:54.371Z" },
{ url = "https://files.pythonhosted.org/packages/33/76/35d05267ac82f53ae9b0e554da7c5e281ee61f3cad44c743f0fcd354f211/xxhash-3.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:599e64ba7f67472481ceb6ee80fa3bd828fd61ba59fb11475572cc5ee52b89ec", size = 32738, upload-time = "2025-10-02T14:34:55.839Z" },
{ url = "https://files.pythonhosted.org/packages/31/a8/3fbce1cd96534a95e35d5120637bf29b0d7f5d8fa2f6374e31b4156dd419/xxhash-3.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d8b8aaa30fca4f16f0c84a5c8d7ddee0e25250ec2796c973775373257dde8f1", size = 30821, upload-time = "2025-10-02T14:34:57.219Z" },
{ url = "https://files.pythonhosted.org/packages/0c/ea/d387530ca7ecfa183cb358027f1833297c6ac6098223fd14f9782cd0015c/xxhash-3.6.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d597acf8506d6e7101a4a44a5e428977a51c0fadbbfd3c39650cca9253f6e5a6", size = 194127, upload-time = "2025-10-02T14:34:59.21Z" },
{ url = "https://files.pythonhosted.org/packages/ba/0c/71435dcb99874b09a43b8d7c54071e600a7481e42b3e3ce1eb5226a5711a/xxhash-3.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:858dc935963a33bc33490128edc1c12b0c14d9c7ebaa4e387a7869ecc4f3e263", size = 212975, upload-time = "2025-10-02T14:35:00.816Z" },
{ url = "https://files.pythonhosted.org/packages/84/7a/c2b3d071e4bb4a90b7057228a99b10d51744878f4a8a6dd643c8bd897620/xxhash-3.6.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba284920194615cb8edf73bf52236ce2e1664ccd4a38fdb543506413529cc546", size = 212241, upload-time = "2025-10-02T14:35:02.207Z" },
{ url = "https://files.pythonhosted.org/packages/81/5f/640b6eac0128e215f177df99eadcd0f1b7c42c274ab6a394a05059694c5a/xxhash-3.6.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b54219177f6c6674d5378bd862c6aedf64725f70dd29c472eaae154df1a2e89", size = 445471, upload-time = "2025-10-02T14:35:03.61Z" },
{ url = "https://files.pythonhosted.org/packages/5e/1e/3c3d3ef071b051cc3abbe3721ffb8365033a172613c04af2da89d5548a87/xxhash-3.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42c36dd7dbad2f5238950c377fcbf6811b1cdb1c444fab447960030cea60504d", size = 193936, upload-time = "2025-10-02T14:35:05.013Z" },
{ url = "https://files.pythonhosted.org/packages/2c/bd/4a5f68381939219abfe1c22a9e3a5854a4f6f6f3c4983a87d255f21f2e5d/xxhash-3.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f22927652cba98c44639ffdc7aaf35828dccf679b10b31c4ad72a5b530a18eb7", size = 210440, upload-time = "2025-10-02T14:35:06.239Z" },
{ url = "https://files.pythonhosted.org/packages/eb/37/b80fe3d5cfb9faff01a02121a0f4d565eb7237e9e5fc66e73017e74dcd36/xxhash-3.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b45fad44d9c5c119e9c6fbf2e1c656a46dc68e280275007bbfd3d572b21426db", size = 197990, upload-time = "2025-10-02T14:35:07.735Z" },
{ url = "https://files.pythonhosted.org/packages/d7/fd/2c0a00c97b9e18f72e1f240ad4e8f8a90fd9d408289ba9c7c495ed7dc05c/xxhash-3.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6f2580ffab1a8b68ef2b901cde7e55fa8da5e4be0977c68f78fc80f3c143de42", size = 210689, upload-time = "2025-10-02T14:35:09.438Z" },
{ url = "https://files.pythonhosted.org/packages/93/86/5dd8076a926b9a95db3206aba20d89a7fc14dd5aac16e5c4de4b56033140/xxhash-3.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40c391dd3cd041ebc3ffe6f2c862f402e306eb571422e0aa918d8070ba31da11", size = 414068, upload-time = "2025-10-02T14:35:11.162Z" },
{ url = "https://files.pythonhosted.org/packages/af/3c/0bb129170ee8f3650f08e993baee550a09593462a5cddd8e44d0011102b1/xxhash-3.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f205badabde7aafd1a31e8ca2a3e5a763107a71c397c4481d6a804eb5063d8bd", size = 191495, upload-time = "2025-10-02T14:35:12.971Z" },
{ url = "https://files.pythonhosted.org/packages/e9/3a/6797e0114c21d1725e2577508e24006fd7ff1d8c0c502d3b52e45c1771d8/xxhash-3.6.0-cp313-cp313-win32.whl", hash = "sha256:2577b276e060b73b73a53042ea5bd5203d3e6347ce0d09f98500f418a9fcf799", size = 30620, upload-time = "2025-10-02T14:35:14.129Z" },
{ url = "https://files.pythonhosted.org/packages/86/15/9bc32671e9a38b413a76d24722a2bf8784a132c043063a8f5152d390b0f9/xxhash-3.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:757320d45d2fbcce8f30c42a6b2f47862967aea7bf458b9625b4bbe7ee390392", size = 31542, upload-time = "2025-10-02T14:35:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/39/c5/cc01e4f6188656e56112d6a8e0dfe298a16934b8c47a247236549a3f7695/xxhash-3.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:457b8f85dec5825eed7b69c11ae86834a018b8e3df5e77783c999663da2f96d6", size = 27880, upload-time = "2025-10-02T14:35:16.315Z" },
{ url = "https://files.pythonhosted.org/packages/f3/30/25e5321c8732759e930c555176d37e24ab84365482d257c3b16362235212/xxhash-3.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a42e633d75cdad6d625434e3468126c73f13f7584545a9cf34e883aa1710e702", size = 32956, upload-time = "2025-10-02T14:35:17.413Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3c/0573299560d7d9f8ab1838f1efc021a280b5ae5ae2e849034ef3dee18810/xxhash-3.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:568a6d743219e717b07b4e03b0a828ce593833e498c3b64752e0f5df6bfe84db", size = 31072, upload-time = "2025-10-02T14:35:18.844Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1c/52d83a06e417cd9d4137722693424885cc9878249beb3a7c829e74bf7ce9/xxhash-3.6.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bec91b562d8012dae276af8025a55811b875baace6af510412a5e58e3121bc54", size = 196409, upload-time = "2025-10-02T14:35:20.31Z" },
{ url = "https://files.pythonhosted.org/packages/e3/8e/c6d158d12a79bbd0b878f8355432075fc82759e356ab5a111463422a239b/xxhash-3.6.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78e7f2f4c521c30ad5e786fdd6bae89d47a32672a80195467b5de0480aa97b1f", size = 215736, upload-time = "2025-10-02T14:35:21.616Z" },
{ url = "https://files.pythonhosted.org/packages/bc/68/c4c80614716345d55071a396cf03d06e34b5f4917a467faf43083c995155/xxhash-3.6.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ed0df1b11a79856df5ffcab572cbd6b9627034c1c748c5566fa79df9048a7c5", size = 214833, upload-time = "2025-10-02T14:35:23.32Z" },
{ url = "https://files.pythonhosted.org/packages/7e/e9/ae27c8ffec8b953efa84c7c4a6c6802c263d587b9fc0d6e7cea64e08c3af/xxhash-3.6.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0e4edbfc7d420925b0dd5e792478ed393d6e75ff8fc219a6546fb446b6a417b1", size = 448348, upload-time = "2025-10-02T14:35:25.111Z" },
{ url = "https://files.pythonhosted.org/packages/d7/6b/33e21afb1b5b3f46b74b6bd1913639066af218d704cc0941404ca717fc57/xxhash-3.6.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fba27a198363a7ef87f8c0f6b171ec36b674fe9053742c58dd7e3201c1ab30ee", size = 196070, upload-time = "2025-10-02T14:35:26.586Z" },
{ url = "https://files.pythonhosted.org/packages/96/b6/fcabd337bc5fa624e7203aa0fa7d0c49eed22f72e93229431752bddc83d9/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:794fe9145fe60191c6532fa95063765529770edcdd67b3d537793e8004cabbfd", size = 212907, upload-time = "2025-10-02T14:35:28.087Z" },
{ url = "https://files.pythonhosted.org/packages/4b/d3/9ee6160e644d660fcf176c5825e61411c7f62648728f69c79ba237250143/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:6105ef7e62b5ac73a837778efc331a591d8442f8ef5c7e102376506cb4ae2729", size = 200839, upload-time = "2025-10-02T14:35:29.857Z" },
{ url = "https://files.pythonhosted.org/packages/0d/98/e8de5baa5109394baf5118f5e72ab21a86387c4f89b0e77ef3e2f6b0327b/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f01375c0e55395b814a679b3eea205db7919ac2af213f4a6682e01220e5fe292", size = 213304, upload-time = "2025-10-02T14:35:31.222Z" },
{ url = "https://files.pythonhosted.org/packages/7b/1d/71056535dec5c3177eeb53e38e3d367dd1d16e024e63b1cee208d572a033/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d706dca2d24d834a4661619dcacf51a75c16d65985718d6a7d73c1eeeb903ddf", size = 416930, upload-time = "2025-10-02T14:35:32.517Z" },
{ url = "https://files.pythonhosted.org/packages/dc/6c/5cbde9de2cd967c322e651c65c543700b19e7ae3e0aae8ece3469bf9683d/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5f059d9faeacd49c0215d66f4056e1326c80503f51a1532ca336a385edadd033", size = 193787, upload-time = "2025-10-02T14:35:33.827Z" },
{ url = "https://files.pythonhosted.org/packages/19/fa/0172e350361d61febcea941b0cc541d6e6c8d65d153e85f850a7b256ff8a/xxhash-3.6.0-cp313-cp313t-win32.whl", hash = "sha256:1244460adc3a9be84731d72b8e80625788e5815b68da3da8b83f78115a40a7ec", size = 30916, upload-time = "2025-10-02T14:35:35.107Z" },
{ url = "https://files.pythonhosted.org/packages/ad/e6/e8cf858a2b19d6d45820f072eff1bea413910592ff17157cabc5f1227a16/xxhash-3.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b1e420ef35c503869c4064f4a2f2b08ad6431ab7b229a05cce39d74268bca6b8", size = 31799, upload-time = "2025-10-02T14:35:36.165Z" },
{ url = "https://files.pythonhosted.org/packages/56/15/064b197e855bfb7b343210e82490ae672f8bc7cdf3ddb02e92f64304ee8a/xxhash-3.6.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ec44b73a4220623235f67a996c862049f375df3b1052d9899f40a6382c32d746", size = 28044, upload-time = "2025-10-02T14:35:37.195Z" },
{ url = "https://files.pythonhosted.org/packages/7e/5e/0138bc4484ea9b897864d59fce9be9086030825bc778b76cb5a33a906d37/xxhash-3.6.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a40a3d35b204b7cc7643cbcf8c9976d818cb47befcfac8bbefec8038ac363f3e", size = 32754, upload-time = "2025-10-02T14:35:38.245Z" },
{ url = "https://files.pythonhosted.org/packages/18/d7/5dac2eb2ec75fd771957a13e5dda560efb2176d5203f39502a5fc571f899/xxhash-3.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a54844be970d3fc22630b32d515e79a90d0a3ddb2644d8d7402e3c4c8da61405", size = 30846, upload-time = "2025-10-02T14:35:39.6Z" },
{ url = "https://files.pythonhosted.org/packages/fe/71/8bc5be2bb00deb5682e92e8da955ebe5fa982da13a69da5a40a4c8db12fb/xxhash-3.6.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:016e9190af8f0a4e3741343777710e3d5717427f175adfdc3e72508f59e2a7f3", size = 194343, upload-time = "2025-10-02T14:35:40.69Z" },
{ url = "https://files.pythonhosted.org/packages/e7/3b/52badfb2aecec2c377ddf1ae75f55db3ba2d321c5e164f14461c90837ef3/xxhash-3.6.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f6f72232f849eb9d0141e2ebe2677ece15adfd0fa599bc058aad83c714bb2c6", size = 213074, upload-time = "2025-10-02T14:35:42.29Z" },
{ url = "https://files.pythonhosted.org/packages/a2/2b/ae46b4e9b92e537fa30d03dbc19cdae57ed407e9c26d163895e968e3de85/xxhash-3.6.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63275a8aba7865e44b1813d2177e0f5ea7eadad3dd063a21f7cf9afdc7054063", size = 212388, upload-time = "2025-10-02T14:35:43.929Z" },
{ url = "https://files.pythonhosted.org/packages/f5/80/49f88d3afc724b4ac7fbd664c8452d6db51b49915be48c6982659e0e7942/xxhash-3.6.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cd01fa2aa00d8b017c97eb46b9a794fbdca53fc14f845f5a328c71254b0abb7", size = 445614, upload-time = "2025-10-02T14:35:45.216Z" },
{ url = "https://files.pythonhosted.org/packages/ed/ba/603ce3961e339413543d8cd44f21f2c80e2a7c5cfe692a7b1f2cccf58f3c/xxhash-3.6.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0226aa89035b62b6a86d3c68df4d7c1f47a342b8683da2b60cedcddb46c4d95b", size = 194024, upload-time = "2025-10-02T14:35:46.959Z" },
{ url = "https://files.pythonhosted.org/packages/78/d1/8e225ff7113bf81545cfdcd79eef124a7b7064a0bba53605ff39590b95c2/xxhash-3.6.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c6e193e9f56e4ca4923c61238cdaced324f0feac782544eb4c6d55ad5cc99ddd", size = 210541, upload-time = "2025-10-02T14:35:48.301Z" },
{ url = "https://files.pythonhosted.org/packages/6f/58/0f89d149f0bad89def1a8dd38feb50ccdeb643d9797ec84707091d4cb494/xxhash-3.6.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9176dcaddf4ca963d4deb93866d739a343c01c969231dbe21680e13a5d1a5bf0", size = 198305, upload-time = "2025-10-02T14:35:49.584Z" },
{ url = "https://files.pythonhosted.org/packages/11/38/5eab81580703c4df93feb5f32ff8fa7fe1e2c51c1f183ee4e48d4bb9d3d7/xxhash-3.6.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c1ce4009c97a752e682b897aa99aef84191077a9433eb237774689f14f8ec152", size = 210848, upload-time = "2025-10-02T14:35:50.877Z" },
{ url = "https://files.pythonhosted.org/packages/5e/6b/953dc4b05c3ce678abca756416e4c130d2382f877a9c30a20d08ee6a77c0/xxhash-3.6.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8cb2f4f679b01513b7adbb9b1b2f0f9cdc31b70007eaf9d59d0878809f385b11", size = 414142, upload-time = "2025-10-02T14:35:52.15Z" },
{ url = "https://files.pythonhosted.org/packages/08/a9/238ec0d4e81a10eb5026d4a6972677cbc898ba6c8b9dbaec12ae001b1b35/xxhash-3.6.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:653a91d7c2ab54a92c19ccf43508b6a555440b9be1bc8be553376778be7f20b5", size = 191547, upload-time = "2025-10-02T14:35:53.547Z" },
{ url = "https://files.pythonhosted.org/packages/f1/ee/3cf8589e06c2164ac77c3bf0aa127012801128f1feebf2a079272da5737c/xxhash-3.6.0-cp314-cp314-win32.whl", hash = "sha256:a756fe893389483ee8c394d06b5ab765d96e68fbbfe6fde7aa17e11f5720559f", size = 31214, upload-time = "2025-10-02T14:35:54.746Z" },
{ url = "https://files.pythonhosted.org/packages/02/5d/a19552fbc6ad4cb54ff953c3908bbc095f4a921bc569433d791f755186f1/xxhash-3.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:39be8e4e142550ef69629c9cd71b88c90e9a5db703fecbcf265546d9536ca4ad", size = 32290, upload-time = "2025-10-02T14:35:55.791Z" },
{ url = "https://files.pythonhosted.org/packages/b1/11/dafa0643bc30442c887b55baf8e73353a344ee89c1901b5a5c54a6c17d39/xxhash-3.6.0-cp314-cp314-win_arm64.whl", hash = "sha256:25915e6000338999236f1eb68a02a32c3275ac338628a7eaa5a269c401995679", size = 28795, upload-time = "2025-10-02T14:35:57.162Z" },
{ url = "https://files.pythonhosted.org/packages/2c/db/0e99732ed7f64182aef4a6fb145e1a295558deec2a746265dcdec12d191e/xxhash-3.6.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c5294f596a9017ca5a3e3f8884c00b91ab2ad2933cf288f4923c3fd4346cf3d4", size = 32955, upload-time = "2025-10-02T14:35:58.267Z" },
{ url = "https://files.pythonhosted.org/packages/55/f4/2a7c3c68e564a099becfa44bb3d398810cc0ff6749b0d3cb8ccb93f23c14/xxhash-3.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1cf9dcc4ab9cff01dfbba78544297a3a01dafd60f3bde4e2bfd016cf7e4ddc67", size = 31072, upload-time = "2025-10-02T14:35:59.382Z" },
{ url = "https://files.pythonhosted.org/packages/c6/d9/72a29cddc7250e8a5819dad5d466facb5dc4c802ce120645630149127e73/xxhash-3.6.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01262da8798422d0685f7cef03b2bd3f4f46511b02830861df548d7def4402ad", size = 196579, upload-time = "2025-10-02T14:36:00.838Z" },
{ url = "https://files.pythonhosted.org/packages/63/93/b21590e1e381040e2ca305a884d89e1c345b347404f7780f07f2cdd47ef4/xxhash-3.6.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51a73fb7cb3a3ead9f7a8b583ffd9b8038e277cdb8cb87cf890e88b3456afa0b", size = 215854, upload-time = "2025-10-02T14:36:02.207Z" },
{ url = "https://files.pythonhosted.org/packages/ce/b8/edab8a7d4fa14e924b29be877d54155dcbd8b80be85ea00d2be3413a9ed4/xxhash-3.6.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9c6df83594f7df8f7f708ce5ebeacfc69f72c9fbaaababf6cf4758eaada0c9b", size = 214965, upload-time = "2025-10-02T14:36:03.507Z" },
{ url = "https://files.pythonhosted.org/packages/27/67/dfa980ac7f0d509d54ea0d5a486d2bb4b80c3f1bb22b66e6a05d3efaf6c0/xxhash-3.6.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:627f0af069b0ea56f312fd5189001c24578868643203bca1abbc2c52d3a6f3ca", size = 448484, upload-time = "2025-10-02T14:36:04.828Z" },
{ url = "https://files.pythonhosted.org/packages/8c/63/8ffc2cc97e811c0ca5d00ab36604b3ea6f4254f20b7bc658ca825ce6c954/xxhash-3.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa912c62f842dfd013c5f21a642c9c10cd9f4c4e943e0af83618b4a404d9091a", size = 196162, upload-time = "2025-10-02T14:36:06.182Z" },
{ url = "https://files.pythonhosted.org/packages/4b/77/07f0e7a3edd11a6097e990f6e5b815b6592459cb16dae990d967693e6ea9/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b465afd7909db30168ab62afe40b2fcf79eedc0b89a6c0ab3123515dc0df8b99", size = 213007, upload-time = "2025-10-02T14:36:07.733Z" },
{ url = "https://files.pythonhosted.org/packages/ae/d8/bc5fa0d152837117eb0bef6f83f956c509332ce133c91c63ce07ee7c4873/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a881851cf38b0a70e7c4d3ce81fc7afd86fbc2a024f4cfb2a97cf49ce04b75d3", size = 200956, upload-time = "2025-10-02T14:36:09.106Z" },
{ url = "https://files.pythonhosted.org/packages/26/a5/d749334130de9411783873e9b98ecc46688dad5db64ca6e04b02acc8b473/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9b3222c686a919a0f3253cfc12bb118b8b103506612253b5baeaac10d8027cf6", size = 213401, upload-time = "2025-10-02T14:36:10.585Z" },
{ url = "https://files.pythonhosted.org/packages/89/72/abed959c956a4bfc72b58c0384bb7940663c678127538634d896b1195c10/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:c5aa639bc113e9286137cec8fadc20e9cd732b2cc385c0b7fa673b84fc1f2a93", size = 417083, upload-time = "2025-10-02T14:36:12.276Z" },
{ url = "https://files.pythonhosted.org/packages/0c/b3/62fd2b586283b7d7d665fb98e266decadf31f058f1cf6c478741f68af0cb/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c1343d49ac102799905e115aee590183c3921d475356cb24b4de29a4bc56518", size = 193913, upload-time = "2025-10-02T14:36:14.025Z" },
{ url = "https://files.pythonhosted.org/packages/9a/9a/c19c42c5b3f5a4aad748a6d5b4f23df3bed7ee5445accc65a0fb3ff03953/xxhash-3.6.0-cp314-cp314t-win32.whl", hash = "sha256:5851f033c3030dd95c086b4a36a2683c2ff4a799b23af60977188b057e467119", size = 31586, upload-time = "2025-10-02T14:36:15.603Z" },
{ url = "https://files.pythonhosted.org/packages/03/d6/4cc450345be9924fd5dc8c590ceda1db5b43a0a889587b0ae81a95511360/xxhash-3.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0444e7967dac37569052d2409b00a8860c2135cff05502df4da80267d384849f", size = 32526, upload-time = "2025-10-02T14:36:16.708Z" },
{ url = "https://files.pythonhosted.org/packages/0f/c9/7243eb3f9eaabd1a88a5a5acadf06df2d83b100c62684b7425c6a11bcaa8/xxhash-3.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bb79b1e63f6fd84ec778a4b1916dfe0a7c3fdb986c06addd5db3a0d413819d95", size = 28898, upload-time = "2025-10-02T14:36:17.843Z" },
{ url = "https://files.pythonhosted.org/packages/93/1e/8aec23647a34a249f62e2398c42955acd9b4c6ed5cf08cbea94dc46f78d2/xxhash-3.6.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0f7b7e2ec26c1666ad5fc9dbfa426a6a3367ceaf79db5dd76264659d509d73b0", size = 30662, upload-time = "2025-10-02T14:37:01.743Z" },
{ url = "https://files.pythonhosted.org/packages/b8/0b/b14510b38ba91caf43006209db846a696ceea6a847a0c9ba0a5b1adc53d6/xxhash-3.6.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5dc1e14d14fa0f5789ec29a7062004b5933964bb9b02aae6622b8f530dc40296", size = 41056, upload-time = "2025-10-02T14:37:02.879Z" },
{ url = "https://files.pythonhosted.org/packages/50/55/15a7b8a56590e66ccd374bbfa3f9ffc45b810886c8c3b614e3f90bd2367c/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:881b47fc47e051b37d94d13e7455131054b56749b91b508b0907eb07900d1c13", size = 36251, upload-time = "2025-10-02T14:37:04.44Z" },
{ url = "https://files.pythonhosted.org/packages/62/b2/5ac99a041a29e58e95f907876b04f7067a0242cb85b5f39e726153981503/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6dc31591899f5e5666f04cc2e529e69b4072827085c1ef15294d91a004bc1bd", size = 32481, upload-time = "2025-10-02T14:37:05.869Z" },
{ url = "https://files.pythonhosted.org/packages/7b/d9/8d95e906764a386a3d3b596f3c68bb63687dfca806373509f51ce8eea81f/xxhash-3.6.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:15e0dac10eb9309508bfc41f7f9deaa7755c69e35af835db9cb10751adebc35d", size = 31565, upload-time = "2025-10-02T14:37:06.966Z" },
]
[[package]]
name = "zstandard"
version = "0.25.0"
@@ -5,5 +5,5 @@ description = "Test for prerelease stuff"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"langchain-openai==1.1.14"
"langchain-openai==1.0.1"
]
@@ -5,7 +5,7 @@ description = "Test for prerelease stuff"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"langchain-openai==1.1.14",
"langchain-openai==1.0.0a2",
"langchain-anthropic==1.0.0a5",
"langgraph==1.1.5"
]
@@ -5,7 +5,7 @@ description = "Test for prerelease stuff"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"langchain-openai==1.1.14",
"langchain-openai==1.0.0a2",
"langgraph==1.1.2",
"langchain_community>=0.3.0",
]
+29 -8
View File
@@ -1086,6 +1086,11 @@
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8"
integrity sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==
"@types/uuid@^10.0.0":
version "10.0.0"
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-10.0.0.tgz#e9c07fe50da0f53dc24970cca94d619ff03f6f6d"
integrity sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==
"@types/yargs-parser@*":
version "21.0.3"
resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15"
@@ -1777,6 +1782,13 @@ concat-map@0.0.1:
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
console-table-printer@^2.12.1:
version "2.15.0"
resolved "https://registry.yarnpkg.com/console-table-printer/-/console-table-printer-2.15.0.tgz#5c808204640b8f024d545bde8aabe5d344dfadc1"
integrity sha512-SrhBq4hYVjLCkBVOWaTzceJalvn5K1Zq5aQA6wXC/cYjI3frKWNPEMK3sZsJfNNQApvCQmgBcc13ZKmFj8qExw==
dependencies:
simple-wcswidth "^1.1.2"
convert-source-map@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a"
@@ -3676,12 +3688,16 @@ keyv@^4.5.4:
json-buffer "3.0.1"
"langsmith@>=0.5.0 <1.0.0":
version "0.5.20"
resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.5.20.tgz#4021847d2ccd5a86c5eb96060f9bb5f19f80eca5"
integrity sha512-ULhLM8RswvQDXufLtNtvclHrWCBx8Cb5UPI6lAZC+8Dq59iHsVPz/3Ac9khWNm1VIvChRsuykixD/WrmzuuA3Q==
version "0.5.4"
resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.5.4.tgz#f75b82b08e30db72a7d1d595b341e9666bd525e5"
integrity sha512-qYkNIoKpf0ZYt+cYzrDV+XI3FCexApmZmp8EMs3eDTMv0OvrHMLoxJ9IpkeoXJSX24+GPk0/jXjKx2hWerpy9w==
dependencies:
p-queue "6.6.2"
uuid "10.0.0"
"@types/uuid" "^10.0.0"
chalk "^4.1.2"
console-table-printer "^2.12.1"
p-queue "^6.6.2"
semver "^7.6.3"
uuid "^10.0.0"
leven@^3.1.0:
version "3.1.0"
@@ -3991,7 +4007,7 @@ p-locate@^5.0.0:
dependencies:
p-limit "^3.0.2"
p-queue@6.6.2, p-queue@^6.6.2:
p-queue@^6.6.2:
version "6.6.2"
resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-6.6.2.tgz#2068a9dcf8e67dd0ec3e7a2bcb76810faa85e426"
integrity sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==
@@ -4287,7 +4303,7 @@ semver@^6.3.1:
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
semver@^7.5.3, semver@^7.5.4, semver@^7.7.2, semver@^7.7.3:
semver@^7.5.3, semver@^7.5.4, semver@^7.6.3, semver@^7.7.2, semver@^7.7.3:
version "7.7.4"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a"
integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==
@@ -4395,6 +4411,11 @@ signal-exit@^4.0.1:
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04"
integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==
simple-wcswidth@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/simple-wcswidth/-/simple-wcswidth-1.1.2.tgz#66722f37629d5203f9b47c5477b1225b85d6525b"
integrity sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw==
slash@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
@@ -4849,7 +4870,7 @@ uri-js@^4.2.2:
dependencies:
punycode "^2.1.0"
uuid@10.0.0, uuid@^10.0.0:
uuid@^10.0.0:
version "10.0.0"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294"
integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==
+72 -7
View File
@@ -217,6 +217,11 @@
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==
"@types/uuid@^10.0.0":
version "10.0.0"
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-10.0.0.tgz#e9c07fe50da0f53dc24970cca94d619ff03f6f6d"
integrity sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==
"@typescript-eslint/eslint-plugin@^8.58.0":
version "8.58.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz#ad40e492f1931f46da1bd888e52b9e56df9063aa"
@@ -338,6 +343,13 @@ ajv@^6.14.0:
json-schema-traverse "^0.4.1"
uri-js "^4.2.2"
ansi-styles@^4.1.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937"
integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==
dependencies:
color-convert "^2.0.1"
ansi-styles@^5.0.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b"
@@ -496,11 +508,38 @@ camelcase@6:
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a"
integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==
chalk@^4.1.2:
version "4.1.2"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
dependencies:
ansi-styles "^4.1.0"
supports-color "^7.1.0"
color-convert@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3"
integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==
dependencies:
color-name "~1.1.4"
color-name@~1.1.4:
version "1.1.4"
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
concat-map@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
console-table-printer@^2.12.1:
version "2.14.6"
resolved "https://registry.yarnpkg.com/console-table-printer/-/console-table-printer-2.14.6.tgz#edfe0bf311fa2701922ed509443145ab51e06436"
integrity sha512-MCBl5HNVaFuuHW6FGbL/4fB7N/ormCy+tQ+sxTrF6QtSbSNETvPuOVbkJBhzDgYhvjWGrTma4eYJa37ZuoQsPw==
dependencies:
simple-wcswidth "^1.0.1"
cross-spawn@^7.0.6:
version "7.0.6"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f"
@@ -1020,6 +1059,11 @@ has-bigints@^1.0.2:
resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.1.0.tgz#28607e965ac967e03cd2a2c70a2636a1edad49fe"
integrity sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==
has-flag@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
has-property-descriptors@^1.0.0, has-property-descriptors@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854"
@@ -1328,12 +1372,16 @@ keyv@^4.5.4:
json-buffer "3.0.1"
"langsmith@>=0.5.0 <1.0.0":
version "0.5.20"
resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.5.20.tgz#4021847d2ccd5a86c5eb96060f9bb5f19f80eca5"
integrity sha512-ULhLM8RswvQDXufLtNtvclHrWCBx8Cb5UPI6lAZC+8Dq59iHsVPz/3Ac9khWNm1VIvChRsuykixD/WrmzuuA3Q==
version "0.5.4"
resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.5.4.tgz#f75b82b08e30db72a7d1d595b341e9666bd525e5"
integrity sha512-qYkNIoKpf0ZYt+cYzrDV+XI3FCexApmZmp8EMs3eDTMv0OvrHMLoxJ9IpkeoXJSX24+GPk0/jXjKx2hWerpy9w==
dependencies:
p-queue "6.6.2"
uuid "10.0.0"
"@types/uuid" "^10.0.0"
chalk "^4.1.2"
console-table-printer "^2.12.1"
p-queue "^6.6.2"
semver "^7.6.3"
uuid "^10.0.0"
levn@^0.4.1:
version "0.4.1"
@@ -1480,7 +1528,7 @@ p-locate@^5.0.0:
dependencies:
p-limit "^3.0.2"
p-queue@6.6.2, p-queue@^6.6.2:
p-queue@^6.6.2:
version "6.6.2"
resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-6.6.2.tgz#2068a9dcf8e67dd0ec3e7a2bcb76810faa85e426"
integrity sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==
@@ -1642,6 +1690,11 @@ semver@^6.3.1:
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
semver@^7.6.3:
version "7.7.2"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58"
integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==
semver@^7.7.3:
version "7.7.4"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a"
@@ -1730,6 +1783,11 @@ side-channel@^1.1.0:
side-channel-map "^1.0.1"
side-channel-weakmap "^1.0.2"
simple-wcswidth@^1.0.1:
version "1.1.2"
resolved "https://registry.yarnpkg.com/simple-wcswidth/-/simple-wcswidth-1.1.2.tgz#66722f37629d5203f9b47c5477b1225b85d6525b"
integrity sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw==
stop-iteration-iterator@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz#f481ff70a548f6124d0312c3aa14cbfa7aa542ad"
@@ -1780,6 +1838,13 @@ strip-json-comments@^3.1.1:
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
supports-color@^7.1.0:
version "7.2.0"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da"
integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==
dependencies:
has-flag "^4.0.0"
supports-preserve-symlinks-flag@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
@@ -1901,7 +1966,7 @@ uri-js@^4.2.2:
dependencies:
punycode "^2.1.0"
uuid@10.0.0, uuid@^10.0.0:
uuid@^10.0.0:
version "10.0.0"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294"
integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.4.24"
__version__ = "0.4.21"
-124
View File
@@ -1,124 +0,0 @@
"""Shared ignore-file handling for local source filtering."""
import pathlib
from dataclasses import dataclass
import pathspec
_ALWAYS_EXCLUDE = [
"__pycache__/",
".git/",
".venv/",
"venv/",
"node_modules/",
".tox/",
".mypy_cache/",
]
_ALWAYS_EXCLUDE_NAMES = frozenset(
pattern.rstrip("/").split("/")[-1] for pattern in _ALWAYS_EXCLUDE
)
_GLOB_CHARS = frozenset("*?[")
@dataclass(frozen=True, slots=True)
class _NegatedDockerignoreHints:
exact_dirs: frozenset[pathlib.PurePosixPath] = frozenset()
wildcard_prefixes: frozenset[pathlib.PurePosixPath] = frozenset()
recurse_all: bool = False
def requires_dir_walk(self, path: pathlib.PurePosixPath) -> bool:
if self.recurse_all or path in self.exact_dirs:
return True
return any(
path == prefix or path in prefix.parents or prefix in path.parents
for prefix in self.wildcard_prefixes
)
def _build_ignore_spec(
directory: pathlib.Path, *, include_gitignore: bool = True
) -> pathspec.PathSpec:
"""Build a PathSpec combining built-in exclusions with ignore files.
Always excludes common non-source directories (`_ALWAYS_EXCLUDE`). On top
of that, patterns from `.dockerignore` are merged in. `.gitignore` patterns
are optional because some callers need Docker build-context semantics,
while archive creation wants both files.
"""
lines: list[str] = list(_ALWAYS_EXCLUDE)
ignore_files = [".dockerignore"]
if include_gitignore:
ignore_files.append(".gitignore")
for name in ignore_files:
ignore_file = directory / name
if ignore_file.is_file():
lines.extend(ignore_file.read_text(encoding="utf-8").splitlines())
return pathspec.PathSpec.from_lines("gitwildmatch", lines)
def _is_always_excluded(path: pathlib.PurePosixPath, *, is_dir: bool) -> bool:
"""Whether `path` lives inside a built-in excluded directory."""
parent_parts = path.parts if is_dir else path.parts[:-1]
return any(part in _ALWAYS_EXCLUDE_NAMES for part in parent_parts)
def _build_dockerignore_negation_hints(
directory: pathlib.Path,
) -> _NegatedDockerignoreHints:
"""Summarize which ignored directories must still be traversed.
Most negations only require walking a small, concrete chain of parent
directories (for example `!assets/keep.txt` requires entering `assets/`).
Broader glob negations may force a wider walk.
"""
ignore_file = directory / ".dockerignore"
if not ignore_file.is_file():
return _NegatedDockerignoreHints()
exact_dirs: set[pathlib.PurePosixPath] = set()
wildcard_prefixes: set[pathlib.PurePosixPath] = set()
recurse_all = False
for raw_line in ignore_file.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or line.startswith("\\!"):
continue
if line.startswith("\\#"):
line = line[1:]
if not line.startswith("!"):
continue
pattern = line[1:].lstrip("/")
while pattern.startswith("./"):
pattern = pattern[2:]
pattern = pattern.rstrip("/")
parts = [part for part in pattern.split("/") if part and part != "."]
if not parts:
recurse_all = True
continue
wildcard_index = next(
(
idx
for idx, part in enumerate(parts)
if any(char in part for char in _GLOB_CHARS)
),
None,
)
if wildcard_index is not None:
literal_parts = parts[:wildcard_index]
if not literal_parts:
recurse_all = True
continue
wildcard_prefixes.add(pathlib.PurePosixPath(*literal_parts))
continue
parent_parts = parts[:-1]
for idx in range(1, len(parent_parts) + 1):
exact_dirs.add(pathlib.PurePosixPath(*parent_parts[:idx]))
return _NegatedDockerignoreHints(
exact_dirs=frozenset(exact_dirs),
wildcard_prefixes=frozenset(wildcard_prefixes),
recurse_all=recurse_all,
)
+3 -10
View File
@@ -26,15 +26,8 @@ class LogData(TypedDict):
params: dict[str, Any]
def get_anonymized_params(
kwargs: dict[str, Any], *, cli_command: str
) -> dict[str, bool | str]:
params: dict[str, bool | str] = {}
if cli_command == "deploy" and (
analytics_source := os.getenv("LANGGRAPH_CLI_ANALYTICS_SOURCE")
):
params["source"] = analytics_source
def get_anonymized_params(kwargs: dict[str, Any]) -> dict[str, bool]:
params = {}
# anonymize params with values
if config := kwargs.get("config"):
@@ -95,7 +88,7 @@ def log_command(func):
"python_version": platform.python_version(),
"cli_version": __version__,
"cli_command": func.__name__,
"params": get_anonymized_params(kwargs, cli_command=func.__name__),
"params": get_anonymized_params(kwargs),
}
background_thread = threading.Thread(target=log_data, args=(data,))
+24 -1
View File
@@ -9,12 +9,35 @@ from contextlib import contextmanager
import click
import pathspec
from langgraph_cli._ignore import _build_ignore_spec
from langgraph_cli.config import Config, _assemble_local_deps
_WARN_SIZE = 50 * 1024 * 1024 # 50 MB
_MAX_SIZE = 200 * 1024 * 1024 # 200 MB
_ALWAYS_EXCLUDE = [
"__pycache__/",
".git/",
".venv/",
"venv/",
"node_modules/",
".tox/",
".mypy_cache/",
]
def _build_ignore_spec(directory: pathlib.Path) -> pathspec.PathSpec:
"""Build a PathSpec combining built-in exclusions with .dockerignore and .gitignore.
Always excludes common non-source directories (_ALWAYS_EXCLUDE). On top of
that, patterns from .dockerignore and .gitignore (if present) are merged in.
"""
lines: list[str] = list(_ALWAYS_EXCLUDE)
for name in (".dockerignore", ".gitignore"):
ignore_file = directory / name
if ignore_file.is_file():
lines.extend(ignore_file.read_text(encoding="utf-8").splitlines())
return pathspec.PathSpec.from_lines("gitwildmatch", lines)
def _tar_filter(tarinfo: tarfile.TarInfo) -> tarfile.TarInfo | None:
"""Strip symlinks, hardlinks, and traversal paths from archive."""
+11 -50
View File
@@ -10,13 +10,7 @@ except ModuleNotFoundError: # pragma: no cover - exercised on Python 3.10.
import tomli as tomllib
import click
import pathspec
from langgraph_cli._ignore import (
_build_dockerignore_negation_hints,
_build_ignore_spec,
_is_always_excluded,
)
from langgraph_cli.schemas import Config
@@ -446,32 +440,16 @@ def _container_root_for_uv_lock_package(
def _uv_lock_package_copy_items(
package: UvLockPackage,
plan: UvLockPlan,
ignore_spec: pathspec.PathSpec,
package: UvLockPackage, plan: UvLockPlan
) -> tuple[tuple[pathlib.PurePosixPath, pathlib.PurePosixPath], ...]:
# Skip entries that .dockerignore / built-in exclusions would strip from
# the build context. Emitting `ADD <path>` for a file that Docker has
# filtered out causes the build to fail with
# "failed to compute cache key: <path> not found".
if package.root != plan.project_root:
relative_root = pathlib.PurePosixPath(
*package.root.relative_to(plan.project_root).parts
)
if _is_always_excluded(relative_root, is_dir=True) or ignore_spec.match_file(
f"{relative_root.as_posix()}/"
):
raise click.UsageError(
f"Workspace member '{package.name}' at {relative_root} is "
"excluded from the Docker build context, but uv.lock requires "
"it to be copied into the build context. Remove the matching "
"pattern or drop the member from [tool.uv.workspace].members."
)
return ((relative_root, plan.container_roots[package.root]),)
root_container = plan.container_roots[package.root]
workspace_member_roots = plan.all_workspace_roots - {plan.project_root}
negated_dockerignore_hints = _build_dockerignore_negation_hints(plan.project_root)
def iter_entries(
current_dir: pathlib.Path,
@@ -483,32 +461,18 @@ def _uv_lock_package_copy_items(
# and excluded entirely otherwise.
continue
descendant_member_roots = [
ws_root
for ws_root in workspace_member_roots
if child in ws_root.parents
]
if child.is_dir() and descendant_member_roots:
entries.extend(iter_entries(child))
continue
relative_child = pathlib.PurePosixPath(
*child.relative_to(plan.project_root).parts
)
is_dir = child.is_dir()
if _is_always_excluded(relative_child, is_dir=is_dir):
continue
ignored = ignore_spec.match_file(
f"{relative_child.as_posix()}/" if is_dir else relative_child.as_posix()
)
is_workspace_parent = is_dir and any(
child in ws_root.parents for ws_root in workspace_member_roots
)
if is_workspace_parent:
entries.extend(iter_entries(child))
continue
if (
is_dir
and ignored
and negated_dockerignore_hints.requires_dir_walk(relative_child)
):
entries.extend(iter_entries(child))
continue
if ignored:
continue
entries.append(
(relative_child, root_container.joinpath(*relative_child.parts))
)
@@ -992,13 +956,10 @@ def python_config_to_docker_uv_lock(
docker_plan.add_raw("# -- End of uv.lock dependencies install --")
docker_plan.add_blank()
ignore_spec = _build_ignore_spec(plan.project_root, include_gitignore=False)
for package in plan.install_order:
package_label = package.root.relative_to(plan.project_root).as_posix() or "."
docker_plan.add_raw(f"# -- Adding workspace package {package_label} --")
for source, destination in _uv_lock_package_copy_items(
package, plan, ignore_spec
):
for source, destination in _uv_lock_package_copy_items(package, plan):
docker_plan.add_raw(copy_from_project_root(source, destination.as_posix()))
docker_plan.add_instruction(
"WORKDIR", plan.container_roots[package.root].as_posix()
+1 -1
View File
@@ -23,7 +23,7 @@ dependencies = [
path = "langgraph_cli/__init__.py"
[project.optional-dependencies]
inmem = [
"langgraph-api>=0.5.35,<0.9.0 ; python_version >= '3.11'",
"langgraph-api>=0.5.35,<0.8.0 ; python_version >= '3.11'",
"langgraph-runtime-inmem>=0.7 ; python_version >= '3.11'",
]
@@ -99,13 +99,6 @@ class TestBuildIgnoreSpec:
assert spec.match_file("app.log")
assert spec.match_file("mod.pyc")
def test_can_skip_gitignore(self, tmp_path):
(tmp_path / ".dockerignore").write_text("*.log\n")
(tmp_path / ".gitignore").write_text("*.pyc\n")
spec = _build_ignore_spec(tmp_path, include_gitignore=False)
assert spec.match_file("app.log")
assert not spec.match_file("mod.pyc")
def test_no_ignore_files_only_builtins(self, tmp_path):
spec = _build_ignore_spec(tmp_path)
assert spec.match_file("__pycache__/")
-359
View File
@@ -4,7 +4,6 @@ import os
import pathlib
import tempfile
import textwrap
from unittest.mock import patch
import click
import pytest
@@ -1856,364 +1855,6 @@ def test_config_to_docker_uv_lock_supports_single_uv_project_root():
assert additional_contexts == {}
def test_config_to_docker_uv_lock_skips_dockerignore_entries():
"""Entries filtered by .dockerignore / built-in excludes must not appear
as ADD lines. Docker fails to compute the cache key for paths that the
build context has stripped."""
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = pathlib.Path(tmpdir)
project_root = tmpdir_path / "single"
project_root.mkdir()
(project_root / "uv.lock").write_text("# uv lock file\n")
(project_root / "pyproject.toml").write_text(
textwrap.dedent(
"""
[project]
name = "single-app"
version = "0.1.0"
dependencies = ["httpx>=0.28"]
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
"""
).strip()
+ "\n"
)
(project_root / "langgraph.json").write_text("{}\n")
(project_root / "src").mkdir()
(project_root / "src" / "agent.py").write_text("graph = object()\n")
(project_root / "README.md").write_text("# hi\n")
# Built-in exclusions — must never appear as ADD lines.
(project_root / ".git").mkdir()
(project_root / ".git" / "HEAD").write_text("ref: refs/heads/main\n")
(project_root / ".venv").mkdir()
(project_root / ".venv" / "pyvenv.cfg").write_text("home = /usr\n")
(project_root / "__pycache__").mkdir()
(project_root / "__pycache__" / "x.cpython-311.pyc").write_bytes(b"\x00")
# .dockerignore excludes .gitignore and a custom path.
(project_root / ".dockerignore").write_text(".gitignore\nsecrets.env\n")
(project_root / ".gitignore").write_text("*.pyc\n")
(project_root / "secrets.env").write_text("TOKEN=abc\n")
config = validate_config(
{
"python_version": "3.11",
"graphs": {"agent": "./src/agent.py:graph"},
"source": {"kind": "uv"},
}
)
docker, _ = config_to_docker(
project_root / "langgraph.json",
config,
base_image="langchain/langgraph-api:0.2.47",
)
for excluded in (
"ADD .git ",
"ADD .gitignore ",
"ADD .venv ",
"ADD __pycache__ ",
"ADD secrets.env ",
):
assert excluded not in docker, (
f"{excluded!r} should be filtered out of Dockerfile:\n{docker}"
)
# The .dockerignore itself is still part of the context and should be
# ADDed (Docker needs it at build time, and archive.py includes it).
assert "ADD .dockerignore /deps/workspace/.dockerignore" in docker
assert "ADD src /deps/workspace/src" in docker
assert "ADD README.md /deps/workspace/README.md" in docker
def test_config_to_docker_uv_lock_does_not_apply_gitignore():
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = pathlib.Path(tmpdir)
project_root = tmpdir_path / "single"
project_root.mkdir()
(project_root / "uv.lock").write_text("# uv lock file\n")
(project_root / "pyproject.toml").write_text(
textwrap.dedent(
"""
[project]
name = "single-app"
version = "0.1.0"
dependencies = ["httpx>=0.28"]
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
"""
).strip()
+ "\n"
)
(project_root / "langgraph.json").write_text("{}\n")
(project_root / "src").mkdir()
(project_root / "src" / "agent.py").write_text("graph = object()\n")
(project_root / "README.md").write_text("# hi\n")
(project_root / ".gitignore").write_text("README.md\n")
config = validate_config(
{
"python_version": "3.11",
"graphs": {"agent": "./src/agent.py:graph"},
"source": {"kind": "uv"},
}
)
docker, _ = config_to_docker(
project_root / "langgraph.json",
config,
base_image="langchain/langgraph-api:0.2.47",
)
assert "ADD README.md /deps/workspace/README.md" in docker
def test_config_to_docker_uv_lock_skips_dockerignore_entries_in_workspace():
"""Multi-member workspace: ignore patterns must filter root-level entries
AND entries encountered while recursing into directories that contain
workspace members (the `descendant_member_roots` branch)."""
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = pathlib.Path(tmpdir)
project_root, config_path = _write_uv_lock_workspace(
tmpdir_path,
agent_dependencies=["workspace-root", "shared", "httpx>=0.28"],
root_sources="[tool.uv.sources]\nshared = { workspace = true }\nworkspace-root = { workspace = true }",
agent_sources="[tool.uv.sources]\nshared = { workspace = true }\nworkspace-root = { workspace = true }",
)
root_src = project_root / "src" / "workspace_root"
root_src.mkdir(parents=True)
(root_src / "__init__.py").write_text("__all__ = []\n")
(project_root / "README.md").write_text("workspace root package\n")
# A non-member sibling of the `apps/agent` member that should be
# filtered out via .dockerignore. This exercises the recursion into
# `apps/` where `apps/agent` is kept (it's a member) but its sibling is
# filtered.
(project_root / "apps" / "scratch.txt").write_text("scratch\n")
# A root-level path that .dockerignore excludes.
(project_root / "secrets.env").write_text("TOKEN=abc\n")
(project_root / ".dockerignore").write_text("secrets.env\napps/scratch.txt\n")
config = validate_config(
{
"python_version": "3.11",
"graphs": {
"agent": "../../apps/agent/src/agent/graph.py:graph",
},
"source": {"kind": "uv", "root": "../..", "package": "agent"},
}
)
docker, _ = config_to_docker(
config_path, config, base_image="langchain/langgraph-api:0.2.47"
)
assert "COPY --from=uv-workspace-root src /deps/workspace/src" in docker
assert (
"COPY --from=uv-workspace-root README.md /deps/workspace/README.md"
in docker
)
assert (
"COPY --from=uv-workspace-root .dockerignore /deps/workspace/.dockerignore"
in docker
)
assert "secrets.env" not in docker
assert "apps/scratch.txt" not in docker
# Workspace members themselves are still copied via their own per-member
# COPY line — the sibling filter must not disturb this.
assert (
"COPY --from=uv-workspace-root apps/agent /deps/workspace/apps/agent"
in docker
)
def test_config_to_docker_uv_lock_preserves_negated_dockerignore_descendants():
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = pathlib.Path(tmpdir)
project_root = tmpdir_path / "single"
project_root.mkdir()
(project_root / "uv.lock").write_text("# uv lock file\n")
(project_root / "pyproject.toml").write_text(
textwrap.dedent(
"""
[project]
name = "single-app"
version = "0.1.0"
dependencies = ["httpx>=0.28"]
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
"""
).strip()
+ "\n"
)
(project_root / "langgraph.json").write_text("{}\n")
(project_root / "src").mkdir()
(project_root / "src" / "agent.py").write_text("graph = object()\n")
(project_root / "assets").mkdir()
(project_root / "assets" / "keep.txt").write_text("keep\n")
(project_root / "assets" / "drop.txt").write_text("drop\n")
(project_root / ".dockerignore").write_text("assets/\n!assets/keep.txt\n")
config = validate_config(
{
"python_version": "3.11",
"graphs": {"agent": "./src/agent.py:graph"},
"source": {"kind": "uv"},
}
)
docker, _ = config_to_docker(
project_root / "langgraph.json",
config,
base_image="langchain/langgraph-api:0.2.47",
)
assert "ADD assets /deps/workspace/assets" not in docker
assert "ADD assets/keep.txt /deps/workspace/assets/keep.txt" in docker
assert "assets/drop.txt" not in docker
def test_config_to_docker_uv_lock_prunes_unrelated_ignored_subtrees():
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = pathlib.Path(tmpdir)
project_root = tmpdir_path / "single"
project_root.mkdir()
(project_root / "uv.lock").write_text("# uv lock file\n")
(project_root / "pyproject.toml").write_text(
textwrap.dedent(
"""
[project]
name = "single-app"
version = "0.1.0"
dependencies = ["httpx>=0.28"]
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
"""
).strip()
+ "\n"
)
(project_root / "langgraph.json").write_text("{}\n")
(project_root / "src").mkdir()
(project_root / "src" / "agent.py").write_text("graph = object()\n")
(project_root / "assets").mkdir()
(project_root / "assets" / "keep.txt").write_text("keep\n")
(project_root / "vendor").mkdir()
(project_root / "vendor" / "huge.txt").write_text("large\n")
(project_root / ".dockerignore").write_text(
"vendor/\nassets/\n!assets/keep.txt\n"
)
config = validate_config(
{
"python_version": "3.11",
"graphs": {"agent": "./src/agent.py:graph"},
"source": {"kind": "uv"},
}
)
original_iterdir = pathlib.Path.iterdir
def guarded_iterdir(self):
if self == project_root / "vendor":
raise AssertionError("should not walk unrelated ignored subtree")
return original_iterdir(self)
with patch.object(
pathlib.Path, "iterdir", autospec=True, side_effect=guarded_iterdir
):
docker, _ = config_to_docker(
project_root / "langgraph.json",
config,
base_image="langchain/langgraph-api:0.2.47",
)
assert "ADD assets/keep.txt /deps/workspace/assets/keep.txt" in docker
assert "vendor/huge.txt" not in docker
def test_config_to_docker_uv_lock_never_reincludes_always_excluded_subtrees():
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = pathlib.Path(tmpdir)
project_root = tmpdir_path / "single"
project_root.mkdir()
(project_root / "uv.lock").write_text("# uv lock file\n")
(project_root / "pyproject.toml").write_text(
textwrap.dedent(
"""
[project]
name = "single-app"
version = "0.1.0"
dependencies = ["httpx>=0.28"]
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
"""
).strip()
+ "\n"
)
(project_root / "langgraph.json").write_text("{}\n")
(project_root / "src").mkdir()
(project_root / "src" / "agent.py").write_text("graph = object()\n")
(project_root / ".venv" / "pkg").mkdir(parents=True)
(project_root / ".venv" / "pkg" / "keep.txt").write_text("keep\n")
(project_root / "node_modules" / "pkg").mkdir(parents=True)
(project_root / "node_modules" / "pkg" / "package.json").write_text("{}\n")
(project_root / ".dockerignore").write_text(
"!.venv/pkg/keep.txt\n!node_modules/pkg/package.json\n"
)
config = validate_config(
{
"python_version": "3.11",
"graphs": {"agent": "./src/agent.py:graph"},
"source": {"kind": "uv"},
}
)
docker, _ = config_to_docker(
project_root / "langgraph.json",
config,
base_image="langchain/langgraph-api:0.2.47",
)
assert ".venv/pkg/keep.txt" not in docker
assert "node_modules/pkg/package.json" not in docker
assert "ADD src /deps/workspace/src" in docker
def test_config_to_docker_uv_lock_rejects_ignored_workspace_member():
"""A workspace member matched by .dockerignore cannot be copied into the
build context — uv.lock requires it, so fail loudly with a clear message."""
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = pathlib.Path(tmpdir)
project_root, config_path = _write_uv_lock_workspace(
tmpdir_path,
agent_sources="[tool.uv.sources]\nshared = { workspace = true }",
)
(project_root / ".dockerignore").write_text("libs/shared\n")
config = validate_config(
{
"python_version": "3.11",
"graphs": {"agent": "../../apps/agent/src/agent/graph.py:graph"},
"source": {"kind": "uv", "root": "../..", "package": "agent"},
"auth": {"path": "../../libs/shared/src/shared/auth.py:create_auth"},
}
)
with pytest.raises(
click.UsageError, match=r"Workspace member 'shared' at libs/shared"
):
config_to_docker(
config_path, config, base_image="langchain/langgraph-api:0.2.47"
)
def test_config_to_docker_uv_lock_rejects_invalid_source_package_type():
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = pathlib.Path(tmpdir)
+3 -3
View File
@@ -290,7 +290,7 @@ wheels = [
[[package]]
name = "langsmith"
version = "0.7.31"
version = "0.7.26"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
@@ -303,9 +303,9 @@ dependencies = [
{ name = "xxhash" },
{ name = "zstandard" },
]
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" }
sdist = { url = "https://files.pythonhosted.org/packages/76/86/6de4f6f0451a9658f26f633e0bb090552a4dafd7df3f1ae7f0d40558e67e/langsmith-0.7.26.tar.gz", hash = "sha256:a3e06f3d689ce7195717aa6b8f91082319819ec7ea9b9a62cdcd3d9dc25bfc7b", size = 1146118, upload-time = "2026-04-06T15:01:03.336Z" }
wheels = [
{ 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" },
{ url = "https://files.pythonhosted.org/packages/81/8e/7eb7d65ce62e98e74b9f18f193ea7ac3996d4fbd71fffcc67d0f7ba3103e/langsmith-0.7.26-py3-none-any.whl", hash = "sha256:fe5c877972cea450c1c48251c8fae0f18543c8d19dfdb9ff9a9c4263763dde4e", size = 360160, upload-time = "2026-04-06T15:01:01.516Z" },
]
[[package]]
+3 -3
View File
@@ -266,7 +266,7 @@ wheels = [
[[package]]
name = "langsmith"
version = "0.7.31"
version = "0.7.26"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
@@ -279,9 +279,9 @@ dependencies = [
{ name = "xxhash" },
{ name = "zstandard" },
]
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" }
sdist = { url = "https://files.pythonhosted.org/packages/76/86/6de4f6f0451a9658f26f633e0bb090552a4dafd7df3f1ae7f0d40558e67e/langsmith-0.7.26.tar.gz", hash = "sha256:a3e06f3d689ce7195717aa6b8f91082319819ec7ea9b9a62cdcd3d9dc25bfc7b", size = 1146118, upload-time = "2026-04-06T15:01:03.336Z" }
wheels = [
{ 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" },
{ url = "https://files.pythonhosted.org/packages/81/8e/7eb7d65ce62e98e74b9f18f193ea7ac3996d4fbd71fffcc67d0f7ba3103e/langsmith-0.7.26-py3-none-any.whl", hash = "sha256:fe5c877972cea450c1c48251c8fae0f18543c8d19dfdb9ff9a9c4263763dde4e", size = 360160, upload-time = "2026-04-06T15:01:01.516Z" },
]
[[package]]
+383 -465
View File
File diff suppressed because it is too large Load Diff
+12 -48
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from collections import ChainMap
from collections.abc import Sequence
from collections.abc import Mapping, Sequence
from os import getenv
from typing import Any, cast
@@ -217,16 +217,14 @@ def get_callback_manager_for_config(
callbacks.add_tags(all_tags)
if metadata := config.get("metadata"):
callbacks.add_metadata(metadata)
manager = callbacks
return callbacks
else:
# otherwise create a new manager
manager = CallbackManager.configure(
return CallbackManager.configure(
inheritable_callbacks=config.get("callbacks"),
inheritable_tags=all_tags,
inheritable_metadata=config.get("metadata"),
langsmith_inheritable_metadata=_get_tracing_metadata_defaults(config),
)
return manager
def get_async_callback_manager_for_config(
@@ -257,16 +255,14 @@ def get_async_callback_manager_for_config(
callbacks.add_tags(all_tags)
if metadata := config.get("metadata"):
callbacks.add_metadata(metadata)
manager = callbacks
return callbacks
else:
# otherwise create a new manager
manager = AsyncCallbackManager.configure(
return AsyncCallbackManager.configure(
inheritable_callbacks=config.get("callbacks"),
inheritable_tags=all_tags,
inheritable_metadata=config.get("metadata"),
langsmith_inheritable_metadata=_get_tracing_metadata_defaults(config),
)
return manager
def _is_not_empty(value: Any) -> bool:
@@ -312,54 +308,22 @@ def ensure_config(*configs: RunnableConfig | None) -> RunnableConfig:
for k, v in config.items():
if _is_not_empty(v) and k not in CONFIG_KEYS:
empty[CONF][k] = v
configurable = empty.get("configurable")
metadata = empty.get("metadata")
if configurable and metadata is not None:
for key in _PROPAGATE_TO_METADATA:
if key in metadata:
continue
value = configurable.get(key)
if value:
metadata[key] = value
_empty_metadata = empty["metadata"]
for key, value in empty[CONF].items():
if _exclude_as_metadata(key, value, _empty_metadata):
continue
_empty_metadata[key] = value
return empty
_OMIT = ("key", "token", "secret", "password", "auth")
def _exclude_as_metadata(key: str, value: Any) -> bool:
def _exclude_as_metadata(key: str, value: Any, metadata: Mapping[str, Any]) -> bool:
key_lower = key.casefold()
return (
key.startswith("__")
or not isinstance(value, (str, int, float, bool))
or key in metadata
or any(substr in key_lower for substr in _OMIT)
)
def _get_tracing_metadata_defaults(
config: RunnableConfig,
) -> dict[str, Any] | None:
"""Get tracer-only metadata defaults from configurable values."""
configurable = config.get("configurable")
if not configurable:
return None
metadata: dict[str, Any] = {}
for key, value in configurable.items():
if _exclude_as_metadata(key, value):
continue
metadata[key] = value
return metadata or None
_PROPAGATE_TO_METADATA = frozenset(
(
"thread_id",
"checkpoint_id",
"checkpoint_ns",
"task_id",
"run_id",
"assistant_id",
"graph_id",
)
)
-394
View File
@@ -1,394 +0,0 @@
"""Graph lifecycle callback interfaces and event payloads.
This module defines the public callback surface for observing LangGraph-specific
lifecycle transitions such as interrupt and resume.
"""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any, Literal, TypeAlias, TypeVar
from uuid import UUID
from langchain_core.callbacks import BaseCallbackHandler, BaseCallbackManager
from langchain_core.callbacks.manager import ahandle_event, handle_event
from langchain_core.runnables import RunnableConfig
from langgraph.types import Interrupt
__all__ = (
"GraphCallbackHandler",
"GraphInterruptEvent",
"GraphLifecycleEvent",
"GraphLifecycleStatus",
"GraphResumeEvent",
"get_async_graph_callback_manager_for_config",
"get_sync_graph_callback_manager_for_config",
)
GraphLifecycleStatus: TypeAlias = Literal[
"input",
"pending",
"done",
"interrupt_before",
"interrupt_after",
"out_of_steps",
]
"""Allowed lifecycle statuses reported in graph lifecycle callback events."""
@dataclass(frozen=True)
class GraphInterruptEvent:
"""Graph lifecycle event emitted when execution pauses for interrupts."""
run_id: UUID | None
"""Run id for the current graph execution, if available."""
status: GraphLifecycleStatus
"""Loop status when the interrupt was captured."""
checkpoint_id: str
"""Checkpoint id associated with the interrupted execution."""
checkpoint_ns: tuple[str, ...]
"""Checkpoint namespace path for the current graph or subgraph."""
interrupts: tuple[Interrupt, ...]
"""Interrupt payloads that caused the graph to pause."""
@dataclass(frozen=True)
class GraphResumeEvent:
"""Graph lifecycle event emitted when execution resumes from a checkpoint."""
run_id: UUID | None
"""Run id for the current graph execution, if available."""
status: GraphLifecycleStatus
"""Loop status when the resume was captured."""
checkpoint_id: str
"""Checkpoint id the graph resumed from."""
checkpoint_ns: tuple[str, ...]
"""Checkpoint namespace path for the current graph or subgraph."""
GraphLifecycleEvent: TypeAlias = GraphInterruptEvent | GraphResumeEvent
"""Union of all public graph lifecycle callback event payloads.
Use this alias when a callback or helper can receive either interrupt or resume
lifecycle events.
"""
class GraphCallbackHandler(BaseCallbackHandler):
"""Base class for graph-level lifecycle callbacks.
Subclass this handler to observe graph lifecycle transitions that are
specific to LangGraph execution, rather than generic LangChain runnable
callbacks.
Instances can be passed through `config["callbacks"]` when invoking a
graph. Only handlers that inherit from `GraphCallbackHandler` receive these
lifecycle events.
"""
def on_interrupt(self, event: GraphInterruptEvent) -> Any:
"""Run when graph execution pauses due to one or more interrupts.
Args:
event: Interrupt lifecycle event payload.
"""
def on_resume(self, event: GraphResumeEvent) -> Any:
"""Run when graph execution resumes from a persisted checkpoint.
Args:
event: Resume lifecycle event payload.
"""
_MISSING = object()
def _filter_graph_handlers(
handlers: list[BaseCallbackHandler],
) -> list[GraphCallbackHandler]:
return [h for h in handlers if isinstance(h, GraphCallbackHandler)]
def _init_base_manager(
manager: BaseCallbackManager,
handlers: Sequence[GraphCallbackHandler] | None,
inheritable_handlers: Sequence[GraphCallbackHandler] | None,
parent_run_id: UUID | None,
*,
tags: list[str] | None,
inheritable_tags: list[str] | None,
metadata: dict[str, Any] | None,
inheritable_metadata: dict[str, Any] | None,
run_id: UUID | None,
) -> None:
base_handlers: list[BaseCallbackHandler] = []
base_inheritable_handlers: list[BaseCallbackHandler] = []
if handlers is not None:
base_handlers.extend(handlers)
if inheritable_handlers is not None:
base_inheritable_handlers.extend(inheritable_handlers)
BaseCallbackManager.__init__(
manager,
handlers=base_handlers,
inheritable_handlers=base_inheritable_handlers,
parent_run_id=parent_run_id,
tags=tags,
inheritable_tags=inheritable_tags,
metadata=metadata,
inheritable_metadata=inheritable_metadata,
)
manager.run_id = run_id # type: ignore[attr-defined]
def _configure_graph_callbacks(
cls: type[_GraphManagerT],
callbacks: object | None,
*,
run_id: UUID | None,
) -> _GraphManagerT:
if callbacks is None:
return cls(run_id=run_id)
if isinstance(callbacks, cls):
return callbacks.copy(run_id=run_id)
if isinstance(callbacks, (_GraphCallbackManager, _AsyncGraphCallbackManager)):
# Cross-type: extract handlers into the requested cls.
return cls(
handlers=_filter_graph_handlers(callbacks.handlers),
inheritable_handlers=_filter_graph_handlers(callbacks.inheritable_handlers),
parent_run_id=callbacks.parent_run_id,
tags=callbacks.tags.copy(),
inheritable_tags=callbacks.inheritable_tags.copy(),
metadata=callbacks.metadata.copy(),
inheritable_metadata=callbacks.inheritable_metadata.copy(),
run_id=run_id,
)
if isinstance(callbacks, BaseCallbackManager):
return cls(
handlers=_filter_graph_handlers(callbacks.handlers),
inheritable_handlers=_filter_graph_handlers(callbacks.inheritable_handlers),
parent_run_id=callbacks.parent_run_id,
tags=callbacks.tags.copy(),
inheritable_tags=callbacks.inheritable_tags.copy(),
metadata=callbacks.metadata.copy(),
inheritable_metadata=callbacks.inheritable_metadata.copy(),
run_id=run_id,
)
if isinstance(callbacks, GraphCallbackHandler):
return cls((callbacks,), run_id=run_id)
if isinstance(callbacks, (str, bytes)) or not isinstance(callbacks, Sequence):
raise TypeError("callbacks must be a handler, sequence, or manager")
return cls(_filter_graph_handlers(list(callbacks)), run_id=run_id)
def _copy_graph_manager(
manager: _GraphCallbackManager | _AsyncGraphCallbackManager,
cls: type[_GraphManagerT],
run_id: UUID | None | object,
) -> _GraphManagerT:
resolved_run_id: UUID | None
if run_id is _MISSING:
resolved_run_id = manager.run_id
else:
if run_id is not None and not isinstance(run_id, UUID):
raise TypeError("run_id must be a UUID or None")
resolved_run_id = run_id
return cls(
handlers=_filter_graph_handlers(manager.handlers),
inheritable_handlers=_filter_graph_handlers(manager.inheritable_handlers),
parent_run_id=manager.parent_run_id,
tags=manager.tags.copy(),
inheritable_tags=manager.inheritable_tags.copy(),
metadata=manager.metadata.copy(),
inheritable_metadata=manager.inheritable_metadata.copy(),
run_id=resolved_run_id,
)
class _GraphCallbackManager(BaseCallbackManager):
"""Sync dispatcher for graph lifecycle events."""
run_id: UUID | None
def __init__(
self,
handlers: Sequence[GraphCallbackHandler] | None = None,
inheritable_handlers: Sequence[GraphCallbackHandler] | None = None,
parent_run_id: UUID | None = None,
*,
tags: list[str] | None = None,
inheritable_tags: list[str] | None = None,
metadata: dict[str, Any] | None = None,
inheritable_metadata: dict[str, Any] | None = None,
run_id: UUID | None = None,
) -> None:
_init_base_manager(
self,
handlers,
inheritable_handlers,
parent_run_id,
tags=tags,
inheritable_tags=inheritable_tags,
metadata=metadata,
inheritable_metadata=inheritable_metadata,
run_id=run_id,
)
def copy(
self,
*,
run_id: UUID | None | object = _MISSING,
) -> _GraphCallbackManager:
return _copy_graph_manager(self, _GraphCallbackManager, run_id)
@classmethod
def configure(
cls,
callbacks: object | None = None,
*,
run_id: UUID | None = None,
) -> _GraphCallbackManager:
return _configure_graph_callbacks(cls, callbacks, run_id=run_id)
def on_interrupt(self, event: GraphInterruptEvent) -> None:
handle_event(
self.handlers,
"on_interrupt",
None,
event,
)
def on_resume(self, event: GraphResumeEvent) -> None:
handle_event(
self.handlers,
"on_resume",
None,
event,
)
class _AsyncGraphCallbackManager(BaseCallbackManager):
"""Async dispatcher for graph lifecycle events."""
run_id: UUID | None
@property
def is_async(self) -> bool:
"""Return whether the manager is async."""
return True
def __init__(
self,
handlers: Sequence[GraphCallbackHandler] | None = None,
inheritable_handlers: Sequence[GraphCallbackHandler] | None = None,
parent_run_id: UUID | None = None,
*,
tags: list[str] | None = None,
inheritable_tags: list[str] | None = None,
metadata: dict[str, Any] | None = None,
inheritable_metadata: dict[str, Any] | None = None,
run_id: UUID | None = None,
) -> None:
_init_base_manager(
self,
handlers,
inheritable_handlers,
parent_run_id,
tags=tags,
inheritable_tags=inheritable_tags,
metadata=metadata,
inheritable_metadata=inheritable_metadata,
run_id=run_id,
)
def copy(
self,
*,
run_id: UUID | None | object = _MISSING,
) -> _AsyncGraphCallbackManager:
return _copy_graph_manager(self, _AsyncGraphCallbackManager, run_id)
@classmethod
def configure(
cls,
callbacks: object | None = None,
*,
run_id: UUID | None = None,
) -> _AsyncGraphCallbackManager:
return _configure_graph_callbacks(cls, callbacks, run_id=run_id)
async def on_interrupt(self, event: GraphInterruptEvent) -> None:
await ahandle_event(
self.handlers,
"on_interrupt",
None,
event,
)
async def on_resume(self, event: GraphResumeEvent) -> None:
await ahandle_event(
self.handlers,
"on_resume",
None,
event,
)
_GraphManagerT = TypeVar(
"_GraphManagerT", _GraphCallbackManager, _AsyncGraphCallbackManager
)
GraphCallbacks: TypeAlias = (
_GraphCallbackManager
| _AsyncGraphCallbackManager
| BaseCallbackManager
| GraphCallbackHandler
| Sequence[BaseCallbackHandler]
| Sequence[GraphCallbackHandler]
| None
)
def get_sync_graph_callback_manager_for_config(
config: RunnableConfig,
*,
run_id: UUID | None = None,
) -> _GraphCallbackManager:
"""Build a sync graph lifecycle callback manager from a runnable config.
This helper filters `config["callbacks"]` down to handlers that inherit
from `GraphCallbackHandler` and binds the provided `run_id` onto the
returned manager.
"""
return _GraphCallbackManager.configure(
config.get("callbacks"),
run_id=run_id,
)
def get_async_graph_callback_manager_for_config(
config: RunnableConfig,
*,
run_id: UUID | None = None,
) -> _AsyncGraphCallbackManager:
"""Build an async graph lifecycle callback manager from a runnable config.
This helper filters `config["callbacks"]` down to handlers that inherit
from `GraphCallbackHandler` and binds the provided `run_id` onto the
returned manager.
"""
return _AsyncGraphCallbackManager.configure(
config.get("callbacks"),
run_id=run_id,
)
+11 -100
View File
@@ -62,11 +62,6 @@ from langgraph._internal._constants import (
from langgraph._internal._replay import ReplayState
from langgraph._internal._scratchpad import PregelScratchpad
from langgraph._internal._typing import EMPTY_SEQ, MISSING
from langgraph.callbacks import (
GraphInterruptEvent,
GraphLifecycleEvent,
GraphResumeEvent,
)
from langgraph.channels.base import BaseChannel
from langgraph.channels.untracked_value import UntrackedValue
from langgraph.constants import TAG_HIDDEN
@@ -122,7 +117,6 @@ from langgraph.types import (
CachePolicy,
Command,
Durability,
Interrupt,
PregelExecutableTask,
RetryPolicy,
Send,
@@ -209,8 +203,6 @@ class PregelLoop:
tasks: dict[str, PregelExecutableTask]
output: None | dict[str, Any] | Any = None
updated_channels: set[str] | None = None
_graph_lifecycle_events: deque[GraphLifecycleEvent]
_has_graph_lifecycle_callbacks: bool
# public
@@ -236,7 +228,6 @@ class PregelLoop:
migrate_checkpoint: Callable[[Checkpoint], None] | None = None,
retry_policy: Sequence[RetryPolicy] = (),
cache_policy: CachePolicy | None = None,
has_graph_lifecycle_callbacks: bool = False,
) -> None:
self.stream = stream
self.config = config
@@ -261,8 +252,6 @@ class PregelLoop:
self.retry_policy = retry_policy
self.cache_policy = cache_policy
self.durability = durability
self._has_graph_lifecycle_callbacks = has_graph_lifecycle_callbacks
self._graph_lifecycle_events = deque()
if self.stream is not None and CONFIG_KEY_STREAM in config[CONF]:
self.stream = DuplexStream(self.stream, config[CONF][CONFIG_KEY_STREAM])
scratchpad: PregelScratchpad | None = config[CONF].get(CONFIG_KEY_SCRATCHPAD)
@@ -314,40 +303,6 @@ class PregelLoop:
)
self.prev_checkpoint_config = None
def _push_graph_lifecycle_event(
self,
kind: Literal["resume", "interrupt"],
*,
interrupts: tuple[Interrupt, ...] = (),
) -> None:
if kind == "resume":
self._graph_lifecycle_events.append(
GraphResumeEvent(
run_id=None,
status=self.status,
checkpoint_id=self.checkpoint["id"],
checkpoint_ns=self.checkpoint_ns,
)
)
elif kind == "interrupt":
self._graph_lifecycle_events.append(
GraphInterruptEvent(
run_id=None,
status=self.status,
checkpoint_id=self.checkpoint["id"],
checkpoint_ns=self.checkpoint_ns,
interrupts=interrupts,
)
)
else:
msg = f"Unknown graph lifecycle event type: {kind}"
raise AssertionError(msg)
def _pop_lifecycle_event(self) -> GraphLifecycleEvent | None:
if not self._graph_lifecycle_events:
return None
return self._graph_lifecycle_events.popleft()
def put_writes(self, task_id: str, writes: WritesT) -> None:
"""Put writes for a task, to be read by the next tick."""
if not writes:
@@ -692,7 +647,7 @@ class PregelLoop:
# writes so that interrupt() calls re-fire instead of returning
# stale values. But if we're actively resuming, keep them —
# multi-interrupt scenarios need previously resolved values preserved.
is_time_traveling = self.is_replaying and (
if self.is_replaying and (
# Time-travel to a subgraph checkpoint: the parent sets
# RESUMING=True (it can't distinguish time-travel from resume),
# so we check if this subgraph's own ns is in checkpoint_map.
@@ -710,8 +665,7 @@ class PregelLoop:
# (subgraph input is a Send arg, not a Command)
or configurable.get(CONFIG_KEY_RESUMING, False)
)
)
if is_time_traveling:
):
self.checkpoint_pending_writes = [
w for w in self.checkpoint_pending_writes if w[1] != RESUME
]
@@ -766,26 +720,6 @@ class PregelLoop:
if k in self.checkpoint["channel_versions"]:
version = self.checkpoint["channel_versions"][k]
self.checkpoint["versions_seen"][INTERRUPT][k] = version
# When time-traveling (replaying from a specific checkpoint),
# save a fork checkpoint so the replayed execution creates a
# new branch. Without this, if the execution hits an interrupt
# before after_tick() runs, no new checkpoint is created —
# the parent's latest checkpoint remains the old one and
# subsequent resumes load the wrong state.
# Skip for update_state forks (source=update/fork) since they
# already have their own fork checkpoint.
if is_time_traveling and self.checkpoint_metadata.get("source") not in (
"update",
"fork",
):
# Clear old INTERRUPT writes from the loaded checkpoint.
# The fork will have a new checkpoint_id which changes
# task IDs — stale interrupt writes would accumulate and
# confuse the multiple-interrupt check in future resumes.
self.checkpoint_pending_writes = [
w for w in self.checkpoint_pending_writes if w[1] != INTERRUPT
]
self._put_checkpoint({"source": "fork"})
# produce values output
self._emit(
"values", map_output_values, self.output_keys, True, self.channels
@@ -828,28 +762,14 @@ class PregelLoop:
if not self.is_nested:
# Pass the resolved before-bound checkpoint ID so subgraphs can
# find their corresponding checkpoint without re-fetching the
# parent. For forks (source=update/fork), use the fork's parent
# parent. For forks (source=update), use the fork's parent
# checkpoint ID since the fork was created after the subgraph's
# checkpoints from the original execution.
#
# Only gate on is_time_traveling (not is_replaying). When the
# client resumes with an explicit checkpoint_id that happens to
# point at the current head (e.g. LangGraph Studio sending
# `checkpoint: {checkpoint_id}` alongside Command(resume=...)),
# is_replaying is True but is_time_traveling is False. In that
# case subgraphs should load their latest checkpoint normally,
# not go through ReplayState's before-bound lookup which would
# miss subgraph checkpoints created during processing of the
# current parent step.
replay_state: ReplayState | None = None
if is_time_traveling:
if self.is_replaying:
replay_checkpoint_id = self.checkpoint["id"]
if (
self.checkpoint_metadata.get("source")
in (
"update",
"fork",
)
self.checkpoint_metadata.get("source") == "update"
and self.prev_checkpoint_config
):
replay_checkpoint_id = self.prev_checkpoint_config[CONF].get(
@@ -865,8 +785,6 @@ class PregelLoop:
)
# set flag
self.status = "pending"
if is_resuming:
self._push_graph_lifecycle_event("resume")
return updated_channels
def _put_checkpoint(self, metadata: CheckpointMetadata) -> None:
@@ -967,10 +885,8 @@ class PregelLoop:
self._put_checkpoint(self.checkpoint_metadata)
self._put_pending_writes()
# suppress interrupt
if isinstance(exc_value, GraphInterrupt) and not self.is_nested:
interrupt = exc_value
interrupts = tuple(interrupt.args[0]) if interrupt.args else ()
self._push_graph_lifecycle_event("interrupt", interrupts=interrupts)
suppress = isinstance(exc_value, GraphInterrupt) and not self.is_nested
if suppress:
# emit one last "values" event, with pending writes applied
if (
hasattr(self, "tasks")
@@ -997,11 +913,12 @@ class PregelLoop:
self.channels,
)
# emit INTERRUPT if exception is empty (otherwise emitted by put_writes)
if not interrupt.args or not interrupt.args[0]:
interrupt_payload = interrupt.args[0] if interrupt.args else ()
if exc_value is not None and (not exc_value.args or not exc_value.args[0]):
self._emit(
"updates",
lambda: iter([{INTERRUPT: interrupt_payload}]),
lambda: iter(
[{INTERRUPT: cast(GraphInterrupt, exc_value).args[0]}]
),
)
# save final output
self.output = read_channels(self.channels, self.output_keys)
@@ -1123,7 +1040,6 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
migrate_checkpoint: Callable[[Checkpoint], None] | None = None,
retry_policy: Sequence[RetryPolicy] = (),
cache_policy: CachePolicy | None = None,
has_graph_lifecycle_callbacks: bool = False,
) -> None:
super().__init__(
input,
@@ -1145,7 +1061,6 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
retry_policy=retry_policy,
cache_policy=cache_policy,
durability=durability,
has_graph_lifecycle_callbacks=has_graph_lifecycle_callbacks,
)
self.stack = ExitStack()
if checkpointer:
@@ -1221,7 +1136,6 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
# context manager
def __enter__(self) -> Self:
self._graph_lifecycle_events = deque()
if not self.checkpointer:
saved = None
elif self.checkpoint_config[CONF].get(CONFIG_KEY_CHECKPOINT_ID):
@@ -1322,7 +1236,6 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
migrate_checkpoint: Callable[[Checkpoint], None] | None = None,
retry_policy: Sequence[RetryPolicy] = (),
cache_policy: CachePolicy | None = None,
has_graph_lifecycle_callbacks: bool = False,
) -> None:
super().__init__(
input,
@@ -1344,7 +1257,6 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
retry_policy=retry_policy,
cache_policy=cache_policy,
durability=durability,
has_graph_lifecycle_callbacks=has_graph_lifecycle_callbacks,
)
self.stack = AsyncExitStack()
if checkpointer:
@@ -1423,7 +1335,6 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
# context manager
async def __aenter__(self) -> Self:
self._graph_lifecycle_events = deque()
if not self.checkpointer:
saved = None
elif self.checkpoint_config[CONF].get(CONFIG_KEY_CHECKPOINT_ID):
@@ -0,0 +1,807 @@
"""Protocol-native content-block message handler for StreamingHandler.
Emits structured content-block lifecycle events (message-start,
content-block-start/delta/finish, message-finish) instead of raw
``(AIMessageChunk, metadata)`` tuples. The existing
:class:`~langgraph.pregel._messages.StreamMessagesHandler` is NOT
modified this handler is only activated when
``__protocol_messages_stream`` is ``True`` in the run's configurable.
"""
from __future__ import annotations
import json
from collections.abc import AsyncIterator, Callable, Iterator, Sequence
from dataclasses import dataclass, field
from typing import Any, TypeVar, cast
from uuid import UUID, uuid4
from langchain_core.callbacks import BaseCallbackHandler
from langchain_core.messages import AIMessageChunk, BaseMessage
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, LLMResult
from langgraph._internal._constants import NS_SEP
from langgraph.constants import TAG_HIDDEN, TAG_NOSTREAM
from langgraph.pregel.protocol import StreamChunk
from langgraph.stream._types import (
ContentBlockDeltaData,
ContentBlockFinishData,
ContentBlockStartData,
FinishReason,
InvalidToolCallBlock,
MessageErrorData,
MessageStartData,
ReasoningBlock,
TextBlock,
ToolCallBlock,
UsageInfo,
)
try:
from langchain_core.tracers._streaming import _StreamingCallbackHandler
except ImportError:
_StreamingCallbackHandler = object # type: ignore
T = TypeVar("T")
Meta = tuple[tuple[str, ...], dict[str, Any]]
PROTOCOL_MESSAGES_STREAM_KEY = "__protocol_messages_stream"
# ---------------------------------------------------------------------------
# Content-block accumulation helpers
# ---------------------------------------------------------------------------
# A "compatible content block" is a dict matching one of the protocol block
# TypedDicts (TextBlock, ReasoningBlock, ToolCallChunkBlock, etc.).
CompatBlock = dict[str, Any]
@dataclass
class _ProtocolRunState:
"""Per-run state for tracking the active message lifecycle."""
message_id: str | None = None
started: bool = False
blocks: dict[int, CompatBlock] = field(default_factory=dict)
usage: dict[str, Any] | None = None
def _accumulate_block(accumulated: CompatBlock, delta: CompatBlock) -> CompatBlock:
"""Merge *delta* into *accumulated*, returning the updated block."""
btype = accumulated.get("type", "text")
if btype == "text" and delta.get("type", "text") == "text":
accumulated["text"] = accumulated.get("text", "") + delta.get("text", "")
elif btype == "reasoning" and delta.get("type") == "reasoning":
accumulated["reasoning"] = accumulated.get("reasoning", "") + delta.get(
"reasoning", ""
)
elif btype == "tool_call_chunk" and delta.get("type") == "tool_call_chunk":
accumulated["args"] = accumulated.get("args", "") + delta.get("args", "")
if delta.get("id") is not None:
accumulated["id"] = delta["id"]
if delta.get("name") is not None:
accumulated["name"] = delta["name"]
return accumulated
def _delta_block(previous: CompatBlock, current: CompatBlock) -> CompatBlock | None:
"""Compute the delta between *previous* and *current*.
Returns ``None`` if there is nothing new to emit.
"""
btype = current.get("type", "text")
if btype == "text":
prev_text = previous.get("text", "")
cur_text = current.get("text", "")
delta_text = cur_text[len(prev_text) :]
if not delta_text:
return None
return TextBlock(type="text", text=delta_text)
elif btype == "reasoning":
prev_r = previous.get("reasoning", "")
cur_r = current.get("reasoning", "")
delta_r = cur_r[len(prev_r) :]
if not delta_r:
return None
return ReasoningBlock(type="reasoning", reasoning=delta_r)
elif btype == "tool_call_chunk":
prev_args = previous.get("args", "")
cur_args = current.get("args", "")
delta_args = cur_args[len(prev_args) :]
has_meta = current.get("id") is not None or current.get("name") is not None
if not delta_args and not has_meta:
return None
result: CompatBlock = {"type": "tool_call_chunk", "args": delta_args}
if current.get("id") is not None and previous.get("id") is None:
result["id"] = current["id"]
if current.get("name") is not None and previous.get("name") is None:
result["name"] = current["name"]
return result
# Unrecognized block type — pass through unchanged
return current
def _finalize_block(block: CompatBlock) -> CompatBlock:
"""Convert a ``tool_call_chunk`` block to a finalized ``tool_call`` or
``invalid_tool_call`` block. Other block types pass through unchanged.
"""
if block.get("type") != "tool_call_chunk":
return block
raw_args = block.get("args", "{}")
try:
parsed_args = json.loads(raw_args) if raw_args else {}
return ToolCallBlock(
type="tool_call",
id=block.get("id", ""),
name=block.get("name", ""),
args=parsed_args,
)
except (json.JSONDecodeError, TypeError):
return InvalidToolCallBlock(
type="invalid_tool_call",
id=block.get("id"),
name=block.get("name"),
args=raw_args,
error="Failed to parse tool call arguments as JSON",
)
def _normalize_finish_reason(value: Any) -> FinishReason:
"""Map provider-specific stop reasons to protocol finish reasons."""
if value == "length":
return "length"
if value == "content_filter":
return "content_filter"
if value in ("tool_use", "tool_calls"):
return "tool_use"
# "end_turn", "stop", None, and anything else → "stop"
return "stop"
def _accumulate_usage(
current: dict[str, Any] | None, delta: Any
) -> dict[str, Any] | None:
"""Accumulate usage metadata from streamed chunks."""
if not isinstance(delta, dict):
return current
if current is None:
return dict(delta)
for key in ("input_tokens", "output_tokens", "total_tokens", "cached_tokens"):
if key in delta:
current[key] = current.get(key, 0) + delta[key]
# Merge detail dicts
for detail_key in ("input_token_details", "output_token_details"):
if detail_key in delta and isinstance(delta[detail_key], dict):
if detail_key not in current:
current[detail_key] = {}
current[detail_key].update(delta[detail_key])
return current
def _to_protocol_usage(usage: dict[str, Any] | None) -> UsageInfo | None:
"""Convert LangChain usage metadata to protocol ``UsageInfo``."""
if usage is None:
return None
result: dict[str, Any] = {}
if "input_tokens" in usage:
result["input_tokens"] = usage["input_tokens"]
if "output_tokens" in usage:
result["output_tokens"] = usage["output_tokens"]
if "total_tokens" in usage:
result["total_tokens"] = usage["total_tokens"]
if "cached_tokens" in usage:
result["cached_tokens"] = usage["cached_tokens"]
return UsageInfo(**result) if result else None
# ---------------------------------------------------------------------------
# Extracting content blocks from LangChain messages
# ---------------------------------------------------------------------------
def _extract_blocks_from_chunk(msg: AIMessageChunk) -> list[tuple[int, CompatBlock]]:
"""Extract ``(index, block)`` pairs from an ``AIMessageChunk``.
LangChain stores content in several places:
- ``content: str`` a single text block at index 0
- ``content: list[dict]`` explicit content blocks with their own types
- ``tool_call_chunks`` separate list for streamed tool call deltas
"""
blocks: list[tuple[int, CompatBlock]] = []
content = msg.content
if isinstance(content, str) and content:
blocks.append((0, dict(TextBlock(type="text", text=content))))
elif isinstance(content, list):
for i, item in enumerate(content):
if not isinstance(item, dict):
continue
ctype = item.get("type", "")
if ctype == "text" and item.get("text"):
blocks.append(
(
item.get("index", i),
dict(TextBlock(type="text", text=item["text"])),
)
)
elif ctype in ("reasoning_content", "reasoning", "thinking"):
reasoning_text = (
item.get("reasoning_content")
or item.get("reasoning")
or item.get("thinking", "")
)
if reasoning_text:
blocks.append(
(
item.get("index", i),
dict(
ReasoningBlock(
type="reasoning", reasoning=reasoning_text
)
),
)
)
# Tool call chunks live in a separate field
for tc in msg.tool_call_chunks or []:
idx = tc.get("index")
if idx is None:
# Assign indices after text content blocks
idx = len(blocks)
block: CompatBlock = {"type": "tool_call_chunk", "args": tc.get("args", "")}
if tc.get("id") is not None:
block["id"] = tc["id"]
if tc.get("name") is not None:
block["name"] = tc["name"]
blocks.append((idx, block))
return blocks
# ---------------------------------------------------------------------------
# The handler
# ---------------------------------------------------------------------------
class StreamProtocolMessagesHandler(BaseCallbackHandler, _StreamingCallbackHandler):
"""Callback handler that emits content-block protocol events.
Activated when ``__protocol_messages_stream`` is ``True`` in the run's
configurable metadata. Emits ``StreamChunk`` tuples of the form
``(namespace, "messages", data)`` where *data* is one of the
``MessagesData`` event types (``message-start``, ``content-block-start``,
etc.).
"""
run_inline = True
def __init__(
self,
stream: Callable[[StreamChunk], None],
subgraphs: bool,
*,
parent_ns: tuple[str, ...] | None = None,
) -> None:
self.stream = stream
self.subgraphs = subgraphs
self.parent_ns = parent_ns
# Per-run metadata: run_id → (namespace, metadata_dict)
self.metadata: dict[UUID, Meta] = {}
# Per-run protocol state for streamed messages
self.protocol_runs: dict[UUID, _ProtocolRunState] = {}
# Stable message ID mapping: run_id → message_id
self.stable_message_ids: dict[UUID, str] = {}
# Seen message IDs for deduplication of chain-emitted messages
self.seen: set[str | int] = set()
def _emit(self, meta: Meta, data: Any) -> None:
"""Emit a protocol event as a StreamChunk.
The node name from *meta* is embedded at ``"__node__"`` so the
stream pump can lift it into ``params.node`` without changing the
``StreamChunk`` tuple shape.
"""
node = meta[1].get("langgraph_node")
if node and isinstance(data, dict):
data = {**data, "__node__": node}
self.stream((meta[0], "messages", data))
# -- Chat model callbacks -----------------------------------------------
def on_chat_model_start(
self,
serialized: dict[str, Any],
messages: list[list[BaseMessage]],
*,
run_id: UUID,
parent_run_id: UUID | None = None,
tags: list[str] | None = None,
metadata: dict[str, Any] | None = None,
**kwargs: Any,
) -> Any:
if metadata and (not tags or (TAG_NOSTREAM not in tags)):
ns = tuple(cast(str, metadata["langgraph_checkpoint_ns"]).split(NS_SEP))[
:-1
]
if not self.subgraphs and len(ns) > 0 and ns != self.parent_ns:
return
if tags:
if filtered := [t for t in tags if not t.startswith("seq:step")]:
metadata["tags"] = filtered
self.metadata[run_id] = (ns, metadata)
self.protocol_runs[run_id] = _ProtocolRunState()
def on_llm_new_token(
self,
token: str,
*,
chunk: ChatGenerationChunk | None = None,
run_id: UUID,
parent_run_id: UUID | None = None,
tags: list[str] | None = None,
**kwargs: Any,
) -> Any:
if not isinstance(chunk, ChatGenerationChunk):
return
meta = self.metadata.get(run_id)
if meta is None:
return
state = self.protocol_runs.get(run_id)
if state is None:
return
msg = chunk.message
if not isinstance(msg, AIMessageChunk):
return
# Emit message-start on first token
if not state.started:
message_id = self._normalize_message_id(msg, run_id)
state.message_id = message_id
state.started = True
start_data = dict(
MessageStartData(
event="message-start",
role="ai",
)
)
if message_id:
start_data["message_id"] = message_id
self._emit(meta, start_data)
# Extract content blocks from this chunk
extracted = _extract_blocks_from_chunk(msg)
for idx, delta_block in extracted:
if idx not in state.blocks:
# New block — emit content-block-start
state.blocks[idx] = dict(delta_block)
# Start block has empty content placeholder
start_block = _make_start_block(delta_block)
self._emit(
meta,
ContentBlockStartData(
event="content-block-start",
index=idx,
content_block=start_block,
),
)
# Then emit the first delta
first_delta = _delta_block(
_make_start_block(delta_block), state.blocks[idx]
)
if first_delta is not None:
self._emit(
meta,
ContentBlockDeltaData(
event="content-block-delta",
index=idx,
content_block=first_delta,
),
)
else:
# Existing block — compute delta, accumulate, emit
previous = dict(state.blocks[idx])
state.blocks[idx] = _accumulate_block(state.blocks[idx], delta_block)
delta = _delta_block(previous, state.blocks[idx])
if delta is not None:
self._emit(
meta,
ContentBlockDeltaData(
event="content-block-delta",
index=idx,
content_block=delta,
),
)
# Accumulate usage from chunk
if msg.usage_metadata:
state.usage = _accumulate_usage(state.usage, msg.usage_metadata)
def on_llm_end(
self,
response: LLMResult,
*,
run_id: UUID,
parent_run_id: UUID | None = None,
**kwargs: Any,
) -> Any:
meta = self.metadata.pop(run_id, None)
state = self.protocol_runs.pop(run_id, None)
if meta is None or state is None:
return
# Extract finish reason and usage from the final generation
finish_reason: FinishReason = "stop"
final_usage = state.usage
if response.generations and response.generations[0]:
gen = response.generations[0][0]
if isinstance(gen, ChatGeneration):
final_msg = gen.message
# Get finish reason from response_metadata
rm = getattr(final_msg, "response_metadata", {}) or {}
raw_reason = rm.get("finish_reason") or rm.get("stop_reason")
if raw_reason:
finish_reason = _normalize_finish_reason(raw_reason)
# If we have tool calls in the final message, infer tool_use
if (
finish_reason == "stop"
and hasattr(final_msg, "tool_calls")
and final_msg.tool_calls
):
finish_reason = "tool_use"
# Get usage from final message if not accumulated from chunks
if final_usage is None and hasattr(final_msg, "usage_metadata"):
final_usage = (
dict(final_msg.usage_metadata)
if final_msg.usage_metadata
else None
)
# If we never got streaming tokens (non-streamed model call),
# emit the full message lifecycle now
if not state.started:
self._emit_full_message(meta, final_msg, finish_reason, final_usage)
return
# Close out any open content blocks
for idx in sorted(state.blocks):
finalized = _finalize_block(state.blocks[idx])
self._emit(
meta,
ContentBlockFinishData(
event="content-block-finish",
index=idx,
content_block=finalized,
),
)
# Emit message-finish
finish_data: dict[str, Any] = {
"event": "message-finish",
"reason": finish_reason,
}
usage_info = _to_protocol_usage(final_usage)
if usage_info is not None:
finish_data["usage"] = usage_info
self._emit(meta, finish_data)
# Track the message as seen for dedup
if state.message_id:
self.seen.add(state.message_id)
def on_llm_error(
self,
error: BaseException,
*,
run_id: UUID,
parent_run_id: UUID | None = None,
**kwargs: Any,
) -> Any:
meta = self.metadata.pop(run_id, None)
state = self.protocol_runs.pop(run_id, None)
self.stable_message_ids.pop(run_id, None)
if meta is None or state is None:
return
if state.started:
self._emit(
meta,
MessageErrorData(
event="error",
message=str(error),
),
)
# -- Chain callbacks (for node-level message dedup) ---------------------
def on_chain_start(
self,
serialized: dict[str, Any],
inputs: dict[str, Any],
*,
run_id: UUID,
parent_run_id: UUID | None = None,
tags: list[str] | None = None,
metadata: dict[str, Any] | None = None,
**kwargs: Any,
) -> Any:
if (
metadata
and kwargs.get("name") == metadata.get("langgraph_node")
and (not tags or TAG_HIDDEN not in tags)
):
ns = tuple(cast(str, metadata["langgraph_checkpoint_ns"]).split(NS_SEP))[
:-1
]
if not self.subgraphs and len(ns) > 0:
return
self.metadata[run_id] = (ns, metadata)
# Record input message IDs for deduplication
self._record_seen_messages(inputs)
def on_chain_end(
self,
response: Any,
*,
run_id: UUID,
parent_run_id: UUID | None = None,
**kwargs: Any,
) -> Any:
meta = self.metadata.pop(run_id, None)
if meta is None:
return
# Emit protocol events for any new messages in the node's output
self._emit_chain_messages(meta, response)
def on_chain_error(
self,
error: BaseException,
*,
run_id: UUID,
parent_run_id: UUID | None = None,
**kwargs: Any,
) -> Any:
self.metadata.pop(run_id, None)
# -- Iterator taps (required by _StreamingCallbackHandler) ---------------
def tap_output_aiter(
self, run_id: UUID, output: AsyncIterator[T]
) -> AsyncIterator[T]:
return output
def tap_output_iter(self, run_id: UUID, output: Iterator[T]) -> Iterator[T]:
return output
# -- Internal helpers ---------------------------------------------------
def _normalize_message_id(self, msg: BaseMessage, run_id: UUID) -> str | None:
"""Return a stable message ID for this run, creating one if needed."""
msg_id = msg.id
if msg_id is None:
msg_id = self.stable_message_ids.get(run_id)
if msg_id is None:
msg_id = f"run-{run_id}"
self.stable_message_ids[run_id] = msg_id
# Mutate the message for consistency downstream
if msg.id != msg_id:
msg.id = msg_id
return msg_id
def _emit_full_message(
self,
meta: Meta,
msg: BaseMessage,
finish_reason: FinishReason,
usage: dict[str, Any] | None,
role: str = "ai",
) -> None:
"""Emit a complete message lifecycle for a non-streamed model call."""
message_id = msg.id or str(uuid4())
if message_id in self.seen:
return
self.seen.add(message_id)
# message-start
start_data = dict(
MessageStartData(
event="message-start",
role=role,
)
)
start_data["message_id"] = message_id
self._emit(meta, start_data)
# Extract all blocks from the final message
blocks = _extract_final_blocks(msg)
for idx, block in blocks:
# content-block-start with the full content
self._emit(
meta,
ContentBlockStartData(
event="content-block-start",
index=idx,
content_block=_make_start_block(block),
),
)
# content-block-delta with the full content
delta = _delta_block(_make_start_block(block), block)
if delta is not None:
self._emit(
meta,
ContentBlockDeltaData(
event="content-block-delta",
index=idx,
content_block=delta,
),
)
# content-block-finish
finalized = _finalize_block(block)
self._emit(
meta,
ContentBlockFinishData(
event="content-block-finish",
index=idx,
content_block=finalized,
),
)
# message-finish
finish_data: dict[str, Any] = {
"event": "message-finish",
"reason": finish_reason,
}
usage_info = _to_protocol_usage(usage)
if usage_info is not None:
finish_data["usage"] = usage_info
self._emit(meta, finish_data)
def _record_seen_messages(self, obj: Any) -> None:
"""Record message IDs from node inputs for deduplication."""
if isinstance(obj, BaseMessage):
if obj.id is not None:
self.seen.add(obj.id)
elif isinstance(obj, dict):
for value in obj.values():
self._record_seen_messages(value)
elif isinstance(obj, Sequence) and not isinstance(obj, (str, bytes)):
for item in obj:
self._record_seen_messages(item)
def _emit_chain_messages(self, meta: Meta, response: Any) -> None:
"""Emit protocol events for messages found in chain output."""
from langgraph.types import Command
if isinstance(response, Command):
self._emit_chain_messages(meta, response.update)
elif isinstance(response, BaseMessage):
self._emit_message_from_chain(meta, response)
elif isinstance(response, Sequence) and not isinstance(response, (str, bytes)):
for item in response:
if isinstance(item, Command):
self._emit_chain_messages(meta, item.update)
elif isinstance(item, BaseMessage):
self._emit_message_from_chain(meta, item)
elif isinstance(response, dict):
for value in response.values():
if isinstance(value, BaseMessage):
self._emit_message_from_chain(meta, value)
elif isinstance(value, Sequence) and not isinstance(
value, (str, bytes)
):
for item in value:
if isinstance(item, BaseMessage):
self._emit_message_from_chain(meta, item)
def _emit_message_from_chain(self, meta: Meta, msg: BaseMessage) -> None:
"""Emit a full message lifecycle for a message from a chain output,
deduplicating against previously-seen messages."""
if msg.id is not None and msg.id in self.seen:
return
if msg.id is None:
msg.id = str(uuid4())
# Determine role and finish reason
role = "ai"
if hasattr(msg, "type"):
if msg.type == "human":
role = "human"
elif msg.type == "system":
role = "system"
finish_reason: FinishReason = "stop"
rm = getattr(msg, "response_metadata", {}) or {}
raw_reason = rm.get("finish_reason") or rm.get("stop_reason")
if raw_reason:
finish_reason = _normalize_finish_reason(raw_reason)
if finish_reason == "stop" and hasattr(msg, "tool_calls") and msg.tool_calls:
finish_reason = "tool_use"
raw_usage = getattr(msg, "usage_metadata", None)
usage = dict(raw_usage) if raw_usage else None
self._emit_full_message(meta, msg, finish_reason, usage, role=role)
# ---------------------------------------------------------------------------
# Block extraction for finalized (non-streamed) messages
# ---------------------------------------------------------------------------
def _extract_final_blocks(msg: BaseMessage) -> list[tuple[int, CompatBlock]]:
"""Extract ``(index, block)`` pairs from a finalized ``AIMessage``."""
blocks: list[tuple[int, CompatBlock]] = []
content = msg.content
if isinstance(content, str) and content:
blocks.append((0, dict(TextBlock(type="text", text=content))))
elif isinstance(content, list):
for i, item in enumerate(content):
if not isinstance(item, dict):
continue
ctype = item.get("type", "")
if ctype == "text" and item.get("text"):
blocks.append((i, dict(TextBlock(type="text", text=item["text"]))))
elif ctype in ("reasoning_content", "reasoning", "thinking"):
reasoning_text = (
item.get("reasoning_content")
or item.get("reasoning")
or item.get("thinking", "")
)
if reasoning_text:
blocks.append(
(
i,
dict(
ReasoningBlock(
type="reasoning", reasoning=reasoning_text
)
),
)
)
# Finalized tool calls (already parsed, not chunks)
for tc in getattr(msg, "tool_calls", None) or []:
idx = len(blocks)
blocks.append(
(
idx,
dict(
ToolCallBlock(
type="tool_call",
id=tc.get("id", ""),
name=tc.get("name", ""),
args=tc.get("args", {}),
)
),
)
)
return blocks
def _make_start_block(block: CompatBlock) -> CompatBlock:
"""Create an empty start placeholder for a content block."""
btype = block.get("type", "text")
if btype == "text":
return TextBlock(type="text", text="")
elif btype == "reasoning":
return ReasoningBlock(type="reasoning", reasoning="")
elif btype == "tool_call_chunk":
result: CompatBlock = {"type": "tool_call_chunk", "args": ""}
if "id" in block:
result["id"] = block["id"]
if "name" in block:
result["name"] = block["name"]
return result
elif btype == "tool_call":
# Already finalized — return as-is for start event
return ToolCallBlock(
type="tool_call",
id=block.get("id", ""),
name=block.get("name", ""),
args=block.get("args", {}),
)
return dict(block)
__all__ = ["PROTOCOL_MESSAGES_STREAM_KEY", "StreamProtocolMessagesHandler"]
+30 -65
View File
@@ -16,7 +16,7 @@ from collections.abc import (
Mapping,
Sequence,
)
from dataclasses import is_dataclass, replace
from dataclasses import is_dataclass
from functools import partial
from inspect import isclass
from typing import (
@@ -96,12 +96,6 @@ from langgraph._internal._runnable import (
coerce_to_runnable,
)
from langgraph._internal._typing import MISSING, DeprecatedKwargs
from langgraph.callbacks import (
GraphInterruptEvent,
GraphResumeEvent,
get_async_graph_callback_manager_for_config,
get_sync_graph_callback_manager_for_config,
)
from langgraph.channels.base import BaseChannel
from langgraph.channels.topic import Topic
from langgraph.config import get_config
@@ -134,6 +128,10 @@ from langgraph.pregel._loop import (
SyncPregelLoop,
)
from langgraph.pregel._messages import StreamMessagesHandler
from langgraph.pregel._messages_v2 import (
PROTOCOL_MESSAGES_STREAM_KEY,
StreamProtocolMessagesHandler,
)
from langgraph.pregel._read import DEFAULT_BOUND, PregelNode
from langgraph.pregel._retry import RetryPolicy
from langgraph.pregel._runner import PregelRunner
@@ -2591,10 +2589,6 @@ class Pregel(
name=config.get("run_name", self.get_name()),
run_id=config.get("run_id"),
)
graph_callback_manager = get_sync_graph_callback_manager_for_config(
config,
run_id=run_manager.run_id,
)
try:
# assign defaults
(
@@ -2626,8 +2620,15 @@ class Pregel(
# set up messages stream mode
if "messages" in stream_modes:
ns_ = cast(str | None, config[CONF].get(CONFIG_KEY_CHECKPOINT_NS))
_msg_cls = (
StreamProtocolMessagesHandler
if config.get("configurable", {}).get(
PROTOCOL_MESSAGES_STREAM_KEY, False
)
else StreamMessagesHandler
)
run_manager.inheritable_handlers.append(
StreamMessagesHandler(
_msg_cls(
stream.put,
subgraphs,
parent_ns=tuple(ns_.split(NS_SEP)) if ns_ else None,
@@ -2679,17 +2680,6 @@ class Pregel(
_output_mapper = self._output_mapper if version == "v2" else None
_state_mapper = self._state_mapper if version == "v2" else None
def emit_graph_lifecycle_events(loop: SyncPregelLoop) -> None:
while (event := loop._pop_lifecycle_event()) is not None:
if isinstance(event, GraphResumeEvent):
graph_callback_manager.on_resume(
replace(event, run_id=graph_callback_manager.run_id)
)
else:
graph_callback_manager.on_interrupt(
replace(event, run_id=graph_callback_manager.run_id)
)
with SyncPregelLoop(
input,
stream=StreamProtocol(stream.put, stream_modes),
@@ -2710,9 +2700,7 @@ class Pregel(
migrate_checkpoint=self._migrate_checkpoint,
retry_policy=self.retry_policy,
cache_policy=self.cache_policy,
has_graph_lifecycle_callbacks=bool(graph_callback_manager.handlers),
) as loop:
emit_graph_lifecycle_events(loop)
# create runner
runner = PregelRunner(
submit=config[CONF].get(
@@ -2774,11 +2762,9 @@ class Pregel(
_state_mapper,
)
loop.after_tick()
emit_graph_lifecycle_events(loop)
# wait for checkpoint
if durability_ == "sync":
loop._put_checkpoint_fut.result()
emit_graph_lifecycle_events(loop)
# emit output
yield from _output(
stream_mode,
@@ -2953,10 +2939,6 @@ class Pregel(
name=config.get("run_name", self.get_name()),
run_id=config.get("run_id"),
)
graph_callback_manager = get_async_graph_callback_manager_for_config(
config,
run_id=run_manager.run_id,
)
# if running from astream_log() run each proc with streaming
do_stream = (
next(
@@ -2964,7 +2946,10 @@ class Pregel(
True
for h in run_manager.handlers
if isinstance(h, _StreamingCallbackHandler)
and not isinstance(h, StreamMessagesHandler)
and not isinstance(
h,
(StreamMessagesHandler, StreamProtocolMessagesHandler),
)
),
False,
)
@@ -3001,10 +2986,16 @@ class Pregel(
config[CONF][CONFIG_KEY_CHECKPOINT_NS] = recast_checkpoint_ns(ns)
# set up messages stream mode
if "messages" in stream_modes:
# namespace can be None in a root level graph?
ns_ = cast(str | None, config[CONF].get(CONFIG_KEY_CHECKPOINT_NS))
_msg_cls = (
StreamProtocolMessagesHandler
if config.get("configurable", {}).get(
PROTOCOL_MESSAGES_STREAM_KEY, False
)
else StreamMessagesHandler
)
run_manager.inheritable_handlers.append(
StreamMessagesHandler(
_msg_cls(
stream_put,
subgraphs,
parent_ns=tuple(ns_.split(NS_SEP)) if ns_ else None,
@@ -3071,28 +3062,6 @@ class Pregel(
_output_mapper = self._output_mapper if version == "v2" else None
_state_mapper = self._state_mapper if version == "v2" else None
async def aemit_graph_lifecycle_events(loop: AsyncPregelLoop) -> None:
while (event := loop._pop_lifecycle_event()) is not None:
if isinstance(event, GraphResumeEvent):
await graph_callback_manager.on_resume(
GraphResumeEvent(
run_id=graph_callback_manager.run_id,
status=event.status,
checkpoint_id=event.checkpoint_id,
checkpoint_ns=event.checkpoint_ns,
)
)
else:
await graph_callback_manager.on_interrupt(
GraphInterruptEvent(
run_id=graph_callback_manager.run_id,
status=event.status,
checkpoint_id=event.checkpoint_id,
checkpoint_ns=event.checkpoint_ns,
interrupts=event.interrupts,
)
)
async with AsyncPregelLoop(
input,
stream=StreamProtocol(stream.put_nowait, stream_modes),
@@ -3113,9 +3082,7 @@ class Pregel(
migrate_checkpoint=self._migrate_checkpoint,
retry_policy=self.retry_policy,
cache_policy=self.cache_policy,
has_graph_lifecycle_callbacks=bool(graph_callback_manager.handlers),
) as loop:
await aemit_graph_lifecycle_events(loop)
# create runner
runner = PregelRunner(
submit=config[CONF].get(
@@ -3197,7 +3164,6 @@ class Pregel(
):
yield o
loop.after_tick()
await aemit_graph_lifecycle_events(loop)
# wait for checkpoint
if durability_ == "sync":
await cast(asyncio.Future, loop._put_checkpoint_fut)
@@ -3206,8 +3172,6 @@ class Pregel(
if _cleanup_waiter is not None:
await _cleanup_waiter()
await aemit_graph_lifecycle_events(loop)
# emit output
for o in _output(
stream_mode,
@@ -3715,14 +3679,15 @@ def _coerce_checkpoint_values(payload: Any, mapper: Callable[[Any], Any]) -> Non
def _build_server_info(
config: RunnableConfig, parent_runtime: Runtime[Any]
) -> ServerInfo | None:
"""Build ServerInfo from config configurable.
"""Build ServerInfo from config metadata and configurable.
The server puts assistant_id/graph_id in config configurable and the
The server puts assistant_id/graph_id in config metadata and the
authenticated user dict in configurable["langgraph_auth_user"].
"""
metadata = config.get("metadata") or {}
configurable = config.get(CONF) or {}
assistant_id = configurable.get("assistant_id")
graph_id = configurable.get("graph_id")
assistant_id = metadata.get("assistant_id")
graph_id = metadata.get("graph_id")
# Read authenticated user from configurable (set by LangGraph Server).
# We prefer isinstance(BaseUser) but fall back to hasattr("identity")
@@ -0,0 +1,49 @@
"""Stream protocol types and infrastructure for LangGraph."""
from langgraph.stream._convert import STREAM_V2_MODES, convert_to_protocol_event
from langgraph.stream._event_log import EventLog
from langgraph.stream._mux import AsyncStreamMux, StreamMux
from langgraph.stream._types import (
InterruptPayload,
ProtocolEvent,
StreamTransformer,
)
from langgraph.stream.chat_model_stream import AsyncChatModelStream, ChatModelStream
from langgraph.stream.run_stream import (
AsyncGraphRunStream,
AsyncSubgraphRunStream,
GraphRunStream,
SubgraphRunStream,
create_async_graph_run_stream,
create_graph_run_stream,
)
from langgraph.stream.stream_channel import StreamChannel, is_stream_channel
from langgraph.stream.streaming_handler import StreamingHandler
from langgraph.stream.transformers import (
MessagesTransformer,
ValuesTransformer,
)
__all__ = [
"STREAM_V2_MODES",
"AsyncStreamMux",
"AsyncChatModelStream",
"AsyncGraphRunStream",
"AsyncSubgraphRunStream",
"ChatModelStream",
"EventLog",
"GraphRunStream",
"InterruptPayload",
"MessagesTransformer",
"ProtocolEvent",
"StreamChannel",
"StreamMux",
"SubgraphRunStream",
"StreamTransformer",
"StreamingHandler",
"ValuesTransformer",
"convert_to_protocol_event",
"create_async_graph_run_stream",
"create_graph_run_stream",
"is_stream_channel",
]
@@ -0,0 +1,79 @@
"""Convert raw ``StreamChunk`` tuples to ``ProtocolEvent`` envelopes.
Each ``StreamMode`` is mapped to a ``ProtocolEvent`` whose ``method``
field matches the mode name and whose ``params.data`` wraps the
original payload.
"""
from __future__ import annotations
import time
from typing import Any
from langgraph.stream._types import ProtocolEvent, _ProtocolEventParams
from langgraph.types import StreamMode
#: All stream modes requested by ``StreamingHandler`` when calling the
#: underlying ``stream()`` / ``astream()``.
STREAM_V2_MODES: list[StreamMode] = [
"values",
"updates",
"messages",
"custom",
"checkpoints",
"tasks",
"debug",
]
_SUPPORTED_MODES: set[str] = set(STREAM_V2_MODES)
def convert_to_protocol_event(
ns: tuple[str, ...],
mode: str,
payload: Any,
*,
node: str | None = None,
) -> ProtocolEvent | None:
"""Convert a ``StreamChunk`` to a ``ProtocolEvent``.
Returns ``None`` for unsupported or unknown modes.
The ``seq`` field is left as ``0`` here; the :class:`StreamMux` is
the sole seq assigner and overwrites it inside ``push()``.
Parameters
----------
ns:
Namespace tuple from the ``StreamChunk``.
mode:
Stream mode string (``"values"``, ``"updates"``, etc.).
payload:
The raw payload from the stream.
node:
Optional node name for provenance.
"""
if mode not in _SUPPORTED_MODES:
return None
params: _ProtocolEventParams = {
"namespace": list(ns),
"timestamp": _now_ms(),
"data": payload,
}
if node is not None:
params["node"] = node
return ProtocolEvent(
type="event",
method=mode,
params=params,
)
def _now_ms() -> int:
"""Current time in milliseconds since epoch."""
return int(time.time() * 1000)
__all__ = ["STREAM_V2_MODES", "convert_to_protocol_event"]
@@ -0,0 +1,136 @@
"""Replayable append-only event buffer for StreamingHandler.
``EventLog`` stores protocol events in an ordered list and supports
multiple independent async iterators, each with their own cursor
offset. Subscribers that join mid-stream replay from a given offset
without losing earlier events.
"""
from __future__ import annotations
import asyncio
import threading
from typing import Generic, TypeVar
T = TypeVar("T")
def _resolve_future(fut: asyncio.Future[None]) -> None:
"""Set a future's result if it hasn't already completed or been cancelled.
Runs on the event loop thread (scheduled via ``call_soon_threadsafe``)
so that the ``done()`` check and ``set_result`` are atomic with
respect to cancellation.
"""
if not fut.done():
fut.set_result(None)
class EventLog(Generic[T]):
"""Append-only event buffer with cursor-based async iteration.
Multiple consumers can subscribe independently and each will see
every event from their starting offset onward.
"""
__slots__ = ("_items", "_closed", "_error", "_waiters", "_lock")
def __init__(self) -> None:
self._items: list[T] = []
self._closed = False
self._error: BaseException | None = None
self._waiters: list[asyncio.Future[None]] = []
self._lock = threading.Lock()
# -- Producer API -------------------------------------------------------
def append(self, item: T) -> None:
"""Append an event and wake all waiting consumers."""
with self._lock:
if self._closed:
raise RuntimeError("EventLog is closed")
self._items.append(item)
self._wake_all()
def close(self) -> None:
"""Mark the log as complete. Iterators will end gracefully."""
with self._lock:
self._closed = True
self._wake_all()
def fail(self, error: BaseException) -> None:
"""Mark the log as failed. Iterators will raise *error*."""
with self._lock:
self._error = error
self._closed = True
self._wake_all()
# -- Consumer API -------------------------------------------------------
def __aiter__(self) -> _Cursor[T]:
"""Return a fresh cursor from the beginning of the log."""
return _Cursor(self)
# -- Inspection ---------------------------------------------------------
def __len__(self) -> int:
return len(self._items)
def __getitem__(self, index: int) -> T:
return self._items[index]
@property
def closed(self) -> bool:
return self._closed
# -- Internal -----------------------------------------------------------
def _wake_all(self) -> None:
for fut in self._waiters:
try:
fut.get_loop().call_soon_threadsafe(_resolve_future, fut)
except RuntimeError:
# Loop already closed — ignore.
pass
self._waiters.clear()
class _Cursor(Generic[T]):
"""An independent async iterator over an :class:`EventLog`."""
__slots__ = ("_log", "_offset")
def __init__(self, log: EventLog[T]) -> None:
self._log = log
self._offset = 0
def __aiter__(self) -> _Cursor[T]:
return self
async def __anext__(self) -> T:
while True:
with self._log._lock:
if self._offset < len(self._log._items):
item = self._log._items[self._offset]
self._offset += 1
return item
if self._log._error is not None:
raise self._log._error
if self._log._closed:
raise StopAsyncIteration
# Nothing available yet — register a waiter
fut: asyncio.Future[None] = asyncio.get_running_loop().create_future()
self._log._waiters.append(fut)
# Wait outside the lock
try:
await fut
except asyncio.CancelledError:
with self._log._lock:
try:
self._log._waiters.remove(fut)
except ValueError:
pass # Already removed by _wake_all
raise
__all__ = ["EventLog"]
+425
View File
@@ -0,0 +1,425 @@
"""Central event dispatcher with transformer pipeline for StreamingHandler.
``StreamMux`` is the sync-safe core: it holds the main
:class:`EventLog`, tracks discovered namespaces for subgraph stream
creation, and pipes every event through the registered
:class:`StreamTransformer` pipeline before appending it to the log.
``AsyncStreamMux`` extends the base with async subscription endpoints
(output futures, namespace waiters, filtered event iteration).
"""
from __future__ import annotations
import asyncio
import time
from collections.abc import AsyncIterator
from typing import Any
from langgraph.stream._event_log import EventLog
from langgraph.stream._types import InterruptPayload, ProtocolEvent, StreamTransformer
from langgraph.stream.stream_channel import StreamChannel, is_stream_channel
class StreamMux:
"""Sync-safe event dispatcher for the StreamingHandler infrastructure.
The mux owns the main event log, applies the transformer pipeline to
every incoming event, and tracks namespace discovery and latest values.
For async subscription endpoints (output futures, namespace waiters,
filtered event iteration), use :class:`AsyncStreamMux`.
"""
def __init__(self, transformers: list[StreamTransformer] | None = None) -> None:
self._event_log: EventLog[ProtocolEvent] = EventLog()
self._transformers: list[StreamTransformer] = list(transformers or [])
self._channels: list[StreamChannel[Any]] = []
self._current_namespace: list[str] = []
self._next_emit_seq: int = 0
# Namespace discovery: maps top-level ns segment → True
self._discovered_ns: dict[str, bool] = {}
# Latest values per namespace (list-of-strings key)
self._latest_values: dict[str, Any] = {}
# Interrupt tracking
self._interrupts: list[InterruptPayload] = []
self._interrupted = False
# Closed state
self._closed = False
self._error: BaseException | None = None
# -- Producer API -------------------------------------------------------
def push(self, event: ProtocolEvent) -> None:
"""Push an event through the transformer pipeline and into the log.
Each registered transformer's ``process()`` is called in order.
If any transformer returns ``False``, the event is suppressed
(not appended to the main log).
"""
if self._closed:
return
# Mux is the sole seq assigner — ensures all events in the log
# (including those from StreamChannel forwarders) share a single
# monotonically increasing counter.
event["seq"] = self._next_emit_seq
self._next_emit_seq += 1
# Track namespace
ns = event["params"].get("namespace", [])
if ns:
top_segment = ns[0]
if top_segment not in self._discovered_ns:
self._discovered_ns[top_segment] = True
self._on_ns_discovered(top_segment)
# Track values
if event["method"] == "values":
ns_key = _ns_key(ns)
self._latest_values[ns_key] = event["params"]["data"]
# Track interrupts from values events
if event["method"] == "values":
data = event["params"]["data"]
if isinstance(data, dict) and "__interrupt__" in data:
interrupt_info = data["__interrupt__"]
if isinstance(interrupt_info, (list, tuple)):
for item in interrupt_info:
iid = getattr(item, "id", None) or str(id(item))
self._interrupts.append(
InterruptPayload(
interrupt_id=iid,
payload=item,
)
)
self._interrupted = True
# Run transformer pipeline
self._current_namespace = ns
keep = True
for transformer in self._transformers:
result = transformer.process(event)
if result is False:
keep = False
self._current_namespace = []
# Append to main log if not suppressed
if keep:
self._event_log.append(event)
def close(self, output: Any = None) -> None:
"""Close the mux, finalizing transformers and the event log."""
if self._closed:
return
self._closed = True
# Finalize transformers (optional method)
for transformer in self._transformers:
if hasattr(transformer, "finalize"):
transformer.finalize()
# Close wired channels
for channel in self._channels:
channel._close()
# Close the event log
self._event_log.close()
def fail(self, error: BaseException) -> None:
"""Fail the mux, propagating the error to transformers and channels."""
if self._closed:
return
self._closed = True
self._error = error
# Fail transformers (optional method)
for transformer in self._transformers:
if hasattr(transformer, "fail"):
transformer.fail(error)
# Fail wired channels
for channel in self._channels:
channel._fail(error)
# Fail the event log
self._event_log.fail(error)
# -- Inspection ---------------------------------------------------------
@property
def interrupted(self) -> bool:
return self._interrupted
@property
def interrupts(self) -> list[InterruptPayload]:
return list(self._interrupts)
@property
def event_log(self) -> EventLog[ProtocolEvent]:
return self._event_log
def get_latest_values(self, ns: list[str] | None = None) -> Any:
"""Return the most recent values for a namespace."""
return self._latest_values.get(_ns_key(ns or []))
# -- Internal -----------------------------------------------------------
def _on_ns_discovered(self, segment: str) -> None:
"""Hook called when a new top-level namespace segment is discovered.
The base implementation is a no-op. :class:`AsyncStreamMux`
overrides this to wake namespace waiters.
"""
def register_transformer(self, transformer: StreamTransformer) -> None:
"""Register a new transformer and replay all buffered events through it.
This is the safe way to add a late-arriving transformer after the mux
has already started processing events. The sequence is:
1. Snapshot the current log length (no await no gap possible in
asyncio's cooperative threading model).
2. Append the transformer so future ``push()`` calls reach it.
3. Replay events ``[0, snapshot)`` through the transformer.
4. If the mux is already closed, call ``finalize()`` immediately so
the transformer's log/channel terminates cleanly.
``process()`` is only called for events whose namespace starts with
any prefix callers that need namespace filtering should do so inside
their ``process()`` implementation, or wrap this call with their own
filtering logic.
"""
snapshot = len(self._event_log)
self._transformers.append(transformer)
for i in range(snapshot):
transformer.process(self._event_log[i])
if self._closed:
if hasattr(transformer, "finalize"):
transformer.finalize()
def wire_channels(self, projection: Any) -> None:
"""Scan *projection* for :class:`StreamChannel` instances and wire them.
For each ``StreamChannel`` found, registers a push callback that
appends a :class:`ProtocolEvent` directly to the main event log
with ``method`` set to the channel's name.
Channel events bypass the transformer pipeline (matching the JS
implementation). They are visible to raw event iteration and
remote SDK clients but not to other transformers' ``process()``.
"""
if projection is None:
return
items: dict[str, Any] = {}
if isinstance(projection, dict):
items = projection
elif hasattr(projection, "__dict__"):
items = vars(projection)
for _key, value in items.items():
if is_stream_channel(value):
channel: StreamChannel[Any] = value
self._channels.append(channel)
def _make_forwarder(ch: StreamChannel[Any]) -> Any:
def _forward(item: Any) -> None:
if self._closed:
return
# Append directly to the event log, bypassing
# the transformer pipeline. This matches the JS
# implementation and avoids re-entrancy bugs
# (namespace clobbering, infinite recursion).
self._event_log.append(
ProtocolEvent(
type="event",
seq=self._next_emit_seq,
method=ch.channel_name,
params={
"namespace": list(self._current_namespace),
"timestamp": int(time.time() * 1000),
"data": item,
},
)
)
self._next_emit_seq += 1
return _forward
channel._wire(_make_forwarder(channel))
class AsyncStreamMux(StreamMux):
"""Async extension of :class:`StreamMux`.
Adds output futures, namespace waiters, and async subscription
endpoints (``subscribe_events``, ``subscribe_subgraphs``,
``get_output_future``).
"""
def __init__(self, transformers: list[StreamTransformer] | None = None) -> None:
super().__init__(transformers)
# Waiters for new namespace discovery
self._ns_waiters: list[asyncio.Future[None]] = []
# Output promise tracking
self._output_futures: dict[str, asyncio.Future[Any]] = {}
# -- Producer API overrides ---------------------------------------------
def close(self, output: Any = None) -> None:
"""Close the mux, resolving all output futures."""
if self._closed:
return
# Let the base class finalize transformers, channels, and event log
super().close(output)
# Resolve output futures
for ns_key, fut in self._output_futures.items():
if not fut.done():
value = self._latest_values.get(ns_key)
try:
fut.get_loop().call_soon_threadsafe(fut.set_result, value)
except RuntimeError:
pass
# Wake namespace waiters
self._wake_ns_waiters()
def fail(self, error: BaseException) -> None:
"""Fail the mux, rejecting all output futures."""
if self._closed:
return
# Let the base class fail transformers, channels, and event log
super().fail(error)
# Reject output futures
for fut in self._output_futures.values():
if not fut.done():
try:
fut.get_loop().call_soon_threadsafe(fut.set_exception, error)
except RuntimeError:
pass
# Wake namespace waiters
self._wake_ns_waiters()
# -- Consumer API -------------------------------------------------------
def subscribe_events(
self, path: list[str] | None = None
) -> AsyncIterator[ProtocolEvent]:
"""Return an async iterator over events matching *path*.
If *path* is ``None`` or empty, all events are yielded.
Otherwise, only events whose namespace starts with *path*
are yielded.
"""
cursor = aiter(self._event_log)
if not path:
return cursor
return _FilteredEventIterator(cursor, path)
async def subscribe_subgraphs(
self, path: list[str] | None = None, offset: int = 0
) -> AsyncIterator[str]:
"""Yield top-level namespace segments as they are discovered.
Each yielded value is the first namespace segment of a newly
discovered subgraph (e.g. ``"agent:0"``).
"""
yielded: set[str] = set()
while True:
# Yield any newly discovered namespaces
for ns_segment in list(self._discovered_ns):
if ns_segment not in yielded:
# Filter by path prefix if specified
if path:
if not ns_segment.startswith(path[0]):
continue
yielded.add(ns_segment)
yield ns_segment
if self._closed:
return
# Wait for new namespaces
loop = asyncio.get_running_loop()
fut: asyncio.Future[None] = loop.create_future()
self._ns_waiters.append(fut)
await fut
def get_output_future(self, ns: list[str] | None = None) -> asyncio.Future[Any]:
"""Get or create an output future for a namespace.
The future resolves to the latest ``values`` event data when
the mux is closed.
"""
ns_key = _ns_key(ns or [])
if ns_key not in self._output_futures:
loop = asyncio.get_running_loop()
self._output_futures[ns_key] = loop.create_future()
# If already closed, resolve immediately
if self._closed:
value = self._latest_values.get(ns_key)
if self._error is not None:
self._output_futures[ns_key].set_exception(self._error)
else:
self._output_futures[ns_key].set_result(value)
return self._output_futures[ns_key]
# -- Internal -----------------------------------------------------------
def _on_ns_discovered(self, segment: str) -> None:
"""Wake namespace waiters when a new namespace is discovered."""
self._wake_ns_waiters()
def _wake_ns_waiters(self) -> None:
for fut in self._ns_waiters:
if not fut.done():
try:
fut.get_loop().call_soon_threadsafe(fut.set_result, None)
except RuntimeError:
pass
self._ns_waiters.clear()
class _FilteredEventIterator:
"""Async iterator that filters events by namespace prefix."""
__slots__ = ("_cursor", "_path")
def __init__(self, cursor: AsyncIterator[ProtocolEvent], path: list[str]) -> None:
self._cursor = cursor
self._path = path
def __aiter__(self) -> _FilteredEventIterator:
return self
async def __anext__(self) -> ProtocolEvent:
while True:
event = await self._cursor.__anext__()
ns = event["params"].get("namespace", [])
if _ns_starts_with(ns, self._path):
return event
def _ns_key(ns: list[str] | tuple[str, ...]) -> str:
"""Convert a namespace list to a hashable key."""
return "|".join(ns)
def _ns_starts_with(ns: list[str], prefix: list[str]) -> bool:
"""Check if *ns* starts with *prefix*."""
if len(ns) < len(prefix):
return False
return ns[: len(prefix)] == prefix
__all__ = ["AsyncStreamMux", "StreamMux"]
+167
View File
@@ -0,0 +1,167 @@
"""Protocol types for StreamingHandler.
Re-exports CDDL-derived types from ``langchain-protocol`` and defines
in-process-only types needed by the LangGraph streaming infrastructure.
"""
from __future__ import annotations
from typing import Any, Protocol, runtime_checkable
# ---------------------------------------------------------------------------
# Re-exports from langchain-protocol (CDDL-derived)
# ---------------------------------------------------------------------------
# Primitives
# Content blocks
# Messages data
# Tools data
from langchain_protocol import (
Annotation,
Citation,
ContentBlock,
ContentBlockDeltaData,
ContentBlockFinishData,
ContentBlockStartData,
FinalizedContentBlock,
FinishReason,
InvalidToolCallBlock,
MessageErrorData,
MessageFinishData,
MessageMetadata,
MessageRole,
MessagesData,
MessageStartData,
MetadataScalar,
Namespace,
ReasoningBlock,
TextBlock,
ToolCallBlock,
ToolCallChunkBlock,
ToolErrorData,
ToolFinishedData,
ToolOutputDeltaData,
ToolsData,
ToolStartedData,
UsageInfo,
)
from typing_extensions import NotRequired, TypedDict
# ---------------------------------------------------------------------------
# In-process types (not in the CDDL spec)
# ---------------------------------------------------------------------------
class _ProtocolEventParams(TypedDict):
"""Payload envelope for a :class:`ProtocolEvent`."""
namespace: Namespace
timestamp: int
node: NotRequired[str]
data: Any
class ProtocolEvent(TypedDict):
"""A single protocol event emitted by the StreamingHandler infrastructure.
``method`` corresponds to a
:pydata:`~langgraph.types.StreamMode` value (``"messages"``,
``"updates"``, etc.).
"""
type: str # always "event"
seq: NotRequired[int] # assigned by StreamMux.push(); absent before push()
method: str # StreamMode value
params: _ProtocolEventParams
@runtime_checkable
class StreamTransformer(Protocol):
"""Extension point for custom stream projections.
Implementations are registered with ``StreamingHandler`` and receive every
:class:`ProtocolEvent` before it is appended to the event log.
Any :class:`~langgraph.stream.stream_channel.StreamChannel` instances
returned by ``init()`` are automatically wired to the protocol event
stream by the mux.
"""
def init(self) -> Any:
"""Return the initial projection value.
Called once before the run. Any
:class:`~langgraph.stream.stream_channel.StreamChannel` instances
in the return value are automatically wired by the mux.
"""
...
def process(self, event: ProtocolEvent) -> bool:
"""Process an event.
Return ``True`` to keep the event in the log, ``False`` to suppress
it.
"""
...
def finalize(self) -> None:
"""Called once when the run completes successfully.
Optional the mux auto-closes any :class:`StreamChannel` instances,
so transformers that only use channels can omit this.
"""
...
def fail(self, err: BaseException) -> None:
"""Called once when the run fails.
Optional the mux auto-fails any :class:`StreamChannel` instances,
so transformers that only use channels can omit this.
"""
...
class InterruptPayload(TypedDict):
"""An interrupt produced during a StreamingHandler run."""
interrupt_id: str
payload: Any
__all__ = [
# Primitives (re-exported)
"Namespace",
"MessageRole",
"MessageMetadata",
"MetadataScalar",
# Content blocks (re-exported)
"TextBlock",
"ReasoningBlock",
"ToolCallBlock",
"ToolCallChunkBlock",
"InvalidToolCallBlock",
"ContentBlock",
"FinalizedContentBlock",
"Annotation",
"Citation",
# Messages data (re-exported)
"MessagesData",
"MessageStartData",
"ContentBlockStartData",
"ContentBlockDeltaData",
"ContentBlockFinishData",
"MessageFinishData",
"MessageErrorData",
"FinishReason",
"UsageInfo",
# Tools data (re-exported)
"ToolsData",
"ToolStartedData",
"ToolOutputDeltaData",
"ToolFinishedData",
"ToolErrorData",
# In-process types
"ProtocolEvent",
"StreamTransformer",
"InterruptPayload",
]
@@ -0,0 +1,402 @@
"""Per-message streaming objects for StreamingHandler.
``ChatModelStream`` is the synchronous variant returned by
``GraphRunStream.messages``. Properties (``.text``, ``.reasoning``,
``.usage``) return final accumulated values.
``AsyncChatModelStream`` is the asynchronous variant returned by
``AsyncGraphRunStream.messages``. Projections are dual
async-iterable + awaitable (e.g. ``async for delta in msg.text``
or ``full = await msg.text``).
"""
from __future__ import annotations
import asyncio
from collections.abc import Callable, Generator, Iterator
from typing import Any
from langgraph.stream._types import UsageInfo
# ---------------------------------------------------------------------------
# Sync dual projection — iterable of deltas, str() for accumulated text
# ---------------------------------------------------------------------------
class _SyncDualProjection:
"""Pump-driven sync iterable of string deltas.
Iterating yields incremental text fragments as the pump delivers
new ``content-block-delta`` events. Calling ``str()`` drains the
pump and returns the full accumulated string.
This is the sync counterpart of :class:`_DualProjection` (the async
variant used by ``AsyncChatModelStream``).
"""
__slots__ = ("_stream", "_attr", "_pump_one")
def __init__(
self,
stream: ChatModelStream,
attr: str,
pump_one: Callable[[], bool],
) -> None:
self._stream = stream
self._attr = attr
self._pump_one = pump_one
def __iter__(self) -> Iterator[str]:
prev_len = 0
while True:
cur = getattr(self._stream, self._attr)
if len(cur) > prev_len:
yield cur[prev_len:]
prev_len = len(cur)
if self._stream._done:
return
if not self._pump_one():
# Source exhausted — yield any remaining
cur = getattr(self._stream, self._attr)
if len(cur) > prev_len:
yield cur[prev_len:]
return
def __str__(self) -> str:
while not self._stream._done:
if not self._pump_one():
break
return getattr(self._stream, self._attr)
def __repr__(self) -> str:
return repr(getattr(self._stream, self._attr))
def __bool__(self) -> bool:
return bool(getattr(self._stream, self._attr))
# ---------------------------------------------------------------------------
# Sync variant
# ---------------------------------------------------------------------------
class ChatModelStream:
"""Synchronous per-message object for a single LLM response.
Created by :class:`~langgraph.stream.transformers.MessagesTransformer`
and yielded by ``GraphRunStream.messages``. By the time the sync
iterator yields a ``ChatModelStream``, the message lifecycle is
complete and all properties contain their final values.
Projections:
- ``.text`` accumulated text content (``str``)
- ``.reasoning`` accumulated reasoning content (``str``)
- ``.usage`` :class:`UsageInfo` or ``None``
- ``.namespace`` / ``.node`` provenance metadata
"""
def __init__(
self,
*,
namespace: list[str] | None = None,
node: str | None = None,
message_id: str | None = None,
) -> None:
self._namespace = namespace or []
self._node = node
self._message_id = message_id
# Accumulated state
self._text_acc = ""
self._reasoning_acc = ""
self._usage_value: UsageInfo | None = None
self._done = False
# Optional pump for sync streaming (set via _bind_pump)
self._pump_one: Callable[[], bool] | None = None
# -- Pump binding (called by GraphRunStream) ---------------------------
def _bind_pump(self, pump_one: Callable[[], bool]) -> None:
"""Bind a pump function for sync token-by-token streaming.
When bound, ``.text`` and ``.reasoning`` return
:class:`_SyncDualProjection` instances that drive the pump and
yield deltas as the LLM produces tokens.
"""
self._pump_one = pump_one
# -- Public projections ------------------------------------------------
@property
def text(self) -> str | _SyncDualProjection:
"""Text content.
When a pump is bound (sync streaming), returns a
:class:`_SyncDualProjection` iterable of deltas,
``str()`` for the full accumulated text. Otherwise returns
the accumulated text string directly.
"""
if self._pump_one is not None and not self._done:
return _SyncDualProjection(self, "_text_acc", self._pump_one)
return self._text_acc
@property
def reasoning(self) -> str | _SyncDualProjection:
"""Reasoning content.
Same dual behavior as :attr:`text`.
"""
if self._pump_one is not None and not self._done:
return _SyncDualProjection(self, "_reasoning_acc", self._pump_one)
return self._reasoning_acc
@property
def usage(self) -> UsageInfo | None:
"""Usage info, available after the message finishes."""
if self._pump_one is not None and not self._done:
while not self._done:
if not self._pump_one():
break
return self._usage_value
@property
def namespace(self) -> list[str]:
return self._namespace
@property
def node(self) -> str | None:
return self._node
@property
def message_id(self) -> str | None:
return self._message_id
@property
def done(self) -> bool:
return self._done
# -- Internal API (called by MessagesTransformer) ----------------------
def _push_content_block_delta(self, data: dict[str, Any]) -> None:
"""Process a ``content-block-delta`` event."""
block = data.get("content_block", {})
btype = block.get("type", "")
if btype == "text":
delta_text = block.get("text", "")
if delta_text:
self._text_acc += delta_text
elif btype == "reasoning":
delta_r = block.get("reasoning", "")
if delta_r:
self._reasoning_acc += delta_r
def _push_content_block_finish(self, data: dict[str, Any]) -> None:
"""Process a ``content-block-finish`` event."""
block = data.get("content_block", {})
btype = block.get("type", "")
if btype == "text":
full_text = block.get("text", "")
if full_text and full_text != self._text_acc:
self._text_acc = full_text
elif btype == "reasoning":
full_r = block.get("reasoning", "")
if full_r and full_r != self._reasoning_acc:
self._reasoning_acc = full_r
def _finish(self, data: dict[str, Any]) -> None:
"""Process a ``message-finish`` event."""
self._done = True
self._usage_value = data.get("usage")
def _fail(self, error: BaseException) -> None:
"""Process a ``message-error`` event."""
self._done = True
# ---------------------------------------------------------------------------
# Async dual-projection helpers
# ---------------------------------------------------------------------------
class _DualProjection:
"""Async iterable of deltas that is also awaitable for the final value.
When iterated, yields delta values (e.g. text fragments) as they arrive.
When awaited, returns the accumulated final value (e.g. full text string).
"""
def __init__(self) -> None:
self._deltas: list[Any] = []
self._done = False
self._error: BaseException | None = None
self._waiters: list[asyncio.Future[None]] = []
self._final_value: Any = None
self._final_set = False
# -- Producer API (called by AsyncChatModelStream) ---------------------
def _push(self, delta: Any) -> None:
"""Add a new delta value."""
self._deltas.append(delta)
self._wake()
def _finish(self, accumulated: Any) -> None:
"""Set the final accumulated value and mark as done."""
self._final_value = accumulated
self._final_set = True
self._done = True
self._wake()
def _fail(self, error: BaseException) -> None:
self._error = error
self._done = True
self._wake()
def _wake(self) -> None:
for fut in self._waiters:
if not fut.done():
try:
fut.get_loop().call_soon_threadsafe(fut.set_result, None)
except RuntimeError:
pass
self._waiters.clear()
# -- Async iterable (yields deltas) ------------------------------------
def __aiter__(self) -> _DualProjectionIterator:
return _DualProjectionIterator(self)
# -- Awaitable (returns final value) -----------------------------------
def __await__(self) -> Generator[Any, None, Any]:
return self._await_impl().__await__()
async def _await_impl(self) -> Any:
while not self._final_set:
if self._error is not None:
raise self._error
loop = asyncio.get_running_loop()
fut: asyncio.Future[None] = loop.create_future()
self._waiters.append(fut)
await fut
if self._error is not None:
raise self._error
return self._final_value
class _DualProjectionIterator:
"""Async iterator over a :class:`_DualProjection`'s deltas."""
__slots__ = ("_proj", "_offset")
def __init__(self, proj: _DualProjection) -> None:
self._proj = proj
self._offset = 0
def __aiter__(self) -> _DualProjectionIterator:
return self
async def __anext__(self) -> Any:
while True:
if self._offset < len(self._proj._deltas):
item = self._proj._deltas[self._offset]
self._offset += 1
return item
if self._proj._error is not None:
raise self._proj._error
if self._proj._done:
raise StopAsyncIteration
loop = asyncio.get_running_loop()
fut: asyncio.Future[None] = loop.create_future()
self._proj._waiters.append(fut)
await fut
# ---------------------------------------------------------------------------
# Async variant
# ---------------------------------------------------------------------------
class AsyncChatModelStream(ChatModelStream):
"""Asynchronous per-message streaming object for a single LLM response.
Created by :class:`~langgraph.stream.transformers.MessagesTransformer`
and yielded by ``AsyncGraphRunStream.messages``. Content-block events
are fed into this object until ``message-finish``.
Projections:
- ``.text`` async iterable of text deltas; awaitable for full text
- ``.reasoning`` async iterable of reasoning deltas; awaitable for
full reasoning text
- ``.usage`` awaitable for :class:`UsageInfo`
- ``.namespace`` / ``.node`` provenance metadata
"""
def __init__(
self,
*,
namespace: list[str] | None = None,
node: str | None = None,
message_id: str | None = None,
) -> None:
super().__init__(namespace=namespace, node=node, message_id=message_id)
self._text_proj = _DualProjection()
self._reasoning_proj = _DualProjection()
self._usage_proj = _DualProjection()
# -- Public projections (override sync properties) ---------------------
@property
def text(self) -> _DualProjection:
"""Text content — async iterable of deltas, awaitable for full text."""
return self._text_proj
@property
def reasoning(self) -> _DualProjection:
"""Reasoning content — async iterable of deltas, awaitable for full text."""
return self._reasoning_proj
@property
def usage(self) -> _DualProjection:
"""Usage info — awaitable for :class:`UsageInfo`."""
return self._usage_proj
# -- Internal API (extend base to also drive projections) --------------
def _push_content_block_delta(self, data: dict[str, Any]) -> None:
"""Process a ``content-block-delta`` event."""
super()._push_content_block_delta(data)
block = data.get("content_block", {})
btype = block.get("type", "")
if btype == "text":
delta_text = block.get("text", "")
if delta_text:
self._text_proj._push(delta_text)
elif btype == "reasoning":
delta_r = block.get("reasoning", "")
if delta_r:
self._reasoning_proj._push(delta_r)
def _finish(self, data: dict[str, Any]) -> None:
"""Process a ``message-finish`` event."""
super()._finish(data)
self._text_proj._finish(self._text_acc)
self._reasoning_proj._finish(self._reasoning_acc)
self._usage_proj._finish(self._usage_value)
def _fail(self, error: BaseException) -> None:
"""Process a ``message-error`` event."""
super()._fail(error)
self._text_proj._fail(error)
self._reasoning_proj._fail(error)
self._usage_proj._fail(error)
__all__ = ["AsyncChatModelStream", "ChatModelStream", "_SyncDualProjection"]
@@ -0,0 +1,783 @@
"""GraphRunStream and AsyncGraphRunStream for StreamingHandler.
These are the top-level objects returned by
``StreamingHandler.stream()`` / ``StreamingHandler.astream()``.
They wrap a :class:`StreamMux` and expose named
projections (``.values``, ``.messages``, ``.subgraphs``, ``.output``)
for ergonomic consumption.
"""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator, Callable, Iterator
from typing import Any
from langgraph.stream._convert import convert_to_protocol_event
from langgraph.stream._event_log import EventLog
from langgraph.stream._mux import AsyncStreamMux, StreamMux
from langgraph.stream._types import InterruptPayload, ProtocolEvent, StreamTransformer
from langgraph.stream.chat_model_stream import AsyncChatModelStream, ChatModelStream
from langgraph.stream.transformers import MessagesTransformer, ValuesTransformer
# ---------------------------------------------------------------------------
# Values projection — dual async-iterable + awaitable
# ---------------------------------------------------------------------------
class _ValuesProjection:
"""Async iterable of intermediate state snapshots; awaitable for final."""
def __init__(
self,
mux: AsyncStreamMux,
values_transformer: ValuesTransformer,
ns: list[str],
mapper: Callable[[Any], Any] | None = None,
) -> None:
self._mux = mux
self._values_transformer = values_transformer
self._ns = ns
self._mapper = mapper
def __aiter__(self) -> AsyncIterator[Any]:
return _ValuesIterator(self._values_transformer, self._ns, self._mapper)
def __await__(self) -> Any:
return self._await_impl().__await__()
async def _await_impl(self) -> Any:
value = await self._mux.get_output_future(self._ns)
if value is not None and self._mapper is not None:
return self._mapper(value)
return value
class _ValuesIterator:
"""Filters the values log to events matching a namespace."""
def __init__(
self,
transformer: ValuesTransformer,
ns: list[str],
mapper: Callable[[Any], Any] | None = None,
) -> None:
self._cursor = aiter(transformer.values_log)
self._ns = ns
self._mapper = mapper
def __aiter__(self) -> _ValuesIterator:
return self
async def __anext__(self) -> Any:
while True:
item = await self._cursor.__anext__()
item_ns = item.get("namespace", [])
if item_ns == self._ns:
data = item["data"]
if data is not None and self._mapper is not None:
return self._mapper(data)
return data
# ---------------------------------------------------------------------------
# Messages projection
# ---------------------------------------------------------------------------
class _MessagesProjection:
"""Async iterable of :class:`AsyncChatModelStream` instances."""
def __init__(self, messages_transformer: MessagesTransformer) -> None:
self._transformer = messages_transformer
def __aiter__(self) -> AsyncIterator[AsyncChatModelStream]:
return aiter(self._transformer.messages_log)
# ---------------------------------------------------------------------------
# Subgraphs projection
# ---------------------------------------------------------------------------
class _SubgraphsProjection:
"""Async iterable yielding :class:`AsyncSubgraphRunStream` for each discovered subgraph."""
def __init__(self, mux: AsyncStreamMux, ns: list[str]) -> None:
self._mux = mux
self._ns = ns
async def __aiter__(self) -> AsyncIterator[AsyncSubgraphRunStream]:
async for segment in self._mux.subscribe_subgraphs(self._ns):
child_ns = self._ns + [segment]
child_transformers: list[StreamTransformer] = [
ValuesTransformer(),
MessagesTransformer(
namespace=child_ns, stream_cls=AsyncChatModelStream
),
]
for t in child_transformers:
t.init()
self._mux.register_transformer(t)
yield AsyncSubgraphRunStream(
mux=self._mux,
namespace=child_ns,
transformers=child_transformers,
)
# ---------------------------------------------------------------------------
# AsyncGraphRunStream
# ---------------------------------------------------------------------------
class AsyncGraphRunStream:
"""The async run stream returned by ``StreamingHandler.astream()``.
Async-iterable over all :class:`ProtocolEvent` instances. Named
projections provide ergonomic access to values, messages, subgraphs,
and output.
"""
def __init__(
self,
*,
mux: AsyncStreamMux,
namespace: list[str] | None = None,
transformers: list[StreamTransformer],
abort_event: asyncio.Event | None = None,
output_mapper: Callable[[Any], Any] | None = None,
) -> None:
self._mux = mux
self._ns = namespace or []
self._transformers = transformers
self._abort_event = abort_event or asyncio.Event()
self._output_mapper = output_mapper
# -- Transformer lookup -------------------------------------------------
def _find_transformer(self, name: str) -> StreamTransformer | None:
for t in self._transformers:
if getattr(t, "name", None) == name:
return t
return None
# -- Raw event iteration ------------------------------------------------
def __aiter__(self) -> AsyncIterator[ProtocolEvent]:
return self._mux.subscribe_events(self._ns)
# -- Named projections --------------------------------------------------
@property
def values(self) -> _ValuesProjection:
"""Async iterable of state snapshots; awaitable for final state."""
t = self._find_transformer("values")
return _ValuesProjection(self._mux, t, self._ns, self._output_mapper)
@property
def output(self) -> _ValuesProjection:
"""Awaitable for the final output state."""
t = self._find_transformer("values")
return _ValuesProjection(self._mux, t, self._ns, self._output_mapper)
@property
def messages(self) -> _MessagesProjection:
"""Async iterable of :class:`AsyncChatModelStream` instances."""
t = self._find_transformer("messages")
return _MessagesProjection(t)
def messages_from(self, node: str) -> _MessagesProjection:
"""Async iterable of messages from a specific node."""
filtered = MessagesTransformer(
namespace=self._ns,
node_filter=node,
stream_cls=AsyncChatModelStream,
)
self._mux.register_transformer(filtered)
return _MessagesProjection(filtered)
@property
def subgraphs(self) -> _SubgraphsProjection:
"""Async iterable of :class:`AsyncSubgraphRunStream` for child graphs."""
return _SubgraphsProjection(self._mux, self._ns)
# -- State --------------------------------------------------------------
@property
def interrupted(self) -> bool:
return self._mux.interrupted
@property
def interrupts(self) -> list[InterruptPayload]:
return self._mux.interrupts
# -- Cancellation -------------------------------------------------------
def abort(self, reason: str | None = None) -> None:
"""Signal cancellation of the run."""
self._abort_event.set()
@property
def signal(self) -> asyncio.Event:
"""The underlying cancellation event."""
return self._abort_event
# -- Extensions ---------------------------------------------------------
@property
def extensions(self) -> dict[str, Any]:
"""All transformer projections."""
result: dict[str, Any] = {}
for t in self._transformers:
name = getattr(t, "name", None)
value = getattr(t, "value", None)
if name is not None and value is not None:
result[name] = value
return result
# ---------------------------------------------------------------------------
# AsyncSubgraphRunStream
# ---------------------------------------------------------------------------
class AsyncSubgraphRunStream(AsyncGraphRunStream):
"""An :class:`AsyncGraphRunStream` for a child subgraph.
Adds ``.name`` and ``.index`` parsed from the last namespace segment
(e.g. ``"researcher:2"`` ``name="researcher"``, ``index=2``).
"""
@property
def name(self) -> str:
if self._ns:
segment = self._ns[-1]
return segment.split(":")[0] if ":" in segment else segment
return ""
@property
def index(self) -> int:
if self._ns:
segment = self._ns[-1]
if ":" in segment:
try:
return int(segment.split(":")[-1])
except ValueError:
pass
return 0
# ---------------------------------------------------------------------------
# Async factory
# ---------------------------------------------------------------------------
async def create_async_graph_run_stream(
source: AsyncIterator[tuple[tuple[str, ...], str, Any]],
*,
transformers: list[StreamTransformer] | None = None,
abort_event: asyncio.Event | None = None,
output_mapper: Callable[[Any], Any] | None = None,
) -> AsyncGraphRunStream:
"""Create an :class:`AsyncGraphRunStream` from a raw async stream source.
1. Creates a :class:`StreamMux`
2. Registers built-in ``ValuesTransformer`` and ``MessagesTransformer``
3. Registers user-supplied transformers
4. Creates the root ``AsyncGraphRunStream``
5. Starts a background pump task that reads from *source*,
converts each chunk to a ``ProtocolEvent``, and pushes it
through the mux
6. Returns the ``AsyncGraphRunStream``
"""
abort = abort_event or asyncio.Event()
# Built-in transformers first, then user-supplied
all_transformers: list[StreamTransformer] = [
ValuesTransformer(),
MessagesTransformer(stream_cls=AsyncChatModelStream),
]
all_transformers.extend(transformers or [])
# Initialize transformers, collecting projections to wire after mux creation
projections: list[Any] = []
for t in all_transformers:
projection = t.init()
if projection is not None:
projections.append(projection)
mux = AsyncStreamMux(transformers=all_transformers)
# Wire any StreamChannel instances found in transformer projections
for projection in projections:
mux.wire_channels(projection)
# Create the root stream
run_stream = AsyncGraphRunStream(
mux=mux,
transformers=all_transformers,
abort_event=abort,
output_mapper=output_mapper,
)
# Start the pump task
async def pump() -> None:
try:
async for ns, mode, payload in source:
if abort.is_set():
break
# Extract node name embedded by StreamProtocolMessagesHandler.
node: str | None = None
if (
mode == "messages"
and isinstance(payload, dict)
and "__node__" in payload
):
payload = dict(payload)
node = payload.pop("__node__")
event = convert_to_protocol_event(ns, mode, payload, node=node)
if event is not None:
mux.push(event)
mux.close()
except Exception as exc:
mux.fail(exc)
asyncio.get_running_loop().create_task(pump())
return run_stream
# ---------------------------------------------------------------------------
# GraphRunStream — returned by StreamingHandler.stream()
# ---------------------------------------------------------------------------
class _PumpDrivenLog:
"""Wraps an ``EventLog`` so that iteration drives the sync pump.
Used by :attr:`GraphRunStream.extensions` to make extension logs
iterable without requiring the caller to drain the stream first.
"""
__slots__ = ("_log", "_pump_one")
def __init__(self, log: EventLog, pump_one: Callable[[], bool]) -> None:
self._log = log
self._pump_one = pump_one
def __iter__(self) -> Iterator[Any]:
cursor = 0
while True:
if cursor < len(self._log):
yield self._log[cursor]
cursor += 1
elif not self._pump_one():
return
def __len__(self) -> int:
return len(self._log)
def __getitem__(self, index: int) -> Any:
return self._log[index]
class GraphRunStream:
"""Synchronous run stream returned by ``StreamingHandler.stream()``.
All projections are blocking / sync-iterable. Internally uses
the same ``StreamMux`` and transformer pipeline, but without an
async event loop.
The source iterator is consumed lazily: each projection pulls
events from the source on demand rather than eagerly buffering
everything upfront. This means callers see events as soon as
they are produced by the underlying ``stream()`` call.
"""
def __init__(
self,
*,
mux: StreamMux,
source: Iterator[tuple[tuple[str, ...], str, Any]],
namespace: list[str] | None = None,
transformers: list[StreamTransformer],
output_mapper: Callable[[Any], Any] | None = None,
) -> None:
self._mux = mux
self._source = source
self._source_exhausted = False
self._ns = namespace or []
self._transformers = transformers
self._output_mapper = output_mapper
# -- Transformer lookup -------------------------------------------------
def _find_transformer(self, name: str) -> StreamTransformer | None:
for t in self._transformers:
if getattr(t, "name", None) == name:
return t
return None
# -- Lazy pump ----------------------------------------------------------
def _pump_one(self) -> bool:
"""Pull one item from the source, convert it, and push through the mux.
Returns ``True`` if an item was consumed, ``False`` if the source
is exhausted (or was already exhausted).
"""
if self._source_exhausted:
return False
try:
ns, mode, payload = next(self._source)
except StopIteration:
self._source_exhausted = True
self._mux.close()
return False
except Exception as exc:
self._source_exhausted = True
self._mux.fail(exc)
return False
node: str | None = None
if mode == "messages" and isinstance(payload, dict) and "__node__" in payload:
payload = dict(payload)
node = payload.pop("__node__")
event = convert_to_protocol_event(ns, mode, payload, node=node)
if event is not None:
self._mux.push(event)
return True
def _pump_all(self) -> None:
"""Drain the source iterator completely."""
while self._pump_one():
pass
# -- Helpers ------------------------------------------------------------
def _map(self, value: Any) -> Any:
if value is not None and self._output_mapper is not None:
return self._output_mapper(value)
return value
# -- Raw event iteration (sync) -----------------------------------------
def __iter__(self) -> Iterator[ProtocolEvent]:
for event in _PumpDrivenLog(self._mux.event_log, self._pump_one):
ns = event["params"].get("namespace", [])
if not self._ns or ns[: len(self._ns)] == self._ns:
yield event
# -- Named projections (sync) -------------------------------------------
@property
def output(self) -> Any:
"""The final output state (blocking). Drains the source."""
self._pump_all()
return self._map(self._mux.get_latest_values(self._ns))
@property
def values(self) -> Iterator[Any]:
"""Sync iterable of intermediate state snapshots."""
t = self._find_transformer("values")
if t is None:
return
for item in _PumpDrivenLog(t.value, self._pump_one):
if item.get("namespace", []) == self._ns:
yield self._map(item["data"])
@property
def messages(self) -> Iterator[ChatModelStream]:
"""Sync iterable of :class:`ChatModelStream` instances.
Each ``ChatModelStream`` is yielded as soon as the LLM begins
responding (on ``message-start``). Its ``.text`` and
``.reasoning`` properties are pump-driven
:class:`~langgraph.stream.chat_model_stream._SyncDualProjection`
instances that yield deltas as tokens arrive::
for msg in run.messages:
for delta in msg.text:
print(delta, end="", flush=True)
If you don't need streaming, ``str(msg.text)`` pumps until
the message completes and returns the full text.
After each message is consumed, the pump advances through
non-message events (tool completions, values, etc.) so that
other transformer state is up-to-date before the next message
is yielded. This means you can check
``run.extensions["tools"]`` between messages and see inline
results.
"""
t = self._find_transformer("messages")
if t is None:
return
log = t.value
for msg in _PumpDrivenLog(log, self._pump_one):
msg._bind_pump(self._pump_one)
yield msg
# Advance the pump past non-message events so other
# transformers have up-to-date state before the next
# message is yielded.
prev_count = len(log)
while len(log) == prev_count:
if not self._pump_one():
break
# -- Subgraphs ----------------------------------------------------------
@property
def subgraphs(self) -> Iterator[SubgraphRunStream]:
"""Sync iterable of :class:`SubgraphRunStream` for child graphs.
Namespaces are discovered lazily as events are pumped from the
source. Each yielded stream has its own ``values``, ``messages``,
and ``output`` projections scoped to the child namespace.
After yielding a subgraph, the caller may consume its projections
(e.g. ``sub.values``), which pumps more events and can discover
new namespaces. The loop re-checks for newly discovered
namespaces after each yield before attempting another pump.
"""
yielded: set[str] = set()
while True:
# Yield any newly discovered namespaces. Re-check after
# each yield because consuming a subgraph's projections
# can pump events that discover further namespaces.
found_new = False
for ns_segment in list(self._mux._discovered_ns):
if ns_segment in yielded:
continue
found_new = True
yielded.add(ns_segment)
child_ns = self._ns + [ns_segment]
child_transformers: list[StreamTransformer] = [
ValuesTransformer(),
MessagesTransformer(namespace=child_ns),
]
for t in child_transformers:
t.init()
self._mux.register_transformer(t)
yield SubgraphRunStream(
mux=self._mux,
namespace=child_ns,
transformers=child_transformers,
pump_one=self._pump_one,
output_mapper=self._output_mapper,
)
if found_new:
continue # re-check before pumping
# No new namespaces — pump one event
if not self._pump_one():
break
# -- State --------------------------------------------------------------
@property
def interrupted(self) -> bool:
return self._mux.interrupted
@property
def interrupts(self) -> list[InterruptPayload]:
return self._mux.interrupts
# -- Extensions ---------------------------------------------------------
@property
def extensions(self) -> dict[str, Any]:
"""All transformer projections as pump-driven iterables."""
result: dict[str, Any] = {}
for t in self._transformers:
name = getattr(t, "name", None)
value = getattr(t, "value", None)
if name is not None and value is not None:
if isinstance(value, EventLog):
result[name] = _PumpDrivenLog(value, self._pump_one)
else:
result[name] = value
return result
# ---------------------------------------------------------------------------
# SubgraphRunStream — sync child stream
# ---------------------------------------------------------------------------
class SubgraphRunStream:
"""Synchronous run stream for a child subgraph.
Shares the parent's :class:`StreamMux` and pump function. Has its
own transformer set registered on the shared mux so that projections
(``values``, ``messages``, ``output``) are scoped to the child
namespace.
Adds ``.name`` and ``.index`` parsed from the last namespace segment
(e.g. ``"researcher:2"`` ``name="researcher"``, ``index=2``).
"""
def __init__(
self,
*,
mux: StreamMux,
namespace: list[str],
transformers: list[StreamTransformer],
pump_one: Callable[[], bool],
output_mapper: Callable[[Any], Any] | None = None,
) -> None:
self._mux = mux
self._ns = namespace
self._transformers = transformers
self._pump_one = pump_one
self._output_mapper = output_mapper
# -- Identity -----------------------------------------------------------
@property
def name(self) -> str:
if self._ns:
segment = self._ns[-1]
return segment.split(":")[0] if ":" in segment else segment
return ""
@property
def index(self) -> int:
if self._ns:
segment = self._ns[-1]
if ":" in segment:
try:
return int(segment.split(":")[-1])
except ValueError:
pass
return 0
# -- Transformer lookup -------------------------------------------------
def _find_transformer(self, name: str) -> StreamTransformer | None:
for t in self._transformers:
if getattr(t, "name", None) == name:
return t
return None
# -- Helpers ------------------------------------------------------------
def _map(self, value: Any) -> Any:
if value is not None and self._output_mapper is not None:
return self._output_mapper(value)
return value
def _pump_all(self) -> None:
while self._pump_one():
pass
# -- Raw event iteration (sync) -----------------------------------------
def __iter__(self) -> Iterator[ProtocolEvent]:
for event in _PumpDrivenLog(self._mux.event_log, self._pump_one):
ns = event["params"].get("namespace", [])
if ns[: len(self._ns)] == self._ns:
yield event
# -- Named projections (sync) -------------------------------------------
@property
def output(self) -> Any:
"""The final output state (blocking). Drains the source."""
self._pump_all()
return self._map(self._mux.get_latest_values(self._ns))
@property
def values(self) -> Iterator[Any]:
"""Sync iterable of intermediate state snapshots."""
t = self._find_transformer("values")
if t is None:
return
for item in _PumpDrivenLog(t.value, self._pump_one):
if item.get("namespace", []) == self._ns:
yield self._map(item["data"])
@property
def messages(self) -> Iterator[ChatModelStream]:
"""Sync iterable of :class:`ChatModelStream` instances.
Each ``ChatModelStream`` is yielded as soon as the LLM begins
responding. See :attr:`GraphRunStream.messages` for usage.
"""
t = self._find_transformer("messages")
if t is None:
return
log = t.value
for msg in _PumpDrivenLog(log, self._pump_one):
msg._bind_pump(self._pump_one)
yield msg
prev_count = len(log)
while len(log) == prev_count:
if not self._pump_one():
break
# -- State --------------------------------------------------------------
@property
def interrupted(self) -> bool:
return self._mux.interrupted
@property
def interrupts(self) -> list[InterruptPayload]:
return self._mux.interrupts
def create_graph_run_stream(
source: Iterator[tuple[tuple[str, ...], str, Any]],
*,
transformers: list[StreamTransformer] | None = None,
output_mapper: Callable[[Any], Any] | None = None,
) -> GraphRunStream:
"""Create a :class:`GraphRunStream` from a sync stream source.
The source iterator is stored on the returned stream and consumed
lazily as projections are iterated.
Built-in transformers (values, messages) are always registered first
so that user-supplied transformers see events after built-in
processing.
"""
# Built-in transformers first, then user-supplied
all_transformers: list[StreamTransformer] = [
ValuesTransformer(),
MessagesTransformer(),
]
all_transformers.extend(transformers or [])
projections: list[Any] = []
for t in all_transformers:
projection = t.init()
if projection is not None:
projections.append(projection)
mux = StreamMux(transformers=all_transformers)
for projection in projections:
mux.wire_channels(projection)
return GraphRunStream(
mux=mux,
source=source,
transformers=all_transformers,
output_mapper=output_mapper,
)
__all__ = [
"AsyncGraphRunStream",
"AsyncSubgraphRunStream",
"GraphRunStream",
"SubgraphRunStream",
"create_async_graph_run_stream",
"create_graph_run_stream",
]
@@ -0,0 +1,76 @@
"""StreamChannel — typed push-based channel for StreamTransformer projections.
A ``StreamChannel`` wraps an :class:`EventLog` and declares a protocol
channel name. When the :class:`StreamMux` detects a ``StreamChannel``
in a transformer's ``init()`` return, it wires every ``push()`` call to
inject a :class:`ProtocolEvent` into the main event stream using the
channel's name as the ``method``.
In-process consumers iterate the channel directly (it is an async
iterable). Remote SDK clients subscribe via
``session.subscribe("custom:<channelName>")``.
"""
from __future__ import annotations
from collections.abc import AsyncIterator, Callable
from typing import Any, Generic, TypeVar
from langgraph.stream._event_log import EventLog
T = TypeVar("T")
class StreamChannel(Generic[T]):
"""A typed push-based channel that integrates with the mux.
Transformer authors create a ``StreamChannel`` in ``init()`` and
call ``push()`` inside ``process()`` to emit domain objects. The
mux auto-wires pushes to protocol events and auto-closes/fails the
channel on run completion.
"""
__slots__ = ("channel_name", "_log", "_on_push")
def __init__(self, name: str) -> None:
self.channel_name = name
self._log: EventLog[T] = EventLog()
self._on_push: Callable[[Any], None] | None = None
def push(self, item: T) -> None:
"""Push an item to the channel.
If the mux has wired this channel, the push also injects a
protocol event into the main event stream.
"""
self._log.append(item)
if self._on_push is not None:
self._on_push(item)
# -- Async iteration (in-process consumption) ---------------------------
def __aiter__(self) -> AsyncIterator[T]:
return aiter(self._log)
# -- Internal (called by the mux) ---------------------------------------
def _wire(self, fn: Callable[[Any], None]) -> None:
"""Wire a callback invoked on every ``push()``. Called by the mux."""
self._on_push = fn
def _close(self) -> None:
"""Close the underlying log. Called by the mux on normal completion."""
self._log.close()
def _fail(self, err: BaseException) -> None:
"""Fail the underlying log. Called by the mux on failure."""
self._log.fail(err)
def is_stream_channel(value: object) -> bool:
"""Check if *value* is a :class:`StreamChannel` instance."""
return isinstance(value, StreamChannel)
__all__ = ["StreamChannel", "is_stream_channel"]
@@ -0,0 +1,168 @@
"""Experimental streaming wrapper for CompiledGraph.
``StreamingHandler`` wraps a compiled graph and exposes the new streaming
API without adding methods to the ``CompiledGraph`` class itself.
Usage::
from langgraph.stream import StreamingHandler
s = StreamingHandler(graph)
# async
run = await s.astream(input)
async for msg in run.messages:
...
# sync
run = s.stream(input)
for event in run:
...
"""
from __future__ import annotations
from collections.abc import AsyncIterator, Iterator, Sequence
from typing import TYPE_CHECKING, Any, cast
from langchain_core.runnables import RunnableConfig
from langgraph._internal._config import patch_configurable
from langgraph.stream._convert import STREAM_V2_MODES
from langgraph.stream._types import StreamTransformer
from langgraph.stream.run_stream import (
AsyncGraphRunStream,
GraphRunStream,
create_async_graph_run_stream,
create_graph_run_stream,
)
from langgraph.types import All
if TYPE_CHECKING:
from langgraph.pregel import Pregel
#: Config key that activates the protocol messages handler.
#: Duplicated here to avoid a circular import with ``pregel._messages_v2``.
PROTOCOL_MESSAGES_STREAM_KEY = "__protocol_messages_stream"
class StreamingHandler:
"""Experimental streaming wrapper around a compiled graph.
Provides ``.stream()`` and ``.astream()`` returning
:class:`GraphRunStream` / :class:`AsyncGraphRunStream` with
ergonomic projections (``run.values``, ``run.messages``,
``run.subgraphs``, ``run.output``).
Args:
graph: A compiled LangGraph (``Pregel`` instance).
"""
def __init__(self, graph: Pregel) -> None:
self._graph = graph
async def astream(
self,
input: Any,
config: RunnableConfig | None = None,
*,
context: Any | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
debug: bool | None = None,
transformers: list[StreamTransformer] | None = None,
) -> AsyncGraphRunStream:
"""Stream graph execution, returning an
:class:`~langgraph.stream.run_stream.AsyncGraphRunStream`.
The returned stream provides ergonomic projections:
- ``await run.output`` -- final state
- ``async for v in run.values`` -- intermediate state snapshots
- ``async for msg in run.messages`` -- per-message
:class:`~langgraph.stream.chat_model_stream.AsyncChatModelStream`
objects
- ``async for sub in run.subgraphs`` -- child
:class:`~langgraph.stream.run_stream.AsyncSubgraphRunStream`
instances
- ``async for event in run`` -- raw
:class:`~langgraph.stream._types.ProtocolEvent` objects
Args:
input: The input to the graph.
config: The configuration to use for the run.
context: The static context to use for the run.
interrupt_before: Nodes to interrupt before.
interrupt_after: Nodes to interrupt after.
debug: Whether to emit debug events.
transformers: Optional user-supplied
:class:`~langgraph.stream._types.StreamTransformer` instances
for custom projections (available on ``run.extensions``).
Returns:
An :class:`~langgraph.stream.run_stream.AsyncGraphRunStream`.
"""
merged_config = patch_configurable(config, {PROTOCOL_MESSAGES_STREAM_KEY: True})
source = cast(
AsyncIterator[tuple[tuple[str, ...], str, Any]],
self._graph.astream(
input,
merged_config,
context=context,
stream_mode=STREAM_V2_MODES,
subgraphs=True,
interrupt_before=interrupt_before,
interrupt_after=interrupt_after,
debug=debug,
version="v1",
),
)
return await create_async_graph_run_stream(
source,
transformers=transformers,
output_mapper=self._graph._output_mapper,
)
def stream(
self,
input: Any,
config: RunnableConfig | None = None,
*,
context: Any | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
debug: bool | None = None,
transformers: list[StreamTransformer] | None = None,
) -> GraphRunStream:
"""Synchronous variant of :meth:`astream`.
Returns a :class:`~langgraph.stream.run_stream.GraphRunStream`
immediately. The underlying source is consumed lazily as
projections are iterated.
See :meth:`astream` for full documentation.
"""
merged_config = patch_configurable(config, {PROTOCOL_MESSAGES_STREAM_KEY: True})
source = cast(
Iterator[tuple[tuple[str, ...], str, Any]],
self._graph.stream(
input,
merged_config,
context=context,
stream_mode=STREAM_V2_MODES,
subgraphs=True,
interrupt_before=interrupt_before,
interrupt_after=interrupt_after,
debug=debug,
version="v1",
),
)
return create_graph_run_stream(
source,
transformers=transformers,
output_mapper=self._graph._output_mapper,
)
@@ -0,0 +1,179 @@
"""Built-in stream transformers for StreamingHandler.
``ValuesTransformer`` extracts ``values`` events and maintains the latest
state per namespace. ``MessagesTransformer`` groups ``messages`` events
into :class:`ChatModelStream` instances.
"""
from __future__ import annotations
from typing import Any
from langgraph.stream._event_log import EventLog
from langgraph.stream._types import ProtocolEvent
from langgraph.stream.chat_model_stream import ChatModelStream
# Type alias for the stream class constructor signature
_StreamCls = type[ChatModelStream]
class ValuesTransformer:
"""Extracts ``values`` events and populates a values event log.
Maintains the latest state per namespace and provides a separate
event log that :class:`AsyncGraphRunStream` / :class:`GraphRunStream` uses for ``.values``
iteration.
Implements the :class:`StreamTransformer` protocol.
"""
name = "values"
def __init__(self) -> None:
self._values_log: EventLog[dict[str, Any]] = EventLog()
self._latest: dict[str, Any] = {}
@property
def value(self) -> EventLog[dict[str, Any]]:
return self._values_log
@property
def values_log(self) -> EventLog[dict[str, Any]]:
return self._values_log
def get_latest(self, ns_key: str = "") -> Any:
return self._latest.get(ns_key)
def init(self) -> Any:
return None
def process(self, event: ProtocolEvent) -> bool:
if event["method"] != "values":
return True
ns = event["params"].get("namespace", [])
data = event["params"]["data"]
ns_key = "|".join(ns) if ns else ""
self._latest[ns_key] = data
# Append to the values log for iteration
self._values_log.append({"namespace": ns, "data": data})
return True
def finalize(self) -> None:
self._values_log.close()
def fail(self, err: BaseException) -> None:
self._values_log.fail(err)
class MessagesTransformer:
"""Groups ``messages`` events into :class:`ChatModelStream` instances.
One ``ChatModelStream`` is created per ``message-start`` event.
Content-block events are routed to the active stream until
``message-finish`` or ``message-error`` closes it.
Implements the :class:`StreamTransformer` protocol.
"""
name = "messages"
def __init__(
self,
*,
namespace: list[str] | None = None,
node_filter: str | None = None,
stream_cls: _StreamCls | None = None,
) -> None:
self._namespace = namespace
self._node_filter = node_filter
self._stream_cls: _StreamCls = stream_cls or ChatModelStream
# Message log for .messages iteration
self._messages_log: EventLog[ChatModelStream] = EventLog()
# Current active stream per namespace key
self._active: dict[str, ChatModelStream] = {}
@property
def value(self) -> EventLog[ChatModelStream]:
return self._messages_log
@property
def messages_log(self) -> EventLog[ChatModelStream]:
return self._messages_log
def init(self) -> Any:
return None
def process(self, event: ProtocolEvent) -> bool:
if event["method"] != "messages":
return True
ns = event["params"].get("namespace", [])
node = event["params"].get("node")
data = event["params"]["data"]
# Apply namespace filter
if self._namespace is not None:
if ns[: len(self._namespace)] != self._namespace:
return True
# Apply node filter
if self._node_filter is not None and node != self._node_filter:
return True
ns_key = "|".join(ns) if ns else ""
event_type = data.get("event") if isinstance(data, dict) else None
if event_type == "message-start":
stream = self._stream_cls(
namespace=ns,
node=node,
message_id=data.get("message_id"),
)
self._active[ns_key] = stream
self._messages_log.append(stream)
elif event_type in ("content-block-delta", "content-block-start"):
active = self._active.get(ns_key)
if active is not None and event_type == "content-block-delta":
active._push_content_block_delta(data)
elif event_type == "content-block-finish":
active = self._active.get(ns_key)
if active is not None:
active._push_content_block_finish(data)
elif event_type == "message-finish":
active = self._active.pop(ns_key, None)
if active is not None:
active._finish(data)
elif event_type == "error":
active = self._active.pop(ns_key, None)
if active is not None:
msg = data.get("message", "Unknown error")
active._fail(RuntimeError(msg))
return True
def finalize(self) -> None:
# Close any remaining active streams
for stream in self._active.values():
stream._finish({"reason": "stop"})
self._active.clear()
self._messages_log.close()
def fail(self, err: BaseException) -> None:
for stream in self._active.values():
stream._fail(err)
self._active.clear()
self._messages_log.fail(err)
__all__ = [
"MessagesTransformer",
"ValuesTransformer",
]
+3 -3
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph"
version = "1.1.10"
version = "1.1.6"
description = "Building stateful, multi-actor applications with LLMs"
authors = []
requires-python = ">=3.10"
@@ -24,10 +24,10 @@ classifiers = [
'Programming Language :: Python :: 3.13',
]
dependencies = [
"langchain-core>=1.3.0,<2",
"langchain-core>=0.1",
"langgraph-checkpoint>=2.1.0,<5.0.0",
"langgraph-sdk>=0.3.0,<0.4.0",
"langgraph-prebuilt>=1.0.12,<1.1.0",
"langgraph-prebuilt>=1.0.9,<1.1.0",
"xxhash>=3.5.0",
"pydantic>=2.7.4",
]
@@ -1,344 +0,0 @@
from __future__ import annotations
import sys
from typing import Any
import pytest
from langchain_core.callbacks.base import BaseCallbackHandler
from langchain_core.callbacks.manager import CallbackManager
from langgraph.checkpoint.memory import InMemorySaver
from typing_extensions import TypedDict
from langgraph.callbacks import (
GraphCallbackHandler,
GraphInterruptEvent,
GraphResumeEvent,
)
from langgraph.graph import START, StateGraph
from langgraph.types import Command, Interrupt, interrupt
NEEDS_CONTEXTVARS = pytest.mark.skipif(
sys.version_info < (3, 11),
reason="Python 3.11+ is required for async contextvars support",
)
class _GraphEventHandler(GraphCallbackHandler):
def __init__(self) -> None:
self.interrupt_events: list[GraphInterruptEvent] = []
self.resume_events: list[GraphResumeEvent] = []
def on_interrupt(self, event: GraphInterruptEvent) -> Any:
self.interrupt_events.append(event)
def on_resume(self, event: GraphResumeEvent) -> Any:
self.resume_events.append(event)
class _LangChainCustomEventHandler(BaseCallbackHandler):
run_inline = True
def __init__(self) -> None:
self.events: list[str] = []
def on_custom_event(self, name: str, data: Any, **kwargs: Any) -> Any:
self.events.append(name)
class _RaisingGraphEventHandler(GraphCallbackHandler):
def __init__(
self,
*,
raise_on_interrupt: bool = False,
raise_on_resume: bool = False,
raise_error: bool = False,
) -> None:
self.raise_on_interrupt = raise_on_interrupt
self.raise_on_resume = raise_on_resume
self.raise_error = raise_error
def on_interrupt(self, event: GraphInterruptEvent) -> Any:
if self.raise_on_interrupt:
raise ValueError("boom-interrupt")
def on_resume(self, event: GraphResumeEvent) -> Any:
if self.raise_on_resume:
raise ValueError("boom-resume")
class _AsyncRaisingGraphEventHandler(GraphCallbackHandler):
def __init__(
self,
*,
raise_on_interrupt: bool = False,
raise_on_resume: bool = False,
raise_error: bool = False,
) -> None:
self.raise_on_interrupt = raise_on_interrupt
self.raise_on_resume = raise_on_resume
self.raise_error = raise_error
async def on_interrupt(self, event: GraphInterruptEvent) -> Any:
if self.raise_on_interrupt:
raise ValueError("boom-interrupt")
async def on_resume(self, event: GraphResumeEvent) -> Any:
if self.raise_on_resume:
raise ValueError("boom-resume")
class _State(TypedDict):
answer: str | None
def _build_interrupt_graph() -> Any:
def ask(state: _State) -> _State:
answer = interrupt("Provide value")
return {"answer": answer}
builder = StateGraph(_State)
builder.add_node("ask", ask)
builder.add_edge(START, "ask")
return builder.compile(checkpointer=InMemorySaver())
def test_graph_callbacks_interrupt_and_resume_sync() -> None:
graph = _build_interrupt_graph()
handler = _GraphEventHandler()
langchain_handler = _LangChainCustomEventHandler()
config = {
"configurable": {"thread_id": "graph-callback-sync"},
"callbacks": [langchain_handler, handler],
}
first = graph.invoke({"answer": None}, config)
assert "__interrupt__" in first
assert len(handler.interrupt_events) == 1
assert handler.interrupt_events[0].interrupts
assert isinstance(handler.interrupt_events[0].interrupts[0], Interrupt)
assert handler.interrupt_events[0].checkpoint_ns == ()
assert langchain_handler.events == []
handler.resume_events.clear()
resumed = graph.invoke(Command(resume="done"), config)
assert resumed == {"answer": "done"}
assert len(handler.resume_events) == 1
assert handler.resume_events[0].checkpoint_ns == ()
assert langchain_handler.events == []
@pytest.mark.anyio
@NEEDS_CONTEXTVARS
async def test_graph_callbacks_interrupt_and_resume_async() -> None:
graph = _build_interrupt_graph()
handler = _GraphEventHandler()
langchain_handler = _LangChainCustomEventHandler()
config = {
"configurable": {"thread_id": "graph-callback-async"},
"callbacks": [langchain_handler, handler],
}
first = await graph.ainvoke({"answer": None}, config)
assert "__interrupt__" in first
assert len(handler.interrupt_events) == 1
assert handler.interrupt_events[0].interrupts
assert isinstance(handler.interrupt_events[0].interrupts[0], Interrupt)
assert handler.interrupt_events[0].checkpoint_ns == ()
assert langchain_handler.events == []
handler.resume_events.clear()
resumed = await graph.ainvoke(Command(resume="done"), config)
assert resumed == {"answer": "done"}
assert len(handler.resume_events) == 1
assert handler.resume_events[0].checkpoint_ns == ()
assert langchain_handler.events == []
def test_graph_callbacks_continue_when_interrupt_handler_raises_sync() -> None:
graph = _build_interrupt_graph()
raising_handler = _RaisingGraphEventHandler(raise_on_interrupt=True)
recording_handler = _GraphEventHandler()
first = graph.invoke(
{"answer": None},
{
"configurable": {"thread_id": "graph-callback-sync-raises"},
"callbacks": [raising_handler, recording_handler],
},
)
assert "__interrupt__" in first
assert len(recording_handler.interrupt_events) == 1
def test_graph_callbacks_continue_when_resume_handler_raises_sync() -> None:
graph = _build_interrupt_graph()
raising_handler = _RaisingGraphEventHandler(raise_on_resume=True)
recording_handler = _GraphEventHandler()
config = {
"configurable": {"thread_id": "graph-callback-sync-raises-resume"},
"callbacks": [raising_handler, recording_handler],
}
first = graph.invoke({"answer": None}, config)
assert "__interrupt__" in first
resumed = graph.invoke(Command(resume="done"), config)
assert resumed == {"answer": "done"}
assert len(recording_handler.resume_events) == 1
def test_graph_callbacks_raise_error_propagates_sync() -> None:
graph = _build_interrupt_graph()
raising_handler = _RaisingGraphEventHandler(
raise_on_interrupt=True,
raise_error=True,
)
with pytest.raises(ValueError, match="boom-interrupt"):
graph.invoke(
{"answer": None},
{
"configurable": {"thread_id": "graph-callback-sync-raise-error"},
"callbacks": [raising_handler],
},
)
@pytest.mark.anyio
@NEEDS_CONTEXTVARS
async def test_graph_callbacks_continue_when_handler_raises_async() -> None:
graph = _build_interrupt_graph()
raising_interrupt_handler = _AsyncRaisingGraphEventHandler(raise_on_interrupt=True)
recording_handler = _GraphEventHandler()
config = {
"configurable": {"thread_id": "graph-callback-async-raises-interrupt"},
"callbacks": [raising_interrupt_handler, recording_handler],
}
first = await graph.ainvoke({"answer": None}, config)
assert "__interrupt__" in first
assert len(recording_handler.interrupt_events) == 1
graph = _build_interrupt_graph()
raising_resume_handler = _AsyncRaisingGraphEventHandler(raise_on_resume=True)
recording_handler = _GraphEventHandler()
config = {
"configurable": {"thread_id": "graph-callback-async-raises-resume"},
"callbacks": [raising_resume_handler, recording_handler],
}
first = await graph.ainvoke({"answer": None}, config)
assert "__interrupt__" in first
resumed = await graph.ainvoke(Command(resume="done"), config)
assert resumed == {"answer": "done"}
assert len(recording_handler.resume_events) == 1
@pytest.mark.anyio
@NEEDS_CONTEXTVARS
async def test_graph_callbacks_raise_error_propagates_async() -> None:
graph = _build_interrupt_graph()
raising_handler = _AsyncRaisingGraphEventHandler(
raise_on_interrupt=True,
raise_error=True,
)
with pytest.raises(ValueError, match="boom-interrupt"):
await graph.ainvoke(
{"answer": None},
{
"configurable": {"thread_id": "graph-callback-async-raise-error"},
"callbacks": [raising_handler],
},
)
def test_graph_callbacks_accept_base_callback_manager() -> None:
graph = _build_interrupt_graph()
graph_handler = _GraphEventHandler()
custom_handler = _LangChainCustomEventHandler()
manager = CallbackManager.configure(inheritable_callbacks=[custom_handler])
manager.add_handler(graph_handler)
first = graph.invoke(
{"answer": None},
{
"configurable": {"thread_id": "graph-callback-base-manager"},
"callbacks": manager,
},
)
assert "__interrupt__" in first
assert len(graph_handler.interrupt_events) == 1
def test_non_graph_handler_via_add_handler_does_not_crash() -> None:
"""Non-GraphCallbackHandler added via add_handler should not raise.
Libraries like opentelemetry-instrumentation-langchain monkey-patch
BaseCallbackManager.__init__ and inject handlers via add_handler().
These handlers inherit from BaseCallbackHandler, not
GraphCallbackHandler. They must be silently accepted graph lifecycle
events will simply not be dispatched to them.
"""
from langgraph.callbacks import _GraphCallbackManager
manager = _GraphCallbackManager()
plain_handler = _LangChainCustomEventHandler()
manager.add_handler(plain_handler, inherit=True)
assert plain_handler in manager.handlers
def test_non_graph_handler_does_not_receive_lifecycle_events() -> None:
"""Non-GraphCallbackHandler added alongside a GraphCallbackHandler
should not interfere with lifecycle event dispatch."""
graph = _build_interrupt_graph()
graph_handler = _GraphEventHandler()
plain_handler = _LangChainCustomEventHandler()
config = {
"configurable": {"thread_id": "graph-callback-mixed-handlers"},
"callbacks": [plain_handler, graph_handler],
}
first = graph.invoke({"answer": None}, config)
assert "__interrupt__" in first
assert len(graph_handler.interrupt_events) == 1
assert plain_handler.events == []
resumed = graph.invoke(Command(resume="done"), config)
assert resumed == {"answer": "done"}
assert len(graph_handler.resume_events) == 1
assert plain_handler.events == []
@pytest.mark.anyio
@NEEDS_CONTEXTVARS
async def test_non_graph_handler_does_not_receive_lifecycle_events_async() -> None:
"""Async variant: non-GraphCallbackHandler should not interfere."""
graph = _build_interrupt_graph()
graph_handler = _GraphEventHandler()
plain_handler = _LangChainCustomEventHandler()
config = {
"configurable": {"thread_id": "graph-callback-mixed-handlers-async"},
"callbacks": [plain_handler, graph_handler],
}
first = await graph.ainvoke({"answer": None}, config)
assert "__interrupt__" in first
assert len(graph_handler.interrupt_events) == 1
assert plain_handler.events == []
resumed = await graph.ainvoke(Command(resume="done"), config)
assert resumed == {"answer": "done"}
assert len(graph_handler.resume_events) == 1
assert plain_handler.events == []
@@ -0,0 +1,558 @@
import asyncio
from typing import Annotated, Any
import pytest
from langchain_core.messages import AIMessage, HumanMessage
from pydantic import BaseModel
from typing_extensions import TypedDict
from langgraph.config import get_stream_writer
from langgraph.graph import END, START, MessagesState, StateGraph
from langgraph.stream import AsyncChatModelStream, StreamingHandler
from langgraph.stream._types import ProtocolEvent
from tests.fake_chat import FakeChatModel
class State(TypedDict):
value: str
items: Annotated[list[str], lambda a, b: a + b]
def make_simple_graph():
def node_a(state):
return {"value": state["value"] + "_a", "items": ["a"]}
def node_b(state):
return {"value": state["value"] + "_b", "items": ["b"]}
graph = StateGraph(State)
graph.add_node("node_a", node_a)
graph.add_node("node_b", node_b)
graph.add_edge(START, "node_a")
graph.add_edge("node_a", "node_b")
graph.add_edge("node_b", END)
return graph.compile()
@pytest.mark.anyio
async def test_output():
graph = make_simple_graph()
run = await StreamingHandler(graph).astream({"value": "x", "items": []})
await asyncio.sleep(0.1)
output = await run.output
assert output == {"value": "x_a_b", "items": ["a", "b"]}
@pytest.mark.anyio
async def test_values_iteration():
graph = make_simple_graph()
run = await StreamingHandler(graph).astream({"value": "x", "items": []})
await asyncio.sleep(0.1)
snapshots = []
async for v in run.values:
snapshots.append(v)
assert len(snapshots) == 3
assert snapshots[0]["value"] == "x"
assert snapshots[1]["value"] == "x_a"
assert snapshots[2]["value"] == "x_a_b"
@pytest.mark.anyio
async def test_updates_in_raw_events():
graph = make_simple_graph()
run = await StreamingHandler(graph).astream({"value": "x", "items": []})
await asyncio.sleep(0.1)
updates = []
async for event in run:
if event["method"] == "updates":
updates.append(event["params"]["data"])
assert len(updates) == 2
assert "node_a" in updates[0]
assert "node_b" in updates[1]
@pytest.mark.anyio
async def test_messages_with_chat_model():
model = FakeChatModel(messages=[AIMessage(content="Hello world")])
def agent(state):
return {"messages": [model.invoke(state["messages"])]}
graph = StateGraph(MessagesState)
graph.add_node("agent", agent)
graph.add_edge(START, "agent")
graph.add_edge("agent", END)
compiled = graph.compile()
run = await StreamingHandler(compiled).astream(
{"messages": [HumanMessage(content="hi")]}
)
await asyncio.sleep(0.1)
messages_seen = []
async for msg in run.messages:
messages_seen.append(msg)
assert len(messages_seen) >= 1
msg = messages_seen[0]
assert isinstance(msg, AsyncChatModelStream)
text = await msg.text
assert text == "Hello world"
@pytest.mark.anyio
async def test_custom_events():
def node(state):
writer = get_stream_writer()
writer("hello")
writer(42)
return {"value": state["value"] + "_a", "items": ["a"]}
graph = StateGraph(State)
graph.add_node("node_a", node)
graph.add_edge(START, "node_a")
graph.add_edge("node_a", END)
compiled = graph.compile()
run = await StreamingHandler(compiled).astream({"value": "x", "items": []})
await asyncio.sleep(0.1)
custom_payloads = []
async for event in run:
if event["method"] == "custom":
custom_payloads.append(event["params"]["data"])
assert "hello" in custom_payloads
assert 42 in custom_payloads
@pytest.mark.anyio
async def test_multiple_modes_present():
graph = make_simple_graph()
run = await StreamingHandler(graph).astream({"value": "x", "items": []})
await asyncio.sleep(0.1)
methods = set()
async for event in run:
methods.add(event["method"])
assert {"values", "updates", "tasks", "debug"} <= methods
@pytest.mark.anyio
async def test_interrupted_false():
graph = make_simple_graph()
run = await StreamingHandler(graph).astream({"value": "x", "items": []})
await asyncio.sleep(0.1)
async for _ in run:
pass
assert run.interrupted is False
@pytest.mark.anyio
async def test_regression_v1_stream_unchanged():
graph = make_simple_graph()
chunks = []
async for chunk in graph.astream(
{"value": "x", "items": []}, stream_mode="values", version="v1"
):
chunks.append(chunk)
for chunk in chunks:
assert isinstance(chunk, dict)
@pytest.mark.anyio
async def test_regression_v2_stream_unchanged():
graph = make_simple_graph()
chunks = []
async for chunk in graph.astream(
{"value": "x", "items": []}, stream_mode="values", version="v2"
):
chunks.append(chunk)
assert len(chunks) >= 1
for chunk in chunks:
assert isinstance(chunk, dict)
assert "type" in chunk
assert chunk["type"] == "values"
@pytest.mark.anyio
async def test_regression_invoke_unchanged():
graph = make_simple_graph()
result = await graph.ainvoke({"value": "x", "items": []})
assert result == {"value": "x_a_b", "items": ["a", "b"]}
def test_sync_stream_output():
graph = make_simple_graph()
run = StreamingHandler(graph).stream({"value": "x", "items": []})
assert run.output == {"value": "x_a_b", "items": ["a", "b"]}
def test_sync_stream_values():
graph = make_simple_graph()
run = StreamingHandler(graph).stream({"value": "x", "items": []})
snapshots = list(run.values)
assert len(snapshots) == 3
assert snapshots[0]["value"] == "x"
assert snapshots[2]["value"] == "x_a_b"
def test_sync_stream_raw_events():
graph = make_simple_graph()
run = StreamingHandler(graph).stream({"value": "x", "items": []})
methods = {e["method"] for e in run}
assert {"values", "updates", "tasks", "debug"} <= methods
# ---------------------------------------------------------------------------
# Typed output (pydantic)
# ---------------------------------------------------------------------------
class ModelState(BaseModel):
value: str
items: Annotated[list[str], lambda a, b: a + b]
def _make_model_state_graph():
def node_a(state):
return {"value": state.value + "_a", "items": ["a"]}
graph = StateGraph(ModelState)
graph.add_node("node_a", node_a)
graph.add_edge(START, "node_a")
graph.add_edge("node_a", END)
return graph.compile()
@pytest.mark.anyio
async def test_pydantic_output():
graph = _make_model_state_graph()
run = await StreamingHandler(graph).astream(ModelState(value="x", items=[]))
await asyncio.sleep(0.1)
output = await run.output
assert isinstance(output, ModelState)
assert output.value == "x_a"
@pytest.mark.anyio
async def test_pydantic_values():
graph = _make_model_state_graph()
run = await StreamingHandler(graph).astream(ModelState(value="x", items=[]))
await asyncio.sleep(0.1)
snapshots = []
async for v in run.values:
snapshots.append(v)
for v in snapshots:
assert isinstance(v, ModelState)
def test_sync_pydantic_output():
graph = _make_model_state_graph()
run = StreamingHandler(graph).stream(ModelState(value="x", items=[]))
assert isinstance(run.output, ModelState)
assert run.output.value == "x_a"
# ---------------------------------------------------------------------------
# Interrupts
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_interrupts():
from langgraph.checkpoint.memory import MemorySaver
from langgraph.types import interrupt
def ask_human(state: State):
answer = interrupt("what do you want?")
return {"value": state["value"] + f"_{answer}", "items": [answer]}
graph = StateGraph(State)
graph.add_node("ask", ask_human)
graph.add_edge(START, "ask")
graph.add_edge("ask", END)
compiled = graph.compile(checkpointer=MemorySaver())
config = {"configurable": {"thread_id": "t1"}}
run = await StreamingHandler(compiled).astream(
{"value": "x", "items": []}, config=config
)
await asyncio.sleep(0.1)
# Drain events
async for _ in run:
pass
assert run.interrupted is True
assert len(run.interrupts) > 0
# ---------------------------------------------------------------------------
# messages_from(node)
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_messages_from_node():
model = FakeChatModel(messages=[AIMessage(content="from agent")])
def agent(state):
return {"messages": [model.invoke(state["messages"])]}
def postprocess(state):
return {"messages": state["messages"]}
graph = StateGraph(MessagesState)
graph.add_node("agent", agent)
graph.add_node("postprocess", postprocess)
graph.add_edge(START, "agent")
graph.add_edge("agent", "postprocess")
graph.add_edge("postprocess", END)
compiled = graph.compile()
run = await StreamingHandler(compiled).astream(
{"messages": [HumanMessage(content="hi")]}
)
await asyncio.sleep(0.1)
# All messages
all_msgs = []
async for m in run.messages:
all_msgs.append(m)
assert len(all_msgs) >= 1
# Node provenance should be set
assert all_msgs[0].node == "agent"
# ---------------------------------------------------------------------------
# Subgraph child stream
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_subgraph_child_output():
"""AsyncSubgraphRunStream.output should contain the child graph's final state."""
class ChildState(TypedDict):
value: str
class ParentState(TypedDict):
value: str
def child_node(state):
return {"value": state["value"] + "_child"}
child_graph = StateGraph(ChildState)
child_graph.add_node("child_node", child_node)
child_graph.add_edge(START, "child_node")
child_graph.add_edge("child_node", END)
# Add the compiled child as a node — this triggers LangGraph's
# subgraph streaming mechanism and emits child namespace events.
child_compiled = child_graph.compile()
parent_graph = StateGraph(ParentState)
parent_graph.add_node("child_node", child_compiled)
parent_graph.add_edge(START, "child_node")
parent_graph.add_edge("child_node", END)
parent_compiled = parent_graph.compile()
run = await StreamingHandler(parent_compiled).astream({"value": "x"})
await asyncio.sleep(0.1)
subgraph_streams = []
async for sub in run.subgraphs:
subgraph_streams.append(sub)
assert len(subgraph_streams) >= 1
child_output = await subgraph_streams[0].output
assert child_output is not None
assert child_output["value"] == "x_child"
# ---------------------------------------------------------------------------
# Custom reducers / .extensions
# ---------------------------------------------------------------------------
class _CountTransformer:
"""Counts events. Exposes count via .value for extensions."""
name = "event_count"
def __init__(self) -> None:
self.value = 0
def init(self) -> Any:
return None
def process(self, event: ProtocolEvent) -> bool:
self.value += 1
return True
def finalize(self) -> None:
pass
def fail(self, err: BaseException) -> None:
pass
@pytest.mark.anyio
async def test_custom_reducer_extensions():
graph = make_simple_graph()
counter = _CountTransformer()
run = await StreamingHandler(graph).astream(
{"value": "x", "items": []}, transformers=[counter]
)
await asyncio.sleep(0.1)
async for _ in run:
pass
assert counter.value > 0
assert run.extensions["event_count"] == counter.value
def test_sync_custom_reducer_extensions():
graph = make_simple_graph()
counter = _CountTransformer()
run = StreamingHandler(graph).stream(
{"value": "x", "items": []}, transformers=[counter]
)
for _ in run:
pass
assert counter.value > 0
assert run.extensions["event_count"] == counter.value
# ---------------------------------------------------------------------------
# Tool transformer via extensions
# ---------------------------------------------------------------------------
class _ToolExecution:
def __init__(self, tool_call_id: str, tool_name: str, input: Any, output: Any):
self.tool_call_id = tool_call_id
self.tool_name = tool_name
self.input = input
self.output = output
class _ToolsTransformer:
"""Groups tool-started/tool-finished custom events into _ToolExecution objects."""
name = "tools"
def __init__(self) -> None:
from langgraph.stream._event_log import EventLog
self._log: EventLog[_ToolExecution] = EventLog()
self._pending: dict[str, dict] = {}
self.value = self._log
def init(self) -> Any:
return None
def process(self, event: ProtocolEvent) -> bool:
if event["method"] != "custom":
return True
data = event["params"]["data"]
if not isinstance(data, dict) or "event" not in data:
return True
tool_call_id = data.get("tool_call_id")
if tool_call_id is None:
return True
if data["event"] == "tool-started":
self._pending[tool_call_id] = data
return False
if data["event"] == "tool-finished":
started = self._pending.pop(tool_call_id, {})
self._log.append(_ToolExecution(
tool_call_id=tool_call_id,
tool_name=started.get("tool_name", ""),
input=started.get("input"),
output=data["output"],
))
return False
return True
def finalize(self) -> None:
self._log.close()
def fail(self, err: BaseException) -> None:
self._log.fail(err)
def _make_tool_graph():
"""Graph: agent emits a tool call, custom_tools executes it with writer events."""
from langgraph.types import StreamWriter
def agent(state):
return {
"value": "called",
"items": ["agent"],
}
def custom_tools(state, *, writer: StreamWriter):
writer({
"event": "tool-started",
"tool_call_id": "call_1",
"tool_name": "get_weather",
"input": {"city": "SF"},
})
writer({
"event": "tool-finished",
"tool_call_id": "call_1",
"output": {"temp_f": 64},
})
return {"value": "done", "items": ["tools"]}
graph = StateGraph(State)
graph.add_node("agent", agent)
graph.add_node("custom_tools", custom_tools)
graph.add_edge(START, "agent")
graph.add_edge("agent", "custom_tools")
graph.add_edge("custom_tools", END)
return graph.compile()
def test_sync_tool_transformer_via_extensions():
"""Tool events flow through extensions and are iterable without draining raw events."""
graph = _make_tool_graph()
run = StreamingHandler(graph).stream(
{"value": "", "items": []},
transformers=[_ToolsTransformer()],
)
# Iterating extensions drives the pump — no need to drain raw events first
executions = list(run.extensions["tools"])
assert len(executions) == 1
assert executions[0].tool_name == "get_weather"
assert executions[0].input == {"city": "SF"}
assert executions[0].output == {"temp_f": 64}
@pytest.mark.anyio
async def test_async_tool_transformer_via_extensions():
"""Tool events flow through extensions in async mode."""
graph = _make_tool_graph()
run = await StreamingHandler(graph).astream(
{"value": "", "items": []},
transformers=[_ToolsTransformer()],
)
await asyncio.sleep(0.1)
# Drain main stream so transformer processes all events
async for _ in run:
pass
tools_log = run.extensions["tools"]
assert len(tools_log) == 1
assert tools_log[0].tool_name == "get_weather"
assert tools_log[0].output == {"temp_f": 64}
+6
View File
@@ -1396,6 +1396,7 @@ def test_prebuilt_tool_chat(snapshot: SnapshotAssertion) -> None:
"langgraph_path": (PULL, "agent"),
"langgraph_checkpoint_ns": AnyStr("agent:"),
"checkpoint_ns": AnyStr("agent:"),
"_type": "generic-fake-chat-model",
"ls_provider": "fakechatmodel",
"ls_model_type": "chat",
"ls_integration": "langchain_chat_model",
@@ -1458,6 +1459,7 @@ def test_prebuilt_tool_chat(snapshot: SnapshotAssertion) -> None:
"langgraph_path": (PULL, "agent"),
"langgraph_checkpoint_ns": AnyStr("agent:"),
"checkpoint_ns": AnyStr("agent:"),
"_type": "generic-fake-chat-model",
"ls_provider": "fakechatmodel",
"ls_model_type": "chat",
"ls_integration": "langchain_chat_model",
@@ -1510,6 +1512,7 @@ def test_prebuilt_tool_chat(snapshot: SnapshotAssertion) -> None:
"langgraph_path": (PULL, "agent"),
"langgraph_checkpoint_ns": AnyStr("agent:"),
"checkpoint_ns": AnyStr("agent:"),
"_type": "generic-fake-chat-model",
"ls_provider": "fakechatmodel",
"ls_model_type": "chat",
"ls_integration": "langchain_chat_model",
@@ -6881,6 +6884,7 @@ def test_weather_subgraph(
"langgraph_path": ("__pregel_pull", "router_node"),
"langgraph_checkpoint_ns": AnyStr("router_node:"),
"checkpoint_ns": AnyStr("router_node:"),
"_type": "fake-messages-list-chat-model",
"ls_provider": "fakemessageslistchatmodel",
"ls_model_type": "chat",
"ls_integration": "langchain_chat_model",
@@ -6908,6 +6912,7 @@ def test_weather_subgraph(
"langgraph_path": ("__pregel_pull", "model_node"),
"langgraph_checkpoint_ns": AnyStr("weather_graph:"),
"checkpoint_ns": AnyStr("weather_graph:"),
"_type": "fake-messages-list-chat-model",
"ls_provider": "fakemessageslistchatmodel",
"ls_model_type": "chat",
"ls_integration": "langchain_chat_model",
@@ -6944,6 +6949,7 @@ def test_weather_subgraph(
"langgraph_path": ("__pregel_pull", "router_node"),
"langgraph_checkpoint_ns": AnyStr("router_node:"),
"checkpoint_ns": AnyStr("router_node:"),
"_type": "fake-messages-list-chat-model",
"ls_provider": "fakemessageslistchatmodel",
"ls_model_type": "chat",
"ls_integration": "langchain_chat_model",
@@ -1147,6 +1147,7 @@ async def test_prebuilt_tool_chat() -> None:
"langgraph_path": (PULL, "agent"),
"langgraph_checkpoint_ns": AnyStr("agent:"),
"checkpoint_ns": AnyStr("agent:"),
"_type": "generic-fake-chat-model",
"ls_provider": "fakechatmodel",
"ls_model_type": "chat",
"ls_integration": "langchain_chat_model",
@@ -1209,6 +1210,7 @@ async def test_prebuilt_tool_chat() -> None:
"langgraph_path": (PULL, "agent"),
"langgraph_checkpoint_ns": AnyStr("agent:"),
"checkpoint_ns": AnyStr("agent:"),
"_type": "generic-fake-chat-model",
"ls_provider": "fakechatmodel",
"ls_model_type": "chat",
"ls_integration": "langchain_chat_model",
@@ -1261,6 +1263,7 @@ async def test_prebuilt_tool_chat() -> None:
"langgraph_path": (PULL, "agent"),
"langgraph_checkpoint_ns": AnyStr("agent:"),
"checkpoint_ns": AnyStr("agent:"),
"_type": "generic-fake-chat-model",
"ls_provider": "fakechatmodel",
"ls_model_type": "chat",
"ls_integration": "langchain_chat_model",
@@ -3978,6 +3981,7 @@ async def test_weather_subgraph(
"langgraph_path": ("__pregel_pull", "router_node"),
"langgraph_checkpoint_ns": AnyStr("router_node:"),
"checkpoint_ns": AnyStr("router_node:"),
"_type": "fake-messages-list-chat-model",
"ls_provider": "fakemessageslistchatmodel",
"ls_model_type": "chat",
"ls_integration": "langchain_chat_model",
@@ -4005,6 +4009,7 @@ async def test_weather_subgraph(
"langgraph_path": ("__pregel_pull", "model_node"),
"langgraph_checkpoint_ns": AnyStr("weather_graph:"),
"checkpoint_ns": AnyStr("weather_graph:"),
"_type": "fake-messages-list-chat-model",
"ls_provider": "fakemessageslistchatmodel",
"ls_model_type": "chat",
"ls_integration": "langchain_chat_model",
@@ -4041,6 +4046,7 @@ async def test_weather_subgraph(
"langgraph_path": ("__pregel_pull", "router_node"),
"langgraph_checkpoint_ns": AnyStr("router_node:"),
"checkpoint_ns": AnyStr("router_node:"),
"_type": "fake-messages-list-chat-model",
"ls_provider": "fakemessageslistchatmodel",
"ls_model_type": "chat",
"ls_integration": "langchain_chat_model",
+531
View File
@@ -0,0 +1,531 @@
from uuid import uuid4
import pytest
from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, LLMResult
from langgraph.constants import TAG_HIDDEN, TAG_NOSTREAM
from langgraph.pregel._messages_v2 import StreamProtocolMessagesHandler
from langgraph.types import Command
META = {"langgraph_checkpoint_ns": "root:", "langgraph_node": "agent"}
def make_handler(subgraphs=True):
events = []
handler = StreamProtocolMessagesHandler(events.append, subgraphs)
return handler, events
def test_streamed_text():
handler, events = make_handler()
run_id = uuid4()
handler.on_chat_model_start(
serialized={}, messages=[[]], run_id=run_id, metadata=META, tags=[]
)
for token_text in ("Hello", " ", "world"):
chunk = ChatGenerationChunk(
message=AIMessageChunk(content=token_text, id=f"run-{run_id}")
)
handler.on_llm_new_token(token_text, chunk=chunk, run_id=run_id)
final_msg = AIMessage(content="Hello world", id=f"run-{run_id}")
handler.on_llm_end(
LLMResult(generations=[[ChatGeneration(message=final_msg)]]),
run_id=run_id,
)
data_events = [e[2] for e in events]
assert data_events[0]["event"] == "message-start"
assert data_events[1]["event"] == "content-block-start"
assert data_events[1]["index"] == 0
deltas = [d for d in data_events if d["event"] == "content-block-delta"]
assert len(deltas) == 3
assert deltas[0]["content_block"]["text"] == "Hello"
assert deltas[1]["content_block"]["text"] == " "
assert deltas[2]["content_block"]["text"] == "world"
finish_blocks = [d for d in data_events if d["event"] == "content-block-finish"]
assert len(finish_blocks) == 1
assert finish_blocks[0]["content_block"]["text"] == "Hello world"
assert data_events[-1]["event"] == "message-finish"
assert data_events[-1]["reason"] == "stop"
def test_tool_calls():
handler, events = make_handler()
run_id = uuid4()
handler.on_chat_model_start(
serialized={}, messages=[[]], run_id=run_id, metadata=META, tags=[]
)
chunk1 = ChatGenerationChunk(
message=AIMessageChunk(
content="",
tool_call_chunks=[
{"name": "search", "args": '{"q', "id": "call_1", "index": 0}
],
id=f"run-{run_id}",
)
)
handler.on_llm_new_token("", chunk=chunk1, run_id=run_id)
chunk2 = ChatGenerationChunk(
message=AIMessageChunk(
content="",
tool_call_chunks=[
{"name": None, "args": 'uery":"hi"}', "id": None, "index": 0}
],
id=f"run-{run_id}",
)
)
handler.on_llm_new_token("", chunk=chunk2, run_id=run_id)
final_msg = AIMessage(
content="",
tool_calls=[{"name": "search", "args": {"query": "hi"}, "id": "call_1"}],
id=f"run-{run_id}",
)
handler.on_llm_end(
LLMResult(generations=[[ChatGeneration(message=final_msg)]]),
run_id=run_id,
)
data_events = [e[2] for e in events]
finish_blocks = [d for d in data_events if d["event"] == "content-block-finish"]
assert len(finish_blocks) == 1
fb = finish_blocks[0]["content_block"]
assert fb["type"] == "tool_call"
assert fb["args"] == {"query": "hi"}
assert fb["name"] == "search"
assert fb["id"] == "call_1"
def test_invalid_tool_call_json():
handler, events = make_handler()
run_id = uuid4()
handler.on_chat_model_start(
serialized={}, messages=[[]], run_id=run_id, metadata=META, tags=[]
)
chunk = ChatGenerationChunk(
message=AIMessageChunk(
content="",
tool_call_chunks=[
{
"name": "search",
"args": "{not valid json",
"id": "call_2",
"index": 0,
}
],
id=f"run-{run_id}",
)
)
handler.on_llm_new_token("", chunk=chunk, run_id=run_id)
final_msg = AIMessage(content="", id=f"run-{run_id}")
handler.on_llm_end(
LLMResult(generations=[[ChatGeneration(message=final_msg)]]),
run_id=run_id,
)
data_events = [e[2] for e in events]
finish_blocks = [d for d in data_events if d["event"] == "content-block-finish"]
assert len(finish_blocks) == 1
fb = finish_blocks[0]["content_block"]
assert fb["type"] == "invalid_tool_call"
assert "Failed to parse" in fb["error"]
def test_reasoning_blocks():
handler, events = make_handler()
run_id = uuid4()
handler.on_chat_model_start(
serialized={}, messages=[[]], run_id=run_id, metadata=META, tags=[]
)
chunk = ChatGenerationChunk(
message=AIMessageChunk(
content=[{"type": "reasoning_content", "reasoning_content": "thinking..."}],
id=f"run-{run_id}",
)
)
handler.on_llm_new_token("", chunk=chunk, run_id=run_id)
final_msg = AIMessage(content="", id=f"run-{run_id}")
handler.on_llm_end(
LLMResult(generations=[[ChatGeneration(message=final_msg)]]),
run_id=run_id,
)
data_events = [e[2] for e in events]
block_starts = [d for d in data_events if d["event"] == "content-block-start"]
assert len(block_starts) == 1
assert block_starts[0]["content_block"]["type"] == "reasoning"
deltas = [d for d in data_events if d["event"] == "content-block-delta"]
assert len(deltas) == 1
assert deltas[0]["content_block"]["reasoning"] == "thinking..."
def test_multiple_content_blocks():
handler, events = make_handler()
run_id = uuid4()
handler.on_chat_model_start(
serialized={}, messages=[[]], run_id=run_id, metadata=META, tags=[]
)
chunk1 = ChatGenerationChunk(
message=AIMessageChunk(content="hello", id=f"run-{run_id}")
)
handler.on_llm_new_token("hello", chunk=chunk1, run_id=run_id)
chunk2 = ChatGenerationChunk(
message=AIMessageChunk(
content="",
tool_call_chunks=[
{"name": "lookup", "args": '{"x":1}', "id": "call_3", "index": 1}
],
id=f"run-{run_id}",
)
)
handler.on_llm_new_token("", chunk=chunk2, run_id=run_id)
final_msg = AIMessage(
content="hello",
tool_calls=[{"name": "lookup", "args": {"x": 1}, "id": "call_3"}],
id=f"run-{run_id}",
)
handler.on_llm_end(
LLMResult(generations=[[ChatGeneration(message=final_msg)]]),
run_id=run_id,
)
data_events = [e[2] for e in events]
finish_blocks = [d for d in data_events if d["event"] == "content-block-finish"]
assert len(finish_blocks) == 2
def test_usage_metadata():
handler, events = make_handler()
run_id = uuid4()
handler.on_chat_model_start(
serialized={}, messages=[[]], run_id=run_id, metadata=META, tags=[]
)
chunk = ChatGenerationChunk(
message=AIMessageChunk(content="hi", id=f"run-{run_id}")
)
handler.on_llm_new_token("hi", chunk=chunk, run_id=run_id)
final_msg = AIMessage(
content="hi",
id=f"run-{run_id}",
usage_metadata={"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
)
handler.on_llm_end(
LLMResult(generations=[[ChatGeneration(message=final_msg)]]),
run_id=run_id,
)
data_events = [e[2] for e in events]
finish_event = [d for d in data_events if d["event"] == "message-finish"][0]
assert "usage" in finish_event
assert finish_event["usage"]["input_tokens"] == 10
@pytest.mark.parametrize(
"raw_reason,expected",
[
("stop", "stop"),
("tool_calls", "tool_use"),
("length", "length"),
("content_filter", "content_filter"),
("end_turn", "stop"),
],
)
def test_finish_reason_normalization(raw_reason, expected):
handler, events = make_handler()
run_id = uuid4()
handler.on_chat_model_start(
serialized={}, messages=[[]], run_id=run_id, metadata=META, tags=[]
)
chunk = ChatGenerationChunk(message=AIMessageChunk(content="x", id=f"run-{run_id}"))
handler.on_llm_new_token("x", chunk=chunk, run_id=run_id)
final_msg = AIMessage(
content="x",
id=f"run-{run_id}",
response_metadata={"finish_reason": raw_reason},
)
handler.on_llm_end(
LLMResult(generations=[[ChatGeneration(message=final_msg)]]),
run_id=run_id,
)
data_events = [e[2] for e in events]
finish_event = [d for d in data_events if d["event"] == "message-finish"][0]
assert finish_event["reason"] == expected
def test_tag_nostream():
handler, events = make_handler()
run_id = uuid4()
handler.on_chat_model_start(
serialized={}, messages=[[]], run_id=run_id, metadata=META, tags=[TAG_NOSTREAM]
)
chunk = ChatGenerationChunk(
message=AIMessageChunk(content="secret", id=f"run-{run_id}")
)
handler.on_llm_new_token("secret", chunk=chunk, run_id=run_id)
final_msg = AIMessage(content="secret", id=f"run-{run_id}")
handler.on_llm_end(
LLMResult(generations=[[ChatGeneration(message=final_msg)]]),
run_id=run_id,
)
assert events == []
def test_tag_hidden_chain():
handler, events = make_handler()
run_id = uuid4()
handler.on_chain_start(
serialized={},
inputs={},
run_id=run_id,
metadata=META,
tags=[TAG_HIDDEN],
name="agent",
)
handler.on_chain_end(
{"messages": [AIMessage(content="hidden", id="msg-1")]},
run_id=run_id,
)
assert events == []
def test_subgraph_filtering():
handler, events = make_handler(subgraphs=False)
run_id = uuid4()
subgraph_meta = {
"langgraph_checkpoint_ns": "root:|child:",
"langgraph_node": "agent",
}
handler.on_chat_model_start(
serialized={}, messages=[[]], run_id=run_id, metadata=subgraph_meta, tags=[]
)
chunk = ChatGenerationChunk(
message=AIMessageChunk(content="sub", id=f"run-{run_id}")
)
handler.on_llm_new_token("sub", chunk=chunk, run_id=run_id)
final_msg = AIMessage(content="sub", id=f"run-{run_id}")
handler.on_llm_end(
LLMResult(generations=[[ChatGeneration(message=final_msg)]]),
run_id=run_id,
)
assert events == []
def test_chain_emits_messages():
handler, events = make_handler()
run_id = uuid4()
handler.on_chain_start(
serialized={}, inputs={}, run_id=run_id, metadata=META, tags=[], name="agent"
)
handler.on_chain_end(
{"messages": [AIMessage(content="hello", id="msg-chain-1")]},
run_id=run_id,
)
data_events = [e[2] for e in events]
assert len(data_events) > 0
assert data_events[0]["event"] == "message-start"
assert data_events[-1]["event"] == "message-finish"
def test_llm_error_after_start():
"""on_llm_error should emit a message-error event for a started stream."""
handler, events = make_handler()
run_id = uuid4()
handler.on_chat_model_start(
serialized={}, messages=[[]], run_id=run_id, metadata=META, tags=[]
)
chunk = ChatGenerationChunk(
message=AIMessageChunk(content="partial", id=f"run-{run_id}")
)
handler.on_llm_new_token("partial", chunk=chunk, run_id=run_id)
handler.on_llm_error(RuntimeError("connection lost"), run_id=run_id)
data_events = [e[2] for e in events]
assert data_events[0]["event"] == "message-start"
error_events = [d for d in data_events if d["event"] == "error"]
assert len(error_events) == 1
assert "connection lost" in error_events[0]["message"]
def test_llm_error_before_start_no_emit():
"""on_llm_error before any tokens should not emit error events."""
handler, events = make_handler()
run_id = uuid4()
handler.on_chat_model_start(
serialized={}, messages=[[]], run_id=run_id, metadata=META, tags=[]
)
# Error before any token — state.started is False
handler.on_llm_error(RuntimeError("immediate fail"), run_id=run_id)
data_events = [e[2] for e in events]
error_events = [d for d in data_events if d.get("event") == "error"]
assert len(error_events) == 0
def test_non_streamed_model():
handler, events = make_handler()
run_id = uuid4()
handler.on_chat_model_start(
serialized={}, messages=[[]], run_id=run_id, metadata=META, tags=[]
)
final_msg = AIMessage(
content="full response",
id=f"run-{run_id}",
response_metadata={"finish_reason": "stop"},
)
handler.on_llm_end(
LLMResult(generations=[[ChatGeneration(message=final_msg)]]),
run_id=run_id,
)
data_events = [e[2] for e in events]
assert len(data_events) > 0
assert data_events[0]["event"] == "message-start"
deltas = [d for d in data_events if d["event"] == "content-block-delta"]
assert len(deltas) == 1
assert deltas[0]["content_block"]["text"] == "full response"
assert data_events[-1]["event"] == "message-finish"
assert data_events[-1]["reason"] == "stop"
def test_chain_emits_command_with_message():
"""on_chain_end should emit protocol events for messages inside a Command."""
handler, events = make_handler()
run_id = uuid4()
handler.on_chain_start(
serialized={}, inputs={}, run_id=run_id, metadata=META, tags=[], name="agent"
)
handler.on_chain_end(
Command(update={"messages": [AIMessage(content="from command", id="cmd-1")]}),
run_id=run_id,
)
data_events = [e[2] for e in events]
assert len(data_events) > 0
assert data_events[0]["event"] == "message-start"
deltas = [d for d in data_events if d["event"] == "content-block-delta"]
assert len(deltas) == 1
assert deltas[0]["content_block"]["text"] == "from command"
assert data_events[-1]["event"] == "message-finish"
def test_chain_emits_command_in_list():
"""on_chain_end should handle a list containing Command objects."""
handler, events = make_handler()
run_id = uuid4()
handler.on_chain_start(
serialized={}, inputs={}, run_id=run_id, metadata=META, tags=[], name="agent"
)
handler.on_chain_end(
[Command(update={"messages": [AIMessage(content="listed", id="cmd-2")]})],
run_id=run_id,
)
data_events = [e[2] for e in events]
starts = [d for d in data_events if d["event"] == "message-start"]
assert len(starts) == 1
def test_chain_deduplicates_seen_messages():
"""Messages already seen from LLM streaming should not be re-emitted by chain end."""
handler, events = make_handler()
run_id_llm = uuid4()
run_id_chain = uuid4()
msg_id = f"run-{run_id_llm}"
# Simulate LLM streaming
handler.on_chat_model_start(
serialized={}, messages=[[]], run_id=run_id_llm, metadata=META, tags=[]
)
chunk = ChatGenerationChunk(message=AIMessageChunk(content="hello", id=msg_id))
handler.on_llm_new_token("hello", chunk=chunk, run_id=run_id_llm)
final_msg = AIMessage(content="hello", id=msg_id)
handler.on_llm_end(
LLMResult(generations=[[ChatGeneration(message=final_msg)]]),
run_id=run_id_llm,
)
events_before = len(events)
# Now chain end with the same message ID
handler.on_chain_start(
serialized={},
inputs={},
run_id=run_id_chain,
metadata=META,
tags=[],
name="agent",
)
handler.on_chain_end(
{"messages": [AIMessage(content="hello", id=msg_id)]},
run_id=run_id_chain,
)
# No new events should have been emitted for the duplicate
data_events_after = [e[2] for e in events[events_before:]]
starts = [d for d in data_events_after if d.get("event") == "message-start"]
assert len(starts) == 0
def test_chain_emits_human_message_role():
"""Non-AI messages from chain output should have the correct role."""
handler, events = make_handler()
run_id = uuid4()
handler.on_chain_start(
serialized={}, inputs={}, run_id=run_id, metadata=META, tags=[], name="agent"
)
handler.on_chain_end(
{"messages": [HumanMessage(content="user msg", id="hmsg-1")]},
run_id=run_id,
)
data_events = [e[2] for e in events]
starts = [d for d in data_events if d["event"] == "message-start"]
assert len(starts) == 1
assert starts[0]["role"] == "human"
+6 -62
View File
@@ -615,11 +615,8 @@ def test_run_from_checkpoint_id_retains_previous_writes(
)
]
# +2: one fork checkpoint from time travel, one from the new execution
assert len(new_history) == len(history) + 2
# new_history[0] is the new execution result, new_history[1] is the fork
assert new_history[1].metadata["source"] == "fork"
for original, new in zip(history, new_history[2:]):
assert len(new_history) == len(history) + 1
for original, new in zip(history, new_history[1:]):
assert original.values == new.values
assert original.next == new.next
assert original.metadata["step"] == new.metadata["step"]
@@ -627,7 +624,7 @@ def test_run_from_checkpoint_id_retains_previous_writes(
def _get_tasks(hist: list, start: int):
return [h.tasks for h in hist[start:]]
assert _get_tasks(new_history, 2) == _get_tasks(history, 0)
assert _get_tasks(new_history, 1) == _get_tasks(history, 0)
def test_batch_two_processes_in_out() -> None:
@@ -6274,7 +6271,7 @@ def test_sync_streaming_with_functional_api() -> None:
@task()
def slow() -> dict:
time.sleep(time_delay) # Simulate a delay of 10 ms
return {"tic": time.monotonic()}
return {"tic": time.time()}
@entrypoint()
def graph(inputs: dict) -> list:
@@ -6287,7 +6284,7 @@ def test_sync_streaming_with_functional_api() -> None:
for chunk in graph.stream({}):
if "slow" not in chunk: # We'll just look at the updates from `slow`
continue
arrival_times.append(time.monotonic())
arrival_times.append(time.time())
assert len(arrival_times) == 2
delta = arrival_times[1] - arrival_times[0]
@@ -6896,6 +6893,7 @@ def test_tags_stream_mode_messages() -> None:
"langgraph_path": ("__pregel_pull", "call_model"),
"langgraph_checkpoint_ns": AnyStr("call_model:"),
"checkpoint_ns": AnyStr("call_model:"),
"_type": "generic-fake-chat-model",
"ls_provider": "genericfakechatmodel",
"ls_model_type": "chat",
"ls_integration": "langchain_chat_model",
@@ -6905,60 +6903,6 @@ def test_tags_stream_mode_messages() -> None:
]
def test_configurable_propagates_to_stream_metadata() -> None:
"""Regression: thread_id, run_id, assistant_id, graph_id,
and langgraph_auth_user_id from configurable must appear
in stream_mode='messages' metadata."""
def my_node(state):
return {"messages": HumanMessage(content="hello")}
graph = (
StateGraph(MessagesState)
.add_node("my_node", my_node)
.add_edge(START, "my_node")
.compile()
)
config = {
"configurable": {
"thread_id": "th-123",
"checkpoint_id": "ckpt-1",
"checkpoint_ns": "ns-1",
"task_id": "task-1",
"run_id": "run-456",
"assistant_id": "asst-789",
"graph_id": "graph-0",
"model": "gpt-4o",
"user_id": "uid-1",
"cron_id": "cron-1",
"langgraph_auth_user_id": "user-1",
# these should NOT be propagated into metadata
"some_api_key": "secret",
"custom_setting": {"nested": True},
},
}
results = list(graph.stream({"messages": []}, config, stream_mode="messages"))
assert len(results) == 1
_, metadata = results[0]
# propagated keys
assert metadata["thread_id"] == "th-123"
assert metadata["checkpoint_id"] == "ckpt-1"
assert metadata["checkpoint_ns"] == "ns-1"
assert metadata["task_id"] == "task-1"
assert metadata["run_id"] == "run-456"
assert metadata["assistant_id"] == "asst-789"
assert metadata["graph_id"] == "graph-0"
# These are only present in trace metadata by default as of langgraph 1.2
# assert metadata["model"] == "gpt-4o"
# assert metadata["user_id"] == "uid-1"
# assert metadata["cron_id"] == "cron-1"
# assert metadata["langgraph_auth_user_id"] == "user-1"
# non-allowlisted keys must not appear
assert "some_api_key" not in metadata
assert "custom_setting" not in metadata
def test_stream_mode_messages_command() -> None:
from langchain_core.messages import HumanMessage
+4 -68
View File
@@ -20,7 +20,6 @@ from uuid import UUID
import pytest
from langchain_core.language_models import GenericFakeChatModel
from langchain_core.messages import HumanMessage
from langchain_core.runnables import RunnableConfig, RunnableLambda, RunnablePassthrough
from langchain_core.utils.aiter import aclosing
from langgraph.cache.base import BaseCache
@@ -2086,11 +2085,8 @@ async def test_run_from_checkpoint_id_retains_previous_writes(
)
]
# +2: one fork checkpoint from time travel, one from the new execution
assert len(new_history) == len(history) + 2
# new_history[0] is the new execution result, new_history[1] is the fork
assert new_history[1].metadata["source"] == "fork"
for original, new in zip(history, new_history[2:]):
assert len(new_history) == len(history) + 1
for original, new in zip(history, new_history[1:]):
assert original.values == new.values
assert original.next == new.next
assert original.metadata["step"] == new.metadata["step"]
@@ -2098,7 +2094,7 @@ async def test_run_from_checkpoint_id_retains_previous_writes(
def _get_tasks(hist: list, start: int):
return [h.tasks for h in hist[start:]]
assert _get_tasks(new_history, 2) == _get_tasks(history, 0)
assert _get_tasks(new_history, 1) == _get_tasks(history, 0)
async def test_cond_edge_after_send() -> None:
@@ -7545,6 +7541,7 @@ async def test_tags_stream_mode_messages() -> None:
"langgraph_path": ("__pregel_pull", "call_model"),
"langgraph_checkpoint_ns": AnyStr("call_model:"),
"checkpoint_ns": AnyStr("call_model:"),
"_type": "generic-fake-chat-model",
"ls_provider": "genericfakechatmodel",
"ls_model_type": "chat",
"ls_integration": "langchain_chat_model",
@@ -7554,67 +7551,6 @@ async def test_tags_stream_mode_messages() -> None:
]
async def test_configurable_propagates_to_stream_metadata() -> None:
"""Regression: thread_id, run_id, assistant_id, graph_id,
and langgraph_auth_user_id from configurable must appear
in stream_mode='messages' metadata."""
def my_node(state):
return {"messages": HumanMessage(content="hello")}
graph = (
StateGraph(MessagesState)
.add_node("my_node", my_node)
.add_edge(START, "my_node")
.compile()
)
config = {
"configurable": {
"thread_id": "th-123",
"checkpoint_id": "ckpt-1",
"checkpoint_ns": "ns-1",
"task_id": "task-1",
"run_id": "run-456",
"assistant_id": "asst-789",
"graph_id": "graph-0",
"model": "gpt-4o",
"user_id": "uid-1",
"cron_id": "cron-1",
"langgraph_auth_user_id": "user-1",
# these should NOT be propagated into metadata
"some_api_key": "secret",
"custom_setting": {"nested": True},
},
}
results = [
chunk
async for chunk in graph.astream(
{"messages": []}, config, stream_mode="messages"
)
]
assert len(results) == 1
_, metadata = results[0]
# propagated keys
assert metadata["thread_id"] == "th-123"
assert metadata["checkpoint_id"] == "ckpt-1"
assert metadata["checkpoint_ns"] == "ns-1"
assert metadata["task_id"] == "task-1"
assert metadata["run_id"] == "run-456"
assert metadata["assistant_id"] == "asst-789"
assert metadata["graph_id"] == "graph-0"
# These will only be traced as of langgraph 1.2 and not present by default in
# metadata
# assert metadata["model"] == "gpt-4o"
# assert metadata["user_id"] == "uid-1"
# assert metadata["cron_id"] == "cron-1"
# assert metadata["langgraph_auth_user_id"] == "user-1"
# non-allowlisted keys must not appear
assert "some_api_key" not in metadata
assert "custom_setting" not in metadata
async def test_stream_mode_messages_command() -> None:
from langchain_core.messages import HumanMessage
+7 -10
View File
@@ -501,13 +501,13 @@ async def test_execution_info_populated_in_graph_async() -> None:
assert isinstance(info.node_first_attempt_time, float)
def test_server_info_from_configurable() -> None:
"""server_info is built from assistant_id/graph_id in config configurable."""
def test_server_info_from_metadata() -> None:
"""server_info is built from assistant_id/graph_id in config metadata."""
captured: dict[str, Any] = {}
compiled = _make_capture_graph(captured)
compiled.invoke(
{"message": "hi"},
config={"configurable": {"assistant_id": "asst-abc", "graph_id": "my-graph"}},
config={"metadata": {"assistant_id": "asst-abc", "graph_id": "my-graph"}},
)
si = captured["server_info"]
assert si is not None
@@ -516,8 +516,8 @@ def test_server_info_from_configurable() -> None:
assert si.user is None
def test_server_info_none_without_configurable() -> None:
"""server_info is None when no assistant_id/graph_id in configurable."""
def test_server_info_none_without_metadata() -> None:
"""server_info is None when no assistant_id/graph_id in metadata."""
captured: dict[str, Any] = {}
compiled = _make_capture_graph(captured)
compiled.invoke({"message": "hi"})
@@ -579,11 +579,8 @@ def test_server_info_user_from_auth_user() -> None:
compiled.invoke(
{"message": "hi"},
config={
"configurable": {
"langgraph_auth_user": proxy,
"assistant_id": "asst-proxy",
"graph_id": "graph-proxy",
},
"configurable": {"langgraph_auth_user": proxy},
"metadata": {"assistant_id": "asst-proxy", "graph_id": "graph-proxy"},
},
)
si = captured["server_info"]
@@ -0,0 +1,245 @@
import pytest
from langgraph.stream.chat_model_stream import AsyncChatModelStream, ChatModelStream
def _text_delta(text: str) -> dict:
return {"content_block": {"type": "text", "text": text}}
def _reasoning_delta(text: str) -> dict:
return {"content_block": {"type": "reasoning", "reasoning": text}}
# ---------------------------------------------------------------------------
# Sync ChatModelStream tests
# ---------------------------------------------------------------------------
def test_sync_text_accumulates():
stream = ChatModelStream()
stream._push_content_block_delta(_text_delta("Hello"))
stream._push_content_block_delta(_text_delta(", world"))
stream._finish({"reason": "stop"})
assert stream.text == "Hello, world"
assert isinstance(stream.text, str)
def test_sync_reasoning_accumulates():
stream = ChatModelStream()
stream._push_content_block_delta(_reasoning_delta("step 1"))
stream._push_content_block_delta(_reasoning_delta(" -> step 2"))
stream._finish({"reason": "stop"})
assert stream.reasoning == "step 1 -> step 2"
assert isinstance(stream.reasoning, str)
def test_sync_usage():
stream = ChatModelStream()
usage = {"input_tokens": 10, "output_tokens": 5}
stream._finish({"reason": "stop", "usage": usage})
assert stream.usage == usage
def test_sync_mixed_blocks():
stream = ChatModelStream()
stream._push_content_block_delta(_text_delta("answer"))
stream._push_content_block_delta(
{"content_block": {"type": "tool_call", "name": "search"}}
)
stream._push_content_block_delta(_text_delta(" here"))
stream._finish({"reason": "stop"})
assert stream.text == "answer here"
def test_sync_tool_call_only_text_empty():
stream = ChatModelStream()
stream._push_content_block_delta(
{"content_block": {"type": "tool_call", "name": "search"}}
)
stream._finish({"reason": "stop"})
assert stream.text == ""
def test_sync_fail_marks_done():
stream = ChatModelStream()
assert not stream.done
stream._fail(RuntimeError("err"))
assert stream.done
def test_sync_namespace_and_node():
stream = ChatModelStream(
namespace=["agent:0", "tools:1"],
node="chat_model",
message_id="msg-123",
)
assert stream.namespace == ["agent:0", "tools:1"]
assert stream.node == "chat_model"
assert stream.message_id == "msg-123"
def test_sync_content_block_finish_authoritative():
"""content-block-finish with authoritative text overrides accumulated."""
stream = ChatModelStream()
stream._push_content_block_delta(_text_delta("partial"))
stream._push_content_block_finish(
{"content_block": {"type": "text", "text": "full text"}}
)
assert stream.text == "full text"
# ---------------------------------------------------------------------------
# Async ChatModelStream tests
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_async_text_iterable_yields_deltas():
stream = AsyncChatModelStream()
stream._push_content_block_delta(_text_delta("Hello"))
stream._push_content_block_delta(_text_delta(", world"))
stream._finish({"reason": "stop"})
collected = []
async for delta in stream.text:
collected.append(delta)
assert collected == ["Hello", ", world"]
@pytest.mark.anyio
async def test_async_text_awaitable_returns_full():
stream = AsyncChatModelStream()
stream._push_content_block_delta(_text_delta("Hello"))
stream._push_content_block_delta(_text_delta(", world"))
stream._finish({"reason": "stop"})
result = await stream.text
assert result == "Hello, world"
@pytest.mark.anyio
async def test_async_reasoning_dual_pattern():
stream = AsyncChatModelStream()
stream._push_content_block_delta(_reasoning_delta("step 1"))
stream._push_content_block_delta(_reasoning_delta(" -> step 2"))
stream._finish({"reason": "stop"})
collected = []
async for delta in stream.reasoning:
collected.append(delta)
assert collected == ["step 1", " -> step 2"]
stream2 = AsyncChatModelStream()
stream2._push_content_block_delta(_reasoning_delta("thinking"))
stream2._finish({"reason": "stop"})
full = await stream2.reasoning
assert full == "thinking"
@pytest.mark.anyio
async def test_async_usage_resolves():
stream = AsyncChatModelStream()
usage = {"input_tokens": 10, "output_tokens": 5}
stream._finish({"reason": "stop", "usage": usage})
result = await stream.usage
assert result == usage
@pytest.mark.anyio
async def test_async_mixed_blocks_text_only():
stream = AsyncChatModelStream()
stream._push_content_block_delta(_text_delta("answer"))
stream._push_content_block_delta(
{"content_block": {"type": "tool_call", "name": "search"}}
)
stream._push_content_block_delta(_text_delta(" here"))
stream._finish({"reason": "stop"})
collected = []
async for delta in stream.text:
collected.append(delta)
assert collected == ["answer", " here"]
@pytest.mark.anyio
async def test_async_tool_call_only_text_empty():
stream = AsyncChatModelStream()
stream._push_content_block_delta(
{"content_block": {"type": "tool_call", "name": "search"}}
)
stream._finish({"reason": "stop"})
result = await stream.text
assert result == ""
@pytest.mark.anyio
async def test_async_fail_raises_on_text_await():
stream = AsyncChatModelStream()
stream._push_content_block_delta(_text_delta("partial"))
stream._fail(RuntimeError("model error"))
with pytest.raises(RuntimeError, match="model error"):
await stream.text
@pytest.mark.anyio
async def test_async_fail_raises_on_reasoning_await():
stream = AsyncChatModelStream()
stream._push_content_block_delta(_reasoning_delta("thinking"))
stream._fail(RuntimeError("model error"))
with pytest.raises(RuntimeError, match="model error"):
await stream.reasoning
@pytest.mark.anyio
async def test_async_fail_raises_on_usage_await():
stream = AsyncChatModelStream()
stream._fail(RuntimeError("model error"))
with pytest.raises(RuntimeError, match="model error"):
await stream.usage
@pytest.mark.anyio
async def test_async_fail_raises_during_text_iteration():
stream = AsyncChatModelStream()
stream._push_content_block_delta(_text_delta("partial"))
stream._fail(RuntimeError("model error"))
collected = []
with pytest.raises(RuntimeError, match="model error"):
async for delta in stream.text:
collected.append(delta)
assert collected == ["partial"]
@pytest.mark.anyio
async def test_async_fail_marks_done():
stream = AsyncChatModelStream()
assert not stream.done
stream._fail(RuntimeError("err"))
assert stream.done
@pytest.mark.anyio
async def test_async_namespace_and_node():
stream = AsyncChatModelStream(
namespace=["agent:0", "tools:1"],
node="chat_model",
message_id="msg-123",
)
assert stream.namespace == ["agent:0", "tools:1"]
assert stream.node == "chat_model"
assert stream.message_id == "msg-123"
@pytest.mark.anyio
async def test_async_inherits_from_sync():
"""AsyncChatModelStream is a subclass of ChatModelStream."""
stream = AsyncChatModelStream()
assert isinstance(stream, ChatModelStream)
@@ -0,0 +1,86 @@
from langgraph.stream._convert import STREAM_V2_MODES, convert_to_protocol_event
def test_values_mode():
evt = convert_to_protocol_event((), "values", {"x": 1})
assert evt is not None
assert evt["method"] == "values"
assert evt["params"]["data"] == {"x": 1}
def test_updates_mode():
evt = convert_to_protocol_event((), "updates", {"node": "out"})
assert evt is not None
assert evt["method"] == "updates"
def test_messages_mode():
evt = convert_to_protocol_event((), "messages", {"event": "msg"})
assert evt is not None
assert evt["method"] == "messages"
def test_custom_mode():
evt = convert_to_protocol_event((), "custom", "hello")
assert evt is not None
assert evt["method"] == "custom"
assert evt["params"]["data"] == "hello"
def test_debug_mode():
evt = convert_to_protocol_event((), "debug", {})
assert evt is not None
assert evt["method"] == "debug"
def test_checkpoints_mode():
evt = convert_to_protocol_event((), "checkpoints", {})
assert evt is not None
assert evt["method"] == "checkpoints"
def test_tasks_mode():
evt = convert_to_protocol_event((), "tasks", {})
assert evt is not None
assert evt["method"] == "tasks"
def test_namespace_passthrough():
evt = convert_to_protocol_event(("agent", "0"), "values", {})
assert evt is not None
assert evt["params"]["namespace"] == ["agent", "0"]
def test_timestamp_populated():
evt = convert_to_protocol_event((), "values", {})
assert evt is not None
assert isinstance(evt["params"]["timestamp"], int)
assert evt["params"]["timestamp"] > 0
def test_unknown_mode_returns_none():
assert convert_to_protocol_event((), "unknown_mode", {}) is None
def test_node_parameter():
evt = convert_to_protocol_event((), "values", {}, node="agent")
assert evt is not None
assert evt["params"]["node"] == "agent"
def test_type_is_event():
evt = convert_to_protocol_event((), "values", {})
assert evt is not None
assert evt["type"] == "event"
def test_stream_v2_modes_complete():
assert set(STREAM_V2_MODES) == {
"values",
"updates",
"messages",
"custom",
"checkpoints",
"tasks",
"debug",
}
@@ -0,0 +1,133 @@
import asyncio
import pytest
from langgraph.stream._event_log import EventLog
@pytest.mark.anyio
async def test_push_and_iterate_in_order():
log = EventLog()
log.append("a")
log.append("b")
log.append("c")
log.close()
items = [item async for item in aiter(log)]
assert items == ["a", "b", "c"]
@pytest.mark.anyio
async def test_multiple_independent_cursors():
log = EventLog()
log.append("x")
log.append("y")
log.close()
items1 = [item async for item in aiter(log)]
items2 = [item async for item in aiter(log)]
assert items1 == ["x", "y"]
assert items2 == ["x", "y"]
@pytest.mark.anyio
async def test_close_ends_iteration():
log = EventLog()
log.close()
items = [item async for item in aiter(log)]
assert items == []
@pytest.mark.anyio
async def test_fail_raises_error():
log = EventLog()
log.fail(RuntimeError("boom"))
with pytest.raises(RuntimeError, match="boom"):
async for _ in aiter(log):
pass
@pytest.mark.anyio
async def test_concurrent_push_and_iterate():
log = EventLog()
received = []
async def consumer():
async for item in aiter(log):
received.append(item)
async def producer():
for i in range(5):
log.append(i)
await asyncio.sleep(0.01)
log.close()
await asyncio.gather(producer(), consumer())
assert received == [0, 1, 2, 3, 4]
@pytest.mark.anyio
async def test_items_before_cursor_visible():
log = EventLog()
log.append("a")
log.append("b")
cursor = aiter(log)
log.append("c")
log.close()
items = [item async for item in cursor]
assert items == ["a", "b", "c"]
@pytest.mark.anyio
async def test_empty_log_closed_yields_nothing():
log = EventLog()
log.close()
items = [item async for item in aiter(log)]
assert items == []
@pytest.mark.anyio
async def test_fail_mid_iteration():
"""A cursor that has consumed some items should raise when fail() is called."""
log = EventLog()
received = []
async def consumer():
async for item in aiter(log):
received.append(item)
async def producer():
log.append("a")
log.append("b")
await asyncio.sleep(0.02)
log.fail(RuntimeError("mid-stream error"))
with pytest.raises(RuntimeError, match="mid-stream error"):
await asyncio.gather(producer(), consumer())
assert received == ["a", "b"]
@pytest.mark.anyio
async def test_abandoned_cursor_cleans_up_waiters():
"""Abandoned async cursors should not leave stale futures in the
EventLog waiter list.
When a cursor's __anext__ is cancelled (e.g. consumer breaks out of
``async for``), the Future it registered in ``_waiters`` should be
cleaned up. Otherwise the list grows without bound until the next
append/close/fail triggers ``_wake_all()``.
"""
log: EventLog[str] = EventLog()
for _ in range(10):
cursor = aiter(log)
task = asyncio.ensure_future(cursor.__anext__())
await asyncio.sleep(0) # let task register its waiter
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
assert len(log._waiters) == 0, (
f"Expected 0 waiters after abandoning 10 cursors, "
f"got {len(log._waiters)}. Abandoned cursors leak futures."
)
+293
View File
@@ -0,0 +1,293 @@
from typing import Any
import pytest
from langgraph.stream._convert import convert_to_protocol_event
from langgraph.stream._mux import AsyncStreamMux, StreamMux
from langgraph.stream._types import ProtocolEvent
from langgraph.stream.stream_channel import StreamChannel
def _event(mode: str, data: Any, ns: list[str] | None = None) -> ProtocolEvent:
ev = convert_to_protocol_event(tuple(ns or []), mode, data)
assert ev is not None
return ev
class _MockTransformer:
def __init__(self, *, suppress: bool = False):
self.calls: list[ProtocolEvent] = []
self._suppress = suppress
def init(self) -> Any:
return None
def process(self, event: ProtocolEvent) -> bool:
self.calls.append(event)
return not self._suppress
def finalize(self) -> None:
pass
def fail(self, err: BaseException) -> None:
pass
@pytest.mark.anyio
async def test_events_through_reducer_pipeline():
reducer = _MockTransformer()
mux = StreamMux(transformers=[reducer])
event = _event("values", {"key": "val"})
mux.push(event)
assert len(reducer.calls) == 1
assert reducer.calls[0] is event
@pytest.mark.anyio
async def test_reducer_suppresses_event():
reducer = _MockTransformer(suppress=True)
mux = StreamMux(transformers=[reducer])
mux.push(_event("values", {"x": 1}))
mux.close()
assert len(reducer.calls) == 1
assert len(mux.event_log) == 0
@pytest.mark.anyio
async def test_namespace_discovery():
mux = StreamMux()
mux.push(_event("values", {"a": 1}, ns=["child:0"]))
assert "child:0" in mux._discovered_ns
@pytest.mark.anyio
async def test_top_level_ns_only():
mux = StreamMux()
mux.push(_event("values", {"a": 1}, ns=["agent:0", "tools:1"]))
assert "agent:0" in mux._discovered_ns
assert "tools:1" not in mux._discovered_ns
@pytest.mark.anyio
async def test_subscribe_events_filter():
mux = AsyncStreamMux()
mux.push(_event("values", {"a": 1}, ns=["child:0"]))
mux.push(_event("values", {"b": 2}, ns=["other:1"]))
mux.push(_event("values", {"c": 3}, ns=["child:0"]))
mux.close()
collected = []
async for ev in mux.subscribe_events(["child:0"]):
collected.append(ev)
assert len(collected) == 2
assert collected[0]["params"]["data"] == {"a": 1}
assert collected[1]["params"]["data"] == {"c": 3}
@pytest.mark.anyio
async def test_close_resolves_output():
mux = AsyncStreamMux()
fut = mux.get_output_future()
mux.push(_event("values", {"v": 1}))
mux.push(_event("values", {"v": 2}))
mux.close()
result = await fut
assert result == {"v": 2}
@pytest.mark.anyio
async def test_fail_rejects_output():
mux = AsyncStreamMux()
fut = mux.get_output_future()
mux.fail(ValueError("boom"))
with pytest.raises(ValueError, match="boom"):
await fut
@pytest.mark.anyio
async def test_latest_values_tracked():
mux = StreamMux()
mux.push(_event("values", {"v": 1}, ns=["child:0"]))
mux.push(_event("values", {"v": 2}, ns=["child:0"]))
assert mux.get_latest_values(["child:0"]) == {"v": 2}
@pytest.mark.anyio
async def test_interrupt_tracking():
"""StreamMux should track __interrupt__ payloads in values events."""
class _FakeInterrupt:
def __init__(self, id: str, payload: Any):
self.id = id
self.payload = payload
mux = StreamMux()
interrupt_obj = _FakeInterrupt("int-1", "what do you want?")
mux.push(
_event(
"values",
{"__interrupt__": [interrupt_obj]},
)
)
assert mux.interrupted is True
assert len(mux.interrupts) == 1
assert mux.interrupts[0]["interrupt_id"] == "int-1"
assert mux.interrupts[0]["payload"] is interrupt_obj
@pytest.mark.anyio
async def test_no_interrupt_by_default():
mux = StreamMux()
mux.push(_event("values", {"x": 1}))
mux.close()
assert mux.interrupted is False
assert mux.interrupts == []
@pytest.mark.anyio
async def test_push_after_close_ignored():
mux = StreamMux()
mux.push(_event("values", {"a": 1}))
mux.close()
mux.push(_event("values", {"b": 2}))
assert len(mux.event_log) == 1
@pytest.mark.anyio
async def test_fail_rejects_all_futures():
mux = AsyncStreamMux()
fut1 = mux.get_output_future([])
fut2 = mux.get_output_future(["child:0"])
mux.fail(ValueError("boom"))
with pytest.raises(ValueError, match="boom"):
await fut1
with pytest.raises(ValueError, match="boom"):
await fut2
@pytest.mark.anyio
async def test_channel_events_bypass_transformer_pipeline():
"""Events emitted via ``StreamChannel.push()`` are appended directly
to the event log, bypassing the transformer pipeline. This matches
the JS implementation and avoids re-entrancy bugs.
"""
mock = _MockTransformer()
mux = AsyncStreamMux(transformers=[mock])
channel: StreamChannel[str] = StreamChannel("my_channel")
mux.wire_channels({"ch": channel})
# Regular push — transformer sees it
mux.push(_event("values", {"a": 1}))
assert len(mock.calls) == 1
# Channel push — bypasses transformers, goes straight to event log
channel.push("hello from channel")
assert len(mock.calls) == 1, (
f"Transformer saw {len(mock.calls)} events (expected 1). "
"Channel events should bypass the transformer pipeline."
)
# But the event IS in the log
mux.close()
events = []
async for ev in mux.subscribe_events():
events.append(ev)
assert len(events) == 2
assert events[1]["method"] == "my_channel"
assert events[1]["params"]["data"] == "hello from channel"
@pytest.mark.anyio
async def test_event_log_has_monotonic_seq_numbers():
"""All events in the event log should have strictly monotonically
increasing seq numbers so consumers can reason about ordering.
Events from ``mux.push()`` carry seq numbers assigned by the pump
while channel-emitted events use a separate counter
(``_next_emit_seq``). When interleaved, seq numbers can duplicate.
"""
mux = AsyncStreamMux()
channel: StreamChannel[str] = StreamChannel("test_ch")
mux.wire_channels({"ch": channel})
mux.push(_event("values", {"a": 1})) # log seq: 0
channel.push("from_channel") # log seq: 0 (from _next_emit_seq)
mux.push(_event("values", {"b": 2})) # log seq: 1
mux.close()
seqs: list[int] = []
async for event in mux.subscribe_events():
seqs.append(event["seq"])
assert len(seqs) == 3, f"Expected 3 events but got {len(seqs)}"
for i in range(1, len(seqs)):
assert seqs[i] > seqs[i - 1], (
f"Seq numbers not strictly monotonic: {seqs}. "
f"seq[{i}]={seqs[i]} <= seq[{i - 1}]={seqs[i - 1]}. "
"Channel events use a separate counter from push() events."
)
@pytest.mark.anyio
async def test_channel_push_during_process_preserves_namespace():
"""When two transformers both call channel.push() during the same
outer mux.push(), the second transformer's channel event should
still carry the original event's namespace.
Bug: the first channel.push() re-enters mux.push(), which resets
``_current_namespace`` to ``[]`` on exit. The second transformer's
channel.push() then reads the clobbered value and its event gets
``namespace: []`` instead of the original.
"""
class _ChannelTransformer:
"""Pushes to its channel whenever it sees a ``values`` event."""
def __init__(self, name: str) -> None:
self.name = name
self.channel: StreamChannel[str] = StreamChannel(name)
def init(self) -> Any:
return {self.name: self.channel}
def process(self, event: ProtocolEvent) -> bool:
if event["method"] == "values":
self.channel.push(f"from_{self.name}")
return True
def finalize(self) -> None:
pass
def fail(self, err: BaseException) -> None:
pass
t1 = _ChannelTransformer("first")
t2 = _ChannelTransformer("second")
mux = AsyncStreamMux(transformers=[t1, t2])
mux.wire_channels({"first": t1.channel})
mux.wire_channels({"second": t2.channel})
# Push a values event with a non-root namespace
mux.push(_event("values", {"x": 1}, ns=["agent:0"]))
mux.close()
# Collect channel events emitted by each transformer
channel_events: list[ProtocolEvent] = []
async for ev in mux.subscribe_events():
if ev["method"] in ("first", "second"):
channel_events.append(ev)
assert len(channel_events) == 2, (
f"Expected 2 channel events but got {len(channel_events)}"
)
for ev in channel_events:
assert ev["params"]["namespace"] == ["agent:0"], (
f"Channel event for method={ev['method']!r} has "
f"namespace={ev['params']['namespace']!r}, expected ['agent:0']. "
"The nested mux.push() from the first channel.push() clobbered "
"_current_namespace before the second transformer ran."
)
@@ -0,0 +1,214 @@
from typing import Any
import pytest
from langgraph.stream._convert import convert_to_protocol_event
from langgraph.stream._types import ProtocolEvent
from langgraph.stream.chat_model_stream import ChatModelStream
from langgraph.stream.transformers import MessagesTransformer, ValuesTransformer
def _event(
mode: str,
data: Any,
ns: list[str] | None = None,
node: str | None = None,
) -> ProtocolEvent:
ev = convert_to_protocol_event(tuple(ns or []), mode, data, node=node)
assert ev is not None
return ev
# -- ValuesTransformer ---------------------------------------------------------
@pytest.mark.anyio
async def test_values_captures_values_events():
reducer = ValuesTransformer()
reducer.init()
reducer.process(_event("values", {"a": 1}))
reducer.process(_event("values", {"b": 2}))
reducer.finalize()
collected = []
async for item in reducer.values_log:
collected.append(item)
assert len(collected) == 2
assert collected[0]["data"] == {"a": 1}
assert collected[1]["data"] == {"b": 2}
@pytest.mark.anyio
async def test_values_ignores_other_modes():
reducer = ValuesTransformer()
reducer.init()
reducer.process(_event("updates", {"x": 1}))
reducer.process(_event("messages", {"event": "message-start"}))
reducer.finalize()
collected = []
async for item in reducer.values_log:
collected.append(item)
assert len(collected) == 0
@pytest.mark.anyio
async def test_values_latest_per_namespace():
reducer = ValuesTransformer()
reducer.init()
reducer.process(_event("values", {"v": 1}, ns=["child:0"]))
reducer.process(_event("values", {"v": 2}, ns=["child:0"]))
assert reducer.get_latest("child:0") == {"v": 2}
@pytest.mark.anyio
async def test_values_finalize_closes_log():
reducer = ValuesTransformer()
reducer.init()
reducer.process(_event("values", {"a": 1}))
reducer.finalize()
assert reducer.values_log.closed
# -- MessagesTransformer -------------------------------------------------------
def _msg_start(ns=None, node=None, message_id="msg-1"):
return _event(
"messages",
{"event": "message-start", "message_id": message_id},
ns=ns,
node=node,
)
def _content_delta(text, ns=None, node=None):
return _event(
"messages",
{
"event": "content-block-delta",
"content_block": {"type": "text", "text": text},
},
ns=ns,
node=node,
)
def _msg_finish(ns=None, node=None):
return _event(
"messages",
{"event": "message-finish", "reason": "stop"},
ns=ns,
node=node,
)
@pytest.mark.anyio
async def test_messages_groups_lifecycle():
reducer = MessagesTransformer()
reducer.init()
reducer.process(_msg_start())
reducer.process(_content_delta("hi"))
reducer.process(_msg_finish())
reducer.finalize()
collected = []
async for stream in reducer.messages_log:
collected.append(stream)
assert len(collected) == 1
assert isinstance(collected[0], ChatModelStream)
assert collected[0].done
@pytest.mark.anyio
async def test_messages_multiple_sequential():
reducer = MessagesTransformer()
reducer.init()
reducer.process(_msg_start(message_id="m1"))
reducer.process(_msg_finish())
reducer.process(_msg_start(message_id="m2"))
reducer.process(_msg_finish())
reducer.finalize()
collected = []
async for stream in reducer.messages_log:
collected.append(stream)
assert len(collected) == 2
@pytest.mark.anyio
async def test_messages_namespace_filter():
reducer = MessagesTransformer(namespace=["root"])
reducer.init()
reducer.process(_msg_start(ns=["root"]))
reducer.process(_msg_finish(ns=["root"]))
reducer.process(_msg_start(ns=["other"], message_id="m2"))
reducer.process(_msg_finish(ns=["other"]))
reducer.finalize()
collected = []
async for stream in reducer.messages_log:
collected.append(stream)
assert len(collected) == 1
@pytest.mark.anyio
async def test_messages_node_filter():
reducer = MessagesTransformer(node_filter="agent")
reducer.init()
reducer.process(_msg_start(node="agent"))
reducer.process(_msg_finish(node="agent"))
reducer.process(_msg_start(node="tools", message_id="m2"))
reducer.process(_msg_finish(node="tools"))
reducer.finalize()
collected = []
async for stream in reducer.messages_log:
collected.append(stream)
assert len(collected) == 1
@pytest.mark.anyio
async def test_messages_error_event():
"""An error event should fail the active ChatModelStream."""
reducer = MessagesTransformer()
reducer.init()
reducer.process(_msg_start())
reducer.process(_content_delta("partial"))
reducer.process(
_event("messages", {"event": "error", "message": "connection lost"}),
)
reducer.finalize()
collected: list[ChatModelStream] = []
async for stream in reducer.messages_log:
collected.append(stream)
assert len(collected) == 1
assert collected[0].done
@pytest.mark.anyio
async def test_messages_fail_propagates_to_active():
"""transformer.fail() should propagate the error to any active streams."""
reducer = MessagesTransformer()
reducer.init()
reducer.process(_msg_start())
reducer.process(_content_delta("partial"))
reducer.fail(RuntimeError("graph failed"))
# The messages log should be failed too
with pytest.raises(RuntimeError, match="graph failed"):
async for _ in reducer.messages_log:
pass
@pytest.mark.anyio
async def test_values_fail_propagates():
reducer = ValuesTransformer()
reducer.init()
reducer.process(_event("values", {"a": 1}))
reducer.fail(RuntimeError("graph failed"))
with pytest.raises(RuntimeError, match="graph failed"):
async for _ in reducer.values_log:
pass
@@ -0,0 +1,980 @@
import asyncio
from collections.abc import AsyncIterator, Iterator
from typing import Any
import pytest
from langgraph.stream._mux import AsyncStreamMux
from langgraph.stream._types import ProtocolEvent
from langgraph.stream.chat_model_stream import ChatModelStream
from langgraph.stream.run_stream import (
AsyncGraphRunStream,
AsyncSubgraphRunStream,
SubgraphRunStream,
create_async_graph_run_stream,
create_graph_run_stream,
)
from langgraph.stream.transformers import MessagesTransformer, ValuesTransformer
async def _mock_source(
chunks: list[tuple[tuple[str, ...], str, Any]],
) -> AsyncIterator[tuple[tuple[str, ...], str, Any]]:
for chunk in chunks:
yield chunk
@pytest.mark.anyio
async def test_aiter_yields_all_events():
chunks = [
((), "values", {"step": 1}),
((), "values", {"step": 2}),
((), "updates", {"node": "a"}),
]
run = await create_async_graph_run_stream(_mock_source(chunks))
await asyncio.sleep(0.05)
collected: list[ProtocolEvent] = []
async for event in run:
collected.append(event)
assert len(collected) == 3
assert collected[0]["method"] == "values"
assert collected[2]["method"] == "updates"
@pytest.mark.anyio
async def test_subgraph_name_and_index():
vr, mr = ValuesTransformer(), MessagesTransformer()
mux = AsyncStreamMux(transformers=[vr, mr])
sub = AsyncSubgraphRunStream(
mux=mux,
namespace=["researcher:2"],
transformers=[vr, mr],
)
assert sub.name == "researcher"
assert sub.index == 2
@pytest.mark.anyio
async def test_subgraph_name_no_index():
vr, mr = ValuesTransformer(), MessagesTransformer()
mux = AsyncStreamMux(transformers=[vr, mr])
sub = AsyncSubgraphRunStream(
mux=mux, namespace=["agent"], transformers=[vr, mr]
)
assert sub.name == "agent"
assert sub.index == 0
@pytest.mark.anyio
async def test_values_iterable():
chunks = [
((), "values", {"v": 1}),
((), "values", {"v": 2}),
]
run = await create_async_graph_run_stream(_mock_source(chunks))
await asyncio.sleep(0.05)
collected = []
async for v in run.values:
collected.append(v)
assert len(collected) == 2
assert collected[0] == {"v": 1}
assert collected[1] == {"v": 2}
@pytest.mark.anyio
async def test_values_awaitable():
chunks = [
((), "values", {"v": 1}),
((), "values", {"v": 2}),
]
run = await create_async_graph_run_stream(_mock_source(chunks))
await asyncio.sleep(0.05)
result = await run.values
assert result == {"v": 2}
@pytest.mark.anyio
async def test_output_resolves():
chunks = [((), "values", {"final": True})]
run = await create_async_graph_run_stream(_mock_source(chunks))
await asyncio.sleep(0.05)
result = await run.output
assert result == {"final": True}
@pytest.mark.anyio
async def test_messages_yields_streams():
chunks = [
((), "messages", {"event": "message-start", "message_id": "m1"}),
(
(),
"messages",
{
"event": "content-block-delta",
"content_block": {"type": "text", "text": "hi"},
},
),
((), "messages", {"event": "message-finish", "reason": "stop"}),
]
run = await create_async_graph_run_stream(_mock_source(chunks))
await asyncio.sleep(0.05)
collected: list[ChatModelStream] = []
async for stream in run.messages:
collected.append(stream)
assert len(collected) == 1
assert isinstance(collected[0], ChatModelStream)
assert collected[0].done
@pytest.mark.anyio
async def test_interrupted_false_by_default():
vr, mr = ValuesTransformer(), MessagesTransformer()
mux = AsyncStreamMux(transformers=[vr, mr])
run = AsyncGraphRunStream(mux=mux, transformers=[vr, mr])
assert run.interrupted is False
@pytest.mark.anyio
async def test_abort_sets_signal():
vr, mr = ValuesTransformer(), MessagesTransformer()
mux = AsyncStreamMux(transformers=[vr, mr])
run = AsyncGraphRunStream(mux=mux, transformers=[vr, mr])
assert not run.signal.is_set()
run.abort()
assert run.signal.is_set()
@pytest.mark.anyio
async def test_abort_stops_pump():
"""Calling abort() should stop the pump from processing further chunks."""
gate = asyncio.Event()
async def _gated_source():
yield ((), "values", {"v": 1})
yield ((), "values", {"v": 2})
await gate.wait() # Block until released
yield ((), "values", {"v": 3}) # Should not be processed
run = await create_async_graph_run_stream(_gated_source())
await asyncio.sleep(0.05) # Let first two events through
run.abort()
gate.set() # Unblock the source so the pump can check abort and exit
await asyncio.sleep(0.05) # Let pump close the mux
collected = []
async for event in run:
if event["method"] == "values":
collected.append(event["params"]["data"])
# v:3 should not have been processed because abort was set
assert all(v.get("v") != 3 for v in collected)
@pytest.mark.anyio
async def test_messages_from_filters_by_node():
"""messages_from(node) should only yield messages from the specified node."""
chunks = [
(
(),
"messages",
{"event": "message-start", "message_id": "m1", "__node__": "agent"},
),
(
(),
"messages",
{
"event": "content-block-delta",
"content_block": {"type": "text", "text": "from agent"},
"__node__": "agent",
},
),
(
(),
"messages",
{"event": "message-finish", "reason": "stop", "__node__": "agent"},
),
(
(),
"messages",
{"event": "message-start", "message_id": "m2", "__node__": "tools"},
),
(
(),
"messages",
{
"event": "content-block-delta",
"content_block": {"type": "text", "text": "from tools"},
"__node__": "tools",
},
),
(
(),
"messages",
{"event": "message-finish", "reason": "stop", "__node__": "tools"},
),
]
run = await create_async_graph_run_stream(_mock_source(chunks))
await asyncio.sleep(0.05)
agent_msgs: list[ChatModelStream] = []
async for stream in run.messages_from("agent"):
agent_msgs.append(stream)
assert len(agent_msgs) == 1
assert agent_msgs[0].node == "agent"
# ---------------------------------------------------------------------------
# GraphRunStream / create_graph_run_stream
# ---------------------------------------------------------------------------
def _sync_source(
chunks: list[tuple[tuple[str, ...], str, Any]],
) -> Iterator[tuple[tuple[str, ...], str, Any]]:
yield from chunks
def test_sync_create_yields_all_events():
chunks = [
((), "values", {"step": 1}),
((), "values", {"step": 2}),
((), "updates", {"node": "a"}),
]
run = create_graph_run_stream(_sync_source(chunks))
collected = list(run)
assert len(collected) == 3
assert collected[0]["method"] == "values"
assert collected[2]["method"] == "updates"
def test_sync_output():
chunks = [
((), "values", {"v": 1}),
((), "values", {"v": 2}),
]
run = create_graph_run_stream(_sync_source(chunks))
assert run.output == {"v": 2}
def test_sync_values_iteration():
chunks = [
((), "values", {"v": 1}),
((), "values", {"v": 2}),
]
run = create_graph_run_stream(_sync_source(chunks))
collected = list(run.values)
assert len(collected) == 2
assert collected[0] == {"v": 1}
assert collected[1] == {"v": 2}
def test_sync_messages():
chunks = [
((), "messages", {"event": "message-start", "message_id": "m1"}),
(
(),
"messages",
{
"event": "content-block-delta",
"content_block": {"type": "text", "text": "hi"},
},
),
((), "messages", {"event": "message-finish", "reason": "stop"}),
]
run = create_graph_run_stream(_sync_source(chunks))
collected = list(run.messages)
assert len(collected) == 1
assert isinstance(collected[0], ChatModelStream)
assert collected[0].done
def test_sync_messages_text_streaming():
"""Sync consumers can iterate msg.text for deltas."""
chunks = [
((), "messages", {"event": "message-start", "message_id": "m1"}),
(
(),
"messages",
{
"event": "content-block-delta",
"content_block": {"type": "text", "text": "Hello"},
},
),
(
(),
"messages",
{
"event": "content-block-delta",
"content_block": {"type": "text", "text": " world"},
},
),
((), "messages", {"event": "message-finish", "reason": "stop"}),
]
# Iterate deltas
run = create_graph_run_stream(_sync_source(chunks))
for msg in run.messages:
deltas = list(msg.text)
assert deltas == ["Hello", " world"]
assert msg.done
# str() returns full text
run = create_graph_run_stream(_sync_source(chunks))
for msg in run.messages:
assert str(msg.text) == "Hello world"
# After message is done, .text returns plain str
run = create_graph_run_stream(_sync_source(chunks))
for msg in run.messages:
list(msg.text) # exhaust deltas
assert isinstance(msg.text, str)
assert msg.text == "Hello world"
def test_sync_messages_multiple():
"""Multiple sync messages each stream their own deltas."""
chunks = [
((), "messages", {"event": "message-start", "message_id": "m1"}),
(
(),
"messages",
{
"event": "content-block-delta",
"content_block": {"type": "text", "text": "answer"},
},
),
((), "messages", {"event": "message-finish", "reason": "stop"}),
((), "messages", {"event": "message-start", "message_id": "m2"}),
(
(),
"messages",
{
"event": "content-block-delta",
"content_block": {"type": "text", "text": "second"},
},
),
((), "messages", {"event": "message-finish", "reason": "stop"}),
]
run = create_graph_run_stream(_sync_source(chunks))
all_deltas = []
for msg in run.messages:
all_deltas.append(list(msg.text))
assert all_deltas == [["answer"], ["second"]]
def test_sync_output_mapper():
chunks = [((), "values", {"v": 1})]
run = create_graph_run_stream(
_sync_source(chunks), output_mapper=lambda x: {"mapped": x["v"]}
)
assert run.output == {"mapped": 1}
def test_sync_interrupted_false():
chunks = [((), "values", {"v": 1})]
run = create_graph_run_stream(_sync_source(chunks))
assert run.interrupted is False
def test_sync_source_error():
"""If the source raises, the mux should fail and the error should propagate."""
def _bad_source():
yield ((), "values", {"v": 1})
raise ValueError("source error")
run = create_graph_run_stream(_bad_source())
collected = list(run)
# Events before the error are still accessible
assert len(collected) >= 1
assert collected[0]["method"] == "values"
# The mux recorded the failure
assert run._mux._error is not None
assert isinstance(run._mux._error, ValueError)
assert "source error" in str(run._mux._error)
# ---------------------------------------------------------------------------
# GraphRunStream — lazy consumption tests
# ---------------------------------------------------------------------------
def test_sync_lazy_not_consumed_on_creation():
"""Source iterator should not be consumed when the stream is created."""
consumed = 0
def counting_source():
nonlocal consumed
for chunk in [
((), "values", {"v": 1}),
((), "values", {"v": 2}),
((), "values", {"v": 3}),
]:
consumed += 1
yield chunk
create_graph_run_stream(counting_source())
assert consumed == 0
def test_sync_lazy_values_pull_incrementally():
"""Iterating .values should pull from the source one event at a time."""
consumed = 0
def counting_source():
nonlocal consumed
for chunk in [
((), "values", {"v": 1}),
((), "values", {"v": 2}),
((), "values", {"v": 3}),
]:
consumed += 1
yield chunk
run = create_graph_run_stream(counting_source())
assert consumed == 0
it = iter(run.values)
v = next(it)
assert v == {"v": 1}
assert consumed == 1
v = next(it)
assert v == {"v": 2}
assert consumed == 2
# Source not fully drained yet
assert consumed < 3
def test_sync_lazy_output_drains_all():
"""Accessing .output should drain the entire source."""
consumed = 0
def counting_source():
nonlocal consumed
for chunk in [((), "values", {"v": i}) for i in range(5)]:
consumed += 1
yield chunk
run = create_graph_run_stream(counting_source())
assert consumed == 0
assert run.output == {"v": 4}
assert consumed == 5
def test_sync_lazy_early_break():
"""Breaking out of a projection early should leave the source partially consumed."""
consumed = 0
def counting_source():
nonlocal consumed
for chunk in [((), "values", {"v": i}) for i in range(10)]:
consumed += 1
yield chunk
run = create_graph_run_stream(counting_source())
for v in run.values:
break # consume only the first value
assert consumed == 1
assert consumed < 10
def test_sync_lazy_interleaved_projections():
"""Switching between projections replays buffered items then resumes pumping."""
consumed = 0
def counting_source():
nonlocal consumed
for chunk in [
((), "values", {"v": 1}),
((), "messages", {"event": "message-start", "message_id": "m1"}),
((), "messages", {"event": "message-finish", "reason": "stop"}),
((), "values", {"v": 2}),
]:
consumed += 1
yield chunk
run = create_graph_run_stream(counting_source())
# Pull first value — consumes 1 source item
vit = iter(run.values)
assert next(vit) == {"v": 1}
assert consumed == 1
# Pull first message — yielded on message-start (item 2).
# Consuming str(msg.text) drives the pump to message-finish (item 3).
mit = iter(run.messages)
msg = next(mit)
assert isinstance(msg, ChatModelStream)
assert consumed == 2
assert not msg.done
str(msg.text) # pump until message completes
assert msg.done
assert consumed == 3
# Pull second value — pumps values (item 4)
assert next(vit) == {"v": 2}
assert consumed == 4
def test_sync_lazy_iter_pulls_incrementally():
"""Raw __iter__ should pull from the source lazily."""
consumed = 0
def counting_source():
nonlocal consumed
for chunk in [
((), "values", {"v": 1}),
((), "updates", {"node": "a"}),
((), "values", {"v": 2}),
]:
consumed += 1
yield chunk
run = create_graph_run_stream(counting_source())
it = iter(run)
event = next(it)
assert event["method"] == "values"
assert consumed == 1
event = next(it)
assert event["method"] == "updates"
assert consumed == 2
def test_sync_lazy_source_error():
"""If the source raises mid-stream, earlier events are still accessible."""
consumed = 0
def bad_source():
nonlocal consumed
consumed += 1
yield ((), "values", {"v": 1})
raise ValueError("boom")
run = create_graph_run_stream(bad_source())
collected = list(run)
assert len(collected) >= 1
assert collected[0]["method"] == "values"
@pytest.mark.anyio
async def test_subgraph_child_values_receive_post_discovery_events():
"""Child AsyncSubgraphRunStream.values iteration should include events
that arrive AFTER the subgraph namespace is first discovered.
``_SubgraphsProjection`` creates a local ``ValuesTransformer`` for
each child and replays existing events, but never registers the
transformer with the mux. Events that arrive after discovery are
not routed to it, and ``finalize()`` is not called (the mux wasn't
closed at discovery time), so the child's values_log is never
closed and iteration hangs.
"""
gate = asyncio.Event()
async def _source() -> AsyncIterator[tuple[tuple[str, ...], str, Any]]:
# First event from child namespace — triggers discovery
yield (("child:0",), "values", {"v": 1})
await gate.wait()
# Second event from same child — arrives after discovery
yield (("child:0",), "values", {"v": 2})
# Root event so the mux tracks output
yield ((), "values", {"done": True})
run = await create_async_graph_run_stream(_source())
await asyncio.sleep(0.05) # let pump process first event
# Get the first subgraph while the mux is still open
sub = None
async for s in run.subgraphs:
sub = s
break
assert sub is not None
# Release the gate so the pump finishes
gate.set()
await asyncio.sleep(0.05) # let pump close mux
# ``await sub.output`` uses the mux's output future — works fine
output = await sub.output
assert output == {"v": 2}, "await sub.output should reflect the latest value"
# But ``async for v in sub.values`` only gets the replayed event
# and then hangs because the child's values_log is never closed.
values: list[Any] = []
try:
async with asyncio.timeout(1.0):
async for v in sub.values:
values.append(v)
except (asyncio.TimeoutError, TimeoutError):
pass
assert len(values) == 2, (
f"Expected 2 child value snapshots but got {len(values)}: {values}. "
"Child transformer missed post-discovery events."
)
# ---------------------------------------------------------------------------
# SubgraphRunStream — sync subgraph tests
# ---------------------------------------------------------------------------
def test_sync_subgraphs_discovery():
"""Iterating .subgraphs should discover child namespaces and yield
SubgraphRunStream instances with correct name and index.
"""
chunks = [
(("agent:0",), "values", {"v": 1}),
(("agent:1",), "values", {"v": 2}),
((), "values", {"done": True}),
]
run = create_graph_run_stream(_sync_source(chunks))
subs = list(run.subgraphs)
assert len(subs) == 2
assert all(isinstance(s, SubgraphRunStream) for s in subs)
assert subs[0].name == "agent"
assert subs[0].index == 0
assert subs[1].name == "agent"
assert subs[1].index == 1
def test_sync_subgraph_name_no_index():
"""Subgraph without a colon-delimited index should have index=0."""
chunks = [
(("planner",), "values", {"v": 1}),
((), "values", {"done": True}),
]
run = create_graph_run_stream(_sync_source(chunks))
subs = list(run.subgraphs)
assert len(subs) == 1
assert subs[0].name == "planner"
assert subs[0].index == 0
def test_sync_subgraph_no_subgraphs():
"""When all events are root-level, .subgraphs should yield nothing."""
chunks = [
((), "values", {"v": 1}),
((), "values", {"v": 2}),
]
run = create_graph_run_stream(_sync_source(chunks))
subs = list(run.subgraphs)
assert subs == []
def test_sync_subgraph_values():
"""SubgraphRunStream.values should yield only values from the child namespace."""
chunks = [
(("child:0",), "values", {"v": 1}),
((), "values", {"root": True}),
(("child:0",), "values", {"v": 2}),
((), "values", {"done": True}),
]
run = create_graph_run_stream(_sync_source(chunks))
for sub in run.subgraphs:
vals = list(sub.values)
assert vals == [{"v": 1}, {"v": 2}]
def test_sync_subgraph_values_multiple_children():
"""Each child stream should only see its own values."""
chunks = [
(("a:0",), "values", {"who": "a0"}),
(("b:0",), "values", {"who": "b0"}),
(("a:0",), "values", {"who": "a0-2"}),
((), "values", {"done": True}),
]
run = create_graph_run_stream(_sync_source(chunks))
children: dict[str, list[Any]] = {}
for sub in run.subgraphs:
children[f"{sub.name}:{sub.index}"] = list(sub.values)
assert children["a:0"] == [{"who": "a0"}, {"who": "a0-2"}]
assert children["b:0"] == [{"who": "b0"}]
def test_sync_subgraph_output():
"""SubgraphRunStream.output should return the last values for the child."""
chunks = [
(("child:0",), "values", {"v": 1}),
(("child:0",), "values", {"v": 2}),
((), "values", {"done": True}),
]
run = create_graph_run_stream(_sync_source(chunks))
for sub in run.subgraphs:
assert sub.output == {"v": 2}
def test_sync_subgraph_output_with_mapper():
"""Output mapper should apply to subgraph output."""
chunks = [
(("child:0",), "values", {"v": 42}),
((), "values", {"done": True}),
]
run = create_graph_run_stream(
_sync_source(chunks), output_mapper=lambda x: {"mapped": x.get("v")}
)
for sub in run.subgraphs:
assert sub.output == {"mapped": 42}
def test_sync_subgraph_values_with_mapper():
"""Output mapper should apply to each yielded value snapshot."""
chunks = [
(("child:0",), "values", {"v": 1}),
(("child:0",), "values", {"v": 2}),
((), "values", {"done": True}),
]
run = create_graph_run_stream(
_sync_source(chunks), output_mapper=lambda x: {"m": x.get("v")}
)
for sub in run.subgraphs:
vals = list(sub.values)
assert vals == [{"m": 1}, {"m": 2}]
def test_sync_subgraph_messages():
"""SubgraphRunStream.messages should yield fully populated ChatModelStream instances."""
chunks = [
(
("agent:0",),
"messages",
{"event": "message-start", "message_id": "m1", "__node__": "agent"},
),
(
("agent:0",),
"messages",
{
"event": "content-block-delta",
"content_block": {"type": "text", "text": "hello"},
"__node__": "agent",
},
),
(
("agent:0",),
"messages",
{"event": "message-finish", "reason": "stop", "__node__": "agent"},
),
((), "values", {"done": True}),
]
run = create_graph_run_stream(_sync_source(chunks))
for sub in run.subgraphs:
msgs = list(sub.messages)
assert len(msgs) == 1
assert isinstance(msgs[0], ChatModelStream)
assert msgs[0].done
assert msgs[0].text == "hello"
def test_sync_subgraph_messages_isolated():
"""Messages from different subgraphs should not leak between children."""
chunks = [
(
("a:0",),
"messages",
{"event": "message-start", "message_id": "m-a", "__node__": "a"},
),
(
("a:0",),
"messages",
{
"event": "content-block-delta",
"content_block": {"type": "text", "text": "from-a"},
"__node__": "a",
},
),
(
("a:0",),
"messages",
{"event": "message-finish", "reason": "stop", "__node__": "a"},
),
(
("b:0",),
"messages",
{"event": "message-start", "message_id": "m-b", "__node__": "b"},
),
(
("b:0",),
"messages",
{
"event": "content-block-delta",
"content_block": {"type": "text", "text": "from-b"},
"__node__": "b",
},
),
(
("b:0",),
"messages",
{"event": "message-finish", "reason": "stop", "__node__": "b"},
),
((), "values", {"done": True}),
]
run = create_graph_run_stream(_sync_source(chunks))
msg_texts: dict[str, list[str]] = {}
for sub in run.subgraphs:
msg_texts[sub.name] = [str(m.text) for m in sub.messages]
assert msg_texts["a"] == ["from-a"]
assert msg_texts["b"] == ["from-b"]
def test_sync_subgraph_raw_iter():
"""Iterating a SubgraphRunStream directly should yield events scoped
to the child namespace.
"""
chunks = [
(("child:0",), "values", {"v": 1}),
((), "values", {"root": True}),
(("child:0",), "updates", {"node": "x"}),
((), "values", {"done": True}),
]
run = create_graph_run_stream(_sync_source(chunks))
for sub in run.subgraphs:
events = list(sub)
methods = [e["method"] for e in events]
assert "values" in methods
assert "updates" in methods
# Root events should not appear
for e in events:
assert e["params"]["namespace"] == ["child:0"]
def test_sync_subgraph_events_after_discovery():
"""Events arriving after a namespace is first discovered should still
be visible in the child's values iteration.
"""
chunks = [
(("child:0",), "values", {"v": 1}), # triggers discovery
((), "values", {"root": 1}),
(("child:0",), "values", {"v": 2}), # after discovery
(("child:0",), "values", {"v": 3}), # after discovery
((), "values", {"done": True}),
]
run = create_graph_run_stream(_sync_source(chunks))
for sub in run.subgraphs:
vals = list(sub.values)
assert vals == [{"v": 1}, {"v": 2}, {"v": 3}]
def test_sync_subgraph_lazy_pump():
"""Subgraph iteration should pump the source lazily."""
consumed = 0
def counting_source():
nonlocal consumed
for chunk in [
(("child:0",), "values", {"v": 1}),
(("child:0",), "values", {"v": 2}),
(("child:0",), "values", {"v": 3}),
((), "values", {"done": True}),
]:
consumed += 1
yield chunk
run = create_graph_run_stream(counting_source())
assert consumed == 0
for sub in run.subgraphs:
# Discovery pumped the first event
it = iter(sub.values)
v = next(it)
assert v == {"v": 1}
# Should not have consumed everything yet
assert consumed < 4
break # don't exhaust subgraphs
def test_sync_subgraph_interleave_parent_values():
"""Parent values and subgraph values should both be accessible
when interleaving iteration.
"""
chunks = [
((), "values", {"root": 1}),
(("child:0",), "values", {"child": 1}),
((), "values", {"root": 2}),
(("child:0",), "values", {"child": 2}),
((), "values", {"root": 3}),
]
run = create_graph_run_stream(_sync_source(chunks))
# First drain parent values
root_vals = list(run.values)
assert root_vals == [{"root": 1}, {"root": 2}, {"root": 3}]
# Source is exhausted, but subgraph transformers were registered
# via replay — subgraph iteration should still see buffered events
# Note: subgraphs must be iterated while source is being pumped
# to discover namespaces. Since we drained via values, namespace
# "child:0" was already discovered. But subgraphs iteration also
# needs to pump — and the source is exhausted. Let's verify it
# yields the discovered child.
subs = list(run.subgraphs)
assert len(subs) == 1
assert subs[0].name == "child"
# The child transformer was registered via replay, so it saw the events
vals = list(subs[0].values)
assert vals == [{"child": 1}, {"child": 2}]
def test_sync_subgraph_interrupted():
"""Subgraph .interrupted should reflect the mux's interrupt state."""
class _FakeInterrupt:
def __init__(self, id: str):
self.id = id
chunks = [
(("child:0",), "values", {"__interrupt__": [_FakeInterrupt("i1")]}),
((), "values", {"done": True}),
]
run = create_graph_run_stream(_sync_source(chunks))
for sub in run.subgraphs:
# Pump to process the interrupt
_ = sub.output
assert sub.interrupted is True
assert len(sub.interrupts) == 1
def test_sync_subgraph_source_error():
"""If the source raises mid-stream, subgraphs that were already
discovered should still have their buffered data.
"""
def bad_source():
yield (("child:0",), "values", {"v": 1})
yield (("child:0",), "values", {"v": 2})
raise ValueError("boom")
run = create_graph_run_stream(bad_source())
for sub in run.subgraphs:
vals = list(sub.values)
assert vals == [{"v": 1}, {"v": 2}]
assert run._mux._error is not None
def test_sync_subgraph_output_drains_source():
"""Accessing subgraph .output should drain the full source."""
consumed = 0
def counting_source():
nonlocal consumed
for chunk in [
(("child:0",), "values", {"v": 1}),
(("child:0",), "values", {"v": 2}),
((), "values", {"done": True}),
]:
consumed += 1
yield chunk
run = create_graph_run_stream(counting_source())
for sub in run.subgraphs:
result = sub.output
assert result == {"v": 2}
assert consumed == 3
@@ -0,0 +1,420 @@
"""Prove V1 and StreamingHandler APIs expose identical information.
Each test runs the same graph through both APIs and asserts data
equivalence same state snapshots, same messages, same custom events,
same interrupts. Sync APIs are used where possible; async tests cover
features without sync equivalents (subgraphs projection, messages_from).
Run with:
TEST=tests/test_streaming_comparison.py make test
"""
from __future__ import annotations
from typing import Annotated
import pytest
from langchain_core.messages import AIMessage, HumanMessage
from langgraph.checkpoint.memory import MemorySaver
from typing_extensions import TypedDict
from langgraph.config import get_stream_writer
from langgraph.graph import END, START, MessagesState, StateGraph
from langgraph.stream import StreamingHandler
from langgraph.stream._convert import STREAM_V2_MODES
from langgraph.types import interrupt
from tests.fake_chat import FakeChatModel
# ---------------------------------------------------------------------------
# Graph factories
# ---------------------------------------------------------------------------
class State(TypedDict):
value: str
items: Annotated[list[str], lambda a, b: a + b]
def _linear_graph(n_nodes: int = 3):
"""Chain of *n_nodes* that concatenate strings."""
g = StateGraph(State)
names = [f"node_{i}" for i in range(n_nodes)]
for name in names:
def make_fn(n):
def fn(state: State) -> dict:
return {"value": state["value"] + f"_{n}", "items": [n]}
return fn
g.add_node(name, make_fn(name))
g.add_edge(START, names[0])
for i in range(len(names) - 1):
g.add_edge(names[i], names[i + 1])
g.add_edge(names[-1], END)
return g.compile()
def _chat_graph():
"""Single agent node with a FakeChatModel."""
model = FakeChatModel(messages=[AIMessage(content="Hello from agent")])
def agent(state: dict) -> dict:
return {"messages": [model.invoke(state["messages"])]}
g = StateGraph(MessagesState)
g.add_node("agent", agent)
g.add_edge(START, "agent")
g.add_edge("agent", END)
return g.compile()
def _multi_node_chat_graph():
"""Two LLM nodes: agent -> reviewer."""
agent_model = FakeChatModel(messages=[AIMessage(content="Agent reply")])
reviewer_model = FakeChatModel(messages=[AIMessage(content="Reviewer reply")])
def agent(state: dict) -> dict:
return {"messages": [agent_model.invoke(state["messages"])]}
def reviewer(state: dict) -> dict:
return {"messages": [reviewer_model.invoke(state["messages"])]}
g = StateGraph(MessagesState)
g.add_node("agent", agent)
g.add_node("reviewer", reviewer)
g.add_edge(START, "agent")
g.add_edge("agent", "reviewer")
g.add_edge("reviewer", END)
return g.compile()
def _custom_events_graph():
"""Node that emits custom events via StreamWriter."""
def worker(state: State) -> dict:
writer = get_stream_writer()
writer({"step": 1, "msg": "started"})
writer({"step": 2, "msg": "processing"})
writer({"step": 3, "msg": "done"})
return {"value": state["value"] + "_done", "items": ["done"]}
g = StateGraph(State)
g.add_node("worker", worker)
g.add_edge(START, "worker")
g.add_edge("worker", END)
return g.compile()
def _interrupt_graph():
"""Graph that interrupts for human input."""
def ask_human(state: State) -> dict:
answer = interrupt("What next?")
return {"value": state["value"] + f"_{answer}", "items": [answer]}
g = StateGraph(State)
g.add_node("ask", ask_human)
g.add_edge(START, "ask")
g.add_edge("ask", END)
return g.compile(checkpointer=MemorySaver())
def _subgraph():
"""Parent with a compiled child subgraph."""
class ChildState(TypedDict):
value: str
class ParentState(TypedDict):
value: str
def child_node(state: ChildState) -> dict:
return {"value": state["value"] + "_child"}
child = StateGraph(ChildState)
child.add_node("inner", child_node)
child.add_edge(START, "inner")
child.add_edge("inner", END)
child_compiled = child.compile()
parent = StateGraph(ParentState)
parent.add_node("child", child_compiled)
parent.add_edge(START, "child")
parent.add_edge("child", END)
return parent.compile()
# ===================================================================
# 1. Final output
# ===================================================================
def test_output():
"""graph.invoke() produces the same result as StreamingHandler().stream().output."""
graph = _linear_graph()
inp = {"value": "x", "items": []}
v1 = graph.invoke(inp)
run = StreamingHandler(graph).stream(inp)
v2 = run.output
assert v1 == v2
# ===================================================================
# 2. Intermediate state snapshots (values mode)
# ===================================================================
def test_values():
"""stream(mode='values') snapshots == StreamingHandler().stream().values snapshots."""
graph = _linear_graph()
inp = {"value": "x", "items": []}
v1 = list(graph.stream(inp, stream_mode="values"))
run = StreamingHandler(graph).stream(inp)
v2 = list(run.values)
assert v1 == v2
# ===================================================================
# 3. Per-node updates (updates mode)
# ===================================================================
def test_updates():
"""stream(mode='updates') data == StreamingHandler raw events[method=updates]."""
graph = _linear_graph()
inp = {"value": "x", "items": []}
v1 = list(graph.stream(inp, stream_mode="updates"))
run = StreamingHandler(graph).stream(inp)
v2 = [
e["params"]["data"]
for e in run
if e["method"] == "updates" and not e["params"]["namespace"]
]
assert v1 == v2
# ===================================================================
# 4. Message text and node attribution
# ===================================================================
def test_messages():
"""Reassembled V1 message text per node == V2 .messages text per node."""
graph = _multi_node_chat_graph()
inp = {"messages": [HumanMessage(content="hi")]}
# V1: collect (chunk, metadata) pairs, group text by node
v1_text_by_node: dict[str, list[str]] = {}
for chunk, metadata in graph.stream(inp, stream_mode="messages"):
node = metadata["langgraph_node"]
v1_text_by_node.setdefault(node, []).append(chunk.content)
v1_text = {k: "".join(v) for k, v in v1_text_by_node.items()}
# V2: each ChatModelStream has .text and .node
run = StreamingHandler(graph).stream(inp)
v2_text: dict[str, str] = {}
for msg in run.messages:
assert msg.done is True
v2_text[msg.node] = msg.text
assert v1_text == v2_text
# ===================================================================
# 5. Custom events
# ===================================================================
def test_custom_events():
"""stream(mode='custom') payloads == StreamingHandler raw events[method=custom]."""
graph = _custom_events_graph()
inp = {"value": "x", "items": []}
v1 = list(graph.stream(inp, stream_mode="custom"))
run = StreamingHandler(graph).stream(inp)
v2 = [
e["params"]["data"]
for e in run
if e["method"] == "custom" and not e["params"]["namespace"]
]
assert v1 == v2
# ===================================================================
# 6. Mode coverage
# ===================================================================
def test_mode_coverage():
"""V2 produces events for the same set of modes as V1."""
graph = _chat_graph()
inp = {"messages": [HumanMessage(content="hi")]}
# V1: request all modes, collect which ones appear
v1_modes: set[str] = set()
for ns, mode, _ in graph.stream(
inp, stream_mode=STREAM_V2_MODES, subgraphs=True, version="v1"
):
if not ns:
v1_modes.add(mode)
# V2: iterate raw events, collect methods
run = StreamingHandler(graph).stream(inp)
v2_modes = {e["method"] for e in run if not e["params"]["namespace"]}
assert v1_modes == v2_modes
# ===================================================================
# 7. Interrupt detection
# ===================================================================
def test_interrupts():
"""V1 __interrupt__ value == V2 .interrupted and .interrupts payload."""
graph = _interrupt_graph()
inp = {"value": "x", "items": []}
# V1: detect __interrupt__ in values stream
config1 = {"configurable": {"thread_id": "equiv-1"}}
v1_interrupt_value = None
for chunk in graph.stream(inp, config1, stream_mode="values"):
if isinstance(chunk, dict) and "__interrupt__" in chunk:
info = chunk["__interrupt__"]
if info:
v1_interrupt_value = info[0].value
assert v1_interrupt_value is not None
# V2: .interrupted and .interrupts (fresh thread)
config2 = {"configurable": {"thread_id": "equiv-2"}}
run = StreamingHandler(graph).stream(inp, config=config2)
for _ in run:
pass
assert run.interrupted is True
assert len(run.interrupts) > 0
v2_interrupt_value = run.interrupts[0]["payload"].value
assert v1_interrupt_value == v2_interrupt_value
# ===================================================================
# 8. Subgraph state snapshots
# ===================================================================
def test_subgraph_values():
"""V1 child namespace values == V2 child namespace values."""
graph = _subgraph()
inp = {"value": "x"}
# V1: stream with subgraphs=True, collect child values
v1_child_values = []
for ns, data in graph.stream(inp, stream_mode="values", subgraphs=True):
if ns:
v1_child_values.append(data)
# V2: filter raw events for child namespace + values mode
run = StreamingHandler(graph).stream(inp)
v2_child_values = [
e["params"]["data"]
for e in run
if e["method"] == "values" and e["params"]["namespace"]
]
assert v1_child_values == v2_child_values
# ===================================================================
# 9. Node filtering on messages
# ===================================================================
def test_messages_node_filtering():
"""V1 manual metadata filter == V2 .messages filtered by .node."""
graph = _multi_node_chat_graph()
inp = {"messages": [HumanMessage(content="hi")]}
# V1: manual filter for "agent" node only
v1_agent_text: list[str] = []
for chunk, metadata in graph.stream(inp, stream_mode="messages"):
if metadata.get("langgraph_node") == "agent":
v1_agent_text.append(chunk.content)
v1_text = "".join(v1_agent_text)
# V2: filter .messages by .node
run = StreamingHandler(graph).stream(inp)
v2_agent_msgs = [msg for msg in run.messages if msg.node == "agent"]
assert len(v2_agent_msgs) == 1
v2_text = v2_agent_msgs[0].text
assert v1_text == v2_text
# ===================================================================
# 10. Async: subgraphs projection
# ===================================================================
@pytest.mark.anyio
async def test_async_subgraph_projection():
"""V2 .subgraphs child output matches V1 child namespace output."""
graph = _subgraph()
inp = {"value": "x"}
# V1
v1_child_output = None
async for ns, data in graph.astream(inp, stream_mode="values", subgraphs=True):
if ns:
v1_child_output = data
# V2: .subgraphs yields typed child stream objects
run = await StreamingHandler(graph).astream(inp)
v2_child_output = None
async for sub in run.subgraphs:
v2_child_output = await sub.output
assert v1_child_output == v2_child_output
# ===================================================================
# 11. Async: messages_from projection
# ===================================================================
@pytest.mark.anyio
async def test_async_messages_from():
"""V2 .messages_from('agent') text matches V1 filtered by metadata."""
graph = _multi_node_chat_graph()
inp = {"messages": [HumanMessage(content="hi")]}
# V1: manual filter for agent node
v1_agent_text: list[str] = []
async for chunk, metadata in graph.astream(inp, stream_mode="messages"):
if metadata.get("langgraph_node") == "agent":
v1_agent_text.append(chunk.content)
v1_text = "".join(v1_agent_text)
# V2: declarative node filtering
run = await StreamingHandler(graph).astream(inp)
v2_texts: list[str] = []
async for msg in run.messages_from("agent"):
v2_texts.append(await msg.text)
assert len(v2_texts) == 1
v2_text = v2_texts[0]
assert v1_text == v2_text
+7 -791
View File
@@ -37,7 +37,6 @@ def _checkpoint_summary(history: list) -> list[dict]:
Returns a list of dicts (newest-first, matching get_state_history order) with:
- id: short checkpoint id suffix (last 6 chars)
- parent_id: short parent checkpoint id suffix or None
- source: checkpoint metadata source (input, loop, fork, update)
- next: tuple of next node names
- values: channel values snapshot
"""
@@ -53,7 +52,6 @@ def _checkpoint_summary(history: list) -> list[dict]:
{
"id": cid[-6:],
"parent_id": pid[-6:] if pid else None,
"source": s.metadata.get("source"),
"next": s.next,
"values": s.values,
}
@@ -282,116 +280,6 @@ def test_replay_from_before_interrupt_refires(
assert call_count["node_b"] == 1 # NOT re-executed (after interrupt)
def test_replay_from_before_interrupt_then_resume(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
"""Replay from checkpoint before interrupt node, then resume with a new
answer and verify the graph completes with the new value.
Graph: START --> node_a --> ask_human (interrupt) --> node_b --> END
Original run:
source=input next=(__start__,) values=[]
source=loop next=(node_a,) values=[]
source=loop next=(ask_human,) values=[a] <-- replay from here
source=loop next=(node_b,) values=[a, human:old_answer]
source=loop next=() values=[a, human:old_answer, b]
After replay (fork created) + resume with "new_answer":
source=input next=(__start__,) values=[]
source=loop next=(node_a,) values=[]
source=loop next=(ask_human,) values=[a] <-- branch point
source=loop next=(node_b,) values=[a, human:old_answer]
source=loop next=() values=[a, human:old_answer, b] (old branch)
source=fork next=(ask_human,) values=[a] <-- fork from branch point
source=loop next=(node_b,) values=[a, human:new_answer]
source=loop next=() values=[a, human:new_answer, b] (new branch)
"""
called: list[str] = []
def node_a(state: State) -> State:
called.append("node_a")
return {"value": ["a"]}
def ask_human(state: State) -> State:
called.append("ask_human")
answer = interrupt("What is your input?")
return {"value": [f"human:{answer}"]}
def node_b(state: State) -> State:
called.append("node_b")
return {"value": ["b"]}
graph = (
StateGraph(State)
.add_node("node_a", node_a)
.add_node("ask_human", ask_human)
.add_node("node_b", node_b)
.add_edge(START, "node_a")
.add_edge("node_a", "ask_human")
.add_edge("ask_human", "node_b")
.compile(checkpointer=sync_checkpointer)
)
config = {"configurable": {"thread_id": "1"}}
# --- Original run: invoke until interrupt, then resume to complete ---
graph.invoke({"value": []}, config)
graph.invoke(Command(resume="old_answer"), config)
original_history = list(graph.get_state_history(config))
original = _checkpoint_summary(original_history)
assert [(s["source"], s["next"], s["values"]) for s in original] == [
("loop", (), {"value": ["a", "human:old_answer", "b"]}),
("loop", ("node_b",), {"value": ["a", "human:old_answer"]}),
("loop", ("ask_human",), {"value": ["a"]}),
("loop", ("node_a",), {"value": []}),
("input", ("__start__",), {"value": []}),
]
# --- Replay from checkpoint before ask_human ---
before_ask = next(s for s in original_history if s.next == ("ask_human",))
called.clear()
replay_result = graph.invoke(None, before_ask.config)
assert replay_result["__interrupt__"][0].value == "What is your input?"
assert "ask_human" in called
assert "node_a" not in called # before the replay point, not re-executed
# A fork checkpoint is now the latest — it branches from the replay point
post_replay = _checkpoint_summary(list(graph.get_state_history(config)))
assert [(s["source"], s["next"]) for s in post_replay] == [
("fork", ("ask_human",)), # <-- new fork (latest)
("loop", ()), # original done
("loop", ("node_b",)),
("loop", ("ask_human",)), # branch point
("loop", ("node_a",)),
("input", ("__start__",)),
]
# --- Resume with a new answer ---
called.clear()
final_result = graph.invoke(Command(resume="new_answer"), config)
assert final_result["value"] == ["a", "human:new_answer", "b"]
assert "ask_human" in called
assert "node_b" in called
final = _checkpoint_summary(list(graph.get_state_history(config)))
assert [(s["source"], s["next"], s["values"]) for s in final] == [
# New branch (from fork)
("loop", (), {"value": ["a", "human:new_answer", "b"]}),
("loop", ("node_b",), {"value": ["a", "human:new_answer"]}),
("fork", ("ask_human",), {"value": ["a"]}),
# Original branch (preserved)
("loop", (), {"value": ["a", "human:old_answer", "b"]}),
("loop", ("node_b",), {"value": ["a", "human:old_answer"]}),
("loop", ("ask_human",), {"value": ["a"]}),
("loop", ("node_a",), {"value": []}),
("input", ("__start__",), {"value": []}),
]
def test_replay_interrupt_stable_across_replays(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
@@ -432,14 +320,8 @@ def test_replay_interrupt_stable_across_replays(
r = graph.invoke(None, before_ask.config)
results.append(r)
# Each replay creates a fork with a unique interrupt ID, so we compare
# interrupt values and state values rather than full equality.
assert all("__interrupt__" in r for r in results)
assert all(
r["__interrupt__"][0].value == results[0]["__interrupt__"][0].value
for r in results
)
assert all(r["value"] == results[0]["value"] for r in results)
assert all(r == results[0] for r in results)
assert "__interrupt__" in results[0]
def test_fork_from_before_interrupt_refires(
@@ -972,354 +854,6 @@ def test_subgraph_interrupt_replay_from_interrupt_checkpoint(
assert "step_b" not in called
def test_subgraph_interrupt_replay_from_parent_then_resume(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
"""Replay from the parent checkpoint where a subgraph interrupt fired,
then resume with a new answer. Verifies that a fork is created and the
full graph completes. Checks full checkpoint history at each stage."""
called: list[str] = []
def router(state: State) -> State:
called.append("router")
return {"value": ["routed"]}
def step_a(state: State) -> State:
called.append("step_a")
return {"value": ["sub_a"]}
def ask_human(state: State) -> State:
called.append("ask_human")
answer = interrupt("Provide input:")
return {"value": [f"human:{answer}"]}
def step_b(state: State) -> State:
called.append("step_b")
return {"value": ["sub_b"]}
subgraph = (
StateGraph(State)
.add_node("step_a", step_a)
.add_node("ask_human", ask_human)
.add_node("step_b", step_b)
.add_edge(START, "step_a")
.add_edge("step_a", "ask_human")
.add_edge("ask_human", "step_b")
.compile(checkpointer=True)
)
def post_process(state: State) -> State:
called.append("post_process")
return {"value": ["post"]}
graph = (
StateGraph(State)
.add_node("router", router)
.add_node("subgraph_node", subgraph)
.add_node("post_process", post_process)
.add_edge(START, "router")
.add_edge("router", "subgraph_node")
.add_edge("subgraph_node", "post_process")
.compile(checkpointer=sync_checkpointer)
)
config = {"configurable": {"thread_id": "1"}}
# Run until interrupt, then resume to complete
graph.invoke({"value": []}, config)
graph.invoke(Command(resume="old_answer"), config)
# Original parent history (newest first)
original_history = list(graph.get_state_history(config))
assert [s.next for s in original_history] == [
(), # done
("post_process",),
("subgraph_node",), # subgraph ran, interrupt fired here
("router",),
("__start__",),
]
# Find the parent checkpoint where the interrupt fired
interrupt_checkpoint = next(
s for s in original_history if s.next == ("subgraph_node",)
)
# Replay from parent checkpoint — subgraph re-executes, interrupt re-fires
called.clear()
replay_result = graph.invoke(None, interrupt_checkpoint.config)
assert "__interrupt__" in replay_result
assert replay_result["__interrupt__"][0].value == "Provide input:"
assert "step_a" in called
assert "ask_human" in called
assert "step_b" not in called
# Verify fork checkpoint was created
post_replay_history = list(graph.get_state_history(config))
assert [s.next for s in post_replay_history] == [
("subgraph_node",), # fork (interrupt pending)
(), # original done
("post_process",),
("subgraph_node",),
("router",),
("__start__",),
]
assert [s.metadata["source"] for s in post_replay_history] == [
"fork",
"loop",
"loop",
"loop",
"loop",
"input",
]
fork = post_replay_history[0]
assert (
fork.parent_config["configurable"]["checkpoint_id"]
== interrupt_checkpoint.config["configurable"]["checkpoint_id"]
)
# Resume with a new answer — full graph should complete
called.clear()
final_result = graph.invoke(Command(resume="new_answer"), config)
assert "__interrupt__" not in final_result
assert "human:new_answer" in final_result["value"]
assert "sub_b" in final_result["value"]
assert "post" in final_result["value"]
assert "ask_human" in called
assert "step_b" in called
assert "post_process" in called
# Final checkpoint history
final_history = list(graph.get_state_history(config))
assert [s.next for s in final_history] == [
(), # new branch done
("post_process",), # new branch post_process
("subgraph_node",), # fork
(), # original done
("post_process",),
("subgraph_node",),
("router",),
("__start__",),
]
assert [s.metadata["source"] for s in final_history] == [
"loop",
"loop",
"fork",
"loop",
"loop",
"loop",
"loop",
"input",
]
def test_subgraph_interrupt_resume_with_explicit_head_checkpoint_id(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
"""Resume with Command(resume=...) plus the current head checkpoint_id
in config. The subgraph must continue from the interrupted node, not
restart from scratch. Explicit checkpoint_id triggers is_replaying but
this is a resume, not a time-travel, so ReplayState should not apply."""
called: list[str] = []
def step_a(state: State) -> State:
called.append("step_a")
return {"value": ["sub_a"]}
def ask_human(state: State) -> State:
called.append("ask_human")
answer = interrupt("Provide input:")
return {"value": [f"human:{answer}"]}
def step_b(state: State) -> State:
called.append("step_b")
return {"value": ["sub_b"]}
subgraph = (
StateGraph(State)
.add_node("step_a", step_a)
.add_node("ask_human", ask_human)
.add_node("step_b", step_b)
.add_edge(START, "step_a")
.add_edge("step_a", "ask_human")
.add_edge("ask_human", "step_b")
.compile(checkpointer=True)
)
graph = (
StateGraph(State)
.add_node("subgraph_node", subgraph)
.add_edge(START, "subgraph_node")
.compile(checkpointer=sync_checkpointer)
)
config = {"configurable": {"thread_id": "1"}}
# Run until interrupt fires in subgraph
graph.invoke({"value": []}, config)
assert called == ["step_a", "ask_human"]
# Resume with explicit head checkpoint_id in config
head_checkpoint_id = graph.get_state(config).config["configurable"]["checkpoint_id"]
called.clear()
resume_config = {
"configurable": {
"thread_id": "1",
"checkpoint_id": head_checkpoint_id,
"checkpoint_ns": "",
}
}
result = graph.invoke(Command(resume="answer"), resume_config)
assert called == ["ask_human", "step_b"]
assert "__interrupt__" not in result
assert result["value"] == ["sub_a", "human:answer", "sub_b"]
def test_subgraph_replay_loads_accumulated_state_then_resume(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
"""Two parent invocations, then replay from before the subgraph in the
2nd invocation. The subgraph (checkpointer=True) should load its
accumulated state from the 1st invocation via ReplayState, re-fire
the interrupt, and then resume + complete.
This tests the ReplayState path: the parent is replaying and the
subgraph uses list(before=parent_checkpoint_id) to find its
corresponding checkpoint from the original execution.
"""
class SubState(TypedDict):
value: Annotated[list[str], operator.add]
class ParentState(TypedDict):
results: Annotated[list[str], operator.add]
started_state: list[dict] = []
def step_a(state: SubState) -> SubState:
started_state.append(dict(state))
answer = interrupt("question_a")
return {"value": [f"a:{answer}"]}
subgraph = (
StateGraph(SubState)
.add_node("step_a", step_a)
.add_edge(START, "step_a")
.compile(checkpointer=True)
)
def parent_node(state: ParentState) -> ParentState:
return {"results": ["p"]}
graph = (
StateGraph(ParentState)
.add_node("parent_node", parent_node)
.add_node("sub_node", subgraph)
.add_edge(START, "parent_node")
.add_edge("parent_node", "sub_node")
.compile(checkpointer=sync_checkpointer)
)
config = {"configurable": {"thread_id": "1"}}
# === 1st invocation: complete with answer "a1" ===
graph.invoke({"results": []}, config)
graph.invoke(Command(resume="a1"), config)
# step_a saw empty state (fresh subgraph)
assert started_state[0] == {"value": []}
# === 2nd invocation: complete with answer "a2" ===
started_state.clear()
graph.invoke({"results": []}, config)
graph.invoke(Command(resume="a2"), config)
# Stateful subgraph retained state from 1st invocation
assert started_state[0] == {"value": ["a:a1"]}
# Original history (newest first)
original_history = list(graph.get_state_history(config))
assert [s.next for s in original_history] == [
(), # 2nd done
("sub_node",), # 2nd sub_node
("parent_node",), # 2nd parent_node
("__start__",), # 2nd input
(), # 1st done
("sub_node",), # 1st sub_node
("parent_node",), # 1st parent_node
("__start__",), # 1st input
]
# Replay from before sub_node in 2nd invocation (newest match)
before_sub_2nd = [s for s in original_history if s.next == ("sub_node",)][0]
started_state.clear()
replay = graph.invoke(None, before_sub_2nd.config)
assert "__interrupt__" in replay
# Subgraph should see accumulated state from END of 1st invocation
assert started_state[0] == {"value": ["a:a1"]}
# Verify fork was created
post_replay_history = list(graph.get_state_history(config))
assert [s.next for s in post_replay_history] == [
("sub_node",), # fork (interrupt pending)
(), # 2nd done
("sub_node",), # 2nd sub_node
("parent_node",), # 2nd parent_node
("__start__",), # 2nd input
(), # 1st done
("sub_node",), # 1st sub_node
("parent_node",), # 1st parent_node
("__start__",), # 1st input
]
assert [s.metadata["source"] for s in post_replay_history] == [
"fork",
"loop",
"loop",
"loop",
"input",
"loop",
"loop",
"loop",
"input",
]
# Resume with a new answer
started_state.clear()
final = graph.invoke(Command(resume="a3"), config)
assert "__interrupt__" not in final
assert final["results"] == ["p", "p"]
# Final history
final_history = list(graph.get_state_history(config))
assert [s.next for s in final_history] == [
(), # new branch done
("sub_node",), # fork
(), # 2nd done
("sub_node",), # 2nd sub_node
("parent_node",), # 2nd parent_node
("__start__",), # 2nd input
(), # 1st done
("sub_node",), # 1st sub_node
("parent_node",), # 1st parent_node
("__start__",), # 1st input
]
assert [s.metadata["source"] for s in final_history] == [
"loop",
"fork",
"loop",
"loop",
"loop",
"input",
"loop",
"loop",
"loop",
"input",
]
def test_subgraph_interrupt_full_flow(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
@@ -1756,321 +1290,6 @@ def test_subgraph_time_travel_to_second_interrupt(
assert "ask_1" not in called
def test_subgraph_time_travel_resume_from_first_interrupt(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
"""Time travel to a subgraph checkpoint at the first interrupt, then
resume through both interrupts with new answers.
This verifies the key bug fix: after time-traveling to a subgraph
checkpoint with an interrupt, a fork checkpoint is created so that
subsequent resumes find the correct state (not the old branch tip).
Parent: START --> executor (subgraph, checkpointer=True) --> END
Executor: START --> step_a --> ask_1 (interrupt) --> ask_2 (interrupt) --> END
Parent history after original run completes:
source=input next=(__start__,) values=[]
source=loop next=(executor,) values=[]
source=loop next=() values=[step_a_done, ask_1:answer_1, ask_2:answer_2]
After time-traveling to 1st interrupt + resuming with new answers:
source=input next=(__start__,) values=[]
source=loop next=(executor,) values=[] <-- branch point
source=loop next=() values=[..., ask_2:answer_2] (old branch)
source=fork next=(executor,) values=[] <-- fork from time travel
source=loop next=() values=[..., ask_2:new_answer_2] (new branch)
"""
called: list[str] = []
def step_a(state: State) -> State:
called.append("step_a")
return {"value": ["step_a_done"]}
def ask_1(state: State) -> State:
called.append("ask_1")
answer = interrupt("Question 1?")
return {"value": [f"ask_1:{answer}"]}
def ask_2(state: State) -> State:
called.append("ask_2")
answer = interrupt("Question 2?")
return {"value": [f"ask_2:{answer}"]}
executor = (
StateGraph(State)
.add_node("step_a", step_a)
.add_node("ask_1", ask_1)
.add_node("ask_2", ask_2)
.add_edge(START, "step_a")
.add_edge("step_a", "ask_1")
.add_edge("ask_1", "ask_2")
.add_edge("ask_2", "__end__")
.compile(checkpointer=True)
)
graph = (
StateGraph(State)
.add_node("executor", executor)
.add_edge(START, "executor")
.compile(checkpointer=sync_checkpointer)
)
config = {"configurable": {"thread_id": "1"}}
# --- Original run: hit both interrupts and resume ---
graph.invoke({"value": []}, config)
sub_config_at_first = graph.get_state(config, subgraphs=True).tasks[0].state.config
graph.invoke(Command(resume="answer_1"), config)
graph.invoke(Command(resume="answer_2"), config)
original = _checkpoint_summary(list(graph.get_state_history(config)))
assert [(s["source"], s["next"], s["values"]) for s in original] == [
("loop", (), {"value": ["step_a_done", "ask_1:answer_1", "ask_2:answer_2"]}),
("loop", ("executor",), {"value": []}),
("input", ("__start__",), {"value": []}),
]
# --- Time travel to first interrupt's subgraph checkpoint ---
called.clear()
replay_result = graph.invoke(None, sub_config_at_first)
assert replay_result["__interrupt__"][0].value == "Question 1?"
assert "step_a" not in called # before interrupt, not re-executed
# Fork is now the latest parent checkpoint
post_tt = _checkpoint_summary(list(graph.get_state_history(config)))
assert [(s["source"], s["next"]) for s in post_tt] == [
("fork", ("executor",)), # <-- new fork (latest)
("loop", ()), # original done
("loop", ("executor",)),
("input", ("__start__",)),
]
# --- Resume both interrupts with new answers ---
called.clear()
resume_1 = graph.invoke(Command(resume="new_answer_1"), config)
assert resume_1["__interrupt__"][0].value == "Question 2?"
assert "ask_1" in called
called.clear()
resume_2 = graph.invoke(Command(resume="new_answer_2"), config)
assert resume_2["value"] == [
"step_a_done",
"ask_1:new_answer_1",
"ask_2:new_answer_2",
]
# Verify final history: original branch preserved, new branch appended
final = _checkpoint_summary(list(graph.get_state_history(config)))
assert [(s["source"], s["next"], s["values"]) for s in final] == [
# New branch (from time travel fork)
(
"loop",
(),
{"value": ["step_a_done", "ask_1:new_answer_1", "ask_2:new_answer_2"]},
),
("fork", ("executor",), {"value": []}),
# Original branch (preserved)
("loop", (), {"value": ["step_a_done", "ask_1:answer_1", "ask_2:answer_2"]}),
("loop", ("executor",), {"value": []}),
("input", ("__start__",), {"value": []}),
]
def test_subgraph_time_travel_resume_from_second_interrupt(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
"""Time travel to a subgraph checkpoint at the second interrupt, then
resume with a new answer. The first interrupt's answer should be preserved.
Parent: START --> executor (subgraph, checkpointer=True) --> END
Executor: START --> step_a --> ask_1 (interrupt) --> ask_2 (interrupt) --> END
Key assertion: after resuming from a time-travel to the 2nd interrupt,
the final state keeps ask_1's original answer but uses the new ask_2 answer.
"""
called: list[str] = []
def step_a(state: State) -> State:
called.append("step_a")
return {"value": ["step_a_done"]}
def ask_1(state: State) -> State:
called.append("ask_1")
answer = interrupt("Question 1?")
return {"value": [f"ask_1:{answer}"]}
def ask_2(state: State) -> State:
called.append("ask_2")
answer = interrupt("Question 2?")
return {"value": [f"ask_2:{answer}"]}
executor = (
StateGraph(State)
.add_node("step_a", step_a)
.add_node("ask_1", ask_1)
.add_node("ask_2", ask_2)
.add_edge(START, "step_a")
.add_edge("step_a", "ask_1")
.add_edge("ask_1", "ask_2")
.add_edge("ask_2", "__end__")
.compile(checkpointer=True)
)
graph = (
StateGraph(State)
.add_node("executor", executor)
.add_edge(START, "executor")
.compile(checkpointer=sync_checkpointer)
)
config = {"configurable": {"thread_id": "1"}}
# --- Original run: hit both interrupts and resume ---
graph.invoke({"value": []}, config)
graph.invoke(Command(resume="answer_1"), config)
sub_config_at_second = graph.get_state(config, subgraphs=True).tasks[0].state.config
graph.invoke(Command(resume="answer_2"), config)
original = _checkpoint_summary(list(graph.get_state_history(config)))
assert [(s["source"], s["next"], s["values"]) for s in original] == [
("loop", (), {"value": ["step_a_done", "ask_1:answer_1", "ask_2:answer_2"]}),
("loop", ("executor",), {"value": []}),
("input", ("__start__",), {"value": []}),
]
# --- Time travel to second interrupt ---
called.clear()
replay_result = graph.invoke(None, sub_config_at_second)
assert replay_result["__interrupt__"][0].value == "Question 2?"
assert "step_a" not in called
assert "ask_1" not in called # already resolved, not re-executed
# Fork is now the latest parent checkpoint
post_tt = _checkpoint_summary(list(graph.get_state_history(config)))
assert [(s["source"], s["next"]) for s in post_tt] == [
("fork", ("executor",)), # <-- new fork (latest)
("loop", ()), # original done
("loop", ("executor",)),
("input", ("__start__",)),
]
# --- Resume with a new answer for ask_2 only ---
called.clear()
resume_result = graph.invoke(Command(resume="new_answer_2"), config)
# ask_1's original answer preserved, ask_2 uses the new answer
assert resume_result["value"] == [
"step_a_done",
"ask_1:answer_1",
"ask_2:new_answer_2",
]
# Verify final history: original branch preserved, new branch appended
final = _checkpoint_summary(list(graph.get_state_history(config)))
assert [(s["source"], s["next"], s["values"]) for s in final] == [
# New branch (from time travel fork)
(
"loop",
(),
{"value": ["step_a_done", "ask_1:answer_1", "ask_2:new_answer_2"]},
),
("fork", ("executor",), {"value": []}),
# Original branch (preserved)
("loop", (), {"value": ["step_a_done", "ask_1:answer_1", "ask_2:answer_2"]}),
("loop", ("executor",), {"value": []}),
("input", ("__start__",), {"value": []}),
]
def test_subgraph_time_travel_checkpoint_pattern(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
"""Verify the checkpoint pattern created by time travel to a subgraph
interrupt. A fork checkpoint should branch from the replay point and
become the latest parent checkpoint.
Parent: START --> executor (subgraph, checkpointer=True) --> END
Executor: START --> ask (interrupt) --> END
Original run (after completing):
source=input next=(__start__,) values=[]
source=loop next=(executor,) values=[] <-- replay point
source=loop next=() values=[a:first]
After time travel to interrupt + resume with "second":
source=input next=(__start__,) values=[]
source=loop next=(executor,) values=[] <-- branch point
source=loop next=() values=[a:first] (old branch)
source=fork next=(executor,) values=[] <-- fork
source=loop next=() values=[a:second] (new branch)
"""
def ask(state: State) -> State:
answer = interrupt("Q?")
return {"value": [f"a:{answer}"]}
executor = (
StateGraph(State)
.add_node("ask", ask)
.add_edge(START, "ask")
.compile(checkpointer=True)
)
graph = (
StateGraph(State)
.add_node("executor", executor)
.add_edge(START, "executor")
.compile(checkpointer=sync_checkpointer)
)
config = {"configurable": {"thread_id": "1"}}
# Run until interrupt, then complete
graph.invoke({"value": []}, config)
sub_config = graph.get_state(config, subgraphs=True).tasks[0].state.config
graph.invoke(Command(resume="first"), config)
original = _checkpoint_summary(list(graph.get_state_history(config)))
assert [(s["source"], s["next"], s["values"]) for s in original] == [
("loop", (), {"value": ["a:first"]}),
("loop", ("executor",), {"value": []}),
("input", ("__start__",), {"value": []}),
]
# Time travel to the interrupt
graph.invoke(None, sub_config)
# Fork is now the latest, branching from the original replay point
post_tt = list(graph.get_state_history(config))
post_tt_summary = _checkpoint_summary(post_tt)
assert [(s["source"], s["next"]) for s in post_tt_summary] == [
("fork", ("executor",)), # <-- new fork (latest)
("loop", ()),
("loop", ("executor",)), # <-- replay point / fork parent
("input", ("__start__",)),
]
# Verify the fork's parent is the original replay point
replay_point_id = sub_config["configurable"]["checkpoint_map"][""]
assert post_tt[0].parent_config["configurable"]["checkpoint_id"] == replay_point_id
# Resume from the fork — graph completes with new answer
result = graph.invoke(Command(resume="second"), config)
assert result["value"] == ["a:second"]
final = _checkpoint_summary(list(graph.get_state_history(config)))
assert [(s["source"], s["next"], s["values"]) for s in final] == [
# New branch
("loop", (), {"value": ["a:second"]}),
("fork", ("executor",), {"value": []}),
# Original branch
("loop", (), {"value": ["a:first"]}),
("loop", ("executor",), {"value": []}),
("input", ("__start__",), {"value": []}),
]
def test_subgraph_time_travel_after_completion(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
@@ -3064,16 +2283,14 @@ def test_replay_creates_branch_preserving_old_checkpoints(
# -- Post-replay checkpoint history (newest first) --
post_replay_history = list(graph.get_state_history(config))
post_summary = _checkpoint_summary(post_replay_history)
# 5 original + 1 fork + 2 new branch checkpoints = 8
assert len(post_summary) == 8
assert len(post_summary) == 7 # 5 original + 2 new branch checkpoints
# Verify the full shape after replay
assert [s["next"] for s in post_summary] == [
(), # new branch tip
("node_c",), # new branch
("node_b",), # fork from replay point
(), # old branch tip
("node_c",), # old
(), # new branch tip (C6)
("node_c",), # new branch (C5)
(), # old branch tip (C4)
("node_c",), # old (C3)
("node_b",), # branch point (C2)
("node_a",), # old (C1)
("__start__",), # old (C0)
@@ -3081,7 +2298,6 @@ def test_replay_creates_branch_preserving_old_checkpoints(
assert [s["values"] for s in post_summary] == [
{"value": ["a", "b2", "c"]}, # new branch tip
{"value": ["a", "b2"]}, # new: node_b re-ran with call_count=2
{"value": ["a"]}, # fork from replay point
{"value": ["a", "b1", "c"]}, # old branch tip preserved
{"value": ["a", "b1"]}, # old
{"value": ["a"]}, # branch point
+7 -403
View File
@@ -46,7 +46,6 @@ def _checkpoint_summary(history: list) -> list[dict]:
Returns a list of dicts (newest-first, matching get_state_history order) with:
- id: short checkpoint id suffix (last 6 chars)
- parent_id: short parent checkpoint id suffix or None
- source: checkpoint metadata source (input, loop, fork, update)
- next: tuple of next node names
- values: channel values snapshot
"""
@@ -62,7 +61,6 @@ def _checkpoint_summary(history: list) -> list[dict]:
{
"id": cid[-6:],
"parent_id": pid[-6:] if pid else None,
"source": s.metadata.get("source"),
"next": s.next,
"values": s.values,
}
@@ -337,14 +335,8 @@ async def test_replay_interrupt_stable_across_replays(
r = await graph.ainvoke(None, before_ask.config)
results.append(r)
# Each replay creates a fork with a unique interrupt ID, so we compare
# interrupt values and state values rather than full equality.
assert all("__interrupt__" in r for r in results)
assert all(
r["__interrupt__"][0].value == results[0]["__interrupt__"][0].value
for r in results
)
assert all(r["value"] == results[0]["value"] for r in results)
assert all(r == results[0] for r in results)
assert "__interrupt__" in results[0]
@NEEDS_CONTEXTVARS
@@ -1269,391 +1261,6 @@ async def test_subgraph_time_travel_after_completion_async(
assert "ask_2:answer_2" in replay_result["value"]
@NEEDS_CONTEXTVARS
async def test_replay_from_before_interrupt_then_resume_async(
async_checkpointer: BaseCheckpointSaver,
) -> None:
"""Replay from checkpoint before interrupt node, then resume with a new
answer and verify the graph completes with the new value.
Graph: START --> node_a --> ask_human (interrupt) --> node_b --> END
"""
called: list[str] = []
async def node_a(state: State) -> State:
called.append("node_a")
return {"value": ["a"]}
async def ask_human(state: State) -> State:
called.append("ask_human")
answer = interrupt("What is your input?")
return {"value": [f"human:{answer}"]}
async def node_b(state: State) -> State:
called.append("node_b")
return {"value": ["b"]}
graph = (
StateGraph(State)
.add_node("node_a", node_a)
.add_node("ask_human", ask_human)
.add_node("node_b", node_b)
.add_edge(START, "node_a")
.add_edge("node_a", "ask_human")
.add_edge("ask_human", "node_b")
.compile(checkpointer=async_checkpointer)
)
config = {"configurable": {"thread_id": "1"}}
# --- Original run: invoke until interrupt, then resume to complete ---
await graph.ainvoke({"value": []}, config)
await graph.ainvoke(Command(resume="old_answer"), config)
original_history = [s async for s in graph.aget_state_history(config)]
original = _checkpoint_summary(original_history)
assert [(s["source"], s["next"], s["values"]) for s in original] == [
("loop", (), {"value": ["a", "human:old_answer", "b"]}),
("loop", ("node_b",), {"value": ["a", "human:old_answer"]}),
("loop", ("ask_human",), {"value": ["a"]}),
("loop", ("node_a",), {"value": []}),
("input", ("__start__",), {"value": []}),
]
# --- Replay from checkpoint before ask_human ---
before_ask = next(s for s in original_history if s.next == ("ask_human",))
called.clear()
replay_result = await graph.ainvoke(None, before_ask.config)
assert replay_result["__interrupt__"][0].value == "What is your input?"
assert "ask_human" in called
assert "node_a" not in called
# A fork checkpoint is now the latest
post_replay = _checkpoint_summary(
[s async for s in graph.aget_state_history(config)]
)
assert [(s["source"], s["next"]) for s in post_replay] == [
("fork", ("ask_human",)),
("loop", ()),
("loop", ("node_b",)),
("loop", ("ask_human",)),
("loop", ("node_a",)),
("input", ("__start__",)),
]
# --- Resume with a new answer ---
called.clear()
final_result = await graph.ainvoke(Command(resume="new_answer"), config)
assert final_result["value"] == ["a", "human:new_answer", "b"]
assert "ask_human" in called
assert "node_b" in called
final = _checkpoint_summary([s async for s in graph.aget_state_history(config)])
assert [(s["source"], s["next"], s["values"]) for s in final] == [
# New branch (from fork)
("loop", (), {"value": ["a", "human:new_answer", "b"]}),
("loop", ("node_b",), {"value": ["a", "human:new_answer"]}),
("fork", ("ask_human",), {"value": ["a"]}),
# Original branch (preserved)
("loop", (), {"value": ["a", "human:old_answer", "b"]}),
("loop", ("node_b",), {"value": ["a", "human:old_answer"]}),
("loop", ("ask_human",), {"value": ["a"]}),
("loop", ("node_a",), {"value": []}),
("input", ("__start__",), {"value": []}),
]
@NEEDS_CONTEXTVARS
async def test_subgraph_time_travel_resume_from_first_interrupt_async(
async_checkpointer: BaseCheckpointSaver,
) -> None:
"""Time travel to a subgraph checkpoint at the first interrupt, then
resume through both interrupts with new answers.
Parent: START --> executor (subgraph, checkpointer=True) --> END
Executor: START --> step_a --> ask_1 (interrupt) --> ask_2 (interrupt) --> END
"""
called: list[str] = []
async def step_a(state: State) -> State:
called.append("step_a")
return {"value": ["step_a_done"]}
async def ask_1(state: State) -> State:
called.append("ask_1")
answer = interrupt("Question 1?")
return {"value": [f"ask_1:{answer}"]}
async def ask_2(state: State) -> State:
called.append("ask_2")
answer = interrupt("Question 2?")
return {"value": [f"ask_2:{answer}"]}
executor = (
StateGraph(State)
.add_node("step_a", step_a)
.add_node("ask_1", ask_1)
.add_node("ask_2", ask_2)
.add_edge(START, "step_a")
.add_edge("step_a", "ask_1")
.add_edge("ask_1", "ask_2")
.add_edge("ask_2", "__end__")
.compile(checkpointer=True)
)
graph = (
StateGraph(State)
.add_node("executor", executor)
.add_edge(START, "executor")
.compile(checkpointer=async_checkpointer)
)
config = {"configurable": {"thread_id": "1"}}
# --- Original run: hit both interrupts and resume ---
await graph.ainvoke({"value": []}, config)
sub_config_at_first = (
(await graph.aget_state(config, subgraphs=True)).tasks[0].state.config
)
await graph.ainvoke(Command(resume="answer_1"), config)
await graph.ainvoke(Command(resume="answer_2"), config)
original = _checkpoint_summary([s async for s in graph.aget_state_history(config)])
assert [(s["source"], s["next"], s["values"]) for s in original] == [
("loop", (), {"value": ["step_a_done", "ask_1:answer_1", "ask_2:answer_2"]}),
("loop", ("executor",), {"value": []}),
("input", ("__start__",), {"value": []}),
]
# --- Time travel to first interrupt's subgraph checkpoint ---
called.clear()
replay_result = await graph.ainvoke(None, sub_config_at_first)
assert replay_result["__interrupt__"][0].value == "Question 1?"
assert "step_a" not in called
# Fork is now the latest parent checkpoint
post_tt = _checkpoint_summary([s async for s in graph.aget_state_history(config)])
assert [(s["source"], s["next"]) for s in post_tt] == [
("fork", ("executor",)), # <-- new fork (latest)
("loop", ()), # original done
("loop", ("executor",)),
("input", ("__start__",)),
]
# --- Resume both interrupts with new answers ---
called.clear()
resume_1 = await graph.ainvoke(Command(resume="new_answer_1"), config)
assert resume_1["__interrupt__"][0].value == "Question 2?"
assert "ask_1" in called
called.clear()
resume_2 = await graph.ainvoke(Command(resume="new_answer_2"), config)
assert resume_2["value"] == [
"step_a_done",
"ask_1:new_answer_1",
"ask_2:new_answer_2",
]
# Verify final history: original branch preserved, new branch appended
final = _checkpoint_summary([s async for s in graph.aget_state_history(config)])
assert [(s["source"], s["next"], s["values"]) for s in final] == [
# New branch (from time travel fork)
(
"loop",
(),
{"value": ["step_a_done", "ask_1:new_answer_1", "ask_2:new_answer_2"]},
),
("fork", ("executor",), {"value": []}),
# Original branch (preserved)
("loop", (), {"value": ["step_a_done", "ask_1:answer_1", "ask_2:answer_2"]}),
("loop", ("executor",), {"value": []}),
("input", ("__start__",), {"value": []}),
]
@NEEDS_CONTEXTVARS
async def test_subgraph_time_travel_resume_from_second_interrupt_async(
async_checkpointer: BaseCheckpointSaver,
) -> None:
"""Time travel to a subgraph checkpoint at the second interrupt, then
resume with a new answer. The first interrupt's answer should be preserved.
Parent: START --> executor (subgraph, checkpointer=True) --> END
Executor: START --> step_a --> ask_1 (interrupt) --> ask_2 (interrupt) --> END
"""
called: list[str] = []
async def step_a(state: State) -> State:
called.append("step_a")
return {"value": ["step_a_done"]}
async def ask_1(state: State) -> State:
called.append("ask_1")
answer = interrupt("Question 1?")
return {"value": [f"ask_1:{answer}"]}
async def ask_2(state: State) -> State:
called.append("ask_2")
answer = interrupt("Question 2?")
return {"value": [f"ask_2:{answer}"]}
executor = (
StateGraph(State)
.add_node("step_a", step_a)
.add_node("ask_1", ask_1)
.add_node("ask_2", ask_2)
.add_edge(START, "step_a")
.add_edge("step_a", "ask_1")
.add_edge("ask_1", "ask_2")
.add_edge("ask_2", "__end__")
.compile(checkpointer=True)
)
graph = (
StateGraph(State)
.add_node("executor", executor)
.add_edge(START, "executor")
.compile(checkpointer=async_checkpointer)
)
config = {"configurable": {"thread_id": "1"}}
# --- Original run: hit both interrupts and resume ---
await graph.ainvoke({"value": []}, config)
await graph.ainvoke(Command(resume="answer_1"), config)
sub_config_at_second = (
(await graph.aget_state(config, subgraphs=True)).tasks[0].state.config
)
await graph.ainvoke(Command(resume="answer_2"), config)
original = _checkpoint_summary([s async for s in graph.aget_state_history(config)])
assert [(s["source"], s["next"], s["values"]) for s in original] == [
("loop", (), {"value": ["step_a_done", "ask_1:answer_1", "ask_2:answer_2"]}),
("loop", ("executor",), {"value": []}),
("input", ("__start__",), {"value": []}),
]
# --- Time travel to second interrupt ---
called.clear()
replay_result = await graph.ainvoke(None, sub_config_at_second)
assert replay_result["__interrupt__"][0].value == "Question 2?"
assert "step_a" not in called
assert "ask_1" not in called
# Fork is now the latest parent checkpoint
post_tt = _checkpoint_summary([s async for s in graph.aget_state_history(config)])
assert [(s["source"], s["next"]) for s in post_tt] == [
("fork", ("executor",)), # <-- new fork (latest)
("loop", ()), # original done
("loop", ("executor",)),
("input", ("__start__",)),
]
# --- Resume with a new answer for ask_2 only ---
called.clear()
resume_result = await graph.ainvoke(Command(resume="new_answer_2"), config)
assert resume_result["value"] == [
"step_a_done",
"ask_1:answer_1",
"ask_2:new_answer_2",
]
# Verify final history
final = _checkpoint_summary([s async for s in graph.aget_state_history(config)])
assert [(s["source"], s["next"], s["values"]) for s in final] == [
# New branch (from time travel fork)
(
"loop",
(),
{"value": ["step_a_done", "ask_1:answer_1", "ask_2:new_answer_2"]},
),
("fork", ("executor",), {"value": []}),
# Original branch (preserved)
("loop", (), {"value": ["step_a_done", "ask_1:answer_1", "ask_2:answer_2"]}),
("loop", ("executor",), {"value": []}),
("input", ("__start__",), {"value": []}),
]
@NEEDS_CONTEXTVARS
async def test_subgraph_time_travel_checkpoint_pattern_async(
async_checkpointer: BaseCheckpointSaver,
) -> None:
"""Verify the checkpoint pattern created by time travel to a subgraph
interrupt. A fork checkpoint should branch from the replay point.
Parent: START --> executor (subgraph, checkpointer=True) --> END
Executor: START --> ask (interrupt) --> END
"""
async def ask(state: State) -> State:
answer = interrupt("Q?")
return {"value": [f"a:{answer}"]}
executor = (
StateGraph(State)
.add_node("ask", ask)
.add_edge(START, "ask")
.compile(checkpointer=True)
)
graph = (
StateGraph(State)
.add_node("executor", executor)
.add_edge(START, "executor")
.compile(checkpointer=async_checkpointer)
)
config = {"configurable": {"thread_id": "1"}}
# Run until interrupt, then complete
await graph.ainvoke({"value": []}, config)
sub_config = (await graph.aget_state(config, subgraphs=True)).tasks[0].state.config
await graph.ainvoke(Command(resume="first"), config)
original = _checkpoint_summary([s async for s in graph.aget_state_history(config)])
assert [(s["source"], s["next"], s["values"]) for s in original] == [
("loop", (), {"value": ["a:first"]}),
("loop", ("executor",), {"value": []}),
("input", ("__start__",), {"value": []}),
]
# Time travel to the interrupt
await graph.ainvoke(None, sub_config)
# Fork is now the latest, branching from the original replay point
post_tt = [s async for s in graph.aget_state_history(config)]
post_tt_summary = _checkpoint_summary(post_tt)
assert [(s["source"], s["next"]) for s in post_tt_summary] == [
("fork", ("executor",)), # <-- new fork (latest)
("loop", ()),
("loop", ("executor",)), # <-- replay point / fork parent
("input", ("__start__",)),
]
# Verify the fork's parent is the original replay point
replay_point_id = sub_config["configurable"]["checkpoint_map"][""]
assert post_tt[0].parent_config["configurable"]["checkpoint_id"] == replay_point_id
# Resume from the fork
result = await graph.ainvoke(Command(resume="second"), config)
assert result["value"] == ["a:second"]
final = _checkpoint_summary([s async for s in graph.aget_state_history(config)])
assert [(s["source"], s["next"], s["values"]) for s in final] == [
# New branch
("loop", (), {"value": ["a:second"]}),
("fork", ("executor",), {"value": []}),
# Original branch
("loop", (), {"value": ["a:first"]}),
("loop", ("executor",), {"value": []}),
("input", ("__start__",), {"value": []}),
]
@NEEDS_CONTEXTVARS
async def test_3_levels_deep_time_travel_to_first_interrupt_async(
async_checkpointer: BaseCheckpointSaver,
@@ -2481,15 +2088,13 @@ async def test_replay_creates_branch_preserving_old_checkpoints(
# -- Post-replay checkpoint history (newest first) --
post_replay_history = [s async for s in graph.aget_state_history(config)]
post_summary = _checkpoint_summary(post_replay_history)
# 5 original + 1 fork + 2 new branch checkpoints = 8
assert len(post_summary) == 8
assert len(post_summary) == 7 # 5 original + 2 new branch checkpoints
assert [s["next"] for s in post_summary] == [
(), # new branch tip
("node_c",), # new branch
("node_b",), # fork from replay point
(), # old branch tip
("node_c",), # old
(), # new branch tip (C6)
("node_c",), # new branch (C5)
(), # old branch tip (C4)
("node_c",), # old (C3)
("node_b",), # branch point (C2)
("node_a",), # old (C1)
("__start__",), # old (C0)
@@ -2497,7 +2102,6 @@ async def test_replay_creates_branch_preserving_old_checkpoints(
assert [s["values"] for s in post_summary] == [
{"value": ["a", "b2", "c"]}, # new branch tip
{"value": ["a", "b2"]}, # new: node_b re-ran with call_count=2
{"value": ["a"]}, # fork from replay point
{"value": ["a", "b1", "c"]}, # old branch tip preserved
{"value": ["a", "b1"]}, # old
{"value": ["a"]}, # branch point
+5 -115
View File
@@ -11,19 +11,13 @@ from typing import (
TypeVar,
Union,
)
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import langsmith
import pytest
from langchain_core.runnables import RunnableConfig
from langchain_core.tracers import LangChainTracer
from typing_extensions import NotRequired, Required, TypedDict
from langgraph._internal._config import (
_is_not_empty,
ensure_config,
get_callback_manager_for_config,
)
from langgraph._internal._config import _is_not_empty, ensure_config
from langgraph._internal._fields import (
_is_optional_type,
get_enhanced_type_hints,
@@ -304,7 +298,7 @@ def test_is_not_empty() -> None:
assert not _is_not_empty({})
def test_configurable_metadata() -> None:
def test_configurable_metadata():
config = {
"configurable": {
"a-key": "foo",
@@ -315,115 +309,11 @@ def test_configurable_metadata() -> None:
"andme": 42,
"nested": {"foo": "bar"},
"nooverride": -2,
"thread_id": "th-123",
"checkpoint_id": "ckpt-1",
"checkpoint_ns": "ns-1",
"task_id": "task-1",
"run_id": "run-456",
"assistant_id": "asst-789",
"graph_id": "graph-0",
"model": "gpt-4o",
"user_id": "uid-1",
"cron_id": "cron-1",
"langgraph_auth_user_id": "user-1",
},
"metadata": {"nooverride": 18},
}
expected = {"includeme", "andme", "nooverride"}
merged = ensure_config(config)
metadata = merged["metadata"]
assert set(metadata) == {
"nooverride",
"assistant_id",
"thread_id",
"checkpoint_id",
"run_id",
"graph_id",
"checkpoint_ns",
"task_id",
}
assert metadata.keys() == expected
assert metadata["nooverride"] == 18
def test_callback_manager_copies_whitelisted_configurable_ids_to_metadata() -> None:
config = {
"configurable": {
"thread_id": "th-123",
"checkpoint_id": "ckpt-1",
"checkpoint_ns": "ns-1",
"task_id": "task-1",
"run_id": "run-456",
"assistant_id": "asst-789",
"graph_id": "graph-0",
"model": "gpt-4o",
"user_id": "uid-1",
"cron_id": "cron-1",
"langgraph_auth_user_id": "user-1",
},
"metadata": {
"thread_id": "from-metadata",
"nooverride": 18,
},
}
manager = ensure_config(config)
callback_manager = get_callback_manager_for_config(manager)
assert callback_manager.metadata == {
"thread_id": "from-metadata",
"nooverride": 18,
"checkpoint_id": "ckpt-1",
"checkpoint_ns": "ns-1",
"task_id": "task-1",
"run_id": "run-456",
"assistant_id": "asst-789",
"graph_id": "graph-0",
}
def test_callback_manager_copies_configurable_ids_to_tracing_metadata() -> None:
tracer = LangChainTracer(client=MagicMock())
config: RunnableConfig = {
"configurable": {
"thread_id": "th-123",
"checkpoint_id": "ckpt-1",
"checkpoint_ns": "ns-1",
"task_id": "task-1",
"run_id": "run-456",
"assistant_id": "asst-789",
"graph_id": "graph-0",
"model": "gpt-4o",
"user_id": "uid-1",
"cron_id": "cron-1",
"langgraph_auth_user_id": "user-1",
"includeme": "hi",
"andme": 42,
"__dontinclude": "bar",
"some_api_key": "secret",
"custom_setting": {"nested": True},
},
"metadata": {
"thread_id": "from-metadata",
"user_id": "from-metadata-user",
"includeme": "from-metadata",
},
"callbacks": [tracer],
}
manager = ensure_config(config)
callback_manager = get_callback_manager_for_config(manager)
handlers = callback_manager.handlers
tracers = [handler for handler in handlers if isinstance(handler, LangChainTracer)]
assert len(tracers) == 1
tracer = tracers[0]
assert tracer.tracing_metadata == {
"checkpoint_id": "ckpt-1",
"checkpoint_ns": "ns-1",
"task_id": "task-1",
"run_id": "run-456",
"assistant_id": "asst-789",
"graph_id": "graph-0",
"model": "gpt-4o",
"cron_id": "cron-1",
"andme": 42,
"includeme": "hi",
"thread_id": "th-123",
"user_id": "uid-1",
}
+21 -22
View File
@@ -1348,7 +1348,7 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.3.1"
version = "1.2.22"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonpatch" },
@@ -1360,14 +1360,14 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "uuid-utils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f4/fe/abeae8d0d2899e191d67c6c7f065f7e52a953f30b21ef327fa49084e4af9/langchain_core-1.3.1.tar.gz", hash = "sha256:41b384055799f93f34520df6bf7b80e2e5e23153cdfd46874251c6c9916ea030", size = 862403, upload-time = "2026-04-23T18:54:01.857Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b1/a3/c4cd6827a1df46c821e7214b7f7b7a28b189e6c9b84ef15c6d629c5e3179/langchain_core-1.2.22.tar.gz", hash = "sha256:8d8f726d03d3652d403da915126626bb6250747e8ba406537d849e68b9f5d058", size = 842487, upload-time = "2026-03-24T18:48:44.9Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a1/c2/8493be505921857988db068b7c027f28a9b1587b4425c6a32b1221c9c9fe/langchain_core-1.3.1-py3-none-any.whl", hash = "sha256:8b13d19d3bed3f4768df12c7f6932d2ada715f3ac9fd020c63d28c693968269e", size = 515879, upload-time = "2026-04-23T18:53:59.94Z" },
{ url = "https://files.pythonhosted.org/packages/c7/a6/2ffacf0f1a3788f250e75d0b52a24896c413be11be3a6d42bcdf46fbea48/langchain_core-1.2.22-py3-none-any.whl", hash = "sha256:7e30d586b75918e828833b9ec1efc25465723566845dd652c277baf751e9c04b", size = 506829, upload-time = "2026-03-24T18:48:43.286Z" },
]
[[package]]
name = "langgraph"
version = "1.1.10"
version = "1.1.6"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
@@ -1439,7 +1439,7 @@ test = [
[package.metadata]
requires-dist = [
{ name = "langchain-core", specifier = ">=1.3.0,<2" },
{ name = "langchain-core", specifier = ">=0.1" },
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-prebuilt", editable = "../prebuilt" },
{ name = "langgraph-sdk", editable = "../sdk-py" },
@@ -1548,7 +1548,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
version = "4.0.3"
version = "4.0.1"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
@@ -1706,7 +1706,7 @@ inmem = [
requires-dist = [
{ name = "click", specifier = ">=8.1.7" },
{ name = "httpx", specifier = ">=0.24.0" },
{ name = "langgraph-api", marker = "python_full_version >= '3.11' and extra == 'inmem'", specifier = ">=0.5.35,<0.9.0" },
{ name = "langgraph-api", marker = "python_full_version >= '3.11' and extra == 'inmem'", specifier = ">=0.5.35,<0.8.0" },
{ name = "langgraph-runtime-inmem", marker = "python_full_version >= '3.11' and extra == 'inmem'", specifier = ">=0.7" },
{ name = "langgraph-sdk", marker = "python_full_version >= '3.11'", specifier = ">=0.1.0" },
{ name = "pathspec", specifier = ">=0.11.0" },
@@ -1742,7 +1742,7 @@ test = [
[[package]]
name = "langgraph-prebuilt"
version = "1.0.13"
version = "1.0.9"
source = { editable = "../prebuilt" }
dependencies = [
{ name = "langchain-core" },
@@ -1751,7 +1751,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "langchain-core", specifier = ">=1.3.1" },
{ name = "langchain-core", specifier = ">=1.0.0" },
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
]
@@ -1852,7 +1852,7 @@ test = [
[[package]]
name = "langsmith"
version = "0.7.31"
version = "0.6.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
@@ -1862,12 +1862,11 @@ dependencies = [
{ name = "requests" },
{ name = "requests-toolbelt" },
{ name = "uuid-utils" },
{ name = "xxhash" },
{ name = "zstandard" },
]
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" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/85/9c7933052a997da1b85bc5c774f3865e9b1da1c8d71541ea133178b13229/langsmith-0.6.4.tar.gz", hash = "sha256:36f7223a01c218079fbb17da5e536ebbaf5c1468c028abe070aa3ae59bc99ec8", size = 919964, upload-time = "2026-01-15T20:02:28.873Z" }
wheels = [
{ 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" },
{ url = "https://files.pythonhosted.org/packages/66/0f/09a6637a7ba777eb307b7c80852d9ee26438e2bdafbad6fcc849ff9d9192/langsmith-0.6.4-py3-none-any.whl", hash = "sha256:ac4835860160be371042c7adbba3cb267bcf8d96a5ea976c33a8a4acad6c5486", size = 283503, upload-time = "2026-01-15T20:02:26.662Z" },
]
[package.optional-dependencies]
@@ -2140,7 +2139,7 @@ wheels = [
[[package]]
name = "nbconvert"
version = "7.17.1"
version = "7.17.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "beautifulsoup4" },
@@ -2158,9 +2157,9 @@ dependencies = [
{ name = "pygments" },
{ name = "traitlets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/01/b1/708e53fe2e429c103c6e6e159106bcf0357ac41aa4c28772bd8402339051/nbconvert-7.17.1.tar.gz", hash = "sha256:34d0d0a7e73ce3cbab6c5aae8f4f468797280b01fd8bd2ca746da8569eddd7d2", size = 865311, upload-time = "2026-04-08T00:44:14.914Z" }
sdist = { url = "https://files.pythonhosted.org/packages/38/47/81f886b699450d0569f7bc551df2b1673d18df7ff25cc0c21ca36ed8a5ff/nbconvert-7.17.0.tar.gz", hash = "sha256:1b2696f1b5be12309f6c7d707c24af604b87dfaf6d950794c7b07acab96dda78", size = 862855, upload-time = "2026-01-29T16:37:48.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/f8/bb0a9d5f46819c821dc1f004aa2cc29b1d91453297dbf5ff20470f00f193/nbconvert-7.17.1-py3-none-any.whl", hash = "sha256:aa85c087b435e7bf1ffd03319f658e285f2b89eccab33bc1ba7025495ab3e7c8", size = 261927, upload-time = "2026-04-08T00:44:12.845Z" },
{ url = "https://files.pythonhosted.org/packages/0d/4b/8d5f796a792f8a25f6925a96032f098789f448571eb92011df1ae59e8ea8/nbconvert-7.17.0-py3-none-any.whl", hash = "sha256:4f99a63b337b9a23504347afdab24a11faa7d86b405e5c8f9881cd313336d518", size = 261510, upload-time = "2026-01-29T16:37:46.322Z" },
]
[[package]]
@@ -2906,7 +2905,7 @@ wheels = [
[[package]]
name = "pytest"
version = "9.0.3"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -2917,9 +2916,9 @@ dependencies = [
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
@@ -3018,11 +3017,11 @@ wheels = [
[[package]]
name = "python-dotenv"
version = "1.2.2"
version = "1.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
]
[[package]]
+49 -166
View File
@@ -44,7 +44,7 @@ import inspect
import json
from collections.abc import Awaitable, Callable
from copy import copy, deepcopy
from dataclasses import dataclass, field, replace
from dataclasses import dataclass, replace
from types import UnionType
from typing import (
TYPE_CHECKING,
@@ -82,7 +82,6 @@ from langchain_core.tools.base import (
_is_injected_arg_type,
get_all_basemodel_annotations,
)
from langgraph._internal._constants import CONF, CONFIG_KEY_READ
from langgraph._internal._runnable import RunnableCallable
from langgraph.errors import GraphBubbleUp
from langgraph.graph.message import REMOVE_ALL_MESSAGES
@@ -615,7 +614,6 @@ class _InjectedArgs:
store: str | None
runtime: str | None
all_injected_keys: set[str]
_optional_state_args: set[str]
class ToolNode(RunnableCallable):
@@ -801,7 +799,7 @@ class ToolNode(RunnableCallable):
# Construct ToolRuntime instances at the top level for each tool call
tool_runtimes = []
for call, cfg in zip(tool_calls, config_list, strict=False):
state = self._extract_state(input, cfg)
state = self._extract_state(input)
tool_runtime = ToolRuntime(
state=state,
tool_call_id=call["id"],
@@ -809,7 +807,6 @@ class ToolNode(RunnableCallable):
context=runtime.context,
store=runtime.store,
stream_writer=runtime.stream_writer,
tools=list(self.tools_by_name.values()),
execution_info=runtime.execution_info,
server_info=runtime.server_info,
)
@@ -836,7 +833,7 @@ class ToolNode(RunnableCallable):
# Construct ToolRuntime instances at the top level for each tool call
tool_runtimes = []
for call, cfg in zip(tool_calls, config_list, strict=False):
state = self._extract_state(input, cfg)
state = self._extract_state(input)
tool_runtime = ToolRuntime(
state=state,
tool_call_id=call["id"],
@@ -844,7 +841,6 @@ class ToolNode(RunnableCallable):
context=runtime.context,
store=runtime.store,
stream_writer=runtime.stream_writer,
tools=list(self.tools_by_name.values()),
execution_info=runtime.execution_info,
server_info=runtime.server_info,
)
@@ -860,30 +856,14 @@ class ToolNode(RunnableCallable):
def _combine_tool_outputs(
self,
outputs: list[ToolMessage | Command | list[ToolMessage | Command]],
outputs: list[ToolMessage | Command],
input_type: Literal["list", "dict", "tool_calls"],
) -> list[Command | list[ToolMessage] | dict[str, list[ToolMessage]]]:
# Flatten list entries from tools that returned multiple items
flat_outputs: list[ToolMessage | Command]
if any(isinstance(output, list) for output in outputs):
flat_outputs = []
for output in outputs:
if isinstance(output, list):
flat_outputs.extend(output)
else:
flat_outputs.append(output)
else:
flat_outputs = cast("list[ToolMessage | Command]", outputs)
# preserve existing behavior for non-command tool outputs for backwards
# compatibility
if not any(isinstance(output, Command) for output in flat_outputs):
if not any(isinstance(output, Command) for output in outputs):
# TypedDict, pydantic, dataclass, etc. should all be able to load from dict
return (
flat_outputs
if input_type == "list"
else {self._messages_key: flat_outputs}
)
return outputs if input_type == "list" else {self._messages_key: outputs}
# LangGraph will automatically handle list of Command and non-command node
# updates
@@ -893,7 +873,7 @@ class ToolNode(RunnableCallable):
# combine all parent commands with goto into a single parent command
parent_command: Command | None = None
for output in flat_outputs:
for output in outputs:
if isinstance(output, Command):
if (
output.graph is Command.PARENT
@@ -923,7 +903,7 @@ class ToolNode(RunnableCallable):
request: ToolCallRequest,
input_type: Literal["list", "dict", "tool_calls"],
config: RunnableConfig,
) -> ToolMessage | Command | list[Command | ToolMessage]:
) -> ToolMessage | Command:
"""Execute tool call with configured error handling.
Args:
@@ -932,7 +912,7 @@ class ToolNode(RunnableCallable):
config: Runnable configuration.
Returns:
ToolMessage, Command, or list of Command/ToolMessage.
ToolMessage or Command.
Raises:
Exception: If tool fails and handle_tool_errors is False.
@@ -964,11 +944,6 @@ class ToolNode(RunnableCallable):
call["name"], exc, call["args"], filtered_errors
) from exc
# Inside try so validation errors route through _handle_tool_errors
return self._normalize_tool_response(
response, request.tool_call, input_type
)
# GraphInterrupt is a special exception that will always be raised.
# It can be triggered in the following scenarios,
# Where GraphInterrupt(GraphBubbleUp) is raised from an `interrupt` invocation
@@ -1010,12 +985,23 @@ class ToolNode(RunnableCallable):
status="error",
)
# Process successful response
if isinstance(response, Command):
# Validate Command before returning to handler
return self._validate_tool_command(response, request.tool_call, input_type)
if isinstance(response, ToolMessage):
response.content = cast("str | list", msg_content_output(response.content))
return response
msg = f"Tool {call['name']} returned unexpected type: {type(response)}"
raise TypeError(msg)
def _run_one(
self,
call: ToolCall,
input_type: Literal["list", "dict", "tool_calls"],
tool_runtime: ToolRuntime,
) -> ToolMessage | Command | list[Command | ToolMessage]:
) -> ToolMessage | Command:
"""Execute single tool call with wrap_tool_call wrapper if configured.
Args:
@@ -1070,7 +1056,7 @@ class ToolNode(RunnableCallable):
request: ToolCallRequest,
input_type: Literal["list", "dict", "tool_calls"],
config: RunnableConfig,
) -> ToolMessage | Command | list[Command | ToolMessage]:
) -> ToolMessage | Command:
"""Execute tool call asynchronously with configured error handling.
Args:
@@ -1079,7 +1065,7 @@ class ToolNode(RunnableCallable):
config: Runnable configuration.
Returns:
ToolMessage, Command, or list of Command/ToolMessage.
ToolMessage or Command.
Raises:
Exception: If tool fails and handle_tool_errors is False.
@@ -1111,11 +1097,6 @@ class ToolNode(RunnableCallable):
call["name"], exc, call["args"], filtered_errors
) from exc
# Inside try so validation errors route through _handle_tool_errors
return self._normalize_tool_response(
response, request.tool_call, input_type
)
# GraphInterrupt is a special exception that will always be raised.
# It can be triggered in the following scenarios,
# Where GraphInterrupt(GraphBubbleUp) is raised from an `interrupt` invocation
@@ -1157,12 +1138,23 @@ class ToolNode(RunnableCallable):
status="error",
)
# Process successful response
if isinstance(response, Command):
# Validate Command before returning to handler
return self._validate_tool_command(response, request.tool_call, input_type)
if isinstance(response, ToolMessage):
response.content = cast("str | list", msg_content_output(response.content))
return response
msg = f"Tool {call['name']} returned unexpected type: {type(response)}"
raise TypeError(msg)
async def _arun_one(
self,
call: ToolCall,
input_type: Literal["list", "dict", "tool_calls"],
tool_runtime: ToolRuntime,
) -> ToolMessage | Command | list[Command | ToolMessage]:
) -> ToolMessage | Command:
"""Execute single tool call asynchronously with awrap_tool_call wrapper if configured.
Args:
@@ -1278,37 +1270,18 @@ class ToolNode(RunnableCallable):
return None
def _extract_state(
self,
input: list[AnyMessage] | dict[str, Any] | BaseModel,
config: RunnableConfig,
self, input: list[AnyMessage] | dict[str, Any] | BaseModel
) -> list[AnyMessage] | dict[str, Any] | BaseModel:
"""Extract state from input.
"""Extract state from input, handling ToolCallWithContext if present.
Three input shapes:
Args:
input: The input which may be raw state or ToolCallWithContext.
- `ToolCallWithContext` dict legacy Send payload carrying an inlined
state snapshot; return `input["state"]`.
- list of `ToolCall` dicts new Send payload with no inlined state;
hydrate state from channels via `CONFIG_KEY_READ`.
- regular graph state (dict/list/BaseModel) return `input` as-is.
Returns:
The actual state to pass to wrap_tool_call wrappers.
"""
if isinstance(input, dict) and input.get("__type") == "tool_call_with_context":
return input["state"]
if (
isinstance(input, list)
and input
and isinstance(input[-1], dict)
and input[-1].get("type") == "tool_call"
):
read = config.get(CONF, {}).get(CONFIG_KEY_READ)
if read is None:
return {}
# Pregel installs CONFIG_KEY_READ as
# `functools.partial(local_read, scratchpad, channels, managed, task)`.
# Match the previous inlined-state contract by reading channels only;
# managed values have their own injection path (`ToolRuntime.context`).
channels = read.args[1]
return cast("dict[str, Any]", read(list(channels), True))
return input
def _inject_tool_args(
@@ -1360,7 +1333,7 @@ class ToolNode(RunnableCallable):
return tool_call
tool_call_copy: ToolCall = copy(tool_call)
injected_args: dict[str, Any] = {}
injected_args = {}
# Inject state
if injected.state:
@@ -1388,20 +1361,14 @@ class ToolNode(RunnableCallable):
# Extract state values
if isinstance(state, dict):
for tool_arg, state_field in injected.state.items():
if not state_field:
injected_args[tool_arg] = state
elif state_field in state:
injected_args[tool_arg] = state[state_field]
elif tool_arg not in injected._optional_state_args:
raise KeyError(state_field)
injected_args[tool_arg] = (
state[state_field] if state_field else state
)
else:
for tool_arg, state_field in injected.state.items():
if not state_field:
injected_args[tool_arg] = state
elif hasattr(state, state_field):
injected_args[tool_arg] = getattr(state, state_field)
elif tool_arg not in injected._optional_state_args:
raise AttributeError(state_field)
injected_args[tool_arg] = (
getattr(state, state_field) if state_field else state
)
# Inject store
if injected.store:
@@ -1428,84 +1395,11 @@ class ToolNode(RunnableCallable):
tool_call_copy["args"] = {**stripped_args, **injected_args}
return tool_call_copy
def _normalize_tool_response(
self,
response: Any,
tool_call: ToolCall,
input_type: Literal["list", "dict", "tool_calls"],
) -> ToolMessage | Command | list[Command | ToolMessage]:
"""Validate and normalize a tool's raw return value."""
if isinstance(response, Command):
return self._validate_tool_command(response, tool_call, input_type)
if isinstance(response, ToolMessage):
response.content = cast("str | list", msg_content_output(response.content))
return response
if isinstance(response, list):
if all(isinstance(r, (Command, ToolMessage)) for r in response):
return self._validate_tool_command_list(response, tool_call, input_type)
msg = (
f"Tool {tool_call['name']} returned a list with invalid element "
"types: expected all Command or ToolMessage"
)
raise TypeError(msg)
msg = f"Tool {tool_call['name']} returned unexpected type: {type(response)}"
raise TypeError(msg)
def _validate_tool_command_list(
self,
response: list[Command | ToolMessage],
tool_call: ToolCall,
input_type: Literal["list", "dict", "tool_calls"],
) -> list[Command | ToolMessage]:
"""Validate a list of Command/ToolMessage returned by a single tool call.
Requires exactly one terminating ToolMessage (matching the outer tool_call_id)
across the list either as a top-level element or nested in a
Command.update["messages"].
"""
expected_id = tool_call["id"]
terminator_count = 0
for item in response:
if isinstance(item, ToolMessage):
if item.tool_call_id == expected_id:
terminator_count += 1
elif isinstance(item, Command) and isinstance(item.update, dict):
for msg in item.update.get(self._messages_key, []):
if isinstance(msg, ToolMessage) and msg.tool_call_id == expected_id:
terminator_count += 1
if terminator_count != 1:
msg = (
f"Tool {tool_call['name']} returned a list with "
f"{terminator_count} messages bound to tool_call_id "
f"{expected_id!r}; expected exactly one terminating ToolMessage."
)
raise ValueError(msg)
# Per-Command normalization still runs, but the list-level count above
# already guarantees exactly one terminator, so individual Commands may
# lack one.
validated: list[Command | ToolMessage] = []
for item in response:
if isinstance(item, Command):
validated.append(
self._validate_tool_command(
item, tool_call, input_type, require_terminator=False
)
)
else:
item.content = cast("str | list", msg_content_output(item.content))
validated.append(item)
return validated
def _validate_tool_command(
self,
command: Command,
call: ToolCall,
input_type: Literal["list", "dict", "tool_calls"],
*,
require_terminator: bool = True,
) -> Command:
if isinstance(command.update, dict):
# input type is dict when ToolNode is invoked with a dict input
@@ -1555,11 +1449,7 @@ class ToolNode(RunnableCallable):
# validate that we always have a ToolMessage matching the tool call in
# Command.update if command is sent to the CURRENT graph
if (
require_terminator
and updated_command.graph is None
and not has_matching_tool_message
):
if updated_command.graph is None and not has_matching_tool_message:
example_update = (
'`Command(update={"messages": '
'[ToolMessage("Success", tool_call_id=tool_call_id), ...]}, ...)`'
@@ -1679,7 +1569,6 @@ class ToolRuntime(_DirectlyInjectedToolArg, Generic[ContextT, StateT]):
- `context`: Runtime context (shared with `Runtime`)
- `store`: `BaseStore` instance for persistent storage (shared with `Runtime`)
- `stream_writer`: `StreamWriter` for streaming output (shared with `Runtime`)
- `tools`: List of all available `BaseTool` instances
No `Annotated` wrapper is needed - just use `runtime: ToolRuntime`
as a parameter.
@@ -1724,7 +1613,6 @@ class ToolRuntime(_DirectlyInjectedToolArg, Generic[ContextT, StateT]):
stream_writer: StreamWriter
tool_call_id: str | None
store: BaseStore | None
tools: list[BaseTool] = field(default_factory=list)
execution_info: ExecutionInfo | None = None
server_info: ServerInfo | None = None
@@ -1971,7 +1859,6 @@ def _get_all_injected_args(tool: BaseTool) -> _InjectedArgs:
store_arg: str | None = None
runtime_arg: str | None = None
all_injected_keys: set[str] = set()
_optional_state_args: set[str] = set()
for name, type_ in all_annotations.items():
# Track all InjectedToolArg-annotated params (including custom subclasses)
@@ -1986,9 +1873,6 @@ def _get_all_injected_args(tool: BaseTool) -> _InjectedArgs:
if state_inj := _get_injection_from_type(type_, InjectedState):
if isinstance(state_inj, InjectedState) and state_inj.field:
state_args[name] = state_inj.field
field_info = full_schema.model_fields.get(name)
if field_info and not field_info.is_required():
_optional_state_args.add(name)
else:
state_args[name] = None
@@ -2005,5 +1889,4 @@ def _get_all_injected_args(tool: BaseTool) -> _InjectedArgs:
store=store_arg,
runtime=runtime_arg,
all_injected_keys=all_injected_keys,
_optional_state_args=_optional_state_args,
)
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph-prebuilt"
version = "1.0.13"
version = "1.0.9"
description = "Library with high-level APIs for creating and executing LangGraph agents and tools."
authors = []
requires-python = ">=3.10"
@@ -25,7 +25,7 @@ classifiers = [
]
dependencies = [
"langgraph-checkpoint>=2.1.0,<5.0.0",
"langchain-core>=1.3.1",
"langchain-core>=1.0.0",
]
[project.urls]
@@ -1,285 +0,0 @@
"""Test InjectedState with NotRequired state fields.
This tests the fix for https://github.com/langchain-ai/langchain/issues/35585
When using InjectedState(<field>) on a tool parameter, and the referenced field is
declared as NotRequired in the custom state schema, the ToolNode should gracefully
handle missing fields by injecting None instead of raising KeyError.
"""
import sys
from typing import Annotated
import pytest
from langchain_core.messages import AIMessage, AnyMessage, HumanMessage, ToolMessage
from langchain_core.tools import tool
from langgraph.graph.message import add_messages
from pydantic import BaseModel, Field
from typing_extensions import NotRequired
from langgraph.prebuilt import InjectedState, ToolNode, create_react_agent
from langgraph.prebuilt.chat_agent_executor import AgentState
from .model import FakeToolCallingModel
class CustomAgentStateWithNotRequired(AgentState):
"""Custom state with a NotRequired field (TypedDict style)."""
city: NotRequired[str]
class CustomAgentStatePydanticWithDefault(BaseModel):
"""Custom state with Optional field and default (Pydantic style)."""
messages: Annotated[list[AnyMessage], add_messages]
remaining_steps: int = Field(default=10)
city: str | None = Field(default=None)
@tool
def get_weather(city: Annotated[str | None, InjectedState("city")] = None) -> str:
"""Get weather for a given city."""
if city is None:
return "No city provided"
return f"It's always sunny in {city}!"
def _create_mock_runtime(
state: dict | None = None,
store=None,
):
"""Create a mock Runtime for testing ToolNode directly."""
from unittest.mock import Mock
from langgraph.runtime import Runtime
mock_runtime = Mock(spec=Runtime)
mock_runtime.context = {}
return mock_runtime
def _create_config_with_runtime(store=None, state=None):
"""Create a RunnableConfig with mocked runtime for direct ToolNode testing."""
from langgraph.prebuilt.tool_node import ToolRuntime
tool_runtime = ToolRuntime(
state=state or {},
config={},
context={},
store=store,
stream_writer=None,
tools=[],
tool_call_id="test_id",
)
return {
"configurable": {
"__pregel_runtime": _create_mock_runtime(),
"__tool_runtime__": tool_runtime,
}
}
@pytest.mark.skipif(
sys.version_info < (3, 11),
reason="InjectedState field extraction from Optional[Annotated[...]] not supported on Python <3.11",
)
def test_injected_state_not_required_field_missing_injects_none():
"""Test that InjectedState with NotRequired field injects None when field is missing.
This verifies the fix for https://github.com/langchain-ai/langchain/issues/35585
"""
tool_node = ToolNode([get_weather])
tool_call = {
"name": "get_weather",
"args": {},
"id": "call_1",
"type": "tool_call",
}
ai_msg = AIMessage("Let me check the weather", tool_calls=[tool_call])
# State WITHOUT the "city" field - should inject None instead of raising KeyError
state_without_city: CustomAgentStateWithNotRequired = {
"messages": [HumanMessage("What's the weather?"), ai_msg],
}
result = tool_node.invoke(
state_without_city,
config=_create_config_with_runtime(state=state_without_city),
)
assert len(result["messages"]) == 1
tool_msg = result["messages"][0]
assert isinstance(tool_msg, ToolMessage)
assert "No city provided" in tool_msg.content
@pytest.mark.skipif(
sys.version_info < (3, 11),
reason="InjectedState field extraction from Optional[Annotated[...]] not supported on Python <3.11",
)
def test_injected_state_not_required_field_present_works():
"""Test that InjectedState with NotRequired field works when field IS present."""
tool_node = ToolNode([get_weather])
tool_call = {
"name": "get_weather",
"args": {},
"id": "call_1",
"type": "tool_call",
}
ai_msg = AIMessage("Let me check the weather", tool_calls=[tool_call])
# State WITH the "city" field - this should work
state_with_city: CustomAgentStateWithNotRequired = {
"messages": [HumanMessage("What's the weather?"), ai_msg],
"city": "San Francisco",
}
result = tool_node.invoke(
state_with_city,
config=_create_config_with_runtime(state=state_with_city),
)
assert len(result["messages"]) == 1
tool_msg = result["messages"][0]
assert isinstance(tool_msg, ToolMessage)
assert "San Francisco" in tool_msg.content
@pytest.mark.skipif(
sys.version_info < (3, 11),
reason="InjectedState field extraction from Optional[Annotated[...]] not supported on Python <3.11",
)
def test_create_react_agent_injected_state_not_required_field_missing():
"""Test create_react_agent with InjectedState using NotRequired field that is missing.
This verifies the fix for https://github.com/langchain-ai/langchain/issues/35585
"""
model = FakeToolCallingModel(
tool_calls=[
[{"name": "get_weather", "args": {}, "id": "call_1"}],
[], # No more tool calls, agent should stop
]
)
agent = create_react_agent(
model,
tools=[get_weather],
state_schema=CustomAgentStateWithNotRequired,
)
# Invoke WITHOUT the city field - should work, injecting None
result = agent.invoke(
{"messages": [HumanMessage("What's the weather?")]},
)
# Check that the tool was called successfully with None injected
messages = result["messages"]
tool_messages = [m for m in messages if isinstance(m, ToolMessage)]
assert len(tool_messages) == 1
assert "No city provided" in tool_messages[0].content
@pytest.mark.skipif(
sys.version_info < (3, 11),
reason="InjectedState field extraction from Optional[Annotated[...]] not supported on Python <3.11",
)
def test_create_react_agent_injected_state_not_required_field_present():
"""Test create_react_agent with InjectedState using NotRequired field that IS present."""
model = FakeToolCallingModel(
tool_calls=[
[{"name": "get_weather", "args": {}, "id": "call_1"}],
[], # No more tool calls, agent should stop
]
)
agent = create_react_agent(
model,
tools=[get_weather],
state_schema=CustomAgentStateWithNotRequired,
)
# Invoke WITH the city field
result = agent.invoke(
{
"messages": [HumanMessage("What's the weather?")],
"city": "San Francisco",
},
)
# Check that the tool was called successfully
messages = result["messages"]
tool_messages = [m for m in messages if isinstance(m, ToolMessage)]
assert len(tool_messages) == 1
assert "San Francisco" in tool_messages[0].content
@tool
def get_weather_optional(city: Annotated[str | None, InjectedState("city")]) -> str:
"""Get weather for a given city (accepts None)."""
if city is None:
return "Please provide a city!"
return f"It's always sunny in {city}!"
def test_pydantic_state_with_default_field_missing_works():
"""Test that Pydantic state with Optional field and default=None works when field is missing.
This is the workaround suggested in the issue comments - using Pydantic BaseModel
with `city: Optional[str] = Field(default=None)` instead of TypedDict with NotRequired.
"""
model = FakeToolCallingModel(
tool_calls=[
[{"name": "get_weather_optional", "args": {}, "id": "call_1"}],
[], # No more tool calls, agent should stop
]
)
agent = create_react_agent(
model,
tools=[get_weather_optional],
state_schema=CustomAgentStatePydanticWithDefault,
)
# Invoke WITHOUT the city field - should work because Pydantic provides default
result = agent.invoke(
{"messages": [HumanMessage("What's the weather?")]},
)
# Check that the tool was called successfully with None
messages = result["messages"]
tool_messages = [m for m in messages if isinstance(m, ToolMessage)]
assert len(tool_messages) == 1
assert "Please provide a city!" in tool_messages[0].content
def test_pydantic_state_with_default_field_present_works():
"""Test that Pydantic state with Optional field works when field IS present."""
model = FakeToolCallingModel(
tool_calls=[
[{"name": "get_weather_optional", "args": {}, "id": "call_1"}],
[], # No more tool calls, agent should stop
]
)
agent = create_react_agent(
model,
tools=[get_weather_optional],
state_schema=CustomAgentStatePydanticWithDefault,
)
# Invoke WITH the city field
result = agent.invoke(
{
"messages": [HumanMessage("What's the weather?")],
"city": "San Francisco",
},
)
# Check that the tool was called successfully
messages = result["messages"]
tool_messages = [m for m in messages if isinstance(m, ToolMessage)]
assert len(tool_messages) == 1
assert "San Francisco" in tool_messages[0].content
-92
View File
@@ -1320,98 +1320,6 @@ async def test_state_extraction_with_tool_call_with_context_async() -> None:
assert "tool_call" not in state_seen[0]
def _config_with_channel_read(
channel_values: dict[str, object],
store: BaseStore | None = None,
) -> RunnableConfig:
"""Build a config that mimics `CONFIG_KEY_READ` as Pregel installs it.
Pregel always installs a `functools.partial(local_read, scratchpad,
channels, managed, task)`, and `ToolNode` introspects that partial to
learn channel names. The stub matches the shape: partial whose second and
third positional args are `channels` and `managed` mappings.
"""
import functools
channels_stub = {k: None for k in channel_values}
managed_stub: dict[str, object] = {}
# Shape matches pregel's real partial:
# functools.partial(local_read, scratchpad, channels, managed, task)
def _read(scratchpad, channels, managed, task, select, fresh): # noqa: ARG001
if isinstance(select, str):
return channel_values[select]
return {k: channel_values[k] for k in select if k in channel_values}
read = functools.partial(_read, None, channels_stub, managed_stub, None)
cfg = _create_config_with_runtime(store)
cfg["configurable"]["__pregel_read"] = read
return cfg
def test_list_form_send_hydrates_state_from_channel_read() -> None:
"""Send('tools', [tool_call]) with no inlined state should hydrate
ToolRuntime.state from CONFIG_KEY_READ (full state read)."""
state_seen = []
def state_inspector_handler(
request: ToolCallRequest,
execute: Callable[[ToolCallRequest], ToolMessage | Command],
) -> ToolMessage | Command:
state_seen.append(request.state)
return execute(request)
channel_values = {
"messages": [AIMessage("from channels")],
"files": {"/a.md": "body"},
}
tool_node = ToolNode([add], wrap_tool_call=state_inspector_handler)
tool_call: ToolCall = {
"name": "add",
"args": {"a": 1, "b": 2},
"id": "call_1",
"type": "tool_call",
}
tool_node.invoke([tool_call], config=_config_with_channel_read(channel_values))
assert len(state_seen) == 1
got = state_seen[0]
assert got == channel_values
assert "messages" in got and "files" in got
async def test_list_form_send_hydrates_state_async() -> None:
state_seen = []
def state_inspector_handler(
request: ToolCallRequest,
execute: Callable[[ToolCallRequest], ToolMessage | Command],
) -> ToolMessage | Command:
state_seen.append(request.state)
return execute(request)
channel_values = {"messages": [AIMessage("from channels")], "files": {}}
tool_node = ToolNode([add], wrap_tool_call=state_inspector_handler)
tool_call: ToolCall = {
"name": "add",
"args": {"a": 1, "b": 2},
"id": "call_1",
"type": "tool_call",
}
await tool_node.ainvoke(
[tool_call], config=_config_with_channel_read(channel_values)
)
assert len(state_seen) == 1
assert state_seen[0] == channel_values
def test_tool_call_request_is_frozen() -> None:
"""Test that ToolCallRequest raises deprecation warnings on direct attribute reassignment."""
tool_call: ToolCall = {"name": "add", "args": {"a": 1, "b": 2}, "id": "call_1"}
+8 -234
View File
@@ -2016,21 +2016,8 @@ async def test_tool_node_inject_runtime_dynamic_tool_via_wrap_tool_call_async()
assert tool_message.tool_call_id == "call_dynamic_2"
def test_tool_runtime_defaults_tools_to_empty_list() -> None:
runtime = ToolRuntime(
state={},
context=None,
config={},
stream_writer=lambda *args, **kwargs: None,
tool_call_id=None,
store=None,
)
assert runtime.tools == []
def test_tool_runtime_forwards_execution_info_server_info_and_tools() -> None:
"""Test that execution_info, server_info, and tools are forwarded from Runtime to ToolRuntime."""
def test_tool_runtime_forwards_execution_info_and_server_info() -> None:
"""Test that execution_info and server_info are forwarded from Runtime to ToolRuntime."""
from langgraph.runtime import ExecutionInfo, ServerInfo
exec_info = ExecutionInfo(
@@ -2056,15 +2043,9 @@ def test_tool_runtime_forwards_execution_info_server_info_and_tools() -> None:
"""Tool that captures runtime info."""
captured["execution_info"] = runtime.execution_info
captured["server_info"] = runtime.server_info
captured["tools"] = runtime.tools
return "ok"
@dec_tool
def other_tool(y: int) -> str:
"""Another tool available to the runtime."""
return str(y)
node = ToolNode([info_tool, other_tool])
node = ToolNode([info_tool])
tool_call = {
"name": "info_tool",
"args": {"x": 1},
@@ -2073,21 +2054,17 @@ def test_tool_runtime_forwards_execution_info_server_info_and_tools() -> None:
}
msg = AIMessage("", tool_calls=[tool_call])
config: RunnableConfig = {"configurable": {"__pregel_runtime": mock_runtime}}
result = node.invoke({"messages": [msg]}, config=config)
node.invoke({"messages": [msg]}, config=config)
assert result["messages"][-1].content == "ok"
assert captured["execution_info"] is exec_info
assert captured["execution_info"].thread_id == "t-1"
assert captured["execution_info"].task_id == "tk-1"
assert captured["server_info"] is server_info
assert captured["server_info"].assistant_id == "asst-1"
assert [tool.name for tool in captured["tools"]] == ["info_tool", "other_tool"]
async def test_tool_runtime_forwards_execution_info_server_info_and_tools_async() -> (
None
):
"""Test that execution_info, server_info, and tools are forwarded in async path."""
async def test_tool_runtime_forwards_execution_info_and_server_info_async() -> None:
"""Test that execution_info and server_info are forwarded in async path."""
from langgraph.runtime import ExecutionInfo, ServerInfo
exec_info = ExecutionInfo(
@@ -2113,15 +2090,9 @@ async def test_tool_runtime_forwards_execution_info_server_info_and_tools_async(
"""Async tool that captures runtime info."""
captured["execution_info"] = runtime.execution_info
captured["server_info"] = runtime.server_info
captured["tools"] = runtime.tools
return "ok"
@dec_tool
async def other_tool_async(y: int) -> str:
"""Another async tool available to the runtime."""
return str(y)
node = ToolNode([info_tool_async, other_tool_async])
node = ToolNode([info_tool_async])
tool_call = {
"name": "info_tool_async",
"args": {"x": 1},
@@ -2130,17 +2101,12 @@ async def test_tool_runtime_forwards_execution_info_server_info_and_tools_async(
}
msg = AIMessage("", tool_calls=[tool_call])
config: RunnableConfig = {"configurable": {"__pregel_runtime": mock_runtime}}
result = await node.ainvoke({"messages": [msg]}, config=config)
await node.ainvoke({"messages": [msg]}, config=config)
assert result["messages"][-1].content == "ok"
assert captured["execution_info"] is exec_info
assert captured["execution_info"].thread_id == "t-2"
assert captured["server_info"] is server_info
assert captured["server_info"].graph_id == "graph-2"
assert [tool.name for tool in captured["tools"]] == [
"info_tool_async",
"other_tool_async",
]
# --- InjectedToolArg security tests ---
@@ -2236,195 +2202,3 @@ def test_tool_node_injected_state_overwrites_llm_value() -> None:
)
tool_message = result["messages"][-1]
assert tool_message.content == "PUBLIC_DATA"
class _ReturningTool(BaseTool):
"""A tool that returns a configured value verbatim."""
name: str = "list_tool"
description: str = "Returns a configured value"
return_value: Any = None
def _run(self, **kwargs: Any) -> Any:
return self.return_value
async def _arun(self, **kwargs: Any) -> Any:
return self.return_value
def _list_tool_call(outer_id: str = "call-1") -> dict[str, Any]:
return {"name": "list_tool", "args": {}, "id": outer_id, "type": "tool_call"}
def _invoke_returning(
return_value: Any,
*,
outer_id: str = "call-1",
handle_tool_errors: bool = True,
) -> Any:
node = ToolNode(
[_ReturningTool(return_value=return_value)],
handle_tool_errors=handle_tool_errors,
)
return node.invoke(
{"messages": [AIMessage("", tool_calls=[_list_tool_call(outer_id)])]},
config=_create_config_with_runtime(),
)
def test_tool_node_list_return_command_and_tool_message() -> None:
"""Valid: tool returns [Command(update={...}), ToolMessage(...)]."""
outer_id = "call-1"
result = _invoke_returning(
[
Command(update={"foo": "bar"}),
ToolMessage(content="done", tool_call_id=outer_id),
]
)
assert isinstance(result, list)
commands = [r for r in result if isinstance(r, Command)]
assert len(commands) == 1
assert commands[0].update == {"foo": "bar"}
non_commands = [r for r in result if not isinstance(r, Command)]
assert len(non_commands) == 1
assert isinstance(non_commands[0], dict)
msgs = non_commands[0]["messages"]
assert len(msgs) == 1
assert isinstance(msgs[0], ToolMessage)
assert msgs[0].content == "done"
assert msgs[0].tool_call_id == outer_id
def test_tool_node_list_return_nested_terminator() -> None:
"""Valid: terminator nested inside Command.update['messages']."""
outer_id = "call-1"
result = _invoke_returning(
[
Command(update={"foo": "bar"}),
Command(
update={
"messages": [ToolMessage(content="done", tool_call_id=outer_id)]
}
),
]
)
assert isinstance(result, list)
commands = [r for r in result if isinstance(r, Command)]
assert len(commands) == 2
updates = [c.update for c in commands]
assert {"foo": "bar"} in updates
msgs_update = next(u for u in updates if "messages" in (u or {}))
assert any(
isinstance(m, ToolMessage) and m.tool_call_id == outer_id
for m in msgs_update["messages"]
)
def test_tool_node_list_return_parent_goto_with_terminator() -> None:
"""Valid: [Command(graph=PARENT, goto=[Send(...)]), ToolMessage(...)]."""
outer_id = "call-1"
result = _invoke_returning(
[
Command(graph=Command.PARENT, goto=[Send("child", {})]),
ToolMessage(content="ok", tool_call_id=outer_id),
]
)
assert isinstance(result, list)
parent_cmds = [
r for r in result if isinstance(r, Command) and r.graph is Command.PARENT
]
assert len(parent_cmds) == 1
assert isinstance(parent_cmds[0].goto, list)
assert any(isinstance(s, Send) for s in parent_cmds[0].goto)
non_commands = [r for r in result if not isinstance(r, Command)]
assert len(non_commands) == 1
def test_tool_node_list_return_no_terminator_raises() -> None:
"""Invalid: list with no terminating ToolMessage."""
with pytest.raises(ValueError, match="0 messages bound to tool_call_id"):
_invoke_returning([Command(update={"foo": "bar"})], handle_tool_errors=False)
def test_tool_node_list_return_multiple_terminators_raises() -> None:
"""Invalid: list with two terminating ToolMessages."""
outer_id = "call-1"
with pytest.raises(ValueError, match="2 messages bound to tool_call_id"):
_invoke_returning(
[
ToolMessage(content="a", tool_call_id=outer_id),
ToolMessage(content="b", tool_call_id=outer_id),
],
handle_tool_errors=False,
)
def test_tool_node_list_return_validation_error_handled() -> None:
"""handle_tool_errors=True converts validation errors to an error ToolMessage."""
result = _invoke_returning([Command(update={"foo": "bar"})])
assert isinstance(result, dict)
msg = result["messages"][0]
assert isinstance(msg, ToolMessage)
assert msg.status == "error"
assert "0 messages bound to tool_call_id" in msg.content
async def test_tool_node_list_return_async_smoke() -> None:
"""Async path parallels sync for the happy case."""
outer_id = "call-1"
node = ToolNode(
[
_ReturningTool(
return_value=[
Command(update={"foo": "bar"}),
ToolMessage(content="done", tool_call_id=outer_id),
]
)
]
)
result = await node.ainvoke(
{"messages": [AIMessage("", tool_calls=[_list_tool_call(outer_id)])]},
config=_create_config_with_runtime(),
)
assert isinstance(result, list)
commands = [r for r in result if isinstance(r, Command)]
assert len(commands) == 1 and commands[0].update == {"foo": "bar"}
def test_tool_node_list_return_mixed_with_regular_tool() -> None:
"""List-returning tool and a regular tool dispatched from the same AIMessage."""
list_tool_id = "call-list"
regular_tool_id = "call-regular"
list_tool = _ReturningTool(
return_value=[
Command(update={"foo": "bar"}),
ToolMessage(content="list done", tool_call_id=list_tool_id),
]
)
def regular_tool(x: int) -> str:
"""A normal tool."""
return f"regular: {x}"
tool_calls = [
{"name": "list_tool", "args": {}, "id": list_tool_id, "type": "tool_call"},
{
"name": "regular_tool",
"args": {"x": 7},
"id": regular_tool_id,
"type": "tool_call",
},
]
node = ToolNode([list_tool, regular_tool])
result = node.invoke(
{"messages": [AIMessage("", tool_calls=tool_calls)]},
config=_create_config_with_runtime(),
)
assert isinstance(result, list)
commands = [r for r in result if isinstance(r, Command)]
assert len(commands) == 1
assert commands[0].update == {"foo": "bar"}
all_msgs = [m for r in result if isinstance(r, dict) for m in r["messages"]]
tool_call_ids = {m.tool_call_id for m in all_msgs}
assert list_tool_id in tool_call_ids
assert regular_tool_id in tool_call_ids
+14 -15
View File
@@ -249,7 +249,7 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.3.1"
version = "1.2.25"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonpatch" },
@@ -261,14 +261,14 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "uuid-utils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f4/fe/abeae8d0d2899e191d67c6c7f065f7e52a953f30b21ef327fa49084e4af9/langchain_core-1.3.1.tar.gz", hash = "sha256:41b384055799f93f34520df6bf7b80e2e5e23153cdfd46874251c6c9916ea030", size = 862403, upload-time = "2026-04-23T18:54:01.857Z" }
sdist = { url = "https://files.pythonhosted.org/packages/86/2a/d65de24fc9b7989137253da8973f850f3e39b4ce3e0377bc8200d6b3c189/langchain_core-1.2.25.tar.gz", hash = "sha256:77e032b96509d0eb1f6875042fdf97b7e2334a815314700c6894d9d078909b9c", size = 842347, upload-time = "2026-04-02T22:39:11.528Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a1/c2/8493be505921857988db068b7c027f28a9b1587b4425c6a32b1221c9c9fe/langchain_core-1.3.1-py3-none-any.whl", hash = "sha256:8b13d19d3bed3f4768df12c7f6932d2ada715f3ac9fd020c63d28c693968269e", size = 515879, upload-time = "2026-04-23T18:53:59.94Z" },
{ url = "https://files.pythonhosted.org/packages/3d/0e/7b31b0249f9b9b0fc7829d5b0ee484b8f8d43c78e376e9951e2ef3eac70c/langchain_core-1.2.25-py3-none-any.whl", hash = "sha256:0c05bf395aec6d2dfa14488fd006f7bcd0540e7e89287e04f92203532a82c828", size = 506866, upload-time = "2026-04-02T22:39:10.137Z" },
]
[[package]]
name = "langgraph"
version = "1.1.10"
version = "1.1.6"
source = { editable = "../langgraph" }
dependencies = [
{ name = "langchain-core" },
@@ -281,7 +281,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "langchain-core", specifier = ">=1.3.0,<2" },
{ name = "langchain-core", specifier = ">=0.1" },
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-prebuilt", editable = "." },
{ name = "langgraph-sdk", editable = "../sdk-py" },
@@ -352,7 +352,7 @@ test = [
[[package]]
name = "langgraph-checkpoint"
version = "4.0.3"
version = "4.0.1"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
@@ -490,7 +490,7 @@ test = [
[[package]]
name = "langgraph-prebuilt"
version = "1.0.13"
version = "1.0.9"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
@@ -535,7 +535,7 @@ test = [
[package.metadata]
requires-dist = [
{ name = "langchain-core", specifier = ">=1.3.1" },
{ name = "langchain-core", specifier = ">=1.0.0" },
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
]
@@ -619,7 +619,7 @@ test = [
[[package]]
name = "langsmith"
version = "0.7.31"
version = "0.6.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
@@ -629,12 +629,11 @@ dependencies = [
{ name = "requests" },
{ name = "requests-toolbelt" },
{ name = "uuid-utils" },
{ name = "xxhash" },
{ name = "zstandard" },
]
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" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/85/9c7933052a997da1b85bc5c774f3865e9b1da1c8d71541ea133178b13229/langsmith-0.6.4.tar.gz", hash = "sha256:36f7223a01c218079fbb17da5e536ebbaf5c1468c028abe070aa3ae59bc99ec8", size = 919964, upload-time = "2026-01-15T20:02:28.873Z" }
wheels = [
{ 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" },
{ url = "https://files.pythonhosted.org/packages/66/0f/09a6637a7ba777eb307b7c80852d9ee26438e2bdafbad6fcc849ff9d9192/langsmith-0.6.4-py3-none-any.whl", hash = "sha256:ac4835860160be371042c7adbba3cb267bcf8d96a5ea976c33a8a4acad6c5486", size = 283503, upload-time = "2026-01-15T20:02:26.662Z" },
]
[[package]]
@@ -1183,7 +1182,7 @@ wheels = [
[[package]]
name = "pytest"
version = "9.0.3"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -1194,9 +1193,9 @@ dependencies = [
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
+14 -14
View File
@@ -262,7 +262,7 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.3.1"
version = "1.2.28"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonpatch" },
@@ -274,14 +274,14 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "uuid-utils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f4/fe/abeae8d0d2899e191d67c6c7f065f7e52a953f30b21ef327fa49084e4af9/langchain_core-1.3.1.tar.gz", hash = "sha256:41b384055799f93f34520df6bf7b80e2e5e23153cdfd46874251c6c9916ea030", size = 862403, upload-time = "2026-04-23T18:54:01.857Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/a4/317a1a3ac1df33a64adb3670bf88bbe3b3d5baa274db6863a979db472897/langchain_core-1.2.28.tar.gz", hash = "sha256:271a3d8bd618f795fdeba112b0753980457fc90537c46a0c11998516a74dc2cb", size = 846119, upload-time = "2026-04-08T18:19:34.867Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a1/c2/8493be505921857988db068b7c027f28a9b1587b4425c6a32b1221c9c9fe/langchain_core-1.3.1-py3-none-any.whl", hash = "sha256:8b13d19d3bed3f4768df12c7f6932d2ada715f3ac9fd020c63d28c693968269e", size = 515879, upload-time = "2026-04-23T18:53:59.94Z" },
{ url = "https://files.pythonhosted.org/packages/a8/92/32f785f077c7e898da97064f113c73fbd9ad55d1e2169cf3a391b183dedb/langchain_core-1.2.28-py3-none-any.whl", hash = "sha256:80764232581eaf8057bcefa71dbf8adc1f6a28d257ebd8b95ba9b8b452e8c6ac", size = 508727, upload-time = "2026-04-08T18:19:32.823Z" },
]
[[package]]
name = "langgraph"
version = "1.1.10"
version = "1.1.6"
source = { editable = "../langgraph" }
dependencies = [
{ name = "langchain-core" },
@@ -294,7 +294,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "langchain-core", specifier = ">=1.3.0,<2" },
{ name = "langchain-core", specifier = ">=0.1" },
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-prebuilt", editable = "../prebuilt" },
{ name = "langgraph-sdk", editable = "." },
@@ -365,7 +365,7 @@ test = [
[[package]]
name = "langgraph-checkpoint"
version = "4.0.3"
version = "4.0.1"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
@@ -413,7 +413,7 @@ test = [
[[package]]
name = "langgraph-prebuilt"
version = "1.0.13"
version = "1.0.9"
source = { editable = "../prebuilt" }
dependencies = [
{ name = "langchain-core" },
@@ -422,7 +422,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "langchain-core", specifier = ">=1.3.1" },
{ name = "langchain-core", specifier = ">=1.0.0" },
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
]
@@ -534,7 +534,7 @@ test = [
[[package]]
name = "langsmith"
version = "0.7.31"
version = "0.7.20"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
@@ -547,9 +547,9 @@ dependencies = [
{ name = "xxhash" },
{ name = "zstandard" },
]
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" }
sdist = { url = "https://files.pythonhosted.org/packages/80/c6/cbdc6638207f68a3c61ec0b64fa593f6b11de3170d03c852238c31b54960/langsmith-0.7.20.tar.gz", hash = "sha256:fa983a74f75648ee0e80d3f9751162b6f9a438896d5f9bdb6cba9abda451e234", size = 1134732, upload-time = "2026-03-18T00:03:39.129Z" }
wheels = [
{ 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" },
{ url = "https://files.pythonhosted.org/packages/e8/46/9294d4f49de6a8f08e8b83907713ca545459d87d474c6add15d31a36f5dc/langsmith-0.7.20-py3-none-any.whl", hash = "sha256:0162faf791ea48d69009a12a3da917468556b99cf5d5fcacbb8cda064262e118", size = 359314, upload-time = "2026-03-18T00:03:37.59Z" },
]
[[package]]
@@ -1000,7 +1000,7 @@ wheels = [
[[package]]
name = "pytest"
version = "9.0.3"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -1011,9 +1011,9 @@ dependencies = [
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]